From 602bc9dddb66b59b14a0b47c03f77cbf0c54684d Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 28 Jul 2026 09:26:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(render):=20V6e=20=E2=80=94=20both=20partic?= =?UTF-8?q?le=20shaders=20cross=20the=20dialect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Campaign V slice V6e, second of three. Billboard particles and mesh particles are the last two pairs blocked on the texture-table shape; sky follows. particle takes the same treatment mesh_modern took: the `flat uvec2` handle varying becomes a `flat uint` slot and the fragment stage samples through ACDREAM_SAMPLE_ARRAY. What is different here is the untextured particle. The shader used to ask "is the handle I was given zero", which GL can answer because its emulated table stores handles; Vulkan cannot, because set 2 is an opaque descriptor array and reading an element nobody wrote is undefined rather than zero. So the question moves to the index: the CPU writes ACDREAM_TEXTURE_NONE for a particle with no texture instead of interning the null handle as a table slot, and both dialects test the same value. GL renders identically — the same particles take the same branch to the same procedural blob — and the handle table simply stops carrying an entry that never named a texture. A test pins the sentinel across all three declarations of it, because a silent disagreement here would sample slot 0xFFFFFFFF instead of drawing the blob. particle_mesh needed no restructuring, only names. Vulkan GLSL has no default uniform block, so `uniform uint uTextureIndex;` is not unsupported but unspellable, and the two values are per-pass — one texture and one layer for a whole sub-batch — which is exactly what the shared push-constant block is for. uTextureIndex becomes uTextureIndexA; uTextureLayer becomes uParamA, which was the spare scalar and is a natural fit because the shader converted the layer to float anyway. The widening moved from the shader to the CPU; layers are small integers, so the sampled value is bit-identical. Gates: Release build clean; App tests 4,058 passed / 3 skipped (baseline 4,057 plus the sentinel drift guard). Offline pixel gate against 95f8c25f: two captures, 29 px and 21 px of 563,200 compared (3.73e-05 and 5.15e-05), with a same-commit control between them of 13 px and this commit measuring 14 px against its own parent. The scene draws no particles, so this gate is a tripwire that the world path is undisturbed, not evidence about particles. Particles remain user-gate debt — the same debt V2c and V4e already carry, to be paid by casting a spell in a connected session. Manifest: 6/9 pairs compile. Remaining: mesh (legacy, no consumer), sky (next commit), terrain_modern. Co-Authored-By: Claude Fable 5 --- .../Rendering/Gpu/GpuPushConstants.cs | 6 +- src/AcDream.App/Rendering/ParticleRenderer.cs | 39 +++++++++-- .../Rendering/Shaders/particle.frag | 11 ++-- .../Rendering/Shaders/particle.vert | 11 ++-- .../Rendering/Shaders/particle_mesh.frag | 15 +++-- .../Rendering/Shaders/spv/particle.frag.spv | Bin 0 -> 1352 bytes .../Rendering/Shaders/spv/particle.vert.spv | Bin 0 -> 1844 bytes .../Shaders/spv/particle_mesh.frag.spv | Bin 0 -> 1376 bytes .../Shaders/spv/particle_mesh.vert.spv | Bin 0 -> 1384 bytes .../Shaders/spv/shaders.manifest.json | 19 +++--- .../ParticleBindlessInstanceTests.cs | 61 ++++++++++++++++-- 11 files changed, 126 insertions(+), 36 deletions(-) create mode 100644 src/AcDream.App/Rendering/Shaders/spv/particle.frag.spv create mode 100644 src/AcDream.App/Rendering/Shaders/spv/particle.vert.spv create mode 100644 src/AcDream.App/Rendering/Shaders/spv/particle_mesh.frag.spv create mode 100644 src/AcDream.App/Rendering/Shaders/spv/particle_mesh.vert.spv diff --git a/src/AcDream.App/Rendering/Gpu/GpuPushConstants.cs b/src/AcDream.App/Rendering/Gpu/GpuPushConstants.cs index e7ff3827..5e50d0fe 100644 --- a/src/AcDream.App/Rendering/Gpu/GpuPushConstants.cs +++ b/src/AcDream.App/Rendering/Gpu/GpuPushConstants.cs @@ -58,7 +58,11 @@ internal struct GpuPushConstants /// GLSL uTextureIndexB. Secondary per-pass slot — currently the terrain alpha-mask array. public uint TextureIndexB; - /// GLSL uParamA. Spare scalar; unclaimed at V0. + /// + /// GLSL uParamA. Spare scalar, claimed at slice V6e by + /// particle_mesh as the array layer its per-pass texture is sampled + /// from — a value the shader converted to float anyway. + /// public float ParamA; /// GLSL uParamB. Spare scalar; unclaimed at V0. diff --git a/src/AcDream.App/Rendering/ParticleRenderer.cs b/src/AcDream.App/Rendering/ParticleRenderer.cs index 81657d9a..b00d3d91 100644 --- a/src/AcDream.App/Rendering/ParticleRenderer.cs +++ b/src/AcDream.App/Rendering/ParticleRenderer.cs @@ -116,6 +116,11 @@ public sealed unsafe class ParticleRenderer : IDisposable private bool _disposing; private bool _disposed; private readonly HashSet _meshLoadRequestedThisFrame = new(); + // Campaign V slice V6e: particle_mesh.frag's two loose uniforms were renamed + // onto members of the shared push-constant block (uTextureIndex → + // uTextureIndexA, uTextureLayer → uParamA), because Vulkan GLSL has no + // default uniform block to declare them in. Under GL they are still plain + // program uniforms set exactly as before; only the names moved. private readonly int _meshTextureIndexLoc = -1; private readonly int _meshTextureLayerLoc = -1; @@ -266,8 +271,8 @@ public sealed unsafe class ParticleRenderer : IDisposable constructionResources.Add( "particle mesh shader", _meshShader.Dispose); - _meshTextureIndexLoc = _gl.GetUniformLocation(_meshShader.Program, "uTextureIndex"); - _meshTextureLayerLoc = _gl.GetUniformLocation(_meshShader.Program, "uTextureLayer"); + _meshTextureIndexLoc = _gl.GetUniformLocation(_meshShader.Program, "uTextureIndexA"); + _meshTextureLayerLoc = _gl.GetUniformLocation(_meshShader.Program, "uParamA"); } // Campaign V slice V2c: binding=9 texture-table SSBO (GL-only @@ -564,7 +569,10 @@ public sealed unsafe class ParticleRenderer : IDisposable _meshShader.Program, _meshTextureIndexLoc, _textureTable.GetOrAdd(batch.BindlessTextureHandle)); - _gl.ProgramUniform1(_meshShader.Program, _meshTextureLayerLoc, batch.TextureIndex); + // Slice V6e: uParamA is a float, so the layer is widened here rather + // than in the shader's float(uTextureLayer). Layers are small + // integers; the sampled value is bit-identical. + _gl.ProgramUniform1(_meshShader.Program, _meshTextureLayerLoc, (float)batch.TextureIndex); UploadMeshInstances(_meshRunScratch); PrepareMeshPipeline(viewProjection, global); @@ -756,7 +764,10 @@ public sealed unsafe class ParticleRenderer : IDisposable _meshShader.Program, _meshTextureIndexLoc, _textureTable.GetOrAdd(batch.BindlessTextureHandle)); - _gl.ProgramUniform1(_meshShader.Program, _meshTextureLayerLoc, batch.TextureIndex); + // Slice V6e: uParamA is a float, so the layer is widened here rather + // than in the shader's float(uTextureLayer). Layers are small + // integers; the sampled value is bit-identical. + _gl.ProgramUniform1(_meshShader.Program, _meshTextureLayerLoc, (float)batch.TextureIndex); _gl.BindVertexArray(_meshVao); FlushAndBindTextureTable(); // Campaign V slice V2c (binding=9) @@ -1057,6 +1068,22 @@ public sealed unsafe class ParticleRenderer : IDisposable PersistActiveDynamicBufferCapacities(); } + /// + /// Campaign V slice V6e: the shader-side spelling of "this particle has no + /// texture, draw the procedural blob". It must agree with + /// ACDREAM_TEXTURE_NONE in Shaders/common.glsl and in the + /// Vulkan preamble. + /// + /// V2c encoded the same fact as "a table slot whose handle is zero", + /// which particle.frag could test because GL's emulated table stores the + /// handles themselves. Vulkan's table is an opaque descriptor array with + /// nothing to compare — reading an unwritten element of a partially-bound + /// array is undefined, not zero — so the fact moves into the index, where + /// both dialects test it the same way. GL renders identically: the same + /// particles take the same branch. + /// + private const uint NoTextureSlot = 0xFFFFFFFFu; + // Campaign V slice V2c: instance method (not static) because it converts // the particle's raw bindless handle to a _textureTable slot. private void WriteBillboardGpuInstance( @@ -1073,7 +1100,9 @@ public sealed unsafe class ParticleRenderer : IDisposable ((particle.ColorArgb >> 8) & 0xFF) / 255f, (particle.ColorArgb & 0xFF) / 255f, ((particle.ColorArgb >> 24) & 0xFF) / 255f), - TextureIndex = _textureTable.GetOrAdd(particle.TextureHandle), + TextureIndex = particle.TextureHandle == 0UL + ? NoTextureSlot + : _textureTable.GetOrAdd(particle.TextureHandle), }; } diff --git a/src/AcDream.App/Rendering/Shaders/particle.frag b/src/AcDream.App/Rendering/Shaders/particle.frag index 526183d4..a4a571e3 100644 --- a/src/AcDream.App/Rendering/Shaders/particle.frag +++ b/src/AcDream.App/Rendering/Shaders/particle.frag @@ -3,14 +3,17 @@ in vec2 vTex; in vec4 vColor; -flat in uvec2 vTextureHandle; +// Campaign V slice V6e: the texture-table SLOT, not the bindless handle — see +// particle.vert. ACDREAM_TEXTURE_NONE is the untextured particle, which used to +// be spelled "the slot whose handle is zero"; a Vulkan descriptor array cannot +// be asked that question, so the answer lives in the index (common.glsl). +flat in uint vTextureIndex; out vec4 fragColor; void main() { vec4 texel; - if (any(notEqual(vTextureHandle, uvec2(0)))) { - sampler2DArray particleTexture = sampler2DArray(vTextureHandle); - texel = texture(particleTexture, vec3(vTex, 0.0)); + if (vTextureIndex != ACDREAM_TEXTURE_NONE) { + texel = ACDREAM_SAMPLE_ARRAY(vTextureIndex, vec3(vTex, 0.0)); } else { vec2 d = vTex - vec2(0.5, 0.5); float r = length(d) * 2.0; diff --git a/src/AcDream.App/Rendering/Shaders/particle.vert b/src/AcDream.App/Rendering/Shaders/particle.vert index 11162a38..e9259d61 100644 --- a/src/AcDream.App/Rendering/Shaders/particle.vert +++ b/src/AcDream.App/Rendering/Shaders/particle.vert @@ -18,7 +18,10 @@ uniform mat4 uViewProjection; out vec2 vTex; out vec4 vColor; -flat out uvec2 vTextureHandle; +// Campaign V slice V6e: was `flat uvec2 vTextureHandle`. A varying cannot carry +// a Vulkan descriptor, so the table SLOT crosses the stage boundary and +// particle.frag does the lookup at the sample site. +flat out uint vTextureIndex; void main() { vec3 world = aCenter.xyz @@ -27,8 +30,8 @@ void main() { vTex = aTex; vColor = aColor; - // Reconstruct the SAME uvec2 handle particle.frag used to receive - // directly — one binding=9 lookup, identical value downstream. - vTextureHandle = ACDREAM_TEXTURE_HANDLE(aTextureIndex); + // Slice V6e: forward the slot untouched; the lookup moved to the fragment + // stage, which is the only form Vulkan can express. + vTextureIndex = aTextureIndex; gl_Position = uViewProjection * vec4(world, 1.0); } diff --git a/src/AcDream.App/Rendering/Shaders/particle_mesh.frag b/src/AcDream.App/Rendering/Shaders/particle_mesh.frag index d880c95d..dd992060 100644 --- a/src/AcDream.App/Rendering/Shaders/particle_mesh.frag +++ b/src/AcDream.App/Rendering/Shaders/particle_mesh.frag @@ -7,12 +7,19 @@ out vec4 fragColor; // Campaign V slice V2c (2026-07-27): was uvec2 uTextureHandle (a raw // ARB_bindless_texture handle); now a slot into the binding=9 handle table. -uniform uint uTextureIndex; -uniform int uTextureLayer; +// +// Slice V6e renamed both onto members of the shared 96-byte push-constant block +// (GpuPushConstants), which is the only home Vulkan has for a loose uniform: +// there is no default uniform block, so `uniform uint uTextureIndex;` is not +// merely unsupported, it is unspellable. Both values are per-pass — one texture, +// one layer, for a whole mesh-particle sub-batch — which is exactly what the +// block is for. uParamA carries the layer because it is consumed as a float +// anyway; the CPU writes the same integral value it always did. +uniform uint uTextureIndexA; +uniform float uParamA; void main() { - sampler2DArray tex = sampler2DArray(ACDREAM_TEXTURE_HANDLE(uTextureIndex)); - vec4 color = texture(tex, vec3(vTexCoord, float(uTextureLayer))) * vColor; + vec4 color = ACDREAM_SAMPLE_ARRAY(uTextureIndexA, vec3(vTexCoord, uParamA)) * vColor; if (color.a < 0.02) discard; fragColor = color; diff --git a/src/AcDream.App/Rendering/Shaders/spv/particle.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/particle.frag.spv new file mode 100644 index 0000000000000000000000000000000000000000..c4e9788ba9927f37116faa8244ed4f6619134441 GIT binary patch literal 1352 zcmZ9L%W70X5QcjulZly`+{ld>qi2j4Ffn*b2x?}Iw+u=a(OI|_+_=zJ_BlXp>(5!`amYbpb7whwK4*0!g7KFiJC-KCx{lg|^= zL>E9Quqcl$oi^E~vMukFt4!S@*31&aCZM4vOt=WB4J zzaB_kYf)Qfj;-vp}u`w2HtlYc>Xr^cNOlL`|L0^W4D2^`rWI$ zG;W{2#h$JM{Tap|?#&**r`Shu-{4%$%Y9p?5Bv`7MY{yX>(8^g122Jl{!EBG=a=-fyV{e|Y6K_y3pZxmxZs8lz!xy%+qyC1`JMM{ zbJ#P_yVvuqwbxo_pLO0&|H4o;*jJ5Iqt%P5x5lgfsxRh9HCXTahp!KpzkL0$yt=j` z;#AeC1I3J0fw6iSG&#ifgh{=ow}z}wLMVVe5Dt?;)B6KPTb3Gjr6v^TJO$! zHA|cv;yg#u*tA~nojRYmgD4x>Eq#eEe4$qV)rO*-ZpEoDZl|TmOWSK{>eCKdnqFv! zElm%!qn73lXm47YK4@=S+Hj-2%f6qd_Z)he$gh1qTBp&bV#(VLrgI1K;r#fChU>8# z?(RG*eY4?aj#jw&!JR*oT|RZ4pUBQf7mu4S+GO}-_HJM;HGHZ^>+{pG#mZUgnMYcQ zztnK`uEh2%r5fpX%ntNNz-VNk+5BPdu&4fS5F0c1Em$zspXyV$K z3-mnndvz`S{)%G0x1#E4G%!OitKoY7 z{1?!`@=S7+c;B5FEAjpXX<(K3-Bd9zGl3a1M{?-zTtNSk7&Cu9kca-g(JsW+3ty>y zF}8Utwf#5I!1}*-H=5esZ>c>WyBn0+mt)IAFSQq9tIy}0`Mnk_22XN}W*9GhEyXsM zeC8GB^Db8cGskB(ad+drJr2A}vm)L*+zRNg663CR0{vJ;+l@`@YiUm#tv~xwZq>Ve j7VHG#`OU@8_(^b564I2 z?GGQ{Z13#eF>^LrWdU>NVmjJL@$U88_c~?)>2V2cfd^oo9AhTI5Y3oP(9G;Ux@Uy{ zT&2cDX-}NmgT#;U)G^ka&*JRMxPy%I9=Yck=M-|IjI&3%amLw$++oIb68AFW?8CT| zj60LKSI8kdu;*&#R;;0aVNvqynSS9^Z5+SsY~R(hiam$)KJQxM>l+oo zJG6VrMleIS+>JTLbk`EPZ)dN|$yeRkj`P?aXK^nZ zNNc+@I3)l8 literal 0 HcmV?d00001 diff --git a/src/AcDream.App/Rendering/Shaders/spv/particle_mesh.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/particle_mesh.vert.spv new file mode 100644 index 0000000000000000000000000000000000000000..3b32357f1a662c34377561268db4208144750b81 GIT binary patch literal 1384 zcmYk*J8u&~6b0b1O&q|G5JDb6FdNJ}V1fh)3GtGU?7~G56cjW_G!)Rl58yArucbla zeDN-tk&f=1bMM^8%>GvO>r;i>VidZXy z<3KU%IcJ>+zJB`h&Hhv{3f6;-U^f^Cli+qROSBl8g^S1gEq4}IgI^weHJF!`vK?zV zXLZ`L@8!8plT)0oJI$V^?^!(ZiJM2+i?vn)@g3K{Rcrb=S83{tdsS)j(%w{>`m}ka z>4mnaG(FIcDs9+m?<-9o;yzT`$yWOqYesYE#eIr(pM#cr7u?!Rl(_wP`R%u}Q@06r za$Y^0uL)O6Ed5!_y)ljdbgaAS{J8t4$>%GQ&mD+gi*=SRAMTsz^0{+oJ3sClp=~98 zGqwq=gO*>&o_{ZVD^@+)I6Lp(^}q-E*ojq>Umv*dnWoOg*d~xy9sE*Y?S@~DmDgP3 zOzaJ4;%ev@^aN^OiFJq0sEvCg8voUHFCTs_Rv)-{e9~&}_-6KUcXxQJ_0h%Sz5_YT zBKB@j%FmZi@*j9>b69fxiQ@Zw9@V>^3B2ixc=d?$ng4v?efYd1{yL}4&;;gkZ{qd8 zA2b!ep1oOq=M4XiKn&d(Gu#RE|28ptKWMd>|JuD)>s8wQRx{sGCeiOSa9%vWd-#>S ad=CQk#hAgjc^LRE%wR@b+~3^h)8HShPA)qD literal 0 HcmV?d00001 diff --git a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json index 3c6fb2c1..d7d345b4 100644 --- a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json +++ b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json @@ -53,25 +53,23 @@ }, { "name": "particle", - "vulkanReady": false, + "vulkanReady": true, "stages": [ { "stage": "vert", - "sourceSha256": "6a6ebeaacba95e5e4e8a308ed7c4cd805b80f305650c1e9e03e2bdfc6c18f5e7", - "compiled": false, - "message": "particle.vert:85: error: \u0027assign\u0027 : cannot convert from \u0027layout( location=6) in highp uint\u0027 to \u0027layout( location=2) flat out highp 2-component vector of uint\u0027" + "sourceSha256": "9629271f8997853a3c78a3cb1ec7af02a13518d68bdaa5ae1378997da7e5ab62", + "compiled": true }, { "stage": "frag", - "sourceSha256": "3924ecbabf051349bc6a13e6cff370725a0832f8baf515decdb7e0394304006d", - "compiled": false, - "message": "particle.frag:62: error: \u0027sampler2DArray\u0027 : sampler-constructor requires the extension GL_ARB_bindless_texture enabled" + "sourceSha256": "bc08e4fb6f57da94d6c41d52aa81d73ab287c968214303a446c40a04d0495b40", + "compiled": true } ] }, { "name": "particle_mesh", - "vulkanReady": false, + "vulkanReady": true, "stages": [ { "stage": "vert", @@ -80,9 +78,8 @@ }, { "stage": "frag", - "sourceSha256": "0da368243e967388990f4f4b90e2304044af6187de45f70499a3e4ece8dfd5a8", - "compiled": false, - "message": "particle_mesh.frag:64: error: \u0027uTextureIndex\u0027 : undeclared identifier" + "sourceSha256": "73c8db72aeb44e88054dd79fd722c7f683b27a8bc03368b5201f05adffd92728", + "compiled": true } ] }, diff --git a/tests/AcDream.App.Tests/Rendering/ParticleBindlessInstanceTests.cs b/tests/AcDream.App.Tests/Rendering/ParticleBindlessInstanceTests.cs index 0f18be54..aba04240 100644 --- a/tests/AcDream.App.Tests/Rendering/ParticleBindlessInstanceTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ParticleBindlessInstanceTests.cs @@ -1,3 +1,4 @@ +using System.Reflection; using System.Runtime.InteropServices; using AcDream.App.Rendering; @@ -30,15 +31,61 @@ public sealed class ParticleBindlessInstanceTests string vertex = File.ReadAllText(Path.Combine(shadersDirectory, "particle.vert")); string fragment = File.ReadAllText(Path.Combine(shadersDirectory, "particle.frag")); - // Campaign V slice V2c: the per-instance attribute now carries a - // binding=9 table slot, not the raw handle; particle.vert - // reconstructs the SAME uvec2 handle via ACDREAM_TEXTURE_HANDLE - // before handing it to particle.frag, so the fragment shader's half - // (extension, reconstruction, varying type/name) is untouched. + // Campaign V slice V2c: the per-instance attribute carries a binding=9 + // table slot, not the raw handle. + // + // Campaign V slice V6e: the SLOT is what crosses the stage boundary now, + // and the fragment stage does the lookup — a varying cannot carry a + // Vulkan descriptor. The untextured particle is spelled by the reserved + // index rather than by a null handle, because Vulkan's descriptor array + // cannot be asked whether an element was ever written. Assert.Contains("layout(location = 6) in uint aTextureIndex;", vertex); - Assert.Contains("flat out uvec2 vTextureHandle;", vertex); + Assert.Contains("flat out uint vTextureIndex;", vertex); + Assert.Contains("vTextureIndex = aTextureIndex;", vertex); Assert.Contains("#extension GL_ARB_bindless_texture : require", fragment); - Assert.Contains("sampler2DArray(vTextureHandle)", fragment); + Assert.Contains("flat in uint vTextureIndex;", fragment); + Assert.Contains("ACDREAM_SAMPLE_ARRAY(vTextureIndex, vec3(vTex, 0.0))", fragment); + Assert.Contains("vTextureIndex != ACDREAM_TEXTURE_NONE", fragment); Assert.DoesNotContain("uniform sampler2D uParticleTexture", fragment); } + + /// + /// Campaign V slice V6e: "this particle has no texture" is now a reserved + /// index rather than a null handle, and that value is written in three + /// places — the CPU that produces it, the GL preamble that tests it, and the + /// Vulkan preamble that will. Three copies of a magic number is a drift + /// waiting to happen, and its failure mode is silent: a particle would + /// sample slot 0xFFFFFFFF instead of drawing the procedural blob. + /// + [Fact] + public void TheReservedNoTextureSlotAgreesAcrossCpuAndBothDialects() + { + const string literal = "0xFFFFFFFF"; + + object? cpuValue = typeof(ParticleRenderer) + .GetField("NoTextureSlot", BindingFlags.NonPublic | BindingFlags.Static) + ?.GetRawConstantValue(); + Assert.Equal(0xFFFFFFFFu, Assert.IsType(cpuValue)); + + string common = File.ReadAllText(Path.Combine( + AppContext.BaseDirectory, "Rendering", "Shaders", "common.glsl")); + Assert.Contains($"#define ACDREAM_TEXTURE_NONE {literal}u", common); + + // The Vulkan half is injected by the offline compiler, not by + // common.glsl, so it is a separate declaration that has to say the same + // thing. + string preamble = File.ReadAllText(Path.Combine( + RepositoryRoot(), "tools", "ShaderCompiler", "VulkanGlslPreamble.cs")); + Assert.Contains($"#define ACDREAM_TEXTURE_NONE {literal}u", preamble); + } + + 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."); + } }