T1 (fused BR-2/3): retail frame order - dynamics last, punch+seal, shell chop deleted
The complete retail drawing order in one installment (per the amended plan: every installment is a COMPLETE retail behavior - the half-ported punch of88be519is re-landed here WITH the ordering that makes it correct): static world (sky/terrain/weather/shells/scenery) -> aperture depth writes (interior SEAL at true depth / outdoor+look-in PUNCH to far-Z; PortalDepthMaskRenderer, DrawPortalPolyInternal Ghidra 0x0059bc90) -> interior cells WHOLE, far-to-near, drawn once (DrawCells Loop 2, Ghidra 0x005a4840; use_built_mesh pc:427905) -> per-cell STATIC object lists -> ALL dynamics LAST (DrawDynamicsLast), depth-tested, never hard-clipped InteriorEntityPartition: new contract - every ServerGuid != 0 entity goes to Dynamics regardless of cell (indoor/outdoor/unresolved/hidden); ByCell carries only dat-baked indoor statics of visible cells; Outdoor renamed OutdoorStatic. Fixes the audit's livedynamic-invisible-under-interior-roots divergence as a side effect (live entities are never dropped by the visibility set; culling is T3's viewcone). DELETED (retail has no counterpart): the gl_ClipDistance shell chop (927fd8fenable +9ce335eoutdoor scoping + UseShellClipRouting + the per-slice shell loop + clipShells param) - retail never clips cell geometry; aperture exactness = punch/seal + z-buffer + this order. The old per-slice scissored AABB depth clear is replaced by retail's single gated full clear (ClearDepthForInterior). The interior-root LiveDynamic top-up draw and the look-in's dynamics involvement are gone (one last pass, no double-draws). Closes at the T5 gate (expected): #114 (chop deleted), the char-eaten-by- doorway regression (ordering), outdoor interiors-through-doorways (punch); #108's render half (seal) - its membership half stays re-attributed. Suites: build green, App 226 green (partition tests rewritten to the T1 contract), Core 1398 + 4 pre-existing #99-era + 1 skip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
1e5db94f0e
commit
579c8b06bc
4 changed files with 234 additions and 200 deletions
|
|
@ -171,6 +171,7 @@ public sealed class GameWindow : IDisposable
|
||||||
// each frame on an indoor root (null on the outdoor root).
|
// each frame on an indoor root (null on the outdoor root).
|
||||||
private AcDream.App.Rendering.InteriorRenderer? _interiorRenderer;
|
private AcDream.App.Rendering.InteriorRenderer? _interiorRenderer;
|
||||||
private AcDream.App.Rendering.RetailPViewRenderer? _retailPViewRenderer;
|
private AcDream.App.Rendering.RetailPViewRenderer? _retailPViewRenderer;
|
||||||
|
private AcDream.App.Rendering.PortalDepthMaskRenderer? _portalDepthMask;
|
||||||
private AcDream.App.Rendering.InteriorEntityPartition.Result? _interiorPartition;
|
private AcDream.App.Rendering.InteriorEntityPartition.Result? _interiorPartition;
|
||||||
|
|
||||||
// Phase U.3: the shared per-frame clip data (binding=2 mesh SSBO + terrain
|
// Phase U.3: the shared per-frame clip data (binding=2 mesh SSBO + terrain
|
||||||
|
|
@ -1845,6 +1846,10 @@ public sealed class GameWindow : IDisposable
|
||||||
_clipFrame ??= ClipFrame.NoClip();
|
_clipFrame ??= ClipFrame.NoClip();
|
||||||
_retailPViewRenderer = new AcDream.App.Rendering.RetailPViewRenderer(
|
_retailPViewRenderer = new AcDream.App.Rendering.RetailPViewRenderer(
|
||||||
_gl, _clipFrame, _envCellRenderer!, _wbDrawDispatcher!);
|
_gl, _clipFrame, _envCellRenderer!, _wbDrawDispatcher!);
|
||||||
|
|
||||||
|
// T1: invisible portal depth writes (seal/punch) — retail
|
||||||
|
// DrawPortalPolyInternal (Ghidra 0x0059bc90).
|
||||||
|
_portalDepthMask = new AcDream.App.Rendering.PortalDepthMaskRenderer(_gl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase G.1 sky renderer — its own shader (sky.vert / sky.frag)
|
// Phase G.1 sky renderer — its own shader (sky.vert / sky.frag)
|
||||||
|
|
@ -7632,24 +7637,26 @@ public sealed class GameWindow : IDisposable
|
||||||
renderSky,
|
renderSky,
|
||||||
kf,
|
kf,
|
||||||
environOverrideActive),
|
environOverrideActive),
|
||||||
// The depth clear is a doorway "look-in" trick: clear depth inside a door/window
|
// T1: retail's depth discipline (PView::DrawCells, Ghidra 0x005a4840).
|
||||||
// region so the cell seen THROUGH it draws over the terrain drawn through that
|
// INTERIOR roots: one FULL depth clear between the outside stage and
|
||||||
// region (the indoor root looking out). For the OUTDOOR-node root the only
|
// the interior stage, then SEALS re-stamp every outside-leading
|
||||||
// OutsideView slice is the FULL-SCREEN base terrain, so clearing its depth wipes the
|
// portal's TRUE depth (#108's protective mechanism). OUTDOOR roots:
|
||||||
// entire depth buffer AFTER terrain/exteriors/player drew — the flooded building
|
// no clear (the world's depth must survive) — instead each flooded
|
||||||
// interiors (cellars) would then paint over everything (cellar in front of the
|
// building's entry aperture gets a far-Z PUNCH so its interior shows
|
||||||
// player; building interiors through the ground). Outdoors the interiors must
|
// through the doorway. Both are safe ONLY because dynamics draw LAST
|
||||||
// depth-test against terrain+exteriors and appear only through real door openings,
|
// (DrawDynamicsLast) — the first BR-2 attempt punched after dynamics
|
||||||
// so issue NO depth clear. Interior roots keep the doorway clear (unchanged).
|
// and erased the player (reverted 88be519).
|
||||||
ClearDepthSlice = clipRoot.IsOutdoorNode
|
ClearDepthForInterior = clipRoot.IsOutdoorNode
|
||||||
? null
|
? null
|
||||||
: slice =>
|
: () =>
|
||||||
{
|
{
|
||||||
bool zc = BeginDoorwayScissor(true, slice.NdcAabb);
|
|
||||||
_gl.Clear(ClearBufferMask.DepthBufferBit);
|
|
||||||
if (zc)
|
|
||||||
_gl.Disable(EnableCap.ScissorTest);
|
_gl.Disable(EnableCap.ScissorTest);
|
||||||
|
_gl.DepthMask(true); // depth clears honor glDepthMask (c4df241 lesson)
|
||||||
|
_gl.Clear(ClearBufferMask.DepthBufferBit);
|
||||||
},
|
},
|
||||||
|
DrawExitPortalMasks = sliceCtx =>
|
||||||
|
DrawRetailPViewPortalDepthWrite(sliceCtx, envCellViewProj,
|
||||||
|
forceFarZ: clipRoot.IsOutdoorNode),
|
||||||
DrawCellParticles = sliceCtx =>
|
DrawCellParticles = sliceCtx =>
|
||||||
DrawRetailPViewCellParticles(sliceCtx, camera, camPos),
|
DrawRetailPViewCellParticles(sliceCtx, camera, camPos),
|
||||||
EmitDiagnostics = result =>
|
EmitDiagnostics = result =>
|
||||||
|
|
@ -7703,45 +7710,32 @@ public sealed class GameWindow : IDisposable
|
||||||
|| pviewResult.ClipAssembly.OutsideViewSlices.Length > 0)
|
|| pviewResult.ClipAssembly.OutsideViewSlices.Length > 0)
|
||||||
? "pviewScoped"
|
? "pviewScoped"
|
||||||
: sigSceneParticles;
|
: sigSceneParticles;
|
||||||
sigOutdoorSceneryDrawn = pviewResult.Partition.Outdoor.Count > 0
|
sigOutdoorSceneryDrawn = pviewResult.Partition.OutdoorStatic.Count > 0
|
||||||
&& pviewResult.ClipAssembly.OutsideViewSlices.Length > 0;
|
&& pviewResult.ClipAssembly.OutsideViewSlices.Length > 0;
|
||||||
|
|
||||||
// Render unification: DrawInside draws the Outdoor bucket (through the landscape
|
// T1: DrawInside now draws ALL dynamics itself in its single
|
||||||
// slice) and the per-cell ByCell buckets, but NOT LiveDynamic — server entities with
|
// last entity pass (DrawDynamicsLast) — the old LiveDynamic
|
||||||
// no resolved ParentCellId (the transient just-spawned / unpositioned case the old
|
// top-up draw is gone.
|
||||||
// outdoor branch drew at the bottom of its block). Preserve that draw for the
|
sigLiveDynamicDrawnCount = pviewResult.Partition.Dynamics.Count;
|
||||||
// outdoor-node root so no live entity blinks out outdoors (spec section 10 regression
|
|
||||||
// guard). DrawInside's tail clears entity clip routing and disables clip distances, so
|
|
||||||
// visibleCellIds:null draws them unclipped — identical to the old outdoor path.
|
|
||||||
if (clipRoot.IsOutdoorNode
|
|
||||||
&& _interiorRenderer is not null
|
|
||||||
&& pviewResult.Partition.LiveDynamic.Count > 0)
|
|
||||||
{
|
|
||||||
_interiorRenderer.DrawEntityBucket(
|
|
||||||
camera, frustum, playerLb, animatedIds,
|
|
||||||
pviewResult.Partition.LiveDynamic, visibleCellIds: null);
|
|
||||||
sigLiveDynamicDrawnCount = pviewResult.Partition.LiveDynamic.Count;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
bool liveDynamicsDrawn = false;
|
|
||||||
|
|
||||||
if (_interiorRenderer is not null)
|
if (_interiorRenderer is not null)
|
||||||
{
|
{
|
||||||
_outdoorRootNoCells.Clear();
|
_outdoorRootNoCells.Clear();
|
||||||
var outdoorPartition = AcDream.App.Rendering.InteriorEntityPartition.Partition(
|
var outdoorPartition = AcDream.App.Rendering.InteriorEntityPartition.Partition(
|
||||||
_outdoorRootNoCells, _worldState.LandblockEntries);
|
_outdoorRootNoCells, _worldState.LandblockEntries);
|
||||||
sigOutdoorRootObjectCount = outdoorPartition.Outdoor.Count;
|
sigOutdoorRootObjectCount = outdoorPartition.OutdoorStatic.Count;
|
||||||
|
|
||||||
if (outdoorPartition.Outdoor.Count > 0)
|
// T1: static world first (shells + scenery)…
|
||||||
|
if (outdoorPartition.OutdoorStatic.Count > 0)
|
||||||
{
|
{
|
||||||
_interiorRenderer.DrawEntityBucket(
|
_interiorRenderer.DrawEntityBucket(
|
||||||
camera,
|
camera,
|
||||||
frustum,
|
frustum,
|
||||||
playerLb,
|
playerLb,
|
||||||
animatedIds,
|
animatedIds,
|
||||||
outdoorPartition.Outdoor,
|
outdoorPartition.OutdoorStatic,
|
||||||
visibleCellIds: null);
|
visibleCellIds: null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -7795,6 +7789,12 @@ public sealed class GameWindow : IDisposable
|
||||||
MaxSeedDistance = 48f,
|
MaxSeedDistance = 48f,
|
||||||
LandblockEntries = _worldState.LandblockEntries,
|
LandblockEntries = _worldState.LandblockEntries,
|
||||||
SetTerrainClipUbo = uboId => _terrain?.SetClipUbo(uboId),
|
SetTerrainClipUbo = uboId => _terrain?.SetClipUbo(uboId),
|
||||||
|
// T1: look-in — PUNCH building entry apertures to far-Z so
|
||||||
|
// the flooded interior shows through the doorway. Safe:
|
||||||
|
// dynamics draw after this whole block.
|
||||||
|
DrawExitPortalMasks = sliceCtx =>
|
||||||
|
DrawRetailPViewPortalDepthWrite(sliceCtx, envCellViewProj,
|
||||||
|
forceFarZ: true),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (portalResult is not null)
|
if (portalResult is not null)
|
||||||
|
|
@ -7804,21 +7804,20 @@ public sealed class GameWindow : IDisposable
|
||||||
sigExteriorClipAssembly = portalResult.ClipAssembly;
|
sigExteriorClipAssembly = portalResult.ClipAssembly;
|
||||||
sigExteriorDrawableCells = portalResult.DrawableCells;
|
sigExteriorDrawableCells = portalResult.DrawableCells;
|
||||||
sigExteriorPartition = portalResult.Partition;
|
sigExteriorPartition = portalResult.Partition;
|
||||||
liveDynamicsDrawn = portalResult.Partition.LiveDynamic.Count > 0;
|
|
||||||
if (liveDynamicsDrawn)
|
|
||||||
sigLiveDynamicDrawnCount = portalResult.Partition.LiveDynamic.Count;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!liveDynamicsDrawn && outdoorPartition.LiveDynamic.Count > 0)
|
// T1: …then ALL dynamics last (after the look-in punched +
|
||||||
|
// drew interiors), depth-tested, never hard-clipped.
|
||||||
|
if (outdoorPartition.Dynamics.Count > 0)
|
||||||
{
|
{
|
||||||
sigLiveDynamicDrawnCount = outdoorPartition.LiveDynamic.Count;
|
sigLiveDynamicDrawnCount = outdoorPartition.Dynamics.Count;
|
||||||
_interiorRenderer.DrawEntityBucket(
|
_interiorRenderer.DrawEntityBucket(
|
||||||
camera,
|
camera,
|
||||||
frustum,
|
frustum,
|
||||||
playerLb,
|
playerLb,
|
||||||
animatedIds,
|
animatedIds,
|
||||||
outdoorPartition.LiveDynamic,
|
outdoorPartition.Dynamics,
|
||||||
visibleCellIds: null);
|
visibleCellIds: null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -9333,7 +9332,7 @@ public sealed class GameWindow : IDisposable
|
||||||
if (partition is not null)
|
if (partition is not null)
|
||||||
{
|
{
|
||||||
int shellTotal = 0, shellMesh = 0;
|
int shellTotal = 0, shellMesh = 0;
|
||||||
foreach (var e in partition.Outdoor)
|
foreach (var e in partition.OutdoorStatic)
|
||||||
if (e.IsBuildingShell) { shellTotal++; if (e.MeshRefs.Count > 0) shellMesh++; }
|
if (e.IsBuildingShell) { shellTotal++; if (e.MeshRefs.Count > 0) shellMesh++; }
|
||||||
sb.Append(" bshell=").Append(shellTotal).Append('/').Append(shellMesh);
|
sb.Append(" bshell=").Append(shellTotal).Append('/').Append(shellMesh);
|
||||||
}
|
}
|
||||||
|
|
@ -9457,8 +9456,8 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
if (keys.Count > MaxCells)
|
if (keys.Count > MaxCells)
|
||||||
sb.Append(",...");
|
sb.Append(",...");
|
||||||
sb.Append("] out=").Append(partition.Outdoor.Count)
|
sb.Append("] out=").Append(partition.OutdoorStatic.Count)
|
||||||
.Append(" live=").Append(partition.LiveDynamic.Count);
|
.Append(" live=").Append(partition.Dynamics.Count);
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -9550,6 +9549,51 @@ public sealed class GameWindow : IDisposable
|
||||||
DisableClipDistances();
|
DisableClipDistances();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// T1: retail's invisible portal depth writes on every outside-leading
|
||||||
|
// portal (other_cell_id==0xFFFF) of this cell, clipped to the slice's view
|
||||||
|
// region — D3DPolyRender::DrawPortalPolyInternal (Ghidra 0x0059bc90),
|
||||||
|
// dispatched by PView::DrawCells (Ghidra 0x005a4840). forceFarZ is
|
||||||
|
// retail's maxZ1(true)/maxZ2(false) selector:
|
||||||
|
// • INTERIOR root (false → SEAL, true depth): after the full depth clear,
|
||||||
|
// stamp the door plane so interior geometry beyond the door z-fails
|
||||||
|
// inside the aperture and the terrain drawn through the outside view
|
||||||
|
// keeps its pixels (#108's protective mechanism).
|
||||||
|
// • OUTDOOR root / look-in (true → PUNCH, far depth): erase the world's
|
||||||
|
// depth inside a flooded building's entry aperture so the interior
|
||||||
|
// drawn next shows THROUGH the doorway.
|
||||||
|
// Both are safe ONLY because dynamics draw last (DrawDynamicsLast) — the
|
||||||
|
// first BR-2 attempt punched after dynamics and erased the player
|
||||||
|
// (reverted 88be519). Wiring only — the draw lives in
|
||||||
|
// PortalDepthMaskRenderer.
|
||||||
|
private void DrawRetailPViewPortalDepthWrite(
|
||||||
|
AcDream.App.Rendering.RetailPViewCellSliceContext sliceCtx,
|
||||||
|
System.Numerics.Matrix4x4 viewProjection,
|
||||||
|
bool forceFarZ)
|
||||||
|
{
|
||||||
|
if (_portalDepthMask is null)
|
||||||
|
return;
|
||||||
|
if (!_cellVisibility.TryGetCell(sliceCtx.CellId, out var cell) || cell is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
Span<System.Numerics.Vector3> world = stackalloc System.Numerics.Vector3[32];
|
||||||
|
for (int i = 0; i < cell.Portals.Count; i++)
|
||||||
|
{
|
||||||
|
if (cell.Portals[i].OtherCellId != 0xFFFF)
|
||||||
|
continue; // depth writes apply to portals leading OUTSIDE only
|
||||||
|
if (i >= cell.PortalPolygons.Count)
|
||||||
|
break;
|
||||||
|
var localVerts = cell.PortalPolygons[i];
|
||||||
|
if (localVerts.Length < 3)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
int n = System.Math.Min(localVerts.Length, world.Length);
|
||||||
|
for (int v = 0; v < n; v++)
|
||||||
|
world[v] = System.Numerics.Vector3.Transform(localVerts[v], cell.WorldTransform);
|
||||||
|
|
||||||
|
_portalDepthMask.DrawDepthFan(world[..n], viewProjection, sliceCtx.Slice.Planes, forceFarZ);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void DrawRetailPViewCellParticles(
|
private void DrawRetailPViewCellParticles(
|
||||||
AcDream.App.Rendering.RetailPViewCellSliceContext sliceCtx,
|
AcDream.App.Rendering.RetailPViewCellSliceContext sliceCtx,
|
||||||
ICamera camera,
|
ICamera camera,
|
||||||
|
|
@ -11773,6 +11817,7 @@ public sealed class GameWindow : IDisposable
|
||||||
_audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context
|
_audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context
|
||||||
_wbDrawDispatcher?.Dispose();
|
_wbDrawDispatcher?.Dispose();
|
||||||
_envCellRenderer?.Dispose(); // Phase A8
|
_envCellRenderer?.Dispose(); // Phase A8
|
||||||
|
_portalDepthMask?.Dispose(); // T1
|
||||||
_clipFrame?.Dispose(); // Phase U.3
|
_clipFrame?.Dispose(); // Phase U.3
|
||||||
_skyRenderer?.Dispose(); // depends on sampler cache; dispose first
|
_skyRenderer?.Dispose(); // depends on sampler cache; dispose first
|
||||||
_samplerCache?.Dispose();
|
_samplerCache?.Dispose();
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,35 @@ namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Splits a frame's landblock entities into the draw buckets used by the
|
/// Splits a frame's landblock entities into the draw buckets used by the
|
||||||
/// retail-style DrawInside flood. Indoor ownership wins for live dynamics too:
|
/// retail-style DrawInside flood.
|
||||||
/// a player, NPC, door, or item with a current indoor ParentCellId belongs to
|
///
|
||||||
/// that cell's portal-clipped object list, not a global overlay pass.
|
/// <para>T1 (fused BR-2/3, 2026-06-11) — retail draw-order contract: the
|
||||||
|
/// frame draws STATIC world first (terrain, building shells, scenery, then
|
||||||
|
/// flooded interior cells + their static object lists), and every DYNAMIC
|
||||||
|
/// (server-spawned: player, NPCs, doors, items) draws LAST, depth-tested,
|
||||||
|
/// never hard-clipped. This is what makes the aperture depth punch safe —
|
||||||
|
/// when the punch erases depth inside a doorway, no dynamic has been drawn
|
||||||
|
/// yet, so nothing visible is destroyed (retail: objects draw per cell AFTER
|
||||||
|
/// cells, PView::DrawCells epilogue Ghidra 0x005a4840; the first BR-2 attempt
|
||||||
|
/// punched after dynamics and erased the player, reverted 88be519).</para>
|
||||||
|
///
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><see cref="Result.ByCell"/> — indoor STATICS (dat-baked, ServerGuid==0)
|
||||||
|
/// per visible cell, drawn with their cell.</item>
|
||||||
|
/// <item><see cref="Result.OutdoorStatic"/> — outdoor statics (building
|
||||||
|
/// shells, scenery stabs), drawn with the world/landscape pass.</item>
|
||||||
|
/// <item><see cref="Result.Dynamics"/> — ALL server-spawned entities
|
||||||
|
/// (ServerGuid != 0) regardless of cell, plus unresolved-cell live entities;
|
||||||
|
/// drawn in the frame's single LAST entity pass.</item>
|
||||||
|
/// </list>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class InteriorEntityPartition
|
public static class InteriorEntityPartition
|
||||||
{
|
{
|
||||||
public sealed class Result
|
public sealed class Result
|
||||||
{
|
{
|
||||||
public Dictionary<uint, List<WorldEntity>> ByCell { get; } = new();
|
public Dictionary<uint, List<WorldEntity>> ByCell { get; } = new();
|
||||||
public List<WorldEntity> Outdoor { get; } = new();
|
public List<WorldEntity> OutdoorStatic { get; } = new();
|
||||||
public List<WorldEntity> LiveDynamic { get; } = new();
|
public List<WorldEntity> Dynamics { get; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Result Partition(
|
public static Result Partition(
|
||||||
|
|
@ -32,46 +50,29 @@ public static class InteriorEntityPartition
|
||||||
{
|
{
|
||||||
if (e.MeshRefs.Count == 0) continue;
|
if (e.MeshRefs.Count == 0) continue;
|
||||||
|
|
||||||
|
// Retail contract: every server-spawned entity is a DYNAMIC
|
||||||
|
// and draws in the last pass — indoor, outdoor, or unresolved.
|
||||||
if (e.ServerGuid != 0)
|
if (e.ServerGuid != 0)
|
||||||
{
|
{
|
||||||
if (e.ParentCellId is uint liveCell)
|
result.Dynamics.Add(e);
|
||||||
AddByCellOrOutdoor(e, liveCell, visibleCells, result);
|
|
||||||
else
|
|
||||||
result.LiveDynamic.Add(e);
|
|
||||||
}
|
}
|
||||||
else if (e.ParentCellId is uint cell)
|
else if (e.ParentCellId is uint cell && IsIndoorCellId(cell))
|
||||||
{
|
{
|
||||||
AddByCellOrOutdoor(e, cell, visibleCells, result);
|
if (!visibleCells.Contains(cell))
|
||||||
|
continue;
|
||||||
|
if (!result.ByCell.TryGetValue(cell, out var list))
|
||||||
|
result.ByCell[cell] = list = new List<WorldEntity>();
|
||||||
|
list.Add(e);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
result.Outdoor.Add(e);
|
result.OutdoorStatic.Add(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddByCellOrOutdoor(
|
|
||||||
WorldEntity entity,
|
|
||||||
uint cellId,
|
|
||||||
HashSet<uint> visibleCells,
|
|
||||||
Result result)
|
|
||||||
{
|
|
||||||
if (!IsIndoorCellId(cellId))
|
|
||||||
{
|
|
||||||
result.Outdoor.Add(entity);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!visibleCells.Contains(cellId))
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (!result.ByCell.TryGetValue(cellId, out var list))
|
|
||||||
result.ByCell[cellId] = list = new List<WorldEntity>();
|
|
||||||
list.Add(entity);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsIndoorCellId(uint cellId)
|
private static bool IsIndoorCellId(uint cellId)
|
||||||
{
|
{
|
||||||
uint low = cellId & 0xFFFFu;
|
uint low = cellId & 0xFFFFu;
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ public sealed class RetailPViewRenderer
|
||||||
new(0, new Vector4(-1f, -1f, 1f, 1f), Array.Empty<Vector4>());
|
new(0, new Vector4(-1f, -1f, 1f, 1f), Array.Empty<Vector4>());
|
||||||
|
|
||||||
private readonly HashSet<uint> _oneCell = new(1);
|
private readonly HashSet<uint> _oneCell = new(1);
|
||||||
private readonly Dictionary<uint, int> _oneCellSlot = new(1);
|
|
||||||
|
|
||||||
// R-A2: per-building flood grouping, reused across frames (inner lists cleared each frame).
|
// R-A2: per-building flood grouping, reused across frames (inner lists cleared each frame).
|
||||||
private readonly Dictionary<uint, List<LoadedCell>> _buildingGroups = new();
|
private readonly Dictionary<uint, List<LoadedCell>> _buildingGroups = new();
|
||||||
|
|
@ -90,20 +89,21 @@ public sealed class RetailPViewRenderer
|
||||||
|
|
||||||
ctx.EmitDiagnostics?.Invoke(result);
|
ctx.EmitDiagnostics?.Invoke(result);
|
||||||
|
|
||||||
|
// T1 (fused BR-2/3): retail's frame order — static world, then the
|
||||||
|
// aperture depth writes, then interior cells WHOLE far→near, then
|
||||||
|
// per-cell statics, then ALL dynamics last (retail draws objects after
|
||||||
|
// cells: PView::DrawCells Ghidra 0x005a4840; DrawBuilding 0x0059f2a0).
|
||||||
|
// The geometric shell chop (gl_ClipDistance crop, 927fd8f/9ce335e) is
|
||||||
|
// DELETED — retail never clips cell geometry; aperture exactness comes
|
||||||
|
// from the punch/seal depth writes + the z-buffer, and the dynamics-
|
||||||
|
// last order is what makes the punch safe (the first BR-2 attempt
|
||||||
|
// punched after dynamics and erased the player, reverted 88be519).
|
||||||
DrawLandscapeThroughOutsideView(ctx, clipAssembly, partition);
|
DrawLandscapeThroughOutsideView(ctx, clipAssembly, partition);
|
||||||
UseIndoorMembershipOnlyRouting();
|
UseIndoorMembershipOnlyRouting();
|
||||||
DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells);
|
DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells);
|
||||||
// #113 fix scope (#114): GL-clip the shells only for the OUTDOOR root —
|
DrawEnvCellShells(pvFrame);
|
||||||
// the case the flood replay validated (tight, stable door-aperture
|
|
||||||
// regions) and the one that produced the phantom staircase. The first
|
|
||||||
// user gate (2026-06-11) showed INDOOR clip regions are not yet
|
|
||||||
// draw-quality (chopped stairs / vanishing inner walls at exits /
|
|
||||||
// see-through to neighbour rooms at the meeting hall) — indoor roots
|
|
||||||
// stay unclipped (yesterday's user-accepted state) until #114 brings
|
|
||||||
// the indoor regions to retail's pixel-exact crop.
|
|
||||||
DrawEnvCellShells(ctx, pvFrame, clipAssembly, drawableCells,
|
|
||||||
clipShells: ctx.RootCell.IsOutdoorNode);
|
|
||||||
DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition);
|
DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition);
|
||||||
|
DrawDynamicsLast(ctx, partition);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
@ -201,10 +201,13 @@ public sealed class RetailPViewRenderer
|
||||||
|
|
||||||
ctx.EmitDiagnostics?.Invoke(result);
|
ctx.EmitDiagnostics?.Invoke(result);
|
||||||
|
|
||||||
|
// T1: look-in order — punch the apertures, then interior cells WHOLE,
|
||||||
|
// then the looked-into building's per-cell statics. Dynamics are NOT
|
||||||
|
// drawn here: they belong exclusively to the frame's single last
|
||||||
|
// entity pass (the outdoor root's DrawDynamicsLast), which prevents
|
||||||
|
// double-draws of entities inside looked-into buildings.
|
||||||
DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells);
|
DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells);
|
||||||
// DrawPortal is the from-outside look-in path — same validated outdoor
|
DrawEnvCellShells(pvFrame);
|
||||||
// regime as the outdoor root (see #114 scope note in DrawInside).
|
|
||||||
DrawEnvCellShells(ctx, pvFrame, clipAssembly, drawableCells, clipShells: true);
|
|
||||||
DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition);
|
DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition);
|
||||||
RestoreNoClip(ctx.SetTerrainClipUbo);
|
RestoreNoClip(ctx.SetTerrainClipUbo);
|
||||||
|
|
||||||
|
|
@ -228,11 +231,18 @@ public sealed class RetailPViewRenderer
|
||||||
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeClipRouteEnabled)
|
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeClipRouteEnabled)
|
||||||
EmitClipRouteProbe(clipAssembly, slice, probeSliceIndex);
|
EmitClipRouteProbe(clipAssembly, slice, probeSliceIndex);
|
||||||
probeSliceIndex++;
|
probeSliceIndex++;
|
||||||
ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, partition.Outdoor));
|
ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, partition.OutdoorStatic));
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var slice in clipAssembly.OutsideViewSlices)
|
// T1: retail clears the FULL depth buffer ONCE between the outside
|
||||||
ctx.ClearDepthSlice?.Invoke(slice);
|
// stage and the interior stage (PView::DrawCells, Ghidra 0x005a4840 —
|
||||||
|
// Clear gated on portalsDrawnCount; exact gate semantics is a plan
|
||||||
|
// open question, staged as "any outside slice drawn"), then re-stamps
|
||||||
|
// every outside-leading portal's TRUE depth (the seals,
|
||||||
|
// DrawExitPortalMasks). Replaces the old per-slice scissored AABB
|
||||||
|
// clear (wrong shape, no seal after it).
|
||||||
|
if (clipAssembly.OutsideViewSlices.Length > 0)
|
||||||
|
ctx.ClearDepthForInterior?.Invoke();
|
||||||
|
|
||||||
UseIndoorMembershipOnlyRouting();
|
UseIndoorMembershipOnlyRouting();
|
||||||
}
|
}
|
||||||
|
|
@ -342,68 +352,43 @@ public sealed class RetailPViewRenderer
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DrawEnvCellShells(
|
private void DrawEnvCellShells(PortalVisibilityFrame pvFrame)
|
||||||
IRetailPViewCellDrawCallbacks ctx,
|
|
||||||
PortalVisibilityFrame pvFrame,
|
|
||||||
ClipFrameAssembly clipAssembly,
|
|
||||||
HashSet<uint> drawableCells, // param kept this task; removed in Task 4
|
|
||||||
bool clipShells)
|
|
||||||
{
|
{
|
||||||
// Retail DrawCells Loop 2: every visible cell's shell, reverse cell_draw_list
|
// T1 (fused BR-2/3): retail DrawCells Loop 2 — every visible cell's
|
||||||
// (far→near), per portal_view slice. No drawableCells filter — a cell without a
|
// shell drawn WHOLE, reverse cell_draw_list (far→near), drawn once.
|
||||||
// clip-slot falls through GetCellSlicesOrNoClip to NoClipSlice and draws unclipped
|
// Retail NEVER clips cell geometry: the production path is the
|
||||||
// (sealed; per-slice trim returns in Task 4).
|
// prebuilt mesh (DrawEnvCell use_built_mesh, pc:427905; the
|
||||||
//
|
// planeMask=0xffffffff legacy submit means skip-all-edges), and
|
||||||
// #113 (2026-06-10): the per-slice clip MUST actually clip. Retail clips drawn
|
// aperture exactness comes from the punch/seal depth writes + the
|
||||||
// CELL geometry to the accumulated portal view — Render::set_view (:343750)
|
// z-buffer + this order. The former gl_ClipDistance chop
|
||||||
// installs the view polygon's edge planes and DrawEnvCell submits every cell
|
// (927fd8f/9ce335e, #114) is deleted with this rewrite.
|
||||||
// polygon with planeMask=0xffffffff (:427922) through ACRender::polyClipFinish.
|
// Per-cell opaque+transparent keeps the far→near transparent
|
||||||
// Our equivalent (UseShellClipRouting → mesh_modern.vert gl_ClipDistance) was
|
// compositing the per-cell loop already provided.
|
||||||
// routed but INERT: gl_ClipDistance writes are ignored unless GL_CLIP_DISTANCEi
|
UseIndoorMembershipOnlyRouting();
|
||||||
// is enabled, and no caller enabled it for this pass — so flooded interior cells
|
|
||||||
// drew WHOLE, painting interior geometry across exterior walls (the Holtburg
|
|
||||||
// meeting-hall phantom staircase, AAB3 0x100 stair cell coincident with the
|
|
||||||
// shell's west wall). Self-contained per feedback_render_self_contained_gl_state;
|
|
||||||
// no early-outs between enable and disable. Slot-0 slices (SSBO count=0) still
|
|
||||||
// pass-all — the assembler's >8-plane scissor fallback remains unimplemented
|
|
||||||
// (rare; Issue113MeetingHallFloodTests pins 0 such slices at the hall).
|
|
||||||
// Characters/statics stay unclipped (DrawCellObjectLists): retail's mesh path is
|
|
||||||
// viewcone-check + BoundingType handling, and hard-clipping slices characters at
|
|
||||||
// doorways (the original UseIndoorMembershipOnlyRouting observation).
|
|
||||||
//
|
|
||||||
// clipShells (#114 scope, 2026-06-11): true only for outdoor-eye roots.
|
|
||||||
// The first user gate showed indoor clip regions are not draw-quality
|
|
||||||
// yet (chopped stairs / vanishing walls at exits) — indoor roots draw
|
|
||||||
// unclipped until #114 lands pixel-exact indoor regions.
|
|
||||||
if (clipShells)
|
|
||||||
for (int i = 0; i < ClipFrame.MaxPlanes; i++)
|
|
||||||
_gl.Enable(Silk.NET.OpenGL.EnableCap.ClipDistance0 + i);
|
|
||||||
|
|
||||||
foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame))
|
foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame))
|
||||||
{
|
{
|
||||||
uint cellId = entry.CellId;
|
|
||||||
_oneCell.Clear();
|
_oneCell.Clear();
|
||||||
_oneCell.Add(cellId);
|
_oneCell.Add(entry.CellId);
|
||||||
|
|
||||||
var slices = GetCellSlicesOrNoClip(clipAssembly, cellId);
|
|
||||||
|
|
||||||
// BR-2 phantom-site probe: which cells draw their shell with a
|
|
||||||
// pass-all slice (NoClipSlice fallback or assembler slot-0)?
|
|
||||||
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbePhantomEnabled)
|
|
||||||
EmitPhantomShellProbe(cellId, slices, clipShells,
|
|
||||||
hadSlot: clipAssembly.CellIdToViewSlices.ContainsKey(cellId));
|
|
||||||
|
|
||||||
foreach (var slice in slices)
|
|
||||||
{
|
|
||||||
UseShellClipRouting(cellId, slice);
|
|
||||||
_envCells.Render(WbRenderPass.Opaque, _oneCell);
|
_envCells.Render(WbRenderPass.Opaque, _oneCell);
|
||||||
_envCells.Render(WbRenderPass.Transparent, _oneCell);
|
_envCells.Render(WbRenderPass.Transparent, _oneCell);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (clipShells)
|
// T1: the frame's single LAST entity pass — ALL server-spawned dynamics
|
||||||
for (int i = 0; i < ClipFrame.MaxPlanes; i++)
|
// (player, NPCs, doors, items), indoor or out, drawn after the static
|
||||||
_gl.Disable(Silk.NET.OpenGL.EnableCap.ClipDistance0 + i);
|
// world + punches + interior cells. Depth-tested, never hard-clipped
|
||||||
|
// (retail draws objects per cell AFTER cells and viewcone-culls them —
|
||||||
|
// PView::DrawCells epilogue Ghidra 0x005a4840; the sphere-vs-view cull is
|
||||||
|
// T3). Drawing dynamics last is what makes the aperture punch safe.
|
||||||
|
private void DrawDynamicsLast(
|
||||||
|
IRetailPViewCellDrawContext ctx,
|
||||||
|
InteriorEntityPartition.Result partition)
|
||||||
|
{
|
||||||
|
if (partition.Dynamics.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
UseIndoorMembershipOnlyRouting();
|
||||||
|
DrawEntityBucket(ctx, partition.Dynamics, visibleCellIds: null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DrawCellObjectLists(
|
private void DrawCellObjectLists(
|
||||||
|
|
@ -413,6 +398,11 @@ public sealed class RetailPViewRenderer
|
||||||
HashSet<uint> drawableCells,
|
HashSet<uint> drawableCells,
|
||||||
InteriorEntityPartition.Result partition)
|
InteriorEntityPartition.Result partition)
|
||||||
{
|
{
|
||||||
|
// T1: per-cell STATIC object lists only (dat-baked 0x40 statics) —
|
||||||
|
// dynamics moved to DrawDynamicsLast. Far→near with the cells, after
|
||||||
|
// the shells (retail DrawCells epilogue: PortalList = cell's views →
|
||||||
|
// DrawObjCell, Ghidra 0x005a4840). Unclipped; per-view sphere culling
|
||||||
|
// (viewconeCheck) is T3.
|
||||||
for (int i = pvFrame.OrderedVisibleCells.Count - 1; i >= 0; i--)
|
for (int i = pvFrame.OrderedVisibleCells.Count - 1; i >= 0; i--)
|
||||||
{
|
{
|
||||||
uint cellId = pvFrame.OrderedVisibleCells[i];
|
uint cellId = pvFrame.OrderedVisibleCells[i];
|
||||||
|
|
@ -425,8 +415,8 @@ public sealed class RetailPViewRenderer
|
||||||
_oneCell.Clear();
|
_oneCell.Clear();
|
||||||
_oneCell.Add(cellId);
|
_oneCell.Add(cellId);
|
||||||
|
|
||||||
// BR-2 phantom-site probe: entity buckets draw unclipped +
|
// BR-2 phantom-site probe: static buckets draw unclipped +
|
||||||
// un-viewcone'd by design — log the per-cell exposure.
|
// un-viewcone'd until T3 — log the per-cell exposure.
|
||||||
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbePhantomEnabled)
|
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbePhantomEnabled)
|
||||||
EmitPhantomObjsProbe(cellId, bucket.Count);
|
EmitPhantomObjsProbe(cellId, bucket.Count);
|
||||||
|
|
||||||
|
|
@ -440,29 +430,11 @@ public sealed class RetailPViewRenderer
|
||||||
|
|
||||||
// BR-2 phantom-site probe state: print-on-change per cell so the log stays
|
// BR-2 phantom-site probe state: print-on-change per cell so the log stays
|
||||||
// diffable while the condition persists. Throwaway apparatus — strip when
|
// diffable while the condition persists. Throwaway apparatus — strip when
|
||||||
// the #113 phantom residual closes (plan §BR-2).
|
// the #113 phantom residual closes. (The [phantom-shell] half died with
|
||||||
private readonly Dictionary<uint, string> _phantomShellSig = new();
|
// the T1 chop deletion — shells draw whole, there is no slice state left
|
||||||
|
// to report.)
|
||||||
private readonly Dictionary<uint, int> _phantomObjsSig = new();
|
private readonly Dictionary<uint, int> _phantomObjsSig = new();
|
||||||
|
|
||||||
private void EmitPhantomShellProbe(uint cellId, ClipViewSlice[] slices, bool clipShells, bool hadSlot)
|
|
||||||
{
|
|
||||||
var sb = new System.Text.StringBuilder(96);
|
|
||||||
sb.Append(clipShells ? "clip=on" : "clip=OFF");
|
|
||||||
sb.Append(hadSlot ? " slot=yes" : " slot=NONE(pass-all)");
|
|
||||||
sb.Append(" slices=[");
|
|
||||||
for (int i = 0; i < slices.Length; i++)
|
|
||||||
{
|
|
||||||
if (i > 0) sb.Append(',');
|
|
||||||
sb.Append(slices[i].Slot).Append(':').Append(slices[i].Planes.Length).Append("pl");
|
|
||||||
if (slices[i].Slot == 0) sb.Append("(PASS-ALL)");
|
|
||||||
}
|
|
||||||
sb.Append(']');
|
|
||||||
var sig = sb.ToString();
|
|
||||||
if (_phantomShellSig.TryGetValue(cellId, out var prev) && prev == sig) return;
|
|
||||||
_phantomShellSig[cellId] = sig;
|
|
||||||
Console.WriteLine($"[phantom-shell] cell=0x{cellId:X8} {sig}");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void EmitPhantomObjsProbe(uint cellId, int bucketCount)
|
private void EmitPhantomObjsProbe(uint cellId, int bucketCount)
|
||||||
{
|
{
|
||||||
if (_phantomObjsSig.TryGetValue(cellId, out var prev) && prev == bucketCount) return;
|
if (_phantomObjsSig.TryGetValue(cellId, out var prev) && prev == bucketCount) return;
|
||||||
|
|
@ -483,25 +455,14 @@ public sealed class RetailPViewRenderer
|
||||||
|
|
||||||
private void UseIndoorMembershipOnlyRouting()
|
private void UseIndoorMembershipOnlyRouting()
|
||||||
{
|
{
|
||||||
// For MESHES (characters, statics) retail's DrawMesh performs portal-view
|
// T1: NOTHING in the world passes hard-clips geometry anymore — retail
|
||||||
// visibility checks (Render::viewconeCheck on the drawing sphere) rather
|
// viewcone-CHECKS meshes (sphere vs view planes, T3) and never clips
|
||||||
// than hard per-poly clipping — feeding the 2D views into gl_ClipDistance
|
// cell shells (DrawEnvCell draws the whole prebuilt mesh, pc:427905).
|
||||||
// slices characters at stair/door boundaries, which retail does not do.
|
// This clears any clip routing left by the landscape slices.
|
||||||
// CELL SHELL geometry is different: retail clips it to the portal view
|
|
||||||
// (planeMask=0xffffffff per cell polygon, decomp :427922 + :343750) —
|
|
||||||
// DrawEnvCellShells enables exactly that (#113).
|
|
||||||
_envCells.SetClipRouting(null);
|
_envCells.SetClipRouting(null);
|
||||||
_entities.ClearClipRouting();
|
_entities.ClearClipRouting();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UseShellClipRouting(uint cellId, ClipViewSlice slice)
|
|
||||||
{
|
|
||||||
_oneCellSlot.Clear();
|
|
||||||
_oneCellSlot[cellId] = slice.Slot;
|
|
||||||
_envCells.SetClipRouting(_oneCellSlot);
|
|
||||||
_entities.ClearClipRouting();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DrawEntityBucket(
|
private void DrawEntityBucket(
|
||||||
IRetailPViewCellDrawContext ctx,
|
IRetailPViewCellDrawContext ctx,
|
||||||
IReadOnlyList<WorldEntity> bucket,
|
IReadOnlyList<WorldEntity> bucket,
|
||||||
|
|
@ -575,7 +536,11 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
|
||||||
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries { get; init; }
|
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries { get; init; }
|
||||||
public required Action<uint> SetTerrainClipUbo { get; init; }
|
public required Action<uint> SetTerrainClipUbo { get; init; }
|
||||||
public required Action<RetailPViewLandscapeSliceContext> DrawLandscapeSlice { get; init; }
|
public required Action<RetailPViewLandscapeSliceContext> DrawLandscapeSlice { get; init; }
|
||||||
public Action<ClipViewSlice>? ClearDepthSlice { get; init; }
|
/// <summary>T1: one full-buffer depth clear between the outside stage and the
|
||||||
|
/// interior stage (retail PView::DrawCells, Ghidra 0x005a4840). Null for outdoor
|
||||||
|
/// roots — outdoors the interiors must depth-test against terrain + exteriors and
|
||||||
|
/// appear only through punched apertures.</summary>
|
||||||
|
public Action? ClearDepthForInterior { get; init; }
|
||||||
public Action<RetailPViewCellSliceContext>? DrawExitPortalMasks { get; init; }
|
public Action<RetailPViewCellSliceContext>? DrawExitPortalMasks { get; init; }
|
||||||
public Action<RetailPViewCellSliceContext>? DrawCellParticles { get; init; }
|
public Action<RetailPViewCellSliceContext>? DrawCellParticles { get; init; }
|
||||||
public Action<RetailPViewFrameResult>? EmitDiagnostics { get; init; }
|
public Action<RetailPViewFrameResult>? EmitDiagnostics { get; init; }
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,19 @@ using Xunit;
|
||||||
|
|
||||||
namespace AcDream.App.Tests.Rendering;
|
namespace AcDream.App.Tests.Rendering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// T1 (fused BR-2/3) partition contract — retail draw order: static world
|
||||||
|
/// first, every server-spawned DYNAMIC in the frame's single LAST pass.
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>ALL ServerGuid != 0 entities land in <c>Dynamics</c> regardless of
|
||||||
|
/// cell (indoor, outdoor, unresolved, even non-visible cells — retail never
|
||||||
|
/// drops a live entity for visibility-set reasons; culling is the
|
||||||
|
/// viewcone's job, T3).</item>
|
||||||
|
/// <item><c>ByCell</c> carries only dat-baked indoor statics of VISIBLE
|
||||||
|
/// cells (drawn with their cell).</item>
|
||||||
|
/// <item><c>OutdoorStatic</c> carries shells/scenery (the world pass).</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
public class InteriorEntityPartitionTests
|
public class InteriorEntityPartitionTests
|
||||||
{
|
{
|
||||||
private const uint CellA = 0xA9B40170;
|
private const uint CellA = 0xA9B40170;
|
||||||
|
|
@ -30,7 +43,7 @@ public class InteriorEntityPartitionTests
|
||||||
(IReadOnlyDictionary<uint, WorldEntity>?)null) };
|
(IReadOnlyDictionary<uint, WorldEntity>?)null) };
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Partitions_LiveAndStaticEntities_ByCellOutdoorAndFallback()
|
public void AllServerSpawned_GoToDynamics_StaticsSplitByCellAndOutdoor()
|
||||||
{
|
{
|
||||||
var unresolvedLive = Ent(1, serverGuid: 0x5000000A, parentCell: null);
|
var unresolvedLive = Ent(1, serverGuid: 0x5000000A, parentCell: null);
|
||||||
var liveNpcInCell = Ent(2, serverGuid: 0x80001234, parentCell: CellA);
|
var liveNpcInCell = Ent(2, serverGuid: 0x80001234, parentCell: CellA);
|
||||||
|
|
@ -43,22 +56,25 @@ public class InteriorEntityPartitionTests
|
||||||
var result = InteriorEntityPartition.Partition(
|
var result = InteriorEntityPartition.Partition(
|
||||||
visible, OneLb(0xA9B4FFFF, unresolvedLive, liveNpcInCell, staticA, staticB, scenery, liveOutdoor));
|
visible, OneLb(0xA9B4FFFF, unresolvedLive, liveNpcInCell, staticA, staticB, scenery, liveOutdoor));
|
||||||
|
|
||||||
Assert.Single(result.LiveDynamic);
|
// Every server-spawned entity is a dynamic — drawn in the last pass.
|
||||||
Assert.Contains(unresolvedLive, result.LiveDynamic);
|
Assert.Equal(3, result.Dynamics.Count);
|
||||||
|
Assert.Contains(unresolvedLive, result.Dynamics);
|
||||||
|
Assert.Contains(liveNpcInCell, result.Dynamics);
|
||||||
|
Assert.Contains(liveOutdoor, result.Dynamics);
|
||||||
|
|
||||||
Assert.Equal(2, result.ByCell[CellA].Count);
|
// Indoor statics ride with their (visible) cell.
|
||||||
Assert.Contains(liveNpcInCell, result.ByCell[CellA]);
|
Assert.Single(result.ByCell[CellA]);
|
||||||
Assert.Contains(staticA, result.ByCell[CellA]);
|
Assert.Contains(staticA, result.ByCell[CellA]);
|
||||||
Assert.Single(result.ByCell[CellB]);
|
Assert.Single(result.ByCell[CellB]);
|
||||||
Assert.Contains(staticB, result.ByCell[CellB]);
|
Assert.Contains(staticB, result.ByCell[CellB]);
|
||||||
|
|
||||||
Assert.Equal(2, result.Outdoor.Count);
|
// Outdoor statics (shells/scenery) ride with the world pass.
|
||||||
Assert.Contains(scenery, result.Outdoor);
|
Assert.Single(result.OutdoorStatic);
|
||||||
Assert.Contains(liveOutdoor, result.Outdoor);
|
Assert.Contains(scenery, result.OutdoorStatic);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void IndoorEntity_InNonVisibleCell_IsDropped()
|
public void HiddenCell_DropsStatics_ButNeverDynamics()
|
||||||
{
|
{
|
||||||
var staticHidden = Ent(3, serverGuid: 0, parentCell: HiddenCell);
|
var staticHidden = Ent(3, serverGuid: 0, parentCell: HiddenCell);
|
||||||
var liveHidden = Ent(4, serverGuid: 0x80001234, parentCell: HiddenCell);
|
var liveHidden = Ent(4, serverGuid: 0x80001234, parentCell: HiddenCell);
|
||||||
|
|
@ -67,9 +83,14 @@ public class InteriorEntityPartitionTests
|
||||||
var result = InteriorEntityPartition.Partition(
|
var result = InteriorEntityPartition.Partition(
|
||||||
visible, OneLb(0xA9B4FFFF, staticHidden, liveHidden));
|
visible, OneLb(0xA9B4FFFF, staticHidden, liveHidden));
|
||||||
|
|
||||||
|
// A static in a non-flooded cell is not drawn this frame…
|
||||||
Assert.False(result.ByCell.ContainsKey(HiddenCell));
|
Assert.False(result.ByCell.ContainsKey(HiddenCell));
|
||||||
Assert.Empty(result.Outdoor);
|
Assert.Empty(result.OutdoorStatic);
|
||||||
Assert.Empty(result.LiveDynamic);
|
|
||||||
|
// …but a LIVE entity is never dropped by the visibility set (the old
|
||||||
|
// contract dropped it — the audit's livedynamic-invisible divergence).
|
||||||
|
Assert.Single(result.Dynamics);
|
||||||
|
Assert.Contains(liveHidden, result.Dynamics);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -85,5 +106,7 @@ public class InteriorEntityPartitionTests
|
||||||
new HashSet<uint> { CellA }, OneLb(0xA9B4FFFF, noMesh));
|
new HashSet<uint> { CellA }, OneLb(0xA9B4FFFF, noMesh));
|
||||||
|
|
||||||
Assert.False(result.ByCell.ContainsKey(CellA));
|
Assert.False(result.ByCell.ContainsKey(CellA));
|
||||||
|
Assert.Empty(result.Dynamics);
|
||||||
|
Assert.Empty(result.OutdoorStatic);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue