checkpoint(render): preserve pre-overhaul investigation state

This commit is contained in:
Erik 2026-09-01 18:04:24 +02:00
parent e880860291
commit b3b7d922f1
45 changed files with 3168 additions and 619 deletions

View file

@ -161,6 +161,58 @@ launcher-confirmed full rebuilds with beside-active validation and atomic
promotion; overlays never cross a format boundary. Design and evidence: promotion; overlays never cross a format boundary. Design and evidence:
`docs/plans/2026-08-27-pak-v2-resource-campaign.md`. `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, **Slice I3 prepared collision extension (2026-07-25).** At its introduction,
the package remained format 1 and retained mesh type values 13; bake-tool 4 the package remained format 1 and retained mesh type values 13; bake-tool 4
appended typed GfxObj, appended typed GfxObj,

View file

@ -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_PVINPUT` | "throwaway apparatus — strip once the jitter source is pinned" | `=1` | one `[pv-input]` line/frame with 6-dp-precision `PortalVisibilityBuilder.Build` inputs (camera eye, player position, VP elements) + resulting flood-cell count; deliberately runs WITHOUT the heavier `[flap]` probe so the log stays diffable | print-only | `RenderingDiagnostics.ProbePvInputEnabled` |
| `ACDREAM_PROBE_REMOTE_SLIDE` | bug b, temporary — strip once two-client roof capture lands | `=1` OR `=<comma-separated hex GUID list>` | gates `[remote-slide-up]`/`[remote-slide-vec]`/`[remote-slide-snap]`/`[remote-slide-enq]` lines across `LiveEntityNetworkUpdateController`, `InterpolationManager`, `RuntimeRemotePhysicsUpdater`, `RuntimeRemoteSteadyStatePosition` tracing two candidate remote-slide "blip" producers | print-only; `BeginRemoteSlideAttribution`/GUID-stamping calls are UNCONDITIONAL at several call sites (self-guard is internal), so a `[ThreadStatic]` field write happens on every remote tick regardless of the flag (cheap, non-allocating); a GUID allow-list narrows output to specific entities for a readable two-client capture | `PhysicsDiagnostics.ProbeRemoteSlideEnabled` + `ProbeRemoteSlideGuids` (raw string parsed via `ParseHexIdList` unless it's the literal `"1"`) | | `ACDREAM_PROBE_REMOTE_SLIDE` | bug b, temporary — strip once two-client roof capture lands | `=1` OR `=<comma-separated hex GUID list>` | gates `[remote-slide-up]`/`[remote-slide-vec]`/`[remote-slide-snap]`/`[remote-slide-enq]` lines across `LiveEntityNetworkUpdateController`, `InterpolationManager`, `RuntimeRemotePhysicsUpdater`, `RuntimeRemoteSteadyStatePosition` tracing two candidate remote-slide "blip" producers | print-only; `BeginRemoteSlideAttribution`/GUID-stamping calls are UNCONDITIONAL at several call sites (self-guard is internal), so a `[ThreadStatic]` field write happens on every remote tick regardless of the flag (cheap, non-allocating); a GUID allow-list narrows output to specific entities for a readable two-client capture | `PhysicsDiagnostics.ProbeRemoteSlideEnabled` + `ProbeRemoteSlideGuids` (raw string parsed via `ParseHexIdList` unless it's the literal `"1"`) |
| `ACDREAM_PROBE_REMOTE_TELEPORT` | c4 route 4b-3, temporary | `=1` | gates one `[remote-teleport]` line per routed remote teleport arm in `LiveEntityNetworkUpdateController.ApplyRemoteContactRouting` | print-only; a 2026-08-04 fix moved the enabled-check to the CALL SITE because the probe's internal self-guard did not prevent `teleportStatus.ToString()` from being evaluated/allocated on every teleport regardless of flag state — now properly guarded | `PhysicsDiagnostics.ProbeRemoteTeleportEnabled` | | `ACDREAM_PROBE_REMOTE_TELEPORT` | c4 route 4b-3, temporary | `=1` | gates one `[remote-teleport]` line per routed remote teleport arm in `LiveEntityNetworkUpdateController.ApplyRemoteContactRouting` | print-only; a 2026-08-04 fix moved the enabled-check to the CALL SITE because the probe's internal self-guard did not prevent `teleportStatus.ToString()` from being evaluated/allocated on every teleport regardless of flag state — now properly guarded | `PhysicsDiagnostics.ProbeRemoteTeleportEnabled` |
| `ACDREAM_PROBE_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_SEAMDRAW` | #176, "throwaway apparatus" | `"1"`/`"true"`/blank → default #176 Facility Hub cell set (7 fixed hex ids); otherwise comma-separated hex cell-id list | change-deduped + 2 s-heartbeat `[seam-cell]`/`[seam-snap]`/`[seam-ent]`/`[seam-mask]` lines from `EnvCellRenderer.Render` and `WbDrawDispatcher` describing per-instance transforms and resolved light-set identities at target cells | print-only | `RenderingDiagnostics.ProbeSeamDrawEnabled` / `SeamDrawTargetCells` |
| `ACDREAM_PROBE_STEP_WALK` | a6.p3 issue #98 | `=1` | gates `[step-walk]` lines at select points in the transition sub-step loop and step-down probe (requested vs adjusted offset, sphere positions, contact planes, walkable flags) | print-only; no DebugPanel mirror | `PhysicsDiagnostics.ProbeStepWalkEnabled` | | `ACDREAM_PROBE_STEP_WALK` | a6.p3 issue #98 | `=1` | gates `[step-walk]` lines at select points in the transition sub-step loop and step-down probe (requested vs adjusted offset, sphere positions, contact planes, walkable flags) | print-only; no DebugPanel mirror | `PhysicsDiagnostics.ProbeStepWalkEnabled` |
| `ACDREAM_PROBE_SWEPT` | phase w stage 0 | `=1` | gates one `[cell-swept]` line per `ResolveWithTransition` call comparing the transition's swept cell vs the legacy static `ResolveCellId` path | print-only | `PhysicsDiagnostics.ProbeSweptEnabled` | | `ACDREAM_PROBE_SWEPT` | phase w stage 0 | `=1` | gates one `[cell-swept]` line per `ResolveWithTransition` call comparing the transition's swept cell vs the legacy static `ResolveCellId` path | print-only | `PhysicsDiagnostics.ProbeSweptEnabled` |

View file

@ -507,7 +507,8 @@ internal sealed class FrameRootCompositionPhase
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
"The retail frame walk requires the landscape registry."), "The retail frame walk requires the landscape registry."),
d.CellVisibility, d.CellVisibility,
d.PhysicsEngine.ShadowObjects), d.PhysicsEngine.ShadowObjects,
live.EquippedChildren.FindParentLocalId),
retailPViewPassExecutor, retailPViewPassExecutor,
retailPViewPassExecutor), retailPViewPassExecutor),
retailPViewCells, retailPViewCells,

View file

@ -32,6 +32,7 @@
// silent std430/std140 drift can't reach the GPU. // silent std430/std140 drift can't reach the GPU.
using System; using System;
using System.Numerics; using System.Numerics;
using System.Runtime.InteropServices;
namespace AcDream.App.Rendering; namespace AcDream.App.Rendering;
@ -200,6 +201,31 @@ public sealed class ClipFrame : IDisposable
return slot; return slot;
} }
/// <summary>
/// Borrows the clip-space planes packed for one live slot. The walk uses
/// this to replay an EnvCell's exact captured <c>portal_view</c> while
/// stamping its exit polygons; it must not rebuild those views from the
/// legacy visibility assembly after the walk has already decided them.
/// </summary>
internal ReadOnlySpan<Vector4> 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<byte, Vector4>(
_regionBytes.AsSpan(
byteOffset + CellClipPlanesOffset,
count * sizeof(float) * 4));
}
/// <summary> /// <summary>
/// Set the terrain OutsideView clip region (the single region the terrain /// Set the terrain OutsideView clip region (the single region the terrain
/// shader gates against). <paramref name="planes"/> length 0 ungates terrain /// shader gates against). <paramref name="planes"/> length 0 ungates terrain
@ -243,6 +269,12 @@ public sealed class ClipFrame : IDisposable
dst[offset + 3] = (byte)((value >> 24) & 0xFF); 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) private static void WriteInt(byte[] dst, int offset, int value)
=> WriteUInt(dst, offset, unchecked((uint)value)); => WriteUInt(dst, offset, unchecked((uint)value));

View file

@ -224,10 +224,12 @@ internal readonly record struct GpuDepthState(bool Test, bool Write, GpuCompareO
/// ///
/// <para>Added at slice V6l. Core Vulkan 1.3 makes ALL of these dynamic /// <para>Added at slice V6l. Core Vulkan 1.3 makes ALL of these dynamic
/// (<c>VK_DYNAMIC_STATE_STENCIL_OP</c>, <c>_COMPARE_MASK</c>, <c>_WRITE_MASK</c>, /// (<c>VK_DYNAMIC_STATE_STENCIL_OP</c>, <c>_COMPARE_MASK</c>, <c>_WRITE_MASK</c>,
/// <c>_REFERENCE</c>), and #117's portal punch changes every one of them between /// <c>_REFERENCE</c>). They live here as a pipeline DEFAULT and on
/// its mark pass and its punch pass, so they live here as a pipeline DEFAULT and /// <see cref="IGpuPassEncoder.SetStencil"/> as the per-draw override — exactly
/// on <see cref="IGpuPassEncoder.SetStencil"/> as the per-draw override — /// the split cull mode, front face and depth write already have. The portal
/// exactly the split cull mode, front face and depth write already have. /// 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 /// Whether the pipeline uses the stencil aspect at all is
/// <see cref="GpuPipelineDescription.StencilTest"/>, because that is also the /// <see cref="GpuPipelineDescription.StencilTest"/>, because that is also the
/// attachment intent.</para> /// attachment intent.</para>

View file

@ -9,21 +9,11 @@ namespace AcDream.App.Rendering;
/// Campaign V slice V6l: the portal depth mask's RHI submission arm — V4g's /// 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. /// remaining half, and the reason the slice grew a stencil dimension.
/// ///
/// <para>Plan §5.5.16 defect 2: this renderer's two-pass punch (#117) is built /// <para>Campaign FW restores retail's ordered frame walk, so this Vulkan arm
/// on <c>glStencilFunc</c>/<c>glStencilOp</c>/<c>glStencilMask</c> and the pinned /// now records the exact retail state for both operations: one color-invisible
/// <see cref="GpuPipelineDescription"/> carried no stencil state at all, so it /// triangle fan, depth compare ALWAYS, depth write enabled, culling disabled.
/// stayed raw GL and was invisible to the Vulkan arm. The reviewed amendment /// The push-constant pass selector chooses true projected depth (seal) or
/// puts the ENABLE and the attachment intent in the pipeline /// forced far-Z (punch). There is no stencil mark pass or depth bias.</para>
/// (<see cref="GpuPipelineDescription.StencilTest"/>) and the per-draw compare,
/// ops, reference and masks on <see cref="IGpuPassEncoder.SetStencil"/>, because
/// core Vulkan 1.3 makes exactly that split dynamic.</para>
///
/// <para><b>Three pipelines, not one.</b> Depth COMPARE is not dynamic in the
/// contract (only depth write is), and the punch's two passes differ in it —
/// mark tests <c>LEQUAL</c> and writes no depth, punch tests <c>ALWAYS</c> and
/// writes. The seal is a third: <c>ALWAYS</c> + write, with no stencil at all.
/// All three write no colour, which is what retail's "COLOR-INVISIBLE triangle
/// fan" means.</para>
/// ///
/// <para><b>Two other differences from the GL arm.</b> The fan is expanded to a /// <para><b>Two other differences from the GL arm.</b> The fan is expanded to a
/// triangle LIST on the CPU, because <see cref="GpuPrimitiveTopology"/> has no /// triangle LIST on the CPU, because <see cref="GpuPrimitiveTopology"/> has no
@ -39,9 +29,7 @@ public sealed partial class PortalDepthMaskRenderer
private readonly IGpuDevice? _device; private readonly IGpuDevice? _device;
private readonly ICurrentGpuFrameSource? _frames; private readonly ICurrentGpuFrameSource? _frames;
private readonly IWorldPassScope? _scope; private readonly IWorldPassScope? _scope;
private IGpuPipeline? _sealPipeline; private IGpuPipeline? _depthWritePipeline;
private IGpuPipeline? _punchMarkPipeline;
private IGpuPipeline? _punchWritePipeline;
private bool _rhiFrameStarted; private bool _rhiFrameStarted;
/// <summary>One position per vertex — the only attribute <c>portal_depth.vert</c> reads.</summary> /// <summary>One position per vertex — the only attribute <c>portal_depth.vert</c> reads.</summary>
@ -51,16 +39,8 @@ public sealed partial class PortalDepthMaskRenderer
new GpuVertexAttribute(0, GpuVertexFormat.Float3, 0))); new GpuVertexAttribute(0, GpuVertexFormat.Float3, 0)));
/// <summary> /// <summary>
/// The stencil reference the mark pass writes and the punch pass gates on. /// The RHI arm's constructor. No GL context and no inline program: the one
/// Retail has no equivalent — the whole stencil pass is acdream's /// pipeline compiles <c>portal_depth</c> from the committed SPIR-V, and the
/// z-buffered replacement for retail's painter's-order safety (#117) — so
/// the value is arbitrary and only has to agree with itself.
/// </summary>
private const uint PunchStencilReference = 1;
/// <summary>
/// The RHI arm's constructor. No GL context and no inline program: the three
/// pipelines compile <c>portal_depth</c> from the committed SPIR-V, and the
/// per-frame fan vertices come from the frame ring. /// per-frame fan vertices come from the frame ring.
/// </summary> /// </summary>
internal PortalDepthMaskRenderer( internal PortalDepthMaskRenderer(
@ -76,53 +56,17 @@ public sealed partial class PortalDepthMaskRenderer
try try
{ {
int samples = scope.SampleCount; int samples = scope.SampleCount;
// SEAL: retail maxZ2, bit0 clear, data 0x00820e14 — depth ALWAYS at // Retail DrawPortalPolyInternal @0x0059BC90 uses the same depth
// the polygon's true projected depth, no stencil. It runs // state for seals and punches. maxZ2/maxZ1 only select the vertex
// immediately after the gated full depth clear, so there is no // depth written by the shader.
// nearer content to stomp. _depthWritePipeline = CreatePortalPipeline(
_sealPipeline = CreatePortalPipeline(
device, device,
"portal-depth-seal", "portal-depth-write",
GpuCompareOp.Always, GpuCompareOp.Always,
depthWrite: true, depthWrite: true,
stencilTest: false, stencilTest: false,
GpuStencilState.Default, GpuStencilState.Default,
samples); 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 catch
{ {
@ -202,38 +146,14 @@ public sealed partial class PortalDepthMaskRenderer
for (int i = 0; i < planeCount; i++) for (int i = 0; i < planeCount; i++)
clipPlanes[i] = planes[i]; clipPlanes[i] = planes[i];
if (!forceFarZ)
{
RecordPortalPass( RecordPortalPass(
encoder, encoder,
_sealPipeline!, _depthWritePipeline!,
clip, clip,
vertices, vertices,
vertexCount, vertexCount,
in viewProjection, in viewProjection,
renderPass: 0, renderPass: forceFarZ ? 1 : 0);
depthBias: 0f);
return;
}
RecordPortalPass(
encoder,
_punchMarkPipeline!,
clip,
vertices,
vertexCount,
in viewProjection,
renderPass: 0,
depthBias: PunchMarkDepthBias);
RecordPortalPass(
encoder,
_punchWritePipeline!,
clip,
vertices,
vertexCount,
in viewProjection,
renderPass: 1,
depthBias: 0f);
} }
private static void RecordPortalPass( private static void RecordPortalPass(
@ -243,8 +163,7 @@ public sealed partial class PortalDepthMaskRenderer
in GpuRingAllocation vertices, in GpuRingAllocation vertices,
int vertexCount, int vertexCount,
in Matrix4x4 viewProjection, in Matrix4x4 viewProjection,
int renderPass, int renderPass)
float depthBias)
{ {
encoder.BindPipeline(pipeline); encoder.BindPipeline(pipeline);
encoder.SetPushConstants(new GpuPushConstants encoder.SetPushConstants(new GpuPushConstants
@ -258,8 +177,8 @@ public sealed partial class PortalDepthMaskRenderer
LightDebug = 0, LightDebug = 0,
TextureIndexA = 0, TextureIndexA = 0,
TextureIndexB = 0, TextureIndexB = 0,
ParamA = depthBias, ParamA = 0f,
ParamB = PunchMarkBiasEyeCapMeters * CameraNearPlaneMeters, ParamB = 0f,
}); });
encoder.BindUniformBuffer( encoder.BindUniformBuffer(
ClipFrame.TerrainClipUboBinding, ClipFrame.TerrainClipUboBinding,
@ -286,12 +205,8 @@ public sealed partial class PortalDepthMaskRenderer
catch (Exception error) { (failures ??= []).Add(error); } catch (Exception error) { (failures ??= []).Add(error); }
} }
Attempt(() => _sealPipeline?.Dispose()); Attempt(() => _depthWritePipeline?.Dispose());
_sealPipeline = null; _depthWritePipeline = null;
Attempt(() => _punchMarkPipeline?.Dispose());
_punchMarkPipeline = null;
Attempt(() => _punchWritePipeline?.Dispose());
_punchWritePipeline = null;
_rhiFrameStarted = false; _rhiFrameStarted = false;
if (failures is not null) if (failures is not null)

View file

@ -8,13 +8,12 @@ namespace AcDream.App.Rendering;
/// writes — the port of <c>D3DPolyRender::DrawPortalPolyInternal</c> /// writes — the port of <c>D3DPolyRender::DrawPortalPolyInternal</c>
/// (Ghidra 0x0059bc90, pc:424490). /// (Ghidra 0x0059bc90, pc:424490).
/// ///
/// <para><b>Wired by T1 (BR-3, `579c8b0`):</b> seal on interior roots, punch /// <para>Campaign FW's ordered frame walk restores the positional discipline
/// on outdoor / look-in roots, via <c>RetailPViewPassExecutor.DrawPortalDepthWrite</c> /// on which this primitive depends: farther landscape/static content is
/// (the <c>DrawExitPortalMasks</c> slice callback) — safe alongside the /// submitted before a building punch, the punch precedes that building's
/// dynamics-drawn-LAST frame order (the first BR-2 attempt punched after /// look-in cells, and nearer content repaints afterward. Consequently both
/// dynamics and erased the player; reverted 88be519). #117 (2026-06-11) /// sides now use retail's one-pass depth operation directly; the former
/// added the two-pass stencil depth gate on the punch side — see /// stencil mark/bias approximation from #117 is intentionally gone.</para>
/// <see cref="DrawDepthFan"/>.</para>
/// ///
/// <para>Retail projects a portal polygon, software-clips it against the /// <para>Retail projects a portal polygon, software-clips it against the
/// installed portal view (<c>polyClipFinish</c>), and draws the survivor as a /// installed portal view (<c>polyClipFinish</c>), and draws the survivor as a
@ -78,49 +77,6 @@ public sealed partial class PortalDepthMaskRenderer : IDisposable
} }
/// <summary> /// <summary>
/// #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.
/// </summary>
private const float PunchMarkDepthBias = 0.0005f;
/// <summary>
/// #129 (2026-06-12): NDC depth is non-linear — a constant NDC bias b
/// spans ≈ b·d²/near meters of eye depth at eye distance d. With
/// znear = 0.1, the 0.0005 constant alone spanned 0.125 m at 5 m but
/// ~190 m at a landblock away: every hill/house in front of a distant
/// aperture passed the mark and got far-Z punched — door-shaped leaks
/// through occluders. Fix: cap the bias's EYE-SPACE span at
/// <see cref="PunchMarkBiasEyeCapMeters"/>. Below the ~10 m crossover
/// (sqrt(cap·near/0.0005)) the constant-NDC term is smaller and wins —
/// bit-identical to the T5-validated close-range behavior (#108 grass
/// coverage untouched); beyond it the punch can never reach an occluder
/// more than the cap in front of the aperture plane.
/// </summary>
public const float PunchMarkBiasEyeCapMeters = 0.5f;
/// <summary>Retail <c>Render::znear</c> = 0.1 (decomp :342173, re-landed
/// d4b5c71). The cap conversion below assumes the production camera near
/// plane; the small f/(fn) factor (~1.00002 at far 5000) is ignored.</summary>
public const float CameraNearPlaneMeters = 0.1f;
/// <summary>CPU mirror of the vertex-shader mark-bias expression (keep in
/// sync with <c>VertSrc</c>): the NDC bias applied at eye depth
/// <paramref name="eyeDepthMeters"/>.</summary>
public static float MarkBiasNdc(float eyeDepthMeters) =>
MathF.Min(PunchMarkDepthBias,
PunchMarkBiasEyeCapMeters * CameraNearPlaneMeters
/ MathF.Max(eyeDepthMeters * eyeDepthMeters, 1e-6f));
/// <summary> /// <summary>
/// Draw one portal polygon as an invisible depth write, clipped to the /// Draw one portal polygon as an invisible depth write, clipped to the
/// slice's clip-space half-planes. <paramref name="forceFarZ"/> selects /// slice's clip-space half-planes. <paramref name="forceFarZ"/> selects
@ -130,13 +86,9 @@ public sealed partial class PortalDepthMaskRenderer : IDisposable
/// depth ALWAYS + true projected depth. It runs immediately after the /// depth ALWAYS + true projected depth. It runs immediately after the
/// gated full depth clear, so there is no nearer content to stomp.</para> /// gated full depth clear, so there is no nearer content to stomp.</para>
/// ///
/// <para><b>Punch</b> (outdoor root / look-in): two passes (#117). /// <para><b>Punch</b> (outdoor root / look-in): one pass, retail-verbatim
/// Pass A marks stencil where the aperture fan passes a LEQUAL depth /// — depth ALWAYS + far-Z. The ordered frame walk, not a visibility test
/// test at its (biased) true depth — i.e. where the aperture is /// at the aperture plane, supplies retail's painter-order safety.</para>
/// 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.</para>
/// </summary> /// </summary>
public void DrawDepthFan( public void DrawDepthFan(
ReadOnlySpan<Vector3> worldVerts, ReadOnlySpan<Vector3> worldVerts,

View file

@ -207,7 +207,6 @@ internal sealed class WalkProductionLeafRenderer : IWalkFrameLeafRenderer
private Action _drawExitSeals = null!; private Action _drawExitSeals = null!;
private readonly HashSet<uint> _singleCellScratch = new(); private readonly HashSet<uint> _singleCellScratch = new();
private readonly List<uint> _singleCellListScratch = new(); private readonly List<uint> _singleCellListScratch = new();
private readonly Dictionary<uint, int> _singleCellClipScratch = new(1);
internal WalkProductionLeafRenderer( internal WalkProductionLeafRenderer(
RetailPViewPassExecutor passes, RetailPViewPassExecutor passes,
@ -244,13 +243,24 @@ internal sealed class WalkProductionLeafRenderer : IWalkFrameLeafRenderer
public void DrawTerrainSlice(int sliceIndex) => public void DrawTerrainSlice(int sliceIndex) =>
_passes.DrawWalkTerrainSlice(_frame, _clipAssembly, sliceIndex); _passes.DrawWalkTerrainSlice(_frame, _clipAssembly, sliceIndex);
public void DrawCellShell(uint cellId, uint clipSlot) public void DrawCellShell(uint cellId)
{ {
_singleCellClipScratch.Clear(); // Output-gated binary discriminator for the moving wall-textured
_singleCellClipScratch.Add(cellId, checked((int)clipSlot)); // triangles at the 0xF4180107/0112 floating stairs. Missing walls are
_passes.SetCellShellClipRouting(_singleCellClipScratch); // intentional while enabled: this identifies the visual carrier and
try // must not become a production admission rule.
if (AcDream.Core.Rendering.RenderingDiagnostics
.ProbeCathedralSkipFloatingStairCellShells
&& cellId is 0xF4180107u or 0xF4180112u)
{ {
return;
}
// 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.Clear();
_singleCellScratch.Add(cellId); _singleCellScratch.Add(cellId);
_passes.DrawOpaqueCellShells(_singleCellScratch); _passes.DrawOpaqueCellShells(_singleCellScratch);
@ -261,11 +271,6 @@ internal sealed class WalkProductionLeafRenderer : IWalkFrameLeafRenderer
_passes.DrawTransparentCellShellsOrdered(_singleCellListScratch); _passes.DrawTransparentCellShellsOrdered(_singleCellListScratch);
} }
} }
finally
{
_passes.SetCellShellClipRouting(null);
}
}
public void ClearInteriorDepth() => _clearInteriorDepth(); public void ClearInteriorDepth() => _clearInteriorDepth();

View file

@ -345,8 +345,13 @@ internal sealed partial class RetailPViewPassExecutor :
public void DrawExitPortalMask( public void DrawExitPortalMask(
RetailPViewFrameInput frame, RetailPViewFrameInput frame,
RetailPViewCellSliceContext context) => uint cellId,
DrawPortalDepthWrite(context, frame, forceFarZ: frame.RootCell.IsOutdoorNode); ReadOnlySpan<Vector4> clipPlanes) =>
DrawPortalDepthWrite(
cellId,
clipPlanes,
frame,
forceFarZ: frame.RootCell.IsOutdoorNode);
public void DrawUnattachedSceneParticles( public void DrawUnattachedSceneParticles(
RetailPViewFrameInput frame, RetailPViewFrameInput frame,
@ -422,7 +427,8 @@ internal sealed partial class RetailPViewPassExecutor :
frame.CameraCellResolution); frame.CameraCellResolution);
private void DrawPortalDepthWrite( private void DrawPortalDepthWrite(
RetailPViewCellSliceContext context, uint cellId,
ReadOnlySpan<Vector4> clipPlanes,
RetailPViewFrameInput frame, RetailPViewFrameInput frame,
bool forceFarZ, bool forceFarZ,
int? onlyPortalIndex = null) int? onlyPortalIndex = null)
@ -432,7 +438,14 @@ internal sealed partial class RetailPViewPassExecutor :
// apertures stamp far depth (punch). The renderer owns that choice. // apertures stamp far depth (punch). The renderer owns that choice.
if (_portalDepthMask is null) if (_portalDepthMask is null)
return; 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) if (cell is null)
return; return;
@ -462,7 +475,7 @@ internal sealed partial class RetailPViewPassExecutor :
_diagnostics.EmitSeamMask( _diagnostics.EmitSeamMask(
RenderingDiagnostics.ProbeSeamDrawEnabled, RenderingDiagnostics.ProbeSeamDrawEnabled,
RenderingDiagnostics.SeamDrawTargetCells, RenderingDiagnostics.SeamDrawTargetCells,
context.CellId, cellId,
index, index,
forceFarZ, forceFarZ,
world[..count]); world[..count]);
@ -470,7 +483,7 @@ internal sealed partial class RetailPViewPassExecutor :
_portalDepthMask.DrawDepthFan( _portalDepthMask.DrawDepthFan(
world[..count], world[..count],
frame.ViewProjection, frame.ViewProjection,
context.Slice.Planes, clipPlanes,
forceFarZ); forceFarZ);
} }
} }

View file

@ -20,7 +20,6 @@ internal sealed class RetailPViewRenderer
private static readonly ClipViewSlice NoClipSlice = private static readonly ClipViewSlice NoClipSlice =
new(0, new Vector4(-1f, -1f, 1f, 1f), Array.Empty<Vector4>()); new(0, new Vector4(-1f, -1f, 1f, 1f), Array.Empty<Vector4>());
private static readonly ClipViewSlice[] NoClipSlices = { NoClipSlice };
private static readonly IReadOnlySet<uint> NoParticleOwners = private static readonly IReadOnlySet<uint> NoParticleOwners =
new HashSet<uint>(); new HashSet<uint>();
@ -39,6 +38,13 @@ internal sealed class RetailPViewRenderer
// cleared in finally. // cleared in finally.
private Action? _walkPreClearDynamics; private Action? _walkPreClearDynamics;
// Output-only Facility Hub staircase probe. This counter is consulted
// only while ACDREAM_PROBE_FACILITY_STAIRS=1 and never affects the walk.
private ulong _probeFacilityStairFrame;
private ulong _probeCathedralShellOrderFrame;
private string? _probeFacilityStairRootSignature;
private string? _probeCathedralStairRootSignature;
// FW6 allocation closeout: the walk's large event/view/route scratch, // FW6 allocation closeout: the walk's large event/view/route scratch,
// frame context, and one-cell leaf collections are renderer-lifetime // frame context, and one-cell leaf collections are renderer-lifetime
// owners. Only their frame-local bindings change. Before this cutover all // owners. Only their frame-local bindings change. Before this cutover all
@ -72,7 +78,8 @@ internal sealed class RetailPViewRenderer
Walk.WalkBuildingRegistry walkBuildings, Walk.WalkBuildingRegistry walkBuildings,
Walk.WalkLandscapeAssembler walkLandscape, Walk.WalkLandscapeAssembler walkLandscape,
CellVisibility walkCellRegistry, CellVisibility walkCellRegistry,
ShadowObjectRegistry shadows) ShadowObjectRegistry shadows,
Func<uint, uint?>? findParentLocalId = null)
{ {
_renderSceneShadow = renderSceneShadow _renderSceneShadow = renderSceneShadow
?? throw new ArgumentNullException(nameof(renderSceneShadow)); ?? throw new ArgumentNullException(nameof(renderSceneShadow));
@ -84,7 +91,8 @@ internal sealed class RetailPViewRenderer
?? throw new ArgumentNullException(nameof(walkCellRegistry)); ?? throw new ArgumentNullException(nameof(walkCellRegistry));
_walkWorldData = new Walk.WalkProductionWorldData( _walkWorldData = new Walk.WalkProductionWorldData(
_walkBuildings, _walkBuildings,
shadows ?? throw new ArgumentNullException(nameof(shadows))); shadows ?? throw new ArgumentNullException(nameof(shadows)),
findParentLocalId);
_walkClearInteriorDepthAction = ClearWalkInteriorDepth; _walkClearInteriorDepthAction = ClearWalkInteriorDepth;
_walkDrawExitSealsAction = DrawWalkExitSeals; _walkDrawExitSealsAction = DrawWalkExitSeals;
} }
@ -282,6 +290,134 @@ internal sealed class RetailPViewRenderer
_drawableCellsScratch.UnionWith(walkDriver.VisitedCells); _drawableCellsScratch.UnionWith(walkDriver.VisitedCells);
walkDriver.CopyVisibleCellsTo(_visibleCellsScratch); 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<uint> cells)
{
const uint a = 0x8A02015Eu;
const uint b = 0x8A02015Fu;
const uint c = 0x8A0201C1u;
int mask = 0;
foreach (uint cell in cells)
{
if (cell == a) mask |= 1;
if (cell == b) mask |= 2;
if (cell == c) mask |= 4;
}
return mask;
}
_probeFacilityStairFrame++;
int floodMask = TargetMembership(walkDriver.InteriorFloodCells);
int visitedMask = TargetMembership(walkDriver.VisitedCells);
int turnMask = TargetMembership(walkDriver.LookInCells);
string signature = $"{ctx.ViewerCellId:X8}:{ctx.PlayerCellId:X8}:"
+ $"{ctx.RootCell.CellId:X8}:{floodMask}:{visitedMask}:{turnMask}:"
+ $"{walkDriver.InteriorFloodCells.Count}:{walkDriver.VisitedCells.Count}:"
+ $"{walkDriver.LookInCellTurns.Count}";
bool changed = !string.Equals(
_probeFacilityStairRootSignature,
signature,
StringComparison.Ordinal);
_probeFacilityStairRootSignature = signature;
if (changed)
{
static string DescribeMask(int mask) =>
$"15e={((mask & 1) != 0 ? 1 : 0)},"
+ $"15f={((mask & 2) != 0 ? 1 : 0)},"
+ $"1c1={((mask & 4) != 0 ? 1 : 0)}";
Console.WriteLine(
$"[facility-root] f={_probeFacilityStairFrame} changed={(changed ? 1 : 0)} "
+ $"viewer=0x{ctx.ViewerCellId:X8} player=0x{ctx.PlayerCellId:X8} "
+ $"root=0x{ctx.RootCell.CellId:X8} res={ctx.CameraCellResolution} "
+ $"eye=({ctx.ViewerEyePos.X:F4},{ctx.ViewerEyePos.Y:F4},{ctx.ViewerEyePos.Z:F4}) "
+ $"playerPos=({ctx.PlayerViewPosition.X:F4},{ctx.PlayerViewPosition.Y:F4},{ctx.PlayerViewPosition.Z:F4}) "
+ $"rootFlood={walkDriver.InteriorFloodCells.Count}"
+ $"[{DescribeMask(floodMask)}] "
+ $"visited={walkDriver.VisitedCells.Count}"
+ $"[{DescribeMask(visitedMask)}] "
+ $"turns={walkDriver.LookInCellTurns.Count}"
+ $"[{DescribeMask(turnMask)}]");
}
static int CathedralMembership(IReadOnlyCollection<uint> cells)
{
const uint oldBuilding = 0xF4180107u;
const uint stairParent = 0xF4180112u;
int mask = 0;
foreach (uint cell in cells)
{
if (cell == oldBuilding) mask |= 1;
if (cell == stairParent) mask |= 2;
}
return mask;
}
static uint BuildingAnchor(Walk.WalkBuilding building)
{
foreach (Walk.WalkBldPortal portal in building.Portals)
{
if (portal.OtherCellId != 0xFFFFFFFFu)
return portal.OtherCellId;
}
return 0u;
}
int cathedralFlood = CathedralMembership(walkDriver.InteriorFloodCells);
int cathedralVisited = CathedralMembership(walkDriver.VisitedCells);
int cathedralLookIn = CathedralMembership(walkDriver.LookInCells);
bool bucket107 = _walkWorldData.StaticBucketContains(
0xF4180107u,
0x020009A2u);
bool bucket112 = _walkWorldData.StaticBucketContains(
0xF4180112u,
0x020009A2u);
string buildingAnchors = string.Join(",", walkDriver.VisitedBuildings
.ConvertAll(building => $"0x{BuildingAnchor(building):X8}"));
string cathedralSignature =
$"{ctx.ViewerCellId:X8}:{ctx.PlayerCellId:X8}:{ctx.RootCell.CellId:X8}:"
+ $"{cathedralFlood}:{cathedralVisited}:{cathedralLookIn}:"
+ $"{(bucket107 ? 1 : 0)}:{(bucket112 ? 1 : 0)}:{buildingAnchors}";
bool cathedralChanged = !string.Equals(
_probeCathedralStairRootSignature,
cathedralSignature,
StringComparison.Ordinal);
_probeCathedralStairRootSignature = cathedralSignature;
if (cathedralChanged
&& ((ctx.ViewerCellId & 0xFFFF0000u) == 0xF4180000u
|| (ctx.PlayerCellId & 0xFFFF0000u) == 0xF4180000u))
{
static string DescribeCathedralMask(int mask) =>
$"107={((mask & 1) != 0 ? 1 : 0)},"
+ $"112={((mask & 2) != 0 ? 1 : 0)}";
Console.WriteLine(
$"[cathedral-stair] f={_probeFacilityStairFrame} "
+ $"viewer=0x{ctx.ViewerCellId:X8} player=0x{ctx.PlayerCellId:X8} "
+ $"root=0x{ctx.RootCell.CellId:X8} res={ctx.CameraCellResolution} "
+ $"eye=({ctx.ViewerEyePos.X:F4},{ctx.ViewerEyePos.Y:F4},{ctx.ViewerEyePos.Z:F4}) "
+ $"playerPos=({ctx.PlayerViewPosition.X:F4},{ctx.PlayerViewPosition.Y:F4},{ctx.PlayerViewPosition.Z:F4}) "
+ $"rootFlood=[{DescribeCathedralMask(cathedralFlood)}] "
+ $"visited=[{DescribeCathedralMask(cathedralVisited)}] "
+ $"lookIn=[{DescribeCathedralMask(cathedralLookIn)}] "
+ $"buckets=[107={(bucket107 ? 1 : 0)},112={(bucket112 ? 1 : 0)}] "
+ $"buildings=[{buildingAnchors}]");
}
}
} }
// FW4 slice 1: the ONE clip-region publication, after any walk // FW4 slice 1: the ONE clip-region publication, after any walk
@ -391,14 +527,11 @@ internal sealed class RetailPViewRenderer
RetailPViewPassExecutor passes = _activeWalkPasses RetailPViewPassExecutor passes = _activeWalkPasses
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
"The retained walk leaf has no active pass binding."); "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 Walk.WalkFrameDriver driver = _walkFrameDriverScratch
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
"The retained walk leaf has no active driver binding."); "The retained walk leaf has no active driver binding.");
DrawWalkExitPortalMasks(frame, passes, clipAssembly, driver); DrawWalkExitPortalMasks(frame, passes, driver);
} }
private void ClearWalkFrameBindings() private void ClearWalkFrameBindings()
@ -528,27 +661,27 @@ internal sealed class RetailPViewRenderer
/// #456 cathedral seam band (its never-drawn panel family), leaving /// #456 cathedral seam band (its never-drawn panel family), leaving
/// aperture depth unsealed after the interior clear; the end-of-frame /// aperture depth unsealed after the interior clear; the end-of-frame
/// alpha drain (cell-owned emitters — retail's own timing) then /// alpha drain (cell-owned emitters — retail's own timing) then
/// z-passes across the whole opening (the falls shine-through). Per-cell /// z-passes across the whole opening (the falls shine-through). Each
/// slice clips still come from the old assembly where present; a cell /// portal is stamped once per exact walk-owned view captured for that
/// the old apparatus missed seals unclipped (the depth fan is the exact /// flood cell, matching retail's <c>CEnvCell::setup_view</c> loop. The
/// dat aperture polygon and z-tests, so over-coverage is benign).</summary> /// legacy visibility assembly has no production role here.</summary>
private void DrawWalkExitPortalMasks( private void DrawWalkExitPortalMasks(
RetailPViewFrameInput ctx, RetailPViewFrameInput ctx,
RetailPViewPassExecutor passes, RetailPViewPassExecutor passes,
ClipFrameAssembly clipAssembly,
Walk.WalkFrameDriver driver) Walk.WalkFrameDriver driver)
{ {
List<uint> floodCells = driver.InteriorFloodCells; List<uint> floodCells = driver.InteriorFloodCells;
for (int i = floodCells.Count - 1; i >= 0; i--) for (int i = floodCells.Count - 1; i >= 0; i--)
{ {
uint cellId = floodCells[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( passes.DrawExitPortalMask(
ctx, ctx,
new RetailPViewCellSliceContext(
cellId, cellId,
slice, driver.InteriorFloodViewClipPlanesAt(i, sliceIndex));
NoParticleOwners)); }
} }
} }
@ -573,18 +706,6 @@ internal sealed class RetailPViewRenderer
MeshPartCount: 0); 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 public interface IRetailPViewCellSource

