Opus dual-lens review of05970306+388457a7(APPROVE WITH FIXES). Four items, all landed: 1. FOG (behavioural). Retail's D3D fixed-function fog stage runs AFTER the texture-stage pipeline, so the detail contribution must be fogged, not just the base. mesh_modern.frag already fogs the base colour (applyFog(rgb, vWorldPos)) before mesh_detail's replay draws over it; mesh_detail.frag previously emitted raw detail.rgb, understating fog by f*a*(fog-detail). Fix: mesh_detail.vert now outputs vWorldPos (mirroring mesh_modern.vert); mesh_detail.frag declares the identical SceneLighting UBO and applyFog function (copied verbatim, same binding/std140/math) and fogs detail.rgb before emitting it. This collapses algebraically to retail's fog-after-combine order: (1-a)*mix(base,fog,f) + a*mix(detail,fog,f) = mix(lerp(base,detail,a),fog,f) RetailDetailTextureContract gains ExpectedFogged(base,detail,opacity,fog, fogFactor); RetailDetailTextureContractTests pins the identity across 200 random samples within 1e-6. 2. EnvCellRenderer.Rhi.cs's DrawEnvCell-category comment still said "apply the 10-50 m positive-view-depth fade" — a stale claim from before VM1 removed the fade. Replaced with the mip-chain attenuation statement that mesh_detail.vert's header comment already carries. 3. Added the test the VM1 contract required but never had: TerrainAtlas .TryCreateDetailTexture uploads a full mip chain (MipLevelCount == RhiWorldTextureArray.MipLevelsFor(w,h), GenerateMipChain called) and registers with the repeat/linear world sampler, not single-level or clamped. Drives the private method directly (reflection) against a synthetic PFID_A8R8G8B8 RenderSurface through a minimal in-memory IDatReaderWriter fake, so the lane stays hermetic (no installed DAT). 4. #226 pseudocode note: noted that retail's stage-1 OUTPUT alpha (MODULATE(TEXTURE, CURRENT), 0x0059c549) — the framebuffer blend weight a delayed-alpha subset composites with — is not modelled; acdream instead draws a second pass weighted by detail.a*diffuseAlpha. Identical for opaque subsets, a bounded difference on translucent building/EnvCell subsets already covered by the existing AP-34 shared-alpha-queue divergence row. Also qualified the tmpmaterial.Diffuse.a = 1f (0x0059cb99) citation to name its exact branch (burnedInStaticLights < 0 && *(render_device+0x7e4) == 0); the other branch leaves diffuse FromVertex, but the opaque->1 / fading->opacity mapping still holds either way. Nit also folded in: EnvCellRendererTests' new SubmitRhi instance-alpha test is now a [Theory] over WbRenderPass.Opaque and .Transparent, pinning the bind-before-first-draw invariant on both passes. Regenerated mesh_detail's committed SPIR-V and the shader manifest (tools/compile-shaders.ps1); no other shader pair changed. Verified: dotnet build AcDream.slnx -c Release (0 warnings, 0 errors); dotnet test on AcDream.App.Tests (Release, hermetic lanes) green, including the shader manifest tests explicitly; AcDream.Core.Tests unaffected/green. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
589 lines
24 KiB
C#
589 lines
24 KiB
C#
// Tests for EnvCellRenderer (Phase A8, 2026-05-28).
|
|
// These cover the pure data-handling portions of EnvCellRenderer.
|
|
// The draw-recording Render() and RenderModernMDIInternal() paths require a
|
|
// live GPU device and are visual-verified at the render frame (Task 10).
|
|
//
|
|
// Campaign V slice V11: the raw-GL constructor (which stored a possibly-null
|
|
// GL reference and did no other work) was deleted along with the GL arm. The
|
|
// sole remaining constructor builds three real pipelines against IGpuDevice,
|
|
// so these "no GL calls" tests now construct through the RHI arm with the
|
|
// same lightweight, no-hardware-required fakes the composition tests use:
|
|
// RecordingGpuDevice (records but does no driver work), GpuDeviceFrameLifetime
|
|
// (never began, so CurrentFrame stays null — fine, since these tests never
|
|
// draw), and VulkanWorldPassScope (needs only a sample count, no live surface).
|
|
// meshManager can no longer be null either — the RHI constructor throws on a
|
|
// null ObjectMeshManager, so tests that don't care about mesh-manager
|
|
// behavior get a real one built the same way MeshPipelineDeviceSeamTests
|
|
// does: VulkanMeshPipelineDevice (the production Vulkan seam implementation)
|
|
// over the same RecordingGpuDevice, plus a no-op IPreparedAssetSource.
|
|
|
|
using System.Collections.Generic;
|
|
using System.Numerics;
|
|
using System.Reflection;
|
|
using System.Runtime.InteropServices;
|
|
using System.Threading;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.App.Rendering.Gpu;
|
|
using AcDream.App.Rendering.Gpu.Vk;
|
|
using AcDream.App.Rendering.Wb;
|
|
using AcDream.App.Tests.Rendering.Gpu;
|
|
using AcDream.Content;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Xunit;
|
|
|
|
namespace AcDream.App.Tests.Rendering.Wb;
|
|
|
|
public class EnvCellRendererTests
|
|
{
|
|
private sealed class NullPreparedAssetSource : IPreparedAssetSource
|
|
{
|
|
public PreparedAssetSourceStats Stats => default;
|
|
|
|
public CacheStats DecodedTextureCacheStats => default;
|
|
|
|
public PreparedAssetPresence Probe(
|
|
AcDream.Content.Pak.PakAssetType type,
|
|
uint sourceFileId) =>
|
|
PreparedAssetPresence.Missing;
|
|
|
|
public PreparedAssetReadResult Read(
|
|
in PreparedAssetRequest request,
|
|
CancellationToken cancellationToken = default) =>
|
|
PreparedAssetReadResult.Missing;
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
}
|
|
|
|
private static ObjectMeshManager CreateMeshManager(RecordingGpuDevice device) =>
|
|
new(
|
|
new VulkanMeshPipelineDevice(device.Retirement),
|
|
device,
|
|
new NullPreparedAssetSource(),
|
|
NullLogger<ObjectMeshManager>.Instance);
|
|
|
|
private static EnvCellRenderer CreateRenderer(ObjectMeshManager? meshManager = null)
|
|
{
|
|
var device = new RecordingGpuDevice();
|
|
return new EnvCellRenderer(
|
|
device,
|
|
new GpuDeviceFrameLifetime(device),
|
|
new VulkanWorldPassScope(sampleCount: 1),
|
|
meshManager ?? CreateMeshManager(device),
|
|
new WbFrustum());
|
|
}
|
|
|
|
[Fact]
|
|
public void EnvironmentDetailCategory_BindsValidStorageDescriptorNine()
|
|
{
|
|
using var device = new RecordingGpuDevice();
|
|
device.Clear();
|
|
|
|
using IGpuFrame frame = device.BeginFrame();
|
|
using IGpuPassEncoder pass = frame.BeginPass(
|
|
GpuPassDescription.BackbufferClear(
|
|
"envcell-detail-binding",
|
|
Vector4.Zero,
|
|
sampleCount: 1));
|
|
|
|
EnvCellRenderer.BindEnvironmentDetailCategory(pass, frame);
|
|
|
|
GpuRecordedStorageBind storageBind = Assert.Single(
|
|
device.Calls.OfType<GpuRecordedStorageBind>());
|
|
Assert.Equal(GpuBindingModel.StorageInstanceDetailCategory, storageBind.Binding);
|
|
Assert.Equal((uint)sizeof(uint), storageBind.SizeBytes);
|
|
Assert.Equal(
|
|
1u,
|
|
MemoryMarshal.Read<uint>(
|
|
device.RingBytes.Slice((int)storageBind.OffsetBytes, sizeof(uint))));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign VM VM1 follow-up. EnvCellRenderer.Rhi's SubmitRhi bound
|
|
/// StorageInstances/StorageBatches/StorageClipSlots/StorageGlobalLights/
|
|
/// StorageInstanceLightSets every frame but never
|
|
/// GpuBindingModel.StorageInstanceAlpha (binding 7) — the SSBO both
|
|
/// mesh_modern.vert (interior shells) and mesh_detail.vert (interior
|
|
/// detail replay) read as instanceAlpha[instanceIndex]. Predates VM1
|
|
/// (6c79d35c has the same omission); with mesh_detail.vert now reading
|
|
/// that binding too, an unfixed gap here would have made the interior
|
|
/// detail overlay read whatever section a DIFFERENT renderer (or a
|
|
/// stale ring slot) last left in binding 7, indexed by these cells'
|
|
/// instance ids. This pins that SubmitRhi binds its own constant-1.0f
|
|
/// section, sized to the live instance count, before it records any
|
|
/// draw in the pass — one bind serves every subsequent draw in that
|
|
/// pass, mesh_modern's shell pipeline and (when enabled) mesh_detail's
|
|
/// replay alike.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData(WbRenderPass.Opaque)]
|
|
[InlineData(WbRenderPass.Transparent)]
|
|
public void SubmitRhi_BindsConstantOneInstanceAlphaBeforeAnyDrawInThePass(
|
|
WbRenderPass renderPass)
|
|
{
|
|
const int instanceCount = 5;
|
|
|
|
using var device = new RecordingGpuDevice();
|
|
using var meshManager = CreateMeshManager(device);
|
|
var frameLifetime = new GpuDeviceFrameLifetime(device);
|
|
var scope = new VulkanWorldPassScope(sampleCount: 1);
|
|
using var renderer = new EnvCellRenderer(
|
|
device,
|
|
frameLifetime,
|
|
scope,
|
|
meshManager,
|
|
new WbFrustum());
|
|
|
|
frameLifetime.BeginFrame();
|
|
IGpuFrame frame = frameLifetime.CurrentFrame!;
|
|
using IGpuPassEncoder pass = frame.BeginPass(
|
|
GpuPassDescription.BackbufferClear(
|
|
"envcell-submit-alpha-binding",
|
|
Vector4.Zero,
|
|
sampleCount: 1));
|
|
using IDisposable publication = scope.Publish(pass);
|
|
|
|
// Seed one real draw command, exactly what
|
|
// RenderModernMDIInternal would have built from a live landblock,
|
|
// so the test can assert an actual ordering against a real draw
|
|
// rather than a vacuous "no draw happened" pass. Same command for
|
|
// both passes: with _buildingDetailEnabled left at its default
|
|
// (false), the Transparent branch's own detail-interleave logic
|
|
// (SubmitRhi's "detailEnabled" block) never fires, so it draws
|
|
// through the identical MultiDrawIndexedIndirect call the Opaque
|
|
// pass does — only the pipeline rebound inside the per-range loop
|
|
// differs, which is not what this test is pinning.
|
|
Type rendererType = typeof(EnvCellRenderer);
|
|
FieldInfo commandsField = rendererType.GetField(
|
|
"_commands", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
|
commandsField.SetValue(renderer, new[]
|
|
{
|
|
new DrawElementsIndirectCommand
|
|
{
|
|
Count = 3,
|
|
InstanceCount = (uint)instanceCount,
|
|
FirstIndex = 0,
|
|
BaseVertex = 0,
|
|
BaseInstance = 0,
|
|
},
|
|
});
|
|
FieldInfo batchesField = rendererType.GetField(
|
|
"_modernBatches", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
|
batchesField.SetValue(renderer, new ModernBatchData[] { default });
|
|
FieldInfo rangesField = rendererType.GetField(
|
|
"_mdiDrawRanges", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
|
var ranges = (List<EnvCellRenderer.MdiDrawRange>)rangesField.GetValue(renderer)!;
|
|
ranges.Clear();
|
|
EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 0, firstCommand: 0, commandCount: 1);
|
|
|
|
var allInstances = new List<InstanceData>();
|
|
for (int i = 0; i < instanceCount; i++)
|
|
{
|
|
allInstances.Add(new InstanceData
|
|
{
|
|
Transform = Matrix4x4.Identity,
|
|
CellId = 0x8C040100u + (uint)i,
|
|
});
|
|
}
|
|
|
|
device.Clear();
|
|
|
|
MethodInfo submitRhi = rendererType.GetMethod(
|
|
"SubmitRhi", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
|
submitRhi.Invoke(
|
|
renderer,
|
|
new object[] { allInstances, renderPass, 1, instanceCount });
|
|
|
|
IReadOnlyList<GpuRecordedCall> calls = device.Calls;
|
|
int alphaBindIndex = -1;
|
|
int firstDrawIndex = -1;
|
|
for (int i = 0; i < calls.Count; i++)
|
|
{
|
|
if (alphaBindIndex < 0
|
|
&& calls[i] is GpuRecordedStorageBind bind
|
|
&& bind.Binding == GpuBindingModel.StorageInstanceAlpha)
|
|
{
|
|
alphaBindIndex = i;
|
|
}
|
|
|
|
if (firstDrawIndex < 0 && calls[i] is GpuRecordedMultiDrawIndirect)
|
|
firstDrawIndex = i;
|
|
}
|
|
|
|
Assert.True(alphaBindIndex >= 0, "StorageInstanceAlpha was never bound.");
|
|
Assert.True(firstDrawIndex >= 0, "The seeded draw command was never recorded.");
|
|
Assert.True(
|
|
alphaBindIndex < firstDrawIndex,
|
|
"StorageInstanceAlpha must be bound before the pass's draw call, "
|
|
+ "not left to whatever a prior renderer's bind left in slot 7.");
|
|
|
|
var alphaBind = (GpuRecordedStorageBind)calls[alphaBindIndex];
|
|
Assert.Equal((uint)(instanceCount * sizeof(float)), alphaBind.SizeBytes);
|
|
ReadOnlySpan<float> alphaValues = MemoryMarshal.Cast<byte, float>(
|
|
device.RingBytes.Slice((int)alphaBind.OffsetBytes, (int)alphaBind.SizeBytes));
|
|
Assert.Equal(instanceCount, alphaValues.Length);
|
|
foreach (float value in alphaValues)
|
|
Assert.Equal(1.0f, value);
|
|
}
|
|
|
|
[Fact]
|
|
public void RetailDetailPipelinesPreserveOpaqueAndTransparentDepthWriteContracts()
|
|
{
|
|
using var device = new RecordingGpuDevice();
|
|
using var meshManager = CreateMeshManager(device);
|
|
using var renderer = new EnvCellRenderer(
|
|
device,
|
|
new GpuDeviceFrameLifetime(device),
|
|
new VulkanWorldPassScope(sampleCount: 1),
|
|
meshManager,
|
|
new WbFrustum());
|
|
|
|
GpuPipelineDescription opaqueDetail = Assert.Single(
|
|
device.CreatedPipelines,
|
|
pipeline => pipeline.Description.Name == "envcell-retail-detail")
|
|
.Description;
|
|
GpuPipelineDescription transparentDetail = Assert.Single(
|
|
device.CreatedPipelines,
|
|
pipeline => pipeline.Description.Name == "envcell-retail-detail-alpha")
|
|
.Description;
|
|
|
|
Assert.Equal(GpuBlendMode.RetailDetail, opaqueDetail.Blend);
|
|
Assert.True(opaqueDetail.Depth.Write);
|
|
Assert.Equal(GpuCompareOp.Equal, opaqueDetail.Depth.Compare);
|
|
Assert.False(opaqueDetail.AlphaToCoverage);
|
|
Assert.Equal(GpuBlendMode.RetailDetail, transparentDetail.Blend);
|
|
Assert.False(transparentDetail.Depth.Write);
|
|
Assert.Equal(GpuCompareOp.LessOrEqual, transparentDetail.Depth.Compare);
|
|
Assert.False(transparentDetail.AlphaToCoverage);
|
|
}
|
|
|
|
[Fact]
|
|
public void OrderedMdiRanges_CoalesceAdjacentCellsWithIdenticalState()
|
|
{
|
|
var ranges = new List<EnvCellRenderer.MdiDrawRange>();
|
|
|
|
EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 0, commandCount: 3);
|
|
EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 3, commandCount: 4);
|
|
|
|
Assert.Equal(
|
|
[new EnvCellRenderer.MdiDrawRange(GroupIndex: 2, FirstCommand: 0, CommandCount: 7)],
|
|
ranges);
|
|
}
|
|
|
|
[Fact]
|
|
public void OrderedMdiRanges_PreserveStateAndCommandGapsAsBoundaries()
|
|
{
|
|
var ranges = new List<EnvCellRenderer.MdiDrawRange>();
|
|
|
|
EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 0, commandCount: 3);
|
|
EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 6, firstCommand: 3, commandCount: 2);
|
|
EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 5, commandCount: 1);
|
|
EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 9, commandCount: 2);
|
|
|
|
Assert.Equal(
|
|
[
|
|
new EnvCellRenderer.MdiDrawRange(2, 0, 3),
|
|
new EnvCellRenderer.MdiDrawRange(6, 3, 2),
|
|
new EnvCellRenderer.MdiDrawRange(2, 5, 1),
|
|
new EnvCellRenderer.MdiDrawRange(2, 9, 2),
|
|
],
|
|
ranges);
|
|
}
|
|
|
|
[Fact]
|
|
public void OrderedMdiRanges_IgnoreEmptyCellRanges()
|
|
{
|
|
var ranges = new List<EnvCellRenderer.MdiDrawRange>();
|
|
|
|
EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 0, commandCount: 0);
|
|
|
|
Assert.Empty(ranges);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// GetEnvCellGeomId — verbatim port of WB EnvCellRenderManager.cs:94-103
|
|
// -----------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void GetEnvCellGeomId_DedupBitSet()
|
|
{
|
|
var id = EnvCellRenderer.GetEnvCellGeomId(0x42, 7, new List<ushort> { 1, 2, 3 });
|
|
// Bit 33 (0x2_0000_0000) must be set — distinguishes dedup geom from per-cell ids.
|
|
Assert.NotEqual(0UL, id & 0x2_0000_0000UL);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetEnvCellGeomId_Deterministic()
|
|
{
|
|
var s = new List<ushort> { 1, 2, 3 };
|
|
var a = EnvCellRenderer.GetEnvCellGeomId(0x42, 7, s);
|
|
var b = EnvCellRenderer.GetEnvCellGeomId(0x42, 7, s);
|
|
Assert.Equal(a, b);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetEnvCellGeomId_DiffersByEnvironmentId()
|
|
{
|
|
var a = EnvCellRenderer.GetEnvCellGeomId(0x42, 7, new List<ushort> { 1 });
|
|
var b = EnvCellRenderer.GetEnvCellGeomId(0x43, 7, new List<ushort> { 1 });
|
|
Assert.NotEqual(a, b);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetEnvCellGeomId_DiffersByCellStructure()
|
|
{
|
|
var a = EnvCellRenderer.GetEnvCellGeomId(0x42, 7, new List<ushort> { 1 });
|
|
var b = EnvCellRenderer.GetEnvCellGeomId(0x42, 8, new List<ushort> { 1 });
|
|
Assert.NotEqual(a, b);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetEnvCellGeomId_DiffersBySurfaces()
|
|
{
|
|
var a = EnvCellRenderer.GetEnvCellGeomId(0x42, 7, new List<ushort> { 1 });
|
|
var b = EnvCellRenderer.GetEnvCellGeomId(0x42, 7, new List<ushort> { 2 });
|
|
Assert.NotEqual(a, b);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Constructor — pure data, no GL
|
|
// -----------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void NewRenderer_NeedsPrepareIsTrue()
|
|
{
|
|
// GL and meshManager are null — only valid for pure-data tests (no
|
|
// Initialize() is called, so no GL calls are made).
|
|
var r = CreateRenderer();
|
|
Assert.True(r.NeedsPrepare);
|
|
}
|
|
|
|
[Fact]
|
|
public void NewRenderer_NotDisposed()
|
|
{
|
|
var r = CreateRenderer();
|
|
Assert.False(r.IsDisposed);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// RemoveLandblock — pure data path
|
|
// -----------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void RemoveLandblock_NonExistent_DoesNotThrow()
|
|
{
|
|
var r = CreateRenderer();
|
|
// Should silently no-op.
|
|
r.RemoveLandblock(0xA9B40000u);
|
|
Assert.True(r.NeedsPrepare);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// GetEnvCellGeomId — additional edge cases
|
|
// -----------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void GetEnvCellGeomId_EmptySurfaces_Deterministic()
|
|
{
|
|
var a = EnvCellRenderer.GetEnvCellGeomId(1, 0, new List<ushort>());
|
|
var b = EnvCellRenderer.GetEnvCellGeomId(1, 0, new List<ushort>());
|
|
Assert.Equal(a, b);
|
|
Assert.NotEqual(0UL, a & 0x2_0000_0000UL);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetEnvCellGeomId_SurfaceOrderMatters()
|
|
{
|
|
var a = EnvCellRenderer.GetEnvCellGeomId(1, 1, new List<ushort> { 10, 20 });
|
|
var b = EnvCellRenderer.GetEnvCellGeomId(1, 1, new List<ushort> { 20, 10 });
|
|
// The hash is order-sensitive (matches WB's foreach loop), so
|
|
// swapped order should produce a different id.
|
|
Assert.NotEqual(a, b);
|
|
}
|
|
|
|
// (Render() requires a GL context — visual-verified in Task 10.)
|
|
|
|
[Fact]
|
|
public void GpuInstanceUpload_UsesMeshModernMat4Stride()
|
|
{
|
|
// mesh_modern.vert declares SSBO InstanceData as exactly one mat4,
|
|
// so the GPU array stride is 64 bytes. EnvCellRenderer's CPU
|
|
// InstanceData also carries CellId/Flags for culling/filtering and
|
|
// is 80 bytes; uploading that struct corrupts every instance after 0.
|
|
Assert.Equal(64, Marshal.SizeOf<Matrix4x4>());
|
|
Assert.Equal(80, Marshal.SizeOf<InstanceData>());
|
|
|
|
var field = typeof(EnvCellRenderer).GetField("_gpuInstanceTransforms",
|
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
|
Assert.NotNull(field);
|
|
Assert.Equal(typeof(Matrix4x4[]), field!.FieldType);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Pool-aliasing regression tests (2026-05-28 audit findings).
|
|
//
|
|
// Two interconnected bugs caused the post-Wave-5 visual chaos:
|
|
// 1. GetPooledList didn't clear reused lists, causing AddRange to grow
|
|
// pool entries unbounded across frames.
|
|
// 2. Render's pool cursor reset used `BatchedByCell.Count` (cell count,
|
|
// a small int with no relation to the pool) instead of WB's
|
|
// `PostPreparePoolIndex` (the pool high-water mark after Prepare),
|
|
// pointing Render's GetPooledList back into snapshot-owned lists.
|
|
//
|
|
// These tests use reflection to verify the fixes without widening
|
|
// EnvCellRenderer's public API. If either fix regresses, the
|
|
// corresponding test fails fast.
|
|
// -----------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void Snapshot_PostPreparePoolIndex_IsInitSettable()
|
|
{
|
|
// Compile-time guarantee: the field exists and is init-only.
|
|
// If a future refactor renames or removes it, this test won't compile.
|
|
var s = new EnvCellVisibilitySnapshot { PostPreparePoolIndex = 42 };
|
|
Assert.Equal(42, s.PostPreparePoolIndex);
|
|
}
|
|
|
|
[Fact]
|
|
public void Snapshot_PostPreparePoolIndex_DefaultsToZero()
|
|
{
|
|
var s = new EnvCellVisibilitySnapshot();
|
|
Assert.Equal(0, s.PostPreparePoolIndex);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetPooledList_ReusedList_IsClearedBeforeReturn()
|
|
{
|
|
// The bug: WB's GetPooledList clears the list before returning so
|
|
// the merge phase pattern `gfxDict[k] = list; list.AddRange(...)`
|
|
// populates fresh data. The original port omitted Clear() — each
|
|
// frame's lists grew unbounded with stale data layered on top.
|
|
//
|
|
// Reflection-based test that drives the private GetPooledList +
|
|
// _poolIndex/_listPool fields. If a future refactor removes the
|
|
// Clear() call, this test fails.
|
|
var r = CreateRenderer();
|
|
|
|
var type = typeof(EnvCellRenderer);
|
|
var getPooledListMethod = type.GetMethod("GetPooledList",
|
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
|
Assert.NotNull(getPooledListMethod);
|
|
var poolIndexField = type.GetField("_poolIndex",
|
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
|
Assert.NotNull(poolIndexField);
|
|
|
|
// First call — creates _listPool[0], _poolIndex 0 → 1.
|
|
var first = (List<InstanceData>)getPooledListMethod!.Invoke(r, null)!;
|
|
first.Add(new InstanceData());
|
|
first.Add(new InstanceData());
|
|
Assert.Equal(2, first.Count);
|
|
|
|
// Reset cursor to 0 — simulates the start of the next prepare cycle.
|
|
poolIndexField!.SetValue(r, 0);
|
|
|
|
// Second call — returns _listPool[0] (same as first). With the fix
|
|
// it should be cleared. Without the fix the list still has 2 items.
|
|
var second = (List<InstanceData>)getPooledListMethod.Invoke(r, null)!;
|
|
Assert.Same(first, second); // reuses the same instance
|
|
Assert.Empty(second); // and the data is gone
|
|
}
|
|
|
|
[Fact]
|
|
public void GetPooledList_FreshList_IsAlwaysEmpty()
|
|
{
|
|
// Sanity check for the fresh-list branch. _poolIndex past _listPool.Count
|
|
// should produce a brand-new empty list and grow the pool.
|
|
var r = CreateRenderer();
|
|
|
|
var type = typeof(EnvCellRenderer);
|
|
var getPooledListMethod = type.GetMethod("GetPooledList",
|
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
|
|
|
var a = (List<InstanceData>)getPooledListMethod!.Invoke(r, null)!;
|
|
var b = (List<InstanceData>)getPooledListMethod.Invoke(r, null)!;
|
|
|
|
Assert.NotSame(a, b);
|
|
Assert.Empty(a);
|
|
Assert.Empty(b);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Prepare gate (2026-07-24) — pure camera-tolerance half.
|
|
// The tolerance must swallow the ~36 µm eye rest jitter (RetailPViewRenderer
|
|
// R-A2 note) but never survive real camera motion.
|
|
// -----------------------------------------------------------------------
|
|
|
|
private static Matrix4x4 ViewProjectionFor(Vector3 eye, Vector3 forward)
|
|
{
|
|
var view = Matrix4x4.CreateLookAt(eye, eye + forward, Vector3.UnitZ);
|
|
var proj = Matrix4x4.CreatePerspectiveFieldOfView(
|
|
fieldOfView: 1.2f, aspectRatio: 16f / 9f, nearPlaneDistance: 0.1f, farPlaneDistance: 2000f);
|
|
return view * proj;
|
|
}
|
|
|
|
[Fact]
|
|
public void CameraApproximatelyEqual_IdenticalCamera_True()
|
|
{
|
|
var eye = new Vector3(120f, 80f, 10f);
|
|
var vp = ViewProjectionFor(eye, Vector3.UnitX);
|
|
Assert.True(EnvCellRenderer.CameraApproximatelyEqual(vp, eye, vp, eye));
|
|
}
|
|
|
|
[Fact]
|
|
public void CameraApproximatelyEqual_RestJitter_True()
|
|
{
|
|
// The ~36 µm eye rest jitter must NOT dirty the gate.
|
|
var eye = new Vector3(120.34f, 87.91f, 10.2f);
|
|
var jittered = eye + new Vector3(36e-6f, -36e-6f, 36e-6f);
|
|
var a = ViewProjectionFor(eye, Vector3.UnitX);
|
|
var b = ViewProjectionFor(jittered, Vector3.UnitX);
|
|
Assert.True(EnvCellRenderer.CameraApproximatelyEqual(a, eye, b, jittered));
|
|
}
|
|
|
|
[Fact]
|
|
public void CameraApproximatelyEqual_SmallRealRotation_False()
|
|
{
|
|
// 0.05° of yaw — far below one frame of real mouse motion — must dirty.
|
|
var eye = new Vector3(120.34f, 87.91f, 10.2f);
|
|
float yaw = 0.05f * MathF.PI / 180f;
|
|
var a = ViewProjectionFor(eye, Vector3.UnitX);
|
|
var b = ViewProjectionFor(eye, new Vector3(MathF.Cos(yaw), MathF.Sin(yaw), 0f));
|
|
Assert.False(EnvCellRenderer.CameraApproximatelyEqual(a, eye, b, eye));
|
|
}
|
|
|
|
[Fact]
|
|
public void CameraApproximatelyEqual_SmallRealTranslation_False()
|
|
{
|
|
// 5 cm of movement must dirty the gate.
|
|
var eye = new Vector3(120.34f, 87.91f, 10.2f);
|
|
var moved = eye + new Vector3(0.05f, 0f, 0f);
|
|
var a = ViewProjectionFor(eye, Vector3.UnitX);
|
|
var b = ViewProjectionFor(moved, Vector3.UnitX);
|
|
Assert.False(EnvCellRenderer.CameraApproximatelyEqual(a, eye, b, moved));
|
|
}
|
|
|
|
[Fact]
|
|
public void CameraApproximatelyEqual_GlobalScaleCoordinates_TranslationStillDirties()
|
|
{
|
|
// AC world coordinates reach ~5e4. A relative tolerance applied to the
|
|
// matrix translation row would mask sub-meter motion at that scale —
|
|
// the eye epsilon is absolute precisely so this case stays sharp.
|
|
var eye = new Vector3(40120.34f, 45087.91f, 110.2f);
|
|
var moved = eye + new Vector3(0.07f, 0f, 0f); // one walking frame
|
|
var a = ViewProjectionFor(eye, Vector3.UnitX);
|
|
var b = ViewProjectionFor(moved, Vector3.UnitX);
|
|
Assert.False(EnvCellRenderer.CameraApproximatelyEqual(a, eye, b, moved));
|
|
|
|
var jittered = eye + new Vector3(36e-6f, 0f, 0f);
|
|
var c = ViewProjectionFor(jittered, Vector3.UnitX);
|
|
Assert.True(EnvCellRenderer.CameraApproximatelyEqual(a, eye, c, jittered));
|
|
}
|
|
|
|
[Fact]
|
|
public void NewRenderer_SnapshotGenerationStartsAtZero()
|
|
{
|
|
var r = CreateRenderer();
|
|
Assert.Equal(0, r.SnapshotGeneration);
|
|
}
|
|
}
|