acdream/tests/AcDream.App.Tests/Rendering/TerrainAtlasDetailTextureTests.cs
Erik 75664805f8 feat(rendering): port retail one-pass detail material
Replace the building and EnvCell detail replay with retail's exact single-pass stage result, including authored surface opacity, squared detail alpha, final-alpha clipping, and the original subset pipeline/order. Arm the ordered walk command in place to close #471, delete the replay pipelines/shaders, and advance prepared content to recipe 9.

Mutation witnesses (each restored before commit):
- X=a*qA: RetailDetailTextureContractTests.BothShaderFamiliesUseTheSharedOnePassSourceAndDebugPrecedesDetailSample line 174, missing materialAlpha * detail.a * detail.a.
- X*=base alpha: same test line 175, forbidden baseTexel.a found.
- CLIP against base alpha: EnvCellAlphaDrawSourceTests.ClipShaders_UseGreaterEqualForThePerRangeReference line 260, final-X conditional missing.
- second detail draw: EnvCellAlphaDrawSourceTests.DetailOn_EveryEnvCellFamilyDrawsOnceInPlaceWithAuthoredOpacity line 105, Assert.Single saw 2 MDI calls.
- straight-alpha substitution: WalkStaticStreamPopulatorTests.ImmediateBuildingDetail_RetainsOriginalFramebufferFamily line 1244, Additive first failed (only wb-mesh-alpha-1x recorded; InvAlpha also failed).
- omit ordered arm: OrderPreservingSubmitterTests.PrepareThenDraw_OrdinaryBuildingClipBuildingOrdinary_ArmsOnePassInPlace line 305, expected (77,3.5), got (0,0).
- omit atmospheric combine: RetailDetailTextureContractTests.BothShaderFamiliesUseTheSharedOnePassSourceAndDebugPrecedesDetailSample line 173, atmospheric shared include missing.
- drop serialized opacity: ObjectMeshDataSerializerTests.SurfaceOpacity_RoundTripsBitExactlyAndDeterministically line 288, first reported 0.5 bits 1056964608 vs 1065353216.
- stale detail arm: ordered adjacency test line 307, expected following ordinary (0,0), got (77,3.5).
- per-frame surface map: EnvCellAlphaDrawSourceTests.ProductionWholeLeaf_WarmedScanSubmitRhiAndFilteredReplayDoNotAllocate line 178, expected 0 B, got 147456 B.

Verification before commit: shader compiler 23/23; focused App 213/213; Content 75/75; Core Wb 10/10; launcher migration 6/6; Release solution build 0 warnings / 0 errors; git diff --check clean.
2026-09-05 02:03:13 +02:00

176 lines
7.1 KiB
C#