View file

@ -20,7 +20,6 @@
// selector. This shader's two passes ARE seal // selector. This shader's two passes ARE seal
// (retail maxZ2, true projected depth) and punch // (retail maxZ2, true projected depth) and punch
// (retail maxZ1, far-plane z). // (retail maxZ1, far-plane z).
// uDepthBias / EyeCapN -> uParamA / uParamB.
layout(location = 0) in vec3 aPos; 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). // the shared 96-byte push block (tools/ShaderCompiler/VulkanGlslPreamble.cs).
uniform mat4 uViewProjection; uniform mat4 uViewProjection;
uniform int uRenderPass; // 0 = seal (retail maxZ2), 1 = punch (retail maxZ1) 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 { layout(std140, ACDREAM_UBO_SET binding = 2) uniform TerrainClip {
int uTerrainClipCount; int uTerrainClipCount;
@ -55,16 +52,5 @@ void main()
{ {
clipPos.z = clipPos.w * 0.99999988; // retail far-z punch constant (0x0059bc90 tail) clipPos.z = clipPos.w * 0.99999988; // retail far-z punch constant (0x0059bc90 tail)
} }
else if (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; gl_Position = clipPos;
} }

View file

@ -295,7 +295,7 @@
"stages": [ "stages": [
{ {
"stage": "vert", "stage": "vert",
"sourceSha256": "6214fc04936d92320594acb722e63f1cd1f7106dd4d0af60ed985abc2a50c4a9", "sourceSha256": "1df7e2009cda8f84ba3d84546bf58baf71d10fe53e696655b4ca7b8292a32fa5",
"compiled": true "compiled": true
}, },
{ {

View file

@ -119,8 +119,10 @@ internal interface IWalkFrameLeafRenderer
/// draws ALL shells in reverse <c>cell_draw_list</c> order, then starts a /// draws ALL shells in reverse <c>cell_draw_list</c> order, then starts a
/// second reverse loop for <c>DrawObjCellForDummies</c> @0x005a4b0d. /// second reverse loop for <c>DrawObjCellForDummies</c> @0x005a4b0d.
/// The ordinary interior-root flood and every building look-in flood use /// The ordinary interior-root flood and every building look-in flood use
/// this same two-pass discipline.</summary> /// this same two-pass discipline. Retail's runtime <c>use_built_mesh</c>
void DrawCellShell(uint cellId, uint clipSlot); /// branch submits the complete constructed shell; the cell's drawn stamp,
/// portal depth writes, and later depth-tested repaint own visibility.</summary>
void DrawCellShell(uint cellId);
/// <summary>One landscape cell's or building shell's static-owner /// <summary>One landscape cell's or building shell's static-owner
/// particle submission, at its own walk turn — see /// particle submission, at its own walk turn — see
@ -238,9 +240,10 @@ internal enum WalkFrameEventKind : byte
TerrainSlice, TerrainSlice,
/// <summary><see cref="IWalkFrameLeafRenderer.DrawCellShell"/> — /// <summary><see cref="IWalkFrameLeafRenderer.DrawCellShell"/> —
/// <see cref="WalkFrameEvent.CellId"/> is the cell and /// <see cref="WalkFrameEvent.CellId"/> is the cell. Retail's
/// <see cref="WalkFrameEvent.IntArg"/> is the exact portal-view GPU clip /// <c>DrawEnvCell</c> marks the EnvCell drawn before submitting its built
/// slot for this retail <c>DrawEnvCell</c> turn.</summary> /// mesh, so the shell is whole and frame-deduplicated; portal-view slices
/// are not shell geometry clips.</summary>
CellShell, CellShell,
/// <summary><see cref="IWalkFrameLeafRenderer.DrawPunchFan"/> — /// <summary><see cref="IWalkFrameLeafRenderer.DrawPunchFan"/> —
@ -308,6 +311,14 @@ internal interface IWalkLookInViewSource
in Vector3 center, in Vector3 center,
float radius, float radius,
bool testSphere); bool testSphere);
/// <summary>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.</summary>
string DescribeLookInTurn(
int routeIndex,
in Vector3 center,
float radius) => "unavailable";
} }
internal readonly record struct WalkLookInSlice( internal readonly record struct WalkLookInSlice(
@ -367,8 +378,8 @@ internal readonly struct WalkFrameEvent
internal static WalkFrameEvent TerrainSlice(int sliceIndex) => internal static WalkFrameEvent TerrainSlice(int sliceIndex) =>
new(WalkFrameEventKind.TerrainSlice, sliceIndex, 0, 0f, null); new(WalkFrameEventKind.TerrainSlice, sliceIndex, 0, 0f, null);
internal static WalkFrameEvent CellShell(uint cellId, uint clipSlot) => internal static WalkFrameEvent CellShell(uint cellId) =>
new(WalkFrameEventKind.CellShell, checked((int)clipSlot), cellId, 0f, null); new(WalkFrameEventKind.CellShell, 0, cellId, 0f, null);
internal static WalkFrameEvent PunchFan(WalkPolygon worldPolygon, int activeViewIndex) => internal static WalkFrameEvent PunchFan(WalkPolygon worldPolygon, int activeViewIndex) =>
new(WalkFrameEventKind.PunchFan, activeViewIndex, 0, 0f, worldPolygon); new(WalkFrameEventKind.PunchFan, activeViewIndex, 0, 0f, worldPolygon);
@ -505,12 +516,8 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
private readonly List<int> _floodViewRouteScratch = new(); private readonly List<int> _floodViewRouteScratch = new();
private WalkPlane _lookInCyPlane; 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<RenderProjectionId> _outdoorDrawnThisFrame = new();
private readonly HashSet<uint> _outdoorParticleOwnersDrawnThisFrame = new(); private readonly HashSet<uint> _outdoorParticleOwnersDrawnThisFrame = new();
private readonly HashSet<uint> _cellShellsDrawnThisFrame = new();
IReadOnlyList<uint> IWalkLookInViewSource.LookInCellTurns => LookInCellTurns; IReadOnlyList<uint> IWalkLookInViewSource.LookInCellTurns => LookInCellTurns;
@ -529,6 +536,52 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
/// alpha drain splats through (the cathedral falls shine-through).</summary> /// alpha drain splats through (the cathedral falls shine-through).</summary>
internal List<uint> InteriorFloodCells { get; } = new(); internal List<uint> InteriorFloodCells { get; } = new();
/// <summary>Number of exact walk-owned portal-view slices that retail's
/// <c>CEnvCell::setup_view</c> installs while sealing one entry in
/// <see cref="InteriorFloodCells"/>. The matching route was captured at
/// that cell's shell turn and remains valid through synchronous replay.</summary>
internal int InteriorFloodViewSliceCountAt(int floodCellIndex)
{
int routeIndex = InteriorFloodViewRouteAt(floodCellIndex);
return _lookInTurns[routeIndex].SliceCount;
}
/// <summary>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.</summary>
internal ReadOnlySpan<Vector4> 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<Vector4>.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 // Replay scratch for StaticParticles events (sequential replay — one
// reused set is safe). // reused set is safe).
private readonly HashSet<uint> _staticParticleOwnerScratch = new(); private readonly HashSet<uint> _staticParticleOwnerScratch = new();
@ -624,8 +677,9 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
_lookInPlanes.Clear(); _lookInPlanes.Clear();
_visibleClipSlotScratch.Clear(); _visibleClipSlotScratch.Clear();
_floodViewRouteScratch.Clear(); _floodViewRouteScratch.Clear();
_outdoorDrawnThisFrame.Clear(); _dispatcher.EndWalkPartFrame();
_outdoorParticleOwnersDrawnThisFrame.Clear(); _outdoorParticleOwnersDrawnThisFrame.Clear();
_cellShellsDrawnThisFrame.Clear();
_lookInCyPlane = default; _lookInCyPlane = default;
LookInCells.Clear(); LookInCells.Clear();
VisitedBuildings.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."); + "EndFrame (or let a thrown exception's cleanup run) before starting the next.");
} }
_dispatcher.BeginWalkPartFrame();
_ctx = ctx; _ctx = ctx;
_viewProjection = viewProjection; _viewProjection = viewProjection;
_cameraWorldPosition = cameraWorldPosition; _cameraWorldPosition = cameraWorldPosition;
@ -759,8 +814,8 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
_lookInPlanes.Clear(); _lookInPlanes.Clear();
_visibleClipSlotScratch.Clear(); _visibleClipSlotScratch.Clear();
_lookInCyPlane = ctx.CyPlane; _lookInCyPlane = ctx.CyPlane;
_outdoorDrawnThisFrame.Clear();
_outdoorParticleOwnersDrawnThisFrame.Clear(); _outdoorParticleOwnersDrawnThisFrame.Clear();
_cellShellsDrawnThisFrame.Clear();
LookInCells.Clear(); LookInCells.Clear();
VisitedBuildings.Clear(); VisitedBuildings.Clear();
VisitedLandscapeCellIds.Clear(); VisitedLandscapeCellIds.Clear();
@ -784,6 +839,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
} }
finally finally
{ {
_dispatcher.EndWalkPartFrame();
_ctx = null; _ctx = null;
_readyToReplay = true; _readyToReplay = true;
} }
@ -854,7 +910,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
_leafRenderer.DrawTerrainSlice(e.IntArg); _leafRenderer.DrawTerrainSlice(e.IntArg);
break; break;
case WalkFrameEventKind.CellShell: case WalkFrameEventKind.CellShell:
_leafRenderer.DrawCellShell(e.CellId, checked((uint)e.IntArg)); _leafRenderer.DrawCellShell(e.CellId);
break; break;
case WalkFrameEventKind.PunchFan: case WalkFrameEventKind.PunchFan:
_leafRenderer.DrawPunchFan(e.Polygon!, e.IntArg); _leafRenderer.DrawPunchFan(e.Polygon!, e.IntArg);
@ -920,6 +976,113 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
} }
} }
/// <summary>
/// Output-only Campaign FW cathedral trace. The event list described here
/// is the same list <see cref="Replay"/> immediately executes, so this
/// captures the actual clear/seal/punch/shell submission order without
/// inserting a second visibility walk or changing rendering behavior.
/// </summary>
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 // IWalkEventSink
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@ -1006,13 +1169,13 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
_populator.PopulateOutdoorStatics( _populator.PopulateOutdoorStatics(
_stream, cellId, records.Records, records.TupleLandblockId, _stream, cellId, records.Records, records.TupleLandblockId,
_cameraWorldPosition, _viewProjection, _cameraWorldPosition, _viewProjection,
this, _landscapeViewRouteIndex, _outdoorDrawnThisFrame, this, _landscapeViewRouteIndex,
_alphaSubmissions); alphaSubmissions: _alphaSubmissions);
_populator.PopulateCellDynamics( _populator.PopulateCellDynamics(
_stream, cellId, dynamics.Records, dynamics.TupleLandblockId, _stream, cellId, dynamics.Records, dynamics.TupleLandblockId,
_cameraWorldPosition, _viewProjection, _cameraWorldPosition, _viewProjection,
this, _landscapeViewRouteIndex, _outdoorDrawnThisFrame, this, _landscapeViewRouteIndex,
_alphaSubmissions); alphaSubmissions: _alphaSubmissions);
if (_alphaSubmissions.Count != _alphaSubmitMark) if (_alphaSubmissions.Count != _alphaSubmitMark)
{ {
MarkIfGrown(); MarkIfGrown();
@ -1078,6 +1241,12 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
// look-in flood appended (keeps every range single-stage). // look-in flood appended (keeps every range single-stage).
MarkIfGrown(); MarkIfGrown();
WalkFrameStaticRecords shell = _worldData.GetBuildingShellStatics(building); WalkFrameStaticRecords shell = _worldData.GetBuildingShellStatics(building);
bool skipSouthBuildingShell =
AcDream.Core.Rendering.RenderingDiagnostics
.ProbeCathedralSkipSouthBuildingShell
&& WalkProductionWorldData.AnchorCellId(building) == 0xF4180112u;
if (!skipSouthBuildingShell)
{
_populator.PopulateCell( _populator.PopulateCell(
_stream, WalkDrawStage.BuildingShell, building.PositionCellId, _stream, WalkDrawStage.BuildingShell, building.PositionCellId,
shell.Records, shell.TupleLandblockId, _cameraWorldPosition, _viewProjection, shell.Records, shell.TupleLandblockId, _cameraWorldPosition, _viewProjection,
@ -1087,6 +1256,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
MarkIfGrown(); MarkIfGrown();
MarkAlphaIfGrown(); MarkAlphaIfGrown();
} }
}
// FW4 (the #132 positional invariant): the building's own shell // FW4 (the #132 positional invariant): the building's own shell
// emitters submit at the shell turn, after the shell content // emitters submit at the shell turn, after the shell content
// flushes — see WalkFrameEventKind.StaticParticles. // flushes — see WalkFrameEventKind.StaticParticles.
@ -1104,6 +1274,22 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
ArgumentNullException.ThrowIfNull(polygon); ArgumentNullException.ThrowIfNull(polygon);
RequireOpenFrame(); 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(); MarkIfGrown();
Matrix4x4 worldTransform = _worldData.GetBuildingWorldTransform(building); Matrix4x4 worldTransform = _worldData.GetBuildingWorldTransform(building);
_events.Add( _events.Add(
@ -1115,12 +1301,28 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
ArgumentNullException.ThrowIfNull(cells); ArgumentNullException.ThrowIfNull(cells);
RequireOpenFrame(); 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 // PView::DrawCells @0x005a4840: the gated full depth clear
// (pc:432731-432732) then the exit-portal seals (pc:432785-432786) — // (pc:432731-432732) then the exit-portal seals (pc:432785-432786) —
// both unconditional for an interior root's own flood, whether or // both unconditional for an interior root's own flood, whether or
// not a landscape turn just ran (see this driver's type doc // not a landscape turn just ran (see this driver's type doc
// comment). // comment).
MarkIfGrown();
_events.Add(WalkFrameEvent.ClearInteriorDepth()); _events.Add(WalkFrameEvent.ClearInteriorDepth());
MarkIfGrown(); MarkIfGrown();
@ -1215,18 +1417,32 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
_floodViewRouteScratch.Add(-1); _floodViewRouteScratch.Add(-1);
// PView::DrawCells @0x005A4840, loop 2 (005A4A00005A4ADE): // PView::DrawCells @0x005A4840, loop 2 (005A4A00005A4ADE):
// cell_draw_list[count - 1] down to zero. DrawEnvCell loops the // cell_draw_list[count - 1] down to zero. The loop calls setup_view +
// cell's live portal_view slices, so capture those slices here and // DrawEnvCell for each live view, but DrawEnvCell @0x0059F170 begins
// replay the shell once per exact GPU clip slot. // 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--) for (int i = cells.Count - 1; i >= 0; i--)
{ {
int viewRouteIndex = CaptureCellViewRoute(cells[i]); int viewRouteIndex = CaptureCellViewRoute(cells[i]);
_floodViewRouteScratch[i] = viewRouteIndex; _floodViewRouteScratch[i] = viewRouteIndex;
IReadOnlyList<uint> clipSlots = VisibleClipSlotsInLookInTurn( if (_cellShellsDrawnThisFrame.Add(cells[i]))
viewRouteIndex, default, 0f, testSphere: false); {
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(); MarkIfGrown();
for (int slotIndex = 0; slotIndex < clipSlots.Count; slotIndex++) _events.Add(WalkFrameEvent.CellShell(cells[i]));
_events.Add(WalkFrameEvent.CellShell(cells[i], clipSlots[slotIndex])); }
}
} }
// Loop 3 (005A4ADE005A4B2D): restart at count - 1 and draw each // Loop 3 (005A4ADE005A4B2D): restart at count - 1 and draw each
@ -1372,6 +1588,40 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
return _visibleClipSlotScratch; 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();
}
/// <summary>Converts one retail pixel-space portal_view polygon into the /// <summary>Converts one retail pixel-space portal_view polygon into the
/// clip-space half-planes consumed by mesh_modern.vert. copy_view already /// clip-space half-planes consumed by mesh_modern.vert. copy_view already
/// deduplicates and removes collinear vertices, so 3..8 points map /// deduplicates and removes collinear vertices, so 3..8 points map

View file

@ -24,6 +24,12 @@ public sealed class WalkPView
{ {
private static int _masterTimestamp; private static int _masterTimestamp;
/// <summary>
/// Output-only correlation key for diagnostics emitted downstream from
/// the same frame walk. Production admission must never depend on it.
/// </summary>
internal static int MasterTimestampForDiagnostics => _masterTimestamp;
private readonly struct TodoEntry(WalkCell cell, float dist) private readonly struct TodoEntry(WalkCell cell, float dist)
{ {
public readonly WalkCell Cell = cell; public readonly WalkCell Cell = cell;
@ -177,12 +183,28 @@ public sealed class WalkPView
for (int j = 0; j < cell.Portals.Length; j++) for (int j = 0; j < cell.Portals.Length; j++)
{ {
ref WalkPortalFlags flags = ref top.PortalFlags[j]; ref WalkPortalFlags flags = ref top.PortalFlags[j];
if (!flags.Seen || flags.InView) continue;
ref WalkCellPortal portal = ref cell.Portals[j]; 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) if (cell.CachedNeighbors[j] is null && portal.OtherCellId != 0xFFFFFFFFu)
{ {
cell.CachedNeighbors[j] = ctx.GetVisible(portal.OtherCellId); 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; anyLive = true;
} }
@ -201,7 +223,17 @@ public sealed class WalkPView
cell, portal.PortalSide, cell, portal.PortalSide,
cell.PortalPolygons[portal.PolygonIndex], cell.PortalPolygons[portal.PolygonIndex],
doClip: true, ctx, _clipScratch); doClip: true, ctx, _clipScratch);
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) if (portal.OtherCellId == 0xFFFFFFFFu)
{ {
@ -223,18 +255,125 @@ public sealed class WalkPView
{ {
n = OtherPortalClip(cell, j, n, ctx); n = OtherPortalClip(cell, j, n, ctx);
SetView(top, i); // restore after the far-frame excursion 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) if (neighbor.NumView != 0)
WalkCopyView.Append( appended = WalkCopyView.Append(
neighbor.TopView, _clipScratch.AsSpan(0, n), neighbor.TopView, _clipScratch.AsSpan(0, n),
ctx.Rays, ctx.WorldViewpoint); ctx.Rays, ctx.WorldViewpoint);
EmitFacilityPortalProbe(
cell, j, i, top, portal, flags, ctx,
projectedCount, n, before, neighbor.TopView.ViewCount,
appended,
neighbor.NumView == 0 ? "neighbor-not-pushed"
: appended ? "view-appended" : "append-rejected");
} }
} }
} }
return true; return true;
} }
/// <summary>
/// Output-only Facility Hub discriminator for the residual #177 stair
/// disappearance. It traces only portals entering cell 0x8A02015F so a
/// side-to-away camera turn can distinguish flag admission, homogeneous
/// portal clipping, and copy_view append without changing the walk.
/// </summary>
private void EmitFacilityPortalProbe(
WalkCell cell,
int portalIndex,
int viewIndex,
WalkPortalView top,
WalkCellPortal portal,
WalkPortalFlags flags,
IWalkFrameContext ctx,
int projectedCount,
int clippedCount,
int neighborViewsBefore,
int neighborViewsAfter,
bool appended,
string result)
{
if (!AcDream.Core.Rendering.RenderingDiagnostics.ProbeFacilityStairsEnabled
|| portal.OtherCellId != 0x8A02015Fu
|| (cell.CellId & 0xFFFF0000u) != 0x8A020000u)
{
return;
}
string activeBounds = "none";
if ((uint)viewIndex < (uint)top.ViewCount)
{
WalkViewPoly active = top.View.Polys[viewIndex];
activeBounds = $"({active.XMin:F2},{active.YMin:F2})-"
+ $"({active.XMax:F2},{active.YMax:F2})";
}
string projectedBounds = DescribeScreenBounds(
_projectScratch.AsSpan(0, Math.Max(projectedCount, 0)));
string clippedBounds = DescribeScreenBounds(
_clipScratch.AsSpan(0, Math.Max(clippedCount, 0)));
Console.WriteLine(
$"[facility-portal] frame={_masterTimestamp} "
+ $"from=0x{cell.CellId:X8} portal={portalIndex} "
+ $"to=0x{portal.OtherCellId:X8} back={portal.OtherPortalId} "
+ $"view={viewIndex}/{top.ViewCount} startFlags="
+ $"seen:{(flags.Seen ? 1 : 0)},in:{(flags.InView ? 1 : 0)} "
+ $"side={portal.PortalSide} exact={(portal.ExactMatch ? 1 : 0)} "
+ $"eye=({ctx.WorldViewpoint.X:F6},{ctx.WorldViewpoint.Y:F6},"
+ $"{ctx.WorldViewpoint.Z:F6}) active={activeBounds} "
+ $"projected={projectedCount}:{projectedBounds} "
+ $"clipped={clippedCount}:{clippedBounds} "
+ $"neighborViews={neighborViewsBefore}->{neighborViewsAfter} "
+ $"append={(appended ? 1 : 0)} result={result}");
}
private static string DescribeScreenBounds(ReadOnlySpan<WalkScreenPoint> points)
{
if (points.IsEmpty)
return "none";
float xmin = float.PositiveInfinity;
float xmax = float.NegativeInfinity;
float ymin = float.PositiveInfinity;
float ymax = float.NegativeInfinity;
float wmin = float.PositiveInfinity;
float wmax = float.NegativeInfinity;
int divided = 0;
for (int index = 0; index < points.Length; index++)
{
ref readonly WalkScreenPoint point = ref points[index];
wmin = MathF.Min(wmin, point.W);
wmax = MathF.Max(wmax, point.W);
if (point.W < WalkScreenClip.MinW)
continue;
float x = point.X / point.W;
float y = point.Y / point.W;
xmin = MathF.Min(xmin, x);
xmax = MathF.Max(xmax, x);
ymin = MathF.Min(ymin, y);
ymax = MathF.Max(ymax, y);
divided++;
}
return divided == 0
? $"behind(w={wmin:F4}..{wmax:F4})"
: $"({xmin:F2},{ymin:F2})-({xmax:F2},{ymax:F2});"
+ $"w={wmin:F4}..{wmax:F4};front={divided}";
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// OtherPortalClip @0x005a5400 — the double clip for non-exact_match // OtherPortalClip @0x005a5400 — the double clip for non-exact_match
// portals: snapshot the near-clipped poly as a temp view, then re-clip // portals: snapshot the near-clipped poly as a temp view, then re-clip

View file

@ -1,5 +1,6 @@
using System.Numerics; using System.Numerics;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text;
using AcDream.App.Rendering.Scene; using AcDream.App.Rendering.Scene;
using AcDream.Core.Physics; using AcDream.Core.Physics;
@ -12,10 +13,18 @@ namespace AcDream.App.Rendering.Walk;
/// <see cref="BeginFrame"/>: /// <see cref="BeginFrame"/>:
/// ///
/// <list type="bullet"> /// <list type="bullet">
/// <item>Cell statics — <see cref="RenderSceneQuery.CopyCellStaticsTo"/> on /// <item>Cell statics — ONE <see cref="RenderSceneQuery.CopyIndexTo"/> sweep
/// demand, one arena segment per distinct cell per frame (a cell can be /// over authored indoor statics, bucketed into every cell crossed by the
/// visited once by the root flood OR once per admitting look-in portal; the /// object's visual parts. This is retail's <c>CPartArray::AddPartsShadow</c>
/// per-frame cache keeps the copy single).</item> /// render index, which deliberately includes non-colliding decorations and
/// is distinct from the physics shadow-object index.</item>
/// <item>Live dynamics — ONE global dynamic-index sweep, bucketed into every
/// cell in the object's retained physics <c>CELLARRAY</c>. Retail feeds that
/// exact array to <c>CPhysicsObj::add_shadows_to_cells</c>, which calls
/// <c>CPartArray::AddPartsShadow</c> 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.</item>
/// <item>Outdoor statics — ONE <see cref="RenderSceneQuery.CopyIndexTo"/> /// <item>Outdoor statics — ONE <see cref="RenderSceneQuery.CopyIndexTo"/>
/// sweep bucketed by landscape cell id /// sweep bucketed by landscape cell id
/// (<c>(lb &amp; 0xFFFF0000) | (cellX*8 + cellY + 1)</c> from the record's /// (<c>(lb &amp; 0xFFFF0000) | (cellX*8 + cellY + 1)</c> from the record's
@ -24,10 +33,10 @@ namespace AcDream.App.Rendering.Walk;
/// shell turn, retail <c>CPhysicsPart::Draw(parts, 0)</c> @0x0059f331, not /// shell turn, retail <c>CPhysicsPart::Draw(parts, 0)</c> @0x0059f331, not
/// at the cell's <c>DrawObjCell</c> turn).</item> /// at the cell's <c>DrawObjCell</c> turn).</item>
/// <item>Building shells — the same sweep's <c>IsBuildingShell</c> records /// <item>Building shells — the same sweep's <c>IsBuildingShell</c> records
/// bucketed by <c>Source.BuildingShellAnchorCellId</c>; a /// bucketed by <c>Source.BuildingShellAnchorCellId</c> for portal-bearing
/// <see cref="WalkBuilding"/> maps to its anchor via its first /// buildings. Portal-less buildings have no interior anchor; retail still
/// non-exit portal's destination (the SAME rule /// draws them at their landscape position-cell turn, so those records use
/// <c>LandblockLoader</c> used to author the anchor).</item> /// <c>Source.EffectCellId</c>, matching <see cref="WalkBuilding.PositionCellId"/>.</item>
/// </list> /// </list>
/// ///
/// The tuple landblock id handed to the classifier is the frame's player /// 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 WalkBuildingRegistry _buildings;
private readonly ShadowObjectRegistry _shadows; private readonly ShadowObjectRegistry _shadows;
private readonly Func<uint, uint?> _findParentLocalId;
private RenderSceneQuery _scene; private RenderSceneQuery _scene;
private uint _tupleLandblockId; private uint _tupleLandblockId;
private int _renderCenterLbX; private int _renderCenterLbX;
@ -63,16 +73,22 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
private readonly Dictionary<uint, WalkFrameStaticRecords> _cellCache = new(); private readonly Dictionary<uint, WalkFrameStaticRecords> _cellCache = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _cellDynamicCache = new(); private readonly Dictionary<uint, WalkFrameStaticRecords> _cellDynamicCache = new();
private readonly Dictionary<string, string> _facilityShadowProbeSignatures = new();
private readonly Dictionary<uint, List<RenderProjectionRecord>> _indoorByCell = new();
private readonly Dictionary<uint, List<RenderProjectionRecord>> _indoorDynamicsByCell = new();
private readonly Dictionary<uint, List<RenderProjectionRecord>> _outdoorByCell = new(); private readonly Dictionary<uint, List<RenderProjectionRecord>> _outdoorByCell = new();
private readonly Dictionary<uint, List<RenderProjectionRecord>> _outdoorDynamicsByCell = new(); private readonly Dictionary<uint, List<RenderProjectionRecord>> _outdoorDynamicsByCell = new();
private readonly Dictionary<uint, List<RenderProjectionRecord>> _shellsByAnchor = new(); private readonly Dictionary<uint, List<RenderProjectionRecord>> _shellsByAnchor = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _outdoorMaterialized = new(); private readonly Dictionary<uint, WalkFrameStaticRecords> _outdoorMaterialized = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _outdoorDynamicsMaterialized = new(); private readonly Dictionary<uint, WalkFrameStaticRecords> _outdoorDynamicsMaterialized = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _shellMaterialized = new(); private readonly Dictionary<uint, WalkFrameStaticRecords> _shellMaterialized = new();
private readonly Dictionary<RenderProjectionId, StaticRenderCellCacheEntry>
_staticRenderCellCache = new();
private ulong _staticRenderCellIndexRevision = ulong.MaxValue;
private RenderSceneGeneration _staticRenderCellGeneration;
private RenderProjectionRecord[] _indoorSweepScratch = new RenderProjectionRecord[256];
private RenderProjectionRecord[] _sweepScratch = new RenderProjectionRecord[1024]; private RenderProjectionRecord[] _sweepScratch = new RenderProjectionRecord[1024];
private RenderProjectionRecord[] _dynamicSweepScratch = new RenderProjectionRecord[256]; 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 // Campaign FW3.4a: the per-frame, grow-only materialization arena — see
// this type's own doc comment. // this type's own doc comment.
@ -81,12 +97,16 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
internal WalkProductionWorldData( internal WalkProductionWorldData(
WalkBuildingRegistry buildings, WalkBuildingRegistry buildings,
ShadowObjectRegistry shadows) ShadowObjectRegistry shadows,
Func<uint, uint?>? findParentLocalId = null)
{ {
_buildings = buildings ?? throw new ArgumentNullException(nameof(buildings)); _buildings = buildings ?? throw new ArgumentNullException(nameof(buildings));
_shadows = shadows ?? throw new ArgumentNullException(nameof(shadows)); _shadows = shadows ?? throw new ArgumentNullException(nameof(shadows));
_findParentLocalId = findParentLocalId ?? NoParentLocalId;
} }
private static uint? NoParentLocalId(uint _) => null;
/// <summary>Rebuilds the frame's outdoor/shell buckets and clears the /// <summary>Rebuilds the frame's outdoor/shell buckets and clears the
/// per-cell cache. Call once per frame before the driver runs. /// per-cell cache. Call once per frame before the driver runs.
/// <paramref name="renderCenterLbX"/>/<paramref name="renderCenterLbY"/> /// <paramref name="renderCenterLbX"/>/<paramref name="renderCenterLbY"/>
@ -112,6 +132,10 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
_outdoorDynamicsMaterialized.Clear(); _outdoorDynamicsMaterialized.Clear();
_shellMaterialized.Clear(); _shellMaterialized.Clear();
_arenaLength = 0; _arenaLength = 0;
foreach (List<RenderProjectionRecord> bucket in _indoorByCell.Values)
bucket.Clear();
foreach (List<RenderProjectionRecord> bucket in _indoorDynamicsByCell.Values)
bucket.Clear();
foreach (List<RenderProjectionRecord> bucket in _outdoorByCell.Values) foreach (List<RenderProjectionRecord> bucket in _outdoorByCell.Values)
bucket.Clear(); bucket.Clear();
foreach (List<RenderProjectionRecord> bucket in _outdoorDynamicsByCell.Values) foreach (List<RenderProjectionRecord> bucket in _outdoorDynamicsByCell.Values)
@ -119,23 +143,56 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
foreach (List<RenderProjectionRecord> bucket in _shellsByAnchor.Values) foreach (List<RenderProjectionRecord> bucket in _shellsByAnchor.Values)
bucket.Clear(); 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<uint> renderCells = ResolveStaticRenderCells(in record);
BucketIndoorRecord(
in record,
renderCells,
_indoorByCell,
_outdoorByCell);
}
// CopyIndexTo THROWS on an undersized destination (ArchRenderScene // CopyIndexTo THROWS on an undersized destination (ArchRenderScene
// validates up front — the first connected gate run of the FW3.2b-2 // validates up front — the first connected gate run of the FW3.2b-2
// cutover crashed on exactly this at Aerlinthe's 5,040 outdoor // cutover crashed on exactly this at Aerlinthe's 5,040 outdoor
// statics), so presize from the query's own index counts. // 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) if (required > _sweepScratch.Length)
{ {
_sweepScratch = new RenderProjectionRecord[ _sweepScratch = new RenderProjectionRecord[
Math.Max(required, _sweepScratch.Length * 2)]; 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++) for (int i = 0; i < count; i++)
{ {
ref readonly RenderProjectionRecord record = ref _sweepScratch[i]; ref readonly RenderProjectionRecord record = ref _sweepScratch[i];
if (record.EntityPayload.IsBuildingShell) if (record.EntityPayload.IsBuildingShell)
{ {
uint anchor = record.Source.BuildingShellAnchorCellId; uint anchor = BuildingShellBucketCellId(in record);
if (!_shellsByAnchor.TryGetValue(anchor, out List<RenderProjectionRecord>? shells)) if (!_shellsByAnchor.TryGetValue(anchor, out List<RenderProjectionRecord>? shells))
_shellsByAnchor[anchor] = shells = new List<RenderProjectionRecord>(); _shellsByAnchor[anchor] = shells = new List<RenderProjectionRecord>();
shells.Add(record); shells.Add(record);
@ -149,27 +206,260 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
_renderCenterLbY); _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) if (required > _dynamicSweepScratch.Length)
{ {
_dynamicSweepScratch = new RenderProjectionRecord[ _dynamicSweepScratch = new RenderProjectionRecord[
Math.Max(required, _dynamicSweepScratch.Length * 2)]; Math.Max(required, _dynamicSweepScratch.Length * 2)];
} }
count = _scene.CopyIndexTo( count = _scene.CopyIndexTo(
RenderSceneIndex.OutdoorDynamic, RenderSceneIndex.Dynamic,
_dynamicSweepScratch); _dynamicSweepScratch);
for (int i = 0; i < count; i++) for (int i = 0; i < count; i++)
{ {
ref readonly RenderProjectionRecord record = ref _dynamicSweepScratch[i]; ref readonly RenderProjectionRecord record = ref _dynamicSweepScratch[i];
BucketOutdoorRecord( IReadOnlyList<uint> renderCells = ResolveDynamicRenderCells(
in record, in record,
_shadows.GetOwnerCells(record.Source.LocalEntityId), _shadows.GetOwnerCells,
_findParentLocalId);
BucketDynamicRecord(
in record,
renderCells,
_indoorDynamicsByCell,
_outdoorDynamicsByCell, _outdoorDynamicsByCell,
_renderCenterLbX, _renderCenterLbX,
_renderCenterLbY); _renderCenterLbY);
} }
} }
/// <summary>
/// Retail <c>CPhysicsObj::add_shadows_to_cells</c> 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.
/// </summary>
internal static IReadOnlyList<uint> ResolveDynamicRenderCells(
in RenderProjectionRecord record,
Func<uint, IReadOnlyList<uint>> getOwnerCells,
Func<uint, uint?> findParentLocalId)
{
ArgumentNullException.ThrowIfNull(getOwnerCells);
ArgumentNullException.ThrowIfNull(findParentLocalId);
uint current = record.Source.LocalEntityId;
IReadOnlyList<uint> 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<uint>();
}
private IReadOnlyList<uint> 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<uint> collisionCells =
_shadows.GetOwnerCells(record.Source.LocalEntityId);
IReadOnlyList<uint> cells = Array.Empty<uint>();
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<ShadowShape> 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>();
uint[] snapshot = cells as uint[] ?? cells.ToArray();
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFacilityStairsEnabled
&& record.Source.SourceId == 0x020009A2u)
{
static string DescribeCells(IReadOnlyList<uint> 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<RenderProjectionRecord>? records)
&& records.Exists(record => record.Source.SourceId == sourceId);
/// <summary>
/// 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.
/// </summary>
internal static void BucketIndoorRecord(
in RenderProjectionRecord record,
IReadOnlyList<uint> renderCells,
Dictionary<uint, List<RenderProjectionRecord>> indoorBuckets,
Dictionary<uint, List<RenderProjectionRecord>> 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);
}
/// <summary>
/// Installs one live PartArray into every cell in retail's retained
/// <c>CELLARRAY</c>. 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.
/// </summary>
internal static void BucketDynamicRecord(
in RenderProjectionRecord record,
IReadOnlyList<uint> shadowCells,
Dictionary<uint, List<RenderProjectionRecord>> indoorBuckets,
Dictionary<uint, List<RenderProjectionRecord>> 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);
}
/// <summary> /// <summary>
/// Installs one outdoor object's render shadow in every outdoor cell of /// Installs one outdoor object's render shadow in every outdoor cell of
/// its authoritative physics <c>CELLARRAY</c>. This is retail's /// its authoritative physics <c>CELLARRAY</c>. This is retail's
@ -252,19 +542,13 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
{ {
if (_cellCache.TryGetValue(cellId, out WalkFrameStaticRecords cached)) if (_cellCache.TryGetValue(cellId, out WalkFrameStaticRecords cached))
return cached; return cached;
// Same up-front-validation contract as CopyIndexTo: presize from the WalkFrameStaticRecords records =
// query's own count rather than probing with an undersized span. _indoorByCell.TryGetValue(cellId, out List<RenderProjectionRecord>? bucket)
int required = _scene.GetCellStaticCount(cellId); && bucket.Count > 0
if (required > _cellScratch.Length) ? new WalkFrameStaticRecords(
{ AppendToArena(CollectionsMarshal.AsSpan(bucket)), _tupleLandblockId)
_cellScratch = new RenderProjectionRecord[ : WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId };
Math.Max(required, _cellScratch.Length * 2)]; EmitFacilityShadowProbe(records.Records, cellId, "static");
}
int count = _scene.CopyCellStaticsTo(cellId, _cellScratch);
WalkFrameStaticRecords records = count == 0
? WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId }
: new WalkFrameStaticRecords(
AppendToArena(_cellScratch.AsSpan(0, count)), _tupleLandblockId);
_cellCache[cellId] = records; _cellCache[cellId] = records;
return records; return records;
} }
@ -273,21 +557,81 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
{ {
if (_cellDynamicCache.TryGetValue(cellId, out WalkFrameStaticRecords cached)) if (_cellDynamicCache.TryGetValue(cellId, out WalkFrameStaticRecords cached))
return cached; return cached;
int required = _scene.GetCellDynamicCount(cellId); WalkFrameStaticRecords records =
if (required > _cellDynamicScratch.Length) _indoorDynamicsByCell.TryGetValue(
{ cellId,
_cellDynamicScratch = new RenderProjectionRecord[ out List<RenderProjectionRecord>? bucket)
Math.Max(required, _cellDynamicScratch.Length * 2)]; && bucket.Count > 0
} ? new WalkFrameStaticRecords(
int count = _scene.CopyCellDynamicsTo(cellId, _cellDynamicScratch); AppendToArena(CollectionsMarshal.AsSpan(bucket)), _tupleLandblockId)
WalkFrameStaticRecords records = count == 0 : WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId };
? WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId } EmitFacilityShadowProbe(records.Records, cellId, "dynamic");
: new WalkFrameStaticRecords(
AppendToArena(_cellDynamicScratch.AsSpan(0, count)), _tupleLandblockId);
_cellDynamicCache[cellId] = records; _cellDynamicCache[cellId] = records;
return records; return records;
} }
/// <summary>
/// Facility Hub discriminator for retail's cross-cell render-shadow path.
/// The scene query is currently keyed by authored parent cell, while retail
/// also appends each object's parts to every cell in its physics CELLARRAY
/// through CPhysicsObj::add_shadows_to_cells / CPartArray::AddPartsShadow.
/// This probe prints the authoritative physics owner set without changing
/// admission, so a correction is made only if the live staircase/player
/// registration proves that path is populated.
/// </summary>
private void EmitFacilityShadowProbe(
ReadOnlySpan<RenderProjectionRecord> records,
uint queriedCellId,
string route)
{
if (!AcDream.Core.Rendering.RenderingDiagnostics.ProbeFacilityStairsEnabled)
return;
for (int recordIndex = 0; recordIndex < records.Length; recordIndex++)
{
ref readonly RenderProjectionRecord record = ref records[recordIndex];
bool targetPlayer = record.EntityPayload.CasterIdentity
== RenderCasterIdentityKind.LocalPlayer;
bool targetStair = record.Source.SourceId == 0x02000623u;
bool targetCathedralStair =
record.Source.SourceId == 0x020009A2u;
if (!targetStair && !targetCathedralStair && !targetPlayer)
continue;
IReadOnlyList<uint> ownerCells =
_shadows.GetOwnerCells(record.Source.LocalEntityId);
var cells = new StringBuilder(ownerCells.Count * 11 + 2);
cells.Append('[');
for (int cellIndex = 0; cellIndex < ownerCells.Count; cellIndex++)
{
if (cellIndex != 0)
cells.Append(',');
cells.Append("0x").Append(ownerCells[cellIndex].ToString("X8"));
}
cells.Append(']');
string key = $"{route}:{queriedCellId:X8}:"
+ $"{record.Source.LocalEntityId:X8}";
string signature = $"{record.Source.ParentCellId:X8}:{cells}";
if (_facilityShadowProbeSignatures.TryGetValue(key, out string? prior)
&& string.Equals(prior, signature, StringComparison.Ordinal))
{
continue;
}
_facilityShadowProbeSignatures[key] = signature;
Console.WriteLine(
$"[facility-shadow] route={route} "
+ $"kind={(targetPlayer ? "player" : targetCathedralStair ? "cathedral-stair" : "stair")} "
+ $"guid=0x{record.Source.ServerGuid:X8} "
+ $"local=0x{record.Source.LocalEntityId:X8} "
+ $"query=0x{queriedCellId:X8} "
+ $"parent=0x{record.Source.ParentCellId:X8} "
+ $"owners={cells}");
}
}
public WalkFrameStaticRecords GetOutdoorStatics(uint cellId) public WalkFrameStaticRecords GetOutdoorStatics(uint cellId)
{ {
if (_outdoorMaterialized.TryGetValue(cellId, out WalkFrameStaticRecords cached)) if (_outdoorMaterialized.TryGetValue(cellId, out WalkFrameStaticRecords cached))
@ -325,7 +669,7 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
public WalkFrameStaticRecords GetBuildingShellStatics(WalkBuilding building) public WalkFrameStaticRecords GetBuildingShellStatics(WalkBuilding building)
{ {
uint anchor = AnchorCellId(building); uint anchor = BuildingShellBucketCellId(building);
if (anchor == 0) if (anchor == 0)
return WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId }; return WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId };
if (_shellMaterialized.TryGetValue(anchor, out WalkFrameStaticRecords cached)) if (_shellMaterialized.TryGetValue(anchor, out WalkFrameStaticRecords cached))
@ -382,6 +726,30 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
return 0; return 0;
} }
/// <summary>
/// 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 <c>CBuildingObj</c>; retail's
/// <c>DrawSortCell</c> reaches it through the outdoor cell containing its
/// placement, represented by <c>EffectCellId</c> on the retained record.
/// </summary>
internal static uint BuildingShellBucketCellId(in RenderProjectionRecord record)
=> record.Source.BuildingShellAnchorCellId != 0
? record.Source.BuildingShellAnchorCellId
: record.Source.EffectCellId;
/// <summary>
/// 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.
/// </summary>
internal static uint BuildingShellBucketCellId(WalkBuilding building)
{
ArgumentNullException.ThrowIfNull(building);
uint anchor = AnchorCellId(building);
return anchor != 0 ? anchor : building.PositionCellId;
}
public Matrix4x4 GetBuildingWorldTransform(WalkBuilding building) public Matrix4x4 GetBuildingWorldTransform(WalkBuilding building)
{ {
if (!_buildings.TryGetEntry(building, out WalkBuildingFactory.Entry? entry)) if (!_buildings.TryGetEntry(building, out WalkBuildingFactory.Entry? entry))
@ -391,4 +759,10 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
} }
return entry.WorldTransform; return entry.WorldTransform;
} }
private readonly record struct StaticRenderCellCacheEntry(
uint ParentCellId,
RenderSceneHash128 TransformFingerprint,
RenderSceneHash128 GeometryFingerprint,
uint[] Cells);
} }

