VM2's live cdb read against the PDB-paired retail client (GUID 9e847e2f-777c-4bd9-886c-22256bb87f32) proved m_caps.bCanDoSinglePassDetailing = 1 and trysinglepass = 1 on real hardware, so D3DPolyRender::RenderMeshSubset (0x0059ca10) never falls back to the two-pass framebuffer blend the earlier #226 port reproduced. Every loaded CGfxObj sets use_built_mesh = 1 (CGfxObj::InitLoad 0x005346b0), so buildings and EnvCells always take the single-pass texture-stage combine set up in D3DPolyRender::SetSurface (0x0059c4d0): result = lerp(base * diffuse, detail.rgb, detail.a * diffuse.a) RenderMeshSubset lights opaque built-mesh subsets with tmpmaterial.Diffuse.a = 1, so on the live Dereth category texture 0x06006D58 (mean rgb 0.165, mean alpha 0.132) the combine works out to ~0.868 * base + 0.022 — a mild darkening, the opposite sign of the fallback DstColor blend's brightening. Also removes the invented 10 m / 50 m distance fade. Retail's ACRender::get_alpha_for_z (0x006b6230) is only evaluated in D3DPolyRender::DrawPolyInternal (0x0059d7c0, the immediate-polygon path) and only when the static noFadeDetail (0x00820e38, initialised to 1) is 0 — unreachable for built meshes. Attenuation is the sampler's linear mip chain converging to the texture mean, not a scripted ramp. Changes: - mesh_detail.vert/.frag: drop vDetailFade and its distance term; add vDetailOpacity mirroring mesh_modern.vert's InstanceAlphaBuf (binding 7) read, and output detail.rgb with alpha = detail.a * vDetailOpacity under the corrected pipeline blend. - VulkanViewportMapping.BlendFactorsOf / GpuEnums.GpuBlendMode.RetailDetail: SrcAlpha + OneMinusSrcAlpha instead of DstColor + OneMinusSrcAlpha. - RetailDetailTextureContract: replaced the distance-fade constants and FramebufferFactor with Expected(base, detail, opacity) and IsNeutral, matching the lerp; contract tests cover zero-alpha/zero-opacity no-ops, the measured darkening on the live category texture, and full-alpha replacement. - Regenerated mesh_detail's committed SPIR-V and the shader manifest (tools/compile-shaders.ps1); no other shader pair changed. - Docs: #226's pseudocode note, the docs/ISSUES.md #226 entry, and the retired TS-52 divergence-register row corrected from the two-pass DESTCOLOR description to the single-pass path and the darkening expectation, each citing the VM2 cdb note. Verified: dotnet build AcDream.slnx -c Release (0 warnings, 0 errors); dotnet test on AcDream.App.Tests and AcDream.Core.Tests (Release, hermetic lanes) both green. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
211 lines
9 KiB
C#
211 lines
9 KiB
C#
using System;
|
|
using AcDream.App.Rendering.Gpu;
|
|
using AcDream.App.Rendering.Gpu.Vk;
|
|
using Silk.NET.Vulkan;
|
|
|
|
namespace AcDream.App.Tests.Rendering.Gpu.Vk;
|
|
|
|
/// <summary>
|
|
/// Campaign V slice V6c — the coordinate reconciliation (plan §3.3, §4.7,
|
|
/// §4.10).
|
|
///
|
|
/// <para>Renderers speak GL: viewport origin bottom-left, front faces
|
|
/// counter-clockwise. The Vulkan backend renders with a NEGATIVE viewport
|
|
/// height, which mirrors framebuffer space vertically and therefore also
|
|
/// reverses winding, so the front face is inverted to compensate. The two flips
|
|
/// are exact inverses and must always travel together — which is why they live
|
|
/// in one file, and why these tests assert them together.</para>
|
|
///
|
|
/// <para>The scissor is the trap. It does NOT flip with the viewport:
|
|
/// <c>vkCmdSetScissor</c> is always top-left-origin regardless of viewport sign,
|
|
/// while <c>NdcScissorRect.ToPixels</c> emits GL bottom-left rectangles. The V3
|
|
/// audit flagged this explicitly as a V6 acceptance item, and getting it wrong
|
|
/// shows up as a doorway aperture clipped from the wrong edge — which only a
|
|
/// scene containing one would reveal.</para>
|
|
/// </summary>
|
|
public sealed class VulkanViewportMappingTests
|
|
{
|
|
[Fact]
|
|
public void FullViewportBecomesANegativeHeightRectangleAnchoredAtTheBottom()
|
|
{
|
|
Viewport viewport = VulkanViewportMapping.ToVulkan(0, 0, 1280, 720, attachmentHeight: 720);
|
|
|
|
Assert.Equal(0f, viewport.X);
|
|
// Y is the BOTTOM edge in Vulkan's top-left space, and the height runs
|
|
// upward from it. Together they mirror clip space.
|
|
Assert.Equal(720f, viewport.Y);
|
|
Assert.Equal(1280f, viewport.Width);
|
|
Assert.Equal(-720f, viewport.Height);
|
|
Assert.Equal(0f, viewport.MinDepth);
|
|
Assert.Equal(1f, viewport.MaxDepth);
|
|
}
|
|
|
|
[Fact]
|
|
public void AnOffsetViewportKeepsItsGlBottomLeftMeaning()
|
|
{
|
|
// A 100x50 viewport whose bottom edge sits 30 px above the bottom of a
|
|
// 720 px attachment.
|
|
Viewport viewport = VulkanViewportMapping.ToVulkan(10, 30, 100, 50, attachmentHeight: 720);
|
|
|
|
Assert.Equal(10f, viewport.X);
|
|
Assert.Equal(690f, viewport.Y);
|
|
Assert.Equal(-50f, viewport.Height);
|
|
}
|
|
|
|
[Fact]
|
|
public void ScissorFlipsAgainstTheAttachmentBecauseTheViewportSignDoesNotDoItForUs()
|
|
{
|
|
// GL rectangle: 100 px wide, 50 px tall, bottom edge 30 px up.
|
|
Rect2D scissor = VulkanViewportMapping.ScissorToVulkan(10, 30, 100, 50, attachmentHeight: 720);
|
|
|
|
Assert.Equal(10, scissor.Offset.X);
|
|
// Top edge measured from the top: 720 - (30 + 50).
|
|
Assert.Equal(640, scissor.Offset.Y);
|
|
Assert.Equal(100u, scissor.Extent.Width);
|
|
Assert.Equal(50u, scissor.Extent.Height);
|
|
}
|
|
|
|
[Fact]
|
|
public void AFullAttachmentScissorIsUnchangedByTheFlip()
|
|
{
|
|
Rect2D scissor = VulkanViewportMapping.ScissorToVulkan(0, 0, 1280, 720, attachmentHeight: 720);
|
|
|
|
Assert.Equal(0, scissor.Offset.X);
|
|
Assert.Equal(0, scissor.Offset.Y);
|
|
Assert.Equal(1280u, scissor.Extent.Width);
|
|
Assert.Equal(720u, scissor.Extent.Height);
|
|
}
|
|
|
|
[Fact]
|
|
public void AScissorStraddlingTheTopEdgeIsClampedRatherThanRejected()
|
|
{
|
|
// Bottom edge 700 px up in a 720 px attachment, 50 px tall: 30 px of it
|
|
// is off the top. GL silently clips this; a driver error here would turn
|
|
// a harmless off-screen aperture into a crash.
|
|
Rect2D scissor = VulkanViewportMapping.ScissorToVulkan(0, 700, 100, 50, attachmentHeight: 720);
|
|
|
|
Assert.Equal(0, scissor.Offset.Y);
|
|
Assert.Equal(20u, scissor.Extent.Height);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign V slice V6j: the winding a renderer declares is the winding
|
|
/// Vulkan gets. V6c inverted it on the standard negative-viewport argument;
|
|
/// the world arm — the mapping's first culling consumer — measured that the
|
|
/// inversion culls terrain outright and turns every closed building shell
|
|
/// inside-out. See <c>VulkanViewportMapping</c>'s remarks for the captures.
|
|
/// </summary>
|
|
[Fact]
|
|
public void FrontFacePassesThroughSoRenderersDeclareTheGlWinding()
|
|
{
|
|
Assert.Equal(
|
|
FrontFace.CounterClockwise,
|
|
VulkanViewportMapping.ToVulkan(GpuFrontFace.CounterClockwise));
|
|
Assert.Equal(
|
|
FrontFace.Clockwise,
|
|
VulkanViewportMapping.ToVulkan(GpuFrontFace.Clockwise));
|
|
}
|
|
|
|
[Fact]
|
|
public void TheViewportFlipTravelsAloneAndTheWindingIsUntouched()
|
|
{
|
|
// The viewport still mirrors — that is what puts GL-authored geometry
|
|
// the right way up with no matrix change. What it does NOT do is drag a
|
|
// winding inversion along with it. A later change that reintroduces one
|
|
// fails here and, more usefully, fails visibly on any single-sided
|
|
// surface.
|
|
Viewport once = VulkanViewportMapping.ToVulkan(0, 0, 640, 480, attachmentHeight: 480);
|
|
Assert.Equal(-480f, once.Height);
|
|
Assert.Equal(480f, once.Y);
|
|
|
|
Assert.Equal(
|
|
FrontFace.CounterClockwise,
|
|
VulkanViewportMapping.ToVulkan(GpuFrontFace.CounterClockwise));
|
|
}
|
|
|
|
[Fact]
|
|
public void CullModesMapStraightAcross()
|
|
{
|
|
Assert.Equal(CullModeFlags.None, VulkanViewportMapping.ToVulkan(GpuCullMode.None));
|
|
Assert.Equal(CullModeFlags.BackBit, VulkanViewportMapping.ToVulkan(GpuCullMode.Back));
|
|
Assert.Equal(CullModeFlags.FrontBit, VulkanViewportMapping.ToVulkan(GpuCullMode.Front));
|
|
}
|
|
|
|
[Fact]
|
|
public void AllThreeRetailBlendModesAreRepresentable()
|
|
{
|
|
Assert.Equal(
|
|
(BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha),
|
|
VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.StraightAlpha));
|
|
Assert.Equal(
|
|
(BlendFactor.SrcAlpha, BlendFactor.One),
|
|
VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.Additive));
|
|
// Retail's third mode, found at slice V4c. Mapping it onto straight
|
|
// alpha would have silently changed how every inverse-alpha surface
|
|
// composites.
|
|
Assert.Equal(
|
|
(BlendFactor.OneMinusSrcAlpha, BlendFactor.SrcAlpha),
|
|
VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.InverseAlpha));
|
|
// VM2 (2026-08-22): real hardware runs the single-pass detail combine,
|
|
// a lerp expressed as the ordinary straight-alpha-over factor pair —
|
|
// not the two-pass DstColor fallback.
|
|
Assert.Equal(
|
|
(BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha),
|
|
VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.RetailDetail));
|
|
}
|
|
|
|
[Fact]
|
|
public void IntegerVertexAttributesTakeAUintFormatNotANormalisedOne()
|
|
{
|
|
Assert.Equal(Format.R8G8B8A8Unorm, VulkanViewportMapping.ToVulkan(GpuVertexFormat.UByte4Normalized));
|
|
// terrain_modern.vert reads locations 2-5 as uvec4; those packed bytes
|
|
// carry terrain-type, road and split-direction codes, so normalising
|
|
// them would not be an approximation - it would be garbage.
|
|
Assert.Equal(Format.R8G8B8A8Uint, VulkanViewportMapping.ToVulkan(GpuVertexFormat.UByte4UInt));
|
|
}
|
|
|
|
[Fact]
|
|
public void ResolveIsExpressedByAResolveTargetRatherThanAStoreOp()
|
|
{
|
|
// There is no VK_ATTACHMENT_STORE_OP_RESOLVE; a resolving attachment
|
|
// discards its multisampled contents and names a resolve image instead.
|
|
Assert.Equal(AttachmentStoreOp.DontCare, VulkanViewportMapping.ToVulkan(GpuStoreOp.Resolve));
|
|
Assert.Equal(AttachmentStoreOp.Store, VulkanViewportMapping.ToVulkan(GpuStoreOp.Store));
|
|
Assert.Equal(AttachmentStoreOp.DontCare, VulkanViewportMapping.ToVulkan(GpuStoreOp.DontCare));
|
|
}
|
|
|
|
[Fact]
|
|
public void PipelineCacheHeaderValidationRejectsAnotherDevicesBlob()
|
|
{
|
|
byte[] uuid = new byte[16];
|
|
for (int i = 0; i < 16; i++)
|
|
uuid[i] = (byte)(i + 1);
|
|
|
|
byte[] blob = new byte[64];
|
|
BitConverter.GetBytes(32u).CopyTo(blob, 0);
|
|
BitConverter.GetBytes(1u).CopyTo(blob, 4);
|
|
BitConverter.GetBytes(0x1002u).CopyTo(blob, 8);
|
|
BitConverter.GetBytes(0x7550u).CopyTo(blob, 12);
|
|
uuid.CopyTo(blob, 16);
|
|
|
|
Assert.NotNull(VulkanPipelineCache.ValidateHeader(blob, 0x1002, 0x7550, uuid));
|
|
// A driver update changes the cache UUID, and feeding the old blob back
|
|
// is exactly the case the header exists to catch.
|
|
uuid[0] = 0xFF;
|
|
Assert.Null(VulkanPipelineCache.ValidateHeader(blob, 0x1002, 0x7550, uuid));
|
|
}
|
|
|
|
[Fact]
|
|
public void PipelineCacheHeaderValidationRejectsTruncatedAndForeignBlobs()
|
|
{
|
|
byte[] uuid = new byte[16];
|
|
Assert.Null(VulkanPipelineCache.ValidateHeader(null, 1, 1, uuid));
|
|
Assert.Null(VulkanPipelineCache.ValidateHeader(new byte[8], 1, 1, uuid));
|
|
|
|
byte[] blob = new byte[32];
|
|
BitConverter.GetBytes(32u).CopyTo(blob, 0);
|
|
BitConverter.GetBytes(1u).CopyTo(blob, 4);
|
|
BitConverter.GetBytes(0x8086u).CopyTo(blob, 8);
|
|
Assert.Null(VulkanPipelineCache.ValidateHeader(blob, 0x1002, 0, uuid));
|
|
}
|
|
}
|