using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Reflection;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Wb;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.Content;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Lib.IO;
using DatReaderWriter.Types;
using Xunit;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// Campaign VM VM1 review fix: pins the two facts the #226 note relies on
/// for retail's mip-driven attenuation to actually be true — <c>TerrainAtlas
/// .TryCreateDetailTexture</c> (~TerrainAtlas.cs:488-556) must upload a FULL
/// mip chain, and it must be sampled with the repeat/linear world sampler,
/// or the "attenuation is the sampler's linear mip chain" claim in
/// the one-pass world material contract and the VM1 commit is unverified.
/// Drives the private method directly (reflection, same pattern as
/// EnvCellRendererTests' private-method tests) against a synthetic
/// PFID_A8R8G8B8 RenderSurface, so no installed DAT is required — this lane
/// stays hermetic.
/// </summary>
public sealed class TerrainAtlasDetailTextureTests
{
private const uint SurfaceTextureId = 0x05001787u;
private const uint RenderSurfaceId = 0x06006D58u;
[Fact]
public void DetailTexture_UploadsFullMipChainAndUsesRepeatLinearSampler()
{
const int width = 4;
const int height = 4;
using var device = new RecordingGpuDevice();
device.Clear();
var dats = new FakeDetailTextureDats();
dats.Register(new SurfaceTexture
{
Textures = new List<QualifiedDataId<RenderSurface>> { RenderSurfaceId },
}, SurfaceTextureId);
dats.Register(new RenderSurface
{
Width = width,
Height = height,
Format = PixelFormat.PFID_A8R8G8B8,
SourceData = new byte[width * height * 4],
}, RenderSurfaceId);
var terrain = new TMTerrainDesc
{
TerrainTex = new TerrainTex
{
DetailTextureId = SurfaceTextureId,
DetailTexTiling = 4u,
},
};
// The sampler is created from the PRODUCTION constant so that changing
// TerrainAtlas's choice (e.g. to a clamp sampler) fails the property
// assertions below rather than being forwarded unnoticed.
IGpuSampler sampler = device.CreateSampler(TerrainAtlas.DetailSamplerDescription);
MethodInfo method = typeof(TerrainAtlas).GetMethod(
"TryCreateDetailTexture",
BindingFlags.NonPublic | BindingFlags.Static)!;
object? result = method.Invoke(
null,
new object[] { device, dats, sampler, terrain, "building" });
Assert.NotNull(result);
// Full mip chain: TerrainAtlas.cs sizes MipLevelCount from
// RhiWorldTextureArray.MipLevelsFor(decoded.Width, decoded.Height) and
// then calls GenerateMipChain() — not a single-level upload.
RecordingGpuTexture texture = Assert.Single(device.CreatedTextures);
Assert.Equal(width, texture.Width);
Assert.Equal(height, texture.Height);
int expectedMipLevels = RhiWorldTextureArray.MipLevelsFor(width, height);
Assert.True(expectedMipLevels > 1, "the test fixture must exercise a real mip chain, not a 1x1 edge case");
Assert.Equal(expectedMipLevels, texture.MipLevelCount);
Assert.True(texture.MipChainGenerated);
// Repeat/linear sampler: the exact sampler GpuBindingModel world
// draws use, not WorldClamp (the alpha atlas' sampler) or any
// point-filtered UI sampler.
GpuRecordedTextureRegistration registration = Assert.Single(
device.Calls.OfType<GpuRecordedTextureRegistration>());
Assert.Equal(TerrainAtlas.DetailSamplerDescription, registration.Sampler);
Assert.Equal(GpuFilter.Linear, registration.Sampler.MinFilter);
Assert.Equal(GpuFilter.Linear, registration.Sampler.MagFilter);
Assert.Equal(GpuMipFilter.Linear, registration.Sampler.MipFilter);
Assert.Equal(GpuAddressMode.Repeat, registration.Sampler.AddressU);
Assert.Equal(GpuAddressMode.Repeat, registration.Sampler.AddressV);
}
/// <summary>
/// Minimal synthetic <see cref="IDatReaderWriter"/> — same shape as the
/// NoopDatReaderWriter pattern already used for hermetic tests
/// (LiveEntityNetworkOnPositionCollapseMatrixTests), except <c>Get</c>
/// resolves from an explicit id-&gt;object map instead of always missing.
/// Every other member is unreachable by TryCreateDetailTexture and throws
/// if that assumption ever changes.
/// </summary>
private sealed class FakeDetailTextureDats : IDatReaderWriter
{
private readonly Dictionary<uint, IDBObj> _objects = new();
public void Register<T>(T obj, uint id) where T : IDBObj => _objects[id] = obj;
public string SourceDirectory => string.Empty;
public IDatDatabase Portal => throw new NotSupportedException();
public IDatDatabase Cell => throw new NotSupportedException();
public ReadOnlyDictionary<uint, IDatDatabase> CellRegions { get; } =
new(new Dictionary<uint, IDatDatabase>());
public IDatDatabase HighRes => throw new NotSupportedException();
public IDatDatabase Language => throw new NotSupportedException();
public IDatDatabase Local => throw new NotSupportedException();
public ReadOnlyDictionary<uint, uint> RegionFileMap { get; } =
new(new Dictionary<uint, uint>());
public int PortalIteration => 0;
public int CellIteration => 0;
public int HighResIteration => 0;
public int LanguageIteration => 0;
public bool TryGetFileBytes(
uint regionId,
uint fileId,
ref byte[] bytes,
out int bytesRead)
{
bytesRead = 0;
return false;
}
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj =>
Array.Empty<uint>();
public IEnumerable<IDatReaderWriter.IdResolution> ResolveId(uint id) =>
Array.Empty<IDatReaderWriter.IdResolution>();
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
throw new NotSupportedException();
public bool TrySave<T>(uint regionId, T obj, int iteration = 0) where T : IDBObj =>
throw new NotSupportedException();
[return: MaybeNull]
public T Get<T>(uint fileId) where T : IDBObj =>
_objects.TryGetValue(fileId, out IDBObj? obj) && obj is T typed ? typed : default;
public bool TryGet<T>(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj
{
if (_objects.TryGetValue(fileId, out IDBObj? obj) && obj is T typed)
{
value = typed;
return true;
}
value = default;
return false;
}
public void Dispose()
{
}
}
}