View file

@ -110,7 +110,8 @@ internal sealed class WalkStaticStreamPopulator
ClassifyAndAppend( ClassifyAndAppend(
stream, WalkDrawStage.OutdoorStatic, cellId, in records[i], stream, WalkDrawStage.OutdoorStatic, cellId, in records[i],
tupleLandblockId, cameraWorldPosition, viewProjection, tupleLandblockId, cameraWorldPosition, viewProjection,
liveDynamic: false, views, viewRouteIndex, alphaSubmissions); liveDynamic: false, views, viewRouteIndex,
alphaSubmissions: alphaSubmissions);
} }
} }
@ -142,7 +143,7 @@ internal sealed class WalkStaticStreamPopulator
liveDynamic: true, liveDynamic: true,
lookInViews, lookInViews,
lookInRouteIndex, lookInRouteIndex,
alphaSubmissions); alphaSubmissions: alphaSubmissions);
} }
} }
@ -169,17 +170,25 @@ internal sealed class WalkStaticStreamPopulator
liveDynamic, liveDynamic,
lookInViews, lookInViews,
lookInRouteIndex, lookInRouteIndex,
cellId); cellId,
diagnosticViewProjection: viewProjection);
for (int i = 0; i < _batchScratch.Count; i++) for (int i = 0; i < _batchScratch.Count; i++)
{ {
WbDrawDispatcher.WalkClassifiedBatch batch = _batchScratch[i]; WbDrawDispatcher.WalkClassifiedBatch batch = _batchScratch[i];
if (batch.IsOpaque) if (batch.IsOpaque)
{ {
int commandIndex = stream.Count;
stream.Append(new OrderedDrawCommand( stream.Append(new OrderedDrawCommand(
batch.Key, batch.Transform, stage, cellId, batch.ClipSlot, batch.Key, batch.Transform, stage, cellId, batch.ClipSlot,
batch.Lights, batch.IndoorFlag, batch.Alpha, batch.Lights, batch.IndoorFlag, batch.Alpha,
batch.SelectionLighting, batch.DetailCategory)); batch.SelectionLighting, batch.DetailCategory));
_dispatcher.ProbeFacilityStairCommandAppended(
commandIndex,
record.Source.LocalEntityId,
cellId,
stage,
in batch);
} }
else else
{ {

View file

@ -0,0 +1,171 @@
using System.Text;
using AcDream.App.Rendering.Walk;
using AcDream.Core.Rendering;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Temporary issue-177 live probe. The classifier-side facility trace proves
/// whether the stair GfxObj survives the retail drawing-sphere test; this
/// companion trace follows that same exact object through the ordered stream,
/// ring upload, and final indirect draw call. It is inert unless
/// <c>ACDREAM_PROBE_FACILITY_STAIRS=1</c>.
/// </summary>
public sealed partial class WbDrawDispatcher
{
private const uint FacilityStairSubmissionLocalId = 0x4F41806Cu;
private readonly List<FacilityStairSubmissionCommand> _facilityStairSubmissionCommands = new(4);
private readonly HashSet<int> _facilityStairSubmittedCommands = new();
private int _facilityStairSubmissionFrame = -1;
private bool _facilityStairPrepareSeen;
private bool _facilityStairUploadCompleted;
private int _facilityStairPreparedCount;
private int _facilityStairSubmissionRangeFirst = -1;
private int _facilityStairSubmissionRangeCount;
private int _facilityStairSubmissionRunFirst = -1;
private int _facilityStairSubmissionRunCount;
private readonly record struct FacilityStairSubmissionCommand(
int CommandIndex,
uint CellId,
WalkDrawStage Stage,
System.Numerics.Vector3 Translation,
GroupKey Key);
internal void BeginFacilityStairSubmissionProbeFrame()
{
if (!RenderingDiagnostics.ProbeFacilityStairsEnabled)
return;
_facilityStairSubmissionCommands.Clear();
_facilityStairSubmittedCommands.Clear();
_facilityStairPrepareSeen = false;
_facilityStairUploadCompleted = false;
_facilityStairPreparedCount = 0;
_facilityStairSubmissionRangeFirst = -1;
_facilityStairSubmissionRangeCount = 0;
_facilityStairSubmissionRunFirst = -1;
_facilityStairSubmissionRunCount = 0;
_facilityStairSubmissionFrame = WalkPView.MasterTimestampForDiagnostics;
}
internal void ProbeFacilityStairCommandAppended(
int commandIndex,
uint localEntityId,
uint cellId,
WalkDrawStage stage,
in WalkClassifiedBatch batch)
{
if (!RenderingDiagnostics.ProbeFacilityStairsEnabled
|| localEntityId != FacilityStairSubmissionLocalId)
{
return;
}
_facilityStairSubmissionCommands.Add(new FacilityStairSubmissionCommand(
commandIndex,
cellId,
stage,
batch.Transform.Translation,
batch.Key));
}
private void ProbeFacilityStairPrepareStarted(int streamCount)
{
if (!RenderingDiagnostics.ProbeFacilityStairsEnabled)
return;
int frame = WalkPView.MasterTimestampForDiagnostics;
_facilityStairPrepareSeen = true;
_facilityStairPreparedCount = streamCount;
EmitFacilityStairSubmissionProbe("prepare");
}
private void ProbeFacilityStairUploadCompleted(int preparedCount)
{
if (!RenderingDiagnostics.ProbeFacilityStairsEnabled)
return;
_facilityStairUploadCompleted = true;
_facilityStairPreparedCount = preparedCount;
EmitFacilityStairSubmissionProbe("upload");
}
private void ProbeFacilityStairRunSubmitted(
int rangeFirst,
int rangeCount,
int runFirst,
int runCount)
{
if (!RenderingDiagnostics.ProbeFacilityStairsEnabled
|| _facilityStairSubmissionCommands.Count == 0)
{
return;
}
int runEnd = runFirst + runCount;
int submittedBefore = _facilityStairSubmittedCommands.Count;
for (int i = 0; i < _facilityStairSubmissionCommands.Count; i++)
{
int commandIndex = _facilityStairSubmissionCommands[i].CommandIndex;
if (commandIndex < runFirst || commandIndex >= runEnd)
continue;
_facilityStairSubmittedCommands.Add(commandIndex);
_facilityStairSubmissionRangeFirst = rangeFirst;
_facilityStairSubmissionRangeCount = rangeCount;
_facilityStairSubmissionRunFirst = runFirst;
_facilityStairSubmissionRunCount = runCount;
}
if (_facilityStairSubmittedCommands.Count != submittedBefore)
EmitFacilityStairSubmissionProbe("submit");
}
private void EmitFacilityStairSubmissionProbe(string phase)
{
if (_facilityStairSubmissionFrame < 0
|| _facilityStairSubmissionCommands.Count == 0)
{
return;
}
var commandDetails = new StringBuilder();
for (int i = 0; i < _facilityStairSubmissionCommands.Count; i++)
{
FacilityStairSubmissionCommand command = _facilityStairSubmissionCommands[i];
if (i != 0)
commandDetails.Append(';');
commandDetails.Append(command.CommandIndex)
.Append(':').Append(command.Stage)
.Append(":0x").Append(command.CellId.ToString("X8"))
.Append(":(")
.Append(command.Translation.X.ToString("F4"))
.Append(',')
.Append(command.Translation.Y.ToString("F4"))
.Append(',')
.Append(command.Translation.Z.ToString("F4"))
.Append(')')
.Append(':').Append(command.Key.CullMode)
.Append(':').Append(command.Key.FirstIndex)
.Append('+').Append(command.Key.IndexCount)
.Append(':').Append(command.Key.BaseVertex);
}
Console.WriteLine(
$"[facility-stair-path] phase={phase} frame={_facilityStairSubmissionFrame} "
+ $"local=0x{FacilityStairSubmissionLocalId:X8} "
+ $"commands={_facilityStairSubmissionCommands.Count} "
+ $"detail={commandDetails} "
+ $"prepare={(_facilityStairPrepareSeen ? 1 : 0)} "
+ $"preparedCount={_facilityStairPreparedCount} "
+ $"uploaded={(_facilityStairUploadCompleted ? 1 : 0)} "
+ $"submitted={_facilityStairSubmittedCommands.Count}/"
+ $"{_facilityStairSubmissionCommands.Count} "
+ $"range={_facilityStairSubmissionRangeFirst}+"
+ $"{_facilityStairSubmissionRangeCount} "
+ $"run={_facilityStairSubmissionRunFirst}+"
+ $"{_facilityStairSubmissionRunCount}");
}
}

View file

@ -355,6 +355,7 @@ public sealed unsafe partial class WbDrawDispatcher
_orderedStream = stream; _orderedStream = stream;
_orderedFrame = frame; _orderedFrame = frame;
_orderedPreparedCount = 0; _orderedPreparedCount = 0;
ProbeFacilityStairPrepareStarted(stream.Count);
// Fail loud before any GPU work: a PortalPunch command has no // Fail loud before any GPU work: a PortalPunch command has no
// submission path. // submission path.
@ -436,6 +437,7 @@ public sealed unsafe partial class WbDrawDispatcher
checked((uint)(count * DrawCommandStride))); checked((uint)(count * DrawCommandStride)));
_orderedPreparedCount = count; _orderedPreparedCount = count;
ProbeFacilityStairUploadCompleted(count);
} }
/// <summary> /// <summary>
@ -572,6 +574,11 @@ public sealed unsafe partial class WbDrawDispatcher
DrawIndirectRangeRhi( DrawIndirectRangeRhi(
encoder, ref pushConstants, commandBuffer, commandBase, encoder, ref pushConstants, commandBuffer, commandBase,
run.FirstCommand, run.CommandCount, _orderedDrawCullModes); run.FirstCommand, run.CommandCount, _orderedDrawCullModes);
ProbeFacilityStairRunSubmitted(
firstCommand,
commandCount,
run.FirstCommand,
run.CommandCount);
} }
} }

View file

@ -55,6 +55,86 @@ namespace AcDream.App.Rendering.Wb;
/// </summary> /// </summary>
public sealed partial class WbDrawDispatcher public sealed partial class WbDrawDispatcher
{ {
private static readonly IReadOnlyList<uint> RetailWholeMeshSlot = new uint[] { 0u };
private readonly HashSet<WalkDrawnPartKey> _walkDrawnParts = new();
private bool _walkPartFrameActive;
private readonly Dictionary<string, string> _facilityStairProbeSignatures = new();
private Dictionary<uint, FacilityStairProbeSnapshot> _facilityStairsCurrent = new();
private Dictionary<uint, FacilityStairProbeSnapshot> _facilityStairsPrevious = new();
private int _facilityStairsCurrentFrame = -1;
private int _facilityStairsPreviousFrame = -1;
private readonly record struct FacilityStairProbeSnapshot(
uint CellId,
int RouteIndex,
bool Admitted,
bool ScreenIntersect,
string ScreenBounds,
Vector3 WorldCenter);
private readonly record struct WalkDrawnPartKey(
RenderProjectionId ProjectionId,
int PartIndex);
/// <summary>
/// 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; <see cref="AdvanceWalkPartPassStamp"/>
/// reproduces that mid-frame boundary.
/// </summary>
internal void BeginWalkPartFrame()
{
if (_walkPartFrameActive)
{
throw new InvalidOperationException(
"A walk part frame was opened before the previous scope closed.");
}
_walkDrawnParts.Clear();
BeginFacilityStairSubmissionProbeFrame();
_walkPartFrameActive = true;
}
/// <summary>
/// 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.
/// </summary>
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));
/// <summary> /// <summary>
/// One walk-classified (entity, part, batch) draw candidate — exactly /// One walk-classified (entity, part, batch) draw candidate — exactly
/// <c>OrderedDrawCommand</c>'s per-instance field set (minus /// <c>OrderedDrawCommand</c>'s per-instance field set (minus
@ -188,7 +268,8 @@ public sealed partial class WbDrawDispatcher
bool liveDynamic = false, bool liveDynamic = false,
IWalkLookInViewSource? lookInViews = null, IWalkLookInViewSource? lookInViews = null,
int lookInRouteIndex = -1, int lookInRouteIndex = -1,
uint lookInCellId = 0) uint lookInCellId = 0,
Matrix4x4 diagnosticViewProjection = default)
{ {
ArgumentNullException.ThrowIfNull(batches); ArgumentNullException.ThrowIfNull(batches);
ArgumentNullException.ThrowIfNull(selectionParts); ArgumentNullException.ThrowIfNull(selectionParts);
@ -270,6 +351,17 @@ public sealed partial class WbDrawDispatcher
ObjectRenderData? partData = _meshAdapter.TryGetRenderData(gfxObjId); ObjectRenderData? partData = _meshAdapter.TryGetRenderData(gfxObjId);
if (partData is null) if (partData is null)
{ {
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFacilityStairsEnabled
&& entity.LocalEntityId == 0x4F41806Cu
&& gfxObjId == 0x01001FE8u)
{
Console.WriteLine(
$"[facility-part] frame={WalkPView.MasterTimestampForDiagnostics} "
+ $"local=0x{entity.LocalEntityId:X8} "
+ $"cell=0x{lookInCellId:X8} gfx=0x{gfxObjId:X8} "
+ $"part={setupPartIndex} route={lookInRouteIndex} "
+ "decision=render-data-miss");
}
if (_missRequested.Add(gfxObjId)) if (_missRequested.Add(gfxObjId))
_meshAdapter.EnsureLoaded(gfxObjId); _meshAdapter.EnsureLoaded(gfxObjId);
continue; continue;
@ -282,21 +374,48 @@ public sealed partial class WbDrawDispatcher
(uint)setupPartIndex) (uint)setupPartIndex)
: 1f; : 1f;
if (opacity <= 0f) if (opacity <= 0f)
{
EmitFacilityStairPartProbe(
in projection, in entity, lookInCellId,
lookInRouteIndex, (uint)gfxObjId, setupPartIndex,
partData, default, diagnosticViewProjection,
default, 0f, hasSphere: false,
opacity, "opacity-zero", lookInViews);
continue; continue;
}
Matrix4x4 restPose = partTransform * meshRef.PartTransform; Matrix4x4 restPose = partTransform * meshRef.PartTransform;
Matrix4x4 model = restPose * entity.RootWorld; Matrix4x4 model = restPose * entity.RootWorld;
int selectionPartIndex = unchecked((partIndex << 16) | (setupPartIndex & 0xFFFF)); int selectionPartIndex = unchecked((partIndex << 16) | (setupPartIndex & 0xFFFF));
IReadOnlyList<uint>? partClipSlots = ResolvePartClipSlots( IReadOnlyList<uint>? partClipSlots = ResolvePartClipSlots(
lookInViews, lookInRouteIndex, partData, model); lookInViews,
if (partClipSlots is { Count: 0 }) 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; continue;
EmitClassifiedBatches( EmitClassifiedBatches(
partData, model, in entity, meshRef, paletteIdentity, partData, model, in entity, meshRef, paletteIdentity,
entityHasCutoutSubset, slot, lights, indoor, selectionLighting, entityHasCutoutSubset, slot, lights, indoor,
detailCategory, opacity, partClipSlots, batches); selectionLighting, detailCategory, opacity,
partClipSlots, batches);
selectionParts.Add(new WalkClassifiedSelectionPart( selectionParts.Add(new WalkClassifiedSelectionPart(
entity.ServerGuid, entity.LocalEntityId, selectionPartIndex, entity.ServerGuid, entity.LocalEntityId, selectionPartIndex,
(uint)gfxObjId, model)); (uint)gfxObjId, model));
@ -311,17 +430,45 @@ public sealed partial class WbDrawDispatcher
(uint)partIndex) (uint)partIndex)
: 1f; : 1f;
if (opacity <= 0f) if (opacity <= 0f)
{
EmitFacilityStairPartProbe(
in projection, in entity, lookInCellId,
lookInRouteIndex, (uint)meshRef.GfxObjId, partIndex,
renderData, default, diagnosticViewProjection,
default, 0f, hasSphere: false,
opacity, "opacity-zero", lookInViews);
continue; continue;
}
Matrix4x4 model = meshRef.PartTransform * entity.RootWorld; Matrix4x4 model = meshRef.PartTransform * entity.RootWorld;
IReadOnlyList<uint>? partClipSlots = ResolvePartClipSlots( IReadOnlyList<uint>? partClipSlots = ResolvePartClipSlots(
lookInViews, lookInRouteIndex, renderData, model); lookInViews,
if (partClipSlots is { Count: 0 }) 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; continue;
EmitClassifiedBatches( EmitClassifiedBatches(
renderData, model, in entity, meshRef, paletteIdentity, renderData, model, in entity, meshRef, paletteIdentity,
entityHasCutoutSubsetOverride: null, slot, lights, indoor, entityHasCutoutSubsetOverride: null, slot, lights, indoor,
selectionLighting, detailCategory, opacity, partClipSlots, batches); selectionLighting, detailCategory, opacity,
partClipSlots, batches);
selectionParts.Add(new WalkClassifiedSelectionPart( selectionParts.Add(new WalkClassifiedSelectionPart(
entity.ServerGuid, entity.LocalEntityId, partIndex, entity.ServerGuid, entity.LocalEntityId, partIndex,
(uint)meshRef.GfxObjId, model)); (uint)meshRef.GfxObjId, model));
@ -381,25 +528,250 @@ public sealed partial class WbDrawDispatcher
IWalkLookInViewSource? lookInViews, IWalkLookInViewSource? lookInViews,
int routeIndex, int routeIndex,
ObjectRenderData renderData, 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) if (lookInViews is null)
return null; return null;
if (renderData.SelectionSphere is not { Radius: > 0f } sphere) if (renderData.SelectionSphere is not { Radius: > 0f } sphere)
{ {
return lookInViews.VisibleClipSlotsInLookInTurn( IReadOnlyList<uint> admittedViews = lookInViews.VisibleClipSlotsInLookInTurn(
routeIndex, routeIndex,
Vector3.Zero, Vector3.Zero,
radius: 0f, radius: 0f,
testSphere: false); testSphere: false);
return admittedViews.Count == 0
? admittedViews
: RetailWholeMeshSlot;
} }
TransformDrawingSphere(sphere, localToWorld, out Vector3 center, out float radius); hasSphere = true;
return lookInViews.VisibleClipSlotsInLookInTurn( TransformDrawingSphere(sphere, localToWorld, out sphereCenter, out sphereRadius);
IReadOnlyList<uint> visibleViews = lookInViews.VisibleClipSlotsInLookInTurn(
routeIndex, routeIndex,
in center, in sphereCenter,
radius, sphereRadius,
testSphere: true); 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<uint, FacilityStairProbeSnapshot> swap =
_facilityStairsPrevious;
_facilityStairsPrevious = _facilityStairsCurrent;
_facilityStairsCurrent = swap;
_facilityStairsCurrent.Clear();
_facilityStairsPreviousFrame = _facilityStairsCurrentFrame;
}
_facilityStairsCurrentFrame = frame;
}
private static bool TryProjectFacilityStairBounds(
ObjectRenderData renderData,
Matrix4x4 localToWorld,
Matrix4x4 viewProjection,
out string bounds)
{
Vector3 min = renderData.BoundingBox.Min;
Vector3 max = renderData.BoundingBox.Max;
float minX = float.PositiveInfinity;
float minY = float.PositiveInfinity;
float maxX = float.NegativeInfinity;
float maxY = float.NegativeInfinity;
int projected = 0;
for (int corner = 0; corner < 8; corner++)
{
var local = new Vector3(
(corner & 1) == 0 ? min.X : max.X,
(corner & 2) == 0 ? min.Y : max.Y,
(corner & 4) == 0 ? min.Z : max.Z);
Vector3 world = Vector3.Transform(local, localToWorld);
Vector4 clip = Vector4.Transform(new Vector4(world, 1f), viewProjection);
if (!float.IsFinite(clip.W) || clip.W <= 0.0001f)
continue;
float x = clip.X / clip.W;
float y = clip.Y / clip.W;
if (!float.IsFinite(x) || !float.IsFinite(y))
continue;
minX = MathF.Min(minX, x);
minY = MathF.Min(minY, y);
maxX = MathF.Max(maxX, x);
maxY = MathF.Max(maxY, y);
projected++;
}
if (projected == 0)
{
bounds = "behind";
return false;
}
bounds = $"({minX:F3},{minY:F3})-({maxX:F3},{maxY:F3});corners={projected}";
return maxX >= -1f && minX <= 1f && maxY >= -1f && minY <= 1f;
} }
internal static bool LookInDrawingSphereVisible( internal static bool LookInDrawingSphereVisible(

View file

@ -463,8 +463,6 @@ internal sealed class RuntimeWorldFrameEnvironmentPreparation
private readonly IWorldRenderRangeSource _ranges; private readonly IWorldRenderRangeSource _ranges;
private readonly SkyPesFrameController? _skyPes; private readonly SkyPesFrameController? _skyPes;
private readonly Func<bool> _persistentDaylight; private readonly Func<bool> _persistentDaylight;
private readonly HashSet<uint> _visibleCells = [];
private bool _visibleCellsValid;
public RuntimeWorldFrameEnvironmentPreparation( public RuntimeWorldFrameEnvironmentPreparation(
RuntimeOptions options, RuntimeOptions options,
@ -513,9 +511,12 @@ internal sealed class RuntimeWorldFrameEnvironmentPreparation
UpdateSunFromSky(landscapeLighting, roots.PlayerInsideCell); UpdateSunFromSky(landscapeLighting, roots.PlayerInsideCell);
_lighting.UpdateViewerLight(roots.PlayerViewPosition); _lighting.UpdateViewerLight(roots.PlayerViewPosition);
_lighting.Tick(camera.Position); _lighting.Tick(camera.Position);
_lighting.BuildPointLightSnapshot( // Retail collects point lights from CEnvCell::visible_cell_table, which
roots.PlayerViewPosition, // is the resident EnvCell registry populated at activation time. It is
_visibleCellsValid ? _visibleCells : null); // 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); _dispatcher?.SetSceneLights(_lighting.PointSnapshot);
_environmentCells?.SetPointSnapshot(_lighting.PointSnapshot); _environmentCells?.SetPointSnapshot(_lighting.PointSnapshot);
@ -540,15 +541,12 @@ internal sealed class RuntimeWorldFrameEnvironmentPreparation
_lightingUbo?.Upload(ubo); _lightingUbo?.Upload(ubo);
} }
public void ObserveDrawableCells(IReadOnlySet<uint> drawableCells) public void ObserveDrawableCells(IReadOnlySet<uint> drawableCells) =>
{
ArgumentNullException.ThrowIfNull(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) private void UpdateSunFromSky(SkyKeyframe keyframe, bool playerInsideCell)
{ {

View file

@ -619,7 +619,22 @@ public sealed class MeshExtractor {
BoundingBox = boundingBox, BoundingBox = boundingBox,
SortCenter = gfxObj?.SortCenter ?? Vector3.Zero, SortCenter = gfxObj?.SortCenter ?? Vector3.Zero,
DIDDegrade = gfxObj != null && gfxObj.Flags.HasFlag(GfxObjFlags.HasDIDDegrade) ? gfxObj.DIDDegrade : 0, 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,
}
}; };
} }

View file

@ -31,9 +31,13 @@ public static class PakFormat {
/// binary format remains version 1. Version 6 introduces pak format 2: /// binary format remains version 1. Version 6 introduces pak format 2:
/// globally shared texture payloads plus independently Brotli-compressed /// globally shared texture payloads plus independently Brotli-compressed
/// blobs with raw fallback. Mesh geometry and source texture bytes remain /// 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.
/// </summary> /// </summary>
public const uint CurrentBakeToolVersion = 6; public const uint CurrentBakeToolVersion = 7;
} }
/// <summary> /// <summary>

View file

@ -189,11 +189,11 @@ public sealed class LightManager
/// (2) frame-FLOOD scoping `c500912b` (gaze-dependent: the under-room portal /// (2) frame-FLOOD scoping `c500912b` (gaze-dependent: the under-room portal
/// purples entered/left the pool as the camera turned — the seam-floor /// purples entered/left the pool as the camera turned — the seam-floor
/// blink; probe: [seam-blk]/[seam-snap]). Current model: all registered /// blink; probe: [seam-blk]/[seam-snap]). Current model: all registered
/// (=resident) lit lights optionally FILTERED by last frame's rendered /// (=resident) lit lights, then dynamics-first nearest-player, capped here.
/// visible-cell set (A7.L1, 2026-07-09 — <see cref="BuildPointLightSnapshot"/>'s /// A later last-frame drawable-cell filter was removed after the Facility Hub
/// <c>visibleCells</c> param; fixes Town Network starvation without /// zoom trace proved it recreated the same camera-root coupling: the pool
/// reproducing c500912b — see that method's doc), then dynamics-first nearest- /// collapsed from five lights to one without the player moving. 128 is wider
/// player, capped here. 128 is wider than retail's 40+7 — a documented backstop /// than retail's 40+7 — a documented backstop
/// that in a properly cell-scoped room only ever evicts far-out-of-range /// 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 /// statics; adopting retail's exact dual-pool caps + degrade levels is A7-arc
/// work. The 1024 uncap remains refuted (striped-floor artifact + the unported /// work. The 1024 uncap remains refuted (striped-floor artifact + the unported
@ -248,39 +248,16 @@ public sealed class LightManager
/// set from <c>player-&gt;m_position</c>, SmartBox 0x00453d3a, with the /// set from <c>player-&gt;m_position</c>, SmartBox 0x00453d3a, with the
/// viewer-cell fallback 0x00455ab6). The distance SORT is therefore a function /// viewer-cell fallback 0x00455ab6). The distance SORT is therefore a function
/// of PLAYER position and light registration ONLY — camera rotation/position /// of PLAYER position and light registration ONLY — camera rotation/position
/// cannot change it (both prior camera-ANCHORED pools — nearest-camera cap; /// cannot change it. Both camera-anchored pools and last-frame drawable-cell
/// <c>c500912b</c>'s camera-seeded re-flood — produced the #176 seam-floor /// scoping produced gaze/zoom-dependent membership changes, so neither is an
/// purple blink by making the SORT itself camera-dependent). The optional /// input to this retail resident-cell collection. Call once per frame before
/// <paramref name="visibleCells"/> candidacy FILTER (A7.L1) does not change /// per-object selection.
/// 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.
/// </para> /// </para>
/// </summary> /// </summary>
/// <param name="playerWorldPos">The player's world position (render position; /// <param name="playerWorldPos">The player's world position (render position;
/// callers pass the camera position only when no player exists — retail's /// callers pass the camera position only when no player exists — retail's
/// player/viewer branch).</param> /// player/viewer branch).</param>
/// <param name="visibleCells"> public void BuildPointLightSnapshot(Vector3 playerWorldPos)
/// A7.L1 (2026-07-09) — optional visible-cell scoping. When non-null, a light
/// is a candidate only if it is cell-less (<c>CellId == 0</c> — the viewer fill,
/// always in scope) or its <c>CellId</c> 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.
/// </param>
public void BuildPointLightSnapshot(Vector3 playerWorldPos, IReadOnlySet<uint>? visibleCells = null)
{ {
_pointSnapshot.Clear(); _pointSnapshot.Clear();
_pointSelectionHeap.Clear(); _pointSelectionHeap.Clear();
@ -291,8 +268,6 @@ public sealed class LightManager
foreach (var light in _all) foreach (var light in _all)
{ {
if (!light.IsLit || light.Kind == LightKind.Directional) continue; 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( var ranked = new RankedLight(
light, light,
qualifyingOrdinal++, qualifyingOrdinal++,

View file

@ -1002,11 +1002,6 @@ public static class CellTransit
/// docs/research/2026-08-07-ap159-pseudocode.md for the derivation. /// docs/research/2026-08-07-ap159-pseudocode.md for the derivation.
/// </para> /// </para>
/// </summary> /// </summary>
/// <param name="worldParts">Per-part world-placed authored boxes — the
/// outdoor extent walk's input.</param>
/// <param name="worldPartSpheres">Per-part world-placed BSP root spheres —
/// the indoor residual's and the building bridge's input. Same parts, same
/// order, from the same <see cref="ShadowPartGeometry"/> values.</param>
public static IReadOnlyList<uint> BuildShadowCellSetFromParts( public static IReadOnlyList<uint> BuildShadowCellSetFromParts(
PhysicsDataCache cache, PhysicsDataCache cache,
uint seedCellId, uint seedCellId,
@ -1107,27 +1102,17 @@ public static class CellTransit
} }
} }
// Static prune (do_not_load_cells, 0x0052b66e) — indoor-seeded ONLY. // Do NOT apply CObjCell::find_cell_list's do_not_load_cells prune
// The outdoor rectangle is deliberately unpruned: pruning it would // (0x0052b66e) here. That code belongs to the sphere/cylsphere entry
// re-create #334 in a new form. // point at 0x0052b4e0. The part-array route used here is retail
if (isStatic && seedLow >= 0x0100u) // CPhysicsObj::find_bbox_cell_list @0x00510fc0: it walks the growing
{ // CELLARRAY through CPartArray::calc_cross_cells_static and returns
var seedCell = cache.GetCellStruct(seedCellId); // it directly. calc_cross_cells_static does set do_not_load_cells=1,
if (seedCell is not null) // but the box transit consumes that only as a no-load policy while
{ // resolving neighbouring cells; it does not delete the already-added
var keep = new List<uint>(candidates.Count); // exterior cells. Pruning here removed a static Setup's legitimate
foreach (uint id in candidates.OrderedIds) // outdoor shadow membership after an exit portal crossing (the
{ // cathedral ramp 0x020009A2: collision remained, render vanished).
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);
}
}
}
return candidates.OrderedIds; return candidates.OrderedIds;
} }

View file

@ -360,6 +360,39 @@ public sealed class ShadowObjectRegistry
private PhysicsDataCache? _fallback; private PhysicsDataCache? _fallback;
private PhysicsDataCache FloodCache => DataCache ?? _fallbackCache; private PhysicsDataCache FloodCache => DataCache ?? _fallbackCache;
/// <summary>
/// Computes retail's static PartArray render-cell membership without
/// publishing collision rows. Static visual parts and collision shapes
/// share the same <c>find_bbox_cell_list</c> portal walk, but retail keeps
/// them in separate cell lists (<c>shadow_part_list</c> versus
/// <c>shadow_object_list</c>). Decorative meshes therefore need this path
/// even when <see cref="GetOwnerCells"/> is empty.
/// </summary>
public IReadOnlyList<uint> ComputeStaticRenderCells(
uint seedCellId,
Vector3 entityWorldPosition,
Quaternion entityWorldRotation,
IReadOnlyList<ShadowShape> visualParts)
{
if (seedCellId == 0u || visualParts.Count == 0)
return Array.Empty<uint>();
List<ShadowPartBox> boxes = BuildFloodPartBoxes(
entityWorldPosition,
entityWorldRotation,
visualParts);
List<DatReaderWriter.Types.Sphere> spheres = BuildBspPartSpheres(
entityWorldPosition,
entityWorldRotation,
visualParts);
return CellTransit.BuildShadowCellSetFromParts(
FloodCache,
seedCellId,
boxes,
spheres,
isStatic: true);
}
/// <summary> /// <summary>
/// Register a single-shape entity. <paramref name="seedCellId"/> is the /// Register a single-shape entity. <paramref name="seedCellId"/> is the
/// entity's <c>m_position.objcell_id</c> — the flood seed. Pass 0 to /// entity's <c>m_position.objcell_id</c> — the flood seed. Pass 0 to

View file

@ -342,6 +342,93 @@ public static class ShadowShapeBuilder
return shapes; return shapes;
} }
/// <summary>
/// Resolves every visual part of a static object into the paired sphere +
/// vertex-box geometry used by retail's render-shadow cell registration.
/// Unlike <see cref="FromLandblockBspParts"/>, this deliberately includes
/// decorative GfxObjs with no physics BSP: <c>CEnvCell::init_static_objects</c>
/// creates a real <c>CPhysicsObj</c> for every static and
/// <c>CPartArray::AddPartsShadow</c> registers every visual part in each
/// crossed cell even when that part cannot collide.
///
/// <para>The returned values are geometry carriers only. Callers must not
/// publish them to the collision registry.</para>
/// </summary>
public static List<ShadowShape> FromStaticRenderParts(
IReadOnlyList<MeshRef> meshRefs,
Func<uint, GfxObjPhysics?> getGfxObj,
Func<uint, GfxObjVisualBounds?> getVisualBounds,
out bool hasPhysicsBsp)
{
ArgumentNullException.ThrowIfNull(meshRefs);
ArgumentNullException.ThrowIfNull(getGfxObj);
ArgumentNullException.ThrowIfNull(getVisualBounds);
hasPhysicsBsp = false;
var parts = new List<ShadowShape>(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;
}
/// <summary> /// <summary>
/// The collision identity of part <paramref name="index"/>: the installed /// The collision identity of part <paramref name="index"/>: the installed
/// <c>AnimPartChanged</c> replacement when one was supplied, else the /// <c>AnimPartChanged</c> replacement when one was supplied, else the

View file

@ -99,6 +99,106 @@ public static class RenderingDiagnostics
public static bool ProbeVisibilityEnabled { get; set; } = public static bool ProbeVisibilityEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_VIS") == "1"; Environment.GetEnvironmentVariable("ACDREAM_PROBE_VIS") == "1";
/// <summary>
/// Temporary Facility Hub staircase discriminator. The frame walk emits
/// camera/root/flood facts and the leaf classifier emits change-only
/// decisions for the authored stair GfxObj (0x010000DE) and local-player
/// setup parts in cells 0x8A02015E/015F/01C1. Output-only; it must never
/// influence admission. Initial state from
/// <c>ACDREAM_PROBE_FACILITY_STAIRS=1</c>.
/// </summary>
public static bool ProbeFacilityStairsEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_FACILITY_STAIRS") == "1";
/// <summary>
/// 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.
/// </summary>
public static bool ProbeCathedralSkipStairBuildingPunch { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_CATHEDRAL_SKIP_PUNCH") == "1";
/// <summary>
/// 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.
/// </summary>
public static bool ProbeCathedralSkipFloatingStairSeals { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_CATHEDRAL_SKIP_SEALS") == "1";
/// <summary>
/// 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.
/// </summary>
public static bool ProbeCathedralSkipFloatingStairCellShells { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_CATHEDRAL_SKIP_CELL_SHELLS") == "1";
/// <summary>
/// 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.
/// </summary>
public static bool ProbeCathedralSkipSouthLookInCellShells { get; set; } =
Environment.GetEnvironmentVariable(
"ACDREAM_PROBE_CATHEDRAL_SKIP_SOUTH_LOOKIN_SHELLS") == "1";
/// <summary>
/// Narrow form of <see cref="ProbeCathedralSkipSouthLookInCellShells"/>:
/// suppresses one exact EnvCell shell only during a building look-in.
/// Accepts a full hexadecimal cell id, with or without a <c>0x</c>
/// prefix, through <c>ACDREAM_PROBE_CATHEDRAL_SKIP_LOOKIN_SHELL</c>.
/// Zero means disabled. Behavior-changing diagnostic only.
/// </summary>
public static uint ProbeCathedralSkipLookInShellCellId { get; set; } =
ParseHexCellId(
Environment.GetEnvironmentVariable(
"ACDREAM_PROBE_CATHEDRAL_SKIP_LOOKIN_SHELL"));
/// <summary>
/// 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.
/// </summary>
public static bool ProbeCathedralSkipSouthBuildingShell { get; set; } =
Environment.GetEnvironmentVariable(
"ACDREAM_PROBE_CATHEDRAL_SKIP_SOUTH_BUILDING_SHELL") == "1";
/// <summary>
/// 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
/// <c>ACDREAM_PROBE_CATHEDRAL_SHELL_ORDER=1</c>.
/// </summary>
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<char> 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;
}
/// <summary> /// <summary>
/// Phase U.4c (2026-05-31) flap-convergence probe. When true, the portal /// 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 /// 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 /// collection capped nearest-the-PLAYER — the earlier gaze-coupled scoping
/// (rebuilding the pool from a freshly re-flooded CAMERA-seeded set, /// (rebuilding the pool from a freshly re-flooded CAMERA-seeded set,
/// <c>c500912b</c>) was the #176 flicker mechanism and was deleted. /// <c>c500912b</c>) was the #176 flicker mechanism and was deleted.
/// A7.L1 (2026-07-09): visible-cell scoping is BACK, but sourced differently — /// A7.L1 later added last-frame drawable-cell scoping, but the Facility Hub
/// <c>LightManager.BuildPointLightSnapshot</c> now takes an optional /// zoom trace proved that it also couples pool membership to the camera root
/// <c>visibleCells</c> filter that <c>GameWindow</c> feeds from LAST FRAME's /// (five lights became one while the player stood still), so it was removed.
/// already-rendered <c>RetailPViewFrameResult.DrawableCells</c> (one frame of /// <c>byCell</c> now describes the resident pool; <c>cellLess==pool</c> in a
/// latency, no independent re-flood, no callback threaded into DrawInside) — /// fixture-rich room still
/// 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. <c>byCell</c> shows which
/// cells' lights are pooled; <c>cellLess==pool</c> in a fixture-rich room still
/// means cell tagging FAILED (ParentCellId not flowing). /// means cell tagging FAILED (ParentCellId not flowing).
/// Output-only, inert when off. Initial state from <c>ACDREAM_PROBE_INDOOR_LIGHT=1</c>. /// Output-only, inert when off. Initial state from <c>ACDREAM_PROBE_INDOOR_LIGHT=1</c>.
/// </summary> /// </summary>
@ -403,14 +499,14 @@ public static class RenderingDiagnostics
/// point-light pool: the SET COMPOSITION the <c>[light]</c> counts can't show. /// point-light pool: the SET COMPOSITION the <c>[light]</c> counts can't show.
/// Cheap no-op when <see cref="ProbeIndoorLightEnabled"/> is false; otherwise /// Cheap no-op when <see cref="ProbeIndoorLightEnabled"/> is false; otherwise
/// fires at most once per second. Called from /// fires at most once per second. Called from
/// <c>LightManager.BuildPointLightSnapshot</c> — <paramref name="scopedSnapshot"/> /// <c>LightManager.BuildPointLightSnapshot</c> after resident collection and
/// already reflects any last-frame visible-cell scoping (A7.L1, 2026-07-09). /// the bounded player-nearest selection.
/// </summary> /// </summary>
/// <param name="allRegistered">Every registered light (<c>LightManager._all</c>).</param> /// <param name="allRegistered">Every registered light (<c>LightManager._all</c>).</param>
/// <param name="scopedSnapshot">The point-light pool just built.</param> /// <param name="pointSnapshot">The point-light pool just built.</param>
public static void EmitIndoorLight( public static void EmitIndoorLight(
IReadOnlyList<AcDream.Core.Lighting.LightSource> allRegistered, IReadOnlyList<AcDream.Core.Lighting.LightSource> allRegistered,
IReadOnlyList<AcDream.Core.Lighting.LightSource> scopedSnapshot) IReadOnlyList<AcDream.Core.Lighting.LightSource> pointSnapshot)
{ {
if (!ProbeIndoorLightEnabled) return; if (!ProbeIndoorLightEnabled) return;
@ -423,10 +519,10 @@ public static class RenderingDiagnostics
foreach (var l in allRegistered) foreach (var l in allRegistered)
if (l.IsLit && l.Kind != AcDream.Core.Lighting.LightKind.Directional) registeredLitPoints++; if (l.IsLit && l.Kind != AcDream.Core.Lighting.LightKind.Directional) registeredLitPoints++;
int pool = scopedSnapshot.Count; int pool = pointSnapshot.Count;
int cellLess = 0; int cellLess = 0;
var hist = new Dictionary<uint, int>(); var hist = new Dictionary<uint, int>();
foreach (var l in scopedSnapshot) foreach (var l in pointSnapshot)
{ {
if (l.CellId == 0) cellLess++; if (l.CellId == 0) cellLess++;
hist.TryGetValue(l.CellId, out var c); hist.TryGetValue(l.CellId, out var c);

View file

@ -49,6 +49,10 @@ public static class ContentMigrationCatalog
5, 5,
6, 6,
"the optimized pak v2 texture catalog and compression format require one full rebuild"), "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) public static ContentMigrationPlan Resolve(uint fromRecipeVersion, uint targetRecipeVersion)

View file

@ -35,9 +35,10 @@ public sealed record InstallRecordVerification(
public sealed class LauncherInstallRecordStore public sealed class LauncherInstallRecordStore
{ {
// Kept in lockstep with AcDream.Content.Pak.PakFormat.CurrentBakeToolVersion // Kept in lockstep with AcDream.Content.Pak.PakFormat.CurrentBakeToolVersion
// Version 6 is pak format 2: global texture payload deduplication plus // Version 7 keeps pak format 2 and regenerates every GfxObj render record
// adaptive independent-blob compression. It is a mandatory full rebuild. // with retail's authored DrawingBSP view sphere. It is a mandatory full
public const uint CurrentBakeToolVersion = 6; // rebuild from recipe 6.
public const uint CurrentBakeToolVersion = 7;
private static readonly JsonSerializerOptions SerializerOptions = new() private static readonly JsonSerializerOptions SerializerOptions = new()
{ {

View file

@ -111,6 +111,25 @@ public class ClipFrameLayoutTests
Assert.Equal(0u, ReadUInt(bytes, 0)); 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<Vector4> 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] [Fact]
public void AppendSlot_EmptyPlaneList_PacksNoClipSlot_Count0() public void AppendSlot_EmptyPlaneList_PacksNoClipSlot_Count0()
{ {

View file

@ -1,68 +0,0 @@
using System;
using AcDream.App.Rendering;
using Xunit;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// #129 — doors/doorways leak through terrain and houses from over a landblock
/// away. The punch's mark pass (#117, AD-18) biased the aperture fan toward
/// the viewer by a CONSTANT 0.0005 NDC. NDC depth is non-linear: a constant
/// NDC bias b spans ≈ b·d²·(fn)/(f·n) meters of eye depth at eye distance d
/// — 0.125 m at 5 m but ~190 m at a landblock (znear 0.1), so distant
/// occluders in front of an aperture passed the mark and were far-Z punched:
/// the door-shaped leak. The fix caps the bias's eye-space span
/// (PortalDepthMaskRenderer.MarkBiasNdc): identical to the validated constant
/// below the ~10 m crossover, never more than the cap beyond it.
/// </summary>
public class Issue129PunchBiasTests
{
private const float Near = PortalDepthMaskRenderer.CameraNearPlaneMeters; // 0.1 (retail znear)
private const float Far = 5000f;
/// <summary>Eye-depth span (meters) covered by an NDC depth bias b at eye
/// distance d: ndc(d) = f(dn)/((fn)d) ⇒ d(ndc) inverse ⇒
/// span = b·d²·(fn)/(f·n) (exact for small b via the derivative).</summary>
private static float EyeSpanMeters(float biasNdc, float d) =>
biasNdc * d * d * (Far - Near) / (Far * Near);
[Fact]
public void OldConstantBias_SpansMetersAtALandblock_TheLeak()
{
// The refuted form (documentation of WHY the constant was wrong):
// 0.0005 NDC at ~one landblock spans far more eye depth than any
// occluder separation — everything in front got punched.
Assert.True(EyeSpanMeters(0.0005f, 192f) > 100f);
// ...while at close range it was a sane sliver:
Assert.InRange(EyeSpanMeters(0.0005f, 5f), 0.05f, 0.30f);
}
[Fact]
public void CappedBias_MatchesValidatedConstant_AtCloseRange()
{
// Below the crossover the T5-validated constant must win unchanged —
// this preserves the #108 grass coverage bit-for-bit.
foreach (float d in new[] { 0.5f, 1f, 3f, 5f, 8f, 9.9f })
Assert.Equal(0.0005f, PortalDepthMaskRenderer.MarkBiasNdc(d), 6);
}
[Fact]
public void CappedBias_EyeSpanNeverExceedsCap_AtAnyDistance()
{
for (float d = 1f; d <= 400f; d += 1f)
{
float span = EyeSpanMeters(PortalDepthMaskRenderer.MarkBiasNdc(d), d);
Assert.True(span <= PortalDepthMaskRenderer.PunchMarkBiasEyeCapMeters * 1.02f,
FormattableString.Invariant($"bias spans {span:F2} m of eye depth at d={d} m"));
}
}
[Fact]
public void CappedBias_At200m_CannotReachOccluders()
{
// The reported #129 distance: occluder separations are tens of
// meters; the punch reach must stay under the 0.5 m cap.
float span = EyeSpanMeters(PortalDepthMaskRenderer.MarkBiasNdc(200f), 200f);
Assert.True(span <= 0.51f, FormattableString.Invariant($"span {span:F3} m at 200 m"));
}
}

View file

@ -4,12 +4,19 @@ using System.IO;
using System.Linq; using System.Linq;
using System.Numerics; using System.Numerics;
using AcDream.App.Rendering; 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.Physics;
using AcDream.Core.Rendering; using AcDream.Core.Rendering;
using AcDream.Core.World;
using AcDream.Core.Meshing;
using DatReaderWriter; using DatReaderWriter;
using DatReaderWriter.Options; using DatReaderWriter.Options;
using DatEnvCell = DatReaderWriter.DBObjs.EnvCell; using DatEnvCell = DatReaderWriter.DBObjs.EnvCell;
using DatEnvironment = DatReaderWriter.DBObjs.Environment; using DatEnvironment = DatReaderWriter.DBObjs.Environment;
using DatGfxObj = DatReaderWriter.DBObjs.GfxObj;
using DatSetup = DatReaderWriter.DBObjs.Setup;
using Xunit; using Xunit;
using Xunit.Abstractions; 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."); } 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); 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; uint id = FacilityHub | low;
var envCell = dats.Get<DatEnvCell>(id); var envCell = dats.Get<DatEnvCell>(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<DatSetup>(dats.Get<DatSetup>(setupId));
PhysicsEngine engine = BuildHubEngine(dats);
PhysicsDataCache cache = Assert.IsType<PhysicsDataCache>(engine.DataCache);
IReadOnlyList<MeshRef> meshRefs = SetupMesh.Flatten(setup);
foreach (MeshRef meshRef in meshRefs)
{
DatGfxObj gfxObj = Assert.IsType<DatGfxObj>(
dats.Get<DatGfxObj>(meshRef.GfxObjId));
cache.CacheGfxObj(meshRef.GfxObjId, gfxObj);
}
DatEnvCell parent = Assert.IsType<DatEnvCell>(
dats.Get<DatEnvCell>(FacilityHub | 0x015Fu));
var stair = Assert.Single(
parent.StaticObjects,
staticObject =>
staticObject.Id == setupId);
var rootPosition = new Vector3(
stair.Frame.Origin.X,
stair.Frame.Origin.Y,
stair.Frame.Origin.Z);
Quaternion rootRotation = stair.Frame.Orientation;
List<ShadowShape> 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<uint> 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);
}
/// <summary> /// <summary>
/// #177 re-diagnosis (fix#1 failed the visual gate): the vanish flips on a SLIGHT turn + /// #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 /// depends on zoom, so it is a gaze/eye knife-edge, not the eye-squarely-in-opening case

View file

@ -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<Vector4>.Empty,
forceFarZ: true);
renderer.DrawDepthFan(
triangle,
Matrix4x4.Identity,
ReadOnlySpan<Vector4>.Empty,
forceFarZ: false);
}
frames.EndFrame();
Assert.Equal(
2,
device.Calls.OfType<GpuRecordedPipelineBind>()
.Count(call => call.PipelineName == "portal-depth-write"));
Assert.Equal(2, device.Calls.OfType<GpuRecordedDraw>().Count());
Assert.Collection(
device.Calls.OfType<GpuRecordedPushConstants>(),
punch => Assert.Equal(1, punch.Constants.RenderPass),
seal => Assert.Equal(0, seal.Constants.RenderPass));
Assert.Empty(device.Calls.OfType<GpuRecordedStencil>());
}
}

View file

@ -45,16 +45,16 @@ public sealed class WalkFrameDriverTests
RetailAlphaQueue? alpha = null) : IWalkFrameLeafRenderer RetailAlphaQueue? alpha = null) : IWalkFrameLeafRenderer
{ {
public readonly List<WalkPolygon> Punches = new(); public readonly List<WalkPolygon> Punches = new();
public readonly List<(uint CellId, uint ClipSlot)> Shells = new(); public readonly List<uint> Shells = new();
public readonly List<int> AlphaPendingAtBarrier = new(); public readonly List<int> AlphaPendingAtBarrier = new();
public void DrawSky() => log.Add("SKY"); public void DrawSky() => log.Add("SKY");
public void DrawTerrainSlice(int sliceIndex) => log.Add($"TERRAIN:{sliceIndex}"); 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}"); log.Add($"SHELL:{cellId:x8}");
} }
@ -267,7 +267,9 @@ public sealed class WalkFrameDriverTests
var leaf = new RecordingLeafRenderer(log); var leaf = new RecordingLeafRenderer(log);
var trace = new RecordingTrace(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(); var walk = new RetailFrameWalk();
// A minimal, no-op landscape (1x1 window, the one slot unpublished) // A minimal, no-op landscape (1x1 window, the one slot unpublished)
// — matches RetailFrameWalkTests' own exit-view fixture. LScape::draw // — 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)); Assert.All(mdiCalls, c => Assert.Equal(1u, c.DrawCount));
// Nothing dropped: every populated record reached exactly one indirect draw. // Nothing dropped: every populated record reached exactly one indirect draw.
Assert.Equal(2, mdiCalls.Sum(c => (int)c.DrawCount)); Assert.Equal(2, mdiCalls.Sum(c => (int)c.DrawCount));
// Exit seals replay through the exact portal_view slices captured by
// 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<string>();
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<GpuRecordedMultiDrawIndirect> mdiCalls =
[.. fx.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>()];
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 // ── 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.Equal([0x104u], driver.LookInCells);
Assert.Collection( Assert.Collection(
leaf.Shells, leaf.Shells,
shell => shell => Assert.Equal(0x104u, shell));
{
Assert.Equal(0x104u, shell.CellId);
Assert.NotEqual(0u, shell.ClipSlot);
});
WalkPortalView capturedView = ctx.Cells[0x104].PortalViews[0]; WalkPortalView capturedView = ctx.Cells[0x104].PortalViews[0];
WalkViewPoly capturedPoly = Assert.Single(capturedView.View.Polys); WalkViewPoly capturedPoly = Assert.Single(capturedView.View.Polys);
Vector2 capturedCenter = Vector2.Zero; Vector2 capturedCenter = Vector2.Zero;
@ -515,6 +586,101 @@ public sealed class WalkFrameDriverTests
Assert.Equal(3, mdiCalls.Sum(c => (int)c.DrawCount)); Assert.Equal(3, mdiCalls.Sum(c => (int)c.DrawCount));
} }
[Fact]
public void RepeatedFloodTurns_DrawEnvCellShellWholeOncePerRetailFrameStamp()
{
using var fx = new DispatcherFixture();
var log = new List<string>();
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<string>();
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 // ── Fail-loud: a DrawCells turn with no preceding DrawInside/Building
// turn is a walk/driver desync, not a silent skip. ───────────────────── // turn is a walk/driver desync, not a silent skip. ─────────────────────

View file

@ -236,7 +236,7 @@ public sealed class WalkProductionWorldConformanceTests
[Theory] [Theory]
[InlineData(0xF4180100u, 36.166267f, 79.828407f)] [InlineData(0xF4180100u, 36.166267f, 79.828407f)]
[InlineData(0xF4180101u, 36.391270f, 72.167931f)] [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, uint cameraCellId,
float x, float x,
float y) float y)
@ -244,11 +244,12 @@ public sealed class WalkProductionWorldConformanceTests
// Owner's exact 2026-08-31 repro: the remote player and special NPC // Owner's exact 2026-08-31 repro: the remote player and special NPC
// are parented in 0xF4180112, behind opaque cathedral walls. Retail // are parented in 0xF4180112, behind opaque cathedral walls. Retail
// hides them on BOTH sides of the 0x100 <-> 0x101 transition. The walk // hides them on BOTH sides of the 0x100 <-> 0x101 transition. The walk
// legitimately reaches 0x112 through one authored building aperture; // legitimately reaches 0x112 through one authored building aperture.
// the regression was submitting each admitted mesh with slot 0, so the // Retail uses that cone only for coarse sphere admission, then draws
// whole player/NPC escaped that aperture. Preserve the installed-DAT // each accepted shell/object whole; the complete intervening wall
// fact this fix depends on: every admitted route is a real, bounded // shell hides the remote actors through ordinary depth. Preserve the
// portal polygon, never a pass-all zero-plane route. // 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( var pose = new WalkOraclePose(
cameraCellId, cameraCellId,
new Vector3(x, y, 169.804993f), new Vector3(x, y, 169.804993f),

View file

@ -6,6 +6,77 @@ namespace AcDream.App.Tests.Rendering.Walk;
public sealed class WalkProductionWorldDataTests 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] [Fact]
public void BucketOutdoorRecord_UsesEveryOutdoorPhysicsShadowCellAcrossLandblockEdge() public void BucketOutdoorRecord_UsesEveryOutdoorPhysicsShadowCellAcrossLandblockEdge()
{ {
@ -44,6 +115,160 @@ public sealed class WalkProductionWorldDataTests
Assert.Equal(record, Assert.Single(buckets[0xF07F0002u])); 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<uint, List<RenderProjectionRecord>>();
var outdoor = new Dictionary<uint, List<RenderProjectionRecord>>();
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<uint, List<RenderProjectionRecord>>();
var outdoor = new Dictionary<uint, List<RenderProjectionRecord>>();
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<uint, List<RenderProjectionRecord>>();
var outdoor = new Dictionary<uint, List<RenderProjectionRecord>>();
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<uint, List<RenderProjectionRecord>>();
var outdoor = new Dictionary<uint, List<RenderProjectionRecord>>();
WalkProductionWorldData.BucketDynamicRecord(
in record,
Array.Empty<uint>(),
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<uint, IReadOnlyList<uint>>
{
[0x5000000Au] = [0xF4180106u, 0xF4180104u],
};
var parents = new Dictionary<uint, uint>
{
[0x800045EEu] = 0x80000461u,
[0x80000461u] = 0x5000000Au,
};
IReadOnlyList<uint> cells =
WalkProductionWorldData.ResolveDynamicRenderCells(
in wand,
id => owners.TryGetValue(id, out IReadOnlyList<uint>? value)
? value
: Array.Empty<uint>(),
id => parents.TryGetValue(id, out uint value)
? value
: null);
Assert.Equal([0xF4180106u, 0xF4180104u], cells);
}
private static RenderProjectionRecord Record(uint id, Vector3 position) => private static RenderProjectionRecord Record(uint id, Vector3 position) =>
new RenderProjectionRecord() with new RenderProjectionRecord() with
{ {

View file

@ -69,7 +69,9 @@ public sealed class WalkStaticStreamPopulatorTests
Vector3 position, Vector3 position,
IReadOnlyList<MeshRef> meshRefs, IReadOnlyList<MeshRef> meshRefs,
bool isBuildingShell = false, bool isBuildingShell = false,
uint parentCellId = 0u) => uint parentCellId = 0u,
RenderCasterIdentityKind casterIdentity =
RenderCasterIdentityKind.Unclassified) =>
new( new(
Id: RenderProjectionId.FromRaw(localEntityId), Id: RenderProjectionId.FromRaw(localEntityId),
ProjectionClass: RenderProjectionClass.OutdoorStatic, ProjectionClass: RenderProjectionClass.OutdoorStatic,
@ -97,7 +99,8 @@ public sealed class WalkStaticStreamPopulatorTests
EntityPayload: new RenderEntityPayload( EntityPayload: new RenderEntityPayload(
MeshRefs: meshRefs, MeshRefs: meshRefs,
PaletteOverride: null, PaletteOverride: null,
IsBuildingShell: isBuildingShell)); IsBuildingShell: isBuildingShell,
CasterIdentity: casterIdentity));
private static ObjectRenderBatch MakeBatch( private static ObjectRenderBatch MakeBatch(
uint surfaceId, uint surfaceId,
@ -274,7 +277,161 @@ public sealed class WalkStaticStreamPopulatorTests
} }
[Fact] [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<WbDrawDispatcher.WalkClassifiedBatch>();
var selectionParts = new List<WbDrawDispatcher.WalkClassifiedSelectionPart>();
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<WbDrawDispatcher.WalkClassifiedBatch>();
var selectionParts = new List<WbDrawDispatcher.WalkClassifiedSelectionPart>();
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<WbDrawDispatcher.WalkClassifiedBatch>();
var selectionParts = new List<WbDrawDispatcher.WalkClassifiedSelectionPart>();
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(); using var fx = new DispatcherFixture();
const ulong gfxObj = 0x0100_0013UL; const ulong gfxObj = 0x0100_0013UL;
@ -298,7 +455,8 @@ public sealed class WalkStaticStreamPopulatorTests
lookInRouteIndex: 0, lookInRouteIndex: 0,
lookInCellId: 0x8C040112u); 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); Assert.Single(selectionParts);
} }

View file

@ -254,7 +254,7 @@ public sealed class WorldRenderFrameBuilderTests
} }
[Fact] [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 visibleCell = 0x01010100u;
const uint hiddenCell = 0x01010101u; const uint hiddenCell = 0x01010101u;
@ -291,7 +291,7 @@ public sealed class WorldRenderFrameBuilderTests
environment.Prepare(in camera, in roots, in foundation, activeDayGroup: null); environment.Prepare(in camera, in roots, in foundation, activeDayGroup: null);
Assert.Contains(visibleLight, lighting.PointSnapshot); Assert.Contains(visibleLight, lighting.PointSnapshot);
Assert.DoesNotContain(hiddenLight, lighting.PointSnapshot); Assert.Contains(hiddenLight, lighting.PointSnapshot);
environment.ClearDrawableCells(); environment.ClearDrawableCells();
environment.Prepare(in camera, in roots, in foundation, activeDayGroup: null); environment.Prepare(in camera, in roots, in foundation, activeDayGroup: null);

View file

@ -1,12 +1,46 @@
using AcDream.Content.Pak; using AcDream.Content.Pak;
using DatReaderWriter; using DatReaderWriter;
using DatReaderWriter.Options; using DatReaderWriter.Options;
using GfxObj = DatReaderWriter.DBObjs.GfxObj;
namespace AcDream.Content.Tests; namespace AcDream.Content.Tests;
[Trait("Lane", "PreparedPackage")] [Trait("Lane", "PreparedPackage")]
public sealed class InstalledPreparedCollisionCatalogTests 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<GfxObj>(dats.Get<GfxObj>(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] [Fact]
public void InstalledPackage_ContainsAndReadsCanonicalCollisionKeys() public void InstalledPackage_ContainsAndReadsCanonicalCollisionKeys()
{ {

View file

@ -35,6 +35,47 @@ public sealed class MeshExtractorSolidFaceExtractionTests
private const uint SurfaceTextureId = 0x05000001u; private const uint SurfaceTextureId = 0x05000001u;
private const uint RenderSurfaceId = 0x06000001u; 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);
}
/// <summary> /// <summary>
/// One quad polygon, NoPos + Base1Solid: the exact shape of the windmill /// 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 /// axle's own polygons. Must now extract to 4 vertices / 6 indices in a

View file

@ -276,8 +276,7 @@ public sealed class LightManagerTests
LightSource[] expected = FullSortOracle( LightSource[] expected = FullSortOracle(
registered, registered,
Vector3.Zero, Vector3.Zero);
visibleCells: null);
manager.BuildPointLightSnapshot(Vector3.Zero); manager.BuildPointLightSnapshot(Vector3.Zero);
Assert.Equal(expected, manager.PointSnapshot); Assert.Equal(expected, manager.PointSnapshot);
@ -324,23 +323,15 @@ public sealed class LightManagerTests
manager.Register(light); manager.Register(light);
} }
IReadOnlySet<uint>? visibleCells = scenario % 2 == 0
? new HashSet<uint>
{
0xAAAA0101u,
0xAAAA0103u,
}
: null;
Vector3 player = new( Vector3 player = new(
random.Next(-4, 5), random.Next(-4, 5),
random.Next(-4, 5), random.Next(-4, 5),
random.Next(-2, 3)); random.Next(-2, 3));
LightSource[] expected = FullSortOracle( LightSource[] expected = FullSortOracle(
registered, registered,
player, player);
visibleCells);
manager.BuildPointLightSnapshot(player, visibleCells); manager.BuildPointLightSnapshot(player);
Assert.Equal(expected, manager.PointSnapshot); Assert.Equal(expected, manager.PointSnapshot);
} }
@ -369,15 +360,12 @@ public sealed class LightManagerTests
registered.Add(light); registered.Add(light);
manager.Register(light); manager.Register(light);
} }
IReadOnlySet<uint> visibleCells =
new HashSet<uint> { fountainRoom, corridor };
Vector3 player = new(4.25f, -1.5f, 0.7f); Vector3 player = new(4.25f, -1.5f, 0.7f);
LightSource[] expected = FullSortOracle( LightSource[] expected = FullSortOracle(
registered, registered,
player, player);
visibleCells);
manager.BuildPointLightSnapshot(player, visibleCells); manager.BuildPointLightSnapshot(player);
Assert.Equal(LightManager.MaxGlobalLights, manager.PointSnapshot.Count); Assert.Equal(LightManager.MaxGlobalLights, manager.PointSnapshot.Count);
Assert.Equal(expected, manager.PointSnapshot); Assert.Equal(expected, manager.PointSnapshot);
@ -415,71 +403,11 @@ public sealed class LightManagerTests
Assert.Equal(0, allocated); 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] [Fact]
public void BuildPointLightSnapshot_VisibleCellScoping_RoomLightsSurviveOverEuclideanCloserInvisibleCell() public void BuildPointLightSnapshot_UsesAllResidentLights()
{ {
var mgr = new LightManager(); // Retail walks the resident EnvCell registry. A camera-root transition
// cannot make either resident cell stop contributing candidates.
// 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<uint> { 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<uint> { 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.
var mgr = new LightManager(); var mgr = new LightManager();
mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f, cellId: 0xAAAAu)); mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f, cellId: 0xAAAAu));
mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, cellId: 0xBBBBu)); mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, cellId: 0xBBBBu));
@ -685,8 +613,7 @@ public sealed class LightManagerTests
private static LightSource[] FullSortOracle( private static LightSource[] FullSortOracle(
IReadOnlyList<LightSource> registered, IReadOnlyList<LightSource> registered,
Vector3 player, Vector3 player)
IReadOnlySet<uint>? visibleCells)
{ {
var ranked = new List<OracleRank>(); var ranked = new List<OracleRank>();
for (int index = 0; index < registered.Count; index++) for (int index = 0; index < registered.Count; index++)
@ -694,13 +621,6 @@ public sealed class LightManagerTests
LightSource light = registered[index]; LightSource light = registered[index];
if (!light.IsLit || light.Kind == LightKind.Directional) if (!light.IsLit || light.Kind == LightKind.Directional)
continue; continue;
if (visibleCells is not null
&& light.CellId != 0
&& !visibleCells.Contains(light.CellId))
{
continue;
}
ranked.Add(new OracleRank( ranked.Add(new OracleRank(
light, light,
Vector3.DistanceSquared(light.WorldPosition, player))); Vector3.DistanceSquared(light.WorldPosition, player)));

View file

@ -21,7 +21,9 @@ namespace AcDream.Core.Tests.Physics;
public sealed class CellTransitFindTransitCellsBoxTests public sealed class CellTransitFindTransitCellsBoxTests
{ {
private static CellPhysics MakeCellWithPortalAtRightWall( 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 // Portal poly at local x=2.5 (right wall), normal +X. Same shape as
// CellTransitFindTransitCellsSphereTests' fixture, so the sphere-only // CellTransitFindTransitCellsSphereTests' fixture, so the sphere-only
@ -142,6 +144,47 @@ public sealed class CellTransitFindTransitCellsBoxTests
Assert.DoesNotContain(0xA9B40101u, endToEnd); 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<uint> 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 ────── // ── D3.2: inverse guard — a box that DOES cross admits, unchanged ──────
/// <summary> /// <summary>

View file

@ -5,25 +5,26 @@ namespace AcDream.Launcher.Core.Tests.Installation;
public sealed class ContentMigrationCatalogTests public sealed class ContentMigrationCatalogTests
{ {
[Fact] [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(ContentWorkKind.FullRebuild, plan.Kind);
Assert.Equal(5u, plan.FromRecipeVersion); Assert.Equal(6u, plan.FromRecipeVersion);
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);
Assert.Empty(plan.EffectiveDatIds); Assert.Empty(plan.EffectiveDatIds);
Assert.Empty(plan.EffectiveLandblocks); Assert.Empty(plan.EffectiveLandblocks);
} }
[Fact] [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(ContentWorkKind.FullRebuild, plan.Kind);
Assert.Equal(6u, plan.TargetRecipeVersion); Assert.Equal(7u, plan.TargetRecipeVersion);
Assert.Contains("pak v2", plan.Reason, StringComparison.OrdinalIgnoreCase); Assert.Contains("pak v2", plan.Reason, StringComparison.OrdinalIgnoreCase);
Assert.Contains("DrawingBSP", plan.Reason, StringComparison.OrdinalIgnoreCase);
} }
} }

View file

@ -43,6 +43,16 @@ else if (args.Length > 0 && string.Equals(args[0], "gfxobj", StringComparison.Or
foreach (var gfxObjId in ids) foreach (var gfxObjId in ids)
DumpGfxObj(dats, gfxObjId); 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 else
{ {
var ids = args.Length == 0 var ids = args.Length == 0
@ -57,6 +67,76 @@ else
return 0; 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<GfxObj>().OrderBy(id => id))
{
GfxObj? gfxObj = dats.Get<GfxObj>(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) static int AuditCellWindingCatalog(DatCollection dats)
{ {
long environments = 0; long environments = 0;
@ -213,7 +293,22 @@ static void DumpCell(DatCollection dats, uint envCellId)
Console.WriteLine( Console.WriteLine(
$"environment=0x{envId:X8} cellStruct={envCell.CellStructure} " + $"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 posSides = 0;
int negSides = 0; int negSides = 0;
@ -240,6 +335,22 @@ static void DumpCell(DatCollection dats, uint envCellId)
var normal = ComputeNormal(cellStruct, poly); var normal = ComputeNormal(cellStruct, poly);
var uvRange = UvRange(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 string planeHint = Math.Abs(normal.Z) > 0.9f
? normal.Z > 0 ? "floor/up" : "ceiling/down" ? normal.Z > 0 ? "floor/up" : "ceiling/down"
: Math.Abs(normal.Z) > 0.15f ? "slope" : "wall"; : 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} " + $"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} " + $"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} " + $"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( Console.WriteLine(
@ -568,6 +681,46 @@ static void DumpGfxObj(DatCollection dats, uint gfxObjId)
Console.WriteLine( Console.WriteLine(
$"bbox min=({min.X:F2},{min.Y:F2},{min.Z:F2}) max=({max.X:F2},{max.Y:F2},{max.Z:F2}) " + $"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})"); $"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<GfxObjDegradeInfo>(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( Console.WriteLine(
$"classify: walls={walls} (outwardFacing={outwardWalls} inwardFacing={inwardWalls}) " + $"classify: walls={walls} (outwardFacing={outwardWalls} inwardFacing={inwardWalls}) " +
$"floors={floors} ceilings={ceilings} slopes={slopes}"); $"floors={floors} ceilings={ceilings} slopes={slopes}");
@ -595,6 +748,76 @@ static void DumpGfxObj(DatCollection dats, uint gfxObjId)
Console.WriteLine(); 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<Setup>(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<GfxObj>(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) static Vector3 ComputeNormalG(GfxObj g, DatReaderWriter.Types.Polygon poly)
{ {
if (poly.VertexIds.Count < 3) return Vector3.Zero; if (poly.VertexIds.Count < 3) return Vector3.Zero;