feat(render) Campaign FW1: port the PView interior flood
WalkPView transcribes ConstructView @0x005a57b0, InitCell @0x005a4b70, InsCellTodoList @0x005a4f50 (nearest-first pop), ClipPortals @0x005a5520, OtherPortalClip @0x005a5400 (inverted ==0 sidedness), AddViewToPortals @0x005a52d0 (update_count watermark, duplicate-reach arm), AddToCell @0x005a4d90, SetOtherSeen @0x005a4e30, FixCellList/AdjustCellPlace/ AdjustDrawList/AdjustCellView @0x005a5250/0x005a5010/0x005a4e90/ 0x005a5770, plus set_view and PView::GetClip @0x005a4320 over the WalkScreenClip chain. Resolves and documents the portal-flag convention (inflag=1 = the portal FACES the viewer and feeds max_indist; inflag=0 + armed seen = an opening — the same side condition building look-ins require), which the PDB names obscure. WalkWorld supplies the cell model and frame-context seam. Six synthetic-world flood tests pass on first run: traversal, facing rejection with the exact distance key, exit-view raising, entry-portal no-ping-pong, chain ordering, unloaded skips. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
11ca527fb9
commit
161ffaf4e9
3 changed files with 729 additions and 0 deletions
448
src/AcDream.App/Rendering/Walk/WalkPView.cs
Normal file
448
src/AcDream.App/Rendering/Walk/WalkPView.cs
Normal file
|
|
@ -0,0 +1,448 @@
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AcDream.App.Rendering.Walk;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign FW1 — retail <c>PView</c>'s interior flood, transcribed from the
|
||||||
|
/// flood-read appendix (docs/research/2026-08-30-fw-flood-pseudocode-appendix.md)
|
||||||
|
/// plus the raw bodies re-read for AddToCell @0x005a4d90, AdjustDrawList
|
||||||
|
/// @0x005a4e90, AdjustCellPlace @0x005a5010, FixCellList @0x005a5250,
|
||||||
|
/// AdjustCellView @0x005a5770, and ConstructView @0x005a57b0.
|
||||||
|
///
|
||||||
|
/// PORTAL-FLAG CONVENTION (resolved 2026-08-30 — the PDB names mislead):
|
||||||
|
/// <c>inflag</c> (+4) == 1 marks a portal whose polygon FACES the viewer as
|
||||||
|
/// a visible surface (it feeds max_indist); <c>inflag</c> == 0 with
|
||||||
|
/// <c>seen</c> (+0) == 1 marks an OPENING on the viewer's look-through side
|
||||||
|
/// (InitCell's <c>side == portal_side</c> arm — the same side condition
|
||||||
|
/// <c>ConstructView(CBldPortal)</c> REQUIRES for building look-ins).
|
||||||
|
/// ClipPortals' traversal gate <c>seen != 0 && inflag != 1</c>
|
||||||
|
/// therefore selects exactly the armed openings; the entry portal is forced
|
||||||
|
/// {seen=1, inflag=1} so the flood never walks back out of it. Transcribe;
|
||||||
|
/// do not "fix" the names.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WalkPView
|
||||||
|
{
|
||||||
|
private static int _masterTimestamp;
|
||||||
|
|
||||||
|
private readonly struct TodoEntry(WalkCell cell, float dist)
|
||||||
|
{
|
||||||
|
public readonly WalkCell Cell = cell;
|
||||||
|
public readonly float Dist = dist;
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly WalkPortalView OutsideView = new();
|
||||||
|
public readonly List<WalkCell> CellDrawList = new();
|
||||||
|
private readonly List<TodoEntry> _todo = new();
|
||||||
|
private readonly WalkPortalView _otherPortalScratch = new(); // retail's static temp_view
|
||||||
|
private Vector2[] _activeViewVerts = new Vector2[32];
|
||||||
|
private int _activeViewVertCount;
|
||||||
|
private readonly WalkScreenPoint[] _projectScratch = new WalkScreenPoint[64];
|
||||||
|
private readonly WalkScreenPoint[] _clipScratch = new WalkScreenPoint[64];
|
||||||
|
|
||||||
|
/// <summary>Retail <c>PView.draw_landscape</c>: exit portals raise
|
||||||
|
/// <see cref="OutsideView"/> only when set.</summary>
|
||||||
|
public bool DrawLandscape = true;
|
||||||
|
|
||||||
|
/// <summary>Retail <c>Render::PortalList</c> as ClipPortals installs it —
|
||||||
|
/// the last processed cell's top view (consumed by the landscape draw).</summary>
|
||||||
|
public WalkPortalView? PortalList { get; private set; }
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// ConstructView (CEnvCell overload) @0x005a57b0 — the flood.
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
public void ConstructView(WalkCell seed, int throughPortalIndex, IWalkFrameContext ctx)
|
||||||
|
{
|
||||||
|
OutsideView.ResetForPush(); // outside_view.view_count = 0 (counters only)
|
||||||
|
_masterTimestamp++;
|
||||||
|
_todo.Clear();
|
||||||
|
CellDrawList.Clear();
|
||||||
|
InitCell(seed, throughPortalIndex, ctx);
|
||||||
|
InsCellTodoList(seed, 0f);
|
||||||
|
while (_todo.Count > 0)
|
||||||
|
{
|
||||||
|
WalkCell cell = _todo[^1].Cell;
|
||||||
|
_todo.RemoveAt(_todo.Count - 1);
|
||||||
|
CellDrawList.Add(cell);
|
||||||
|
cell.TopView.CellViewDone = true;
|
||||||
|
if (ClipPortals(cell, 0, ctx))
|
||||||
|
AddViewToPortals(cell, ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// InitCell @0x005a4b70 (Ghidra-verified raw re-read 2026-08-30).
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
public bool InitCell(WalkCell cell, int entryPortalIndex, IWalkFrameContext ctx)
|
||||||
|
{
|
||||||
|
WalkPortalView top = cell.TopView;
|
||||||
|
if (top.ViewCount == 0) return false;
|
||||||
|
|
||||||
|
Vector3 viewpoint = ctx.ViewpointIn(cell);
|
||||||
|
top.CellViewDone = false;
|
||||||
|
top.ViewTimestamp = _masterTimestamp;
|
||||||
|
if (top.PortalFlags.Length < cell.Portals.Length)
|
||||||
|
top.PortalFlags = new WalkPortalFlags[cell.Portals.Length];
|
||||||
|
|
||||||
|
float maxDistSquared = 0f;
|
||||||
|
bool anyLookThrough = false; // retail var_4 (uninitialized there; benign — init false)
|
||||||
|
|
||||||
|
for (int i = 0; i < cell.Portals.Length; i++)
|
||||||
|
{
|
||||||
|
ref WalkPortalFlags flags = ref top.PortalFlags[i];
|
||||||
|
WalkPolygon poly = cell.PortalPolygons[cell.Portals[i].PolygonIndex];
|
||||||
|
|
||||||
|
if (i == entryPortalIndex && !flags.InView)
|
||||||
|
{
|
||||||
|
// Entered-through portal: forced facing + armed — never re-traversed.
|
||||||
|
flags.InView = true;
|
||||||
|
flags.Seen = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
flags.Seen = false;
|
||||||
|
float d = Vector3.Dot(poly.Plane.Normal, viewpoint) + poly.Plane.D;
|
||||||
|
if (d <= WalkVisibilityMath.Epsilon && d >= -WalkVisibilityMath.Epsilon)
|
||||||
|
{
|
||||||
|
flags.InView = false; // IN_PLANE: neither surface nor opening
|
||||||
|
anyLookThrough = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
int side = d > WalkVisibilityMath.Epsilon ? 0 : 1;
|
||||||
|
if (side == cell.Portals[i].PortalSide)
|
||||||
|
{
|
||||||
|
flags.InView = false; // viewer on the look-through side (opening)
|
||||||
|
anyLookThrough = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
flags.InView = true; // portal polygon faces the viewer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flags.InView)
|
||||||
|
{
|
||||||
|
foreach (Vector3 v in poly.Vertices)
|
||||||
|
{
|
||||||
|
float dx = viewpoint.X - v.X;
|
||||||
|
float dy = viewpoint.Y - v.Y;
|
||||||
|
float dz = viewpoint.Z - v.Z;
|
||||||
|
float d2 = dx * dx + dy * dy + dz * dz;
|
||||||
|
if (d2 > maxDistSquared) maxDistSquared = d2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
top.MaxInDistSquared = maxDistSquared;
|
||||||
|
|
||||||
|
// Fixup: arm every opening (retail runs this once per view with a
|
||||||
|
// vestigial set_view per pass; the flag writes are idempotent).
|
||||||
|
if (anyLookThrough && top.ViewCount > 0)
|
||||||
|
{
|
||||||
|
for (int j = 0; j < cell.Portals.Length; j++)
|
||||||
|
{
|
||||||
|
ref WalkPortalFlags flags = ref top.PortalFlags[j];
|
||||||
|
if (!flags.InView && !flags.Seen) flags.Seen = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
top.UpdateCount = top.ViewCount;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// InsCellTodoList @0x005a4f50 — descending by dist from index 0;
|
||||||
|
// END = nearest; pop-from-END = nearest-first (the Ghidra-corrected
|
||||||
|
// polarity: the sink stops under the first STRICTLY greater key).
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
public void InsCellTodoList(WalkCell cell, float dist)
|
||||||
|
{
|
||||||
|
int pos = _todo.Count;
|
||||||
|
while (pos > 0 && !(dist < _todo[pos - 1].Dist))
|
||||||
|
pos--;
|
||||||
|
_todo.Insert(pos, new TodoEntry(cell, dist));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// ClipPortals @0x005a5520.
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
public bool ClipPortals(WalkCell cell, int startView, IWalkFrameContext ctx)
|
||||||
|
{
|
||||||
|
WalkPortalView top = cell.TopView;
|
||||||
|
PortalList = top; // set BEFORE any early-out (retail)
|
||||||
|
if (cell.Portals.Length <= 0) return false;
|
||||||
|
|
||||||
|
// Pass 1: which portals are live; resolve+cache neighbors.
|
||||||
|
bool anyLive = false;
|
||||||
|
for (int j = 0; j < cell.Portals.Length; j++)
|
||||||
|
{
|
||||||
|
ref WalkPortalFlags flags = ref top.PortalFlags[j];
|
||||||
|
if (!flags.Seen || flags.InView) continue;
|
||||||
|
ref WalkCellPortal portal = ref cell.Portals[j];
|
||||||
|
if (cell.CachedNeighbors[j] is null && portal.OtherCellId != 0xFFFFFFFFu)
|
||||||
|
{
|
||||||
|
cell.CachedNeighbors[j] = ctx.GetVisible(portal.OtherCellId);
|
||||||
|
if (cell.CachedNeighbors[j] is null) continue; // not loaded: silently dead
|
||||||
|
}
|
||||||
|
anyLive = true;
|
||||||
|
}
|
||||||
|
if (!anyLive) return false;
|
||||||
|
|
||||||
|
// Pass 2: clip every live portal against each view in the window.
|
||||||
|
for (int i = startView; i < top.ViewCount; i++)
|
||||||
|
{
|
||||||
|
SetView(top, i);
|
||||||
|
for (int j = 0; j < cell.Portals.Length; j++)
|
||||||
|
{
|
||||||
|
ref WalkPortalFlags flags = ref top.PortalFlags[j];
|
||||||
|
if (!flags.Seen || flags.InView) continue;
|
||||||
|
ref WalkCellPortal portal = ref cell.Portals[j];
|
||||||
|
int n = GetClip(
|
||||||
|
cell, portal.PortalSide,
|
||||||
|
cell.PortalPolygons[portal.PolygonIndex],
|
||||||
|
doClip: true, ctx, _clipScratch);
|
||||||
|
if (n == 0) continue;
|
||||||
|
|
||||||
|
if (portal.OtherCellId == 0xFFFFFFFFu)
|
||||||
|
{
|
||||||
|
if (DrawLandscape)
|
||||||
|
{
|
||||||
|
if (ctx.ClipLandscape)
|
||||||
|
WalkCopyView.Append(
|
||||||
|
OutsideView, _clipScratch.AsSpan(0, n),
|
||||||
|
ctx.Rays, ctx.WorldViewpoint);
|
||||||
|
else
|
||||||
|
WalkCopyView.AppendFullViewportQuad(
|
||||||
|
OutsideView, ctx.Rays, ctx.WorldViewpoint,
|
||||||
|
ctx.ViewportWidth, ctx.ViewportHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (cell.CachedNeighbors[j] is WalkCell neighbor)
|
||||||
|
{
|
||||||
|
if (!portal.ExactMatch && portal.OtherPortalId >= 0)
|
||||||
|
{
|
||||||
|
n = OtherPortalClip(cell, j, n, ctx);
|
||||||
|
SetView(top, i); // restore after the far-frame excursion
|
||||||
|
if (n == 0) continue;
|
||||||
|
}
|
||||||
|
if (neighbor.NumView != 0)
|
||||||
|
WalkCopyView.Append(
|
||||||
|
neighbor.TopView, _clipScratch.AsSpan(0, n),
|
||||||
|
ctx.Rays, ctx.WorldViewpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// OtherPortalClip @0x005a5400 — the double clip for non-exact_match
|
||||||
|
// portals: snapshot the near-clipped poly as a temp view, then re-clip
|
||||||
|
// against the FAR cell's own portal polygon with INVERTED sidedness
|
||||||
|
// (a literal == 0 test, not an XOR).
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
private int OtherPortalClip(
|
||||||
|
WalkCell cell, int portalIndex, int count, IWalkFrameContext ctx)
|
||||||
|
{
|
||||||
|
_otherPortalScratch.ResetForPush();
|
||||||
|
if (!WalkCopyView.Append(
|
||||||
|
_otherPortalScratch, _clipScratch.AsSpan(0, count),
|
||||||
|
ctx.Rays, ctx.WorldViewpoint))
|
||||||
|
return 0;
|
||||||
|
ref WalkCellPortal portal = ref cell.Portals[portalIndex];
|
||||||
|
WalkCell far = cell.CachedNeighbors[portalIndex]
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"OtherPortalClip requires a resolved neighbor (ClipPortals pass 1 contract).");
|
||||||
|
ref WalkCellPortal farPortal = ref far.Portals[portal.OtherPortalId];
|
||||||
|
SetView(_otherPortalScratch, 0);
|
||||||
|
return GetClip(
|
||||||
|
far, farPortal.PortalSide == 0 ? 1 : 0,
|
||||||
|
far.PortalPolygons[farPortal.PolygonIndex],
|
||||||
|
doClip: true, ctx, _clipScratch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// AddViewToPortals @0x005a52d0.
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
public void AddViewToPortals(WalkCell cell, IWalkFrameContext ctx)
|
||||||
|
{
|
||||||
|
for (int j = 0; j < cell.Portals.Length; j++)
|
||||||
|
{
|
||||||
|
ref WalkCellPortal portal = ref cell.Portals[j];
|
||||||
|
WalkCell? neighbor = cell.CachedNeighbors[j];
|
||||||
|
ref WalkPortalFlags flags = ref cell.TopView.PortalFlags[j];
|
||||||
|
if (neighbor is null || flags.InView || !flags.Seen || neighbor.NumView == 0)
|
||||||
|
continue;
|
||||||
|
WalkPortalView neighborTop = neighbor.TopView;
|
||||||
|
if (neighborTop.ViewCount == 0) continue;
|
||||||
|
|
||||||
|
if (neighborTop.UpdateCount == 0)
|
||||||
|
{
|
||||||
|
// First touch this flood: schedule the neighbor.
|
||||||
|
if (InitCell(neighbor, ToEntryIndex(portal.OtherPortalId), ctx))
|
||||||
|
InsCellTodoList(neighbor, neighborTop.MaxInDistSquared);
|
||||||
|
}
|
||||||
|
else if (neighborTop.UpdateCount != neighborTop.ViewCount)
|
||||||
|
{
|
||||||
|
// Duplicate reach with NEW views since last processed.
|
||||||
|
AddToCell(neighbor, ToEntryIndex(portal.OtherPortalId));
|
||||||
|
if (neighborTop.CellViewDone)
|
||||||
|
FixCellList(neighbor, cell, ctx);
|
||||||
|
neighborTop.UpdateCount = neighborTop.ViewCount; // fresh re-read after recursion
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
continue; // nothing new; NO SetOtherSeen either
|
||||||
|
}
|
||||||
|
|
||||||
|
if (portal.OtherPortalId >= 0) // full-width signed test (−1 sentinel skips)
|
||||||
|
SetOtherSeen(cell, j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Retail passes other_portal_id as a ZERO-EXTENDED 16-bit read:
|
||||||
|
/// −1 becomes 0xFFFF, InitCell/AddToCell's never-matching sentinel.</summary>
|
||||||
|
private static int ToEntryIndex(int otherPortalId)
|
||||||
|
=> otherPortalId < 0 ? 0xFFFF : otherPortalId;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// AddToCell @0x005a4d90 — the duplicate-reach flag refresh over the
|
||||||
|
// NEW views window only.
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
public void AddToCell(WalkCell cell, int entryPortalIndex)
|
||||||
|
{
|
||||||
|
WalkPortalView top = cell.TopView;
|
||||||
|
for (int i = top.UpdateCount; i < top.ViewCount; i++)
|
||||||
|
{
|
||||||
|
// retail set_view(top.view, i) here is side-effect-only (vestigial)
|
||||||
|
for (int j = 0; j < cell.Portals.Length; j++)
|
||||||
|
{
|
||||||
|
ref WalkPortalFlags flags = ref top.PortalFlags[j];
|
||||||
|
if (j == entryPortalIndex && !flags.InView) flags.InView = true;
|
||||||
|
if (!flags.InView && !flags.Seen) flags.Seen = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// SetOtherSeen @0x005a4e30: fill the neighbor's backlink and arm its
|
||||||
|
// back-portal when that portal faces the neighbor's viewer.
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
public void SetOtherSeen(WalkCell cell, int portalIndex)
|
||||||
|
{
|
||||||
|
WalkCell? neighbor = cell.CachedNeighbors[portalIndex];
|
||||||
|
if (neighbor is null) return;
|
||||||
|
int backIndex = cell.Portals[portalIndex].OtherPortalId;
|
||||||
|
ref WalkCellPortal backPortal = ref neighbor.Portals[backIndex];
|
||||||
|
if (neighbor.CachedNeighbors[backIndex] is null)
|
||||||
|
neighbor.CachedNeighbors[backIndex] = cell;
|
||||||
|
ref WalkPortalFlags backFlags = ref neighbor.TopView.PortalFlags[backIndex];
|
||||||
|
if (backFlags.InView) backFlags.Seen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// FixCellList @0x005a5250 = AdjustCellPlace + AdjustCellView.
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
public void FixCellList(WalkCell moved, WalkCell reachedThrough, IWalkFrameContext ctx)
|
||||||
|
{
|
||||||
|
AdjustCellPlace(moved, reachedThrough);
|
||||||
|
AdjustCellView(moved, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdjustCellPlace @0x005a5010: re-place, then recurse through the
|
||||||
|
// reaching cell's armed facing portals.
|
||||||
|
private void AdjustCellPlace(WalkCell moved, WalkCell reachedThrough)
|
||||||
|
{
|
||||||
|
WalkPortalView top = reachedThrough.TopView;
|
||||||
|
if (!AdjustDrawList(moved, reachedThrough)) return;
|
||||||
|
for (int i = 0; i < reachedThrough.Portals.Length; i++)
|
||||||
|
{
|
||||||
|
ref WalkPortalFlags flags = ref top.PortalFlags[i];
|
||||||
|
if (flags.Seen && flags.InView && reachedThrough.CachedNeighbors[i] is WalkCell next)
|
||||||
|
AdjustCellPlace(reachedThrough, next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdjustDrawList @0x005a4e90: if `moved` appears in the draw list BEFORE
|
||||||
|
// `reachedThrough`, insert `reachedThrough` at `moved`'s slot (shifting
|
||||||
|
// the range up) so the walk-from-the-end draws `reachedThrough` earlier
|
||||||
|
// (farther). Appends `reachedThrough` when absent. Returns true when a
|
||||||
|
// move happened.
|
||||||
|
private bool AdjustDrawList(WalkCell moved, WalkCell reachedThrough)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < CellDrawList.Count; i++)
|
||||||
|
{
|
||||||
|
uint id = CellDrawList[i].CellId;
|
||||||
|
if (id == reachedThrough.CellId) break; // already earlier: nothing to do
|
||||||
|
if (id != moved.CellId) continue;
|
||||||
|
|
||||||
|
int at = i;
|
||||||
|
while (at < CellDrawList.Count && CellDrawList[at].CellId != reachedThrough.CellId)
|
||||||
|
at++;
|
||||||
|
if (at == CellDrawList.Count)
|
||||||
|
CellDrawList.Add(reachedThrough); // conceptual append (grown below by the shift)
|
||||||
|
for (int k = at; k > i; k--)
|
||||||
|
CellDrawList[k] = CellDrawList[k - 1];
|
||||||
|
CellDrawList[i] = reachedThrough;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdjustCellView @0x005a5770: incremental re-flood over the new-views
|
||||||
|
// window only (the update_count watermark).
|
||||||
|
private void AdjustCellView(WalkCell cell, IWalkFrameContext ctx)
|
||||||
|
{
|
||||||
|
if (ClipPortals(cell, cell.TopView.UpdateCount, ctx))
|
||||||
|
AddViewToPortals(cell, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// set_view @0x0054d0e0 (the slice GetClip consumes: the installed
|
||||||
|
// view's screen vertices).
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
public void SetView(WalkPortalView portalView, int polyIndex)
|
||||||
|
{
|
||||||
|
WalkViewPoly poly = portalView.View.Polys[polyIndex];
|
||||||
|
if (_activeViewVerts.Length < poly.VertexCount)
|
||||||
|
_activeViewVerts = new Vector2[poly.VertexCount];
|
||||||
|
for (int k = 0; k < poly.VertexCount; k++)
|
||||||
|
_activeViewVerts[k] = portalView.View.Vertices[poly.VertexIndex + k].Point;
|
||||||
|
_activeViewVertCount = poly.VertexCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// PView::GetClip @0x005a4320: project, order by sidedness, optionally
|
||||||
|
// clip against the installed view. Returns the surviving count (0 on
|
||||||
|
// full rejection — the caller's pre-zero contract).
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
public int GetClip(
|
||||||
|
WalkCell cell, int side, WalkPolygon polygon, bool doClip,
|
||||||
|
IWalkFrameContext ctx, Span<WalkScreenPoint> output)
|
||||||
|
{
|
||||||
|
int n = polygon.Vertices.Length;
|
||||||
|
Matrix4x4 objectToClip = ctx.ObjectToClip(cell);
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
{
|
||||||
|
_projectScratch[i] = WalkScreenClip.TransformToScreen(
|
||||||
|
polygon.Vertices[i], objectToClip, ctx.ViewportWidth, ctx.ViewportHeight);
|
||||||
|
}
|
||||||
|
// Sidedness != POSITIVE reverses the winding (IN_PLANE takes the
|
||||||
|
// reversed branch too, though flood callers never pass it).
|
||||||
|
if (side != 0)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < n / 2; i++)
|
||||||
|
(_projectScratch[i], _projectScratch[n - 1 - i])
|
||||||
|
= (_projectScratch[n - 1 - i], _projectScratch[i]);
|
||||||
|
}
|
||||||
|
if (!doClip)
|
||||||
|
{
|
||||||
|
_projectScratch.AsSpan(0, n).CopyTo(output);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
return WalkScreenClip.ClipAgainstView(
|
||||||
|
_projectScratch.AsSpan(0, n),
|
||||||
|
_activeViewVerts.AsSpan(0, _activeViewVertCount),
|
||||||
|
output);
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/AcDream.App/Rendering/Walk/WalkWorld.cs
Normal file
89
src/AcDream.App/Rendering/Walk/WalkWorld.cs
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AcDream.App.Rendering.Walk;
|
||||||
|
|
||||||
|
/// <summary>A cell-local portal polygon with its plane (retail
|
||||||
|
/// <c>CPolygon</c>: vertices + <c>plane</c> @+0x20).</summary>
|
||||||
|
public sealed class WalkPolygon
|
||||||
|
{
|
||||||
|
public Vector3[] Vertices = [];
|
||||||
|
public WalkPlane Plane;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Retail <c>CCellPortal</c> (stride 0x18). The exit-to-landscape
|
||||||
|
/// sentinel is <c>OtherCellId == 0xFFFFFFFF</c>; <c>OtherPortalId == -1</c>
|
||||||
|
/// means "no reciprocal".</summary>
|
||||||
|
public struct WalkCellPortal
|
||||||
|
{
|
||||||
|
public uint OtherCellId;
|
||||||
|
public int PolygonIndex;
|
||||||
|
public int PortalSide;
|
||||||
|
public int OtherPortalId;
|
||||||
|
public bool ExactMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The flood's cell model (retail <c>CEnvCell</c> as the walk sees it).
|
||||||
|
/// Retail stores the walk state ON the cell (num_view / portal_view stack /
|
||||||
|
/// cached neighbor pointers); this port keeps the same placement so the
|
||||||
|
/// transcription stays line-by-line — production adapters construct these
|
||||||
|
/// from the committed cell registry (FW3 wiring).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WalkCell
|
||||||
|
{
|
||||||
|
public uint CellId;
|
||||||
|
public WalkCellPortal[] Portals = [];
|
||||||
|
public WalkPolygon[] PortalPolygons = [];
|
||||||
|
public uint[] StabList = [];
|
||||||
|
|
||||||
|
// ---- walk state (retail: fields on CEnvCell) ----
|
||||||
|
public int NumView;
|
||||||
|
public readonly List<WalkPortalView> PortalViews = new();
|
||||||
|
public WalkCell?[] CachedNeighbors = [];
|
||||||
|
|
||||||
|
public WalkPortalView TopView => PortalViews[NumView - 1];
|
||||||
|
|
||||||
|
/// <summary><c>CEnvCell::curr_view_push</c> @0x005a5090: push one
|
||||||
|
/// view-recursion level (lazy slot, exactly three counters reset).</summary>
|
||||||
|
public void PushView()
|
||||||
|
{
|
||||||
|
while (PortalViews.Count <= NumView)
|
||||||
|
PortalViews.Add(new WalkPortalView());
|
||||||
|
PortalViews[NumView].ResetForPush();
|
||||||
|
NumView++;
|
||||||
|
if (CachedNeighbors.Length < Portals.Length)
|
||||||
|
CachedNeighbors = new WalkCell?[Portals.Length];
|
||||||
|
}
|
||||||
|
|
||||||
|
public void PopView() => NumView--;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The per-frame context retail keeps in globals (<c>Render::FrameCurrent</c>
|
||||||
|
/// after <c>positionPush(3, cell.pos)</c>, the projection state, the visible
|
||||||
|
/// cell registry, and the <c>cliplandscape</c> toggle — .data default 1).
|
||||||
|
/// </summary>
|
||||||
|
public interface IWalkFrameContext
|
||||||
|
{
|
||||||
|
/// <summary>The eye position in the cell's local frame (retail: the
|
||||||
|
/// pushed frame's <c>viewer.viewpoint</c>).</summary>
|
||||||
|
Vector3 ViewpointIn(WalkCell cell);
|
||||||
|
|
||||||
|
/// <summary>Object(cell-local)→clip matrix for projecting the cell's
|
||||||
|
/// portal polygons (retail: the pushed frame composed with
|
||||||
|
/// WorldToView·ViewToClip).</summary>
|
||||||
|
Matrix4x4 ObjectToClip(WalkCell cell);
|
||||||
|
|
||||||
|
/// <summary><c>CEnvCell::GetVisible</c>: the committed/visible cell
|
||||||
|
/// registry. Returning null skips the portal silently (retail behavior —
|
||||||
|
/// but implementations should count the miss for the fail-loud rule).</summary>
|
||||||
|
WalkCell? GetVisible(uint cellId);
|
||||||
|
|
||||||
|
IWalkRayCaster Rays { get; }
|
||||||
|
Vector3 WorldViewpoint { get; }
|
||||||
|
float ViewportWidth { get; }
|
||||||
|
float ViewportHeight { get; }
|
||||||
|
|
||||||
|
/// <summary>Retail global <c>cliplandscape</c> (.data @0x00820f4c = 1).</summary>
|
||||||
|
bool ClipLandscape => true;
|
||||||
|
}
|
||||||
192
tests/AcDream.App.Tests/Rendering/Walk/WalkPViewFloodTests.cs
Normal file
192
tests/AcDream.App.Tests/Rendering/Walk/WalkPViewFloodTests.cs
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.Rendering.Walk;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Rendering.Walk;
|
||||||
|
|
||||||
|
public sealed class WalkPViewFloodTests
|
||||||
|
{
|
||||||
|
private sealed class TestContext : IWalkFrameContext
|
||||||
|
{
|
||||||
|
private sealed class Caster : IWalkRayCaster
|
||||||
|
{
|
||||||
|
public Vector3 RayThrough(float screenX, float screenY)
|
||||||
|
=> new(screenX, screenY, 100f);
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly Dictionary<uint, WalkCell> Cells = new();
|
||||||
|
private readonly Matrix4x4 _viewProj;
|
||||||
|
|
||||||
|
public TestContext()
|
||||||
|
{
|
||||||
|
Matrix4x4 view = Matrix4x4.CreateLookAt(
|
||||||
|
Vector3.Zero, new Vector3(0, 0, -1), Vector3.UnitY);
|
||||||
|
Matrix4x4 proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1f, 0.1f, 1000f);
|
||||||
|
_viewProj = view * proj;
|
||||||
|
}
|
||||||
|
|
||||||
|
// All test cells sit at identity transforms: cell-local == world.
|
||||||
|
public Vector3 ViewpointIn(WalkCell cell) => Vector3.Zero;
|
||||||
|
public Matrix4x4 ObjectToClip(WalkCell cell) => _viewProj;
|
||||||
|
public WalkCell? GetVisible(uint cellId) => Cells.GetValueOrDefault(cellId);
|
||||||
|
public IWalkRayCaster Rays { get; } = new Caster();
|
||||||
|
public Vector3 WorldViewpoint => Vector3.Zero;
|
||||||
|
public float ViewportWidth => 640f;
|
||||||
|
public float ViewportHeight => 480f;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WalkPolygon Quad(float z, float half = 0.5f, bool facingViewer = true) => new()
|
||||||
|
{
|
||||||
|
Vertices =
|
||||||
|
[
|
||||||
|
new Vector3(-half, -half, z), new Vector3(half, -half, z),
|
||||||
|
new Vector3(half, half, z), new Vector3(-half, half, z),
|
||||||
|
],
|
||||||
|
// Plane through the quad: for z=-2 facing +z, N=(0,0,1), D=2 (eye at
|
||||||
|
// origin sits on the POSITIVE side: d = +2).
|
||||||
|
Plane = new WalkPlane(new Vector3(0, 0, facingViewer ? 1f : -1f), facingViewer ? -z : z),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static WalkCell Cell(
|
||||||
|
TestContext ctx, uint id, params (WalkCellPortal Portal, WalkPolygon Polygon)[] portals)
|
||||||
|
{
|
||||||
|
var cell = new WalkCell
|
||||||
|
{
|
||||||
|
CellId = id,
|
||||||
|
Portals = portals.Select(p => p.Portal).ToArray(),
|
||||||
|
PortalPolygons = portals.Select(p => p.Polygon).ToArray(),
|
||||||
|
};
|
||||||
|
cell.PushView(); // add_views/stab-list stand-in: one pushed slot
|
||||||
|
ctx.Cells[id] = cell;
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WalkPView SeedAndFlood(TestContext ctx, WalkCell seed)
|
||||||
|
{
|
||||||
|
var pview = new WalkPView();
|
||||||
|
WalkCopyView.AppendFullViewportQuad(
|
||||||
|
seed.TopView, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
|
||||||
|
pview.ConstructView(seed, 0xFFFF, ctx);
|
||||||
|
return pview;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Flood_traverses_a_look_through_portal_into_the_neighbor()
|
||||||
|
{
|
||||||
|
var ctx = new TestContext();
|
||||||
|
// Eye on the positive side of the portal plane (d=+2 → side 0);
|
||||||
|
// PortalSide=0 → side == PortalSide → an OPENING (look-through).
|
||||||
|
WalkCell seed = Cell(ctx, 0x100,
|
||||||
|
(new WalkCellPortal { OtherCellId = 0x101, PolygonIndex = 0, PortalSide = 0, OtherPortalId = 0 },
|
||||||
|
Quad(-2f)));
|
||||||
|
WalkCell neighbor = Cell(ctx, 0x101,
|
||||||
|
(new WalkCellPortal { OtherCellId = 0x100, PolygonIndex = 0, PortalSide = 1, OtherPortalId = 0 },
|
||||||
|
Quad(-2f)));
|
||||||
|
|
||||||
|
WalkPView pview = SeedAndFlood(ctx, seed);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { 0x100u, 0x101u }, pview.CellDrawList.Select(c => c.CellId));
|
||||||
|
Assert.Equal(1, neighbor.TopView.ViewCount); // the clipped view arrived
|
||||||
|
Assert.True(neighbor.TopView.CellViewDone); // and the neighbor was processed
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Facing_portal_is_not_traversed_but_feeds_the_distance_key()
|
||||||
|
{
|
||||||
|
var ctx = new TestContext();
|
||||||
|
// PortalSide=1 while the eye computes side 0 → the portal FACES the
|
||||||
|
// viewer (a visible surface, not an opening).
|
||||||
|
WalkCell seed = Cell(ctx, 0x100,
|
||||||
|
(new WalkCellPortal { OtherCellId = 0x101, PolygonIndex = 0, PortalSide = 1, OtherPortalId = 0 },
|
||||||
|
Quad(-2f)));
|
||||||
|
Cell(ctx, 0x101,
|
||||||
|
(new WalkCellPortal { OtherCellId = 0x100, PolygonIndex = 0, PortalSide = 0, OtherPortalId = 0 },
|
||||||
|
Quad(-2f)));
|
||||||
|
|
||||||
|
WalkPView pview = SeedAndFlood(ctx, seed);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { 0x100u }, pview.CellDrawList.Select(c => c.CellId));
|
||||||
|
// max_indist = squared distance to the farthest facing-portal vertex:
|
||||||
|
// (±0.5, ±0.5, −2) from the origin → 0.25 + 0.25 + 4.
|
||||||
|
Assert.Equal(4.5f, seed.TopView.MaxInDistSquared, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Exit_portal_raises_the_outside_view_only_when_landscape_is_drawn()
|
||||||
|
{
|
||||||
|
var ctx = new TestContext();
|
||||||
|
WalkCell seed = Cell(ctx, 0x100,
|
||||||
|
(new WalkCellPortal { OtherCellId = 0xFFFFFFFF, PolygonIndex = 0, PortalSide = 0, OtherPortalId = -1 },
|
||||||
|
Quad(-2f)));
|
||||||
|
|
||||||
|
WalkPView pview = SeedAndFlood(ctx, seed);
|
||||||
|
Assert.Equal(1, pview.OutsideView.ViewCount);
|
||||||
|
|
||||||
|
// draw_landscape == 0 discards exit views entirely.
|
||||||
|
seed.PopView();
|
||||||
|
seed.PushView();
|
||||||
|
var noLandscape = new WalkPView { DrawLandscape = false };
|
||||||
|
WalkCopyView.AppendFullViewportQuad(
|
||||||
|
seed.TopView, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
|
||||||
|
noLandscape.ConstructView(seed, 0xFFFF, ctx);
|
||||||
|
Assert.Equal(0, noLandscape.OutsideView.ViewCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Flood_never_walks_back_through_the_entry_portal()
|
||||||
|
{
|
||||||
|
var ctx = new TestContext();
|
||||||
|
// Two cells looking into each other; both sides traversable by
|
||||||
|
// geometry. Without the entry-portal force, the flood would ping-pong.
|
||||||
|
WalkCell a = Cell(ctx, 0x100,
|
||||||
|
(new WalkCellPortal { OtherCellId = 0x101, PolygonIndex = 0, PortalSide = 0, OtherPortalId = 0 },
|
||||||
|
Quad(-2f)));
|
||||||
|
WalkCell b = Cell(ctx, 0x101,
|
||||||
|
(new WalkCellPortal { OtherCellId = 0x100, PolygonIndex = 0, PortalSide = 0, OtherPortalId = 0 },
|
||||||
|
Quad(-2f)));
|
||||||
|
|
||||||
|
WalkPView pview = SeedAndFlood(ctx, a);
|
||||||
|
|
||||||
|
Assert.Equal(2, pview.CellDrawList.Count);
|
||||||
|
Assert.Equal(2, pview.CellDrawList.Select(c => c.CellId).Distinct().Count());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Deeper_chain_floods_in_nearest_first_pop_order()
|
||||||
|
{
|
||||||
|
var ctx = new TestContext();
|
||||||
|
// seed → mid (portal at z=-2) → far (portal at z=-4): the draw list
|
||||||
|
// appends in pop order (nearest first), so the end-first draw walk
|
||||||
|
// is far-to-near.
|
||||||
|
WalkCell seed = Cell(ctx, 0x100,
|
||||||
|
(new WalkCellPortal { OtherCellId = 0x101, PolygonIndex = 0, PortalSide = 0, OtherPortalId = 0 },
|
||||||
|
Quad(-2f)));
|
||||||
|
WalkCell mid = Cell(ctx, 0x101,
|
||||||
|
(new WalkCellPortal { OtherCellId = 0x100, PolygonIndex = 0, PortalSide = 1, OtherPortalId = 0 },
|
||||||
|
Quad(-2f)),
|
||||||
|
(new WalkCellPortal { OtherCellId = 0x102, PolygonIndex = 1, PortalSide = 0, OtherPortalId = 0 },
|
||||||
|
Quad(-4f, half: 0.4f)));
|
||||||
|
WalkCell far = Cell(ctx, 0x102,
|
||||||
|
(new WalkCellPortal { OtherCellId = 0x101, PolygonIndex = 0, PortalSide = 1, OtherPortalId = 1 },
|
||||||
|
Quad(-4f, half: 0.4f)));
|
||||||
|
|
||||||
|
WalkPView pview = SeedAndFlood(ctx, seed);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
new[] { 0x100u, 0x101u, 0x102u },
|
||||||
|
pview.CellDrawList.Select(c => c.CellId));
|
||||||
|
Assert.Equal(1, far.TopView.ViewCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unloaded_neighbor_is_silently_skipped()
|
||||||
|
{
|
||||||
|
var ctx = new TestContext();
|
||||||
|
WalkCell seed = Cell(ctx, 0x100,
|
||||||
|
(new WalkCellPortal { OtherCellId = 0x0DEAD, PolygonIndex = 0, PortalSide = 0, OtherPortalId = 0 },
|
||||||
|
Quad(-2f)));
|
||||||
|
|
||||||
|
WalkPView pview = SeedAndFlood(ctx, seed);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { 0x100u }, pview.CellDrawList.Select(c => c.CellId));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue