The indoor GREY flap at a top-floor connecting room. The render portal side-cull reconstructed each doorway's "interior side" (PortalClipPlane.InsideSide) from the cell's AABB CENTROID. For a THIN connector cell (0xF6820118, 5 render polys), the bounding-box center falls on the WRONG side of the 0118->0116 doorway, so the eye read as a back-portal and the forward room 0116 was culled -> the aperture showed the fog clear color = grey. Retail's PView::InitCell (0x005a4b70) and acdream's own PHYSICS path (CellTransit.cs:190) both read the explicit dat PortalSide bit ((Flags&2)==0) instead of guessing from geometry. Port the render path (GameWindow.BuildLoadedCell) to the same bit. Proven by a live retail cdb trace (retail draws 0116 from the 0118 root at the grey pose; tools/cdb/issue186-connector-decider.cdb) + an offline dat diagnostic (Issue186...PortalSide_CentroidVsDatBit_AtGreyEye): the dat bit matches the old centroid on every portal of these cells EXCEPT the one #186 breaks, so the switch is surgical. Full regression green (App 741 / Core 2631); the CornerFlood + Issue113 dat-loading helpers updated to the same bit confirm every real Holtburg/tower/hall cell floods identically. Touches neither PortalSideEpsilon nor the deleted EyeInsidePortalOpening rescue (the two DO-NOT-RETRY traps). Live-gated: user-confirmed no grey at any camera angle; probe shows 216 root=0118 frames, 0 still grey (0118->0116 now TRV, vis=4). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
197 lines
11 KiB
C#
197 lines
11 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Numerics;
|
|
using DatReaderWriter;
|
|
using DatReaderWriter.DBObjs;
|
|
using DatReaderWriter.Options;
|
|
using Xunit;
|
|
using Xunit.Abstractions;
|
|
using Env = System.Environment;
|
|
|
|
namespace AcDream.Core.Tests.Physics;
|
|
|
|
/// <summary>
|
|
/// #186 diagnostic (report-only): dump the render-shell + collision geometry of the
|
|
/// connecting-room grey-flap cells (0xF6820116 player room, 0xF6820117 next room,
|
|
/// 0xF6820118 the connector root where the frame greys). Answers: is 0118 a CLOSED
|
|
/// shell (walls/floor/ceiling covering every direction bar the portal apertures) or a
|
|
/// bare connector? And does it carry collision geometry (so the camera sweep would
|
|
/// hard-stop) or none (so the boom coasts to a degenerate spot)? Skips without the dat.
|
|
/// </summary>
|
|
public class Issue186ConnectorCellGeometryInspectionTests
|
|
{
|
|
private readonly ITestOutputHelper _out;
|
|
public Issue186ConnectorCellGeometryInspectionTests(ITestOutputHelper output) => _out = output;
|
|
|
|
private static string? DatDir()
|
|
{
|
|
var d = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
|
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile), "Documents", "Asheron's Call");
|
|
return Directory.Exists(d) ? d : null;
|
|
}
|
|
|
|
[Fact]
|
|
public void Dump_ConnectorCells_ShellAndCollision()
|
|
{
|
|
var datDir = DatDir();
|
|
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
|
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
|
|
|
foreach (uint cellId in new[] { 0xF6820116u, 0xF6820117u, 0xF6820118u })
|
|
{
|
|
var env = dats.Get<EnvCell>(cellId);
|
|
if (env is null) { _out.WriteLine($"cell 0x{cellId:X8}: EnvCell NOT FOUND"); continue; }
|
|
|
|
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | env.EnvironmentId);
|
|
environment!.Cells.TryGetValue(env.CellStructure, out var cs);
|
|
|
|
var portals = env.CellPortals?.Select(p => $"0x{(0xF6820000u | p.OtherCellId):X8}") ?? Enumerable.Empty<string>();
|
|
_out.WriteLine($"=== cell 0x{cellId:X8} envId=0x{env.EnvironmentId:X4} struct={env.CellStructure} " +
|
|
$"pos=({env.Position.Origin.X:F2},{env.Position.Origin.Y:F2},{env.Position.Origin.Z:F2}) ===");
|
|
_out.WriteLine($" CellPortals -> [{string.Join(",", portals)}] VisibleCells={env.VisibleCells?.Count ?? 0}");
|
|
|
|
if (cs is null) { _out.WriteLine(" CellStruct: NULL"); continue; }
|
|
|
|
int renderPolys = cs.Polygons?.Count ?? 0;
|
|
int physPolys = cs.PhysicsPolygons?.Count ?? 0;
|
|
bool physBsp = cs.PhysicsBSP?.Root is not null;
|
|
bool cellBsp = cs.CellBSP?.Root is not null;
|
|
_out.WriteLine($" renderPolys={renderPolys} physicsPolys={physPolys} physicsBSP={(physBsp ? "YES" : "NO")} cellBSP={(cellBsp ? "YES" : "NO")}");
|
|
|
|
// Render-shell normal coverage: bucket each render poly's local normal by
|
|
// dominant axis to see whether the shell encloses (has +/-X,+/-Y,+/-Z faces).
|
|
if (cs.Polygons is not null && cs.VertexArray?.Vertices is not null)
|
|
{
|
|
var buckets = new Dictionary<string, int>();
|
|
foreach (var poly in cs.Polygons.Values)
|
|
{
|
|
var n = PolyNormal(poly, cs.VertexArray);
|
|
if (n is null) continue;
|
|
buckets.TryGetValue(DomAxis(n.Value), out int c);
|
|
buckets[DomAxis(n.Value)] = c + 1;
|
|
}
|
|
_out.WriteLine(" render-shell normal buckets: " +
|
|
string.Join(" ", buckets.OrderBy(k => k.Key).Select(k => $"{k.Key}={k.Value}")));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// #186 root-cause probe (report-only): for every portal of the three connector
|
|
/// cells, reproduce acdream's RENDER side test (centroid-derived InsideSide,
|
|
/// GameWindow.cs:7425-7438 + PortalVisibilityBuilder.cs:857-864) and compare it to
|
|
/// the dat <c>PortalSide</c> bit that retail's PView::InitCell (0x005a4b70) and
|
|
/// acdream's own PHYSICS path (CellTransit.cs:190) use. Evaluates each at the live
|
|
/// retail/acdream grey-pose eye. The retail cdb trace proved retail ADMITS 0118->0116
|
|
/// at this eye; acdream CULLs it. This dump shows whether the centroid InsideSide
|
|
/// disagrees with the dat PortalSide for 0118->0116 (the fix) and AGREES elsewhere
|
|
/// (so the fix is surgical, not a global side-test change).
|
|
/// </summary>
|
|
[Fact]
|
|
public void PortalSide_CentroidVsDatBit_AtGreyEye()
|
|
{
|
|
var datDir = DatDir();
|
|
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
|
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
|
|
|
// Live grey-pose eye (fresh HEAD capture + retail cdb trace both ~here).
|
|
var eyeWorld = new Vector3(135.51f, 83.32f, 69.16f);
|
|
|
|
var disagreements = new List<string>(); // portals where centroid != dat bit
|
|
bool sawConnectorBackPortal = false; // the 0118->0116 case
|
|
bool connectorCentroidCulls = false, connectorDatAdmits = false;
|
|
|
|
foreach (uint cellId in new[] { 0xF6820116u, 0xF6820117u, 0xF6820118u })
|
|
{
|
|
var env = dats.Get<EnvCell>(cellId);
|
|
if (env is null) { _out.WriteLine($"cell 0x{cellId:X8}: EnvCell NOT FOUND"); continue; }
|
|
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | env.EnvironmentId);
|
|
if (environment is null || !environment.Cells.TryGetValue(env.CellStructure, out var cs) || cs is null)
|
|
{ _out.WriteLine($"cell 0x{cellId:X8}: no CellStruct"); continue; }
|
|
|
|
var origin = new Vector3(env.Position.Origin.X, env.Position.Origin.Y, env.Position.Origin.Z);
|
|
var orient = new Quaternion(env.Position.Orientation.X, env.Position.Orientation.Y,
|
|
env.Position.Orientation.Z, env.Position.Orientation.W);
|
|
var world = Matrix4x4.CreateFromQuaternion(orient) * Matrix4x4.CreateTranslation(origin);
|
|
Matrix4x4.Invert(world, out var inverse);
|
|
var localEye = Vector3.Transform(eyeWorld, inverse);
|
|
|
|
// Centroid = AABB center over ALL cell verts (GameWindow.cs:7373-7394).
|
|
var bmin = new Vector3(float.MaxValue); var bmax = new Vector3(float.MinValue);
|
|
foreach (var kv in cs.VertexArray!.Vertices)
|
|
{ var p = new Vector3(kv.Value.Origin.X, kv.Value.Origin.Y, kv.Value.Origin.Z);
|
|
bmin = Vector3.Min(bmin, p); bmax = Vector3.Max(bmax, p); }
|
|
var centroid = (bmin + bmax) * 0.5f;
|
|
|
|
_out.WriteLine($"=== cell 0x{cellId:X8} localEye=({localEye.X:F2},{localEye.Y:F2},{localEye.Z:F2}) ===");
|
|
foreach (var portal in env.CellPortals!)
|
|
{
|
|
if (!cs.Polygons!.TryGetValue(portal.PolygonId, out var poly) || poly.VertexIds is null || poly.VertexIds.Count < 3)
|
|
{ _out.WriteLine($" p->0x{portal.OtherCellId:X4}: no poly"); continue; }
|
|
Vector3 V(int k) { var v = cs.VertexArray.Vertices[(ushort)poly.VertexIds[k]]; return new Vector3(v.Origin.X, v.Origin.Y, v.Origin.Z); }
|
|
var p0 = V(0); var p1 = V(1); var p2 = V(2);
|
|
var normal = Vector3.Normalize(Vector3.Cross(p1 - p0, p2 - p0));
|
|
float d = -Vector3.Dot(normal, p0);
|
|
|
|
float centroidDot = Vector3.Dot(normal, centroid) + d;
|
|
int centroidInside = centroidDot >= 0 ? 0 : 1;
|
|
|
|
ushort flags = (ushort)portal.Flags;
|
|
bool portalSide = (flags & 0x2) == 0; // PortalInfo.cs:44 / CellPortal.cs:24
|
|
int datInside = portalSide ? 1 : 0; // mapping derived from retail InitCell + physics CellTransit
|
|
|
|
float eyeDot = Vector3.Dot(normal, localEye) + d;
|
|
const float eps = 0.01f;
|
|
bool admitCentroid = centroidInside == 0 ? eyeDot >= -eps : eyeDot <= eps;
|
|
bool admitDat = datInside == 0 ? eyeDot >= -eps : eyeDot <= eps;
|
|
|
|
if (centroidInside != datInside)
|
|
disagreements.Add($"0x{cellId & 0xFFFF:X4}->0x{portal.OtherCellId:X4}");
|
|
if (cellId == 0xF6820118u && portal.OtherCellId == 0x0116)
|
|
{
|
|
sawConnectorBackPortal = true;
|
|
connectorCentroidCulls = !admitCentroid;
|
|
connectorDatAdmits = admitDat;
|
|
}
|
|
|
|
string flag = centroidInside != datInside ? " <<< DISAGREE" : "";
|
|
string verdict = admitCentroid != admitDat ? $" centroid={(admitCentroid ? "ADMIT" : "CULL")} dat={(admitDat ? "ADMIT" : "CULL")}" : "";
|
|
_out.WriteLine($" p->0x{portal.OtherCellId:X4} flags=0x{flags:X2} PortalSide={portalSide,-5} " +
|
|
$"centroidInside={centroidInside} datInside={datInside} eyeDot={eyeDot,7:F3} " +
|
|
$"admit[centroid={admitCentroid,-5} dat={admitDat,-5}]{flag}{verdict}");
|
|
}
|
|
}
|
|
|
|
// Root cause + fix pins (the retail cdb trace is the oracle: retail draws 0116
|
|
// from the 0118 root at this eye; acdream's centroid rule culled it → grey).
|
|
Assert.True(sawConnectorBackPortal, "0xF6820118->0116 portal not found in the dat");
|
|
Assert.True(connectorCentroidCulls, "the OLD centroid rule should CULL 0118->0116 at the grey eye (the bug)");
|
|
Assert.True(connectorDatAdmits, "the dat PortalSide bit should ADMIT 0118->0116 at the grey eye (the fix, matches retail)");
|
|
// Surgical: the ONLY portal across these three cells where the centroid rule
|
|
// disagrees with the dat bit is the one #186 breaks. If this list ever grows,
|
|
// the centroid→dat-bit switch is no longer a no-op for the working portals.
|
|
Assert.Equal(new[] { "0x0118->0x0116" }, disagreements.ToArray());
|
|
}
|
|
|
|
private static Vector3? PolyNormal(DatReaderWriter.Types.Polygon poly, DatReaderWriter.Types.VertexArray va)
|
|
{
|
|
if (poly.VertexIds is null || poly.VertexIds.Count < 3) return null;
|
|
if (!va.Vertices.TryGetValue((ushort)poly.VertexIds[0], out var a)) return null;
|
|
if (!va.Vertices.TryGetValue((ushort)poly.VertexIds[1], out var b)) return null;
|
|
if (!va.Vertices.TryGetValue((ushort)poly.VertexIds[2], out var c)) return null;
|
|
var n = Vector3.Cross(
|
|
new Vector3(b.Origin.X, b.Origin.Y, b.Origin.Z) - new Vector3(a.Origin.X, a.Origin.Y, a.Origin.Z),
|
|
new Vector3(c.Origin.X, c.Origin.Y, c.Origin.Z) - new Vector3(a.Origin.X, a.Origin.Y, a.Origin.Z));
|
|
return n.LengthSquared() < 1e-9f ? (Vector3?)null : Vector3.Normalize(n);
|
|
}
|
|
|
|
private static string DomAxis(Vector3 n)
|
|
{
|
|
float ax = MathF.Abs(n.X), ay = MathF.Abs(n.Y), az = MathF.Abs(n.Z);
|
|
if (ax >= ay && ax >= az) return n.X >= 0 ? "+X" : "-X";
|
|
if (ay >= ax && ay >= az) return n.Y >= 0 ? "+Y" : "-Y";
|
|
return n.Z >= 0 ? "+Z" : "-Z";
|
|
}
|
|
}
|