diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 8d322fbe..ea7660ba 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -1102,8 +1102,10 @@ rooting and portal recursion. Its root is the collided camera/viewer cell or retail's synthetic outdoor cell; the player's current cell separately owns sunlight and indoor lighting. A null root exists only for login/debug/streaming- gap fallback frames. `RetailPViewRenderer` and `RetailPViewPassExecutor` are -composition/pass facades, and `PortalVisibilityBuilder` has no production -caller. **Campaign OVERHAUL S2 (chunks 1-5, closed 2026-09-03) gave render +composition/pass facades. The superseded `PortalVisibilityBuilder`, +`IndoorDrawPlan`, and `ViewconeCuller` research path was deleted in Campaign +OVERHAUL S5-c5; production has one visibility graph and one walk owner. +**Campaign OVERHAUL S2 (chunks 1-5, closed 2026-09-03) gave render and physics cross-cell membership one canonical owner:** `ShadowObjectRegistry` computes retail's exact CELLARRAY (`calc_cross_cells_static`/`add_shadows_to_cells`, Contract A/B) once per @@ -1123,23 +1125,21 @@ cross-cell membership only. Full detail: `docs/architecture/worldbuilder-inventory.md`'s "Retail static render-shadow cell membership" / "Consumer cutover to a borrowed per-cell view" entries. -**Current draw discipline and known non-retail order.** The walk records an +**Current draw discipline and bounded residuals.** The walk records an ordered event stream for landscape cells, building portals, EnvCell shells, statics, dynamics, particles, depth boundaries, and alpha barriers, then `WalkFrameDriver` replays it through the Vulkan leaves. Private portal/ paperdoll viewports and retained UI execute after the world stream, and -projectiles remain ordinary live-entity draws. Three current details are -explicit Campaign OVERHAUL divergences, not accepted retail contracts: - -- terrain is staged before all per-cell building/object turns instead of - retail's `DrawLandCell` then `DrawSortCell` interleave for each land cell; -- depth clear/seal events are emitted for zero-outside-view roots and do not - consume retail's persistent previous-call `uint16 portalsDrawnCount` gate; -- ordinary object and particle parts now receive retail's stable per-cell - authored-CYpt order before entering the two FIFO mesh lists, which drain - CLIP before ALPHA at exact barriers. The retained differences are AP-241 - (opaque/row-5 GPU channels), AP-242 (equal cross-source tie ordinal), and - AP-243 (retail's >50 m shared-cell-key optimization). +projectiles remain ordinary live-entity draws. Campaign OVERHAUL S3/S4 landed +retail's per-landcell terrain turn followed by that cell's sort-cell turn, +outside-view/latch-gated clear and seal events, and the two FIFO lists (bounded +CLIP and ALPHA) drained at their exact barriers. The remaining explicit deviations +are AP-238 (EnvCell token granularity), AP-239 (collapsed raw surface-mask +origin), AP-240 (ordinary Gfx/building pure-Clip immediate A2C placement), +AP-241 (opaque object and row-5 particle GPU channels), AP-242 (equal-key +cross-source tie ordinal), and AP-243 (retail's >50 m shared-cell-key +optimization). They are bounded residuals, not a second visibility or ordering +model. The exact target and evidence are recorded in `docs/research/2026-09-01-overhaul/oh1-retail-world-contract.md`; OH5, OH6, and diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 328b6ea8..2db7d732 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -114,7 +114,7 @@ readiness/requeue adaptation. See | AD-119 | **Filed 2026-09-03 at Campaign OVERHAUL v2 S4 chunk 1 (S4-c1 C2; `docs/research/2026-09-01-overhaul/s4-depth-alpha-packet.md` §6 R3).** Retail's portal-depth draws (`D3DPolyRender::DrawPortalPolyInternal` @0x0059bc90, the `BLEND_SRCALPHA`/`BLEND_INVSRCALPHA` `SetBlendFunction` call) keep color writes ENABLED with a zero-source-alpha `SRCALPHA`/`INVSRCALPHA` blend — every OTHER piece of R3's state (`DEPTHTEST_ALWAYS`, depth write on, `CULLMODE_NONE`, no stencil) is ported exactly. acdream instead disables the color-write mask outright on the SAME pipeline (`ColorWrite = false` alongside `Blend = GpuBlendMode.None`) and `portal_depth.frag`'s `main()` writes no color output at all — a write-mask substituting for a zero-alpha blend. | `src/AcDream.App/Rendering/PortalDepthMaskRenderer.Rhi.cs:92,100` (`CreatePortalPipeline`'s `Blend`/`ColorWrite` fields); `src/AcDream.App/Rendering/Shaders/portal_depth.frag` (empty `main()`, no color output) | Retail's blend equation is `dst' = src*srcAlpha + dst*(1-srcAlpha)`; with `srcAlpha` fixed at 0 this collapses to `dst' = dst` for every fragment regardless of its RGB — the destination color buffer is left byte-identical either way. A write mask reaches the SAME outcome (the destination is never touched) through a structurally simpler path — no blend-unit work per fragment, no fragment color output to author or keep in sync with a "must stay zero" alpha invariant — so the two are pixel-identical, not merely usually-equivalent. | None expected: the equivalence is provable from the blend algebra above, not measured, so no capture, transcript, or visual gate can distinguish the two. The write mask is in fact the SAFER of the two going forward — a future edit that gives `portal_depth.frag` a real color output (e.g. an authored debug tint) still writes nothing under today's mask, where a ported zero-alpha blend would depend on that same edit remembering to keep alpha at exactly 0. | `D3DPolyRender::DrawPortalPolyInternal` @0x0059bc90 (`SetBlendFunction(BLEND_SRCALPHA, BLEND_INVSRCALPHA, BLENDOP_ADD)`, `SetDepthBufferMode(DEPTHTEST_ALWAYS, ...)`, `SetCullMode(CULLMODE_NONE)`); `PortalDepthMaskRenderer.Rhi.cs` | | AD-120 | **Filed 2026-09-04 at the S4-c2 fix round 1 (M3).** Retail's `DrawMesh` row 1 immediate draw happens IN PLACE, inside the mesh draw call itself, the instant `curr_detail_surface != null` is observed for a translucent building-shell subset — there is no separate "later" point at which it could draw. acdream's walk stream-batches ORDINARY opaque instances into one deferred submission, so a translucent building-shell instance under building detail cannot draw "in place" in that same sense; `WbDrawDispatcher.SubmitToAlphaQueue`'s `RetailAlphaMeshAction.Immediate` branch instead draws it via `DrawImmediateAlphaInstance`/`DrawImmediateAlphaInstanceRhi` at the entity's own `AlphaSubmitMark` — the point `WalkFrameDriver.Replay` reaches that instance's alpha submission within the walk's existing turn order (after the entity's building/land-cell turn, same relative position an ordinary deferred instance would occupy). | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`SubmitToAlphaQueue`, `DrawImmediateAlphaInstance`); `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs` (`DrawImmediateAlphaInstanceRhi`) | The walk's turn order already places a building's alpha-eligible instances at the correct relative position among that turn's other content (retail draws building shells within `DrawBuilding`, itself ordered exactly where the walk visits that building) — moving the draw from "mid-mesh-call" to "at the turn's alpha submission point" changes WHEN within one turn it executes, never WHICH turn, so it still lands between the same neighboring opaque/translucent content retail's own turn order would bracket it with. | A translucent building-shell instance under detail could theoretically composite differently than retail if some OTHER alpha content is submitted between the entity's own opaque stream mark and its alpha submission mark within the SAME turn — no such intervening content exists in the current walk (an entity's alpha submission immediately follows its own classification), so this is a structural note, not an observed symptom. | `D3DPolyRender::DrawMesh` @0x0059d4a0 (row 1); `RenderDeviceD3D::DrawBuilding` @0x0059f2a0 (`curr_detail_surface` install/clear) | | AD-117 | **Filed 2026-09-03 at the Campaign OVERHAUL S2 review fix round.** Three residual Contract A/B readings (the row's original item 1 — a render-only owner's no-cell-array SetPosition commit republishing at its destination cell alone, `ShadowObjectRegistry.RefreshPositionRows` — was verified statically the same night as retail's own mechanism: `CObjCell::find_cell_list` 0x0052b4e0 with `num_sphere == 0` adds only the current cell (interior `add_cell` at 0x0052b563; outdoor `CLandCell::add_all_outside_cells` 0x00533630 `arg2 <= 0` branch) and skips the transit walk (`arg2 != 0` gate at 0x0052b576); it is a port, not a deviation). (2) `ShadowShapeBuilder.FromStaticRenderParts` uses the visual-AABB circumsphere as the per-portal cheap-reject sphere for a part with no physics BSP, where retail uses `gfxobj->physics_sphere` else `drawing_sphere` (pc:310147-310152) — strictly larger, so it can only WIDEN membership. (3) `PublishRetailPartEntries` publishes part rows into every CELLARRAY id, including an unloaded neighbour cell `CEnvCell::find_transit_cells` added with a null owner, where retail's `add_shadows_to_cells` (pc:282850) zeroes that shadow's cell and skips `AddPartsShadow` until the cell loads. (4) PORTED at Campaign OVERHAUL S2 chunk 6: an emitter now owns exactly one draw membership in its own current cell via `ParticleSystem`'s per-pass cell index (`CopyRenderableEmittersInCell`), matching `add_particle_shadow_to_cell` 0x00514a70's own-cell-only, no-clip-planes rule and drawn at that cell's own walk turn independent of its attached owner's registry membership. The residual is architectural, not behavioral: the membership index lives in `ParticleSystem` rather than as a `ShadowObjectRegistry` row, because an emitter is not a `CPhysicsObj` in acdream and never registers with the shadow registry at all. Two behavioral residuals remain: acdream's emitter cell is its OWNER's pose cell (`ParticleEmitter.OwnerCellId` ← `EntityEffectPoseRegistry.CellId`), where retail's emitter is a standalone `CPhysicsObj` (`makeParticleObject` 0x00512640) with its own position and cell; and retail re-evaluates membership per emission (`ParticleEmitter::EmitParticle` → `CPhysicsObj::AddPartToShadowCells` at 0x0051d126), which acdream does not — a caster in a doorway draws the cloud at the caster's cell turn only. | `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`RefreshPositionRows` render-only branch, `PublishRetailPartEntries`), `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`FromStaticRenderParts` non-BSP arm), `src/AcDream.Core/Vfx/ParticleSystem.cs` (per-pass cell index, item 4) | (2) A cheap reject that fires less often admits a superset; the admitting tests (`Plane::intersect_box`, `box_intersects_cell`) are ported exactly. (3) The extra rows are unreachable by the walk until the cell is resident and `RefloodLandblock` converges them at hydration. (4) `ParticleSystem`'s cell index is maintained at every point renderable state or `OwnerCellId` changes (`RefreshRenderableIndex`, `UpdateEmitterOwnerCell`), so an emitter's draw membership always matches its live cell regardless of its attached owner's suspended/hidden registry state. | (2) A decorative non-BSP part admitted to a neighbouring cell retail's cheap reject would have dropped — a draw clipped by that cell's portal planes, at worst a sliver. (3) A one-frame draw into a cell that just hydrated before its reflood ran. (4) None behavioral — a code-location note only: if particle emitters are ever modeled as registry-backed physics objects, this cell index should be retired in favor of a genuine `ShadowObjectRegistry` row rather than kept as a parallel mechanism. | `CPhysicsObj::SetPositionInternal` 0x00515330 (pc:283530-283541), `CPhysicsObj::add_shadows_to_cells` 0x00514ae0 (pc:282837-282875), `CEnvCell::find_transit_cells` 0x0052cae0 (pc:310147-310217), `CPhysicsObj::add_particle_shadow_to_cell` 0x00514a70, `CObjCell::find_cell_list` 0x0052b4e0. | -| AD-116 | **Filed 2026-09-03 at Campaign OVERHAUL S2 chunk 5 (consumer cutover).** `WalkProductionWorldData.ResolveCellView`'s borrowed per-cell view treats an entity the registry HAS flooded into its retail CELLARRAY (so `ShadowObjectRegistry.GetRetailPartEntriesInCell` names it) but whose `RenderProjectionRecord` `RenderSceneQuery.TryGetByLocalEntityId` cannot resolve yet as contributing to NO cell for that frame — it is silently skipped rather than falling back to its authored parent cell or an outdoor root-position cell (both deleted this chunk). Every distinct entity id this happens for in one frame is counted once in `WalkProductionWorldData.UnregisteredRenderMembershipCount` and, when nonzero, reported by one print-only `[walk-membership]` line at the start of the next `BeginFrame`, gated on `RenderingDiagnostics.ProbeFacilityStairsEnabled`. | `src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs` (`ResolveCellView`, `UnregisteredRenderMembershipCount`, the `BeginFrame` diagnostic line) | Retail has no such gap at all: `CEnvCell::init_static_objects` installs the CELLARRAY before a static is ever drawable, and Contract B's collision (`shadow_object_list`) and render (`shadow_part_list`) products are ONE transaction, so they can never race. acdream's registry (the physics publisher) and its presentation scene (the projection journal) are two independently incremental pipelines fed off the same Create/appearance edge, so a transient one-frame window where the registry runs first is possible during streaming — the same class of race AD-49's residency reasoning already accepts for `CellTransit`'s own outdoor seed. Contributing NOTHING for that one frame matches retail's own rule ("an object not yet in a cell is not drawn") more closely than the deleted parent-cell/root-position fallbacks did, which could draw an object at a cell its real CELLARRAY does not actually include. | If the presentation journal's apply cadence ever falls more than one frame behind the registry's registration (not merely a same-frame ordering race), an entity would stay missing for several consecutive frames instead of appearing on the very next one — `UnregisteredRenderMembershipCount` staying nonzero across consecutive frames (not a single one-frame spike) is the signal that this row's "transient" premise has broken and needs re-investigation, not a widened fallback. | `CEnvCell::init_static_objects`; `CPartArray::AddPartsShadow` 0x00517e40 (`docs/research/2026-09-01-overhaul/oh1-construction-landscape-contract.md` Contract B) | +| AD-116 | **Filed 2026-09-03 at Campaign OVERHAUL S2 chunk 5 (consumer cutover); amended 2026-09-05 at S5-c5 when the spent Facility print probe was deleted.** `WalkProductionWorldData.ResolveCellView`'s borrowed per-cell view treats an entity the registry HAS flooded into its retail CELLARRAY (so `ShadowObjectRegistry.GetRetailPartEntriesInCell` names it) but whose `RenderProjectionRecord` `RenderSceneQuery.TryGetByLocalEntityId` cannot resolve yet as contributing to NO cell for that frame — it is silently skipped rather than falling back to its authored parent cell or an outdoor root-position cell (both deleted this chunk). Every distinct entity id this happens for in one frame is counted once in `WalkProductionWorldData.UnregisteredRenderMembershipCount`; the invariant remains test-visible, but the former next-frame `[walk-membership]` output and `ProbeFacilityStairsEnabled` gate no longer exist. | `src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs` (`ResolveCellView`, `UnregisteredRenderMembershipCount`) | Retail has no such gap at all: `CEnvCell::init_static_objects` installs the CELLARRAY before a static is ever drawable, and Contract B's collision (`shadow_object_list`) and render (`shadow_part_list`) products are ONE transaction, so they can never race. acdream's registry (the physics publisher) and its presentation scene (the projection journal) are two independently incremental pipelines fed off the same Create/appearance edge, so a transient one-frame window where the registry runs first is possible during streaming — the same class of race AD-49's residency reasoning already accepts for `CellTransit`'s own outdoor seed. Contributing NOTHING for that one frame matches retail's own rule ("an object not yet in a cell is not drawn") more closely than the deleted parent-cell/root-position fallbacks did, which could draw an object at a cell its real CELLARRAY does not actually include. | If the presentation journal's apply cadence ever falls more than one frame behind the registry's registration (not merely a same-frame ordering race), an entity would stay missing for several consecutive frames instead of appearing on the very next one — `UnregisteredRenderMembershipCount` staying nonzero across consecutive frames (not a single one-frame spike) is the signal that this row's "transient" premise has broken and needs re-investigation, not a widened fallback. | `CEnvCell::init_static_objects`; `CPartArray::AddPartsShadow` 0x00517e40 (`docs/research/2026-09-01-overhaul/oh1-construction-landscape-contract.md` Contract B) | | AD-115 | **Filed 2026-08-25 at Campaign AS slice AS2 review fix round (F16), classification: intentional.** `AppraisalUiController.BuildCharacterTitleDisplay` composes examination element `0x10000151` (Profession/title): when Int 261 `CharacterTitleId` is absent/unresolvable AND String 5 `Template` is also absent, it returns an empty string, and `ClearCreatureText` has already blanked the element for this `ApplyCreature` call, so the element stays cleared. Retail never clears `0x10000150`/`0x10000151`/`0x10000152` anywhere — neither `CharExamineUI::Show @0x004AB5D0` nor `BasicCreatureExamineUI::Init @0x004AB9C0` writes an empty string to those elements — so in this exact case retail would keep showing the PREVIOUS assessed target's title text on screen instead of clearing it. | `src/AcDream.App/UI/Layout/AppraisalUiController.cs` (`BuildCharacterTitleDisplay`, `ClearCreatureText`) | Deliberate improvement over retail's quirk: a stale leftover title from a prior target reads as more confusing/wrong to a player than a blank line for the current one; review F16 (2026-08-25) accepted the clear-on-no-source behavior as intentional. | None expected — this is a deliberate, reviewed divergence, not a game-feel regression; a future retail-faithfulness audit assuming `0x10000151` always mirrors retail's persistent stale-text behavior would be surprised to see it clear instead when the current target's title can't be resolved. | `CharExamineUI::Show @0x004AB5D0`; `BasicCreatureExamineUI::Init @0x004AB9C0` | | AD-114 | **Filed 2026-08-25 at Campaign AS slice AS2, owner-ruled 2026-08-25 (verbatim "we animate it, and I like it").** acdream's examination-window preview (`CreatureAppraisalFramePresenter` / `RetailCreatureAppraisalCloneFactory`) shares the assessed target's already-resolved live MeshRefs and re-synchronizes them every frame, so the preview clone plays the SAME current animated pose the live target is actually doing right now (attack, cast, run, idle, ...). Retail's `BasicCreatureExamineUI::Init @0x004AB9C0` instead clones the selected physics object ONCE, fixes its heading at 191.367905°, and lets its own private `CreatureMode` animate that clone independently — decoupled from whatever the live target is currently doing. | `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs` (`CreatureAppraisalFramePresenter`, `RetailCreatureAppraisalCloneFactory`) | Explicit owner direction, 2026-08-25 (`docs/plans/2026-08-25-assess-window-parity-campaign.md`: "The animated 3D paperdoll is an INTENTIONAL acdream deviation... Keep it"), noted alongside the owner's own observation that retail's static-clone colors are buggy — porting the decoupled-motion clone would not even be a faithfulness win here. | None expected — a deliberate, user-approved visual improvement over retail's decoupled clone motion, not a game-feel divergence; a future faithfulness audit assuming the preview mirrors retail's independent `CreatureMode` cycle would be surprised to see it track the live target's pose instead. | `BasicCreatureExamineUI::Init @0x004AB9C0`; `docs/plans/2026-08-25-assess-window-parity-campaign.md` | | AD-112 | **Filed 2026-08-23 with the sky default-script port (issues #28/#2, phase C.1.5c).** Retail plays the sky carriers' aurora/lightning/thunder PES by creating real `CPhysicsObj`s in the viewer-centered sky cells (`GameSky::MakeObject @0x00506EE0` → `CPhysicsObj::makeObject @0x00513970`; the Setup's `DefaultScript` marks `state \|= 0x80000` and `CPhysicsObj::animate_static_object @0x00513DF0` ticks `ScriptManager::UpdateScripts` + the ParticleManager). acdream plays the same scripts through `PhysicsScriptRunner` synthetic owners anchored at the camera (`SkyPesFrameController`), pass-routed by `props & 1` into the existing SkyPreScene/SkyPostScene particle draws — no sky-cell physics objects exist. Slot persistence keys on (index, gfx id, properties), the `CreateDeletePhysicsObjects @0x005073C0` identity law. The heading/rotation → pose axis mapping uses `UnitY` where retail's `GameSky::CalcFrame @0x00506F80` runs `set_heading` + `grotate`; every current PES carrier authors 0°/0° so the axis is unexercised. | `src/AcDream.App/Rendering/SkyPesFrameController.cs`; `src/AcDream.Core/World/SkyDescLoader.cs` (`SkyObjectData.DefaultScriptId`) | Retail's sky cells are camera-centered, so a camera-anchored world-space owner is the same geometry; the script/emitter engines are the production ones shared with entity effects; `SkyPesFrameControllerTests` pins the identity/persistence/window lifecycle. | A sky carrier authored with nonzero heading/rotation angles would orbit around the wrong axis; a PES whose hooks depend on real part frames (part_index targeting) would find only the synthetic owner's root pose. | `GameSky::MakeObject @0x00506EE0`; `GameSky::CreateDeletePhysicsObjects @0x005073C0`; `CPhysicsObj::animate_static_object @0x00513DF0`; `docs/research/2026-08-23-sky-default-script-port.md`; `SkyPesFrameControllerTests` | @@ -220,7 +220,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 161 active rows (AP-117 retired 2026-09-04 at Campaign OVERHAUL S5-c1; AP-241/AP-242/AP-243 filed 2026-09-04 at S4-c3a; recounted honestly 2026-09-04 at the S4-c2 fix round 1 — the prior "161" was stale; AP-240 filed 2026-09-04 at the S4-c2 fix round 1 (M2) — clip-mapped GfxObj/scenery/building subsets draw immediately outside retail's CLIP FIFO, `WbDrawDispatcher.IsOpaque` filtering them out before they ever reach the alpha queue; AP-34 RETIRED 2026-09-04 at the S4-c2 fix round 1 — its residuals continue as the renumbered AP-238/AP-239 (both were filed as the colliding/dangling "AP-236"/"AP-237"/"AP-236b" ids the S4-c2 chunk 2 commit introduced; ids are never reused, so the fix round renumbered them and restored AP-34's own row as struck-through rather than deleted outright — see A2/M8c); AP-234 RETIRED 2026-09-02 at OH2/S1 chunk B — retail's exact CellStruct surface/subset construction (`CellStructSideCandidates`, source-surface-index subsets, and the built-EnvCell `(Surface.Type & 6) != 0` admission test) replaces the NoPos-based approximation, ported from `D3DPolyRender::ConstructMesh` @0x0059DFA0, `RenderDeviceD3D::DrawEnvCell` @0x0059F170, and `D3DPolyRender::DrawMesh` @0x0059D4A0 (docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md), with chunk-A/B test evidence; AP-202 RETIRED 2026-08-26 by #446 — retail PFile `.keymap` Load/Save/startup/shutdown persistence is now live; AP-235 filed 2026-08-25 at the Campaign CT4 fix round — `CharacterIdentityText.GenderDisplayName`/`HeritageGroupDisplayName` are hardcoded C# switches instead of a live `EnumMapper` read; AP-233 filed 2026-08-23 at the Holtburg windmill fix — the render-side inter-frame animation blend, now holding the boundary frame at every seam; AP-232 RETIRED 2026-09-04 at Campaign OVERHAUL S5-c4 — exact one-pass detail material with authored surface opacity and squared final alpha replaces the two-draw approximation; AP-185 RETIRED 2026-08-20 — `RetailWindowLockPresentationController` now swaps all eight imported locked/live chrome blocks, hides live-only floating-chat and SmartBox grips, suppresses only the nine-slice grip overlay, and applies the current lock before a late-mounted window's first `OnShown`; the radar's persistent B7/B8 semantic face is pinned against pointer-state clobber and covered by a real-fixture draw cycle; AP-231 filed 2026-08-16 at the Campaign CC gate round 1 closeout Group 2 — the Skills page formula-connector-text approximation in `ComposeFormula`, see the row's own text for the full disclosure of what is byte-verified versus best-derived; AP-213 RETIRED 2026-08-16 at the Campaign CC gate round 1 closeout Group 2 — the remaining flat-list-vs-four-bucket-sorted-model half is now ported: `ChargenSkillDetail`/`ChargenSkillFormula` (Core) thread `SkillBase.MinLevel`/`Description`/`Formula` from the global SkillTable through `ChargenOptions.TryGetSkillDetail` (`ChargenTableReader.Project` populates it, live-DAT-pinned at 38 entries — 23 MinLevel<=1/15 MinLevel==2, matching the Batch F investigation's own recorded finding exactly), and `CharacterCreationSkillsPage` now groups every costable skill into `SkillBucket` (Specialized/Trained/UseableUntrained/UnuseableUntrained, `UpdateSkillEntry`'s own `iMinlevel <= 1` test), sorts each bucket alphabetically (`InsertEntrySorted`'s `wcscmp`, ported as `string.CompareOrdinal`), and builds one `Templates[0]` header row per bucket ahead of that bucket's `Templates[1]` skill rows — `DoSkillRecords`'s own unconditional 4-header-then-populate build order. A level change re-buckets the row (detected per-refresh against each row's own cached bucket, then a full rebuild — the observable placement matches retail's incremental single-row `InsertEntrySorted` move without reproducing its internal mechanism, a documented and harmless substitution). 3 new fixture tests (`SkillsPage_BucketHeaders_AlwaysBuildAllFour_InRetailOrder`, `SkillsPage_UntrainedSkill_BucketsByMinLevel`, `SkillsPage_AdvancingASkill_MovesItsRowIntoTheNewBucket`) plus 1 new live-DAT test (`InstalledSkillTable_GlobalSkillDetails_MinLevelDistributionMatchesCostCoverage`); AP-216/AP-217 RETIRED 2026-08-16 at the Campaign CC gate round 1 closeout Group 1 — both rows' STOPPED items are now landed: `CharacterCreationUiController.AppearancePalSetSource`/`AppearanceClothingTableSource`/`AppearancePaletteColorSource` wire a DAT-backed `ChargenAppearanceCatalog` into the Appearance page from `LivePresentationComposition` (mirroring the existing `AppearancePreviewControl` seam), and `UiButton`/`UiDatElement` both gained a per-instance `Tint` property threaded into every existing `DrawSprite` call they make; `CharacterCreationAppearancePage` now sets `Tint` directly on each swatch button and the GradCircle element instead of layering a flat-fill `ChargenSwatchColorTile` overlay on top (that class is deleted) — a genuine multiplicative sprite tint on the widget's OWN authored art, matching retail's `SurfaceWindow::BlitAndColor(..., Blit_Multiply, color)` exactly rather than approximating it with an opaque rectangle. Both fixture test suites (`CharacterCreationAppearancePageSwatchColorTests`, 8 tests) and the live-DAT color pins (`ChargenAppearanceCatalogColorTests`) pass unchanged against the new mechanism; AP-218 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-6) — `gmCGAppearancePage::Update`'s heritage-flavored static Hair/Eyes/Skin spin caption (`ID_CharGen_HairStyle`/`_Eyes`/`_Skin`, Gearknight `GearText_*`, Olthoi/OlthoiAcid `OlthoiText_*`) is now ported verbatim by `RefreshSpinCaptions`, replacing the prior ordinal substitution outright — see AP-215's own rewritten row for what remains open (the icon-thumbnail gap, restated); recount at this same edit: the row count this header carried before Batch B was already one LOW relative to the physical table (Batch A's own ending state: header said 164, the physical table already held 165 rows — verified by direct count against that commit) — a pre-existing drift this edit corrects to the counted total, not an artifact of Batch B's own net change (F12 correction, gate round 1 closeout, 2026-08-16: this note originally said "one high", the inverted direction — the header was UNDER-counting, not over-counting); AP-222 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch B fix (GF-11b) — the Appearance spins' current-part highlight and the Town buttons' Normal-to-white caption swap both port retail's actual mechanism (per-state label color/outline commit off the REQUESTED retail state id, independent of art-media availability — `UiButton.SetPerStateLabelStyle`/`ComputeRequestedStateId`), closing the row's own "not yet resolved which side is wrong" question: NEITHER client's spin ART changes (no Highlight media exists on either), but BOTH clients' spin TEXT does, matching retail's `SetState(1)`/`SetState(6)` property commit exactly (live-DAT-measured 218,167,85 -> 255,221,131, outline off -> on); AP-215 NARROWED the same batch (GF-9) — item 1 (the swatch-selection substitution) is RETIRED now that the real companion-overlay mechanism (`SetColor`'s `m_tColorWheel[...][0x10][iCurColor*7]->SetVisible`) is ported (`CharacterCreationAppearancePage`'s nine `SwatchOverlayIds`), leaving only item 2 (the icon-less style-spin ordinal label) open; AP-230 filed 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-13) — the chargen-scoped-vs-general-importer-wide honor split for dat property 0x3B (Invisible: `UIElement::OnSetAttribute` case 8 hides an element), with the general client-wide honor deferred as its own visual gate (docs/ISSUES.md #408, 1,083 elements affected); AP-213 NARROWED the same gate round (GF-5) — the Skills page's click-to-advance/double-click-retreat single-button substitution is RETIRED now that the real per-row `pSkillUpButton`/`pSkillDownButton` arrows are wired to retail's own plain-click dispatch, leaving open only the flat-list-vs-four-bucket-sorted-model half; AP-229 filed 2026-08-16 at the Campaign CC CC7 review-fix round, F1 — the screen-layering divergence: retail's `UIFlow::UseNewMode` destroys/reconstructs the current UI framework on every mode switch where acdream's CC7 keeps both `CharacterManagementUiController` and `CharacterCreationUiController` mounted for the whole lifetime and only reveals/occludes them; AP-228 filed 2026-08-16 at the CC5 re-review residual round (R4) — the Summary listbox's skill-row KEY source, same divergence class as AP-226 filed the same round, a few retail lines away; AP-227 filed 2026-08-16 at the same review-fix round, F9 — an empty Summary name-field commit calls `SetName("")` (clearing the state), where retail's own NUL-inclusive length gate leaves `CharGenState.name` UNCHANGED for that specific case; AP-226 filed 2026-08-16 at the Campaign CC CC5 review-fix round, F11 — the Summary page's DAT-sourced labels versus retail's static `pcProfessions`/`pcGender`/`pcHeritage`/`pcTown` tables, including the non-human-heritage-renders-bare-"Heritage: " retail quirk; AP-225 RETIRED the same round, F6 — the reviewer re-derived `gmCGSummaryPage::ListenToElementMessage @0x0047bf40`'s length check and proved the 32-vs-33 threshold this row flagged as "not fully certain" does NOT exist: the compared length is NUL-inclusive (an empty field's length is 1, matching AP-226's own F11/F9 finding), so `length > 0x21` is EXACTLY `visibleChars > 32` — acdream's `MaxNameLength = 32` was always byte-correct, not merely internally-consistent; AP-223/AP-224 filed 2026-08-15 at Campaign CC slice CC5 — the acdream-only `HeritageOrGenderUnset` Finish refusal and the Summary listbox's two-bucket (Specialized/Trained only) skill-list narrowing (AP-224 corrected 2026-08-16 at the same review-fix round, F3 — its "template mechanism ported exactly" claim was FALSE as shipped, now fixed and true again, see its own row); AP-214 RETIRED the same slice — `RandomizeCharacter` is now ported and wired at the screen-open edge, closing the honest-blank-open gap it recorded; AP-212 NARROWED the same slice — the Appearance/Summary Random-button primitives are now real faithful ports, not uniform-pick approximations, leaving only Heritage/Profession/Town (still uniform-pick) and Skills (still unported) open; AP-222 filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — the current-part spin highlight is a measured no-op for all nine spins, no Highlight media authored on any of them; AP-221 filed the same re-review (R2) — the chargen preview's one-shot-composition-vs-retryable-coordinator binding gap; AP-217 rewritten and AP-220 tightened the same re-review (R3 corrects the GradCircle from a dead click target to unported paint-art; N1 narrows the Gearknight-exit wording to non-Olthoi); AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; AP-203 RETIRED 2026-08-26 by #446 — all 306 installed-DAT rows now have distinct identities and concrete consumers; AP-204 corrected and RETIRED the same day — exact capture/conflict/button semantics now follow named retail; AP-202 RETIRED 2026-08-26 by #446 — retail `.keymap` file interchange and profile lifetime now ship; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 160 active rows (AP-19 retired 2026-09-05 at Campaign OVERHAUL S5-c5; AP-117 retired 2026-09-04 at Campaign OVERHAUL S5-c1; AP-241/AP-242/AP-243 filed 2026-09-04 at S4-c3a; recounted honestly 2026-09-04 at the S4-c2 fix round 1 — the prior "161" was stale; AP-240 filed 2026-09-04 at the S4-c2 fix round 1 (M2) — clip-mapped GfxObj/scenery/building subsets draw immediately outside retail's CLIP FIFO, `WbDrawDispatcher.IsOpaque` filtering them out before they ever reach the alpha queue; AP-34 RETIRED 2026-09-04 at the S4-c2 fix round 1 — its residuals continue as the renumbered AP-238/AP-239 (both were filed as the colliding/dangling "AP-236"/"AP-237"/"AP-236b" ids the S4-c2 chunk 2 commit introduced; ids are never reused, so the fix round renumbered them and restored AP-34's own row as struck-through rather than deleted outright — see A2/M8c); AP-234 RETIRED 2026-09-02 at OH2/S1 chunk B — retail's exact CellStruct surface/subset construction (`CellStructSideCandidates`, source-surface-index subsets, and the built-EnvCell `(Surface.Type & 6) != 0` admission test) replaces the NoPos-based approximation, ported from `D3DPolyRender::ConstructMesh` @0x0059DFA0, `RenderDeviceD3D::DrawEnvCell` @0x0059F170, and `D3DPolyRender::DrawMesh` @0x0059D4A0 (docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md), with chunk-A/B test evidence; AP-202 RETIRED 2026-08-26 by #446 — retail PFile `.keymap` Load/Save/startup/shutdown persistence is now live; AP-235 filed 2026-08-25 at the Campaign CT4 fix round — `CharacterIdentityText.GenderDisplayName`/`HeritageGroupDisplayName` are hardcoded C# switches instead of a live `EnumMapper` read; AP-233 filed 2026-08-23 at the Holtburg windmill fix — the render-side inter-frame animation blend, now holding the boundary frame at every seam; AP-232 RETIRED 2026-09-04 at Campaign OVERHAUL S5-c4 — exact one-pass detail material with authored surface opacity and squared final alpha replaces the two-draw approximation; AP-185 RETIRED 2026-08-20 — `RetailWindowLockPresentationController` now swaps all eight imported locked/live chrome blocks, hides live-only floating-chat and SmartBox grips, suppresses only the nine-slice grip overlay, and applies the current lock before a late-mounted window's first `OnShown`; the radar's persistent B7/B8 semantic face is pinned against pointer-state clobber and covered by a real-fixture draw cycle; AP-231 filed 2026-08-16 at the Campaign CC gate round 1 closeout Group 2 — the Skills page formula-connector-text approximation in `ComposeFormula`, see the row's own text for the full disclosure of what is byte-verified versus best-derived; AP-213 RETIRED 2026-08-16 at the Campaign CC gate round 1 closeout Group 2 — the remaining flat-list-vs-four-bucket-sorted-model half is now ported: `ChargenSkillDetail`/`ChargenSkillFormula` (Core) thread `SkillBase.MinLevel`/`Description`/`Formula` from the global SkillTable through `ChargenOptions.TryGetSkillDetail` (`ChargenTableReader.Project` populates it, live-DAT-pinned at 38 entries — 23 MinLevel<=1/15 MinLevel==2, matching the Batch F investigation's own recorded finding exactly), and `CharacterCreationSkillsPage` now groups every costable skill into `SkillBucket` (Specialized/Trained/UseableUntrained/UnuseableUntrained, `UpdateSkillEntry`'s own `iMinlevel <= 1` test), sorts each bucket alphabetically (`InsertEntrySorted`'s `wcscmp`, ported as `string.CompareOrdinal`), and builds one `Templates[0]` header row per bucket ahead of that bucket's `Templates[1]` skill rows — `DoSkillRecords`'s own unconditional 4-header-then-populate build order. A level change re-buckets the row (detected per-refresh against each row's own cached bucket, then a full rebuild — the observable placement matches retail's incremental single-row `InsertEntrySorted` move without reproducing its internal mechanism, a documented and harmless substitution). 3 new fixture tests (`SkillsPage_BucketHeaders_AlwaysBuildAllFour_InRetailOrder`, `SkillsPage_UntrainedSkill_BucketsByMinLevel`, `SkillsPage_AdvancingASkill_MovesItsRowIntoTheNewBucket`) plus 1 new live-DAT test (`InstalledSkillTable_GlobalSkillDetails_MinLevelDistributionMatchesCostCoverage`); AP-216/AP-217 RETIRED 2026-08-16 at the Campaign CC gate round 1 closeout Group 1 — both rows' STOPPED items are now landed: `CharacterCreationUiController.AppearancePalSetSource`/`AppearanceClothingTableSource`/`AppearancePaletteColorSource` wire a DAT-backed `ChargenAppearanceCatalog` into the Appearance page from `LivePresentationComposition` (mirroring the existing `AppearancePreviewControl` seam), and `UiButton`/`UiDatElement` both gained a per-instance `Tint` property threaded into every existing `DrawSprite` call they make; `CharacterCreationAppearancePage` now sets `Tint` directly on each swatch button and the GradCircle element instead of layering a flat-fill `ChargenSwatchColorTile` overlay on top (that class is deleted) — a genuine multiplicative sprite tint on the widget's OWN authored art, matching retail's `SurfaceWindow::BlitAndColor(..., Blit_Multiply, color)` exactly rather than approximating it with an opaque rectangle. Both fixture test suites (`CharacterCreationAppearancePageSwatchColorTests`, 8 tests) and the live-DAT color pins (`ChargenAppearanceCatalogColorTests`) pass unchanged against the new mechanism; AP-218 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-6) — `gmCGAppearancePage::Update`'s heritage-flavored static Hair/Eyes/Skin spin caption (`ID_CharGen_HairStyle`/`_Eyes`/`_Skin`, Gearknight `GearText_*`, Olthoi/OlthoiAcid `OlthoiText_*`) is now ported verbatim by `RefreshSpinCaptions`, replacing the prior ordinal substitution outright — see AP-215's own rewritten row for what remains open (the icon-thumbnail gap, restated); recount at this same edit: the row count this header carried before Batch B was already one LOW relative to the physical table (Batch A's own ending state: header said 164, the physical table already held 165 rows — verified by direct count against that commit) — a pre-existing drift this edit corrects to the counted total, not an artifact of Batch B's own net change (F12 correction, gate round 1 closeout, 2026-08-16: this note originally said "one high", the inverted direction — the header was UNDER-counting, not over-counting); AP-222 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch B fix (GF-11b) — the Appearance spins' current-part highlight and the Town buttons' Normal-to-white caption swap both port retail's actual mechanism (per-state label color/outline commit off the REQUESTED retail state id, independent of art-media availability — `UiButton.SetPerStateLabelStyle`/`ComputeRequestedStateId`), closing the row's own "not yet resolved which side is wrong" question: NEITHER client's spin ART changes (no Highlight media exists on either), but BOTH clients' spin TEXT does, matching retail's `SetState(1)`/`SetState(6)` property commit exactly (live-DAT-measured 218,167,85 -> 255,221,131, outline off -> on); AP-215 NARROWED the same batch (GF-9) — item 1 (the swatch-selection substitution) is RETIRED now that the real companion-overlay mechanism (`SetColor`'s `m_tColorWheel[...][0x10][iCurColor*7]->SetVisible`) is ported (`CharacterCreationAppearancePage`'s nine `SwatchOverlayIds`), leaving only item 2 (the icon-less style-spin ordinal label) open; AP-230 filed 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-13) — the chargen-scoped-vs-general-importer-wide honor split for dat property 0x3B (Invisible: `UIElement::OnSetAttribute` case 8 hides an element), with the general client-wide honor deferred as its own visual gate (docs/ISSUES.md #408, 1,083 elements affected); AP-213 NARROWED the same gate round (GF-5) — the Skills page's click-to-advance/double-click-retreat single-button substitution is RETIRED now that the real per-row `pSkillUpButton`/`pSkillDownButton` arrows are wired to retail's own plain-click dispatch, leaving open only the flat-list-vs-four-bucket-sorted-model half; AP-229 filed 2026-08-16 at the Campaign CC CC7 review-fix round, F1 — the screen-layering divergence: retail's `UIFlow::UseNewMode` destroys/reconstructs the current UI framework on every mode switch where acdream's CC7 keeps both `CharacterManagementUiController` and `CharacterCreationUiController` mounted for the whole lifetime and only reveals/occludes them; AP-228 filed 2026-08-16 at the CC5 re-review residual round (R4) — the Summary listbox's skill-row KEY source, same divergence class as AP-226 filed the same round, a few retail lines away; AP-227 filed 2026-08-16 at the same review-fix round, F9 — an empty Summary name-field commit calls `SetName("")` (clearing the state), where retail's own NUL-inclusive length gate leaves `CharGenState.name` UNCHANGED for that specific case; AP-226 filed 2026-08-16 at the Campaign CC CC5 review-fix round, F11 — the Summary page's DAT-sourced labels versus retail's static `pcProfessions`/`pcGender`/`pcHeritage`/`pcTown` tables, including the non-human-heritage-renders-bare-"Heritage: " retail quirk; AP-225 RETIRED the same round, F6 — the reviewer re-derived `gmCGSummaryPage::ListenToElementMessage @0x0047bf40`'s length check and proved the 32-vs-33 threshold this row flagged as "not fully certain" does NOT exist: the compared length is NUL-inclusive (an empty field's length is 1, matching AP-226's own F11/F9 finding), so `length > 0x21` is EXACTLY `visibleChars > 32` — acdream's `MaxNameLength = 32` was always byte-correct, not merely internally-consistent; AP-223/AP-224 filed 2026-08-15 at Campaign CC slice CC5 — the acdream-only `HeritageOrGenderUnset` Finish refusal and the Summary listbox's two-bucket (Specialized/Trained only) skill-list narrowing (AP-224 corrected 2026-08-16 at the same review-fix round, F3 — its "template mechanism ported exactly" claim was FALSE as shipped, now fixed and true again, see its own row); AP-214 RETIRED the same slice — `RandomizeCharacter` is now ported and wired at the screen-open edge, closing the honest-blank-open gap it recorded; AP-212 NARROWED the same slice — the Appearance/Summary Random-button primitives are now real faithful ports, not uniform-pick approximations, leaving only Heritage/Profession/Town (still uniform-pick) and Skills (still unported) open; AP-222 filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — the current-part spin highlight is a measured no-op for all nine spins, no Highlight media authored on any of them; AP-221 filed the same re-review (R2) — the chargen preview's one-shot-composition-vs-retryable-coordinator binding gap; AP-217 rewritten and AP-220 tightened the same re-review (R3 corrects the GradCircle from a dead click target to unported paint-art; N1 narrows the Gearknight-exit wording to non-Olthoi); AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; AP-203 RETIRED 2026-08-26 by #446 — all 306 installed-DAT rows now have distinct identities and concrete consumers; AP-204 corrected and RETIRED the same day — exact capture/conflict/button semantics now follow named retail; AP-202 RETIRED 2026-08-26 by #446 — retail `.keymap` file interchange and profile lifetime now ship; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) **2026-08-28 count correction:** #408 retired AP-230 by moving property `0x3B` visibility into the shared importer and every retained stateful widget; @@ -273,6 +273,8 @@ retains AP-240's immediate opaque/A2C pipeline while gaining final-X ParamB; only EnvCell pure Clip uses `ONE/INVSRCALPHA`. The retired row's recipe-9-only and `None` residual wording is superseded by this correction and AP-244; AP-238/AP-239/AP-240 remain separate queue/token/raw-mask/placement residuals. +Campaign OVERHAUL S5-c5 subsequently retired AP-19 with its deleted research +builder, reducing the current active total to **160**. | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -335,7 +337,7 @@ AP-238/AP-239/AP-240 remain separate queue/token/raw-mask/placement residuals. | AP-15 | WeenieError **sentence** table covers only ~30 common codes (from ACE enum docs, not retail string_table.bin); unknown codes render raw hex. The row's older *code-catalog* caveat is superseded: `WeenieError` carried a curated 16-member subset until 2026-07-29 and now holds the full 372-code table, so a code being unnamed is no longer a way for this to bite | `src/AcDream.Core/Chat/WeenieErrorMessages.cs:26` | Untranslated codes are rare, fall back losslessly, 30-second add when reported | Server messages outside the table show as raw hex instead of the retail sentence | retail string_table.bin; ACE WeenieError*.cs; `src/AcDream.Core/Physics/WeenieError.cs` | | AP-16 | Point/spot lights selected per-object / per-cell as the **8 nearest reaching lights** (sphere-overlap, nearest-first) via `LightManager.SelectForObject`, capped at `MaxLightsPerObject=8`; called from `WbDrawDispatcher.ComputeEntityLightSet` (objects) and `EnvCellRenderer.GetCellLightSet` (cell shells). Retail's bake (`SetStaticLightingVertexColors`) sums ALL reaching static lights per vertex with no count cap. Retail's *hardware* path (`minimize_object_lighting` 0x0054d480) DOES cap at 8 per object, so the cap is faithful to retail's hardware path — not to its bake path. The `LightManager.Tick` UBO path survives for DIRECTIONAL (sun) lights only; `mesh_modern.vert`'s UBO loop skips point/spot entries (`posAndKind.w != 0 → continue`) — point lights reach the shader exclusively via the per-object SSBO (binding 5) | `src/AcDream.Core/Lighting/LightManager.cs:234` (`SelectForObject`); `MaxLightsPerObject` ~line 174; call sites `WbDrawDispatcher.ComputeEntityLightSet` + `EnvCellRenderer.GetCellLightSet` | Matches retail's hardware constraint (8 lights per object/cell); selection is nearest-sphere-overlap which faithfully allocates lights to the surfaces that actually see them | Surfaces reached by >8 point lights are dimmer than retail's uncapped bake — rare (a dungeon room has a handful of torches), but real; see AP-35 for the bake-vs-GPU-evaluate architecture difference | `minimize_object_lighting` 0x0054d480 (retail's 8-light hardware cap); `SetStaticLightingVertexColors` 0x0059cfe0 (retail's bake, no count cap) | | ~~AP-18~~ | **RETIRED 2026-07-10 — faithful retail radar port.** Exact `RGBAColor_Radar*` floats were recovered from named static data and `gmRadarUI::GetBlipColor` was re-ported with `_blipColor` overrides plus portal/vendor/attackable-creature/admin/PK/PKLite/free-PK/fellowship precedence. The old implementation was not merely hue-tuned: it also had wrong portal/vendor colors and an incomplete dispatch matrix. | `src/AcDream.Core/Ui/RadarBlipColors.cs` + radar classification tests | — | — | `gmRadarUI::GetBlipColor` 0x004d76f0; static RGBA initializers at named decomp pc:1089736-1089804 | -| AP-19 | `PortalSideEpsilon` 0.01 (≈1 cm) instead of retail F_EPSILON ≈ 0.0002 — a documented render-root-lag tolerance, NOT a retail constant. DO-NOT-RETRY: T2 (BR-4) tried the retail value; CornerFloodReplay refuted it | `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:49` | Retail's tight epsilon only works with eye-exact swept curr_cell tracking; our viewer cell lags the eye by up to ~1 cm at pressed corners. Tighten after the #108-membership family + cdstW near-clip pin land | A 1 cm misclassification band at portal planes can flood or cull a portal the eye hasn't crossed — one-frame leaks / grey flashes at knife-edge doorway/corner positions | F_EPSILON @0x007c8c70; `PView::InitCell` 0x005a4b70 | +| ~~AP-19~~ | **RETIRED 2026-09-05, Campaign OVERHAUL S5-c5.** The superseded `PortalVisibilityBuilder` and its acdream-only 0.01 m `PortalSideEpsilon` were deleted outright. The retained `RetailFrameWalk`/`WalkPView` production graph owns portal traversal and never consumed this tolerance; no replacement constant was introduced. | deleted `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` | — | — | F_EPSILON @0x007c8c70; `PView::InitCell` 0x005a4b70 | | AP-20 | Sub-pixel view-polygon vertex merge fixed at 1080p-reference NDC units (2/1080); retail merges at ~1 actual screen pixel | `src/AcDream.App/Rendering/PortalProjection.cs:179` | Unit approximation whose coarseness only strengthens convergence — the merge is the flood's fixpoint floor (replaced MaxReprocessPerCell=16) | At 4K+ a legitimately visible 1–2 px sliver aperture collapses to degenerate and rejects — a thin/distant doorway stops admitting its flood slightly earlier than retail | `Render::copy_view` 0x0054dfc0 | | AP-21 | Entity translucency retains the invented α<0.05 fragment discard. World GfxObj/Setup instances now apply their DAT AlphaBlend/Additive/InvAlpha factors through the retail shared alpha queue, but sealed off-screen WbDrawDispatcher consumers (paperdoll/UI Studio) retain the old immediate normal-alpha pass for all three kinds | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag`; `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`DrawDeferredAlphaBatch` versus immediate Phase 8) | World presentation needed exact per-surface blend for spell/particle density and translucent-object intersections. Off-screen object previews are isolated render targets and have not shown an authored additive entity surface that justifies splitting their compact immediate pass | A faint world fringe below 5% alpha is discarded; a hypothetical additive/inverse-alpha paperdoll or UI Studio entity composites darker than retail inside that private viewport | `D3DPolyRender::SetSurface`; `D3DPolyRender::RenderMeshSubset`; SurfaceType.Additive → D3DBLEND_ONE | | ~~AP-22~~ | **RETIRED 2026-08-06.** The invented cylinder is deleted, not re-derived: retail synthesizes NO shape for a shapeless object. `CPhysicsObj::FindObjCollisions` @0x0050f050 dispatches EXCLUSIVELY — BSP xor CylSphere xor Sphere xor nothing — and with zero cylspheres, zero spheres and no physics BSP it branches straight to the epilogue at `0x0050f22f je 0x50f31b`, returning the `OK_TS` seeded at `0x0050f13b mov edi,1`. Byte-verified against the PDB-paired binary (`9e847e2f-777c-4bd9-886c-22256bb87f32`), disassembled independently rather than read from the Binary Ninja text, whose `ebp_1` aliasing in this function is visibly corrupt. **`CPartArray::GetRadius` (0x005180a0) and `GetHeight` (0x005180b0) are absent from the function's entire call set** (which is exactly `GetNumCylsphere` 0x518080, `GetCylsphere` 0x518090, `GetNumSphere` 0x518060, `GetSphere` 0x518070, `CCylSphere::intersects_sphere` 0x53b8f0, `CSphere::intersects_sphere` 0x537fd0, `OBJECTINFO::missile_ignore` 0x50ceb0, `CPartArray::FindObjCollisions` 0x518180, `COLLISIONINFO::add_object` 0x6b4e20) — `Setup.Radius`/`Height` serve attack cones, `cylinder_distance` and MoveTo, never collision geometry; that consumer (`LiveEntityMotionRuntimeController.GetSetupCylinder`) is retail-faithful and untouched. **The row's site list was incomplete and partly wrong**: it named `ShadowShapeBuilder.cs`, which never reads `Setup.Radius` at all, and omitted TWO real copies — including `LandblockPhysicsContentBuilder`, the ONLY one the headless host executes. Fixing just the cited site would have left headless statics on the invented footprint. All three are deleted. **The row's risk statement was also stale**: it described a live approximation over "rare decorative props", but the branch was unreachable dead code. A sweep of all 5,935 Setups in the installed `client_portal.dat` — validated by byte accounting (5,935/5,935 records consumed with an exact `20 + 48*numLights` residual tail and zero unexplained bytes) and reproduced independently by the production `FlatCollisionAssetBuilder.FlattenSetup` path — finds **0** Setups satisfying the guard: every Setup with `Radius > 0.0001` carries at least one CylSphere or Sphere, and all 1,294 genuinely shapeless Setups have `Radius` exactly 0. Buckets: 678 with ≥1 CylSphere, 3,605 with 0 CylSpheres and ≥1 Sphere, 358 with no primitive but ≥1 physics-BSP part, 1,294 shapeless, 4,282 with `Radius > 0.0001`. Nothing loses collision, because nothing gained it; no visual gate is required. Pinned by `InstalledSetupCollisionReachabilityTests` (negative claim plus five external positive controls so a broken enumeration cannot pass it vacuously — sabotage-verified: inverting the claim reddens it, and an emptied enumeration fails on the controls at `0 != 5935` rather than passing) and by `ShapelessSetupWithRadius_ProducesNoRegistration`, whose sabotage (restoring the deleted block) reddens exactly that fact. The one prior test pinning the fallback built a DAT-impossible Setup; its live state/flag/seed-cell assertions were re-hosted onto a CylSphere fixture rather than deleted, and sabotage-verified in both directions. Evidence: `docs/research/2026-08-06-ap22-contract.md`. | `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs` (`Build`); `src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs` (`PublishStaticEntity`); `src/AcDream.Content/LandblockPhysicsContentBuilder.cs` (`PublishStaticCollision`, headless-only); `tests/AcDream.Content.Tests/InstalledSetupCollisionReachabilityTests.cs`; `tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs` | — | — | `CPhysicsObj::FindObjCollisions` 0x0050f050 (OK_TS seed 0x0050f13b; BSP dispatch `test …,0x10000` 0x0050f165 / `je` 0x0050f16f; BSP-branch exit `jmp` 0x0050f19d; zero-spheres exit `je 0x50f31b` 0x0050f22f; epilogue 0x0050f31b); `CPartArray::GetRadius` 0x005180a0 and `GetHeight` 0x005180b0 (absent from that call set); consumers `CPhysicsObj::check_attack` 0x0050ec80, `get_distance_to_object` 0x0050f7a0 | **EVIDENCE CORRECTED 2026-08-06 at the AP-22 architecture review.** The retirement commit claimed "Headless.Tests 89/89 exercises the site-3 copy"; that is FALSE, proven by sabotage - restoring the invented cylinder in BOTH static sites left the entire suite green. No test references `PublishStaticCollision`, and the headless suite's dummy DAT proxy makes `LandblockLoader.Load` fail so the code is never reached. Two of the three deletions, including the headless-only one, are pinned by the installed-DAT reachability proof ALONE. The behaviour is right; the coverage claim was not. Two further precisions: sites 2/3 guarded on the strictly wider `Radius > 0f` (not site 1's `> 0.0001f`), which differ over the DAT by exactly one Setup - `0x02001657`, denormal radius 1.3e-39 - and the reachability test now evaluates BOTH guards, each measured zero; and the stronger true fact is that all 1,652 no-primitive Setups carry `Radius` exactly 0, which is what actually makes sites 2/3 safe, rather than the 1,294 figure this row first cited. Retail DOES read `GetHeight` inside `report_object_collision` for the quadrant field - that is not a refutation of "never collision geometry", which is a claim about `FindObjCollisions`' shape dispatch only. Reachability independently reproduced by four decoders (contract sweep, implementer parser, both reviewers' from-scratch parsers) plus `tools/SetupInspect`. diff --git a/docs/architecture/worldbuilder-inventory.md b/docs/architecture/worldbuilder-inventory.md index fc8ae21d..a880bd0e 100644 --- a/docs/architecture/worldbuilder-inventory.md +++ b/docs/architecture/worldbuilder-inventory.md @@ -230,10 +230,9 @@ matching retail's own rule that an object not yet in a cell is not drawn. Every distinct entity id this happens for in one frame is counted once in `WalkProductionWorldData.UnregisteredRenderMembershipCount` (renamed from `UnregisteredStaticRenderFallbackCount`, which under the pre-chunk-5 model -also covered the separate dynamic-record fallback) and reported by one -print-only `[walk-membership]` line — gated on -`RenderingDiagnostics.ProbeFacilityStairsEnabled`, the walk family's -existing diagnostic flag — at the start of the next frame when nonzero. +also covered the separate dynamic-record fallback). The count remains a cheap +per-frame invariant available to tests and diagnostics consumers; Campaign +OVERHAUL S5-c5 deleted its spent print-only Facility probe and next-frame log. A primitive-only Setup may reuse its already-authored cylsphere collision cell set; BSP-bearing and pure-visual statics use the all-visual-part box walk. The Facility Hub stair Setup `0x02000623` is the installed-DAT diff --git a/docs/launch-options.md b/docs/launch-options.md index 83ffba72..7518a463 100644 --- a/docs/launch-options.md +++ b/docs/launch-options.md @@ -249,13 +249,6 @@ $env:ACDREAM_FRAME_HISTORY = "$scratch\frames.csv" | `ACDREAM_DUMP_PLAYSCRIPT` | `="1"` (ordinal) | Traces PhysicsScript playback: missing/empty script resolution, malformed `StartTime` entries, and other `[pes]`-prefixed hook-dispatch events | print-only (`Console.WriteLine`) at all 4 use sites (`:85-86,136,300,328`) | unset (off) | `PhysicsScriptRunner.DiagEnabled` (`PhysicsScriptRunner.cs:61-62`) — per-instance settable property seeded from the env var, not a shared static diagnostics-owner class | | `ACDREAM_DUMP_SURFACES` | `="1"` (ordinal) | One-shot (per session) surface-format histogram dump for the atlas-opportunity audit — fires once after `_dumpFrameCounter>=600` OnRender ticks AND `_uploadMetadata.Count>=100` uploaded textures; writes to the host diagnostics directory | Doc comment claims "Zero cost when off" but `_uploadMetadata[name]=(w,h,fmt)` (`TextureCache.cs:1042`) is written **unconditionally on every texture upload regardless of the flag** — real (small) always-on dictionary-write cost. `TickSurfaceHistogramDumpIfEnabled` also re-reads `Environment.GetEnvironmentVariable` every OnRender frame (not cached) until the one-shot fires. Dump-write failures are caught and logged to stderr, not fatal. | unset (off) | `TextureCache` (`TextureCache.cs:102-113` fields, gate at `TextureCache.cs:802-812`, dump at `TextureCache.cs:814-829`), Phase N.6 slice 1 | | `ACDREAM_FRAME_PROF` | `=1` | master toggle for the frame profiler: CPU frame time, GPU time samples, per-stage CPU attribution, per-frame alloc/GC, `[frame-prof]` report every ~5 s (doc: "permanent apparatus ... do not strip with session probes") | when on, samples `GC.GetAllocatedBytesForCurrentThread()` and stage-scope timing every frame (cheap, by design); its own XML doc claims a GPU-query self-disable tied to `ACDREAM_WB_DIAG=1` that `FrameProfiler.cs` says no longer exists — see Notes #1; startup-only in practice (its DebugPanel/DebugVM mirror is unreachable — #434)| false (off) | `RenderingDiagnostics.FrameProfEnabled` / `FrameProfiler` | -| `ACDREAM_PROBE_ENVCELL` | `=1` | emits one `[envcells]` line per indoor frame: `CellsRendered`/`TrianglesDrawn` + ourBldgs/otherBldgs/filter counts (phase a8 relic; its own render pass was removed but the probe was kept) | print-only; implicitly turned on whenever `ACDREAM_PROBE_VIS` is on (getter is `_probeEnvCellEnabled \ | \ | `RenderingDiagnostics.ProbeEnvCellEnabled` (backing field OR'd with `ProbeVisibilityEnabled`) | -| `ACDREAM_PROBE_INDOOR_ALL` | `=1` | master switch that reads as AND / writes as cascade across Walk, Lookup, Upload, Xform, Cull | print-only (every underlying probe is print-only); startup-only in practice (its DebugPanel/DebugVM mirror is unreachable — #434)| false (off) | `RenderingDiagnostics.IndoorAll` (cascades to the 5 flags below) | -| `ACDREAM_PROBE_INDOOR_CULL` | `=1` (also set by `ACDREAM_PROBE_INDOOR_ALL=1`) | emits `[indoor-cull]` per culled cell entity with cull reason (visibleCellIds-miss / frustum / landblock) | print-only; startup-only (its DebugPanel/DebugVM mirror is unreachable — #434) | false (off) | `RenderingDiagnostics.ProbeIndoorCullEnabled` | -| `ACDREAM_PROBE_INDOOR_LOOKUP` | `=1` (also via `ACDREAM_PROBE_INDOOR_ALL`) | emits `[indoor-lookup]` per visible cell entity/sec: render-data hit/miss, IsSetup, parts-hit/parts-miss tallies | print-only; startup-only (its DebugPanel/DebugVM mirror is unreachable — #434) | false (off) | `RenderingDiagnostics.ProbeIndoorLookupEnabled` | -| `ACDREAM_PROBE_INDOOR_UPLOAD` | `=1` (also via `ACDREAM_PROBE_INDOOR_ALL`) | emits `[indoor-upload]` requested/completed lines per EnvCell id at `WbMeshAdapter`'s staged-drain time | print-only; startup-only (its DebugPanel/DebugVM mirror is unreachable — #434) | false (off) | `RenderingDiagnostics.ProbeIndoorUploadEnabled` | -| `ACDREAM_PROBE_INDOOR_WALK` | `=1` (also via `ACDREAM_PROBE_INDOOR_ALL`) | emits `[indoor-walk]` per visible cell entity/sec: world position, parent cell, landblock/AABB-visible flags, "drew" flag | print-only; startup-only (its DebugPanel/DebugVM mirror is unreachable — #434) | false (off) | `RenderingDiagnostics.ProbeIndoorWalkEnabled` | -| `ACDREAM_PROBE_INDOOR_XFORM` | `=1` (also via `ACDREAM_PROBE_INDOOR_ALL`) | emits `[indoor-xform]` per visible cell entity/sec: cell-geometry SetupPart's composed world-matrix translation | print-only; startup-only (its DebugPanel/DebugVM mirror is unreachable — #434) | false (off) | `RenderingDiagnostics.ProbeIndoorXformEnabled` | | `ACDREAM_PROBE_LOGIN_FRAMES` | `="1"` | Per-completed-frame login/portal-wormhole presentation classification (`world`/`tunnel`/`black`/`void`); logs `[login-frames]` on each classification transition | print-only. "Not a user setting; not in RuntimeOptions; not persisted" (doc comment). | unset (off) | `RenderPresentationDiagnostics.ProbeLoginFrames` (`LoginPresentationFrameProbe.cs:28-29`), consumed by `LoginPresentationFrameProbe.Process` | | `ACDREAM_PROBE_NET` | `="1"` | Emits `[net-out]` (per outbound reliable message), `[net-tick]` (1 Hz WorldSession.Tick summary incl. reliable-transport rates), `[net-final]` (cumulative stats at Dispose), and `[cmd-gate]` (generation-gated command rejections) | print-only. Doc comment: "the counters themselves increment unconditionally in `TransportStats`; only the string work is gated" — i.e. the underlying stats tracking has a small always-on cost independent of this flag, but this flag itself gates only string/console formatting. | unset (off) | `NetDiagnostics.ProbeNet` (`NetDiagnostics.cs:56-57`), issue #260 probe family | | `ACDREAM_PROBE_RESOLVE` | `=1` | gates one structured `[resolve]` line per `PhysicsEngine.ResolveWithTransition` call (in/target/out position+cell, ok-vs-partial, grounded/contact status, wall normal, walkable-polygon validity, responsible entity) (l.2a slice 1, general-purpose resolver probe) | print-only, ~30 Hz per moving entity while on | off | `PhysicsDiagnostics.ProbeResolveEnabled` | @@ -264,7 +257,6 @@ $env:ACDREAM_FRAME_HISTORY = "$scratch\frames.csv" | `ACDREAM_PROBE_TUNNEL_FREEZE` | `=1` or `=N` | #419 RenderDoc apparatus: holds the teleport state in stable `Tunnel` after destination readiness and freezes the portal-space animation/roll at frame 72 (`=1`) or an explicit frame 2–120 (`=N`); emits one `[tunnel-freeze]` line with the actual frame and retail Setup/animation ids | **behavior-changing diagnostic:** placement, world viewport reveal, and LoginComplete are intentionally withheld until transition cancellation/process exit. For static visual inspection only; never use in a performance or lifecycle measurement. | unset (off) | `StreamingDiagnostics.TunnelFreezeFrame`; consumed by `LocalPlayerTeleportPresentation` and `PortalTunnelPresentation` | | `ACDREAM_PROBE_SOUND_WIRE` | `="1"` | One line per inbound server Sound event (`0xF750`) and per wire-sound play decision, with the drop reason when nothing plays — used to determine whether missing interior soundscapes are server- or client-side | print-only, consumed at `AudioHookSink.cs:159` and `EntityEffectController.cs:123` | unset (off) | `AudioDiagnostics.ProbeWireSoundsEnabled` (`AudioDiagnostics.cs:20-21`) | | `ACDREAM_PROBE_USEABILITY_FALLBACK` | `=1` | gates a per-call log of `IsUseableTarget` calls that take the null-useability fallback path (creature/door/lifestone passes) (measures a real ace-vs-retail data gap, not a bug investigation) | print-only; measures how often ACE ships entities without `_useability` set | off | `PhysicsDiagnostics.ProbeUseabilityFallbackEnabled` | -| `ACDREAM_PROBE_VIS` | `=1` | emits `[vis]` line on root-cell CHANGE: visible cell ids, OutsideView poly/plane counts, per-cell plane counts, scissor-fallback count (phase u.2d repurposed the flag; its DebugPanel mirror is unreachable — #434) | print-only; ALSO implicitly enables the separate `ACDREAM_PROBE_ENVCELL` probe (its getter ORs with this flag — see Notes #3); startup-only in practice (its DebugPanel/DebugVM mirror is unreachable — #434)| false (off) | `RenderingDiagnostics.ProbeVisibilityEnabled` | | `ACDREAM_REMOTE_VEL_DIAG` | `=1` | prints per-UM/per-tick remote-velocity and animation-cycle diagnostic lines; `Runtime/Physics/RemoteMotion.cs` carries diagnostic-only fields (`PrevServerPos`, `PrevServerPosTime`, `MaxRootMotionSpeedSinceLastUP`, `LastOmegaDiagLogTime`) unconditionally on every remote — small fixed per-instance memory regardless of the flag, not gated (long-lived remote-velocity/animation diagnostic, commit a.1) | print-only, but the raw-site reads in `LiveEntityNetworkUpdateController.cs` fire on every UM/tick even when off (rule-5 violation, `Environment.GetEnvironmentVariable` call per event, 6+ call sites) | off | THREE readers: `AnimationPresentationDiagnostics.FromEnvironment()` (App owner record, cached at startup, consumed by `LiveEntityAnimationPresenter` for `[SEQSTATE]`/`[CURRNODE]`/other part-diagnostic lines, throttled to 1/sec/entity) + raw `Environment.GetEnvironmentVariable` reads scattered across `LiveEntityNetworkUpdateController.cs` (6+ sites: `[UM_RAW]`, `[FWD_WIRE]`, `[VEL_DIAG]`, `[UPCYCLE_SRC]`, `[UM_STALE]`) + `RemoteServerControlledVelocityCycle.cs:68` (`[UPCYCLE]`) | | `ACDREAM_DUMP_CELLS` | `=` | 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` | `=` | overrides the output directory for `ACDREAM_DUMP_CELLS` — Companion output-directory knob for ACDREAM_DUMP_CELLS. | print/file-path only; no effect unless `ACDREAM_DUMP_CELLS` is also set | off/unset | `PhysicsDiagnostics.ProbeDumpCellsPath` | @@ -313,25 +305,17 @@ issue is closed, the strip was missed; delete both. | `ACDREAM_PROBE_CELLSET` | a6.p5 | `=1` | gates `PhysicsDiagnostics.LogCellSetBuild`, one `[cellset-build]` line per `BuildCellSetAndPickContaining` call (seed cell, sphere XY, candidate list) from `CellTransit.cs:1468` | print-only; builds a `StringBuilder` of the candidate id list only when the flag is on | `PhysicsDiagnostics.ProbeCellSetEnabled` | | `ACDREAM_PROBE_CELL_CACHE` | indoor walking phase d | `=1` | gates one `[cell-cache]` line per EnvCell first-cached in `PhysicsDataCache.CacheCellStruct` (poly counts, BSP root structure) | print-only; fires at most once per EnvCell (cache is no-op after first population); no DebugPanel mirror | `PhysicsDiagnostics.ProbeCellCacheEnabled` | | `ACDREAM_PROBE_CHILD_CELL` | c4 route 7 | `=1` | gates one `[child-cell]` line per Runtime committed-child canonical-cell write in `RuntimeLiveEntitySessionController`, `RuntimeEntityObjectLifetime`, `RuntimeEntityDirectory` (parent/child guid, old/new cell, cause tag) | print-only | `PhysicsDiagnostics.ProbeChildCellEnabled` | -| `ACDREAM_PROBE_CLIPROUTE` | "throwaway apparatus — strip once §4 ships" | `=1` | print-on-change `[clip-route]` / `[clip-route-scis]` lines: outside-slice clip routing + region-SSBO bytes, actual GL/RHI scissor state (S3 review fix round 1, F5: the third producer, `[clip-route-disp]` — WbDrawDispatcher's per-entity clip-slot routing histogram — is deleted with the routing state it reported on) | print-only | `RenderingDiagnostics.ProbeClipRouteEnabled` | | `ACDREAM_PROBE_CONTACT_PLANE` | spike-only, 2026-05-20 | `=1` | gates one `[cp-write]` line per write to `CollisionInfo.ContactPlane*`/`LastKnownContactPlane*` fields (field, old→new, caller method via stack walk, source line); only logs on actual value changes | print-only, but performs a stack walk to identify the caller method when firing — real CPU cost per write while on (not just a string format); suppresses no-op writes to bound volume | `PhysicsDiagnostics.ProbeContactPlaneEnabled` | | `ACDREAM_PROBE_ENT` | #138 | `="1"` | Traces the persistent player entity across teleport streaming churn: presence in the render draw-set flat view vs. survival of the dynamics cull, to distinguish "missing from draw set" vs "present but culled" | print-only, "Observation-only — emits no behavior change" (doc comment). `LogPlayerDynOnChange` dedupes by transition to avoid per-frame spam. Marked STRIP-once-root-caused (like the dense-town FPS apparatus). | `EntityVanishProbe.Enabled` (`EntityVanishProbe.cs:23-24`), issue #138-B | -| `ACDREAM_PROBE_FLAP` | "throwaway apparatus — strip once the flap mechanism is confirmed" | `=1` | EVERY FRAME (unthrottled, not change-gated) while the camera root is indoor: `[flap]` from `PortalVisibilityBuilder.Build` (portal side-test/traverse/cull/projection) + paired `[flap-cam]` from `PhysicsCameraCollisionProbe`/`[flap-sweep]` (FindCameraCell resolution, eye positions) | print-only, but unthrottled per-frame `StringBuilder` allocation + `Console.WriteLine` on multiple call sites while indoor — heavy log volume/allocation under sustained indoor play; does not alter rendered output | `RenderingDiagnostics.ProbeFlapEnabled` | -| `ACDREAM_PROBE_GLSTATE` | "throwaway apparatus — strip once §4 ships" | `=1` | print-on-change `[gl-state]` line: depth/blend/cull/scissor/viewport/draw-FBO/color-mask/`glGetError` snapshot | print-only per its docstring; the actual state-snapshot/comparison call site lives outside `RenderingDiagnostics.cs` and was outside this pass's cited read sites | `RenderingDiagnostics.ProbeGlStateEnabled` | | `ACDREAM_PROBE_INDOOR_BSP` | indoor walking phase 1 / cellar-lip wedge | `=1` | gates `[indoor-bsp]` (per `BSPQuery.FindCollisions` indoor call), `[neg-poly]` (near-miss polygon detail in `BSPQuery`), and `[stepdown-decide]` (step-down accept/reject inputs in `TransitionTypes`) trace lines | print-only; also un-gates the `LastBspHitPoly` diagnostic side-channel write (same as `ACDREAM_PROBE_BUILDING`) | `PhysicsDiagnostics.ProbeIndoorBspEnabled` | -| `ACDREAM_PROBE_INDOOR_LIGHT` | #176/#177 discriminator, a7.l1 | `=1` | rate-limited (1 Hz) `[indoor-light]` line from `LightManager.BuildPointLightSnapshot`: point-light pool set composition (pool/cellLess/registered/capped/byCell histogram) | print-only, explicitly "inert unless set" per the call-site comment (LightManager.cs:368-370); no DebugPanel mirror | `RenderingDiagnostics.ProbeIndoorLightEnabled` | | `ACDREAM_PROBE_JUMP` | campaign ch round 2 | `=1` | gates the `[jump]` line in `PlayerMovementController.ReportJumpRefusal`, printed UNCONDITIONALLY (even when `OnInterfaceText` is null) to distinguish "branch never fired" from "branch fired, callback dropped it" | print-only; `Headless/Policies/HeadlessBotPolicy.cs`'s `JumpProbeHeadlessBotPolicy` doc comment references this flag as a companion but does not itself read it — it is a headless bot behavior meant to be run alongside `ACDREAM_PROBE_JUMP=1`, not a second consumer | `PhysicsDiagnostics.ProbeJumpEnabled` | | `ACDREAM_PROBE_LOCAL_TELEPORT` | c4 route 3 d-t8 | `=1` | gates one `[local-tp]` line per local-player portal-arrival attempt (committed AND refused) from `LocalPlayerTeleportController` and `RuntimeAcceptedPositionDriveController.LogPortalArrivalAttempt` — the single Runtime chokepoint both graphical and headless hosts share | print-only; dual-host parity evidence (same line shape from both hosts) | `PhysicsDiagnostics.ProbeLocalTeleportEnabled` | | `ACDREAM_PROBE_PARK` | issue #309 | `=1` | gates `[park]`/`[park-restore]` lines when a `RuntimeSetPositionState` placement parks or a cancelled park's withdrawal is rolled back | print-only, low volume (parks are rare); in a MULTI-session headless host, `HeadlessStaticStateAudit.ValidateProcessIsolation` THROWS `HeadlessConfigurationException` at startup if this (or any other process-global `Probe*`/`Dump*` boolean, `CollisionShadowSampleEvery`, or `PhysicsResolveCapture`) is enabled — refusal is waived only when `sessionCount==1` (logs loudly and proceeds instead) | `PhysicsDiagnostics.ProbeParkEnabled` | | `ACDREAM_PROBE_PLACEMENT_FAIL` | issue #98 | `=1` | gates one `[place-fail]` line per Path-1 (Placement/Ethereal) `Collided` return in `BSPQuery.FindCollisions`, plus one per `Transition.DoStepDown` placement-insert rejection | print-only; low volume (fires only on actual rejection) | `PhysicsDiagnostics.ProbePlacementFailEnabled` | | `ACDREAM_PROBE_POLY_DUMP` | a6.p3 slice 4, issue #98 | `=1` | gates one `[poly-dump]` line (full polygon geometry: cell, poly index, sides, plane, all vertices) per `AdjustSphereToPlane` push-back call | print-only; HEAVY output (one full-geometry dump per push-back call) — doc explicitly says "use briefly, then turn off" | `PhysicsDiagnostics.ProbePolyDumpEnabled` | -| `ACDREAM_PROBE_PORTAL_CHURN` | "throwaway apparatus — strip once the bound ships" | `=1` | one `[portal-churn]` summary per `PortalVisibilityBuilder.Build` call: per-cell pop/re-pop counts, re-enqueue totals, reciprocal-clip pre→post region growth | print-only | `RenderingDiagnostics.ProbePortalChurnEnabled` | | `ACDREAM_PROBE_PUSH_BACK` | phase a6.p1 | `=1` | gates `[push-back]` (`BSPQuery.AdjustSphereToPlane`), `[push-back-disp]` (`BSPQuery.FindCollisions` 6-path dispatcher), `[push-back-cell]` (`Transition.CheckOtherCells` multi-cell BSP) lines | print-only; the `DebugVM.cs:380` "runtime mirror" is dead code — `DebugVM`/`DebugPanel` (`AcDream.UI.Abstractions/Panels/Debug/`) are never instantiated anywhere in `src/` (the ImGui frontend they required was removed at Campaign V slice V11); only the startup env var takes effect | `PhysicsDiagnostics.ProbePushBackEnabled` | -| `ACDREAM_PROBE_PVINPUT` | "throwaway apparatus — strip once the jitter source is pinned" | `=1` | one `[pv-input]` line/frame with 6-dp-precision `PortalVisibilityBuilder.Build` inputs (camera eye, player position, VP elements) + resulting flood-cell count; deliberately runs WITHOUT the heavier `[flap]` probe so the log stays diffable | print-only | `RenderingDiagnostics.ProbePvInputEnabled` | | `ACDREAM_PROBE_REMOTE_SLIDE` | bug b, temporary — strip once two-client roof capture lands | `=1` OR `=` | gates `[remote-slide-up]`/`[remote-slide-vec]`/`[remote-slide-snap]`/`[remote-slide-enq]` lines across `LiveEntityNetworkUpdateController`, `InterpolationManager`, `RuntimeRemotePhysicsUpdater`, `RuntimeRemoteSteadyStatePosition` tracing two candidate remote-slide "blip" producers | print-only; `BeginRemoteSlideAttribution`/GUID-stamping calls are UNCONDITIONAL at several call sites (self-guard is internal), so a `[ThreadStatic]` field write happens on every remote tick regardless of the flag (cheap, non-allocating); a GUID allow-list narrows output to specific entities for a readable two-client capture | `PhysicsDiagnostics.ProbeRemoteSlideEnabled` + `ProbeRemoteSlideGuids` (raw string parsed via `ParseHexIdList` unless it's the literal `"1"`) | | `ACDREAM_PROBE_REMOTE_TELEPORT` | c4 route 4b-3, temporary | `=1` | gates one `[remote-teleport]` line per routed remote teleport arm in `LiveEntityNetworkUpdateController.ApplyRemoteContactRouting` | print-only; a 2026-08-04 fix moved the enabled-check to the CALL SITE because the probe's internal self-guard did not prevent `teleportStatus.ToString()` from being evaluated/allocated on every teleport regardless of flag state — now properly guarded | `PhysicsDiagnostics.ProbeRemoteTeleportEnabled` | -| `ACDREAM_PROBE_FACILITY_STAIRS` | #177 Facility Hub stair probe (committed by the `b3b7d922` investigation checkpoint) | `=1` | traces the classifier admission, ordered-stream append, ring upload, and final indirect draw of the authored stair GfxObj `0x010000DE` and the local-player setup parts in cells `0x8A02015E/015F/01C1`, plus the walk root signature for those cells; Campaign OVERHAUL S2 chunk 5 added a `[walk-membership]` line, printed once at the start of a frame only when `WalkProductionWorldData.UnregisteredRenderMembershipCount` was nonzero the PRIOR frame (an entity the registry flooded into a cell but the presentation scene could not resolve yet that frame) | print-only; never influences admission, order, or depth state; scheduled for deletion in Campaign OVERHAUL S5 | `RenderingDiagnostics.ProbeFacilityStairsEnabled` (read sites in `RetailPViewRenderer`, `WalkPView`, `WalkProductionWorldData`, `WalkStaticStreamPopulator`, `WbDrawDispatcher.*`) | -| `ACDREAM_PROBE_SEAMDRAW` | #176, "throwaway apparatus" | `"1"`/`"true"`/blank → default #176 Facility Hub cell set (7 fixed hex ids); otherwise comma-separated hex cell-id list | change-deduped + 2 s-heartbeat `[seam-cell]`/`[seam-snap]`/`[seam-ent]`/`[seam-mask]` lines from `EnvCellRenderer.Render` and `WbDrawDispatcher` describing per-instance transforms and resolved light-set identities at target cells | print-only | `RenderingDiagnostics.ProbeSeamDrawEnabled` / `SeamDrawTargetCells` | | `ACDREAM_PROBE_STEP_WALK` | a6.p3 issue #98 | `=1` | gates `[step-walk]` lines at select points in the transition sub-step loop and step-down probe (requested vs adjusted offset, sphere positions, contact planes, walkable flags) | print-only; no DebugPanel mirror | `PhysicsDiagnostics.ProbeStepWalkEnabled` | | `ACDREAM_PROBE_SWEPT` | phase w stage 0 | `=1` | gates one `[cell-swept]` line per `ResolveWithTransition` call comparing the transition's swept cell vs the legacy static `ResolveCellId` path | print-only | `PhysicsDiagnostics.ProbeSweptEnabled` | | `ACDREAM_PROBE_TELEPORT` | 2026-06-22, "removable diagnostic" | `=1` | gates `[tp-probe]` lines (`LogTeleport`) at AIM/ENQ/BUILD/APPLY/PLACED teleport-pipeline events across `LocalPlayerTeleportController` and `RuntimeAcceptedPositionDriveController`, with cross-thread monotonic timestamps | print-only | `PhysicsDiagnostics.ProbeTeleportEnabled` | @@ -358,7 +342,7 @@ from the "must still exist" check. | `ACDREAM_JUMP_SKILL` | As above. The fallback is 300, not the 200 that CLAUDE.md advertised. | none — server-authoritative | | `ACDREAM_RENDER_BACKEND` | Selected the GL-vs-Vulkan backend. Campaign V deleted the OpenGL backend; Vulkan is the only one. Two comments still named it as a live co-requisite until 2026-08-24. | none | | `ACDREAM_ANIM_SPEED_SCALE` | Animation-speed multiplier from the pre-retail-sequencer era; died with the 1.248x factor. | none | -| `ACDREAM_A8_AUDIT` | Phase A8 EnvCell batch/cull audit dump. Its only caller never existed; `EnvCellRenderer.CollectCellAuditLines` was unreachable and was deleted 2026-08-24. | `ACDREAM_PROBE_ENVCELL` | +| `ACDREAM_A8_AUDIT` | Phase A8 EnvCell batch/cull audit dump. Its only caller never existed; `EnvCellRenderer.CollectCellAuditLines` was unreachable and was deleted 2026-08-24. | none | | `ACDREAM_AIRBORNE_DIAG` | #42 airborne-sweep `[SWEEP]`/`[SWEEP-OBJ]` XY-drift trace. Investigation closed; stripped 2026-08-24 (#435) along with its 16 siblings below. | none | | `ACDREAM_DUMP_ENTITY` | #119 tower-staircase HYDRATE/DRAW/WALK-REJECT entity watchlist. Investigation closed; stripped 2026-08-24 (#435). | none | | `ACDREAM_PROBE_AUTOWALK` | Issue #63 server-initiated auto-walk trace. Investigation closed; stripped 2026-08-24 (#435). | none | diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs index 8b544a76..926d9479 100644 --- a/src/AcDream.App/Composition/FrameRootComposition.cs +++ b/src/AcDream.App/Composition/FrameRootComposition.cs @@ -358,15 +358,8 @@ internal sealed class FrameRootCompositionPhase { // Campaign V slice V6j: the world scene is composed on BOTH arms. // Every renderer below now exists on Vulkan too; what forks is one - // pass surface, one state restorer, one GL-state reader, and the - // three renderers that stay raw GL until their own slices — sky, - // particles and the portal depth mask, which the executors already - // accept as absent. WorldRenderDiagnostics worldRenderDiagnostics = - host.WorldRenderDiagnostics - ?? new WorldRenderDiagnostics( - NullRenderGlStateReader.Instance, - d.RenderDiagnosticLog); + new(d.RenderDiagnosticLog); IRenderFrameGlState worldFrameGlState = NullRenderFrameGlState.Instance; IWorldPassScope? worldPassScope = d.Graphics.WorldPassScope; IWorldPassSurface worldPassSurface = new RhiWorldPassSurface( @@ -445,10 +438,8 @@ internal sealed class FrameRootCompositionPhase live.ParticleRenderer, live.PortalDepthMask, d.RetailAlphaQueue, - worldRenderDiagnostics, terrainDrawDiagnostics); var worldSceneDiagnostics = new WorldSceneDiagnosticsController( - worldRenderDiagnostics, new RuntimeWorldScenePViewDiagnosticSource( d.PlayerController, d.PhysicsEngine, diff --git a/src/AcDream.App/Composition/HostInputCameraComposition.cs b/src/AcDream.App/Composition/HostInputCameraComposition.cs index 93bd8cf9..34680ec6 100644 --- a/src/AcDream.App/Composition/HostInputCameraComposition.cs +++ b/src/AcDream.App/Composition/HostInputCameraComposition.cs @@ -33,7 +33,6 @@ internal sealed record HostInputCameraResult( IRenderFrameSlotSource FrameSlots, IGpuDevice GpuDevice, GpuDeviceFrameLifetime GpuFrameLifetime, - WorldRenderDiagnostics? WorldRenderDiagnostics, SilkKeyboardSource? KeyboardSource, SilkMouseSource? MouseSource, IMouseLookCursor? MouseLookCursor, @@ -86,11 +85,6 @@ internal interface IHostInputCameraCompositionFactory GpuFrameFlightController? frameFlights, IGpuDevice device); - /// The raw-GL state tripwire, or null on a backend that has no GL state. - WorldRenderDiagnostics? CreateWorldRenderDiagnostics( - GameWindowGraphics graphics, - IRenderFrameDiagnosticLog log); - SilkKeyboardSource CreateKeyboardSource( IKeyboard keyboard, HostQuiescenceGate quiescence); @@ -250,11 +244,6 @@ internal sealed class HostInputCameraCompositionPhase : var gpuFrameLifetime = new GpuDeviceFrameLifetime(gpuDevice); _publication.PublishGpuFrameLifetime(gpuFrameLifetime); - WorldRenderDiagnostics? diagnostics = - _factory.CreateWorldRenderDiagnostics( - graphics, - _dependencies.RenderDiagnosticLog); - IKeyboard? firstKeyboard = input.Keyboards.FirstOrDefault(); IMouse? firstMouse = input.Mice.FirstOrDefault(); SilkKeyboardSource? keyboard = null; @@ -356,7 +345,6 @@ internal sealed class HostInputCameraCompositionPhase : frameSlots, gpuDevice, gpuFrameLifetime, - diagnostics, keyboard, mouse, cursor, diff --git a/src/AcDream.App/Composition/VulkanHostInputCameraCompositionFactory.cs b/src/AcDream.App/Composition/VulkanHostInputCameraCompositionFactory.cs index a7091979..15f7ffe9 100644 --- a/src/AcDream.App/Composition/VulkanHostInputCameraCompositionFactory.cs +++ b/src/AcDream.App/Composition/VulkanHostInputCameraCompositionFactory.cs @@ -16,10 +16,7 @@ namespace AcDream.App.Composition; /// /// What is absent, and why. There is no GL fence ring: the RHI /// device owns its own frames-in-flight through a timeline semaphore, so -/// retirement and slot indexing come from the device instead. There is no -/// : 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. +/// retirement and slot indexing come from the device instead. /// internal sealed class VulkanHostInputCameraCompositionFactory : IHostInputCameraCompositionFactory @@ -57,10 +54,6 @@ internal sealed class VulkanHostInputCameraCompositionFactory IGpuDevice device) => new VulkanRenderFrameSlotSource(RequireContext(graphics).Device); - public WorldRenderDiagnostics? CreateWorldRenderDiagnostics( - GameWindowGraphics graphics, - IRenderFrameDiagnosticLog log) => null; - public SilkKeyboardSource CreateKeyboardSource( IKeyboard keyboard, HostQuiescenceGate quiescence) => diff --git a/src/AcDream.App/Rendering/CellVisibility.cs b/src/AcDream.App/Rendering/CellVisibility.cs index eac7bf94..59705c86 100644 --- a/src/AcDream.App/Rendering/CellVisibility.cs +++ b/src/AcDream.App/Rendering/CellVisibility.cs @@ -89,8 +89,7 @@ public sealed class LoadedCell /// CEnvCell.stab_list (acclient.h ~30925), the stable set of cells potentially /// visible from this cell, precomputed by the AC content tools. Refreshed only at /// hydration (= retail's per-cell-entry grab_visible_cells, decomp:311878). - /// PortalVisibilityBuilder grounds set membership in it so a brittle per-frame - /// portal-side test can't drop a potentially-visible cell from the visible set. + /// The frame walk uses it to ground authored potentially-visible membership. /// Empty when the dat carried no stab list (degenerate / old cell). /// public IReadOnlyList VisibleCells = System.Array.Empty(); @@ -105,9 +104,8 @@ public sealed class LoadedCell /// /// Render unification (2026-06-07): true for the synthetic OUTDOOR cell node built by - /// — the outdoor world modelled as a flood-graph cell whose - /// shell is the landscape. seeds OutsideView - /// full-screen when the root carries this flag (so terrain/sky/scenery draw as the node's shell). + /// — the outdoor world modelled as a walk root whose + /// shell is the landscape. The frame assembler seeds its outside view full-screen. /// An explicit flag, not a cell-id heuristic: interior EnvCell ids are >= 0x100 in production but /// test fixtures use low ids for interior cells, so keying on the id would misfire. /// @@ -168,10 +166,8 @@ public struct PortalClipPlane } /// -/// Phase U.4c flap probe (diagnostic — OBSOLETE as of Stage 3). Previously tracked -/// which branch of FindCameraCell (now deleted) resolved the camera cell. Retained -/// for binary compatibility with the [flap-cam] probe log site in GameWindow.cs that -/// still prints (always None post-Stage 3). +/// Historical result describing which branch of the former camera-cell resolver ran. +/// The retained physics-membership route always reports . /// public enum CameraCellResolution { @@ -219,7 +215,7 @@ public sealed class CellVisibility /// /// Stage 3 (2026-06-02): always — the FindCameraCell /// AABB grace-frame resolver was deleted; the physics membership answer is the sole root. - /// Retained for the [flap-cam] probe log line in GameWindow.cs. + /// Retained as a stable diagnostic result for existing frame records. /// public CameraCellResolution LastCameraCellResolution { get; private set; } = CameraCellResolution.None; diff --git a/src/AcDream.App/Rendering/ClipFrameAssembler.cs b/src/AcDream.App/Rendering/ClipFrameAssembler.cs index 46a01e39..fc7ae8db 100644 --- a/src/AcDream.App/Rendering/ClipFrameAssembler.cs +++ b/src/AcDream.App/Rendering/ClipFrameAssembler.cs @@ -1,9 +1,8 @@ // ClipFrameAssembler.cs // // Retail view assembly policy. The production frame walk supplies its -// outside_view directly; the legacy PortalVisibilityBuilder overload remains -// only for isolated research/replay tests. Each visible polygon is packed as -// an individual GPU clip slot. +// outside_view directly. Each visible polygon is packed as an individual GPU +// clip slot. // // outside_view landscape slices // reverse cell_draw_list exit masks @@ -19,34 +18,10 @@ using System.Numerics; namespace AcDream.App.Rendering; -/// -/// S3 chunk 4 fix round 2 (L2): retired from the walk's own clip assembly — -/// no longer tracks a terrain clip mode at -/// all, because the walk draws the sky, terrain and weather unclipped, one -/// call each, matching retail (LScape::draw never installs a view -/// before any of the three). The type stays only because -/// 's separate flat-world safety path still -/// uses it for its own unrelated "did the flat terrain draw this frame" -/// diagnostic flag (Planes = drew, Skip = the PView walk ran -/// instead). S3 landing hygiene (H4) deleted the Scissor member: no -/// producer ever wrote it ( only ever -/// assigns Planes or Skip), because there is no GPU scissor -/// consumer left anywhere in this contract, walk or flat. -/// -public enum TerrainClipMode -{ - /// The flat-world path drew terrain this frame. - Planes, - - /// The PView walk ran instead of the flat-world path this frame; - /// flat terrain was not drawn. - Skip, -} - /// /// One retail portal_view slice mapped to a GPU clip slot. The AABB is -/// retained for diagnostics () only — no -/// GPU pass reads it; there is no scissor consumer left in the walk. +/// retained as part of the exact outside-view reconstruction proof — no GPU +/// pass reads it; there is no scissor consumer left in the walk. /// /// S3 review fix round 1 (F1): /// distinguishes the "this walk view's own polygon collapsed" case (retail @@ -95,7 +70,7 @@ public sealed class ClipFrameAssembly public Dictionary PerCellPlaneCounts { get; } = new(); public int ScissorFallbacks { get; internal set; } - // The assembly is frame-scoped. An owner that passes it back to Assemble may reuse the + // The assembly is frame-scoped. An owner may pass it back to reuse the // dictionaries, per-cell arrays, and construction list after the prior frame is consumed. // Exact-length pools keep the public array API honest: Length is always the live slice count. private readonly Dictionary> _sliceArraysByLength = new(); @@ -279,8 +254,7 @@ public sealed class ClipFrameAssembly public static class ClipFrameAssembler { /// - /// Starts one production walk assembly without constructing a parallel - /// PortalVisibilityFrame. Outdoor roots begin with retail's full-screen + /// Starts the one production walk assembly. Outdoor roots begin with retail's full-screen /// default view; interior roots begin empty and are populated from /// after Collect. /// @@ -321,109 +295,6 @@ public static class ClipFrameAssembler return assembly; } - public static ClipFrameAssembly Assemble( - ClipFrame frame, - PortalVisibilityFrame pvFrame, - ClipFrameAssembly? reuseAssembly = null) - { - System.ArgumentNullException.ThrowIfNull(frame); - System.ArgumentNullException.ThrowIfNull(pvFrame); - - frame.Reset(); - ClipFrameAssembly assembly = reuseAssembly ?? new ClipFrameAssembly(); - assembly.Reset(frame); - - Dictionary cellIdToSlot = assembly.CellIdToSlot; - Dictionary cellIdToViewSlots = assembly.CellIdToViewSlots; - Dictionary cellIdToViewSlices = assembly.CellIdToViewSlices; - Dictionary perCellPlaneCounts = assembly.PerCellPlaneCounts; - int scissorFallbacks = 0; - - foreach (uint cellId in pvFrame.OrderedVisibleCells) - { - if (!pvFrame.CellViews.TryGetValue(cellId, out var view)) - continue; - - List 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(); - 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 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; - } - /// /// Campaign FW4 slice 1 — the interior root's outside-view cutover. /// Replaces the assembly's outside-view block (slices, terrain mode, @@ -433,13 +304,8 @@ public static class ClipFrameAssembler /// exactly ONE visibility structure per frame: LScape::draw's /// terrain clip, the punch fans' building_view planes, and the /// landscape turn's view count all read the views the walk itself - /// installed. Feeding these from the old PortalVisibilityBuilder - /// assembly let the two systems desynchronize on camera-transition - /// boundary frames — terrain splashed through stale/fat old-apparatus - /// exit views over interior pixels the walk's flood never repainted - /// (the FW3 visual-gate stairwell/grass flash, probe-pinned - /// 2026-08-30), and punch fans indexed the old slice array with walk - /// view indices. + /// installed. This keeps terrain, punch fans, and landscape turns on + /// the same frame-owned walk views during camera transitions. /// /// The walk stores view vertices as PIXEL screen points /// (copy_view's post-divide viewport coordinates, origin @@ -463,8 +329,7 @@ public static class ClipFrameAssembler /// by that index. The /// shared helper SKIPS a view whose /// polygon collapses () — - /// correct for 's "empty regions are omitted - /// entirely" legacy policy, but wrong here: skipping would make the + /// unsuitable here because skipping would make the /// slice array SHORTER than the view list, so every later view's fan /// would read the WRONG view's planes (off by however many views /// collapsed before it). A collapsed view instead gets its own slice @@ -564,22 +429,14 @@ public static class ClipFrameAssembler assembly.OutdoorVisible = outdoorVisible; assembly.HasOutsideView = outdoorVisible; assembly.OutsideViewNdcAabb = outsideViewNdcAabb; - // S3 chunk 4 fix round 2 (L2): see Assemble's matching comment — the - // same reduction applies here. + // Any scissor fallback makes the aggregate plane count zero. assembly.OutsidePlaneCount = outsideHasScissorFallback ? 0 : outsideMaxPlaneCount; assembly.ScissorFallbacks = scissorFallbacks; } /// - /// S3 chunk 4 fix round 2 (L4): the ONE place a single outside_view - /// becomes an appended clip slot plus its - /// — both 's - /// outside_view loop and '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 ClipFrameLayoutTests' - /// punch-fan pin). A polygon entirely outside every plane + /// Converts one outside_view into an appended + /// clip slot and . A polygon entirely outside every plane /// () appends nothing and /// returns false; the caller's own loop simply moves to the next /// polygon either way, so the return value only matters to a caller that diff --git a/src/AcDream.App/Rendering/ClipPlaneSet.cs b/src/AcDream.App/Rendering/ClipPlaneSet.cs index 44f636ee..c8287c35 100644 --- a/src/AcDream.App/Rendering/ClipPlaneSet.cs +++ b/src/AcDream.App/Rendering/ClipPlaneSet.cs @@ -5,7 +5,7 @@ // gl_ClipDistance, or a signal that the region OVER-includes (draws // unclipped) because it cannot be expressed as one convex plane set. // -// This is the bridge between PortalVisibilityBuilder's 2D NDC view polygons and +// This is the bridge between the frame walk's 2D NDC view polygons and // the per-vertex clip the mesh/terrain shaders will perform (Phase U.2c → U.2e). // Pure System.Numerics math; NO GL. The shader consumes each plane as // d = nx*clip.x + ny*clip.y + 0*clip.z + dw*clip.w (>= 0 ⇒ keep) @@ -186,8 +186,7 @@ public readonly struct ClipPlaneSet new(Array.Empty(), isPlaneOverflow: true, isNothingVisible: false); /// - /// Return the polygon wound CCW with collinear vertices removed. The PortalVisibilityBuilder - /// already EnsureCcw's its output, but From() is a public entry point that must be robust to + /// Return the polygon wound CCW with collinear vertices removed. From() must be robust to /// either winding (e.g. a hand-built CellView), so we normalize here too. /// private static int NormalizeAndMerge(ReadOnlySpan input, Span points) diff --git a/src/AcDream.App/Rendering/IndoorDrawPlan.cs b/src/AcDream.App/Rendering/IndoorDrawPlan.cs deleted file mode 100644 index a3aab2a2..00000000 --- a/src/AcDream.App/Rendering/IndoorDrawPlan.cs +++ /dev/null @@ -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 Slices); - -public static class IndoorDrawPlan -{ - /// 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. - public static List ShellPass(PortalVisibilityFrame frame) - { - var result = new List(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; - } -} diff --git a/src/AcDream.App/Rendering/OutdoorCellNode.cs b/src/AcDream.App/Rendering/OutdoorCellNode.cs index 13ae4d0d..df355a40 100644 --- a/src/AcDream.App/Rendering/OutdoorCellNode.cs +++ b/src/AcDream.App/Rendering/OutdoorCellNode.cs @@ -6,15 +6,15 @@ namespace AcDream.App.Rendering; /// Factory for the OUTDOOR render root — the cell the render roots at when the camera eye is outdoors. /// Retail roots every in-world frame at viewer_cell (SmartBox::RenderNormalMode → /// DrawInside(viewer_cell), decomp:92635); when outdoors that is a CLandCell. acdream models it -/// as a portal-less carrying only (so -/// seeds OutsideView FULL-SCREEN → terrain/sky/scenery draw -/// as the root's shell) and . +/// as a portal-less carrying only and +/// . seeds the +/// full-screen outside view for this root. /// /// R-A2 (2026-06-08): the node no longer carries reverse portals into nearby buildings. Retail /// does NOT flood buildings from the land root — buildings flood SEPARATELY, per-building, during the /// landscape draw (terrain BSP → DrawPortal → ConstructView(CBldPortal), decomp:326881/433895/433827). -/// acdream issues those via per nearby -/// building inside . The pre-R-A2 design flooded all +/// acdream issues those through the retained frame walk per nearby building inside +/// . The pre-R-A2 design flooded all /// buildings from one root through reverse portals, coupling their interior membership to a single /// root-level portal-side test that oscillated as the chase eye grazed a doorway — the indoor flap. /// diff --git a/src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs b/src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs index 23662168..095435b7 100644 --- a/src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs +++ b/src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs @@ -71,24 +71,6 @@ public sealed class PhysicsCameraCollisionProbe : ICameraCollisionProbe Vector3 eye = FromSpherePath(r.Position, ViewerSphereRadius); - // [flap-sweep] camera-collision probe (ACDREAM_PROBE_FLAP), paired with the - // builder's [flap]/[flap-cam]. start = the pivot-seated start cell (vs cell = the - // player feet cell); ok = the sweep found a valid position (find_valid_position != 0). - if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled) - { - var cp = _physics.DataCache?.GetCellStruct(startCell); - string bsp = cp?.FlatPhysicsBsp is null - ? "noflatbsp" - : (cp.FlatPhysicsBsp.RootIndex < 0 ? "noroot" : "ok"); - float desiredBack = Vector3.Distance(pivot, desiredEye); - float eyeBack = Vector3.Distance(pivot, eye); - System.Console.WriteLine( - $"[flap-sweep] cell=0x{cellId:X8} start=0x{startCell:X8} ok={r.Ok} resolved={(cp is not null ? "Y" : "n")} bsp={bsp} " + - $"desiredBack={desiredBack:F2} eyeBack={eyeBack:F2} pulledIn={desiredBack - eyeBack:F2} " + - $"in=({desiredEye.X:F6},{desiredEye.Y:F6},{desiredEye.Z:F6}) out=({eye.X:F6},{eye.Y:F6},{eye.Z:F6}) " + - $"viewerCell=0x{r.CellId:X8} collNormValid={r.CollisionNormalValid}"); - } - // success: set_viewer(curr_pos, 0); viewer_cell = sphere_path.curr_cell (pc:92870-92871). // Graph-tracked, no AABB/grace. if (r.Ok) return new CameraSweepResult(eye, r.CellId); diff --git a/src/AcDream.App/Rendering/PortalProjection.cs b/src/AcDream.App/Rendering/PortalProjection.cs index d6092ded..965d24e7 100644 --- a/src/AcDream.App/Rendering/PortalProjection.cs +++ b/src/AcDream.App/Rendering/PortalProjection.cs @@ -93,7 +93,7 @@ public static class PortalProjection } /// Project a cell-local polygon to NDC, preserving the projected winding of - /// the input (NOT normalized to CCW). The caller (PortalVisibilityBuilder) is responsible + /// the input (NOT normalized to CCW). The frame-walk caller is responsible /// for feeding camera-facing portal polygons (via the portal-side test) so the result is /// CCW for the CCW-only . Returns fewer than 3 verts when /// the polygon is entirely behind the camera / degenerate. diff --git a/src/AcDream.App/Rendering/PortalView.cs b/src/AcDream.App/Rendering/PortalView.cs index 9dd082cd..b3f04919 100644 --- a/src/AcDream.App/Rendering/PortalView.cs +++ b/src/AcDream.App/Rendering/PortalView.cs @@ -189,8 +189,8 @@ public sealed class CellView { if (p.IsEmpty) return false; - // Drift-tolerant, rotation-invariant dedup (2026-06-06 hang fix). PortalVisibilityBuilder.Build - // re-queues a cell every time its CellView GROWS, so the flood only terminates when Add + // Drift-tolerant, rotation-invariant dedup (2026-06-06 hang fix). A caller may + // re-queue a cell every time its CellView grows, so the flood only terminates when Add // recognises a re-clipped region as a duplicate. Across BFS rounds the SAME region returns // float-drifted, vertex-rotated, and/or with a ±1 vertex count (homogeneous Sutherland-Hodgman + // EnsureCcw); the old exact index-by-index match (eps 1e-4) caught none of those, so the region diff --git a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs deleted file mode 100644 index 24d9fe37..00000000 --- a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs +++ /dev/null @@ -1,1291 +0,0 @@ -// PortalVisibilityBuilder.cs -// -// Phase A8.F: recursive portal-clip visibility (the builder). Port of retail -// PView::ConstructView (decomp:433750) -> ClipPortals (433572) -> AddViewToPortals -// (433446). Walks the portal graph from the camera cell, accumulating a per-cell -// screen-space CellView; exit portals union their clipped region into OutsideView. -// GL-free; unit-tested without a GPU context. -using System; -using System.Collections.Generic; -using System.Numerics; - -namespace AcDream.App.Rendering; - -/// Per-frame output of the portal-frame BFS. -public sealed class PortalVisibilityFrame -{ - private const int MaxRetainedCellViews = 512; - internal const int MaxRetainedBuildCollectionCapacity = 512; - internal const int CapacityTrimIdleFrames = 120; - private readonly Stack _cellViewPool = new(); - private readonly PortalPolygonVertexStore _polygonVertices = new(); - private int _cellViewsUnderusedFrames; - private int _crossBuildingViewsUnderusedFrames; - private int _queuedUnderusedFrames; - private int _drawListedUnderusedFrames; - private int _processedViewCountsUnderusedFrames; - private int _orderedVisibleCellsUnderusedFrames; - private int _todoUnderusedFrames; - private int _exteriorSeedPortalsUnderusedFrames; - - internal PortalPolygonVertexStore PolygonVertices => _polygonVertices; - internal int PolygonVertexAllocationCount => _polygonVertices.AllocationCount; - internal int RetainedPolygonVertexArrayCount => _polygonVertices.RetainedArrayCount; - - // Interior-root look-ins are constructed one building at a time. Keep - // the source identity on the retained frame so the landscape pass can - // pair retail's portal-only traversal with that building's own exterior - // shell instead of repainting every nearby shell after every look-in. - // Building ids are publication-local, so the landblock is part of the - // identity. An unstamped building uses its seed cell id as the key. - internal uint SourceBuildingKey { get; set; } - internal uint SourceBuildingLandblockId { get; set; } - - /// Screen region (NDC) where outdoor terrain/scenery may draw — exit portals - /// recursively clipped to their portal chain. The cellar-flap fix. - public CellView OutsideView { get; private set; } = new(); - - /// Per-cell accumulated clip region, keyed by full cell id (wire-in #2). - public Dictionary CellViews { get; } = new(); - - /// Visible interior cells in the exact order they were first dequeued from the - /// distance-priority work list — closest-first (Phase U.2a). Mirrors retail's - /// PView::cell_draw_list, appended in PView::ConstructView (decomp:433783) as each cell pops - /// off the nearest-vertex-sorted cell_todo_list (InsCellTodoList 433183). Deduplicated: a cell - /// appears exactly once, on its first dequeue. The camera cell is always first. - public List OrderedVisibleCells { get; } = new(); - - /// Entry clip regions for other buildings reached through our portals, keyed by the - /// neighbour cell id that left the camera building's cell set (wire-in #3 / Step 5). - public Dictionary CrossBuildingViews { get; } = new(); - - /// - /// Exact outside-facing building portals that successfully seeded this - /// exterior construction, together with the clipped view region produced - /// by that portal. Retail's DrawBuilding pass punches these CBldPortal - /// apertures themselves; it does not infer them later from every exit on a - /// cell reached by the resulting flood. - /// - public List ExteriorSeedPortals { get; } = new(); - - // Build scratch belongs to the frame so a caller that reuses a frame also reuses the large - // hash tables and the 128-entry convergence trace. This is especially important outdoors, - // where retail runs one small ConstructView flood per nearby building every frame. - internal HashSet QueuedScratch { get; } = new(); - internal HashSet DrawListedScratch { get; } = new(); - internal Dictionary ProcessedViewCountsScratch { get; } = new(); - internal uint[] PropagationChainScratch { get; } = new uint[128]; - internal List<(LoadedCell Cell, float Distance)> TodoScratch { get; } = new(); - - // A build can recurse while an outer portal's clipped region is still live, so one mutable - // singleton is unsafe. Leases reuse Lists by recursion lifetime rather than by total portal-call - // count: ordinary sequential portals need one List, while nested AdjustCellView propagation gets - // one per active depth. Dispose returns scratch on every continue/exception path. - private readonly Stack> _clipRegionPool = new(); - private const int MaxRetainedClipRegionLists = 132; // recursion tripwire (128) + nested side work - private const int MaxRetainedClipRegionCapacity = 256; - - internal int ClipRegionScratchCount => _clipRegionPool.Count; - internal int RetainedCellViewCount => _cellViewPool.Count; - internal int CellViewMapCapacity => CellViews.EnsureCapacity(0); - internal int CrossBuildingViewMapCapacity => CrossBuildingViews.EnsureCapacity(0); - internal int QueuedCapacity => QueuedScratch.EnsureCapacity(0); - internal int DrawListedCapacity => DrawListedScratch.EnsureCapacity(0); - internal int ProcessedViewCountCapacity => ProcessedViewCountsScratch.EnsureCapacity(0); - - internal ClipRegionScratchLease RentClipRegionScratch() - { - List region = _clipRegionPool.Count != 0 - ? _clipRegionPool.Pop() - : new List(2); - region.Clear(); - return new ClipRegionScratchLease(this, region); - } - - private void ReturnClipRegionScratch(List region) - { - region.Clear(); - if (region.Capacity <= MaxRetainedClipRegionCapacity - && _clipRegionPool.Count < MaxRetainedClipRegionLists) - _clipRegionPool.Push(region); - } - - internal readonly ref struct ClipRegionScratchLease - { - private readonly PortalVisibilityFrame _owner; - public List Region { get; } - - internal ClipRegionScratchLease(PortalVisibilityFrame owner, List region) - { - _owner = owner; - Region = region; - } - - public void Dispose() => _owner.ReturnClipRegionScratch(Region); - } - - internal void ResetForBuild() - { - int cellViewCount = CellViews.Count; - int crossBuildingViewCount = CrossBuildingViews.Count; - int queuedCount = QueuedScratch.Count; - int drawListedCount = DrawListedScratch.Count; - int processedViewCount = ProcessedViewCountsScratch.Count; - int orderedVisibleCellCount = OrderedVisibleCells.Count; - int todoCount = TodoScratch.Count; - int exteriorSeedPortalCount = ExteriorSeedPortals.Count; - - if (OutsideView.IsRetainable) - OutsideView.Reset(); - else - OutsideView = new CellView(); - ReturnCellViews(CellViews); - ReturnCellViews(CrossBuildingViews); - for (int index = 0; index < ExteriorSeedPortals.Count; index++) - ReturnCellView(ExteriorSeedPortals[index].View); - CellViews.Clear(); - OrderedVisibleCells.Clear(); - CrossBuildingViews.Clear(); - ExteriorSeedPortals.Clear(); - SourceBuildingKey = 0; - SourceBuildingLandblockId = 0; - QueuedScratch.Clear(); - DrawListedScratch.Clear(); - ProcessedViewCountsScratch.Clear(); - TodoScratch.Clear(); - _polygonVertices.ResetUsage(); - - // These collections live as long as RetailPViewRenderer. A fixed - // capacity cutoff is unsafe here: a legitimate large portal flood can - // exceed it every frame, causing us to discard and rebuild the same - // warmed tables forever. Retire high-water storage only after it has - // remained at least 50% underused for a sustained idle window. Once a - // collection is right-sized for its recurring workload it stays warm. - TrimIfCold(CellViews, cellViewCount, ref _cellViewsUnderusedFrames); - TrimIfCold( - CrossBuildingViews, - crossBuildingViewCount, - ref _crossBuildingViewsUnderusedFrames); - TrimIfCold(QueuedScratch, queuedCount, ref _queuedUnderusedFrames); - TrimIfCold(DrawListedScratch, drawListedCount, ref _drawListedUnderusedFrames); - TrimIfCold( - ProcessedViewCountsScratch, - processedViewCount, - ref _processedViewCountsUnderusedFrames); - TrimIfCold( - OrderedVisibleCells, - orderedVisibleCellCount, - ref _orderedVisibleCellsUnderusedFrames); - TrimIfCold(TodoScratch, todoCount, ref _todoUnderusedFrames); - TrimIfCold( - ExteriorSeedPortals, - exteriorSeedPortalCount, - ref _exteriorSeedPortalsUnderusedFrames); - } - - internal ViewPolygon CopyPolygon(ReadOnlySpan vertices) - { - if (vertices.Length < 3) - return default; - Vector2[] owned = _polygonVertices.Rent(vertices.Length); - vertices.CopyTo(owned); - return new ViewPolygon(owned); - } - - internal CellView RentCellView(bool fullScreen = false) - { - CellView result = _cellViewPool.Count != 0 - ? _cellViewPool.Pop() - : new CellView(); - if (fullScreen) - result.SetFullScreen(); - else - result.Reset(); - return result; - } - - private void ReturnCellViews(Dictionary views) - { - foreach (CellView view in views.Values) - ReturnCellView(view); - } - - private void ReturnCellView(CellView view) - { - view.Reset(); - if (view.IsRetainable && _cellViewPool.Count < MaxRetainedCellViews) - _cellViewPool.Push(view); - } - - private static void TrimIfCold( - Dictionary values, - int usedCount, - ref int underusedFrames) - where TKey : notnull - { - int capacity = values.EnsureCapacity(0); - if (!ShouldTrim(capacity, usedCount, ref underusedFrames)) - return; - - if (capacity > MaxRetainedBuildCollectionCapacity) - values.TrimExcess(); - } - - private static void TrimIfCold( - HashSet values, - int usedCount, - ref int underusedFrames) - { - int capacity = values.EnsureCapacity(0); - if (!ShouldTrim(capacity, usedCount, ref underusedFrames)) - return; - - if (capacity > MaxRetainedBuildCollectionCapacity) - values.TrimExcess(); - } - - private static void TrimIfCold( - List values, - int usedCount, - ref int underusedFrames) - { - int capacity = values.Capacity; - if (!ShouldTrim(capacity, usedCount, ref underusedFrames)) - return; - - if (capacity > MaxRetainedBuildCollectionCapacity) - values.Capacity = 0; - } - - private static bool ShouldTrim(int capacity, int usedCount, ref int underusedFrames) - { - if (capacity <= MaxRetainedBuildCollectionCapacity - || (long)usedCount * 2L > capacity) - { - underusedFrames = 0; - return false; - } - - underusedFrames++; - if (underusedFrames < CapacityTrimIdleFrames) - return false; - - underusedFrames = 0; - return true; - } -} - -/// -/// One outside-facing portal accepted as an exterior building-view seed. -/// is the exact installed-view-clipped aperture. -/// -public readonly record struct ExteriorPortalSeed( - uint CellId, - int PortalIndex, - CellView View); - -public static class PortalVisibilityBuilder -{ - // Side-classification epsilon. Retail's is F_EPSILON = 0.000199999995 - // (const @0x007c8c70; PView::InitCell Ghidra 0x005a4b70). T2 (BR-4) - // attempted the retail value and the CornerFloodReplay gate REFUTED it: - // retail's tight epsilon works because retail's viewer cell transits the - // INSTANT the eye crosses a portal plane (the sweep's curr_cell), so the - // side test never sees a stale root more than F_EPSILON past a plane. Our - // root can lag the eye by up to ~1 cm at pressed corners (the harness's - // fixed-root sweep models this), and 0.01 is that documented root-lag - // tolerance — NOT a retail constant. Tighten to F_EPSILON only together - // with eye-exact viewer-cell tracking verification (the #108-membership - // family) + the cdstW near-clip pin. - private const float PortalSideEpsilon = 0.01f; - - // Retail F_EPSILON proper — used where the semantic is knife-edge - // REJECTION (ConstructView(CBldPortal) Sidedness IN_PLANE → return 0, - // Ghidra 0x005a59a0), which must NOT inherit the root-lag tolerance above - // (a 1 cm-wide in-plane band would reject look-in seeds whenever the eye - // stands near a doorway plane). - private const float SeedInPlaneEpsilon = 0.0002f; - - /// - /// #120 observable: total convergence-tripwire firings across both the - /// interior and the exterior look-in propagation. - /// The tripwire firing means the in-place growth's fixpoint invariant - /// broke (T2/BR-4) — tests reset this and assert it stays 0. - /// - public static int ConvergenceTripwireCount; - - /// - /// #120 self-attribution dump: the growth-recursion path that exceeded - /// the tripwire, as a per-cell frequency summary plus the chain tail — - /// the cycle's structure (e.g. 0174↔0175 ping-pong vs a 3-cycle lap) - /// reads directly off the output. - /// - private static void DumpPropagationChain(uint[] chain, int depth, uint rootCellId, Vector3 eye) - { - int n = Math.Min(depth, chain.Length); - var freq = new Dictionary(); - for (int i = 0; i < n; i++) - { - freq.TryGetValue(chain[i], out int c); - freq[chain[i]] = c + 1; - } - var summary = new System.Text.StringBuilder(256); - foreach (var kvp in freq) - summary.Append(System.FormattableString.Invariant($" 0x{kvp.Key:X8}x{kvp.Value}")); - - var tail = new System.Text.StringBuilder(256); - for (int i = Math.Max(0, n - 24); i < n; i++) - tail.Append(System.FormattableString.Invariant($" 0x{chain[i] & 0xFFFFu:X4}")); - - Console.WriteLine(System.FormattableString.Invariant( - $"[pv-ERROR] chain root=0x{rootCellId:X8} eye=({eye.X:F3},{eye.Y:F3},{eye.Z:F3}) cells:{summary}")); - Console.WriteLine($"[pv-ERROR] chain tail(24):{tail}"); - } - - // Campaign FW3.3 (2026-08-30): ShellDrawLiftZ (the 0.02 m drawn-shell - // lift, register row AP-32) is RETIRED. Retail draws cell geometry at - // the dat EnvCell origin verbatim; the coplanar tie-breaks the lift - // stood in for are now owned by the walk's retail draw ORDER under - // WorldDepthContract's Less (first-drawn-wins — RenderDeviceD3D:: - // DrawBlock terrain-then-objects per cell, PView::DrawCells shells-then- - // contents). With the lift gone, the visibility gate and the drawn - // geometry share ONE space, so the #130 strip mechanism (gate space ≠ - // drawn space) no longer exists. - - /// Resolve a full cell id to its LoadedCell, or null if not loaded. - /// Optional: true if a cell id is in the camera building's cell - /// set. When provided, a neighbour OUTSIDE the set routes to CrossBuildingViews instead of - /// continuing the in-building BFS. Pass null to treat all reachable cells as in-building. - public static PortalVisibilityFrame Build( - LoadedCell cameraCell, - Vector3 cameraPos, - Func lookup, - Matrix4x4 viewProj, - Func? buildingMembership = null, - PortalVisibilityFrame? reuseFrame = null) - { - var frame = reuseFrame ?? new PortalVisibilityFrame(); - frame.ResetForBuild(); - if (cameraCell == null) return frame; - - // Interior portals never cross landblocks (same invariant as CellVisibility.GetVisibleCells); - // building-boundary crossings are handled separately via the buildingMembership escape hatch. - uint lbMask = cameraCell.CellId & 0xFFFF0000u; - - frame.CellViews[cameraCell.CellId] = frame.RentCellView(fullScreen: true); - - // Render unification (outdoor-as-cell, 2026-06-07): when the root IS the synthetic outdoor - // node, the landscape is visible FULL-SCREEN, so seed OutsideView with the full-screen NDC - // quad. ClipFrameAssembler turns that into a full-screen OutsideView slice, so DrawInside's - // DrawLandscapeThroughOutsideView draws terrain/sky/scenery/weather as the node's "shell" — - // the very same callback that already draws the doorway slice when an INTERIOR root reaches - // outdoors. Keyed on the explicit IsOutdoorNode flag (set by OutdoorCellNode.Build), NOT a - // cell-id heuristic: production EnvCell ids are >= 0x100 but test fixtures use low interior - // ids, so an id test would misfire. An interior root never sets this flag, so the indoor - // exit-portal path (OtherCellId==0xFFFF below) still owns the doorway OutsideView region. - if (cameraCell.IsOutdoorNode) - frame.OutsideView.Add(frame.CopyPolygon(FullScreenQuad)); - - // Distance-priority work list (retail PView::cell_todo_list). Cells pop closest-first; - // each cell carries the camera→nearest-portal-vertex distance that put it on the list - // (retail keys on InitCell's per-portal min-vertex distance, decomp 432988-433004). The - // camera cell seeds at distance 0 (retail InsCellTodoList(this, arg2, 0f) at 433758) so it - // always pops first. - List<(LoadedCell Cell, float Distance)> todo = frame.TodoScratch; - InsertTodo(todo, cameraCell, 0f); - - // Fixpoint termination replacing the old MaxReprocessPerCell hard cap. This mirrors the - // retail portal_view slice offset 0x44 (last-incorporated view-poly watermark) vs 0x38 - // (current view_count) decision in AddViewToPortals (433446): a cell is INSERTED into the - // todo list exactly once — on first discovery (retail's ecx_5==0 branch calls - // InsCellTodoList; the ecx_5!=eax_2 growth branch calls AddToCell IN PLACE and never - // re-enqueues). Later growth into an already-discovered cell is unioned into its CellView but - // does NOT re-enqueue it — the `cell_view_done` guarantee (ConstructView sets it at 433784 - // the instant a cell is popped). Enqueue-once across the cell set is the hard termination - // guarantee for cyclic / hub / diamond graphs: at most N cells are ever processed. The - // camera cell is pre-marked so a portal looping back to it can never re-enqueue it. - HashSet queued = frame.QueuedScratch; - queued.Add(cameraCell.CellId); - HashSet drawListed = frame.DrawListedScratch; - Dictionary processedViewCounts = frame.ProcessedViewCountsScratch; - var trace = PortalBuildTrace.Start(cameraCell, cameraPos); - - // [portal-churn] apparatus (2026-06-08): when ProbePortalChurnEnabled, accumulate re-enqueue churn - // + reciprocal pre/post region counts, emitted as one summary line at end of Build. Inert when off. - bool churnProbe = AcDream.Core.Rendering.RenderingDiagnostics.ProbePortalChurnEnabled; - int churnReenqueues = 0; - var churnReciprocal = churnProbe ? new System.Text.StringBuilder(256) : null; - - // T2 (BR-4): retail's growth propagation is IN PLACE, never by re-enqueue - // — PView::AddViewToPortals (Ghidra 0x005a52d0, pc:433446): first - // discovery enqueues via InsCellTodoList; growth into a cell whose - // cell_view_done is set calls AdjustCellView (pc:433741-433745), which - // re-clips ONLY the new views (the update_count watermark) through that - // cell's portals immediately. Our processedViewCounts IS that watermark, - // so in-place propagation = call ProcessCellPortals on the grown - // neighbour; it processes exactly the new tail and recurses further - // growth. Termination is physical: recursion fires only when AddRegion - // added a DISTINCT polygon (CanonicalKey dedup) that survived the 1-px - // vertex merge — the finite fixpoint floor that replaced the old - // MaxReprocessPerCell=16 drift cap (deleted). The depth tripwire below - // is a loud failsafe, not control flow: it firing means the convergence - // invariant broke and must be fixed, not tuned. - const int RecursionTripwire = 128; - // #120 self-attribution: the recursion path (cell id per depth), so a - // tripwire firing names the growth CYCLE instead of just the tip. - // Harness sweeps (CornerFloodReplayTests *Converges tests) could not - // reproduce the T5 firing — production-only ingredients (full lookup - // graph / real camera path) are suspected; this dump pins them on the - // next natural occurrence. - uint[] propagationChain = frame.PropagationChainScratch; - - void ProcessCellPortals(LoadedCell cell, int depth) - { - if (depth >= RecursionTripwire) - { - System.Threading.Interlocked.Increment(ref ConvergenceTripwireCount); - Console.WriteLine($"[pv-ERROR] in-place propagation tripwire at depth {depth} on cell=0x{cell.CellId:X8} — convergence invariant broken, investigate"); - DumpPropagationChain(propagationChain, depth, cameraCell.CellId, cameraPos); - return; - } - propagationChain[depth] = cell.CellId; - if (!frame.CellViews.TryGetValue(cell.CellId, out var currentView) || currentView.IsEmpty) - { - trace?.Add($"proc cell=0x{cell.CellId:X8} skip=no-view"); - return; - } - - processedViewCounts.TryGetValue(cell.CellId, out int processedCount); - int endCount = currentView.Polygons.Count; - if (processedCount >= endCount) - { - trace?.Add($"proc cell=0x{cell.CellId:X8} skip=processed processed={processedCount} views={endCount}"); - return; - } - trace?.Add($"proc cell=0x{cell.CellId:X8} processed={processedCount}->{endCount} depth={depth}"); - - processedViewCounts[cell.CellId] = endCount; - - for (int i = 0; i < cell.Portals.Count; i++) - { - var portal = cell.Portals[i]; - if (i >= cell.PortalPolygons.Count) - { - trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=no-poly-slot"); - continue; - } - var poly = cell.PortalPolygons[i]; - if (poly == null || poly.Length < 3) - { - trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=degenerate-poly len={(poly?.Length ?? -1)}"); - continue; - } - - // (R-A2b Phase 1 pin, throwaway) Log the side-test inputs for EVERY portal so a back-portal - // traversal (cell=0x..0173 p->0x0171) can be attributed to the side test. - // Strip with the rest of the [pv-trace] apparatus. - if (trace != null) - { - bool camInterior = i >= cell.ClipPlanes.Count || CameraOnInteriorSide(cell, i, cameraPos); - float sideD = (i < cell.ClipPlanes.Count && cell.ClipPlanes[i].Normal.LengthSquared() >= 1e-8f) - ? Vector3.Dot(cell.ClipPlanes[i].Normal, Vector3.Transform(cameraPos, cell.InverseWorldTransform)) + cell.ClipPlanes[i].D - : float.NaN; - trace.Add($"sidechk cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} camInterior={camInterior} D={(float.IsNaN(sideD) ? "na" : sideD.ToString("F2"))}"); - } - - // Portal-side test (retail PView::InitCell side test, decomp:432962): only traverse a portal - // the camera is on the INTERIOR side of. Retail culls the back-facing portal (the doorway just - // flooded through) by this test ALONE — there is NO eye-in-opening bypass. R-A2b: the old - // `&& !eyeInsideOpening` bypass let a back portal within 1.75 m through, forming the - // 0171<->0173 flood cycle -> re-enqueue churn -> the doorway flap (pinned in flap-sidechk.log: - // back portals show camInterior=False eyeIn=True). - if (i < cell.ClipPlanes.Count - && !CameraOnInteriorSide(cell, i, cameraPos)) - { - trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=side"); - continue; - } - - // Retail PView::ClipPortals calls GetClip(..., finish=1): transform to - // homogeneous clip space, clip at the eye, then clip against the current - // portal_view region before the divide. Do the same here; the old early - // ProjectToNdc + 2D intersect path is too unstable for near/grazing doorways. - using PortalVisibilityFrame.ClipRegionScratchLease clippedRegionLease = - frame.RentClipRegionScratch(); - List clippedRegion = clippedRegionLease.Region; - ClipPortalAgainstView( - frame, - poly, - cell.WorldTransform, - viewProj, - currentView.Polygons, - processedCount, - endCount - processedCount, - clippedRegion, - out int clipVerts); - - // Empty clip = no flood through this portal, period — retail's empty-GetClip rule - // (polyClipFinish <3 survivors → reject; ClipPortals adds no view). The - // EyeInsidePortalOpening rescue that used to substitute the current view here was - // the documented compensation for ProjectToClip's old EyePlaneW=1e-4 divergence - // from polyClipFinish's exact W=0 clip; with the W=0 port (2026-06-11, pseudocode - // at docs/research/2026-06-11-polyclipfinish-w0-clip-pseudocode.md) an eye-crossing - // portal projects to its true half-region and the rescue is DELETED. - if (clippedRegion.Count == 0) - { - trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=clip-empty clipVerts={clipVerts}"); - continue; - } - - if (portal.OtherCellId == 0xFFFF) - { - // Exit portal -> outdoors visible through this (clipped) - // opening. FW3.3: shells draw at the dat origin (the - // ShellDrawLiftZ retirement), so the gate and the drawn - // aperture share one space — the unlifted clippedRegion - // IS the draw-space region. - AddRegion(frame.OutsideView, clippedRegion); - trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={clippedRegion.Count} clipVerts={clipVerts}"); - continue; - } - - uint neighbourId = lbMask | portal.OtherCellId; - - // Cross-building boundary: route to CrossBuildingViews, don't continue in-building BFS. - // (Cross-building entry is retail's CBldPortal/AddToCell channel, not OtherPortalClip; - // the reciprocal clip below is interior-cell-to-cell only, matching the OtherPortalClip - // call inside ConstructView at decomp:433692.) - if (buildingMembership != null && !buildingMembership(neighbourId)) - { - var xview = GetOrCreate(frame, frame.CrossBuildingViews, neighbourId); - bool grewCross = AddRegion(xview, clippedRegion); - trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} crossBldg polys={clippedRegion.Count} grew={grewCross}"); - continue; - } - - var neighbour = lookup(neighbourId); - if (neighbour == null) - { - trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} skip=lookup-miss polys={clippedRegion.Count}"); - continue; - } - - // Phase U.2b — neighbour-side OtherPortalClip (retail PView::OtherPortalClip - // decomp:433524). The portal opening seen from THIS cell may be wider than the - // SAME opening seen from the neighbour (skewed/oblique apertures), so retail - // re-clips the already-near-side-clipped region against the neighbour's matching - // (reciprocal) portal polygon — the propagated region is the intersection of the - // opening "seen from A" AND "seen from B". This can only TIGHTEN, never widen, and - // degrades to the prior near-side-only region when the reciprocal is unresolvable - // (over-include is the safe default). The reciprocal is the portal at index - // `portal.OtherPortalId` in the NEIGHBOUR's portal list — retail's direct back-link - // (arg2->other_portal_id, 433557), NOT a scan for the first OtherCellId match. The - // direct index is what lets a cell with TWO portals to the same neighbour clip each - // opening against its OWN reciprocal instead of the first one. Mutates clippedRegion - // in place before the union below. - // T2 (BR-4): reciprocal-empty culls — retail OtherPortalClip - // returning nothing means the opening is invisible from the - // neighbour's side; the old eye-in-opening restore was part of - // the deleted rescue. - int preReciprocalCount = clippedRegion.Count; - ApplyReciprocalClip( - frame, clippedRegion, portal.OtherPortalId, portal.Flags, neighbour, viewProj); - if (churnProbe) - churnReciprocal!.Append(System.FormattableString.Invariant( - $" recip[0x{neighbourId:X8} {preReciprocalCount}->{clippedRegion.Count}]")); - if (clippedRegion.Count == 0) - { - trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} skip=reciprocal-empty pre={preReciprocalCount} otherPortal={portal.OtherPortalId}"); - continue; - } - - // Union the clipped region into the neighbour's accumulated view. - var nview = GetOrCreate(frame, frame.CellViews, neighbourId); - bool grew = AddRegion(nview, clippedRegion); - bool inserted = false; - bool inPlace = false; - float dist = float.NaN; - - if (grew) - { - // First discovery → enqueue once (retail InsCellTodoList in - // the ecx_5==0 branch). Distance = camera→nearest portal- - // opening vertex (retail InitCell min-vertex distance, - // pc:432988-433004). - if (queued.Add(neighbourId)) - { - dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos); - InsertTodo(todo, neighbour, dist); - inserted = true; - } - // Growth into an already-POPPED cell → retail AdjustCellView: - // process only the new views, immediately, in place. A cell - // discovered but still pending in the todo list needs nothing - // — its pop processes everything to date via the watermark. - else if (drawListed.Contains(neighbourId)) - { - inPlace = true; - if (churnProbe) churnReenqueues++; - ProcessCellPortals(neighbour, depth + 1); - } - } - trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} addCell polys={clippedRegion.Count} clipVerts={clipVerts} recip={preReciprocalCount}->{clippedRegion.Count} grew={grew} queued={inserted} inPlace={inPlace} dist={(float.IsNaN(dist) ? "na" : dist.ToString("F2"))}"); - } - } - - while (todo.Count > 0) - { - LoadedCell cell = PopNearest(todo); - - // Single pop per cell (enqueue-once) IS the cell's closest-first - // draw position (retail appends to cell_draw_list once per pop, - // pc:433783). Note: retail also RE-SORTS the draw list when a - // late-grown cell's dependency order changes (AdjustCellPlace, - // pc:433247); we keep first-pop order — under T1's whole-cell - // far→near draws + depth testing, order affects only transparent- - // pass compositing in exotic chains (documented residual for T5). - if (drawListed.Add(cell.CellId)) - frame.OrderedVisibleCells.Add(cell.CellId); - trace?.Add($"pop cell=0x{cell.CellId:X8} drawPos={frame.OrderedVisibleCells.Count - 1}"); - - ProcessCellPortals(cell, 0); - } - - // Phase U.4c flap probe (ACDREAM_PROBE_FLAP) — read-only per-frame snapshot of the - // root cell's per-portal side-test + projection + the frame's exit/visible counts. - if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled) - EmitFlapProbe(cameraCell, cameraPos, viewProj, frame); - trace?.Emit(frame); - - if (churnProbe) - { - // T2: pops are enqueue-once now; churnReenqueues counts retail-style - // IN-PLACE propagations (AdjustCellView equivalents) instead. - Console.WriteLine(System.FormattableString.Invariant( - $"[portal-churn] root=0x{cameraCell.CellId:X8} cells={frame.OrderedVisibleCells.Count} inPlaceProps={churnReenqueues}") + churnReciprocal); - } - - return frame; - } - - /// - /// Build a portal visibility frame for an OUTDOOR viewer looking into one or more - /// outside-facing cell portals. This is the reciprocal of : - /// the seed view is the projected exit-portal opening instead of a full-screen - /// camera cell. It keeps the same retail distance-priority traversal and - /// neighbour reciprocal clipping once inside the building. - /// - /// Optional NDC region the seed apertures clip against — - /// retail's GetClip runs under the CURRENTLY INSTALLED view (PView::GetClip - /// 0x005a4320): full screen when the viewer is outdoors, the accumulated - /// outside (doorway) view when a building is looked into from an interior - /// root (#124). Null = full screen (the outdoor-root behavior). - public static PortalVisibilityFrame BuildFromExterior( - IEnumerable candidateCells, - Vector3 cameraPos, - Func lookup, - Matrix4x4 viewProj, - float maxSeedDistance = float.PositiveInfinity, - IReadOnlyList? seedRegion = null, - PortalVisibilityFrame? reuseFrame = null) - { - var frame = reuseFrame ?? new PortalVisibilityFrame(); - frame.ResetForBuild(); - List<(LoadedCell Cell, float Distance)> todo = frame.TodoScratch; - HashSet queued = frame.QueuedScratch; - HashSet drawListed = frame.DrawListedScratch; - Dictionary processedViewCounts = frame.ProcessedViewCountsScratch; - - foreach (var cell in candidateCells) - { - if (cell is null) continue; - - for (int i = 0; i < cell.Portals.Count; i++) - { - var portal = cell.Portals[i]; - if (portal.OtherCellId != 0xFFFF) - continue; - if (i >= cell.PortalPolygons.Count) - continue; - - var poly = cell.PortalPolygons[i]; - if (poly == null || poly.Length < 3) - continue; - - // Exterior peering starts from the OUTSIDE face of an exit portal. - // If the camera is on the cell-interior side, the normal indoor - // DrawInside path owns this portal instead. T2 (BR-4): a seed - // portal the eye is IN-PLANE with (|dist| <= F_EPSILON) rejects - // OUTRIGHT — retail ConstructView(CBldPortal) returns 0 on - // Sidedness IN_PLANE (Ghidra 0x005a59a0); no degenerate view is - // ever built from a knife-edge aperture. - if (i < cell.ClipPlanes.Count) - { - if (EyeInPlaneOfPortal(cell, i, cameraPos)) - continue; - - // Do NOT reuse the ordinary EnvCell traversal tolerance - // here. PortalSideEpsilon deliberately admits a 1 cm - // stale-root margin, but retail ConstructView(CBldPortal) - // classifies the seed with Sidedness's exact F_EPSILON - // (0.0002 m). At Sanctuary the 0104 and 0106 exterior - // planes coincide; the chase eye can sit ~4 mm outside the - // opposite cathedral half while its EnvCell root remains - // 0104. Applying the 1 cm margin calls that eye "inside" - // 0106 and drops the entire look-in flood, exposing the - // landscape/waterfall through half the cathedral. - if (CameraOnInteriorSide( - cell, - i, - cameraPos, - SeedInPlaneEpsilon)) - { - continue; - } - } - - float seedDistance = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos); - if (seedDistance > maxSeedDistance) - continue; - - using PortalVisibilityFrame.ClipRegionScratchLease clippedRegionLease = - frame.RentClipRegionScratch(); - List clippedRegion = clippedRegionLease.Region; - IReadOnlyList activeSeedRegion = seedRegion ?? FullScreenRegion; - ClipPortalAgainstView( - frame, - poly, - cell.WorldTransform, - viewProj, - activeSeedRegion, - 0, - activeSeedRegion.Count, - clippedRegion, - out _); - - // T2 (BR-4): empty clip = no seed, no exceptions (retail's - // empty-GetClip rule; the full-screen substitute rescue is - // deleted — see Build()). - if (clippedRegion.Count == 0) - continue; - - // Preserve the exact CBldPortal that produced this view. The - // renderer must punch only accepted seed apertures; iterating - // every OtherCellId==0xFFFF portal on the reached cell turns a - // single accepted opening into unrelated far-depth holes. - var seedPortalView = frame.RentCellView(); - AddRegion(seedPortalView, clippedRegion); - frame.ExteriorSeedPortals.Add(new ExteriorPortalSeed( - cell.CellId, - i, - seedPortalView)); - - var seedView = GetOrCreate(frame, frame.CellViews, cell.CellId); - bool grew = AddRegion(seedView, clippedRegion); - - if (grew && queued.Add(cell.CellId)) - InsertTodo(todo, cell, seedDistance); - } - } - - // T2 (BR-4): in-place growth propagation — mirrors Build()'s - // ProcessCellPortals (retail AdjustCellView via the watermark); the - // re-enqueue + MaxReprocessPerCell cap and the eye-in-opening rescues - // are deleted (empty clip culls, period). - const int RecursionTripwire = 128; - uint[] propagationChain = frame.PropagationChainScratch; // #120 self-attribution — see Build() - - void ProcessCellPortals(LoadedCell cell, int depth) - { - if (depth >= RecursionTripwire) - { - System.Threading.Interlocked.Increment(ref ConvergenceTripwireCount); - Console.WriteLine($"[pv-ERROR] look-in in-place propagation tripwire at depth {depth} on cell=0x{cell.CellId:X8} — convergence invariant broken, investigate"); - DumpPropagationChain(propagationChain, depth, 0u, cameraPos); - return; - } - propagationChain[depth] = cell.CellId; - if (!frame.CellViews.TryGetValue(cell.CellId, out var currentView) || currentView.IsEmpty) - return; - - processedViewCounts.TryGetValue(cell.CellId, out int processedCount); - int endCount = currentView.Polygons.Count; - if (processedCount >= endCount) - return; - - processedViewCounts[cell.CellId] = endCount; - uint lbMask = cell.CellId & 0xFFFF0000u; - - for (int i = 0; i < cell.Portals.Count; i++) - { - if (i >= cell.PortalPolygons.Count) - continue; - - var poly = cell.PortalPolygons[i]; - if (poly == null || poly.Length < 3) - continue; - - var portal = cell.Portals[i]; - if (portal.OtherCellId == 0xFFFF) - continue; // already outdoors; exterior terrain was drawn by the caller. - - // R-A2b: cull back portals by the side test alone — see Build(). - if (i < cell.ClipPlanes.Count - && !CameraOnInteriorSide(cell, i, cameraPos)) - continue; - - using PortalVisibilityFrame.ClipRegionScratchLease clippedRegionLease = - frame.RentClipRegionScratch(); - List clippedRegion = clippedRegionLease.Region; - ClipPortalAgainstView( - frame, - poly, - cell.WorldTransform, - viewProj, - currentView.Polygons, - processedCount, - endCount - processedCount, - clippedRegion, - out _); - - if (clippedRegion.Count == 0) - continue; - - uint neighbourId = lbMask | portal.OtherCellId; - var neighbour = lookup(neighbourId); - if (neighbour == null) - continue; - - ApplyReciprocalClip( - frame, clippedRegion, portal.OtherPortalId, portal.Flags, neighbour, viewProj); - if (clippedRegion.Count == 0) - continue; - - var nview = GetOrCreate(frame, frame.CellViews, neighbourId); - bool grew = AddRegion(nview, clippedRegion); - - if (grew) - { - if (queued.Add(neighbourId)) - { - float dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos); - InsertTodo(todo, neighbour, dist); - } - else if (drawListed.Contains(neighbourId)) - { - ProcessCellPortals(neighbour, depth + 1); - } - } - } - } - - while (todo.Count > 0) - { - LoadedCell cell = PopNearest(todo); - - if (drawListed.Add(cell.CellId)) - frame.OrderedVisibleCells.Add(cell.CellId); - - ProcessCellPortals(cell, 0); - } - - return frame; - } - - /// - /// Retail per-building flood — PView::ConstructView(CBldPortal*, …) (decomp:433827), - /// reached from BSPPORTAL::portal_draw_portals_only (0x53d870) → DrawPortal - /// (0x5a5ab0) during the terrain BSP walk. Floods ONE building's cells from its outside-facing - /// entrance portal(s). Identical machinery to , but the CONTRACT is - /// per-building: the caller passes exactly one building's cells, so the seed is that building's - /// FINITE entrance opening (bounded flood depth → the stable ~2-cell view retail draws per visible - /// building, measured live §3.4). This differs from the synthetic outdoor node's single unified - /// flood whose full-screen-ish seed reaches variable depth into a building as the eye moves — the - /// 2↔6 oscillation. Robustness is validated by the conformance test, not assumed. - /// - public static PortalVisibilityFrame ConstructViewBuilding( - IEnumerable buildingCells, - Vector3 cameraPos, - Func lookup, - Matrix4x4 viewProj, - float maxSeedDistance = float.PositiveInfinity, - IReadOnlyList? seedRegion = null, - PortalVisibilityFrame? reuseFrame = null) - => BuildFromExterior( - buildingCells, - cameraPos, - lookup, - viewProj, - maxSeedDistance, - seedRegion, - reuseFrame); - - // The NDC [-1,1] viewport quad (CCW), reused by the flap probe's clip recompute. - private static readonly Vector2[] FullScreenQuad = - { new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f) }; - - private static readonly ViewPolygon[] FullScreenRegion = - { new ViewPolygon(FullScreenQuad) }; - - private static void ClipPortalAgainstView( - PortalVisibilityFrame frame, - Vector3[] localPoly, - Matrix4x4 cellToWorld, - Matrix4x4 viewProj, - IReadOnlyList viewPolygons, - int viewStart, - int viewCount, - List clippedRegion, - out int clipVertexCount) - { - using PortalProjection.ClipPolygonLease portalClip = - PortalProjection.ProjectToClipLease(localPoly, cellToWorld, viewProj); - clipVertexCount = portalClip.Count; - if (portalClip.Count < 3) - return; - - int viewEnd = viewStart + viewCount; - for (int i = viewStart; i < viewEnd; i++) - { - ViewPolygon vp = viewPolygons[i]; - if (vp.IsEmpty) - continue; - - var clipped = PortalProjection.ClipToRegion( - portalClip.Span, vp.Vertices, frame.PolygonVertices); - if (clipped.Length >= 3) - clippedRegion.Add(new ViewPolygon(clipped)); - } - } - - private const int PortalTraceEmitLimit = 160; - private static readonly object s_portalTraceLock = new(); - private static readonly Dictionary s_portalTraceLastSignature = new(); - private static int s_portalTraceEmits; - - private sealed class PortalBuildTrace - { - private readonly uint _rootCellId; - private readonly Vector3 _eye; - private readonly List _lines = new(); - - private PortalBuildTrace(uint rootCellId, Vector3 eye) - { - _rootCellId = rootCellId; - _eye = eye; - } - - public static PortalBuildTrace? Start(LoadedCell root, Vector3 eye) - { - if (!AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled) - return null; - if (!IsHoltburgIndoorProbeCell(root.CellId)) - return null; - return new PortalBuildTrace(root.CellId, eye); - } - - public void Add(string line) - { - if (_lines.Count < 96) - _lines.Add(line); - } - - public void Emit(PortalVisibilityFrame frame) - { - string signature = BuildSignature(frame); - lock (s_portalTraceLock) - { - if (s_portalTraceEmits >= PortalTraceEmitLimit) - return; - if (s_portalTraceLastSignature.TryGetValue(_rootCellId, out var last) && - string.Equals(last, signature, StringComparison.Ordinal)) - return; - s_portalTraceLastSignature[_rootCellId] = signature; - s_portalTraceEmits++; - } - - Console.WriteLine($"[pv-trace] root=0x{_rootCellId:X8} eye=({_eye.X:F2},{_eye.Y:F2},{_eye.Z:F2}) {signature}"); - foreach (var line in _lines) - Console.WriteLine("[pv-trace] " + line); - } - } - - private static bool IsHoltburgIndoorProbeCell(uint cellId) - { - if ((cellId & 0xFFFF0000u) != 0xA9B40000u) - return false; - uint low = cellId & 0xFFFFu; - return low >= 0x016F && low <= 0x0175; - } - - private static string BuildSignature(PortalVisibilityFrame frame) - { - var sb = new System.Text.StringBuilder(160); - sb.Append("outPolys=").Append(frame.OutsideView.Polygons.Count); - sb.Append(" cells=["); - for (int i = 0; i < frame.OrderedVisibleCells.Count; i++) - { - if (i != 0) sb.Append(','); - sb.Append("0x").Append((frame.OrderedVisibleCells[i] & 0xFFFFu).ToString("X4")); - } - sb.Append("] views=["); - bool first = true; - foreach (var kvp in frame.CellViews) - { - if (!first) sb.Append(','); - first = false; - sb.Append("0x").Append((kvp.Key & 0xFFFFu).ToString("X4")).Append(':').Append(kvp.Value.Polygons.Count); - } - sb.Append(']'); - return sb.ToString(); - } - - // Phase U.4c flap probe. One [flap] line per Build: the root cell's per-portal - // signed distance D (eye→portal plane), traverse/cull decision, and NDC projection - // vertex count, plus the frame's OutsideView polygon count + visible-cell count. - // `localEye` is the eye in root-local space — its component along an interior portal - // plane reveals when the eye has crossed past that plane (the stale-root region that - // makes the side test cull a still-needed portal). Read-only recompute; no effect on - // the returned frame. Throwaway apparatus — strip with the probe. - private static void EmitFlapProbe( - LoadedCell cameraCell, Vector3 cameraPos, Matrix4x4 viewProj, PortalVisibilityFrame frame) - { - var localEye = Vector3.Transform(cameraPos, cameraCell.InverseWorldTransform); - var sb = new System.Text.StringBuilder(220); - sb.Append("[flap] root=0x").Append(cameraCell.CellId.ToString("X8")); - sb.Append(" eye=(").Append(cameraPos.X.ToString("F2")).Append(',') - .Append(cameraPos.Y.ToString("F2")).Append(',').Append(cameraPos.Z.ToString("F2")).Append(')'); - sb.Append(" localEye=(").Append(localEye.X.ToString("F2")).Append(',') - .Append(localEye.Y.ToString("F2")).Append(',').Append(localEye.Z.ToString("F2")).Append(')'); - for (int i = 0; i < cameraCell.Portals.Count; i++) - { - var portal = cameraCell.Portals[i]; - float d = float.NaN; - bool side = true; - if (i < cameraCell.ClipPlanes.Count && cameraCell.ClipPlanes[i].Normal.LengthSquared() >= 1e-8f) - { - var pl = cameraCell.ClipPlanes[i]; - d = Vector3.Dot(pl.Normal, localEye) + pl.D; - side = CameraOnInteriorSide(cameraCell, i, cameraPos); - } - // Replicate the walk's faithful path exactly (ProjectToClip → ClipToRegion(FullScreen)) so - // proj/clip mean the same as production: proj = clip-space verts in front of the eye, - // clip = verts surviving the screen-region clip. clip=0 with proj>=3 ⇒ the portal is - // genuinely off-screen; the ndc coords (post-clip, bounded) show where on screen it lands. - int projN = -1, clipN = -1; - string ndcText = ""; - if (i < cameraCell.PortalPolygons.Count) - { - var poly = cameraCell.PortalPolygons[i]; - if (poly != null && poly.Length >= 3) - { - var clip = PortalProjection.ProjectToClip(poly, cameraCell.WorldTransform, viewProj); - projN = clip.Length; - if (clip.Length >= 3) - { - var ndc = PortalProjection.ClipToRegion(clip, FullScreenQuad); - clipN = ndc.Length; - var ns = new System.Text.StringBuilder(48); - foreach (var v in ndc) ns.Append('(').Append(v.X.ToString("F1")).Append(',').Append(v.Y.ToString("F1")).Append(')'); - ndcText = ns.ToString(); - } - } - } - sb.Append(" | p").Append(i).Append("->0x").Append(portal.OtherCellId.ToString("X4")); - sb.Append(" D=").Append(float.IsNaN(d) ? "na" : d.ToString("F2")); - sb.Append(side ? " TRV" : " CULL"); - sb.Append(" proj=").Append(projN).Append(" clip=").Append(clipN); - if (ndcText.Length > 0) sb.Append(" ndc=").Append(ndcText); - } - sb.Append(" || outPolys=").Append(frame.OutsideView.Polygons.Count); - sb.Append(" vis=").Append(frame.OrderedVisibleCells.Count); - Console.WriteLine(sb.ToString()); - } - - // Mirrors CellVisibility's portal-side test (InsideSide convention). - // In-plane (|dot| <= PortalSideEpsilon) counts as interior-side — retail - // InitCell leaves the in-plane case a CANDIDATE for cell portals (Ghidra - // 0x005a4b70); building/exterior SEED portals additionally reject in-plane - // via EyeInPlaneOfPortal (retail ConstructView(CBldPortal) IN_PLANE → 0). - private static bool CameraOnInteriorSide( - LoadedCell cell, - int portalIndex, - Vector3 cameraPos, - float epsilon = PortalSideEpsilon) - { - var plane = cell.ClipPlanes[portalIndex]; - if (plane.Normal.LengthSquared() < 1e-8f) return true; // no usable plane → allow - var localCam = Vector3.Transform(cameraPos, cell.InverseWorldTransform); - float dot = Vector3.Dot(plane.Normal, localCam) + plane.D; - return plane.InsideSide == 0 ? dot >= -epsilon : dot <= epsilon; - } - - // T2 (BR-4): retail ConstructView(CBldPortal)'s Sidedness IN_PLANE reject - // (Ghidra 0x005a59a0): |eye·N + d| <= F_EPSILON → the building/exterior - // portal contributes nothing this frame (knife-edge aperture). Uses the - // true retail epsilon, NOT the side test's root-lag tolerance. - private static bool EyeInPlaneOfPortal(LoadedCell cell, int portalIndex, Vector3 cameraPos) - { - var plane = cell.ClipPlanes[portalIndex]; - if (plane.Normal.LengthSquared() < 1e-8f) return false; - var localCam = Vector3.Transform(cameraPos, cell.InverseWorldTransform); - float dot = Vector3.Dot(plane.Normal, localCam) + plane.D; - return MathF.Abs(dot) <= SeedInPlaneEpsilon; - } - - // Reverse vertex order in place if the polygon is wound clockwise (signed area < 0). - private static void EnsureCcw(Vector2[] poly) - { - 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; - } - if (area2 < 0f) Array.Reverse(poly); - } - - // Phase U.2b — reciprocal OtherPortalClip (retail PView::OtherPortalClip decomp:433524). - // Resolves the neighbour's reciprocal back-portal by DIRECT INDEX (`otherPortalId`), projects - // that reciprocal polygon through the NEIGHBOUR's world transform to NDC, and intersects it into - // every polygon of `clippedRegion` (already clipped against the near-side opening + current - // view). The net region is "opening seen from the near cell" ∩ "opening seen from the - // neighbour" — a strict tightening that prevents over-inclusion through skewed apertures. - // - // `otherPortalId` is the near-side portal's reciprocal back-link, straight from the dat's - // CellPortal.OtherPortalId. Retail indexes the neighbour's portal array with it directly — - // `portals->portal[arg2->other_portal_id ...]` at 005a54b2/005a54f6 — rather than scanning for - // the first OtherCellId match. A scan picks the FIRST back-portal for EVERY near-side portal to - // the same neighbour, so a cell with two openings into one neighbour clips both against the same - // (first) reciprocal — hiding the second opening when the apertures are disjoint (under-inclusion - // bug #102 M-4). The direct index gives each opening its own reciprocal. - // - // GUARDS — degrade to over-include (leave `clippedRegion` untouched), NEVER clip against a - // guessed polygon: the index is out of range, OR the indexed polygon is missing/degenerate - // (< 3 verts), OR it projects entirely behind the camera. Over-inclusion is the safe default; - // mis-resolution is the bug this method exists to remove. PortalPolygons is in lockstep with - // Portals, so index `otherPortalId` selects the reciprocal polygon. NEVER throws. - // Dat CellPortal flags bit 0 (DatReaderWriter.Enums.PortalFlags.ExactMatch; retail - // CCellPortal.exact_match at +0x14, acclient.h:32300). - private const ushort PortalFlagExactMatch = 0x0001; - - private static void ApplyReciprocalClip( - PortalVisibilityFrame frame, - List clippedRegion, ushort otherPortalId, ushort portalFlags, - LoadedCell neighbour, Matrix4x4 viewProj) - { - if (clippedRegion.Count == 0) return; - - // Retail skips OtherPortalClip entirely for exact-match portals — both cells share - // the SAME opening polygon, so re-clipping against the reciprocal can only re-derive - // the near-side clip: PView::ClipPortals decomp:433689 - // `if (exact_match != 0 || other_portal_id < 0) goto propagate-without-reciprocal`. - if ((portalFlags & PortalFlagExactMatch) != 0) return; - - // Direct back-link index (retail arg2->other_portal_id). Out-of-range → over-include. - if (otherPortalId >= neighbour.PortalPolygons.Count) return; - Vector3[]? reciprocalPoly = neighbour.PortalPolygons[otherPortalId]; - if (reciprocalPoly == null || reciprocalPoly.Length < 3) return; // missing/degenerate → over-include - - // §4 corner/doorway fix (2026-06-10): the reciprocal clip now runs the SAME homogeneous - // pipeline as the forward clip — retail PView::OtherPortalClip (decomp:433524-433563) routes - // the reciprocal polygon through the very same GetClip(finish=1) → ACRender::polyClipFinish - // homogeneous clipper as the near-side portal; there is no divide-first special case. - // - // HISTORY: this used to be ProjectToNdc + 2D ScreenPolygonClip.Intersect, justified by "the - // reciprocal is a back-portal one hop away — never near the eye". That assumption is FALSE - // exactly at doorways/corners: the reciprocal IS the same opening whose plane the eye presses - // against (2-60 cm). ProjectToNdc's MinW=0.05 eye-clip + side-plane clip + divide is knife-edge - // there — 2 cm eye moves flipped its output between "covers the region" and a duplicated-vertex - // hairline, which CellView.Add's snap-dedup then rejected → the neighbour room dropped from the - // flood for isolated frames → the corner/transition background strobe (CornerFloodReplayTests - // pins this deterministically; the glitch steps die with this change). The old path's other - // rationale — per-round float drift defeating the exact-match CellView dedup — is obsolete: - // CanonicalKey's 1e-3-grid snap dedup (2026-06-06) absorbs re-clip drift by construction. - using PortalProjection.ClipPolygonLease reciprocalClip = - PortalProjection.ProjectToClipLease( - reciprocalPoly, - neighbour.WorldTransform, - viewProj); - if (reciprocalClip.Count < 3) return; // reciprocal entirely behind the eye → no constraint (over-include) - - // Intersect the reciprocal opening into each near-side polygon; drop any that fall away. - // ClipToRegion(subject=homogeneous reciprocal, region=near-side NDC polygon) = the same - // region-edge homogeneous Sutherland-Hodgman the forward hop uses (polyClipFinish port). - for (int k = clippedRegion.Count - 1; k >= 0; k--) - { - var tightened = PortalProjection.ClipToRegion( - reciprocalClip.Span, - clippedRegion[k].Vertices, - frame.PolygonVertices); - if (tightened.Length >= 3) clippedRegion[k] = new ViewPolygon(tightened); - else clippedRegion.RemoveAt(k); - } - } - - private static CellView GetOrCreate( - PortalVisibilityFrame frame, - Dictionary map, - uint key) - { - if (!map.TryGetValue(key, out var v)) - { - v = frame.RentCellView(); - map[key] = v; - } - return v; - } - - private static bool AddRegion(CellView view, List region) - { - bool grew = false; - foreach (var poly in region) - grew |= view.Add(poly); - return grew; - } - - // Camera→nearest-vertex distance for a portal polygon, in world space. Mirrors the per-portal - // min-distance loop retail runs in PView::InitCell (decomp:432988-433004) to key the todo list: - // it walks the portal's vertices, transforms each to world space, and keeps the smallest - // straight-line distance to the camera viewpoint. Keying on the portal opening (not the cell - // origin) is both retail-faithful and robust to cells whose WorldPosition was never populated. - private static float NearestPortalVertexDistance(Vector3[] localPoly, Matrix4x4 worldTransform, Vector3 cameraPos) - { - float best = float.MaxValue; - for (int i = 0; i < localPoly.Length; i++) - { - var world = Vector3.Transform(localPoly[i], worldTransform); - float d2 = Vector3.DistanceSquared(world, cameraPos); - if (d2 < best) best = d2; - } - return best == float.MaxValue ? 0f : MathF.Sqrt(best); - } - - /// - /// Distance-sorted work list for the portal BFS, ported from retail PView::cell_todo_list + - /// InsCellTodoList (decomp:433183). Insertion keeps the list ordered so the NEAREST cell sits at - /// the tail; removes the tail — giving closest-first traversal exactly - /// as ConstructView's pop-from-(cell_todo_num-1) does (433767-433769). The insertion only shifts - /// entries strictly farther than the newcomer (retail's flag test breaks on the first - /// not-greater entry), so an equal-distance newcomer lands at the tail and pops FIRST — - /// LIFO on ties, matching retail's break-on-first-not-greater + pop-from-tail. - /// - private static void InsertTodo( - List<(LoadedCell Cell, float Distance)> items, - LoadedCell cell, - float distance) - { - int index = items.Count; - while (index > 0 && items[index - 1].Distance < distance) - index--; - items.Insert(index, (cell, distance)); - } - - private static LoadedCell PopNearest(List<(LoadedCell Cell, float Distance)> items) - { - int last = items.Count - 1; - LoadedCell cell = items[last].Cell; - items.RemoveAt(last); - return cell; - } -} diff --git a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs index b6d185e3..91d81390 100644 --- a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs +++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs @@ -62,7 +62,6 @@ internal sealed partial class RetailPViewPassExecutor : IEnvCellImmediateDrawSin private readonly ParticleRenderer? _particleRenderer; private readonly PortalDepthMaskRenderer? _portalDepthMask; private readonly RetailAlphaQueue _alpha; - private readonly WorldRenderDiagnostics _diagnostics; private readonly TerrainDrawDiagnosticsController _terrainDiagnostics; private readonly HashSet _noSceneParticleEntityIds = []; private readonly EnvCellAlphaDrawSource _envCellClipAlphaSource; @@ -85,7 +84,6 @@ internal sealed partial class RetailPViewPassExecutor : IEnvCellImmediateDrawSin ParticleRenderer? particleRenderer, PortalDepthMaskRenderer? portalDepthMask, RetailAlphaQueue alpha, - WorldRenderDiagnostics diagnostics, TerrainDrawDiagnosticsController terrainDiagnostics) { _surface = surface ?? throw new ArgumentNullException(nameof(surface)); @@ -100,7 +98,6 @@ internal sealed partial class RetailPViewPassExecutor : IEnvCellImmediateDrawSin _particleRenderer = particleRenderer; _portalDepthMask = portalDepthMask; _alpha = alpha ?? throw new ArgumentNullException(nameof(alpha)); - _diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics)); _terrainDiagnostics = terrainDiagnostics ?? throw new ArgumentNullException(nameof(terrainDiagnostics)); _envCellClipAlphaSource = new EnvCellAlphaDrawSource( @@ -663,20 +660,6 @@ internal sealed partial class RetailPViewPassExecutor : IEnvCellImmediateDrawSin clipSlot: 0); } - public void EmitDiagnostics( - RetailPViewFrameInput frame, - RetailPViewFrameResult result) => - _diagnostics.EmitRetailPViewDiagnostics( - RenderingDiagnostics.ProbeVisibilityEnabled, - RenderingDiagnostics.ProbeFlapEnabled, - result, - frame.RootCell, - frame.ViewerCellId, - frame.PlayerCellId, - frame.CameraWorldPosition, - frame.PlayerViewPosition, - frame.CameraCellResolution); - /// Retail D3DPolyRender::DrawPortalPolyInternal /// @0x0059BC90. Main interior roots stamp true depth (seal); outdoor and /// look-in apertures stamp far depth (punch). The renderer owns that @@ -737,14 +720,6 @@ internal sealed partial class RetailPViewPassExecutor : IEnvCellImmediateDrawSin cell.WorldTransform); } - _diagnostics.EmitSeamMask( - RenderingDiagnostics.ProbeSeamDrawEnabled, - RenderingDiagnostics.SeamDrawTargetCells, - cellId, - index, - forceFarZ, - world[..count]); - // S4-c1 fix round 1 F2: retail increments portalsDrawnCount // (0x59BD70-0x59BD74) BEFORE polyClipFinish runs (0x59BDB0) — // the counter records accepted ATTEMPTS, not successful GPU diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index 00354879..708d8d1b 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -32,12 +32,6 @@ internal sealed class RetailPViewRenderer // cleared in finally. private Action? _walkPreClearDynamics; - // Output-only Facility Hub staircase probe. This counter is consulted - // only while ACDREAM_PROBE_FACILITY_STAIRS=1 and never affects the walk. - private ulong _probeFacilityStairFrame; - private string? _probeFacilityStairRootSignature; - private string? _probeCathedralStairRootSignature; - // FW6 allocation closeout: the walk's large event/view/route scratch, // frame context, and one-cell leaf collections are renderer-lifetime // owners. Only their frame-local bindings change. Before this cutover all @@ -108,11 +102,8 @@ internal sealed class RetailPViewRenderer ArgumentNullException.ThrowIfNull(ctx); ArgumentNullException.ThrowIfNull(passes); passes.BeginFrame(); - // Campaign FW4: there is one production renderer and one visibility - // owner. The fake/legacy executor path was useful during the cutover, - // but retaining it here kept the retired PortalVisibilityBuilder and - // look-in seed machinery alive. Fail loudly if composition ever tries - // to reintroduce that split. + // There is one production renderer and one frame-walk owner. Fail + // loudly if composition attempts to split that path. RetailPViewPassExecutor walkExecutor = passes as RetailPViewPassExecutor ?? throw new InvalidOperationException( "The retail frame walk requires RetailPViewPassExecutor."); @@ -294,128 +285,13 @@ internal sealed class RetailPViewRenderer viewportHeight); } - // The walk's visited set replaces PortalVisibilityFrame's old - // OrderedVisibleCells side-channel for every production consumer. + // The walk's visited set feeds every production consumer. _drawableCellsScratch.Clear(); _drawableCellsScratch.UnionWith(walkDriver.VisitedCells); walkDriver.CopyVisibleCellsTo(_visibleCellsScratch); _visibleLandscapeCellsScratch.Clear(); _visibleLandscapeCellsScratch.UnionWith(walkDriver.VisitedLandscapeCellIds); - if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFacilityStairsEnabled) - { - static int TargetMembership(IReadOnlyCollection 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 cells) - { - const uint oldBuilding = 0xF4180107u; - const uint stairParent = 0xF4180112u; - int mask = 0; - foreach (uint cell in cells) - { - if (cell == oldBuilding) mask |= 1; - if (cell == stairParent) mask |= 2; - } - return mask; - } - - static uint BuildingAnchor(Walk.WalkBuilding building) - { - foreach (Walk.WalkBldPortal portal in building.Portals) - { - if (portal.OtherCellId != 0xFFFFFFFFu) - return portal.OtherCellId; - } - return 0u; - } - - int cathedralFlood = CathedralMembership(walkDriver.InteriorFloodCells); - int cathedralVisited = CathedralMembership(walkDriver.VisitedCells); - int cathedralLookIn = CathedralMembership(walkDriver.LookInCells); - bool bucket107 = _walkWorldData.StaticBucketContains( - 0xF4180107u, - 0x020009A2u); - bool bucket112 = _walkWorldData.StaticBucketContains( - 0xF4180112u, - 0x020009A2u); - string buildingAnchors = string.Join(",", walkDriver.VisitedBuildings - .ConvertAll(building => $"0x{BuildingAnchor(building):X8}")); - string cathedralSignature = - $"{ctx.ViewerCellId:X8}:{ctx.PlayerCellId:X8}:{ctx.RootCell.CellId:X8}:" - + $"{cathedralFlood}:{cathedralVisited}:{cathedralLookIn}:" - + $"{(bucket107 ? 1 : 0)}:{(bucket112 ? 1 : 0)}:{buildingAnchors}"; - bool cathedralChanged = !string.Equals( - _probeCathedralStairRootSignature, - cathedralSignature, - StringComparison.Ordinal); - _probeCathedralStairRootSignature = cathedralSignature; - if (cathedralChanged - && ((ctx.ViewerCellId & 0xFFFF0000u) == 0xF4180000u - || (ctx.PlayerCellId & 0xFFFF0000u) == 0xF4180000u)) - { - static string DescribeCathedralMask(int mask) => - $"107={((mask & 1) != 0 ? 1 : 0)}," - + $"112={((mask & 2) != 0 ? 1 : 0)}"; - Console.WriteLine( - $"[cathedral-stair] f={_probeFacilityStairFrame} " - + $"viewer=0x{ctx.ViewerCellId:X8} player=0x{ctx.PlayerCellId:X8} " - + $"root=0x{ctx.RootCell.CellId:X8} res={ctx.CameraCellResolution} " - + $"eye=({ctx.ViewerEyePos.X:F4},{ctx.ViewerEyePos.Y:F4},{ctx.ViewerEyePos.Z:F4}) " - + $"playerPos=({ctx.PlayerViewPosition.X:F4},{ctx.PlayerViewPosition.Y:F4},{ctx.PlayerViewPosition.Z:F4}) " - + $"rootFlood=[{DescribeCathedralMask(cathedralFlood)}] " - + $"visited=[{DescribeCathedralMask(cathedralVisited)}] " - + $"lookIn=[{DescribeCathedralMask(cathedralLookIn)}] " - + $"buckets=[107={(bucket107 ? 1 : 0)},112={(bucket112 ? 1 : 0)}] " - + $"buildings=[{buildingAnchors}]"); - } - } - } // FW4 slice 1: the ONE clip-region publication, after any walk @@ -448,8 +324,6 @@ internal sealed class RetailPViewRenderer counts, sourceCounts, diagnosticPartition: null); - passes.EmitDiagnostics(ctx, result); - // The one collected walk owns terrain, cell shells, buildings, // statics, dynamics, particles, punches, and alpha barriers at // their retail turns. Interior-root landscape services run at the diff --git a/src/AcDream.App/Rendering/ViewconeCuller.cs b/src/AcDream.App/Rendering/ViewconeCuller.cs deleted file mode 100644 index d686deeb..00000000 --- a/src/AcDream.App/Rendering/ViewconeCuller.cs +++ /dev/null @@ -1,207 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Numerics; - -namespace AcDream.App.Rendering; - -/// -/// T3 (BR-5): the port of retail's Render::viewconeCheck (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 -/// (view_vertex { Vec2D pt; Plane plane }, 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). -/// -/// Our views are clip-space half-planes (≤8 per slice, -/// 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). -/// -/// 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. -/// -public sealed class ViewconeCuller -{ - private const int MaxRetainedCellPlaneSets = 512; - private const int MaxRetainedPlanesPerCell = 256; - private const int MaxRetainedSlicesPerCell = 64; - - private readonly Dictionary _cellPlanes = new(); - private readonly Stack _planeSetPool = new(); - private PlaneSet _outsidePlanes = new(); - - private readonly record struct SliceRange(int Start, int Count); - private readonly record struct LiftedPlane(Vector4 Equation, float NormalLength); - - /// - /// 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. - /// - private sealed class PlaneSet - { - public List Planes { get; } = new(); - public List Slices { get; } = new(); - - public bool IsRetainable => - Planes.Capacity <= MaxRetainedPlanesPerCell - && Slices.Capacity <= MaxRetainedSlicesPerCell; - - public void Reset() - { - Planes.Clear(); - Slices.Clear(); - } - } - - /// True when the outside view is a full-screen pass-all (the - /// synthetic outdoor root) — every outside-test passes. - 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; - } - - /// 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. - 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; - } - - /// 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). - 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; - } - - /// Sphere vs ONE outside slice (the landscape pass draws per - /// slice; its statics pre-filter tests against exactly that slice). - 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); - } -} diff --git a/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs b/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs index bc4ef30f..af3c5c93 100644 --- a/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs +++ b/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs @@ -107,9 +107,8 @@ public sealed class RetailFrameWalk /// exit-view polygons THIS walk's own ConstructView installed /// during the most recent . The production /// renderer materializes the frame's terrain/sky/punch clip slices from - /// these views (retail has exactly one visibility structure; the old - /// PortalVisibilityBuilder assembly no longer feeds the walk path's - /// outside views). Valid after returns for an + /// these views (retail has exactly one visibility structure). Valid after + /// returns for an /// interior root, until the next interior root's view push resets it. internal WalkPortalView InteriorOutsideView => _interiorPView.OutsideView; diff --git a/src/AcDream.App/Rendering/Walk/WalkPView.cs b/src/AcDream.App/Rendering/Walk/WalkPView.cs index cc65f9b6..4ad74e12 100644 --- a/src/AcDream.App/Rendering/Walk/WalkPView.cs +++ b/src/AcDream.App/Rendering/Walk/WalkPView.cs @@ -186,11 +186,6 @@ public sealed class WalkPView ref WalkCellPortal portal = ref cell.Portals[j]; if (!flags.Seen || flags.InView) { - EmitFacilityPortalProbe( - cell, j, viewIndex: -1, top, portal, flags, ctx, - projectedCount: -1, clippedCount: -1, - neighborViewsBefore: -1, neighborViewsAfter: -1, - appended: false, result: "flag-gate"); continue; } if (cell.CachedNeighbors[j] is null && portal.OtherCellId != 0xFFFFFFFFu) @@ -198,11 +193,6 @@ public sealed class WalkPView cell.CachedNeighbors[j] = ctx.GetVisible(portal.OtherCellId); if (cell.CachedNeighbors[j] is null) { - EmitFacilityPortalProbe( - cell, j, viewIndex: -1, top, portal, flags, ctx, - projectedCount: -1, clippedCount: -1, - neighborViewsBefore: -1, neighborViewsAfter: -1, - appended: false, result: "neighbor-missing"); continue; // not loaded: silently dead } } @@ -223,15 +213,8 @@ public sealed class WalkPView cell, portal.PortalSide, cell.PortalPolygons[portal.PolygonIndex], doClip: true, ctx, _clipScratch); - int projectedCount = - cell.PortalPolygons[portal.PolygonIndex].Vertices.Length; if (n == 0) { - EmitFacilityPortalProbe( - cell, j, i, top, portal, flags, ctx, - projectedCount, clippedCount: 0, - neighborViewsBefore: -1, neighborViewsAfter: -1, - appended: false, result: "clip-empty"); continue; } @@ -257,123 +240,19 @@ public sealed class WalkPView SetView(top, i); // restore after the far-frame excursion if (n == 0) { - EmitFacilityPortalProbe( - cell, j, i, top, portal, flags, ctx, - projectedCount, clippedCount: 0, - neighborViewsBefore: neighbor.TopView.ViewCount, - neighborViewsAfter: neighbor.TopView.ViewCount, - appended: false, result: "far-clip-empty"); continue; } } - int before = neighbor.TopView.ViewCount; - bool appended = false; if (neighbor.NumView != 0) - appended = WalkCopyView.Append( + WalkCopyView.Append( neighbor.TopView, _clipScratch.AsSpan(0, n), ctx.Rays, ctx.WorldViewpoint); - EmitFacilityPortalProbe( - cell, j, i, top, portal, flags, ctx, - projectedCount, n, before, neighbor.TopView.ViewCount, - appended, - neighbor.NumView == 0 ? "neighbor-not-pushed" - : appended ? "view-appended" : "append-rejected"); } } } return true; } - /// - /// 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. - /// - 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 points) - { - if (points.IsEmpty) - return "none"; - - float xmin = float.PositiveInfinity; - float xmax = float.NegativeInfinity; - float ymin = float.PositiveInfinity; - float ymax = float.NegativeInfinity; - float wmin = float.PositiveInfinity; - float wmax = float.NegativeInfinity; - int divided = 0; - for (int index = 0; index < points.Length; index++) - { - ref readonly WalkScreenPoint point = ref points[index]; - wmin = MathF.Min(wmin, point.W); - wmax = MathF.Max(wmax, point.W); - if (point.W < WalkScreenClip.MinW) - continue; - float x = point.X / point.W; - float y = point.Y / point.W; - xmin = MathF.Min(xmin, x); - xmax = MathF.Max(xmax, x); - ymin = MathF.Min(ymin, y); - ymax = MathF.Max(ymax, y); - divided++; - } - - return divided == 0 - ? $"behind(w={wmin:F4}..{wmax:F4})" - : $"({xmin:F2},{ymin:F2})-({xmax:F2},{ymax:F2});" - + $"w={wmin:F4}..{wmax:F4};front={divided}"; - } - // ------------------------------------------------------------------ // OtherPortalClip @0x005a5400 — the double clip for non-exact_match // portals: snapshot the near-clipped poly as a temp view, then re-clip diff --git a/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs b/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs index 497302f8..e26efd5c 100644 --- a/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs +++ b/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs @@ -84,7 +84,6 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData private readonly Dictionary _cellCache = new(); private readonly Dictionary _cellDynamicCache = new(); private readonly Dictionary _cellObjectsCache = new(); - private readonly Dictionary _facilityShadowProbeSignatures = new(); private readonly Dictionary> _shellsByAnchor = new(); private readonly Dictionary _outdoorMaterialized = new(); private readonly Dictionary _outdoorDynamicsMaterialized = new(); @@ -162,21 +161,6 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData int renderCenterLbX, int renderCenterLbY) { - // Campaign OVERHAUL S2 chunk 5: report the PRIOR frame's count - // before resetting it — the count is only final once that frame's - // walk (a sequence of on-demand Get* calls this class has no other - // "frame is done" hook for) has finished, so this is necessarily a - // one-frame-delayed report, matching every other per-second/per- - // change probe in this file family (print-only; never influences - // admission). - if (UnregisteredRenderMembershipCount > 0 - && AcDream.Core.Rendering.RenderingDiagnostics.ProbeFacilityStairsEnabled) - { - Console.WriteLine( - $"[walk-membership] unregistered={UnregisteredRenderMembershipCount} " - + $"tupleLandblock=0x{_tupleLandblockId:X8}"); - } - _scene = scene; _tupleLandblockId = tupleLandblockId; _cellCache.Clear(); @@ -217,41 +201,11 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData } } - /// 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 — 's - /// ProbeFacilityStairsEnabled block is the only caller. - internal bool StaticBucketContains(uint cellId, uint sourceId) - { - IReadOnlyList entries = - _shadows.GetRetailPartEntriesInCell(cellId); - uint previousEntityId = 0; - bool havePrevious = false; - for (int i = 0; i < entries.Count; i++) - { - uint entityId = entries[i].EntityId; - if (havePrevious && entityId == previousEntityId) - continue; - previousEntityId = entityId; - havePrevious = true; - - if (_scene.TryGetByLocalEntityId(entityId, out RenderProjectionRecord record) - && record.Source.SourceId == sourceId) - { - return true; - } - } - return false; - } - public WalkFrameStaticRecords GetCellStatics(uint cellId) { if (_cellCache.TryGetValue(cellId, out WalkFrameStaticRecords cached)) return cached; WalkFrameStaticRecords records = ResolveCellView(cellId, dynamic: false); - EmitFacilityShadowProbe(records.Records, cellId, "static"); _cellCache[cellId] = records; return records; } @@ -261,7 +215,6 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData if (_cellObjectsCache.TryGetValue(cellId, out WalkFrameStaticRecords cached)) return cached; WalkFrameStaticRecords records = ResolveCellView(cellId, dynamic: null); - EmitFacilityShadowProbe(records.Records, cellId, "combined"); _cellObjectsCache[cellId] = records; return records; } @@ -271,7 +224,6 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData if (_cellDynamicCache.TryGetValue(cellId, out WalkFrameStaticRecords cached)) return cached; WalkFrameStaticRecords records = ResolveCellView(cellId, dynamic: true); - EmitFacilityShadowProbe(records.Records, cellId, "dynamic"); _cellDynamicCache[cellId] = records; return records; } @@ -391,68 +343,6 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData projectionClass is RenderProjectionClass.LiveDynamicRoot or RenderProjectionClass.EquippedChild; - /// - /// 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. - /// - private void EmitFacilityShadowProbe( - ReadOnlySpan 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 ownerCells = - _shadows.GetOwnerCells(record.Source.LocalEntityId); - var cells = new StringBuilder(ownerCells.Count * 11 + 2); - cells.Append('['); - for (int cellIndex = 0; cellIndex < ownerCells.Count; cellIndex++) - { - if (cellIndex != 0) - cells.Append(','); - cells.Append("0x").Append(ownerCells[cellIndex].ToString("X8")); - } - cells.Append(']'); - - string key = $"{route}:{queriedCellId:X8}:" - + $"{record.Source.LocalEntityId:X8}"; - string signature = $"{record.Source.ParentCellId:X8}:{cells}"; - if (_facilityShadowProbeSignatures.TryGetValue(key, out string? prior) - && string.Equals(prior, signature, StringComparison.Ordinal)) - { - continue; - } - _facilityShadowProbeSignatures[key] = signature; - - Console.WriteLine( - $"[facility-shadow] route={route} " - + $"kind={(targetPlayer ? "player" : targetCathedralStair ? "cathedral-stair" : "stair")} " - + $"guid=0x{record.Source.ServerGuid:X8} " - + $"local=0x{record.Source.LocalEntityId:X8} " - + $"query=0x{queriedCellId:X8} " - + $"parent=0x{record.Source.ParentCellId:X8} " - + $"owners={cells}"); - } - } - public WalkFrameStaticRecords GetBuildingShellStatics(WalkBuilding building) { uint anchor = BuildingShellBucketCellId(building); diff --git a/src/AcDream.App/Rendering/Walk/WalkStaticStreamPopulator.cs b/src/AcDream.App/Rendering/Walk/WalkStaticStreamPopulator.cs index 039a5310..d8ebd2fa 100644 --- a/src/AcDream.App/Rendering/Walk/WalkStaticStreamPopulator.cs +++ b/src/AcDream.App/Rendering/Walk/WalkStaticStreamPopulator.cs @@ -224,8 +224,7 @@ internal sealed class WalkStaticStreamPopulator liveDynamic, views, viewRouteIndex, - cellId, - diagnosticViewProjection: viewProjection); + cellId); for (int batchIndex = 0; batchIndex < _batchScratch.Count; batchIndex++) { @@ -257,17 +256,10 @@ internal sealed class WalkStaticStreamPopulator WbDrawDispatcher.WalkClassifiedBatch batch = item.Batch; if (batch.IsOpaque) { - int commandIndex = stream.Count; stream.Append(new OrderedDrawCommand( batch.Key, batch.Transform, item.Stage, cellId, batch.ClipSlot, batch.Lights, batch.IndoorFlag, batch.Alpha, batch.SelectionLighting, batch.DetailCategory)); - _dispatcher.ProbeFacilityStairCommandAppended( - commandIndex, - item.LocalEntityId, - cellId, - item.Stage, - in batch); } else { @@ -318,7 +310,6 @@ internal sealed class WalkStaticStreamPopulator lookInViews, lookInRouteIndex, cellId, - diagnosticViewProjection: viewProjection, buildingSelection: buildingSelection, buildingPartTransform: buildingPartTransform); @@ -327,17 +318,10 @@ internal sealed class WalkStaticStreamPopulator WbDrawDispatcher.WalkClassifiedBatch batch = _batchScratch[i]; if (batch.IsOpaque) { - int commandIndex = stream.Count; stream.Append(new OrderedDrawCommand( batch.Key, batch.Transform, stage, cellId, batch.ClipSlot, batch.Lights, batch.IndoorFlag, batch.Alpha, batch.SelectionLighting, batch.DetailCategory)); - _dispatcher.ProbeFacilityStairCommandAppended( - commandIndex, - record.Source.LocalEntityId, - cellId, - stage, - in batch); } else { diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs index f8ec0351..56cbdc8e 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs @@ -220,12 +220,6 @@ public sealed unsafe partial class EnvCellRenderer Array.Copy(cellSet, 0, _lightSetData, i * lightStride, lightStride); } - if (renderPass == WbRenderPass.Opaque - && AcDream.Core.Rendering.RenderingDiagnostics.ProbeSeamDrawEnabled) - { - EmitSeamDrawProbe(_renderDrawCalls, allInstances, _seamProbeFilter); - } - int lightCount = GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData); int globalLightUploadCount = lightCount > 0 ? lightCount : 1; diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs index ec82fdfd..9ea94e1c 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs @@ -203,21 +203,6 @@ public sealed partial class EnvCellRenderer : public struct LastFrameStats { public int CellsRendered; public int TrianglesDrawn; } private LastFrameStats _lastFrameStats; - /// - /// 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. - /// - public (int PoolTotal, int SnapshotPoolHwm) GetPoolDiagnostics() - { - int poolTotal; - lock (_listPool) poolTotal = _listPool.Count; - int hwm; - lock (_renderLock) hwm = _activeSnapshot.PostPreparePoolIndex; - return (poolTotal, hwm); - } - // --------------------------------------------------------------------------- // Constructor // Campaign V slice V11: the raw-GL constructor + Initialize(Shader) two-step @@ -1004,12 +989,6 @@ public sealed partial class EnvCellRenderer : } } - // #176 seam-draw probe: stash this call's filter so the opaque-pass - // emitter inside RenderModernMDIInternal can report flood membership - // per target cell (null on the unfiltered/outdoor path). - if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeSeamDrawEnabled) - _seamProbeFilter = filter; - // WB EnvCellRenderManager.cs:470-483: if (allInstances.Count > 0) { @@ -1421,104 +1400,6 @@ public sealed partial class EnvCellRenderer : ranges.Add(new MdiDrawRange(groupIndex, firstCommand, commandCount, materialState)); } - // --------------------------------------------------------------------------- - // #176 seam-draw probe (ACDREAM_PROBE_SEAMDRAW) — throwaway apparatus. - // The in-engine replacement for the RenderDoc pixel-history the pipeline - // can't have (RenderDoc hides GL_ARB_bindless_texture → our mandatory-modern - // startup gate throws). Per opaque pass: for each target cell — flood - // membership, every shell instance (count + translation, F3 z shows the - // +0.02 lift; n≥2 for one (cell,gfx) = the runtime double-draw), and the - // cell's 8-light set resolved to stable IDENTITIES (owner-cell low16 + - // intensity; raw indices shuffle when the pool rebuilds). Plus the - // snapshot's HOT lights (intensity ≥ 50 — the portal purples; fixtures are - // ~1–2). Change-deduped block with a 2 s heartbeat: a purple identity - // flipping with flood membership = the snapshot-scope mechanism; two - // coincident instances = the z-fight. See RenderingDiagnostics. - // --------------------------------------------------------------------------- - - private HashSet? _seamProbeFilter; - private string? _seamSig; - private long _seamLastEmitMs; - - private void EmitSeamDrawProbe( - List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)> drawCalls, - List allInstances, - HashSet? filter) - { - var ci = System.Globalization.CultureInfo.InvariantCulture; - var snap = _pointSnapshot; - var sb = new System.Text.StringBuilder(640); - - var sorted = new List(AcDream.Core.Rendering.RenderingDiagnostics.SeamDrawTargetCells); - sorted.Sort(); - foreach (uint cell in sorted) - { - sb.Append("\n[seam-cell] cell=0x").Append(cell.ToString("X8")); - sb.Append(" flood=").Append(filter is null ? '?' : (filter.Contains(cell) ? 'Y' : 'N')); - - int totalInst = 0; - foreach (var dc in drawCalls) - { - int n = 0; - InstanceData first = default; - for (int i = dc.offset; i < dc.offset + dc.count; i++) - { - if (allInstances[i].CellId != cell) continue; - if (n == 0) first = allInstances[i]; - n++; - } - if (n == 0) continue; - totalInst += n; - var t = first.Transform.Translation; - sb.AppendFormat(ci, " g=0x{0:X8}:n={1}@({2:F2},{3:F2},{4:F3})", - dc.gfxObjId, n, t.X, t.Y, t.Z); - } - if (totalInst == 0) sb.Append(" inst=0"); - - // The 8-light set this cell's instances carry (fresh: the per-pass - // cache was cleared at the top of RenderModernMDIInternal). - int[] set = GetCellLightSet(cell); - sb.Append(" L=["); - bool any = false; - for (int k = 0; k < set.Length; k++) - { - int idx = set[k]; - if (idx < 0) continue; - if (any) sb.Append(','); - if (snap is not null && idx < snap.Count) - sb.AppendFormat(ci, "{0:X4}:I{1:F0}", snap[idx].CellId & 0xFFFFu, snap[idx].Intensity); - else - sb.Append('?').Append(idx); - any = true; - } - sb.Append(']'); - } - - sb.Append("\n[seam-snap] pool=").Append(snap?.Count ?? 0).Append(" hot=["); - if (snap is not null) - { - bool anyHot = false; - for (int i = 0; i < snap.Count; i++) - { - var ls = snap[i]; - if (ls.Intensity < 50f) continue; - if (anyHot) sb.Append(','); - sb.AppendFormat(ci, "0x{0:X8}:I{1:F0}rgb({2:F2},{3:F2},{4:F2})", - ls.CellId, ls.Intensity, ls.ColorLinear.X, ls.ColorLinear.Y, ls.ColorLinear.Z); - anyHot = true; - } - } - sb.Append(']'); - - string sig = sb.ToString(); - long now = System.Environment.TickCount64; - bool changed = sig != _seamSig; - if (!changed && (now - _seamLastEmitMs) < 2000) return; - _seamSig = sig; - _seamLastEmitMs = now; - System.Console.WriteLine($"[seam-blk] t={now} changed={(changed ? 1 : 0)}{sig}"); - } - // --------------------------------------------------------------------------- // List pool (GetPooledList) // Copied from WB ObjectRenderManagerBase (pattern). diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.FacilityStairProbe.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.FacilityStairProbe.cs deleted file mode 100644 index 5ba8cd45..00000000 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.FacilityStairProbe.cs +++ /dev/null @@ -1,171 +0,0 @@ -using System.Text; -using AcDream.App.Rendering.Walk; -using AcDream.Core.Rendering; - -namespace AcDream.App.Rendering.Wb; - -/// -/// 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 -/// ACDREAM_PROBE_FACILITY_STAIRS=1. -/// -public sealed partial class WbDrawDispatcher -{ - private const uint FacilityStairSubmissionLocalId = 0x4F41806Cu; - - private readonly List _facilityStairSubmissionCommands = new(4); - private readonly HashSet _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}"); - } -} diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.OrderedStream.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.OrderedStream.cs index df8bdd53..c991cb2c 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.OrderedStream.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.OrderedStream.cs @@ -350,7 +350,6 @@ public sealed unsafe partial class WbDrawDispatcher _orderedStream = stream; _orderedFrame = frame; _orderedPreparedCount = 0; - ProbeFacilityStairPrepareStarted(stream.Count); // Fail loud before any GPU work: a PortalPunch command has no // submission path. @@ -433,7 +432,6 @@ public sealed unsafe partial class WbDrawDispatcher checked((uint)(count * DrawCommandStride))); _orderedPreparedCount = count; - ProbeFacilityStairUploadCompleted(count); } /// @@ -587,11 +585,6 @@ public sealed unsafe partial class WbDrawDispatcher DrawIndirectRangeRhi( encoder, ref pushConstants, commandBuffer, commandBase, run.FirstCommand, run.CommandCount, _orderedDrawCullModes); - ProbeFacilityStairRunSubmitted( - firstCommand, - commandCount, - run.FirstCommand, - run.CommandCount); } ClearDetailPushConstants(ref pushConstants); diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs index a36fc067..c7c6544f 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs @@ -55,20 +55,6 @@ public sealed partial class WbDrawDispatcher { private readonly HashSet _walkDrawnParts = new(); private bool _walkPartFrameActive; - private readonly Dictionary _facilityStairProbeSignatures = new(); - private Dictionary _facilityStairsCurrent = new(); - private Dictionary _facilityStairsPrevious = new(); - private int _facilityStairsCurrentFrame = -1; - private int _facilityStairsPreviousFrame = -1; - - private readonly record struct FacilityStairProbeSnapshot( - uint CellId, - int RouteIndex, - bool Admitted, - bool ScreenIntersect, - string ScreenBounds, - Vector3 WorldCenter); - private readonly record struct WalkDrawnPartKey( RenderProjectionId ProjectionId, int PartIndex); @@ -93,7 +79,6 @@ public sealed partial class WbDrawDispatcher _missRequested.Clear(); _walkDrawnParts.Clear(); - BeginFacilityStairSubmissionProbeFrame(); _walkPartFrameActive = true; } @@ -275,7 +260,6 @@ public sealed partial class WbDrawDispatcher IWalkLookInViewSource? lookInViews = null, int lookInRouteIndex = -1, uint lookInCellId = 0, - Matrix4x4 diagnosticViewProjection = default, WalkBuildingSelection? buildingSelection = null, Matrix4x4 buildingPartTransform = default) { @@ -369,17 +353,6 @@ public sealed partial class WbDrawDispatcher ObjectRenderData? partData = _meshAdapter.TryGetRenderData(gfxObjId); if (partData is null) { - if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFacilityStairsEnabled - && entity.LocalEntityId == 0x4F41806Cu - && gfxObjId == 0x01001FE8u) - { - Console.WriteLine( - $"[facility-part] frame={WalkPView.MasterTimestampForDiagnostics} " - + $"local=0x{entity.LocalEntityId:X8} " - + $"cell=0x{lookInCellId:X8} gfx=0x{gfxObjId:X8} " - + $"part={setupPartIndex} route={lookInRouteIndex} " - + "decision=render-data-miss"); - } if (_missRequested.Add(gfxObjId)) _meshAdapter.EnsureLoaded(gfxObjId); continue; @@ -393,12 +366,6 @@ public sealed partial class WbDrawDispatcher : 1f; if (opacity <= 0f) { - EmitFacilityStairPartProbe( - in projection, in entity, lookInCellId, - lookInRouteIndex, (uint)gfxObjId, setupPartIndex, - partData, default, diagnosticViewProjection, - default, 0f, hasSphere: false, - opacity, "opacity-zero", lookInViews); continue; } @@ -411,21 +378,11 @@ public sealed partial class WbDrawDispatcher lookInRouteIndex, partData, model, - out Vector3 sphereCenter, - out float sphereRadius, - out bool hasSphere); + out _, + out _, + out _); bool firstAdmission = visible && TryStampWalkPart(in projection, selectionPartIndex); - EmitFacilityStairPartProbe( - in projection, in entity, lookInCellId, - lookInRouteIndex, (uint)gfxObjId, setupPartIndex, - partData, model, diagnosticViewProjection, - sphereCenter, sphereRadius, hasSphere, - opacity, - !visible - ? "sphere-reject" - : firstAdmission ? "admit" : "already-drawn", - lookInViews); if (!firstAdmission) continue; EmitClassifiedBatches( @@ -448,12 +405,6 @@ public sealed partial class WbDrawDispatcher : 1f; if (opacity <= 0f) { - EmitFacilityStairPartProbe( - in projection, in entity, lookInCellId, - lookInRouteIndex, (uint)meshRef.GfxObjId, partIndex, - renderData, default, diagnosticViewProjection, - default, 0f, hasSphere: false, - opacity, "opacity-zero", lookInViews); continue; } @@ -463,21 +414,11 @@ public sealed partial class WbDrawDispatcher lookInRouteIndex, renderData, model, - out Vector3 sphereCenter, - out float sphereRadius, - out bool hasSphere); + out _, + out _, + out _); bool firstAdmission = visible && TryStampWalkPart(in projection, partIndex); - EmitFacilityStairPartProbe( - in projection, in entity, lookInCellId, - lookInRouteIndex, (uint)meshRef.GfxObjId, partIndex, - renderData, model, diagnosticViewProjection, - sphereCenter, sphereRadius, hasSphere, - opacity, - !visible - ? "sphere-reject" - : firstAdmission ? "admit" : "already-drawn", - lookInViews); if (!firstAdmission) continue; EmitClassifiedBatches( @@ -585,213 +526,6 @@ public sealed partial class WbDrawDispatcher return lookInViews.SphereVisibleInLookInTurn(routeIndex, in sphereCenter, sphereRadius); } - private void EmitFacilityStairPartProbe( - in RenderProjectionRecord projection, - in RenderInstanceCandidate entity, - uint cellId, - int routeIndex, - uint gfxObjId, - int partIndex, - ObjectRenderData renderData, - Matrix4x4 localToWorld, - Matrix4x4 viewProjection, - Vector3 sphereCenter, - float sphereRadius, - bool hasSphere, - float opacity, - string decision, - IWalkLookInViewSource? lookInViews) - { - if (!AcDream.Core.Rendering.RenderingDiagnostics.ProbeFacilityStairsEnabled) - return; - - bool targetStairCell = cellId is 0x8A02015Eu or 0x8A02015Fu; - bool targetPlayerCell = targetStairCell || cellId == 0x8A0201C1u; - bool targetStair = targetStairCell && gfxObjId == 0x010000DEu; - bool targetPlayer = targetPlayerCell - && projection.EntityPayload.CasterIdentity - == RenderCasterIdentityKind.LocalPlayer; - // Static render projections carry the local render identity here; - // SourceId is not guaranteed to retain the Setup DID after the - // landblock projection has been materialized. - // Cathedral floating stair Setup 0x020009A2 is seven independent - // visual parts (0x01001FE8 + six 0x01001FE6 slabs). Tracing only the - // first GfxObj hid the actual camera-angle failure on the six visible - // slabs, so keep the probe keyed to the complete PartArray owner. - bool targetCathedralRamp = entity.LocalEntityId == 0x4F41806Cu; - if (!targetStair && !targetPlayer && !targetCathedralRamp) - return; - - int frame = WalkPView.MasterTimestampForDiagnostics; - string screenBounds = "unavailable"; - bool screenIntersect = false; - if (targetStair || targetCathedralRamp) - { - AdvanceFacilityStairProbeFrame(frame); - screenIntersect = TryProjectFacilityStairBounds( - renderData, - localToWorld, - viewProjection, - out screenBounds); - var snapshot = new FacilityStairProbeSnapshot( - cellId, - routeIndex, - string.Equals(decision, "admit", StringComparison.Ordinal), - screenIntersect, - screenBounds, - hasSphere ? sphereCenter : localToWorld.Translation); - if (!_facilityStairsCurrent.TryGetValue( - entity.LocalEntityId, - out FacilityStairProbeSnapshot existing) - || (!existing.Admitted && snapshot.Admitted)) - { - _facilityStairsCurrent[entity.LocalEntityId] = snapshot; - } - } - - string key = $"{entity.ServerGuid:X8}:{entity.LocalEntityId:X8}:" - + $"{cellId:X8}:{gfxObjId:X8}:{partIndex}"; - // A route index is allocated afresh every frame. Including it in the - // signature made a stationary cathedral trace print at frame rate and - // obscure the useful admit/reject transition. Cell + part are already - // in the key; emit again only when that route's decision changes. - string signature = targetCathedralRamp - ? $"{decision}:{(hasSphere ? 1 : 0)}:" - + $"{(opacity <= 0f ? 0 : opacity < 1f ? 1 : 2)}" - : $"{routeIndex}:{decision}:{(hasSphere ? 1 : 0)}:" - + $"{(opacity <= 0f ? 0 : opacity < 1f ? 1 : 2)}"; - if (_facilityStairProbeSignatures.TryGetValue(key, out string? prior) - && string.Equals(prior, signature, StringComparison.Ordinal)) - { - return; - } - _facilityStairProbeSignatures[key] = signature; - - string authored = renderData.SelectionSphere is { } sourceSphere - ? $" authored=({sourceSphere.Origin.X:F4},{sourceSphere.Origin.Y:F4}," - + $"{sourceSphere.Origin.Z:F4};r={sourceSphere.Radius:F4})" - : " authored=none"; - string world = hasSphere - ? $" world=({sphereCenter.X:F4},{sphereCenter.Y:F4}," - + $"{sphereCenter.Z:F4};r={sphereRadius:F4})" - : " world=none"; - string cone = targetCathedralRamp && hasSphere && lookInViews is not null - ? " cone={" + lookInViews.DescribeLookInTurn( - routeIndex, in sphereCenter, sphereRadius) + "}" - : string.Empty; - Console.WriteLine( - $"[facility-part] frame={frame} " - + $"kind={projection.EntityPayload.CasterIdentity} " - + $"guid=0x{entity.ServerGuid:X8} local=0x{entity.LocalEntityId:X8} " - + $"cell=0x{cellId:X8} parent=0x{entity.ParentCellId:X8} " - + $"gfx=0x{gfxObjId:X8} part={partIndex} route={routeIndex} " - + $"opacity={opacity:F4} decision={decision} " - + $"screen={(screenIntersect ? 1 : 0)} ndc={screenBounds}" - + authored + world + cone); - } - - private void AdvanceFacilityStairProbeFrame(int frame) - { - if (frame == _facilityStairsCurrentFrame) - return; - - if (_facilityStairsCurrentFrame >= 0) - { - if (_facilityStairsPreviousFrame >= 0) - { - foreach ((uint localId, FacilityStairProbeSnapshot previous) - in _facilityStairsPrevious) - { - if (!_facilityStairsCurrent.TryGetValue(localId, out _)) - { - Console.WriteLine( - $"[facility-stair-drop] frame={_facilityStairsCurrentFrame} " - + $"previousFrame={_facilityStairsPreviousFrame} " - + $"local=0x{localId:X8} cell=0x{previous.CellId:X8} " - + $"route={previous.RouteIndex} admitted={(previous.Admitted ? 1 : 0)} " - + $"screen={(previous.ScreenIntersect ? 1 : 0)} " - + $"lastNdc={previous.ScreenBounds} " - + $"world=({previous.WorldCenter.X:F4},{previous.WorldCenter.Y:F4}," - + $"{previous.WorldCenter.Z:F4}) reason=cell-not-walked"); - } - } - - foreach ((uint localId, FacilityStairProbeSnapshot current) - in _facilityStairsCurrent) - { - if (_facilityStairsPrevious.TryGetValue( - localId, - out FacilityStairProbeSnapshot previous) - && current.Admitted != previous.Admitted) - { - Console.WriteLine( - $"[facility-stair-decision] frame={_facilityStairsCurrentFrame} " - + $"local=0x{localId:X8} cell=0x{current.CellId:X8} " - + $"admitted={(previous.Admitted ? 1 : 0)}->" - + $"{(current.Admitted ? 1 : 0)} " - + $"screen={(current.ScreenIntersect ? 1 : 0)} " - + $"ndc={current.ScreenBounds}"); - } - } - } - - Dictionary swap = - _facilityStairsPrevious; - _facilityStairsPrevious = _facilityStairsCurrent; - _facilityStairsCurrent = swap; - _facilityStairsCurrent.Clear(); - _facilityStairsPreviousFrame = _facilityStairsCurrentFrame; - } - - _facilityStairsCurrentFrame = frame; - } - - private static bool TryProjectFacilityStairBounds( - ObjectRenderData renderData, - Matrix4x4 localToWorld, - Matrix4x4 viewProjection, - out string bounds) - { - Vector3 min = renderData.BoundingBox.Min; - Vector3 max = renderData.BoundingBox.Max; - float minX = float.PositiveInfinity; - float minY = float.PositiveInfinity; - float maxX = float.NegativeInfinity; - float maxY = float.NegativeInfinity; - int projected = 0; - - for (int corner = 0; corner < 8; corner++) - { - var local = new Vector3( - (corner & 1) == 0 ? min.X : max.X, - (corner & 2) == 0 ? min.Y : max.Y, - (corner & 4) == 0 ? min.Z : max.Z); - Vector3 world = Vector3.Transform(local, localToWorld); - Vector4 clip = Vector4.Transform(new Vector4(world, 1f), viewProjection); - if (!float.IsFinite(clip.W) || clip.W <= 0.0001f) - continue; - - float x = clip.X / clip.W; - float y = clip.Y / clip.W; - if (!float.IsFinite(x) || !float.IsFinite(y)) - continue; - minX = MathF.Min(minX, x); - minY = MathF.Min(minY, y); - maxX = MathF.Max(maxX, x); - maxY = MathF.Max(maxY, y); - projected++; - } - - if (projected == 0) - { - bounds = "behind"; - return false; - } - - bounds = $"({minX:F3},{minY:F3})-({maxX:F3},{maxY:F3});corners={projected}"; - return maxX >= -1f && minX <= 1f && maxY >= -1f && minY <= 1f; - } - internal static bool LookInDrawingSphereVisible( IWalkLookInViewSource lookInViews, int routeIndex, diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index 68cb51e6..d1856450 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -750,31 +750,6 @@ public sealed partial class WbDrawDispatcher : IDisposable private bool _disposing; private bool _disposed; - /// - /// 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. - /// - private readonly Dictionary _lastIndoorProbeFrame = new(); - private int _indoorProbeFrameCounter; - private const int IndoorProbeRateLimitFrames = 30; - - /// - /// Returns true at most once per - /// frames per cellId. Caller must already have checked that an indoor - /// probe flag is enabled. - /// - private bool ShouldEmitIndoorProbe(ulong cellId) - { - if (!_lastIndoorProbeFrame.TryGetValue(cellId, out int last) - || _indoorProbeFrameCounter - last >= IndoorProbeRateLimitFrames) - { - _lastIndoorProbeFrame[cellId] = _indoorProbeFrameCounter; - return true; - } - return false; - } - // Diagnostic counters logged once per ~5s under ACDREAM_WB_DIAG=1. private int _entitiesSeen; private int _entitiesDrawn; @@ -947,16 +922,6 @@ public sealed partial class WbDrawDispatcher : IDisposable /// list. reuses a per-dispatcher scratch field across frames to /// avoid the 480+ KB / frame GC pressure that the test-friendly overload incurs. /// Returns walk count via 's EntitiesWalked field. - /// - /// - /// When is non-null the method emits - /// [indoor-cull] lines for cell entities rejected by the - /// visibleCellIds or frustum filters, and [indoor-walk] lines for - /// cell entities that pass all filters. Rate-limited by - /// . Pass (the default) - /// to disable all probe emission — used by the test-friendly - /// overload. - /// /// internal static void WalkEntitiesInto( IEnumerable landblockEntries, @@ -966,7 +931,6 @@ public sealed partial class WbDrawDispatcher : IDisposable HashSet? animatedEntityIds, List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> scratch, ref WalkResult result, - IndoorProbeState? indoorProbeState = null, EntitySet set = EntitySet.All) { scratch.Clear(); @@ -1015,13 +979,6 @@ public sealed partial class WbDrawDispatcher : IDisposable if (!EntityMatchesSet(entity, set)) continue; if (entity.MeshRefs.Count == 0) continue; - // Detect cell entity for indoor probes — first MeshRef.GfxObjId - // is an EnvCell id (low 16 bits ≥ 0x0100). Cheap to compute; - // result reused for all probe checks below. - ulong cellProbeId = (ulong)entity.MeshRefs[0].GfxObjId; - bool isCellEntity = indoorProbeState is not null - && RenderingDiagnostics.IsEnvCellId(cellProbeId); - bool shellScoped = IsShellScopedSet(set) && entity.IsBuildingShell && visibleCellIds is not null; @@ -1029,14 +986,6 @@ public sealed partial class WbDrawDispatcher : IDisposable if (!cellInVis) { if (shellScoped) result.BuildingShellAnchorReject++; - if (isCellEntity && RenderingDiagnostics.ProbeIndoorCullEnabled - && indoorProbeState!.ShouldEmit(cellProbeId)) - { - Console.WriteLine( - $"[indoor-cull] cellEnt=0x{entity.Id:X8} " + - $"reason=visibleCellIds-miss " + - $"parentCell=0x{entity.ParentCellId!.Value:X8}"); - } continue; } if (shellScoped) result.BuildingShellAnchorPass++; @@ -1054,31 +1003,9 @@ public sealed partial class WbDrawDispatcher : IDisposable if (!aabbVisible) { - if (isCellEntity && RenderingDiagnostics.ProbeIndoorCullEnabled - && indoorProbeState!.ShouldEmit(cellProbeId)) - { - Console.WriteLine( - $"[indoor-cull] cellEnt=0x{entity.Id:X8} " + - $"reason=frustum " + - $"aabbMin=({entity.AabbMin.X:F1},{entity.AabbMin.Y:F1},{entity.AabbMin.Z:F1}) " + - $"aabbMax=({entity.AabbMax.X:F1},{entity.AabbMax.Y:F1},{entity.AabbMax.Z:F1})"); - } continue; } - // Passed all filters — emit walk probe. - if (isCellEntity && RenderingDiagnostics.ProbeIndoorWalkEnabled - && indoorProbeState!.ShouldEmit(cellProbeId)) - { - Console.WriteLine( - $"[indoor-walk] cellEnt=0x{entity.Id:X8} " + - $"pos=({entity.Position.X:F1},{entity.Position.Y:F1},{entity.Position.Z:F1}) " + - $"parentCell=0x{(entity.ParentCellId ?? 0u):X8} " + - $"meshRef0=0x{cellProbeId:X8} " + - $"meshRefCount={entity.MeshRefs.Count} " + - $"landblockVisible=true aabbVisible=true cellInVis=true"); - } - result.EntitiesWalked++; for (int i = 0; i < entity.MeshRefs.Count; i++) scratch.Add((entity, i, entry.LandblockId)); @@ -1196,23 +1123,6 @@ public sealed partial class WbDrawDispatcher : IDisposable // that populates _walkScratch (a per-dispatcher field reused across frames) // instead of allocating a fresh List<(WorldEntity, int)> per frame. // - // Pass an IndoorProbeState when any indoor probe is active so the static - // WalkEntitiesInto can emit rate-limited [indoor-cull] / [indoor-walk] - // lines without needing access to instance fields. Null = probes off. - IndoorProbeState? probeState = null; - if (RenderingDiagnostics.ProbeIndoorCullEnabled || RenderingDiagnostics.ProbeIndoorWalkEnabled) - { - // _currentFrame is snapped at construction time. Construct - // once per Draw() call only — a second construction within - // the same frame would stamp the dictionary with the - // (already-advanced) counter value, suppressing the second - // pass's emissions for IndoorProbeRateLimitFrames frames. - // Today Draw() is called exactly once per frame; if a - // future refactor adds a shadow / reflection / second pass, - // this assumption needs revisiting. - probeState = new IndoorProbeState(_lastIndoorProbeFrame, _indoorProbeFrameCounter); - } - var walkResult = default(WalkResult); WalkEntitiesInto( ToEntries(landblockEntries), @@ -1222,7 +1132,6 @@ public sealed partial class WbDrawDispatcher : IDisposable animatedEntityIds, _walkScratch, ref walkResult, - probeState, set); _currentRenderSceneObserver?.ObserveDispatcherDraw( set, @@ -1358,14 +1267,6 @@ public sealed partial class WbDrawDispatcher : IDisposable ? new Vector2(lighting.Luminosity, lighting.Diffuse) : new Vector2(0f, 1f); - // #176 seam-draw probe: any entity parented to a target cell reports - // its position + light set (a floor-coincident static/plate would be - // the z-fight's second draw; the player entity is the positive - // control). Before the culled-continue, like the dump above. - if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeSeamDrawEnabled - && entity.ParentCell is { } seamPc - && AcDream.Core.Rendering.RenderingDiagnostics.SeamDrawTargetCells.Contains(seamPc)) - MaybeEmitSeamEnt(entity); } prevTupleEntityId = entity.Id; @@ -1468,41 +1369,6 @@ public sealed partial class WbDrawDispatcher : IDisposable var renderData = _meshAdapter.TryGetRenderData(gfxObjId); - // [indoor-lookup] probe — emit once per cell entity per sec. - // Fires BEFORE the null-renderData early-continue so a miss still - // emits hit=false, distinguishing H2 (empty batches) from H6 - // (dispatcher fails to traverse Setup). - ulong lookupCellId = (ulong)gfxObjId; - if (RenderingDiagnostics.IsEnvCellId(lookupCellId) - && RenderingDiagnostics.ProbeIndoorLookupEnabled - // Rate-limit in a separate namespace from [indoor-walk]/[indoor-cull] - // (which key on the same gfxObjId). Without this, IndoorAll=1 would - // silence the lookup probe whenever the walk probe fired first. - && ShouldEmitIndoorProbe(lookupCellId | 0x8000_0000_0000_0000UL)) - { - bool hit = renderData is not null; - bool isSetup = hit && renderData!.IsSetup; - int partCount = isSetup ? renderData!.SetupParts.Count : 0; - - int partsHit = 0, partsMiss = 0; - if (isSetup) - { - foreach (var (partId, _) in renderData!.SetupParts) - { - if (_meshAdapter.TryGetRenderData(partId) is not null) partsHit++; - else partsMiss++; - } - } - - bool hasEnvCellGeom = isSetup - && renderData!.SetupParts.Exists(t => (t.GfxObjId & 0x1_0000_0000UL) != 0); - - Console.WriteLine( - $"[indoor-lookup] cellId=0x{lookupCellId:X8} " + - $"hit={hit} isSetup={isSetup} partCount={partCount} " + - $"hasEnvCellGeom={hasEnvCellGeom} partsHit={partsHit} partsMiss={partsMiss}"); - } - if (renderData is null) { // Tier 1 cache (#53): mesh data is still async-decoding via @@ -1595,23 +1461,6 @@ public sealed partial class WbDrawDispatcher : IDisposable var model = ComposePartWorldMatrix( entityWorld, meshRef.PartTransform, partTransform); - // [indoor-xform] probe — only for the cell's synthetic - // geometry part (bit 32 set, per WB's PrepareEnvCellMeshData - // cellGeomId convention). One line per part per sec. - // Disambiguates hypothesis H5 (transform double-apply — - // composedT lands at 2 × cellOrigin). - if ((partGfxObjId & 0x1_0000_0000UL) != 0 - && RenderingDiagnostics.ProbeIndoorXformEnabled - && ShouldEmitIndoorProbe(partGfxObjId)) - { - Console.WriteLine( - $"[indoor-xform] cellGeomId=0x{partGfxObjId:X16} " + - $"entityWorldT=({entityWorld.Translation.X:F2},{entityWorld.Translation.Y:F2},{entityWorld.Translation.Z:F2}) " + - $"meshRefT=({meshRef.PartTransform.Translation.X:F2},{meshRef.PartTransform.Translation.Y:F2},{meshRef.PartTransform.Translation.Z:F2}) " + - $"partT=({partTransform.Translation.X:F2},{partTransform.Translation.Y:F2},{partTransform.Translation.Z:F2}) " + - $"composedT=({model.Translation.X:F2},{model.Translation.Y:F2},{model.Translation.Z:F2})"); - } - var restPose = partTransform * meshRef.PartTransform; // #188 retail CPhysicsPart::Draw (0x0050d7a0) early-out: once a @@ -1718,11 +1567,6 @@ public sealed partial class WbDrawDispatcher : IDisposable populateEntityId, populateLandblockId, _cache, _populateScratch, _populateSelectionScratch); - // S3 review fix round 1 (F5): the §4 flap [clip-route-disp] probe - // (2026-06-10, throwaway) is deleted with the routing state it - // reported on — it could only ever print "cullEnt=0" now that - // ResolveSlotForFrame never culls anything. - ExecuteClassifiedGroups( vp, camPos, @@ -1777,7 +1621,6 @@ public sealed partial class WbDrawDispatcher : IDisposable out Vector3 cameraWorldPosition) { _selectionLighting?.TickLighting(); - _indoorProbeFrameCounter++; viewProjection = camera.View * camera.Projection; _missRequested.Clear(); @@ -3085,44 +2928,6 @@ public sealed partial class WbDrawDispatcher : IDisposable /// (AP-43) and docs/research/2026-06-19-lighting-a7-fixD-round2-*. /// /// - // #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 _seamEntSigs = new(); - - private void MaybeEmitSeamEnt(in RenderInstanceCandidate entity) - { - var ci = System.Globalization.CultureInfo.InvariantCulture; - var snap = _pointSnapshot; - var sb = new System.Text.StringBuilder(200); - sb.AppendFormat(ci, - "guid=0x{0:X8} cell=0x{1:X8} pos=({2:F2},{3:F2},{4:F3}) culled={5} slot={6} indoor={7} L=[", - entity.ServerGuid, entity.ParentCellId, - entity.Position.X, entity.Position.Y, entity.Position.Z, - _currentEntityCulled ? 1 : 0, _currentEntitySlot, _currentEntityIndoor ? 1 : 0); - bool any = false; - for (int k = 0; k < LightManager.MaxLightsPerObject; k++) - { - int idx = _currentEntityLightSet[k]; - if (idx < 0) continue; - if (any) sb.Append(','); - if (snap is not null && idx < snap.Count) - sb.AppendFormat(ci, "{0:X4}:I{1:F0}", snap[idx].CellId & 0xFFFFu, snap[idx].Intensity); - else - sb.Append('?').Append(idx); - any = true; - } - sb.Append(']'); - - string sig = sb.ToString(); - if (_seamEntSigs.TryGetValue(entity.Id, out var prev) && prev == sig) return; - _seamEntSigs[entity.Id] = sig; - Console.WriteLine($"[seam-ent] t={Environment.TickCount64} {sig}"); - } - private void ComputeEntityLightSet( in RenderInstanceCandidate entity) { @@ -3632,41 +3437,6 @@ public sealed partial class WbDrawDispatcher : IDisposable // ──────────────────────────────────────────────────────────────────────── - /// - /// Thin wrapper around an instance's rate-limit dictionary + frame - /// counter, passed into the static - /// overload so it can emit rate-limited probe lines without access - /// to instance fields. Null = probes disabled (test-friendly overload). - /// - internal sealed class IndoorProbeState - { - private readonly Dictionary _lastFrame; - private readonly int _currentFrame; - private const int RateLimit = IndoorProbeRateLimitFrames; - - internal IndoorProbeState(Dictionary lastFrame, int currentFrame) - { - _lastFrame = lastFrame; - _currentFrame = currentFrame; - } - - /// - /// Returns true at most once per - /// frames per . Side-effect: stamps the frame - /// number into the dictionary on success. - /// - internal bool ShouldEmit(ulong cellId) - { - if (!_lastFrame.TryGetValue(cellId, out int last) - || _currentFrame - last >= RateLimit) - { - _lastFrame[cellId] = _currentFrame; - return true; - } - return false; - } - } - internal sealed class InstanceGroup { // Nonzero only while this exact object is registered in _groups. diff --git a/src/AcDream.App/Rendering/WorldPassSurface.cs b/src/AcDream.App/Rendering/WorldPassSurface.cs index fd671293..5a421d50 100644 --- a/src/AcDream.App/Rendering/WorldPassSurface.cs +++ b/src/AcDream.App/Rendering/WorldPassSurface.cs @@ -13,7 +13,6 @@ internal interface IRenderFrameGlState { void RestoreFrameDefaults(); } - /// /// Campaign V slice V6j: the small graphics surface the two world pass executors /// touch directly, expressed once so their ordering logic — which is retail's, @@ -186,25 +185,3 @@ internal sealed class NullRenderFrameGlState : IRenderFrameGlState { } } - -/// -/// Campaign V slice V6j: the GL state reader on a backend with no GL state. -/// -/// WorldRenderDiagnostics 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. -/// -internal sealed class NullRenderGlStateReader : IRenderGlStateReader -{ - public static NullRenderGlStateReader Instance { get; } = new(); - - private NullRenderGlStateReader() - { - } - - public RenderGlStateSnapshot CaptureState() => default; - - public RenderGlScissorSnapshot CaptureScissor() => default; -} diff --git a/src/AcDream.App/Rendering/WorldRenderDiagnostics.cs b/src/AcDream.App/Rendering/WorldRenderDiagnostics.cs index 079ff766..337bd612 100644 --- a/src/AcDream.App/Rendering/WorldRenderDiagnostics.cs +++ b/src/AcDream.App/Rendering/WorldRenderDiagnostics.cs @@ -1,79 +1,22 @@ -using System.Numerics; using System.Diagnostics; -using System.Text; -using AcDream.Core.Vfx; -using AcDream.Core.World; namespace AcDream.App.Rendering; -internal readonly record struct IntRenderRectangle(int X, int Y, int Width, int Height); - -internal readonly record struct RenderGlStateSnapshot( - bool DepthTest, - bool DepthWrite, - int DepthFunction, - bool Blend, - int BlendSource, - int BlendDestination, - bool CullFace, - int CullMode, - int FrontFace, - bool Scissor, - IntRenderRectangle ScissorBox, - IntRenderRectangle Viewport, - int DrawFramebuffer, - bool AlphaToCoverage, - bool Stencil, - int ClipBits, - int Error); - -internal readonly record struct RenderGlScissorSnapshot( - bool Enabled, - IntRenderRectangle Box); - internal readonly record struct TerrainRenderDiagnosticFacts( int VisibleSlots, int Draws, int LoadedSlots, int CapacitySlots); -internal interface IRenderGlStateReader -{ - RenderGlStateSnapshot CaptureState(); - - RenderGlScissorSnapshot CaptureScissor(); -} - -/// -/// 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. -/// +/// Owns permanent terrain timing and diagnostic publication. internal sealed class WorldRenderDiagnostics { - private readonly IRenderGlStateReader _gl; private readonly IRenderFrameDiagnosticLog _log; private readonly Stopwatch _terrainStopwatch = new(); private readonly RollingTimingSampleWindow _terrainSamples = new(256); - private string? _lastRenderSignature; - private int _renderSignatureFrame; - private int _renderSignatureStableFrames; - private string? _lastGlStateSignature; - private long _glStateFrame; - private long _glStateStableFrames; - private string? _lastPostWorldGlStateSignature; - private long _postWorldGlStateFrame; - private long _postWorldGlStateStableFrames; - private string? _lastScissorSignature; - private long _scissorSequence; - private string? _lastClipRouteSignature; - private long _clipRouteSequence; - private readonly List _clipRouteCellKeys = []; - public WorldRenderDiagnostics( - IRenderGlStateReader gl, - IRenderFrameDiagnosticLog log) + public WorldRenderDiagnostics(IRenderFrameDiagnosticLog log) { - _gl = gl ?? throw new ArgumentNullException(nameof(gl)); _log = log ?? throw new ArgumentNullException(nameof(log)); } @@ -86,12 +29,6 @@ internal sealed class WorldRenderDiagnostics (long)(_terrainStopwatch.Elapsed.TotalMicroseconds * 100.0)); } - /// S3 chunk 3 fix round 1 (F3): the walk path's per-frame - /// analogue of — 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. public void PushTerrainSample(long elapsedHundredthsMicroseconds) => _terrainSamples.PushHundredthsMicroseconds(elapsedHundredthsMicroseconds); @@ -107,442 +44,4 @@ internal sealed class WorldRenderDiagnostics + $"visible={facts.VisibleSlots} loaded={facts.LoadedSlots} " + $"capacity={facts.CapacitySlots}"); } - - public void EmitGlStateTripwireIfChanged(bool enabled) - { - if (!enabled) - return; - - _glStateFrame++; - string signature = FormatGlState(_gl.CaptureState()); - if (signature == _lastGlStateSignature) - { - _glStateStableFrames++; - return; - } - - _log.WriteLine( - $"[gl-state] frame={_glStateFrame} stable={_glStateStableFrames} {signature}"); - _lastGlStateSignature = signature; - _glStateStableFrames = 0; - } - - /// - /// Second sample of the same snapshot, taken at the END of the normal-world - /// phase instead of at the frame clear. - /// - /// - /// samples immediately after the - /// clear phase has run RestoreFrameDefaults, 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. - /// - 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 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 targetCells, - uint cellId, - int portalIndex, - bool forceFarZ, - ReadOnlySpan 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 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? visibleCells, - ClipFrameAssembly? clipAssembly, - IReadOnlySet? 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? ids, bool preserveOrder) - { - if (ids is null) - return "[]"; - - var values = new List(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? visibleCells, - IReadOnlySet? 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(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(); - } } diff --git a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs index d37aebcd..552b90a1 100644 --- a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs +++ b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs @@ -718,11 +718,6 @@ internal sealed class RuntimeWorldFrameBuildingSource : IWorldFrameBuildingSourc if (viewerRoot is null) outdoorNode = OutdoorCellNode.Build(viewerCellId); - if (RenderingDiagnostics.ProbeFlapEnabled) - { - Console.WriteLine(FormattableString.Invariant( - $"[outdoor-node] cell=0x{viewerCellId:X8} root={(viewerRoot is null ? "OUT" : "IN")} nearbyCells={_scratch.Count} (T2 frustum-gated per-building floods)")); - } return new WorldBuildingFrame(outdoorNode, _scratch); } diff --git a/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs b/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs index 41fb229c..d2e65a80 100644 --- a/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs +++ b/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs @@ -13,36 +13,6 @@ internal interface IWorldSceneDiagnostics { CameraCellResolution CameraCellResolution { get; } - void EmitPViewInput( - IReadOnlySet visibleCells, - int outsideViewCount, - Matrix4x4 viewProjection, - LoadedCell clipRoot, - Vector3 cameraPosition, - Vector3 playerPosition); - - void EmitRenderSignature( - string branch, - LoadedCell? clipRoot, - LoadedCell? viewerRoot, - LoadedCell? playerRoot, - uint viewerCellId, - uint playerCellId, - bool playerIndoorGate, - bool cameraInsideCell, - bool renderSky, - bool drawSkyThisFrame, - bool terrainDrawn, - TerrainClipMode terrainClipMode, - bool skyDrawn, - bool depthClear, - bool outdoorSceneryDrawn, - int liveDynamicDrawnCount, - string sceneParticles, - RetailPViewFrameResult? pviewResult, - Vector3 cameraPosition, - Vector3 playerPosition); - WorldSceneDiagnosticOutcome DrawAndPublish( in WorldCameraFrame camera, IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> bounds); @@ -55,7 +25,6 @@ internal interface IWorldSceneDiagnostics /// internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics { - private readonly WorldRenderDiagnostics _diagnostics; private readonly IWorldScenePViewDiagnosticSource _pview; private readonly IWorldSceneDebugStateSource _state; private readonly DebugLineRenderer? _lines; @@ -67,7 +36,6 @@ internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics private int _debugDrawLogCount; public WorldSceneDiagnosticsController( - WorldRenderDiagnostics diagnostics, IWorldScenePViewDiagnosticSource pview, IWorldSceneDebugStateSource state, DebugLineRenderer? lines, @@ -77,7 +45,6 @@ internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics DebugVmRenderFactsPublisher debugVm, bool debugVmConsumerActive) { - _diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics)); _pview = pview ?? throw new ArgumentNullException(nameof(pview)); _state = state ?? throw new ArgumentNullException(nameof(state)); _lines = lines; @@ -90,89 +57,11 @@ internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics public CameraCellResolution CameraCellResolution => _pview.CameraCellResolution; - public void EmitPViewInput( - IReadOnlySet visibleCells, - int outsideViewCount, - Matrix4x4 viewProjection, - LoadedCell clipRoot, - Vector3 cameraPosition, - Vector3 playerPosition) - { - if (!RenderingDiagnostics.ProbePvInputEnabled) - return; - - _diagnostics.EmitPViewInput( - enabled: true, - visibleCells, - outsideViewCount, - viewProjection, - clipRoot.IsOutdoorNode, - cameraPosition, - playerPosition, - _pview.RawPlayerPositionOr(playerPosition), - _pview.PlayerYaw, - _pview.SampleTerrainZ(cameraPosition.X, cameraPosition.Y)); - } - - public void EmitRenderSignature( - string branch, - LoadedCell? clipRoot, - LoadedCell? viewerRoot, - LoadedCell? playerRoot, - uint viewerCellId, - uint playerCellId, - bool playerIndoorGate, - bool cameraInsideCell, - bool renderSky, - bool drawSkyThisFrame, - bool terrainDrawn, - TerrainClipMode terrainClipMode, - bool skyDrawn, - bool depthClear, - bool outdoorSceneryDrawn, - int liveDynamicDrawnCount, - string sceneParticles, - RetailPViewFrameResult? pviewResult, - Vector3 cameraPosition, - Vector3 playerPosition) - { - _diagnostics.EmitRenderSignatureIfChanged( - RenderingDiagnostics.ProbeFlapEnabled, - branch, - clipRoot, - viewerRoot, - playerRoot, - viewerCellId, - playerCellId, - playerIndoorGate, - cameraInsideCell, - renderSky, - drawSkyThisFrame, - terrainDrawn, - terrainClipMode, - skyDrawn, - depthClear, - outdoorSceneryDrawn, - liveDynamicDrawnCount, - sceneParticles, - pviewResult?.VisibleCells, - pviewResult?.ClipAssembly, - pviewResult?.DrawableCells, - pviewResult?.DiagnosticPartition, - cameraPosition, - playerPosition); - } - public WorldSceneDiagnosticOutcome DrawAndPublish( in WorldCameraFrame camera, IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> bounds) { ArgumentNullException.ThrowIfNull(bounds); - // Brackets the normal-world phase against the clear-phase tripwire: any - // GL state the world's geometry drew under but the next frame's clear - // no longer sees shows up as a difference between the two lines. - _diagnostics.EmitPostWorldGlStateIfChanged( - AcDream.Core.Rendering.RenderingDiagnostics.ProbeGlStateEnabled); DrawCollisionWireframes(in camera); int visible = 0; diff --git a/src/AcDream.App/Rendering/WorldScenePassExecutor.cs b/src/AcDream.App/Rendering/WorldScenePassExecutor.cs index 25a0704e..e8c3e3f6 100644 --- a/src/AcDream.App/Rendering/WorldScenePassExecutor.cs +++ b/src/AcDream.App/Rendering/WorldScenePassExecutor.cs @@ -29,11 +29,10 @@ internal interface IWorldScenePassExecutor uint? playerLandblockId, HashSet animatedEntityIds); - string DrawPostWorldParticles( + void DrawPostWorldParticles( LoadedCell? clipRoot, ClipFrameAssembly? clipAssembly, - in WorldCameraFrame camera, - string currentSignature); + in WorldCameraFrame camera); void DrawFlatWeather( in WorldCameraFrame camera, @@ -182,14 +181,13 @@ internal sealed class WorldScenePassExecutor : IWorldScenePassExecutor visibleCellIds: null, animatedEntityIds: animatedEntityIds); - public string DrawPostWorldParticles( + public void DrawPostWorldParticles( LoadedCell? clipRoot, ClipFrameAssembly? clipAssembly, - in WorldCameraFrame camera, - string currentSignature) + in WorldCameraFrame camera) { if (_particles is null || _particleRenderer is null) - return currentSignature; + return; if (clipRoot is null) { @@ -202,21 +200,21 @@ internal sealed class WorldScenePassExecutor : IWorldScenePassExecutor _visibleParticleOwners, includeUnattached: true, excludedAttachedOwnerIds: _noExcludedParticleOwners); - return AppendSignature(currentSignature, "filtered"); + return; } _particleRenderer.Draw( camera.Camera, camera.Position, ParticleRenderPass.Scene); - return AppendSignature(currentSignature, "global"); + return; } // Every PView root, including the outdoor sentinel, now submits scene // particles inside LScape::draw. Replaying them here is both a duplicate // and too late: it occurs after nested building cells, allowing exterior // waterfall/foliage alpha to repaint an indoor/outdoor transition. - return currentSignature; + return; } public void DrawFlatWeather( @@ -291,6 +289,4 @@ internal sealed class WorldScenePassExecutor : IWorldScenePassExecutor } } - private static string AppendSignature(string current, string value) => - current == "none" ? value : current + "+" + value; } diff --git a/src/AcDream.App/Rendering/WorldSceneRenderer.cs b/src/AcDream.App/Rendering/WorldSceneRenderer.cs index c66b3788..30a931e2 100644 --- a/src/AcDream.App/Rendering/WorldSceneRenderer.cs +++ b/src/AcDream.App/Rendering/WorldSceneRenderer.cs @@ -176,23 +176,15 @@ internal sealed class WorldSceneRenderer : IPreparedWorldSceneFramePhase WorldCameraFrame camera = world.Camera; WorldRootFrame roots = world.Roots; - LoadedCell? clipRoot = world.ClipRoot; - bool renderSky = roots.RenderSky; - bool drawSkyThisFrame = false; - bool terrainDrawn = false; - bool skyDrawn = false; - bool depthClear = false; - bool outdoorSceneryDrawn = false; - int liveDynamicDrawnCount = 0; - string sceneParticles = "none"; - TerrainClipMode terrainClipMode = TerrainClipMode.Planes; - RetailPViewFrameResult? pviewResult = null; + LoadedCell? clipRoot = world.ClipRoot; + bool renderSky = roots.RenderSky; + bool drawSkyThisFrame = false; + RetailPViewFrameResult? pviewResult = null; if (clipRoot is null) { - _passes.PrepareFlatWorldClip(); - drawSkyThisFrame = renderSky; - skyDrawn = drawSkyThisFrame; + _passes.PrepareFlatWorldClip(); + drawSkyThisFrame = renderSky; if (drawSkyThisFrame) { _passes.DrawFlatSky( @@ -210,12 +202,7 @@ internal sealed class WorldSceneRenderer : IPreparedWorldSceneFramePhase // waiting frame. One gate computes the frame's visibility; // this phase only enforces it. _passes.DrawFlatTerrain(in camera, roots.PlayerLandblockId); - terrainDrawn = true; - } - else - { - terrainClipMode = TerrainClipMode.Skip; - } + } if (clipRoot is not null) { @@ -253,30 +240,6 @@ internal sealed class WorldSceneRenderer : IPreparedWorldSceneFramePhase // VisibleCells remains a diagnostics-only union. _particleVisibility.MarkVisibleLandscapeCells( pviewResult.VisibleLandscapeCells); - _diagnostics.EmitPViewInput( - pviewResult.VisibleCells, - pviewResult.ClipAssembly.OutsideViewSlices.Length, - camera.ViewProjection, - clipRoot, - camera.Position, - roots.PlayerViewPosition); - - bool hasOutsideSlice = pviewResult.ClipAssembly.OutsideViewSlices.Length > 0; - terrainDrawn = hasOutsideSlice; - skyDrawn = renderSky && hasOutsideSlice; - // This mirrors the established diagnostic meaning, including - // outdoor roots whose PView executor does not issue an interior - // depth clear. - depthClear = hasOutsideSlice; - RenderProjectionCounts sourceCounts = - pviewResult.SourceCounts; - if (sourceCounts.IndoorCellStatic > 0 || hasOutsideSlice) - sceneParticles = "pviewScoped"; - outdoorSceneryDrawn = - sourceCounts.OutdoorStatic > 0 - && hasOutsideSlice; - liveDynamicDrawnCount = - sourceCounts.LiveDynamicRoot; } else { @@ -288,43 +251,20 @@ internal sealed class WorldSceneRenderer : IPreparedWorldSceneFramePhase } _passes.DisableClipDistances(); - sceneParticles = _passes.DrawPostWorldParticles( - clipRoot, - pviewResult?.ClipAssembly, - in camera, - sceneParticles); + _passes.DrawPostWorldParticles( + clipRoot, + pviewResult?.ClipAssembly, + in camera); if (clipRoot is null && drawSkyThisFrame) { - skyDrawn = true; - _passes.DrawFlatWeather( + _passes.DrawFlatWeather( in camera, in foundation, _sky.ActiveDayGroup, _sky.DayFraction); } - _diagnostics.EmitRenderSignature( - clipRoot is null ? "OutdoorRoot" : "RetailPViewInside", - clipRoot, - roots.ViewerRoot, - roots.PlayerRoot, - roots.ViewerCellId, - roots.PlayerCellId, - roots.PlayerIndoorGate, - roots.CameraInsideCell, - renderSky, - drawSkyThisFrame, - terrainDrawn, - terrainClipMode, - skyDrawn, - depthClear, - outdoorSceneryDrawn, - liveDynamicDrawnCount, - sceneParticles, - pviewResult, - camera.Position, - roots.PlayerViewPosition); WorldSceneDiagnosticOutcome diagnostic = _diagnostics.DrawAndPublish( in camera, diff --git a/src/AcDream.Core/Lighting/LightManager.cs b/src/AcDream.Core/Lighting/LightManager.cs index 0ae76e15..8c0ddbbd 100644 --- a/src/AcDream.Core/Lighting/LightManager.cs +++ b/src/AcDream.Core/Lighting/LightManager.cs @@ -188,7 +188,7 @@ public sealed class LightManager /// registered lights (chase-boom swing churned the eviction boundary), then /// (2) frame-FLOOD scoping `c500912b` (gaze-dependent: the under-room portal /// purples entered/left the pool as the camera turned — the seam-floor - /// blink; probe: [seam-blk]/[seam-snap]). Current model: all registered + /// blink). Current model: all registered /// (=resident) lit lights, then dynamics-first nearest-player, capped here. /// A later last-frame drawable-cell filter was removed after the Facility Hub /// zoom trace proved it recreated the same camera-root coupling: the pool @@ -341,10 +341,6 @@ public sealed class LightManager } } - // A7.L1 SET-COMPOSITION probe. Inert unless ACDREAM_PROBE_INDOOR_LIGHT=1; - // the flag check keeps it zero-cost off. - if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeIndoorLightEnabled) - AcDream.Core.Rendering.RenderingDiagnostics.EmitIndoorLight(_all, _pointSnapshot); } private static int CompareRankedLights( diff --git a/src/AcDream.Core/Lighting/LightSource.cs b/src/AcDream.Core/Lighting/LightSource.cs index a33afb98..5ce1fb2f 100644 --- a/src/AcDream.Core/Lighting/LightSource.cs +++ b/src/AcDream.Core/Lighting/LightSource.cs @@ -63,7 +63,7 @@ public sealed class LightSource // cross-cell block-offset distance math. #176 correction (2026-07-06): it is // NOT a pool filter — retail collects from ALL resident cells // (CEnvCell::visible_cell_table = the loaded-cell registry, not the flood); - // acdream keeps the tag for probes ([indoor-light]/[seam-*]) + future parity. + // acdream keeps the tag for future parity work. public bool IsLit = true; // SetLightHook latch public bool IsDynamic; // #143: true = D3D hardware path (1/d att, range×1.5); // false = static dat-baked bake (1/d³, range×1.3) diff --git a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs index 21c47726..cd8a4ad8 100644 --- a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs +++ b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs @@ -1,612 +1,44 @@ using System.Globalization; -using System; -using System.Collections.Generic; -using System.Text; namespace AcDream.Core.Rendering; -/// -/// 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.) -/// -/// -/// Mirrors the L.2a -/// pattern. The master toggle is the user's -/// common case — flipping it cascades to all five probe flags. -/// -/// -/// -/// Spec: docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md. -/// -/// +/// Permanent rendering diagnostics and retail render-route helpers. public static class RenderingDiagnostics { /// - /// When true, WbDrawDispatcher.WalkVisibleEntities emits one - /// [indoor-walk] line per visible cell entity per second: - /// entity id, world position, parent cell id, landblock visible flag, - /// AABB-visible flag, "in visible cells" flag, drew flag. - /// Initial state from ACDREAM_PROBE_INDOOR_WALK=1. - /// - public static bool ProbeIndoorWalkEnabled { get; set; } = - Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_WALK") == "1" - || Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1"; - - /// - /// When true, WbDrawDispatcher emits one [indoor-lookup] - /// line per visible cell entity per second: render-data hit/miss, - /// IsSetup flag, SetupParts count, parts-hit / parts-miss tallies. - /// Initial state from ACDREAM_PROBE_INDOOR_LOOKUP=1. - /// - public static bool ProbeIndoorLookupEnabled { get; set; } = - Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_LOOKUP") == "1" - || Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1"; - - /// - /// When true, WbMeshAdapter emits two lines per EnvCell id: - /// [indoor-upload] requested on first IncrementRefCount and - /// [indoor-upload] completed when WB's staged drain produces - /// its ObjectMeshData. Missing "completed" lines indicate WB - /// silently returned null (hypothesis H1). - /// Initial state from ACDREAM_PROBE_INDOOR_UPLOAD=1. - /// - public static bool ProbeIndoorUploadEnabled { get; set; } = - Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_UPLOAD") == "1" - || Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1"; - - /// - /// When true, WbDrawDispatcher emits one [indoor-xform] - /// line per visible cell entity per second: cell-geometry SetupPart's - /// composed world matrix translation. Disambiguates transform - /// double-apply (hypothesis H5). - /// Initial state from ACDREAM_PROBE_INDOOR_XFORM=1. - /// - public static bool ProbeIndoorXformEnabled { get; set; } = - Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_XFORM") == "1" - || Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1"; - - /// - /// When true, WbDrawDispatcher.WalkVisibleEntities emits one - /// [indoor-cull] line per cell entity that gets culled, with - /// the reason (visibleCellIds-miss, frustum, landblock). Disambiguates - /// cull bugs (hypothesis H3). - /// Initial state from ACDREAM_PROBE_INDOOR_CULL=1. - /// - public static bool ProbeIndoorCullEnabled { get; set; } = - Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_CULL") == "1" - || Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1"; - - /// - /// When true, the unified portal-visibility pass emits one [vis] - /// line whenever the camera's root cell CHANGES (see ): - /// 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 ACDREAM_PROBE_VIS=1. - /// - /// 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). - /// - /// - public static bool ProbeVisibilityEnabled { get; set; } = - Environment.GetEnvironmentVariable("ACDREAM_PROBE_VIS") == "1"; - - /// - /// 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 - /// ACDREAM_PROBE_FACILITY_STAIRS=1. - /// - public static bool ProbeFacilityStairsEnabled { get; set; } = - Environment.GetEnvironmentVariable("ACDREAM_PROBE_FACILITY_STAIRS") == "1"; - - /// - /// 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 - /// [flap] 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 [flap-cam] line (FindCameraCell resolution reason, - /// camera EYE worldpos, player worldpos, eye-in-root-AABB flag). Unlike the - /// cell-change-throttled 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 ACDREAM_PROBE_FLAP=1. - /// - public static bool ProbeFlapEnabled { get; set; } = - Environment.GetEnvironmentVariable("ACDREAM_PROBE_FLAP") == "1"; - - /// - /// Flap root-cause apparatus (2026-06-07). When true, the indoor render path emits ONE - /// [pv-input] 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 [flap]/[render-sig] - /// spam so the log stays diffable. Throwaway apparatus — strip once the jitter source is pinned. - /// Initial state from ACDREAM_PROBE_PVINPUT=1. - /// - public static bool ProbePvInputEnabled { get; set; } = - Environment.GetEnvironmentVariable("ACDREAM_PROBE_PVINPUT") == "1"; - - /// - /// §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 [gl-state] 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 ACDREAM_PROBE_GLSTATE=1. - /// - public static bool ProbeGlStateEnabled { get; set; } = - Environment.GetEnvironmentVariable("ACDREAM_PROBE_GLSTATE") == "1"; - - /// - /// §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 [clip-route] 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 [clip-route-disp] 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 [clip-route-scis] 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 ACDREAM_PROBE_CLIPROUTE=1. - /// - public static bool ProbeClipRouteEnabled { get; set; } = - Environment.GetEnvironmentVariable("ACDREAM_PROBE_CLIPROUTE") == "1"; - - /// - /// 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. - /// - public static bool ProbePortalChurnEnabled { get; set; } = - Environment.GetEnvironmentVariable("ACDREAM_PROBE_PORTAL_CHURN") == "1"; - - /// - /// A7.L1 (2026-07-06) light-pool SET-COMPOSITION probe — the apparatus the - /// [light] counts could not provide (the #176/#177 discriminator: the bug - /// lived in set MEMBERSHIP, not counts). When true, - /// LightManager.BuildPointLightSnapshot emits ONE rate-limited - /// [indoor-light] line describing the point-light pool - /// (see ): - /// - /// [indoor-light] pool=<M> cellLess=<K> registered=<R> capped=<R-M> - /// byCell=[0x<id>:<count>,...] - /// - /// #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, - /// c500912b) 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. - /// byCell now describes the resident pool; cellLess==pool in a - /// fixture-rich room still - /// means cell tagging FAILED (ParentCellId not flowing). - /// Output-only, inert when off. Initial state from ACDREAM_PROBE_INDOOR_LIGHT=1. - /// - public static bool ProbeIndoorLightEnabled { get; set; } = - Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_LIGHT") == "1"; - - /// - /// #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): - /// - /// [seam-cell]EnvCellRenderer.Render - /// (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. - /// [seam-snap] — 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. - /// [seam-ent]WbDrawDispatcher: 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. - /// [seam-mask]RetailPViewPassExecutor.DrawPortalDepthWrite: - /// 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. - /// - /// Value 1 = 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 - /// ACDREAM_PROBE_SEAMDRAW. - /// - public static bool ProbeSeamDrawEnabled { get; set; } = - !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("ACDREAM_PROBE_SEAMDRAW")); - - /// Target cell ids for the #176 seam-draw probe (see - /// ). Full 32-bit cell ids. - public static IReadOnlySet SeamDrawTargetCells { get; } = - ParseSeamDrawTargets(Environment.GetEnvironmentVariable("ACDREAM_PROBE_SEAMDRAW")); - - /// - /// #176 stripe-hunt shader isolation mode (ACDREAM_LIGHT_DEBUG) — - /// throwaway diagnostic, uploaded as uLightDebug by EnvCellRenderer + - /// WbDrawDispatcher each pass. 0 = off; 1 = ambient-only vertex lighting - /// (all point/sun contributions killed); 2 = DYNAMIC point lights killed - /// (the intensity-100 portal purples + the viewer fill off; statics stay); - /// 3 = raw vLit visualization in the fragment shader (texture ignored). - /// Discriminates lighting-driven stripes (gone at 1/2, visible in the field - /// at 3) from texture/per-pixel machinery (survive 1). Settable at - /// runtime by direct assignment. + /// #176 stripe-hunt shader isolation mode. Zero disables the override; + /// values 1-3 retain the established lighting diagnostic modes. /// public static int LightDebugMode { get; set; } = int.TryParse( Environment.GetEnvironmentVariable("ACDREAM_LIGHT_DEBUG"), NumberStyles.Integer, CultureInfo.InvariantCulture, - out int ldm) - ? ldm + out int mode) + ? mode : 0; - // S3 review fix round 1 (F5): ClipDebugNoShellTrim (ACDREAM_CLIP_DEBUG) - // is deleted — the #176 stripe-hunt isolation it toggled between "clip - // shells to their cell's portal-slice region" and "map every instance to - // slot 0" is moot now that EnvCellRenderer's per-cell clip routing is - // deleted outright (its arming method never had a live caller): shells - // always draw WHOLE, unconditionally, the same behavior this flag used - // to force. - - /// 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 ). Internal for unit tests. - internal static IReadOnlySet 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 - { - 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; - - /// - /// Emit ONE concise, information-dense [vis] line for the portal- - /// visibility frame, but only when is - /// true AND differs from the last root the - /// probe reported (cell-change gating). Cheap no-op otherwise. - /// - /// Decoupled by design: the OutsideView is passed as pre-computed - /// + - /// primitives rather than the App-layer CellView/ClipPlaneSet - /// types, because this owner lives in AcDream.Core and Core must not - /// reference the App project (Code Structure Rule 2). The U.4a call site - /// supplies OutsideView.Polygons.Count and the OutsideView's - /// ClipPlaneSet.Count. - /// - /// - /// The camera's root cell id (the BFS seed). - /// Ordered visible cell ids for this frame. - /// Polygon count of the single OutsideView region. - /// Clip-plane count the OutsideView reduced to (0 ⇒ scissor/empty). - /// Per-cell clip-plane count (cell id → plane count). - /// Number of regions that fell back to a scissor AABB this frame. - public static void EmitVis(uint rootCellId, - IReadOnlyList visibleCells, - int outsidePolyCount, - int outsidePlaneCount, - IReadOnlyDictionary 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()); - } - - /// - /// Reset the 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. - /// - 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; - - /// - /// A7.L1 — emit ONE rate-limited [indoor-light] line describing the - /// point-light pool: the SET COMPOSITION the [light] counts can't show. - /// Cheap no-op when is false; otherwise - /// fires at most once per second. Called from - /// LightManager.BuildPointLightSnapshot after resident collection and - /// the bounded player-nearest selection. - /// - /// Every registered light (LightManager._all). - /// The point-light pool just built. - public static void EmitIndoorLight( - IReadOnlyList allRegistered, - IReadOnlyList 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(); - 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"; - - /// - /// Phase A8 Task 9 (2026-05-28): when true, the indoor EnvCell draw path's - /// [envcells] probe emits one line per indoor frame — - /// CellsRendered / TrianglesDrawn from EnvCellRenderer.Stats + - /// ourBldgs/otherBldgs/filterCnt. - /// Also enabled implicitly when is true. - /// Initial state from ACDREAM_PROBE_ENVCELL=1. - /// (The two-pipe RenderInsideOutAcdream pass that originally owned - /// this probe was removed in Phase U.1; the env var + the - /// EnvCellRenderer.Stats source remain.) - /// - public static bool ProbeEnvCellEnabled - { - get => _probeEnvCellEnabled || ProbeVisibilityEnabled; - set => _probeEnvCellEnabled = value; - } - - /// - /// Master toggle. Reading reflects the AND of all five flags - /// (true only when every probe is on). Writing cascades — setting - /// to turns ALL five flags on; setting to - /// turns ALL five off. - /// - public static bool IndoorAll - { - get => ProbeIndoorWalkEnabled - && ProbeIndoorLookupEnabled - && ProbeIndoorUploadEnabled - && ProbeIndoorXformEnabled - && ProbeIndoorCullEnabled; - set - { - ProbeIndoorWalkEnabled = value; - ProbeIndoorLookupEnabled = value; - ProbeIndoorUploadEnabled = value; - ProbeIndoorXformEnabled = value; - ProbeIndoorCullEnabled = value; - } - } - - /// - /// Helper for probe call sites. Returns when - /// the low 16 bits of are ≥ 0x0100 — the AC - /// convention for EnvCell (indoor) cells, as opposed to outdoor cells - /// in the 8×8 landblock grid (0x0001–0x0040). - /// + /// Returns true for AC indoor EnvCell ids. public static bool IsEnvCellId(ulong id) => (id & 0xFFFFu) >= 0x0100u; /// - /// Parse the ACDREAM_DUMP_ENTITY value: comma-separated hex ids, - /// optional 0x prefix, whitespace tolerated, malformed segments ignored - /// (probes are forgiving — a typo'd segment must not take the launch down). - /// Internal for unit tests. - /// - internal static IReadOnlySet ParseDumpEntityIds(string? raw) - { - var set = new HashSet(); - 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; - } - - /// - /// The top-level render branch: should this frame run the indoor (DrawInside) path? - /// - /// Retail SmartBox::RenderNormalMode (0x453aa0, pc:92665) branches - /// DrawInside vs the outdoor LScape::draw on is_player_outside — the - /// PLAYER's cell ((player->m_position.objcell_id & 0xFFFF) < 0x100, - /// SmartBox::is_player_outside 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. - /// - /// acdream historically branched on the camera cell (a non-null - /// visibility.CameraCell). 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. - /// - /// The player's current cell id (0 if unresolved → outside). - /// Whether the player's indoor render root is loaded and - /// available to DrawInside. + /// Retail chooses the indoor path from the player's cell, provided the + /// transition-owned render root is available. /// public static bool ShouldRenderIndoor(uint playerCellId, bool renderRootResolved) => renderRootResolved && IsEnvCellId(playerCellId); - /// - /// MP0 (2026-07-05) — master toggle for the permanent frame profiler - /// (AcDream.App.Diagnostics.FrameProfiler): CPU frame time - /// (swap-to-swap), whole-frame GPU time, per-stage CPU attribution, - /// per-frame allocation counters, reported as one [frame-prof] - /// 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 ACDREAM_WB_DIAG=1; - /// Campaign V slice V11 deleted that self-disable along with the GL query - /// ring it protected, and the two flags are now independent — see - /// FrameProfiler's own class doc. Every backend reports GPU time - /// through FrameProfiler.RecordGpuSample. - /// Initial state from ACDREAM_FRAME_PROF=1; runtime-toggleable - /// by direct assignment (its DebugPanel mirror is gone — #434). - /// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §5. - /// + /// Permanent frame-profiler toggle. public static bool FrameProfEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_FRAME_PROF") == "1"; - /// - /// 2026-07-24 measurement-tooling review — opt-in per-frame history - /// export path for AcDream.App.Diagnostics.FrameProfiler. 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 Dispose — the aggregated 5-second - /// [frame-prof] report is unaffected. History recording only - /// takes effect while is also true: it - /// reuses the same per-frame instrumentation rather than duplicating - /// it. Startup-only (not runtime-toggleable) — read once, like every - /// other ACDREAM_* launch flag on this owner. - /// + /// Optional per-frame CSV history path for the frame profiler. public static string? FrameHistoryPath { get; } = Environment.GetEnvironmentVariable("ACDREAM_FRAME_HISTORY"); /// - /// Campaign OVERHAUL S3 chunk 1 (§11.2 B1): print-only walk-transcript - /// emitter. When true, the production frame walk - /// (RetailFrameWalk + WalkFrameDriver, - /// src/AcDream.App/Rendering/Walk/WalkTranscriptDump.cs) 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 - /// (tools/walk-oracle/oh/oh-capture-walk.cdb.template). 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. - /// - /// This is the "owned by RenderingDiagnostics (rule 5)" half of §11.2 - /// B1's design; the sole authoritative read of - /// ACDREAM_DUMP_WALK_TRANSCRIPT lives in - /// RuntimeOptions.DumpWalkTranscript (rule 4) — this property - /// defaults false and is set exactly once, at GameWindow - /// 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 RuntimeOptions reference - /// still get one static, flippable-at-runtime gate to check. - /// + /// Print-only production walk transcript gate. RuntimeOptions owns the + /// sole environment read and assigns this property during construction. /// public static bool DumpWalkTranscriptEnabled { get; set; } } diff --git a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs index d28ed25d..57fb1ead 100644 --- a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs +++ b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs @@ -1969,7 +1969,7 @@ public sealed class PlayerMovementController // PhysicsEngine.ResolveWithTransition. That ran for EVERY entity, so a Holtburg NPC // jump-looping near the cottage doorway clobbered the render root every tick → the render // rooted at the NPC's tiny connector cell → only its ~8-tri shell drew, rest = GL clear - // color = the cottage doorway "blue-hole" flap (diagnosed 2026-06-03 via [flap-cam]/[shell]). + // color = the cottage doorway "blue-hole" flap (diagnosed 2026-06-03 from a camera capture). if (publishRenderRoot) _physics.UpdatePlayerCurrCell(newCellId); } diff --git a/tests/AcDream.App.Tests/Composition/HostInputCameraCompositionTests.cs b/tests/AcDream.App.Tests/Composition/HostInputCameraCompositionTests.cs index 6bd44921..0d2b96a3 100644 --- a/tests/AcDream.App.Tests/Composition/HostInputCameraCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/HostInputCameraCompositionTests.cs @@ -325,11 +325,6 @@ public sealed class HostInputCameraCompositionTests GpuFrameFlightController? frameFlights, IGpuDevice device) => frameFlights!; - public WorldRenderDiagnostics? CreateWorldRenderDiagnostics( - GameWindowGraphics graphics, - IRenderFrameDiagnosticLog log) => - new(new GlStateReader(), log); - public SilkKeyboardSource CreateKeyboardSource( IKeyboard keyboard, HostQuiescenceGate quiescence) => @@ -498,12 +493,6 @@ public sealed class HostInputCameraCompositionTests public void Delete(nint fence) { } } - private sealed class GlStateReader : IRenderGlStateReader - { - public RenderGlStateSnapshot CaptureState() => default; - public RenderGlScissorSnapshot CaptureScissor() => default; - } - private sealed class DiagnosticLog : IRenderFrameDiagnosticLog { public void WriteLine(string message) { } diff --git a/tests/AcDream.App.Tests/Rendering/CameraDiagnosticsCollection.cs b/tests/AcDream.App.Tests/Rendering/CameraDiagnosticsCollection.cs index c9b04e0b..d04df6e4 100644 --- a/tests/AcDream.App.Tests/Rendering/CameraDiagnosticsCollection.cs +++ b/tests/AcDream.App.Tests/Rendering/CameraDiagnosticsCollection.cs @@ -19,7 +19,7 @@ namespace AcDream.App.Tests.Rendering; /// each class's finally sufficient. /// /// -/// Three distinct globals are covered, and every one of them is a shared +/// The shared globals below are covered, and every one of them is an /// edge between at least two classes in this collection: /// /// AcDream.Core.Rendering.CameraDiagnostics — @@ -28,15 +28,12 @@ namespace AcDream.App.Tests.Rendering; /// RetailChaseCamera.Update, CameraController.Active, /// CameraFrameController, WorldRenderFrameBuilder and /// MouseLookController. -/// AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled -/// — written by CornerFloodReplayTests and -/// Issue181WallPressEquilibriumTests. /// AcDream.Core.Rendering.RenderingDiagnostics.DumpWalkTranscriptEnabled /// (Campaign OVERHAUL S3 chunk 1) — written by /// WalkFrameDriverTests' transcript-emitter tests /// (WalkFrameDriverTranscriptTests.cs). /// System.Console.Out — redirected via -/// Console.SetOut by those same classes to capture probe output. +/// Console.SetOut by the transcript tests to capture output. /// Interleaved redirection can restore a DISPOSED StringWriter as the /// process-wide Console.Out, which then throws in unrelated /// tests. diff --git a/tests/AcDream.App.Tests/Rendering/ChargenPreviewControllerTests.cs b/tests/AcDream.App.Tests/Rendering/ChargenPreviewControllerTests.cs index 95363d8a..445cf527 100644 --- a/tests/AcDream.App.Tests/Rendering/ChargenPreviewControllerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ChargenPreviewControllerTests.cs @@ -416,7 +416,7 @@ public sealed class ChargenPreviewControllerTests /// private bool TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter) { - string? datDir = CornerFloodReplayTests.ResolveDatDir(); + string? datDir = InstalledDatTestPath.Resolve(); if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); diff --git a/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs index 07e7f459..57343010 100644 --- a/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs @@ -29,7 +29,7 @@ public sealed class ChargenPreviewEntityBuilderTests [Fact] public void TryBuild_AluvianMaleDefaultSelection_ProducesANonEmptyStaticPoseEntity() { - string? datDir = CornerFloodReplayTests.ResolveDatDir(); + string? datDir = InstalledDatTestPath.Resolve(); if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); } using var dats = new DatCollection(datDir, DatAccessType.Read); @@ -87,7 +87,7 @@ public sealed class ChargenPreviewEntityBuilderTests ChargenPreviewEntityBuilder.PreviewBackdropRenderId, ChargenPreviewEntityBuilder.SummaryPreviewBackdropRenderId); - string? datDir = CornerFloodReplayTests.ResolveDatDir(); + string? datDir = InstalledDatTestPath.Resolve(); if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); } using var dats = new DatCollection(datDir, DatAccessType.Read); @@ -121,7 +121,7 @@ public sealed class ChargenPreviewEntityBuilderTests [Fact] public void TryBuild_UnknownSetupId_ReturnsNull() { - string? datDir = CornerFloodReplayTests.ResolveDatDir(); + string? datDir = InstalledDatTestPath.Resolve(); if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); } using var dats = new DatCollection(datDir, DatAccessType.Read); @@ -145,7 +145,7 @@ public sealed class ChargenPreviewEntityBuilderTests [Fact] public void TryBuild_OlthoiHeritage_ResolvesADifferentRestPoseDidThanStandardHeritages() { - string? datDir = CornerFloodReplayTests.ResolveDatDir(); + string? datDir = InstalledDatTestPath.Resolve(); if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); } using var dats = new DatCollection(datDir, DatAccessType.Read); @@ -186,7 +186,7 @@ public sealed class ChargenPreviewEntityBuilderTests [Fact] public void TryBuildAnimated_AluvianMaleDefaultSelection_ResolvesARealIdleCycle() { - string? datDir = CornerFloodReplayTests.ResolveDatDir(); + string? datDir = InstalledDatTestPath.Resolve(); if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); } using var dats = new DatCollection(datDir, DatAccessType.Read); @@ -232,7 +232,7 @@ public sealed class ChargenPreviewEntityBuilderTests [Fact] public void TryBuildAnimated_UnknownSetupId_ReturnsNull() { - string? datDir = CornerFloodReplayTests.ResolveDatDir(); + string? datDir = InstalledDatTestPath.Resolve(); if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); } using var dats = new DatCollection(datDir, DatAccessType.Read); @@ -274,7 +274,7 @@ public sealed class ChargenPreviewEntityBuilderTests [InlineData(0x10000013u)] // OlthoiAcid's shared idle/rest enum key. public void OlthoiFamily_SharedIdleRestEnumKey_ResolvesToARealInstalledDid(uint sharedEnumKey) { - string? datDir = CornerFloodReplayTests.ResolveDatDir(); + string? datDir = InstalledDatTestPath.Resolve(); if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); } using var dats = new DatCollection(datDir, DatAccessType.Read); @@ -297,7 +297,7 @@ public sealed class ChargenPreviewEntityBuilderTests [Fact] public void TryBuildAnimated_OlthoiHeritage_ResolvesARealIdleAnimationToo() { - string? datDir = CornerFloodReplayTests.ResolveDatDir(); + string? datDir = InstalledDatTestPath.Resolve(); if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); } using var dats = new DatCollection(datDir, DatAccessType.Read); @@ -337,7 +337,7 @@ public sealed class ChargenPreviewEntityBuilderTests [Fact] public void TryBuildBackdrop_AluvianHeritage_ResolvesANonEmptyMesh() { - string? datDir = CornerFloodReplayTests.ResolveDatDir(); + string? datDir = InstalledDatTestPath.Resolve(); if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); } using var dats = new DatCollection(datDir, DatAccessType.Read); @@ -376,7 +376,7 @@ public sealed class ChargenPreviewEntityBuilderTests [Fact] public void TryBuildBackdrop_ExplicitRenderId_StampsThatIdOnTheEntity() { - string? datDir = CornerFloodReplayTests.ResolveDatDir(); + string? datDir = InstalledDatTestPath.Resolve(); if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); } using var dats = new DatCollection(datDir, DatAccessType.Read); @@ -405,7 +405,7 @@ public sealed class ChargenPreviewEntityBuilderTests [Fact] public void TryBuildBackdrop_UnsetEnvironmentSetupId_ReturnsNull() { - string? datDir = CornerFloodReplayTests.ResolveDatDir(); + string? datDir = InstalledDatTestPath.Resolve(); if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); } using var dats = new DatCollection(datDir, DatAccessType.Read); @@ -419,7 +419,7 @@ public sealed class ChargenPreviewEntityBuilderTests [Fact] public void TryBuildBackdrop_UnknownSetupId_ReturnsNull() { - string? datDir = CornerFloodReplayTests.ResolveDatDir(); + string? datDir = InstalledDatTestPath.Resolve(); if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); } using var dats = new DatCollection(datDir, DatAccessType.Read); @@ -440,7 +440,7 @@ public sealed class ChargenPreviewEntityBuilderTests [Fact] public void TryBuildBackdrop_AllThirteenHeritages_ResolveOrAreReportedByName() { - string? datDir = CornerFloodReplayTests.ResolveDatDir(); + string? datDir = InstalledDatTestPath.Resolve(); if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); } using var dats = new DatCollection(datDir, DatAccessType.Read); diff --git a/tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs b/tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs index 1c8a9c8b..0a661b86 100644 --- a/tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using System.Numerics; using AcDream.App.Rendering; using Xunit; @@ -7,30 +6,12 @@ namespace AcDream.App.Tests.Rendering; public class ClipFrameAssemblerTests { - private static ViewPolygon Square(float cx, float cy, float half) => new(new[] - { - new Vector2(cx - half, cy - half), - new Vector2(cx + half, cy - half), - new Vector2(cx + half, cy + half), - new Vector2(cx - half, cy + half), - }); - - private static CellView ViewOf(params ViewPolygon[] polys) - { - var view = new CellView(); - foreach (var p in polys) - view.Add(p); - return view; - } - [Fact] public void BeginWalkFrame_OutdoorRoot_SeedsOneFullScreenDefaultView() { using ClipFrame frame = ClipFrame.NoClip(); - ClipFrameAssembly assembly = ClipFrameAssembler.BeginWalkFrame( - frame, - outdoorRoot: true); + ClipFrameAssembly assembly = ClipFrameAssembler.BeginWalkFrame(frame, outdoorRoot: true); ClipViewSlice slice = Assert.Single(assembly.OutsideViewSlices); Assert.Equal(0, slice.Slot); @@ -45,9 +26,7 @@ public class ClipFrameAssemblerTests public void BeginWalkFrame_InteriorRoot_ResetsReusedOutdoorAssemblyToEmpty() { using ClipFrame frame = ClipFrame.NoClip(); - ClipFrameAssembly reuse = ClipFrameAssembler.BeginWalkFrame( - frame, - outdoorRoot: true); + ClipFrameAssembly reuse = ClipFrameAssembler.BeginWalkFrame(frame, outdoorRoot: true); ClipFrameAssembly assembly = ClipFrameAssembler.BeginWalkFrame( frame, @@ -62,270 +41,4 @@ public class ClipFrameAssemblerTests Assert.Equal(0, assembly.ScissorFallbacks); Assert.Equal(1, frame.SlotCount); } - - [Fact] - public void TwoVisibleCells_PlusOutsideView_ProducesCorrectSlotMapAndCounts() - { - const uint cellA = 0xA9B40100; - const uint cellB = 0xA9B40101; - - var pv = new PortalVisibilityFrame(); - pv.CellViews[cellA] = ViewOf(Square(-0.3f, 0f, 0.3f)); - pv.CellViews[cellB] = ViewOf(Square(0.3f, 0f, 0.2f)); - pv.OrderedVisibleCells.Add(cellA); - pv.OrderedVisibleCells.Add(cellB); - pv.OutsideView.Add(Square(0f, 0.5f, 0.25f)); - - var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv); - - Assert.Equal(4, asm.Frame.SlotCount); // slot 0 + two cells + one outside slice - Assert.Contains(cellA, asm.CellIdToSlot.Keys); - Assert.Contains(cellB, asm.CellIdToSlot.Keys); - Assert.NotEqual(0, asm.CellIdToSlot[cellA]); - Assert.NotEqual(0, asm.CellIdToSlot[cellB]); - Assert.NotEqual(asm.CellIdToSlot[cellA], asm.CellIdToSlot[cellB]); - Assert.Equal(4, asm.PerCellPlaneCounts[cellA]); - Assert.Equal(4, asm.PerCellPlaneCounts[cellB]); - - Assert.True(asm.OutdoorVisible); - Assert.NotEqual(0, asm.OutdoorSlot); - Assert.Single(asm.OutsideViewSlices); - Assert.Equal(asm.OutdoorSlot, asm.OutsideViewSlices[0].Slot); - Assert.Equal(4, asm.OutsidePlaneCount); - Assert.Equal(0, asm.ScissorFallbacks); - } - - [Fact] - public void NothingVisibleCell_IsExcludedFromSlotMap_AndNotAppended() - { - const uint cellA = 0xA9B40100; - const uint cellB = 0xA9B40101; - - var pv = new PortalVisibilityFrame(); - pv.CellViews[cellA] = ViewOf(Square(0f, 0f, 0.3f)); - pv.CellViews[cellB] = new CellView(); - pv.OrderedVisibleCells.Add(cellA); - pv.OrderedVisibleCells.Add(cellB); - - var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv); - - Assert.Equal(2, asm.Frame.SlotCount); - Assert.Contains(cellA, asm.CellIdToSlot.Keys); - Assert.DoesNotContain(cellB, asm.CellIdToSlot.Keys); - Assert.False(asm.OutdoorVisible); - Assert.Empty(asm.OutsideViewSlices); - Assert.Equal(0, asm.OutsidePlaneCount); - } - - [Fact] - public void OutsideViewMultiPolygon_PreservesRetailSlices() - { - const uint cellA = 0xA9B40100; - - var pv = new PortalVisibilityFrame(); - pv.CellViews[cellA] = ViewOf(Square(0f, 0f, 0.3f)); - pv.OrderedVisibleCells.Add(cellA); - pv.OutsideView.Add(Square(-0.5f, 0f, 0.1f)); - pv.OutsideView.Add(Square(0.5f, 0f, 0.1f)); - - var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv); - - Assert.True(asm.OutdoorVisible); - Assert.NotEqual(0, asm.OutdoorSlot); - Assert.Equal(2, asm.OutsideViewSlices.Length); - Assert.NotEqual(asm.OutsideViewSlices[0].Slot, asm.OutsideViewSlices[1].Slot); - Assert.Equal(4, asm.OutsidePlaneCount); - Assert.Equal(0, asm.ScissorFallbacks); - Assert.Equal(4, asm.Frame.SlotCount); // slot 0 + cell + two outside slices - } - - [Fact] - public void CellMultiPolygonView_PreservesRetailViewSlices() - { - const uint cellA = 0xA9B40100; - - var pv = new PortalVisibilityFrame(); - pv.CellViews[cellA] = ViewOf( - Square(-0.4f, 0f, 0.1f), - Square(0.4f, 0f, 0.1f)); - pv.OrderedVisibleCells.Add(cellA); - pv.OutsideView.Add(Square(0f, 0f, 0.3f)); - - var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv); - - Assert.True(asm.CellIdToSlot[cellA] > 0); - Assert.Equal(2, asm.CellIdToViewSlots[cellA].Length); - Assert.Equal(2, asm.CellIdToViewSlices[cellA].Length); - Assert.NotEqual(asm.CellIdToViewSlots[cellA][0], asm.CellIdToViewSlots[cellA][1]); - Assert.Equal(4, asm.PerCellPlaneCounts[cellA]); - Assert.Single(asm.OutsideViewSlices); - Assert.Equal(4, asm.Frame.SlotCount); // slot 0 + two cell slices + outside slice - Assert.Equal(0, asm.ScissorFallbacks); - } - - [Fact] - public void Assemble_OutsideViewWithExitPortal_HasOutsideViewTrue_AabbMatchesBounds() - { - var pv = new PortalVisibilityFrame(); - pv.OutsideView.Add(Square(-0.3f, 0.2f, 0.25f)); - - var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv); - - Assert.True(asm.HasOutsideView); - Assert.Single(asm.OutsideViewSlices); - - var expected = new Vector4( - pv.OutsideView.MinX, pv.OutsideView.MinY, - pv.OutsideView.MaxX, pv.OutsideView.MaxY); - Assert.Equal(expected, asm.OutsideViewNdcAabb); - } - - [Fact] - public void Assemble_OutsideViewMultiPolygon_PreservesSlicesAndUnionAabb() - { - var pv = new PortalVisibilityFrame(); - pv.OutsideView.Add(Square(-0.6f, 0f, 0.15f)); - pv.OutsideView.Add(Square(0.6f, 0f, 0.15f)); - - var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv); - - Assert.True(asm.HasOutsideView); - Assert.Equal(2, asm.OutsideViewSlices.Length); - - var expected = new Vector4( - pv.OutsideView.MinX, pv.OutsideView.MinY, - pv.OutsideView.MaxX, pv.OutsideView.MaxY); - Assert.Equal(expected, asm.OutsideViewNdcAabb); - } - - [Fact] - public void Assemble_EmptyOutsideView_HasOutsideViewFalse_AabbZero() - { - const uint cellA = 0xA9B40100; - var pv = new PortalVisibilityFrame(); - pv.CellViews[cellA] = ViewOf(Square(0f, 0f, 0.3f)); - pv.OrderedVisibleCells.Add(cellA); - - var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv); - - Assert.False(asm.HasOutsideView); - Assert.Equal(Vector4.Zero, asm.OutsideViewNdcAabb); - } - - [Fact] - public void Reset_ReusesFrame_NoSlotLeakAcrossAssemblies() - { - var frame = ClipFrame.NoClip(); - - var pv1 = new PortalVisibilityFrame(); - pv1.CellViews[0xA9B40100] = ViewOf(Square(-0.3f, 0f, 0.2f)); - pv1.CellViews[0xA9B40101] = ViewOf(Square(0.3f, 0f, 0.2f)); - pv1.OrderedVisibleCells.Add(0xA9B40100); - pv1.OrderedVisibleCells.Add(0xA9B40101); - pv1.OutsideView.Add(Square(0f, 0.4f, 0.2f)); - var asm1 = ClipFrameAssembler.Assemble(frame, pv1); - Assert.Equal(4, asm1.Frame.SlotCount); - - var pv2 = new PortalVisibilityFrame(); - pv2.CellViews[0xA9B40200] = ViewOf(Square(0f, 0f, 0.25f)); - pv2.OrderedVisibleCells.Add(0xA9B40200); - var asm2 = ClipFrameAssembler.Assemble(frame, pv2); - - Assert.Equal(2, asm2.Frame.SlotCount); - Assert.Contains(0xA9B40200, asm2.CellIdToSlot.Keys); - Assert.DoesNotContain(0xA9B40100, asm2.CellIdToSlot.Keys); - Assert.False(asm2.OutdoorVisible); - } - - [Fact] - public void Assemble_ReusedAssembly_ClearsStateAndReusesExactLengthArrays() - { - const uint firstCell = 0xA9B40100; - const uint secondCell = 0xA9B40200; - var frame = ClipFrame.NoClip(); - var reuse = new ClipFrameAssembly(); - - var first = new PortalVisibilityFrame(); - first.CellViews[firstCell] = ViewOf( - Square(-0.4f, 0f, 0.15f), - Square(0.4f, 0f, 0.15f)); - first.OrderedVisibleCells.Add(firstCell); - first.OutsideView.Add(Square(0f, 0.5f, 0.2f)); - ClipFrameAssembly firstResult = ClipFrameAssembler.Assemble(frame, first, reuse); - Assert.Same(reuse, firstResult); - int sliceAllocations = reuse.SliceArrayAllocationCount; - int slotAllocations = reuse.SlotArrayAllocationCount; - - var second = new PortalVisibilityFrame(); - second.CellViews[secondCell] = ViewOf( - Square(-0.4f, 0f, 0.15f), - Square(0.4f, 0f, 0.15f)); - second.OrderedVisibleCells.Add(secondCell); - second.OutsideView.Add(Square(0f, 0.5f, 0.2f)); - - for (int i = 0; i < 12; i++) - { - ClipFrameAssembly actual = ClipFrameAssembler.Assemble(frame, second, reuse); - Assert.Same(reuse, actual); - Assert.DoesNotContain(firstCell, actual.CellIdToViewSlices.Keys); - Assert.Contains(secondCell, actual.CellIdToViewSlices.Keys); - Assert.Equal(2, actual.CellIdToViewSlices[secondCell].Length); - Assert.Equal(2, actual.CellIdToViewSlots[secondCell].Length); - Assert.Single(actual.OutsideViewSlices); - Assert.Equal(sliceAllocations, reuse.SliceArrayAllocationCount); - Assert.Equal(slotAllocations, reuse.SlotArrayAllocationCount); - } - - var empty = new PortalVisibilityFrame(); - ClipFrameAssembly cleared = ClipFrameAssembler.Assemble(frame, empty, reuse); - Assert.Empty(cleared.CellIdToSlot); - Assert.Empty(cleared.CellIdToViewSlots); - Assert.Empty(cleared.CellIdToViewSlices); - Assert.Empty(cleared.PerCellPlaneCounts); - Assert.Empty(cleared.OutsideViewSlices); - } - - [Fact] - public void Assemble_ReusedAssembly_ChangingCardinalityStaysBoundedThenReachesSteadyState() - { - const uint cellId = 0xA9B40100; - var frame = ClipFrame.NoClip(); - var reuse = new ClipFrameAssembly(); - - static PortalVisibilityFrame FrameWithSlices(uint id, int count) - { - var portalFrame = new PortalVisibilityFrame(); - var view = new CellView(); - for (int i = 0; i < count; i++) - { - // The assembler consumes the retail view_poly list directly. Distinct placement is - // irrelevant to this cache-cardinality stress; avoid CellView dedup collapsing it. - view.Polygons.Add(Square(i * 0.001f, 0f, 0.0004f)); - } - portalFrame.CellViews[id] = view; - portalFrame.OrderedVisibleCells.Add(id); - return portalFrame; - } - - for (int count = 1; count <= 180; count++) - ClipFrameAssembler.Assemble(frame, FrameWithSlices(cellId, count), reuse); - - // Flush the final live arrays into the cache too. - ClipFrameAssembler.Assemble(frame, new PortalVisibilityFrame(), reuse); - Assert.InRange(reuse.RetainedSliceItems, 0, ClipFrameAssembly.MaxRetainedSliceItems); - Assert.InRange(reuse.RetainedSlotItems, 0, ClipFrameAssembly.MaxRetainedSlotItems); - Assert.InRange(reuse.RetainedSliceArrays, 0, ClipFrameAssembly.MaxRetainedArraysPerPool); - Assert.InRange(reuse.RetainedSlotArrays, 0, ClipFrameAssembly.MaxRetainedArraysPerPool); - - PortalVisibilityFrame steady = FrameWithSlices(cellId, 73); - ClipFrameAssembler.Assemble(frame, steady, reuse); - int sliceAllocations = reuse.SliceArrayAllocationCount; - int slotAllocations = reuse.SlotArrayAllocationCount; - for (int i = 0; i < 16; i++) - { - ClipFrameAssembler.Assemble(frame, steady, reuse); - Assert.Equal(sliceAllocations, reuse.SliceArrayAllocationCount); - Assert.Equal(slotAllocations, reuse.SlotArrayAllocationCount); - } - } } diff --git a/tests/AcDream.App.Tests/Rendering/CornerFloodReplayTests.cs b/tests/AcDream.App.Tests/Rendering/CornerFloodReplayTests.cs deleted file mode 100644 index 34686a15..00000000 --- a/tests/AcDream.App.Tests/Rendering/CornerFloodReplayTests.cs +++ /dev/null @@ -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; - -/// -/// §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 -/// 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. -/// -[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; - } - - /// - /// 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. - /// - internal static LoadedCell LoadCell(DatCollection dats, uint cellId) - => LoadCell(dats, cellId, Vector3.Zero); - - /// worldOffset: block offset for multi-landblock fixtures (dat cell - /// positions are landblock-local; a neighbour block needs ±192 per axis). - internal static LoadedCell LoadCell(DatCollection dats, uint cellId, Vector3 worldOffset) - { - var envCell = dats.Get(cellId) - ?? throw new InvalidOperationException($"EnvCell 0x{cellId:X8} not found"); - var environment = dats.Get(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(); - var clipPlanes = new List(); - var portalPolygons = new List(); - - 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(); - 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(); - } - portalPolygons.Add(polyVerts); - } - - uint lbPrefix = cellId & 0xFFFF0000u; - var visibleCells = new List(); - 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 LoadBuilding(DatCollection dats) - { - var cells = new Dictionary(); - 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); - - /// - /// 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. - /// - [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 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)"); - } - } - } - - /// - /// §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. - /// - [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 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(); - 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(); - 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)); - } - - /// - /// #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 - /// from BOTH sides' cells. - /// The in-place growth's fixpoint invariant must hold at every step — - /// the tripwire count stays 0. - /// - [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 lookup = id => cells.TryGetValue(id, out var c) ? c : null; - - PortalVisibilityBuilder.ConvergenceTripwireCount = 0; - var firings = new List(); - - 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 { 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)); - } - - /// - /// #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. - /// - [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 lookup = id => cells.TryGetValue(id, out var c) ? c : null; - - PortalVisibilityBuilder.ConvergenceTripwireCount = 0; - var firings = new List(); - 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)); - } - - /// - /// 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. - /// - [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); - } - } - - /// Scratch: the homogeneous reciprocal primitive in isolation, on the - /// synthetic fixture geometry that Build_AppliesReciprocalOtherPortalClip uses. - [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 { 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 { new CellPortalInfo(0x0001, 0, 0, 0) }, - }; - b.PortalPolygons.Add(narrow); - var all = new Dictionary { [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}"); - } - - /// - /// 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. - /// - [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 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}")); - } - } -} diff --git a/tests/AcDream.App.Tests/Rendering/EnvCellAlphaDrawSourceTests.cs b/tests/AcDream.App.Tests/Rendering/EnvCellAlphaDrawSourceTests.cs index 950d2b87..cfd34ccc 100644 --- a/tests/AcDream.App.Tests/Rendering/EnvCellAlphaDrawSourceTests.cs +++ b/tests/AcDream.App.Tests/Rendering/EnvCellAlphaDrawSourceTests.cs @@ -738,7 +738,6 @@ public sealed class EnvCellAlphaDrawSourceTests particleRenderer: null, portalDepthMask: null, Queue, - (WorldRenderDiagnostics)RuntimeHelpers.GetUninitializedObject(typeof(WorldRenderDiagnostics)), (TerrainDrawDiagnosticsController)RuntimeHelpers.GetUninitializedObject( typeof(TerrainDrawDiagnosticsController))); Leaf = new WalkProductionLeafRenderer( diff --git a/tests/AcDream.App.Tests/Rendering/IndoorDrawPlanTests.cs b/tests/AcDream.App.Tests/Rendering/IndoorDrawPlanTests.cs deleted file mode 100644 index 6b0de649..00000000 --- a/tests/AcDream.App.Tests/Rendering/IndoorDrawPlanTests.cs +++ /dev/null @@ -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()); - } -} diff --git a/tests/AcDream.App.Tests/Rendering/InstalledDatTestPath.cs b/tests/AcDream.App.Tests/Rendering/InstalledDatTestPath.cs new file mode 100644 index 00000000..658bce08 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/InstalledDatTestPath.cs @@ -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; + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Issue113MeetingHallFloodTests.cs b/tests/AcDream.App.Tests/Rendering/Issue113MeetingHallFloodTests.cs deleted file mode 100644 index 1abe9e82..00000000 --- a/tests/AcDream.App.Tests/Rendering/Issue113MeetingHallFloodTests.cs +++ /dev/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; - -/// -/// #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. -/// -[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(cellId) - ?? throw new InvalidOperationException($"EnvCell 0x{cellId:X8} not found"); - var environment = dats.Get(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(); - var clipPlanes = new List(); - var portalPolygons = new List(); - - 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(); - 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(); - } - portalPolygons.Add(polyVerts); - } - - uint lbPrefix = cellId & 0xFFFF0000u; - var visibleCells = new List(); - 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 LoadHall(DatCollection dats) - { - var cells = new Dictionary(); - 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; - } - - /// - /// 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. - /// - [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 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(); - - 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(); - 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)."); - } -} diff --git a/tests/AcDream.App.Tests/Rendering/Issue120ReciprocalPingPongTests.cs b/tests/AcDream.App.Tests/Rendering/Issue120ReciprocalPingPongTests.cs deleted file mode 100644 index c8e2d16a..00000000 --- a/tests/AcDream.App.Tests/Rendering/Issue120ReciprocalPingPongTests.cs +++ /dev/null @@ -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; - -/// -/// #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. -/// -[Trait("Lane", "InstalledDat")] -public class Issue120ReciprocalPingPongTests -{ - private readonly ITestOutputHelper _out; - public Issue120ReciprocalPingPongTests(ITestOutputHelper output) => _out = output; - - internal static Dictionary LoadAllInteriorCells(DatCollection dats, uint landblock) - { - var lbi = dats.Get(landblock | 0xFFFEu); - Assert.NotNull(lbi); - var cells = new Dictionary(); - 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 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 }, - }; - - /// - /// 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). - /// - [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 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(); - 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)"); - } - - /// - /// 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. - /// - [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 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(); - 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)"); - } -} diff --git a/tests/AcDream.App.Tests/Rendering/Issue124LookInSeedRegionTests.cs b/tests/AcDream.App.Tests/Rendering/Issue124LookInSeedRegionTests.cs deleted file mode 100644 index 3d2fc822..00000000 --- a/tests/AcDream.App.Tests/Rendering/Issue124LookInSeedRegionTests.cs +++ /dev/null @@ -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; - -/// -/// #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. -/// -[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 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"); - } -} diff --git a/tests/AcDream.App.Tests/Rendering/Issue127FloodFlipReplayTests.cs b/tests/AcDream.App.Tests/Rendering/Issue127FloodFlipReplayTests.cs deleted file mode 100644 index 6a53f9ae..00000000 --- a/tests/AcDream.App.Tests/Rendering/Issue127FloodFlipReplayTests.cs +++ /dev/null @@ -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; - -/// -/// #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. -/// -[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 Cells, - List> BuildingGroups, - Func Lookup); - - private static void LoadLandblockBuildings( - DatCollection dats, uint landblock, Vector3 offset, - Dictionary cells, List> groups) - { - var lbi = dats.Get(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(); - 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(); - 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(); - var groups = new List>(); - // 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 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 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 so the gate itself can be - // implicated or exonerated. - private static HashSet 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(pv.OrderedVisibleCells); - } - - /// - /// 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. - /// - [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 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 BuildingAdmits( - World w, List group, Vector3 eye, Matrix4x4 viewProj, - FrustumPlanes frustum, bool withPreGate) - { - var result = new HashSet(); - 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; - } - - /// - /// #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. - /// - [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(); - var prevIn = new Dictionary(); - 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(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(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"); - } -} diff --git a/tests/AcDream.App.Tests/Rendering/Issue131SetupProbeTests.cs b/tests/AcDream.App.Tests/Rendering/Issue131SetupProbeTests.cs deleted file mode 100644 index 12012f04..00000000 --- a/tests/AcDream.App.Tests/Rendering/Issue131SetupProbeTests.cs +++ /dev/null @@ -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; - -/// -/// #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. -/// -[Trait("Purpose", "Diagnostic")] -[Trait("Lane", "InstalledDat")] -public class Issue131SetupProbeTests -{ - private readonly ITestOutputHelper _out; - public Issue131SetupProbeTests(ITestOutputHelper output) => _out = output; - - /// #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. - [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(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(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()); - } - } - } -} diff --git a/tests/AcDream.App.Tests/Rendering/Issue176177FacilityHubFloodReplayTests.cs b/tests/AcDream.App.Tests/Rendering/Issue176177FacilityHubFloodReplayTests.cs deleted file mode 100644 index 3412fc32..00000000 --- a/tests/AcDream.App.Tests/Rendering/Issue176177FacilityHubFloodReplayTests.cs +++ /dev/null @@ -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; - -/// -/// #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). -/// -[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 Flood( - Dictionary cells, uint rootId, Vector3 eye, Vector3 gazeDir) - { - Func 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(frame.OrderedVisibleCells); - result.Sort(); - return result; - } - - private static string CellSetString(IEnumerable 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? 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? prevSet = null; - float prevYaw = 0f; - var perYawSets = new List<(float yaw, List 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}"); - } - - /// - /// 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. - /// - [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? 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}"); - } -} diff --git a/tests/AcDream.App.Tests/Rendering/Issue177StairDescentCameraFloodTests.cs b/tests/AcDream.App.Tests/Rendering/Issue177StairDescentCameraFloodTests.cs deleted file mode 100644 index e1c768a8..00000000 --- a/tests/AcDream.App.Tests/Rendering/Issue177StairDescentCameraFloodTests.cs +++ /dev/null @@ -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; - -/// -/// #177 decisive full-chain replay: drive the REAL -/// + REAL down the Facility Hub -/// staircase against the REAL BSP, then feed the swept (ViewerCellId, eye, -/// View*Projection) into — 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. -/// -[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(id); - if (datCell is null) continue; - var environment = dats.Get(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(), Array.Empty(), 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 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(id); - if (envCell is null) { _out.WriteLine($"0x{low:X4}: (no dat cell)"); continue; } - var environment = dats.Get(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; } - } - } - - /// - /// Campaign OVERHAUL S2 chunk 2: retargeted from the deleted - /// ShadowObjectRegistry.ComputeStaticRenderCells pure-function - /// side channel to the production - /// transaction - /// (the SAME BSP-collision + partArray: recipe - /// LandblockPhysicsPublisher.PublishStaticEntity uses, and the - /// same one RetailCellArrayComparatorInstalledDatTests' fixture 1 - /// exercises for this exact Setup) plus - /// . The entity - /// id now MUST match the projection's - /// — chunk 2's - /// 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 GetOwnerCells for this BSP-bearing - /// Setup reflects that registration — and, per the comparator's fixture-1 - /// evidence, equals the retail CELLARRAY exactly. - /// - [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(dats.Get(setupId)); - PhysicsEngine engine = BuildHubEngine(dats); - PhysicsDataCache cache = Assert.IsType(engine.DataCache); - IReadOnlyList meshRefs = SetupMesh.Flatten(setup); - foreach (MeshRef meshRef in meshRefs) - { - DatGfxObj gfxObj = Assert.IsType( - dats.Get(meshRef.GfxObjId)); - cache.CacheGfxObj(meshRef.GfxObjId, gfxObj); - } - - DatEnvCell parent = Assert.IsType( - dats.Get(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 bspShapes = ShadowShapeBuilder.FromLandblockBspParts( - meshRefs, isBuildingShell: false, cache.GetGfxObj); - List 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 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); - } - - /// - /// #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. - /// - [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 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; } - } - - /// - /// #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). - /// - [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 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; - } - - /// - /// #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. - /// - [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 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) - } - - /// - /// #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. - /// - [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 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 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(); - 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; - } - } -} diff --git a/tests/AcDream.App.Tests/Rendering/Issue181VisFlapReplayTests.cs b/tests/AcDream.App.Tests/Rendering/Issue181VisFlapReplayTests.cs deleted file mode 100644 index 13f4c827..00000000 --- a/tests/AcDream.App.Tests/Rendering/Issue181VisFlapReplayTests.cs +++ /dev/null @@ -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; - -/// -/// #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. -/// -[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 Flood( - Dictionary cells, uint rootId, Vector3 eye, Vector3 gazeDir) - { - Func 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(frame.OrderedVisibleCells); - result.Sort(); - return result; - } - - private static string CellSetString(IEnumerable 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 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. - } -} diff --git a/tests/AcDream.App.Tests/Rendering/Issue181WallPressEquilibriumTests.cs b/tests/AcDream.App.Tests/Rendering/Issue181WallPressEquilibriumTests.cs index 54506c2d..17ce6b7a 100644 --- a/tests/AcDream.App.Tests/Rendering/Issue181WallPressEquilibriumTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Issue181WallPressEquilibriumTests.cs @@ -155,29 +155,12 @@ public class Issue181WallPressEquilibriumTests _out.WriteLine(FormattableString.Invariant( $"viewer cells seen: {cells.Count} transitions={cellTransitions} eye=({cam.Position.X:F6},{cam.Position.Y:F6},{cam.Position.Z:F6}) cell=0x{cam.ViewerCellId:X8}")); - // Orbit structure: 16 consecutive frames at 6dp, with the sweep's - // own [flap-sweep] lines captured for the same frames. - bool savedFlap = RenderingDiagnostics.ProbeFlapEnabled; - var savedOut = Console.Out; - try + // Orbit structure: 16 consecutive frames at 6dp. + for (int i = 0; i < 16; i++) { - RenderingDiagnostics.ProbeFlapEnabled = true; - using var writer = new StringWriter(); - Console.SetOut(writer); - for (int i = 0; i < 16; i++) - { - Step(); - writer.WriteLine(FormattableString.Invariant( - $"orbit[{i:D2}] eye=({cam.Position.X:F6},{cam.Position.Y:F6},{cam.Position.Z:F6})")); - } - Console.SetOut(savedOut); - foreach (var line in writer.ToString().Split('\n')) - if (line.Length > 1) _out.WriteLine(line.TrimEnd()); - } - finally - { - Console.SetOut(savedOut); - RenderingDiagnostics.ProbeFlapEnabled = savedFlap; + Step(); + _out.WriteLine(FormattableString.Invariant( + $"orbit[{i:D2}] eye=({cam.Position.X:F6},{cam.Position.Y:F6},{cam.Position.Z:F6})")); } } finally diff --git a/tests/AcDream.App.Tests/Rendering/Issue95DungeonFloodDiagnosticTests.cs b/tests/AcDream.App.Tests/Rendering/Issue95DungeonFloodDiagnosticTests.cs deleted file mode 100644 index 014b9e22..00000000 --- a/tests/AcDream.App.Tests/Rendering/Issue95DungeonFloodDiagnosticTests.cs +++ /dev/null @@ -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; - -/// -/// #95 MEASUREMENT (2026-06-13): entering the 0x0007 dungeon (Town Network) explodes -/// WB-DIAG to ~9.1M instances/frame. Suspected cause: -/// floods the dungeon's portal graph WITHOUT the retail grab_visible_cells stab_list bounding -/// (decomp:311878). A dungeon cell has seen_outside==0; retail's PVS for it is just the -/// cell's stab_list () — 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 () vs the -/// root's stab_list size (), plus how many visited cells -/// cross landblocks. The single assertion just guarantees the test ran; the VALUE is the output. -/// -[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(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 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(); - 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? 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."); - } -} diff --git a/tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs b/tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs index 01996744..406f8e86 100644 --- a/tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs +++ b/tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs @@ -1,52 +1,21 @@ -using System; using System.Numerics; using AcDream.App.Rendering; using Xunit; namespace AcDream.App.Tests.Rendering; -/// -/// The OUTDOOR render root (R-A2, 2026-06-08): a portal-less whose only job is -/// to root the render outdoors so the flood seeds OutsideView FULL-SCREEN (terrain draws). Buildings are -/// flooded SEPARATELY per-building (), 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 UnifiedFloodTests was retired. -/// public class OutdoorCellNodeTests { [Fact] public void Build_ReturnsPortallessOutdoorRoot() { uint outdoorId = 0xA9B40031; - var node = OutdoorCellNode.Build(outdoorId); + LoadedCell node = OutdoorCellNode.Build(outdoorId); Assert.Equal(outdoorId, node.CellId); - Assert.True(node.IsOutdoorNode); // the flag PortalVisibilityBuilder keys the full-screen OutsideView on + Assert.True(node.IsOutdoorNode); Assert.True(node.SeenOutside); Assert.Equal(Matrix4x4.Identity, node.WorldTransform); - Assert.Empty(node.Portals); // R-A2: no reverse portals into buildings (buildings flood per-building) - } - - [Fact] - public void Build_OutdoorRoot_SeedsFullScreenOutsideView_OnlyNodeVisible() - { - // The load-bearing outdoor-terrain behavior: rooting at the IsOutdoorNode node seeds OutsideView - // with the FULL-SCREEN NDC quad, so ClipFrameAssembler yields TerrainMode != Skip and - // DrawLandscapeThroughOutsideView draws terrain/sky/scenery everywhere. The portal-less node - // resolves to exactly {node} — no interior cells flooded from the root (those come per-building). - var node = OutdoorCellNode.Build(0xA9B40031); - - var eye = new Vector3(0, -3, 1); - var view = Matrix4x4.CreateLookAt(eye, new Vector3(0, 5, 1), Vector3.UnitZ); - var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 16f / 9f, 1f, 5000f); - - var frame = PortalVisibilityBuilder.Build(node, eye, _ => null, view * proj); - - Assert.Equal(new[] { 0xA9B40031u }, frame.OrderedVisibleCells); // only the node - Assert.False(frame.OutsideView.IsEmpty); - Assert.Equal(-1f, frame.OutsideView.MinX, 3); - Assert.Equal(-1f, frame.OutsideView.MinY, 3); - Assert.Equal(1f, frame.OutsideView.MaxX, 3); - Assert.Equal(1f, frame.OutsideView.MaxY, 3); + Assert.Empty(node.Portals); } } diff --git a/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs deleted file mode 100644 index 238c261e..00000000 --- a/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs +++ /dev/null @@ -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(portals), - }; - - private static PortalVisibilityFrame Build(LoadedCell cam, Dictionary 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 - { - [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(() => 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 { [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 - { [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 { [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 { [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 { [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 { [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 { [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 { [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 { [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 { [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 { [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 { [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 { [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 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 { [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 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 { [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 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 { [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 { [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 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 { [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 { [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 { [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(), 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 - { - [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); - } -} diff --git a/tests/AcDream.App.Tests/Rendering/PortalVisibilityRobustnessTests.cs b/tests/AcDream.App.Tests/Rendering/PortalVisibilityRobustnessTests.cs deleted file mode 100644 index 6bab3328..00000000 --- a/tests/AcDream.App.Tests/Rendering/PortalVisibilityRobustnessTests.cs +++ /dev/null @@ -1,96 +0,0 @@ -using System.Collections.Generic; -using System.Numerics; -using AcDream.App.Rendering; -using Xunit; - -namespace AcDream.App.Tests.Rendering; - -/// -/// R-A2 conformance: the per-building flood () -/// 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). -/// -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(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 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 { [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 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); - } -} diff --git a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs index 30b436e6..20cc5dac 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs @@ -564,12 +564,8 @@ public sealed class RetailPViewPassExecutorTests var frames = new GpuDeviceFrameLifetime(device); var scope = new VulkanWorldPassScope(sampleCount: 1); using var portalDepthMask = new PortalDepthMaskRenderer(device, frames, scope); - var diagnostics = new WorldRenderDiagnostics(new NeverCalledGlStateReader(), new NeverCalledDiagnosticLog()); - // DrawPortalDepthWrite only reads _portalDepthMask, frame.Cells, - // frame.RootCell.IsOutdoorNode, frame.ViewProjection, and - // _diagnostics (short-circuited off by RenderingDiagnostics. - // ProbeSeamDrawEnabled's default-off value) — the same + // frame.RootCell.IsOutdoorNode, and frame.ViewProjection — the same // constructor-bypass pattern WalkOutsideViewReassemblyTests already // uses for exercising a real leaf without a full GL/DAT renderer // graph. @@ -578,10 +574,6 @@ public sealed class RetailPViewPassExecutorTests typeof(RetailPViewPassExecutor) .GetField("_portalDepthMask", BindingFlags.NonPublic | BindingFlags.Instance)! .SetValue(executor, portalDepthMask); - typeof(RetailPViewPassExecutor) - .GetField("_diagnostics", BindingFlags.NonPublic | BindingFlags.Instance)! - .SetValue(executor, diagnostics); - var root = new LoadedCell { CellId = 0xF4180003u, IsOutdoorNode = false }; RetailPViewFrameInput frame = new RetailPViewFrameInput().Reset( rootCell: root, @@ -625,21 +617,6 @@ public sealed class RetailPViewPassExecutorTests public LoadedCell? Find(uint cellId) => cellId == cell.CellId ? cell : null; } - private sealed class NeverCalledGlStateReader : IRenderGlStateReader - { - public RenderGlStateSnapshot CaptureState() => - throw new InvalidOperationException("EmitSeamMask must short-circuit before reading GL state."); - - public RenderGlScissorSnapshot CaptureScissor() => - throw new InvalidOperationException("EmitSeamMask must short-circuit before reading GL state."); - } - - private sealed class NeverCalledDiagnosticLog : IRenderFrameDiagnosticLog - { - public void WriteLine(string message) => - throw new InvalidOperationException("EmitSeamMask must short-circuit before logging (ProbeSeamDrawEnabled defaults off)."); - } - private static int RequiredCallIndex( IReadOnlyList calls, Type declaringType, diff --git a/tests/AcDream.App.Tests/Rendering/TerrainDrawDiagnosticsControllerTests.cs b/tests/AcDream.App.Tests/Rendering/TerrainDrawDiagnosticsControllerTests.cs index 4368283a..46fbe8ea 100644 --- a/tests/AcDream.App.Tests/Rendering/TerrainDrawDiagnosticsControllerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/TerrainDrawDiagnosticsControllerTests.cs @@ -9,7 +9,7 @@ public sealed class TerrainDrawDiagnosticsControllerTests { var log = new RecordingLog(); var facts = new RecordingFacts(); - var world = new WorldRenderDiagnostics(new NullGlStateReader(), log); + var world = new WorldRenderDiagnostics(log); var controller = new TerrainDrawDiagnosticsController(false, world, facts, log); controller.Begin(); @@ -25,7 +25,7 @@ public sealed class TerrainDrawDiagnosticsControllerTests { var log = new ThrowOnceLog(); var facts = new RecordingFacts(); - var world = new WorldRenderDiagnostics(new NullGlStateReader(), log); + var world = new WorldRenderDiagnostics(log); var controller = new TerrainDrawDiagnosticsController(true, world, facts, log); controller.Begin(); @@ -47,7 +47,7 @@ public sealed class TerrainDrawDiagnosticsControllerTests { var log = new ThrowOnFirstFrameLog(); var facts = new RecordingFacts(); - var world = new WorldRenderDiagnostics(new NullGlStateReader(), log); + var world = new WorldRenderDiagnostics(log); var controller = new TerrainDrawDiagnosticsController(true, world, facts, log); controller.Begin(); @@ -70,7 +70,7 @@ public sealed class TerrainDrawDiagnosticsControllerTests { var log = new RecordingLog(); var facts = new RecordingFacts(); - var world = new WorldRenderDiagnostics(new NullGlStateReader(), log); + var world = new WorldRenderDiagnostics(log); var controller = new TerrainDrawDiagnosticsController(true, world, facts, log); controller.Begin(); @@ -99,7 +99,7 @@ public sealed class TerrainDrawDiagnosticsControllerTests { var log = new RecordingLog(); var facts = new RecordingFacts(); - var world = new WorldRenderDiagnostics(new NullGlStateReader(), log); + var world = new WorldRenderDiagnostics(log); var controller = new TerrainDrawDiagnosticsController(true, world, facts, log); controller.AccumulateWalkBatch(1_000); @@ -121,7 +121,7 @@ public sealed class TerrainDrawDiagnosticsControllerTests // not depend on AccumulateWalkBatch ever having been called. var log = new RecordingLog(); var facts = new RecordingFacts(); - var world = new WorldRenderDiagnostics(new NullGlStateReader(), log); + var world = new WorldRenderDiagnostics(log); var controller = new TerrainDrawDiagnosticsController(true, world, facts, log); controller.CompleteWalkFrame(10_000); @@ -135,7 +135,7 @@ public sealed class TerrainDrawDiagnosticsControllerTests { var log = new RecordingLog(); var facts = new RecordingFacts(); - var world = new WorldRenderDiagnostics(new NullGlStateReader(), log); + var world = new WorldRenderDiagnostics(log); var controller = new TerrainDrawDiagnosticsController(true, world, facts, log); controller.AccumulateWalkBatch(5_000); @@ -171,12 +171,6 @@ public sealed class TerrainDrawDiagnosticsControllerTests } } - private sealed class NullGlStateReader : IRenderGlStateReader - { - public RenderGlStateSnapshot CaptureState() => default; - public RenderGlScissorSnapshot CaptureScissor() => default; - } - private sealed class RecordingLog : IRenderFrameDiagnosticLog { public List Messages { get; } = []; diff --git a/tests/AcDream.App.Tests/Rendering/TowerAscentReplayTests.cs b/tests/AcDream.App.Tests/Rendering/TowerAscentReplayTests.cs deleted file mode 100644 index 93317d31..00000000 --- a/tests/AcDream.App.Tests/Rendering/TowerAscentReplayTests.cs +++ /dev/null @@ -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; - -/// -/// #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. -/// -[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 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 RunAscent() - { - var datDir = RequireDatDir(); - - using var dats = new DatCollection(datDir, DatAccessType.Read); - var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, Landblock); - Func 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(); - 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(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 steps, Func? 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}")); - } - } - - /// - /// 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? - /// - [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"); - } - - /// - /// 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). - /// - [Fact] - public void TowerAscent_RootDoesNotPingPong() - { - var steps = RunAscent(); - var flips = new List(); - 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)"); - } - - /// - /// 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). - /// - [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 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() : cells; - if (lift) - { - var datDir2 = CornerFloodReplayTests.ResolveDatDir()!; - using var dats2 = new DatCollection(datDir2, DatAccessType.Read); - var lbi = dats2.Get(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 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"); - } - } - } - - /// - /// 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). - /// - [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(); - _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(); - 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}"); - } - } - - /// Full per-step table for the investigation record. - [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."); -} diff --git a/tests/AcDream.App.Tests/Rendering/ViewconeCullerReuseTests.cs b/tests/AcDream.App.Tests/Rendering/ViewconeCullerReuseTests.cs deleted file mode 100644 index 38c6d201..00000000 --- a/tests/AcDream.App.Tests/Rendering/ViewconeCullerReuseTests.cs +++ /dev/null @@ -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), - }); -} diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkBuildingInstalledDatCensusTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkBuildingInstalledDatCensusTests.cs index 699b379f..8b50d4bb 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkBuildingInstalledDatCensusTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkBuildingInstalledDatCensusTests.cs @@ -12,7 +12,7 @@ public sealed class WalkBuildingInstalledDatCensusTests [Fact] public void CompleteInstalledBuildingCorpusMatchesBoundedSelectionCensus() { - string? datDir = CornerFloodReplayTests.ResolveDatDir(); + string? datDir = InstalledDatTestPath.Resolve(); if (datDir is null) Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory."); using var dats = new DatCollection(datDir!, DatAccessType.Read); diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkBuildingJoinDiagnosticTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkBuildingJoinDiagnosticTests.cs index 98c00214..2e3d6397 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkBuildingJoinDiagnosticTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkBuildingJoinDiagnosticTests.cs @@ -23,7 +23,7 @@ public sealed class WalkBuildingJoinDiagnosticTests public void Traced_lookin_cells_are_reachable_through_the_building_portal_table( uint buildingCellId, uint[] tracedLookInCells) { - string? datDir = CornerFloodReplayTests.ResolveDatDir(); + string? datDir = InstalledDatTestPath.Resolve(); if (datDir is null) { Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory."); diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs index 7e5ee8c3..15b3b971 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs @@ -446,7 +446,7 @@ public sealed partial class WalkFrameDriverTests Assert.Equal(2, mdiCalls.Sum(c => (int)c.DrawCount)); // Exit seals replay through the exact portal_view slices captured by - // this walk. The legacy PortalVisibilityFrame is not consulted. + // this walk; there is no independently reconstructed visibility graph. Assert.Equal(1, driver.InteriorFloodViewSliceCountAt(0)); Assert.Equal(1, driver.InteriorFloodViewSliceCountAt(1)); Assert.Equal(4, driver.InteriorFloodViewClipPlanesAt(0, 0).Length); diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTranscriptTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTranscriptTests.cs index 54f21cbb..0f464860 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTranscriptTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTranscriptTests.cs @@ -25,8 +25,8 @@ namespace AcDream.App.Tests.Rendering.Walk; /// and /// are both process-wide mutable statics — joins /// for the SAME reason -/// CornerFloodReplayTests/Issue181WallPressEquilibriumTests do -/// (that collection's own doc comment, issue #251): interleaved +/// Issue181WallPressEquilibriumTests does (that collection's own doc +/// comment, issue #251): interleaved /// Console.SetOut redirection across parallel test classes can /// restore a disposed process-wide. /// diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkLookInGateSweepTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkLookInGateSweepTests.cs index c798d628..eba4504c 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkLookInGateSweepTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkLookInGateSweepTests.cs @@ -31,7 +31,7 @@ public sealed class WalkLookInGateSweepTests + "(the doorway-still fixture tests it in isolation).")] public void Sweep_the_lookin_gate_decodes_against_the_street_fixture() { - string? datDir = CornerFloodReplayTests.ResolveDatDir(); + string? datDir = InstalledDatTestPath.Resolve(); if (datDir is null) { Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory."); diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkPortalGateDumpTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkPortalGateDumpTests.cs index ce0ada67..1d06c245 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkPortalGateDumpTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkPortalGateDumpTests.cs @@ -20,7 +20,7 @@ public sealed class WalkPortalGateDumpTests [Fact] public void Dump_the_gate_inputs_for_a_punching_and_a_silent_building() { - string? datDir = CornerFloodReplayTests.ResolveDatDir(); + string? datDir = InstalledDatTestPath.Resolve(); if (datDir is null) { Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory."); @@ -102,7 +102,7 @@ public sealed class WalkPortalGateDumpTests [Fact] public void Dump_the_exit_view_look_in_inputs_for_walkout_f2() { - string? datDir = CornerFloodReplayTests.ResolveDatDir(); + string? datDir = InstalledDatTestPath.Resolve(); if (datDir is null) { Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory."); @@ -232,7 +232,7 @@ public sealed class WalkPortalGateDumpTests [Fact] public void Dump_the_foundry_f67_bsp_traversal_for_0036() { - string? datDir = CornerFloodReplayTests.ResolveDatDir(); + string? datDir = InstalledDatTestPath.Resolve(); if (datDir is null) { Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory."); diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldConformanceTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldConformanceTests.cs index 4da2cc09..0322cc9b 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldConformanceTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldConformanceTests.cs @@ -59,7 +59,7 @@ public sealed class WalkProductionWorldConformanceTests private static DatCollection OpenDats() { - string? datDir = CornerFloodReplayTests.ResolveDatDir(); + string? datDir = InstalledDatTestPath.Resolve(); if (datDir is null) { Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldDataTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldDataTests.cs index 95238fb0..46222b3a 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldDataTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldDataTests.cs @@ -453,31 +453,6 @@ public sealed class WalkProductionWorldDataTests Assert.Equal(first.Records.Count, second.Records.Count); } - [Fact] - public void StaticBucketContains_FindsARegisteredSourceIdInItsRetailCell() - { - const uint entityId = 0x48A02040u; - const uint sourceId = 0x020009A2u; - const uint retailCellId = 0xF4180112u; - - var shadows = new ShadowObjectRegistry(); - Register(shadows, entityId, retailCellId, landblockId: 0xF4180000u); - - RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(1); - using var scene = new ArchRenderScene(generation); - RenderProjectionRecord projection = - IndoorStaticRecord(entityId, sourceId, retailCellId); - scene.Apply([RenderProjectionDelta.Register(generation, 1, projection)]); - - var worldData = new WalkProductionWorldData(new WalkBuildingRegistry(), shadows); - worldData.BeginFrame( - scene.OpenQuery(), 0xF4180000u, renderCenterLbX: 0xF4, renderCenterLbY: 0x18); - - Assert.True(worldData.StaticBucketContains(retailCellId, sourceId)); - Assert.False(worldData.StaticBucketContains(retailCellId, sourceId + 1)); - Assert.False(worldData.StaticBucketContains(0xF4180107u, sourceId)); - } - private static RenderProjectionRecord Record(uint id, Vector3 position) => new RenderProjectionRecord() with { diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkRendererArchitectureGuardTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkRendererArchitectureGuardTests.cs new file mode 100644 index 00000000..f0cbf9a2 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkRendererArchitectureGuardTests.cs @@ -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(); + + 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 methods = owner.GetMethods(Members).Cast() + .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 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 sources, IEnumerable 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."); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs index 64faeb61..a4209ffe 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs @@ -318,7 +318,7 @@ public sealed class WalkStaticStreamPopulatorTests Assert.DoesNotContain( "one stable far-to-near stream keyed", architecture, StringComparison.OrdinalIgnoreCase); - Assert.Contains("active total to **161**", register, StringComparison.Ordinal); + Assert.Contains("current active total to **160**", register, StringComparison.Ordinal); int apSectionStart = register.IndexOf( "## 3. Documented approximation (AP)", StringComparison.Ordinal); int apSectionEnd = register.IndexOf( @@ -342,7 +342,7 @@ public sealed class WalkStaticStreamPopulatorTests Assert.Single(File.ReadLines(registerPath), static line => line.StartsWith("| AP-244 |", StringComparison.Ordinal)); Assert.Equal( - 161, + 160, File.ReadLines(registerPath).Count(static line => line.StartsWith("| AP-", StringComparison.Ordinal))); Assert.Contains("greater than 50 m", register, StringComparison.Ordinal); diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceConformanceTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceConformanceTests.cs index 6ad6926f..4b78c5f3 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceConformanceTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceConformanceTests.cs @@ -114,7 +114,7 @@ public sealed partial class WalkTraceConformanceTests private static DatCollection OpenDats() { - string? datDir = CornerFloodReplayTests.ResolveDatDir(); + string? datDir = InstalledDatTestPath.Resolve(); if (datDir is null) { Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkWorldDatAdapterTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkWorldDatAdapterTests.cs index ce777f60..1b3477e1 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkWorldDatAdapterTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkWorldDatAdapterTests.cs @@ -19,7 +19,7 @@ public sealed class WalkWorldDatAdapterTests { private static DatCollection OpenDats() { - string? datDir = CornerFloodReplayTests.ResolveDatDir(); + string? datDir = InstalledDatTestPath.Resolve(); if (datDir is null) { Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); diff --git a/tests/AcDream.App.Tests/Rendering/WalkOutsideViewReassemblyTests.cs b/tests/AcDream.App.Tests/Rendering/WalkOutsideViewReassemblyTests.cs index e1791cd1..1f413f5c 100644 --- a/tests/AcDream.App.Tests/Rendering/WalkOutsideViewReassemblyTests.cs +++ b/tests/AcDream.App.Tests/Rendering/WalkOutsideViewReassemblyTests.cs @@ -39,21 +39,13 @@ public class WalkOutsideViewReassemblyTests return view; } - private static ClipFrameAssembly AssembledWithOldOutside() - { - var pv = new PortalVisibilityFrame(); - pv.OutsideView.Add(new ViewPolygon(new[] - { - new Vector2(-0.9f, -0.9f), new Vector2(0.9f, -0.9f), - new Vector2(0.9f, 0.9f), new Vector2(-0.9f, 0.9f), - })); - return ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv); - } + private static ClipFrameAssembly BeginAssembly() + => ClipFrameAssembler.BeginWalkFrame(ClipFrame.NoClip(), outdoorRoot: true); [Fact] public void PixelDoorway_MapsToExpectedNdcAabbAndPlanes() { - ClipFrameAssembly asm = AssembledWithOldOutside(); + ClipFrameAssembly asm = BeginAssembly(); // Pixel quad x∈[256,512], y∈[180,360] → NDC x∈[-0.5,0], y∈[0,0.5] // (yNdc = 1 − 2·py/H flips the axis: py=180 → +0.5, py=360 → 0). WalkPortalView walkView = WalkViewOfPixelQuads(new[] @@ -95,7 +87,7 @@ public class WalkOutsideViewReassemblyTests [Fact] public void FullViewportWalkQuad_CoversFullNdc() { - ClipFrameAssembly asm = AssembledWithOldOutside(); + ClipFrameAssembly asm = BeginAssembly(); var walkView = new WalkPortalView(); Assert.True(WalkCopyView.AppendFullViewportQuad( walkView, new StubRays(), Vector3.Zero, W, H)); @@ -109,8 +101,8 @@ public class WalkOutsideViewReassemblyTests [Fact] public void EmptyWalkView_YieldsSkipMode() { - ClipFrameAssembly asm = AssembledWithOldOutside(); - Assert.True(asm.OutdoorVisible); // the old view was visible pre-cutover + ClipFrameAssembly asm = BeginAssembly(); + Assert.True(asm.OutdoorVisible); ClipFrameAssembler.ReassembleOutsideViewFromWalk(asm, new WalkPortalView(), W, H); @@ -138,7 +130,7 @@ public class WalkOutsideViewReassemblyTests [Fact] public void FirstViewCollapses_SecondSurvives_SlicesStayIndexAligned() { - ClipFrameAssembly asm = AssembledWithOldOutside(); + ClipFrameAssembly asm = BeginAssembly(); var walkView = new WalkPortalView(); // View 0: three EXACTLY collinear pixel points -> zero-area triangle, @@ -196,7 +188,7 @@ public class WalkOutsideViewReassemblyTests [Fact] public void PunchLeaf_UsesIndexAlignedSlice_DrawsNothingForCollapsed_ThrowsOutOfRange() { - ClipFrameAssembly asm = AssembledWithOldOutside(); + ClipFrameAssembly asm = BeginAssembly(); var walkView = new WalkPortalView(); walkView.View.Vertices.Add(new WalkViewVertex { Point = new Vector2(100f, 100f) }); walkView.View.Vertices.Add(new WalkViewVertex { Point = new Vector2(200f, 100f) }); @@ -356,7 +348,7 @@ public class WalkOutsideViewReassemblyTests [Fact] public void NineVertexOutsideView_PunchSliceHasZeroPlanes_DrawsUnclipped_NotNothingVisible() { - ClipFrameAssembly asm = AssembledWithOldOutside(); + ClipFrameAssembly asm = BeginAssembly(); const int n = 9; var verts = new Vector2[n]; @@ -387,7 +379,7 @@ public class WalkOutsideViewReassemblyTests [Fact] public void TwoWalkViews_ProduceTwoSlicesWithDistinctSlots() { - ClipFrameAssembly asm = AssembledWithOldOutside(); + ClipFrameAssembly asm = BeginAssembly(); WalkPortalView walkView = WalkViewOfPixelQuads( new[] { diff --git a/tests/AcDream.App.Tests/Rendering/WorldRenderDiagnosticsTests.cs b/tests/AcDream.App.Tests/Rendering/WorldRenderDiagnosticsTests.cs index 48effcec..cfa8561e 100644 --- a/tests/AcDream.App.Tests/Rendering/WorldRenderDiagnosticsTests.cs +++ b/tests/AcDream.App.Tests/Rendering/WorldRenderDiagnosticsTests.cs @@ -1,194 +1,29 @@ -using System.Numerics; -using System.Reflection; using AcDream.App.Rendering; -using AcDream.Core.Vfx; namespace AcDream.App.Tests.Rendering; public sealed class WorldRenderDiagnosticsTests { - [Fact] - public void GlTripwire_DisabledPerformsNoGlRead() - { - var gl = new RecordingGlStateReader(); - var log = new RecordingLog(); - var diagnostics = new WorldRenderDiagnostics(gl, log); - - diagnostics.EmitGlStateTripwireIfChanged(enabled: false); - - Assert.Equal(0, gl.StateCaptureCount); - Assert.Empty(log.Messages); - } - - [Fact] - public void GlTripwire_LogsOnlyChangesAndReportsStableFrameCount() - { - var gl = new RecordingGlStateReader - { - State = State(depthFunction: 0x201), - }; - var log = new RecordingLog(); - var diagnostics = new WorldRenderDiagnostics(gl, log); - - diagnostics.EmitGlStateTripwireIfChanged(enabled: true); - diagnostics.EmitGlStateTripwireIfChanged(enabled: true); - gl.State = State(depthFunction: 0x203); - diagnostics.EmitGlStateTripwireIfChanged(enabled: true); - - Assert.Equal(3, gl.StateCaptureCount); - Assert.Collection( - log.Messages, - first => - { - Assert.StartsWith("[gl-state] frame=1 stable=0", first); - Assert.Contains("dfunc=0x201", first); - }, - changed => - { - Assert.StartsWith("[gl-state] frame=3 stable=1", changed); - Assert.Contains("dfunc=0x203", changed); - }); - } - - [Fact] - public void ScissorProbe_SequenceAdvancesAcrossSuppressedDuplicates() - { - var gl = new RecordingGlStateReader - { - Scissor = new RenderGlScissorSnapshot( - true, - new IntRenderRectangle(1, 2, 3, 4)), - }; - var log = new RecordingLog(); - var diagnostics = new WorldRenderDiagnostics(gl, log); - - diagnostics.EmitClipRouteScissorProbe(true, true, new Vector4(-1, -1, 1, 1)); - diagnostics.EmitClipRouteScissorProbe(true, true, new Vector4(-1, -1, 1, 1)); - gl.Scissor = new RenderGlScissorSnapshot( - true, - new IntRenderRectangle(5, 6, 7, 8)); - diagnostics.EmitClipRouteScissorProbe(true, true, new Vector4(-1, -1, 1, 1)); - - Assert.Equal(3, gl.ScissorCaptureCount); - Assert.Collection( - log.Messages, - first => Assert.StartsWith("[clip-route-scis] n=1", first), - changed => Assert.StartsWith("[clip-route-scis] n=3", changed)); - } - - [Fact] - public void Owner_RetainsNoBorrowedFrameProductsOrBroadRuntimeOwners() - { - FieldInfo[] fields = typeof(WorldRenderDiagnostics).GetFields( - BindingFlags.Instance | BindingFlags.NonPublic); - - Type[] forbidden = - [ - typeof(GameWindow), - typeof(PortalVisibilityFrame), - typeof(ClipFrameAssembly), - typeof(InteriorEntityPartition.Result), - typeof(AcDream.Core.Physics.PhysicsEngine), - typeof(CameraController), - typeof(CellVisibility), - ]; - foreach (Type type in forbidden) - Assert.DoesNotContain(fields, field => field.FieldType == type); - Assert.DoesNotContain( - fields, - field => typeof(Delegate).IsAssignableFrom(field.FieldType)); - } - - // Campaign V slice V11 deleted SilkRenderGlStateReader, the raw-GL - // IRenderGlStateReader implementation this test pinned (it was never - // constructed in production — NullRenderGlStateReader.Instance is the - // sole surviving implementer), so the "consume the entering GL error - // before other state reads" source-order tripwire it protected has - // nothing left to pin. - [Fact] public void TerrainDiagnostics_RetryFailedPublicationWithoutLosingSamples() { var log = new ThrowOnceLog(); - var diagnostics = new WorldRenderDiagnostics(new RecordingGlStateReader(), log); - var facts = new TerrainRenderDiagnosticFacts(VisibleSlots: 3, Draws: 3, LoadedSlots: 5, CapacitySlots: 8); + var diagnostics = new WorldRenderDiagnostics(log); + var facts = new TerrainRenderDiagnosticFacts(3, 3, 5, 8); diagnostics.BeginTerrainDraw(); diagnostics.EndTerrainDraw(); - Assert.Throws(() => - diagnostics.PublishTerrainDiagnostics(facts)); + Assert.Throws(() => diagnostics.PublishTerrainDiagnostics(facts)); diagnostics.BeginTerrainDraw(); diagnostics.EndTerrainDraw(); diagnostics.PublishTerrainDiagnostics(facts); - Assert.Single(log.Messages); - Assert.Contains("draws=3/frame", log.Messages[0]); - Assert.Contains("visible=3", log.Messages[0]); - Assert.Contains("loaded=5", log.Messages[0]); - Assert.Contains("capacity=8", log.Messages[0]); - } - - [Fact] - public void RenderSignature_LogsOnlyChangesAndCarriesStableCount() - { - var log = new RecordingLog(); - var diagnostics = new WorldRenderDiagnostics(new RecordingGlStateReader(), log); - - EmitMinimalSignature(diagnostics, "flat"); - EmitMinimalSignature(diagnostics, "flat"); - EmitMinimalSignature(diagnostics, "pview"); - - Assert.Collection( - log.Messages, - first => Assert.StartsWith("[render-sig] frame=1 stable=0 branch=flat", first), - changed => Assert.StartsWith("[render-sig] frame=3 stable=1 branch=pview", changed)); - } - - private static RenderGlStateSnapshot State(int depthFunction) => new( - DepthTest: true, - DepthWrite: true, - DepthFunction: depthFunction, - Blend: false, - BlendSource: 1, - BlendDestination: 0, - CullFace: true, - CullMode: 0x405, - FrontFace: 0x900, - Scissor: false, - ScissorBox: new IntRenderRectangle(0, 0, 1280, 720), - Viewport: new IntRenderRectangle(0, 0, 1280, 720), - DrawFramebuffer: 0, - AlphaToCoverage: false, - Stencil: false, - ClipBits: 0, - Error: 0); - - private sealed class RecordingGlStateReader : IRenderGlStateReader - { - public RenderGlStateSnapshot State { get; set; } = WorldRenderDiagnosticsTests.State(0x201); - public RenderGlScissorSnapshot Scissor { get; set; } - public int StateCaptureCount { get; private set; } - public int ScissorCaptureCount { get; private set; } - - public RenderGlStateSnapshot CaptureState() - { - StateCaptureCount++; - return State; - } - - public RenderGlScissorSnapshot CaptureScissor() - { - ScissorCaptureCount++; - return Scissor; - } - } - - private sealed class RecordingLog : IRenderFrameDiagnosticLog - { - public List Messages { get; } = []; - - public void WriteLine(string message) => Messages.Add(message); + string message = Assert.Single(log.Messages); + Assert.Contains("draws=3/frame", message); + Assert.Contains("visible=3", message); + Assert.Contains("loaded=5", message); + Assert.Contains("capacity=8", message); } private sealed class ThrowOnceLog : IRenderFrameDiagnosticLog @@ -207,33 +42,4 @@ public sealed class WorldRenderDiagnosticsTests Messages.Add(message); } } - - private static void EmitMinimalSignature( - WorldRenderDiagnostics diagnostics, - string branch) => - diagnostics.EmitRenderSignatureIfChanged( - enabled: true, - branch: branch, - clipRoot: null, - viewerRoot: null, - playerRoot: null, - viewerCellId: 0, - playerCellId: 0, - playerIndoorGate: false, - cameraInsideCell: false, - renderSkyGate: false, - drawSkyThisFrame: false, - terrainDrawn: false, - terrainClipMode: TerrainClipMode.Skip, - skyDrawn: false, - depthClear: false, - outdoorSceneryDrawn: false, - liveDynamicDrawnCount: 0, - sceneParticles: "none", - visibleCells: null, - clipAssembly: null, - drawableCells: null, - partition: null, - cameraPosition: Vector3.Zero, - playerPosition: Vector3.Zero); } diff --git a/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs b/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs index 4e64cd33..16940738 100644 --- a/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs +++ b/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs @@ -198,7 +198,6 @@ public sealed class WorldSceneRendererTests "passes:disable-clip", "particles:global", "flat:weather", - "diagnostics:signature:OutdoorRoot", "diagnostics:draw", "alpha:end", "visibility:complete", @@ -238,10 +237,8 @@ public sealed class WorldSceneRendererTests "passes:begin", "pview:draw", "visibility:mark", - "diagnostics:pview", "passes:disable-clip", - "particles:none", - "diagnostics:signature:RetailPViewInside", + "particles:skipped", "diagnostics:draw", "alpha:end", "visibility:complete", @@ -307,7 +304,7 @@ public sealed class WorldSceneRendererTests } [Fact] - public void OutdoorPView_UsesPViewOwnersForPostWorldParticlesWithoutFlatWeather() + public void OutdoorPView_SkipsPostWorldParticleReplayAndFlatWeather() { var root = new LoadedCell { @@ -319,7 +316,7 @@ public sealed class WorldSceneRendererTests WorldRenderFrameOutcome result = rig.Renderer.Render(default); Assert.True(result.NormalWorldDrawn); - Assert.Contains("particles:pviewScoped", rig.Calls); + Assert.Contains("particles:skipped", rig.Calls); Assert.DoesNotContain("flat:weather", rig.Calls); } @@ -498,7 +495,6 @@ public sealed class WorldSceneRendererTests Type[] borrowedTypes = [ typeof(RetailPViewFrameResult), - typeof(PortalVisibilityFrame), typeof(ClipFrameAssembly), ]; @@ -690,9 +686,7 @@ public sealed class WorldSceneRendererTests new WbFrustum()); _clipFrame = ClipFrame.NoClip(); - var glDiagnostics = new WorldRenderDiagnostics( - new DefaultGlStateReader(), - new NullDiagnosticLog()); + var glDiagnostics = new WorldRenderDiagnostics(new NullDiagnosticLog()); var terrainDiagnostics = new TerrainDrawDiagnosticsController( enabled: false, glDiagnostics, @@ -710,7 +704,6 @@ public sealed class WorldSceneRendererTests particleRenderer: null, portalDepthMask: null, alpha, - glDiagnostics, terrainDiagnostics); const uint landblockId = 0xF4180000u; @@ -859,13 +852,6 @@ public sealed class WorldSceneRendererTests } } - private sealed class DefaultGlStateReader : IRenderGlStateReader - { - public RenderGlStateSnapshot CaptureState() => default; - - public RenderGlScissorSnapshot CaptureScissor() => default; - } - private sealed class NullDiagnosticLog : IRenderFrameDiagnosticLog { public void WriteLine(string message) { } @@ -1093,20 +1079,13 @@ public sealed class WorldSceneRendererTests uint? playerLandblockId, HashSet animatedEntityIds) => calls.Add("flat:entities"); - public string DrawPostWorldParticles( + public void DrawPostWorldParticles( LoadedCell? clipRoot, ClipFrameAssembly? clipAssembly, - in WorldCameraFrame camera, - string currentSignature) + in WorldCameraFrame camera) { - string kind = clipRoot switch - { - null => "global", - { IsOutdoorNode: true } => currentSignature, - _ => currentSignature, - }; + string kind = clipRoot is null ? "global" : "skipped"; calls.Add($"particles:{kind}"); - return kind; } public void DrawFlatWeather( @@ -1135,37 +1114,6 @@ public sealed class WorldSceneRendererTests { public CameraCellResolution CameraCellResolution => CameraCellResolution.None; - public void EmitPViewInput( - IReadOnlySet visibleCells, - int outsideViewCount, - Matrix4x4 viewProjection, - LoadedCell clipRoot, - Vector3 cameraPosition, - Vector3 playerPosition) => calls.Add("diagnostics:pview"); - - public void EmitRenderSignature( - string branch, - LoadedCell? clipRoot, - LoadedCell? viewerRoot, - LoadedCell? playerRoot, - uint viewerCellId, - uint playerCellId, - bool playerIndoorGate, - bool cameraInsideCell, - bool renderSky, - bool drawSkyThisFrame, - bool terrainDrawn, - TerrainClipMode terrainClipMode, - bool skyDrawn, - bool depthClear, - bool outdoorSceneryDrawn, - int liveDynamicDrawnCount, - string sceneParticles, - RetailPViewFrameResult? pviewResult, - Vector3 cameraPosition, - Vector3 playerPosition) => - calls.Add($"diagnostics:signature:{branch}"); - public WorldSceneDiagnosticOutcome DrawAndPublish( in WorldCameraFrame camera, IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> bounds) diff --git a/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs b/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs index 628bc5bf..175495f3 100644 --- a/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs @@ -5,175 +5,24 @@ namespace AcDream.Core.Tests.Rendering; public sealed class RenderingDiagnosticsTests { - // Each flag-mutating test snapshots the IndoorAll state on entry and - // restores it via try/finally. RenderingDiagnostics is a process-wide - // static (env-var-initialized); without restoration a mutated state - // leaks into other tests + into parallel test runs. Mirrors the - // PhysicsDiagnosticsTests pattern at line 30-49. - - [Fact] - public void IndoorAll_True_TurnsAllFlagsOn() - { - bool initial = RenderingDiagnostics.IndoorAll; - try - { - // Reset all flags off first to make the test deterministic - // regardless of env-var state on the test runner. - RenderingDiagnostics.ProbeIndoorWalkEnabled = false; - RenderingDiagnostics.ProbeIndoorLookupEnabled = false; - RenderingDiagnostics.ProbeIndoorUploadEnabled = false; - RenderingDiagnostics.ProbeIndoorXformEnabled = false; - RenderingDiagnostics.ProbeIndoorCullEnabled = false; - - RenderingDiagnostics.IndoorAll = true; - - Assert.True(RenderingDiagnostics.ProbeIndoorWalkEnabled); - Assert.True(RenderingDiagnostics.ProbeIndoorLookupEnabled); - Assert.True(RenderingDiagnostics.ProbeIndoorUploadEnabled); - Assert.True(RenderingDiagnostics.ProbeIndoorXformEnabled); - Assert.True(RenderingDiagnostics.ProbeIndoorCullEnabled); - Assert.True(RenderingDiagnostics.IndoorAll); - } - finally - { - RenderingDiagnostics.IndoorAll = initial; - } - } - - [Fact] - public void IndoorAll_False_TurnsAllFlagsOff() - { - bool initial = RenderingDiagnostics.IndoorAll; - try - { - RenderingDiagnostics.IndoorAll = true; // start from all-on - RenderingDiagnostics.IndoorAll = false; - - Assert.False(RenderingDiagnostics.ProbeIndoorWalkEnabled); - Assert.False(RenderingDiagnostics.ProbeIndoorLookupEnabled); - Assert.False(RenderingDiagnostics.ProbeIndoorUploadEnabled); - Assert.False(RenderingDiagnostics.ProbeIndoorXformEnabled); - Assert.False(RenderingDiagnostics.ProbeIndoorCullEnabled); - Assert.False(RenderingDiagnostics.IndoorAll); - } - finally - { - RenderingDiagnostics.IndoorAll = initial; - } - } - - [Fact] - public void IndoorAll_OneOff_ReadsAsFalse() - { - bool initial = RenderingDiagnostics.IndoorAll; - try - { - RenderingDiagnostics.IndoorAll = true; - RenderingDiagnostics.ProbeIndoorCullEnabled = false; // flip one off - Assert.False(RenderingDiagnostics.IndoorAll); - } - finally - { - RenderingDiagnostics.IndoorAll = initial; - } - } + [Theory] + [InlineData(0x00000029ul, false)] + [InlineData(0xA9B40029ul, false)] + [InlineData(0x00000100ul, true)] + [InlineData(0xA9B40105ul, true)] + public void IsEnvCellId_DistinguishesOutdoorVsIndoorByLow16Bits(ulong id, bool expected) + => Assert.Equal(expected, RenderingDiagnostics.IsEnvCellId(id)); [Theory] - [InlineData(0x00000029ul, false)] // outdoor cell 0x29 in 8x8 grid - [InlineData(0xA9B40029ul, false)] // outdoor cell with landblock prefix - [InlineData(0x00000100ul, true)] // indoor cell minimum - [InlineData(0x00000105ul, true)] // typical Holtburg Inn interior - [InlineData(0xA9B40105ul, true)] // indoor with landblock prefix - [InlineData(0xA9B401FFul, true)] // indoor near top of range - public void IsEnvCellId_DistinguishesOutdoorVsIndoorByLow16Bits(ulong id, bool expected) - { - Assert.Equal(expected, RenderingDiagnostics.IsEnvCellId(id)); - } - - // ── Render inside/outside branch (retail RenderNormalMode is_player_outside) ── - // The top-level render branch decides DrawInside vs DrawOutside. Retail - // (SmartBox::RenderNormalMode 0x453aa0:92665) keys it on is_player_outside (the - // PLAYER's cell, 0x451e80), NOT the camera cell. acdream previously branched on the - // camera cell, so a chase camera lagging in a doorway while the player was already - // outside took the DrawInside path and degenerated to a grey world + entities showing - // through walls. These pin the player-keyed branch and loaded player-root requirement. - - [Fact] - public void ShouldRenderIndoor_PlayerOutside_CameraInside_ReturnsFalse() - { - // THE doorway-grey regression: the player stepped onto a landcell (0x...0031) but the - // chase camera still resolves an interior EnvCell. Branch on the PLAYER → outdoor. - Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40031u, renderRootResolved: true)); - } - - [Fact] - public void ShouldRenderIndoor_PlayerInside_CameraInside_ReturnsTrue() - { - Assert.True(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40171u, renderRootResolved: true)); - } - - [Fact] - public void ShouldRenderIndoor_PlayerInside_RootNotLoaded_ReturnsFalse() - { - // Opposite lag (camera pulled outside while the player is inside): no viewer cell to - // root DrawInside at → outdoor. Defensive; matches prior null-CameraCell behavior. - Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40171u, renderRootResolved: false)); - } - - [Fact] - public void ShouldRenderIndoor_PlayerOutside_CameraOutside_ReturnsFalse() - { - Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40031u, renderRootResolved: false)); - } - - [Fact] - public void ShouldRenderIndoor_UnknownPlayerCell_TreatedAsOutside_ReturnsFalse() - { - // playerCellId == 0 (unresolved) → treat as outside (safe default: outdoor render). - Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0u, renderRootResolved: true)); - } - - [Fact] - public void ProbePortalChurn_DefaultsFalse_WhenEnvUnset() - { - // Env var is absent in the test host, so the flag must default false (inert probe). - Assert.False(AcDream.Core.Rendering.RenderingDiagnostics.ProbePortalChurnEnabled); - } - - // ── ACDREAM_DUMP_ENTITY parser (#119 decisive probe) ────────────────── - - [Fact] - public void ParseDumpEntityIds_NullOrEmpty_ReturnsEmptySet() - { - Assert.Empty(RenderingDiagnostics.ParseDumpEntityIds(null)); - Assert.Empty(RenderingDiagnostics.ParseDumpEntityIds("")); - Assert.Empty(RenderingDiagnostics.ParseDumpEntityIds(" ")); - } - - [Fact] - public void ParseDumpEntityIds_CommaSeparatedWithPrefixes_ParsesAll() - { - var set = RenderingDiagnostics.ParseDumpEntityIds("0x020003F2,0x020005D8"); - Assert.Equal(2, set.Count); - Assert.Contains(0x020003F2u, set); - Assert.Contains(0x020005D8u, set); - } - - [Fact] - public void ParseDumpEntityIds_NoPrefixMixedCaseAndWhitespace_Parses() - { - var set = RenderingDiagnostics.ParseDumpEntityIds(" 020003f2 , 0X020005D8 "); - Assert.Equal(2, set.Count); - Assert.Contains(0x020003F2u, set); - Assert.Contains(0x020005D8u, set); - } - - [Fact] - public void ParseDumpEntityIds_MalformedSegment_IgnoredNotFatal() - { - // A typo'd segment must not take the launch down — parse what's valid. - var set = RenderingDiagnostics.ParseDumpEntityIds("0x020003F2,zzz,,0x"); - Assert.Single(set); - Assert.Contains(0x020003F2u, set); - } + [InlineData(0xA9B40031u, true, false)] + [InlineData(0xA9B40171u, true, true)] + [InlineData(0xA9B40171u, false, false)] + [InlineData(0u, true, false)] + public void ShouldRenderIndoor_UsesPlayerCellAndResolvedRoot( + uint playerCellId, + bool renderRootResolved, + bool expected) + => Assert.Equal( + expected, + RenderingDiagnostics.ShouldRenderIndoor(playerCellId, renderRootResolved)); } diff --git a/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsVisibilityTests.cs b/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsVisibilityTests.cs deleted file mode 100644 index b854fb35..00000000 --- a/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsVisibilityTests.cs +++ /dev/null @@ -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 { [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 { [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(); - } - } -}