Exclude deferred EnvCell subsets from the opaque turn, preserve exact CLIP/ALPHA fixed-state groups through leaf replay, and use retail's row-3 override, blend, depth, and texture-class alpha references. Keep the existing building-detail sentinel distinct from the two CLIP references.
Bound rejected source payload, replace no-op allocation proofs with actual EnvCell and particle RHI paths, rebuild checked-in SPIR-V, and correct AP-238/AP-240 plus the S4-c2 evidence record.
Gates: Release 0W/0E; hermetic 16735/0/0; InstalledDat 255 pass/10 known fail/1 skip; shaders 32/32; focused 239/239; allocation 2/2 at 0 B. The evidence/comment repair re-ran Release 0W/0E, shaders 32/32, affected 37/37, and allocation 2/2; mesh_detail.vert.spv remained byte-identical at SHA-256 5346247ab7d606046943e19b28888c814e08dc6cb27cd9750096ac055457eb57.
Mutation proof, with each mutation restored after its named first failure:
1. Restoring the opaque predicate to !IsAdditive fails WholeLeaf_MixedCellDrawsOpaqueAtTurnThenClipAndAlphaAtDrain first at draw count: expected 1, actual 3.
2. Selecting _alphaPipeline for CLIP fails WholeLeaf_ClipDrainBindsExactStateAndTextureClassReference first at the bind sequence: expected [envcell-clip], actual [envcell-alpha].
3. Disabling CLIP depth write fails that production Theory first at Assert.True(clipPipeline.Depth.Write): expected true, actual false.
4. Swapping palette/DDS references fails the DDS row first: expected 0.784313738, actual 0.392156869; the palette row reports the inverse.
5. Mutating mesh_modern.frag from < to <= fails ClipShaders_UseGreaterEqualForThePerRangeReference first at Assert.Contains("if (color.a < alphaCutoff) discard;"): the required source spelling is absent.
6. Restoring row-3 OverrideClipmap=true fails the real-interface clip Theory first at Assert.False: expected false, actual true.
7. Deleting failed-append rollback fails the flush/end/abort rejection-storm rows first at the bounded pending-count assertion: expected 0, actual 9000.
8. Selecting _transparentDetailPipeline for a ClipMap detail contribution fails the leaf detail pin first at the bind sequence: expected second bind envcell-retail-detail-clip, actual envcell-retail-detail-alpha.
9. Resetting detail ParamB to zero fails the same detail pin first at the second pushed reference: expected 0.784313738, actual 0.
10. Classifying CLIP with exact mask equality excludes legal 0x09 and fails WholeLeaf_PositiveStippleClipMaskUsesClipPipelineAndDdsReference first at pipeline: expected envcell-clip, actual envcell-alpha.
11. Mapping the new blend to SRC_ALPHA/INVSRCALPHA fails AllRetailBlendModesAreRepresentable first at the tuple: expected (One, OneMinusSrcAlpha), actual (SrcAlpha, OneMinusSrcAlpha).
12. Restoring mesh_detail.vert's uParamB > 0.5 category predicate fails ClipShaders_UseGreaterEqualForThePerRangeReference first because vDetailCategory = uParamB == 1.0 is absent.
13. Treating every positive detail uParamB as a cutoff fails that source pin first because isRetailClipReference(uParamB) ? uParamB : 0.05 is absent.
14. Adding arbitrary 0.5 as an accepted reference to either mesh_modern.frag or mesh_detail.frag fails that source pin first at Assert.DoesNotContain("value - 0.5"); both mutations were run and reversed independently.
Retail: D3DPolyRender::SetSurface @ 0x0059c4d0; paired binary @ 0x0059c72a, 0x0059c747/0x0059c74f, 0x0059c821, 0x0059c838, 0x0059c866.
382 lines
20 KiB
C#
382 lines
20 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace AcDream.App.Tests.Rendering.Gpu.Vk;
|
|
|
|
/// <summary>
|
|
/// Campaign V slice V6c, plan §4.6: the committed SPIR-V must match the GLSL it
|
|
/// was compiled from.
|
|
///
|
|
/// <para>Plan §4.6 rules out runtime shader compilation — CI has no Vulkan SDK,
|
|
/// and a native shaderc dependency plus a startup cost would be paid for shaders
|
|
/// that never change at runtime — so the <c>.spv</c> artifacts are committed.
|
|
/// The obvious hazard follows immediately: someone edits a shader, the GL
|
|
/// backend picks it up because it compiles GLSL at startup, and the Vulkan
|
|
/// backend silently keeps rendering the old one. This test is what turns that
|
|
/// into a red build.</para>
|
|
///
|
|
/// <para>It also pins which production shaders are Vulkan-expressible TODAY. As
|
|
/// of Campaign V slice V11 (which deleted the one pair that never was —
|
|
/// <c>mesh.vert</c>/<c>mesh.frag</c>, the pre-modern-pipeline shader the N.5 ship
|
|
/// amendment's mandatory modern path made unreachable) every remaining pair
|
|
/// compiles. A future non-ready pair's failure is a specific source-level fact
|
|
/// belonging to a renderer-port slice that has not landed — not a toolchain gap.
|
|
/// Recording them here means the next slice inherits an inventory rather than a
|
|
/// rediscovery.</para>
|
|
/// </summary>
|
|
public sealed class VulkanShaderManifestTests
|
|
{
|
|
// Exact binaries from the pre-campaign retail-authoritative renderer at
|
|
// 5ca029d3. New opt-in pack shaders may be added, but recompiling these
|
|
// with a different toolchain is itself an unreviewed default-path change.
|
|
private static readonly IReadOnlyDictionary<string, string> RetailOracleSpirvSha256 =
|
|
new Dictionary<string, string>(StringComparer.Ordinal)
|
|
{
|
|
["debug_line.frag.spv"] = "02fc04880bc5eb74353566f914675244038125c71443964decdc28e8199264df",
|
|
["debug_line.vert.spv"] = "f9c6a9b575bb07a426fb6ade677bca96a7752ca6b120e8f6451363ba73b51140",
|
|
// Re-pinned 2026-09-04 (Campaign OVERHAUL S4-c2 owner-authorized
|
|
// repair): Vulkan expresses SetSurface's pure-ClipMap alpha test
|
|
// through uParamB, recognizing only the paletted 100/255 and DDS
|
|
// 200/255 references and using discard-on-less so equality passes.
|
|
// The paired binary selects those globals and
|
|
// D3DCMP_GREATEREQUAL at 0x0059c73c..0x0059c838.
|
|
["mesh_modern.frag.spv"] = "1b3a59d0cc7c5bb6acf793d57d9673d860a7eb50bdd51770781f59feac4604a1",
|
|
// mesh_modern.vert re-pinned 2026-09-03 (S3 review fix round 1,
|
|
// F5): the binding=2 ClipRegionBuf (CellClip) SSBO, its
|
|
// gl_ClipDistance loop, and the gl_PerVertex redeclaration are
|
|
// deleted outright — the ONE WbDrawDispatcher API that could
|
|
// ever arm per-instance clip routing had ZERO production
|
|
// callers, so every instance has always mapped to slot
|
|
// 0 (no-clip) in every shipped build; the shader-side clip test
|
|
// clipped nothing. binding=3 instanceClipSlot[] stays declared,
|
|
// unread (fed 0; the instance-buffer layout is S5's).
|
|
["mesh_modern.vert.spv"] = "f9ed4ee7140ccd136130559dbea68545f733f6022f52b085b6df4bcb787223c6",
|
|
["particle.frag.spv"] = "680da227704e0b3afa9b5226a7d73dd65aa9d8759d081cf4d5009d30e148726b",
|
|
// Re-pinned 2026-08-27: portal-view clip slots now travel with
|
|
// deferred billboard particles, matching retail PortalList draws.
|
|
// Re-pinned again 2026-09-03 (S3 review fix round 1, F5): same
|
|
// deletion as mesh_modern.vert above — the binding=2 ClipRegionBuf
|
|
// fetch, gl_ClipDistance loop, and gl_PerVertex redeclaration are
|
|
// gone; the aClipSlot vertex attribute stays declared, unread.
|
|
["particle.vert.spv"] = "95ce6ecf834930a92da5c5fe9aef513b38b5ba104704b98c1606af71fe17eaf3",
|
|
["particle_mesh.frag.spv"] = "7696b1dc0613b5a724c55df465173f613ae047da9675895b149b7c71b009cc7c",
|
|
// Same contract for full-mesh particle geometry.
|
|
// Re-pinned again 2026-09-03 (S3 review fix round 1, F5): same
|
|
// ClipRegionBuf/gl_ClipDistance/gl_PerVertex deletion as
|
|
// particle.vert above.
|
|
["particle_mesh.vert.spv"] = "043482b97c2ed036511692f89c75a0a6c298aba48cb519e5e3aff7fe7ba6371b",
|
|
["portal_depth.frag.spv"] = "96755196d4d0da7be4792107557465778be2ebefb5584834cc75bf90ec55a6cc",
|
|
// Re-pinned 2026-09-02 (Campaign OVERHAUL S1 hygiene): the b3b7d922
|
|
// investigation checkpoint removed the #117 mark-pass NDC bias and the
|
|
// #129 eye-span cap from portal_depth.vert (the exact retail depth-state
|
|
// candidate: ALWAYS / write / no stencil; far-Z punch, true-depth seal).
|
|
// Its acceptance belongs to OVERHAUL S4 (gate G3); this pin records the
|
|
// binary that is actually on the branch so accidental drift is still caught.
|
|
// Re-pinned 2026-09-03 (S4-c1 C0): the far-Z punch constant now writes
|
|
// retail's EXACT bits (uintBitsToFloat(0x3F7FFFEFu),
|
|
// D3DPolyRender::DrawPortalPolyInternal @0x0059bc90's tail) instead of
|
|
// the decimal literal 0.99999988f, which round-tripped to a DIFFERENT
|
|
// bit pattern (0x3F7FFFFE — FARTHER from the camera, larger z/w, than
|
|
// retail's real constant by fifteen ULPs; fix round 1 F4 corrected this
|
|
// comment's direction — see T1,
|
|
// PortalDepthVert_FarPunchConstant_MatchesRetailExactBits, in THIS file).
|
|
["portal_depth.vert.spv"] = "51c60d0924d62c61548efcf5f9e7672a121b1b68ca0a06755e32f1a4d73a8acf",
|
|
// sky.frag re-pinned 2026-08-23: the dome's fog blend lost its
|
|
// 0.2 floor and is now applied only under an AdminEnvirons fog
|
|
// override, retail's GameSky::Draw @0x00506FF0 rule (see
|
|
// SkyFogRuleTests). A deliberate default-path change, reviewed
|
|
// with the world-fog-range fix in the same commit.
|
|
["sky.frag.spv"] = "1c4ae77056837cbdc188f8cfcc4b0e8851647cdfaf398f25d8c8ff489ef84d57",
|
|
// sky.vert re-pinned 2026-09-03 (Campaign OVERHAUL S3 chunk 4 fix
|
|
// round 2, L3): the screen-space TerrainClip UBO (binding=2) and
|
|
// its gl_ClipDistance writes are deleted outright — retail draws
|
|
// the sky ONCE, unclipped (GameSky::Draw(sky,0) @0x0050633c), and
|
|
// that mechanism's only CPU-side writer was already retired at
|
|
// fix round 1's K3, leaving the shader-side declaration dead.
|
|
["sky.vert.spv"] = "7d67a9e3624d198b370d402b5c12e4ce925bf9b8e646ef5123636a86d5985ab5",
|
|
["terrain_modern.frag.spv"] = "7b3cdb01b837ed77ee20559a81c1ce5c9d5395300efcc072560ab0be3c5a1af9",
|
|
// terrain_modern.vert re-pinned 2026-09-03 (Campaign OVERHAUL S3
|
|
// chunk 4 fix round 2, L3): same deletion as sky.vert above —
|
|
// retail never view-clips terrain (LScape::draw draws whole
|
|
// landblocks; RenderDeviceD3D::DrawBlock @0x005a17c0).
|
|
["terrain_modern.vert.spv"] = "8a73d89ef0e51e550327b9ff8c24857e309103b1d491030cf0d4d8594b45068c",
|
|
["ui_text.frag.spv"] = "37a281bf80441cb425eaa3ad8e0b3a43cfa21b74b60973ed4201718b9dc102df",
|
|
["ui_text.vert.spv"] = "018ac64477cf7d4c3fc0c5878951b148c7bfeb6ee3a7eebb02381d7904877798",
|
|
["vk_probe.frag.spv"] = "c2dedbcc6dcc89744707b4b47138f1c31b38ef9088e584f1da07dd6953586c42",
|
|
["vk_probe.vert.spv"] = "6c3260b45644033d607727cbd2e11fb4f60eb4a5b18bfd0997710f2ca518a023",
|
|
};
|
|
|
|
private sealed record StageEntry(string Stage, string SourceSha256, bool Compiled, string? Message);
|
|
|
|
private sealed record ShaderEntry(string Name, bool VulkanReady, IReadOnlyList<StageEntry> Stages);
|
|
|
|
private sealed record Manifest(string Note, IReadOnlyList<ShaderEntry> Shaders);
|
|
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
};
|
|
|
|
private static string RepositoryRoot()
|
|
{
|
|
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
|
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
|
directory = directory.Parent;
|
|
return directory?.FullName
|
|
?? throw new InvalidOperationException("Could not locate the repository root from the test binary.");
|
|
}
|
|
|
|
private static string ShadersDirectory() =>
|
|
Path.Combine(RepositoryRoot(), "src", "AcDream.App", "Rendering", "Shaders");
|
|
|
|
private static string SpirvDirectory() => Path.Combine(ShadersDirectory(), "spv");
|
|
|
|
private static Manifest ReadManifest()
|
|
{
|
|
string path = Path.Combine(SpirvDirectory(), "shaders.manifest.json");
|
|
Assert.True(File.Exists(path), $"The shader manifest is missing at {path}. Run tools/compile-shaders.ps1.");
|
|
return JsonSerializer.Deserialize<Manifest>(File.ReadAllText(path), JsonOptions)
|
|
?? throw new InvalidOperationException("The shader manifest could not be parsed.");
|
|
}
|
|
|
|
private static string Sha256OfSource(string path)
|
|
{
|
|
// Line endings are normalised before hashing so a checkout with a
|
|
// different core.autocrlf setting does not report every shader stale.
|
|
string text = File.ReadAllText(path);
|
|
if (text.Contains("#include \"", StringComparison.Ordinal))
|
|
{
|
|
text = ExpandIncludes(
|
|
text,
|
|
Path.GetDirectoryName(path)!,
|
|
[]);
|
|
}
|
|
text = text.Replace("\r\n", "\n", StringComparison.Ordinal);
|
|
return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(text)));
|
|
}
|
|
|
|
private static string ExpandIncludes(
|
|
string source,
|
|
string sourceDirectory,
|
|
HashSet<string> active)
|
|
{
|
|
var output = new StringBuilder();
|
|
foreach (string line in source.Replace("\r\n", "\n").Split('\n'))
|
|
{
|
|
string trimmed = line.Trim();
|
|
if (!trimmed.StartsWith("#include \"", StringComparison.Ordinal)
|
|
|| !trimmed.EndsWith('"'))
|
|
{
|
|
output.AppendLine(line);
|
|
continue;
|
|
}
|
|
string key = trimmed[10..^1];
|
|
Assert.DoesNotContain("..", key, StringComparison.Ordinal);
|
|
Assert.DoesNotContain('\\', key);
|
|
string include = Path.GetFullPath(Path.Combine(sourceDirectory, key));
|
|
Assert.StartsWith(
|
|
Path.GetFullPath(sourceDirectory) + Path.DirectorySeparatorChar,
|
|
include,
|
|
StringComparison.Ordinal);
|
|
Assert.True(File.Exists(include), $"GLSL include '{key}' is missing.");
|
|
Assert.True(active.Add(include), $"GLSL include '{key}' is cyclic.");
|
|
output.AppendLine($"// ---- begin include: {key} ----");
|
|
output.Append(ExpandIncludes(File.ReadAllText(include), sourceDirectory, active));
|
|
output.AppendLine($"// ---- end include: {key} ----");
|
|
active.Remove(include);
|
|
}
|
|
return output.ToString();
|
|
}
|
|
|
|
[Fact]
|
|
public void EveryGlslPairIsRecordedInTheManifest()
|
|
{
|
|
Manifest manifest = ReadManifest();
|
|
string[] pairs = Directory
|
|
.EnumerateFiles(ShadersDirectory(), "*.vert")
|
|
.Select(Path.GetFileNameWithoutExtension)
|
|
.Where(name => name is not null && File.Exists(Path.Combine(ShadersDirectory(), $"{name}.frag")))
|
|
.Select(name => name!)
|
|
.OrderBy(name => name, StringComparer.Ordinal)
|
|
.ToArray();
|
|
|
|
Assert.Equal(pairs, manifest.Shaders.Select(shader => shader.Name).OrderBy(n => n, StringComparer.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public void PreCampaignRetailSpirvBinariesRemainByteExact()
|
|
{
|
|
foreach ((string fileName, string expectedSha256) in RetailOracleSpirvSha256)
|
|
{
|
|
string path = Path.Combine(SpirvDirectory(), fileName);
|
|
Assert.True(File.Exists(path), $"Retail shader oracle '{fileName}' is missing.");
|
|
string actual = Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(path)));
|
|
Assert.Equal(expectedSha256, actual);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void CommittedSpirvIsNotStaleAgainstItsGlslSource()
|
|
{
|
|
Manifest manifest = ReadManifest();
|
|
var stale = new List<string>();
|
|
|
|
foreach (ShaderEntry shader in manifest.Shaders)
|
|
{
|
|
foreach (StageEntry stage in shader.Stages)
|
|
{
|
|
string source = Path.Combine(ShadersDirectory(), $"{shader.Name}.{stage.Stage}");
|
|
if (!File.Exists(source))
|
|
{
|
|
stale.Add($"{shader.Name}.{stage.Stage}: the GLSL source no longer exists");
|
|
continue;
|
|
}
|
|
|
|
string actual = Sha256OfSource(source);
|
|
if (!string.Equals(actual, stage.SourceSha256, StringComparison.Ordinal))
|
|
stale.Add($"{shader.Name}.{stage.Stage}: source changed since the .spv was built");
|
|
}
|
|
}
|
|
|
|
Assert.True(
|
|
stale.Count == 0,
|
|
"Committed SPIR-V is out of date. Run tools/compile-shaders.ps1 and commit the result.\n "
|
|
+ string.Join("\n ", stale));
|
|
}
|
|
|
|
[Fact]
|
|
public void EveryShaderTheManifestCallsReadyHasBothSpirvArtifacts()
|
|
{
|
|
Manifest manifest = ReadManifest();
|
|
foreach (ShaderEntry shader in manifest.Shaders.Where(entry => entry.VulkanReady))
|
|
{
|
|
foreach (string stage in (string[])["vert", "frag"])
|
|
{
|
|
string path = Path.Combine(SpirvDirectory(), $"{shader.Name}.{stage}.spv");
|
|
Assert.True(File.Exists(path), $"{shader.Name} is marked Vulkan-ready but {path} is missing.");
|
|
long length = new FileInfo(path).Length;
|
|
Assert.True(length > 0 && length % 4 == 0, $"{path} is not a whole number of SPIR-V words.");
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ShadersTheManifestCallsUnreadyHaveNoStaleSpirvLeftBehind()
|
|
{
|
|
Manifest manifest = ReadManifest();
|
|
foreach (ShaderEntry shader in manifest.Shaders.Where(entry => !entry.VulkanReady))
|
|
{
|
|
foreach (string stage in (string[])["vert", "frag"])
|
|
{
|
|
string path = Path.Combine(SpirvDirectory(), $"{shader.Name}.{stage}.spv");
|
|
// A leftover .spv from an earlier attempt would be loaded
|
|
// happily by the device and would be a shader nobody can account
|
|
// for.
|
|
Assert.False(File.Exists(path), $"{shader.Name} is not Vulkan-ready but {path} exists.");
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void EveryUnreadyShaderRecordsWhyItCannotBeCompiledYet()
|
|
{
|
|
Manifest manifest = ReadManifest();
|
|
foreach (ShaderEntry shader in manifest.Shaders.Where(entry => !entry.VulkanReady))
|
|
{
|
|
Assert.Contains(shader.Stages, stage => !stage.Compiled && !string.IsNullOrWhiteSpace(stage.Message));
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void TheRhiVerificationShaderIsCompiled()
|
|
{
|
|
Manifest manifest = ReadManifest();
|
|
ShaderEntry probe = Assert.Single(
|
|
manifest.Shaders,
|
|
shader => string.Equals(shader.Name, "vk_probe", StringComparison.Ordinal));
|
|
|
|
// Plan §4.11 wants the capability probe to build a real pipeline from
|
|
// committed .spv; slice V5 deferred that to V6c because no toolchain
|
|
// existed. If this pair ever stops compiling, the Vulkan backend has no
|
|
// pipeline it can build at all.
|
|
Assert.True(probe.VulkanReady, "vk_probe must compile — the whole Vulkan backend draws with it.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// S4-c1 C0 (T1): <c>D3DPolyRender::DrawPortalPolyInternal</c>
|
|
/// @0x0059bc90's tail writes the far-Z punch depth from the EXACT bit
|
|
/// pattern <c>0x3F7FFFEF</c> — the depth-lifecycle spec's "Current
|
|
/// acdream correspondence" table's "wrong constant" row, and
|
|
/// docs/research/2026-09-01-overhaul/s4-depth-alpha-packet.md §6's R1/C0.
|
|
/// This is a SOURCE pin, deliberately independent of
|
|
/// <see cref="PreCampaignRetailSpirvBinariesRemainByteExact"/>'s compiled
|
|
/// hash pin above: it reads the punch line directly out of the GLSL and
|
|
/// reinterprets whatever numeric literal it finds as raw bits, so a
|
|
/// future edit that swaps in an "equivalent-looking" decimal literal (or
|
|
/// any other constant) is caught even before anyone re-runs
|
|
/// tools/compile-shaders.ps1. Recognizes both the current
|
|
/// <c>uintBitsToFloat(0x...u)</c> form and a plain decimal float literal
|
|
/// (the pre-C0 shape), so it can also be pointed at old source to prove
|
|
/// it used to fail. At `d0c981212` (pre-C0) the line read
|
|
/// <c>clipPos.z = clipPos.w * 0.99999988;</c>, whose bits are
|
|
/// <c>0x3F7FFFFE</c> (confirmed via
|
|
/// <c>BitConverter.SingleToInt32Bits(0.99999988f)</c>) — FIFTEEN ULPs
|
|
/// off from retail's <c>0x3F7FFFEF</c>; running this exact assertion
|
|
/// against that source produces (verified by hand-editing the line back
|
|
/// to the old literal and re-running this test during S4-c1's own
|
|
/// implementation — the commit body carries the same transcript):
|
|
/// "Assert.Equal() Failure: Values differ\nExpected: 1065353199\nActual: 1065353214"
|
|
/// (1065353199 = 0x3F7FFFEF, 1065353214 = 0x3F7FFFFE).
|
|
/// MUTATION: change the punch literal to anything other than bits
|
|
/// <c>0x3F7FFFEF</c> (restore the old decimal, or substitute a different
|
|
/// hex constant) — <see cref="Assert.Equal(uint, uint)"/> below fails.
|
|
/// </summary>
|
|
[Fact]
|
|
public void PortalDepthVert_FarPunchConstant_MatchesRetailExactBits()
|
|
{
|
|
string source = File.ReadAllText(Path.Combine(ShadersDirectory(), "portal_depth.vert"));
|
|
string line = source
|
|
.Split('\n')
|
|
.Select(l => l.Trim())
|
|
.SingleOrDefault(l => l.StartsWith("clipPos.z = clipPos.w * ", StringComparison.Ordinal))
|
|
?? throw new InvalidOperationException(
|
|
"portal_depth.vert no longer has a 'clipPos.z = clipPos.w * <literal>;' punch "
|
|
+ "line for T1 to read — did the far-Z punch assignment move or get restructured?");
|
|
|
|
uint bits = ParsePunchLiteralBits(line);
|
|
Assert.Equal(0x3F7FFFEFu, bits);
|
|
}
|
|
|
|
private static uint ParsePunchLiteralBits(string assignmentLine)
|
|
{
|
|
// assignmentLine looks like:
|
|
// "clipPos.z = clipPos.w * uintBitsToFloat(0x3F7FFFEFu);" (post-C0)
|
|
// or:
|
|
// "clipPos.z = clipPos.w * 0.99999988;" (pre-C0)
|
|
const string prefix = "clipPos.z = clipPos.w * ";
|
|
string rhs = assignmentLine[prefix.Length..];
|
|
int commentStart = rhs.IndexOf("//", StringComparison.Ordinal);
|
|
if (commentStart >= 0)
|
|
rhs = rhs[..commentStart];
|
|
rhs = rhs.Trim().TrimEnd(';', ' ');
|
|
|
|
Match hexMatch = Regex.Match(rhs, @"uintBitsToFloat\(\s*0x([0-9A-Fa-f]+)u?\s*\)");
|
|
if (hexMatch.Success)
|
|
return Convert.ToUInt32(hexMatch.Groups[1].Value, 16);
|
|
|
|
string literal = rhs.TrimEnd('f', 'F');
|
|
if (float.TryParse(literal, NumberStyles.Float, CultureInfo.InvariantCulture, out float value))
|
|
return unchecked((uint)BitConverter.SingleToInt32Bits(value));
|
|
|
|
throw new InvalidOperationException(
|
|
$"portal_depth.vert's punch literal '{rhs}' is neither a uintBitsToFloat(0x...) call "
|
|
+ "nor a plain float literal T1 knows how to reinterpret as bits.");
|
|
}
|
|
}
|