diff --git a/docs/architecture/worldbuilder-inventory.md b/docs/architecture/worldbuilder-inventory.md index 36cbb903..ce46942b 100644 --- a/docs/architecture/worldbuilder-inventory.md +++ b/docs/architecture/worldbuilder-inventory.md @@ -161,6 +161,58 @@ launcher-confirmed full rebuilds with beside-active validation and atomic promotion; overlays never cross a format boundary. Design and evidence: `docs/plans/2026-08-27-pak-v2-resource-campaign.md`. +**Retail GfxObj view-sphere correction (2026-08-31).** Bake recipe 7 +regenerates prepared GfxObj render records so their portal-view admission +uses the authored DrawingBSP root sphere retained by retail +`CGfxObj::Serialize`, rather than a sphere synthesized from the extracted +vertex AABB. The binary package format remains 2. Because this field affects +every GfxObj and the old records cannot be corrected without their source +DrawingBSP, migration 6→7 is an explicit full rebuild. + +The production leaf follows retail `RenderDeviceD3D::DrawMesh` / +`DrawMeshInternal`: portal viewcones perform coarse authored-sphere +admission, but an admitted GfxObj is submitted once as a complete mesh. +Likewise `PView::DrawCells` calls `DrawEnvCell` once per admitted cell and +draws its complete constructed shell. Portal polygons are not GPU mesh clips; +pixel occlusion comes from the ordered walk and depth buffer. + +**Retail static render-shadow cell membership (2026-08-31).** Indoor +statics are not rendered solely from their authored parent EnvCell. Retail's +`CEnvCell::init_static_objects` creates a `CPhysicsObj` for every static, +then `calc_cross_cells_static` + `CPartArray::AddPartsShadow` places every +visual part in each crossed cell's `shadow_part_list`, including decorative +GfxObjs that have no collision BSP. `WalkProductionWorldData` therefore owns +a distinct render-only per-cell index built from each MeshRef's prepared +visual AABB and the existing exact static part transit walk. This must not be +collapsed into `ShadowObjectRegistry`'s collision rows: the two retail lists +share transit math but have different membership. 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 regression: its authored parent is +`0x8A02015F`, while its visual parts also register in `0x8A02015E` and the +adjoining vertical cells. + +Moving multipart objects follow the sibling retail route rather than their +authored/feet cell alone: `CPhysicsObj::add_shadows_to_cells` installs every +`CPartArray` part in every cell of the object's retained `CELLARRAY`, and +`CPhysicsPart::Draw` stamps each part only after that part passes a cell's +portal test. `WalkProductionWorldData` therefore buckets live projections by +all `ShadowObjectRegistry.GetOwnerCells` rows, while the walk classifier owns +a projection+part drawn-pass stamp. Retail advances that stamp after +`LScape::draw` + `FlushAlphaList` and before the interior depth clear +(`PView::DrawCells` @0x005A4886), so a part may draw once in the landscape +pass and once again in the post-clear interior-cell pass; acdream re-arms the +classifier at that exact walk boundary. `add_shadows_to_cells` recursively +passes the root's same `CELLARRAY` to every attached child, so equipped-child render +projections resolve their accepted parent chain to the root's owner cells; +they do not fall back to one authored cell. Never replace the per-part stamp +with a whole-projection drawn-once gate: at a stair portal the torso may pass +in the first cell while head/hair fails and must retry through another crossed +cell. One binding exception is the local player: retail +`RenderDeviceD3D::DrawMeshInternal` bypasses `Get/SetDrawnThisFrame` when +`CPhysicsPart::IsPartOfPlayerObj` is true, repainting the player's parts at +every crossed-cell turn so later wall/depth ordering remains correct. + **Slice I3 prepared collision extension (2026-07-25).** At its introduction, the package remained format 1 and retained mesh type values 1–3; bake-tool 4 appended typed GfxObj, diff --git a/docs/launch-options.md b/docs/launch-options.md index 9decd203..2f5242d9 100644 --- a/docs/launch-options.md +++ b/docs/launch-options.md @@ -330,6 +330,12 @@ issue is closed, the strip was missed; delete both. | `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_CATHEDRAL_SKIP_SEALS` | Campaign FW floating-stair discriminator | `=1` | suppresses only the true-depth exit seals of cells `0xF4180107` and `0xF4180112`, whose coplanar authored exit polygons span the floating-stair seam at world Y~=24 | **Behavior-changing diagnostic**; never use as a production fix | `RenderingDiagnostics.ProbeCathedralSkipFloatingStairSeals` | +| `ACDREAM_PROBE_CATHEDRAL_SKIP_CELL_SHELLS` | Campaign FW floating-stair carrier discriminator | `=1` | suppresses only the visible EnvCell shells of cells `0xF4180107` and `0xF4180112`; missing walls are intentional so the moving wall-textured triangles can be attributed or exonerated | **Behavior-changing diagnostic**; never use as a production fix | `RenderingDiagnostics.ProbeCathedralSkipFloatingStairCellShells` | +| `ACDREAM_PROBE_CATHEDRAL_SKIP_SOUTH_LOOKIN_SHELLS` | Campaign FW south-building carrier discriminator | `=1` | suppresses the visible EnvCell shells of `0xF4180112/0113/0114` only during the south building's pre-clear look-in; the ordinary interior-root repaint is unchanged | **Behavior-changing diagnostic**; never use as a production fix | `RenderingDiagnostics.ProbeCathedralSkipSouthLookInCellShells` | +| `ACDREAM_PROBE_CATHEDRAL_SKIP_LOOKIN_SHELL` | Campaign FW exact look-in shell discriminator | full hexadecimal cell id, e.g. `F4180112` | suppresses one exact EnvCell shell only when reached through a building look-in; its ordinary interior-root repaint is unchanged | **Behavior-changing diagnostic**; never use as a production fix | `RenderingDiagnostics.ProbeCathedralSkipLookInShellCellId` | +| `ACDREAM_PROBE_CATHEDRAL_SKIP_SOUTH_BUILDING_SHELL` | Campaign FW south-building carrier discriminator | `=1` | suppresses only the own exterior shell of the cathedral building anchored at `0xF4180112`; its portal walk, look-in cells, and particles remain live | **Behavior-changing diagnostic**; never use as a production fix | `RenderingDiagnostics.ProbeCathedralSkipSouthBuildingShell` | +| `ACDREAM_PROBE_CATHEDRAL_SHELL_ORDER` | Campaign FW floating-stair shell-order trace | `=1` | samples the ONE walk's actual replay event order every fifth cathedral frame (`CLEAR`, `SEALS`, punches, marks, and EnvCell shells) and reports render-stamp-deduplicated whole-shell turns for cells `0xF4180104/0106/0107/0112/0113/0114` | print-only; never changes admission, depth state, or draw order | `RenderingDiagnostics.ProbeCathedralShellOrderEnabled` | | `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` | diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs index a1a2d5e2..28c3199e 100644 --- a/src/AcDream.App/Composition/FrameRootComposition.cs +++ b/src/AcDream.App/Composition/FrameRootComposition.cs @@ -507,7 +507,8 @@ internal sealed class FrameRootCompositionPhase ?? throw new InvalidOperationException( "The retail frame walk requires the landscape registry."), d.CellVisibility, - d.PhysicsEngine.ShadowObjects), + d.PhysicsEngine.ShadowObjects, + live.EquippedChildren.FindParentLocalId), retailPViewPassExecutor, retailPViewPassExecutor), retailPViewCells, diff --git a/src/AcDream.App/Rendering/ClipFrame.cs b/src/AcDream.App/Rendering/ClipFrame.cs index 7f8020f1..5e890cda 100644 --- a/src/AcDream.App/Rendering/ClipFrame.cs +++ b/src/AcDream.App/Rendering/ClipFrame.cs @@ -32,6 +32,7 @@ // silent std430/std140 drift can't reach the GPU. using System; using System.Numerics; +using System.Runtime.InteropServices; namespace AcDream.App.Rendering; @@ -200,6 +201,31 @@ public sealed class ClipFrame : IDisposable return slot; } + /// + /// Borrows the clip-space planes packed for one live slot. The walk uses + /// this to replay an EnvCell's exact captured portal_view while + /// stamping its exit polygons; it must not rebuild those views from the + /// legacy visibility assembly after the walk has already decided them. + /// + internal ReadOnlySpan GetSlotPlanes(uint slot) + { + if (slot >= (uint)_slotCount) + throw new ArgumentOutOfRangeException(nameof(slot)); + + int byteOffset = checked((int)slot * CellClipStrideBytes); + int count = checked((int)ReadUInt(_regionBytes, byteOffset)); + if ((uint)count > MaxPlanes) + { + throw new InvalidOperationException( + $"Clip slot {slot} contains invalid plane count {count}."); + } + + return MemoryMarshal.Cast( + _regionBytes.AsSpan( + byteOffset + CellClipPlanesOffset, + count * sizeof(float) * 4)); + } + /// /// Set the terrain OutsideView clip region (the single region the terrain /// shader gates against). length 0 ungates terrain @@ -243,6 +269,12 @@ public sealed class ClipFrame : IDisposable dst[offset + 3] = (byte)((value >> 24) & 0xFF); } + private static uint ReadUInt(byte[] src, int offset) => + (uint)(src[offset + 0] + | (src[offset + 1] << 8) + | (src[offset + 2] << 16) + | (src[offset + 3] << 24)); + private static void WriteInt(byte[] dst, int offset, int value) => WriteUInt(dst, offset, unchecked((uint)value)); diff --git a/src/AcDream.App/Rendering/Gpu/GpuPipelineDescription.cs b/src/AcDream.App/Rendering/Gpu/GpuPipelineDescription.cs index ef4b9cb8..cf702c2e 100644 --- a/src/AcDream.App/Rendering/Gpu/GpuPipelineDescription.cs +++ b/src/AcDream.App/Rendering/Gpu/GpuPipelineDescription.cs @@ -224,10 +224,12 @@ internal readonly record struct GpuDepthState(bool Test, bool Write, GpuCompareO /// /// Added at slice V6l. Core Vulkan 1.3 makes ALL of these dynamic /// (VK_DYNAMIC_STATE_STENCIL_OP, _COMPARE_MASK, _WRITE_MASK, -/// _REFERENCE), and #117's portal punch changes every one of them between -/// its mark pass and its punch pass, so they live here as a pipeline DEFAULT and -/// on as the per-draw override — -/// exactly the split cull mode, front face and depth write already have. +/// _REFERENCE). They live here as a pipeline DEFAULT and on +/// as the per-draw override — exactly +/// the split cull mode, front face and depth write already have. The portal +/// renderer no longer consumes stencil after Campaign FW restored retail's +/// ordered one-pass depth punch; the facility remains for render packs and +/// future stencil users. /// Whether the pipeline uses the stencil aspect at all is /// , because that is also the /// attachment intent. diff --git a/src/AcDream.App/Rendering/PortalDepthMaskRenderer.Rhi.cs b/src/AcDream.App/Rendering/PortalDepthMaskRenderer.Rhi.cs index 48432059..6548353d 100644 --- a/src/AcDream.App/Rendering/PortalDepthMaskRenderer.Rhi.cs +++ b/src/AcDream.App/Rendering/PortalDepthMaskRenderer.Rhi.cs @@ -9,21 +9,11 @@ namespace AcDream.App.Rendering; /// Campaign V slice V6l: the portal depth mask's RHI submission arm — V4g's /// remaining half, and the reason the slice grew a stencil dimension. /// -/// Plan §5.5.16 defect 2: this renderer's two-pass punch (#117) is built -/// on glStencilFunc/glStencilOp/glStencilMask and the pinned -/// carried no stencil state at all, so it -/// stayed raw GL and was invisible to the Vulkan arm. The reviewed amendment -/// puts the ENABLE and the attachment intent in the pipeline -/// () and the per-draw compare, -/// ops, reference and masks on , because -/// core Vulkan 1.3 makes exactly that split dynamic. -/// -/// Three pipelines, not one. Depth COMPARE is not dynamic in the -/// contract (only depth write is), and the punch's two passes differ in it — -/// mark tests LEQUAL and writes no depth, punch tests ALWAYS and -/// writes. The seal is a third: ALWAYS + write, with no stencil at all. -/// All three write no colour, which is what retail's "COLOR-INVISIBLE triangle -/// fan" means. +/// Campaign FW restores retail's ordered frame walk, so this Vulkan arm +/// now records the exact retail state for both operations: one color-invisible +/// triangle fan, depth compare ALWAYS, depth write enabled, culling disabled. +/// The push-constant pass selector chooses true projected depth (seal) or +/// forced far-Z (punch). There is no stencil mark pass or depth bias. /// /// Two other differences from the GL arm. The fan is expanded to a /// triangle LIST on the CPU, because has no @@ -39,9 +29,7 @@ public sealed partial class PortalDepthMaskRenderer private readonly IGpuDevice? _device; private readonly ICurrentGpuFrameSource? _frames; private readonly IWorldPassScope? _scope; - private IGpuPipeline? _sealPipeline; - private IGpuPipeline? _punchMarkPipeline; - private IGpuPipeline? _punchWritePipeline; + private IGpuPipeline? _depthWritePipeline; private bool _rhiFrameStarted; /// One position per vertex — the only attribute portal_depth.vert reads. @@ -51,16 +39,8 @@ public sealed partial class PortalDepthMaskRenderer new GpuVertexAttribute(0, GpuVertexFormat.Float3, 0))); /// - /// The stencil reference the mark pass writes and the punch pass gates on. - /// Retail has no equivalent — the whole stencil pass is acdream's - /// z-buffered replacement for retail's painter's-order safety (#117) — so - /// the value is arbitrary and only has to agree with itself. - /// - private const uint PunchStencilReference = 1; - - /// - /// The RHI arm's constructor. No GL context and no inline program: the three - /// pipelines compile portal_depth from the committed SPIR-V, and the + /// The RHI arm's constructor. No GL context and no inline program: the one + /// pipeline compiles portal_depth from the committed SPIR-V, and the /// per-frame fan vertices come from the frame ring. /// internal PortalDepthMaskRenderer( @@ -76,53 +56,17 @@ public sealed partial class PortalDepthMaskRenderer try { int samples = scope.SampleCount; - // SEAL: retail maxZ2, bit0 clear, data 0x00820e14 — depth ALWAYS at - // the polygon's true projected depth, no stencil. It runs - // immediately after the gated full depth clear, so there is no - // nearer content to stomp. - _sealPipeline = CreatePortalPipeline( + // Retail DrawPortalPolyInternal @0x0059BC90 uses the same depth + // state for seals and punches. maxZ2/maxZ1 only select the vertex + // depth written by the shader. + _depthWritePipeline = CreatePortalPipeline( device, - "portal-depth-seal", + "portal-depth-write", GpuCompareOp.Always, depthWrite: true, stencilTest: false, GpuStencilState.Default, samples); - // PUNCH pass A: mark stencil where the aperture fan passes a LEQUAL - // depth test at its (biased) true depth — i.e. where the aperture is - // actually visible against everything drawn so far. - _punchMarkPipeline = CreatePortalPipeline( - device, - "portal-depth-punch-mark", - GpuCompareOp.LessOrEqual, - depthWrite: false, - stencilTest: true, - GpuStencilState.Default with - { - Compare = GpuCompareOp.Always, - Fail = GpuStencilOp.Keep, - DepthFail = GpuStencilOp.Keep, - Pass = GpuStencilOp.Replace, - Reference = PunchStencilReference, - }, - samples); - // PUNCH pass B: the far-Z write on marked pixels only, zeroing the - // stencil as it goes so the buffer is self-cleaning. - _punchWritePipeline = CreatePortalPipeline( - device, - "portal-depth-punch-write", - GpuCompareOp.Always, - depthWrite: true, - stencilTest: true, - GpuStencilState.Default with - { - Compare = GpuCompareOp.Equal, - Fail = GpuStencilOp.Keep, - DepthFail = GpuStencilOp.Keep, - Pass = GpuStencilOp.Zero, - Reference = PunchStencilReference, - }, - samples); } catch { @@ -202,38 +146,14 @@ public sealed partial class PortalDepthMaskRenderer for (int i = 0; i < planeCount; i++) clipPlanes[i] = planes[i]; - if (!forceFarZ) - { - RecordPortalPass( - encoder, - _sealPipeline!, - clip, - vertices, - vertexCount, - in viewProjection, - renderPass: 0, - depthBias: 0f); - return; - } - RecordPortalPass( encoder, - _punchMarkPipeline!, + _depthWritePipeline!, clip, vertices, vertexCount, in viewProjection, - renderPass: 0, - depthBias: PunchMarkDepthBias); - RecordPortalPass( - encoder, - _punchWritePipeline!, - clip, - vertices, - vertexCount, - in viewProjection, - renderPass: 1, - depthBias: 0f); + renderPass: forceFarZ ? 1 : 0); } private static void RecordPortalPass( @@ -243,8 +163,7 @@ public sealed partial class PortalDepthMaskRenderer in GpuRingAllocation vertices, int vertexCount, in Matrix4x4 viewProjection, - int renderPass, - float depthBias) + int renderPass) { encoder.BindPipeline(pipeline); encoder.SetPushConstants(new GpuPushConstants @@ -258,8 +177,8 @@ public sealed partial class PortalDepthMaskRenderer LightDebug = 0, TextureIndexA = 0, TextureIndexB = 0, - ParamA = depthBias, - ParamB = PunchMarkBiasEyeCapMeters * CameraNearPlaneMeters, + ParamA = 0f, + ParamB = 0f, }); encoder.BindUniformBuffer( ClipFrame.TerrainClipUboBinding, @@ -286,12 +205,8 @@ public sealed partial class PortalDepthMaskRenderer catch (Exception error) { (failures ??= []).Add(error); } } - Attempt(() => _sealPipeline?.Dispose()); - _sealPipeline = null; - Attempt(() => _punchMarkPipeline?.Dispose()); - _punchMarkPipeline = null; - Attempt(() => _punchWritePipeline?.Dispose()); - _punchWritePipeline = null; + Attempt(() => _depthWritePipeline?.Dispose()); + _depthWritePipeline = null; _rhiFrameStarted = false; if (failures is not null) diff --git a/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs b/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs index a20f286f..13ea1ffc 100644 --- a/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs +++ b/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs @@ -8,13 +8,12 @@ namespace AcDream.App.Rendering; /// writes — the port of D3DPolyRender::DrawPortalPolyInternal /// (Ghidra 0x0059bc90, pc:424490). /// -/// Wired by T1 (BR-3, `579c8b0`): seal on interior roots, punch -/// on outdoor / look-in roots, via RetailPViewPassExecutor.DrawPortalDepthWrite -/// (the DrawExitPortalMasks slice callback) — safe alongside the -/// dynamics-drawn-LAST frame order (the first BR-2 attempt punched after -/// dynamics and erased the player; reverted 88be519). #117 (2026-06-11) -/// added the two-pass stencil depth gate on the punch side — see -/// . +/// Campaign FW's ordered frame walk restores the positional discipline +/// on which this primitive depends: farther landscape/static content is +/// submitted before a building punch, the punch precedes that building's +/// look-in cells, and nearer content repaints afterward. Consequently both +/// sides now use retail's one-pass depth operation directly; the former +/// stencil mark/bias approximation from #117 is intentionally gone. /// /// Retail projects a portal polygon, software-clips it against the /// installed portal view (polyClipFinish), and draws the survivor as a @@ -78,49 +77,6 @@ public sealed partial class PortalDepthMaskRenderer : IDisposable } /// - /// #117 (2026-06-11): the mark-pass depth bias, in NDC, toward the - /// viewer. Retail's punch is DEPTHTEST_ALWAYS and is safe only because - /// retail's outdoor pass is painter's-ordered far→near (anything nearer - /// redraws AFTER the punch and re-covers it). Our z-buffered MDI frame - /// has no such order, so an unconditional far-Z punch erased the depth - /// of NEARER occluders (terrain hills, closer buildings) at aperture - /// pixels — doors/interiors painted through them (the T5 #117 report). - /// The z-buffer-correct equivalent: punch ONLY where the aperture - /// polygon itself wins a depth test at its true depth (two-pass - /// stencil below). The bias keeps the #108 case covered — terrain - /// hugging the door plane (centimeters in front of the aperture) must - /// still be punched; a hill or another house meters nearer must not. - /// - private const float PunchMarkDepthBias = 0.0005f; - - /// - /// #129 (2026-06-12): NDC depth is non-linear — a constant NDC bias b - /// spans ≈ b·d²/near meters of eye depth at eye distance d. With - /// znear = 0.1, the 0.0005 constant alone spanned 0.125 m at 5 m but - /// ~190 m at a landblock away: every hill/house in front of a distant - /// aperture passed the mark and got far-Z punched — door-shaped leaks - /// through occluders. Fix: cap the bias's EYE-SPACE span at - /// . Below the ~10 m crossover - /// (sqrt(cap·near/0.0005)) the constant-NDC term is smaller and wins — - /// bit-identical to the T5-validated close-range behavior (#108 grass - /// coverage untouched); beyond it the punch can never reach an occluder - /// more than the cap in front of the aperture plane. - /// - public const float PunchMarkBiasEyeCapMeters = 0.5f; - - /// Retail Render::znear = 0.1 (decomp :342173, re-landed - /// d4b5c71). The cap conversion below assumes the production camera near - /// plane; the small f/(f−n) factor (~1.00002 at far 5000) is ignored. - public const float CameraNearPlaneMeters = 0.1f; - - /// CPU mirror of the vertex-shader mark-bias expression (keep in - /// sync with VertSrc): the NDC bias applied at eye depth - /// . - public static float MarkBiasNdc(float eyeDepthMeters) => - MathF.Min(PunchMarkDepthBias, - PunchMarkBiasEyeCapMeters * CameraNearPlaneMeters - / MathF.Max(eyeDepthMeters * eyeDepthMeters, 1e-6f)); - /// /// Draw one portal polygon as an invisible depth write, clipped to the /// slice's clip-space half-planes. selects @@ -130,13 +86,9 @@ public sealed partial class PortalDepthMaskRenderer : IDisposable /// depth ALWAYS + true projected depth. It runs immediately after the /// gated full depth clear, so there is no nearer content to stomp. /// - /// Punch (outdoor root / look-in): two passes (#117). - /// Pass A marks stencil where the aperture fan passes a LEQUAL depth - /// test at its (biased) true depth — i.e. where the aperture is - /// actually visible against everything drawn so far. Pass B writes the - /// far-Z punch with depth ALWAYS but stencil-gated to the marked - /// pixels, and zeroes the stencil as it goes (self-cleaning). This is - /// the z-buffered equivalent of retail's painter's-order safety. + /// Punch (outdoor root / look-in): one pass, retail-verbatim + /// — depth ALWAYS + far-Z. The ordered frame walk, not a visibility test + /// at the aperture plane, supplies retail's painter-order safety. /// public void DrawDepthFan( ReadOnlySpan worldVerts, diff --git a/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs index f58d36fb..2e529507 100644 --- a/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs +++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs @@ -207,7 +207,6 @@ internal sealed class WalkProductionLeafRenderer : IWalkFrameLeafRenderer private Action _drawExitSeals = null!; private readonly HashSet _singleCellScratch = new(); private readonly List _singleCellListScratch = new(); - private readonly Dictionary _singleCellClipScratch = new(1); internal WalkProductionLeafRenderer( RetailPViewPassExecutor passes, @@ -244,26 +243,32 @@ internal sealed class WalkProductionLeafRenderer : IWalkFrameLeafRenderer public void DrawTerrainSlice(int sliceIndex) => _passes.DrawWalkTerrainSlice(_frame, _clipAssembly, sliceIndex); - public void DrawCellShell(uint cellId, uint clipSlot) + public void DrawCellShell(uint cellId) { - _singleCellClipScratch.Clear(); - _singleCellClipScratch.Add(cellId, checked((int)clipSlot)); - _passes.SetCellShellClipRouting(_singleCellClipScratch); - try + // Output-gated binary discriminator for the moving wall-textured + // triangles at the 0xF4180107/0112 floating stairs. Missing walls are + // intentional while enabled: this identifies the visual carrier and + // must not become a production admission rule. + if (AcDream.Core.Rendering.RenderingDiagnostics + .ProbeCathedralSkipFloatingStairCellShells + && cellId is 0xF4180107u or 0xF4180112u) { - _singleCellScratch.Clear(); - _singleCellScratch.Add(cellId); - _passes.DrawOpaqueCellShells(_singleCellScratch); - if (_passes.CellHasTransparentShell(cellId)) - { - _singleCellListScratch.Clear(); - _singleCellListScratch.Add(cellId); - _passes.DrawTransparentCellShellsOrdered(_singleCellListScratch); - } + return; } - finally + + // Retail DrawEnvCell @0x0059F170 submits the prepared EnvCell mesh + // whole after stamping it drawn-this-frame. obj_view_set prepares + // legacy polygon/object tests, but the use_built_mesh branch calls + // D3DPolyRender::DrawMesh directly; it does not slice the shell mesh + // once per portal polygon. + _singleCellScratch.Clear(); + _singleCellScratch.Add(cellId); + _passes.DrawOpaqueCellShells(_singleCellScratch); + if (_passes.CellHasTransparentShell(cellId)) { - _passes.SetCellShellClipRouting(null); + _singleCellListScratch.Clear(); + _singleCellListScratch.Add(cellId); + _passes.DrawTransparentCellShellsOrdered(_singleCellListScratch); } } diff --git a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs index 44d0a30f..eee5062c 100644 --- a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs +++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs @@ -345,8 +345,13 @@ internal sealed partial class RetailPViewPassExecutor : public void DrawExitPortalMask( RetailPViewFrameInput frame, - RetailPViewCellSliceContext context) => - DrawPortalDepthWrite(context, frame, forceFarZ: frame.RootCell.IsOutdoorNode); + uint cellId, + ReadOnlySpan clipPlanes) => + DrawPortalDepthWrite( + cellId, + clipPlanes, + frame, + forceFarZ: frame.RootCell.IsOutdoorNode); public void DrawUnattachedSceneParticles( RetailPViewFrameInput frame, @@ -422,7 +427,8 @@ internal sealed partial class RetailPViewPassExecutor : frame.CameraCellResolution); private void DrawPortalDepthWrite( - RetailPViewCellSliceContext context, + uint cellId, + ReadOnlySpan clipPlanes, RetailPViewFrameInput frame, bool forceFarZ, int? onlyPortalIndex = null) @@ -432,7 +438,14 @@ internal sealed partial class RetailPViewPassExecutor : // apertures stamp far depth (punch). The renderer owns that choice. if (_portalDepthMask is null) return; - LoadedCell? cell = frame.Cells.Find(context.CellId); + if (!forceFarZ + && AcDream.Core.Rendering.RenderingDiagnostics + .ProbeCathedralSkipFloatingStairSeals + && cellId is 0xF4180107u or 0xF4180112u) + { + return; + } + LoadedCell? cell = frame.Cells.Find(cellId); if (cell is null) return; @@ -462,7 +475,7 @@ internal sealed partial class RetailPViewPassExecutor : _diagnostics.EmitSeamMask( RenderingDiagnostics.ProbeSeamDrawEnabled, RenderingDiagnostics.SeamDrawTargetCells, - context.CellId, + cellId, index, forceFarZ, world[..count]); @@ -470,7 +483,7 @@ internal sealed partial class RetailPViewPassExecutor : _portalDepthMask.DrawDepthFan( world[..count], frame.ViewProjection, - context.Slice.Planes, + clipPlanes, forceFarZ); } } diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index fb6cf940..d8ceb098 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -20,7 +20,6 @@ internal sealed class RetailPViewRenderer private static readonly ClipViewSlice NoClipSlice = new(0, new Vector4(-1f, -1f, 1f, 1f), Array.Empty()); - private static readonly ClipViewSlice[] NoClipSlices = { NoClipSlice }; private static readonly IReadOnlySet NoParticleOwners = new HashSet(); @@ -39,6 +38,13 @@ 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 ulong _probeCathedralShellOrderFrame; + 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 @@ -72,7 +78,8 @@ internal sealed class RetailPViewRenderer Walk.WalkBuildingRegistry walkBuildings, Walk.WalkLandscapeAssembler walkLandscape, CellVisibility walkCellRegistry, - ShadowObjectRegistry shadows) + ShadowObjectRegistry shadows, + Func? findParentLocalId = null) { _renderSceneShadow = renderSceneShadow ?? throw new ArgumentNullException(nameof(renderSceneShadow)); @@ -84,7 +91,8 @@ internal sealed class RetailPViewRenderer ?? throw new ArgumentNullException(nameof(walkCellRegistry)); _walkWorldData = new Walk.WalkProductionWorldData( _walkBuildings, - shadows ?? throw new ArgumentNullException(nameof(shadows))); + shadows ?? throw new ArgumentNullException(nameof(shadows)), + findParentLocalId); _walkClearInteriorDepthAction = ClearWalkInteriorDepth; _walkDrawExitSealsAction = DrawWalkExitSeals; } @@ -282,6 +290,134 @@ internal sealed class RetailPViewRenderer _drawableCellsScratch.UnionWith(walkDriver.VisitedCells); walkDriver.CopyVisibleCellsTo(_visibleCellsScratch); + if (AcDream.Core.Rendering.RenderingDiagnostics + .ProbeCathedralShellOrderEnabled + && ((ctx.ViewerCellId & 0xFFFF0000u) == 0xF4180000u + || (ctx.PlayerCellId & 0xFFFF0000u) == 0xF4180000u)) + { + _probeCathedralShellOrderFrame++; + walkDriver.TraceCathedralShellOrder( + _probeCathedralShellOrderFrame, + ctx.ViewerCellId, + ctx.PlayerCellId, + ctx.RootCell.CellId, + ctx.ViewerEyePos); + } + + 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 @@ -391,14 +527,11 @@ internal sealed class RetailPViewRenderer RetailPViewPassExecutor passes = _activeWalkPasses ?? throw new InvalidOperationException( "The retained walk leaf has no active pass binding."); - ClipFrameAssembly clipAssembly = _activeWalkClipAssembly - ?? throw new InvalidOperationException( - "The retained walk leaf has no active clip binding."); Walk.WalkFrameDriver driver = _walkFrameDriverScratch ?? throw new InvalidOperationException( "The retained walk leaf has no active driver binding."); - DrawWalkExitPortalMasks(frame, passes, clipAssembly, driver); + DrawWalkExitPortalMasks(frame, passes, driver); } private void ClearWalkFrameBindings() @@ -528,27 +661,27 @@ internal sealed class RetailPViewRenderer /// #456 cathedral seam band (its never-drawn panel family), leaving /// aperture depth unsealed after the interior clear; the end-of-frame /// alpha drain (cell-owned emitters — retail's own timing) then - /// z-passes across the whole opening (the falls shine-through). Per-cell - /// slice clips still come from the old assembly where present; a cell - /// the old apparatus missed seals unclipped (the depth fan is the exact - /// dat aperture polygon and z-tests, so over-coverage is benign). + /// z-passes across the whole opening (the falls shine-through). Each + /// portal is stamped once per exact walk-owned view captured for that + /// flood cell, matching retail's CEnvCell::setup_view loop. The + /// legacy visibility assembly has no production role here. private void DrawWalkExitPortalMasks( RetailPViewFrameInput ctx, RetailPViewPassExecutor passes, - ClipFrameAssembly clipAssembly, Walk.WalkFrameDriver driver) { List floodCells = driver.InteriorFloodCells; for (int i = floodCells.Count - 1; i >= 0; i--) { uint cellId = floodCells[i]; - foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId)) + int sliceCount = driver.InteriorFloodViewSliceCountAt(i); + for (int sliceIndex = 0; sliceIndex < sliceCount; sliceIndex++) + { passes.DrawExitPortalMask( ctx, - new RetailPViewCellSliceContext( - cellId, - slice, - NoParticleOwners)); + cellId, + driver.InteriorFloodViewClipPlanesAt(i, sliceIndex)); + } } } @@ -573,18 +706,6 @@ internal sealed class RetailPViewRenderer MeshPartCount: 0); } - private static ClipViewSlice[] GetCellSlicesOrNoClip( - ClipFrameAssembly clipAssembly, - uint cellId) - { - if (clipAssembly.CellIdToViewSlices.TryGetValue(cellId, out var slices) - && slices.Length > 0) - { - return slices; - } - - return NoClipSlices; - } } public interface IRetailPViewCellSource diff --git a/src/AcDream.App/Rendering/Shaders/portal_depth.vert b/src/AcDream.App/Rendering/Shaders/portal_depth.vert index 1183c01e..47d667cb 100644 --- a/src/AcDream.App/Rendering/Shaders/portal_depth.vert +++ b/src/AcDream.App/Rendering/Shaders/portal_depth.vert @@ -20,7 +20,6 @@ // selector. This shader's two passes ARE seal // (retail maxZ2, true projected depth) and punch // (retail maxZ1, far-plane z). -// uDepthBias / EyeCapN -> uParamA / uParamB. layout(location = 0) in vec3 aPos; @@ -29,8 +28,6 @@ layout(location = 0) in vec3 aPos; // the shared 96-byte push block (tools/ShaderCompiler/VulkanGlslPreamble.cs). uniform mat4 uViewProjection; uniform int uRenderPass; // 0 = seal (retail maxZ2), 1 = punch (retail maxZ1) -uniform float uParamA; // #117 mark-pass NDC bias toward the viewer -uniform float uParamB; // #129 eye-span cap x near plane layout(std140, ACDREAM_UBO_SET binding = 2) uniform TerrainClip { int uTerrainClipCount; @@ -55,16 +52,5 @@ void main() { clipPos.z = clipPos.w * 0.99999988; // retail far-z punch constant (0x0059bc90 tail) } - else if (uParamA > 0.0) - { - // #117 mark-pass bias, #129 eye-space cap. clipPos.w = eye depth d; - // an NDC bias b spans ~b*d*d/near meters of eye depth, so the - // constant-NDC form alone reached METERS at distance (door-shaped - // leaks through hills/houses). Keep in sync with - // PortalDepthMaskRenderer.MarkBiasNdc. - float biasNdc = min(uParamA, uParamB / max(clipPos.w * clipPos.w, 1e-6)); - clipPos.z -= biasNdc * clipPos.w; - } - gl_Position = clipPos; } diff --git a/src/AcDream.App/Rendering/Shaders/spv/portal_depth.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/portal_depth.vert.spv index 87457f0c..5174cf0c 100644 Binary files a/src/AcDream.App/Rendering/Shaders/spv/portal_depth.vert.spv and b/src/AcDream.App/Rendering/Shaders/spv/portal_depth.vert.spv differ diff --git a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json index 3792e997..699a7c54 100644 --- a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json +++ b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json @@ -295,7 +295,7 @@ "stages": [ { "stage": "vert", - "sourceSha256": "6214fc04936d92320594acb722e63f1cd1f7106dd4d0af60ed985abc2a50c4a9", + "sourceSha256": "1df7e2009cda8f84ba3d84546bf58baf71d10fe53e696655b4ca7b8292a32fa5", "compiled": true }, { diff --git a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs index 5892c3ae..35ddc08f 100644 --- a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs +++ b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs @@ -119,8 +119,10 @@ internal interface IWalkFrameLeafRenderer /// draws ALL shells in reverse cell_draw_list order, then starts a /// second reverse loop for DrawObjCellForDummies @0x005a4b0d. /// The ordinary interior-root flood and every building look-in flood use - /// this same two-pass discipline. - void DrawCellShell(uint cellId, uint clipSlot); + /// this same two-pass discipline. Retail's runtime use_built_mesh + /// branch submits the complete constructed shell; the cell's drawn stamp, + /// portal depth writes, and later depth-tested repaint own visibility. + void DrawCellShell(uint cellId); /// One landscape cell's or building shell's static-owner /// particle submission, at its own walk turn — see @@ -238,9 +240,10 @@ internal enum WalkFrameEventKind : byte TerrainSlice, /// — - /// is the cell and - /// is the exact portal-view GPU clip - /// slot for this retail DrawEnvCell turn. + /// is the cell. Retail's + /// DrawEnvCell marks the EnvCell drawn before submitting its built + /// mesh, so the shell is whole and frame-deduplicated; portal-view slices + /// are not shell geometry clips. CellShell, /// — @@ -308,6 +311,14 @@ internal interface IWalkLookInViewSource in Vector3 center, float radius, bool testSphere); + + /// Output-only description of the exact CY/edge-plane distances + /// used by one DrawObjCell turn. Kept behind the existing Facility probe; + /// production admission never consumes this text. + string DescribeLookInTurn( + int routeIndex, + in Vector3 center, + float radius) => "unavailable"; } internal readonly record struct WalkLookInSlice( @@ -367,8 +378,8 @@ internal readonly struct WalkFrameEvent internal static WalkFrameEvent TerrainSlice(int sliceIndex) => new(WalkFrameEventKind.TerrainSlice, sliceIndex, 0, 0f, null); - internal static WalkFrameEvent CellShell(uint cellId, uint clipSlot) => - new(WalkFrameEventKind.CellShell, checked((int)clipSlot), cellId, 0f, null); + internal static WalkFrameEvent CellShell(uint cellId) => + new(WalkFrameEventKind.CellShell, 0, cellId, 0f, null); internal static WalkFrameEvent PunchFan(WalkPolygon worldPolygon, int activeViewIndex) => new(WalkFrameEventKind.PunchFan, activeViewIndex, 0, 0f, worldPolygon); @@ -505,12 +516,8 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource private readonly List _floodViewRouteScratch = new(); private WalkPlane _lookInCyPlane; - // Retail adds each physics object to every overlapped cell's shadow-part - // list, then CPhysicsPart::Get/SetDrawnThisFrame (0x0059F388) prevents - // those aliases from drawing more than once. The walk's outdoor buckets - // now carry the same multi-cell aliases, so retain the same frame guard. - private readonly HashSet _outdoorDrawnThisFrame = new(); private readonly HashSet _outdoorParticleOwnersDrawnThisFrame = new(); + private readonly HashSet _cellShellsDrawnThisFrame = new(); IReadOnlyList IWalkLookInViewSource.LookInCellTurns => LookInCellTurns; @@ -529,6 +536,52 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource /// alpha drain splats through (the cathedral falls shine-through). internal List InteriorFloodCells { get; } = new(); + /// Number of exact walk-owned portal-view slices that retail's + /// CEnvCell::setup_view installs while sealing one entry in + /// . The matching route was captured at + /// that cell's shell turn and remains valid through synchronous replay. + internal int InteriorFloodViewSliceCountAt(int floodCellIndex) + { + int routeIndex = InteriorFloodViewRouteAt(floodCellIndex); + return _lookInTurns[routeIndex].SliceCount; + } + + /// Borrows the clip-space half-planes for one exact walk-owned + /// portal-view slice. An empty span is the legitimate no-clip slot, not a + /// fallback to another visibility structure. + internal ReadOnlySpan InteriorFloodViewClipPlanesAt( + int floodCellIndex, + int sliceOffset) + { + int routeIndex = InteriorFloodViewRouteAt(floodCellIndex); + WalkLookInTurn turn = _lookInTurns[routeIndex]; + if ((uint)sliceOffset >= (uint)turn.SliceCount) + throw new ArgumentOutOfRangeException(nameof(sliceOffset)); + + WalkLookInSlice slice = _lookInSlices[turn.SliceStart + sliceOffset]; + if (_clipFrame is null) + return ReadOnlySpan.Empty; + return _clipFrame.GetSlotPlanes(slice.ClipSlot); + } + + private int InteriorFloodViewRouteAt(int floodCellIndex) + { + if ((uint)floodCellIndex >= (uint)InteriorFloodCells.Count + || (uint)floodCellIndex >= (uint)_floodViewRouteScratch.Count) + { + throw new ArgumentOutOfRangeException(nameof(floodCellIndex)); + } + + int routeIndex = _floodViewRouteScratch[floodCellIndex]; + if ((uint)routeIndex >= (uint)_lookInTurns.Count) + { + throw new InvalidOperationException( + $"Interior flood cell 0x{InteriorFloodCells[floodCellIndex]:X8} " + + "has no captured portal-view route."); + } + return routeIndex; + } + // Replay scratch for StaticParticles events (sequential replay — one // reused set is safe). private readonly HashSet _staticParticleOwnerScratch = new(); @@ -624,8 +677,9 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource _lookInPlanes.Clear(); _visibleClipSlotScratch.Clear(); _floodViewRouteScratch.Clear(); - _outdoorDrawnThisFrame.Clear(); + _dispatcher.EndWalkPartFrame(); _outdoorParticleOwnersDrawnThisFrame.Clear(); + _cellShellsDrawnThisFrame.Clear(); _lookInCyPlane = default; LookInCells.Clear(); VisitedBuildings.Clear(); @@ -741,6 +795,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource + "EndFrame (or let a thrown exception's cleanup run) before starting the next."); } + _dispatcher.BeginWalkPartFrame(); _ctx = ctx; _viewProjection = viewProjection; _cameraWorldPosition = cameraWorldPosition; @@ -759,8 +814,8 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource _lookInPlanes.Clear(); _visibleClipSlotScratch.Clear(); _lookInCyPlane = ctx.CyPlane; - _outdoorDrawnThisFrame.Clear(); _outdoorParticleOwnersDrawnThisFrame.Clear(); + _cellShellsDrawnThisFrame.Clear(); LookInCells.Clear(); VisitedBuildings.Clear(); VisitedLandscapeCellIds.Clear(); @@ -784,6 +839,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource } finally { + _dispatcher.EndWalkPartFrame(); _ctx = null; _readyToReplay = true; } @@ -854,7 +910,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource _leafRenderer.DrawTerrainSlice(e.IntArg); break; case WalkFrameEventKind.CellShell: - _leafRenderer.DrawCellShell(e.CellId, checked((uint)e.IntArg)); + _leafRenderer.DrawCellShell(e.CellId); break; case WalkFrameEventKind.PunchFan: _leafRenderer.DrawPunchFan(e.Polygon!, e.IntArg); @@ -920,6 +976,113 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource } } + /// + /// Output-only Campaign FW cathedral trace. The event list described here + /// is the same list immediately executes, so this + /// captures the actual clear/seal/punch/shell submission order without + /// inserting a second visibility walk or changing rendering behavior. + /// + internal void TraceCathedralShellOrder( + ulong frame, + uint viewerCellId, + uint playerCellId, + uint rootCellId, + Vector3 viewerEye) + { + if (!AcDream.Core.Rendering.RenderingDiagnostics + .ProbeCathedralShellOrderEnabled + || !_readyToReplay + || (frame != 1 && frame % 5 != 0)) + { + return; + } + + static bool IsCathedralCell(uint cellId) => + (cellId & 0xFFFF0000u) == 0xF4180000u + && (cellId & 0xFFFFu) >= 0x0100u; + + static bool IsFloatingStairCell(uint cellId) => cellId is + 0xF4180104u or 0xF4180106u or 0xF4180107u + or 0xF4180112u or 0xF4180113u or 0xF4180114u; + + var order = new System.Text.StringBuilder(1024); + order.Append("[cathedral-shell-order] f=").Append(frame) + .Append(" viewer=0x").Append(viewerCellId.ToString("X8")) + .Append(" player=0x").Append(playerCellId.ToString("X8")) + .Append(" root=0x").Append(rootCellId.ToString("X8")) + .Append(" eye=(").Append(viewerEye.X.ToString("F4")) + .Append(',').Append(viewerEye.Y.ToString("F4")) + .Append(',').Append(viewerEye.Z.ToString("F4")).Append(')') + .Append(" flood=["); + for (int i = 0; i < InteriorFloodCells.Count; i++) + { + if (i != 0) + order.Append(','); + order.Append("0x").Append(InteriorFloodCells[i].ToString("X8")); + } + order.Append("] lookIn=["); + for (int i = 0; i < LookInCellTurns.Count; i++) + { + if (i != 0) + order.Append(','); + order.Append("0x").Append(LookInCellTurns[i].ToString("X8")); + } + order.Append("] replay="); + + var clips = new System.Text.StringBuilder(1024); + clips.Append("[cathedral-shell-clips] f=").Append(frame).Append(" slots="); + bool wroteClip = false; + + for (int i = 0; i < _events.Count; i++) + { + WalkFrameEvent e = _events[i]; + switch (e.Kind) + { + case WalkFrameEventKind.StreamMark: + order.Append('>').Append(i).Append(":M").Append(e.IntArg); + break; + case WalkFrameEventKind.AlphaSubmitMark: + order.Append('>').Append(i).Append(":AM").Append(e.IntArg); + break; + case WalkFrameEventKind.Sky: + order.Append('>').Append(i).Append(":SKY"); + break; + case WalkFrameEventKind.TerrainSlice: + order.Append('>').Append(i).Append(":T").Append(e.IntArg); + break; + case WalkFrameEventKind.CellShell when IsCathedralCell(e.CellId): + order.Append('>').Append(i).Append(":S") + .Append((e.CellId & 0xFFFFu).ToString("X4")); + + if (IsFloatingStairCell(e.CellId)) + { + if (wroteClip) + clips.Append(';'); + wroteClip = true; + clips.Append('S') + .Append((e.CellId & 0xFFFFu).ToString("X4")) + .Append("=whole-once"); + } + break; + case WalkFrameEventKind.PunchFan: + order.Append('>').Append(i).Append(":P").Append(e.IntArg); + break; + case WalkFrameEventKind.AlphaBarrier: + order.Append('>').Append(i).Append(":AB"); + break; + case WalkFrameEventKind.ClearInteriorDepth: + order.Append('>').Append(i).Append(":CLEAR"); + break; + case WalkFrameEventKind.ExitSeals: + order.Append('>').Append(i).Append(":SEALS"); + break; + } + } + + Console.WriteLine(order.ToString()); + Console.WriteLine(clips.ToString()); + } + // ------------------------------------------------------------------ // IWalkEventSink // ------------------------------------------------------------------ @@ -1006,13 +1169,13 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource _populator.PopulateOutdoorStatics( _stream, cellId, records.Records, records.TupleLandblockId, _cameraWorldPosition, _viewProjection, - this, _landscapeViewRouteIndex, _outdoorDrawnThisFrame, - _alphaSubmissions); + this, _landscapeViewRouteIndex, + alphaSubmissions: _alphaSubmissions); _populator.PopulateCellDynamics( _stream, cellId, dynamics.Records, dynamics.TupleLandblockId, _cameraWorldPosition, _viewProjection, - this, _landscapeViewRouteIndex, _outdoorDrawnThisFrame, - _alphaSubmissions); + this, _landscapeViewRouteIndex, + alphaSubmissions: _alphaSubmissions); if (_alphaSubmissions.Count != _alphaSubmitMark) { MarkIfGrown(); @@ -1078,14 +1241,21 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource // look-in flood appended (keeps every range single-stage). MarkIfGrown(); WalkFrameStaticRecords shell = _worldData.GetBuildingShellStatics(building); - _populator.PopulateCell( - _stream, WalkDrawStage.BuildingShell, building.PositionCellId, - shell.Records, shell.TupleLandblockId, _cameraWorldPosition, _viewProjection, - alphaSubmissions: _alphaSubmissions); - if (_alphaSubmissions.Count != _alphaSubmitMark) + bool skipSouthBuildingShell = + AcDream.Core.Rendering.RenderingDiagnostics + .ProbeCathedralSkipSouthBuildingShell + && WalkProductionWorldData.AnchorCellId(building) == 0xF4180112u; + if (!skipSouthBuildingShell) { - MarkIfGrown(); - MarkAlphaIfGrown(); + _populator.PopulateCell( + _stream, WalkDrawStage.BuildingShell, building.PositionCellId, + shell.Records, shell.TupleLandblockId, _cameraWorldPosition, _viewProjection, + alphaSubmissions: _alphaSubmissions); + if (_alphaSubmissions.Count != _alphaSubmitMark) + { + MarkIfGrown(); + MarkAlphaIfGrown(); + } } // FW4 (the #132 positional invariant): the building's own shell // emitters submit at the shell turn, after the shell content @@ -1104,6 +1274,22 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource ArgumentNullException.ThrowIfNull(polygon); RequireOpenFrame(); + // Output-gated binary discriminator for the cathedral's moving + // transparent seam. Cell 0xF4180112 belongs to a distinct authored + // building which is reached as a look-in while the camera remains in + // 0xF4180107/0108. Suppress only that building's far-Z punch so a + // visual gate can prove whether the apparent "beam" is the aperture + // depth write or the seven-part stair Setup behind it. Never enabled + // in normal production. + if (AcDream.Core.Rendering.RenderingDiagnostics + .ProbeCathedralSkipStairBuildingPunch + && Array.Exists( + building.Portals, + portal => portal.OtherCellId == 0xF4180112u)) + { + return; + } + MarkIfGrown(); Matrix4x4 worldTransform = _worldData.GetBuildingWorldTransform(building); _events.Add( @@ -1115,12 +1301,28 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource ArgumentNullException.ThrowIfNull(cells); RequireOpenFrame(); + // PView::DrawCells @0x005a4840 advances m_nFrameStamp at 0x005a4886 + // after LScape::draw + FlushAlphaList and before the depth clear. Its + // drawn-part AND DrawEnvCell dedupe is therefore per render stamp, not + // per presented frame: content admitted during the landscape must + // remain eligible for the interior-cell repaint after the clear. In + // Collect, every landscape candidate has been classified by this point + // and no interior-root candidate has, so re-arming both CPU-side stamp + // mirrors here is the exact boundary. Without the shell re-arm, color + // from a pre-clear building look-in survives while the root repaint is + // incorrectly suppressed, producing wall-textured bleed slabs. + MarkIfGrown(); + if (_skyDrawnThisFrame) + { + _dispatcher.AdvanceWalkPartPassStamp(); + _cellShellsDrawnThisFrame.Clear(); + } + // PView::DrawCells @0x005a4840: the gated full depth clear // (pc:432731-432732) then the exit-portal seals (pc:432785-432786) — // both unconditional for an interior root's own flood, whether or // not a landscape turn just ran (see this driver's type doc // comment). - MarkIfGrown(); _events.Add(WalkFrameEvent.ClearInteriorDepth()); MarkIfGrown(); @@ -1215,18 +1417,32 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource _floodViewRouteScratch.Add(-1); // PView::DrawCells @0x005A4840, loop 2 (005A4A00–005A4ADE): - // cell_draw_list[count - 1] down to zero. DrawEnvCell loops the - // cell's live portal_view slices, so capture those slices here and - // replay the shell once per exact GPU clip slot. + // cell_draw_list[count - 1] down to zero. The loop calls setup_view + + // DrawEnvCell for each live view, but DrawEnvCell @0x0059F170 begins + // with GetDrawnThisFrame/SetDrawnThisFrame. Its built-mesh path then + // calls D3DPolyRender::DrawMesh directly: the EnvCell shell is drawn + // WHOLE on the first call and every later view/cross-flood call is a + // no-op for this render stamp. Portal-view clipping remains active for + // the following object-list loop; it is not a shell geometry clip. for (int i = cells.Count - 1; i >= 0; i--) { int viewRouteIndex = CaptureCellViewRoute(cells[i]); _floodViewRouteScratch[i] = viewRouteIndex; - IReadOnlyList clipSlots = VisibleClipSlotsInLookInTurn( - viewRouteIndex, default, 0f, testSphere: false); - MarkIfGrown(); - for (int slotIndex = 0; slotIndex < clipSlots.Count; slotIndex++) - _events.Add(WalkFrameEvent.CellShell(cells[i], clipSlots[slotIndex])); + if (_cellShellsDrawnThisFrame.Add(cells[i])) + { + bool skipSouthLookInShell = + stage == WalkDrawStage.LookInStatic + && ((AcDream.Core.Rendering.RenderingDiagnostics + .ProbeCathedralSkipSouthLookInCellShells + && cells[i] is 0xF4180112u or 0xF4180113u or 0xF4180114u) + || AcDream.Core.Rendering.RenderingDiagnostics + .ProbeCathedralSkipLookInShellCellId == cells[i]); + if (!skipSouthLookInShell) + { + MarkIfGrown(); + _events.Add(WalkFrameEvent.CellShell(cells[i])); + } + } } // Loop 3 (005A4ADE–005A4B2D): restart at count - 1 and draw each @@ -1372,6 +1588,40 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource return _visibleClipSlotScratch; } + public string DescribeLookInTurn( + int routeIndex, + in Vector3 center, + float radius) + { + if ((uint)routeIndex >= (uint)_lookInTurns.Count) + return "route-missing"; + + WalkLookInTurn turn = _lookInTurns[routeIndex]; + var description = new System.Text.StringBuilder(192); + float cyDistance = Vector3.Dot(_lookInCyPlane.Normal, center) + + _lookInCyPlane.D; + description.Append("turnCell=0x") + .Append(turn.CellId.ToString("X8")) + .Append(" slices=").Append(turn.SliceCount) + .Append(" cy=").Append(cyDistance.ToString("F5")) + .Append(" cyMargin=").Append((cyDistance + radius).ToString("F5")); + + for (int sliceOffset = 0; sliceOffset < turn.SliceCount; sliceOffset++) + { + WalkLookInSlice slice = _lookInSlices[turn.SliceStart + sliceOffset]; + description.Append(" slice[").Append(sliceOffset).Append("]="); + for (int planeOffset = 0; planeOffset < slice.PlaneCount; planeOffset++) + { + if (planeOffset != 0) + description.Append(','); + WalkPlane plane = _lookInPlanes[slice.PlaneStart + planeOffset]; + float distance = Vector3.Dot(plane.Normal, center) + plane.D; + description.Append(distance.ToString("F5")); + } + } + return description.ToString(); + } + /// Converts one retail pixel-space portal_view polygon into the /// clip-space half-planes consumed by mesh_modern.vert. copy_view already /// deduplicates and removes collinear vertices, so 3..8 points map diff --git a/src/AcDream.App/Rendering/Walk/WalkPView.cs b/src/AcDream.App/Rendering/Walk/WalkPView.cs index 3e5f7984..cc65f9b6 100644 --- a/src/AcDream.App/Rendering/Walk/WalkPView.cs +++ b/src/AcDream.App/Rendering/Walk/WalkPView.cs @@ -24,6 +24,12 @@ public sealed class WalkPView { private static int _masterTimestamp; + /// + /// Output-only correlation key for diagnostics emitted downstream from + /// the same frame walk. Production admission must never depend on it. + /// + internal static int MasterTimestampForDiagnostics => _masterTimestamp; + private readonly struct TodoEntry(WalkCell cell, float dist) { public readonly WalkCell Cell = cell; @@ -177,12 +183,28 @@ public sealed class WalkPView for (int j = 0; j < cell.Portals.Length; j++) { ref WalkPortalFlags flags = ref top.PortalFlags[j]; - if (!flags.Seen || flags.InView) continue; 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) { cell.CachedNeighbors[j] = ctx.GetVisible(portal.OtherCellId); - if (cell.CachedNeighbors[j] is null) continue; // not loaded: silently dead + 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 + } } anyLive = true; } @@ -201,7 +223,17 @@ public sealed class WalkPView cell, portal.PortalSide, cell.PortalPolygons[portal.PolygonIndex], doClip: true, ctx, _clipScratch); - if (n == 0) continue; + 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; + } if (portal.OtherCellId == 0xFFFFFFFFu) { @@ -223,18 +255,125 @@ public sealed class WalkPView { n = OtherPortalClip(cell, j, n, ctx); SetView(top, i); // restore after the far-frame excursion - if (n == 0) continue; + 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) - WalkCopyView.Append( + appended = 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 a8aed48e..cb149869 100644 --- a/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs +++ b/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs @@ -1,5 +1,6 @@ using System.Numerics; using System.Runtime.InteropServices; +using System.Text; using AcDream.App.Rendering.Scene; using AcDream.Core.Physics; @@ -12,10 +13,18 @@ namespace AcDream.App.Rendering.Walk; /// : /// /// -/// Cell statics — on -/// demand, one arena segment per distinct cell per frame (a cell can be -/// visited once by the root flood OR once per admitting look-in portal; the -/// per-frame cache keeps the copy single). +/// Cell statics — ONE sweep +/// over authored indoor statics, bucketed into every cell crossed by the +/// object's visual parts. This is retail's CPartArray::AddPartsShadow +/// render index, which deliberately includes non-colliding decorations and +/// is distinct from the physics shadow-object index. +/// Live dynamics — ONE global dynamic-index sweep, bucketed into every +/// cell in the object's retained physics CELLARRAY. Retail feeds that +/// exact array to CPhysicsObj::add_shadows_to_cells, which calls +/// CPartArray::AddPartsShadow for every member cell. A creature crossing +/// a stair portal must therefore remain drawable from both the feet cell and +/// the head cell; indexing only by its authored parent makes individual body +/// parts disappear at the portal edge. /// Outdoor statics — ONE /// sweep bucketed by landscape cell id /// ((lb & 0xFFFF0000) | (cellX*8 + cellY + 1) from the record's @@ -24,10 +33,10 @@ namespace AcDream.App.Rendering.Walk; /// shell turn, retail CPhysicsPart::Draw(parts, 0) @0x0059f331, not /// at the cell's DrawObjCell turn). /// Building shells — the same sweep's IsBuildingShell records -/// bucketed by Source.BuildingShellAnchorCellId; a -/// maps to its anchor via its first -/// non-exit portal's destination (the SAME rule -/// LandblockLoader used to author the anchor). +/// bucketed by Source.BuildingShellAnchorCellId for portal-bearing +/// buildings. Portal-less buildings have no interior anchor; retail still +/// draws them at their landscape position-cell turn, so those records use +/// Source.EffectCellId, matching . /// /// /// The tuple landblock id handed to the classifier is the frame's player @@ -56,6 +65,7 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData { private readonly WalkBuildingRegistry _buildings; private readonly ShadowObjectRegistry _shadows; + private readonly Func _findParentLocalId; private RenderSceneQuery _scene; private uint _tupleLandblockId; private int _renderCenterLbX; @@ -63,16 +73,22 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData private readonly Dictionary _cellCache = new(); private readonly Dictionary _cellDynamicCache = new(); + private readonly Dictionary _facilityShadowProbeSignatures = new(); + private readonly Dictionary> _indoorByCell = new(); + private readonly Dictionary> _indoorDynamicsByCell = new(); private readonly Dictionary> _outdoorByCell = new(); private readonly Dictionary> _outdoorDynamicsByCell = new(); private readonly Dictionary> _shellsByAnchor = new(); private readonly Dictionary _outdoorMaterialized = new(); private readonly Dictionary _outdoorDynamicsMaterialized = new(); private readonly Dictionary _shellMaterialized = new(); + private readonly Dictionary + _staticRenderCellCache = new(); + private ulong _staticRenderCellIndexRevision = ulong.MaxValue; + private RenderSceneGeneration _staticRenderCellGeneration; + private RenderProjectionRecord[] _indoorSweepScratch = new RenderProjectionRecord[256]; private RenderProjectionRecord[] _sweepScratch = new RenderProjectionRecord[1024]; private RenderProjectionRecord[] _dynamicSweepScratch = new RenderProjectionRecord[256]; - private RenderProjectionRecord[] _cellScratch = new RenderProjectionRecord[256]; - private RenderProjectionRecord[] _cellDynamicScratch = new RenderProjectionRecord[256]; // Campaign FW3.4a: the per-frame, grow-only materialization arena — see // this type's own doc comment. @@ -81,12 +97,16 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData internal WalkProductionWorldData( WalkBuildingRegistry buildings, - ShadowObjectRegistry shadows) + ShadowObjectRegistry shadows, + Func? findParentLocalId = null) { _buildings = buildings ?? throw new ArgumentNullException(nameof(buildings)); _shadows = shadows ?? throw new ArgumentNullException(nameof(shadows)); + _findParentLocalId = findParentLocalId ?? NoParentLocalId; } + private static uint? NoParentLocalId(uint _) => null; + /// Rebuilds the frame's outdoor/shell buckets and clears the /// per-cell cache. Call once per frame before the driver runs. /// / @@ -112,6 +132,10 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData _outdoorDynamicsMaterialized.Clear(); _shellMaterialized.Clear(); _arenaLength = 0; + foreach (List bucket in _indoorByCell.Values) + bucket.Clear(); + foreach (List bucket in _indoorDynamicsByCell.Values) + bucket.Clear(); foreach (List bucket in _outdoorByCell.Values) bucket.Clear(); foreach (List bucket in _outdoorDynamicsByCell.Values) @@ -119,23 +143,56 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData foreach (List bucket in _shellsByAnchor.Values) bucket.Clear(); + if (_staticRenderCellIndexRevision != scene.IndexRevision + || _staticRenderCellGeneration != scene.Generation) + { + _staticRenderCellCache.Clear(); + _staticRenderCellIndexRevision = scene.IndexRevision; + _staticRenderCellGeneration = scene.Generation; + } + + // Retail CEnvCell::init_static_objects does not leave an object solely + // in its authored parent cell. add_obj_to_cell -> + // calc_cross_cells_static -> CPartArray::AddPartsShadow registers all + // visual parts in every crossed cell, including parts with no physics + // BSP. Build that render-only index before the walk starts. + int required = _scene.IndexCounts.For(RenderSceneIndex.IndoorCellStatic); + if (required > _indoorSweepScratch.Length) + { + _indoorSweepScratch = new RenderProjectionRecord[ + Math.Max(required, _indoorSweepScratch.Length * 2)]; + } + int count = _scene.CopyIndexTo( + RenderSceneIndex.IndoorCellStatic, + _indoorSweepScratch); + for (int i = 0; i < count; i++) + { + ref readonly RenderProjectionRecord record = ref _indoorSweepScratch[i]; + IReadOnlyList renderCells = ResolveStaticRenderCells(in record); + BucketIndoorRecord( + in record, + renderCells, + _indoorByCell, + _outdoorByCell); + } + // CopyIndexTo THROWS on an undersized destination (ArchRenderScene // validates up front — the first connected gate run of the FW3.2b-2 // cutover crashed on exactly this at Aerlinthe's 5,040 outdoor // statics), so presize from the query's own index counts. - int required = _scene.IndexCounts.For(RenderSceneIndex.OutdoorStatic); + required = _scene.IndexCounts.For(RenderSceneIndex.OutdoorStatic); if (required > _sweepScratch.Length) { _sweepScratch = new RenderProjectionRecord[ Math.Max(required, _sweepScratch.Length * 2)]; } - int count = _scene.CopyIndexTo(RenderSceneIndex.OutdoorStatic, _sweepScratch); + count = _scene.CopyIndexTo(RenderSceneIndex.OutdoorStatic, _sweepScratch); for (int i = 0; i < count; i++) { ref readonly RenderProjectionRecord record = ref _sweepScratch[i]; if (record.EntityPayload.IsBuildingShell) { - uint anchor = record.Source.BuildingShellAnchorCellId; + uint anchor = BuildingShellBucketCellId(in record); if (!_shellsByAnchor.TryGetValue(anchor, out List? shells)) _shellsByAnchor[anchor] = shells = new List(); shells.Add(record); @@ -149,27 +206,260 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData _renderCenterLbY); } - required = _scene.IndexCounts.For(RenderSceneIndex.OutdoorDynamic); + // Retail CPhysicsObj::add_shadows_to_cells installs every PartArray in + // every cell from calc_cross_cells' retained CELLARRAY. The scene's + // parent-cell dictionary cannot represent that membership, so consume + // the global dynamic index once and rebuild both indoor and outdoor + // render buckets from ShadowObjectRegistry's exact retained array. + required = _scene.IndexCounts.For(RenderSceneIndex.Dynamic); if (required > _dynamicSweepScratch.Length) { _dynamicSweepScratch = new RenderProjectionRecord[ Math.Max(required, _dynamicSweepScratch.Length * 2)]; } count = _scene.CopyIndexTo( - RenderSceneIndex.OutdoorDynamic, + RenderSceneIndex.Dynamic, _dynamicSweepScratch); for (int i = 0; i < count; i++) { ref readonly RenderProjectionRecord record = ref _dynamicSweepScratch[i]; - BucketOutdoorRecord( + IReadOnlyList renderCells = ResolveDynamicRenderCells( in record, - _shadows.GetOwnerCells(record.Source.LocalEntityId), + _shadows.GetOwnerCells, + _findParentLocalId); + BucketDynamicRecord( + in record, + renderCells, + _indoorDynamicsByCell, _outdoorDynamicsByCell, _renderCenterLbX, _renderCenterLbY); } } + /// + /// Retail CPhysicsObj::add_shadows_to_cells recursively passes the + /// root object's same CELLARRAY to every child in its CHILDLIST. Attached + /// projections deliberately own no independent collision rows in acdream, + /// so walk their accepted parent chain until the root's retained array is + /// found. Ordinary dynamics continue to consume only their own rows. + /// + internal static IReadOnlyList ResolveDynamicRenderCells( + in RenderProjectionRecord record, + Func> getOwnerCells, + Func findParentLocalId) + { + ArgumentNullException.ThrowIfNull(getOwnerCells); + ArgumentNullException.ThrowIfNull(findParentLocalId); + + uint current = record.Source.LocalEntityId; + IReadOnlyList cells = getOwnerCells(current); + if (cells.Count > 0 + || record.EntityPayload.CasterIdentity + != RenderCasterIdentityKind.EquippedChild) + { + return cells; + } + + // ParentAttachmentState rejects cycles. Keep a hard bound here so a + // corrupted diagnostic callback still cannot stall a render frame. + for (int depth = 0; depth < 64; depth++) + { + uint? parent = findParentLocalId(current); + if (parent is not { } parentId + || parentId == 0u + || parentId == current) + { + break; + } + + current = parentId; + cells = getOwnerCells(current); + if (cells.Count > 0) + return cells; + } + + return Array.Empty(); + } + + private IReadOnlyList ResolveStaticRenderCells( + in RenderProjectionRecord record) + { + if (_staticRenderCellCache.TryGetValue( + record.Id, + out StaticRenderCellCacheEntry cached) + && cached.ParentCellId == record.Source.ParentCellId + && cached.TransformFingerprint == record.Source.TransformFingerprint + && cached.GeometryFingerprint == record.Source.GeometryFingerprint) + { + return cached.Cells; + } + + IReadOnlyList collisionCells = + _shadows.GetOwnerCells(record.Source.LocalEntityId); + IReadOnlyList cells = Array.Empty(); + int visualPartCount = 0; + bool hasPhysicsBsp = false; + bool primitiveSetup = false; + PhysicsDataCache? cache = _shadows.DataCache; + if (cache is not null + && record.EntityPayload.MeshRefs is { Count: > 0 } meshRefs) + { + List visualParts = + ShadowShapeBuilder.FromStaticRenderParts( + meshRefs, + cache.GetGfxObj, + cache.GetVisualBounds, + out hasPhysicsBsp); + visualPartCount = visualParts.Count; + + // A primitive-only Setup takes retail's cylsphere/sorting-sphere + // calc_cross_cells_static arm. Its collision registration already + // carries that exact cell set. BSP-bearing objects and pure visual + // objects take the per-visual-part box walk instead. + primitiveSetup = + (record.Source.SourceId & 0xFF000000u) == 0x02000000u + && !hasPhysicsBsp + && collisionCells.Count > 0; + if (primitiveSetup) + { + cells = collisionCells; + } + else if (visualParts.Count > 0) + { + cells = _shadows.ComputeStaticRenderCells( + record.Source.ParentCellId, + record.Transform.Position, + record.Transform.Rotation, + visualParts); + } + } + + if (cells.Count == 0) + cells = collisionCells.Count > 0 + ? collisionCells + : record.Source.ParentCellId != 0u + ? new[] { record.Source.ParentCellId } + : Array.Empty(); + + uint[] snapshot = cells as uint[] ?? cells.ToArray(); + if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFacilityStairsEnabled + && record.Source.SourceId == 0x020009A2u) + { + static string DescribeCells(IReadOnlyList source) => + "[" + string.Join(",", source.Select(id => $"0x{id:X8}")) + "]"; + Console.WriteLine( + $"[cathedral-static-index] source=0x{record.Source.SourceId:X8} " + + $"local=0x{record.Source.LocalEntityId:X8} " + + $"parent=0x{record.Source.ParentCellId:X8} " + + $"parts={visualPartCount} hasPhysicsBsp={(hasPhysicsBsp ? 1 : 0)} " + + $"primitiveSetup={(primitiveSetup ? 1 : 0)} " + + $"collision={DescribeCells(collisionCells)} " + + $"render={DescribeCells(snapshot)}"); + } + _staticRenderCellCache[record.Id] = new StaticRenderCellCacheEntry( + record.Source.ParentCellId, + record.Source.TransformFingerprint, + record.Source.GeometryFingerprint, + snapshot); + return snapshot; + } + + internal bool StaticBucketContains(uint cellId, uint sourceId) => + _indoorByCell.TryGetValue( + cellId, + out List? records) + && records.Exists(record => record.Source.SourceId == sourceId); + + /// + /// Buckets one authored indoor static into the render cell lists produced + /// by retail's cross-cell PartArray walk. Outdoor cells are routed to the + /// landscape turn because a visual part may cross an exit portal. + /// + internal static void BucketIndoorRecord( + in RenderProjectionRecord record, + IReadOnlyList renderCells, + Dictionary> indoorBuckets, + Dictionary> outdoorBuckets) + { + ArgumentNullException.ThrowIfNull(renderCells); + ArgumentNullException.ThrowIfNull(indoorBuckets); + ArgumentNullException.ThrowIfNull(outdoorBuckets); + + bool added = false; + for (int i = 0; i < renderCells.Count; i++) + { + uint cellId = renderCells[i]; + uint low = cellId & 0xFFFFu; + if (low is >= 1u and <= 64u) + { + AddToBucket(in record, cellId, outdoorBuckets); + added = true; + } + else if (low >= 0x100u) + { + AddToBucket(in record, cellId, indoorBuckets); + added = true; + } + } + + if (!added && record.Source.ParentCellId != 0u) + AddToBucket(in record, record.Source.ParentCellId, indoorBuckets); + } + + /// + /// Installs one live PartArray into every cell in retail's retained + /// CELLARRAY. Interior and landscape memberships can coexist while + /// crossing a building exit. If the collision owner is not registered yet + /// (or the object is a visual-only effect), the authored interior parent + /// or outdoor root-position cell remains the conservative fallback. + /// + internal static void BucketDynamicRecord( + in RenderProjectionRecord record, + IReadOnlyList shadowCells, + Dictionary> indoorBuckets, + Dictionary> outdoorBuckets, + int renderCenterLbX, + int renderCenterLbY) + { + ArgumentNullException.ThrowIfNull(shadowCells); + ArgumentNullException.ThrowIfNull(indoorBuckets); + ArgumentNullException.ThrowIfNull(outdoorBuckets); + + bool added = false; + for (int i = 0; i < shadowCells.Count; i++) + { + uint cellId = shadowCells[i]; + uint low = cellId & 0xFFFFu; + if (low is >= 1u and <= 64u) + { + AddToBucket(in record, cellId, outdoorBuckets); + added = true; + } + else if (low >= 0x100u) + { + AddToBucket(in record, cellId, indoorBuckets); + added = true; + } + } + + if (added) + return; + + uint parentLow = record.Source.ParentCellId & 0xFFFFu; + if (record.Source.ParentCellId != 0u && parentLow >= 0x100u) + { + AddToBucket(in record, record.Source.ParentCellId, indoorBuckets); + return; + } + + uint outdoorCell = LandscapeCellId( + record.Transform.Position, + renderCenterLbX, + renderCenterLbY); + AddToBucket(in record, outdoorCell, outdoorBuckets); + } + /// /// Installs one outdoor object's render shadow in every outdoor cell of /// its authoritative physics CELLARRAY. This is retail's @@ -252,19 +542,13 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData { if (_cellCache.TryGetValue(cellId, out WalkFrameStaticRecords cached)) return cached; - // Same up-front-validation contract as CopyIndexTo: presize from the - // query's own count rather than probing with an undersized span. - int required = _scene.GetCellStaticCount(cellId); - if (required > _cellScratch.Length) - { - _cellScratch = new RenderProjectionRecord[ - Math.Max(required, _cellScratch.Length * 2)]; - } - int count = _scene.CopyCellStaticsTo(cellId, _cellScratch); - WalkFrameStaticRecords records = count == 0 - ? WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId } - : new WalkFrameStaticRecords( - AppendToArena(_cellScratch.AsSpan(0, count)), _tupleLandblockId); + WalkFrameStaticRecords records = + _indoorByCell.TryGetValue(cellId, out List? bucket) + && bucket.Count > 0 + ? new WalkFrameStaticRecords( + AppendToArena(CollectionsMarshal.AsSpan(bucket)), _tupleLandblockId) + : WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId }; + EmitFacilityShadowProbe(records.Records, cellId, "static"); _cellCache[cellId] = records; return records; } @@ -273,21 +557,81 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData { if (_cellDynamicCache.TryGetValue(cellId, out WalkFrameStaticRecords cached)) return cached; - int required = _scene.GetCellDynamicCount(cellId); - if (required > _cellDynamicScratch.Length) - { - _cellDynamicScratch = new RenderProjectionRecord[ - Math.Max(required, _cellDynamicScratch.Length * 2)]; - } - int count = _scene.CopyCellDynamicsTo(cellId, _cellDynamicScratch); - WalkFrameStaticRecords records = count == 0 - ? WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId } - : new WalkFrameStaticRecords( - AppendToArena(_cellDynamicScratch.AsSpan(0, count)), _tupleLandblockId); + WalkFrameStaticRecords records = + _indoorDynamicsByCell.TryGetValue( + cellId, + out List? bucket) + && bucket.Count > 0 + ? new WalkFrameStaticRecords( + AppendToArena(CollectionsMarshal.AsSpan(bucket)), _tupleLandblockId) + : WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId }; + EmitFacilityShadowProbe(records.Records, cellId, "dynamic"); _cellDynamicCache[cellId] = records; return records; } + /// + /// 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 GetOutdoorStatics(uint cellId) { if (_outdoorMaterialized.TryGetValue(cellId, out WalkFrameStaticRecords cached)) @@ -325,7 +669,7 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData public WalkFrameStaticRecords GetBuildingShellStatics(WalkBuilding building) { - uint anchor = AnchorCellId(building); + uint anchor = BuildingShellBucketCellId(building); if (anchor == 0) return WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId }; if (_shellMaterialized.TryGetValue(anchor, out WalkFrameStaticRecords cached)) @@ -382,6 +726,30 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData return 0; } + /// + /// Selects the retained-scene bucket for a shell record. Portal-bearing + /// buildings use their authored EnvCell anchor. A portal-less building has + /// no such anchor, but it is still a real CBuildingObj; retail's + /// DrawSortCell reaches it through the outdoor cell containing its + /// placement, represented by EffectCellId on the retained record. + /// + internal static uint BuildingShellBucketCellId(in RenderProjectionRecord record) + => record.Source.BuildingShellAnchorCellId != 0 + ? record.Source.BuildingShellAnchorCellId + : record.Source.EffectCellId; + + /// + /// Resolves the same bucket from the walk-side building. The authored + /// interior anchor wins when present; otherwise the landscape assembler's + /// exact position cell is the shell turn that retail uses. + /// + internal static uint BuildingShellBucketCellId(WalkBuilding building) + { + ArgumentNullException.ThrowIfNull(building); + uint anchor = AnchorCellId(building); + return anchor != 0 ? anchor : building.PositionCellId; + } + public Matrix4x4 GetBuildingWorldTransform(WalkBuilding building) { if (!_buildings.TryGetEntry(building, out WalkBuildingFactory.Entry? entry)) @@ -391,4 +759,10 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData } return entry.WorldTransform; } + + private readonly record struct StaticRenderCellCacheEntry( + uint ParentCellId, + RenderSceneHash128 TransformFingerprint, + RenderSceneHash128 GeometryFingerprint, + uint[] Cells); } diff --git a/src/AcDream.App/Rendering/Walk/WalkStaticStreamPopulator.cs b/src/AcDream.App/Rendering/Walk/WalkStaticStreamPopulator.cs index b1858697..54d543cf 100644 --- a/src/AcDream.App/Rendering/Walk/WalkStaticStreamPopulator.cs +++ b/src/AcDream.App/Rendering/Walk/WalkStaticStreamPopulator.cs @@ -110,7 +110,8 @@ internal sealed class WalkStaticStreamPopulator ClassifyAndAppend( stream, WalkDrawStage.OutdoorStatic, cellId, in records[i], tupleLandblockId, cameraWorldPosition, viewProjection, - liveDynamic: false, views, viewRouteIndex, alphaSubmissions); + liveDynamic: false, views, viewRouteIndex, + alphaSubmissions: alphaSubmissions); } } @@ -142,7 +143,7 @@ internal sealed class WalkStaticStreamPopulator liveDynamic: true, lookInViews, lookInRouteIndex, - alphaSubmissions); + alphaSubmissions: alphaSubmissions); } } @@ -169,17 +170,25 @@ internal sealed class WalkStaticStreamPopulator liveDynamic, lookInViews, lookInRouteIndex, - cellId); + cellId, + diagnosticViewProjection: viewProjection); for (int i = 0; i < _batchScratch.Count; i++) { 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/WbDrawDispatcher.FacilityStairProbe.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.FacilityStairProbe.cs new file mode 100644 index 00000000..5ba8cd45 --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.FacilityStairProbe.cs @@ -0,0 +1,171 @@ +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 468323fa..2d2ac957 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.OrderedStream.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.OrderedStream.cs @@ -355,6 +355,7 @@ 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. @@ -436,6 +437,7 @@ public sealed unsafe partial class WbDrawDispatcher checked((uint)(count * DrawCommandStride))); _orderedPreparedCount = count; + ProbeFacilityStairUploadCompleted(count); } /// @@ -572,6 +574,11 @@ public sealed unsafe partial class WbDrawDispatcher DrawIndirectRangeRhi( encoder, ref pushConstants, commandBuffer, commandBase, run.FirstCommand, run.CommandCount, _orderedDrawCullModes); + ProbeFacilityStairRunSubmitted( + firstCommand, + commandCount, + run.FirstCommand, + run.CommandCount); } } diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs index 038dc3e3..759aaedb 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs @@ -55,6 +55,86 @@ namespace AcDream.App.Rendering.Wb; /// public sealed partial class WbDrawDispatcher { + private static readonly IReadOnlyList RetailWholeMeshSlot = new uint[] { 0u }; + 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); + + /// + /// Opens retail's per-part drawn-stamp scope. A CPartArray may be present + /// in several cells from its retained CELLARRAY; CPhysicsPart::Draw stamps + /// only a part that actually passed the current cell's portal test. That + /// lets a rejected head/hair part retry through another crossed cell while + /// preventing already-admitted torso parts from being submitted twice in + /// one retail draw pass. PView::DrawCells advances the stamp between its + /// landscape and interior-cell passes; + /// reproduces that mid-frame boundary. + /// + internal void BeginWalkPartFrame() + { + if (_walkPartFrameActive) + { + throw new InvalidOperationException( + "A walk part frame was opened before the previous scope closed."); + } + + _walkDrawnParts.Clear(); + BeginFacilityStairSubmissionProbeFrame(); + _walkPartFrameActive = true; + } + + /// + /// Re-arms retail's per-part drawn stamp at the mid-DrawCells pass + /// boundary. Retail increments RenderDevice::m_nFrameStamp at 0x005A4886 + /// after LScape::draw + FlushAlphaList and before the interior depth clear, + /// so a shadow part drawn through the landscape may draw once more when + /// the interior cell list repaints after that clear. + /// + internal void AdvanceWalkPartPassStamp() + { + if (!_walkPartFrameActive) + { + throw new InvalidOperationException( + "The walk part pass stamp cannot advance outside an active frame."); + } + + _walkDrawnParts.Clear(); + } + + internal void EndWalkPartFrame() + { + _walkPartFrameActive = false; + _walkDrawnParts.Clear(); + } + + private bool TryStampWalkPart( + in RenderProjectionRecord projection, + int partIndex) => + !_walkPartFrameActive + // RenderDeviceD3D::DrawMeshInternal @0x0059F360 explicitly bypasses + // Get/SetDrawnThisFrame when IsPartOfPlayerObj is true. The local + // player must repaint at every crossed-cell turn so later wall/depth + // ordering cannot leave only the parts admitted by an earlier cell. + || projection.EntityPayload.CasterIdentity + == RenderCasterIdentityKind.LocalPlayer + || _walkDrawnParts.Add(new WalkDrawnPartKey(projection.Id, partIndex)); + /// /// One walk-classified (entity, part, batch) draw candidate — exactly /// OrderedDrawCommand's per-instance field set (minus @@ -188,7 +268,8 @@ public sealed partial class WbDrawDispatcher bool liveDynamic = false, IWalkLookInViewSource? lookInViews = null, int lookInRouteIndex = -1, - uint lookInCellId = 0) + uint lookInCellId = 0, + Matrix4x4 diagnosticViewProjection = default) { ArgumentNullException.ThrowIfNull(batches); ArgumentNullException.ThrowIfNull(selectionParts); @@ -270,6 +351,17 @@ 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; @@ -282,21 +374,48 @@ public sealed partial class WbDrawDispatcher (uint)setupPartIndex) : 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; + } Matrix4x4 restPose = partTransform * meshRef.PartTransform; Matrix4x4 model = restPose * entity.RootWorld; int selectionPartIndex = unchecked((partIndex << 16) | (setupPartIndex & 0xFFFF)); IReadOnlyList? partClipSlots = ResolvePartClipSlots( - lookInViews, lookInRouteIndex, partData, model); - if (partClipSlots is { Count: 0 }) + lookInViews, + lookInRouteIndex, + partData, + model, + out Vector3 sphereCenter, + out float sphereRadius, + out bool hasSphere); + bool visible = partClipSlots is not { Count: 0 }; + 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( partData, model, in entity, meshRef, paletteIdentity, - entityHasCutoutSubset, slot, lights, indoor, selectionLighting, - detailCategory, opacity, partClipSlots, batches); + entityHasCutoutSubset, slot, lights, indoor, + selectionLighting, detailCategory, opacity, + partClipSlots, batches); selectionParts.Add(new WalkClassifiedSelectionPart( entity.ServerGuid, entity.LocalEntityId, selectionPartIndex, (uint)gfxObjId, model)); @@ -311,17 +430,45 @@ public sealed partial class WbDrawDispatcher (uint)partIndex) : 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; + } Matrix4x4 model = meshRef.PartTransform * entity.RootWorld; IReadOnlyList? partClipSlots = ResolvePartClipSlots( - lookInViews, lookInRouteIndex, renderData, model); - if (partClipSlots is { Count: 0 }) + lookInViews, + lookInRouteIndex, + renderData, + model, + out Vector3 sphereCenter, + out float sphereRadius, + out bool hasSphere); + bool visible = partClipSlots is not { Count: 0 }; + 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( renderData, model, in entity, meshRef, paletteIdentity, entityHasCutoutSubsetOverride: null, slot, lights, indoor, - selectionLighting, detailCategory, opacity, partClipSlots, batches); + selectionLighting, detailCategory, opacity, + partClipSlots, batches); selectionParts.Add(new WalkClassifiedSelectionPart( entity.ServerGuid, entity.LocalEntityId, partIndex, (uint)meshRef.GfxObjId, model)); @@ -381,25 +528,250 @@ public sealed partial class WbDrawDispatcher IWalkLookInViewSource? lookInViews, int routeIndex, ObjectRenderData renderData, - Matrix4x4 localToWorld) + Matrix4x4 localToWorld, + out Vector3 sphereCenter, + out float sphereRadius, + out bool hasSphere) { + sphereCenter = default; + sphereRadius = 0f; + hasSphere = false; if (lookInViews is null) return null; + if (renderData.SelectionSphere is not { Radius: > 0f } sphere) { - return lookInViews.VisibleClipSlotsInLookInTurn( + IReadOnlyList admittedViews = lookInViews.VisibleClipSlotsInLookInTurn( routeIndex, Vector3.Zero, radius: 0f, testSphere: false); + return admittedViews.Count == 0 + ? admittedViews + : RetailWholeMeshSlot; } - TransformDrawingSphere(sphere, localToWorld, out Vector3 center, out float radius); - return lookInViews.VisibleClipSlotsInLookInTurn( + hasSphere = true; + TransformDrawingSphere(sphere, localToWorld, out sphereCenter, out sphereRadius); + IReadOnlyList visibleViews = lookInViews.VisibleClipSlotsInLookInTurn( routeIndex, - in center, - radius, + in sphereCenter, + sphereRadius, testSphere: true); + // RenderDeviceD3D::DrawMesh @0x005A0860 tests the authored drawing + // sphere against each active PortalList view. Once any view admits + // it, DrawMeshInternal @0x0059F360 submits the complete constructed + // mesh. The portal polygon is not forwarded as a GPU mesh clip. + return visibleViews.Count == 0 + ? visibleViews + : RetailWholeMeshSlot; + } + + 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( diff --git a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs index 19371426..ef9cc364 100644 --- a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs +++ b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs @@ -463,8 +463,6 @@ internal sealed class RuntimeWorldFrameEnvironmentPreparation private readonly IWorldRenderRangeSource _ranges; private readonly SkyPesFrameController? _skyPes; private readonly Func _persistentDaylight; - private readonly HashSet _visibleCells = []; - private bool _visibleCellsValid; public RuntimeWorldFrameEnvironmentPreparation( RuntimeOptions options, @@ -513,9 +511,12 @@ internal sealed class RuntimeWorldFrameEnvironmentPreparation UpdateSunFromSky(landscapeLighting, roots.PlayerInsideCell); _lighting.UpdateViewerLight(roots.PlayerViewPosition); _lighting.Tick(camera.Position); - _lighting.BuildPointLightSnapshot( - roots.PlayerViewPosition, - _visibleCellsValid ? _visibleCells : null); + // Retail collects point lights from CEnvCell::visible_cell_table, which + // is the resident EnvCell registry populated at activation time. It is + // not the camera's per-frame portal walk. Feeding last frame's visited + // cells here made the Facility Hub room jump between five lights and + // the cell-less viewer light when zoom changed the camera root cell. + _lighting.BuildPointLightSnapshot(roots.PlayerViewPosition); _dispatcher?.SetSceneLights(_lighting.PointSnapshot); _environmentCells?.SetPointSnapshot(_lighting.PointSnapshot); @@ -540,15 +541,12 @@ internal sealed class RuntimeWorldFrameEnvironmentPreparation _lightingUbo?.Upload(ubo); } - public void ObserveDrawableCells(IReadOnlySet drawableCells) - { + public void ObserveDrawableCells(IReadOnlySet drawableCells) => ArgumentNullException.ThrowIfNull(drawableCells); - _visibleCells.Clear(); - _visibleCells.UnionWith(drawableCells); - _visibleCellsValid = true; - } - public void ClearDrawableCells() => _visibleCellsValid = false; + public void ClearDrawableCells() + { + } private void UpdateSunFromSky(SkyKeyframe keyframe, bool playerInsideCell) { diff --git a/src/AcDream.Content/MeshExtractor.cs b/src/AcDream.Content/MeshExtractor.cs index 0791423d..c50eb5b1 100644 --- a/src/AcDream.Content/MeshExtractor.cs +++ b/src/AcDream.Content/MeshExtractor.cs @@ -619,7 +619,22 @@ public sealed class MeshExtractor { BoundingBox = boundingBox, SortCenter = gfxObj?.SortCenter ?? Vector3.Zero, DIDDegrade = gfxObj != null && gfxObj.Flags.HasFlag(GfxObjFlags.HasDIDDegrade) ? gfxObj.DIDDegrade : 0, - SelectionSphere = new Sphere { Origin = boundingBox.Center, Radius = Vector3.Distance(boundingBox.Max, boundingBox.Min) / 2.0f } + // Retail CGfxObj::Serialize @ 0x00534970 stores drawing_sphere as + // BSPTREE::GetSphere(drawing_bsp), and RenderDeviceD3D::DrawMesh + // @ 0x005A0860 passes that exact sphere to viewconeCheck. The + // vertex-AABB sphere used here previously is a different shape + // (notably on long Facility Hub stair sections) and can change a + // portal-edge admission decision. Preserve the authored root + // sphere in prepared content; retain the AABB only for malformed + // or drawing-BSP-less assets which have no retail sphere to carry. + SelectionSphere = gfxObj?.DrawingBSP?.Root?.BoundingSphere + ?? new Sphere + { + Origin = boundingBox.Center, + Radius = Vector3.Distance( + boundingBox.Max, + boundingBox.Min) / 2.0f, + } }; } diff --git a/src/AcDream.Content/Pak/PakFormat.cs b/src/AcDream.Content/Pak/PakFormat.cs index 68898031..b24b223b 100644 --- a/src/AcDream.Content/Pak/PakFormat.cs +++ b/src/AcDream.Content/Pak/PakFormat.cs @@ -31,9 +31,13 @@ public static class PakFormat { /// binary format remains version 1. Version 6 introduces pak format 2: /// globally shared texture payloads plus independently Brotli-compressed /// blobs with raw fallback. Mesh geometry and source texture bytes remain - /// exact; unedited DXT surfaces now retain their native BC blocks. + /// exact; unedited DXT surfaces now retain their native BC blocks. Version + /// 7 replaces the synthetic vertex-AABB GfxObj view sphere with retail's + /// authored DrawingBSP root sphere. The binary format remains version 2, + /// but every prepared GfxObj render record must be regenerated because + /// the sphere participates in portal-view admission. /// - public const uint CurrentBakeToolVersion = 6; + public const uint CurrentBakeToolVersion = 7; } /// diff --git a/src/AcDream.Core/Lighting/LightManager.cs b/src/AcDream.Core/Lighting/LightManager.cs index 0fb11c00..0ae76e15 100644 --- a/src/AcDream.Core/Lighting/LightManager.cs +++ b/src/AcDream.Core/Lighting/LightManager.cs @@ -189,11 +189,11 @@ public sealed class LightManager /// (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 - /// (=resident) lit lights optionally FILTERED by last frame's rendered - /// visible-cell set (A7.L1, 2026-07-09 — 's - /// visibleCells param; fixes Town Network starvation without - /// reproducing c500912b — see that method's doc), then dynamics-first nearest- - /// player, capped here. 128 is wider than retail's 40+7 — a documented backstop + /// (=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 + /// collapsed from five lights to one without the player moving. 128 is wider + /// than retail's 40+7 — a documented backstop /// that in a properly cell-scoped room only ever evicts far-out-of-range /// statics; adopting retail's exact dual-pool caps + degrade levels is A7-arc /// work. The 1024 uncap remains refuted (striped-floor artifact + the unported @@ -248,39 +248,16 @@ public sealed class LightManager /// set from player->m_position, SmartBox 0x00453d3a, with the /// viewer-cell fallback 0x00455ab6). The distance SORT is therefore a function /// of PLAYER position and light registration ONLY — camera rotation/position - /// cannot change it (both prior camera-ANCHORED pools — nearest-camera cap; - /// c500912b's camera-seeded re-flood — produced the #176 seam-floor - /// purple blink by making the SORT itself camera-dependent). The optional - /// candidacy FILTER (A7.L1) does not change - /// this: it narrows the input set before the player-anchored sort runs, using - /// a value the caller captured from last frame's already-rendered draw list, - /// not a fresh camera-seeded computation performed here. Call once per frame - /// before per-object selection. + /// cannot change it. Both camera-anchored pools and last-frame drawable-cell + /// scoping produced gaze/zoom-dependent membership changes, so neither is an + /// input to this retail resident-cell collection. Call once per frame before + /// per-object selection. /// /// /// The player's world position (render position; /// callers pass the camera position only when no player exists — retail's /// player/viewer branch). - /// - /// A7.L1 (2026-07-09) — optional visible-cell scoping. When non-null, a light - /// is a candidate only if it is cell-less (CellId == 0 — the viewer fill, - /// always in scope) or its CellId is in this set. Fixes the Town Network - /// starvation case (463 registered fixtures): the player-nearest cap sorts by - /// raw Euclidean distance, which is not a reliable proxy for "same room" in a - /// dense, maze-like hub — a fixture on the other side of a wall can be - /// geometrically closer than the player's own room's torches and win the cap, - /// leaving the visible room dark. Scoping candidacy to the frame's actual - /// visible cells (the render already computes this — callers pass last frame's - /// retail-walk visited-cell set, one frame of latency, to avoid re-threading - /// a mid-render callback) removes - /// non-visible cells from contention before the - /// cap ever applies. The distance-sort anchor stays the PLAYER either way — this - /// parameter only narrows candidacy, it does not change the sort (the #176 - /// correction: CAMERA anchoring, not cell scoping itself, caused the earlier - /// seam-floor flicker regression, c500912b). Null (the default) preserves the - /// legacy unscoped behavior — outdoor / no-clipRoot callers pass null. - /// - public void BuildPointLightSnapshot(Vector3 playerWorldPos, IReadOnlySet? visibleCells = null) + public void BuildPointLightSnapshot(Vector3 playerWorldPos) { _pointSnapshot.Clear(); _pointSelectionHeap.Clear(); @@ -291,8 +268,6 @@ public sealed class LightManager foreach (var light in _all) { if (!light.IsLit || light.Kind == LightKind.Directional) continue; - if (visibleCells is not null && light.CellId != 0 && !visibleCells.Contains(light.CellId)) continue; - var ranked = new RankedLight( light, qualifyingOrdinal++, diff --git a/src/AcDream.Core/Physics/CellTransit.cs b/src/AcDream.Core/Physics/CellTransit.cs index 388634ff..8847fdbb 100644 --- a/src/AcDream.Core/Physics/CellTransit.cs +++ b/src/AcDream.Core/Physics/CellTransit.cs @@ -1002,11 +1002,6 @@ public static class CellTransit /// docs/research/2026-08-07-ap159-pseudocode.md for the derivation. /// /// - /// Per-part world-placed authored boxes — the - /// outdoor extent walk's input. - /// Per-part world-placed BSP root spheres — - /// the indoor residual's and the building bridge's input. Same parts, same - /// order, from the same values. public static IReadOnlyList BuildShadowCellSetFromParts( PhysicsDataCache cache, uint seedCellId, @@ -1107,27 +1102,17 @@ public static class CellTransit } } - // Static prune (do_not_load_cells, 0x0052b66e) — indoor-seeded ONLY. - // The outdoor rectangle is deliberately unpruned: pruning it would - // re-create #334 in a new form. - if (isStatic && seedLow >= 0x0100u) - { - var seedCell = cache.GetCellStruct(seedCellId); - if (seedCell is not null) - { - var keep = new List(candidates.Count); - foreach (uint id in candidates.OrderedIds) - { - if (id == seedCellId || seedCell.VisibleCellIds.Contains(id)) - keep.Add(id); - } - if (keep.Count != candidates.Count) - { - candidates.Clear(); - foreach (uint id in keep) candidates.Add(id); - } - } - } + // Do NOT apply CObjCell::find_cell_list's do_not_load_cells prune + // (0x0052b66e) here. That code belongs to the sphere/cylsphere entry + // point at 0x0052b4e0. The part-array route used here is retail + // CPhysicsObj::find_bbox_cell_list @0x00510fc0: it walks the growing + // CELLARRAY through CPartArray::calc_cross_cells_static and returns + // it directly. calc_cross_cells_static does set do_not_load_cells=1, + // but the box transit consumes that only as a no-load policy while + // resolving neighbouring cells; it does not delete the already-added + // exterior cells. Pruning here removed a static Setup's legitimate + // outdoor shadow membership after an exit portal crossing (the + // cathedral ramp 0x020009A2: collision remained, render vanished). return candidates.OrderedIds; } diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index 21ceebb3..943753e2 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -360,6 +360,39 @@ public sealed class ShadowObjectRegistry private PhysicsDataCache? _fallback; private PhysicsDataCache FloodCache => DataCache ?? _fallbackCache; + /// + /// Computes retail's static PartArray render-cell membership without + /// publishing collision rows. Static visual parts and collision shapes + /// share the same find_bbox_cell_list portal walk, but retail keeps + /// them in separate cell lists (shadow_part_list versus + /// shadow_object_list). Decorative meshes therefore need this path + /// even when is empty. + /// + public IReadOnlyList ComputeStaticRenderCells( + uint seedCellId, + Vector3 entityWorldPosition, + Quaternion entityWorldRotation, + IReadOnlyList visualParts) + { + if (seedCellId == 0u || visualParts.Count == 0) + return Array.Empty(); + + List boxes = BuildFloodPartBoxes( + entityWorldPosition, + entityWorldRotation, + visualParts); + List spheres = BuildBspPartSpheres( + entityWorldPosition, + entityWorldRotation, + visualParts); + return CellTransit.BuildShadowCellSetFromParts( + FloodCache, + seedCellId, + boxes, + spheres, + isStatic: true); + } + /// /// Register a single-shape entity. is the /// entity's m_position.objcell_id — the flood seed. Pass 0 to diff --git a/src/AcDream.Core/Physics/ShadowShapeBuilder.cs b/src/AcDream.Core/Physics/ShadowShapeBuilder.cs index 35b532fd..3db0729e 100644 --- a/src/AcDream.Core/Physics/ShadowShapeBuilder.cs +++ b/src/AcDream.Core/Physics/ShadowShapeBuilder.cs @@ -342,6 +342,93 @@ public static class ShadowShapeBuilder return shapes; } + /// + /// Resolves every visual part of a static object into the paired sphere + + /// vertex-box geometry used by retail's render-shadow cell registration. + /// Unlike , this deliberately includes + /// decorative GfxObjs with no physics BSP: CEnvCell::init_static_objects + /// creates a real CPhysicsObj for every static and + /// CPartArray::AddPartsShadow registers every visual part in each + /// crossed cell even when that part cannot collide. + /// + /// The returned values are geometry carriers only. Callers must not + /// publish them to the collision registry. + /// + public static List FromStaticRenderParts( + IReadOnlyList meshRefs, + Func getGfxObj, + Func getVisualBounds, + out bool hasPhysicsBsp) + { + ArgumentNullException.ThrowIfNull(meshRefs); + ArgumentNullException.ThrowIfNull(getGfxObj); + ArgumentNullException.ThrowIfNull(getVisualBounds); + + hasPhysicsBsp = false; + var parts = new List(meshRefs.Count); + foreach (MeshRef meshRef in meshRefs) + { + GfxObjPhysics? physics = getGfxObj(meshRef.GfxObjId); + bool partHasPhysicsBsp = physics?.FlatPhysicsBsp is { RootIndex: >= 0 } + || physics?.BSP?.Root is not null; + hasPhysicsBsp |= partHasPhysicsBsp; + + FlatGfxObjVisualBounds? flatBounds = physics?.VisualBounds; + if (flatBounds is null && getVisualBounds(meshRef.GfxObjId) is { } visual) + { + flatBounds = new FlatGfxObjVisualBounds( + visual.Min, + visual.Max, + visual.Center, + visual.Radius, + visual.HalfExtents); + } + if (flatBounds is not { } bounds) + continue; + + if (!Matrix4x4.Decompose( + meshRef.PartTransform, + out Vector3 partScaleVector, + out Quaternion partRotation, + out Vector3 partPosition)) + { + partScaleVector = Vector3.One; + partRotation = Quaternion.Identity; + partPosition = meshRef.PartTransform.Translation; + } + + float partScale = partScaleVector.X > 0f ? partScaleVector.X : 1f; + FlatCollisionSphere sphere; + if (partHasPhysicsBsp) + { + FlatPhysicsBsp? flat = physics!.FlatPhysicsBsp; + sphere = flat is { RootIndex: >= 0 } + ? flat.Nodes[flat.RootIndex].BoundingSphere + : new FlatCollisionSphere( + physics.BoundingSphere?.Origin ?? bounds.Center, + physics.BoundingSphere?.Radius ?? bounds.Radius); + } + else + { + // Retail falls back from physics_sphere to the GfxObj drawing + // sphere. The prepared collision catalog retains the exact + // visual AABB, not that drawing sphere; its circumsphere is a + // conservative cheap reject while the exact box below remains + // the admitting test. + sphere = new FlatCollisionSphere(bounds.Center, bounds.Radius); + } + + parts.Add(ShadowShape.Bsp( + meshRef.GfxObjId, + partPosition, + partRotation, + partScale, + ShadowPartGeometry.Create(sphere, bounds))); + } + + return parts; + } + /// /// The collision identity of part : the installed /// AnimPartChanged replacement when one was supplied, else the diff --git a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs index 634627f4..e1a47d1f 100644 --- a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs +++ b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs @@ -99,6 +99,106 @@ public static class RenderingDiagnostics 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"; + + /// + /// Temporary cathedral discriminator: suppresses only the far-Z portal + /// punch for the authored building whose look-in owns cell 0xF4180112. + /// This is a binary visual experiment for the moving transparent seam at + /// the 0xF4180107/0112 floating stairs, never a production admission rule. + /// + public static bool ProbeCathedralSkipStairBuildingPunch { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_CATHEDRAL_SKIP_PUNCH") == "1"; + + /// + /// Temporary cathedral discriminator: suppresses only the true-depth exit + /// seals authored by the two cells that meet at the floating-stair seam. + /// Both portal polygons lie on world Y ~= 24 and span the reported moving + /// triangle. This is a binary visual experiment only; normal production + /// behavior is unchanged while the flag is unset. + /// + public static bool ProbeCathedralSkipFloatingStairSeals { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_CATHEDRAL_SKIP_SEALS") == "1"; + + /// + /// Temporary cathedral carrier discriminator: suppresses only the visible + /// EnvCell shells for cells 0xF4180107 and 0xF4180112. This deliberately + /// removes their authored walls so a visual A/B can prove whether the + /// moving wall-textured triangles at the floating stairs come from either + /// cell shell. It is never a production visibility rule. + /// + public static bool ProbeCathedralSkipFloatingStairCellShells { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_CATHEDRAL_SKIP_CELL_SHELLS") == "1"; + + /// + /// Temporary cathedral carrier discriminator: suppresses the visible + /// EnvCell shells for 0xF4180112/0113/0114 only while they are reached by + /// a building look-in. Their ordinary interior-root repaint remains + /// untouched. This separates a pre-clear look-in cell shell from the + /// south building's own exterior shell; it is never a production rule. + /// + public static bool ProbeCathedralSkipSouthLookInCellShells { get; set; } = + Environment.GetEnvironmentVariable( + "ACDREAM_PROBE_CATHEDRAL_SKIP_SOUTH_LOOKIN_SHELLS") == "1"; + + /// + /// Narrow form of : + /// suppresses one exact EnvCell shell only during a building look-in. + /// Accepts a full hexadecimal cell id, with or without a 0x + /// prefix, through ACDREAM_PROBE_CATHEDRAL_SKIP_LOOKIN_SHELL. + /// Zero means disabled. Behavior-changing diagnostic only. + /// + public static uint ProbeCathedralSkipLookInShellCellId { get; set; } = + ParseHexCellId( + Environment.GetEnvironmentVariable( + "ACDREAM_PROBE_CATHEDRAL_SKIP_LOOKIN_SHELL")); + + /// + /// Temporary cathedral carrier discriminator: suppresses only the own + /// exterior shell of the south cathedral building (authored anchor cell + /// 0xF4180112). Its portal walk, look-in cells, and particles remain live. + /// This is a binary visual experiment, never a production rule. + /// + public static bool ProbeCathedralSkipSouthBuildingShell { get; set; } = + Environment.GetEnvironmentVariable( + "ACDREAM_PROBE_CATHEDRAL_SKIP_SOUTH_BUILDING_SHELL") == "1"; + + /// + /// Output-only cathedral floating-stair trace. Samples the ONE frame + /// walk's recorded clear/seal/punch/shell replay order and reports the + /// render-stamp-deduplicated whole-shell turns for cells 0xF4180104/0106/0107 + /// and 0xF4180112/0113/0114. It never changes admission, depth state, or + /// draw order. Initial state from + /// ACDREAM_PROBE_CATHEDRAL_SHELL_ORDER=1. + /// + public static bool ProbeCathedralShellOrderEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_CATHEDRAL_SHELL_ORDER") == "1"; + + private static uint ParseHexCellId(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return 0; + ReadOnlySpan value = raw.AsSpan().Trim(); + if (value.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + value = value[2..]; + return uint.TryParse( + value, + NumberStyles.HexNumber, + CultureInfo.InvariantCulture, + out uint cellId) + ? cellId + : 0; + } + /// /// 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 @@ -186,15 +286,11 @@ public static class RenderingDiagnostics /// 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 (2026-07-09): visible-cell scoping is BACK, but sourced differently — - /// LightManager.BuildPointLightSnapshot now takes an optional - /// visibleCells filter that GameWindow feeds from LAST FRAME's - /// already-rendered RetailPViewFrameResult.DrawableCells (one frame of - /// latency, no independent re-flood, no callback threaded into DrawInside) — - /// fixes the Town Network starvation case (463 fixtures, a wall-adjacent - /// corridor's fixtures out-ranking the player's own room in raw Euclidean - /// distance) without reproducing the #176 mechanism. byCell shows which - /// cells' lights are pooled; cellLess==pool in a fixture-rich room still + /// 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. /// @@ -403,14 +499,14 @@ public static class RenderingDiagnostics /// 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 - /// already reflects any last-frame visible-cell scoping (A7.L1, 2026-07-09). + /// LightManager.BuildPointLightSnapshot after resident collection and + /// the bounded player-nearest selection. /// /// Every registered light (LightManager._all). - /// The point-light pool just built. + /// The point-light pool just built. public static void EmitIndoorLight( IReadOnlyList allRegistered, - IReadOnlyList scopedSnapshot) + IReadOnlyList pointSnapshot) { if (!ProbeIndoorLightEnabled) return; @@ -423,10 +519,10 @@ public static class RenderingDiagnostics foreach (var l in allRegistered) if (l.IsLit && l.Kind != AcDream.Core.Lighting.LightKind.Directional) registeredLitPoints++; - int pool = scopedSnapshot.Count; + int pool = pointSnapshot.Count; int cellLess = 0; var hist = new Dictionary(); - foreach (var l in scopedSnapshot) + foreach (var l in pointSnapshot) { if (l.CellId == 0) cellLess++; hist.TryGetValue(l.CellId, out var c); diff --git a/src/AcDream.Launcher.Core/Installation/ContentMigrationCatalog.cs b/src/AcDream.Launcher.Core/Installation/ContentMigrationCatalog.cs index f31c2085..fe7aa9d8 100644 --- a/src/AcDream.Launcher.Core/Installation/ContentMigrationCatalog.cs +++ b/src/AcDream.Launcher.Core/Installation/ContentMigrationCatalog.cs @@ -49,6 +49,10 @@ public static class ContentMigrationCatalog 5, 6, "the optimized pak v2 texture catalog and compression format require one full rebuild"), + [7] = FullRebuild( + 6, + 7, + "GfxObj portal admission now uses the authored DrawingBSP sphere"), }; public static ContentMigrationPlan Resolve(uint fromRecipeVersion, uint targetRecipeVersion) diff --git a/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs b/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs index 4d4b3ec6..085507f3 100644 --- a/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs +++ b/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs @@ -35,9 +35,10 @@ public sealed record InstallRecordVerification( public sealed class LauncherInstallRecordStore { // Kept in lockstep with AcDream.Content.Pak.PakFormat.CurrentBakeToolVersion - // Version 6 is pak format 2: global texture payload deduplication plus - // adaptive independent-blob compression. It is a mandatory full rebuild. - public const uint CurrentBakeToolVersion = 6; + // Version 7 keeps pak format 2 and regenerates every GfxObj render record + // with retail's authored DrawingBSP view sphere. It is a mandatory full + // rebuild from recipe 6. + public const uint CurrentBakeToolVersion = 7; private static readonly JsonSerializerOptions SerializerOptions = new() { diff --git a/tests/AcDream.App.Tests/Rendering/ClipFrameLayoutTests.cs b/tests/AcDream.App.Tests/Rendering/ClipFrameLayoutTests.cs index ccb0f221..ce22e3f2 100644 --- a/tests/AcDream.App.Tests/Rendering/ClipFrameLayoutTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ClipFrameLayoutTests.cs @@ -111,6 +111,25 @@ public class ClipFrameLayoutTests Assert.Equal(0u, ReadUInt(bytes, 0)); } + [Fact] + public void GetSlotPlanes_BorrowsTheExactPackedClipRegion() + { + using ClipFrame frame = ClipFrame.NoClip(); + Vector4[] expected = + [ + new(1f, 2f, 3f, 4f), + new(-5f, 6f, -7f, 8f), + ]; + + int slot = frame.AppendSlot(expected); + + ReadOnlySpan actual = frame.GetSlotPlanes(checked((uint)slot)); + Assert.Equal(expected.Length, actual.Length); + Assert.Equal(expected[0], actual[0]); + Assert.Equal(expected[1], actual[1]); + Assert.Equal(0, frame.GetSlotPlanes(0).Length); + } + [Fact] public void AppendSlot_EmptyPlaneList_PacksNoClipSlot_Count0() { diff --git a/tests/AcDream.App.Tests/Rendering/Issue129PunchBiasTests.cs b/tests/AcDream.App.Tests/Rendering/Issue129PunchBiasTests.cs deleted file mode 100644 index 8686c1c0..00000000 --- a/tests/AcDream.App.Tests/Rendering/Issue129PunchBiasTests.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System; -using AcDream.App.Rendering; -using Xunit; - -namespace AcDream.App.Tests.Rendering; - -/// -/// #129 — doors/doorways leak through terrain and houses from over a landblock -/// away. The punch's mark pass (#117, AD-18) biased the aperture fan toward -/// the viewer by a CONSTANT 0.0005 NDC. NDC depth is non-linear: a constant -/// NDC bias b spans ≈ b·d²·(f−n)/(f·n) meters of eye depth at eye distance d -/// — 0.125 m at 5 m but ~190 m at a landblock (znear 0.1), so distant -/// occluders in front of an aperture passed the mark and were far-Z punched: -/// the door-shaped leak. The fix caps the bias's eye-space span -/// (PortalDepthMaskRenderer.MarkBiasNdc): identical to the validated constant -/// below the ~10 m crossover, never more than the cap beyond it. -/// -public class Issue129PunchBiasTests -{ - private const float Near = PortalDepthMaskRenderer.CameraNearPlaneMeters; // 0.1 (retail znear) - private const float Far = 5000f; - - /// Eye-depth span (meters) covered by an NDC depth bias b at eye - /// distance d: ndc(d) = f(d−n)/((f−n)d) ⇒ d(ndc) inverse ⇒ - /// span = b·d²·(f−n)/(f·n) (exact for small b via the derivative). - private static float EyeSpanMeters(float biasNdc, float d) => - biasNdc * d * d * (Far - Near) / (Far * Near); - - [Fact] - public void OldConstantBias_SpansMetersAtALandblock_TheLeak() - { - // The refuted form (documentation of WHY the constant was wrong): - // 0.0005 NDC at ~one landblock spans far more eye depth than any - // occluder separation — everything in front got punched. - Assert.True(EyeSpanMeters(0.0005f, 192f) > 100f); - // ...while at close range it was a sane sliver: - Assert.InRange(EyeSpanMeters(0.0005f, 5f), 0.05f, 0.30f); - } - - [Fact] - public void CappedBias_MatchesValidatedConstant_AtCloseRange() - { - // Below the crossover the T5-validated constant must win unchanged — - // this preserves the #108 grass coverage bit-for-bit. - foreach (float d in new[] { 0.5f, 1f, 3f, 5f, 8f, 9.9f }) - Assert.Equal(0.0005f, PortalDepthMaskRenderer.MarkBiasNdc(d), 6); - } - - [Fact] - public void CappedBias_EyeSpanNeverExceedsCap_AtAnyDistance() - { - for (float d = 1f; d <= 400f; d += 1f) - { - float span = EyeSpanMeters(PortalDepthMaskRenderer.MarkBiasNdc(d), d); - Assert.True(span <= PortalDepthMaskRenderer.PunchMarkBiasEyeCapMeters * 1.02f, - FormattableString.Invariant($"bias spans {span:F2} m of eye depth at d={d} m")); - } - } - - [Fact] - public void CappedBias_At200m_CannotReachOccluders() - { - // The reported #129 distance: occluder separations are tens of - // meters; the punch reach must stay under the 0.5 m cap. - float span = EyeSpanMeters(PortalDepthMaskRenderer.MarkBiasNdc(200f), 200f); - Assert.True(span <= 0.51f, FormattableString.Invariant($"span {span:F3} m at 200 m")); - } -} diff --git a/tests/AcDream.App.Tests/Rendering/Issue177StairDescentCameraFloodTests.cs b/tests/AcDream.App.Tests/Rendering/Issue177StairDescentCameraFloodTests.cs index 32435cbd..421a53fc 100644 --- a/tests/AcDream.App.Tests/Rendering/Issue177StairDescentCameraFloodTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Issue177StairDescentCameraFloodTests.cs @@ -4,12 +4,19 @@ 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; @@ -113,7 +120,7 @@ public class Issue177StairDescentCameraFloodTests 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[] { 0x01C8, 0x01C4, 0x01C9, 0x0210, 0x020E, 0x01C1, 0x01C0 }) + foreach (uint low in new uint[] { 0x015E, 0x015F, 0x01C8, 0x01C4, 0x01C9, 0x0210, 0x020E, 0x01C1, 0x01C0 }) { uint id = FacilityHub | low; var envCell = dats.Get(id); @@ -160,6 +167,113 @@ public class Issue177StairDescentCameraFloodTests } } + [Fact] + public void FacilityStairAssembly_RegisterAcross015FTo015EWithoutCollisionRows() + { + 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 parts = ShadowShapeBuilder.FromStaticRenderParts( + meshRefs, + cache.GetGfxObj, + cache.GetVisualBounds, + out bool hasPhysicsBsp); + Assert.True(hasPhysicsBsp); + Assert.NotEmpty(parts); + + const uint syntheticOwner = 0x7F00DEADu; + Assert.Empty(engine.ShadowObjects.GetOwnerCells(syntheticOwner)); + IReadOnlyList cells = engine.ShadowObjects.ComputeStaticRenderCells( + FacilityHub | 0x015Fu, + rootPosition, + rootRotation, + parts); + + 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); + Assert.Empty(engine.ShadowObjects.GetOwnerCells(syntheticOwner)); + + RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(1); + using var scene = new ArchRenderScene(generation); + RenderProjectionId projectionId = RenderProjectionId.FromRaw(0x48A02000u); + 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 = 0x48A02000u, + 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); + } + /// /// #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 diff --git a/tests/AcDream.App.Tests/Rendering/PortalDepthMaskRetailStateTests.cs b/tests/AcDream.App.Tests/Rendering/PortalDepthMaskRetailStateTests.cs new file mode 100644 index 00000000..60474009 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/PortalDepthMaskRetailStateTests.cs @@ -0,0 +1,67 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Gpu.Vk; +using AcDream.App.Tests.Rendering.Gpu; + +namespace AcDream.App.Tests.Rendering; + +public sealed class PortalDepthMaskRetailStateTests +{ + [Fact] + public void SealAndPunchUseOneRetailAlwaysWritePipelineAndOneDrawEach() + { + using var device = new RecordingGpuDevice(); + var frames = new GpuDeviceFrameLifetime(device); + var scope = new VulkanWorldPassScope(sampleCount: 1); + using var renderer = new PortalDepthMaskRenderer(device, frames, scope); + + RecordingGpuPipeline pipeline = Assert.Single(device.CreatedPipelines); + Assert.Equal("portal-depth-write", pipeline.Description.Name); + Assert.Equal( + new GpuDepthState(Test: true, Write: true, GpuCompareOp.Always), + pipeline.Description.Depth); + Assert.False(pipeline.Description.StencilTest); + Assert.False(pipeline.Description.ColorWrite); + Assert.Equal(GpuCullMode.None, pipeline.Description.Cull); + + frames.BeginFrame(); + renderer.BeginFrame(frameSlot: 0); + using (IGpuPassEncoder pass = frames.CurrentFrame!.BeginPass( + GpuPassDescription.BackbufferClear( + "portal-depth-retail-state", + Vector4.Zero, + sampleCount: 1))) + using (scope.Publish(pass)) + { + Vector3[] triangle = + [ + new(-1f, -1f, 1f), + new(1f, -1f, 1f), + new(0f, 1f, 1f), + ]; + renderer.DrawDepthFan( + triangle, + Matrix4x4.Identity, + ReadOnlySpan.Empty, + forceFarZ: true); + renderer.DrawDepthFan( + triangle, + Matrix4x4.Identity, + ReadOnlySpan.Empty, + forceFarZ: false); + } + frames.EndFrame(); + + Assert.Equal( + 2, + device.Calls.OfType() + .Count(call => call.PipelineName == "portal-depth-write")); + Assert.Equal(2, device.Calls.OfType().Count()); + Assert.Collection( + device.Calls.OfType(), + punch => Assert.Equal(1, punch.Constants.RenderPass), + seal => Assert.Equal(0, seal.Constants.RenderPass)); + Assert.Empty(device.Calls.OfType()); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs index 4960f4b7..2502e9cb 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs @@ -45,16 +45,16 @@ public sealed class WalkFrameDriverTests RetailAlphaQueue? alpha = null) : IWalkFrameLeafRenderer { public readonly List Punches = new(); - public readonly List<(uint CellId, uint ClipSlot)> Shells = new(); + public readonly List Shells = new(); public readonly List AlphaPendingAtBarrier = new(); public void DrawSky() => log.Add("SKY"); public void DrawTerrainSlice(int sliceIndex) => log.Add($"TERRAIN:{sliceIndex}"); - public void DrawCellShell(uint cellId, uint clipSlot) + public void DrawCellShell(uint cellId) { - Shells.Add((cellId, clipSlot)); + Shells.Add(cellId); log.Add($"SHELL:{cellId:x8}"); } @@ -267,7 +267,9 @@ public sealed class WalkFrameDriverTests var leaf = new RecordingLeafRenderer(log); var trace = new RecordingTrace(log); - var driver = new WalkFrameDriver(fx.Dispatcher, leaf, worldData, trace); + using ClipFrame clipFrame = ClipFrame.NoClip(); + var driver = new WalkFrameDriver( + fx.Dispatcher, leaf, worldData, trace, clipFrame); var walk = new RetailFrameWalk(); // A minimal, no-op landscape (1x1 window, the one slot unpublished) // — matches RetailFrameWalkTests' own exit-view fixture. LScape::draw @@ -296,6 +298,79 @@ public sealed class WalkFrameDriverTests Assert.All(mdiCalls, c => Assert.Equal(1u, c.DrawCount)); // Nothing dropped: every populated record reached exactly one indirect draw. 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. + Assert.Equal(1, driver.InteriorFloodViewSliceCountAt(0)); + Assert.Equal(1, driver.InteriorFloodViewSliceCountAt(1)); + Assert.Equal(4, driver.InteriorFloodViewClipPlanesAt(0, 0).Length); + Assert.Equal(4, driver.InteriorFloodViewClipPlanesAt(1, 0).Length); + Assert.True(clipFrame.SlotCount >= 3); + } + + [Fact] + public void InteriorFloodAfterLandscape_RearmsRetailPartStampForPostClearCellRepaint() + { + using var fx = new DispatcherFixture(); + var log = new List(); + const ulong gfxObj = 0x0200_0021UL; + const uint outdoorCellId = 0xF4180009u; + const uint interiorCellId = 0xF4180112u; + InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh( + MakeBatch(0x08100021u, TranslucencyKind.Opaque, 0, 0, 3, 1))); + + RenderProjectionRecord sharedPart = MakeRecord( + 0x4F41806Cu, + 0, + Vector3.Zero, + [new MeshRef((uint)gfxObj, Matrix4x4.Identity)]); + var worldData = new FakeWorldData(); + worldData.OutdoorStaticsByCell[outdoorCellId] = + new WalkFrameStaticRecords(new[] { sharedPart }, 0xF418u); + worldData.CellStaticsByCell[interiorCellId] = + new WalkFrameStaticRecords(new[] { sharedPart }, 0xF418u); + + var ctx = new TestContext(); + var interiorCell = new WalkCell { CellId = interiorCellId }; + interiorCell.PushView(); + WalkCopyView.AppendFullViewportQuad( + interiorCell.TopView, + ctx.Rays, + ctx.WorldViewpoint, + ctx.ViewportWidth, + ctx.ViewportHeight); + ctx.Cells[interiorCellId] = interiorCell; + + var driver = new WalkFrameDriver( + fx.Dispatcher, + new RecordingLeafRenderer(log), + worldData, + new RecordingTrace(log)); + IWalkEventSink sink = driver; + + using DrawScope draw = fx.BeginDraw(); + driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero); + sink.Emit(WalkEvent.Landscape(activeViewCount: 1)); + var landscapeViews = new WalkPortalView(); + WalkCopyView.AppendFullViewportQuad( + landscapeViews, + ctx.Rays, + ctx.WorldViewpoint, + ctx.ViewportWidth, + ctx.ViewportHeight); + sink.OnLandscapeViews(landscapeViews); + sink.OnLandscapeCellTurn(outdoorCellId); + sink.OnInteriorFloodDrawTurn([interiorCellId]); + driver.EndFrame(); + driver.Replay(draw.Frame, draw.Pass); + + List mdiCalls = + [.. fx.Device.Calls.OfType()]; + Assert.Equal(2, mdiCalls.Count); + Assert.All(mdiCalls, call => Assert.Equal(1u, call.DrawCount)); + Assert.Equal(2, mdiCalls.Sum(call => (int)call.DrawCount)); + Assert.Equal(2, log.Count(entry => entry == "FLUSH:1:OutdoorStatic" + || entry == "FLUSH:1:CellStatic")); } // ── Deliverable: the ov==0 interior case — no exit view survives, so @@ -479,11 +554,7 @@ public sealed class WalkFrameDriverTests Assert.Equal([0x104u], driver.LookInCells); Assert.Collection( leaf.Shells, - shell => - { - Assert.Equal(0x104u, shell.CellId); - Assert.NotEqual(0u, shell.ClipSlot); - }); + shell => Assert.Equal(0x104u, shell)); WalkPortalView capturedView = ctx.Cells[0x104].PortalViews[0]; WalkViewPoly capturedPoly = Assert.Single(capturedView.View.Polys); Vector2 capturedCenter = Vector2.Zero; @@ -515,6 +586,101 @@ public sealed class WalkFrameDriverTests Assert.Equal(3, mdiCalls.Sum(c => (int)c.DrawCount)); } + [Fact] + public void RepeatedFloodTurns_DrawEnvCellShellWholeOncePerRetailFrameStamp() + { + using var fx = new DispatcherFixture(); + var log = new List(); + var leaf = new RecordingLeafRenderer(log); + var ctx = new TestContext(); + const uint cellId = 0xF4180112u; + var cell = new WalkCell { CellId = cellId }; + cell.PushView(); + WalkCopyView.AppendFullViewportQuad( + cell.TopView, + ctx.Rays, + ctx.WorldViewpoint, + ctx.ViewportWidth, + ctx.ViewportHeight); + ctx.Cells[cellId] = cell; + + var driver = new WalkFrameDriver( + fx.Dispatcher, + leaf, + new FakeWorldData()); + IWalkEventSink sink = driver; + + using DrawScope draw = fx.BeginDraw(); + driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero); + sink.OnInteriorFloodDrawTurn([cellId]); + sink.OnInteriorFloodDrawTurn([cellId]); + driver.EndFrame(); + driver.Replay(draw.Frame, draw.Pass); + + Assert.Equal([cellId], leaf.Shells); + Assert.Equal(1, log.Count(entry => entry == "SHELL:f4180112")); + Assert.Equal(2, log.Count(entry => entry == "CLEAR")); + Assert.Equal(2, log.Count(entry => entry == "SEALS")); + } + + [Fact] + public void LandscapeStampBoundary_RearmsWholeShellForPostClearRootRepaint() + { + using var fx = new DispatcherFixture(); + var log = new List(); + var leaf = new RecordingLeafRenderer(log); + var ctx = new TestContext(); + const uint cellId = 0xF4180112u; + var cell = new WalkCell { CellId = cellId }; + cell.PushView(); + WalkCopyView.AppendFullViewportQuad( + cell.TopView, + ctx.Rays, + ctx.WorldViewpoint, + ctx.ViewportWidth, + ctx.ViewportHeight); + ctx.Cells[cellId] = cell; + + var driver = new WalkFrameDriver( + fx.Dispatcher, + leaf, + new FakeWorldData()); + IWalkEventSink sink = driver; + + using DrawScope draw = fx.BeginDraw(); + driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero); + + // LScape::draw has begun. A building look-in reached this cell before + // PView::DrawCells advances m_nFrameStamp and clears interior depth. + sink.Emit(WalkEvent.Landscape(activeViewCount: 1)); + var landscapeViews = new WalkPortalView(); + WalkCopyView.AppendFullViewportQuad( + landscapeViews, + ctx.Rays, + ctx.WorldViewpoint, + ctx.ViewportWidth, + ctx.ViewportHeight); + sink.OnLandscapeViews(landscapeViews); + sink.OnBuildingTurn(new WalkBuilding()); + sink.Emit(WalkEvent.DrawCells(outsideViewCount: 0, [cellId])); + + // The same shell must draw again after the retail stamp increment and + // full depth clear; otherwise the pre-clear color survives unpaired + // with depth and bleeds through the root's walls. + sink.OnInteriorFloodDrawTurn([cellId]); + driver.EndFrame(); + driver.Replay(draw.Frame, draw.Pass); + + Assert.Equal([cellId, cellId], leaf.Shells); + Assert.Equal(2, log.Count(entry => entry == "SHELL:f4180112")); + Assert.True( + log.IndexOf("SHELL:f4180112") < log.IndexOf("CLEAR"), + "The look-in shell must precede the interior clear."); + Assert.True( + log.LastIndexOf("SHELL:f4180112") > log.IndexOf("SEALS"), + "The rearmed root shell must repaint after the clear and seals."); + } + // ── Fail-loud: a DrawCells turn with no preceding DrawInside/Building // turn is a walk/driver desync, not a silent skip. ───────────────────── diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldConformanceTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldConformanceTests.cs index 27fecc03..ee941cd8 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldConformanceTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldConformanceTests.cs @@ -236,7 +236,7 @@ public sealed class WalkProductionWorldConformanceTests [Theory] [InlineData(0xF4180100u, 36.166267f, 79.828407f)] [InlineData(0xF4180101u, 36.391270f, 72.167931f)] - public void Cathedral_transition_keeps_south_hall_draws_inside_the_authored_aperture( + public void Cathedral_transition_keeps_south_hall_admission_bounded_for_depth_occlusion( uint cameraCellId, float x, float y) @@ -244,11 +244,12 @@ public sealed class WalkProductionWorldConformanceTests // Owner's exact 2026-08-31 repro: the remote player and special NPC // are parented in 0xF4180112, behind opaque cathedral walls. Retail // hides them on BOTH sides of the 0x100 <-> 0x101 transition. The walk - // legitimately reaches 0x112 through one authored building aperture; - // the regression was submitting each admitted mesh with slot 0, so the - // whole player/NPC escaped that aperture. Preserve the installed-DAT - // fact this fix depends on: every admitted route is a real, bounded - // portal polygon, never a pass-all zero-plane route. + // legitimately reaches 0x112 through one authored building aperture. + // Retail uses that cone only for coarse sphere admission, then draws + // each accepted shell/object whole; the complete intervening wall + // shell hides the remote actors through ordinary depth. Preserve the + // installed-DAT fact this depends on: every admitted route is a real, + // bounded portal polygon, never a pass-all zero-plane route. var pose = new WalkOraclePose( cameraCellId, new Vector3(x, y, 169.804993f), diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldDataTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldDataTests.cs index 98509591..d8d3a6c7 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldDataTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldDataTests.cs @@ -6,6 +6,77 @@ namespace AcDream.App.Tests.Rendering.Walk; public sealed class WalkProductionWorldDataTests { + [Fact] + public void BuildingShellBucketCellId_PortalLessBuildingUsesLandscapePositionCell() + { + const uint positionCellId = 0xF4180011u; + RenderProjectionRecord record = Record( + id: 0xCF418060u, + position: new Vector3(53.57f, 13.874f, 160f)) with + { + Source = new RenderSourceMetadata() with + { + LocalEntityId = 0xCF418060u, + SourceId = 0x01001FD3u, + EffectCellId = positionCellId, + BuildingShellAnchorCellId = 0, + }, + EntityPayload = new RenderEntityPayload() with + { + IsBuildingShell = true, + }, + }; + var building = new WalkBuilding + { + PositionCellId = positionCellId, + Portals = [], + }; + + Assert.Equal( + positionCellId, + WalkProductionWorldData.BuildingShellBucketCellId(in record)); + Assert.Equal( + positionCellId, + WalkProductionWorldData.BuildingShellBucketCellId(building)); + } + + [Fact] + public void BuildingShellBucketCellId_PortalBearingBuildingKeepsInteriorAnchor() + { + const uint anchorCellId = 0xF4180112u; + RenderProjectionRecord record = Record( + id: 0xCF41805Fu, + position: new Vector3(36f, 13.8349f, 160f)) with + { + Source = new RenderSourceMetadata() with + { + LocalEntityId = 0xCF41805Fu, + SourceId = 0x01001FB7u, + EffectCellId = 0xF4180009u, + BuildingShellAnchorCellId = anchorCellId, + }, + EntityPayload = new RenderEntityPayload() with + { + IsBuildingShell = true, + }, + }; + var building = new WalkBuilding + { + PositionCellId = 0xF4180009u, + Portals = + [ + new WalkBldPortal { OtherCellId = anchorCellId }, + ], + }; + + Assert.Equal( + anchorCellId, + WalkProductionWorldData.BuildingShellBucketCellId(in record)); + Assert.Equal( + anchorCellId, + WalkProductionWorldData.BuildingShellBucketCellId(building)); + } + [Fact] public void BucketOutdoorRecord_UsesEveryOutdoorPhysicsShadowCellAcrossLandblockEdge() { @@ -44,6 +115,160 @@ public sealed class WalkProductionWorldDataTests Assert.Equal(record, Assert.Single(buckets[0xF07F0002u])); } + [Fact] + public void BucketIndoorRecord_InstallsCrossCellPartInBothInteriorCells() + { + RenderProjectionRecord record = Record( + id: 0x48A02035u, + position: new Vector3(55.25f, -46.47f, -3f)) with + { + Source = new RenderSourceMetadata() with + { + LocalEntityId = 0x48A02035u, + ParentCellId = 0x8A02015Fu, + }, + }; + var indoor = new Dictionary>(); + var outdoor = new Dictionary>(); + + WalkProductionWorldData.BucketIndoorRecord( + in record, + [0x8A02015Fu, 0x8A02015Eu], + indoor, + outdoor); + + Assert.Equal([0x8A02015Eu, 0x8A02015Fu], indoor.Keys.Order()); + Assert.All(indoor.Values, bucket => Assert.Equal(record, Assert.Single(bucket))); + Assert.Empty(outdoor); + } + + [Fact] + public void BucketIndoorRecord_CanCrossAnExitIntoLandscapeCell() + { + RenderProjectionRecord record = Record( + id: 0x48A02035u, + position: Vector3.Zero) with + { + Source = new RenderSourceMetadata() with + { + LocalEntityId = 0x48A02035u, + ParentCellId = 0x8A02015Fu, + }, + }; + var indoor = new Dictionary>(); + var outdoor = new Dictionary>(); + + WalkProductionWorldData.BucketIndoorRecord( + in record, + [0x8A02015Fu, 0x8A020021u], + indoor, + outdoor); + + Assert.Equal(record, Assert.Single(indoor[0x8A02015Fu])); + Assert.Equal(record, Assert.Single(outdoor[0x8A020021u])); + } + + [Fact] + public void BucketDynamicRecord_InstallsMultipartPlayerInEveryCrossedInteriorCell() + { + RenderProjectionRecord record = Record( + id: 0x000F4243u, + position: new Vector3(58.81f, -49.42f, -0.85f)) with + { + Source = new RenderSourceMetadata() with + { + LocalEntityId = 0x000F4243u, + ParentCellId = 0x8A02015Eu, + }, + }; + var indoor = new Dictionary>(); + var outdoor = new Dictionary>(); + + WalkProductionWorldData.BucketDynamicRecord( + in record, + [0x8A02015Eu, 0x8A02015Fu, 0x8A0201C1u], + indoor, + outdoor, + renderCenterLbX: 0x8A, + renderCenterLbY: 0x02); + + Assert.Equal( + [0x8A02015Eu, 0x8A02015Fu, 0x8A0201C1u], + indoor.Keys.Order()); + Assert.All( + indoor.Values, + bucket => Assert.Equal(record, Assert.Single(bucket))); + Assert.Empty(outdoor); + } + + [Fact] + public void BucketDynamicRecord_UnregisteredInteriorEffectFallsBackToParentCell() + { + RenderProjectionRecord record = Record( + id: 0x00001234u, + position: Vector3.Zero) with + { + Source = new RenderSourceMetadata() with + { + LocalEntityId = 0x00001234u, + ParentCellId = 0x8A02015Fu, + }, + }; + var indoor = new Dictionary>(); + var outdoor = new Dictionary>(); + + WalkProductionWorldData.BucketDynamicRecord( + in record, + Array.Empty(), + indoor, + outdoor, + renderCenterLbX: 0x8A, + renderCenterLbY: 0x02); + + Assert.Equal(record, Assert.Single(indoor[0x8A02015Fu])); + Assert.Empty(outdoor); + } + + [Fact] + public void ResolveDynamicRenderCells_EquippedDescendantInheritsRootCellArray() + { + RenderProjectionRecord wand = Record( + id: 0x800045EEu, + position: Vector3.Zero) with + { + Source = new RenderSourceMetadata() with + { + LocalEntityId = 0x800045EEu, + ParentCellId = 0xF4180104u, + }, + EntityPayload = new RenderEntityPayload() with + { + CasterIdentity = RenderCasterIdentityKind.EquippedChild, + }, + }; + var owners = new Dictionary> + { + [0x5000000Au] = [0xF4180106u, 0xF4180104u], + }; + var parents = new Dictionary + { + [0x800045EEu] = 0x80000461u, + [0x80000461u] = 0x5000000Au, + }; + + IReadOnlyList cells = + WalkProductionWorldData.ResolveDynamicRenderCells( + in wand, + id => owners.TryGetValue(id, out IReadOnlyList? value) + ? value + : Array.Empty(), + id => parents.TryGetValue(id, out uint value) + ? value + : null); + + Assert.Equal([0xF4180106u, 0xF4180104u], cells); + } + private static RenderProjectionRecord Record(uint id, Vector3 position) => new RenderProjectionRecord() with { diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs index dce5a30f..b0d9850f 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs @@ -69,7 +69,9 @@ public sealed class WalkStaticStreamPopulatorTests Vector3 position, IReadOnlyList meshRefs, bool isBuildingShell = false, - uint parentCellId = 0u) => + uint parentCellId = 0u, + RenderCasterIdentityKind casterIdentity = + RenderCasterIdentityKind.Unclassified) => new( Id: RenderProjectionId.FromRaw(localEntityId), ProjectionClass: RenderProjectionClass.OutdoorStatic, @@ -97,7 +99,8 @@ public sealed class WalkStaticStreamPopulatorTests EntityPayload: new RenderEntityPayload( MeshRefs: meshRefs, PaletteOverride: null, - IsBuildingShell: isBuildingShell)); + IsBuildingShell: isBuildingShell, + CasterIdentity: casterIdentity)); private static ObjectRenderBatch MakeBatch( uint surfaceId, @@ -274,7 +277,161 @@ public sealed class WalkStaticStreamPopulatorTests } [Fact] - public void ClassifyEntityForWalk_EmitsOneGpuClippedInstancePerPortalViewSlice() + public void ClassifyEntityForWalk_FrameScopeStampsEachAdmittedSetupPartOncePerRetailPass() + { + using var fx = new DispatcherFixture(); + const ulong setupGfxObj = 0x1000_0020UL; + const ulong headGfxObj = 0x0100_0021UL; + const ulong torsoGfxObj = 0x0100_0022UL; + + InjectRenderData(fx.Manager, headGfxObj, MakeFlatMesh( + MakeBatch(0x08000021u, TranslucencyKind.Opaque, 0, 0, 3, 1))); + InjectRenderData(fx.Manager, torsoGfxObj, MakeFlatMesh( + MakeBatch(0x08000022u, TranslucencyKind.Opaque, 3, 4, 6, 2))); + InjectRenderData(fx.Manager, setupGfxObj, new ObjectRenderData + { + IsSetup = true, + SetupParts = new List<(ulong GfxObjId, Matrix4x4 Transform)> + { + (headGfxObj, Matrix4x4.CreateTranslation(0, 0, 2)), + (torsoGfxObj, Matrix4x4.CreateTranslation(0, 0, 1)), + }, + }); + + RenderProjectionRecord record = MakeRecord( + 220, + 0, + Vector3.Zero, + [new MeshRef((uint)setupGfxObj, Matrix4x4.Identity)]); + var batches = new List(); + var selectionParts = new List(); + + fx.Dispatcher.BeginWalkPartFrame(); + try + { + fx.Dispatcher.ClassifyEntityForWalk( + in record, 0x8C04u, batches, selectionParts); + Assert.Equal(2, batches.Count); + Assert.Equal(2, selectionParts.Count); + + batches.Clear(); + selectionParts.Clear(); + fx.Dispatcher.ClassifyEntityForWalk( + in record, 0x8C04u, batches, selectionParts); + Assert.Empty(batches); + Assert.Empty(selectionParts); + + // PView::DrawCells @0x005A4886 increments m_nFrameStamp after + // the landscape pass and before its interior-cell repaint. The + // same parts may therefore submit once again in the second pass. + fx.Dispatcher.AdvanceWalkPartPassStamp(); + fx.Dispatcher.ClassifyEntityForWalk( + in record, 0x8C04u, batches, selectionParts); + Assert.Equal(2, batches.Count); + Assert.Equal(2, selectionParts.Count); + } + finally + { + fx.Dispatcher.EndWalkPartFrame(); + } + + // Direct classifier calls outside a production walk frame remain + // independent, as the conformance/referee tests require. + batches.Clear(); + selectionParts.Clear(); + fx.Dispatcher.ClassifyEntityForWalk( + in record, 0x8C04u, batches, selectionParts); + Assert.Equal(2, batches.Count); + Assert.Equal(2, selectionParts.Count); + } + + [Fact] + public void ClassifyEntityForWalk_FrameScopeDoesNotStampPortalRejectedPart() + { + using var fx = new DispatcherFixture(); + const ulong gfxObj = 0x0100_0023UL; + InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh( + MakeBatch(0x08000023u, TranslucencyKind.Opaque, 0, 0, 3, 1))); + RenderProjectionRecord record = MakeRecord( + 221, + 0, + Vector3.Zero, + [new MeshRef((uint)gfxObj, Matrix4x4.Identity)]); + var batches = new List(); + var selectionParts = new List(); + + fx.Dispatcher.BeginWalkPartFrame(); + try + { + fx.Dispatcher.ClassifyEntityForWalk( + in record, + 0x8C04u, + batches, + selectionParts, + liveDynamic: true, + lookInViews: new FixedWalkViews(), + lookInRouteIndex: 0); + Assert.Empty(batches); + Assert.Empty(selectionParts); + + fx.Dispatcher.ClassifyEntityForWalk( + in record, + 0x8C04u, + batches, + selectionParts, + liveDynamic: true, + lookInViews: new FixedWalkViews(7u), + lookInRouteIndex: 1); + Assert.Single(batches); + Assert.Single(selectionParts); + } + finally + { + fx.Dispatcher.EndWalkPartFrame(); + } + } + + [Fact] + public void ClassifyEntityForWalk_LocalPlayerBypassesDrawnPartStampLikeRetail() + { + using var fx = new DispatcherFixture(); + const ulong gfxObj = 0x0100_0024UL; + InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh( + MakeBatch(0x08000024u, TranslucencyKind.Opaque, 0, 0, 3, 1))); + RenderProjectionRecord player = MakeRecord( + 222, + 0x5000_0001u, + Vector3.Zero, + [new MeshRef((uint)gfxObj, Matrix4x4.Identity)], + casterIdentity: RenderCasterIdentityKind.LocalPlayer); + var batches = new List(); + var selectionParts = new List(); + + fx.Dispatcher.BeginWalkPartFrame(); + try + { + fx.Dispatcher.ClassifyEntityForWalk( + in player, 0xF418u, batches, selectionParts, + liveDynamic: true); + Assert.Single(batches); + Assert.Single(selectionParts); + + batches.Clear(); + selectionParts.Clear(); + fx.Dispatcher.ClassifyEntityForWalk( + in player, 0xF418u, batches, selectionParts, + liveDynamic: true); + Assert.Single(batches); + Assert.Single(selectionParts); + } + finally + { + fx.Dispatcher.EndWalkPartFrame(); + } + } + + [Fact] + public void ClassifyEntityForWalk_PortalViewsAdmitOneCompleteUnclippedMesh() { using var fx = new DispatcherFixture(); const ulong gfxObj = 0x0100_0013UL; @@ -298,7 +455,8 @@ public sealed class WalkStaticStreamPopulatorTests lookInRouteIndex: 0, lookInCellId: 0x8C040112u); - Assert.Equal([7u, 9u], batches.Select(static batch => batch.ClipSlot)); + WbDrawDispatcher.WalkClassifiedBatch batch = Assert.Single(batches); + Assert.Equal(0u, batch.ClipSlot); Assert.Single(selectionParts); } diff --git a/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs index afefb701..4aed26cf 100644 --- a/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs +++ b/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs @@ -254,7 +254,7 @@ public sealed class WorldRenderFrameBuilderTests } [Fact] - public void Runtime_environment_copies_visible_cells_and_clear_restores_unscoped_lights() + public void Runtime_environment_uses_resident_lights_independent_of_drawable_cells() { const uint visibleCell = 0x01010100u; const uint hiddenCell = 0x01010101u; @@ -291,7 +291,7 @@ public sealed class WorldRenderFrameBuilderTests environment.Prepare(in camera, in roots, in foundation, activeDayGroup: null); Assert.Contains(visibleLight, lighting.PointSnapshot); - Assert.DoesNotContain(hiddenLight, lighting.PointSnapshot); + Assert.Contains(hiddenLight, lighting.PointSnapshot); environment.ClearDrawableCells(); environment.Prepare(in camera, in roots, in foundation, activeDayGroup: null); diff --git a/tests/AcDream.Content.Tests/InstalledPreparedCollisionCatalogTests.cs b/tests/AcDream.Content.Tests/InstalledPreparedCollisionCatalogTests.cs index 186ecde2..e3580ebd 100644 --- a/tests/AcDream.Content.Tests/InstalledPreparedCollisionCatalogTests.cs +++ b/tests/AcDream.Content.Tests/InstalledPreparedCollisionCatalogTests.cs @@ -1,12 +1,46 @@ using AcDream.Content.Pak; using DatReaderWriter; using DatReaderWriter.Options; +using GfxObj = DatReaderWriter.DBObjs.GfxObj; namespace AcDream.Content.Tests; [Trait("Lane", "PreparedPackage")] public sealed class InstalledPreparedCollisionCatalogTests { + [Fact] + public void InstalledPackage_FacilityHubStepsCarryRetailDrawingSphere() + { + const uint stairGfxObjId = 0x0100_00DEu; + string? datDir = ResolveDatDir(); + if (datDir is null) + Assert.Fail("Lane=PreparedPackage requires installed retail DATs and a validated acdream.pak; see docs/release-gate.md."); + + string packagePath = ResolvePackagePath(datDir); + if (!File.Exists(packagePath)) + Assert.Fail("Lane=PreparedPackage requires installed retail DATs and a validated acdream.pak; see docs/release-gate.md."); + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + using var source = new PakPreparedAssetSource(packagePath, adapter); + GfxObj stair = Assert.IsType(dats.Get(stairGfxObjId)); + + PreparedAssetReadResult prepared = source.Read( + PreparedAssetRequest.GfxObj(stairGfxObjId)); + + Assert.Equal(PreparedAssetReadStatus.Loaded, prepared.Status); + Assert.NotNull(prepared.Data); + Assert.NotNull(prepared.Data!.SelectionSphere); + DatReaderWriter.Types.DrawingBSPNode root = Assert.IsType< + DatReaderWriter.Types.DrawingBSPNode>(stair.DrawingBSP.Root); + Assert.Equal( + root.BoundingSphere.Origin, + prepared.Data.SelectionSphere.Origin); + Assert.Equal( + root.BoundingSphere.Radius, + prepared.Data.SelectionSphere.Radius); + } + [Fact] public void InstalledPackage_ContainsAndReadsCanonicalCollisionKeys() { diff --git a/tests/AcDream.Content.Tests/MeshExtractorSolidFaceExtractionTests.cs b/tests/AcDream.Content.Tests/MeshExtractorSolidFaceExtractionTests.cs index d8e51d06..aa88fed6 100644 --- a/tests/AcDream.Content.Tests/MeshExtractorSolidFaceExtractionTests.cs +++ b/tests/AcDream.Content.Tests/MeshExtractorSolidFaceExtractionTests.cs @@ -35,6 +35,47 @@ public sealed class MeshExtractorSolidFaceExtractionTests private const uint SurfaceTextureId = 0x05000001u; private const uint RenderSurfaceId = 0x06000001u; + [Fact] + public void PrepareMeshData_CarriesAuthoredDrawingBspSphereInsteadOfVertexAabbSphere() + { + var authored = new Sphere + { + Origin = new Vector3(-0.25f, 0.125f, 0.5f), + Radius = 3.125f, + }; + GfxObj gfxObj = BuildQuadGfxObj(SolidSurfaceId, noPos: true); + gfxObj.DrawingBSP = new DrawingBSPTree + { + Root = new DrawingBSPNode { BoundingSphere = authored }, + }; + var dats = new FakeMeshExtractorDats(); + dats.RegisterRootGfxObj(GfxObjId, gfxObj); + dats.Register(SolidSurfaceId, new Surface + { + Type = SurfaceType.Base1Solid, + ColorValue = new ColorARGB + { + Alpha = 255, + Red = 12, + Green = 34, + Blue = 56, + }, + }); + var extractor = new MeshExtractor( + dats, + NullLogger.Instance, + sideStagedSink: null); + + ObjectMeshData? mesh = extractor.PrepareMeshData( + GfxObjId, + isSetup: false); + + Assert.NotNull(mesh); + Assert.NotNull(mesh!.SelectionSphere); + Assert.Equal(authored.Origin, mesh.SelectionSphere.Origin); + Assert.Equal(authored.Radius, mesh.SelectionSphere.Radius); + } + /// /// One quad polygon, NoPos + Base1Solid: the exact shape of the windmill /// axle's own polygons. Must now extract to 4 vertices / 6 indices in a diff --git a/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs b/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs index 86e4f4fd..c76c8b86 100644 --- a/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs +++ b/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs @@ -276,8 +276,7 @@ public sealed class LightManagerTests LightSource[] expected = FullSortOracle( registered, - Vector3.Zero, - visibleCells: null); + Vector3.Zero); manager.BuildPointLightSnapshot(Vector3.Zero); Assert.Equal(expected, manager.PointSnapshot); @@ -324,23 +323,15 @@ public sealed class LightManagerTests manager.Register(light); } - IReadOnlySet? visibleCells = scenario % 2 == 0 - ? new HashSet - { - 0xAAAA0101u, - 0xAAAA0103u, - } - : null; Vector3 player = new( random.Next(-4, 5), random.Next(-4, 5), random.Next(-2, 3)); LightSource[] expected = FullSortOracle( registered, - player, - visibleCells); + player); - manager.BuildPointLightSnapshot(player, visibleCells); + manager.BuildPointLightSnapshot(player); Assert.Equal(expected, manager.PointSnapshot); } @@ -369,15 +360,12 @@ public sealed class LightManagerTests registered.Add(light); manager.Register(light); } - IReadOnlySet visibleCells = - new HashSet { fountainRoom, corridor }; Vector3 player = new(4.25f, -1.5f, 0.7f); LightSource[] expected = FullSortOracle( registered, - player, - visibleCells); + player); - manager.BuildPointLightSnapshot(player, visibleCells); + manager.BuildPointLightSnapshot(player); Assert.Equal(LightManager.MaxGlobalLights, manager.PointSnapshot.Count); Assert.Equal(expected, manager.PointSnapshot); @@ -415,71 +403,11 @@ public sealed class LightManagerTests Assert.Equal(0, allocated); } - // ── Visible-cell scoping (A7.L1, 2026-07-09 — the Town Network starvation fix) ── - // BuildPointLightSnapshot's player-nearest cap sorts by raw Euclidean distance, - // which is not a reliable proxy for "same room" in a dense, maze-like hub: a - // fixture on the other side of a wall can be geometrically closer than the - // player's own room's torches. The Town Network fountain room (463 registered - // fixtures, cap 128) went dark because far-denser, closer-in-a-straight-line - // corridor fixtures won the cap over the room's own lights. Filtering candidacy - // by the frame's actual visible-cell set (the render already computes this) - // fixes it without touching the distance-sort anchor (still the PLAYER, per the - // #176 correction — camera anchoring is what caused the earlier flicker). - [Fact] - public void BuildPointLightSnapshot_VisibleCellScoping_RoomLightsSurviveOverEuclideanCloserInvisibleCell() + public void BuildPointLightSnapshot_UsesAllResidentLights() { - var mgr = new LightManager(); - - // A different, NOT-visible cell packed with fixtures that are, in raw - // straight-line distance, closer to the player than the room's own - // torches (e.g. a corridor on the other side of a wall). - const uint otherCellId = 0xAAAA0102u; - for (int i = 0; i < LightManager.MaxGlobalLights + 50; i++) - mgr.Register(MakePoint(new Vector3(1f + i * 0.001f, 1f, 0), range: 5f, ownerId: (uint)(i + 1), cellId: otherCellId)); - - // The player's own room: a handful of torches, each FARTHER in raw - // distance than every "other cell" fixture above, but the only cell - // actually visible from the player's viewpoint this frame. - const uint roomCellId = 0xAAAA0101u; - var roomTorches = new LightSource[5]; - for (int i = 0; i < roomTorches.Length; i++) - { - roomTorches[i] = MakePoint(new Vector3(50f + i, 0, 0), range: 15f, cellId: roomCellId); - mgr.Register(roomTorches[i]); - } - - var visibleCells = new HashSet { roomCellId }; - mgr.BuildPointLightSnapshot(playerWorldPos: Vector3.Zero, visibleCells); - - foreach (var torch in roomTorches) - Assert.Contains(torch, mgr.PointSnapshot); - } - - [Fact] - public void BuildPointLightSnapshot_VisibleCellScoping_CellLessLightAlwaysIncluded() - { - // The viewer fill light (CellId==0) must survive scoping unconditionally — - // retail's per-frame add_dynamic_light(&viewer_light, ...) is unconditional - // (LightManager.UpdateViewerLight's doc comment). - var mgr = new LightManager(); - var viewerFill = MakePoint(new Vector3(0, 0, 2), range: 15f, cellId: 0u); - mgr.Register(viewerFill); - var otherRoom = MakePoint(new Vector3(2, 0, 0), range: 5f, cellId: 0xBEEFu); - mgr.Register(otherRoom); - - var visibleCells = new HashSet { 0xF00Du }; // neither light's cell - mgr.BuildPointLightSnapshot(Vector3.Zero, visibleCells); - - Assert.Contains(viewerFill, mgr.PointSnapshot); - Assert.DoesNotContain(otherRoom, mgr.PointSnapshot); - } - - [Fact] - public void BuildPointLightSnapshot_NoVisibleCellsArg_UnscopedLegacyBehavior() - { - // Outdoor / no-clipRoot callers omit visibleCells — every registered lit - // light stays a candidate, exactly the pre-A7.L1 behavior. + // Retail walks the resident EnvCell registry. A camera-root transition + // cannot make either resident cell stop contributing candidates. var mgr = new LightManager(); mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f, cellId: 0xAAAAu)); mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, cellId: 0xBBBBu)); @@ -685,8 +613,7 @@ public sealed class LightManagerTests private static LightSource[] FullSortOracle( IReadOnlyList registered, - Vector3 player, - IReadOnlySet? visibleCells) + Vector3 player) { var ranked = new List(); for (int index = 0; index < registered.Count; index++) @@ -694,13 +621,6 @@ public sealed class LightManagerTests LightSource light = registered[index]; if (!light.IsLit || light.Kind == LightKind.Directional) continue; - if (visibleCells is not null - && light.CellId != 0 - && !visibleCells.Contains(light.CellId)) - { - continue; - } - ranked.Add(new OracleRank( light, Vector3.DistanceSquared(light.WorldPosition, player))); diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsBoxTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsBoxTests.cs index 6556e3cc..d5f9482c 100644 --- a/tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsBoxTests.cs +++ b/tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsBoxTests.cs @@ -21,7 +21,9 @@ namespace AcDream.Core.Tests.Physics; public sealed class CellTransitFindTransitCellsBoxTests { private static CellPhysics MakeCellWithPortalAtRightWall( - Matrix4x4 worldTransform, uint otherCellId, ushort flags) + Matrix4x4 worldTransform, + uint otherCellId, + ushort flags) { // Portal poly at local x=2.5 (right wall), normal +X. Same shape as // CellTransitFindTransitCellsSphereTests' fixture, so the sphere-only @@ -142,6 +144,47 @@ public sealed class CellTransitFindTransitCellsBoxTests Assert.DoesNotContain(0xA9B40101u, endToEnd); } + [Fact] + public void StaticPartArrayCrossesExteriorPortal_KeepsOutdoorCells() + { + // CPhysicsObj::calc_cross_cells_static sets do_not_load_cells before + // entering find_bbox_cell_list, but retail's bbox worklist returns + // the CELLARRAY directly. The post-pass prune at CObjCell:: + // find_cell_list+0x18E belongs only to that separate sphere route. + // This is the cathedral-ramp shape: an indoor static box genuinely + // reaches an exterior portal and must remain registered in outdoor + // shadow cells even when the seed's stab list is empty. + var cache = new PhysicsDataCache(); + const uint seedCell = 0xF4180112u; + cache.RegisterCellStructForTest( + seedCell, + MakeCellWithPortalAtRightWall( + Matrix4x4.Identity, + otherCellId: 0xFFFF, + flags: 0)); + + var partWorldPos = new Vector3(2.0f, 0f, 2.5f); + var sphere = new Sphere { Origin = partWorldPos, Radius = 0.7f }; + var box = new[] + { + MakeBox( + new Vector3(-0.7f), + new Vector3(0.7f), + partWorldPos, + Quaternion.Identity), + }; + + IReadOnlyList cells = CellTransit.BuildShadowCellSetFromParts( + cache, + seedCell, + box, + new[] { sphere }, + isStatic: true); + + Assert.Contains(seedCell, cells); + Assert.Contains(cells, id => (id & 0xFFFFu) is >= 1u and <= 64u); + } + // ── D3.2: inverse guard — a box that DOES cross admits, unchanged ────── /// diff --git a/tests/AcDream.Launcher.Core.Tests/Installation/ContentMigrationCatalogTests.cs b/tests/AcDream.Launcher.Core.Tests/Installation/ContentMigrationCatalogTests.cs index f73b3fac..0ff88665 100644 --- a/tests/AcDream.Launcher.Core.Tests/Installation/ContentMigrationCatalogTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Installation/ContentMigrationCatalogTests.cs @@ -5,25 +5,26 @@ namespace AcDream.Launcher.Core.Tests.Installation; public sealed class ContentMigrationCatalogTests { [Fact] - public void RecipeFiveToSixRequiresOneExplicitFullRebuild() + public void RecipeSixToSevenRequiresOneExplicitFullRebuild() { - ContentMigrationPlan plan = ContentMigrationCatalog.Resolve(5, 6); + ContentMigrationPlan plan = ContentMigrationCatalog.Resolve(6, 7); Assert.Equal(ContentWorkKind.FullRebuild, plan.Kind); - Assert.Equal(5u, plan.FromRecipeVersion); - Assert.Equal(6u, plan.TargetRecipeVersion); - Assert.Contains("pak v2", plan.Reason, StringComparison.OrdinalIgnoreCase); + Assert.Equal(6u, plan.FromRecipeVersion); + Assert.Equal(7u, plan.TargetRecipeVersion); + Assert.Contains("DrawingBSP", plan.Reason, StringComparison.OrdinalIgnoreCase); Assert.Empty(plan.EffectiveDatIds); Assert.Empty(plan.EffectiveLandblocks); } [Fact] - public void AnyOlderRecipeToSixCollapsesToOneFullRebuild() + public void AnyOlderRecipeToSevenCollapsesToOneFullRebuild() { - ContentMigrationPlan plan = ContentMigrationCatalog.Resolve(1, 6); + ContentMigrationPlan plan = ContentMigrationCatalog.Resolve(1, 7); Assert.Equal(ContentWorkKind.FullRebuild, plan.Kind); - Assert.Equal(6u, plan.TargetRecipeVersion); + Assert.Equal(7u, plan.TargetRecipeVersion); Assert.Contains("pak v2", plan.Reason, StringComparison.OrdinalIgnoreCase); + Assert.Contains("DrawingBSP", plan.Reason, StringComparison.OrdinalIgnoreCase); } } diff --git a/tools/A8CellAudit/Program.cs b/tools/A8CellAudit/Program.cs index 654ccd56..308063c7 100644 --- a/tools/A8CellAudit/Program.cs +++ b/tools/A8CellAudit/Program.cs @@ -43,6 +43,16 @@ else if (args.Length > 0 && string.Equals(args[0], "gfxobj", StringComparison.Or foreach (var gfxObjId in ids) DumpGfxObj(dats, gfxObjId); } +else if (args.Length > 0 && string.Equals(args[0], "scan-drawing-spheres", StringComparison.OrdinalIgnoreCase)) +{ + return AuditDrawingSpheres(dats); +} +else if (args.Length > 0 && string.Equals(args[0], "setup", StringComparison.OrdinalIgnoreCase)) +{ + var ids = args.Skip(1).Select(ParseHex).ToArray(); + foreach (var setupId in ids) + DumpSetup(dats, setupId); +} else { var ids = args.Length == 0 @@ -57,6 +67,76 @@ else return 0; +static int AuditDrawingSpheres(DatCollection dats) +{ + const float absoluteTolerance = 1e-4f; + long objects = 0; + long withDrawingSphere = 0; + long withoutRenderableVertices = 0; + long invalid = 0; + var failures = new List<(uint Id, int RenderVertices, float Radius, float RequiredRadius, Vector3 Center)>(); + + foreach (uint gfxObjId in dats.GetAllIdsOfType().OrderBy(id => id)) + { + GfxObj? gfxObj = dats.Get(gfxObjId); + if (gfxObj is null) + continue; + + objects++; + if (gfxObj.DrawingBSP?.Root?.BoundingSphere is not { } sphere) + continue; + + withDrawingSphere++; + var renderVertexIds = gfxObj.Polygons.Values + .SelectMany(poly => poly.VertexIds) + .Distinct() + .ToArray(); + if (renderVertexIds.Length == 0) + { + withoutRenderableVertices++; + continue; + } + + float requiredRadius = 0f; + foreach (var vertexId in renderVertexIds) + { + if (!gfxObj.VertexArray.Vertices.TryGetValue((ushort)vertexId, out var vertex)) + continue; + requiredRadius = MathF.Max(requiredRadius, Vector3.Distance(sphere.Origin, vertex.Origin)); + } + + float tolerance = MathF.Max(absoluteTolerance, MathF.Abs(sphere.Radius) * 1e-5f); + if (!float.IsFinite(sphere.Radius) + || sphere.Radius < 0f + || requiredRadius > sphere.Radius + tolerance) + { + invalid++; + failures.Add((gfxObjId, renderVertexIds.Length, sphere.Radius, requiredRadius, sphere.Origin)); + } + } + + Console.WriteLine( + $"gfxobjs={objects} withDrawingSphere={withDrawingSphere} " + + $"withoutRenderableVertices={withoutRenderableVertices} invalid={invalid}"); + foreach (var failure in failures + .OrderByDescending(entry => entry.Radius > 0f + ? entry.RequiredRadius / entry.Radius + : float.PositiveInfinity) + .ThenBy(entry => entry.Id) + .Take(100)) + { + float ratio = failure.Radius > 0f + ? failure.RequiredRadius / failure.Radius + : float.PositiveInfinity; + Console.WriteLine( + $"0x{failure.Id:X8} renderVerts={failure.RenderVertices} " + + $"sphere=({failure.Center.X:R},{failure.Center.Y:R},{failure.Center.Z:R};r={failure.Radius:R}) " + + $"required={failure.RequiredRadius:R} ratio={ratio:R}"); + } + + return 0; +} + static int AuditCellWindingCatalog(DatCollection dats) { long environments = 0; @@ -213,7 +293,22 @@ static void DumpCell(DatCollection dats, uint envCellId) Console.WriteLine( $"environment=0x{envId:X8} cellStruct={envCell.CellStructure} " + - $"surfaces={envCell.Surfaces.Count} verts={cellStruct.VertexArray.Vertices.Count} polys={cellStruct.Polygons.Count}"); + $"surfaces={envCell.Surfaces.Count} verts={cellStruct.VertexArray.Vertices.Count} polys={cellStruct.Polygons.Count} " + + $"pos=({envCell.Position.Origin.X:R},{envCell.Position.Origin.Y:R},{envCell.Position.Origin.Z:R}) " + + $"quat=({envCell.Position.Orientation.W:R},{envCell.Position.Orientation.X:R}," + + $"{envCell.Position.Orientation.Y:R},{envCell.Position.Orientation.Z:R})"); + Console.WriteLine( + "visible=[" + string.Join(",", envCell.VisibleCells.Select( + id => $"0x{((envCellId & 0xFFFF0000u) | id):X8}")) + "]"); + for (int staticIndex = 0; staticIndex < envCell.StaticObjects.Count; staticIndex++) + { + var staticObject = envCell.StaticObjects[staticIndex]; + Console.WriteLine( + $"static[{staticIndex}] model=0x{staticObject.Id:X8} " + + $"pos=({staticObject.Frame.Origin.X:R},{staticObject.Frame.Origin.Y:R},{staticObject.Frame.Origin.Z:R}) " + + $"quat=({staticObject.Frame.Orientation.W:R},{staticObject.Frame.Orientation.X:R}," + + $"{staticObject.Frame.Orientation.Y:R},{staticObject.Frame.Orientation.Z:R})"); + } int posSides = 0; int negSides = 0; @@ -240,6 +335,22 @@ static void DumpCell(DatCollection dats, uint envCellId) var normal = ComputeNormal(cellStruct, poly); var uvRange = UvRange(cellStruct, poly); + var localMin = new Vector3(float.MaxValue); + var localMax = new Vector3(float.MinValue); + foreach (int vertexId in poly.VertexIds) + { + if (!TryGetOrigin(cellStruct, checked((ushort)vertexId), out Vector3 vertex)) + continue; + localMin = Vector3.Min(localMin, vertex); + localMax = Vector3.Max(localMax, vertex); + } + Matrix4x4 cellTransform = + Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) + * Matrix4x4.CreateTranslation(envCell.Position.Origin); + Vector3 worldA = Vector3.Transform(localMin, cellTransform); + Vector3 worldB = Vector3.Transform(localMax, cellTransform); + Vector3 worldMin = Vector3.Min(worldA, worldB); + Vector3 worldMax = Vector3.Max(worldA, worldB); string planeHint = Math.Abs(normal.Z) > 0.9f ? normal.Z > 0 ? "floor/up" : "ceiling/down" : Math.Abs(normal.Z) > 0.15f ? "slope" : "wall"; @@ -253,7 +364,9 @@ static void DumpCell(DatCollection dats, uint envCellId) $"poly=0x{polyId:X4} pts={poly.VertexIds.Count} n=({normal.X:F3},{normal.Y:F3},{normal.Z:F3}) {planeHint,-12} " + $"stip={poly.Stippling} sides={poly.SidesType} pos={poly.PosSurface}->{posSurf} neg={poly.NegSurface}->{negSurf} " + $"emitPos={emitPos} emitNeg={emitNeg} posUv={poly.PosUVIndices?.Count ?? 0} negUv={poly.NegUVIndices?.Count ?? 0} " + - $"uv=({uvRange.Min.X:F3},{uvRange.Min.Y:F3})..({uvRange.Max.X:F3},{uvRange.Max.Y:F3})"); + $"uv=({uvRange.Min.X:F3},{uvRange.Min.Y:F3})..({uvRange.Max.X:F3},{uvRange.Max.Y:F3}) " + + $"local=({localMin.X:F3},{localMin.Y:F3},{localMin.Z:F3})..({localMax.X:F3},{localMax.Y:F3},{localMax.Z:F3}) " + + $"world=({worldMin.X:F3},{worldMin.Y:F3},{worldMin.Z:F3})..({worldMax.X:F3},{worldMax.Y:F3},{worldMax.Z:F3})"); } Console.WriteLine( @@ -568,6 +681,46 @@ static void DumpGfxObj(DatCollection dats, uint gfxObjId) Console.WriteLine( $"bbox min=({min.X:F2},{min.Y:F2},{min.Z:F2}) max=({max.X:F2},{max.Y:F2},{max.Z:F2}) " + $"size=({max.X - min.X:F2},{max.Y - min.Y:F2},{max.Z - min.Z:F2})"); + Console.WriteLine( + $"bboxExact min=({min.X:R},{min.Y:R},{min.Z:R}) " + + $"max=({max.X:R},{max.Y:R},{max.Z:R})"); + if (g.DrawingBSP?.Root?.BoundingSphere is { } drawingSphere) + { + float requiredRadius = 0f; + foreach (var vertexId in g.Polygons.Values.SelectMany(poly => poly.VertexIds).Distinct()) + { + if (g.VertexArray.Vertices.TryGetValue((ushort)vertexId, out var vertex)) + requiredRadius = MathF.Max(requiredRadius, Vector3.Distance(drawingSphere.Origin, vertex.Origin)); + } + Console.WriteLine( + $"drawingSphere=({drawingSphere.Origin.X:R},{drawingSphere.Origin.Y:R}," + + $"{drawingSphere.Origin.Z:R};r={drawingSphere.Radius:R}) " + + $"requiredForRenderVertices={requiredRadius:R} " + + $"ratio={(drawingSphere.Radius > 0f ? requiredRadius / drawingSphere.Radius : float.PositiveInfinity):R}"); + DumpDrawingBsp(g.DrawingBSP.Root, depth: 0); + } + if (g.PhysicsBSP?.Root?.BoundingSphere is { } physicsSphere) + { + Console.WriteLine( + $"physicsSphere=({physicsSphere.Origin.X:R},{physicsSphere.Origin.Y:R}," + + $"{physicsSphere.Origin.Z:R};r={physicsSphere.Radius:R})"); + } + if (g.Flags.HasFlag(GfxObjFlags.HasDIDDegrade) && g.DIDDegrade != 0) + { + GfxObjDegradeInfo? degrade = dats.Get(g.DIDDegrade); + Console.WriteLine( + $"degrade=0x{g.DIDDegrade:X8} entries={degrade?.Degrades.Count ?? 0}"); + if (degrade is not null) + { + for (int i = 0; i < degrade.Degrades.Count; i++) + { + var level = degrade.Degrades[i]; + Console.WriteLine( + $" level[{i}] gfx=0x{(uint)level.Id:X8} min={level.MinDist:R} " + + $"ideal={level.IdealDist:R} max={level.MaxDist:R} mode={level.DegradeMode}"); + } + } + } Console.WriteLine( $"classify: walls={walls} (outwardFacing={outwardWalls} inwardFacing={inwardWalls}) " + $"floors={floors} ceilings={ceilings} slopes={slopes}"); @@ -595,6 +748,76 @@ static void DumpGfxObj(DatCollection dats, uint gfxObjId) Console.WriteLine(); } +static void DumpDrawingBsp( + DatReaderWriter.Types.DrawingBSPNode? node, + int depth) +{ + if (node is null) + return; + + string indent = new(' ', depth * 2); + var sphere = node.BoundingSphere; + string polygons = node.Polygons is null + ? "[]" + : "[" + string.Join(",", node.Polygons.Select(id => $"0x{id:X4}")) + "]"; + Console.WriteLine( + $"{indent}drawingNode type={node.GetType().Name} " + + $"sphere=({sphere.Origin.X:R},{sphere.Origin.Y:R},{sphere.Origin.Z:R};r={sphere.Radius:R}) " + + $"polys={polygons} portals={node.Portals?.Count ?? 0}"); + DumpDrawingBsp(node.PosNode, depth + 1); + DumpDrawingBsp(node.NegNode, depth + 1); +} + +static void DumpSetup(DatCollection dats, uint setupId) +{ + Console.WriteLine($"=== Setup 0x{setupId:X8} ==="); + var setup = dats.Get(setupId); + if (setup is null) + { + Console.WriteLine("missing Setup"); + return; + } + + Console.WriteLine( + $"flags={setup.Flags} radius={setup.Radius:R} height={setup.Height:R} " + + $"parts={setup.Parts.Count} cylspheres={setup.CylSpheres.Count} spheres={setup.Spheres.Count}"); + for (int i = 0; i < setup.CylSpheres.Count; i++) + { + var cylinder = setup.CylSpheres[i]; + Console.WriteLine( + $"cyl[{i}] origin=({cylinder.Origin.X:R},{cylinder.Origin.Y:R},{cylinder.Origin.Z:R}) " + + $"radius={cylinder.Radius:R} height={cylinder.Height:R}"); + } + for (int i = 0; i < setup.Spheres.Count; i++) + { + var sphere = setup.Spheres[i]; + Console.WriteLine( + $"sphere[{i}] origin=({sphere.Origin.X:R},{sphere.Origin.Y:R},{sphere.Origin.Z:R}) " + + $"radius={sphere.Radius:R}"); + } + for (int i = 0; i < setup.Parts.Count; i++) + { + uint partId = setup.Parts[i]; + GfxObj? part = dats.Get(partId); + Console.WriteLine( + $"part[{i}] gfx=0x{partId:X8} flags={(part is null ? "missing" : part.Flags)} " + + $"physics={(part?.PhysicsBSP?.Root is null ? 0 : 1)} " + + $"visualVerts={part?.VertexArray?.Vertices.Count ?? 0} visualPolys={part?.Polygons?.Count ?? 0}"); + } + foreach (var (placement, frames) in setup.PlacementFrames) + { + Console.WriteLine($"placement={placement} frames={frames.Frames.Count}"); + for (int i = 0; i < frames.Frames.Count; i++) + { + var frame = frames.Frames[i]; + Console.WriteLine( + $" frame[{i}] pos=({frame.Origin.X:R},{frame.Origin.Y:R},{frame.Origin.Z:R}) " + + $"quat=({frame.Orientation.W:R},{frame.Orientation.X:R}," + + $"{frame.Orientation.Y:R},{frame.Orientation.Z:R})"); + } + } +} + static Vector3 ComputeNormalG(GfxObj g, DatReaderWriter.Types.Polygon poly) { if (poly.VertexIds.Count < 3) return Vector3.Zero;