checkpoint(render): preserve pre-overhaul investigation state
This commit is contained in:
parent
e880860291
commit
b3b7d922f1
45 changed files with 3168 additions and 619 deletions
|
|
@ -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 1–3; bake-tool 4
|
the package remained format 1 and retained mesh type values 1–3; bake-tool 4
|
||||||
appended typed GfxObj,
|
appended typed GfxObj,
|
||||||
|
|
|
||||||
|
|
@ -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` |
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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(
|
|
||||||
encoder,
|
|
||||||
_sealPipeline!,
|
|
||||||
clip,
|
|
||||||
vertices,
|
|
||||||
vertexCount,
|
|
||||||
in viewProjection,
|
|
||||||
renderPass: 0,
|
|
||||||
depthBias: 0f);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
RecordPortalPass(
|
RecordPortalPass(
|
||||||
encoder,
|
encoder,
|
||||||
_punchMarkPipeline!,
|
_depthWritePipeline!,
|
||||||
clip,
|
clip,
|
||||||
vertices,
|
vertices,
|
||||||
vertexCount,
|
vertexCount,
|
||||||
in viewProjection,
|
in viewProjection,
|
||||||
renderPass: 0,
|
renderPass: forceFarZ ? 1 : 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)
|
||||||
|
|
|
||||||
|
|
@ -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/(f−n) 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,
|
||||||
|
|
|
||||||
|
|
@ -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,26 +243,32 @@ 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)
|
||||||
{
|
{
|
||||||
_singleCellScratch.Clear();
|
return;
|
||||||
_singleCellScratch.Add(cellId);
|
|
||||||
_passes.DrawOpaqueCellShells(_singleCellScratch);
|
|
||||||
if (_passes.CellHasTransparentShell(cellId))
|
|
||||||
{
|
|
||||||
_singleCellListScratch.Clear();
|
|
||||||
_singleCellListScratch.Add(cellId);
|
|
||||||
_passes.DrawTransparentCellShellsOrdered(_singleCellListScratch);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
finally
|
|
||||||
|
// Retail DrawEnvCell @0x0059F170 submits the prepared EnvCell mesh
|
||||||
|
// whole after stamping it drawn-this-frame. obj_view_set prepares
|
||||||
|
// legacy polygon/object tests, but the use_built_mesh branch calls
|
||||||
|
// D3DPolyRender::DrawMesh directly; it does not slice the shell mesh
|
||||||
|
// once per portal polygon.
|
||||||
|
_singleCellScratch.Clear();
|
||||||
|
_singleCellScratch.Add(cellId);
|
||||||
|
_passes.DrawOpaqueCellShells(_singleCellScratch);
|
||||||
|
if (_passes.CellHasTransparentShell(cellId))
|
||||||
{
|
{
|
||||||
_passes.SetCellShellClipRouting(null);
|
_singleCellListScratch.Clear();
|
||||||
|
_singleCellListScratch.Add(cellId);
|
||||||
|
_passes.DrawTransparentCellShellsOrdered(_singleCellListScratch);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
driver.InteriorFloodViewClipPlanesAt(i, sliceIndex));
|
||||||
slice,
|
}
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -295,7 +295,7 @@
|
||||||
"stages": [
|
"stages": [
|
||||||
{
|
{
|
||||||
"stage": "vert",
|
"stage": "vert",
|
||||||
"sourceSha256": "6214fc04936d92320594acb722e63f1cd1f7106dd4d0af60ed985abc2a50c4a9",
|
"sourceSha256": "1df7e2009cda8f84ba3d84546bf58baf71d10fe53e696655b4ca7b8292a32fa5",
|
||||||
"compiled": true
|
"compiled": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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,14 +1241,21 @@ 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);
|
||||||
_populator.PopulateCell(
|
bool skipSouthBuildingShell =
|
||||||
_stream, WalkDrawStage.BuildingShell, building.PositionCellId,
|
AcDream.Core.Rendering.RenderingDiagnostics
|
||||||
shell.Records, shell.TupleLandblockId, _cameraWorldPosition, _viewProjection,
|
.ProbeCathedralSkipSouthBuildingShell
|
||||||
alphaSubmissions: _alphaSubmissions);
|
&& WalkProductionWorldData.AnchorCellId(building) == 0xF4180112u;
|
||||||
if (_alphaSubmissions.Count != _alphaSubmitMark)
|
if (!skipSouthBuildingShell)
|
||||||
{
|
{
|
||||||
MarkIfGrown();
|
_populator.PopulateCell(
|
||||||
MarkAlphaIfGrown();
|
_stream, WalkDrawStage.BuildingShell, building.PositionCellId,
|
||||||
|
shell.Records, shell.TupleLandblockId, _cameraWorldPosition, _viewProjection,
|
||||||
|
alphaSubmissions: _alphaSubmissions);
|
||||||
|
if (_alphaSubmissions.Count != _alphaSubmitMark)
|
||||||
|
{
|
||||||
|
MarkIfGrown();
|
||||||
|
MarkAlphaIfGrown();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// FW4 (the #132 positional invariant): the building's own shell
|
// 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
|
||||||
|
|
@ -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 (005A4A00–005A4ADE):
|
// PView::DrawCells @0x005A4840, loop 2 (005A4A00–005A4ADE):
|
||||||
// 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);
|
{
|
||||||
MarkIfGrown();
|
bool skipSouthLookInShell =
|
||||||
for (int slotIndex = 0; slotIndex < clipSlots.Count; slotIndex++)
|
stage == WalkDrawStage.LookInStatic
|
||||||
_events.Add(WalkFrameEvent.CellShell(cells[i], clipSlots[slotIndex]));
|
&& ((AcDream.Core.Rendering.RenderingDiagnostics
|
||||||
|
.ProbeCathedralSkipSouthLookInCellShells
|
||||||
|
&& cells[i] is 0xF4180112u or 0xF4180113u or 0xF4180114u)
|
||||||
|
|| AcDream.Core.Rendering.RenderingDiagnostics
|
||||||
|
.ProbeCathedralSkipLookInShellCellId == cells[i]);
|
||||||
|
if (!skipSouthLookInShell)
|
||||||
|
{
|
||||||
|
MarkIfGrown();
|
||||||
|
_events.Add(WalkFrameEvent.CellShell(cells[i]));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loop 3 (005A4ADE–005A4B2D): restart at count - 1 and draw each
|
// Loop 3 (005A4ADE–005A4B2D): 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 & 0xFFFF0000) | (cellX*8 + cellY + 1)</c> from the record's
|
/// (<c>(lb & 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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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->m_position</c>, SmartBox 0x00453d3a, with the
|
/// set from <c>player->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++,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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²·(f−n)/(f·n) meters of eye depth at eye distance d
|
|
||||||
/// — 0.125 m at 5 m but ~190 m at a landblock (znear 0.1), so distant
|
|
||||||
/// occluders in front of an aperture passed the mark and were far-Z punched:
|
|
||||||
/// the door-shaped leak. The fix caps the bias's eye-space span
|
|
||||||
/// (PortalDepthMaskRenderer.MarkBiasNdc): identical to the validated constant
|
|
||||||
/// below the ~10 m crossover, never more than the cap beyond it.
|
|
||||||
/// </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(d−n)/((f−n)d) ⇒ d(ndc) inverse ⇒
|
|
||||||
/// span = b·d²·(f−n)/(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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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. ─────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)));
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue