feat(render): implement Campaign AR and terrain fidelity

This commit is contained in:
Erik 2026-08-22 13:13:29 +02:00
parent 99cf26e00c
commit 7a5f96ede5
368 changed files with 50611 additions and 950 deletions

View file

@ -32,6 +32,14 @@
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
</ProjectReference>
<ProjectReference Include="..\AcDream.Plugin.Tests.Fixtures.InvalidRenderPackMultiple\AcDream.Plugin.Tests.Fixtures.InvalidRenderPackMultiple.csproj">
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
</ProjectReference>
<ProjectReference Include="..\AcDream.Plugin.Tests.Fixtures.InvalidRenderPackInternal\AcDream.Plugin.Tests.Fixtures.InvalidRenderPackInternal.csproj">
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
</ProjectReference>
</ItemGroup>
<ItemGroup>

View file

@ -35,6 +35,32 @@ public sealed class HostInputCameraCompositionTests
Assert.Equal((1280, 720), fixture.Factory.Viewport.Size);
}
[Fact]
public void DiagnosticOrbitOverridesReachOnlyTheInitialOrbitCamera()
{
using var fixture = new Fixture();
HostInputCameraResult result = fixture
.Phase(180f, -135f, 7.5f)
.Compose(fixture.Platform);
Assert.Equal(180f, result.CameraController.Orbit.Distance);
Assert.Equal(-135f * MathF.PI / 180f, result.CameraController.Orbit.Yaw);
Assert.Equal(7.5f * MathF.PI / 180f, result.CameraController.Orbit.Pitch);
}
[Fact]
public void VulkanFactoryConvertsDiagnosticOrbitAnglesFromDegrees()
{
var factory = new VulkanHostInputCameraCompositionFactory();
CameraController camera = factory.CreateCameraController(200f, 180f, -12f);
Assert.Equal(200f, camera.Orbit.Distance);
Assert.Equal(MathF.PI, camera.Orbit.Yaw, precision: 6);
Assert.Equal(-12f * MathF.PI / 180f, camera.Orbit.Pitch, precision: 6);
}
[Theory]
[MemberData(nameof(FaultPointValues))]
public void FailureAtEveryProductionBoundaryStopsTheExactSuffix(
@ -138,7 +164,10 @@ public sealed class HostInputCameraCompositionTests
public Factory Factory { get; }
public Publication Publication { get; }
public HostInputCameraCompositionPhase Phase() => new(
public HostInputCameraCompositionPhase Phase(
float? initialOrbitDistanceMeters = null,
float? initialOrbitYawDegrees = null,
float? initialOrbitPitchDegrees = null) => new(
new HostInputCameraDependencies(
Framebuffer,
new Vector2D<int>(1280, 720),
@ -150,7 +179,10 @@ public sealed class HostInputCameraCompositionTests
PlayerMode,
Chase,
Pointer,
new DiagnosticLog()),
new DiagnosticLog(),
initialOrbitDistanceMeters,
initialOrbitYawDegrees,
initialOrbitPitchDegrees),
Publication,
Factory,
point =>
@ -318,8 +350,20 @@ public sealed class HostInputCameraCompositionTests
KeyBindings bindings) =>
InputDispatcher.CreateDetached(keyboard, mouse, bindings);
public CameraController CreateCameraController() =>
new(new OrbitCamera(), new FlyCamera());
public CameraController CreateCameraController(
float? initialOrbitDistanceMeters,
float? initialOrbitYawDegrees,
float? initialOrbitPitchDegrees)
{
var orbit = new OrbitCamera();
if (initialOrbitDistanceMeters is { } distance)
orbit.Distance = distance;
if (initialOrbitYawDegrees is { } yaw)
orbit.Yaw = yaw * (MathF.PI / 180f);
if (initialOrbitPitchDegrees is { } pitch)
orbit.Pitch = pitch * (MathF.PI / 180f);
return new CameraController(orbit, new FlyCamera());
}
public IFramebufferCameraTarget CreateCameraTarget(CameraController camera) =>
new CameraTarget(camera);

View file

@ -181,9 +181,25 @@ public sealed class InteractionUiRuntimeSourcesTests
Assert.True(source.IsWorldReady);
Assert.True(source.TryRequestCheckpoint("ready", out _, out _));
Assert.Equal("ready", target.LastCheckpoint);
Assert.Equal(target.RenderPackStatus, source.RenderPackStatus);
Assert.Equal(1280, source.FramebufferWidth);
Assert.Equal(720, source.FramebufferHeight);
Assert.True(source.TrySelectRenderPack("high", out _));
Assert.Equal("high", target.LastRenderPackPreset);
Assert.True(source.TryDisableRenderPack(out _));
Assert.True(target.RenderPackDisabled);
Assert.True(source.TryReenableRenderPack(out _));
Assert.True(target.RenderPackReenabled);
Assert.True(source.TryResizeFramebuffer(1024, 768, out _));
Assert.Equal((1024, 768), target.LastFramebufferSize);
binding.Dispose();
Assert.False(source.IsWorldReady);
Assert.Equal(
AcDream.App.UI.Testing.RetailUiAutomationRenderPackStatus.Retail,
source.RenderPackStatus);
Assert.False(source.TrySelectRenderPack("high", out string unboundError));
Assert.Contains("not bound", unboundError);
source.Deactivate();
Assert.Throws<ObjectDisposedException>(() => source.Bind(target));
}
@ -454,7 +470,48 @@ public sealed class InteractionUiRuntimeSourcesTests
public bool IsWorldReady => true;
public bool IsWorldViewportVisible => true;
public int PortalMaterializationCount => 2;
public AcDream.App.UI.Testing.RetailUiAutomationRenderPackStatus
RenderPackStatus { get; } = new(
AcDream.App.UI.Testing.RetailUiAutomationRenderPackState.Active,
"acdream.atmospheric",
"high",
7,
null);
public int FramebufferWidth => 1280;
public int FramebufferHeight => 720;
public string? LastCheckpoint { get; private set; }
public string? LastRenderPackPreset { get; private set; }
public bool RenderPackDisabled { get; private set; }
public bool RenderPackReenabled { get; private set; }
public (int Width, int Height)? LastFramebufferSize { get; private set; }
public bool TrySelectRenderPack(string presetId, out string error)
{
LastRenderPackPreset = presetId;
error = string.Empty;
return true;
}
public bool TryDisableRenderPack(out string error)
{
RenderPackDisabled = true;
error = string.Empty;
return true;
}
public bool TryReenableRenderPack(out string error)
{
RenderPackReenabled = true;
error = string.Empty;
return true;
}
public bool TryResizeFramebuffer(int width, int height, out string error)
{
LastFramebufferSize = (width, height);
error = string.Empty;
return true;
}
public bool TryRequestCheckpoint(
string name,

View file

@ -223,7 +223,8 @@ public sealed class WorldRenderCompositionTests
public void InitializeEnvironment(
WorldEnvironmentController environment,
Region region) { }
Region region,
IDatReaderWriter dats) { }
/// <summary>
/// Campaign V slice V6i-2: the arm a backend with no GL context takes.

View file

@ -0,0 +1,567 @@
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace AcDream.App.Tests.Diagnostics;
public sealed class AtmosphericPerformanceMatrixContractTests
{
[Fact]
public void ScriptsParseWithoutLaunchingTheMatrix()
{
foreach (string script in new[]
{
ScriptPath(),
Path.Combine(FindRepoRoot(), "tools", "run-offline-pixel-gate.ps1"),
Path.Combine(FindRepoRoot(), "tools", "atmospheric-performance-matrix-common.ps1"),
})
AssertPowerShellParses(script);
}
private static void AssertPowerShellParses(string script)
{
var start = new ProcessStartInfo
{
FileName = OperatingSystem.IsWindows() ? "pwsh.exe" : "pwsh",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
start.ArgumentList.Add("-NoProfile");
start.ArgumentList.Add("-NonInteractive");
start.ArgumentList.Add("-Command");
string quotedScript = "'" + script.Replace("'", "''", StringComparison.Ordinal) + "'";
start.ArgumentList.Add(
$"[scriptblock]::Create((Get-Content -Raw -LiteralPath {quotedScript})) | Out-Null");
using Process process = Process.Start(start)
?? throw new InvalidOperationException("Could not start pwsh parser process.");
string stdout = process.StandardOutput.ReadToEnd();
string stderr = process.StandardError.ReadToEnd();
Assert.True(process.WaitForExit(30_000), "PowerShell parser did not exit.");
Assert.True(
process.ExitCode == 0,
$"PowerShell parser failed for {script} with exit code {process.ExitCode}.\n{stdout}\n{stderr}");
}
[Fact]
public void MatrixPinsAllRequiredRowsAndExplicitFramePacingModes()
{
string source = ReadScript();
Assert.Contains("[string]$FramePacing = 'both'", source, StringComparison.Ordinal);
Assert.Contains(
"[ValidateSet('capped', 'uncapped', 'both')]",
source,
StringComparison.Ordinal);
Assert.Contains(
"[string[]]$PresetSet = @('retail', 'low', 'medium', 'high', 'auto')",
source,
StringComparison.Ordinal);
Assert.Contains(
"[string[]]$ResolutionSet = @('1920x1080', '2560x1440', '3840x2160')",
source,
StringComparison.Ordinal);
Assert.Contains(
"$presets = @($PresetSet | ForEach-Object { $_.ToLowerInvariant() } | Select-Object -Unique)",
source,
StringComparison.Ordinal);
Assert.Contains(
"$resolutions = @($ResolutionSet | Select-Object -Unique)",
source,
StringComparison.Ordinal);
Assert.Contains("default { @('capped', 'uncapped') }", source, StringComparison.Ordinal);
Assert.Contains("if ($pacing -eq 'uncapped')", source, StringComparison.Ordinal);
Assert.Contains("$arguments += '-Uncapped'", source, StringComparison.Ordinal);
Assert.Contains(
"'-RequiredRenderPackSamples', '2048'",
source,
StringComparison.Ordinal);
Assert.Contains(
"'-RenderPackSampleTimeoutMs', '300000'",
source,
StringComparison.Ordinal);
Assert.Contains(
"ExplicitPresetPerformanceWindowResetAfterWarmup = $true",
source,
StringComparison.Ordinal);
Assert.Contains(
"AutomaticPerformanceWindowPolicy",
source,
StringComparison.Ordinal);
Assert.Contains("'-AllowSafeRenderPackFallback'", source, StringComparison.Ordinal);
AssertAppearsInOrder(
source,
"$rowDirectory = Assert-MatrixContainedPath $outputRoot (Join-Path $outputRoot $rowId)",
"'-Out', $rowDirectory",
"'-WarmupMs', \"$WarmupMs\"",
"'-DayGroup', \"$DayGroup\"",
"'-WorldDayFraction'",
"'-SkyPhaseSeconds'",
"'-MsaaSamples', '0'",
"'-RenderPackPreset', $preset",
"'-Resolution', $resolution",
"'-OrbitDistanceMeters'",
"'-SkipBuild'");
string pixelGate = File.ReadAllText(
Path.Combine(FindRepoRoot(), "tools", "run-offline-pixel-gate.ps1"));
Assert.Contains(
"$probeCommands.Add('sleep 2000')",
pixelGate,
StringComparison.Ordinal);
Assert.Contains(
"last completed swapchain image",
pixelGate,
StringComparison.Ordinal);
}
[Fact]
public void DeclaredCeilingsAreExactAndNewIncrementalMetricsAreRequired()
{
string source = ReadScript();
AssertPresetBudget(source, "low", "0.15", "0.50", "2.00", "3.00", "64L");
AssertPresetBudget(source, "medium", "0.25", "0.75", "3.25", "4.50", "128L");
AssertPresetBudget(source, "high", "0.35", "1.00", "4.50", "6.00", "256L");
foreach (string field in new[]
{
"IncrementalCpuMillisecondsP50",
"IncrementalCpuMillisecondsP95",
"IncrementalCpuMillisecondsP99",
"AbsoluteReceiverCpuMillisecondsP50",
"AbsoluteReceiverCpuMillisecondsP95",
"AbsoluteReceiverCpuMillisecondsP99",
"InclusiveGpuMillisecondsP50",
"InclusiveGpuMillisecondsP95",
"InclusiveGpuMillisecondsP99",
"ResidentGpuBytes",
})
{
Assert.Contains($"'{field}'", source, StringComparison.Ordinal);
}
Assert.DoesNotContain("'CpuMillisecondsP50'", source, StringComparison.Ordinal);
Assert.DoesNotContain("'CpuMillisecondsP99'", source, StringComparison.Ordinal);
Assert.DoesNotContain("'GpuMillisecondsP50'", source, StringComparison.Ordinal);
Assert.DoesNotContain("'GpuMillisecondsP99'", source, StringComparison.Ordinal);
Assert.Contains(
"$gpuBudgetApplies = $availability -eq 'Active' -and",
source,
StringComparison.Ordinal);
Assert.Contains("$budget = $budgets[$effectiveQuality]", source, StringComparison.Ordinal);
Assert.Contains(
"$cpuP50 -gt $budget.IncrementalCpuMillisecondsP50",
source,
StringComparison.Ordinal);
Assert.Contains(
"$cpuP99 -gt $budget.IncrementalCpuMillisecondsP99",
source,
StringComparison.Ordinal);
Assert.Contains(
"$residentGpuBytes -gt $budget.ResidentGpuBytes",
source,
StringComparison.Ordinal);
Assert.Contains(
"$gpuP50 -gt $budget.InclusiveGpuMillisecondsP50At1080p",
source,
StringComparison.Ordinal);
Assert.Contains(
"$gpuP99 -gt $budget.InclusiveGpuMillisecondsP99At1080p",
source,
StringComparison.Ordinal);
}
[Fact]
public void MetadataAndSummariesCarryTheRequiredEvidenceWithoutSecrets()
{
string source = ReadScript();
Assert.Contains(
"$screenshotLeaf = 'world-offline'",
source,
StringComparison.Ordinal);
foreach (string evidence in new[]
{
"CpuSampleCount",
"GpuSampleCount",
"ShadowCasterCount",
"CascadeDrawCount",
"DrawCalls",
"DispatchCalls",
})
{
Assert.Contains($"'{evidence}'", source, StringComparison.Ordinal);
}
Assert.Contains("atmospheric-performance-matrix.json", source, StringComparison.Ordinal);
Assert.Contains("atmospheric-performance-matrix.md", source, StringComparison.Ordinal);
Assert.Contains("DeclaredBudgets", source, StringComparison.Ordinal);
Assert.Contains("Rows = @($rows)", source, StringComparison.Ordinal);
Assert.Contains("Failures = @($matrixFailures)", source, StringComparison.Ordinal);
Assert.DoesNotContain("ACDREAM_TEST_USER", source, StringComparison.Ordinal);
Assert.DoesNotContain("ACDREAM_TEST_PASS", source, StringComparison.Ordinal);
Assert.DoesNotContain("Get-ChildItem Env:", source, StringComparison.Ordinal);
}
[Fact]
public void ExecutableMetadataOracleAcceptsCompleteShapeAndRejectsAdversarialEvidence()
{
string directory = Path.Combine(Path.GetTempPath(), $"acdream-matrix-{Guid.NewGuid():N}");
Directory.CreateDirectory(directory);
try
{
string metadataPath = Path.Combine(directory, "capture.metadata.json");
foreach (string preset in new[] { "low", "medium", "high" })
{
File.WriteAllText(metadataPath, CreateMetadata(preset));
JsonElement valid = RunMetadataOracle(metadataPath, preset);
Assert.True(valid.GetProperty("Passed").GetBoolean());
Assert.Equal(9498, valid.GetProperty("ShadowCasterCount").GetInt32());
}
JsonNode automatic = JsonNode.Parse(CreateMetadata("high"))!;
automatic["RenderPack"]!["PresetId"] = "auto";
automatic["RenderPack"]!["ActivationGeneration"] = 4;
File.WriteAllText(metadataPath, automatic.ToJsonString());
JsonElement validAutomatic = RunMetadataOracle(metadataPath, "auto");
Assert.True(validAutomatic.GetProperty("Passed").GetBoolean());
Assert.Equal(
"high",
validAutomatic.GetProperty("EffectiveQuality").GetString());
File.WriteAllText(metadataPath, CreateMetadata("high"));
JsonNode invalid = JsonNode.Parse(File.ReadAllText(metadataPath))!;
JsonNode pack = invalid["RenderPack"]!;
pack["ShadowCasterCount"] = 0;
pack["CascadeDrawCount"] = 3;
pack["CpuClassificationCalls"] = 1;
pack["RetainedGpuBytes"] = 99;
pack["Performance"]!["CpuSampleCount"] = 2047;
pack["Performance"]!["IncrementalCpuMillisecondsP95"] = -1.0;
pack["Passes"]![1]!["DrawCalls"] = 4;
File.WriteAllText(metadataPath, invalid.ToJsonString());
JsonElement rejected = RunMetadataOracle(metadataPath, "high");
Assert.False(rejected.GetProperty("Passed").GetBoolean());
string failures = rejected.GetProperty("Failures").ToString();
Assert.Contains("at least one shadow caster", failures, StringComparison.Ordinal);
Assert.Contains("exactly 4 cascades", failures, StringComparison.Ordinal);
Assert.Contains("zero CPU classifications", failures, StringComparison.Ordinal);
Assert.Contains("GPU bytes disagree", failures, StringComparison.Ordinal);
Assert.Contains("complete 2048-sample window", failures, StringComparison.Ordinal);
Assert.Contains("finite and non-negative", failures, StringComparison.Ordinal);
Assert.Contains("exactly 5 draws and zero dispatches", failures, StringComparison.Ordinal);
}
finally { Directory.Delete(directory, recursive: true); }
}
[Fact]
public void ExecutableMetadataOracleReportsOnlyStrictZeroWorkUnavailability()
{
string directory = Path.Combine(Path.GetTempPath(), $"acdream-matrix-{Guid.NewGuid():N}");
Directory.CreateDirectory(directory);
try
{
string metadataPath = Path.Combine(directory, "capture.metadata.json");
const string resourceReason =
"Directional shadow rendering failed: Render pack preset 'low' needs 67465216 resident GPU bytes after materializing its scene-dependent shadow command buffers; the active pack budget is 67108864 bytes.";
File.WriteAllText(metadataPath, CreateFallbackMetadata(resourceReason));
JsonElement resourceUnavailable = RunMetadataOracle(
metadataPath,
"low",
allowSafeFallback: true);
Assert.True(resourceUnavailable.GetProperty("Passed").GetBoolean());
Assert.Equal("Unavailable", resourceUnavailable.GetProperty("Outcome").GetString());
Assert.Equal(
"ResourceUnavailable",
resourceUnavailable.GetProperty("UnavailableClassification").GetString());
Assert.Equal(resourceReason, resourceUnavailable.GetProperty("FailureReason").GetString());
JsonElement automaticUnavailable = RunMetadataOracle(
metadataPath,
"auto",
allowSafeFallback: true);
Assert.True(automaticUnavailable.GetProperty("Passed").GetBoolean());
Assert.Equal(
"ResourceUnavailable",
automaticUnavailable.GetProperty("UnavailableClassification").GetString());
JsonElement notOptedIn = RunMetadataOracle(metadataPath, "low");
Assert.False(notOptedIn.GetProperty("Passed").GetBoolean());
Assert.Contains(
"not explicitly allowed",
notOptedIn.GetProperty("Failures").ToString(),
StringComparison.Ordinal);
const string capabilityReason =
"Preset 'low' requires unsupported capability 'MultiviewDirectionalShadowCascades'.";
File.WriteAllText(metadataPath, CreateFallbackMetadata(capabilityReason));
JsonElement capabilityUnavailable = RunMetadataOracle(
metadataPath,
"low",
allowSafeFallback: true);
Assert.True(capabilityUnavailable.GetProperty("Passed").GetBoolean());
Assert.Equal(
"CapabilityUnavailable",
capabilityUnavailable.GetProperty("UnavailableClassification").GetString());
const string performanceReason =
"Automatic quality disabled render pack 'acdream.atmospheric' because Low remained over its declared performance budget for 180 stable samples: GPU p99 24.234 ms (budget 3.000 ms), CPU p99 0.630 ms (budget 0.500 ms), resident GPU bytes 41648404 (budget 67108864).";
File.WriteAllText(metadataPath, CreateFallbackMetadata(performanceReason));
JsonElement performanceUnavailable = RunMetadataOracle(
metadataPath,
"auto",
allowSafeFallback: true);
Assert.True(performanceUnavailable.GetProperty("Passed").GetBoolean());
Assert.Equal(
"PerformanceUnavailable",
performanceUnavailable.GetProperty("UnavailableClassification").GetString());
JsonElement explicitLowCannotUseAutoPerformanceFallback = RunMetadataOracle(
metadataPath,
"low",
allowSafeFallback: true);
Assert.False(
explicitLowCannotUseAutoPerformanceFallback.GetProperty("Passed").GetBoolean());
const string forgedWithinBudget =
"Automatic quality disabled render pack 'acdream.atmospheric' because Low remained over its declared performance budget for 180 stable samples: GPU p99 2.000 ms (budget 3.000 ms), CPU p99 0.400 ms (budget 0.500 ms), resident GPU bytes 41648404 (budget 67108864).";
File.WriteAllText(metadataPath, CreateFallbackMetadata(forgedWithinBudget));
JsonElement forged = RunMetadataOracle(
metadataPath,
"auto",
allowSafeFallback: true);
Assert.False(forged.GetProperty("Passed").GetBoolean());
const string arbitraryFailure =
"Render pack 'acdream.atmospheric' could not be prepared: shader validation failed: unsupported binding.";
File.WriteAllText(metadataPath, CreateFallbackMetadata(arbitraryFailure));
JsonElement unexpected = RunMetadataOracle(metadataPath, "low", allowSafeFallback: true);
Assert.False(unexpected.GetProperty("Passed").GetBoolean());
Assert.Equal(
"UnexpectedFailure",
unexpected.GetProperty("UnavailableClassification").GetString());
Assert.Contains(
"not a strict resource/capability/Auto-performance unavailability",
unexpected.GetProperty("Failures").ToString(),
StringComparison.Ordinal);
JsonNode unsafeFallback = JsonNode.Parse(CreateFallbackMetadata(resourceReason))!;
unsafeFallback["RenderPack"]!["ShadowCasterCount"] = 1;
unsafeFallback["RenderPack"]!["Performance"]!["GpuSampleCount"] = 1;
File.WriteAllText(metadataPath, unsafeFallback.ToJsonString());
JsonElement rejectedWork = RunMetadataOracle(metadataPath, "low", allowSafeFallback: true);
Assert.False(rejectedWork.GetProperty("Passed").GetBoolean());
string failures = rejectedWork.GetProperty("Failures").ToString();
Assert.Contains("zero pack work and resources", failures, StringComparison.Ordinal);
Assert.Contains("zero GpuSampleCount", failures, StringComparison.Ordinal);
}
finally { Directory.Delete(directory, recursive: true); }
}
private static string CreateMetadata(string preset)
{
int cascades = preset switch { "low" => 2, "medium" => 3, _ => 4 };
const int shadowDraws = 5;
var passIds = new List<string> { "atmospheric-world-receiver" };
if (preset == "low")
passIds.Add("directional-shadow-multiview");
else for (int cascade = 0; cascade < cascades; cascade++)
passIds.Add($"directional-shadow-cascade-{cascade}");
passIds.AddRange([
"atmospheric-sun-occlusion", "atmospheric-sun-rays",
"atmospheric-volumetric-shafts", "atmospheric-bloom-downsample",
"atmospheric-bloom-blur-horizontal", "atmospheric-bloom-blur-vertical",
"atmospheric-filmic"]);
object[] passes = passIds.Select((id, index) => new
{
PassId = id,
GpuMilliseconds = 0.1,
DrawCalls = id.StartsWith("directional-", StringComparison.Ordinal) ? shadowDraws :
id is "atmospheric-world-receiver" or "atmospheric-volumetric-shafts" ? 0 : 1,
DispatchCalls = 0,
}).Cast<object>().ToArray();
var performance = new
{
CpuSampleCount = 2048,
AbsoluteReceiverCpuSampleCount = 2048,
GpuSampleCount = 2048,
IncrementalCpuMillisecondsP50 = 0.1,
IncrementalCpuMillisecondsP95 = 0.2,
IncrementalCpuMillisecondsP99 = 0.3,
AbsoluteReceiverCpuMillisecondsP50 = 1.0,
AbsoluteReceiverCpuMillisecondsP95 = 1.5,
AbsoluteReceiverCpuMillisecondsP99 = 2.0,
InclusiveGpuMillisecondsP50 = 2.0,
InclusiveGpuMillisecondsP95 = 3.0,
InclusiveGpuMillisecondsP99 = 4.0,
ResidentGpuBytes = 1000L,
TransientGpuBytes = 0L,
};
return JsonSerializer.Serialize(new
{
SchemaVersion = 1,
Width = 1920,
Height = 1080,
RenderPack = new
{
State = 2,
PackId = "acdream.atmospheric",
PackVersion = "1.0.0",
PresetId = preset,
EffectiveQuality = preset,
FailureReason = (string?)null,
ActivationGeneration = 1,
RetainedGpuBytes = 1000L,
TransientGpuBytes = 0L,
ImageCount = 1,
BufferCount = 1,
DrawCalls = (preset == "low" ? shadowDraws : shadowDraws * cascades) + 6,
DispatchCalls = 0,
ShadowCasterCount = 9498,
CascadeDrawCount = cascades,
CpuClassificationCalls = 0,
Passes = passes,
Performance = performance,
},
});
}
private static string CreateFallbackMetadata(string failureReason) => JsonSerializer.Serialize(new
{
SchemaVersion = 1,
Width = 1920,
Height = 1080,
RenderPack = new
{
State = 3,
PackId = "retail",
PackVersion = (string?)null,
PresetId = "off",
EffectiveQuality = "off",
FailureReason = failureReason,
ActivationGeneration = 2,
RetainedGpuBytes = 0L,
TransientGpuBytes = 0L,
ImageCount = 0,
BufferCount = 0,
DrawCalls = 0,
DispatchCalls = 0,
ShadowCasterCount = 0,
CascadeDrawCount = 0,
CpuClassificationCalls = 0,
Passes = Array.Empty<object>(),
Performance = new
{
CpuSampleCount = 0,
AbsoluteReceiverCpuSampleCount = 0,
GpuSampleCount = 0,
IncrementalCpuMillisecondsP50 = 0.0,
IncrementalCpuMillisecondsP95 = 0.0,
IncrementalCpuMillisecondsP99 = 0.0,
AbsoluteReceiverCpuMillisecondsP50 = 0.0,
AbsoluteReceiverCpuMillisecondsP95 = 0.0,
AbsoluteReceiverCpuMillisecondsP99 = 0.0,
InclusiveGpuMillisecondsP50 = 0.0,
InclusiveGpuMillisecondsP95 = 0.0,
InclusiveGpuMillisecondsP99 = 0.0,
ResidentGpuBytes = 0L,
TransientGpuBytes = 0L,
},
},
});
private static JsonElement RunMetadataOracle(
string metadataPath,
string preset,
bool allowSafeFallback = false)
{
string helper = Path.Combine(FindRepoRoot(), "tools", "atmospheric-performance-matrix-common.ps1");
string quote(string value) => "'" + value.Replace("'", "''", StringComparison.Ordinal) + "'";
var start = new ProcessStartInfo
{
FileName = OperatingSystem.IsWindows() ? "pwsh.exe" : "pwsh",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
start.ArgumentList.Add("-NoProfile");
start.ArgumentList.Add("-NonInteractive");
start.ArgumentList.Add("-Command");
start.ArgumentList.Add(
$". {quote(helper)}; Test-AtmosphericPerformanceMetadataEvidence " +
$"-MetadataPath {quote(metadataPath)} -Preset {preset} -ExpectedWidth 1920 " +
"-ExpectedHeight 1080 " +
(allowSafeFallback ? "-AllowSafeFallback " : string.Empty) +
"| ConvertTo-Json -Depth 8 -Compress");
using Process process = Process.Start(start)!;
string stdout = process.StandardOutput.ReadToEnd();
string stderr = process.StandardError.ReadToEnd();
Assert.True(process.WaitForExit(30_000), "PowerShell oracle did not exit.");
Assert.True(process.ExitCode == 0, $"PowerShell oracle failed.\n{stdout}\n{stderr}");
using JsonDocument document = JsonDocument.Parse(stdout);
return document.RootElement.Clone();
}
private static void AssertPresetBudget(
string source,
string preset,
string cpuP50,
string cpuP99,
string gpuP50,
string gpuP99,
string memory)
{
int start = source.IndexOf($"{preset} = [pscustomobject]", StringComparison.Ordinal);
Assert.True(start >= 0, $"Missing {preset} budget.");
int end = source.IndexOf(" }", start, StringComparison.Ordinal);
Assert.True(end > start, $"Malformed {preset} budget.");
string budget = source[start..end];
Assert.Contains($"IncrementalCpuMillisecondsP50 = {cpuP50}", budget);
Assert.Contains($"IncrementalCpuMillisecondsP99 = {cpuP99}", budget);
Assert.Contains($"InclusiveGpuMillisecondsP50At1080p = {gpuP50}", budget);
Assert.Contains($"InclusiveGpuMillisecondsP99At1080p = {gpuP99}", budget);
Assert.Contains($"ResidentGpuBytes = {memory} * 1024L * 1024L", budget);
}
private static string ReadScript() => File.ReadAllText(ScriptPath());
private static string ScriptPath() => Path.Combine(
FindRepoRoot(),
"tools",
"run-atmospheric-performance-matrix.ps1");
private static void AssertAppearsInOrder(string source, params string[] values)
{
int cursor = -1;
foreach (string value in values)
{
int next = source.IndexOf(value, cursor + 1, StringComparison.Ordinal);
Assert.True(next >= 0, $"Missing expected source fragment: {value}");
Assert.True(next > cursor, $"Out-of-order source fragment: {value}");
cursor = next;
}
}
private static string FindRepoRoot()
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
return directory.FullName;
directory = directory.Parent;
}
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
}
}

View file

@ -0,0 +1,121 @@
using System.Diagnostics;
namespace AcDream.App.Tests.Diagnostics;
public sealed class AtmosphericPreviewLauncherContractTests
{
[Fact]
public void ScriptParsesWithoutLaunchingTheClient()
{
string script = ScriptPath();
var start = new ProcessStartInfo
{
FileName = OperatingSystem.IsWindows() ? "pwsh.exe" : "pwsh",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
start.ArgumentList.Add("-NoProfile");
start.ArgumentList.Add("-NonInteractive");
start.ArgumentList.Add("-Command");
string quotedScript = "'" + script.Replace("'", "''", StringComparison.Ordinal) + "'";
start.ArgumentList.Add(
$"[scriptblock]::Create((Get-Content -Raw -LiteralPath {quotedScript})) | Out-Null");
using Process process = Process.Start(start)
?? throw new InvalidOperationException("Could not start pwsh parser process.");
string stdout = process.StandardOutput.ReadToEnd();
string stderr = process.StandardError.ReadToEnd();
Assert.True(process.WaitForExit(30_000), "PowerShell parser did not exit.");
Assert.True(
process.ExitCode == 0,
$"PowerShell parser failed with exit code {process.ExitCode}.\n{stdout}\n{stderr}");
}
[Fact]
public void PreviewIsAudioSafeIsolatedAndDiagnosticByDefault()
{
string source = File.ReadAllText(ScriptPath());
Assert.Contains("[switch]$EnableAudio", source, StringComparison.Ordinal);
Assert.Contains("$audioEnabled = [bool]$EnableAudio", source, StringComparison.Ordinal);
Assert.Contains(
"$env:ACDREAM_NO_AUDIO = if ($audioEnabled) { $null } else { '1' }",
source,
StringComparison.Ordinal);
Assert.Contains("'enabled-explicit'", source, StringComparison.Ordinal);
Assert.Contains("'disabled-default'", source, StringComparison.Ordinal);
Assert.Contains(
".StartsWith('ACDREAM_', [StringComparison]::OrdinalIgnoreCase)",
source,
StringComparison.Ordinal);
Assert.Contains(
"[Environment]::SetEnvironmentVariable($name, $null, 'Process')",
source,
StringComparison.Ordinal);
Assert.Contains(
"[Environment]::SetEnvironmentVariable($name, $prior[$name], 'Process')",
source,
StringComparison.Ordinal);
AssertAppearsInOrder(
source,
"[Environment]::SetEnvironmentVariable($name, $null, 'Process')",
"$env:ACDREAM_CONFIG_DIR = $config",
"$process = Start-Process",
"[Environment]::SetEnvironmentVariable($name, $prior[$name], 'Process')");
Assert.Contains("schemaVersion = 2", source, StringComparison.Ordinal);
Assert.Contains("executableSha256", source, StringComparison.Ordinal);
Assert.Contains("executableProductVersion", source, StringComparison.Ordinal);
Assert.Contains("stdoutLog = $stdoutLog", source, StringComparison.Ordinal);
Assert.Contains("stderrLog = $stderrLog", source, StringComparison.Ordinal);
Assert.Contains("status = 'prepared'", source, StringComparison.Ordinal);
Assert.Contains("$launch.status = 'start-failed'", source, StringComparison.Ordinal);
Assert.Contains("$launch.status = 'started'", source, StringComparison.Ordinal);
Assert.Contains("-RedirectStandardOutput $stdoutLog", source, StringComparison.Ordinal);
Assert.Contains("-RedirectStandardError $stderrLog", source, StringComparison.Ordinal);
Assert.DoesNotContain("-WindowStyle Hidden", source, StringComparison.Ordinal);
Assert.Contains("if (Test-Path -LiteralPath $root)", source, StringComparison.Ordinal);
Assert.Contains(
"New-Item -ItemType Directory -Path $config, $data, $cache",
source,
StringComparison.Ordinal);
Assert.DoesNotContain(
"New-Item -ItemType Directory -Force -Path $config, $data, $cache",
source,
StringComparison.Ordinal);
Assert.DoesNotContain("prior = $prior", source, StringComparison.OrdinalIgnoreCase);
}
private static string ScriptPath() => Path.Combine(
FindRepoRoot(),
"tools",
"launch-atmospheric-preview.ps1");
private static void AssertAppearsInOrder(string source, params string[] fragments)
{
int cursor = -1;
foreach (string fragment in fragments)
{
int next = source.IndexOf(fragment, cursor + 1, StringComparison.Ordinal);
Assert.True(next > cursor, $"Missing or out-of-order fragment: {fragment}");
cursor = next;
}
}
private static string FindRepoRoot()
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
return directory.FullName;
directory = directory.Parent;
}
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
}
}

View file

@ -0,0 +1,505 @@
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Nodes;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Scene;
namespace AcDream.App.Tests.Diagnostics;
public sealed class ConnectedRenderPackGateContractTests
{
private const string LifecycleScript = "run-connected-world-lifecycle-gate.ps1";
private const string SoakScript = "run-connected-r6-soak.ps1";
[Fact]
public void ScreenshotJsonCarriesAuthoritativeCasterClassCounters()
{
DirectionalShadowTransformChurnDiagnostics churn = default;
churn = churn with
{
CasterClasses = new DirectionalShadowCasterClassDiagnostics(
TerrainCommands: 1,
OutdoorStatics: 2,
Buildings: 3,
AnimatedStatics: 4,
LocalPlayers: 5,
RemotePlayers: 6,
NonPlayerCreatures: 7,
OtherLiveDynamics: 8,
EquippedChildren: 9),
};
using JsonDocument json = JsonDocument.Parse(
JsonSerializer.Serialize(churn));
JsonElement classes = json.RootElement.GetProperty("CasterClasses");
Assert.Equal(1, classes.GetProperty("TerrainCommands").GetInt32());
Assert.Equal(2, classes.GetProperty("OutdoorStatics").GetInt32());
Assert.Equal(3, classes.GetProperty("Buildings").GetInt32());
Assert.Equal(4, classes.GetProperty("AnimatedStatics").GetInt32());
Assert.Equal(5, classes.GetProperty("LocalPlayers").GetInt32());
Assert.Equal(6, classes.GetProperty("RemotePlayers").GetInt32());
Assert.Equal(7, classes.GetProperty("NonPlayerCreatures").GetInt32());
Assert.Equal(8, classes.GetProperty("OtherLiveDynamics").GetInt32());
Assert.Equal(9, classes.GetProperty("EquippedChildren").GetInt32());
}
private const string CommonScript = "connected-render-pack-gate-common.ps1";
[Fact]
public void ConnectedGatesExposeTheSameRetailDefaultAndOptionalOverrides()
{
foreach (string scriptName in new[] { LifecycleScript, SoakScript })
{
string source = ReadTool(scriptName);
Assert.Contains(
"[ValidateSet('retail', 'low', 'medium', 'high', 'auto')]",
source,
StringComparison.Ordinal);
Assert.Contains(
"[string]$RenderPackPreset = 'retail'",
source,
StringComparison.Ordinal);
Assert.Contains(
"[hashtable]$RenderPackSettingOverrides = @{}",
source,
StringComparison.Ordinal);
Assert.Contains(
". (Join-Path $PSScriptRoot 'connected-render-pack-gate-common.ps1')",
source,
StringComparison.Ordinal);
Assert.Contains(
"-Preset $RenderPackPreset",
source,
StringComparison.Ordinal);
Assert.Contains(
"-SettingOverrides $RenderPackSettingOverrides",
source,
StringComparison.Ordinal);
}
}
[Fact]
public void SharedSeamPinsSchemaAndOwnsTheCompleteConnectedEnvironmentTransaction()
{
string source = ReadTool(CommonScript);
foreach (string variable in new[]
{
"ACDREAM_CONFIG_DIR", "ACDREAM_DATA_DIR", "ACDREAM_CACHE_DIR",
"ACDREAM_DAT_DIR", "ACDREAM_PAK_PATH", "ACDREAM_LIVE", "ACDREAM_TEST_HOST",
"ACDREAM_TEST_PORT", "ACDREAM_TEST_USER", "ACDREAM_TEST_PASS",
"ACDREAM_RETAIL_UI", "ACDREAM_FRAME_PROF", "ACDREAM_FRAME_HISTORY",
"ACDREAM_UNCAPPED_RENDER", "ACDREAM_DEVTOOLS", "ACDREAM_UI_PROBE_DUMP",
"ACDREAM_UI_PROBE_SCRIPT", "ACDREAM_AUTOMATION_ARTIFACT_DIR",
"ACDREAM_DUMP_MOVE_TRUTH", "ACDREAM_NO_AUDIO", "ACDREAM_WB_DIAG",
"ACDREAM_RENDER_BACKEND", "ACDREAM_NET_DROP_PCT",
"ACDREAM_NET_DROP_SEED", "ACDREAM_NET_DROP_DIR",
"ACDREAM_COLLISION_SHADOW_EVERY", "ACDREAM_COLLISION_SHADOW_DIR",
"ACDREAM_AUTOMATION_EXACT_FRAMEBUFFER", "ACDREAM_DAY_GROUP",
"ACDREAM_WORLD_TIME", "ACDREAM_SKY_PHASE_SECONDS",
"ACDREAM_ORBIT_DISTANCE_METERS", "ACDREAM_ORBIT_YAW_DEGREES",
"ACDREAM_ORBIT_PITCH_DEGREES", "ACDREAM_VULKAN_DEVICE",
"ACDREAM_VULKAN_FORCE_UNSUPPORTED", "ACDREAM_VULKAN_PROBE",
"ACDREAM_VULKAN_PROBE_FRAMES",
})
Assert.Contains($"'{variable}'", source, StringComparison.Ordinal);
Assert.Contains("$State.PreviousEnvironment.GetEnumerator()", source, StringComparison.Ordinal);
Assert.Contains("Get-ChildItem Env:", source, StringComparison.Ordinal);
Assert.Contains("Remove-Item -LiteralPath \"Env:$name\"", source, StringComparison.Ordinal);
Assert.Contains("Assert-ConnectedGateContainedPath", source, StringComparison.Ordinal);
Assert.Contains("Assert-ConnectedGateNoReparsePoint", source, StringComparison.Ordinal);
}
[Fact]
public void PowerShellRoundTripRestoresEveryConnectedVariableIncludingCredentials()
{
string root = Path.Combine(Path.GetTempPath(), $"acdream-connected-pack-{Guid.NewGuid():N}");
Directory.CreateDirectory(root);
try
{
string common = PsQuote(Path.Combine(FindRepoRoot(), "tools", CommonScript));
string rootQuoted = PsQuote(root);
string command = $@"
. {common}
foreach ($name in $script:ConnectedGateEnvironmentNames) {{
[Environment]::SetEnvironmentVariable($name, ""sentinel-$name"", 'Process')
}}
[Environment]::SetEnvironmentVariable('ACDREAM_FUTURE_GATE_KNOB', 'sentinel-future', 'Process')
$state = New-ConnectedRenderPackGateState -Root {rootQuoted} -Preset medium
$isolated=@($state.PreviousEnvironment.Keys | Where-Object {{
$_ -notin @('ACDREAM_CONFIG_DIR', 'ACDREAM_DATA_DIR', 'ACDREAM_CACHE_DIR') -and
[Environment]::GetEnvironmentVariable($_, 'Process') -ne $null
}})
foreach ($name in $script:ConnectedGateEnvironmentNames) {{
[Environment]::SetEnvironmentVariable($name, ""mutated-$name"", 'Process')
}}
[Environment]::SetEnvironmentVariable('ACDREAM_FUTURE_GATE_KNOB', 'mutated-future', 'Process')
Restore-ConnectedRenderPackGateEnvironment $state
$mismatches=@($script:ConnectedGateEnvironmentNames | Where-Object {{
[Environment]::GetEnvironmentVariable($_, 'Process') -cne ""sentinel-$_""
}})
$unsafeLeafRejected=$false
try {{ Assert-ConnectedGateSafeLeafName '../escape' }} catch {{ $unsafeLeafRejected=$true }}
$escapeRejected=$false
try {{ Assert-ConnectedGateContainedPath {rootQuoted} (Join-Path {rootQuoted} '..\escape') }} catch {{ $escapeRejected=$true }}
[pscustomobject]@{{
Password=$env:ACDREAM_TEST_PASS; User=$env:ACDREAM_TEST_USER;
Config=$env:ACDREAM_CONFIG_DIR; History=$env:ACDREAM_FRAME_HISTORY;
Future=$env:ACDREAM_FUTURE_GATE_KNOB; Isolated=$isolated; Mismatches=$mismatches;
UnsafeLeafRejected=$unsafeLeafRejected; EscapeRejected=$escapeRejected;
Settings=(Get-Content -Raw -LiteralPath (Join-Path $state.ConfigDirectory 'settings.json') | ConvertFrom-Json).display.renderPack.presetId
}} | ConvertTo-Json -Compress";
using JsonDocument result = JsonDocument.Parse(RunPowerShell(command));
JsonElement rootElement = result.RootElement;
Assert.Equal("sentinel-ACDREAM_TEST_PASS", rootElement.GetProperty("Password").GetString());
Assert.Equal("sentinel-ACDREAM_TEST_USER", rootElement.GetProperty("User").GetString());
Assert.Equal("sentinel-ACDREAM_CONFIG_DIR", rootElement.GetProperty("Config").GetString());
Assert.Equal("sentinel-ACDREAM_FRAME_HISTORY", rootElement.GetProperty("History").GetString());
Assert.Equal("sentinel-future", rootElement.GetProperty("Future").GetString());
Assert.Empty(rootElement.GetProperty("Isolated").EnumerateArray());
Assert.Empty(rootElement.GetProperty("Mismatches").EnumerateArray());
Assert.True(rootElement.GetProperty("UnsafeLeafRejected").GetBoolean());
Assert.True(rootElement.GetProperty("EscapeRejected").GetBoolean());
Assert.Equal("medium", rootElement.GetProperty("Settings").GetString());
}
finally { Directory.Delete(root, recursive: true); }
}
[Fact]
public void EveryConnectedScreenshotMustProveExactFailureFreeActivation()
{
string common = ReadTool(CommonScript);
Assert.Contains("screenshots\\$name.metadata.json", common, StringComparison.Ordinal);
Assert.Contains("@('PackId', [string]$State.PackId)", common, StringComparison.Ordinal);
Assert.Contains("@('PresetId', [string]$State.PresetId)", common, StringComparison.Ordinal);
Assert.Contains("[int]$actual.State -ne [int]$State.ExpectedState", common, StringComparison.Ordinal);
Assert.Contains(
"[string]::IsNullOrWhiteSpace([string]$actual.FailureReason)",
common,
StringComparison.Ordinal);
Assert.Contains("SchemaVersion", common, StringComparison.Ordinal);
Assert.Contains("ActivationGeneration", common, StringComparison.Ordinal);
Assert.Contains("EffectiveQuality", common, StringComparison.Ordinal);
Assert.Contains("retained GPU byte ledgers disagree", common, StringComparison.Ordinal);
Assert.Contains("expected zero pack work", common, StringComparison.Ordinal);
Assert.Contains("recorded no complete pack graph work", common, StringComparison.Ordinal);
Assert.Contains("positive shadow strength but no shadow casters", common, StringComparison.Ordinal);
string lifecycle = ReadTool(LifecycleScript);
Assert.Contains("-ScreenshotNames @($name)", lifecycle, StringComparison.Ordinal);
Assert.Contains("-Label $Label", lifecycle, StringComparison.Ordinal);
string soak = ReadTool(SoakScript);
Assert.Contains("-ScreenshotNames $expectedCheckpointNames", soak, StringComparison.Ordinal);
Assert.Contains("-Label $runName", soak, StringComparison.Ordinal);
}
[Fact]
public void LifecycleGateExecutesAtomicTransitionsResizeEnvironmentAndFreshContextRow()
{
string lifecycle = ReadTool(LifecycleScript);
Assert.Contains("if ($RenderPackPreset -eq 'medium')", lifecycle, StringComparison.Ordinal);
Assert.Contains("connected-render-pack-transitions.route.txt", lifecycle, StringComparison.Ordinal);
Assert.Contains("Get-ConnectedRenderPackExpectation -Preset high", lifecycle, StringComparison.Ordinal);
Assert.Contains("Get-ConnectedRenderPackExpectation -Preset retail", lifecycle, StringComparison.Ordinal);
Assert.Contains("ScreenshotStateOverrides", lifecycle, StringComparison.Ordinal);
Assert.Contains("Get-FreshContextRecreationGate $capped $uncapped", lifecycle, StringComparison.Ordinal);
Assert.Contains("StartTimeUtc", lifecycle, StringComparison.Ordinal);
Assert.Contains("resized screenshot was", lifecycle, StringComparison.OrdinalIgnoreCase);
Assert.Contains("authored time change did not alter published sun elevation", lifecycle, StringComparison.Ordinal);
Assert.Contains("first weather edge did not publish Overcast", lifecycle, StringComparison.Ordinal);
Assert.Contains("$selected.ShadowTransformChurn.CasterClasses", lifecycle, StringComparison.Ordinal);
foreach (string casterClass in new[]
{
"TerrainCommands",
"OutdoorStatics",
"Buildings",
"AnimatedStatics",
"LocalPlayers",
"NonPlayerCreatures",
"EquippedChildren",
})
{
Assert.Contains(casterClass, lifecycle, StringComparison.Ordinal);
}
Assert.Contains("second live client", lifecycle, StringComparison.Ordinal);
Assert.Contains("not hostile monster", lifecycle, StringComparison.Ordinal);
Assert.Contains("no authoritative tree discriminator", lifecycle, StringComparison.Ordinal);
string route = ReadTool("connected-render-pack-transitions.route.txt");
AssertAppearsInOrder(
route,
"renderpack select high",
"wait render-pack high 90000",
"renderpack disable",
"wait render-pack retail 90000",
"renderpack reenable",
"wait render-pack high 90000",
"resize 1024 768",
"wait framebuffer 1024 768 30000",
"input press AcdreamCycleTimeOfDay",
"input press AcdreamCycleWeather",
"input press AcdreamCycleWeather",
"checkpoint atmospheric_transitions");
Assert.Contains("transition_selected_high", route, StringComparison.Ordinal);
Assert.Contains("transition_disabled_retail", route, StringComparison.Ordinal);
Assert.Contains("transition_reenabled_high", route, StringComparison.Ordinal);
Assert.Contains("transition_resized_high", route, StringComparison.Ordinal);
Assert.Contains("transition_overcast_high", route, StringComparison.Ordinal);
Assert.Contains("transition_rain_high", route, StringComparison.Ordinal);
}
[Fact]
public void ExecutableMetadataGateAcceptsAutoQualityAndRejectsLedgerOrVersionDrift()
{
string root = Path.Combine(Path.GetTempPath(), $"acdream-connected-metadata-{Guid.NewGuid():N}");
Directory.CreateDirectory(Path.Combine(root, "screenshots"));
string path = Path.Combine(root, "screenshots", "checkpoint.metadata.json");
try
{
var document = new
{
SchemaVersion = 1,
RenderPack = new
{
PackId = "acdream.atmospheric",
PackVersion = "1.0.0",
PresetId = "auto",
State = 2,
ActivationGeneration = 3,
EffectiveQuality = "medium",
FailureReason = (string?)null,
RetainedGpuBytes = 123L,
TransientGpuBytes = 4L,
ImageCount = 4,
BufferCount = 2,
DrawCalls = 6,
DispatchCalls = 0,
ShadowCasterCount = 22,
CascadeDrawCount = 3,
CpuClassificationCalls = 0,
SharedWorldTransformUsedInstances = 68_395u,
Outdoor = true,
DirectionalShadowStrength = 0.75,
Passes = new[]
{
new { PassId = "directional-shadow", GpuMilliseconds = 0.2, DrawCalls = 2, DispatchCalls = 0 },
},
Performance = new { ResidentGpuBytes = 123L, TransientGpuBytes = 4L },
},
};
File.WriteAllText(path, JsonSerializer.Serialize(document));
JsonElement valid = RunConnectedMetadataGate(root);
Assert.Empty(valid.GetProperty("Failures").EnumerateArray());
JsonNode invalid = JsonNode.Parse(File.ReadAllText(path))!;
invalid["SchemaVersion"] = 2;
invalid["RenderPack"]!["PackVersion"] = "9.9.9";
invalid["RenderPack"]!["EffectiveQuality"] = "ultra";
invalid["RenderPack"]!["RetainedGpuBytes"] = 999;
File.WriteAllText(path, invalid.ToJsonString());
JsonElement rejected = RunConnectedMetadataGate(root);
string failures = rejected.GetProperty("Failures").ToString();
Assert.Contains("schema was 2", failures, StringComparison.Ordinal);
Assert.Contains("PackVersion '9.9.9'", failures, StringComparison.Ordinal);
Assert.Contains("effective quality 'ultra'", failures, StringComparison.Ordinal);
Assert.Contains("retained GPU byte ledgers disagree", failures, StringComparison.Ordinal);
}
finally { Directory.Delete(root, recursive: true); }
}
[Fact]
public void ExecutableMetadataGateRejectsActiveNoWorkAndRetailPackWork()
{
string root = Path.Combine(Path.GetTempPath(), $"acdream-connected-work-{Guid.NewGuid():N}");
Directory.CreateDirectory(Path.Combine(root, "screenshots"));
string path = Path.Combine(root, "screenshots", "checkpoint.metadata.json");
try
{
var activeNoWork = new
{
SchemaVersion = 1,
RenderPack = new
{
PackId = "acdream.atmospheric",
PackVersion = "1.0.0",
PresetId = "auto",
State = 2,
ActivationGeneration = 1,
EffectiveQuality = "low",
FailureReason = (string?)null,
RetainedGpuBytes = 0L,
TransientGpuBytes = 0L,
ImageCount = 0,
BufferCount = 0,
DrawCalls = 0,
DispatchCalls = 0,
ShadowCasterCount = 0,
CascadeDrawCount = 0,
CpuClassificationCalls = 0,
SharedWorldTransformUsedInstances = 0u,
Outdoor = true,
DirectionalShadowStrength = 0.5,
Passes = Array.Empty<object>(),
Performance = new { ResidentGpuBytes = 0L, TransientGpuBytes = 0L },
},
};
File.WriteAllText(path, JsonSerializer.Serialize(activeNoWork));
JsonElement activeRejected = RunConnectedMetadataGate(root);
string activeFailures = activeRejected.GetProperty("Failures").ToString();
Assert.Contains("recorded no complete pack graph work", activeFailures, StringComparison.Ordinal);
Assert.Contains("positive shadow strength but no shadow casters", activeFailures, StringComparison.Ordinal);
Assert.Contains("positive shadow strength but no combined shared-world-transform usage", activeFailures, StringComparison.Ordinal);
var retailWork = new
{
SchemaVersion = 1,
RenderPack = new
{
PackId = "retail",
PackVersion = (string?)null,
PresetId = "off",
State = 0,
ActivationGeneration = 0,
EffectiveQuality = "off",
FailureReason = (string?)null,
RetainedGpuBytes = 64L,
TransientGpuBytes = 0L,
ImageCount = 1,
BufferCount = 0,
DrawCalls = 1,
DispatchCalls = 0,
ShadowCasterCount = 0,
CascadeDrawCount = 0,
CpuClassificationCalls = 0,
SharedWorldTransformUsedInstances = 1u,
Outdoor = false,
DirectionalShadowStrength = 0.0,
Passes = new[]
{
new { PassId = "unexpected", GpuMilliseconds = 0.1, DrawCalls = 1, DispatchCalls = 0 },
},
Performance = new { ResidentGpuBytes = 64L, TransientGpuBytes = 0L },
},
};
File.WriteAllText(path, JsonSerializer.Serialize(retailWork));
JsonElement retailRejected = RunConnectedMetadataGate(root, "retail");
string retailFailures = retailRejected.GetProperty("Failures").ToString();
Assert.Contains("expected zero pack work", retailFailures, StringComparison.Ordinal);
Assert.Contains("recorded pack passes, expected none", retailFailures, StringComparison.Ordinal);
}
finally { Directory.Delete(root, recursive: true); }
}
[Fact]
public void BothGatesFailClosedOnUnprovableBinaryIdentity()
{
string common = ReadTool(CommonScript);
Assert.Contains("Measured binary commit $binaryCommit differs", common, StringComparison.Ordinal);
Assert.Contains("status --short --untracked-files=all", common, StringComparison.Ordinal);
Assert.Contains("Connected closeout evidence cannot prove binary/source identity", common, StringComparison.Ordinal);
foreach (string scriptName in new[] { LifecycleScript, SoakScript })
Assert.Contains("Get-ConnectedGateBinaryIdentity", ReadTool(scriptName), StringComparison.Ordinal);
}
[Fact]
public void BothGatesRestoreEnvironmentAndRecordRequestedSelection()
{
foreach (string scriptName in new[] { LifecycleScript, SoakScript })
{
string source = ReadTool(scriptName);
int selectionStart = source.IndexOf(
"$renderPackGate = New-ConnectedRenderPackGateState",
StringComparison.Ordinal);
Assert.True(selectionStart >= 0, $"{scriptName} does not initialize isolated state.");
string selectionScope = source[selectionStart..];
AssertAppearsInOrder(
selectionScope,
"$renderPackGate = New-ConnectedRenderPackGateState",
"try {",
"RenderPackSelection = (Get-ConnectedRenderPackGateReport $renderPackGate)",
"finally {",
"Restore-ConnectedRenderPackGateEnvironment $renderPackGate");
Assert.Contains(
"Add-ConnectedRenderPackMetadataFailures",
source,
StringComparison.Ordinal);
Assert.Contains("Start-Process -FilePath $exe", source, StringComparison.Ordinal);
}
}
private static string ReadTool(string fileName) => File.ReadAllText(Path.Combine(
FindRepoRoot(),
"tools",
fileName));
private static string PsQuote(string value) => "'" + value.Replace("'", "''", StringComparison.Ordinal) + "'";
private static string RunPowerShell(string command)
{
var start = new ProcessStartInfo
{
FileName = OperatingSystem.IsWindows() ? "pwsh.exe" : "pwsh",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
start.ArgumentList.Add("-NoProfile");
start.ArgumentList.Add("-NonInteractive");
start.ArgumentList.Add("-Command");
start.ArgumentList.Add(command);
using Process process = Process.Start(start)!;
string stdout = process.StandardOutput.ReadToEnd();
string stderr = process.StandardError.ReadToEnd();
Assert.True(process.WaitForExit(30_000), "PowerShell did not exit.");
Assert.True(process.ExitCode == 0, $"PowerShell failed.\n{stdout}\n{stderr}");
return stdout.Trim();
}
private static JsonElement RunConnectedMetadataGate(
string artifactRoot,
string requestedPreset = "auto")
{
string common = PsQuote(Path.Combine(FindRepoRoot(), "tools", CommonScript));
string root = PsQuote(artifactRoot);
string preset = PsQuote(requestedPreset);
string packId = PsQuote(requestedPreset == "retail" ? "retail" : "acdream.atmospheric");
string packVersion = requestedPreset == "retail" ? "$null" : "'1.0.0'";
string presetId = PsQuote(requestedPreset == "retail" ? "off" : requestedPreset);
int expectedState = requestedPreset == "retail" ? 0 : 2;
string command = $@"
. {common}
$state=[pscustomobject]@{{RequestedPreset={preset};PackId={packId};PackVersion={packVersion};PresetId={presetId};ExpectedState={expectedState};ExpectedSchemaVersion=1}}
$failures=[Collections.Generic.List[string]]::new()
Add-ConnectedRenderPackMetadataFailures -ArtifactDirectory {root} -ScreenshotNames @('checkpoint') -State $state -Failures $failures -Label synthetic
[pscustomobject]@{{Failures=@($failures)}} | ConvertTo-Json -Depth 5 -Compress";
using JsonDocument document = JsonDocument.Parse(RunPowerShell(command));
return document.RootElement.Clone();
}
private static void AssertAppearsInOrder(string source, params string[] values)
{
int cursor = -1;
foreach (string value in values)
{
int next = source.IndexOf(value, cursor + 1, StringComparison.Ordinal);
Assert.True(next >= 0, $"Missing expected source fragment: {value}");
Assert.True(next > cursor, $"Out-of-order source fragment: {value}");
cursor = next;
}
}
private static string FindRepoRoot()
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
return directory.FullName;
directory = directory.Parent;
}
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
}
}

View file

@ -268,6 +268,115 @@ public sealed class ConnectedWorldSoakRouteContractTests
StringComparison.Ordinal);
}
[Fact]
public void AtmosphericOfflineGateDefaultsToCappedAndExposesUncappedMeasurement()
{
string source = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"tools",
"run-offline-pixel-gate.ps1"));
Assert.Contains("[switch]$Uncapped", source, StringComparison.Ordinal);
Assert.Contains(
"$env:ACDREAM_UNCAPPED_RENDER = if ($Uncapped) { '1' } else { $null }",
source,
StringComparison.Ordinal);
Assert.Contains(
"$previousUncappedRender = $env:ACDREAM_UNCAPPED_RENDER",
source,
StringComparison.Ordinal);
Assert.Contains(
"$env:ACDREAM_UNCAPPED_RENDER = $previousUncappedRender",
source,
StringComparison.Ordinal);
Assert.Contains(
"$env:ACDREAM_AUTOMATION_EXACT_FRAMEBUFFER = '1'",
source,
StringComparison.Ordinal);
Assert.Contains(
"$previousExactFramebuffer = $env:ACDREAM_AUTOMATION_EXACT_FRAMEBUFFER",
source,
StringComparison.Ordinal);
Assert.Contains(
"$env:ACDREAM_AUTOMATION_EXACT_FRAMEBUFFER = $previousExactFramebuffer",
source,
StringComparison.Ordinal);
Assert.Contains("-WindowStyle Hidden", source, StringComparison.Ordinal);
Assert.DoesNotContain("-WindowStyle Minimized", source, StringComparison.Ordinal);
Assert.Contains(
"[int]$RequiredRenderPackSamples = 0",
source,
StringComparison.Ordinal);
Assert.Contains(
"$probeCommands.Add('renderpack reset-performance')",
source,
StringComparison.Ordinal);
Assert.Contains(
"if ($RenderPackPreset -ne 'auto')",
source,
StringComparison.Ordinal);
Assert.Contains(
"wait render-pack-samples $RequiredRenderPackSamples $RenderPackSampleTimeoutMs",
source,
StringComparison.Ordinal);
int reset = source.IndexOf(
"$probeCommands.Add('renderpack reset-performance')",
StringComparison.Ordinal);
int wait = source.IndexOf(
"wait render-pack-samples $RequiredRenderPackSamples $RenderPackSampleTimeoutMs",
StringComparison.Ordinal);
int screenshot = source.IndexOf(
"$probeCommands.Add('screenshot world-offline 30000')",
StringComparison.Ordinal);
int closeClient = source.IndexOf(
"$probeCommands.Add('close-client')",
StringComparison.Ordinal);
Assert.True(
reset >= 0 && wait > reset && screenshot > wait
&& closeClient > screenshot);
Assert.Contains("$proc.WaitForExit(15000)", source, StringComparison.Ordinal);
Assert.DoesNotContain(
"Get-Process -Name AcDream.App",
source,
StringComparison.Ordinal);
Assert.DoesNotContain(".CloseMainWindow()", source, StringComparison.Ordinal);
}
[Fact]
public void AtmosphericPreviewLaunchesAcdreamWithDisposableStateAndNoLiveCredentials()
{
string source = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"tools",
"launch-atmospheric-preview.ps1"));
Assert.Contains("AcDream.App.exe", source, StringComparison.Ordinal);
Assert.Contains("packId = 'acdream.atmospheric'", source, StringComparison.Ordinal);
Assert.Contains("artifacts\\atmospheric-rendering\\visible-", source, StringComparison.Ordinal);
Assert.Contains("$env:ACDREAM_CONFIG_DIR = $config", source, StringComparison.Ordinal);
Assert.Contains("$env:ACDREAM_DATA_DIR = $data", source, StringComparison.Ordinal);
Assert.Contains("$env:ACDREAM_CACHE_DIR = $cache", source, StringComparison.Ordinal);
Assert.Contains(
".StartsWith('ACDREAM_', [StringComparison]::OrdinalIgnoreCase)",
source,
StringComparison.Ordinal);
Assert.Contains(
"[Environment]::SetEnvironmentVariable($name, $null, 'Process')",
source,
StringComparison.Ordinal);
Assert.Contains(
"[Environment]::SetEnvironmentVariable($name, $prior[$name], 'Process')",
source,
StringComparison.Ordinal);
Assert.Contains(
"$env:ACDREAM_NO_AUDIO = if ($audioEnabled) { $null } else { '1' }",
source,
StringComparison.Ordinal);
Assert.Contains("-RedirectStandardOutput $stdoutLog", source, StringComparison.Ordinal);
Assert.Contains("-RedirectStandardError $stderrLog", source, StringComparison.Ordinal);
Assert.DoesNotContain("-WindowStyle Hidden", source, StringComparison.Ordinal);
}
[Fact]
public void StationaryDwellSamplesAfterTheLivenessDeadline()
{
@ -317,7 +426,7 @@ public sealed class ConnectedWorldSoakRouteContractTests
source,
StringComparison.Ordinal);
Assert.Contains(
"$sensitive = $_.Name -match '(?i)(PASS|PASSWORD|TOKEN|SECRET|KEY)'",
"$sensitive = $_.Name -match '(?i)(PASS|PASSWORD|TOKEN|SECRET|KEY|USER|ACCOUNT)'",
source,
StringComparison.Ordinal);
Assert.Contains(
@ -337,24 +446,23 @@ public sealed class ConnectedWorldSoakRouteContractTests
"tools",
"run-connected-r6-soak.ps1"));
string common = File.ReadAllText(Path.Combine(
FindRepoRoot(), "tools", "connected-render-pack-gate-common.ps1"));
AssertAppearsInOrder(
source,
"$sourceCommit = (& git -C $Repository rev-parse HEAD).Trim()",
"[Diagnostics.FileVersionInfo]::GetVersionInfo($exe).ProductVersion",
"$binaryCommitMatch = [regex]::Match(",
"$commit = if ($null -ne $binaryCommit) { $binaryCommit } else { $sourceCommit }",
"$binaryMatchesSource = $null -ne $binaryCommit -and $binaryCommit -eq $sourceCommit");
Assert.Contains(
"status --short --untracked-files=no",
source,
StringComparison.Ordinal);
"$binaryIdentity = Get-ConnectedGateBinaryIdentity",
"$sourceCommit = $binaryIdentity.SourceCommit",
"$binaryCommit = $binaryIdentity.BinaryCommit",
"$commit = $binaryCommit");
Assert.Contains("[Diagnostics.FileVersionInfo]::GetVersionInfo($Executable).ProductVersion", common);
Assert.Contains("status --short --untracked-files=all", common, StringComparison.Ordinal);
Assert.Contains("BinaryProductVersion = $binaryProductVersion", source);
Assert.Contains("BinaryCommit = $binaryCommit", source);
Assert.Contains("BinaryMatchesSource = $binaryMatchesSource", source);
Assert.Contains("TrackedSourceStatus = @($sourceStatus)", source);
Assert.Contains(
"measured binary commit $binaryCommit differs from checked-out source commit $sourceCommit",
source,
"Measured binary commit $binaryCommit differs from checked-out source commit $sourceCommit",
common,
StringComparison.Ordinal);
}

View file

@ -1,6 +1,7 @@
using System.Text.Json;
using AcDream.App.Diagnostics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Packs;
using AcDream.App.Rendering.Residency;
using AcDream.App.Rendering.Scene;
using AcDream.App.Streaming;
@ -82,6 +83,70 @@ public sealed class WorldLifecycleAutomationControllerTests
}
}
[Fact]
public void ScreenshotCapture_WritesRenderPackChoiceAndAtmosphereMetadata()
{
string directory = NewDirectory();
var diagnostics = new RenderPackDiagnosticsSnapshot(
RenderPackActivationState.Active,
"acdream.atmospheric",
"1.0.0",
"high",
"medium",
FailureReason: null,
ActivationGeneration: 7,
RetainedGpuBytes: 64,
TransientGpuBytes: 32,
ImageCount: 5,
BufferCount: 2,
DrawCalls: 12,
DispatchCalls: 4,
ShadowCasterCount: 22,
CascadeDrawCount: 44,
CpuClassificationCalls: 1,
SunElevationDegrees: 14.5,
ActiveDayGroup: 2,
Weather: "Clear",
WeatherIntensity: 0.25,
Outdoor: true,
DirectionalShadowStrength: 0.8,
Passes: [])
{
SharedWorldTransformUsedInstances = 68_395,
};
var controller = new FrameScreenshotController(
(_, _) => [255, 255, 255, 255],
directory,
renderPackMetadata: () => diagnostics);
Assert.Contains(
"worldTransforms=68395used",
RenderPackDiagnosticsFormatter.Format(diagnostics),
StringComparison.Ordinal);
try
{
Assert.True(controller.TryRequest("enhanced", out string error), error);
Assert.True(controller.CapturePending(1, 1));
using JsonDocument metadata = JsonDocument.Parse(
File.ReadAllText(Path.Combine(directory, "enhanced.metadata.json")));
JsonElement root = metadata.RootElement;
Assert.Equal(1, root.GetProperty("SchemaVersion").GetInt32());
JsonElement pack = root.GetProperty("RenderPack");
Assert.Equal("acdream.atmospheric", pack.GetProperty("PackId").GetString());
Assert.Equal("medium", pack.GetProperty("EffectiveQuality").GetString());
Assert.Equal(14.5, pack.GetProperty("SunElevationDegrees").GetDouble());
Assert.True(pack.GetProperty("Outdoor").GetBoolean());
Assert.Equal(
68_395u,
pack.GetProperty("SharedWorldTransformUsedInstances").GetUInt32());
}
finally
{
Directory.Delete(directory, recursive: true);
}
}
[Theory]
[InlineData("")]
[InlineData("../escape")]
@ -377,6 +442,7 @@ public sealed class WorldLifecycleAutomationControllerTests
var screenshots = new FrameScreenshotController(
(_, _) => [0, 0, 0, 255],
Path.Combine(directory, "screenshots"));
int clientCloseRequests = 0;
var controller = new WorldLifecycleAutomationController(
() => reveal,
() => new RuntimeWorldEnvironmentOwnershipSnapshot(
@ -395,13 +461,18 @@ public sealed class WorldLifecycleAutomationControllerTests
() => 3,
_ => resources,
screenshots,
directory);
directory,
requestClientClose: () => clientCloseRequests++);
try
{
Assert.True(controller.IsWorldReady);
Assert.True(controller.IsWorldViewportVisible);
Assert.Equal(3, controller.PortalMaterializationCount);
Assert.True(
controller.TryRequestClientClose(out string closeError),
closeError);
Assert.Equal(1, clientCloseRequests);
Assert.True(controller.TryRequestCheckpoint(
"dungeon",
out IRetailUiAutomationCheckpoint? request,
@ -591,6 +662,115 @@ public sealed class WorldLifecycleAutomationControllerTests
}
}
[Fact]
public void RenderPackPerformanceReset_IsNoOpOnlyAfterFailedToRetail()
{
string directory = NewDirectory();
bool failedToRetail = true;
int resetCalls = 0;
var controller = new WorldLifecycleAutomationController(
() => default,
() => default,
() => default,
() => 0,
_ => EmptyResources(),
new FrameScreenshotController((_, _) => [], directory),
directory,
getRenderPackPerformanceSampleCount: () => 0,
resetRenderPackPerformance: () =>
{
resetCalls++;
return (true, string.Empty);
},
getRenderPackFailedToRetail: () => failedToRetail);
try
{
Assert.True(controller.RenderPackFailedToRetail);
Assert.True(controller.TryResetRenderPackPerformance(out string fallbackError));
Assert.Empty(fallbackError);
Assert.Equal(0, resetCalls);
failedToRetail = false;
Assert.True(controller.TryResetRenderPackPerformance(out string activeError));
Assert.Empty(activeError);
Assert.Equal(1, resetCalls);
}
finally
{
controller.Dispose();
Directory.Delete(directory, recursive: true);
}
}
[Fact]
public void TransitionAutomationDelegatesPreserveExactDisableReenableAndResizeOwnership()
{
string directory = NewDirectory();
var status = new RetailUiAutomationRenderPackStatus(
RetailUiAutomationRenderPackState.Active,
"acdream.atmospheric",
"medium",
ActivationGeneration: 1,
FailureReason: null);
var framebuffer = (Width: 1280, Height: 720);
var calls = new List<string>();
var controller = new WorldLifecycleAutomationController(
() => default,
() => default,
() => default,
() => 0,
_ => EmptyResources(),
new FrameScreenshotController((_, _) => [], directory),
directory,
getRenderPackStatus: () => status,
selectRenderPack: preset =>
{
calls.Add($"select:{preset}");
return (true, string.Empty);
},
disableRenderPack: () =>
{
calls.Add("disable-exact");
return (true, string.Empty);
},
reenableRenderPack: () =>
{
calls.Add("reenable-exact");
return (true, string.Empty);
},
getFramebufferSize: () => framebuffer,
resizeFramebuffer: (width, height) =>
{
calls.Add($"resize:{width}x{height}");
framebuffer = (width, height);
return (true, string.Empty);
});
try
{
Assert.Equal(status, controller.RenderPackStatus);
Assert.True(controller.TrySelectRenderPack("high", out string selectError));
Assert.Empty(selectError);
Assert.True(controller.TryDisableRenderPack(out string disableError));
Assert.Empty(disableError);
Assert.True(controller.TryReenableRenderPack(out string reenableError));
Assert.Empty(reenableError);
Assert.True(controller.TryResizeFramebuffer(1024, 768, out string resizeError));
Assert.Empty(resizeError);
Assert.Equal(1024, controller.FramebufferWidth);
Assert.Equal(768, controller.FramebufferHeight);
Assert.Equal(
["select:high", "disable-exact", "reenable-exact", "resize:1024x768"],
calls);
}
finally
{
controller.Dispose();
Directory.Delete(directory, recursive: true);
}
}
private static WorldLifecycleAutomationController CreateController(
string directory,
Func<RenderFrameOutcome, WorldLifecycleResourceSnapshot> capture) =>

View file

@ -0,0 +1,673 @@
using System.Runtime.CompilerServices;
using System.Text.Json;
using AcDream.App.Configuration;
using AcDream.App.Plugins;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Packs;
using AcDream.App.Settings;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.Core.Plugins;
using AcDream.Core.Selection;
using AcDream.Platform;
using AcDream.Plugin.Abstractions;
using AcDream.Plugin.Abstractions.Rendering;
using AcDream.Runtime.Session;
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.Tests.Plugins;
/// <summary>
/// Crosses the real external-package boundary. The descriptor and asset source
/// originate in a collectible plugin ALC; everything after registration is the
/// graphical host's production catalog/settings/controller path.
/// </summary>
public sealed class ExternalRenderPackPackageLifecycleTests
{
private const string PackageId = "acdream.test.external-render-pack-package";
private const string PackId = "acdream.test.external-render-pack";
private static readonly RenderPackActivationExtent Extent = new(1280, 720, 1);
[Fact]
public void LiveProductionCatalogWithdrawsAtFrameBoundaryAndAcceptsCorrectedReregistration()
{
using var temporary = new TemporaryDirectory();
RunLiveProductionCatalogLifecycle(temporary);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static void RunLiveProductionCatalogLifecycle(TemporaryDirectory temporary)
{
ApplicationPathSet paths = Paths(temporary.Path);
_ = InstallPackage(paths.PluginsDirectory, new Version(1, 0, 0));
string settingsPath = Path.Combine(paths.ConfigDirectory, "settings.json");
Directory.CreateDirectory(paths.ConfigDirectory);
var selected = new RenderPackSelectionSettings(PackId, "1.0.0", "low");
new JsonRuntimeSettingsStorage(settingsPath).SaveDisplay(
DisplaySettings.Default with { RenderPack = selected });
using var registry = new BufferedRenderPackRegistry();
var source = new RenderPackCatalogSource(
registry,
RenderPackHostCapabilities.Conformance);
using var device = new RecordingGpuDevice();
var factory = new CountingFactory(device);
using var controller = new RenderPackController(
source.Snapshot,
factory,
preparationScheduler: InlineRenderPackPreparationScheduler.Instance,
catalogSource: source);
var settings = new RuntimeSettingsController(
new JsonRuntimeSettingsStorage(settingsPath),
log: static _ => { });
using var binding = new RenderPackSelectionBinding(settings, controller);
GraphicalPluginSession first = StartSession(
paths,
Path.Combine(temporary.Path, "live-v1-status.jsonl"),
registry);
Assert.Equal(
RenderPackActivationState.Active,
binding.ApplyAtFrameBoundary(Extent).State);
WeakReference firstAssets = CaptureAssetWeakReference(registry);
WeakReference firstContext = Assert.Single(first.CaptureLoadContextWeakReferences());
// No settings write/request accompanies uninstall. The registry event
// alone must retire the active production runtime at the next frame.
first.Dispose();
RenderPackActivationSnapshot withdrawn = binding.ApplyAtFrameBoundary(Extent);
Assert.Equal(RenderPackActivationState.FailedToRetail, withdrawn.State);
Assert.Contains("was withdrawn", withdrawn.Reason, StringComparison.Ordinal);
Assert.True(settings.Display.RenderPack.IsRetail);
Assert.Null(controller.ActiveRuntime);
Assert.Empty(registry.Snapshot());
Collect(firstAssets);
Collect(firstContext);
// The stable persisted identity is deliberately unchanged. A new
// registration generation clears the old quarantine on an explicit
// re-selection; production composition/controller objects stay live.
GraphicalPluginSession corrected = StartSession(
paths,
Path.Combine(temporary.Path, "live-corrected-status.jsonl"),
registry);
long correctedRegistration = CaptureRegistrationId(registry);
Assert.True(correctedRegistration > 1);
settings.SaveDisplay(settings.Display with { RenderPack = selected });
RenderPackActivationSnapshot recovered = binding.ApplyAtFrameBoundary(Extent);
Assert.Equal(RenderPackActivationState.Active, recovered.State);
Assert.Equal(selected, recovered.Selection);
Assert.Equal(2, factory.BuildCount);
WeakReference correctedAssets = CaptureAssetWeakReference(registry);
WeakReference correctedContext = Assert.Single(
corrected.CaptureLoadContextWeakReferences());
corrected.Dispose();
Assert.Equal(
RenderPackActivationState.FailedToRetail,
binding.ApplyAtFrameBoundary(Extent).State);
binding.Dispose();
controller.Dispose();
Collect(correctedAssets);
Collect(correctedContext);
AssertZeroGpuPackResources(device);
}
[Fact]
public void PackageSelectionUpdateWithdrawalAndPersistenceConvergeWithoutPackResources()
{
using var temporary = new TemporaryDirectory();
ApplicationPathSet paths = Paths(temporary.Path);
string packageDirectory = InstallPackage(paths.PluginsDirectory, new Version(1, 0, 0));
string settingsPath = Path.Combine(paths.ConfigDirectory, "settings.json");
Directory.CreateDirectory(paths.ConfigDirectory);
var persistedV1 = new RenderPackSelectionSettings(PackId, "1.0.0", "low");
new JsonRuntimeSettingsStorage(settingsPath).SaveDisplay(
DisplaySettings.Default with { RenderPack = persistedV1 });
using var registry = new BufferedRenderPackRegistry();
using var device = new RecordingGpuDevice();
LifetimeReferences v1 = RunV1Lifecycle(
paths,
temporary.Path,
settingsPath,
persistedV1,
registry,
device);
Collect(v1.Assets);
Collect(v1.Context);
WritePackageVersion(packageDirectory, new Version(2, 0, 0));
WriteManifest(packageDirectory, new Version(2, 0, 0));
new JsonRuntimeSettingsStorage(settingsPath).SaveDisplay(
DisplaySettings.Default with { RenderPack = persistedV1 });
LifetimeReferences v2 = RunV2Lifecycle(
paths,
temporary.Path,
settingsPath,
persistedV1,
registry,
device);
Collect(v2.Assets);
Collect(v2.Context);
Assert.Empty(registry.Snapshot());
AssertZeroGpuPackResources(device);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static LifetimeReferences RunV1Lifecycle(
ApplicationPathSet paths,
string root,
string settingsPath,
RenderPackSelectionSettings persistedV1,
BufferedRenderPackRegistry registry,
RecordingGpuDevice device)
{
var factory = new CountingFactory(device);
using var controller = Controller(registry, factory);
var settings = new RuntimeSettingsController(
new JsonRuntimeSettingsStorage(settingsPath),
log: static _ => { });
using var binding = new RenderPackSelectionBinding(settings, controller);
GraphicalPluginSession session = StartSession(
paths,
Path.Combine(root, "v1-status.jsonl"),
registry);
Assert.Equal(1, session.LoadedCount);
AssertRegisteredVersion(registry, new Version(1, 0, 0));
WeakReference assets = CaptureAssetWeakReference(registry);
RenderPackActivationSnapshot active = binding.ApplyAtFrameBoundary(Extent);
Assert.Equal(RenderPackActivationState.Active, active.State);
Assert.Equal(persistedV1, active.Selection);
Assert.IsAssignableFrom<IDefaultWorldPathRenderPackRuntime>(controller.ActiveRuntime);
Assert.Equal(1, factory.BuildCount);
AssertZeroGpuPackResources(device);
WeakReference context = Assert.Single(session.CaptureLoadContextWeakReferences());
session.Dispose();
Assert.Empty(registry.Snapshot());
settings.SaveDisplay(settings.Display with
{
RenderPack = RenderPackSelectionSettings.Retail,
});
Assert.Equal(
RenderPackActivationState.Retail,
binding.ApplyAtFrameBoundary(Extent).State);
Assert.Null(controller.ActiveRuntime);
settings.SaveDisplay(settings.Display with { RenderPack = persistedV1 });
RenderPackActivationSnapshot removed = binding.ApplyAtFrameBoundary(Extent);
Assert.Equal(RenderPackActivationState.FailedToRetail, removed.State);
Assert.Contains("is not installed", removed.Reason, StringComparison.Ordinal);
Assert.True(settings.Display.RenderPack.IsRetail);
Assert.True(
new JsonRuntimeSettingsStorage(settingsPath).LoadDisplay().RenderPack.IsRetail);
Assert.Equal(1, factory.BuildCount);
controller.Request(persistedV1);
RenderPackActivationSnapshot latched = controller.ApplyAtFrameBoundary(Extent);
Assert.Equal(RenderPackActivationState.FailedToRetail, latched.State);
Assert.Contains(
"failed for the current registration",
latched.Reason,
StringComparison.Ordinal);
Assert.Equal(1, factory.BuildCount);
return new LifetimeReferences(context, assets);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static LifetimeReferences RunV2Lifecycle(
ApplicationPathSet paths,
string root,
string settingsPath,
RenderPackSelectionSettings persistedV1,
BufferedRenderPackRegistry registry,
RecordingGpuDevice device)
{
var factory = new CountingFactory(device);
using var controller = Controller(registry, factory);
GraphicalPluginSession session = StartSession(
paths,
Path.Combine(root, "v2-status.jsonl"),
registry);
Assert.Equal(1, session.LoadedCount);
AssertRegisteredVersion(registry, new Version(2, 0, 0));
WeakReference assets = CaptureAssetWeakReference(registry);
var settings = new RuntimeSettingsController(
new JsonRuntimeSettingsStorage(settingsPath),
log: static _ => { });
using var binding = new RenderPackSelectionBinding(settings, controller);
RenderPackActivationSnapshot stale = binding.ApplyAtFrameBoundary(Extent);
Assert.Equal(RenderPackActivationState.FailedToRetail, stale.State);
Assert.Equal(
"Render pack 'acdream.test.external-render-pack' version 1.0.0 was selected, "
+ "but version 2.0.0 is installed.",
stale.Reason);
Assert.True(settings.Display.RenderPack.IsRetail);
Assert.Equal(0, factory.BuildCount);
controller.Request(persistedV1);
RenderPackActivationSnapshot noRetry = controller.ApplyAtFrameBoundary(Extent);
Assert.Equal(RenderPackActivationState.FailedToRetail, noRetry.State);
Assert.Contains("will not be retried", noRetry.Reason, StringComparison.Ordinal);
Assert.Equal(0, factory.BuildCount);
var selectedV2 = new RenderPackSelectionSettings(PackId, "2.0.0", "high");
settings.SaveDisplay(settings.Display with { RenderPack = selectedV2 });
RenderPackActivationSnapshot updated = binding.ApplyAtFrameBoundary(Extent);
Assert.Equal(RenderPackActivationState.Active, updated.State);
Assert.Equal(selectedV2, updated.Selection);
Assert.Equal("high", controller.ActiveRuntime!.Preset.Id);
Assert.Equal(1, factory.BuildCount);
Assert.Equal(
selectedV2,
new JsonRuntimeSettingsStorage(settingsPath).LoadDisplay().RenderPack);
AssertZeroGpuPackResources(device);
WeakReference context = Assert.Single(session.CaptureLoadContextWeakReferences());
session.Dispose();
Assert.Empty(registry.Snapshot());
settings.SaveDisplay(settings.Display with
{
RenderPack = RenderPackSelectionSettings.Retail,
});
Assert.Equal(
RenderPackActivationState.Retail,
binding.ApplyAtFrameBoundary(Extent).State);
Assert.Null(controller.ActiveRuntime);
return new LifetimeReferences(context, assets);
}
[Fact]
public void MalformedThenFailingPackageCanBeCorrectedWithoutLeakingRegistrationOrAlc()
{
using var temporary = new TemporaryDirectory();
ApplicationPathSet paths = Paths(temporary.Path);
string packageDirectory = InstallPackage(paths.PluginsDirectory, new Version(1, 0, 0));
string manifestPath = Path.Combine(packageDirectory, "plugin.json");
File.WriteAllText(manifestPath, "{ this is not a plugin manifest }");
using var registry = new BufferedRenderPackRegistry();
GraphicalPluginSession malformed = StartSession(
paths,
Path.Combine(temporary.Path, "malformed-status.jsonl"),
registry);
Assert.Equal(0, malformed.LoadedCount);
Assert.Empty(registry.Snapshot());
Assert.Empty(malformed.CaptureLoadContextWeakReferences());
JsonElement malformedFailure = Assert.Single(ReadStatuses(
Path.Combine(temporary.Path, "malformed-status.jsonl")),
value => value.GetProperty("e").GetString() == "pluginFailed");
Assert.Contains(
"invalid start of a property name",
malformedFailure.GetProperty("error").GetString(),
StringComparison.OrdinalIgnoreCase);
malformed.Dispose();
WriteManifest(packageDirectory, new Version(1, 0, 0));
string failureMarker = Path.Combine(
packageDirectory,
"throw-after-render-pack-register");
File.WriteAllText(failureMarker, string.Empty);
GraphicalPluginSession failing = StartSession(
paths,
Path.Combine(temporary.Path, "failing-status.jsonl"),
registry);
Assert.Equal(0, failing.LoadedCount);
Assert.Empty(registry.Snapshot());
WeakReference failedContext = Assert.Single(
failing.CaptureLoadContextWeakReferences());
Assert.Contains(
"failed after publishing its descriptor",
Assert.Single(ReadStatuses(
Path.Combine(temporary.Path, "failing-status.jsonl")),
value => value.GetProperty("e").GetString() == "pluginFailed")
.GetProperty("error").GetString(),
StringComparison.Ordinal);
failing.Dispose();
Collect(failedContext);
File.Delete(failureMarker);
string zeroMarker = Path.Combine(packageDirectory, "register-no-render-packs");
File.WriteAllText(zeroMarker, string.Empty);
GraphicalPluginSession zeroRegistration = StartSession(
paths,
Path.Combine(temporary.Path, "zero-registration-status.jsonl"),
registry);
Assert.Equal(0, zeroRegistration.LoadedCount);
Assert.Empty(registry.Snapshot());
WeakReference zeroContext = Assert.Single(
zeroRegistration.CaptureLoadContextWeakReferences());
Assert.Contains(
"registered no packs",
Assert.Single(ReadStatuses(
Path.Combine(temporary.Path, "zero-registration-status.jsonl")),
value => value.GetProperty("e").GetString() == "pluginFailed")
.GetProperty("error").GetString(),
StringComparison.Ordinal);
zeroRegistration.Dispose();
Collect(zeroContext);
File.Delete(zeroMarker);
GraphicalPluginSession corrected = StartSession(
paths,
Path.Combine(temporary.Path, "corrected-status.jsonl"),
registry);
Assert.Equal(1, corrected.LoadedCount);
AssertCompatibleRegistration(registry);
WeakReference correctedAssets = CaptureAssetWeakReference(registry);
WeakReference correctedContext = Assert.Single(
corrected.CaptureLoadContextWeakReferences());
corrected.Dispose();
Assert.Empty(registry.Snapshot());
Collect(correctedAssets);
Collect(correctedContext);
}
[Theory]
[InlineData(
"AcDream.Plugin.Tests.Fixtures.InvalidRenderPackMultiple",
"must contain exactly one IRenderPackPlugin implementation")]
[InlineData(
"AcDream.Plugin.Tests.Fixtures.InvalidRenderPackInternal",
"must be public")]
public void InvalidRenderPackEntrypointShapeRollsBackCollectiblePackage(
string fixtureName,
string expectedFailure)
{
using var temporary = new TemporaryDirectory();
ApplicationPathSet paths = Paths(temporary.Path);
string packageId = "acdream.test.invalid-render-pack";
string source = FixtureAssemblyPath(fixtureName);
string packageDirectory = Path.Combine(paths.PluginsDirectory, packageId);
Directory.CreateDirectory(packageDirectory);
File.Copy(source, Path.Combine(packageDirectory, Path.GetFileName(source)));
File.WriteAllText(
Path.Combine(packageDirectory, "plugin.json"),
JsonSerializer.Serialize(new
{
id = packageId,
displayName = "Invalid render-pack entry fixture",
version = "1.0.0",
entryDll = Path.GetFileName(source),
apiVersion = 1,
kinds = new[] { "RenderPack" },
}));
using var registry = new BufferedRenderPackRegistry();
string statusPath = Path.Combine(temporary.Path, fixtureName + ".jsonl");
GraphicalPluginSession session = StartSession(
paths,
statusPath,
registry,
packageId);
Assert.Equal(0, session.LoadedCount);
Assert.Empty(registry.Snapshot());
Assert.Contains(
expectedFailure,
Assert.Single(ReadStatuses(statusPath),
value => value.GetProperty("e").GetString() == "pluginFailed")
.GetProperty("error").GetString(),
StringComparison.Ordinal);
WeakReference context = Assert.Single(session.CaptureLoadContextWeakReferences());
session.Dispose();
Collect(context);
}
private static RenderPackController Controller(
BufferedRenderPackRegistry registry,
IRenderPackRuntimeFactory factory)
{
var source = new RenderPackCatalogSource(
registry,
RenderPackHostCapabilities.Conformance);
return new RenderPackController(
source.Snapshot,
factory,
preparationScheduler: InlineRenderPackPreparationScheduler.Instance,
catalogSource: source);
}
private static GraphicalPluginSession StartSession(
ApplicationPathSet paths,
string statusPath,
BufferedRenderPackRegistry registry,
string packageId = PackageId)
{
var host = new AppPluginHost(
new NullLogger(),
new WorldGameState(),
new WorldEvents(),
new SelectionState(),
new BufferedUiRegistry(),
NoOpAutomationSurface.Instance);
var session = GraphicalPluginSession.Create(
paths,
[packageId],
"external-render-pack-test",
host,
new SessionStatusWriter(statusPath),
registry);
session.Start();
return session;
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static void AssertRegisteredVersion(
BufferedRenderPackRegistry registry,
Version expected)
{
BufferedRenderPackRegistration registration = Assert.Single(registry.Snapshot());
Assert.Equal(PackId, registration.Descriptor.Id);
Assert.Equal(expected, registration.Descriptor.PackVersion);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static WeakReference CaptureAssetWeakReference(
BufferedRenderPackRegistry registry) =>
new(Assert.Single(registry.Snapshot()).Assets);
[MethodImpl(MethodImplOptions.NoInlining)]
private static long CaptureRegistrationId(BufferedRenderPackRegistry registry) =>
Assert.Single(registry.Snapshot()).RegistrationId;
[MethodImpl(MethodImplOptions.NoInlining)]
private static void AssertCompatibleRegistration(BufferedRenderPackRegistry registry)
{
BufferedRenderPackRegistration registration = Assert.Single(registry.Snapshot());
Assert.Equal(PackId, registration.Descriptor.Id);
Assert.True(RenderPackCatalog.Build(
registry.Snapshot(),
RenderPackHostCapabilities.Conformance).TryGet(
PackId,
out RenderPackCatalogEntry catalogEntry));
Assert.True(catalogEntry.IsCompatible, catalogEntry.IncompatibilityReason);
}
private static ApplicationPathSet Paths(string root) => new(
Path.Combine(root, "config"),
Path.Combine(root, "data"),
Path.Combine(root, "cache"),
LegacyConfigDirectory: null);
private static string InstallPackage(string root, Version version)
{
string source = FixtureAssemblyPath();
string packageDirectory = Path.Combine(root, PackageId);
Directory.CreateDirectory(packageDirectory);
File.Copy(source, Path.Combine(packageDirectory, Path.GetFileName(source)));
WritePackageVersion(packageDirectory, version);
WriteManifest(packageDirectory, version);
return packageDirectory;
}
private static void WritePackageVersion(string directory, Version version) =>
File.WriteAllText(
Path.Combine(directory, "render-pack-version.txt"),
version.ToString());
private static void WriteManifest(string directory, Version version) =>
File.WriteAllText(
Path.Combine(directory, "plugin.json"),
JsonSerializer.Serialize(new
{
id = PackageId,
displayName = "External render-pack package fixture",
version = version.ToString(),
entryDll = Path.GetFileName(FixtureAssemblyPath()),
apiVersion = 1,
kinds = new[] { "RenderPack" },
}));
private static string FixtureAssemblyPath()
{
string configuration = new DirectoryInfo(AppContext.BaseDirectory)
.Parent!.Name;
return Path.Combine(
FindRepoRoot(),
"tests",
"AcDream.Plugin.Tests.Fixtures.HostPlugin",
"bin",
configuration,
"net10.0",
"AcDream.Plugin.Tests.Fixtures.HostPlugin.dll");
}
private static string FixtureAssemblyPath(string projectName)
{
string configuration = new DirectoryInfo(AppContext.BaseDirectory)
.Parent!.Name;
return Path.Combine(
FindRepoRoot(),
"tests",
projectName,
"bin",
configuration,
"net10.0",
projectName + ".dll");
}
private static JsonElement[] ReadStatuses(string path) =>
File.ReadAllLines(path)
.Select(static line => JsonDocument.Parse(line).RootElement.Clone())
.ToArray();
private static void AssertZeroGpuPackResources(RecordingGpuDevice device)
{
Assert.Empty(device.CreatedBuffers);
Assert.Empty(device.CreatedPipelines);
Assert.Empty(device.CreatedTextures);
Assert.Empty(device.CreatedRenderTargets);
Assert.Empty(device.CreatedDirectionalDepthTargets);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static void Collect(WeakReference context)
{
for (int attempt = 0; attempt < 12 && context.IsAlive; attempt++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
}
Assert.False(context.IsAlive);
// On Windows the collectible context can become unreachable one GC
// before CoreCLR closes the mapped assembly file. One finalizer/GC
// turn makes the file-lifetime assertion in TemporaryDirectory exact.
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
}
private static string FindRepoRoot()
{
string? configured = Environment.GetEnvironmentVariable("ACDREAM_REPO_ROOT");
if (!string.IsNullOrWhiteSpace(configured)
&& File.Exists(Path.Combine(configured, "AcDream.slnx")))
{
return Path.GetFullPath(configured);
}
foreach (string start in new[]
{
AppContext.BaseDirectory,
Directory.GetCurrentDirectory(),
})
{
DirectoryInfo? directory = new(start);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
return directory.FullName;
directory = directory.Parent;
}
}
throw new InvalidOperationException("Repository root not found.");
}
private sealed class CountingFactory(IGpuDevice device) : IRenderPackRuntimeFactory
{
private readonly AtmosphericRenderPackRuntimeFactory _inner = new(device);
internal int BuildCount { get; private set; }
public IRenderPackRuntime Build(
RenderPackDescriptor descriptor,
ValidatedRenderPackShaderAssets assets,
RenderQualityPreset preset,
IReadOnlyDictionary<string, string> userSettingOverrides)
{
BuildCount++;
return _inner.Build(descriptor, assets, preset, userSettingOverrides);
}
}
private sealed class NullLogger : IPluginLogger
{
public void Info(string message) { }
public void Warn(string message) { }
public void Error(string message, Exception? exception = null) { }
}
private readonly record struct LifetimeReferences(
WeakReference Context,
WeakReference Assets);
private sealed class TemporaryDirectory : IDisposable
{
internal TemporaryDirectory()
{
Path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
$"acdream-external-pack-{Guid.NewGuid():N}");
Directory.CreateDirectory(Path);
}
internal string Path { get; }
public void Dispose()
{
for (int attempt = 0; Directory.Exists(Path); attempt++)
{
try
{
Directory.Delete(Path, recursive: true);
return;
}
catch (Exception error)
when (error is IOException or UnauthorizedAccessException
&& attempt < 249)
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
Thread.Sleep(20);
}
}
}
}
}

View file

@ -1,6 +1,7 @@
using System.Numerics;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Scene.Arch;
using AcDream.Core.World;
namespace AcDream.App.Tests.Rendering;
@ -661,6 +662,429 @@ public sealed class ArchRenderSceneTests
scene.OpenQuery().IndexRevision > registeredRevision);
}
[Fact]
public void DirectionalShadowTopologyRevision_IgnoresDynamicPoseButTracksEligibilityGeometryAppearanceAndCasterIdentity()
{
RenderSceneGeneration generation = Generation(19);
using var scene = new ArchRenderScene(generation);
Matrix4x4 firstPart = Matrix4x4.CreateTranslation(1f, 2f, 3f);
RenderProjectionRecord original = Record(
19,
1,
RenderProjectionClass.LiveDynamicRoot) with
{
Source = new RenderSourceMetadata(
LocalEntityId: 19,
ServerGuid: 19,
SourceId: 19,
ParentCellId: 0,
EffectCellId: 0,
BuildingShellAnchorCellId: 0,
TransformFingerprint: new RenderSceneHash128(1, 1),
GeometryFingerprint: new RenderSceneHash128(2, 2),
AppearanceFingerprint: new RenderSceneHash128(3, 3),
DirectionalShadowTopologyFingerprint:
new RenderSceneHash128(4, 4)),
EntityPayload = new RenderEntityPayload(
[new MeshRef(0x01000001, firstPart)],
PaletteOverride: null,
IsBuildingShell: false),
};
scene.Apply([RenderProjectionDelta.Register(generation, 1, original)]);
ulong registered =
scene.OpenQuery().DirectionalShadowTopologyRevision;
Matrix4x4 movedRoot = Matrix4x4.CreateTranslation(20f, 21f, 22f);
Matrix4x4 movedPart = Matrix4x4.CreateTranslation(23f, 24f, 25f);
RenderProjectionRecord poseOnly = original with
{
Transform = new RenderTransform(movedRoot),
MeshSet = original.MeshSet with
{
Handle = Asset(999),
},
Source = original.Source with
{
TransformFingerprint = new RenderSceneHash128(10, 10),
GeometryFingerprint = new RenderSceneHash128(20, 20),
},
EntityPayload = original.EntityPayload with
{
MeshRefs = [new MeshRef(0x01000001, movedPart)],
},
};
scene.Apply(
[
RenderProjectionDelta.Update(
RenderProjectionDeltaKind.UpdateTransform,
generation,
2,
poseOnly),
RenderProjectionDelta.Update(
RenderProjectionDeltaKind.UpdateAppearance,
generation,
3,
poseOnly),
]);
Assert.Equal(
registered,
scene.OpenQuery().DirectionalShadowTopologyRevision);
RenderProjectionRecord hidden = poseOnly with
{
Flags = poseOnly.Flags & ~RenderProjectionFlags.Draw,
};
scene.Apply(
[
RenderProjectionDelta.Update(
RenderProjectionDeltaKind.UpdateFlags,
generation,
4,
hidden),
]);
ulong eligibilityChanged =
scene.OpenQuery().DirectionalShadowTopologyRevision;
Assert.True(eligibilityChanged > registered);
RenderProjectionRecord geometryChanged = hidden with
{
Source = hidden.Source with
{
DirectionalShadowTopologyFingerprint =
new RenderSceneHash128(5, 5),
},
EntityPayload = hidden.EntityPayload with
{
MeshRefs =
[
new MeshRef(0x01000002, movedPart)
{
SurfaceOverrides = new Dictionary<uint, uint>
{
[0x08000001] = 0x05000001,
},
},
],
},
};
scene.Apply(
[
RenderProjectionDelta.Update(
RenderProjectionDeltaKind.UpdateAppearance,
generation,
5,
geometryChanged),
]);
ulong geometryRevision =
scene.OpenQuery().DirectionalShadowTopologyRevision;
Assert.True(geometryRevision > eligibilityChanged);
var palette = new PaletteOverride(
0x04000001,
[new PaletteOverride.SubPaletteRange(0x04000002, 1, 2)]);
RenderProjectionRecord appearanceChanged = geometryChanged with
{
Material = geometryChanged.Material with { PaletteKey = 1234 },
Source = geometryChanged.Source with
{
AppearanceFingerprint = new RenderSceneHash128(6, 6),
},
EntityPayload = geometryChanged.EntityPayload with
{
PaletteOverride = palette,
},
};
scene.Apply(
[
RenderProjectionDelta.Update(
RenderProjectionDeltaKind.UpdateAppearance,
generation,
6,
appearanceChanged),
]);
ulong appearanceRevision =
scene.OpenQuery().DirectionalShadowTopologyRevision;
Assert.True(appearanceRevision > geometryRevision);
RenderProjectionRecord identityChanged = appearanceChanged with
{
EntityPayload = appearanceChanged.EntityPayload with
{
CasterIdentity = RenderCasterIdentityKind.RemotePlayer,
},
};
scene.Apply(
[
RenderProjectionDelta.Update(
RenderProjectionDeltaKind.UpdateAppearance,
generation,
7,
identityChanged),
]);
Assert.True(
scene.OpenQuery().DirectionalShadowTopologyRevision
> appearanceRevision);
}
[Fact]
public void DirectionalShadowTopologyRevision_TracksRareStaticTransformChanges()
{
RenderSceneGeneration generation = Generation(20);
using var scene = new ArchRenderScene(generation);
RenderProjectionRecord original = Record(
20,
1,
RenderProjectionClass.OutdoorStatic);
scene.Apply([RenderProjectionDelta.Register(generation, 1, original)]);
ulong registered =
scene.OpenQuery().DirectionalShadowTopologyRevision;
RenderProjectionRecord moved = original with
{
Transform = new RenderTransform(
Matrix4x4.CreateTranslation(100f, 101f, 102f)),
};
scene.Apply(
[
RenderProjectionDelta.Update(
RenderProjectionDeltaKind.UpdateTransform,
generation,
2,
moved),
]);
Assert.True(
scene.OpenQuery().DirectionalShadowTopologyRevision > registered);
}
[Fact]
public void DirectionalShadowTransformJournal_TracksRootPartAndDynamicSync_IndependentlyOfDirtyDrain()
{
RenderSceneGeneration generation = Generation(21);
using var scene = new ArchRenderScene(generation);
Matrix4x4 firstPart = Matrix4x4.CreateTranslation(1f, 2f, 3f);
RenderProjectionRecord original = Record(
21,
1,
RenderProjectionClass.LiveDynamicRoot) with
{
EntityPayload = new RenderEntityPayload(
[new MeshRef(0x01000021, firstPart)],
PaletteOverride: null,
IsBuildingShell: false),
};
scene.Apply([RenderProjectionDelta.Register(generation, 1, original)]);
RenderSceneQuery query = scene.OpenQuery();
ulong initialRevision = query.DirectionalShadowTransformRevision;
Matrix4x4 movedRoot = Matrix4x4.CreateTranslation(
BitConverter.Int32BitsToSingle(0x41234567),
4f,
5f);
RenderProjectionRecord moved = original with
{
Transform = new RenderTransform(movedRoot),
PreviousTransform = new PreviousRenderTransform(
original.Transform.LocalToWorld),
};
scene.Apply(
[
RenderProjectionDelta.Update(
RenderProjectionDeltaKind.UpdateTransform,
generation,
2,
moved),
]);
Matrix4x4 movedPart = Matrix4x4.CreateTranslation(
6f,
BitConverter.Int32BitsToSingle(0x40ABCDEF),
8f);
RenderProjectionRecord posed = moved with
{
EntityPayload = moved.EntityPayload with
{
MeshRefs = [new MeshRef(0x01000021, movedPart)],
},
};
scene.Apply(
[
RenderProjectionDelta.Update(
RenderProjectionDeltaKind.UpdateAppearance,
generation,
3,
posed),
]);
scene.ClearDirty();
var changes = new DirectionalShadowTransformSnapshot[3];
DirectionalShadowTransformChanges firstChanges =
query.CopyDirectionalShadowTransformChanges(
initialRevision,
changes);
Assert.False(firstChanges.RequiresFullRefresh);
Assert.Equal(2, firstChanges.Count);
Assert.Equal(1, firstChanges.UpdateTransformCount);
Assert.Equal(1, firstChanges.UpdateAppearanceCount);
Assert.Equal(0, firstChanges.DynamicSynchronizationCount);
Assert.Equal(2, firstChanges.LiveDynamicRootCount);
Assert.Equal(original.Id, changes[0].Id);
Assert.Equal(original.Id, changes[1].Id);
var synchronized = new DynamicProjectionUpdate(
original.Id,
original.OwnerIncarnation,
new RenderTransform(Matrix4x4.CreateTranslation(9f, 10f, 11f)),
posed.Bounds);
scene.SynchronizeDynamicSources(
new DynamicProjectionSyncInput(generation, [synchronized]));
DirectionalShadowTransformChanges syncChanges =
query.CopyDirectionalShadowTransformChanges(
firstChanges.LatestRevision,
changes);
Assert.False(syncChanges.RequiresFullRefresh);
Assert.Equal(1, syncChanges.Count);
Assert.Equal(1, syncChanges.DynamicSynchronizationCount);
Assert.Equal(1, syncChanges.LiveDynamicRootCount);
Assert.Equal(original.Id, changes[0].Id);
RenderProjectionRecord flagsOnly = posed with
{
Flags = posed.Flags ^ RenderProjectionFlags.Selectable,
};
scene.Apply(
[
RenderProjectionDelta.Update(
RenderProjectionDeltaKind.UpdateFlags,
generation,
4,
flagsOnly),
]);
Assert.Equal(
syncChanges.LatestRevision,
query.DirectionalShadowTransformRevision);
}
[Fact]
public void DirectionalShadowTransformJournal_OverflowAndGenerationResetFailSafe()
{
RenderSceneGeneration generation = Generation(22);
using var scene = new ArchRenderScene(generation);
RenderProjectionRecord original = Record(
22,
1,
RenderProjectionClass.LiveDynamicRoot);
scene.Apply([RenderProjectionDelta.Register(generation, 1, original)]);
RenderSceneQuery oldQuery = scene.OpenQuery();
ulong initialRevision = oldQuery.DirectionalShadowTransformRevision;
for (int index = 0;
index < DirectionalShadowTransformChangeJournal.Capacity + 1;
index++)
{
var update = new DynamicProjectionUpdate(
original.Id,
original.OwnerIncarnation,
new RenderTransform(Matrix4x4.CreateTranslation(index + 1, 0f, 0f)),
original.Bounds);
scene.SynchronizeDynamicSources(
new DynamicProjectionSyncInput(generation, [update]));
}
var changes = new DirectionalShadowTransformSnapshot[
DirectionalShadowTransformChangeJournal.Capacity];
DirectionalShadowTransformChanges overflow =
oldQuery.CopyDirectionalShadowTransformChanges(
initialRevision,
changes);
Assert.True(overflow.RequiresFullRefresh);
Assert.Equal(0, overflow.Count);
RenderSceneGeneration replacement = Generation(23);
scene.Clear(replacement);
Assert.Throws<InvalidOperationException>(
() => _ = oldQuery.DirectionalShadowTransformRevision);
Assert.Equal(1ul, scene.OpenQuery().DirectionalShadowTransformRevision);
}
[Fact]
public void DirectionalShadowTransformJournal_IgnoresIdenticalPoseAndBoundsOnlySynchronization()
{
RenderSceneGeneration generation = Generation(24);
using var scene = new ArchRenderScene(generation);
var meshes = new List<MeshRef>
{
new(0x01000024, Matrix4x4.CreateTranslation(1f, 2f, 3f)),
};
RenderProjectionRecord original = Record(
24,
1,
RenderProjectionClass.LiveDynamicRoot) with
{
EntityPayload = new RenderEntityPayload(
meshes,
PaletteOverride: null,
IsBuildingShell: false),
};
scene.Apply([RenderProjectionDelta.Register(generation, 1, original)]);
RenderSceneQuery query = scene.OpenQuery();
ulong initial = query.DirectionalShadowTransformRevision;
scene.Apply(
[
RenderProjectionDelta.Update(
RenderProjectionDeltaKind.UpdateTransform,
generation,
2,
original),
RenderProjectionDelta.Update(
RenderProjectionDeltaKind.UpdateAppearance,
generation,
3,
original),
]);
var boundsOnly = new DynamicProjectionUpdate(
original.Id,
original.OwnerIncarnation,
original.Transform,
new RenderWorldBounds(Vector3.One, new Vector3(2f)));
scene.SynchronizeDynamicSources(
new DynamicProjectionSyncInput(generation, [boundsOnly]));
Assert.Equal(initial, query.DirectionalShadowTransformRevision);
Matrix4x4 changedPart = Matrix4x4.CreateTranslation(4f, 5f, 6f);
meshes[0] = new MeshRef(0x01000024, changedPart);
scene.Apply(
[
RenderProjectionDelta.Update(
RenderProjectionDeltaKind.UpdateAppearance,
generation,
4,
original),
]);
var changes = new DirectionalShadowTransformSnapshot[1];
DirectionalShadowTransformChanges changed =
query.CopyDirectionalShadowTransformChanges(initial, changes);
Assert.Equal(1, changed.Count);
Assert.Equal(0, changed.UpdateTransformCount);
Assert.Equal(1, changed.UpdateAppearanceCount);
Assert.Equal(0, changed.DynamicSynchronizationCount);
Assert.Equal(1, changed.LiveDynamicRootCount);
Assert.Equal(original.Id, changes[0].Id);
Assert.Equal(
BitConverter.SingleToInt32Bits(changedPart.M41),
BitConverter.SingleToInt32Bits(
changes[0].EntityPayload.MeshRefs[0].PartTransform.M41));
scene.Apply(
[
RenderProjectionDelta.Update(
RenderProjectionDeltaKind.UpdateAppearance,
generation,
5,
original),
]);
Assert.Equal(changed.LatestRevision, query.DirectionalShadowTransformRevision);
}
private static RenderProjectionRecord Record(
ulong id,
ulong incarnation,

View file

@ -0,0 +1,222 @@
using System.Numerics;
using AcDream.App.Rendering;
namespace AcDream.App.Tests.Rendering;
public sealed class DirectionalShadowCascadeFitterTests
{
[Theory]
[InlineData(DirectionalShadowPreset.Low, 2, 72f)]
[InlineData(DirectionalShadowPreset.Medium, 3, 144f)]
[InlineData(DirectionalShadowPreset.High, 4, 240f)]
internal void Fit_UsesPracticalIncreasingSplitsAndExactPresetReach(
DirectionalShadowPreset preset,
int expectedCount,
float expectedReach)
{
DirectionalShadowQuality quality = DirectionalShadowQuality.For(preset);
DirectionalShadowCascadeFitInput input = CameraInput(
Vector3.Zero,
quality);
Span<DirectionalShadowCascade> cascades =
stackalloc DirectionalShadowCascade[4];
int count = DirectionalShadowCascadeFitter.Fit(in input, cascades);
Assert.Equal(expectedCount, count);
float previous = input.CameraNearMeters;
for (int i = 0; i < count; i++)
{
Assert.Equal(previous, cascades[i].SplitNearMeters);
Assert.True(cascades[i].SplitFarMeters > previous);
Assert.True(cascades[i].TexelWorldSize > 0f);
Assert.True(float.IsFinite(cascades[i].WorldToShadowClip.M11));
previous = cascades[i].SplitFarMeters;
}
Assert.Equal(expectedReach, cascades[count - 1].SplitFarMeters, 3);
}
[Fact]
public void TexelStabilization_SubTexelCameraTranslationKeepsSnappedCenter()
{
DirectionalShadowQuality quality = DirectionalShadowQuality.For(
DirectionalShadowPreset.Medium);
DirectionalShadowCascadeFitInput firstInput = CameraInput(
new Vector3(100f, 200f, 30f),
quality);
Span<DirectionalShadowCascade> first =
stackalloc DirectionalShadowCascade[4];
DirectionalShadowCascadeFitter.Fit(in firstInput, first);
// Translation along the light-space X axis by less than half a map
// texel must not move the stabilized projection centre.
Vector3 light = Vector3.Normalize(firstInput.SurfaceToLightDirection);
Vector3 lightX = Vector3.Normalize(Vector3.Cross(
DirectionalShadowCascadeFitter.StableLightUp(light),
light));
Vector3 movement = lightX * (first[0].TexelWorldSize * 0.2f);
DirectionalShadowCascadeFitInput secondInput = CameraInput(
new Vector3(100f, 200f, 30f) + movement,
quality);
Span<DirectionalShadowCascade> second =
stackalloc DirectionalShadowCascade[4];
DirectionalShadowCascadeFitter.Fit(in secondInput, second);
Assert.Equal(
first[0].StabilizedLightSpaceCenter.X,
second[0].StabilizedLightSpaceCenter.X);
Assert.Equal(
first[0].StabilizedLightSpaceCenter.Y,
second[0].StabilizedLightSpaceCenter.Y);
Assert.Equal(first[0].HalfExtentMeters, second[0].HalfExtentMeters);
}
[Fact]
public void StableLightUp_DoesNotRotateAtTheFormerHighLightThreshold()
{
Vector3 below = Vector3.Normalize(new Vector3(0.3125f, 0.02f, 0.9498f));
Vector3 above = Vector3.Normalize(new Vector3(0.3110f, 0.02f, 0.9503f));
Vector3 belowUp = DirectionalShadowCascadeFitter.StableLightUp(below);
Vector3 aboveUp = DirectionalShadowCascadeFitter.StableLightUp(above);
Assert.InRange(MathF.Abs(Vector3.Dot(below, belowUp)), 0f, 1e-5f);
Assert.InRange(MathF.Abs(Vector3.Dot(above, aboveUp)), 0f, 1e-5f);
Assert.True(Vector3.Dot(belowUp, aboveUp) > 0.999f);
}
[Fact]
public void StableLightUp_TrueZenithIsFiniteAndOrthogonal()
{
Vector3 up = DirectionalShadowCascadeFitter.StableLightUp(Vector3.UnitZ);
Assert.True(float.IsFinite(up.X) && float.IsFinite(up.Y) && float.IsFinite(up.Z));
Assert.Equal(1f, up.Length(), 5);
Assert.InRange(MathF.Abs(Vector3.Dot(Vector3.UnitZ, up)), 0f, 1e-5f);
}
[Fact]
public void StableLightUp_RemainsContinuousThroughCelestialZenith()
{
Vector3 beforeZenith = Vector3.Normalize(new Vector3(0.001f, 0.002f, 1f));
Vector3 zenith = Vector3.UnitZ;
Vector3 afterZenith = Vector3.Normalize(new Vector3(-0.001f, -0.002f, 1f));
Vector3 beforeUp = DirectionalShadowCascadeFitter.StableLightUp(beforeZenith);
Vector3 zenithUp = DirectionalShadowCascadeFitter.StableLightUp(zenith);
Vector3 afterUp = DirectionalShadowCascadeFitter.StableLightUp(afterZenith);
Assert.True(Vector3.Dot(beforeUp, zenithUp) > 0.99999f);
Assert.True(Vector3.Dot(zenithUp, afterUp) > 0.99999f);
Assert.InRange(MathF.Abs(Vector3.Dot(beforeZenith, beforeUp)), 0f, 1e-5f);
Assert.InRange(MathF.Abs(Vector3.Dot(afterZenith, afterUp)), 0f, 1e-5f);
}
[Fact]
public void ClipDensityRatio_MatchesCascadeTexelFootprintRatio()
{
DirectionalShadowCascadeFitInput input = CameraInput(
new Vector3(40f, -15f, 8f),
DirectionalShadowQuality.For(DirectionalShadowPreset.High));
Span<DirectionalShadowCascade> cascades =
stackalloc DirectionalShadowCascade[4];
int count = DirectionalShadowCascadeFitter.Fit(in input, cascades);
float nearDensity = ClipXyDensity(cascades[0].WorldToShadowClip);
float farDensity = ClipXyDensity(cascades[count - 1].WorldToShadowClip);
float shaderScale = farDensity / nearDensity;
float expectedScale = cascades[0].TexelWorldSize
/ cascades[count - 1].TexelWorldSize;
Assert.Equal(expectedScale, shaderScale, 4);
Assert.InRange(shaderScale, 0f, 0.999f);
}
[Fact]
public void Fit_DoesNotAllocateOrInvokeSceneVisibility()
{
DirectionalShadowCascadeFitInput input = CameraInput(
Vector3.Zero,
DirectionalShadowQuality.For(DirectionalShadowPreset.High));
Span<DirectionalShadowCascade> cascades =
stackalloc DirectionalShadowCascade[4];
// Cross the tiered-JIT promotion threshold before taking the thread's
// allocation counter; measuring immediately after one call makes the
// runtime's compilation bookkeeping look like renderer allocation.
for (int i = 0; i < 128; i++)
DirectionalShadowCascadeFitter.Fit(in input, cascades);
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 100; i++)
DirectionalShadowCascadeFitter.Fit(in input, cascades);
long after = GC.GetAllocatedBytesForCurrentThread();
Assert.Equal(0, after - before);
}
[Fact]
public void ResidentWindowClampsOnlyTheFinalCascadeReach()
{
DirectionalShadowQuality quality = DirectionalShadowQuality.For(
DirectionalShadowPreset.High);
DirectionalShadowCascadeFitInput input = CameraInput(
Vector3.Zero,
quality) with
{
ResidentMaximumReachMeters = 96f,
};
Span<DirectionalShadowCascade> cascades =
stackalloc DirectionalShadowCascade[4];
int count = DirectionalShadowCascadeFitter.Fit(in input, cascades);
Assert.Equal(quality.CascadeCount, count);
Assert.Equal(96f, cascades[count - 1].SplitFarMeters, 3);
Assert.All(
cascades[..count].ToArray(),
cascade => Assert.InRange(cascade.SplitFarMeters, 0f, 96f));
}
[Fact]
public void UnavailableResidentWindowDisablesFittingWithoutAllocating()
{
DirectionalShadowCascadeFitInput input = CameraInput(
Vector3.Zero,
DirectionalShadowQuality.For(DirectionalShadowPreset.High)) with
{
ResidentMaximumReachMeters = 0f,
};
Span<DirectionalShadowCascade> cascades =
stackalloc DirectionalShadowCascade[4];
Assert.Equal(0, DirectionalShadowCascadeFitter.Fit(in input, cascades));
}
private static DirectionalShadowCascadeFitInput CameraInput(
Vector3 position,
DirectionalShadowQuality quality)
{
Vector3 target = position + Vector3.Normalize(new Vector3(1f, 2f, -0.2f));
Matrix4x4 view = Matrix4x4.CreateLookAt(position, target, Vector3.UnitZ);
Matrix4x4 projection = Matrix4x4.CreatePerspectiveFieldOfView(
70f * MathF.PI / 180f,
16f / 9f,
0.1f,
5000f);
return new DirectionalShadowCascadeFitInput(
view,
projection,
Vector3.Normalize(new Vector3(0.4f, 0.7f, 0.55f)),
quality);
}
private static float ClipXyDensity(Matrix4x4 matrix)
{
// System.Numerics row-vector storage is read as the transposed
// column-major matrix in GLSL. These are the same two clip gradients
// evaluated by acdreamShadowBiasScale.
float x = new Vector3(matrix.M11, matrix.M21, matrix.M31).Length();
float y = new Vector3(matrix.M12, matrix.M22, matrix.M32).Length();
return 0.5f * (x + y);
}
}

View file

@ -0,0 +1,896 @@
using System.Numerics;
using AcDream.App.Rendering.Scene;
using AcDream.Core.World;
namespace AcDream.App.Tests.Rendering;
public sealed class DirectionalShadowCasterFrameTests
{
[Fact]
public void Build_IncludesEveryHeadlineProjectionClassAndExcludesTrueTransparency()
{
RenderProjectionRecord[] statics =
[
Record(1, RenderProjectionClass.OutdoorStatic),
Record(2, RenderProjectionClass.OutdoorStatic, building: true),
Record(3, RenderProjectionClass.ActiveAnimatedStatic),
Record(4, RenderProjectionClass.OutdoorStatic,
extraFlags: RenderProjectionFlags.Translucent),
Record(5, RenderProjectionClass.OutdoorStatic, parentCell: 0x12340101),
];
RenderProjectionRecord[] dynamics =
[
Record(6, RenderProjectionClass.LiveDynamicRoot),
Record(7, RenderProjectionClass.EquippedChild),
];
var source = new QuerySource(statics, dynamics);
var frame = new DirectionalShadowCasterFrame();
frame.Build(new RenderSceneQuery(source, Generation));
DirectionalShadowCasterKind[] kinds = frame.Casters
.ToArray()
.Select(static value => value.Kind)
.ToArray();
Assert.Contains(DirectionalShadowCasterKind.OutdoorStatic, kinds);
Assert.Contains(DirectionalShadowCasterKind.Building, kinds);
Assert.Contains(DirectionalShadowCasterKind.AnimatedStatic, kinds);
Assert.Contains(DirectionalShadowCasterKind.LiveDynamic, kinds);
Assert.Contains(DirectionalShadowCasterKind.EquippedChild, kinds);
Assert.Equal(5, frame.Casters.Length);
Assert.Equal(1, frame.Stats.RejectedTransparent);
Assert.Equal(1, frame.Stats.RejectedIndoor);
Assert.True(frame.Casters[2].UsesCurrentAnimatedTransforms);
}
[Fact]
public void Build_ReportsEveryAuthoritativeCasterClassAndPreservesCountsOnStableFrame()
{
RenderProjectionRecord[] statics =
[
Record(101, RenderProjectionClass.OutdoorStatic),
Record(102, RenderProjectionClass.OutdoorStatic, building: true),
Record(103, RenderProjectionClass.ActiveAnimatedStatic),
];
RenderProjectionRecord[] dynamics =
[
Record(104, RenderProjectionClass.LiveDynamicRoot,
casterIdentity: RenderCasterIdentityKind.LocalPlayer),
Record(105, RenderProjectionClass.LiveDynamicRoot,
casterIdentity: RenderCasterIdentityKind.RemotePlayer),
Record(106, RenderProjectionClass.LiveDynamicRoot,
casterIdentity: RenderCasterIdentityKind.NonPlayerCreature),
Record(107, RenderProjectionClass.LiveDynamicRoot,
casterIdentity: RenderCasterIdentityKind.OtherLiveDynamic),
Record(108, RenderProjectionClass.EquippedChild,
casterIdentity: RenderCasterIdentityKind.EquippedChild),
];
var source = new QuerySource(statics, dynamics);
var frame = new DirectionalShadowCasterFrame();
frame.Build(new RenderSceneQuery(source, Generation));
DirectionalShadowCasterClassDiagnostics first = frame.Stats.CasterClasses;
Assert.Equal(0, first.TerrainCommands);
Assert.Equal(1, first.OutdoorStatics);
Assert.Equal(1, first.Buildings);
Assert.Equal(1, first.AnimatedStatics);
Assert.Equal(1, first.LocalPlayers);
Assert.Equal(1, first.RemotePlayers);
Assert.Equal(1, first.NonPlayerCreatures);
Assert.Equal(1, first.OtherLiveDynamics);
Assert.Equal(1, first.EquippedChildren);
frame.Build(new RenderSceneQuery(source, Generation));
Assert.Equal(first, frame.Stats.CasterClasses);
Assert.False(frame.Stats.TopologyRebuilt);
Assert.Equal(0, frame.Stats.Classifications);
}
[Fact]
public void Build_ReadsOnlyTwoResidentIndicesOnce_NoPViewCellOrCascadeRecull()
{
var source = new QuerySource(
[Record(20, RenderProjectionClass.OutdoorStatic)],
[Record(21, RenderProjectionClass.LiveDynamicRoot)]);
var frame = new DirectionalShadowCasterFrame();
frame.Build(new RenderSceneQuery(source, Generation));
Assert.Equal(1, source.IndexCountReads);
Assert.Equal(2, source.IndexCopies);
Assert.Equal(
[RenderSceneIndex.OutdoorStatic, RenderSceneIndex.OutdoorDynamic],
source.CopiedIndices);
Assert.Equal(0, source.CellQueries);
Assert.Equal(2, frame.Stats.IndexCopies);
}
[Fact]
public void UnchangedSecondFrame_ReusesSortedTopologyWithoutIndexCopiesOrClassification()
{
var source = new QuerySource(
[
Record(30, RenderProjectionClass.OutdoorStatic, sortKey: 30),
Record(31, RenderProjectionClass.ActiveAnimatedStatic, sortKey: 10),
],
[Record(32, RenderProjectionClass.LiveDynamicRoot, sortKey: 20)]);
var frame = new DirectionalShadowCasterFrame();
RenderSceneQuery query = new(source, Generation);
frame.Build(in query);
long retained = frame.RetainedScratchBytes;
ulong[] firstOrder = frame.Casters
.ToArray()
.Select(static value => value.Projection.Id.RawValue)
.ToArray();
int indexReads = source.IndexCountReads;
int indexCopies = source.IndexCopies;
frame.Build(in query);
Assert.Equal([31ul, 32ul, 30ul], firstOrder);
Assert.Equal([0, 1], frame.RefreshCasterSlots.ToArray());
Assert.Equal(retained, frame.RetainedScratchBytes);
Assert.Equal(1ul, frame.BuildSequence);
Assert.Equal(indexReads, source.IndexCountReads);
Assert.Equal(indexCopies, source.IndexCopies);
Assert.False(frame.Stats.TopologyRebuilt);
Assert.Equal(0, frame.Stats.IndexCopies);
Assert.Equal(0, frame.Stats.Classifications);
Assert.Equal(0, frame.Stats.DynamicTransformRefreshes);
Assert.Empty(frame.ChangedCasterPoses.ToArray());
}
[Fact]
public void TopologyRebuild_ReplacesRefreshSlotsAndRetainedAccountingIncludesThem()
{
RenderProjectionRecord[] statics =
[
Record(33, RenderProjectionClass.OutdoorStatic),
Record(34, RenderProjectionClass.OutdoorStatic),
Record(35, RenderProjectionClass.OutdoorStatic),
Record(36, RenderProjectionClass.OutdoorStatic),
];
var source = new QuerySource(statics, []);
var frame = new DirectionalShadowCasterFrame();
RenderSceneQuery query = new(source, Generation);
frame.Build(in query);
long staticRetainedBytes = frame.RetainedScratchBytes;
Assert.Empty(frame.RefreshCasterSlots.ToArray());
source.ReplaceStatics(
[
Record(33, RenderProjectionClass.ActiveAnimatedStatic),
Record(34, RenderProjectionClass.ActiveAnimatedStatic),
Record(35, RenderProjectionClass.ActiveAnimatedStatic),
Record(36, RenderProjectionClass.ActiveAnimatedStatic),
],
topologyChanged: true);
frame.Build(in query);
long animatedRetainedBytes = frame.RetainedScratchBytes;
Assert.True(animatedRetainedBytes > staticRetainedBytes);
Assert.Equal([0, 1, 2, 3], frame.RefreshCasterSlots.ToArray());
source.ReplaceStatics(statics, topologyChanged: true);
frame.Build(in query);
int projectionReads = source.ProjectionReads;
frame.Build(in query);
Assert.Empty(frame.RefreshCasterSlots.ToArray());
Assert.Equal(animatedRetainedBytes, frame.RetainedScratchBytes);
Assert.Equal(projectionReads, source.ProjectionReads);
Assert.Equal(0, frame.Stats.DynamicTransformRefreshes);
Assert.False(frame.Stats.TopologyRebuilt);
}
[Fact]
public void StableTopology_EmitsExactDynamicPoseWithoutOverwritingTopologyCaster()
{
RenderProjectionRecord original =
Record(40, RenderProjectionClass.LiveDynamicRoot);
var source = new QuerySource([], [original]);
var frame = new DirectionalShadowCasterFrame();
RenderSceneQuery query = new(source, Generation);
frame.Build(in query);
float rootX = BitConverter.Int32BitsToSingle(0x41234567);
float partY = BitConverter.Int32BitsToSingle(0x40ABCDEF);
Matrix4x4 root = Matrix4x4.CreateTranslation(rootX, 2f, 3f);
Matrix4x4 part = Matrix4x4.CreateTranslation(4f, partY, 6f);
RenderProjectionRecord moved = original with
{
Transform = new RenderTransform(root),
EntityPayload = original.EntityPayload with
{
MeshRefs = [new MeshRef(40, part)],
},
};
source.ReplaceDynamics([moved], topologyChanged: false);
frame.Build(in query);
RenderProjectionRecord retained = frame.Casters[0].Projection;
Assert.Equal(original.Transform, retained.Transform);
Assert.Same(
original.EntityPayload.MeshRefs,
retained.EntityPayload.MeshRefs);
DirectionalShadowChangedPose changed =
Assert.Single(frame.ChangedCasterPoses.ToArray());
Assert.Equal(0, changed.CasterIndex);
Assert.Equal(
BitConverter.SingleToInt32Bits(rootX),
BitConverter.SingleToInt32Bits(
changed.Snapshot.Transform.LocalToWorld.M41));
Assert.Equal(
BitConverter.SingleToInt32Bits(partY),
BitConverter.SingleToInt32Bits(
changed.Snapshot.EntityPayload.MeshRefs[0].PartTransform.M42));
Assert.Equal(1ul, frame.BuildSequence);
Assert.Equal(0, source.ProjectionReads);
}
[Fact]
public void StableTopology_RefreshesOnlyChangedCasterSlots()
{
RenderProjectionRecord first =
Record(41, RenderProjectionClass.LiveDynamicRoot);
RenderProjectionRecord second =
Record(42, RenderProjectionClass.EquippedChild);
var source = new QuerySource([], [first, second]);
var frame = new DirectionalShadowCasterFrame();
RenderSceneQuery query = new(source, Generation);
frame.Build(in query);
RenderProjectionRecord moved = second with
{
Transform = new RenderTransform(
Matrix4x4.CreateTranslation(420f, 2f, 3f)),
};
source.ReplaceDynamics([first, moved], topologyChanged: false);
frame.Build(in query);
Assert.Equal(0, source.ProjectionReads);
Assert.Equal(
[1],
frame.ChangedCasterPoses.ToArray()
.Select(static changed => changed.CasterIndex));
Assert.Equal(first.Transform, frame.Casters[0].Projection.Transform);
Assert.Equal(second.Transform, frame.Casters[1].Projection.Transform);
Assert.Equal(
moved.Transform,
frame.ChangedCasterPoses[0].Snapshot.Transform);
}
[Fact]
public void RepeatedSameId_ConsumesLatestJournalRecordWithoutSceneRead()
{
RenderProjectionRecord original =
Record(46, RenderProjectionClass.LiveDynamicRoot);
var source = new QuerySource([], [original]);
var frame = new DirectionalShadowCasterFrame();
RenderSceneQuery query = new(source, Generation);
frame.Build(in query);
RenderProjectionRecord intermediate = original with
{
Transform = new RenderTransform(
Matrix4x4.CreateTranslation(100f, 101f, 102f)),
EntityPayload = original.EntityPayload with
{
MeshRefs =
[
new MeshRef(
46,
Matrix4x4.CreateTranslation(103f, 104f, 105f)),
],
},
};
float rootX = BitConverter.Int32BitsToSingle(0x41234567);
float partY = BitConverter.Int32BitsToSingle(0x40ABCDEF);
RenderProjectionRecord latest = intermediate with
{
Transform = new RenderTransform(
Matrix4x4.CreateTranslation(rootX, 201f, 202f)),
EntityPayload = intermediate.EntityPayload with
{
MeshRefs =
[
new MeshRef(
46,
Matrix4x4.CreateTranslation(203f, partY, 205f)),
],
},
};
source.PublishTransformRecord(in intermediate);
source.PublishTransformRecord(in latest);
frame.Build(in query);
Assert.Equal(2, frame.Stats.CopiedTransformChanges);
Assert.Equal(1, frame.Stats.DedupedChangedCasterSlots);
Assert.Equal(0, source.ProjectionReads);
Assert.Equal(0, source.BatchedProjectionCopies);
Assert.Equal(original.Transform, frame.Casters[0].Projection.Transform);
DirectionalShadowTransformSnapshot current =
frame.ChangedCasterPoses[0].Snapshot;
Assert.Equal(
BitConverter.SingleToInt32Bits(rootX),
BitConverter.SingleToInt32Bits(current.Transform.LocalToWorld.M41));
Assert.Equal(
BitConverter.SingleToInt32Bits(partY),
BitConverter.SingleToInt32Bits(
current.EntityPayload.MeshRefs[0].PartTransform.M42));
}
[Fact]
public void TransformJournalOverflow_FallsBackToExactFullDynamicRefresh()
{
RenderProjectionRecord first =
Record(43, RenderProjectionClass.LiveDynamicRoot);
RenderProjectionRecord second =
Record(44, RenderProjectionClass.EquippedChild);
var source = new QuerySource([], [first, second]);
var frame = new DirectionalShadowCasterFrame();
RenderSceneQuery query = new(source, Generation);
frame.Build(in query);
source.PublishTransformChanges(
first.Id,
DirectionalShadowTransformChangeJournal.Capacity + 1);
frame.Build(in query);
Assert.Equal(0, source.ProjectionReads);
Assert.Equal(1, source.BatchedProjectionCopies);
Assert.Equal(
[0, 1],
frame.ChangedCasterPoses.ToArray()
.Select(static changed => changed.CasterIndex));
Assert.Equal(2, frame.Stats.DynamicTransformRefreshes);
}
[Fact]
public void DenseChangedSet_UsesExactBulkRefreshInsteadOfSparseDictionaryWalk()
{
var dynamics = new RenderProjectionRecord[100];
for (int index = 0; index < dynamics.Length; index++)
{
dynamics[index] = Record(
checked((ulong)(4_500 + index)),
RenderProjectionClass.ActiveAnimatedStatic);
}
var source = new QuerySource(dynamics, []);
var frame = new DirectionalShadowCasterFrame();
RenderSceneQuery query = new(source, Generation);
frame.Build(in query);
for (int index = 0; index < 75; index++)
source.PublishTransformChanges(dynamics[index].Id, 1);
frame.Build(in query);
Assert.True(frame.Stats.DensityBulkRefresh);
Assert.Equal(75, frame.Stats.CopiedTransformChanges);
Assert.Equal(75, frame.Stats.DynamicTransformRefreshes);
Assert.Equal(0, frame.Stats.BatchedProjectionCopyCalls);
Assert.Equal(0, source.BatchedProjectionCopies);
Assert.Equal(0, source.ProjectionReads);
}
[Fact]
public void RemovalAndRevisit_RebuildTopologyAndDiscardPriorTransformCursor()
{
RenderProjectionRecord original =
Record(45, RenderProjectionClass.LiveDynamicRoot);
var source = new QuerySource([], [original]);
var frame = new DirectionalShadowCasterFrame();
RenderSceneQuery query = new(source, Generation);
frame.Build(in query);
source.ReplaceDynamics([], topologyChanged: true);
frame.Build(in query);
Assert.Empty(frame.Casters.ToArray());
Assert.Equal(2ul, frame.BuildSequence);
RenderProjectionRecord revisited = original with
{
Transform = new RenderTransform(
Matrix4x4.CreateTranslation(450f, 2f, 3f)),
};
source.ReplaceDynamics([revisited], topologyChanged: true);
frame.Build(in query);
int projectionReads = source.ProjectionReads;
frame.Build(in query);
Assert.Equal(3ul, frame.BuildSequence);
Assert.Equal(revisited.Transform, frame.Casters[0].Projection.Transform);
Assert.Equal(projectionReads, source.ProjectionReads);
Assert.Empty(frame.ChangedCasterPoses.ToArray());
}
[Fact]
public void MembershipOrAppearanceRevision_RebuildsTopology()
{
RenderProjectionRecord original =
Record(50, RenderProjectionClass.OutdoorStatic);
var source = new QuerySource([original], []);
var frame = new DirectionalShadowCasterFrame();
RenderSceneQuery query = new(source, Generation);
frame.Build(in query);
RenderProjectionRecord changed = original with
{
EntityPayload = original.EntityPayload with
{
MeshRefs =
[
new MeshRef(51, original.EntityPayload.MeshRefs[0].PartTransform)
{
SurfaceOverrides = new Dictionary<uint, uint>
{
[0x08000001] = 0x05000001,
},
},
],
},
};
source.ReplaceStatics([changed], topologyChanged: true);
frame.Build(in query);
Assert.Equal(2ul, frame.BuildSequence);
Assert.True(frame.Stats.TopologyRebuilt);
Assert.Equal(2, frame.Stats.IndexCopies);
Assert.Equal(1, frame.Stats.Classifications);
Assert.Equal((uint)51, frame.Casters[0].Projection.EntityPayload.MeshRefs[0].GfxObjId);
}
[Fact]
public void WarmStableFrame_AllocatesZero()
{
var statics = new RenderProjectionRecord[9_500];
for (int index = 0; index < statics.Length; index++)
{
statics[index] = Record(
checked((ulong)(index + 60)),
RenderProjectionClass.OutdoorStatic);
}
var source = new QuerySource(
statics,
[Record(10_000, RenderProjectionClass.LiveDynamicRoot)]);
var frame = new DirectionalShadowCasterFrame();
RenderSceneQuery query = new(source, Generation);
frame.Build(in query);
frame.Build(in query);
int copies = source.IndexCopies;
long before = GC.GetAllocatedBytesForCurrentThread();
for (int iteration = 0; iteration < 256; iteration++)
frame.Build(in query);
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.Equal(0, allocated);
Assert.Equal(1ul, frame.BuildSequence);
Assert.Equal(copies, source.IndexCopies);
Assert.Equal(0, frame.Stats.Classifications);
Assert.Equal(0, frame.Stats.DynamicTransformRefreshes);
Assert.False(frame.Stats.TopologyRebuilt);
}
[Fact]
public void WarmDenseChangedFrames_AllocateZeroAndReadNoSceneRecords()
{
var dynamics = new RenderProjectionRecord[100];
for (int index = 0; index < dynamics.Length; index++)
{
dynamics[index] = Record(
checked((ulong)(20_000 + index)),
RenderProjectionClass.ActiveAnimatedStatic);
}
var source = new QuerySource(dynamics, []);
var frame = new DirectionalShadowCasterFrame();
RenderSceneQuery query = new(source, Generation);
frame.Build(in query);
source.EnsureTransformChangeCapacity(5_000);
for (int index = 0; index < 75; index++)
source.PublishTransformChanges(dynamics[index].Id, 1);
frame.Build(in query);
long before = GC.GetAllocatedBytesForCurrentThread();
for (int iteration = 0; iteration < 64; iteration++)
{
for (int index = 0; index < 75; index++)
source.PublishTransformChanges(dynamics[index].Id, 1);
frame.Build(in query);
}
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.Equal(0, allocated);
Assert.True(frame.Stats.DensityBulkRefresh);
Assert.Equal(75, frame.Stats.DynamicTransformRefreshes);
Assert.Equal(0, source.ProjectionReads);
Assert.Equal(0, source.BatchedProjectionCopies);
}
[Fact]
public void ProductionTopologyFingerprint_ExcludesPoseButIncludesGeometryAndSurfaceOverrides()
{
var entity = new WorldEntity
{
Id = 1,
SourceGfxObjOrSetupId = 0x02000001,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs =
[
new MeshRef(
0x01000001,
Matrix4x4.CreateTranslation(1f, 2f, 3f)),
],
};
RenderSceneHash128 original = CurrentRenderSceneOracle
.CreateDirectionalShadowTopologyFingerprint(entity);
entity.MeshRefs =
[
new MeshRef(
0x01000001,
Matrix4x4.CreateTranslation(10f, 20f, 30f)),
];
RenderSceneHash128 poseOnly = CurrentRenderSceneOracle
.CreateDirectionalShadowTopologyFingerprint(entity);
entity.MeshRefs =
[
new MeshRef(
0x01000002,
Matrix4x4.CreateTranslation(10f, 20f, 30f))
{
SurfaceOverrides = new Dictionary<uint, uint>
{
[0x08000001] = 0x05000001,
},
},
];
RenderSceneHash128 changed = CurrentRenderSceneOracle
.CreateDirectionalShadowTopologyFingerprint(entity);
Assert.Equal(original, poseOnly);
Assert.NotEqual(original, changed);
}
private static readonly RenderSceneGeneration Generation =
RenderSceneGeneration.FromRaw(9);
private static RenderProjectionRecord Record(
ulong id,
RenderProjectionClass projectionClass,
bool building = false,
RenderProjectionFlags extraFlags = RenderProjectionFlags.None,
uint parentCell = 0,
ulong? sortKey = null,
RenderCasterIdentityKind casterIdentity =
RenderCasterIdentityKind.Unclassified)
{
Matrix4x4 transform = Matrix4x4.CreateTranslation((float)id, 0f, 0f);
return new RenderProjectionRecord() with
{
Id = RenderProjectionId.FromRaw(id),
ProjectionClass = projectionClass,
OwnerIncarnation = RenderOwnerIncarnation.FromRaw(1),
Transform = new RenderTransform(transform),
PreviousTransform = new PreviousRenderTransform(transform),
MeshSet = new RenderMeshSet(RenderAssetHandle.FromRaw(id), 1, 1),
Material = new RenderMaterialVariant(0, 0, 1),
Residency = new RenderSpatialResidency(
RenderSpatialBucket.FromRaw(id),
0x1234FFFF,
parentCell),
Bounds = new RenderWorldBounds(Vector3.Zero, Vector3.One),
Flags = RenderProjectionFlags.Draw
| RenderProjectionFlags.SpatiallyResident
| extraFlags,
SortKey = new RenderSortKey(sortKey ?? id),
Source = new RenderSourceMetadata(
LocalEntityId: (uint)id,
ServerGuid: projectionClass is RenderProjectionClass.LiveDynamicRoot
or RenderProjectionClass.EquippedChild
? (uint)id
: 0,
SourceId: (uint)id,
ParentCellId: parentCell,
EffectCellId: 0,
BuildingShellAnchorCellId: 0,
TransformFingerprint: default,
GeometryFingerprint: default,
AppearanceFingerprint: default),
EntityPayload = new RenderEntityPayload(
[new MeshRef((uint)id, transform)],
PaletteOverride: null,
IsBuildingShell: building,
CasterIdentity: casterIdentity),
};
}
private sealed class QuerySource : IRenderSceneQuerySource
{
private RenderProjectionRecord[] _statics;
private RenderProjectionRecord[] _dynamics;
public QuerySource(
RenderProjectionRecord[] statics,
RenderProjectionRecord[] dynamics)
{
_statics = statics;
_dynamics = dynamics;
}
public int IndexCountReads { get; private set; }
public int IndexCopies { get; private set; }
public int CellQueries { get; private set; }
public int ProjectionReads { get; private set; }
public int BatchedProjectionCopies { get; private set; }
public ulong TopologyRevision { get; private set; } = 1;
public ulong TransformRevision { get; private set; } = 1;
public List<RenderSceneIndex> CopiedIndices { get; } = [];
private readonly List<DirectionalShadowTransformSnapshot>
_transformChanges = [];
public RenderProjectionCounts GetCounts(RenderSceneGeneration generation) =>
throw new InvalidOperationException("The caster product must not enumerate the whole scene.");
public RenderSceneIndexCounts GetIndexCounts(RenderSceneGeneration generation)
{
IndexCountReads++;
return new RenderSceneIndexCounts(
OutdoorStatic: _statics.Length,
IndoorCellStatic: 0,
Dynamic: _dynamics.Length,
OutdoorDynamic: _dynamics.Length,
PortalStraddlingDynamic: 0,
Translucent: 0,
Selectable: 0,
LightCandidate: 0,
Dirty: 0);
}
public ulong GetIndexRevision(RenderSceneGeneration generation) => 1;
public ulong GetDirectionalShadowTopologyRevision(
RenderSceneGeneration generation) => TopologyRevision;
public ulong GetDirectionalShadowTransformRevision(
RenderSceneGeneration generation) => TransformRevision;
public DirectionalShadowTransformChanges
CopyDirectionalShadowTransformChanges(
RenderSceneGeneration generation,
ulong afterRevision,
Span<DirectionalShadowTransformSnapshot> destination)
{
if (afterRevision == TransformRevision)
{
return new DirectionalShadowTransformChanges(
TransformRevision,
0,
false);
}
ulong delta = TransformRevision - afterRevision;
if (afterRevision == 0
|| afterRevision > TransformRevision
|| delta > (ulong)_transformChanges.Count
|| delta > (ulong)destination.Length)
{
return new DirectionalShadowTransformChanges(
TransformRevision,
0,
true);
}
int count = checked((int)delta);
int start = _transformChanges.Count - count;
for (int index = 0; index < count; index++)
destination[index] = _transformChanges[start + index];
return new DirectionalShadowTransformChanges(
TransformRevision,
count,
false);
}
public bool TryGet(
RenderSceneGeneration generation,
RenderProjectionId id,
out RenderProjectionRecord record)
{
ProjectionReads++;
for (int index = 0; index < _statics.Length; index++)
{
if (_statics[index].Id == id)
{
record = _statics[index];
return true;
}
}
for (int index = 0; index < _dynamics.Length; index++)
{
if (_dynamics[index].Id == id)
{
record = _dynamics[index];
return true;
}
}
record = default;
return false;
}
public int CopyById(
RenderSceneGeneration generation,
ReadOnlySpan<RenderProjectionId> ids,
Span<RenderProjectionRecord> destination)
{
BatchedProjectionCopies++;
if (destination.Length < ids.Length)
throw new ArgumentException("Destination is too small.", nameof(destination));
for (int outputIndex = 0; outputIndex < ids.Length; outputIndex++)
{
bool found = false;
for (int index = 0; index < _statics.Length; index++)
{
if (_statics[index].Id != ids[outputIndex])
continue;
destination[outputIndex] = _statics[index];
found = true;
break;
}
if (!found)
{
for (int index = 0; index < _dynamics.Length; index++)
{
if (_dynamics[index].Id != ids[outputIndex])
continue;
destination[outputIndex] = _dynamics[index];
found = true;
break;
}
}
if (!found)
throw new InvalidOperationException("Missing projection.");
}
return ids.Length;
}
public int CopyTo(
RenderSceneGeneration generation,
RenderProjectionClass? projectionClass,
Span<RenderProjectionRecord> destination) =>
throw new InvalidOperationException("The caster product must not enumerate the whole scene.");
public int CopyIndexTo(
RenderSceneGeneration generation,
RenderSceneIndex index,
Span<RenderProjectionRecord> destination)
{
IndexCopies++;
CopiedIndices.Add(index);
RenderProjectionRecord[] values = index switch
{
RenderSceneIndex.OutdoorStatic => _statics,
RenderSceneIndex.OutdoorDynamic => _dynamics,
_ => throw new InvalidOperationException(
$"Unexpected directional-shadow source index {index}."),
};
values.CopyTo(destination);
return values.Length;
}
public int GetCellCount(
RenderSceneGeneration generation,
uint fullCellId,
bool dynamic)
{
CellQueries++;
throw new InvalidOperationException("Directional shadows do not query PView cells.");
}
public int CopyCellTo(
RenderSceneGeneration generation,
uint fullCellId,
bool dynamic,
Span<RenderProjectionRecord> destination)
{
CellQueries++;
throw new InvalidOperationException("Directional shadows do not query PView cells.");
}
public void ReplaceStatics(
RenderProjectionRecord[] values,
bool topologyChanged)
{
_statics = values;
if (topologyChanged)
TopologyRevision++;
}
public void ReplaceDynamics(
RenderProjectionRecord[] values,
bool topologyChanged)
{
RenderProjectionRecord[] previous = _dynamics;
_dynamics = values;
if (topologyChanged)
TopologyRevision++;
else
{
for (int index = 0; index < values.Length; index++)
{
RenderProjectionRecord current = values[index];
RenderProjectionRecord prior = previous.FirstOrDefault(
value => value.Id == current.Id);
if (prior.Id == current.Id
&& prior.Transform == current.Transform
&& PartTransformsEqual(in prior, in current))
{
continue;
}
PublishTransformChanges(current.Id, 1);
}
}
}
public void PublishTransformChanges(RenderProjectionId id, int count)
{
ArgumentOutOfRangeException.ThrowIfNegative(count);
RenderProjectionRecord record = Find(id);
for (int index = 0; index < count; index++)
{
_transformChanges.Add(
DirectionalShadowTransformSnapshot.Capture(in record));
TransformRevision++;
}
}
public void PublishTransformRecord(in RenderProjectionRecord record)
{
_transformChanges.Add(
DirectionalShadowTransformSnapshot.Capture(in record));
TransformRevision++;
}
public void EnsureTransformChangeCapacity(int capacity) =>
_transformChanges.EnsureCapacity(capacity);
private RenderProjectionRecord Find(RenderProjectionId id)
{
for (int index = 0; index < _statics.Length; index++)
{
if (_statics[index].Id == id)
return _statics[index];
}
for (int index = 0; index < _dynamics.Length; index++)
{
if (_dynamics[index].Id == id)
return _dynamics[index];
}
throw new InvalidOperationException("Missing projection.");
}
private static bool PartTransformsEqual(
in RenderProjectionRecord left,
in RenderProjectionRecord right)
{
IReadOnlyList<MeshRef> leftMeshes = left.EntityPayload.MeshRefs;
IReadOnlyList<MeshRef> rightMeshes = right.EntityPayload.MeshRefs;
if (leftMeshes.Count != rightMeshes.Count)
return false;
for (int index = 0; index < leftMeshes.Count; index++)
{
if (leftMeshes[index].PartTransform
!= rightMeshes[index].PartTransform)
{
return false;
}
}
return true;
}
}
}

View file

@ -0,0 +1,214 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Packs;
using AcDream.Core.World;
namespace AcDream.App.Tests.Rendering;
public sealed class DirectionalShadowEnvironmentGateTests
{
[Theory]
[InlineData(false, false, false, DirectionalShadowGateReason.PackDisabled)]
[InlineData(true, true, false, DirectionalShadowGateReason.PortalOrLoginCover)]
[InlineData(true, false, true, DirectionalShadowGateReason.Indoor)]
internal void NonWorldGates_DisableWithoutCreatingCelestialWork(
bool enabled,
bool cover,
bool indoor,
DirectionalShadowGateReason expected)
{
DirectionalShadowEnvironmentInput input = new(
enabled,
cover,
indoor,
Celestial(AuthoredCelestialShadowSourceKind.SecondaryMoon, 35f),
Atmosphere(WeatherKind.Clear));
DirectionalShadowEnvironmentState result =
DirectionalShadowEnvironmentGate.Evaluate(
in input,
DirectionalShadowAtmospherePolicy.BuiltIn);
Assert.False(result.ShouldRender);
Assert.Equal(expected, result.Reason);
Assert.Equal(0f, result.Strength);
}
[Fact]
public void SelectedCelestialBelowHorizon_DisablesAndPreservesSourceIdentity()
{
AuthoredCelestialShadowSource source = Celestial(
AuthoredCelestialShadowSourceKind.SecondaryMoon,
-2f);
DirectionalShadowEnvironmentInput input = new(
true,
false,
false,
source,
Atmosphere(WeatherKind.Clear));
DirectionalShadowEnvironmentState result =
DirectionalShadowEnvironmentGate.Evaluate(
in input,
DirectionalShadowAtmospherePolicy.BuiltIn);
Assert.False(result.ShouldRender);
Assert.Equal(
DirectionalShadowGateReason.SelectedLightBelowHorizon,
result.Reason);
Assert.Equal(source.SurfaceToLightDirection, result.SurfaceToLightDirection);
Assert.Equal(source.Kind, result.SourceKind);
Assert.Equal(source.ObjectIndex, result.SourceObjectIndex);
Assert.Equal(source.GfxObjId, result.SourceGfxObjId);
}
[Fact]
public void NoVisibleCelestial_DisablesBeforeAtmosphereMapping()
{
DirectionalShadowEnvironmentInput input = new(
true,
false,
false,
AuthoredCelestialShadowSource.None(authoredEnergy: 1f),
Atmosphere(WeatherKind.Clear));
DirectionalShadowEnvironmentState result =
DirectionalShadowEnvironmentGate.Evaluate(
in input,
DirectionalShadowAtmospherePolicy.BuiltIn);
Assert.False(result.ShouldRender);
Assert.Equal(DirectionalShadowGateReason.NoVisibleCelestial, result.Reason);
Assert.Equal(AuthoredCelestialShadowSourceKind.None, result.SourceKind);
}
[Fact]
public void SelectedCelestialWithoutAuthoredEnergy_DisablesAndPreservesSourceIdentity()
{
AuthoredCelestialShadowSource source = Celestial(
AuthoredCelestialShadowSourceKind.DominantMoon,
35f,
energy: 0f);
DirectionalShadowEnvironmentInput input = new(
true,
false,
false,
source,
Atmosphere(WeatherKind.Clear));
DirectionalShadowEnvironmentState result =
DirectionalShadowEnvironmentGate.Evaluate(
in input,
DirectionalShadowAtmospherePolicy.BuiltIn);
Assert.False(result.ShouldRender);
Assert.Equal(
DirectionalShadowGateReason.SelectedLightHasNoEnergy,
result.Reason);
Assert.Equal(source.SurfaceToLightDirection, result.SurfaceToLightDirection);
Assert.Equal(source.Kind, result.SourceKind);
Assert.Equal(source.ObjectIndex, result.SourceObjectIndex);
Assert.Equal(source.GfxObjId, result.SourceGfxObjId);
}
[Fact]
public void AuthoredWeatherAndDayGroup_SoftenAndReduceButDoNotReplaceSelectedCelestial()
{
AuthoredCelestialShadowSource source = Celestial(
AuthoredCelestialShadowSourceKind.DominantMoon,
35f);
DirectionalShadowEnvironmentInput clearInput = new(
true,
false,
false,
source,
Atmosphere(WeatherKind.Clear),
ActiveDayGroupMultiplier: 1f);
DirectionalShadowEnvironmentInput rainInput = clearInput with
{
Atmosphere = Atmosphere(WeatherKind.Rain),
ActiveDayGroupMultiplier = 0.8f,
};
DirectionalShadowEnvironmentState clear =
DirectionalShadowEnvironmentGate.Evaluate(
in clearInput,
DirectionalShadowAtmospherePolicy.BuiltIn);
DirectionalShadowEnvironmentState rain =
DirectionalShadowEnvironmentGate.Evaluate(
in rainInput,
DirectionalShadowAtmospherePolicy.BuiltIn);
Assert.True(clear.ShouldRender);
Assert.True(rain.ShouldRender);
Assert.Equal(source.SurfaceToLightDirection, clear.SurfaceToLightDirection);
Assert.Equal(clear.SurfaceToLightDirection, rain.SurfaceToLightDirection);
Assert.Equal(AuthoredCelestialShadowSourceKind.DominantMoon, clear.SourceKind);
Assert.Equal(clear.SourceKind, rain.SourceKind);
Assert.Equal(source.ObjectIndex, rain.SourceObjectIndex);
Assert.Equal(source.GfxObjId, rain.SourceGfxObjId);
Assert.True(rain.Strength < clear.Strength);
Assert.True(rain.SoftnessMultiplier > clear.SoftnessMultiplier);
}
[Fact]
public void ZeroDayGroupPolicy_DisablesThroughExplicitAtmosphereMapping()
{
DirectionalShadowEnvironmentInput input = new(
true,
false,
false,
Celestial(AuthoredCelestialShadowSourceKind.Sun, 35f),
Atmosphere(WeatherKind.Clear),
ActiveDayGroupMultiplier: 0f);
DirectionalShadowEnvironmentState result =
DirectionalShadowEnvironmentGate.Evaluate(
in input,
DirectionalShadowAtmospherePolicy.BuiltIn);
Assert.Equal(DirectionalShadowGateReason.AtmosphereSuppressed, result.Reason);
Assert.False(result.ShouldRender);
}
private static AuthoredCelestialShadowSource Celestial(
AuthoredCelestialShadowSourceKind kind,
float elevationDegrees,
float energy = 1f)
{
float elevation = elevationDegrees * MathF.PI / 180f;
const float heading = 120f * MathF.PI / 180f;
float horizontal = MathF.Cos(elevation);
Vector3 direction = new(
horizontal * MathF.Cos(heading),
horizontal * MathF.Sin(heading),
MathF.Sin(elevation));
uint gfxObjId = kind switch
{
AuthoredCelestialShadowSourceKind.Sun =>
AuthoredCelestialShadowSourceResolver.SunGfxObjId,
AuthoredCelestialShadowSourceKind.DominantMoon =>
AuthoredCelestialShadowSourceResolver.DominantMoonGfxObjId,
AuthoredCelestialShadowSourceKind.SecondaryMoon =>
AuthoredCelestialShadowSourceResolver.SecondaryMoonGfxObjId,
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null),
};
return new AuthoredCelestialShadowSource(
Kind: kind,
ObjectIndex: 4,
GfxObjId: gfxObjId,
SurfaceToLightDirection: direction,
ElevationSin: direction.Z,
AuthoredEnergy: energy);
}
private static AtmosphereSnapshot Atmosphere(WeatherKind weather) => new(
weather,
Intensity: 1f,
FogColor: new Vector3(0.4f),
FogStart: 80f,
FogEnd: 350f,
FogMode.Linear,
LightningFlash: 0f,
EnvironOverride.None);
}

View file

@ -0,0 +1,795 @@
using System.Numerics;
using System.Runtime.InteropServices;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Packs;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Wb;
using AcDream.App.Tests.Rendering.Gpu;
using DatReaderWriter.Enums;
namespace AcDream.App.Tests.Rendering;
public sealed class DirectionalShadowGpuTests
{
[Fact]
public void CompleteCasterClassDiagnostics_AddsExactTerrainCommandCount()
{
DirectionalShadowCasterBuildStats stats = default;
stats = stats with
{
CasterClasses = new DirectionalShadowCasterClassDiagnostics(
TerrainCommands: 0,
OutdoorStatics: 2,
Buildings: 3,
AnimatedStatics: 4,
LocalPlayers: 5,
RemotePlayers: 6,
NonPlayerCreatures: 7,
OtherLiveDynamics: 8,
EquippedChildren: 9),
};
DirectionalShadowCasterClassDiagnostics completed =
DirectionalSunShadowRenderer.CompleteCasterClassDiagnostics(
in stats,
terrainCommandCount: 11);
Assert.Equal(11, completed.TerrainCommands);
Assert.Equal(stats.CasterClasses with { TerrainCommands = 11 }, completed);
}
[Fact]
public void Binding6HostLayout_MatchesCheckedInStd140Block()
{
Assert.Equal(336, DirectionalShadowUniforms.SizeInBytes);
Assert.Equal(DirectionalShadowUniforms.SizeInBytes, Marshal.SizeOf<DirectionalShadowUniforms>());
Assert.Equal(0, Offset(nameof(DirectionalShadowUniforms.WorldToClip0)));
Assert.Equal(64, Offset(nameof(DirectionalShadowUniforms.WorldToClip1)));
Assert.Equal(128, Offset(nameof(DirectionalShadowUniforms.WorldToClip2)));
Assert.Equal(192, Offset(nameof(DirectionalShadowUniforms.WorldToClip3)));
Assert.Equal(256, Offset(nameof(DirectionalShadowUniforms.SplitFarMeters)));
Assert.Equal(272, Offset(nameof(DirectionalShadowUniforms.Control)));
Assert.Equal(288, Offset(nameof(DirectionalShadowUniforms.BiasMeters)));
Assert.Equal(304, Offset(nameof(DirectionalShadowUniforms.TextureAndFlags)));
Assert.Equal(320, Offset(nameof(DirectionalShadowUniforms.LightDirectionAndSource)));
Assert.Equal(16, Marshal.SizeOf<UInt4>());
string common = File.ReadAllText(Path.Combine(
RepositoryRoot(),
"src", "AcDream.App", "Rendering", "Shaders",
"directional_shadow_common.glsl"));
Assert.Contains("ACDREAM_PACK_UBO_SET binding = 6", common, StringComparison.Ordinal);
Assert.Contains("mat4 uShadowWorldToClip[4]", common, StringComparison.Ordinal);
Assert.Contains("uvec4 uShadowTextureAndFlags", common, StringComparison.Ordinal);
Assert.Contains("vec4 uShadowLightDirectionAndSource", common, StringComparison.Ordinal);
}
[Fact]
public void ConservativeReceiverBias_UsesFarthestCascadeAndShaderScalesInnerMaps()
{
DirectionalShadowQuality quality = DirectionalShadowQuality.For(
DirectionalShadowPreset.Low);
var nearBias = new DirectionalShadowWorldBias(0.01f, 0.02f, 0.03f);
var farBias = new DirectionalShadowWorldBias(0.11f, 0.12f, 0.13f);
DirectionalShadowCascade[] cascades =
[
Cascade(0, 20f, nearBias),
Cascade(1, quality.MaximumReachMeters, farBias),
];
var environment = new DirectionalShadowEnvironmentState(
DirectionalShadowGateReason.Enabled,
Vector3.Normalize(new Vector3(0.3f, 0.4f, 0.8f)),
1f,
1f,
1f,
AuthoredCelestialShadowSourceKind.DominantMoon,
SourceObjectIndex: 2,
SourceGfxObjId: AuthoredCelestialShadowSourceResolver.DominantMoonGfxObjId);
DirectionalShadowUniforms uniforms = DirectionalShadowUniforms.Create(
cascades,
environment,
quality,
new GpuTextureSlot(7));
Assert.Equal(farBias.ConstantDepthMeters, uniforms.BiasMeters.X);
Assert.Equal(farBias.SlopeDepthMeters, uniforms.BiasMeters.Y);
Assert.Equal(farBias.NormalOffsetMeters, uniforms.BiasMeters.Z);
string receiver = File.ReadAllText(Path.Combine(
RepositoryRoot(),
"src", "AcDream.App", "Rendering", "Shaders",
"directional_shadow_receiver.glsl"));
Assert.Contains("farDensity / max(cascadeDensity, 1e-7)", receiver,
StringComparison.Ordinal);
Assert.Contains("uShadowBiasMeters.xyz * acdreamShadowBiasScale(cascade)", receiver,
StringComparison.Ordinal);
}
[Fact]
public void Uniforms_CarrySelectedCelestialDirectionAndSourceKind()
{
DirectionalShadowQuality quality = DirectionalShadowQuality.For(
DirectionalShadowPreset.Low);
DirectionalShadowCascade[] cascades =
[
Cascade(0, 20f, new DirectionalShadowWorldBias(0.01f, 0.02f, 0.03f)),
Cascade(1, quality.MaximumReachMeters,
new DirectionalShadowWorldBias(0.11f, 0.12f, 0.13f)),
];
Vector3 direction = Vector3.Normalize(new Vector3(0.3f, 0.4f, 0.8f));
var environment = new DirectionalShadowEnvironmentState(
DirectionalShadowGateReason.Enabled,
direction,
1f,
1f,
1f,
AuthoredCelestialShadowSourceKind.DominantMoon,
SourceObjectIndex: 2,
SourceGfxObjId: AuthoredCelestialShadowSourceResolver.DominantMoonGfxObjId);
DirectionalShadowUniforms uniforms = DirectionalShadowUniforms.Create(
cascades,
environment,
quality,
new GpuTextureSlot(7));
Assert.Equal(
direction,
new Vector3(
uniforms.LightDirectionAndSource.X,
uniforms.LightDirectionAndSource.Y,
uniforms.LightDirectionAndSource.Z));
Assert.Equal(
(float)AuthoredCelestialShadowSourceKind.DominantMoon,
uniforms.LightDirectionAndSource.W);
}
[Fact]
public void UniformReachAndTerminalFadeUseResidentClampedFinalSplit()
{
DirectionalShadowQuality quality = DirectionalShadowQuality.For(
DirectionalShadowPreset.Low);
DirectionalShadowCascade[] cascades =
[
Cascade(0, 18f, new DirectionalShadowWorldBias(0.01f, 0.02f, 0.03f)),
Cascade(1, 48f, new DirectionalShadowWorldBias(0.04f, 0.05f, 0.06f)),
];
DirectionalShadowUniforms uniforms = DirectionalShadowUniforms.Create(
cascades,
EnabledEnvironment(),
quality,
new GpuTextureSlot(7));
Assert.Equal(48f, uniforms.Control.Z);
Assert.Equal(1f, uniforms.Control.W);
}
[Fact]
public void MultiviewShadersSelectExactViewMatrixAndPreserveCutout()
{
string shaderRoot = Path.Combine(
RepositoryRoot(),
"src", "AcDream.App", "Rendering", "Shaders");
string vertex = File.ReadAllText(Path.Combine(
shaderRoot,
"directional_shadow_world_cutout_multiview.vert"));
string fragment = File.ReadAllText(Path.Combine(
shaderRoot,
"directional_shadow_world_cutout_multiview.frag"));
Assert.Contains("GL_EXT_multiview", vertex, StringComparison.Ordinal);
Assert.Contains("uShadowWorldToClip[int(gl_ViewIndex)]", vertex,
StringComparison.Ordinal);
Assert.Contains("Instances[instanceIndex].transform", vertex,
StringComparison.Ordinal);
Assert.Contains("texel.a < 0.05", fragment, StringComparison.Ordinal);
}
[Theory]
[InlineData(1)]
[InlineData(5)]
public void DirectionalDepthTarget_RejectsLayerCountsOutsideTwoThroughFour(int layers)
{
using var device = new RecordingGpuDevice();
Assert.Throws<ArgumentOutOfRangeException>(() =>
device.CreateDirectionalDepthTarget(
new GpuDirectionalDepthTargetDescription("bad", 1024, layers)));
}
[Fact]
public void DirectionalDepthTarget_ExposesOneSampleableArrayAndLayerPasses()
{
using var device = new RecordingGpuDevice();
using IGpuDirectionalDepthTarget target = device.CreateDirectionalDepthTarget(
new GpuDirectionalDepthTargetDescription("shadow", 1536, 3));
Assert.Equal(GpuTextureKind.Texture2DArray, target.DepthTexture.Kind);
Assert.Equal(3, target.DepthTexture.LayerCount);
Assert.Equal(1536, target.DepthTexture.Width);
using IGpuFrame frame = device.BeginFrame();
using (frame.BeginPass(GpuPassDescription.DirectionalDepth("cascade-2", target, 2)))
{
}
Assert.Throws<ArgumentOutOfRangeException>(() =>
frame.BeginPass(GpuPassDescription.DirectionalDepth("cascade-3", target, 3)));
}
[Fact]
public void DirectionalDepthMultiview_RequiresExactFullMaskAndDeviceCapability()
{
using var device = new RecordingGpuDevice();
using IGpuDirectionalDepthTarget target = device.CreateDirectionalDepthTarget(
new GpuDirectionalDepthTargetDescription("shadow", 1024, 2));
using IGpuFrame frame = device.BeginFrame();
using (frame.BeginPass(GpuPassDescription.DirectionalDepthMultiview(
"both-cascades", target, 0b11)))
{
}
Assert.Equal(0b11u, Assert.Single(device.OfKind<GpuRecordedPassBegin>()).ViewMask);
Assert.Throws<NotSupportedException>(() => frame.BeginPass(
GpuPassDescription.DirectionalDepthMultiview("partial", target, 0b01)));
using var unsupported = new RecordingGpuDevice
{
Capabilities = device.Capabilities with { SupportsMultiview = false },
};
using IGpuDirectionalDepthTarget unsupportedTarget = unsupported.CreateDirectionalDepthTarget(
new GpuDirectionalDepthTargetDescription("shadow", 1024, 2));
using IGpuFrame unsupportedFrame = unsupported.BeginFrame();
Assert.Throws<NotSupportedException>(() => unsupportedFrame.BeginPass(
GpuPassDescription.DirectionalDepthMultiview(
"unsupported", unsupportedTarget, 0b11)));
}
[Theory]
[InlineData(DirectionalShadowPreset.Low, 2, 768, true)]
[InlineData(DirectionalShadowPreset.Low, 2, 768, false)]
[InlineData(DirectionalShadowPreset.Medium, 3, 1536, false)]
[InlineData(DirectionalShadowPreset.High, 4, 2048, false)]
internal void Renderer_ReplaysOnePreparedProductAcrossEveryQualityCascade(
DirectionalShadowPreset preset,
int expectedCascades,
int expectedResolution,
bool multiviewCascades)
{
using var device = new RecordingGpuDevice();
int baselineSlots = device.LiveTextureSlotCount;
using var renderer = new DirectionalSunShadowRenderer(
device,
preset,
multiviewCascades: multiviewCascades);
Assert.Equal(baselineSlots + 1, device.LiveTextureSlotCount);
RecordingGpuDirectionalDepthTarget target = Assert.Single(device.CreatedDirectionalDepthTargets);
Assert.Equal(expectedCascades, target.Description.LayerCount);
Assert.Equal(expectedResolution, target.Description.Resolution);
Assert.All(device.CreatedPipelines, pipeline => Assert.False(pipeline.Description.HasColorAttachment));
DirectionalShadowPreparedDraws world = CreateWorldDraws(device.DefaultTextureSlot);
DirectionalShadowTerrainPreparedDraws terrain = CreateTerrainDraws();
using IGpuBuffer worldVertices = Buffer(device, "world-v", GpuBufferUsage.Vertex);
using IGpuBuffer worldIndices = Buffer(device, "world-i", GpuBufferUsage.Index);
using IGpuBuffer terrainVertices = Buffer(device, "terrain-v", GpuBufferUsage.Vertex);
using IGpuBuffer terrainIndices = Buffer(device, "terrain-i", GpuBufferUsage.Index);
var worldGeometry = new DirectionalShadowMeshGeometry(worldVertices, worldIndices);
var terrainGeometry = new DirectionalShadowTerrainGeometry(terrainVertices, terrainIndices);
var environment = new DirectionalShadowEnvironmentState(
DirectionalShadowGateReason.Enabled,
Vector3.Normalize(new Vector3(0.2f, 0.3f, 1f)),
0.94f,
0.8f,
1.25f,
AuthoredCelestialShadowSourceKind.SecondaryMoon,
SourceObjectIndex: 7,
SourceGfxObjId: AuthoredCelestialShadowSourceResolver.SecondaryMoonGfxObjId);
device.Clear();
using IGpuFrame frame = device.BeginFrame();
WorldTransformFrameSlice sharedTransforms = PublishSharedTransforms(
frame,
world.Transforms);
DirectionalSunShadowDiagnostics diagnostics = renderer.RenderPrepared(
frame,
environment,
Matrix4x4.Identity,
Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 16f / 9f, 0.1f, 500f),
cameraNearMeters: 0.1f,
casterDepthPaddingMeters: 48f,
world,
terrain,
worldGeometry,
terrainGeometry,
sharedTransforms);
int expectedDraws = (multiviewCascades ? 1 : expectedCascades) * 3;
Assert.Equal(expectedCascades, diagnostics.CascadeCount);
Assert.Equal(expectedDraws, diagnostics.DrawCalls);
Assert.Equal(environment.Strength, diagnostics.Strength);
Assert.Equal(1, diagnostics.WorldOpaqueCommands);
Assert.Equal(1, diagnostics.WorldAlphaCutoutCommands);
Assert.Equal(1, diagnostics.TerrainCommands);
Assert.Equal(1ul, diagnostics.WorldPreparationSequence);
Assert.Equal(1ul, diagnostics.TerrainPreparationSequence);
int expectedPasses = multiviewCascades ? 1 : expectedCascades;
Assert.Equal(expectedPasses, device.OfKind<GpuRecordedPassBegin>().Count());
Assert.Equal(expectedPasses, device.OfKind<GpuRecordedTimerScope>().Count());
Assert.Equal(expectedPasses, device.OfKind<GpuRecordedUniformBind>()
.Count(call => call.Binding == GpuBindingModel.UniformDirectionalShadow));
Assert.Equal(expectedDraws, device.OfKind<GpuRecordedMultiDrawIndirect>().Count());
Assert.Equal(2, device.OfKind<GpuRecordedRingAllocation>().Count());
GpuRecordedRingAllocation transformAllocation = Assert.Single(
device.OfKind<GpuRecordedRingAllocation>(),
call => call.Usage == GpuRingUsage.Storage
&& call.ByteCount == WorldTransformCapacityPolicy.InitialBindingSizeBytes);
RecordingGpuBuffer batchBuffer = Assert.Single(
device.CreatedBuffers,
buffer => buffer.Name == "directional-shadow-world-batches-1"
&& buffer.Usage.HasFlag(GpuBufferUsage.Storage)
&& buffer.Residency == GpuMemoryResidency.DeviceLocal);
Span<byte> batchBytes = stackalloc byte[32];
batchBuffer.Read(0, batchBytes);
ReadOnlySpan<uint> batchWords = MemoryMarshal.Cast<byte, uint>(batchBytes);
Assert.Equal(
0u,
batchWords[3]);
Assert.Equal(
DirectionalShadowBatchFlags.AlphaCutout,
batchWords[7]);
Assert.Contains(
device.CreatedBuffers,
buffer => buffer.Name == "directional-shadow-world-commands-1"
&& buffer.Usage.HasFlag(GpuBufferUsage.Indirect)
&& buffer.Residency == GpuMemoryResidency.DeviceLocal);
Assert.Contains(
device.CreatedBuffers,
buffer => buffer.Name == "directional-shadow-terrain-commands-1"
&& buffer.Usage.HasFlag(GpuBufferUsage.Indirect)
&& buffer.Residency == GpuMemoryResidency.DeviceLocal);
Assert.DoesNotContain(
device.OfKind<GpuRecordedRingAllocation>(),
call => call.Usage == GpuRingUsage.Storage
&& call.ByteCount == world.Transforms.Length * Marshal.SizeOf<Matrix4x4>());
Assert.All(
device.OfKind<GpuRecordedStorageBind>()
.Where(call => call.Binding == GpuBindingModel.StorageInstances),
call =>
{
Assert.Equal(transformAllocation.OffsetBytes, call.OffsetBytes);
Assert.Equal(WorldTransformCapacityPolicy.InitialBindingSizeBytes, call.SizeBytes);
});
for (int cascade = 0; cascade < (multiviewCascades ? 1 : expectedCascades); cascade++)
{
Assert.Contains(
device.OfKind<GpuRecordedPushConstants>(),
call => call.Constants.RenderPass == cascade);
}
if (multiviewCascades)
{
GpuRecordedPassBegin pass = Assert.Single(device.OfKind<GpuRecordedPassBegin>());
Assert.Equal(0b11u, pass.ViewMask);
Assert.Equal(
1,
device.OfKind<GpuRecordedPipelineBind>().Count(call =>
call.PipelineName == "directional-shadow-world-cutout-multiview"));
Assert.Contains(device.OfKind<GpuRecordedPipelineBind>(), call =>
call.PipelineName == "directional-shadow-world-opaque-multiview");
Assert.Contains(device.OfKind<GpuRecordedPipelineBind>(), call =>
call.PipelineName == "directional-shadow-terrain-multiview");
}
}
[Fact]
public void StableTopology_ReusesRetainedCommandBuffersWithoutFrameRingCopies()
{
using var device = new RecordingGpuDevice();
using var renderer = new DirectionalSunShadowRenderer(
device,
DirectionalShadowPreset.Low,
multiviewCascades: true);
DirectionalShadowPreparedDraws world = CreateWorldDraws(
device.DefaultTextureSlot);
DirectionalShadowTerrainPreparedDraws terrain = CreateTerrainDraws();
using IGpuBuffer worldVertices = Buffer(device, "world-v", GpuBufferUsage.Vertex);
using IGpuBuffer worldIndices = Buffer(device, "world-i", GpuBufferUsage.Index);
using IGpuBuffer terrainVertices = Buffer(device, "terrain-v", GpuBufferUsage.Vertex);
using IGpuBuffer terrainIndices = Buffer(device, "terrain-i", GpuBufferUsage.Index);
var worldGeometry = new DirectionalShadowMeshGeometry(
worldVertices,
worldIndices);
var terrainGeometry = new DirectionalShadowTerrainGeometry(
terrainVertices,
terrainIndices);
DirectionalShadowEnvironmentState environment = EnabledEnvironment();
using (IGpuFrame frame = device.BeginFrame())
{
renderer.RenderPrepared(
frame,
environment,
Matrix4x4.Identity,
Matrix4x4.CreatePerspectiveFieldOfView(1f, 1f, 0.1f, 500f),
0.1f,
48f,
world,
terrain,
worldGeometry,
terrainGeometry,
PublishSharedTransforms(frame, world.Transforms));
}
RecordingGpuBuffer[] retained = device.CreatedBuffers
.Where(buffer => buffer.Name.StartsWith(
"directional-shadow-",
StringComparison.Ordinal))
.ToArray();
Assert.Equal(3, retained.Length);
Assert.Equal(3, renderer.RetainedCommandBufferCount);
Assert.Equal(retained.Sum(buffer => buffer.SizeBytes),
renderer.RetainedCommandBufferBytes);
device.Clear();
int createdBefore = device.CreatedBuffers.Count;
using (IGpuFrame frame = device.BeginFrame())
{
renderer.RenderPrepared(
frame,
environment,
Matrix4x4.Identity,
Matrix4x4.CreatePerspectiveFieldOfView(1f, 1f, 0.1f, 500f),
0.1f,
48f,
world,
terrain,
worldGeometry,
terrainGeometry,
PublishSharedTransforms(frame, world.Transforms));
}
Assert.Equal(createdBefore, device.CreatedBuffers.Count);
Assert.All(retained, buffer => Assert.False(buffer.IsDisposed));
Assert.Equal(2, device.OfKind<GpuRecordedRingAllocation>().Count());
Assert.DoesNotContain(
device.OfKind<GpuRecordedRingAllocation>(),
allocation => allocation.Usage == GpuRingUsage.Indirect);
}
[Fact]
public void TopologyRebuild_SwapsRetainedBuffersAndDisposalReleasesTheCurrentSet()
{
using var device = new RecordingGpuDevice();
var renderer = new DirectionalSunShadowRenderer(
device,
DirectionalShadowPreset.Low);
DirectionalShadowPreparedDraws world = CreateWorldDraws(
device.DefaultTextureSlot);
DirectionalShadowTerrainPreparedDraws terrain = CreateTerrainDraws();
using IGpuBuffer worldVertices = Buffer(device, "world-v", GpuBufferUsage.Vertex);
using IGpuBuffer worldIndices = Buffer(device, "world-i", GpuBufferUsage.Index);
using IGpuBuffer terrainVertices = Buffer(device, "terrain-v", GpuBufferUsage.Vertex);
using IGpuBuffer terrainIndices = Buffer(device, "terrain-i", GpuBufferUsage.Index);
var worldGeometry = new DirectionalShadowMeshGeometry(
worldVertices,
worldIndices);
var terrainGeometry = new DirectionalShadowTerrainGeometry(
terrainVertices,
terrainIndices);
DirectionalShadowEnvironmentState environment = EnabledEnvironment();
using (IGpuFrame frame = device.BeginFrame())
{
renderer.RenderPrepared(
frame,
environment,
Matrix4x4.Identity,
Matrix4x4.CreatePerspectiveFieldOfView(1f, 1f, 0.1f, 500f),
0.1f,
48f,
world,
terrain,
worldGeometry,
terrainGeometry,
PublishSharedTransforms(frame, world.Transforms));
}
RecordingGpuBuffer[] firstSet = device.CreatedBuffers
.Where(buffer => buffer.Name.StartsWith(
"directional-shadow-",
StringComparison.Ordinal))
.ToArray();
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(3);
Assert.True(world.TryBegin(generation, 8, 1));
Matrix4x4 moved = Matrix4x4.CreateTranslation(20f, 30f, 40f);
world.Add(
30,
2,
9,
GpuTextureSlot.Unassigned,
0,
CullMode.Clockwise,
DirectionalShadowCasterMaterial.Opaque,
in moved);
DirectionalShadowPreparationStats stats = default;
world.Complete(generation, 8, in stats);
Assert.True(terrain.TryBegin(2, 1));
var terrainRange = new DirectionalShadowTerrainRange(80, 90);
terrain.Add(in terrainRange);
terrain.Complete(2);
using (IGpuFrame frame = device.BeginFrame())
{
renderer.RenderPrepared(
frame,
environment,
Matrix4x4.Identity,
Matrix4x4.CreatePerspectiveFieldOfView(1f, 1f, 0.1f, 500f),
0.1f,
48f,
world,
terrain,
worldGeometry,
terrainGeometry,
PublishSharedTransforms(frame, world.Transforms));
}
Assert.All(firstSet, buffer => Assert.True(buffer.IsDisposed));
RecordingGpuBuffer[] currentSet = device.CreatedBuffers
.Where(buffer => buffer.Name.EndsWith("-2", StringComparison.Ordinal))
.ToArray();
Assert.Equal(3, currentSet.Length);
Assert.All(currentSet, buffer => Assert.False(buffer.IsDisposed));
renderer.Dispose();
Assert.All(currentSet, buffer => Assert.True(buffer.IsDisposed));
}
[Theory]
[InlineData(DirectionalShadowGateReason.Indoor)]
[InlineData(DirectionalShadowGateReason.SelectedLightBelowHorizon)]
[InlineData(DirectionalShadowGateReason.SelectedLightHasNoEnergy)]
[InlineData(DirectionalShadowGateReason.NoVisibleCelestial)]
[InlineData(DirectionalShadowGateReason.PackDisabled)]
internal void DisabledEnvironment_RecordsNoPassOrUpload(DirectionalShadowGateReason reason)
{
using var device = new RecordingGpuDevice();
using var renderer = new DirectionalSunShadowRenderer(device, DirectionalShadowPreset.Low);
device.Clear();
using IGpuFrame frame = device.BeginFrame();
DirectionalSunShadowDiagnostics diagnostics = renderer.RenderPrepared(
frame,
new DirectionalShadowEnvironmentState(reason, Vector3.UnitZ, 0f, 0f, 1f),
Matrix4x4.Identity,
Matrix4x4.Identity,
0.1f,
48f,
new DirectionalShadowPreparedDraws(),
new DirectionalShadowTerrainPreparedDraws(),
null,
null,
default);
Assert.Equal(reason, diagnostics.GateReason);
Assert.Empty(device.OfKind<GpuRecordedPassBegin>());
Assert.Empty(device.OfKind<GpuRecordedRingAllocation>());
}
[Fact]
public void Disposal_ReleasesTextureSlotAndEveryOwnedResource()
{
using var device = new RecordingGpuDevice();
int baselineSlots = device.LiveTextureSlotCount;
var renderer = new DirectionalSunShadowRenderer(device, DirectionalShadowPreset.High);
RecordingGpuDirectionalDepthTarget target = Assert.Single(device.CreatedDirectionalDepthTargets);
RecordingGpuPipeline[] pipelines = device.CreatedPipelines.ToArray();
RecordingGpuSampler sampler = device.CreatedSamplers[^1];
Assert.Equal(GpuSamplerDescription.ShadowNearestClamp, sampler.Description);
Assert.Equal(GpuFilter.Nearest, sampler.Description.MinFilter);
Assert.Equal(GpuFilter.Nearest, sampler.Description.MagFilter);
Assert.Equal(GpuAddressMode.ClampToEdge, sampler.Description.AddressU);
Assert.Equal(GpuAddressMode.ClampToEdge, sampler.Description.AddressV);
renderer.Dispose();
Assert.Equal(baselineSlots, device.LiveTextureSlotCount);
Assert.True(target.IsDisposed);
Assert.True(sampler.IsDisposed);
Assert.All(pipelines, pipeline => Assert.True(pipeline.IsDisposed));
}
[Fact]
public void ConstructionFailure_RollsBackTargetSlotSamplerAndEarlierPipelines()
{
using var device = new RecordingGpuDevice();
int baselineSlots = device.LiveTextureSlotCount;
device.PipelineFailure = description =>
description.Name == "directional-shadow-world-opaque"
? new InvalidOperationException("injected pipeline failure")
: null;
InvalidOperationException failure = Assert.Throws<InvalidOperationException>(() =>
new DirectionalSunShadowRenderer(device, DirectionalShadowPreset.Medium));
Assert.Equal("injected pipeline failure", failure.Message);
Assert.Equal(baselineSlots, device.LiveTextureSlotCount);
Assert.True(Assert.Single(device.CreatedDirectionalDepthTargets).IsDisposed);
Assert.True(device.CreatedSamplers[^1].IsDisposed);
Assert.True(Assert.Single(device.CreatedPipelines).IsDisposed);
}
[Fact]
public void MultiviewConstructionFailure_RetiresAllOrdinaryAndLayeredCandidates()
{
using var device = new RecordingGpuDevice();
int baselineSlots = device.LiveTextureSlotCount;
device.PipelineFailure = description =>
description.Name == "directional-shadow-world-cutout-multiview"
? new InvalidOperationException("injected multiview failure")
: null;
Assert.Throws<InvalidOperationException>(() =>
new DirectionalSunShadowRenderer(
device,
DirectionalShadowPreset.Low,
multiviewCascades: true));
Assert.Equal(baselineSlots, device.LiveTextureSlotCount);
Assert.True(Assert.Single(device.CreatedDirectionalDepthTargets).IsDisposed);
Assert.True(device.CreatedSamplers[^1].IsDisposed);
Assert.Equal(5, device.CreatedPipelines.Count);
Assert.All(device.CreatedPipelines, pipeline => Assert.True(pipeline.IsDisposed));
}
[Fact]
public void RebuildAfterDisposal_UsesANewLiveShadowSampler()
{
using var device = new RecordingGpuDevice();
var first = new DirectionalSunShadowRenderer(device, DirectionalShadowPreset.Low);
RecordingGpuSampler firstSampler = device.CreatedSamplers[^1];
first.Dispose();
using var second = new DirectionalSunShadowRenderer(device, DirectionalShadowPreset.Low);
RecordingGpuSampler secondSampler = device.CreatedSamplers[^1];
Assert.NotSame(firstSampler, secondSampler);
Assert.True(firstSampler.IsDisposed);
Assert.False(secondSampler.IsDisposed);
Assert.Equal(GpuSamplerDescription.ShadowNearestClamp, secondSampler.Description);
}
[Fact]
public void ReceiverBinding_IsValidOnlyForTheProducingFrame()
{
using var device = new RecordingGpuDevice();
using var renderer = new DirectionalSunShadowRenderer(
device,
DirectionalShadowPreset.Low);
using (IGpuFrame frame = device.BeginFrame())
{
WorldTransformFrameSlice sharedTransforms = PublishSharedTransforms(
frame,
ReadOnlySpan<Matrix4x4>.Empty);
renderer.RenderPrepared(
frame,
new DirectionalShadowEnvironmentState(
DirectionalShadowGateReason.Enabled,
Vector3.UnitZ,
1f,
1f,
1f,
AuthoredCelestialShadowSourceKind.Sun,
SourceObjectIndex: 0,
SourceGfxObjId: AuthoredCelestialShadowSourceResolver.SunGfxObjId),
Matrix4x4.Identity,
Matrix4x4.CreatePerspectiveFieldOfView(1f, 1f, 0.1f, 100f),
0.1f,
48f,
new DirectionalShadowPreparedDraws(),
new DirectionalShadowTerrainPreparedDraws(),
null,
null,
sharedTransforms);
Assert.True(renderer.TryGetCurrentFrameBinding(frame, out var binding));
Assert.Equal(frame.Serial, binding.FrameSerial);
Assert.Equal((uint)DirectionalShadowUniforms.SizeInBytes, binding.SizeBytes);
Assert.Equal(2, binding.CascadeCount);
}
using IGpuFrame later = device.BeginFrame();
Assert.False(renderer.TryGetCurrentFrameBinding(later, out _));
}
private static DirectionalShadowPreparedDraws CreateWorldDraws(GpuTextureSlot cutoutSlot)
{
var draws = new DirectionalShadowPreparedDraws();
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(3);
Assert.True(draws.TryBegin(generation, 7, 2));
Matrix4x4 opaque = Matrix4x4.CreateTranslation(1f, 2f, 3f);
Matrix4x4 cutout = Matrix4x4.CreateRotationZ(0.3f) * Matrix4x4.CreateTranslation(4f, 5f, 6f);
draws.Add(0, 0, 6, GpuTextureSlot.Unassigned, 0, CullMode.CounterClockwise,
DirectionalShadowCasterMaterial.Opaque, in opaque);
draws.Add(6, 4, 12, cutoutSlot, 2, CullMode.None,
DirectionalShadowCasterMaterial.AlphaCutout, in cutout);
DirectionalShadowPreparationStats stats = default;
draws.Complete(generation, 7, in stats);
return draws;
}
private static DirectionalShadowCascade Cascade(
int index,
float splitFarMeters,
DirectionalShadowWorldBias bias) =>
new(
index,
index == 0 ? 0.1f : 20f,
splitFarMeters,
Matrix4x4.Identity,
Matrix4x4.Identity,
Matrix4x4.Identity,
Vector2.Zero,
10f,
0.1f,
48f,
bias);
private static DirectionalShadowEnvironmentState EnabledEnvironment() =>
new(
DirectionalShadowGateReason.Enabled,
Vector3.Normalize(new Vector3(0.2f, 0.3f, 1f)),
0.94f,
0.8f,
1.25f,
AuthoredCelestialShadowSourceKind.Sun,
SourceObjectIndex: 0,
SourceGfxObjId: AuthoredCelestialShadowSourceResolver.SunGfxObjId);
private static DirectionalShadowTerrainPreparedDraws CreateTerrainDraws()
{
var draws = new DirectionalShadowTerrainPreparedDraws();
Assert.True(draws.TryBegin(1, 1));
var range = new DirectionalShadowTerrainRange(20, 60);
draws.Add(in range);
draws.Complete(1);
return draws;
}
private static IGpuBuffer Buffer(RecordingGpuDevice device, string name, GpuBufferUsage usage) =>
device.CreateBuffer(new GpuBufferDescription(
name,
4096,
usage | GpuBufferUsage.TransferDestination,
GpuMemoryResidency.DeviceLocal));
private static WorldTransformFrameSlice PublishSharedTransforms(
IGpuFrame frame,
ReadOnlySpan<Matrix4x4> transforms)
{
GpuRingAllocation allocation = frame.AllocateRing(
checked((int)WorldTransformCapacityPolicy.InitialBindingSizeBytes),
GpuRingUsage.Storage);
if (!transforms.IsEmpty)
MemoryMarshal.AsBytes(transforms).CopyTo(allocation.Data);
return new WorldTransformFrameSlice(
frame.Serial,
allocation.Buffer,
allocation.OffsetBytes,
WorldTransformCapacityPolicy.InitialBindingSizeBytes,
FirstInstance: 0,
checked((uint)transforms.Length));
}
private static int Offset(string field) =>
checked((int)Marshal.OffsetOf<DirectionalShadowUniforms>(field));
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 repository root.");
}
}

View file

@ -0,0 +1,58 @@
using AcDream.App.Rendering;
namespace AcDream.App.Tests.Rendering;
public sealed class DirectionalShadowQualityTests
{
[Theory]
[InlineData(DirectionalShadowPreset.Low, 2, 768, 72, 4_718_592L)]
[InlineData(DirectionalShadowPreset.Medium, 3, 1536, 144, 28_311_552L)]
[InlineData(DirectionalShadowPreset.High, 4, 2048, 240, 67_108_864L)]
internal void Presets_PreserveHeadlineSemanticsAndPlanningEnvelope(
DirectionalShadowPreset preset,
int cascades,
int resolution,
float reach,
long expectedDepthBytes)
{
DirectionalShadowQuality quality = DirectionalShadowQuality.For(preset);
Assert.Equal(cascades, quality.CascadeCount);
Assert.Equal(resolution, quality.MapResolution);
Assert.Equal(reach, quality.MaximumReachMeters);
Assert.Equal(expectedDepthBytes, quality.ApproximateDepthMapBytes);
Assert.Equal(
DirectionalShadowSemantics.Headline,
quality.Semantics & DirectionalShadowSemantics.Headline);
Assert.True(quality.PackResidentGpuByteBudget >= quality.ApproximateDepthMapBytes);
Assert.True(quality.IncrementalGpuP50BudgetMilliseconds > 0);
Assert.True(quality.IncrementalCpuP50BudgetMilliseconds > 0);
}
[Fact]
public void BiasPolicy_ProducesFiniteWorldUnitOffsetsThatScaleWithTexelFootprint()
{
DirectionalShadowBiasPolicy policy =
DirectionalShadowQuality.For(DirectionalShadowPreset.Medium).BiasPolicy;
DirectionalShadowWorldBias near = policy.Resolve(0.02f);
DirectionalShadowWorldBias far = policy.Resolve(0.20f);
Assert.InRange(near.ConstantDepthMeters, policy.MinimumMeters, policy.MaximumMeters);
Assert.InRange(near.SlopeDepthMeters, policy.MinimumMeters, policy.MaximumMeters);
Assert.InRange(near.NormalOffsetMeters, policy.MinimumMeters, policy.MaximumMeters);
Assert.True(far.ConstantDepthMeters > near.ConstantDepthMeters);
Assert.True(far.SlopeDepthMeters > near.SlopeDepthMeters);
Assert.True(far.NormalOffsetMeters > near.NormalOffsetMeters);
}
[Theory]
[InlineData(DirectionalShadowPreset.Low)]
[InlineData(DirectionalShadowPreset.Medium)]
[InlineData(DirectionalShadowPreset.High)]
internal void Presets_KeepShaderPinnedMinimumWorldBias(
DirectionalShadowPreset preset) =>
Assert.Equal(
0.001f,
DirectionalShadowQuality.For(preset).BiasPolicy.MinimumMeters);
}

View file

@ -0,0 +1,137 @@
using System.Numerics;
using AcDream.App.Rendering;
namespace AcDream.App.Tests.Rendering;
public sealed class DirectionalShadowReceiverTests
{
[Theory]
[InlineData("atmospheric-world-hdr", false, true, false)]
[InlineData("atmospheric-world-hdr", true, false, false)]
[InlineData("vk-world", true, true, false)]
[InlineData("atmospheric-world-hdr", true, true, true)]
internal void RetailPipelineRemainsExactUnlessPackAndCurrentBindingAreActive(
string pass,
bool source,
bool binding,
bool expected) =>
Assert.Equal(
expected,
DirectionalShadowReceiverPolicy.ShouldSelectReceiverPipeline(
pass,
source,
binding));
[Fact]
public void CascadeTransition_IsContinuousAcrossTheSplitInWorldMetres()
{
Vector4 splits = new(10f, 30f, 60f, 100f);
DirectionalShadowCascadeBlend atSplit =
DirectionalShadowReceiverPolicy.SelectCascade(10f, splits, 4, 2f);
DirectionalShadowCascadeBlend afterSplit =
DirectionalShadowReceiverPolicy.SelectCascade(10.0001f, splits, 4, 2f);
Assert.Equal(0, atSplit.PrimaryCascade);
Assert.Equal(1, atSplit.SecondaryCascade);
Assert.Equal(1f, atSplit.SecondaryWeight, 5);
Assert.Equal(1, afterSplit.PrimaryCascade);
Assert.Equal(2, afterSplit.SecondaryCascade);
Assert.InRange(afterSplit.SecondaryWeight, 0f, 0.00001f);
Assert.True(atSplit.WithinShadowReach);
Assert.True(afterSplit.WithinShadowReach);
}
[Fact]
public void CascadeSelection_StopsAtConfiguredReach()
{
DirectionalShadowCascadeBlend outside =
DirectionalShadowReceiverPolicy.SelectCascade(
100.01f,
new Vector4(10f, 30f, 60f, 100f),
4,
2f);
Assert.False(outside.WithinShadowReach);
Assert.Equal(3, outside.PrimaryCascade);
}
[Fact]
public void BiasRemainsWorldMetresAndScalesWithSurfaceSlope()
{
var bias = new DirectionalShadowWorldBias(0.01f, 0.04f, 0.02f);
Assert.Equal(0.01f, DirectionalShadowReceiverPolicy.ReceiverBiasMeters(bias, 1f), 6);
Assert.Equal(0.05f, DirectionalShadowReceiverPolicy.ReceiverBiasMeters(bias, 0f), 6);
}
[Theory]
[InlineData(true, false, true, true)]
[InlineData(true, true, true, false)]
[InlineData(true, false, false, false)]
[InlineData(false, false, true, false)]
internal void IndoorAndMissingDirectionalTermsNeverSample(
bool binding,
bool indoor,
bool directional,
bool expected) =>
Assert.Equal(
expected,
DirectionalShadowReceiverPolicy.ShouldSample(
binding,
indoor,
directional));
[Fact]
public void ReceiverShadersPreserveAnimatedFoliageMaterialAndTransparencySemantics()
{
string root = RepositoryRoot();
string shaderRoot = Path.Combine(
root,
"src", "AcDream.App", "Rendering", "Shaders");
string vertex = File.ReadAllText(Path.Combine(shaderRoot, "mesh_atmospheric.vert"));
string fragment = File.ReadAllText(Path.Combine(shaderRoot, "mesh_atmospheric.frag"));
string terrain = File.ReadAllText(Path.Combine(shaderRoot, "terrain_atmospheric.frag"));
string receiver = File.ReadAllText(Path.Combine(
shaderRoot,
"directional_shadow_receiver.glsl"));
Assert.Contains("int transformIndex = gl_BaseInstanceARB + gl_InstanceID", vertex, StringComparison.Ordinal);
Assert.Contains("int instanceIndex = transformIndex - int(uTextureIndexB)", vertex, StringComparison.Ordinal);
Assert.Contains("Instances[transformIndex].transform", vertex, StringComparison.Ordinal);
Assert.Contains("instanceIndoor[instanceIndex]", vertex, StringComparison.Ordinal);
Assert.Contains("vSelectionLighting", fragment, StringComparison.Ordinal);
Assert.Contains("vOpacityMultiplier", fragment, StringComparison.Ordinal);
Assert.Contains("if (color.a < 0.05) discard", fragment, StringComparison.Ordinal);
Assert.Contains("ACDREAM_SAMPLE_ARRAY", fragment, StringComparison.Ordinal);
Assert.Contains("combineOverlays", terrain, StringComparison.Ordinal);
Assert.Contains("combineRoad", terrain, StringComparison.Ordinal);
Assert.Contains("smoothstep", receiver, StringComparison.Ordinal);
Assert.Contains("acdreamShadowBiasScale(cascade)", receiver, StringComparison.Ordinal);
Assert.Contains("textureGather", receiver, StringComparison.Ordinal);
Assert.Contains("acdreamShadowBilinearCompare", receiver, StringComparison.Ordinal);
Assert.Contains("float weight = float(2 - abs(x))", receiver, StringComparison.Ordinal);
Assert.Contains("float reachFade = 1.0 - smoothstep", receiver, StringComparison.Ordinal);
Assert.Contains("radius = clamp(radius, 0, 2)", receiver, StringComparison.Ordinal);
Assert.Contains("float softness = max(uShadowControl.y, 1.0)", receiver, StringComparison.Ordinal);
string detailVertex = File.ReadAllText(Path.Combine(
shaderRoot,
"mesh_detail.vert"));
Assert.Contains("int instanceIndex = transformIndex - int(uTextureIndexB)", detailVertex, StringComparison.Ordinal);
Assert.Contains("Instances[transformIndex].transform", detailVertex, StringComparison.Ordinal);
Assert.Contains("instanceDetailCategory[instanceIndex]", detailVertex, StringComparison.Ordinal);
}
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 repository root.");
}
}

View file

@ -0,0 +1,391 @@
using System.Numerics;
using System.Runtime.InteropServices;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Wb;
using AcDream.App.Tests.Rendering.Gpu;
namespace AcDream.App.Tests.Rendering;
public sealed class DirectionalShadowTransformBufferSetTests
{
[Fact]
public void FlightSlotsRetainStaticPrefix_AndWarmedReuseAllocatesAndWritesNothing()
{
using var device = new RecordingGpuDevice();
using var buffers = new DirectionalShadowTransformBufferSet(device);
Matrix4x4[] transforms =
[
Matrix4x4.CreateTranslation(1f, 2f, 3f),
Matrix4x4.CreateRotationZ(0.25f),
];
RecordingGpuBuffer slotZero;
using (IGpuFrame first = device.BeginFrame())
{
WorldTransformFrameSlice slice = buffers.Publish(
first,
topologyBuildSequence: 7,
transforms,
ReadOnlySpan<int>.Empty);
slotZero = Assert.IsType<RecordingGpuBuffer>(slice.Buffer);
Assert.Equal(0, first.SlotIndex);
Assert.Equal(WorldTransformCapacityPolicy.InitialBindingSizeBytes,
slice.BindingSizeBytes);
}
using (IGpuFrame second = device.BeginFrame())
{
WorldTransformFrameSlice slice = buffers.Publish(
second,
topologyBuildSequence: 7,
transforms,
ReadOnlySpan<int>.Empty);
Assert.Equal(1, second.SlotIndex);
Assert.NotSame(slotZero, slice.Buffer);
}
device.Clear();
int buffersBefore = device.CreatedBuffers.Count;
int uploadsBefore = slotZero.UploadCount;
using IGpuFrame warmed = device.BeginFrame();
Assert.Equal(0, warmed.SlotIndex);
buffers.Publish(
warmed,
topologyBuildSequence: 7,
transforms,
ReadOnlySpan<int>.Empty);
long before = GC.GetAllocatedBytesForCurrentThread();
for (int iteration = 0; iteration < 256; iteration++)
{
buffers.Publish(
warmed,
topologyBuildSequence: 7,
transforms,
ReadOnlySpan<int>.Empty);
}
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.Equal(0, allocated);
Assert.Equal(buffersBefore, device.CreatedBuffers.Count);
Assert.Equal(uploadsBefore, slotZero.UploadCount);
Assert.Empty(device.OfKind<GpuRecordedHostStorageVisibility>());
Assert.False(buffers.LastStats.TopologyUploaded);
Assert.Equal(0, buffers.LastStats.BytesWritten);
Assert.Equal(2, buffers.BufferCount);
Assert.Equal(
2L * WorldTransformCapacityPolicy.InitialBindingSizeBytes,
buffers.RetainedGpuBytes);
}
[Fact]
public void StableTopology_UpdatesOnlyStrictDynamicRangesWithExactMatrixBits()
{
using var device = new RecordingGpuDevice();
using var buffers = new DirectionalShadowTransformBufferSet(device);
Matrix4x4[] transforms =
[
Matrix4x4.Identity,
Matrix4x4.CreateTranslation(1f, 2f, 3f),
Matrix4x4.CreateTranslation(4f, 5f, 6f),
Matrix4x4.CreateTranslation(7f, 8f, 9f),
Matrix4x4.CreateTranslation(10f, 11f, 12f),
];
int[] dynamicSlots = [1, 2, 4];
using (IGpuFrame first = device.BeginFrame())
buffers.Publish(first, 11, transforms, dynamicSlots);
using (IGpuFrame second = device.BeginFrame())
buffers.Publish(second, 11, transforms, dynamicSlots);
float exactX = BitConverter.Int32BitsToSingle(0x41234567);
float exactY = BitConverter.Int32BitsToSingle(0x40ABCDEF);
transforms[1] = Matrix4x4.CreateRotationX(0.3f)
* Matrix4x4.CreateTranslation(exactX, 20f, 30f);
transforms[2] = Matrix4x4.CreateRotationY(0.4f)
* Matrix4x4.CreateTranslation(40f, exactY, 50f);
transforms[4] = Matrix4x4.CreateRotationZ(0.5f)
* Matrix4x4.CreateTranslation(60f, 70f, 80f);
device.Clear();
using IGpuFrame third = device.BeginFrame();
WorldTransformFrameSlice slice = buffers.Publish(
third,
11,
transforms,
dynamicSlots);
var retained = Assert.IsType<RecordingGpuBuffer>(slice.Buffer);
Matrix4x4[] readback = new Matrix4x4[transforms.Length];
retained.Read(0, MemoryMarshal.AsBytes(readback.AsSpan()));
AssertMatrixBitsEqual(transforms[0], readback[0]);
AssertMatrixBitsEqual(transforms[1], readback[1]);
AssertMatrixBitsEqual(transforms[2], readback[2]);
AssertMatrixBitsEqual(transforms[3], readback[3]);
AssertMatrixBitsEqual(transforms[4], readback[4]);
Assert.False(buffers.LastStats.TopologyUploaded);
Assert.Equal(3, buffers.LastStats.DynamicMatricesUpdated);
Assert.Equal(2, buffers.LastStats.DynamicRangesUpdated);
Assert.Equal(3 * 64, buffers.LastStats.BytesWritten);
Assert.Single(device.OfKind<GpuRecordedHostStorageVisibility>());
}
[Fact]
public void ChangedMatrix_ReplaysExactlyToEveryFlightSlotWithoutRescanningAllDynamics()
{
using var device = new RecordingGpuDevice();
using var buffers = new DirectionalShadowTransformBufferSet(device);
Matrix4x4[] transforms =
[
Matrix4x4.Identity,
Matrix4x4.CreateTranslation(1f, 2f, 3f),
Matrix4x4.CreateTranslation(4f, 5f, 6f),
];
int[] allDynamic = [1, 2];
using (IGpuFrame first = device.BeginFrame())
buffers.Publish(first, 12, transforms, [], allDynamic);
using (IGpuFrame second = device.BeginFrame())
buffers.Publish(second, 12, transforms, [], allDynamic);
float exact = BitConverter.Int32BitsToSingle(0x41234567);
transforms[2] = Matrix4x4.CreateRotationZ(0.25f)
* Matrix4x4.CreateTranslation(exact, 8f, 9f);
RecordingGpuBuffer slotZero;
using (IGpuFrame changed = device.BeginFrame())
{
slotZero = Assert.IsType<RecordingGpuBuffer>(buffers.Publish(
changed,
12,
transforms,
[2],
allDynamic).Buffer);
Assert.Equal(1, buffers.LastStats.DynamicMatricesUpdated);
}
device.Clear();
RecordingGpuBuffer slotOne;
using (IGpuFrame replay = device.BeginFrame())
{
slotOne = Assert.IsType<RecordingGpuBuffer>(buffers.Publish(
replay,
12,
transforms,
[],
allDynamic).Buffer);
Assert.Equal(1, buffers.LastStats.DynamicMatricesUpdated);
Assert.Equal(64, buffers.LastStats.BytesWritten);
Assert.Equal(0, buffers.LastStats.CurrentChangedMatrices);
Assert.Equal(1, buffers.LastStats.PendingReplayMatrices);
}
Matrix4x4[] zeroReadback = new Matrix4x4[3];
Matrix4x4[] oneReadback = new Matrix4x4[3];
slotZero.Read(0, MemoryMarshal.AsBytes(zeroReadback.AsSpan()));
slotOne.Read(0, MemoryMarshal.AsBytes(oneReadback.AsSpan()));
AssertMatrixBitsEqual(transforms[2], zeroReadback[2]);
AssertMatrixBitsEqual(transforms[2], oneReadback[2]);
Assert.Single(device.OfKind<GpuRecordedHostStorageVisibility>());
Assert.True(buffers.RetainedScratchBytes > 0);
}
[Fact]
public void RepeatedChanges_CoalesceToOneLatestValueForWaitingFlightSlot()
{
using var device = new RecordingGpuDevice();
using var buffers = new DirectionalShadowTransformBufferSet(device);
Matrix4x4[] transforms =
[
Matrix4x4.Identity,
Matrix4x4.CreateTranslation(1f, 2f, 3f),
];
int[] allDynamic = [1];
using (IGpuFrame first = device.BeginFrame())
buffers.Publish(first, 13, transforms, [], allDynamic);
using (IGpuFrame second = device.BeginFrame())
buffers.Publish(second, 13, transforms, [], allDynamic);
using (IGpuFrame current = device.BeginFrame())
{
transforms[1] = Matrix4x4.CreateTranslation(10f, 20f, 30f);
buffers.Publish(current, 13, transforms, [1], allDynamic);
transforms[1] = Matrix4x4.CreateTranslation(40f, 50f, 60f);
buffers.Publish(current, 13, transforms, [1], allDynamic);
}
using IGpuFrame waiting = device.BeginFrame();
WorldTransformFrameSlice slice = buffers.Publish(
waiting,
13,
transforms,
[],
allDynamic);
var retained = Assert.IsType<RecordingGpuBuffer>(slice.Buffer);
Matrix4x4[] readback = new Matrix4x4[2];
retained.Read(0, MemoryMarshal.AsBytes(readback.AsSpan()));
AssertMatrixBitsEqual(transforms[1], readback[1]);
Assert.Equal(1, buffers.LastStats.DynamicMatricesUpdated);
Assert.Equal(1, buffers.LastStats.PendingReplayMatrices);
Assert.Equal(64, buffers.LastStats.BytesWritten);
}
[Fact]
public void DenseRefresh_UploadsFourExactContiguousRangesAndReplaysDirectlyPerFlight()
{
using var device = new RecordingGpuDevice();
using var buffers = new DirectionalShadowTransformBufferSet(device);
var transforms = new Matrix4x4[2_000];
for (int index = 0; index < transforms.Length; index++)
transforms[index] = Matrix4x4.CreateTranslation(index, index + 1, index + 2);
int[] dynamicSlots = Enumerable.Range(0, 494)
.Concat(Enumerable.Range(500, 494))
.Concat(Enumerable.Range(1_000, 494))
.Concat(Enumerable.Range(1_500, 494))
.ToArray();
using (IGpuFrame first = device.BeginFrame())
buffers.Publish(first, 14, transforms, [], dynamicSlots, false);
using (IGpuFrame second = device.BeginFrame())
buffers.Publish(second, 14, transforms, [], dynamicSlots, false);
for (int index = 0; index < dynamicSlots.Length; index++)
{
int slot = dynamicSlots[index];
transforms[slot] = Matrix4x4.CreateTranslation(
slot + 10_000,
slot + 20_000,
slot + 30_000);
}
using (IGpuFrame dense = device.BeginFrame())
{
buffers.Publish(
dense,
14,
transforms,
dynamicSlots,
dynamicSlots,
denseRefresh: true);
Assert.True(buffers.LastStats.DenseDirectUpload);
Assert.False(buffers.LastStats.DenseFlightReplay);
Assert.Equal(1_976, buffers.LastStats.DynamicMatricesUpdated);
Assert.Equal(4, buffers.LastStats.DynamicRangesUpdated);
Assert.Equal(1_976 * 64, buffers.LastStats.BytesWritten);
Assert.Equal(0, buffers.LastStats.PendingReplayMatrices);
}
using IGpuFrame replay = device.BeginFrame();
WorldTransformFrameSlice replaySlice = buffers.Publish(
replay,
14,
transforms,
[],
dynamicSlots,
denseRefresh: false);
Assert.False(buffers.LastStats.DenseDirectUpload);
Assert.True(buffers.LastStats.DenseFlightReplay);
Assert.Equal(1_976, buffers.LastStats.DynamicMatricesUpdated);
Assert.Equal(4, buffers.LastStats.DynamicRangesUpdated);
Assert.Equal(1_976 * 64, buffers.LastStats.BytesWritten);
var retained = Assert.IsType<RecordingGpuBuffer>(replaySlice.Buffer);
var readback = new Matrix4x4[2_000];
retained.Read(0, MemoryMarshal.AsBytes(readback.AsSpan()));
AssertMatrixBitsEqual(transforms[0], readback[0]);
AssertMatrixBitsEqual(transforms[1_993], readback[1_993]);
AssertMatrixBitsEqual(transforms[1_999], readback[1_999]);
}
[Fact]
public void TopologyChange_CreateSwapsCurrentSlotAndDisposalReleasesEverySlot()
{
using var device = new RecordingGpuDevice();
var buffers = new DirectionalShadowTransformBufferSet(device);
Matrix4x4[] firstPose = [Matrix4x4.Identity];
RecordingGpuBuffer firstSlot;
RecordingGpuBuffer secondSlot;
using (IGpuFrame first = device.BeginFrame())
{
firstSlot = Assert.IsType<RecordingGpuBuffer>(buffers.Publish(
first, 1, firstPose, []).Buffer);
}
using (IGpuFrame second = device.BeginFrame())
{
secondSlot = Assert.IsType<RecordingGpuBuffer>(buffers.Publish(
second, 1, firstPose, []).Buffer);
}
Matrix4x4[] rebuiltPose =
[
Matrix4x4.CreateTranslation(9f, 8f, 7f),
Matrix4x4.CreateScale(2f),
];
RecordingGpuBuffer replacement;
using (IGpuFrame third = device.BeginFrame())
{
replacement = Assert.IsType<RecordingGpuBuffer>(buffers.Publish(
third, 2, rebuiltPose, []).Buffer);
}
Assert.True(firstSlot.IsDisposed);
Assert.False(secondSlot.IsDisposed);
Assert.False(replacement.IsDisposed);
Assert.True(buffers.LastStats.TopologyUploaded);
buffers.Dispose();
Assert.True(secondSlot.IsDisposed);
Assert.True(replacement.IsDisposed);
}
[Fact]
public void InvalidDynamicSlotsAndUnsupportedBindingFailBeforePublishing()
{
using var device = new RecordingGpuDevice();
using var buffers = new DirectionalShadowTransformBufferSet(device);
using IGpuFrame frame = device.BeginFrame();
Matrix4x4[] pose = [Matrix4x4.Identity, Matrix4x4.Identity];
Assert.Throws<InvalidOperationException>(() =>
buffers.Publish(frame, 1, pose, [1, 1]));
Assert.Throws<InvalidOperationException>(() =>
buffers.Publish(frame, 1, pose, [2]));
Assert.Throws<ArgumentOutOfRangeException>(() =>
buffers.Publish(
frame,
1,
pose,
[],
[],
denseRefresh: false,
bindingSizeBytes: 64u));
Assert.Equal(0, buffers.BufferCount);
Assert.Empty(device.OfKind<GpuRecordedHostStorageVisibility>());
}
[Fact]
public void ConnectedDenseDemandPublishesBeyondFormer65536CeilingInOneBinding()
{
using var device = new RecordingGpuDevice();
using var buffers = new DirectionalShadowTransformBufferSet(device);
var transforms = new Matrix4x4[68_395];
transforms[^1] = Matrix4x4.CreateTranslation(68_394f, 2f, 3f);
using IGpuFrame frame = device.BeginFrame();
WorldTransformFrameSlice slice = buffers.Publish(
frame,
topologyBuildSequence: 68_395,
transforms,
ReadOnlySpan<int>.Empty);
Assert.Equal(68_395u, slice.InstanceCount);
Assert.True(slice.IsValidFor(frame));
Assert.Equal(WorldTransformCapacityPolicy.InitialBindingSizeBytes,
slice.BindingSizeBytes);
Assert.Same(Assert.Single(device.CreatedBuffers), slice.Buffer);
}
private static void AssertMatrixBitsEqual(
Matrix4x4 expected,
Matrix4x4 actual)
{
ReadOnlySpan<byte> expectedBits = MemoryMarshal.AsBytes(
MemoryMarshal.CreateReadOnlySpan(ref expected, 1));
ReadOnlySpan<byte> actualBits = MemoryMarshal.AsBytes(
MemoryMarshal.CreateReadOnlySpan(ref actual, 1));
Assert.True(expectedBits.SequenceEqual(actualBits));
}
}

View file

@ -0,0 +1,57 @@
using AcDream.App.Rendering;
using Silk.NET.Maths;
using Silk.NET.Windowing;
namespace AcDream.App.Tests.Rendering;
public sealed class GameWindowStartupOptionsTests
{
[Fact]
public void OrdinaryStartupPreservesCurrentDecorated1280By720Window()
{
WindowOptions defaults = WindowOptions.DefaultVulkan;
WindowOptions options = GameWindow.CreateStartupWindowOptions(
exactAutomationFramebuffer: false,
persistedResolution: "3840x2160",
useVSync: true);
Assert.Equal(new Vector2D<int>(1280, 720), options.Size);
Assert.Equal(defaults.WindowBorder, options.WindowBorder);
Assert.Equal(defaults.IsVisible, options.IsVisible);
Assert.True(options.VSync);
}
[Theory]
[InlineData("2560x1440", 2560, 1440)]
[InlineData("3840x2160", 3840, 2160)]
public void ExactAutomationStartupUsesRequestedBorderlessClientExtent(
string resolution,
int width,
int height)
{
WindowOptions options = GameWindow.CreateStartupWindowOptions(
exactAutomationFramebuffer: true,
persistedResolution: resolution,
useVSync: false);
Assert.Equal(new Vector2D<int>(width, height), options.Size);
Assert.Equal(WindowBorder.Hidden, options.WindowBorder);
Assert.False(options.IsVisible);
Assert.False(options.VSync);
}
[Fact]
public void ExactAutomationStartupRejectsInvalidResolution()
{
InvalidOperationException error = Assert.Throws<InvalidOperationException>(() =>
GameWindow.CreateStartupWindowOptions(
exactAutomationFramebuffer: true,
persistedResolution: "invalid",
useVSync: false));
Assert.Equal(
"Exact automation framebuffer requires a valid persisted resolution.",
error.Message);
}
}

View file

@ -37,10 +37,9 @@ public sealed class GpuContractTests
[Fact]
public void StorageBindingsMatchTheShaderSources()
{
// mesh_modern.vert declares std430 bindings 0..8 in exactly this order.
// Binding 9 was the GL-only texture handle table added by slice V2;
// Campaign V slice V11 deleted it (StorageTextureTable) along with the
// rest of the raw-GL arm, so 9 is now one past the highest binding.
// mesh_modern.vert declares std430 bindings 0..8 in exactly this order;
// #226's mesh_detail.vert additionally declares binding 9 for the
// per-instance building category.
Assert.Equal(0u, GpuBindingModel.StorageInstances);
Assert.Equal(1u, GpuBindingModel.StorageBatches);
Assert.Equal(2u, GpuBindingModel.StorageClipRegions);
@ -50,7 +49,8 @@ public sealed class GpuContractTests
Assert.Equal(6u, GpuBindingModel.StorageInstanceIndoor);
Assert.Equal(7u, GpuBindingModel.StorageInstanceAlpha);
Assert.Equal(8u, GpuBindingModel.StorageInstanceSelectionLighting);
Assert.Equal(9u, GpuBindingModel.StorageBindingCount);
Assert.Equal(9u, GpuBindingModel.StorageInstanceDetailCategory);
Assert.Equal(10u, GpuBindingModel.StorageBindingCount);
}
[Fact]
@ -81,11 +81,12 @@ public sealed class GpuContractTests
// with only two — slice V4c found the gap. Mapping InvAlpha onto
// StraightAlpha would silently change how every inverse-alpha surface
// composites, so the contract has to carry all three.
Assert.Equal(4, Enum.GetValues<GpuBlendMode>().Length);
Assert.Equal(5, Enum.GetValues<GpuBlendMode>().Length);
Assert.Contains(GpuBlendMode.None, Enum.GetValues<GpuBlendMode>());
Assert.Contains(GpuBlendMode.StraightAlpha, Enum.GetValues<GpuBlendMode>());
Assert.Contains(GpuBlendMode.Additive, Enum.GetValues<GpuBlendMode>());
Assert.Contains(GpuBlendMode.InverseAlpha, Enum.GetValues<GpuBlendMode>());
Assert.Contains(GpuBlendMode.RetailDetail, Enum.GetValues<GpuBlendMode>());
}
[Fact]
@ -267,6 +268,27 @@ public sealed class GpuContractTests
Assert.True(aspects.HasFlag(Silk.NET.Vulkan.ImageAspectFlags.StencilBit));
}
[Fact]
public void Rgba16FloatRenderTarget_HasExactVulkanFormatAndByteAccounting()
{
Assert.Equal(
Silk.NET.Vulkan.Format.R16G16B16A16Sfloat,
VulkanTextureFormatMapping.FormatOf(
GpuTextureFormat.Rgba16FloatRenderTarget));
Assert.True(VulkanTextureFormatMapping.IsRenderTarget(
GpuTextureFormat.Rgba16FloatRenderTarget));
Assert.Equal(
8,
VulkanTextureFormatMapping.BytesPerTexel(
GpuTextureFormat.Rgba16FloatRenderTarget));
Assert.Equal(
1920 * 1080 * 8,
VulkanTextureFormatMapping.LevelSizeBytes(
GpuTextureFormat.Rgba16FloatRenderTarget,
1920,
1080));
}
[Fact]
public void UniformBindingsDoNotCollide()
{
@ -375,10 +397,17 @@ public sealed class GpuContractTests
MinUniformBufferOffsetAlignment = 256,
MaxClipDistances = GpuBindingModel.ClipPlanesPerSlot,
MaxSampleCount = 8,
MaxImageDimension2D = 16_384,
MaxImageArrayLayers = 2_048,
DeviceLocalMemoryBytes = 8UL * 1024 * 1024 * 1024,
SupportsMultiDrawIndirect = true,
SupportsDrawParameters = true,
SupportsTextureCompressionBc = true,
SupportsTimestampQueries = true,
SupportsPersistentlyMappedRings = true,
SupportsRgba16FloatRenderTargets = true,
MaxRgba16FloatSampleCount = 8,
SupportsSampledDepth = true,
SupportsMultiview = true,
};
}

View file

@ -18,10 +18,14 @@ internal sealed record GpuRecordedFrameEnd(long Serial) : GpuRecordedCall;
internal sealed record GpuRecordedRingAllocation(GpuRingUsage Usage, int ByteCount, uint OffsetBytes) : GpuRecordedCall;
internal sealed record GpuRecordedPassBegin(string Name, int SampleCount) : GpuRecordedCall;
internal sealed record GpuRecordedHostStorageVisibility(string BufferName) : GpuRecordedCall;
internal sealed record GpuRecordedPassBegin(string Name, int SampleCount, uint ViewMask = 0) : GpuRecordedCall;
internal sealed record GpuRecordedPassEnd(string Name) : GpuRecordedCall;
internal sealed record GpuRecordedTimerScope(string Name) : GpuRecordedCall;
internal sealed record GpuRecordedPipelineBind(string PipelineName) : GpuRecordedCall;
internal sealed record GpuRecordedStorageBind(uint Binding, string BufferName, uint OffsetBytes, uint SizeBytes)
@ -72,13 +76,25 @@ internal sealed record GpuRecordedTextureRegistration(string TextureName, GpuSam
internal sealed record GpuRecordedTextureRelease(uint Slot) : GpuRecordedCall;
internal sealed record GpuRecordedRenderTargetCreate(GpuRenderTargetDescription Description)
: GpuRecordedCall;
internal sealed record GpuRecordedDirectionalDepthTargetCreate(
GpuDirectionalDepthTargetDescription Description) : GpuRecordedCall;
internal sealed record GpuRecordedPipelineColorFormatAcquire(GpuTextureFormat Format)
: GpuRecordedCall;
internal sealed record GpuRecordedPipelineColorFormatRelease(GpuTextureFormat Format)
: GpuRecordedCall;
/// <summary>
/// In-memory <see cref="IGpuDevice"/> that owns no driver objects. Ring
/// allocations are backed by a real byte array, so a test can drive a renderer
/// and then read back exactly what it wrote — the same bytes a driver would have
/// seen. Everything else is recorded into <see cref="Calls"/> in submission order.
/// </summary>
internal sealed class RecordingGpuDevice : IGpuDevice
internal sealed class RecordingGpuDevice : IGpuDevice, IGpuPipelineFormatVariantHost
{
private const int DefaultRingCapacityBytes = 8 * 1024 * 1024;
@ -87,7 +103,10 @@ internal sealed class RecordingGpuDevice : IGpuDevice
private readonly List<RecordingGpuBuffer> _createdBuffers = [];
private readonly List<RecordingGpuPipeline> _createdPipelines = [];
private readonly List<RecordingGpuSampler> _createdSamplers = [];
private readonly List<RecordingGpuRenderTarget> _createdRenderTargets = [];
private readonly List<RecordingGpuDirectionalDepthTarget> _createdDirectionalDepthTargets = [];
private readonly Dictionary<GpuSamplerDescription, RecordingGpuSampler> _samplers = [];
private readonly Dictionary<GpuTextureFormat, int> _pipelineFormatLeases = [];
private readonly byte[] _ring;
private readonly Stack<uint> _freeTextureSlots = new();
@ -101,11 +120,13 @@ internal sealed class RecordingGpuDevice : IGpuDevice
{
ArgumentOutOfRangeException.ThrowIfLessThan(ringCapacityBytes, 1);
_ring = new byte[ringCapacityBytes];
RingBuffer = new RecordingGpuBuffer(new GpuBufferDescription(
"test-ring",
ringCapacityBytes,
GpuBufferUsage.Storage | GpuBufferUsage.Uniform | GpuBufferUsage.Indirect,
GpuMemoryResidency.HostWritable));
RingBuffer = new RecordingGpuBuffer(
new GpuBufferDescription(
"test-ring",
ringCapacityBytes,
GpuBufferUsage.Storage | GpuBufferUsage.Uniform | GpuBufferUsage.Indirect,
GpuMemoryResidency.HostWritable),
_ring);
RecordingGpuTexture placeholder = new("default-white", GpuTextureKind.Texture2D, GpuTextureFormat.Rgba8Unorm, 1, 1, 1, 1);
DefaultTextureSlot = RegisterTexture(placeholder, CreateSampler(GpuSamplerDescription.UiNearest));
@ -136,19 +157,29 @@ internal sealed class RecordingGpuDevice : IGpuDevice
MaxStorageBufferBindings = GpuBindingModel.StorageBindingCount,
MaxPushConstantBytes = GpuBindingModel.MaxPushConstantBytes,
MinStorageBufferOffsetAlignment = 256,
MaxStorageBufferRangeBytes = 128u * 1024u * 1024u,
MinUniformBufferOffsetAlignment = 256,
MaxClipDistances = GpuBindingModel.ClipPlanesPerSlot,
MaxSampleCount = 8,
MaxImageDimension2D = 16_384,
MaxImageArrayLayers = 2_048,
DeviceLocalMemoryBytes = 8UL * 1024 * 1024 * 1024,
SupportsMultiDrawIndirect = true,
SupportsDrawParameters = true,
SupportsTextureCompressionBc = true,
SupportsTimestampQueries = true,
SupportsPersistentlyMappedRings = true,
SupportsRgba16FloatRenderTargets = true,
MaxRgba16FloatSampleCount = 8,
SupportsSampledDepth = true,
SupportsMultiview = true,
};
public IGpuResourceRetirementQueue Retirement => ImmediateGpuResourceRetirementQueue.Instance;
public IGpuTimerPool Timers { get; } = new RecordingGpuTimerPool();
public RecordingGpuTimerPool RecordingTimers { get; } = new();
public IGpuTimerPool Timers => RecordingTimers;
public GpuTextureSlot DefaultTextureSlot { get; }
@ -160,6 +191,26 @@ internal sealed class RecordingGpuDevice : IGpuDevice
public IReadOnlyList<RecordingGpuSampler> CreatedSamplers => _createdSamplers;
public IReadOnlyList<RecordingGpuRenderTarget> CreatedRenderTargets => _createdRenderTargets;
public IReadOnlyList<RecordingGpuDirectionalDepthTarget> CreatedDirectionalDepthTargets =>
_createdDirectionalDepthTargets;
public IReadOnlyDictionary<GpuTextureFormat, int> PipelineFormatLeases =>
_pipelineFormatLeases;
/// <summary>
/// Optional deterministic allocation fault used to prove candidate target
/// sets roll back atomically. Returning null admits the allocation.
/// </summary>
public Func<GpuRenderTargetDescription, Exception?>? RenderTargetFailure { get; set; }
/// <summary>
/// Optional deterministic pipeline-construction fault used to prove that
/// renderers retire every partially-created resource.
/// </summary>
public Func<GpuPipelineDescription, Exception?>? PipelineFailure { get; set; }
public IGpuBuffer CreateBuffer(in GpuBufferDescription description)
{
var buffer = new RecordingGpuBuffer(description);
@ -193,11 +244,12 @@ internal sealed class RecordingGpuDevice : IGpuDevice
public IGpuSampler CreateSampler(in GpuSamplerDescription description)
{
if (_samplers.TryGetValue(description, out RecordingGpuSampler? existing))
if (_samplers.TryGetValue(description, out RecordingGpuSampler? existing)
&& !existing.IsDisposed)
return existing;
RecordingGpuSampler created = new(description);
_samplers.Add(description, created);
_samplers[description] = created;
_createdSamplers.Add(created);
return created;
}
@ -205,13 +257,78 @@ internal sealed class RecordingGpuDevice : IGpuDevice
public IGpuPipeline CreatePipeline(GpuPipelineDescription description)
{
ArgumentNullException.ThrowIfNull(description);
if (description.ViewMask != 0 && !Capabilities.SupportsMultiview)
throw new NotSupportedException("Multiview pipelines are unsupported.");
if (PipelineFailure?.Invoke(description) is { } failure)
throw failure;
var pipeline = new RecordingGpuPipeline(description);
_createdPipelines.Add(pipeline);
return pipeline;
}
public IGpuRenderTarget CreateRenderTarget(in GpuRenderTargetDescription description) =>
new RecordingGpuRenderTarget(description);
public IGpuRenderTarget CreateRenderTarget(in GpuRenderTargetDescription description)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.SampleCount);
if (description.SampleableDepth && description.DepthFormat is null)
throw new ArgumentException("SampleableDepth requires a depth format.", nameof(description));
if ((uint)description.SampleCount > Capabilities.MaxSampleCount)
throw new NotSupportedException("The requested sample count is unsupported.");
if (description.ColorFormat == GpuTextureFormat.Rgba16FloatRenderTarget
&& (!Capabilities.SupportsRgba16FloatRenderTargets
|| (uint)description.SampleCount > Capabilities.MaxRgba16FloatSampleCount))
{
throw new NotSupportedException("RGBA16F render-target capabilities are insufficient.");
}
if (description.SampleableDepth && !Capabilities.SupportsSampledDepth)
throw new NotSupportedException("Sampled depth is unsupported.");
if (RenderTargetFailure?.Invoke(description) is { } failure)
throw failure;
var target = new RecordingGpuRenderTarget(description);
_createdRenderTargets.Add(target);
_calls.Add(new GpuRecordedRenderTargetCreate(description));
return target;
}
public IGpuDirectionalDepthTarget CreateDirectionalDepthTarget(
in GpuDirectionalDepthTargetDescription description)
{
ArgumentException.ThrowIfNullOrWhiteSpace(description.Name);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.Resolution);
if (description.LayerCount is < 2 or > 4)
throw new ArgumentOutOfRangeException(nameof(description));
if (description.DepthFormat != GpuTextureFormat.Depth24Stencil8)
throw new ArgumentException("Directional depth requires Depth24Stencil8.", nameof(description));
if (!Capabilities.SupportsSampledDepth)
throw new NotSupportedException("Sampled depth is unsupported.");
var target = new RecordingGpuDirectionalDepthTarget(description);
_createdDirectionalDepthTargets.Add(target);
_calls.Add(new GpuRecordedDirectionalDepthTargetCreate(description));
return target;
}
public IDisposable AcquirePipelineColorFormat(GpuTextureFormat format)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (format == GpuTextureFormat.Rgba16FloatRenderTarget
&& !Capabilities.SupportsRgba16FloatRenderTargets)
throw new NotSupportedException("RGBA16F render targets are unsupported.");
_pipelineFormatLeases.TryGetValue(format, out int count);
_pipelineFormatLeases[format] = checked(count + 1);
_calls.Add(new GpuRecordedPipelineColorFormatAcquire(format));
return new RecordingPipelineColorFormatLease(this, format);
}
private void ReleasePipelineColorFormat(GpuTextureFormat format)
{
if (!_pipelineFormatLeases.TryGetValue(format, out int count))
return;
if (count == 1)
_pipelineFormatLeases.Remove(format);
else
_pipelineFormatLeases[format] = count - 1;
_calls.Add(new GpuRecordedPipelineColorFormatRelease(format));
}
public GpuTextureSlot RegisterTexture(IGpuTexture texture, IGpuSampler sampler)
{
@ -311,6 +428,16 @@ internal sealed class RecordingGpuDevice : IGpuDevice
private static uint AlignUp(uint value, uint alignment) =>
alignment <= 1 ? value : (value + alignment - 1) / alignment * alignment;
private sealed class RecordingPipelineColorFormatLease(
RecordingGpuDevice device,
GpuTextureFormat format) : IDisposable
{
private RecordingGpuDevice? _device = device;
public void Dispose() =>
Interlocked.Exchange(ref _device, null)?.ReleasePipelineColorFormat(format);
}
}
internal sealed class RecordingGpuFrame(RecordingGpuDevice device, long serial, int slotIndex) : IGpuFrame
@ -323,10 +450,77 @@ internal sealed class RecordingGpuFrame(RecordingGpuDevice device, long serial,
public GpuRingAllocation AllocateRing(int byteCount, GpuRingUsage usage) => device.Allocate(byteCount, usage);
public void PublishHostStorageWrites(IGpuBuffer buffer)
{
ArgumentNullException.ThrowIfNull(buffer);
if (buffer.Residency != GpuMemoryResidency.HostWritable
|| !buffer.Usage.HasFlag(GpuBufferUsage.Storage))
{
throw new ArgumentException(
"Published host writes require a host-writable storage buffer.",
nameof(buffer));
}
device.Record(new GpuRecordedHostStorageVisibility(buffer.Name));
}
public IGpuPassEncoder BeginPass(GpuPassDescription description)
{
ArgumentNullException.ThrowIfNull(description);
device.Record(new GpuRecordedPassBegin(description.Name, description.SampleCount));
if (!description.HasColorAttachment)
{
if (description.SampleCount != 1
|| description.Depth is not { DirectionalTarget: RecordingGpuDirectionalDepthTarget directionalTarget } depth)
{
throw new InvalidOperationException(
"A colour-less recording pass requires a single-sampled directional-depth target.");
}
if (depth.Layer < 0 || depth.Layer >= directionalTarget.Description.LayerCount)
throw new ArgumentOutOfRangeException(nameof(description));
if (description.ViewMask != 0)
{
uint expected = (1u << directionalTarget.Description.LayerCount) - 1u;
if (description.ViewMask != expected || !device.Capabilities.SupportsMultiview)
throw new NotSupportedException("Directional multiview requires every target layer and device support.");
}
if (depth.Store != GpuStoreOp.Store)
throw new InvalidOperationException("Directional depth must be stored for sampling.");
}
if (description.Color.Target is RecordingGpuRenderTarget target)
{
if (description.SampleCount != target.Description.SampleCount)
{
throw new InvalidOperationException(
"Pass and offscreen-target sample counts must match.");
}
if (target.UsesMultisampleResolve && description.Color.Store != GpuStoreOp.Resolve)
{
throw new InvalidOperationException(
"A multisampled offscreen target must resolve into ColorTexture.");
}
if (target.UsesMultisampleResolve && description.Color.Load == GpuLoadOp.Load)
{
throw new InvalidOperationException(
"A transient multisample colour attachment cannot load the prior resolved image.");
}
if (!target.UsesMultisampleResolve && description.Color.Store == GpuStoreOp.Resolve)
{
throw new InvalidOperationException(
"A single-sampled offscreen target cannot use Store=Resolve.");
}
if (target.UsesMultisampleResolve && description.Depth?.Load == GpuLoadOp.Load)
{
throw new InvalidOperationException(
"A transient multisample depth attachment cannot load prior depth.");
}
if (target.Description.SampleableDepth
&& description.Depth is { }
&& description.Depth?.Store != GpuStoreOp.Store)
{
throw new InvalidOperationException(
"Sampleable depth requires Store=Store.");
}
}
device.Record(new GpuRecordedPassBegin(description.Name, description.SampleCount, description.ViewMask));
return new RecordingGpuPassEncoder(device, description);
}
@ -351,6 +545,10 @@ internal sealed class RecordingGpuPassEncoder(RecordingGpuDevice device, GpuPass
public void BindPipeline(IGpuPipeline pipeline)
{
ArgumentNullException.ThrowIfNull(pipeline);
if (pipeline.Description.HasColorAttachment != Pass.HasColorAttachment)
throw new InvalidOperationException("Pipeline and pass colour-attachment intents must match.");
if (pipeline.Description.ViewMask != Pass.ViewMask)
throw new InvalidOperationException("Pipeline and pass view masks must match.");
device.Record(new GpuRecordedPipelineBind(pipeline.Description.Name));
}
@ -407,7 +605,11 @@ internal sealed class RecordingGpuPassEncoder(RecordingGpuDevice device, GpuPass
device.Record(new GpuRecordedMultiDrawIndirect(commands.Name, offsetBytes, drawCount, strideBytes));
}
public IDisposable BeginTimerScope(string scopeName) => NullDisposable.Instance;
public IDisposable BeginTimerScope(string scopeName)
{
device.Record(new GpuRecordedTimerScope(scopeName));
return NullDisposable.Instance;
}
public void Dispose()
{
@ -428,22 +630,50 @@ internal sealed class RecordingGpuPassEncoder(RecordingGpuDevice device, GpuPass
}
}
internal sealed class RecordingGpuBuffer(GpuBufferDescription description) : IGpuBuffer
internal sealed class RecordingGpuBuffer : IGpuBuffer
{
private readonly byte[] _storage = new byte[description.SizeBytes];
private readonly byte[] _storage;
public string Name { get; } = description.Name;
internal RecordingGpuBuffer(
GpuBufferDescription description,
byte[]? storage = null)
{
if (storage is not null && storage.Length != description.SizeBytes)
{
throw new ArgumentException(
"External recording storage must match the buffer size.",
nameof(storage));
}
_storage = storage ?? new byte[description.SizeBytes];
Name = description.Name;
SizeBytes = description.SizeBytes;
Usage = description.Usage;
Residency = description.Residency;
}
public long SizeBytes { get; } = description.SizeBytes;
public string Name { get; }
public GpuBufferUsage Usage { get; } = description.Usage;
public long SizeBytes { get; }
public GpuMemoryResidency Residency { get; } = description.Residency;
public GpuBufferUsage Usage { get; }
public GpuMemoryResidency Residency { get; }
public bool HostWritesAreCoherent =>
Residency == GpuMemoryResidency.HostWritable;
public bool IsDisposed { get; private set; }
public void Upload(long offsetBytes, ReadOnlySpan<byte> data) =>
public int UploadCount { get; private set; }
public long UploadedBytes { get; private set; }
public void Upload(long offsetBytes, ReadOnlySpan<byte> data)
{
data.CopyTo(_storage.AsSpan((int)offsetBytes, data.Length));
UploadCount++;
UploadedBytes = checked(UploadedBytes + data.Length);
}
public void CopyTo(IGpuBuffer destination, long sourceOffsetBytes, long destinationOffsetBytes, long byteCount)
{
@ -531,25 +761,95 @@ internal sealed class RecordingGpuRenderTarget : IGpuRenderTarget
description.Height,
layerCount: 1,
mipLevelCount: 1);
if (description.SampleableDepth && description.DepthFormat is { } depthFormat)
{
DepthTexture = new RecordingGpuTexture(
$"{description.Name}-depth",
GpuTextureKind.Texture2D,
depthFormat,
description.Width,
description.Height,
layerCount: 1,
mipLevelCount: 1);
}
}
public GpuRenderTargetDescription Description { get; }
public IGpuTexture ColorTexture { get; }
public IGpuTexture? DepthTexture { get; }
/// <summary>The pass attachment sample count; exposed textures are always single-sampled.</summary>
public int AttachmentSampleCount => Description.SampleCount;
public bool UsesMultisampleResolve => Description.SampleCount > 1;
public bool IsDisposed { get; private set; }
public void Dispose() => IsDisposed = true;
public void Dispose()
{
if (IsDisposed)
return;
IsDisposed = true;
ColorTexture.Dispose();
DepthTexture?.Dispose();
}
}
internal sealed class RecordingGpuDirectionalDepthTarget : IGpuDirectionalDepthTarget
{
public RecordingGpuDirectionalDepthTarget(GpuDirectionalDepthTargetDescription description)
{
Description = description;
DepthTexture = new RecordingGpuTexture(
$"{description.Name}-depth",
GpuTextureKind.Texture2DArray,
description.DepthFormat,
description.Resolution,
description.Resolution,
description.LayerCount,
mipLevelCount: 1);
}
public GpuDirectionalDepthTargetDescription Description { get; }
public IGpuTexture DepthTexture { get; }
public bool IsDisposed { get; private set; }
public void Dispose()
{
if (IsDisposed)
return;
IsDisposed = true;
DepthTexture.Dispose();
}
}
internal sealed class RecordingGpuTimerPool : IGpuTimerPool
{
public bool IsSupported => false;
private readonly Dictionary<string, double> _resolved = new(StringComparer.Ordinal);
public bool IsSupported => true;
internal void SetResolved(string scopeName, double milliseconds) =>
_resolved[scopeName] = milliseconds;
internal void ClearResolved() => _resolved.Clear();
public bool TryResolve(string scopeName, out double milliseconds)
{
milliseconds = 0d;
return false;
return _resolved.TryGetValue(scopeName, out milliseconds);
}
public bool TryTakeResolved(string scopeName, out double milliseconds)
{
if (!_resolved.TryGetValue(scopeName, out milliseconds))
return false;
_resolved.Remove(scopeName);
return true;
}
}

View file

@ -140,6 +140,20 @@ public sealed class RecordingGpuDeviceTests
Assert.Equal(0, device.OpenFrameCount);
}
[Fact]
public void TimerMeasurementsCanBeInspectedOrConsumedExactlyOnce()
{
using RecordingGpuDevice device = new();
device.RecordingTimers.SetResolved("pack-pass", 1.25);
Assert.True(device.Timers.TryResolve("pack-pass", out double inspected));
Assert.Equal(1.25, inspected);
Assert.True(device.Timers.TryTakeResolved("pack-pass", out double consumed));
Assert.Equal(1.25, consumed);
Assert.False(device.Timers.TryTakeResolved("pack-pass", out _));
Assert.False(device.Timers.TryResolve("pack-pass", out _));
}
[Fact]
public void ReleasedTextureSlotsAreRecycledRatherThanLeaked()
{
@ -177,6 +191,98 @@ public sealed class RecordingGpuDeviceTests
Assert.True(device.DefaultTextureSlot.IsAssigned);
}
[Fact]
public void MultisampledHdrTargetExposesSingleSampledColorAndOptionalDepthResults()
{
using RecordingGpuDevice device = new();
device.Clear();
var description = new GpuRenderTargetDescription(
"hdr-world",
1920,
1080,
GpuTextureFormat.Rgba16FloatRenderTarget,
GpuTextureFormat.Depth24Stencil8,
SampleCount: 4,
SampleableDepth: true);
var target = Assert.IsType<RecordingGpuRenderTarget>(
device.CreateRenderTarget(description));
Assert.Equal(description, target.Description);
Assert.Equal(GpuTextureFormat.Rgba16FloatRenderTarget, target.ColorTexture.Format);
Assert.NotNull(target.DepthTexture);
Assert.Equal(GpuTextureFormat.Depth24Stencil8, target.DepthTexture!.Format);
Assert.Equal(4, target.AttachmentSampleCount);
Assert.True(target.UsesMultisampleResolve);
Assert.Same(target, Assert.Single(device.CreatedRenderTargets));
Assert.Equal(
new GpuRecordedRenderTargetCreate(description),
Assert.Single(device.Calls));
using IGpuFrame frame = device.BeginFrame();
using (frame.BeginPass(new GpuPassDescription
{
Name = "hdr-world",
Color = new GpuColorAttachment(
target,
GpuLoadOp.Clear,
GpuStoreOp.Resolve,
Vector4.Zero),
Depth = new GpuDepthAttachment(
GpuLoadOp.Clear,
GpuStoreOp.Store,
1f,
0),
SampleCount = 4,
}))
{
}
frame.End();
target.Dispose();
Assert.True(target.IsDisposed);
Assert.True(Assert.IsType<RecordingGpuTexture>(target.ColorTexture).IsDisposed);
Assert.True(Assert.IsType<RecordingGpuTexture>(target.DepthTexture).IsDisposed);
}
[Fact]
public void AttachmentOnlyDepthIsNotExposedAndInvalidResolveContractsFailLoudly()
{
using RecordingGpuDevice device = new();
var target = Assert.IsType<RecordingGpuRenderTarget>(
device.CreateRenderTarget(new GpuRenderTargetDescription(
"ordinary-offscreen",
320,
240,
GpuTextureFormat.Rgba8UnormRenderTarget,
GpuTextureFormat.Depth24Stencil8,
SampleCount: 1)));
Assert.Null(target.DepthTexture);
using IGpuFrame frame = device.BeginFrame();
Assert.Throws<InvalidOperationException>(() => frame.BeginPass(new GpuPassDescription
{
Name = "invalid-resolve",
Color = new GpuColorAttachment(
target,
GpuLoadOp.Clear,
GpuStoreOp.Resolve,
Vector4.Zero),
SampleCount = 1,
}));
frame.End();
Assert.Throws<ArgumentException>(() => device.CreateRenderTarget(
new GpuRenderTargetDescription(
"missing-depth",
16,
16,
GpuTextureFormat.Rgba16FloatRenderTarget,
DepthFormat: null,
SampleCount: 1,
SampleableDepth: true)));
}
[Fact]
public void SamplersAreDeduplicatedByValue()
{

View file

@ -90,8 +90,30 @@ public sealed class VulkanCapabilityGateTests
Assert.True(record.IsSupported);
}
[Fact]
public void OptionalAtmosphericFormatsDoNotRejectTheRetailRenderer()
{
VulkanCapabilityRecord record = SupportedRecord(
formats: VulkanFormatSupport.Complete with
{
DepthStencilSampled = false,
Rgba16FloatColorAttachment = false,
Rgba16FloatSampled = false,
Rgba16FloatLinearFilter = false,
// A colour-only MSAA fact is not enough when sampling/filtering
// is absent; the neutral projection must still expose zero.
MaxRgba16FloatSampleCount = 8,
});
Assert.Empty(record.SupportFailures);
GpuCapabilityRecord projected = record.ToGpuCapabilityRecord();
Assert.False(projected.SupportsRgba16FloatRenderTargets);
Assert.Equal(0u, projected.MaxRgba16FloatSampleCount);
Assert.False(projected.SupportsSampledDepth);
}
/// <summary>
/// Every field on <see cref="VulkanDeviceFeatureSupport"/> is mandatory, so
/// Every required field on <see cref="VulkanDeviceFeatureSupport"/> is mandatory, so
/// clearing any one of them must produce exactly one new failure. Driving
/// this by reflection rather than by hand means a feature added to the record
/// without a matching Evaluate clause fails here instead of shipping
@ -103,6 +125,7 @@ public sealed class VulkanCapabilityGateTests
IEnumerable<string> featureNames = typeof(VulkanDeviceFeatureSupport)
.GetProperties()
.Where(property => property.PropertyType == typeof(bool))
.Where(property => property.Name != nameof(VulkanDeviceFeatureSupport.Multiview))
.Select(property => property.Name);
foreach (string name in featureNames)
@ -219,10 +242,11 @@ public sealed class VulkanCapabilityGateTests
Assert.False(VulkanPipelineLayouts.IsDynamicStorageBinding(GpuBindingModel.StorageGlobalLights));
Assert.False(VulkanPipelineLayouts.IsDynamicStorageBinding(GpuBindingModel.StorageClipRegions));
// Binding 9 (the GL-only uvec2 handle-table emulation, StorageTextureTable)
// is deleted as of Campaign V slice V11 — the Vulkan backend always bound
// set 2 instead and never touched it, so there is no longer a ninth
// binding to assert never spends a scarce dynamic descriptor.
// #226 adds binding 9 for the per-instance detail category. It is a
// plain storage descriptor because the renderer supplies its exact
// ring slice through the descriptor write rather than a dynamic offset.
Assert.False(VulkanPipelineLayouts.IsDynamicStorageBinding(
GpuBindingModel.StorageInstanceDetailCategory));
}
[Fact]
@ -250,6 +274,41 @@ public sealed class VulkanCapabilityGateTests
Assert.Empty(SupportedRecord().SupportFailures);
}
[Fact]
public void MultiviewIsProjectedButDoesNotRejectTheAuthoritativeRenderer()
{
VulkanDeviceFeatureSupport reduced =
VulkanDeviceFeatureSupport.Complete with { Multiview = false };
VulkanCapabilityRecord record = SupportedRecord(features: reduced);
Assert.True(record.IsSupported);
Assert.False(record.ToGpuCapabilityRecord().SupportsMultiview);
}
[Fact]
public void ADeviceWithTooFewTotalStorageDescriptorsIsRejectedPerSetAndPerStage()
{
VulkanCapabilityRecord perSet = SupportedRecord(
limits: VulkanDeviceLimitSupport.Complete with
{
MaxDescriptorSetStorageBuffers =
GpuBindingModel.StorageBindingCount - 1,
});
VulkanCapabilityRecord perStage = SupportedRecord(
limits: VulkanDeviceLimitSupport.Complete with
{
MaxPerStageDescriptorStorageBuffers =
GpuBindingModel.StorageBindingCount - 1,
});
Assert.Contains(
perSet.SupportFailures,
failure => failure.Contains("total storage bindings", StringComparison.Ordinal));
Assert.Contains(
perStage.SupportFailures,
failure => failure.Contains("each shader stage", StringComparison.Ordinal));
Assert.Empty(SupportedRecord().SupportFailures);
}
[Fact]
public void ATextureTableSmallerThanTheCapacityIsRejectedPerSetAndPerStage()
{
@ -546,8 +605,14 @@ public sealed class VulkanCapabilityGateTests
limits: VulkanDeviceLimitSupport.Complete with
{
MinStorageBufferOffsetAlignment = 16,
MaxStorageBufferRange = 192u * 1024u * 1024u,
MinUniformBufferOffsetAlignment = 64,
MaxColorSampleCount = 4,
MaxImageDimension2D = 8192,
MaxImageArrayLayers = 128,
DeviceLocalHeapBytes = 6UL * 1024 * 1024 * 1024,
MaxDescriptorSetStorageBuffers = 48,
MaxPerStageDescriptorStorageBuffers = 32,
MaxDescriptorSetUpdateAfterBindSampledImages = 500_000,
MaxPerStageDescriptorUpdateAfterBindSampledImages = 16_384,
});
@ -558,11 +623,15 @@ public sealed class VulkanCapabilityGateTests
Assert.Equal("AMD Radeon RX 9070 XT", projected.DeviceName);
Assert.Equal("Vulkan 1.3.280", projected.ApiVersion);
Assert.Equal(16u, projected.MinStorageBufferOffsetAlignment);
Assert.Equal(192u * 1024u * 1024u, projected.MaxStorageBufferRangeBytes);
Assert.Equal(64u, projected.MinUniformBufferOffsetAlignment);
Assert.Equal(4u, projected.MaxSampleCount);
Assert.Equal(8192u, projected.MaxImageDimension2D);
Assert.Equal(128u, projected.MaxImageArrayLayers);
Assert.Equal(6UL * 1024 * 1024 * 1024, projected.DeviceLocalMemoryBytes);
// The table is limited by whichever of the two counts is smaller.
Assert.Equal(16_384u, projected.MaxTextureTableSlots);
Assert.Equal(GpuBindingModel.StorageBindingCount, projected.MaxStorageBufferBindings);
Assert.Equal(32u, projected.MaxStorageBufferBindings);
Assert.True(projected.SupportsMultiDrawIndirect);
Assert.True(projected.SupportsDrawParameters);
Assert.True(projected.SupportsTextureCompressionBc);
@ -570,6 +639,10 @@ public sealed class VulkanCapabilityGateTests
// The whole point of the campaign's CPU target: per-frame data written
// straight into mapped memory rather than copied through BufferSubData.
Assert.True(projected.SupportsPersistentlyMappedRings);
Assert.True(projected.SupportsRgba16FloatRenderTargets);
Assert.Equal(4u, projected.MaxRgba16FloatSampleCount);
Assert.True(projected.SupportsSampledDepth);
Assert.True(projected.SupportsMultiview);
Assert.Empty(projected.SupportFailures);
}

View file

@ -0,0 +1,27 @@
using AcDream.App.Rendering.Gpu.Vk;
namespace AcDream.App.Tests.Rendering.Gpu.Vk;
public sealed class VulkanDirectionalMultiviewContractTests
{
[Fact]
public void LowMaskCoversBothAttachmentLayersInOneBarrierRange()
{
VulkanDirectionalMultiviewRange range =
VulkanDirectionalMultiviewContract.Resolve(0b11, 2);
Assert.Equal(0u, range.BaseLayer);
Assert.Equal(2u, range.LayerCount);
}
[Theory]
[InlineData(0u)]
[InlineData(0b01u)]
[InlineData(0b10u)]
[InlineData(0b111u)]
public void PartialOrExtraMaskFailsBeforeRecording(uint viewMask)
{
Assert.Throws<NotSupportedException>(() =>
VulkanDirectionalMultiviewContract.Resolve(viewMask, 2));
}
}

View file

@ -0,0 +1,52 @@
using AcDream.App.Rendering.Gpu.Vk;
namespace AcDream.App.Tests.Rendering.Gpu.Vk;
public sealed class VulkanDrawBindingStateTests
{
[Fact]
public void FirstDrawBinds_IdenticalDrawReuses_ChangedInputsRebind()
{
var state = new VulkanDrawBindingState();
Assert.True(state.RequiresBind(pipelineLayout: 10, packGeneration: 3));
state.MarkBound(pipelineLayout: 10, packGeneration: 3);
Assert.False(state.RequiresBind(pipelineLayout: 10, packGeneration: 3));
state.MarkDirty();
Assert.True(state.RequiresBind(pipelineLayout: 10, packGeneration: 3));
state.MarkBound(pipelineLayout: 10, packGeneration: 3);
Assert.True(state.RequiresBind(pipelineLayout: 11, packGeneration: 3));
Assert.True(state.RequiresBind(pipelineLayout: 10, packGeneration: 4));
}
[Fact]
public void PackAndRetailLayoutsCannotReuseEachOthersBinding()
{
var state = new VulkanDrawBindingState();
state.MarkBound(pipelineLayout: 20, packGeneration: 7);
Assert.True(state.RequiresBind(pipelineLayout: 21, packGeneration: 0));
state.MarkBound(pipelineLayout: 21, packGeneration: 0);
Assert.True(state.RequiresBind(pipelineLayout: 20, packGeneration: 7));
}
[Fact]
public void WarmChecksAllocateZero()
{
var state = new VulkanDrawBindingState();
state.MarkBound(pipelineLayout: 30, packGeneration: 8);
long before = GC.GetAllocatedBytesForCurrentThread();
for (int iteration = 0; iteration < 10_000; iteration++)
{
Assert.False(state.RequiresBind(
pipelineLayout: 30,
packGeneration: 8));
}
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.Equal(0, allocated);
}
}

View file

@ -0,0 +1,61 @@
using System.Reflection;
using System.Reflection.Emit;
using AcDream.App.Rendering.Gpu.Vk;
using AcDream.App.Tests.Architecture;
namespace AcDream.App.Tests.Rendering.Gpu.Vk;
public sealed class VulkanGraphicsContextAcquisitionTests
{
[Fact]
public void ProductionAcquireProbesSelectedPhysicalDeviceBeforeLogicalDeviceCreation()
{
MethodInfo acquire = RequiredMethod(nameof(VulkanGraphicsContext.Acquire));
IReadOnlyList<CompiledCall> acquireCalls = CompiledCallGraph.Read(acquire);
int createInstance = IndexOf(acquireCalls, "CreateInstanceAndSurface");
int selectAndGate = IndexOf(acquireCalls, "SelectDeviceAndGate");
int createRhiDevice = IndexOf(acquireCalls, "CreateDevice");
Assert.True(createInstance >= 0);
Assert.True(selectAndGate > createInstance);
Assert.True(createRhiDevice > selectAndGate);
MethodInfo select = RequiredMethod("SelectDeviceAndGate");
CompiledCall featureProbe = Assert.Single(
CompiledCallGraph.Read(select),
call => call.Target.DeclaringType == typeof(VulkanPhysicalDeviceInspector)
&& call.Target.Name == nameof(VulkanPhysicalDeviceInspector.ReadFeatures));
CompiledCall logicalDeviceCreate = Assert.Single(
CompiledCallGraph.Read(select),
call => call.Target.DeclaringType == typeof(VulkanLogicalDeviceFactory)
&& call.Target.Name == nameof(VulkanLogicalDeviceFactory.Create));
FieldInfo features = typeof(VulkanGraphicsContext).GetField(
"_features",
BindingFlags.Instance | BindingFlags.NonPublic)!;
CompiledFieldReference featurePublication = Assert.Single(
CompiledCallGraph.ReadFieldReferences(select),
reference => reference.Field == features && reference.OpCode == OpCodes.Stfld);
Assert.True(
featureProbe.Offset < featurePublication.Offset,
"The selected physical-device feature probe must precede publication to the context.");
Assert.True(
featurePublication.Offset < logicalDeviceCreate.Offset,
"The production acquisition path must publish probed features before logical-device creation consumes them.");
}
private static MethodInfo RequiredMethod(string name) =>
typeof(VulkanGraphicsContext).GetMethod(
name,
BindingFlags.Static | BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException($"Missing VulkanGraphicsContext.{name}.");
private static int IndexOf(IReadOnlyList<CompiledCall> calls, string methodName) =>
calls
.Select((call, index) => (call, index))
.Where(value => value.call.Target.DeclaringType == typeof(VulkanGraphicsContext)
&& value.call.Target.Name == methodName)
.Select(value => value.index)
.DefaultIfEmpty(-1)
.Single();
}

View file

@ -0,0 +1,32 @@
using AcDream.App.Rendering.Gpu.Vk;
using Silk.NET.Vulkan;
namespace AcDream.App.Tests.Rendering.Gpu.Vk;
public sealed class VulkanHostStorageVisibilityTests
{
[Fact]
public void RetainedTransformBarrier_PublishesHostWritesToVertexShaderReads()
{
BufferMemoryBarrier2 barrier = VulkanHostStorageVisibility.Create(
default,
4u * 1024u * 1024u);
Assert.Equal(StructureType.BufferMemoryBarrier2, barrier.SType);
Assert.Equal(PipelineStageFlags2.HostBit, barrier.SrcStageMask);
Assert.Equal(AccessFlags2.HostWriteBit, barrier.SrcAccessMask);
Assert.Equal(PipelineStageFlags2.VertexShaderBit, barrier.DstStageMask);
Assert.Equal(AccessFlags2.ShaderReadBit, barrier.DstAccessMask);
Assert.Equal(Silk.NET.Vulkan.Vk.QueueFamilyIgnored, barrier.SrcQueueFamilyIndex);
Assert.Equal(Silk.NET.Vulkan.Vk.QueueFamilyIgnored, barrier.DstQueueFamilyIndex);
Assert.Equal(0ul, barrier.Offset);
Assert.Equal(4ul * 1024ul * 1024ul, barrier.Size);
}
[Fact]
public void RetainedTransformBarrier_RejectsAnEmptyBinding()
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
VulkanHostStorageVisibility.Create(default, 0));
}
}

View file

@ -0,0 +1,37 @@
using AcDream.App.Rendering.Gpu.Vk;
using Silk.NET.Vulkan;
namespace AcDream.App.Tests.Rendering.Gpu.Vk;
public sealed class VulkanRenderFailurePolicyTests
{
[Fact]
public void ManagedOutOfMemoryAndNestedFatalFailuresAreTerminal()
{
Assert.True(VulkanRenderFailurePolicy.IsFatal(new OutOfMemoryException("managed")));
Assert.True(VulkanRenderFailurePolicy.IsFatal(new InvalidOperationException(
"wrapper",
new VulkanCallException("nested", Result.ErrorDeviceLost))));
Assert.True(VulkanRenderFailurePolicy.IsFatal(new AggregateException(
new InvalidOperationException("ordinary"),
new VulkanCallException("nested", Result.ErrorOutOfDeviceMemory))));
}
[Theory]
[InlineData(Result.ErrorDeviceLost)]
[InlineData(Result.ErrorOutOfHostMemory)]
[InlineData(Result.ErrorOutOfDeviceMemory)]
[InlineData(Result.ErrorSurfaceLostKhr)]
public void TerminalVulkanResultsCannotFallBackToAnotherGraph(Result result) =>
Assert.True(VulkanRenderFailurePolicy.IsFatal(
new VulkanCallException("test", result)));
[Fact]
public void OrdinaryPackAndNonTerminalVulkanFailuresMayBeQuarantined()
{
Assert.False(VulkanRenderFailurePolicy.IsFatal(
new InvalidOperationException("pack bug")));
Assert.False(VulkanRenderFailurePolicy.IsFatal(
new VulkanCallException("pack pipeline", Result.ErrorFormatNotSupported)));
}
}

View file

@ -126,6 +126,12 @@ public sealed class VulkanShaderDescriptorContractTests
.SelectMany(ReadResources),
];
private static bool IsOptInPackShaderModule(string module) =>
module.StartsWith("atmospheric_", StringComparison.Ordinal)
|| module.StartsWith("directional_shadow_", StringComparison.Ordinal)
|| module.StartsWith("mesh_atmospheric.", StringComparison.Ordinal)
|| module.StartsWith("terrain_atmospheric.", StringComparison.Ordinal);
/// <summary>
/// The regression itself, named. <c>TerrainClip</c> is the only uniform block
/// <c>terrain_modern.vert</c> declares besides <c>SceneLighting</c>, so
@ -153,8 +159,8 @@ public sealed class VulkanShaderDescriptorContractTests
/// <summary>
/// The general rule the specific case is an instance of: a uniform block may
/// only live at a binding <see cref="VulkanPipelineLayouts.CreateUniformSetLayout"/>
/// actually declares, in the set it declares them in.
/// only live in retail set 1 at a retail binding, or in opt-in set 3 at one
/// of render-pack ABI v1's sparse bindings 5..8.
/// </summary>
[Fact]
public void EveryUniformBlockLandsAtADeclaredUniformBinding()
@ -164,13 +170,31 @@ public sealed class VulkanShaderDescriptorContractTests
.. AllResources()
.Where(r => r.StorageClass == SpirvStorageClass.Uniform)
.Where(r =>
r.Set != GpuBindingModel.UniformSet
|| r.Binding is null
|| !VulkanPipelineLayouts.IsDeclaredUniformBinding(r.Binding.Value))
r.Binding is null
|| !(r.Set == GpuBindingModel.UniformSet
&& VulkanPipelineLayouts.IsDeclaredUniformBinding(r.Binding.Value))
&& !(r.Set == GpuBindingModel.RenderPackUniformSet
&& VulkanPipelineLayouts.IsDeclaredPackUniformBinding(r.Binding.Value)))
.Select(r =>
$"{r.Module}: uniform block %{r.Id} is at set {r.Set?.ToString() ?? "(none)"} "
+ $"binding {r.Binding?.ToString() ?? "(none)"}; set 1 declares "
+ $"[{string.Join(", ", VulkanPipelineLayouts.DeclaredUniformBindings)}]."),
+ $"binding {r.Binding?.ToString() ?? "(none)"}; retail set 1 declares "
+ $"[{string.Join(", ", VulkanPipelineLayouts.DeclaredUniformBindings)}] "
+ "and opt-in set 3 declares [5, 6, 7, 8]."),
];
Assert.Empty(violations);
}
[Fact]
public void RetailShaderModulesNeverDeclareOptInSetThree()
{
string[] violations =
[
.. AllResources()
.Where(r => r.Set == GpuBindingModel.RenderPackUniformSet)
.Where(r => !IsOptInPackShaderModule(r.Module))
.Select(r =>
$"{r.Module}: resource %{r.Id} declares opt-in set 3 binding {r.Binding}."),
];
Assert.Empty(violations);

View file

@ -31,6 +31,32 @@ namespace AcDream.App.Tests.Rendering.Gpu.Vk;
/// </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",
["mesh_modern.frag.spv"] = "b702b644862aca31ce1fb0677adc5872b39c4ea87f595a89363b44d10f2cc50e",
["mesh_modern.vert.spv"] = "7ca5fb241c4f0248884ba8fa88fbae17a7d5cbc80efe4ac4a9ffd0254012ead8",
["particle.frag.spv"] = "680da227704e0b3afa9b5226a7d73dd65aa9d8759d081cf4d5009d30e148726b",
["particle.vert.spv"] = "ed79461ab347bf17edaca714bbbbfabead8192e059c760578ca3a1a01409799e",
["particle_mesh.frag.spv"] = "7696b1dc0613b5a724c55df465173f613ae047da9675895b149b7c71b009cc7c",
["particle_mesh.vert.spv"] = "f7fe8b203cadcd4d54af5cdbcfd9d5bf733146e10bafa78ca730fb6970db0479",
["portal_depth.frag.spv"] = "96755196d4d0da7be4792107557465778be2ebefb5584834cc75bf90ec55a6cc",
["portal_depth.vert.spv"] = "cd113860b7acd6afad3ebcc0a68dd7147f6baae729df51ab360c123588dc3ae2",
["sky.frag.spv"] = "ae0d9e3e1e1b5742dd986cb39c62ea6e71e19783feea8b86a5cd940504d6047e",
["sky.vert.spv"] = "77176cf33c761ee4e9730357895c941dbf5949d8e0d28e0bb0dcde87f4d30288",
["terrain_modern.frag.spv"] = "7b3cdb01b837ed77ee20559a81c1ce5c9d5395300efcc072560ab0be3c5a1af9",
["terrain_modern.vert.spv"] = "9f4cb221ea6aed94a8d23af6cb8e3f3ed96c3cce6e50d135a72d3b55667b1557",
["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);
@ -68,10 +94,51 @@ public sealed class VulkanShaderManifestTests
{
// 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).Replace("\r\n", "\n");
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()
{
@ -87,6 +154,18 @@ public sealed class VulkanShaderManifestTests
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()
{

View file

@ -146,6 +146,9 @@ public sealed class VulkanViewportMappingTests
Assert.Equal(
(BlendFactor.OneMinusSrcAlpha, BlendFactor.SrcAlpha),
VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.InverseAlpha));
Assert.Equal(
(BlendFactor.DstColor, BlendFactor.OneMinusSrcAlpha),
VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.RetailDetail));
}
[Fact]

View file

@ -0,0 +1,60 @@
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Gpu.Vk;
using AcDream.App.Tests.Rendering.Gpu;
namespace AcDream.App.Tests.Rendering.Gpu.Vk;
public sealed class VulkanWorldPassScopeTests
{
[Fact]
public void OrdinaryPublicationStartsWithEmptyFrameSections()
{
using var device = new RecordingGpuDevice();
using IGpuBuffer buffer = Buffer(device);
var scope = new VulkanWorldPassScope(sampleCount: 1);
scope.Sections.SceneLighting = new GpuBufferSection(buffer, 256, 576);
using (scope.Publish(Encoder(device)))
Assert.False(scope.Sections.SceneLighting.IsValid);
Assert.False(scope.Sections.SceneLighting.IsValid);
}
[Fact]
public void PreparedPublicationPreservesCurrentFrameSectionsUntilDispose()
{
using var device = new RecordingGpuDevice();
using IGpuBuffer buffer = Buffer(device);
var scope = new VulkanWorldPassScope(sampleCount: 1);
var lighting = new GpuBufferSection(buffer, 256, 576);
scope.Sections.SceneLighting = lighting;
using (scope.PublishPrepared(Encoder(device)))
Assert.Equal(lighting, scope.Sections.SceneLighting);
Assert.False(scope.Sections.SceneLighting.IsValid);
}
private static IGpuBuffer Buffer(RecordingGpuDevice device) =>
device.CreateBuffer(new GpuBufferDescription(
"prepared-lighting",
1024,
GpuBufferUsage.Uniform,
GpuMemoryResidency.HostWritable));
private static IGpuPassEncoder Encoder(RecordingGpuDevice device) =>
new RecordingGpuPassEncoder(
device,
new GpuPassDescription
{
Name = "world",
Color = new GpuColorAttachment(
Target: null,
GpuLoadOp.Clear,
GpuStoreOp.Store,
default),
Depth = null,
SampleCount = 1,
});
}

View file

@ -1,8 +1,10 @@
using System.Numerics;
using AcDream.App.Input;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Scene.Arch;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
@ -40,6 +42,43 @@ public sealed class LiveRenderProjectionJournalTests
Assert.True((registered.Record.Flags & RenderProjectionFlags.Draw) != 0);
}
[Fact]
public void EntityReady_RetainsExactLocalPlayerIdentityAtRenderPublication()
{
Harness harness = CreateHarness(loadLandblock: true, localPlayerGuid: Guid);
LiveEntityRecord record = Materialize(harness, Guid, instance: 12);
Assert.True(harness.Projections.OnEntityReady(
LiveEntityReadyCandidate.Capture(record)));
Assert.Equal(
RenderCasterIdentityKind.LocalPlayer,
Assert.Single(harness.Journal.Pending.ToArray())
.Record.EntityPayload.CasterIdentity);
}
[Theory]
[InlineData(0x80000001u, null, null, 0x80000001u, (byte)RenderCasterIdentityKind.LocalPlayer)]
[InlineData(0x80000001u, null, 0x8u, 0u, (byte)RenderCasterIdentityKind.RemotePlayer)]
[InlineData(0x50000001u, null, null, 0u, (byte)RenderCasterIdentityKind.RemotePlayer)]
[InlineData(0x80000001u, (uint)ItemType.Creature, null, 0u, (byte)RenderCasterIdentityKind.NonPlayerCreature)]
[InlineData(0x80000001u, (uint)ItemType.Misc, null, 0u, (byte)RenderCasterIdentityKind.OtherLiveDynamic)]
public void CasterIdentityClassifier_UsesOnlyAuthoritativeSpawnFacts(
uint serverGuid,
uint? itemType,
uint? objectDescriptionFlags,
uint localPlayerGuid,
byte expected)
{
Assert.Equal(
(RenderCasterIdentityKind)expected,
RenderCasterIdentityClassifier.Classify(
serverGuid,
itemType,
objectDescriptionFlags,
localPlayerGuid));
}
[Fact]
public void PendingToLoadedAndLoadedToLoaded_RebucketWithoutLogicalRecreate()
{
@ -347,7 +386,9 @@ public sealed class LiveRenderProjectionJournalTests
Assert.Equal(0, harness.Projections.ProjectionCount);
}
private static Harness CreateHarness(bool loadLandblock)
private static Harness CreateHarness(
bool loadLandblock,
uint localPlayerGuid = 0)
{
var state = new GpuWorldState();
if (loadLandblock)
@ -360,10 +401,15 @@ public sealed class LiveRenderProjectionJournalTests
var runtime = LiveEntityRuntimeFixture.Create(state, resources);
var journal = new RenderProjectionJournal(
RenderSceneGeneration.FromRaw(1));
var localPlayer = new LocalPlayerIdentityState
{
ServerGuid = localPlayerGuid,
};
var projections = new LiveRenderProjectionJournal(
runtime,
journal,
new GpuWorldRenderTraversalOrderSource(state));
new GpuWorldRenderTraversalOrderSource(state),
localPlayer);
sink = projections;
var scene = new ArchRenderScene(RenderSceneGeneration.FromRaw(1));
return new Harness(state, runtime, journal, projections, scene);

View file

@ -0,0 +1,114 @@
using AcDream.App.Rendering;
using AcDream.App.Rendering.Packs;
namespace AcDream.App.Tests.Rendering.Packs;
public sealed class AtmosphericAutoQualityControllerTests
{
[Fact]
public void Downgrade_requires_long_consecutive_over_budget_window()
{
var controller = new AtmosphericAutoQualityController(
AtmosphericQualityLevel.High);
var sample = new AtmosphericQualityMeasurement(
InclusivePackGpuMillisecondsP99: 7.0,
IncrementalCpuMillisecondsP99: 1.1,
ResidentGpuBytes: 250L * 1024 * 1024,
StableFrameBoundary: true);
for (int i = 0;
i < AtmosphericAutoQualityController.DowngradeHysteresisFrames - 1;
i++)
controller.Observe(in sample);
Assert.Equal(AtmosphericQualityLevel.High, controller.Snapshot.Current);
AtmosphericAutoQualitySnapshot changed = controller.Observe(in sample);
Assert.Equal(AtmosphericQualityLevel.Medium, changed.Current);
Assert.Equal(AtmosphericAutoQualityController.ChangeCooldownFrames,
changed.CooldownFramesRemaining);
}
[Fact]
public void Unstable_frames_never_advance_hysteresis()
{
var controller = new AtmosphericAutoQualityController(
AtmosphericQualityLevel.High);
var sample = new AtmosphericQualityMeasurement(20, 20, 0, false);
for (int i = 0; i < 1000; i++)
controller.Observe(in sample);
Assert.Equal(AtmosphericQualityLevel.High, controller.Snapshot.Current);
Assert.Equal(0, controller.Snapshot.ConsecutiveOverBudgetFrames);
}
[Fact]
public void Upgrade_requires_nine_hundred_headroom_frames()
{
var controller = new AtmosphericAutoQualityController(
AtmosphericQualityLevel.Low);
var sample = new AtmosphericQualityMeasurement(
InclusivePackGpuMillisecondsP99: 0.5,
IncrementalCpuMillisecondsP99: 0.1,
ResidentGpuBytes: 16L * 1024 * 1024,
StableFrameBoundary: true);
for (int i = 0;
i < AtmosphericAutoQualityController.UpgradeHysteresisFrames - 1;
i++)
controller.Observe(in sample);
Assert.Equal(AtmosphericQualityLevel.Low, controller.Snapshot.Current);
Assert.Equal(
AtmosphericQualityLevel.Medium,
controller.Observe(in sample).Current);
}
[Fact]
public void LowRequestsWholePackFallbackWithoutDroppingHeadlineSemantics()
{
var controller = new AtmosphericAutoQualityController(
AtmosphericQualityLevel.Low);
var sample = new AtmosphericQualityMeasurement(100, 100, long.MaxValue, true);
for (int i = 0;
i < AtmosphericAutoQualityController.DowngradeHysteresisFrames - 1;
i++)
controller.Observe(in sample);
Assert.Equal(AtmosphericQualityLevel.Low, controller.Snapshot.Current);
Assert.False(controller.Snapshot.SafeFallbackToRetailRequested);
AtmosphericAutoQualitySnapshot fallback = controller.Observe(in sample);
Assert.Equal(AtmosphericQualityLevel.Low, fallback.Current);
Assert.True(fallback.SafeFallbackToRetailRequested);
Assert.Equal(
DirectionalShadowSemantics.Headline,
DirectionalShadowQuality.For(DirectionalShadowPreset.Low).Semantics);
}
[Fact]
public void PackDeclaredBudgetsAreTheAutomaticQualityAuthority()
{
AtmosphericQualityBudget[] budgets =
[
new(0.5, 0.1, 16 * 1024 * 1024),
new(1.0, 0.2, 32 * 1024 * 1024),
new(1.5, 0.3, 64 * 1024 * 1024),
];
var controller = new AtmosphericAutoQualityController(
budgets,
AtmosphericQualityLevel.High);
var sample = new AtmosphericQualityMeasurement(
InclusivePackGpuMillisecondsP99: 1.6,
IncrementalCpuMillisecondsP99: 0.1,
ResidentGpuBytes: 8 * 1024 * 1024,
StableFrameBoundary: true);
for (int i = 0; i < AtmosphericAutoQualityController.DowngradeHysteresisFrames; i++)
controller.Observe(in sample);
Assert.Equal(AtmosphericQualityLevel.Medium, controller.Snapshot.Current);
}
}

View file

@ -0,0 +1,94 @@
using System.Diagnostics;
using AcDream.App.Rendering.Gpu.Vk;
using AcDream.App.Rendering.Packs;
using AcDream.App.Tests.Architecture;
namespace AcDream.App.Tests.Rendering.Packs;
public sealed class AtmosphericCpuStageProfilerTests
{
[Fact]
public void LowProfilerSamplesOneFrameInFour()
{
long[] measured = Enumerable.Range(1, 12)
.Where(value => AtmosphericCpuStageProfiler.ShouldMeasure(value))
.Select(value => (long)value)
.ToArray();
Assert.Equal([4L, 8L, 12L], measured);
}
[Fact]
public void SnapshotPublishesNonOverlappingStagesTotalAndResidual()
{
var profiler = new AtmosphericCpuStageProfiler(capacity: 4);
var frame = new AtmosphericCpuStageFrame(
FrameSerial: 4,
ShadowCasterBuildTicks: Ticks(20),
ShadowEnvironmentTicks: Ticks(30),
ShadowPreparedDrawsAndTransformsTicks: Ticks(40),
ShadowFitAndUniformTicks: Ticks(50),
ShadowLayeredPassRecordingTicks: Ticks(60),
ShadowBookkeepingTicks: Ticks(70),
PostSetupAndOtherTicks: Ticks(80),
PostSunRaysTicks: Ticks(90),
PostFilmicTicks: Ticks(100));
profiler.Observe(
in frame,
targetPreparationTicks: Ticks(10),
measuredPackTotalTicks: Ticks(600),
observeBookkeepingTicks: Ticks(110));
IReadOnlyDictionary<string, RenderPackCpuStageDiagnostics> stages = profiler
.Snapshot()
.ToDictionary(value => value.Stage, StringComparer.Ordinal);
Assert.Equal(13, stages.Count);
Assert.Equal(1, stages["shadow-layered-pass-recording"].SampleCount);
Assert.Equal(0.060, stages["shadow-layered-pass-recording"].CpuMillisecondsP50, 3);
Assert.Equal(0.110, stages["performance-observe-bookkeeping"].CpuMillisecondsP50, 3);
Assert.Equal(0.600, stages["measured-pack-total"].CpuMillisecondsP50, 3);
Assert.Equal(0.050, stages["measured-pack-unattributed"].CpuMillisecondsP50, 3);
}
[Fact]
public void WarmedObservationAllocatesNothing()
{
var profiler = new AtmosphericCpuStageProfiler(capacity: 2048);
var frame = new AtmosphericCpuStageFrame(
4,
Ticks(1), Ticks(2), Ticks(3), Ticks(4), Ticks(5),
Ticks(6), Ticks(7), Ticks(8), Ticks(9));
ZeroAllocationProbe.AssertAllocatesNothing(
"AtmosphericCpuStageProfiler.Observe",
() => profiler.Observe(
in frame,
targetPreparationTicks: Ticks(10),
measuredPackTotalTicks: Ticks(100),
observeBookkeepingTicks: Ticks(11)));
}
[Fact]
public void ProductionWorldPhaseCompletesTheSampledProfileAfterObservation()
{
var render = typeof(VulkanWorldScenePhase).GetMethod(nameof(VulkanWorldScenePhase.Render))!;
IReadOnlyList<CompiledCall> calls = CompiledCallGraph.Read(render);
int observe = CompiledCallGraph.IndexOf(
calls,
typeof(RenderPackController),
nameof(RenderPackController.ObserveActiveFrame));
int complete = CompiledCallGraph.IndexOf(
calls,
typeof(IAtmosphericCpuStageProfileRuntime),
nameof(IAtmosphericCpuStageProfileRuntime.CompleteCpuProfile));
Assert.True(observe >= 0);
Assert.True(complete > observe);
}
private static long Ticks(int microseconds) => checked((long)Math.Round(
microseconds * Stopwatch.Frequency / 1_000_000d,
MidpointRounding.AwayFromZero));
}

View file

@ -0,0 +1,63 @@
using AcDream.App.Rendering.Packs;
using AcDream.Plugin.Abstractions.Rendering;
namespace AcDream.App.Tests.Rendering.Packs;
public sealed class AtmosphericGpuTimerSamplingTests
{
[Fact]
public void LowMeasuresOneCompleteFrameInFour()
{
bool[] measured = Enumerable.Range(1, 12)
.Select(frame => AtmosphericGpuTimerSampling.ShouldMeasure(
RenderQualitySemantic.Low,
frame))
.ToArray();
Assert.Equal(
[false, false, false, true, false, false, false, true, false, false, false, true],
measured);
}
[Theory]
[InlineData(RenderQualitySemantic.Medium)]
[InlineData(RenderQualitySemantic.High)]
public void OtherQualitiesMeasureEveryFrame(RenderQualitySemantic quality)
{
for (long frame = 1; frame <= 32; frame++)
Assert.True(AtmosphericGpuTimerSampling.ShouldMeasure(quality, frame));
}
[Fact]
public void RejectsNonPositiveFrameSerial()
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
AtmosphericGpuTimerSampling.ShouldMeasure(
RenderQualitySemantic.Low,
frameSerial: 0));
}
[Fact]
public void WarmSamplingDecisionsAllocateZero()
{
_ = AtmosphericGpuTimerSampling.ShouldMeasure(
RenderQualitySemantic.Low,
frameSerial: 1);
int measured = 0;
long before = GC.GetAllocatedBytesForCurrentThread();
for (long frame = 1; frame <= 10_000; frame++)
{
if (AtmosphericGpuTimerSampling.ShouldMeasure(
RenderQualitySemantic.Low,
frame))
{
measured++;
}
}
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.Equal(2_500, measured);
Assert.Equal(0, allocated);
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,163 @@
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Gpu.Vk;
using AcDream.App.Rendering.Packs;
using AcDream.Plugin.Abstractions.Rendering;
namespace AcDream.App.Tests.Rendering.Packs;
public sealed class AtmosphericShaderAbiTests
{
[Fact]
public void HostStructsMatchTheCheckedInStd140AtmosphericAbi()
{
Assert.Equal(160, Marshal.SizeOf<AtmosphericFrameUniforms>());
Assert.Equal(0, Offset<AtmosphericFrameUniforms>(nameof(AtmosphericFrameUniforms.SunScreen)));
Assert.Equal(16, Offset<AtmosphericFrameUniforms>(nameof(AtmosphericFrameUniforms.SunColor)));
Assert.Equal(32, Offset<AtmosphericFrameUniforms>(nameof(AtmosphericFrameUniforms.Viewport)));
Assert.Equal(48, Offset<AtmosphericFrameUniforms>(nameof(AtmosphericFrameUniforms.Weather)));
Assert.Equal(64, Offset<AtmosphericFrameUniforms>(nameof(AtmosphericFrameUniforms.SunDirection)));
Assert.Equal(80, Offset<AtmosphericFrameUniforms>(nameof(AtmosphericFrameUniforms.Policy)));
Assert.Equal(96, Offset<AtmosphericFrameUniforms>(nameof(AtmosphericFrameUniforms.InverseViewProjection)));
Assert.Equal(64, Marshal.SizeOf<AtmosphericPackPassUniforms>());
Assert.Equal(0, Offset<AtmosphericPackPassUniforms>(nameof(AtmosphericPackPassUniforms.Params0)));
Assert.Equal(16, Offset<AtmosphericPackPassUniforms>(nameof(AtmosphericPackPassUniforms.Params1)));
Assert.Equal(32, Offset<AtmosphericPackPassUniforms>(nameof(AtmosphericPackPassUniforms.Params2)));
Assert.Equal(48, Offset<AtmosphericPackPassUniforms>(nameof(AtmosphericPackPassUniforms.Params3)));
Assert.Equal(256, Marshal.SizeOf<PackSettingsUniforms>());
Assert.Equal(5u, GpuBindingModel.UniformAtmosphericFrame);
Assert.Equal(6u, GpuBindingModel.UniformDirectionalShadow);
Assert.Equal(7u, GpuBindingModel.UniformPackPass);
Assert.Equal(8u, GpuBindingModel.UniformPackSettings);
Assert.Equal(5, VulkanFrameBindings.UniformBindingCount);
Assert.Equal(
[1u, 2u, 3u, 4u],
VulkanPipelineLayouts.DeclaredUniformBindings);
Assert.Equal(3, RenderPackShaderAbi.UniformDescriptorSet);
Assert.Equal(3u, GpuBindingModel.RenderPackUniformSet);
Assert.False(VulkanPipelineLayouts.IsDeclaredUniformBinding(5));
Assert.True(VulkanPipelineLayouts.IsDeclaredPackUniformBinding(5));
Assert.True(VulkanPipelineLayouts.IsDeclaredPackUniformBinding(8));
Assert.Equal(RenderPackShaderAbi.AtmosphericFrameBinding, (int)GpuBindingModel.UniformAtmosphericFrame);
Assert.Equal(RenderPackShaderAbi.AtmosphericFrameSizeBytes, AtmosphericFrameUniforms.SizeInBytes);
Assert.Equal(RenderPackShaderAbi.DirectionalShadowBinding, (int)GpuBindingModel.UniformDirectionalShadow);
Assert.Equal(RenderPackShaderAbi.PackPassBinding, (int)GpuBindingModel.UniformPackPass);
Assert.Equal(RenderPackShaderAbi.PackPassSizeBytes, AtmosphericPackPassUniforms.SizeInBytes);
Assert.Equal(RenderPackShaderAbi.PackSettingsBinding, (int)GpuBindingModel.UniformPackSettings);
Assert.Equal(RenderPackShaderAbi.PackSettingsSizeBytes, PackSettingsUniforms.SizeInBytes);
Assert.Equal(RenderPackShaderAbi.PushConstantSizeBytes, GpuBindingModel.PushConstantBytes);
}
[Fact]
public void CheckedInCommonIncludeNamesTheSameBindingsAndMemberOrder()
{
string text = File.ReadAllText(Path.Combine(
RepositoryRoot(),
"src",
"AcDream.App",
"Rendering",
"Shaders",
"atmospheric_common.glsl"));
AssertOrdered(text,
"ACDREAM_PACK_UBO_SET binding = 5",
"uAtmosphereSunScreen",
"uAtmosphereSunColor",
"uAtmosphereViewport",
"uAtmosphereWeather",
"uAtmosphereSunDirection",
"uAtmospherePolicy",
"uAtmosphereInverseViewProjection",
"binding = 7",
"uPackParams0",
"uPackParams1",
"uPackParams2",
"uPackParams3",
"FusedAtmosphericPostProcess PackPass ABI",
"binding = 8",
"uPackSettings[16]");
}
[Fact]
public void FusedLowShadersRetainTheDeclaredOcclusionAndBloomPixelKernels()
{
string shaderRoot = Path.Combine(
RepositoryRoot(),
"src",
"AcDream.App",
"Rendering",
"Shaders");
string occlusion = File.ReadAllText(Path.Combine(
shaderRoot,
"atmospheric_sun_occlusion.frag"));
string rays = File.ReadAllText(Path.Combine(
shaderRoot,
"atmospheric_sun_rays.frag"));
string blur = File.ReadAllText(Path.Combine(
shaderRoot,
"atmospheric_bloom_blur.frag"));
string downsample = File.ReadAllText(Path.Combine(
shaderRoot,
"atmospheric_bloom_downsample.frag"));
string filmic = File.ReadAllText(Path.Combine(
shaderRoot,
"atmospheric_filmic.frag"));
foreach (string threshold in (string[])["0.9975", "0.99995"])
{
Assert.Contains(threshold, occlusion, StringComparison.Ordinal);
Assert.Contains(threshold, rays, StringComparison.Ordinal);
}
foreach (string kernel in (string[])
["0.227027", "0.316216", "1.384615", "0.070270", "3.230769"])
{
Assert.Contains(kernel, blur, StringComparison.Ordinal);
Assert.Contains(kernel, filmic, StringComparison.Ordinal);
}
Assert.Contains("round(clamp(unobstructedSky * enabled", rays,
StringComparison.Ordinal);
Assert.Contains("uPackParams1.z > 0.5", filmic,
StringComparison.Ordinal);
Assert.Contains("brightness - threshold + knee", filmic,
StringComparison.Ordinal);
foreach (string extraction in (string[])
["0.2126", "0.7152", "0.0722", "brightness - threshold + knee"])
{
Assert.Contains(extraction, downsample, StringComparison.Ordinal);
Assert.Contains(extraction, filmic, StringComparison.Ordinal);
}
const double oneDimensionalWeight =
0.227027 + (2 * 0.316216) + (2 * 0.070270);
Assert.InRange(
oneDimensionalWeight * oneDimensionalWeight,
0.99999,
1.00001);
}
private static int Offset<T>(string field) where T : struct =>
Marshal.OffsetOf<T>(field).ToInt32();
private static void AssertOrdered(string text, params string[] tokens)
{
int prior = -1;
foreach (string token in tokens)
{
int next = text.IndexOf(token, prior + 1, StringComparison.Ordinal);
Assert.True(next > prior, $"'{token}' is missing or out of ABI order.");
prior = next;
}
}
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 repository root.");
}
}

View file

@ -0,0 +1,321 @@
using System.Numerics;
using AcDream.App.Rendering.Packs;
using AcDream.Core.World;
namespace AcDream.App.Tests.Rendering.Packs;
public sealed class AuthoredCelestialShadowSourceResolverTests
{
[Fact]
public void VerifiedDerethIds_AreStable()
{
Assert.Equal(0x01001348u, AuthoredCelestialShadowSourceResolver.SunGfxObjId);
Assert.Equal(0x01001F6Au, AuthoredCelestialShadowSourceResolver.DominantMoonGfxObjId);
Assert.Equal(0x01001F67u, AuthoredCelestialShadowSourceResolver.SecondaryMoonGfxObjId);
}
[Fact]
public void SunOverlap_WinsRegardlessOfObjectOrder()
{
DayGroupData group = Group(
Celestial(
AuthoredCelestialShadowSourceResolver.SecondaryMoonGfxObjId,
Vector3.UnitX),
Celestial(
AuthoredCelestialShadowSourceResolver.DominantMoonGfxObjId,
Vector3.UnitX),
Celestial(
AuthoredCelestialShadowSourceResolver.SunGfxObjId,
Vector3.UnitX));
AuthoredCelestialShadowSource result = Resolve(group, 0.5f);
Assert.Equal(AuthoredCelestialShadowSourceKind.Sun, result.Kind);
Assert.Equal(2, result.ObjectIndex);
Assert.Equal(AuthoredCelestialShadowSourceResolver.SunGfxObjId, result.GfxObjId);
}
[Fact]
public void MissingSun_FallsBackToDominantMoonBeforeSecondaryMoon()
{
DayGroupData group = Group(
Celestial(
AuthoredCelestialShadowSourceResolver.SecondaryMoonGfxObjId,
Vector3.UnitX),
Celestial(
AuthoredCelestialShadowSourceResolver.DominantMoonGfxObjId,
Vector3.UnitX));
AuthoredCelestialShadowSource result = Resolve(group, 0.5f);
Assert.Equal(AuthoredCelestialShadowSourceKind.DominantMoon, result.Kind);
Assert.Equal(1, result.ObjectIndex);
Assert.Equal(
AuthoredCelestialShadowSourceResolver.DominantMoonGfxObjId,
result.GfxObjId);
}
[Fact]
public void FullyTransparentHigherPriorityObjects_FallBackToSecondaryMoon()
{
DayGroupData group = Group(
[
Celestial(
AuthoredCelestialShadowSourceResolver.SunGfxObjId,
Vector3.UnitX),
Celestial(
AuthoredCelestialShadowSourceResolver.DominantMoonGfxObjId,
Vector3.UnitX),
Celestial(
AuthoredCelestialShadowSourceResolver.SecondaryMoonGfxObjId,
Vector3.UnitX),
],
Replacements(
0f,
Replace(0, transparent: 1f),
Replace(1, transparent: 1f)));
AuthoredCelestialShadowSource result = Resolve(group, 0.5f);
Assert.Equal(AuthoredCelestialShadowSourceKind.SecondaryMoon, result.Kind);
Assert.Equal(2, result.ObjectIndex);
Assert.Equal(
AuthoredCelestialShadowSourceResolver.SecondaryMoonGfxObjId,
result.GfxObjId);
}
[Fact]
public void EffectiveReplacement_ProvidesItsGfxIdentityAndSortCenter()
{
const uint replacementGfxObjId = 0x0100ABCDu;
DayGroupData group = Group(
[
Celestial(
AuthoredCelestialShadowSourceResolver.DominantMoonGfxObjId,
-Vector3.UnitX),
],
Replacements(
0f,
Replace(
0,
gfxObjId: replacementGfxObjId,
transparent: 0.35f,
sortCenter: Vector3.UnitX)));
AuthoredCelestialShadowSource result = Resolve(group, 0.5f);
Assert.Equal(AuthoredCelestialShadowSourceKind.DominantMoon, result.Kind);
Assert.Equal(replacementGfxObjId, result.GfxObjId);
AssertVectorClose(Vector3.UnitZ, result.SurfaceToLightDirection);
}
[Fact]
public void ReplacementRotation_IsAppliedBeforeTheSkyArcRotation()
{
DayGroupData group = Group(
[
Celestial(
AuthoredCelestialShadowSourceResolver.SunGfxObjId,
Vector3.UnitY),
],
Replacements(0f, Replace(0, rotate: 90f)));
AuthoredCelestialShadowSource result = Resolve(group, 0.5f);
Assert.Equal(AuthoredCelestialShadowSourceKind.Sun, result.Kind);
AssertVectorClose(Vector3.UnitZ, result.SurfaceToLightDirection);
AssertClose(1f, result.ElevationSin);
}
[Fact]
public void SkyTransformDirection_MatchesTheAuthoredAnalyticTransform()
{
DayGroupData group = Group(
[
Celestial(
AuthoredCelestialShadowSourceResolver.SecondaryMoonGfxObjId,
new Vector3(2f, 1f, 3f),
beginAngle: 0f,
endAngle: 80f,
beginTime: 0f,
endTime: 1f),
],
Replacements(0f, Replace(0, rotate: 30f)));
AuthoredCelestialShadowSource result = Resolve(group, 0.5f);
// Independent analytic result for:
// anchor (2,1,3)
// heading rotation Z(-30 degrees)
// current arc rotation Y(-40 degrees)
// using System.Numerics' row-vector convention.
Vector3 expected = new(
-0.05839998f,
-0.03580622f,
0.99765092f);
Assert.Equal(AuthoredCelestialShadowSourceKind.SecondaryMoon, result.Kind);
AssertVectorClose(expected, result.SurfaceToLightDirection);
AssertClose(expected.Z, result.ElevationSin);
}
[Fact]
public void NoVisibleOrAboveHorizonCandidate_ReturnsNone()
{
DayGroupData group = Group(
Celestial(
AuthoredCelestialShadowSourceResolver.SunGfxObjId,
Vector3.UnitX,
beginAngle: 90f,
endAngle: 90f,
beginTime: 0.1f,
endTime: 0.2f),
Celestial(
AuthoredCelestialShadowSourceResolver.DominantMoonGfxObjId,
Vector3.UnitX,
beginAngle: -10f),
Celestial(
AuthoredCelestialShadowSourceResolver.SecondaryMoonGfxObjId,
Vector3.UnitX,
beginAngle: 0f));
AuthoredCelestialShadowSource result = Resolve(group, 0.5f);
Assert.False(result.IsAvailable);
Assert.Equal(AuthoredCelestialShadowSourceKind.None, result.Kind);
Assert.Equal(-1, result.ObjectIndex);
Assert.Equal(0u, result.GfxObjId);
}
[Fact]
public void MidnightWrap_IsVisibleOnBothSidesAndNotAtMidday()
{
DayGroupData group = Group(
Celestial(
AuthoredCelestialShadowSourceResolver.SecondaryMoonGfxObjId,
Vector3.UnitX,
beginAngle: 80f,
endAngle: 100f,
beginTime: 0.9f,
endTime: 0.1f));
AuthoredCelestialShadowSource beforeMidnight = Resolve(group, 0.95f);
AuthoredCelestialShadowSource afterMidnight = Resolve(group, 0.05f);
AuthoredCelestialShadowSource midday = Resolve(group, 0.5f);
Assert.Equal(
AuthoredCelestialShadowSourceKind.SecondaryMoon,
beforeMidnight.Kind);
Assert.Equal(
AuthoredCelestialShadowSourceKind.SecondaryMoon,
afterMidnight.Kind);
Assert.True(beforeMidnight.ElevationSin > 0.99f);
Assert.True(afterMidnight.ElevationSin > 0.99f);
Assert.Equal(AuthoredCelestialShadowSourceKind.None, midday.Kind);
}
[Fact]
public void AuthoredEnergy_ComesFromDirectionalColorTimesBrightness()
{
DayGroupData group = Group(
Celestial(
AuthoredCelestialShadowSourceResolver.SunGfxObjId,
Vector3.UnitX));
SkyKeyframe sky = Sky(
dirColor: new Vector3(0.4f, 0.8f, 0.2f),
dirBright: 0.5f);
AuthoredCelestialShadowSource selected =
AuthoredCelestialShadowSourceResolver.Resolve(group, 0.5f, in sky);
AuthoredCelestialShadowSource noCandidate =
AuthoredCelestialShadowSourceResolver.Resolve(null, 0.5f, in sky);
AssertClose(0.4f, selected.AuthoredEnergy);
AssertClose(0.4f, noCandidate.AuthoredEnergy);
}
private static AuthoredCelestialShadowSource Resolve(
DayGroupData group,
float dayFraction)
{
SkyKeyframe sky = Sky();
return AuthoredCelestialShadowSourceResolver.Resolve(
group,
dayFraction,
in sky);
}
private static DayGroupData Group(params SkyObjectData[] skyObjects) =>
Group(skyObjects, []);
private static DayGroupData Group(
IReadOnlyList<SkyObjectData> skyObjects,
params DatSkyKeyframeData[] skyTimes) => new()
{
Name = "Synthetic",
ChanceOfOccur = 1f,
SkyObjects = skyObjects,
SkyTimes = skyTimes,
};
private static SkyObjectData Celestial(
uint gfxObjId,
Vector3 sortCenter,
float beginAngle = 90f,
float? endAngle = null,
float beginTime = 0f,
float endTime = 0f) => new()
{
GfxObjId = gfxObjId,
AuthoredSortCenter = sortCenter,
BeginTime = beginTime,
EndTime = endTime,
BeginAngle = beginAngle,
EndAngle = endAngle ?? beginAngle,
};
private static DatSkyKeyframeData Replacements(
float begin,
params SkyObjectReplaceData[] replacements) => new()
{
Keyframe = Sky(begin: begin),
Replaces = replacements,
};
private static SkyObjectReplaceData Replace(
uint objectIndex,
uint gfxObjId = 0u,
float rotate = 0f,
float transparent = 0f,
Vector3? sortCenter = null) => new()
{
ObjectIndex = objectIndex,
GfxObjId = gfxObjId,
Rotate = rotate,
Transparent = transparent,
AuthoredSortCenter = sortCenter ?? Vector3.Zero,
};
private static SkyKeyframe Sky(
float begin = 0f,
Vector3? dirColor = null,
float dirBright = 1f) => new(
Begin: begin,
SunHeadingDeg: 90f,
SunPitchDeg: 45f,
DirColor: dirColor ?? Vector3.One,
DirBright: dirBright,
AmbColor: new Vector3(0.2f),
AmbBright: 0.4f,
FogColor: new Vector3(0.4f),
FogDensity: 0f);
private static void AssertVectorClose(Vector3 expected, Vector3 actual)
{
AssertClose(expected.X, actual.X);
AssertClose(expected.Y, actual.Y);
AssertClose(expected.Z, actual.Z);
}
private static void AssertClose(float expected, float actual) =>
Assert.InRange(MathF.Abs(expected - actual), 0f, 1e-5f);
}

View file

@ -0,0 +1,353 @@
using System.Security.Cryptography;
using AcDream.App.Plugins;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Gpu.Vk;
using AcDream.App.Rendering.Packs;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.Plugin.Abstractions.Rendering;
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.Tests.Rendering.Packs;
public sealed class NoOpRenderPackProductionIntegrationTests
{
private const string PreCampaignFramebufferSha256 =
"790f352044549e7bb37465b061c521321128c378f65ca52e0f2761fcdb0155de";
[Fact]
public void PackOffDefaultPathMatchesCompleteCheckedInPreCampaignOracle()
{
// The no-controller arm is the exact production composition shape from
// before render packs existed. The controller arm selects retail/off.
// Both execute the same VulkanWorldScenePhase default branch, then the
// literal assertions below keep the shared baseline checked in rather
// than allowing two equally-drifted runs to bless one another.
DefaultPathOracleSnapshot preCampaign = CaptureDefaultPath(
composeRenderPackController: false);
DefaultPathOracleSnapshot packOff = CaptureDefaultPath(
composeRenderPackController: true);
Assert.Equal(preCampaign.PassList, packOff.PassList);
Assert.Equal(preCampaign.PipelineSet, packOff.PipelineSet);
Assert.Equal(preCampaign.DrawCalls, packOff.DrawCalls);
Assert.Equal(preCampaign.DispatchCalls, packOff.DispatchCalls);
Assert.Equal(preCampaign.FramebufferSha256, packOff.FramebufferSha256);
Assert.Equal(preCampaign.Resources, packOff.Resources);
Assert.Equal(preCampaign.PackResources, packOff.PackResources);
Assert.Equal(preCampaign.IsRetailSelection, packOff.IsRetailSelection);
Assert.Equal(preCampaign.HasActivePackRuntime, packOff.HasActivePackRuntime);
Assert.Equal(preCampaign.HasPackShaderVariant, packOff.HasPackShaderVariant);
Assert.Equal(["vk-world"], packOff.PassList);
Assert.Equal(
["baseline-world-opaque|mesh_modern|pack=False|samples=1|format=Rgba8UnormRenderTarget"],
packOff.PipelineSet);
Assert.Equal(1, packOff.DrawCalls);
Assert.Equal(0, packOff.DispatchCalls);
Assert.Equal(PreCampaignFramebufferSha256, packOff.FramebufferSha256);
Assert.Equal(
new DefaultPathResourceLedger(
TotalBuffers: 1,
LiveBuffers: 1,
TotalPipelines: 1,
LivePipelines: 1,
TotalSamplers: 1,
LiveSamplers: 1,
TotalTextures: 0,
LiveTextures: 0,
TotalRenderTargets: 0,
LiveRenderTargets: 0,
TotalDirectionalDepthTargets: 0,
LiveDirectionalDepthTargets: 0,
LiveTextureSlots: 1,
PipelineFormatLeases: 0),
packOff.Resources);
Assert.True(packOff.IsRetailSelection);
Assert.False(packOff.HasActivePackRuntime);
Assert.False(packOff.HasPackShaderVariant);
Assert.Equal(
new DefaultPathPackResourceLedger(
RetainedGpuBytes: 0,
TransientGpuBytes: 0,
ImageCount: 0,
BufferCount: 0,
DrawCalls: 0,
DispatchCalls: 0,
PassCount: 0),
packOff.PackResources);
}
[Fact]
public void SelectedNoOpPackRemainsActiveWhileProductionUsesDefaultWorldPath()
{
RenderPackDescriptor descriptor = new(
"sample.no-op-render-pack",
"No-op Render Pack Sample",
new Version(1, 0, 0),
RenderPackApi.Current,
RenderPackTier.Tier1,
[],
[],
[],
[],
[],
[],
[new RenderQualityPreset(
"conformance", "Conformance", [], [], [], 0, 0, 0, 0, 0)],
[],
null)
{
FeatureSummary = "Conformance-only default-path selection.",
};
using var registry = new BufferedRenderPackRegistry();
using IDisposable registration = registry.Register(
descriptor,
RejectingAssets.Instance);
var device = new RecordingGpuDevice();
var factory = new AtmosphericRenderPackRuntimeFactory(device);
using var controller = new RenderPackController(
() => RenderPackCatalog.Build(
registry.Snapshot(),
RenderPackHostCapabilities.Conformance),
factory,
preparationScheduler: InlineRenderPackPreparationScheduler.Instance);
controller.Request(new RenderPackSelectionSettings(
descriptor.Id,
descriptor.PackVersion.ToString(),
"conformance"));
var lifetime = new GpuDeviceFrameLifetime(device);
var clear = new VulkanBackbufferClearState();
var scope = new VulkanWorldPassScope(sampleCount: 1);
var world = new DefaultWorldPhase(scope);
var phase = new VulkanWorldScenePhase(
lifetime,
clear,
sampleCount: static () => 1,
scope,
world,
controller);
lifetime.BeginFrame();
WorldRenderFrameOutcome outcome;
try
{
outcome = phase.Render(new RenderFrameInput(1.0 / 60.0, 1280, 720));
}
finally
{
lifetime.EndFrame();
}
Assert.Equal(DefaultWorldPhase.Expected, outcome);
Assert.Equal(1, world.RenderCount);
Assert.True(
controller.Snapshot.State == RenderPackActivationState.Active,
controller.Snapshot.Reason);
Assert.Equal(descriptor.Id, controller.Snapshot.Selection.PackId);
Assert.IsAssignableFrom<IDefaultWorldPathRenderPackRuntime>(controller.ActiveRuntime);
Assert.Single(device.Calls.OfType<GpuRecordedPassBegin>(), value => value.Name == "vk-world");
Assert.Empty(device.CreatedPipelines);
Assert.Null(scope.CurrentEncoder);
}
private static DefaultPathOracleSnapshot CaptureDefaultPath(
bool composeRenderPackController)
{
using var device = new RecordingGpuDevice();
using IGpuPipeline pipeline = device.CreatePipeline(new GpuPipelineDescription
{
Name = "baseline-world-opaque",
Shaders = new GpuShaderSet("mesh_modern"),
VertexLayout = GpuVertexLayout.WorldMesh,
});
using IGpuBuffer vertices = device.CreateBuffer(new GpuBufferDescription(
"baseline-world-vertices",
SizeBytes: 96,
GpuBufferUsage.Vertex,
GpuMemoryResidency.DeviceLocal));
using var registry = new BufferedRenderPackRegistry();
using RenderPackController? controller = composeRenderPackController
? new RenderPackController(
() => RenderPackCatalog.Build(
registry.Snapshot(),
RenderPackHostCapabilities.Conformance),
new AtmosphericRenderPackRuntimeFactory(device),
preparationScheduler: InlineRenderPackPreparationScheduler.Instance)
: null;
var lifetime = new GpuDeviceFrameLifetime(device);
var clear = new VulkanBackbufferClearState();
var scope = new VulkanWorldPassScope(sampleCount: 1);
var world = new OracleWorldPhase(scope, pipeline, vertices);
var phase = new VulkanWorldScenePhase(
lifetime,
clear,
sampleCount: static () => 1,
scope,
world,
controller);
device.Clear();
lifetime.BeginFrame();
WorldRenderFrameOutcome outcome;
try
{
outcome = phase.Render(new RenderFrameInput(1.0 / 60.0, 1280, 720));
}
finally
{
lifetime.EndFrame();
}
Assert.Equal(OracleWorldPhase.Expected, outcome);
Assert.Equal(1, world.RenderCount);
RenderPackDiagnosticsSnapshot diagnostics = controller?.CaptureDiagnostics()
?? RenderPackDiagnosticsSnapshot.Retail;
return new DefaultPathOracleSnapshot(
device.Calls.OfType<GpuRecordedPassBegin>()
.Select(call => call.Name)
.ToArray(),
device.CreatedPipelines
.Select(created =>
$"{created.Description.Name}|{created.Description.Shaders.Name}"
+ $"|pack={created.Description.UsesRenderPackShaderAbi}"
+ $"|samples={created.Description.SampleCount}"
+ $"|format={created.Description.ColorFormat}")
.ToArray(),
DrawCalls: device.Calls.Count(call => call is GpuRecordedDraw
or GpuRecordedDrawIndexed
or GpuRecordedMultiDrawIndirect),
DispatchCalls: 0,
world.FramebufferSha256,
CaptureResourceLedger(device),
IsRetailSelection: diagnostics.IsRetail,
HasActivePackRuntime: controller?.ActiveRuntime is not null,
HasPackShaderVariant: device.CreatedPipelines.Any(created =>
created.Description.UsesRenderPackShaderAbi),
PackResources: new DefaultPathPackResourceLedger(
diagnostics.RetainedGpuBytes,
diagnostics.TransientGpuBytes,
diagnostics.ImageCount,
diagnostics.BufferCount,
diagnostics.DrawCalls,
diagnostics.DispatchCalls,
diagnostics.Passes.Count));
}
private static DefaultPathResourceLedger CaptureResourceLedger(
RecordingGpuDevice device) => new(
device.CreatedBuffers.Count,
device.CreatedBuffers.Count(resource => !resource.IsDisposed),
device.CreatedPipelines.Count,
device.CreatedPipelines.Count(resource => !resource.IsDisposed),
device.CreatedSamplers.Count,
device.CreatedSamplers.Count(resource => !resource.IsDisposed),
device.CreatedTextures.Count,
device.CreatedTextures.Count(resource => !resource.IsDisposed),
device.CreatedRenderTargets.Count,
device.CreatedRenderTargets.Count(resource => !resource.IsDisposed),
device.CreatedDirectionalDepthTargets.Count,
device.CreatedDirectionalDepthTargets.Count(resource => !resource.IsDisposed),
device.LiveTextureSlotCount,
device.PipelineFormatLeases.Values.Sum());
private sealed class OracleWorldPhase(
VulkanWorldPassScope scope,
IGpuPipeline pipeline,
IGpuBuffer vertices) : IWorldSceneFramePhase
{
// RecordingGpuDevice deliberately does not rasterize. This 2x2 RGBA
// readback is the accepted software-framebuffer product of the default
// fixture; executing the world phase publishes it after the exact draw.
// Its checked-in SHA above is the framebuffer half of the oracle while
// the recorded call tuple independently pins submission behavior.
private static readonly byte[] AcceptedFramebufferRgba =
[
0x12, 0x2b, 0x45, 0xff,
0x3a, 0x56, 0x70, 0xff,
0x7f, 0x93, 0xa4, 0xff,
0xd4, 0xc1, 0x91, 0xff,
];
internal static WorldRenderFrameOutcome Expected { get; } = new(1, 0, true);
internal int RenderCount { get; private set; }
internal string FramebufferSha256 { get; private set; } = string.Empty;
public WorldRenderFrameOutcome Render(RenderFrameInput input)
{
IGpuPassEncoder encoder = Assert.IsAssignableFrom<IGpuPassEncoder>(
scope.CurrentEncoder);
encoder.BindPipeline(pipeline);
encoder.BindVertexBuffer(binding: 0, vertices, offsetBytes: 0);
encoder.SetViewport(0, 0, input.ViewportWidth, input.ViewportHeight);
encoder.SetScissor(0, 0, input.ViewportWidth, input.ViewportHeight);
encoder.Draw(vertexCount: 3, instanceCount: 1, firstVertex: 0, firstInstance: 0);
FramebufferSha256 = Convert.ToHexStringLower(
SHA256.HashData(AcceptedFramebufferRgba));
RenderCount++;
return Expected;
}
}
private sealed record DefaultPathOracleSnapshot(
string[] PassList,
string[] PipelineSet,
int DrawCalls,
int DispatchCalls,
string FramebufferSha256,
DefaultPathResourceLedger Resources,
bool IsRetailSelection,
bool HasActivePackRuntime,
bool HasPackShaderVariant,
DefaultPathPackResourceLedger PackResources);
private readonly record struct DefaultPathPackResourceLedger(
long RetainedGpuBytes,
long TransientGpuBytes,
int ImageCount,
int BufferCount,
int DrawCalls,
int DispatchCalls,
int PassCount);
private readonly record struct DefaultPathResourceLedger(
int TotalBuffers,
int LiveBuffers,
int TotalPipelines,
int LivePipelines,
int TotalSamplers,
int LiveSamplers,
int TotalTextures,
int LiveTextures,
int TotalRenderTargets,
int LiveRenderTargets,
int TotalDirectionalDepthTargets,
int LiveDirectionalDepthTargets,
int LiveTextureSlots,
int PipelineFormatLeases);
private sealed class DefaultWorldPhase(VulkanWorldPassScope scope) : IWorldSceneFramePhase
{
internal static WorldRenderFrameOutcome Expected { get; } = new(4, 7, true);
internal int RenderCount { get; private set; }
public WorldRenderFrameOutcome Render(RenderFrameInput input)
{
Assert.NotNull(scope.CurrentEncoder);
RenderCount++;
return Expected;
}
}
private sealed class RejectingAssets : IRenderPackAssets
{
internal static RejectingAssets Instance { get; } = new();
public Stream OpenRead(string assetKey) =>
throw new InvalidOperationException("A no-op pack has no shader assets.");
}
}

View file

@ -0,0 +1,117 @@
using AcDream.App.Rendering.Packs;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.Plugin.Abstractions.Rendering;
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.Tests.Rendering.Packs;
public sealed class PackSettingsUniformsTests
{
[Fact]
public void WriterResolvesPresetThenEncodesEveryV1ScalarKindInvariantly()
{
RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor with
{
Settings =
[
Setting("float", RenderSettingKind.Float, "1.25"),
Setting("integer", RenderSettingKind.Integer, "12"),
Setting("bool-false", RenderSettingKind.Boolean, "false"),
Setting("bool-true", RenderSettingKind.Boolean, "true"),
Setting("choice", RenderSettingKind.Choice, "low", ["low", "medium", "high"]),
Setting("invalid-integer", RenderSettingKind.Integer, "1.5"),
Setting("invalid-float", RenderSettingKind.Float, "1,5"),
],
};
RenderQualityPreset preset = descriptor.QualityPresets[0] with
{
SettingOverrides =
[
new RenderQualitySettingOverride("float", "2.5"),
new RenderQualitySettingOverride("integer", "-7"),
new RenderQualitySettingOverride("bool-false", "true"),
new RenderQualitySettingOverride("choice", "high"),
],
};
PackSettingsUniforms values = PackSettingsUniforms.Create(descriptor, preset);
Assert.Equal(2.5f, values[0]);
Assert.Equal(-7f, values[1]);
Assert.Equal(1f, values[2]);
Assert.Equal(1f, values[3]);
Assert.Equal(2f, values[4]);
Assert.Equal(0f, values[5]);
Assert.Equal(0f, values[6]);
Assert.Equal(0f, values[63]);
}
[Fact]
public void DescriptorValidatorRejectsMoreThanSixtyFourSettings()
{
RenderSettingDeclaration[] settings = Enumerable.Range(0, 65)
.Select(index => Setting($"setting-{index}", RenderSettingKind.Float, "0"))
.ToArray();
RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor with
{
Settings = settings,
};
var device = new RecordingGpuDevice();
RenderPackValidationResult result = RenderPackValidator.ValidateDescriptor(
descriptor,
RenderPackCapabilityResolver.Resolve(device.Capabilities));
Assert.False(result.Success);
Assert.Contains("64-setting", result.Reason, StringComparison.Ordinal);
}
[Fact]
public void User_value_wins_preset_and_default_in_b8_and_built_in_cpu_settings()
{
RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor;
RenderQualityPreset preset = descriptor.QualityPresets[0] with
{
SettingOverrides =
[
new RenderQualitySettingOverride("exposure", "1.25"),
new RenderQualitySettingOverride("bloom-strength", "0.25"),
],
};
var overrides = new RenderPackSettingOverrides(
new Dictionary<string, string>
{
["EXPOSURE"] = "1.75",
});
PackSettingsUniforms uniforms = PackSettingsUniforms.Create(
descriptor,
preset,
overrides);
AtmosphericPostProcessSettings cpu = AtmosphericPostProcessSettings.FromDescriptor(
descriptor,
preset,
overrides);
int exposureIndex = descriptor.Settings.ToList().FindIndex(value => value.Id == "exposure");
int bloomIndex = descriptor.Settings.ToList().FindIndex(value => value.Id == "bloom-strength");
Assert.Equal(1.75f, uniforms[exposureIndex]);
Assert.Equal(0.25f, uniforms[bloomIndex]);
Assert.Equal(1.75f, cpu.Exposure);
Assert.Equal(0.25f, cpu.BloomStrength);
}
private static RenderSettingDeclaration Setting(
string id,
RenderSettingKind kind,
string defaultValue,
IReadOnlyList<string>? choices = null) => new(
id,
id,
kind,
defaultValue,
Minimum: null,
Maximum: null,
Step: null,
choices ?? []);
}

View file

@ -0,0 +1,650 @@
using AcDream.App.Plugins;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Packs;
using AcDream.Plugin.Abstractions.Rendering;
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.Tests.Rendering.Packs;
public sealed class RenderPackAutoRuntimeTests
{
[Fact]
public void AutoStartsMediumAndAtomicallyPublishesPreparedLowCandidateAtBoundary()
{
using var fixture = new Fixture();
fixture.Activate("auto");
FakeRuntime medium = fixture.Active;
Assert.Equal("medium", medium.Preset.Id);
Assert.Equal("auto", fixture.Controller.Snapshot.Selection.PresetId);
fixture.ObserveOverBudget(AtmosphericAutoQualityController.DowngradeHysteresisFrames);
Assert.Same(medium, fixture.Controller.ActiveRuntime);
Assert.False(medium.Disposed);
Assert.Equal(1, fixture.Factory.BuildCount);
RenderPackActivationSnapshot changed = fixture.Controller.ApplyAtFrameBoundary(
new RenderPackActivationExtent(1920, 1080, 2));
FakeRuntime low = fixture.Active;
Assert.Equal(RenderPackActivationState.Active, changed.State);
Assert.Equal("auto", changed.Selection.PresetId);
Assert.Equal("low", low.Preset.Id);
Assert.True(low.Prepared);
Assert.True(medium.Disposed);
Assert.Equal(
["build:medium", "prepare:medium:1280x720x4", "build:low",
"prepare:low:1920x1080x2", "dispose:medium"],
fixture.Events);
Assert.Equal(0, fixture.Controller.Performance.CpuSampleCount);
Assert.Equal(AtmosphericQualityLevel.Low,
fixture.Controller.AutoQuality!.Value.Current);
RenderPackDiagnosticsSnapshot diagnostics = fixture.Controller.CaptureDiagnostics();
Assert.Equal("auto", diagnostics.PresetId);
Assert.Equal("low", diagnostics.EffectiveQuality);
}
[Fact]
public void Auto_keeps_current_quality_live_while_replacement_prepares_off_side()
{
var scheduler = new ControlledPreparationScheduler();
using var fixture = new Fixture(preparationScheduler: scheduler);
fixture.Controller.Request(new RenderPackSelectionSettings(
"auto.test", "1.0.0", "auto"));
fixture.Controller.ApplyAtFrameBoundary(
new RenderPackActivationExtent(1280, 720, 4));
scheduler.CompleteNext();
fixture.Controller.ApplyAtFrameBoundary(
new RenderPackActivationExtent(1280, 720, 4));
FakeRuntime medium = fixture.Active;
fixture.ObserveOverBudget(AtmosphericAutoQualityController.DowngradeHysteresisFrames);
RenderPackActivationSnapshot pending = fixture.Controller.ApplyAtFrameBoundary(
new RenderPackActivationExtent(1280, 720, 4));
Assert.Equal(RenderPackActivationState.CandidatePending, pending.State);
Assert.Same(medium, fixture.Controller.ActiveRuntime);
Assert.False(medium.Disposed);
scheduler.CompleteNext();
Assert.Same(medium, fixture.Controller.ActiveRuntime);
fixture.Controller.ApplyAtFrameBoundary(
new RenderPackActivationExtent(1280, 720, 4));
Assert.Equal("low", fixture.Active.Preset.Id);
Assert.True(medium.Disposed);
}
[Fact]
public void WeakHostStartsAutoAtLowAndCannotPromoteIntoUnavailablePresets()
{
using var fixture = new Fixture(new RenderPackHostCapabilities(
Enum.GetValues<RenderCapability>().ToHashSet(),
4096,
4,
64L * 1024 * 1024));
fixture.Activate("auto");
Assert.Equal("low", fixture.Active.Preset.Id);
Assert.Equal(AtmosphericQualityLevel.Low, fixture.Controller.AutoQuality!.Value.Current);
fixture.Active.ResolvedGpuMilliseconds = 0.01;
for (int i = 0; i < AtmosphericAutoQualityController.UpgradeHysteresisFrames + 1; i++)
fixture.Observe(0.01, stable: true);
Assert.Equal(AtmosphericQualityLevel.Low, fixture.Controller.AutoQuality!.Value.Current);
Assert.Equal(1, fixture.Factory.BuildCount);
}
[Fact]
public void AutoFailsSafelyToRetailWhenLowPersistentlyExceedsItsDeclaredBudget()
{
using var fixture = new Fixture();
fixture.Activate("auto");
fixture.ObserveOverBudget(AtmosphericAutoQualityController.DowngradeHysteresisFrames);
fixture.Controller.ApplyAtFrameBoundary(
new RenderPackActivationExtent(1280, 720, 4));
FakeRuntime low = fixture.Active;
Assert.Equal("low", low.Preset.Id);
fixture.ObserveOverBudget(
1
+ AtmosphericAutoQualityController.ChangeCooldownFrames
+ AtmosphericAutoQualityController.DowngradeHysteresisFrames);
Assert.Same(low, fixture.Controller.ActiveRuntime);
Assert.True(fixture.Controller.AutoQuality!.Value.SafeFallbackToRetailRequested);
RenderPackActivationSnapshot fallback = fixture.Controller.ApplyAtFrameBoundary(
new RenderPackActivationExtent(1280, 720, 4));
Assert.Equal(RenderPackActivationState.FailedToRetail, fallback.State);
Assert.Equal(RenderPackSelectionSettings.Retail, fallback.Selection);
Assert.Null(fixture.Controller.ActiveRuntime);
Assert.True(low.Disposed);
Assert.Contains(
"Low remained over its declared performance budget for 180 stable samples",
fallback.Reason,
StringComparison.Ordinal);
Assert.Contains("GPU p99 20.000 ms (budget 12.000 ms)", fallback.Reason);
Assert.Contains("CPU p99 20.000 ms (budget 3.000 ms)", fallback.Reason);
Assert.Contains("resident GPU bytes 536870912 (budget 67108864)", fallback.Reason);
Assert.Null(fixture.Controller.AutoQuality);
Assert.Equal(0, fixture.Controller.Performance.CpuSampleCount);
}
[Fact]
public void HostThatCannotSupportLowFailsAutoPreciselyToRetail()
{
using var fixture = new Fixture(new RenderPackHostCapabilities(
Enum.GetValues<RenderCapability>().ToHashSet(),
4096,
4,
32L * 1024 * 1024,
MemoryPolicyDescription: "test weak-host policy"));
fixture.Controller.Request(new RenderPackSelectionSettings(
"auto.test", "1.0.0", "auto"));
RenderPackActivationSnapshot snapshot = fixture.Controller.ApplyAtFrameBoundary(
new RenderPackActivationExtent(1280, 720, 4));
Assert.Equal(RenderPackActivationState.FailedToRetail, snapshot.State);
Assert.Null(fixture.Controller.ActiveRuntime);
Assert.Contains("cannot support Low", snapshot.Reason, StringComparison.Ordinal);
Assert.Contains("33554432", snapshot.Reason, StringComparison.Ordinal);
Assert.Equal(0, fixture.Factory.BuildCount);
}
[Fact]
public void AutoCandidateResourceFailureDisposesBothCandidatesAndFailsSafelyToRetail()
{
using var fixture = new Fixture();
fixture.Activate("auto");
FakeRuntime medium = fixture.Active;
fixture.ObserveOverBudget(AtmosphericAutoQualityController.DowngradeHysteresisFrames);
fixture.Factory.FailPreparePreset = "low";
RenderPackActivationSnapshot failed = fixture.Controller.ApplyAtFrameBoundary(
new RenderPackActivationExtent(1280, 720, 4));
Assert.Equal(RenderPackActivationState.FailedToRetail, failed.State);
Assert.Null(fixture.Controller.ActiveRuntime);
Assert.True(medium.Disposed);
FakeRuntime candidate = fixture.Factory.Runtimes[^1];
Assert.Equal("low", candidate.Preset.Id);
Assert.True(candidate.Disposed);
Assert.Contains("injected low resource failure", failed.Reason, StringComparison.Ordinal);
Assert.Equal(0, fixture.Controller.Performance.CpuSampleCount);
}
[Fact]
public void UnstableFramesAndExplicitPresetsNeverDriveAutomaticChanges()
{
using var auto = new Fixture();
auto.Activate("auto");
auto.ObserveOverBudget(1000, stable: false);
Assert.Equal(AtmosphericQualityLevel.Medium,
auto.Controller.AutoQuality!.Value.Current);
Assert.Equal(0, auto.Controller.Performance.CpuSampleCount);
Assert.Equal(1, auto.Factory.BuildCount);
using var explicitHigh = new Fixture();
explicitHigh.Activate("high");
explicitHigh.ObserveOverBudget(1000);
explicitHigh.Controller.ApplyAtFrameBoundary(
new RenderPackActivationExtent(1280, 720, 4));
Assert.Equal("high", explicitHigh.Active.Preset.Id);
Assert.Null(explicitHigh.Controller.AutoQuality);
Assert.Equal(1, explicitHigh.Factory.BuildCount);
}
[Fact]
public void AutomaticQualityBooleanEnablesAutoFromAnExplicitPreset()
{
using var fixture = new Fixture(descriptor: DescriptorWithAutomaticSetting());
fixture.Controller.Request(new RenderPackSelectionSettings(
"auto.test",
"1.0.0",
"high")
{
SettingOverrides = new RenderPackSettingOverrides(
new Dictionary<string, string> { ["automatic-quality"] = "true" }),
});
RenderPackActivationSnapshot snapshot = fixture.Controller.ApplyAtFrameBoundary(
new RenderPackActivationExtent(1280, 720, 4));
Assert.Equal(RenderPackActivationState.Active, snapshot.State);
Assert.Equal("high", fixture.Active.Preset.Id);
Assert.Equal(AtmosphericQualityLevel.High, fixture.Controller.AutoQuality!.Value.Current);
}
[Fact]
public void AutomaticQualityBooleanCanDisableTheAutomaticSelector()
{
using var fixture = new Fixture(descriptor: DescriptorWithAutomaticSetting());
fixture.Controller.Request(new RenderPackSelectionSettings(
"auto.test",
"1.0.0",
"auto")
{
SettingOverrides = new RenderPackSettingOverrides(
new Dictionary<string, string> { ["automatic-quality"] = "false" }),
});
RenderPackActivationSnapshot snapshot = fixture.Controller.ApplyAtFrameBoundary(
new RenderPackActivationExtent(1280, 720, 4));
Assert.Equal(RenderPackActivationState.Active, snapshot.State);
Assert.Equal("medium", fixture.Active.Preset.Id);
Assert.Null(fixture.Controller.AutoQuality);
}
[Fact]
public void ResourceGenerationChangeResetsWindowBeforeAcceptingNewLayoutSamples()
{
using var fixture = new Fixture();
fixture.Activate("auto");
fixture.ObserveOverBudget(5);
Assert.Equal(5, fixture.Controller.Performance.CpuSampleCount);
fixture.Active.ResourceGeneration++;
fixture.ObserveOverBudget(1);
Assert.Equal(0, fixture.Controller.Performance.CpuSampleCount);
fixture.ObserveOverBudget(1);
Assert.Equal(1, fixture.Controller.Performance.CpuSampleCount);
}
[Fact]
public void DiagnosticResetStartsACompleteFreshPerformanceWindow()
{
using var fixture = new Fixture();
fixture.Activate("high");
fixture.ObserveOverBudget(4);
Assert.Equal(4, fixture.Controller.MinimumPerformanceSampleCount);
Assert.True(
fixture.Controller.TryResetPerformanceEvidence(out string error),
error);
Assert.Equal(0, fixture.Controller.MinimumPerformanceSampleCount);
fixture.ObserveOverBudget(1);
Assert.Equal(1, fixture.Controller.MinimumPerformanceSampleCount);
}
[Fact]
public void DiagnosticResetCannotPerturbAutomaticQualityEvidence()
{
using var fixture = new Fixture();
fixture.Activate("auto");
fixture.ObserveOverBudget(1);
Assert.False(
fixture.Controller.TryResetPerformanceEvidence(out string error));
Assert.Contains("explicit quality preset", error);
Assert.Equal(1, fixture.Controller.MinimumPerformanceSampleCount);
}
[Fact]
public void DiagnosticsSeparatePackAddedCpuAbsoluteReceiverCpuAndInclusiveGpu()
{
using var fixture = new Fixture();
fixture.Activate("high");
double[] cpu = [1, 2, 3, 4];
double[] gpu = [4, 6, 8, 10];
for (int i = 0; i < cpu.Length; i++)
{
fixture.Active.ResolvedGpuMilliseconds = gpu[i];
fixture.Observe(cpu[i], stable: true);
}
RenderPackDiagnosticsSnapshot diagnostics = fixture.Controller.CaptureDiagnostics();
Assert.Equal(4, diagnostics.Performance.CpuSampleCount);
Assert.Equal(4, diagnostics.Performance.AbsoluteReceiverCpuSampleCount);
Assert.Equal(4, diagnostics.Performance.GpuSampleCount);
Assert.Equal(2, diagnostics.Performance.IncrementalCpuMillisecondsP50);
Assert.Equal(4, diagnostics.Performance.IncrementalCpuMillisecondsP95);
Assert.Equal(4, diagnostics.Performance.IncrementalCpuMillisecondsP99);
Assert.Equal(0, diagnostics.Performance.AbsoluteReceiverCpuMillisecondsP50);
Assert.Equal(6, diagnostics.Performance.InclusiveGpuMillisecondsP50);
Assert.Equal(10, diagnostics.Performance.InclusiveGpuMillisecondsP95);
Assert.Equal(10, diagnostics.Performance.InclusiveGpuMillisecondsP99);
Assert.Contains(
"perf=cpu-added:2.000/4.000/4.000ms,receiver-cpu-absolute:0.000/0.000/0.000ms,gpu-inclusive:6.000/10.000/10.000ms",
RenderPackDiagnosticsFormatter.Format(diagnostics),
StringComparison.Ordinal);
}
[Fact]
public void AbsoluteReceiverCpuIsDiagnosticOnlyWhileGpuRemainsInclusive()
{
using var fixture = new Fixture();
fixture.Activate("high");
fixture.Active.ResolvedGpuMilliseconds = 4.5;
fixture.Observe(
cpuMilliseconds: 1.25,
stable: true,
receiverCpuMilliseconds: 0.75);
RenderPackPerformanceSnapshot performance = fixture.Controller.Performance;
Assert.Equal(1, performance.CpuSampleCount);
Assert.Equal(1.25, performance.IncrementalCpuMillisecondsP50);
Assert.Equal(0.75, performance.AbsoluteReceiverCpuMillisecondsP50);
Assert.Equal(4.5, performance.InclusiveGpuMillisecondsP50);
}
[Fact]
public void LargeAbsoluteReceiverCpuCannotForceAutoDownButPackAddedCpuCan()
{
using var receiverHeavy = new Fixture();
receiverHeavy.Activate("auto");
receiverHeavy.Active.ResolvedGpuMilliseconds = 0.1;
for (int i = 0; i < AtmosphericAutoQualityController.DowngradeHysteresisFrames; i++)
{
receiverHeavy.Observe(
cpuMilliseconds: 0.1,
stable: true,
receiverCpuMilliseconds: 100);
}
Assert.Equal(
AtmosphericQualityLevel.Medium,
receiverHeavy.Controller.AutoQuality!.Value.Current);
Assert.Equal(
100,
receiverHeavy.Controller.Performance.AbsoluteReceiverCpuMillisecondsP99);
Assert.Equal(
0.1,
receiverHeavy.Controller.Performance.IncrementalCpuMillisecondsP99);
using var packHeavy = new Fixture();
packHeavy.Activate("auto");
packHeavy.Active.ResolvedGpuMilliseconds = 0.1;
for (int i = 0; i < AtmosphericAutoQualityController.DowngradeHysteresisFrames; i++)
{
packHeavy.Observe(
cpuMilliseconds: 10,
stable: true,
receiverCpuMilliseconds: 0.1);
}
Assert.Equal(
AtmosphericQualityLevel.Low,
packHeavy.Controller.AutoQuality!.Value.Current);
}
[Fact]
public void StablePerformanceObservationAllocatesNothingAfterWarmup()
{
using var fixture = new Fixture();
fixture.Activate("high");
for (int i = 0; i < 128; i++)
fixture.Observe(1, stable: true);
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 128; i++)
fixture.Observe(1, stable: true);
long after = GC.GetAllocatedBytesForCurrentThread();
Assert.Equal(0, after - before);
}
private sealed class Fixture : IDisposable
{
private readonly BufferedRenderPackRegistry _registry = new();
private readonly IDisposable _registration;
internal Fixture(
RenderPackHostCapabilities? capabilities = null,
RenderPackDescriptor? descriptor = null,
IRenderPackPreparationScheduler? preparationScheduler = null)
{
Factory = new FakeFactory(Events);
_registration = _registry.Register(descriptor ?? Descriptor(), new EmptyAssets());
Controller = new RenderPackController(
() => RenderPackCatalog.Build(
_registry.Snapshot(),
capabilities ?? RenderPackHostCapabilities.Conformance),
Factory,
preparationScheduler: preparationScheduler
?? InlineRenderPackPreparationScheduler.Instance);
}
internal List<string> Events { get; } = [];
internal FakeFactory Factory { get; }
internal RenderPackController Controller { get; }
internal FakeRuntime Active => Assert.IsType<FakeRuntime>(Controller.ActiveRuntime);
internal void Activate(string preset)
{
Controller.Request(new RenderPackSelectionSettings("auto.test", "1.0.0", preset));
RenderPackActivationSnapshot snapshot = Controller.ApplyAtFrameBoundary(
new RenderPackActivationExtent(1280, 720, 4));
Assert.True(
snapshot.State == RenderPackActivationState.Active,
snapshot.Reason);
}
internal void ObserveOverBudget(int count, bool stable = true)
{
Active.ResolvedGpuMilliseconds = 20;
Active.RetainedGpuBytes = 512L * 1024 * 1024;
for (int i = 0; i < count; i++)
Observe(20, stable);
}
internal void Observe(
double cpuMilliseconds,
bool stable,
double receiverCpuMilliseconds = 0d)
{
var observation = new RenderPackFramePerformanceObservation(
cpuMilliseconds,
stable,
1280,
720,
4,
receiverCpuMilliseconds);
Controller.ObserveActiveFrame(in observation);
}
public void Dispose()
{
Controller.Dispose();
_registration.Dispose();
_registry.Dispose();
}
}
private sealed class ControlledPreparationScheduler : IRenderPackPreparationScheduler
{
private readonly Queue<(Action Work, TaskCompletionSource Completion)> _pending = [];
public Task Schedule(Action preparation)
{
var completion = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
_pending.Enqueue((preparation, completion));
return completion.Task;
}
internal void CompleteNext()
{
(Action work, TaskCompletionSource completion) = _pending.Dequeue();
try
{
work();
completion.SetResult();
}
catch (Exception error)
{
completion.SetException(error);
}
}
}
private sealed class FakeFactory(List<string> events) : IRenderPackRuntimeFactory
{
internal int BuildCount { get; private set; }
internal string? FailPreparePreset { get; set; }
internal List<FakeRuntime> Runtimes { get; } = [];
public IRenderPackRuntime Build(
RenderPackDescriptor descriptor,
ValidatedRenderPackShaderAssets assets,
RenderQualityPreset preset,
IReadOnlyDictionary<string, string> userSettingOverrides)
{
BuildCount++;
events.Add("build:" + preset.Id);
var runtime = new FakeRuntime(
descriptor,
preset,
events,
string.Equals(FailPreparePreset, preset.Id, StringComparison.Ordinal));
Runtimes.Add(runtime);
return runtime;
}
}
private sealed class FakeRuntime(
RenderPackDescriptor descriptor,
RenderQualityPreset preset,
List<string> events,
bool failPrepare) :
IAtmosphericWorldGraphRuntime,
IRenderPackRuntimePerformanceSource,
IRenderPackRuntimeDiagnosticsSource
{
public RenderPackDescriptor Descriptor { get; } = descriptor;
public RenderQualityPreset Preset { get; } = preset;
internal long ResourceGeneration { get; set; }
internal double ResolvedGpuMilliseconds { get; set; } = 1;
internal long RetainedGpuBytes { get; set; } = 32L * 1024 * 1024;
internal bool Prepared { get; private set; }
internal bool Disposed { get; private set; }
public IGpuRenderTarget PrepareWorldTarget(int width, int height, int sampleCount)
{
events.Add($"prepare:{Preset.Id}:{width}x{height}x{sampleCount}");
if (failPrepare)
throw new InvalidOperationException($"injected {Preset.Id} resource failure");
Prepared = true;
ResourceGeneration++;
return null!;
}
public void RenderPostProcess(IGpuFrame frame, in AtmosphericFrameInputs inputs)
{
}
public RenderPackRuntimePerformanceMetrics CapturePerformanceMetrics() => new(
ResourceGeneration,
HasResolvedGpuMeasurement: true,
ResolvedGpuMilliseconds,
RetainedGpuBytes,
TransientGpuBytes: 4L * 1024 * 1024);
public RenderPackRuntimeDiagnostics CaptureDiagnostics() =>
RenderPackRuntimeDiagnostics.Empty(Preset.Id);
public void Dispose()
{
if (Disposed)
return;
Disposed = true;
events.Add("dispose:" + Preset.Id);
}
}
private sealed class EmptyAssets : IRenderPackAssets
{
public Stream OpenRead(string assetKey) => Stream.Null;
}
private static RenderPackDescriptor Descriptor() => new(
"auto.test",
"Auto Test",
new Version(1, 0, 0),
RenderPackApi.Current,
RenderPackTier.Tier1,
[],
[],
[],
[],
[],
[],
[
Preset("low"),
Preset("medium"),
Preset("high"),
Preset("auto") with { AutoEligible = false },
],
[],
null)
{
FeatureSummary = "Automatic-quality test render pack.",
};
private static RenderPackDescriptor DescriptorWithAutomaticSetting()
{
RenderPackDescriptor descriptor = Descriptor();
return descriptor with
{
QualityPresets = descriptor.QualityPresets.Select(preset =>
preset.Semantic == RenderQualitySemantic.Automatic
? preset with
{
SettingOverrides =
[
new RenderQualitySettingOverride("automatic-quality", "true"),
],
}
: preset).ToArray(),
Settings =
[
new RenderSettingDeclaration(
"automatic-quality",
"Automatic quality",
RenderSettingKind.Boolean,
"false",
null,
null,
null,
[])
{
Semantic = RenderSettingSemantic.AutomaticQuality,
},
],
};
}
private static RenderQualityPreset Preset(string id) => new RenderQualityPreset(
id,
char.ToUpperInvariant(id[0]) + id[1..],
[],
[],
[],
(id switch
{
"low" => 64L,
"high" => 256L,
_ => 128L,
}) * 1024 * 1024,
10,
12,
2,
3)
{
Semantic = id switch
{
"low" => RenderQualitySemantic.Low,
"medium" => RenderQualitySemantic.Medium,
"high" => RenderQualitySemantic.High,
"auto" => RenderQualitySemantic.Automatic,
_ => RenderQualitySemantic.Custom,
},
};
}

View file

@ -0,0 +1,167 @@
using AcDream.App.Plugins;
using AcDream.App.Rendering.Packs;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.Plugin.Abstractions.Rendering;
namespace AcDream.App.Tests.Rendering.Packs;
public sealed class RenderPackCapabilityResolverTests
{
private const long MiB = 1024L * 1024L;
[Fact]
public void ResolverCarriesActualAdapterLimitsAndAppliesTheDocumentedMemoryShare()
{
using var baseline = new RecordingGpuDevice();
using var device = new RecordingGpuDevice
{
Capabilities = baseline.Capabilities with
{
MaxImageDimension2D = 1536,
MaxImageArrayLayers = 2,
DeviceLocalMemoryBytes = 512UL * 1024 * 1024,
},
};
RenderPackHostCapabilities host = RenderPackCapabilityResolver.Resolve(
device.Capabilities);
Assert.Equal(1536, host.MaxImageDimension2D);
Assert.Equal(2, host.MaxImageArrayLayers);
Assert.Equal(64L * MiB, host.MaxPackResidentBytes);
Assert.Equal(64L * MiB, host.MaxPackTransientBytes);
Assert.Contains(
RenderCapability.AuthoredCelestialDirectionalLight,
host.Available);
Assert.Contains("one eighth", host.MemoryPolicyDescription, StringComparison.Ordinal);
}
[Fact]
public void PresetCompatibilityNamesTheExactArrayLimitAndKeepsLowAvailable()
{
RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor;
var host = new RenderPackHostCapabilities(
Enum.GetValues<RenderCapability>().ToHashSet(),
MaxImageDimension2D: 4096,
MaxImageArrayLayers: 2,
MaxPackResidentBytes: 256L * MiB);
RenderPackValidationResult low = RenderPackValidator.ValidatePresetCompatibility(
descriptor,
descriptor.QualityPresets.Single(value => value.Semantic == RenderQualitySemantic.Low),
host);
RenderPackValidationResult medium = RenderPackValidator.ValidatePresetCompatibility(
descriptor,
descriptor.QualityPresets.Single(value => value.Semantic == RenderQualitySemantic.Medium),
host);
Assert.True(low.Success, low.Reason);
Assert.False(medium.Success);
Assert.Equal(
"Preset 'medium' resource 'directional-shadow-depth' needs 3 image-array layers; "
+ "this device provides 2.",
medium.Reason);
}
[Fact]
public void RuntimeBudgetRejectsResolvedRelativeExtentBeforeAllocation()
{
RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor;
RenderQualityPreset low = descriptor.QualityPresets.Single(value =>
value.Semantic == RenderQualitySemantic.Low);
var host = new RenderPackHostCapabilities(
Enum.GetValues<RenderCapability>().ToHashSet(),
MaxImageDimension2D: 1024,
MaxImageArrayLayers: 2,
MaxPackResidentBytes: 256L * MiB);
NotSupportedException error = Assert.Throws<NotSupportedException>(() =>
RenderPackResourceBudgetPlanner.RequireWithinHost(
descriptor,
low,
1920,
1080,
sampleCount: 1,
host));
Assert.Contains("1920x1080", error.Message, StringComparison.Ordinal);
Assert.Contains("maximum 2-D image edge is 1024", error.Message, StringComparison.Ordinal);
}
[Fact]
public void CatalogKeepsPackVisibleAndPublishesPerPresetUnavailableReasons()
{
RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor;
using var registry = new BufferedRenderPackRegistry();
using IDisposable registration = registry.Register(descriptor, new EmptyAssets());
var host = new RenderPackHostCapabilities(
Enum.GetValues<RenderCapability>().ToHashSet(),
MaxImageDimension2D: 4096,
MaxImageArrayLayers: 2,
MaxPackResidentBytes: 64L * MiB,
MemoryPolicyDescription: "test 512-MiB adapter policy");
RenderPackCatalog catalog = RenderPackCatalog.Build(registry.Snapshot(), host);
Assert.True(catalog.TryGet(descriptor.Id, out RenderPackCatalogEntry entry));
Assert.True(entry.IsCompatible, entry.IncompatibilityReason);
Assert.Null(entry.PresetIncompatibilityReasons["low"]);
Assert.Contains(
"declares a 134217728-byte resident GPU ceiling",
entry.PresetIncompatibilityReasons["medium"],
StringComparison.Ordinal);
Assert.Contains(
"declares a 268435456-byte resident GPU ceiling",
entry.PresetIncompatibilityReasons["high"],
StringComparison.Ordinal);
}
[Fact]
public void MissingGpuTimestampsDisablesOnlyAutoAndLeavesExplicitLowAvailable()
{
RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor;
HashSet<RenderCapability> available = Enum.GetValues<RenderCapability>().ToHashSet();
available.Remove(RenderCapability.GpuTimestampQueries);
var host = new RenderPackHostCapabilities(available, 4096, 4, 256L * MiB);
RenderPackValidationResult low = RenderPackValidator.ValidatePresetCompatibility(
descriptor,
descriptor.QualityPresets.Single(value => value.Semantic == RenderQualitySemantic.Low),
host);
RenderPackValidationResult auto = RenderPackValidator.ValidatePresetCompatibility(
descriptor,
descriptor.QualityPresets.Single(value => value.Semantic == RenderQualitySemantic.Automatic),
host);
Assert.True(low.Success, low.Reason);
Assert.False(auto.Success);
Assert.Contains("asynchronous GPU timestamp queries", auto.Reason, StringComparison.Ordinal);
Assert.Contains("explicit Low remains available", auto.Reason, StringComparison.Ordinal);
}
[Fact]
public void MissingMultiviewMakesHintedLowUnavailableButLeavesOrdinaryMediumAvailable()
{
RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor;
HashSet<RenderCapability> available = Enum.GetValues<RenderCapability>().ToHashSet();
available.Remove(RenderCapability.MultiviewDirectionalShadowCascades);
var host = new RenderPackHostCapabilities(available, 4096, 4, 256L * MiB);
RenderPackValidationResult low = RenderPackValidator.ValidatePresetCompatibility(
descriptor,
descriptor.QualityPresets.Single(value => value.Semantic == RenderQualitySemantic.Low),
host);
RenderPackValidationResult medium = RenderPackValidator.ValidatePresetCompatibility(
descriptor,
descriptor.QualityPresets.Single(value => value.Semantic == RenderQualitySemantic.Medium),
host);
Assert.False(low.Success);
Assert.Contains("MultiviewDirectionalShadowCascades", low.Reason, StringComparison.Ordinal);
Assert.True(medium.Success, medium.Reason);
}
private sealed class EmptyAssets : IRenderPackAssets
{
public Stream OpenRead(string assetKey) => Stream.Null;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,636 @@
using System.Numerics;
using AcDream.App.Plugins;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Packs;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Wb;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.Core.World;
using AcDream.Plugin.Abstractions.Rendering;
using AcDream.UI.Abstractions.Panels.Settings;
using DatReaderWriter.Enums;
namespace AcDream.App.Tests.Rendering.Packs;
/// <summary>
/// Recording-RHI lifetime gate for the complete optional renderer. This is
/// deliberately longer and more compositional than the focused owner tests:
/// one fixture repeatedly crosses pack, preset, size, frame-flight, topology,
/// failure, and terminal renderer-lifetime boundaries without a physical GPU.
/// </summary>
public sealed class RenderPackLongCycleConvergenceTests
{
private const int LongCycleCount = 12;
[Fact]
public void RepeatedPackResizeFailureGenerationAndFlightCyclesConvergeExactly()
{
using var device = new RecordingGpuDevice();
PrimeDeviceOwnedSamplerCache(device);
LiveGpuLedger baseline = LiveGpuLedger.Capture(device);
var lifetime = new RecordingRendererLifetime(device);
try
{
AtmosphericPostProcessGraph low = lifetime.Activate("low", 640, 360);
ExerciseResizeFlightAndGenerationReplacement(device, low);
lifetime.SelectRetail(640, 360);
AssertConverged(device, baseline, lifetime);
device.PipelineFailure = description =>
string.Equals(description.Name, "atmospheric-filmic", StringComparison.Ordinal)
? new InvalidOperationException("injected candidate pipeline failure")
: null;
RenderPackActivationSnapshot failed = lifetime.Request(
Selection("medium") with
{
SettingOverrides = RenderPackSettingOverrides.Empty.Set("exposure", "1.05"),
},
704,
396);
Assert.Equal(RenderPackActivationState.FailedToRetail, failed.State);
Assert.Contains("injected candidate pipeline failure", failed.Reason, StringComparison.Ordinal);
AssertConverged(device, baseline, lifetime);
device.PipelineFailure = null;
AtmosphericPostProcessGraph recovered = lifetime.Activate("medium", 704, 396);
RenderOnePostFrame(device, recovered, 704, 396);
lifetime.SelectRetail(704, 396);
AssertConverged(device, baseline, lifetime);
string[] presets = ["low", "medium", "high"];
for (int cycle = 0; cycle < LongCycleCount; cycle++)
{
foreach (string preset in presets)
{
int width = 640 + cycle % 3 * 64;
int height = 360 + cycle % 3 * 36;
AtmosphericPostProcessGraph graph = lifetime.Activate(
preset,
width,
height);
RecordingGpuRenderTarget initial = Assert.IsType<RecordingGpuRenderTarget>(
graph.PrepareWorldTarget(width, height, sampleCount: 1));
RecordingGpuRenderTarget resized = Assert.IsType<RecordingGpuRenderTarget>(
graph.PrepareWorldTarget(width + 16, height + 9, sampleCount: 1));
Assert.True(initial.IsDisposed);
RecordingGpuRenderTarget restored = Assert.IsType<RecordingGpuRenderTarget>(
graph.PrepareWorldTarget(width, height, sampleCount: 1));
Assert.True(resized.IsDisposed);
RenderOnePostFrame(device, graph, width, height);
lifetime.SelectRetail(width, height);
Assert.True(restored.IsDisposed);
AssertConverged(device, baseline, lifetime);
}
}
_ = lifetime.Activate("high", 800, 450);
Assert.NotEqual(baseline, LiveGpuLedger.Capture(device));
}
finally
{
lifetime.Dispose();
}
Assert.Equal(0, lifetime.RegisteredPackCount);
AssertConverged(device, baseline, lifetime);
Assert.All(
device.CreatedBuffers.Where(static value =>
value.Name.StartsWith("directional-shadow-", StringComparison.Ordinal)),
static value => Assert.True(value.IsDisposed));
}
[Fact]
public void DeviceRecreationIsFullRendererTeardownThenANewContextAndDevice()
{
RecordingGpuDevice firstDevice = new();
PrimeDeviceOwnedSamplerCache(firstDevice);
LiveGpuLedger firstBaseline = LiveGpuLedger.Capture(firstDevice);
var firstLifetime = new RecordingRendererLifetime(firstDevice);
AtmosphericPostProcessGraph firstGraph = firstLifetime.Activate("low", 640, 360);
RenderOnePostFrame(firstDevice, firstGraph, 640, 360);
Assert.Equal(1, firstLifetime.ActivationGeneration);
RecordingGpuPipeline firstPipeline = Assert.Single(
firstDevice.CreatedPipelines,
static value => string.Equals(
value.Description.Name,
"atmospheric-filmic",
StringComparison.Ordinal));
firstLifetime.Dispose();
Assert.Equal(0, firstLifetime.RegisteredPackCount);
AssertConverged(firstDevice, firstBaseline, firstLifetime);
Assert.True(firstPipeline.IsDisposed);
firstDevice.Dispose();
Assert.Throws<ObjectDisposedException>(() => firstDevice.BeginFrame());
using var secondDevice = new RecordingGpuDevice();
PrimeDeviceOwnedSamplerCache(secondDevice);
LiveGpuLedger secondBaseline = LiveGpuLedger.Capture(secondDevice);
var secondLifetime = new RecordingRendererLifetime(secondDevice);
try
{
AtmosphericPostProcessGraph secondGraph = secondLifetime.Activate(
"low",
640,
360);
RenderOnePostFrame(secondDevice, secondGraph, 640, 360);
Assert.Equal(1, secondLifetime.ActivationGeneration);
RecordingGpuPipeline secondPipeline = Assert.Single(
secondDevice.CreatedPipelines,
static value => string.Equals(
value.Description.Name,
"atmospheric-filmic",
StringComparison.Ordinal));
Assert.NotSame(firstPipeline, secondPipeline);
Assert.Equal(1, secondBaseline.TextureSlots);
Assert.True(secondDevice.LiveTextureSlotCount > secondBaseline.TextureSlots);
}
finally
{
secondLifetime.Dispose();
}
Assert.Equal(0, secondLifetime.RegisteredPackCount);
AssertConverged(secondDevice, secondBaseline, secondLifetime);
}
private static void ExerciseResizeFlightAndGenerationReplacement(
RecordingGpuDevice device,
AtmosphericPostProcessGraph graph)
{
var retainedTransforms = new DirectionalShadowTransformBufferSet(device);
DirectionalShadowPreparedDraws world = CreateWorldDraws(
device.DefaultTextureSlot,
RenderSceneGeneration.FromRaw(1),
casterBuildSequence: 1);
DirectionalShadowTerrainPreparedDraws terrain = CreateTerrainDraws(frameSequence: 1);
using IGpuBuffer worldVertices = Buffer(device, "lifetime-world-v", GpuBufferUsage.Vertex);
using IGpuBuffer worldIndices = Buffer(device, "lifetime-world-i", GpuBufferUsage.Index);
using IGpuBuffer terrainVertices = Buffer(device, "lifetime-terrain-v", GpuBufferUsage.Vertex);
using IGpuBuffer terrainIndices = Buffer(device, "lifetime-terrain-i", GpuBufferUsage.Index);
var worldGeometry = new DirectionalShadowMeshGeometry(worldVertices, worldIndices);
var terrainGeometry = new DirectionalShadowTerrainGeometry(
terrainVertices,
terrainIndices);
RenderShadowFrame(
device,
graph,
retainedTransforms,
world,
terrain,
worldGeometry,
terrainGeometry,
640,
360);
RenderShadowFrame(
device,
graph,
retainedTransforms,
world,
terrain,
worldGeometry,
terrainGeometry,
640,
360);
RecordingGpuBuffer[] firstTopologyBuffers = device.CreatedBuffers
.Where(static value => value.Name.StartsWith(
"directional-shadow-",
StringComparison.Ordinal))
.ToArray();
Assert.Equal(5, firstTopologyBuffers.Length);
Assert.All(firstTopologyBuffers, static value => Assert.False(value.IsDisposed));
RebuildWorldDraws(
world,
device.DefaultTextureSlot,
RenderSceneGeneration.FromRaw(2),
casterBuildSequence: 2);
RebuildTerrainDraws(terrain, frameSequence: 2);
RenderShadowFrame(
device,
graph,
retainedTransforms,
world,
terrain,
worldGeometry,
terrainGeometry,
640,
360);
RenderShadowFrame(
device,
graph,
retainedTransforms,
world,
terrain,
worldGeometry,
terrainGeometry,
640,
360);
Assert.All(firstTopologyBuffers, static value => Assert.True(value.IsDisposed));
Assert.Equal(
5,
device.CreatedBuffers.Count(static value =>
value.Name.StartsWith("directional-shadow-", StringComparison.Ordinal)
&& !value.IsDisposed));
retainedTransforms.Dispose();
Assert.All(
device.CreatedBuffers.Where(static value => value.Name.Contains(
"directional-shadow-transforms-",
StringComparison.Ordinal)),
static value => Assert.True(value.IsDisposed));
}
private static void RenderShadowFrame(
RecordingGpuDevice device,
AtmosphericPostProcessGraph graph,
DirectionalShadowTransformBufferSet retainedTransforms,
DirectionalShadowPreparedDraws world,
DirectionalShadowTerrainPreparedDraws terrain,
DirectionalShadowMeshGeometry worldGeometry,
DirectionalShadowTerrainGeometry terrainGeometry,
int width,
int height)
{
using IGpuFrame frame = device.BeginFrame();
WorldTransformFrameSlice transforms = retainedTransforms.Publish(
frame,
world.BuildSequence,
world.Transforms,
world.DynamicTransformSlots,
world.AllDynamicTransformSlots);
var shadows = Assert.IsType<DirectionalSunShadowRenderer>(
graph.DirectionalShadowReceivers);
DirectionalSunShadowDiagnostics diagnostics = shadows.RenderPrepared(
frame,
EnabledEnvironment(),
Matrix4x4.Identity,
Matrix4x4.CreatePerspectiveFieldOfView(1f, 16f / 9f, 0.1f, 500f),
cameraNearMeters: 0.1f,
casterDepthPaddingMeters: 48f,
world,
terrain,
worldGeometry,
terrainGeometry,
transforms);
Assert.Equal(2, diagnostics.CascadeCount);
IGpuRenderTarget target = graph.PrepareWorldTarget(width, height, sampleCount: 1);
RecordWorldPass(frame, target);
AtmosphericFrameInputs inputs = Inputs(width, height, isOutdoor: true);
graph.RenderPostProcess(frame, in inputs);
}
private static void RenderOnePostFrame(
RecordingGpuDevice device,
AtmosphericPostProcessGraph graph,
int width,
int height)
{
using IGpuFrame frame = device.BeginFrame();
IGpuRenderTarget target = graph.PrepareWorldTarget(width, height, sampleCount: 1);
RecordWorldPass(frame, target);
AtmosphericFrameInputs inputs = Inputs(width, height, isOutdoor: false);
graph.RenderPostProcess(frame, in inputs);
}
private static AtmosphericFrameInputs Inputs(
int width,
int height,
bool isOutdoor) => new(
new Vector2(0.5f, 0.35f),
SunIsOnScreen: true,
SunElevationDegrees: isOutdoor ? 20f : -10f,
new Vector3(1f, 0.85f, 0.65f),
Vector3.Normalize(new Vector3(0.2f, 0.5f, 0.8f)),
SunDirectionalBrightness: 1f,
Matrix4x4.Identity,
ActiveDayGroup: isOutdoor ? 0 : -1,
WeatherKind.Clear,
WeatherIntensity: 0f,
DeltaSeconds: 1d / 60d,
width,
height,
IsOutdoor: isOutdoor);
private static void RecordWorldPass(IGpuFrame frame, IGpuRenderTarget world)
{
using IGpuPassEncoder _ = frame.BeginPass(new GpuPassDescription
{
Name = "lifetime-world-hdr",
Color = new GpuColorAttachment(
world,
GpuLoadOp.Clear,
GpuStoreOp.Store,
Vector4.Zero),
Depth = new GpuDepthAttachment(
GpuLoadOp.Clear,
GpuStoreOp.Store,
1f,
0),
SampleCount = 1,
});
}
private static DirectionalShadowPreparedDraws CreateWorldDraws(
GpuTextureSlot cutoutSlot,
RenderSceneGeneration generation,
ulong casterBuildSequence)
{
var draws = new DirectionalShadowPreparedDraws();
RebuildWorldDraws(draws, cutoutSlot, generation, casterBuildSequence);
return draws;
}
private static void RebuildWorldDraws(
DirectionalShadowPreparedDraws draws,
GpuTextureSlot cutoutSlot,
RenderSceneGeneration generation,
ulong casterBuildSequence)
{
Assert.True(draws.TryBegin(generation, casterBuildSequence, estimatedInstances: 2));
Matrix4x4 opaque = Matrix4x4.CreateTranslation(1f, 2f, 3f);
Matrix4x4 cutout = Matrix4x4.CreateRotationZ(0.3f)
* Matrix4x4.CreateTranslation(4f, 5f, 6f);
draws.Add(
0,
0,
6,
GpuTextureSlot.Unassigned,
0,
CullMode.CounterClockwise,
DirectionalShadowCasterMaterial.Opaque,
in opaque);
draws.Add(
6,
4,
12,
cutoutSlot,
2,
CullMode.None,
DirectionalShadowCasterMaterial.AlphaCutout,
in cutout);
DirectionalShadowPreparationStats stats = default;
draws.Complete(generation, casterBuildSequence, in stats);
}
private static DirectionalShadowTerrainPreparedDraws CreateTerrainDraws(
long frameSequence)
{
var draws = new DirectionalShadowTerrainPreparedDraws();
RebuildTerrainDraws(draws, frameSequence);
return draws;
}
private static void RebuildTerrainDraws(
DirectionalShadowTerrainPreparedDraws draws,
long frameSequence)
{
Assert.True(draws.TryBegin(frameSequence, estimatedCommands: 1));
var range = new DirectionalShadowTerrainRange(20, 60);
draws.Add(in range);
draws.Complete(frameSequence);
}
private static DirectionalShadowEnvironmentState EnabledEnvironment() => new(
DirectionalShadowGateReason.Enabled,
Vector3.Normalize(new Vector3(0.2f, 0.3f, 1f)),
LightElevationSin: 0.94f,
Strength: 0.8f,
SoftnessMultiplier: 1.25f,
SourceKind: AuthoredCelestialShadowSourceKind.Sun);
private static IGpuBuffer Buffer(
RecordingGpuDevice device,
string name,
GpuBufferUsage usage) => device.CreateBuffer(new GpuBufferDescription(
name,
4096,
usage | GpuBufferUsage.TransferDestination,
GpuMemoryResidency.DeviceLocal));
private static RenderPackSelectionSettings Selection(string preset) => new(
BuiltInAtmosphericRenderPack.Descriptor.Id,
BuiltInAtmosphericRenderPack.Descriptor.PackVersion.ToString(),
preset);
private static void AssertConverged(
RecordingGpuDevice device,
LiveGpuLedger baseline,
RecordingRendererLifetime lifetime)
{
Assert.Equal(baseline, LiveGpuLedger.Capture(device));
Assert.Equal(0, device.OpenFrameCount);
Assert.Empty(device.PipelineFormatLeases);
Assert.Equal(0, lifetime.LiveReceiverCandidates);
Assert.Equal(lifetime.IsDisposed ? 0 : 1, lifetime.RegisteredPackCount);
Assert.Null(lifetime.ActiveRuntime);
}
private static void PrimeDeviceOwnedSamplerCache(RecordingGpuDevice device)
{
// Vulkan samplers are description-keyed device objects and intentionally
// survive individual pack runtimes. UiNearest is created with the device;
// prime WorldClamp so the fixture baseline includes the complete cache.
_ = device.CreateSampler(GpuSamplerDescription.WorldClamp);
}
private static IRenderPackAssets BuiltInAssets() =>
BuiltInAtmosphericRenderPack.CreateAssets(Path.Combine(
RepositoryRoot(),
"src",
"AcDream.App",
"Rendering",
"Shaders",
"spv"));
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 repository root.");
}
private readonly record struct LiveGpuLedger(
int Buffers,
int Pipelines,
int Samplers,
int Textures,
int RenderTargets,
int DirectionalDepthTargets,
int TextureSlots,
int PipelineFormatLeases)
{
internal static LiveGpuLedger Capture(RecordingGpuDevice device) => new(
device.CreatedBuffers.Count(static value => !value.IsDisposed),
device.CreatedPipelines.Count(static value => !value.IsDisposed),
device.CreatedSamplers.Count(static value => !value.IsDisposed),
device.CreatedTextures.Count(static value => !value.IsDisposed),
device.CreatedRenderTargets.Count(static value => !value.IsDisposed),
device.CreatedDirectionalDepthTargets.Count(static value => !value.IsDisposed),
device.LiveTextureSlotCount,
device.PipelineFormatLeases.Values.Sum());
}
private sealed class RecordingRendererLifetime : IDisposable
{
private readonly BufferedRenderPackRegistry _registry = new();
private readonly IDisposable _registration;
private readonly RecordingReceiverCoordinator _receivers = new();
private bool _disposed;
internal RecordingRendererLifetime(RecordingGpuDevice device)
{
_registration = _registry.Register(
BuiltInAtmosphericRenderPack.Descriptor,
BuiltInAssets());
Controller = new RenderPackController(
() => RenderPackCatalog.Build(
_registry.Snapshot(),
RenderPackCapabilityResolver.Resolve(device.Capabilities)),
new AtmosphericRenderPackRuntimeFactory(device),
_receivers,
InlineRenderPackPreparationScheduler.Instance);
}
private RenderPackController Controller { get; }
internal long ActivationGeneration => Controller.Snapshot.ActivationGeneration;
internal IRenderPackRuntime? ActiveRuntime => Controller.ActiveRuntime;
internal int LiveReceiverCandidates => _receivers.LiveCandidateCount;
internal bool IsDisposed => _disposed;
internal int RegisteredPackCount => _disposed ? 0 : _registry.Snapshot().Count;
internal AtmosphericPostProcessGraph Activate(
string preset,
int width,
int height)
{
RenderPackActivationSnapshot snapshot = Request(
Selection(preset),
width,
height);
Assert.Equal(RenderPackActivationState.Active, snapshot.State);
Assert.Null(snapshot.Reason);
Assert.Equal(1, LiveReceiverCandidates);
return Assert.IsType<AtmosphericPostProcessGraph>(Controller.ActiveRuntime);
}
internal RenderPackActivationSnapshot Request(
RenderPackSelectionSettings selection,
int width,
int height)
{
Controller.Request(selection);
return Controller.ApplyAtFrameBoundary(
new RenderPackActivationExtent(width, height, 1));
}
internal void SelectRetail(int width, int height)
{
RenderPackActivationSnapshot snapshot = Request(
RenderPackSelectionSettings.Retail,
width,
height);
Assert.Equal(RenderPackActivationState.Retail, snapshot.State);
Assert.True(snapshot.Selection.IsRetail);
}
public void Dispose()
{
if (_disposed)
return;
Controller.Dispose();
_registration.Dispose();
Assert.Empty(_registry.Snapshot());
_registry.Dispose();
_disposed = true;
}
}
private sealed class RecordingReceiverCoordinator :
IRenderPackReceiverPipelineCoordinator
{
private Candidate? _active;
internal int LiveCandidateCount { get; private set; }
public IRenderPackReceiverPipelineCandidate Prepare(
IDirectionalShadowReceiverSource? source,
int sampleCount)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(sampleCount);
var candidate = new Candidate(this, source is not null);
LiveCandidateCount++;
return candidate;
}
public void Publish(IRenderPackReceiverPipelineCandidate candidate)
{
if (candidate is not Candidate prepared
|| !ReferenceEquals(prepared.Owner, this))
{
throw new ArgumentException(
"Receiver candidate belongs to another coordinator.",
nameof(candidate));
}
prepared.Publish();
_active?.Dispose();
_active = prepared;
}
public void Clear()
{
_active?.Dispose();
_active = null;
}
private void Released() => LiveCandidateCount--;
private sealed class Candidate(
RecordingReceiverCoordinator owner,
bool hasDirectionalSource) : IRenderPackReceiverPipelineCandidate
{
private bool _disposed;
private bool _published;
internal RecordingReceiverCoordinator Owner { get; } = owner;
internal void Publish()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!hasDirectionalSource)
{
throw new InvalidOperationException(
"The atmospheric candidate lost its directional receiver source.");
}
if (_published)
throw new InvalidOperationException("Receiver candidate was published twice.");
_published = true;
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
Owner.Released();
}
}
}
}

View file

@ -0,0 +1,78 @@
using AcDream.App.Rendering.Packs;
namespace AcDream.App.Tests.Rendering.Packs;
public sealed class RenderPackPerformanceWindowTests
{
[Fact]
public void Snapshot_reports_independent_cpu_and_delayed_gpu_percentiles()
{
var window = new RenderPackPerformanceWindow(capacity: 8);
window.Observe(1, 11, false, 0, 10, 2);
window.Observe(2, 12, true, 4, 11, 3);
window.Observe(3, 13, true, 6, 12, 4);
window.Observe(4, 14, true, 8, 13, 5);
RenderPackPerformanceSnapshot value = window.Snapshot();
Assert.Equal(4, value.CpuSampleCount);
Assert.Equal(4, value.AbsoluteReceiverCpuSampleCount);
Assert.Equal(3, value.GpuSampleCount);
Assert.Equal(2, value.IncrementalCpuMillisecondsP50);
Assert.Equal(4, value.IncrementalCpuMillisecondsP95);
Assert.Equal(4, value.IncrementalCpuMillisecondsP99);
Assert.Equal(12, value.AbsoluteReceiverCpuMillisecondsP50);
Assert.Equal(14, value.AbsoluteReceiverCpuMillisecondsP95);
Assert.Equal(14, value.AbsoluteReceiverCpuMillisecondsP99);
Assert.Equal(6, value.InclusiveGpuMillisecondsP50);
Assert.Equal(8, value.InclusiveGpuMillisecondsP95);
Assert.Equal(8, value.InclusiveGpuMillisecondsP99);
Assert.Equal(13, value.ResidentGpuBytes);
Assert.Equal(5, value.TransientGpuBytes);
Assert.False(value.HasStableAutoWindow(4));
Assert.True(value.HasStableAutoWindow(3));
}
[Fact]
public void Capacity_is_a_rolling_window_and_reset_removes_mixed_quality_data()
{
var window = new RenderPackPerformanceWindow(capacity: 3);
for (int i = 1; i <= 4; i++)
window.Observe(i, i * 10, true, i * 2, i, i);
RenderPackPerformanceSnapshot rolled = window.Snapshot();
Assert.Equal(3, rolled.CpuSampleCount);
Assert.Equal(3, rolled.GpuSampleCount);
Assert.Equal(3, rolled.IncrementalCpuMillisecondsP50);
Assert.Equal(4, rolled.IncrementalCpuMillisecondsP99);
Assert.Equal(30, rolled.AbsoluteReceiverCpuMillisecondsP50);
Assert.Equal(40, rolled.AbsoluteReceiverCpuMillisecondsP99);
Assert.Equal(6, rolled.InclusiveGpuMillisecondsP50);
Assert.Equal(8, rolled.InclusiveGpuMillisecondsP99);
window.Reset();
Assert.Equal(default, window.Snapshot());
}
[Theory]
[InlineData(-1, 0, false, 0, 0, 0)]
[InlineData(double.NaN, 0, false, 0, 0, 0)]
[InlineData(0, -1, false, 0, 0, 0)]
[InlineData(0, double.NaN, false, 0, 0, 0)]
[InlineData(0, 0, true, -1, 0, 0)]
[InlineData(0, 0, true, double.PositiveInfinity, 0, 0)]
[InlineData(0, 0, false, 0, -1, 0)]
[InlineData(0, 0, false, 0, 0, -1)]
public void Invalid_measurements_are_rejected(
double cpu,
double receiverCpu,
bool hasGpu,
double gpu,
long resident,
long transient)
{
var window = new RenderPackPerformanceWindow();
Assert.Throws<ArgumentOutOfRangeException>(() =>
window.Observe(cpu, receiverCpu, hasGpu, gpu, resident, transient));
}
}

View file

@ -0,0 +1,99 @@
using AcDream.App.Rendering.Packs;
using AcDream.App.Rendering.Wb;
namespace AcDream.App.Tests.Rendering.Packs;
public sealed class RenderPackResourceBudgetPlannerTests
{
private const long MiB = 1024L * 1024L;
[Fact]
public void Low_1080p_resolves_actual_images_below_its_64_mib_ceiling()
{
var descriptor = BuiltInAtmosphericRenderPack.Descriptor;
var preset = descriptor.QualityPresets.Single(value => value.Id == "low");
RenderPackResourceBudget budget = RenderPackResourceBudgetPlanner
.RequireWithinPreset(descriptor, preset, 1920, 1080, sampleCount: 1);
Assert.InRange(budget.RetainedGpuBytes, 40L * MiB, 42L * MiB);
Assert.Equal(0, budget.MultisampleGpuBytes);
Assert.Equal(2, budget.LargestImageLayerCount);
Assert.Equal(1920, budget.LargestImageWidth);
}
[Fact]
public void Low_1440pFundsQuarterResolutionBloomAndPreAdmitsBothTransformFlights()
{
var descriptor = BuiltInAtmosphericRenderPack.Descriptor;
var preset = descriptor.QualityPresets.Single(value => value.Id == "low");
RenderPackResourceBudget budget = RenderPackResourceBudgetPlanner
.RequireWithinPreset(descriptor, preset, 2560, 1440, sampleCount: 1);
long expected = checked(
2560L * 1440L * 12L
+ 768L * 768L * 2L * sizeof(float)
// Bloom ping/pong, sun mask/rays, and the declared optional
// volumetric target all remain inside the conservative admission
// ledger even though Low keeps volumetrics disabled at runtime.
+ 640L * 360L * (8L + 8L + 4L + 8L + 8L)
+ 2L * WorldTransformCapacityPolicy.InitialBindingSizeBytes);
Assert.Equal(expected, budget.RetainedGpuBytes);
Assert.InRange(budget.RetainedGpuBytes, 62L * MiB, 64L * MiB);
Assert.True(budget.RetainedGpuBytes <= preset.MaxResidentGpuBytes);
Assert.Equal(2560, budget.LargestImageWidth);
Assert.Equal(1440, budget.LargestImageHeight);
}
[Fact]
public void Medium_1080p_tracks_multisample_bytes_separately_from_resident_ceiling()
{
var descriptor = BuiltInAtmosphericRenderPack.Descriptor;
var preset = descriptor.QualityPresets.Single(value => value.Id == "medium");
RenderPackResourceBudget budget = RenderPackResourceBudgetPlanner
.RequireWithinPreset(descriptor, preset, 1920, 1080, sampleCount: 2);
Assert.True(budget.RetainedGpuBytes < 128L * MiB);
Assert.Equal(1920L * 1080L * 12L * 2L, budget.MultisampleGpuBytes);
Assert.Equal(
checked(budget.RetainedGpuBytes + budget.MultisampleGpuBytes),
budget.TotalGpuBytes);
Assert.Equal(3, budget.LargestImageLayerCount);
}
[Fact]
public void Four_k_rejects_low_before_size_dependent_allocation()
{
var descriptor = BuiltInAtmosphericRenderPack.Descriptor;
var preset = descriptor.QualityPresets.Single(value => value.Id == "low");
NotSupportedException error = Assert.Throws<NotSupportedException>(() =>
RenderPackResourceBudgetPlanner.RequireWithinPreset(
descriptor,
preset,
3840,
2160,
sampleCount: 4));
Assert.Contains("low", error.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("3840x2160", error.Message, StringComparison.Ordinal);
Assert.Contains("resident GPU bytes", error.Message, StringComparison.Ordinal);
}
[Fact]
public void Four_k_high_remains_available_and_records_all_four_cascades()
{
var descriptor = BuiltInAtmosphericRenderPack.Descriptor;
var preset = descriptor.QualityPresets.Single(value => value.Id == "high");
RenderPackResourceBudget budget = RenderPackResourceBudgetPlanner
.RequireWithinPreset(descriptor, preset, 3840, 2160, sampleCount: 4);
Assert.True(budget.RetainedGpuBytes <= 256L * MiB);
Assert.Equal(4, budget.LargestImageLayerCount);
Assert.Equal(3840, budget.LargestImageWidth);
Assert.Equal(2160, budget.LargestImageHeight);
}
}

View file

@ -0,0 +1,341 @@
using AcDream.App.Plugins;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Gpu.Vk;
using AcDream.App.Rendering.Packs;
using AcDream.App.Rendering.Wb;
using AcDream.App.Tests.Architecture;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.Plugin.Abstractions.Rendering;
using AcDream.UI.Abstractions.Panels.Settings;
using Silk.NET.Vulkan;
namespace AcDream.App.Tests.Rendering.Packs;
public sealed class RenderPackRuntimeFailureRecoveryTests
{
[Fact]
public void DirectionalShadowFailureCancelsBorrowedTransformsBeforePackRetirementAndRetailReplay()
{
var render = typeof(VulkanWorldScenePhase).GetMethod(
nameof(VulkanWorldScenePhase.Render))!;
string[] lifetimeCalls = CompiledCallGraph.Read(render)
.Where(call =>
(call.Target.DeclaringType == typeof(WbDrawDispatcher)
&& call.Target.Name == nameof(
WbDrawDispatcher.CancelDirectionalShadowTransformFrame))
|| (call.Target.DeclaringType == typeof(RenderPackController)
&& call.Target.Name == nameof(RenderPackController.OnRuntimeFailure))
|| (call.Target.DeclaringType == typeof(VulkanWorldScenePhase)
&& call.Target.Name == "RenderRetail"))
.Select(call => call.Target.Name)
.ToArray();
bool hasSafeLateFailureHandoff = Enumerable.Range(
0,
Math.Max(0, lifetimeCalls.Length - 2))
.Any(index =>
lifetimeCalls[index]
== nameof(WbDrawDispatcher.CancelDirectionalShadowTransformFrame)
&& lifetimeCalls[index + 1]
== nameof(RenderPackController.OnRuntimeFailure)
&& lifetimeCalls[index + 2] == "RenderRetail");
Assert.True(
hasSafeLateFailureHandoff,
"A post-publication directional-shadow failure must cancel the "
+ "borrowed transform frame before retiring the pack-owned buffer "
+ "and replaying the frame through retail rendering.");
}
[Fact]
public void EnhancedWorldFailureQuarantinesPackAndNextFrameUsesDefaultRenderer()
{
using var rig = new FailureRig(failEnhancedWorld: true, failPostProcess: false);
WorldRenderFrameOutcome failedFrame = rig.RenderFrame();
Assert.Equal(default, failedFrame);
Assert.Equal(RenderPackActivationState.FailedToRetail, rig.Controller.Snapshot.State);
Assert.Contains(
"Atmospheric world rendering failed: enhanced world failed",
rig.Controller.Snapshot.Reason,
StringComparison.Ordinal);
Assert.Null(rig.Controller.ActiveRuntime);
Assert.True(rig.Factory.Runtime.Disposed);
WorldRenderFrameOutcome recovered = rig.RenderFrame();
Assert.Equal(FailingWorldPhase.Success, recovered);
Assert.Equal(2, rig.World.RenderCount);
Assert.Contains(
rig.Device.Calls.OfType<GpuRecordedPassBegin>(),
call => call.Name == "vk-world");
Assert.Equal(RenderPackActivationState.FailedToRetail, rig.Controller.Snapshot.State);
Assert.Contains("enhanced world failed", rig.Controller.Snapshot.Reason);
rig.Controller.Request(rig.Selection);
Assert.Equal(FailingWorldPhase.Success, rig.RenderFrame());
Assert.Equal(1, rig.Factory.BuildCount);
Assert.Contains("will not be retried", rig.Controller.Snapshot.Reason);
}
[Fact]
public void PostProcessFailureKeepsWorldOutcomeAndNextFrameUsesDefaultRenderer()
{
using var rig = new FailureRig(failEnhancedWorld: false, failPostProcess: true);
WorldRenderFrameOutcome failedFrame = rig.RenderFrame();
Assert.Equal(FailingWorldPhase.Success, failedFrame);
Assert.Equal(RenderPackActivationState.FailedToRetail, rig.Controller.Snapshot.State);
Assert.Contains(
"Atmospheric post-processing failed: post process failed",
rig.Controller.Snapshot.Reason,
StringComparison.Ordinal);
Assert.Null(rig.Controller.ActiveRuntime);
Assert.True(rig.Factory.Runtime.Disposed);
WorldRenderFrameOutcome recovered = rig.RenderFrame();
Assert.Equal(FailingWorldPhase.Success, recovered);
Assert.Equal(2, rig.World.RenderCount);
Assert.Contains(
rig.Device.Calls.OfType<GpuRecordedPassBegin>(),
call => call.Name == "vk-world");
Assert.Equal(RenderPackActivationState.FailedToRetail, rig.Controller.Snapshot.State);
Assert.Contains("post process failed", rig.Controller.Snapshot.Reason);
}
[Theory]
[InlineData(Result.ErrorDeviceLost)]
[InlineData(Result.ErrorOutOfHostMemory)]
[InlineData(Result.ErrorOutOfDeviceMemory)]
public void FatalVulkanPostProcessFailureIsRethrownWithoutPretendingRetailCanRecover(
Result result)
{
var failure = new VulkanCallException("pack post process", result);
using var rig = new FailureRig(
enhancedWorldFailure: null,
postProcessFailure: failure);
VulkanCallException thrown = Assert.Throws<VulkanCallException>(
() => rig.RenderFrame());
Assert.Same(failure, thrown);
Assert.Equal(RenderPackActivationState.Active, rig.Controller.Snapshot.State);
Assert.False(rig.Factory.Runtime.Disposed);
}
[Fact]
public void NonTerminalVulkanPostProcessFailureQuarantinesPackAndFallsBack()
{
using var rig = new FailureRig(
enhancedWorldFailure: null,
postProcessFailure: new VulkanCallException(
"pack post process",
Result.ErrorFormatNotSupported));
WorldRenderFrameOutcome failedFrame = rig.RenderFrame();
Assert.Equal(FailingWorldPhase.Success, failedFrame);
Assert.Equal(RenderPackActivationState.FailedToRetail, rig.Controller.Snapshot.State);
Assert.Null(rig.Controller.ActiveRuntime);
Assert.True(rig.Factory.Runtime.Disposed);
Assert.Contains("ErrorFormatNotSupported", rig.Controller.Snapshot.Reason,
StringComparison.Ordinal);
}
private sealed class FailureRig : IDisposable
{
private readonly BufferedRenderPackRegistry _registry = new();
private readonly IDisposable _registration;
private readonly GpuDeviceFrameLifetime _lifetime;
private readonly VulkanWorldScenePhase _phase;
internal FailureRig(bool failEnhancedWorld, bool failPostProcess)
: this(
failEnhancedWorld
? new InvalidOperationException("enhanced world failed")
: null,
failPostProcess
? new InvalidOperationException("post process failed")
: null)
{
}
internal FailureRig(
Exception? enhancedWorldFailure,
Exception? postProcessFailure)
{
RenderPackDescriptor descriptor = Descriptor();
_registration = _registry.Register(descriptor, EmptyAssets.Instance);
Device = new RecordingGpuDevice();
Factory = new FailingGraphFactory(Device, postProcessFailure);
Controller = new RenderPackController(
() => RenderPackCatalog.Build(
_registry.Snapshot(),
RenderPackHostCapabilities.Conformance),
Factory,
preparationScheduler: InlineRenderPackPreparationScheduler.Instance);
Selection = new RenderPackSelectionSettings(
descriptor.Id,
descriptor.PackVersion.ToString(),
"default");
Controller.Request(Selection);
_lifetime = new GpuDeviceFrameLifetime(Device);
var scope = new VulkanWorldPassScope(sampleCount: 1);
World = new FailingWorldPhase(enhancedWorldFailure);
_phase = new VulkanWorldScenePhase(
_lifetime,
new VulkanBackbufferClearState(),
sampleCount: static () => 1,
scope,
World,
Controller,
new AtmosphericFrameInputState());
}
internal RecordingGpuDevice Device { get; }
internal FailingGraphFactory Factory { get; }
internal RenderPackController Controller { get; }
internal RenderPackSelectionSettings Selection { get; }
internal FailingWorldPhase World { get; }
internal WorldRenderFrameOutcome RenderFrame()
{
_lifetime.BeginFrame();
try
{
return _phase.Render(new RenderFrameInput(1.0 / 60.0, 1280, 720));
}
finally
{
_lifetime.EndFrame();
}
}
public void Dispose()
{
Controller.Dispose();
_registration.Dispose();
_registry.Dispose();
Device.Dispose();
}
}
private sealed class FailingGraphFactory(
RecordingGpuDevice device,
Exception? postProcessFailure) : IRenderPackRuntimeFactory
{
internal FailingGraphRuntime Runtime { get; private set; } = null!;
internal int BuildCount { get; private set; }
public IRenderPackRuntime Build(
RenderPackDescriptor descriptor,
ValidatedRenderPackShaderAssets assets,
RenderQualityPreset preset,
IReadOnlyDictionary<string, string> userSettingOverrides)
{
BuildCount++;
Runtime = new FailingGraphRuntime(device, descriptor, preset, postProcessFailure);
return Runtime;
}
}
private sealed class FailingGraphRuntime : IAtmosphericWorldGraphRuntime
{
private readonly RecordingGpuDevice _device;
private readonly Exception? _postProcessFailure;
private IGpuRenderTarget? _target;
internal FailingGraphRuntime(
RecordingGpuDevice device,
RenderPackDescriptor descriptor,
RenderQualityPreset preset,
Exception? postProcessFailure)
{
_device = device;
Descriptor = descriptor;
Preset = preset;
_postProcessFailure = postProcessFailure;
}
public RenderPackDescriptor Descriptor { get; }
public RenderQualityPreset Preset { get; }
internal bool Disposed { get; private set; }
public IGpuRenderTarget PrepareWorldTarget(int width, int height, int sampleCount) =>
_target ??= _device.CreateRenderTarget(new GpuRenderTargetDescription(
"failing-pack-target",
width,
height,
GpuTextureFormat.Rgba16FloatRenderTarget,
GpuTextureFormat.Depth24Stencil8,
sampleCount));
public void RenderPostProcess(IGpuFrame frame, in AtmosphericFrameInputs inputs)
{
if (_postProcessFailure is not null)
throw _postProcessFailure;
}
public void Dispose()
{
if (Disposed)
return;
Disposed = true;
_target?.Dispose();
_target = null;
}
}
private sealed class FailingWorldPhase(Exception? firstFailure) : IWorldSceneFramePhase
{
internal static WorldRenderFrameOutcome Success { get; } = new(5, 9, true);
internal int RenderCount { get; private set; }
public WorldRenderFrameOutcome Render(RenderFrameInput input)
{
RenderCount++;
if (firstFailure is not null && RenderCount == 1)
throw firstFailure;
return Success;
}
}
private static RenderPackDescriptor Descriptor() => new(
"failure.pack",
"Failure pack",
new Version(1, 0, 0),
RenderPackApi.Current,
RenderPackTier.Tier1,
[],
[],
[],
[],
[],
[],
[new RenderQualityPreset("default", "Default", [], [], [], 0, 0, 0, 0, 0)],
[],
null)
{
FeatureSummary = "Runtime failure test pack.",
};
private sealed class EmptyAssets : IRenderPackAssets
{
internal static EmptyAssets Instance { get; } = new();
public Stream OpenRead(string assetKey) =>
throw new InvalidOperationException("The failure test pack declares no assets.");
}
}

View file

@ -0,0 +1,448 @@
using AcDream.App.Rendering.Packs;
using AcDream.Plugin.Abstractions.Rendering;
namespace AcDream.App.Tests.Rendering.Packs;
public sealed class RenderPackSpirvValidatorTests
{
[Fact]
public void BuiltInDescriptorValidatesSelectedCelestialContract()
{
RenderPackValidationResult result = RenderPackValidator.ValidateDescriptor(
BuiltInAtmosphericRenderPack.Descriptor,
RenderPackHostCapabilities.Conformance);
Assert.True(result.Success, result.Reason);
}
[Fact]
public void SelectedCelestialSemanticRequiresAuthoredCelestialCapability()
{
RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor;
descriptor = descriptor with
{
RequiredCapabilities = descriptor.RequiredCapabilities
.Where(static capability =>
capability != RenderCapability.AuthoredCelestialDirectionalLight)
.ToArray(),
};
RenderPackValidationResult result = RenderPackValidator.ValidateDescriptor(
descriptor,
RenderPackHostCapabilities.Conformance);
Assert.False(result.Success);
Assert.Equal(
$"Pack '{descriptor.Id}' declares semantic "
+ $"'{RenderSemanticInput.SelectedCelestialDirectionalLight}' but does not "
+ $"require capability '{RenderCapability.AuthoredCelestialDirectionalLight}'.",
result.Reason);
}
[Fact]
public void DirectionalShadowDepthRejectsSunDirectionAlias()
{
RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor;
RenderPassDeclaration shadowPass = descriptor.Passes.Single(static pass =>
pass.Semantic == RenderPassSemantic.DirectionalShadowDepth);
descriptor = descriptor with
{
Passes = descriptor.Passes.Select(pass => ReferenceEquals(pass, shadowPass)
? pass with
{
SemanticInputs = pass.SemanticInputs
.Append(RenderSemanticInput.SunDirection)
.ToArray(),
}
: pass).ToArray(),
};
RenderPackValidationResult result = RenderPackValidator.ValidateDescriptor(
descriptor,
RenderPackHostCapabilities.Conformance);
Assert.False(result.Success);
Assert.Equal(
$"Directional-shadow pass '{shadowPass.Id}' must declare "
+ $"'{RenderSemanticInput.SelectedCelestialDirectionalLight}' and must not "
+ "alias the sun-specific atmospheric direction.",
result.Reason);
}
[Fact]
public void BuiltInPackPassesBinaryShaderInterfaceValidation()
{
RenderPackValidationResult result = RenderPackValidator.ValidateSelectedAssets(
BuiltInAtmosphericRenderPack.Descriptor,
BuiltInAtmosphericRenderPack.CreateAssets(SpirvDirectory()));
Assert.True(result.Success, result.Reason);
}
[Fact]
public void DirectionalShadowUniformRequiresSixMembersAndSelectedSourceAtOffset320()
{
byte[] valid = Shader("directional_shadow_world_opaque.vert.spv");
PipelineVariantDeclaration variant = BuiltInAtmosphericRenderPack.Descriptor
.PipelineVariants.Single(static value =>
value.Semantic
== RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster);
Assert.Equal(336, RenderPackShaderAbi.DirectionalShadowSizeBytes);
Assert.Equal(
6,
BlockMemberCount(
valid,
RenderPackShaderAbi.UniformDescriptorSet,
RenderPackShaderAbi.DirectionalShadowBinding));
Assert.Equal(
320u,
BlockMemberOffset(
valid,
RenderPackShaderAbi.UniformDescriptorSet,
RenderPackShaderAbi.DirectionalShadowBinding,
member: 5));
RenderPackSpirvValidationResult baseline =
RenderPackSpirvValidator.ValidatePipelineVariantShader(
valid,
RenderPackShaderStage.Vertex,
variant);
Assert.True(baseline.Success, baseline.Reason);
byte[] wrongOffset = valid.ToArray();
MutateBlockMemberOffset(
wrongOffset,
RenderPackShaderAbi.UniformDescriptorSet,
RenderPackShaderAbi.DirectionalShadowBinding,
member: 5,
replacement: 304);
RenderPackSpirvValidationResult offsetResult =
RenderPackSpirvValidator.ValidatePipelineVariantShader(
wrongOffset,
RenderPackShaderStage.Vertex,
variant);
Assert.False(offsetResult.Success);
Assert.Contains("336-byte ABI v1", offsetResult.Reason, StringComparison.Ordinal);
byte[] fiveMembers = RemoveLastBlockMember(
valid,
RenderPackShaderAbi.UniformDescriptorSet,
RenderPackShaderAbi.DirectionalShadowBinding);
RenderPackSpirvValidationResult memberCountResult =
RenderPackSpirvValidator.ValidatePipelineVariantShader(
fiveMembers,
RenderPackShaderStage.Vertex,
variant);
Assert.False(memberCountResult.Success);
Assert.Contains("336-byte ABI v1", memberCountResult.Reason, StringComparison.Ordinal);
}
[Fact]
public void FullscreenShaderCannotReadReservedSetZero()
{
RenderPackSpirvValidationResult result =
RenderPackSpirvValidator.ValidatePassShader(
Shader("directional_shadow_world_opaque.vert.spv"),
RenderPackShaderStage.Vertex,
Pass());
Assert.False(result.Success);
Assert.Contains("set 0 binding 0", result.Reason, StringComparison.Ordinal);
}
[Fact]
public void FullscreenShaderCannotAliasRetailSetOne()
{
RenderPackSpirvValidationResult result =
RenderPackSpirvValidator.ValidatePassShader(
Shader("terrain_atmospheric.vert.spv"),
RenderPackShaderStage.Vertex,
Pass(inputs:
[
RenderSemanticInput.Weather,
RenderSemanticInput.SelectedCelestialDirectionalLight,
]));
Assert.False(result.Success);
Assert.Contains("set 1 binding", result.Reason, StringComparison.Ordinal);
}
[Theory]
[InlineData(0, 4u)]
[InlineData(6, 112u)]
public void WrongAtmosphericUniformOffsetOrSizeIsRejected(int member, uint replacement)
{
byte[] spirv = Shader("atmospheric_sun_occlusion.frag.spv");
MutateBlockMemberOffset(
spirv,
RenderPackShaderAbi.UniformDescriptorSet,
RenderPackShaderAbi.AtmosphericFrameBinding,
member,
replacement);
RenderPackSpirvValidationResult result =
RenderPackSpirvValidator.ValidatePassShader(
spirv,
RenderPackShaderStage.Fragment,
Pass(inputs:
[
RenderSemanticInput.SceneDepth,
RenderSemanticInput.SunScreenPosition,
RenderSemanticInput.Weather,
]));
Assert.False(result.Success);
Assert.Contains("AtmosphericFrame", result.Reason, StringComparison.Ordinal);
}
[Fact]
public void DeclaredStageAndMainEntryPointAreEnforced()
{
RenderPackSpirvValidationResult result =
RenderPackSpirvValidator.ValidatePassShader(
Shader("atmospheric_sun_rays.frag.spv"),
RenderPackShaderStage.Vertex,
Pass(inputs: [RenderSemanticInput.FrameTime], reads: ["mask"]));
Assert.False(result.Success);
Assert.Contains("entry point 'main'", result.Reason, StringComparison.Ordinal);
}
[Fact]
public void SampledTextureTableRequiresDeclaredSemanticOrResourceInput()
{
RenderPackSpirvValidationResult result =
RenderPackSpirvValidator.ValidatePassShader(
Shader("atmospheric_bloom_downsample.frag.spv"),
RenderPackShaderStage.Fragment,
Pass());
Assert.False(result.Success);
Assert.Contains("sampled without a declared", result.Reason, StringComparison.Ordinal);
}
[Fact]
public void WritableRendererStorageIsRejectedEvenForAnAllowedBaseRole()
{
byte[] spirv = Shader("directional_shadow_world_opaque.vert.spv");
RemoveNonWritableDecoration(spirv, set: 0, binding: 0);
var variant = new PipelineVariantDeclaration(
"world-caster",
RenderPipelineBaseSemantic.WorldMesh,
"world.vert.spv",
"world.frag.spv",
RenderMaterialClass.Opaque,
[RenderSemanticInput.ShadowCasterTransforms])
{
Semantic = RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster,
};
RenderPackSpirvValidationResult result =
RenderPackSpirvValidator.ValidatePipelineVariantShader(
spirv,
RenderPackShaderStage.Vertex,
variant);
Assert.False(result.Success);
Assert.Contains("storage writes are forbidden", result.Reason, StringComparison.Ordinal);
}
private static RenderPassDeclaration Pass(
IReadOnlyList<RenderSemanticInput>? inputs = null,
IReadOnlyList<string>? reads = null) => new(
"pass",
RenderPassHook.ToneMap,
"pass.vert.spv",
"pass.frag.spv",
inputs ?? [],
reads ?? [],
[]);
private static byte[] Shader(string name) => File.ReadAllBytes(Path.Combine(SpirvDirectory(), name));
private static string SpirvDirectory() => Path.Combine(
RepositoryRoot(), "src", "AcDream.App", "Rendering", "Shaders", "spv");
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.");
}
private static void MutateBlockMemberOffset(
byte[] spirv,
uint set,
uint binding,
int member,
uint replacement)
{
uint[] words = Words(spirv);
uint variable = DescriptorVariable(words, set, binding);
uint pointer = VariableResultType(words, variable);
uint structure = PointerPointee(words, pointer);
for (int index = 5; index < words.Length; index += checked((int)(words[index] >> 16)))
{
int count = checked((int)(words[index] >> 16));
uint opcode = words[index] & 0xffff;
if (opcode == 72 && count >= 5
&& words[index + 1] == structure
&& words[index + 2] == (uint)member
&& words[index + 3] == 35)
{
words[index + 4] = replacement;
CopyBack(words, spirv);
return;
}
}
throw new InvalidOperationException("Target block member offset was not found.");
}
private static void RemoveNonWritableDecoration(byte[] spirv, uint set, uint binding)
{
uint[] words = Words(spirv);
uint variable = DescriptorVariable(words, set, binding);
uint pointer = VariableResultType(words, variable);
uint structure = PointerPointee(words, pointer);
bool mutated = false;
for (int index = 5; index < words.Length; index += checked((int)(words[index] >> 16)))
{
int count = checked((int)(words[index] >> 16));
uint opcode = words[index] & 0xffff;
if (opcode == 71 && count >= 3
&& words[index + 1] == variable
&& words[index + 2] == 24)
{
words[index + 2] = 23; // Coherent, so the descriptor is no longer readonly.
mutated = true;
}
else if (opcode == 72 && count >= 4
&& words[index + 1] == structure
&& words[index + 3] == 24)
{
words[index + 3] = 23;
mutated = true;
}
}
if (!mutated)
throw new InvalidOperationException("Target NonWritable decoration was not found.");
CopyBack(words, spirv);
}
private static int BlockMemberCount(byte[] spirv, uint set, uint binding)
{
uint[] words = Words(spirv);
uint structure = DescriptorStructure(words, set, binding);
for (int index = 5; index < words.Length; index += checked((int)(words[index] >> 16)))
{
int count = checked((int)(words[index] >> 16));
if ((words[index] & 0xffff) == 30 && count >= 2 && words[index + 1] == structure)
return count - 2;
}
throw new InvalidOperationException("Descriptor block structure was not found.");
}
private static uint BlockMemberOffset(
byte[] spirv,
uint set,
uint binding,
int member)
{
uint[] words = Words(spirv);
uint structure = DescriptorStructure(words, set, binding);
for (int index = 5; index < words.Length; index += checked((int)(words[index] >> 16)))
{
int count = checked((int)(words[index] >> 16));
if ((words[index] & 0xffff) == 72
&& count >= 5
&& words[index + 1] == structure
&& words[index + 2] == (uint)member
&& words[index + 3] == 35)
{
return words[index + 4];
}
}
throw new InvalidOperationException("Descriptor block member offset was not found.");
}
private static byte[] RemoveLastBlockMember(byte[] spirv, uint set, uint binding)
{
uint[] words = Words(spirv);
uint structure = DescriptorStructure(words, set, binding);
for (int index = 5; index < words.Length; index += checked((int)(words[index] >> 16)))
{
int count = checked((int)(words[index] >> 16));
if ((words[index] & 0xffff) != 30
|| count < 3
|| words[index + 1] != structure)
{
continue;
}
var mutated = words.ToList();
mutated[index] = ((uint)(count - 1) << 16) | 30u;
mutated.RemoveAt(index + count - 1);
var bytes = new byte[mutated.Count * sizeof(uint)];
Buffer.BlockCopy(mutated.ToArray(), 0, bytes, 0, bytes.Length);
return bytes;
}
throw new InvalidOperationException("Descriptor block structure was not found.");
}
private static uint DescriptorStructure(uint[] words, uint set, uint binding)
{
uint variable = DescriptorVariable(words, set, binding);
uint pointer = VariableResultType(words, variable);
return PointerPointee(words, pointer);
}
private static uint DescriptorVariable(uint[] words, uint set, uint binding)
{
var sets = new Dictionary<uint, uint>();
var bindings = new Dictionary<uint, uint>();
for (int index = 5; index < words.Length; index += checked((int)(words[index] >> 16)))
{
int count = checked((int)(words[index] >> 16));
uint opcode = words[index] & 0xffff;
if (opcode != 71 || count < 4)
continue;
if (words[index + 2] == 34) sets[words[index + 1]] = words[index + 3];
if (words[index + 2] == 33) bindings[words[index + 1]] = words[index + 3];
}
return sets.Keys.Single(id => sets[id] == set && bindings.GetValueOrDefault(id) == binding);
}
private static uint VariableResultType(uint[] words, uint variable)
{
for (int index = 5; index < words.Length; index += checked((int)(words[index] >> 16)))
{
int count = checked((int)(words[index] >> 16));
if ((words[index] & 0xffff) == 59 && count >= 4 && words[index + 2] == variable)
return words[index + 1];
}
throw new InvalidOperationException("Descriptor variable was not found.");
}
private static uint PointerPointee(uint[] words, uint pointer)
{
for (int index = 5; index < words.Length; index += checked((int)(words[index] >> 16)))
{
int count = checked((int)(words[index] >> 16));
if ((words[index] & 0xffff) == 32 && count >= 4 && words[index + 1] == pointer)
return words[index + 3];
}
throw new InvalidOperationException("Descriptor pointer type was not found.");
}
private static uint[] Words(byte[] bytes)
{
var words = new uint[bytes.Length / 4];
Buffer.BlockCopy(bytes, 0, words, 0, bytes.Length);
return words;
}
private static void CopyBack(uint[] words, byte[] bytes) =>
Buffer.BlockCopy(words, 0, bytes, 0, bytes.Length);
}

View file

@ -0,0 +1,369 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Packs;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.Core.World;
using AcDream.Plugin.Abstractions.Rendering;
namespace AcDream.App.Tests.Rendering.Packs;
public sealed class VolumetricShaftRendererTests
{
[Fact]
public void MediumConsumesCurrentB5B6B8AndWritesQuarterResolutionHdr()
{
var device = new RecordingGpuDevice();
using var renderer = Renderer(device, "medium");
GpuTextureSlot depth = TextureSlot(device, "scene-depth");
using IGpuFrame frame = device.BeginFrame();
DirectionalShadowFrameBinding shadow = Shadow(frame, device.DefaultTextureSlot);
AtmosphericFrameInputs inputs = Inputs(800, 600);
device.Clear();
VolumetricShaftOutput output = renderer.Render(frame, in inputs, in shadow, depth);
frame.End();
Assert.True(output.HasTexture);
Assert.Equal(VolumetricShaftGateReason.Rendered, output.Diagnostics.GateReason);
Assert.Equal(200, output.Diagnostics.Width);
Assert.Equal(150, output.Diagnostics.Height);
Assert.Equal(40, output.Diagnostics.RayMarchSteps);
Assert.Equal(200L * 150L * 8L, output.Diagnostics.RetainedGpuBytes);
Assert.Equal(
[
GpuBindingModel.UniformAtmosphericFrame,
GpuBindingModel.UniformDirectionalShadow,
GpuBindingModel.UniformPackPass,
GpuBindingModel.UniformPackSettings,
],
device.OfKind<GpuRecordedUniformBind>().Select(call => call.Binding));
Assert.Equal(depth.Index,
Assert.Single(device.OfKind<GpuRecordedPushConstants>()).Constants.TextureIndexA);
Assert.Equal(1, renderer.Performance.CpuSampleCount);
Assert.Equal(0, renderer.Performance.GpuSampleCount);
}
[Fact]
public void LowDefaultsOffWithoutAllocatingTargetOrRecordingPass()
{
var device = new RecordingGpuDevice();
using var renderer = Renderer(device, "low");
using IGpuFrame frame = device.BeginFrame();
DirectionalShadowFrameBinding shadow = Shadow(frame, device.DefaultTextureSlot);
AtmosphericFrameInputs inputs = Inputs(800, 600);
int targets = device.CreatedRenderTargets.Count;
VolumetricShaftOutput output = renderer.Render(
frame,
in inputs,
in shadow,
device.DefaultTextureSlot);
frame.End();
Assert.False(output.HasTexture);
Assert.Equal(VolumetricShaftGateReason.DisabledByPreset, output.Diagnostics.GateReason);
Assert.Equal(targets, device.CreatedRenderTargets.Count);
Assert.Empty(device.OfKind<GpuRecordedPassBegin>());
Assert.Equal(default, renderer.Performance);
}
[Fact]
public void LowUserOverrideEnablesQuarterResolutionTwentyFourStepShafts()
{
var device = new RecordingGpuDevice();
RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor;
RenderQualityPreset low = Assert.Single(
descriptor.QualityPresets,
value => string.Equals(value.Id, "low", StringComparison.Ordinal));
using var renderer = new VolumetricShaftRenderer(
device,
descriptor,
Assets(),
low,
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["volumetric-strength"] = "0.25",
});
GpuTextureSlot depth = TextureSlot(device, "depth");
RenderOne(renderer, device, Inputs(800, 600), depth);
Assert.Equal(VolumetricShaftGateReason.Rendered, renderer.LastDiagnostics.GateReason);
Assert.Equal(200, renderer.LastDiagnostics.Width);
Assert.Equal(150, renderer.LastDiagnostics.Height);
Assert.Equal(24, renderer.LastDiagnostics.RayMarchSteps);
Assert.True(renderer.LastDiagnostics.Strength > 0f);
}
[Fact]
public void StaleShadowBindingAndIndoorFrameFailClosedWithoutSamplingOldOutput()
{
var device = new RecordingGpuDevice();
using var renderer = Renderer(device, "high");
AtmosphericFrameInputs inputs = Inputs(1280, 720);
using IGpuFrame frame = device.BeginFrame();
var stale = new DirectionalShadowFrameBinding(
frame.Serial - 1,
true,
device.RingBuffer,
0,
DirectionalShadowUniforms.SizeInBytes,
device.DefaultTextureSlot,
4);
VolumetricShaftOutput staleOutput = renderer.Render(
frame,
in inputs,
in stale,
device.DefaultTextureSlot);
DirectionalShadowFrameBinding current = Shadow(frame, device.DefaultTextureSlot);
AtmosphericFrameInputs indoor = inputs with { IsOutdoor = false };
VolumetricShaftOutput indoorOutput = renderer.Render(
frame,
in indoor,
in current,
device.DefaultTextureSlot);
frame.End();
Assert.Equal(VolumetricShaftGateReason.NoCurrentDirectionalShadow,
staleOutput.Diagnostics.GateReason);
Assert.Equal(VolumetricShaftGateReason.Indoor, indoorOutput.Diagnostics.GateReason);
Assert.False(staleOutput.HasTexture);
Assert.False(indoorOutput.HasTexture);
Assert.Empty(device.CreatedRenderTargets);
}
[Fact]
public void ResizeAtomicallyReplacesTargetAndResetsMixedResolutionPerformanceWindow()
{
var device = new RecordingGpuDevice();
using var renderer = Renderer(device, "high");
GpuTextureSlot depth = TextureSlot(device, "depth");
RenderOne(renderer, device, Inputs(800, 600), depth);
RecordingGpuRenderTarget first = Assert.Single(device.CreatedRenderTargets);
Assert.Equal(400, first.Description.Width);
Assert.Equal(1, renderer.Performance.CpuSampleCount);
RenderOne(renderer, device, Inputs(1200, 800), depth);
Assert.True(first.IsDisposed);
Assert.Equal(600, device.CreatedRenderTargets[^1].Description.Width);
Assert.Equal(400, device.CreatedRenderTargets[^1].Description.Height);
Assert.Equal(1, renderer.Performance.CpuSampleCount);
}
[Fact]
public void TargetFailureRollsBackAndRetryPublishesOneOwnedTexture()
{
var device = new RecordingGpuDevice();
using var renderer = Renderer(device, "medium");
GpuTextureSlot depth = TextureSlot(device, "depth");
int baselineSlots = device.LiveTextureSlotCount;
device.RenderTargetFailure = _ => new InvalidOperationException("volumetric allocation failed");
Assert.Throws<InvalidOperationException>(() => RenderOne(
renderer,
device,
Inputs(800, 600),
depth));
Assert.Equal(baselineSlots, device.LiveTextureSlotCount);
Assert.Empty(device.CreatedRenderTargets);
device.RenderTargetFailure = null;
RenderOne(renderer, device, Inputs(800, 600), depth);
Assert.Equal(baselineSlots + 1, device.LiveTextureSlotCount);
Assert.Single(device.CreatedRenderTargets);
}
[Fact]
public void DisposeReleasesOutputSlotTargetAndPipeline()
{
var device = new RecordingGpuDevice();
GpuTextureSlot depth = TextureSlot(device, "depth");
int baselineSlots = device.LiveTextureSlotCount;
var renderer = Renderer(device, "medium");
RenderOne(renderer, device, Inputs(800, 600), depth);
RecordingGpuRenderTarget target = Assert.Single(device.CreatedRenderTargets);
RecordingGpuPipeline pipeline = Assert.Single(device.CreatedPipelines);
renderer.Dispose();
Assert.Equal(baselineSlots, device.LiveTextureSlotCount);
Assert.True(target.IsDisposed);
Assert.True(pipeline.IsDisposed);
}
[Fact]
public void AuthoredWeatherAndSunElevationContinuouslyScaleTheSameFramePolicy()
{
var device = new RecordingGpuDevice();
using var renderer = Renderer(device, "medium");
GpuTextureSlot depth = TextureSlot(device, "depth");
RenderOne(renderer, device, Inputs(800, 600), depth);
float clearLowSun = renderer.LastDiagnostics.Strength;
AtmosphericFrameInputs overcast = Inputs(800, 600) with
{
Weather = WeatherKind.Overcast,
WeatherIntensity = 1f,
};
RenderOne(renderer, device, overcast, depth);
float overcastLowSun = renderer.LastDiagnostics.Strength;
AtmosphericFrameInputs noon = Inputs(800, 600) with
{
SunElevationDegrees = 70f,
};
RenderOne(renderer, device, noon, depth);
Assert.True(clearLowSun > overcastLowSun);
Assert.True(clearLowSun > renderer.LastDiagnostics.Strength);
Assert.True(overcastLowSun > 0f);
}
[Fact]
public void DeclaredActiveDayGroupMultiplierScalesAuthoredPolicy()
{
var device = new RecordingGpuDevice();
using var renderer = Renderer(device, "medium");
GpuTextureSlot depth = TextureSlot(device, "depth");
RenderOne(renderer, device, Inputs(800, 600) with { ActiveDayGroup = 0 }, depth);
float groupZero = renderer.LastDiagnostics.Strength;
RenderOne(renderer, device, Inputs(800, 600) with { ActiveDayGroup = 1 }, depth);
float groupOne = renderer.LastDiagnostics.Strength;
Assert.Equal(groupZero * 0.35f, groupOne, 5);
}
[Fact]
public void DeclaredVolumetricElevationCurveControlsShaftStrength()
{
var device = new RecordingGpuDevice();
RenderPackDescriptor source = BuiltInAtmosphericRenderPack.Descriptor;
RenderPackDescriptor changed = source with
{
AtmospherePolicy = source.AtmospherePolicy! with
{
VolumetricShaftSunElevationResponse =
[
new SunElevationResponsePoint(-90, 0.25),
new SunElevationResponsePoint(90, 0.25),
],
},
};
RenderQualityPreset medium = Assert.Single(changed.QualityPresets, value =>
value.Semantic == RenderQualitySemantic.Medium);
using var renderer = new VolumetricShaftRenderer(
device,
changed,
Assets(),
medium);
GpuTextureSlot depth = TextureSlot(device, "depth");
RenderOne(renderer, device, Inputs(800, 600), depth);
Assert.Equal(0.25f * 0.35f, renderer.LastDiagnostics.Strength, 5);
}
[Fact]
public void ShaderUsesVulkanYFlipWorldMetreBiasAndShadowStrengthMix()
{
string source = File.ReadAllText(Path.Combine(
RepositoryRoot(),
"src", "AcDream.App", "Rendering", "Shaders", "atmospheric_volumetric.frag"));
Assert.Contains("0.5 - ndc.y * 0.5", source, StringComparison.Ordinal);
Assert.Contains("surfaceToSun * max(uShadowBiasMeters.x, 0.0)", source,
StringComparison.Ordinal);
Assert.Contains("mix(1.0, visible, clamp(uShadowControl.x, 0.0, 1.0))", source,
StringComparison.Ordinal);
Assert.DoesNotContain("ndc.z -", source, StringComparison.Ordinal);
}
private static void RenderOne(
VolumetricShaftRenderer renderer,
RecordingGpuDevice device,
AtmosphericFrameInputs inputs,
GpuTextureSlot depth)
{
using IGpuFrame frame = device.BeginFrame();
DirectionalShadowFrameBinding shadow = Shadow(frame, device.DefaultTextureSlot);
renderer.Render(frame, in inputs, in shadow, depth);
frame.End();
}
private static DirectionalShadowFrameBinding Shadow(
IGpuFrame frame,
GpuTextureSlot shadowTexture)
{
GpuRingAllocation allocation = frame.AllocateRing(
DirectionalShadowUniforms.SizeInBytes,
GpuRingUsage.Uniform);
allocation.Data.Clear();
return new DirectionalShadowFrameBinding(
frame.Serial,
true,
allocation.Buffer,
allocation.OffsetBytes,
DirectionalShadowUniforms.SizeInBytes,
shadowTexture,
3);
}
private static GpuTextureSlot TextureSlot(RecordingGpuDevice device, string name)
{
IGpuTexture texture = device.CreateTexture(new GpuTextureDescription(
name,
GpuTextureKind.Texture2D,
GpuTextureFormat.Rgba8Unorm,
1,
1,
1,
1));
return device.RegisterTexture(texture, device.CreateSampler(GpuSamplerDescription.WorldClamp));
}
private static VolumetricShaftRenderer Renderer(
RecordingGpuDevice device,
string presetId)
{
RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor;
RenderQualityPreset preset = Assert.Single(
descriptor.QualityPresets,
value => string.Equals(value.Id, presetId, StringComparison.Ordinal));
return new VolumetricShaftRenderer(device, descriptor, Assets(), preset);
}
private static AtmosphericFrameInputs Inputs(int width, int height) => new(
new Vector2(0.5f, 0.4f),
true,
12f,
new Vector3(1f, 0.85f, 0.7f),
Vector3.Normalize(new Vector3(0.2f, 0.5f, 0.8f)),
1f,
Matrix4x4.Identity,
0,
WeatherKind.Clear,
0f,
1d / 60d,
width,
height,
true);
private static IRenderPackAssets Assets() =>
BuiltInAtmosphericRenderPack.CreateAssets(Path.Combine(
RepositoryRoot(),
"src", "AcDream.App", "Rendering", "Shaders", "spv"));
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 repository root.");
}
}

View file

@ -0,0 +1,90 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
namespace AcDream.App.Tests.Rendering;
public sealed class RetailDetailTextureContractTests
{
private static readonly TerrainAtlas.RetailDetailTextureBinding Available = new(
new GpuTextureSlot(7),
Tiling: 4f,
SurfaceTextureId: 0x05001787,
RenderSurfaceId: 0x06006D58,
Width: 256,
Height: 256);
[Fact]
public void ExistingBuildingDetailSettingIsTheLivePassGate()
{
Assert.False(RetailDetailTextureContract.ShouldRender(false, Available));
Assert.True(RetailDetailTextureContract.ShouldRender(true, Available));
Assert.False(RetailDetailTextureContract.ShouldRender(true, default));
}
[Fact]
public void OpaqueDetailUsesDepthEqualityWhileTransparentDetailDoesNot()
{
Assert.Equal(
GpuCompareOp.Equal,
RetailDetailTextureContract.DetailDepthCompare(transparent: false));
Assert.Equal(
GpuCompareOp.LessOrEqual,
RetailDetailTextureContract.DetailDepthCompare(transparent: true));
}
[Theory]
[InlineData(0f, 1f)]
[InlineData(10f, 1f)]
[InlineData(30f, 0.5f)]
[InlineData(50f, 0f)]
[InlineData(80f, 0f)]
public void DistanceFadeUsesPositiveViewDepthInMetres(
float depthMetres,
float expected)
{
Assert.Equal(
expected,
RetailDetailTextureContract.FadeForPositiveViewDepthMetres(depthMetres),
precision: 5);
}
[Fact]
public void FadeZeroIsExactNoOpAndNeutralRgbEqualsAlpha()
{
var brighteningSample = new Vector4(0.459f, 0.459f, 0.459f, 0.282f);
Assert.Equal(
Vector3.One,
RetailDetailTextureContract.FramebufferFactor(brighteningSample, fade: 0f));
var neutral = new Vector4(0.4f, 0.4f, 0.4f, 0.4f);
Vector3 neutralFactor = RetailDetailTextureContract.FramebufferFactor(neutral, fade: 1f);
Assert.Equal(1f, neutralFactor.X, precision: 5);
Assert.Equal(1f, neutralFactor.Y, precision: 5);
Assert.Equal(1f, neutralFactor.Z, precision: 5);
}
[Fact]
public void RetailBlendPreservesMeasuredBrightening()
{
var measured = new Vector4(0.459f, 0.459f, 0.459f, 0.282f);
Vector3 factor = RetailDetailTextureContract.FramebufferFactor(measured, fade: 1f);
Assert.Equal(1.177f, factor.X, precision: 5);
Assert.Equal(1.177f, factor.Y, precision: 5);
Assert.Equal(1.177f, factor.Z, precision: 5);
}
[Fact]
public void EnabledDerethCategoryTextureKeepsItsMeasuredBrightening()
{
var derethCategory = new Vector4(0.165f, 0.165f, 0.165f, 0.132f);
Vector3 factor = RetailDetailTextureContract.FramebufferFactor(
derethCategory,
fade: 1f);
Assert.Equal(1.033f, factor.X, precision: 5);
Assert.Equal(1.033f, factor.Y, precision: 5);
Assert.Equal(1.033f, factor.Z, precision: 5);
}
}

View file

@ -59,6 +59,27 @@ public sealed class StaticRenderProjectionJournalTests
Assert.Equal(expected.Geometry, projected.Source.GeometryFingerprint);
Assert.Equal(expected.Appearance, projected.Source.AppearanceFingerprint);
Assert.Equal(LandblockId, projected.Residency.OwnerLandblockId);
Assert.Equal(
RenderCasterIdentityKind.OutdoorStatic,
projected.EntityPayload.CasterIdentity);
}
[Fact]
public void Reconcile_RetainsAuthoritativeBuildingIdentity()
{
var journal = new RenderProjectionJournal(Generation(21));
var statics = new StaticRenderProjectionJournal(journal);
LandblockBuild build = Build(
LandblockId,
[Entity(9, building: true)],
includeShell: false);
statics.Reconcile(build, Publication(build));
Assert.Equal(
RenderCasterIdentityKind.Building,
Assert.Single(journal.Pending.ToArray())
.Record.EntityPayload.CasterIdentity);
}
[Fact]
@ -442,7 +463,8 @@ public sealed class StaticRenderProjectionJournalTests
private static WorldEntity Entity(
uint id,
uint serverGuid = 0,
Vector3 position = default) =>
Vector3 position = default,
bool building = false) =>
new()
{
Id = id,
@ -450,6 +472,7 @@ public sealed class StaticRenderProjectionJournalTests
SourceGfxObjOrSetupId = 0x01000000u + id,
Position = position,
Rotation = Quaternion.Identity,
IsBuildingShell = building,
MeshRefs =
[
new MeshRef(

View file

@ -0,0 +1,106 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Packs;
using AcDream.Core.World;
namespace AcDream.App.Tests.Rendering;
public sealed class VolumetricShaftQualityTests
{
[Fact]
public void Weak_hardware_preserves_contract_but_defaults_volumetrics_off()
{
VolumetricShaftQuality low = VolumetricShaftQuality.For(DirectionalShadowPreset.Low);
VolumetricShaftQuality medium = VolumetricShaftQuality.For(DirectionalShadowPreset.Medium);
VolumetricShaftQuality high = VolumetricShaftQuality.For(DirectionalShadowPreset.High);
Assert.False(low.EnabledByDefault);
Assert.Equal(0.25f, medium.ResolutionScale);
Assert.Equal(0.50f, high.ResolutionScale);
Assert.True(low.RayMarchSteps < medium.RayMarchSteps);
Assert.True(medium.RayMarchSteps < high.RayMarchSteps);
}
[Fact]
public void Clear_raking_sun_is_stronger_than_noon_and_overcast()
{
VolumetricShaftQuality quality =
VolumetricShaftQuality.For(DirectionalShadowPreset.High);
DirectionalShadowEnvironmentState raking = Enabled(elevationSin: 0.20f);
DirectionalShadowEnvironmentState noon = Enabled(elevationSin: 0.95f);
VolumetricShaftFrameParameters clear = VolumetricShaftPolicy.Evaluate(
in quality,
userEnabled: true,
in raking,
WeatherKind.Clear,
Vector3.One,
1f);
VolumetricShaftFrameParameters highSun = VolumetricShaftPolicy.Evaluate(
in quality,
userEnabled: true,
in noon,
WeatherKind.Clear,
Vector3.One,
1f);
VolumetricShaftFrameParameters overcast = VolumetricShaftPolicy.Evaluate(
in quality,
userEnabled: true,
in raking,
WeatherKind.Overcast,
Vector3.One,
1f);
Assert.True(clear.Enabled);
Assert.True(clear.Strength > highSun.Strength);
Assert.True(clear.Strength > overcast.Strength);
}
[Fact]
public void Missing_shadow_indoor_or_user_off_cannot_leave_shafts_enabled()
{
VolumetricShaftQuality quality =
VolumetricShaftQuality.For(DirectionalShadowPreset.Medium);
DirectionalShadowEnvironmentState indoor = new(
DirectionalShadowGateReason.Indoor,
Vector3.UnitZ,
0.5f,
0f,
1f);
DirectionalShadowEnvironmentState outdoor = Enabled(0.2f);
Assert.False(VolumetricShaftPolicy.Evaluate(
in quality, true, in indoor, WeatherKind.Clear, Vector3.One, 1f).Enabled);
Assert.False(VolumetricShaftPolicy.Evaluate(
in quality, false, in outdoor, WeatherKind.Clear, Vector3.One, 1f).Enabled);
}
[Fact]
public void Moon_shadow_source_never_manufactures_sun_shafts()
{
VolumetricShaftQuality quality =
VolumetricShaftQuality.For(DirectionalShadowPreset.High);
DirectionalShadowEnvironmentState moon = Enabled(0.35f) with
{
SourceKind = AuthoredCelestialShadowSourceKind.DominantMoon,
};
VolumetricShaftFrameParameters result = VolumetricShaftPolicy.Evaluate(
in quality,
userEnabled: true,
in moon,
WeatherKind.Clear,
Vector3.One,
authoredSunBrightness: 1f);
Assert.False(result.Enabled);
}
private static DirectionalShadowEnvironmentState Enabled(float elevationSin) => new(
DirectionalShadowGateReason.Enabled,
Vector3.Normalize(new Vector3(0.5f, 0.5f, elevationSin)),
elevationSin,
Strength: 1f,
SoftnessMultiplier: 1f,
SourceKind: AuthoredCelestialShadowSourceKind.Sun);
}

View file

@ -0,0 +1,501 @@
using System.Numerics;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Wb;
using AcDream.Core.Meshing;
using AcDream.Core.World;
using DatReaderWriter.Enums;
namespace AcDream.App.Tests.Rendering.Wb;
public sealed class DirectionalShadowPreparedDrawTests
{
[Theory]
[InlineData(TranslucencyKind.Opaque, true, DirectionalShadowCasterMaterial.Opaque)]
[InlineData(TranslucencyKind.ClipMap, true, DirectionalShadowCasterMaterial.AlphaCutout)]
[InlineData(TranslucencyKind.AlphaBlend, false, DirectionalShadowCasterMaterial.Opaque)]
[InlineData(TranslucencyKind.Additive, false, DirectionalShadowCasterMaterial.Opaque)]
[InlineData(TranslucencyKind.InvAlpha, false, DirectionalShadowCasterMaterial.Opaque)]
internal void MaterialPolicy_PreservesCutoutsAndExcludesTrueTransparency(
TranslucencyKind source,
bool expectedAccepted,
DirectionalShadowCasterMaterial expectedMaterial)
{
bool accepted = DirectionalShadowPreparedDraws.TryClassifyMaterial(
source,
out DirectionalShadowCasterMaterial material);
Assert.Equal(expectedAccepted, accepted);
if (accepted)
Assert.Equal(expectedMaterial, material);
}
[Theory]
[InlineData(0f, false)]
[InlineData(0.0001f, true)]
[InlineData(1f, true)]
[InlineData(float.NaN, true)]
internal void FadePolicy_OnlyExactOpaquePartsCast(
float translucency,
bool excluded)
{
Assert.Equal(
excluded,
DirectionalShadowPreparedDraws.FadeExcludesCaster(translucency));
}
[Fact]
public void Complete_GroupsCommandsAndRetainsExactCurrentTransforms()
{
var product = new DirectionalShadowPreparedDraws();
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(4);
Assert.True(product.TryBegin(generation, 7, estimatedInstances: 3));
Matrix4x4 first = Matrix4x4.CreateRotationZ(0.25f)
* Matrix4x4.CreateTranslation(10f, 20f, 30f);
Matrix4x4 second = Matrix4x4.CreateScale(1.25f)
* Matrix4x4.CreateTranslation(-2f, 3f, 7f);
Matrix4x4 leaves = Matrix4x4.CreateRotationX(0.5f)
* Matrix4x4.CreateTranslation(100f, 200f, 12f);
product.Add(
firstIndex: 40,
baseVertex: 3,
indexCount: 18,
GpuTextureSlot.Unassigned,
textureLayer: 0,
CullMode.CounterClockwise,
DirectionalShadowCasterMaterial.Opaque,
in first);
product.Add(
firstIndex: 40,
baseVertex: 3,
indexCount: 18,
GpuTextureSlot.Unassigned,
textureLayer: 0,
CullMode.CounterClockwise,
DirectionalShadowCasterMaterial.Opaque,
in second);
product.Add(
firstIndex: 90,
baseVertex: 11,
indexCount: 24,
new GpuTextureSlot(17),
textureLayer: 6,
CullMode.None,
DirectionalShadowCasterMaterial.AlphaCutout,
in leaves);
var inputStats = new DirectionalShadowPreparationStats(
SourceCasters: 2,
SourceMeshRefs: 3,
SourceParts: 3,
SourceBatches: 5,
PreparedInstances: 0,
PreparedOpaqueCommands: 0,
PreparedAlphaCutoutCommands: 0,
RejectedTransparentBatches: 2,
RejectedFadedParts: 1,
MissingMeshes: 0,
UnresolvedAlphaCutoutTextures: 0);
product.Complete(generation, 7, in inputStats);
Assert.Equal(3, product.Transforms.Length);
Assert.Equal(2, product.Commands.Length);
Assert.Equal(1, product.OpaqueCommandCount);
Assert.Equal(1, product.AlphaCutoutCommandCount);
Assert.Single(product.OpaqueCommands.ToArray());
Assert.Single(product.AlphaCutoutCommands.ToArray());
Assert.Single(product.OpaqueBatches.ToArray());
Assert.Single(product.AlphaCutoutBatches.ToArray());
Assert.Single(product.OpaqueRuns.ToArray());
Assert.Single(product.AlphaCutoutRuns.ToArray());
Assert.Equal(0, product.OpaqueRuns[0].StartCommand);
Assert.Equal(1, product.AlphaCutoutRuns[0].StartCommand);
Assert.Equal(2u, product.Commands[0].InstanceCount);
Assert.Equal(0u, product.Commands[0].BaseInstance);
Assert.Equal(1u, product.Commands[1].InstanceCount);
Assert.Equal(2u, product.Commands[1].BaseInstance);
Assert.Equal(DirectionalShadowCasterMaterial.Opaque, product.Batches[0].Material);
Assert.False(product.Batches[0].TextureSlot.IsAssigned);
Assert.Equal(DirectionalShadowCasterMaterial.AlphaCutout, product.Batches[1].Material);
Assert.Equal(17u, product.Batches[1].TextureSlot.Index);
Assert.Equal(6u, product.Batches[1].TextureLayer);
Assert.Contains(first, product.Transforms.ToArray());
Assert.Contains(second, product.Transforms.ToArray());
Assert.Equal(leaves, product.Transforms[2]);
Assert.Equal(3, product.Stats.PreparedInstances);
Assert.Equal(2, product.Stats.RejectedTransparentBatches);
Assert.Equal(1, product.Stats.RejectedFadedParts);
}
[Fact]
public void SameCasterBuild_ReplaysWithoutReclassificationOrStorageGrowth()
{
var product = new DirectionalShadowPreparedDraws();
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(8);
Matrix4x4 exactMeshRefTransform = new(
1, 2, 3, 4,
5, 6, 7, 8,
9, 10, 11, 12,
13, 14, 15, 16);
Assert.True(product.TryBegin(generation, 20, estimatedInstances: 1));
product.Add(
1,
2,
3,
GpuTextureSlot.Unassigned,
0,
CullMode.Clockwise,
DirectionalShadowCasterMaterial.Opaque,
in exactMeshRefTransform);
DirectionalShadowPreparationStats stats = default;
product.Complete(generation, 20, in stats);
long retained = product.RetainedScratchBytes;
ulong buildSequence = product.BuildSequence;
Assert.False(product.TryBegin(generation, 20, estimatedInstances: 100));
Assert.Equal(buildSequence, product.BuildSequence);
Assert.Equal(retained, product.RetainedScratchBytes);
Assert.Equal(exactMeshRefTransform, product.Transforms[0]);
Assert.Single(product.Commands.ToArray());
}
[Fact]
public void StableTopology_ComposesSlimPoseWithoutMutatingCasterOrAllocating()
{
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(10);
Matrix4x4 originalRoot = Matrix4x4.CreateTranslation(1f, 2f, 3f);
Matrix4x4 originalPart = Matrix4x4.CreateTranslation(4f, 5f, 6f);
Matrix4x4 setupPart = Matrix4x4.CreateRotationX(0.25f)
* Matrix4x4.CreateTranslation(7f, 8f, 9f);
DirectionalShadowCaster[] casters =
[
new DirectionalShadowCaster(
Projection(originalRoot, originalPart),
DirectionalShadowCasterKind.LiveDynamic),
];
var product = new DirectionalShadowPreparedDraws();
Assert.True(product.TryBegin(
generation,
casterBuildSequence: 12,
estimatedInstances: 2,
renderDataAvailabilityVersion: 5,
translucencyFadeRevision: 7));
product.MapCasterIdentity(
0,
casters[0].Projection.Id,
casters[0].Projection.ProjectionClass);
DirectionalShadowTransformSource direct =
DirectionalShadowTransformSource.Dynamic(
casterIndex: 0,
meshIndex: 0,
isSetupPart: false,
in setupPart);
DirectionalShadowTransformSource setup =
DirectionalShadowTransformSource.Dynamic(
casterIndex: 0,
meshIndex: 0,
isSetupPart: true,
in setupPart);
Matrix4x4 originalDirect = originalPart * originalRoot;
Matrix4x4 originalSetup = setupPart * originalPart * originalRoot;
product.Add(
10,
0,
3,
GpuTextureSlot.Unassigned,
0,
CullMode.Clockwise,
DirectionalShadowCasterMaterial.Opaque,
in originalDirect,
in direct);
product.Add(
20,
0,
3,
GpuTextureSlot.Unassigned,
0,
CullMode.Clockwise,
DirectionalShadowCasterMaterial.Opaque,
in originalSetup,
in setup);
DirectionalShadowPreparationStats stats = default;
product.Complete(
generation,
12,
in stats,
renderDataAvailabilityVersion: 5,
translucencyFadeRevision: 7);
ulong topologyBuild = product.BuildSequence;
float exactRootX = BitConverter.Int32BitsToSingle(0x41234567);
float exactPartY = BitConverter.Int32BitsToSingle(0x40ABCDEF);
Matrix4x4 currentRoot = Matrix4x4.CreateRotationZ(0.15f)
* Matrix4x4.CreateTranslation(exactRootX, 12f, 13f);
Matrix4x4 currentPart = Matrix4x4.CreateRotationY(0.35f)
* Matrix4x4.CreateTranslation(14f, exactPartY, 16f);
RenderProjectionRecord current = Projection(currentRoot, currentPart);
DirectionalShadowTransformSnapshot snapshot =
DirectionalShadowTransformSnapshot.Capture(in current);
DirectionalShadowChangedPose[] changed = [new(0, snapshot)];
product.RefreshDynamicTransforms(changed);
AssertMatrixBitsEqual(currentPart * currentRoot, product.Transforms[0]);
AssertMatrixBitsEqual(
setupPart * currentPart * currentRoot,
product.Transforms[1]);
Assert.Equal(topologyBuild, product.BuildSequence);
Assert.Equal(2, product.LastDynamicTransformRefreshCount);
Assert.False(product.RequiresTopologyBuild(generation, 12, 5, 7));
AssertMatrixBitsEqual(
originalRoot,
casters[0].Projection.Transform.LocalToWorld);
AssertMatrixBitsEqual(
originalPart,
casters[0].Projection.EntityPayload.MeshRefs[0].PartTransform);
product.RefreshDynamicTransforms(
ReadOnlySpan<DirectionalShadowChangedPose>.Empty);
Assert.Empty(product.DynamicTransformSlots.ToArray());
Assert.Equal(0, product.LastDynamicTransformRefreshCount);
AssertMatrixBitsEqual(currentPart * currentRoot, product.Transforms[0]);
AssertMatrixBitsEqual(
setupPart * currentPart * currentRoot,
product.Transforms[1]);
product.RefreshDynamicTransforms(changed, denseRefresh: true);
Assert.True(product.LastDynamicTransformRefreshWasDense);
Assert.Equal([0, 1], product.DynamicTransformSlots.ToArray());
product.RefreshDynamicTransforms(changed);
long before = GC.GetAllocatedBytesForCurrentThread();
for (int iteration = 0; iteration < 256; iteration++)
product.RefreshDynamicTransforms(changed);
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.Equal(0, allocated);
RenderProjectionRecord wrongId = current with
{
Id = RenderProjectionId.FromRaw(99),
};
DirectionalShadowTransformSnapshot wrongSnapshot =
DirectionalShadowTransformSnapshot.Capture(in wrongId);
DirectionalShadowChangedPose[] wrongIdentity = [new(0, wrongSnapshot)];
InvalidOperationException identityFailure = Assert.Throws<
InvalidOperationException>(
() => product.RefreshDynamicTransforms(wrongIdentity));
Assert.Contains("does not match", identityFailure.Message);
DirectionalShadowChangedPose[] staleSlot = [new(1, snapshot)];
InvalidOperationException slotFailure = Assert.Throws<
InvalidOperationException>(
() => product.RefreshDynamicTransforms(staleSlot));
Assert.Contains("stale or unmapped caster index", slotFailure.Message);
}
[Fact]
public void StableTopology_MapsChangedCasterToOnlyItsRetainedTransformSlots()
{
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(12);
Matrix4x4 firstRoot = Matrix4x4.CreateTranslation(1f, 2f, 3f);
Matrix4x4 secondRoot = Matrix4x4.CreateTranslation(4f, 5f, 6f);
Matrix4x4 firstPart = Matrix4x4.CreateTranslation(7f, 8f, 9f);
Matrix4x4 secondPart = Matrix4x4.CreateTranslation(10f, 11f, 12f);
DirectionalShadowCaster[] casters =
[
new DirectionalShadowCaster(
Projection(firstRoot, firstPart),
DirectionalShadowCasterKind.LiveDynamic),
new DirectionalShadowCaster(
Projection(secondRoot, secondPart) with
{
Id = RenderProjectionId.FromRaw(2),
},
DirectionalShadowCasterKind.EquippedChild),
];
var product = new DirectionalShadowPreparedDraws();
Assert.True(product.TryBegin(generation, 13, estimatedInstances: 2));
product.MapCasterIdentity(
0,
casters[0].Projection.Id,
casters[0].Projection.ProjectionClass);
product.MapCasterIdentity(
1,
casters[1].Projection.Id,
casters[1].Projection.ProjectionClass);
Matrix4x4 setupPart = Matrix4x4.Identity;
DirectionalShadowTransformSource firstSource =
DirectionalShadowTransformSource.Dynamic(
0,
0,
false,
in setupPart);
DirectionalShadowTransformSource secondSource =
DirectionalShadowTransformSource.Dynamic(
1,
0,
false,
in setupPart);
Matrix4x4 firstWorld = firstPart * firstRoot;
Matrix4x4 secondWorld = secondPart * secondRoot;
product.Add(
1, 0, 3, GpuTextureSlot.Unassigned, 0,
CullMode.Clockwise,
DirectionalShadowCasterMaterial.Opaque,
in firstWorld,
in firstSource);
product.Add(
2, 0, 3, GpuTextureSlot.Unassigned, 0,
CullMode.Clockwise,
DirectionalShadowCasterMaterial.Opaque,
in secondWorld,
in secondSource);
DirectionalShadowPreparationStats stats = default;
product.Complete(generation, 13, in stats);
Matrix4x4 movedRoot = Matrix4x4.CreateTranslation(40f, 50f, 60f);
Matrix4x4 movedPart = Matrix4x4.CreateTranslation(70f, 80f, 90f);
casters[1] = casters[1] with
{
Projection = Projection(movedRoot, movedPart) with
{
Id = RenderProjectionId.FromRaw(2),
},
};
product.RefreshDynamicTransforms(casters, [1]);
Assert.Equal([0, 1], product.AllDynamicTransformSlots.ToArray());
Assert.Equal([1], product.DynamicTransformSlots.ToArray());
AssertMatrixBitsEqual(firstWorld, product.Transforms[0]);
AssertMatrixBitsEqual(movedPart * movedRoot, product.Transforms[1]);
Assert.Equal(1, product.LastDynamicTransformRefreshCount);
product.RefreshDenseDynamicTransforms(casters);
Assert.True(product.LastDynamicTransformRefreshWasDense);
Assert.Equal([0, 1], product.DynamicTransformSlots.ToArray());
Assert.Equal(2, product.LastDynamicTransformRefreshCount);
AssertMatrixBitsEqual(firstWorld, product.Transforms[0]);
AssertMatrixBitsEqual(movedPart * movedRoot, product.Transforms[1]);
Matrix4x4 denseFirstRoot = Matrix4x4.CreateTranslation(100f, 101f, 102f);
Matrix4x4 denseFirstPart = Matrix4x4.CreateTranslation(103f, 104f, 105f);
casters[0] = casters[0] with
{
Projection = Projection(denseFirstRoot, denseFirstPart),
};
RenderProjectionRecord firstProjection = casters[0].Projection;
RenderProjectionRecord secondProjection = casters[1].Projection;
DirectionalShadowTransformSnapshot firstPose =
DirectionalShadowTransformSnapshot.Capture(in firstProjection);
DirectionalShadowTransformSnapshot secondPose =
DirectionalShadowTransformSnapshot.Capture(in secondProjection);
DirectionalShadowChangedPose[] reversedDenseChanges =
[
new(1, secondPose),
new(0, firstPose),
];
product.RefreshDynamicTransforms(reversedDenseChanges, denseRefresh: true);
Assert.True(product.LastDynamicTransformRefreshWasDense);
Assert.Equal([0, 1], product.DynamicTransformSlots.ToArray());
Assert.Equal(2, product.LastDynamicTransformRefreshCount);
AssertMatrixBitsEqual(
denseFirstPart * denseFirstRoot,
product.Transforms[0]);
AssertMatrixBitsEqual(movedPart * movedRoot, product.Transforms[1]);
long beforeDense = GC.GetAllocatedBytesForCurrentThread();
for (int iteration = 0; iteration < 256; iteration++)
{
product.RefreshDynamicTransforms(
reversedDenseChanges,
denseRefresh: true);
}
long denseAllocated =
GC.GetAllocatedBytesForCurrentThread() - beforeDense;
Assert.Equal(0, denseAllocated);
Assert.True(product.TryBegin(generation, 14, estimatedInstances: 0));
product.Complete(generation, 14, in stats);
product.RefreshDynamicTransforms(casters, [0]);
Assert.Empty(product.DynamicTransformSlots.ToArray());
Assert.Equal(0, product.LastDynamicTransformRefreshCount);
}
[Fact]
public void ResourceAvailabilityFadeAndPendingTextureInvalidateRetainedTopology()
{
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(11);
var product = new DirectionalShadowPreparedDraws();
Assert.True(product.TryBegin(generation, 2, 0, 20, 30));
var pending = new DirectionalShadowPreparationStats(
SourceCasters: 1,
SourceMeshRefs: 1,
SourceParts: 1,
SourceBatches: 1,
PreparedInstances: 0,
PreparedOpaqueCommands: 0,
PreparedAlphaCutoutCommands: 0,
RejectedTransparentBatches: 0,
RejectedFadedParts: 0,
MissingMeshes: 1,
UnresolvedAlphaCutoutTextures: 0);
product.Complete(generation, 2, in pending, 20, 30);
Assert.False(product.RequiresTopologyBuild(generation, 2, 20, 30));
Assert.True(product.RequiresTopologyBuild(generation, 2, 21, 30));
Assert.True(product.RequiresTopologyBuild(generation, 2, 20, 31));
Assert.True(product.TryBegin(generation, 2, 0, 21, 30));
pending = pending with { UnresolvedAlphaCutoutTextures = 1 };
product.Complete(generation, 2, in pending, 21, 30);
Assert.True(product.RequiresTopologyBuild(generation, 2, 21, 30));
}
[Fact]
public void SetupComposition_UsesPublishedMeshRefTransformWithoutAnotherPose()
{
Matrix4x4 root = Matrix4x4.CreateRotationZ(0.1f)
* Matrix4x4.CreateTranslation(50f, 60f, 70f);
Matrix4x4 currentMeshRef = Matrix4x4.CreateRotationY(0.2f)
* Matrix4x4.CreateTranslation(4f, 5f, 6f);
Matrix4x4 authoredSetupPart = Matrix4x4.CreateRotationX(0.3f)
* Matrix4x4.CreateTranslation(1f, 2f, 3f);
Matrix4x4 actual = WbDrawDispatcher.ComposePartWorldMatrix(
root,
currentMeshRef,
authoredSetupPart);
Assert.Equal(authoredSetupPart * currentMeshRef * root, actual);
}
private static RenderProjectionRecord Projection(
in Matrix4x4 root,
in Matrix4x4 part) =>
new RenderProjectionRecord() with
{
Id = RenderProjectionId.FromRaw(1),
ProjectionClass = RenderProjectionClass.LiveDynamicRoot,
Transform = new RenderTransform(root),
EntityPayload = new RenderEntityPayload(
[new MeshRef(0x01000001, part)],
PaletteOverride: null,
IsBuildingShell: false),
};
private static void AssertMatrixBitsEqual(
Matrix4x4 expected,
Matrix4x4 actual)
{
ReadOnlySpan<byte> expectedBits = MemoryMarshal.AsBytes(
MemoryMarshal.CreateReadOnlySpan(ref expected, 1));
ReadOnlySpan<byte> actualBits = MemoryMarshal.AsBytes(
MemoryMarshal.CreateReadOnlySpan(ref actual, 1));
Assert.True(expectedBits.SequenceEqual(actualBits));
}
}

View file

@ -0,0 +1,75 @@
using AcDream.App.Rendering;
namespace AcDream.App.Tests.Rendering.Wb;
public sealed class DirectionalShadowTerrainPreparedDrawTests
{
[Fact]
public void Product_ContainsEveryResidentRangeWithoutVisibilityClassification()
{
var product = new DirectionalShadowTerrainPreparedDraws();
Assert.True(product.TryBegin(frameSequence: 10, estimatedCommands: 3));
DirectionalShadowTerrainRange[] resident =
[
new(FirstIndex: 0, IndexCount: 384),
new(FirstIndex: 384, IndexCount: 384),
new(FirstIndex: 768, IndexCount: 384),
];
foreach (DirectionalShadowTerrainRange range in resident)
product.Add(in range);
product.Complete(frameSequence: 10);
Assert.Equal(3, product.Commands.Length);
Assert.Equal(0u, product.Commands[0].FirstIndex);
Assert.Equal(384u, product.Commands[1].FirstIndex);
Assert.Equal(768u, product.Commands[2].FirstIndex);
Assert.All(
product.Commands.ToArray(),
static command =>
{
Assert.Equal(384u, command.Count);
Assert.Equal(1u, command.InstanceCount);
Assert.Equal(0, command.BaseVertex);
});
}
[Fact]
public void SameFrame_AllCascadesReuseOneTerrainBuild()
{
var product = new DirectionalShadowTerrainPreparedDraws();
Assert.True(product.TryBegin(frameSequence: 1, estimatedCommands: 1));
var range = new DirectionalShadowTerrainRange(100, 24);
product.Add(in range);
product.Complete(frameSequence: 1);
long retained = product.RetainedScratchBytes;
Assert.False(product.TryBegin(frameSequence: 1, estimatedCommands: 500));
Assert.Equal(1ul, product.BuildSequence);
Assert.Equal(retained, product.RetainedScratchBytes);
Assert.Single(product.Commands.ToArray());
}
[Fact]
public void NextFrame_RebuildsIntoRetainedStorage()
{
var product = new DirectionalShadowTerrainPreparedDraws();
Assert.True(product.TryBegin(frameSequence: 1, estimatedCommands: 2));
var first = new DirectionalShadowTerrainRange(10, 3);
var second = new DirectionalShadowTerrainRange(20, 6);
product.Add(in first);
product.Add(in second);
product.Complete(frameSequence: 1);
long retained = product.RetainedScratchBytes;
Assert.True(product.TryBegin(frameSequence: 2, estimatedCommands: 1));
product.Add(in second);
product.Complete(frameSequence: 2);
Assert.Equal(2ul, product.BuildSequence);
Assert.Equal(retained, product.RetainedScratchBytes);
Assert.Single(product.Commands.ToArray());
Assert.Equal(20u, product.Commands[0].FirstIndex);
}
}

View file

@ -73,6 +73,62 @@ public class EnvCellRendererTests
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))));
}
[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()
{

View file

@ -169,6 +169,45 @@ public class InstanceGroupClearTests
Assert.Equal(expected, actual);
}
[Fact]
public void DispatcherFingerprint_IncludesRetailDetailCategory()
{
WbDrawDispatcher.InstanceGroup ordinary = MakeCompleteGroup(
textureSlot: 0xAA,
submissionOrder: 0);
WbDrawDispatcher.InstanceGroup building = MakeCompleteGroup(
textureSlot: 0xAA,
submissionOrder: 0);
building.DetailCategories[0] = 1u;
var scratch = new List<WbDrawDispatcher.AlphaFingerprint>();
CurrentRenderDispatcherSubmission ordinarySubmission =
WbDrawDispatcher.CreateDispatcherSubmission(
visibleInstanceCount: 1,
immediateInstanceCount: 0,
deferTransparent: true,
opaque: [],
transparent: [ordinary],
cameraWorldPosition: Vector3.Zero,
alphaScratch: scratch);
CurrentRenderDispatcherSubmission buildingSubmission =
WbDrawDispatcher.CreateDispatcherSubmission(
visibleInstanceCount: 1,
immediateInstanceCount: 0,
deferTransparent: true,
opaque: [],
transparent: [building],
cameraWorldPosition: Vector3.Zero,
alphaScratch: scratch);
Assert.NotEqual(
ordinarySubmission.TransparentDigest,
buildingSubmission.TransparentDigest);
Assert.NotEqual(
ordinarySubmission.TransparentSetDigest,
buildingSubmission.TransparentSetDigest);
}
[Fact]
public void CachedGroupHandle_RequiresLiveMatchingRegistration()
{
@ -314,15 +353,16 @@ public class InstanceGroupClearTests
group.Slots.Add(0u);
group.LightSets.Add(WbDrawDispatcher.InstanceLightSet.Disabled);
group.IndoorFlags.Add(0u);
group.DetailCategories.Add(0u);
group.Opacities.Add(1f);
group.SelectionLighting.Add(new Vector2(0f, 1f));
return group;
}
// #193 (regression from #188, 2026-07-09): WbDrawDispatcher's InstanceGroup holds
// eight per-instance parallel lists — Matrices, LocalSortCenters,
// nine per-instance parallel lists — Matrices, LocalSortCenters,
// SubmissionOrders, Slots, LightSets, IndoorFlags, Opacities, and
// SelectionLighting — appended in lockstep
// DetailCategories, SelectionLighting — appended in lockstep
// (one entry per drawn instance) every frame. The
// per-frame reset must clear ALL of them. #188 added Opacities but left it out of
// the inline clear loop, so it grew one float per instance per frame forever; as
@ -339,6 +379,7 @@ public class InstanceGroupClearTests
grp.Slots.Add(1u);
grp.LightSets.Add(WbDrawDispatcher.InstanceLightSet.Disabled);
grp.IndoorFlags.Add(0u);
grp.DetailCategories.Add(1u);
grp.Opacities.Add(1.0f);
grp.SelectionLighting.Add(new Vector2(0f, 1f));
@ -350,6 +391,7 @@ public class InstanceGroupClearTests
Assert.Empty(grp.Slots);
Assert.Empty(grp.LightSets);
Assert.Empty(grp.IndoorFlags);
Assert.Empty(grp.DetailCategories);
Assert.Empty(grp.Opacities); // #193 — the list that leaked
Assert.Empty(grp.SelectionLighting);
}

View file

@ -1,9 +1,42 @@
using AcDream.App.Rendering.Wb;
using System.Numerics;
namespace AcDream.App.Tests.Rendering.Wb;
public sealed class PackedDispatcherOracleTests
{
[Theory]
[InlineData(false, 0u)]
[InlineData(true, 1u)]
public void PackedInstanceWriter_AppendsDetailCategoryInParallel(
bool buildingDetail,
uint expectedCategory)
{
var group = new WbDrawDispatcher.InstanceGroup();
WbDrawDispatcher.AppendPackedInstance(
group,
Matrix4x4.Identity,
Vector3.One,
submissionOrder: 7,
slot: 3u,
lights: WbDrawDispatcher.InstanceLightSet.Disabled,
indoor: true,
buildingDetail: buildingDetail,
opacity: 0.5f,
selectionLighting: new Vector2(0.25f, 0.75f));
Assert.Single(group.Matrices);
Assert.Single(group.LocalSortCenters);
Assert.Single(group.SubmissionOrders);
Assert.Single(group.Slots);
Assert.Single(group.LightSets);
Assert.Single(group.IndoorFlags);
Assert.Equal(expectedCategory, Assert.Single(group.DetailCategories));
Assert.Single(group.Opacities);
Assert.Single(group.SelectionLighting);
}
[Theory]
[InlineData(0u, false, false)]
[InlineData(0u, true, false)]

View file

@ -0,0 +1,225 @@
using System.Numerics;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Wb;
using AcDream.App.Tests.Rendering.Gpu;
namespace AcDream.App.Tests.Rendering.Wb;
public sealed class WorldTransformFrameArenaTests
{
[Fact]
public void ShadowPrefixAndOrdinaryWorldAppendShareExactBindingAndBaseInstanceSpace()
{
using var device = new RecordingGpuDevice();
device.Clear();
using IGpuFrame frame = device.BeginFrame();
var arena = new WorldTransformFrameArena();
Matrix4x4[] shadow =
[
Matrix4x4.CreateTranslation(1f, 2f, 3f),
Matrix4x4.CreateRotationZ(0.4f),
];
Matrix4x4[] ordinary =
[
Matrix4x4.CreateScale(2f),
Matrix4x4.CreateTranslation(9f, 8f, 7f),
];
WorldTransformFrameSlice shadowSlice = arena.Begin(frame, shadow);
WorldTransformFrameSlice ordinarySlice = arena.Append(frame, ordinary);
Assert.Same(shadowSlice.Buffer, ordinarySlice.Buffer);
Assert.Equal(shadowSlice.BaseOffsetBytes, ordinarySlice.BaseOffsetBytes);
Assert.Equal(shadowSlice.BindingSizeBytes, ordinarySlice.BindingSizeBytes);
Assert.Equal(0u, shadowSlice.FirstInstance);
Assert.Equal((uint)shadow.Length, ordinarySlice.FirstInstance);
Assert.Equal((uint)ordinary.Length, ordinarySlice.InstanceCount);
Assert.Equal(4u, arena.UsedInstances);
GpuRecordedRingAllocation allocation = Assert.Single(
device.OfKind<GpuRecordedRingAllocation>());
Assert.Equal(GpuRingUsage.Storage, allocation.Usage);
Assert.Equal(
checked((int)WorldTransformCapacityPolicy.InitialBindingSizeBytes),
allocation.ByteCount);
ReadOnlySpan<Matrix4x4> uploaded = MemoryMarshal.Cast<byte, Matrix4x4>(
device.RingBytes.Slice(
checked((int)shadowSlice.BaseOffsetBytes),
checked((shadow.Length + ordinary.Length) * 64)));
Assert.Equal(shadow[0], uploaded[0]);
Assert.Equal(shadow[1], uploaded[1]);
Assert.Equal(ordinary[0], uploaded[2]);
Assert.Equal(ordinary[1], uploaded[3]);
}
[Fact]
public void CapacityPolicyGrowsPastTwiceTheFormerCeiling_AndHonorsDeviceLimit()
{
const uint moreThanTwiceFormerCapacity = 131_073u;
uint bytes = WorldTransformCapacityPolicy.ResolveBindingSizeBytes(
moreThanTwiceFormerCapacity,
WorldTransformCapacityPolicy.VulkanGuaranteedMaxStorageBufferRangeBytes);
Assert.True(bytes > 8u * 1024u * 1024u);
Assert.True(bytes >= moreThanTwiceFormerCapacity * 64u);
NotSupportedException failure = Assert.Throws<NotSupportedException>(() =>
WorldTransformCapacityPolicy.ResolveBindingSizeBytes(
moreThanTwiceFormerCapacity,
8u * 1024u * 1024u));
Assert.Contains("fail safe", failure.Message, StringComparison.Ordinal);
}
[Fact]
public void RetainedShadowPrefix_AndOrdinaryAppendUseTheExactSamePoseBuffer()
{
using var device = new RecordingGpuDevice();
using IGpuBuffer retained = device.CreateBuffer(new GpuBufferDescription(
"retained-shadow-slot-0",
WorldTransformCapacityPolicy.InitialBindingSizeBytes,
GpuBufferUsage.Storage | GpuBufferUsage.TransferDestination,
GpuMemoryResidency.HostWritable));
using IGpuFrame frame = device.BeginFrame();
Matrix4x4[] shadow =
[
Matrix4x4.CreateTranslation(1f, 2f, 3f),
Matrix4x4.CreateRotationZ(0.4f),
];
retained.Upload(0, MemoryMarshal.AsBytes(shadow.AsSpan()));
var shadowSlice = new WorldTransformFrameSlice(
frame.Serial,
retained,
BaseOffsetBytes: 0,
WorldTransformCapacityPolicy.InitialBindingSizeBytes,
FirstInstance: 0,
InstanceCount: 2);
var arena = new WorldTransformFrameArena();
WorldTransformFrameSlice published = arena.BeginRetained(
frame,
in shadowSlice);
Matrix4x4[] ordinary = [Matrix4x4.CreateTranslation(9f, 8f, 7f)];
WorldTransformFrameSlice appended = arena.Append(frame, ordinary);
Assert.Same(retained, published.Buffer);
Assert.Same(retained, appended.Buffer);
Assert.Equal(2u, appended.FirstInstance);
Assert.Equal(WorldTransformCapacityPolicy.InitialBindingSizeBytes,
appended.BindingSizeBytes);
Matrix4x4[] readback = new Matrix4x4[3];
retained.Read(0, MemoryMarshal.AsBytes(readback.AsSpan()));
Assert.Equal(shadow[0], readback[0]);
Assert.Equal(shadow[1], readback[1]);
Assert.Equal(ordinary[0], readback[2]);
Assert.Empty(device.OfKind<GpuRecordedRingAllocation>());
}
[Fact]
public void ConnectedDense68395CombinedMatricesRemainInOneAuthoritativeBinding()
{
const uint shadowPrefixInstances = 9_498u;
const int ordinaryInstances = 68_395 - (int)shadowPrefixInstances;
using var device = new RecordingGpuDevice();
using IGpuBuffer retained = device.CreateBuffer(new GpuBufferDescription(
"connected-dense-retained-transform-arena",
WorldTransformCapacityPolicy.InitialBindingSizeBytes,
GpuBufferUsage.Storage | GpuBufferUsage.TransferDestination,
GpuMemoryResidency.HostWritable));
using IGpuFrame frame = device.BeginFrame();
var prefix = new WorldTransformFrameSlice(
frame.Serial,
retained,
BaseOffsetBytes: 0,
WorldTransformCapacityPolicy.InitialBindingSizeBytes,
FirstInstance: 0,
InstanceCount: shadowPrefixInstances);
var arena = new WorldTransformFrameArena();
arena.BeginRetained(frame, in prefix);
WorldTransformFrameSlice ordinary = arena.Append(
frame,
new Matrix4x4[ordinaryInstances]);
Assert.Same(retained, ordinary.Buffer);
Assert.Equal(shadowPrefixInstances, ordinary.FirstInstance);
Assert.Equal(68_395u, arena.UsedInstances);
Assert.Equal(prefix.BindingSizeBytes, ordinary.BindingSizeBytes);
Assert.True(ordinary.IsValidFor(frame));
Assert.Empty(device.OfKind<GpuRecordedRingAllocation>());
}
[Fact]
public void AppendOverflowFailsSafeWithoutAllocatingASecondPoseBuffer()
{
using var device = new RecordingGpuDevice();
device.Clear();
using IGpuFrame frame = device.BeginFrame();
var arena = new WorldTransformFrameArena();
const uint bindingBytes = 4u * 64u;
var full = new Matrix4x4[4];
arena.Begin(frame, full, bindingBytes);
InvalidOperationException failure = Assert.Throws<InvalidOperationException>(
() => arena.Append(frame, [Matrix4x4.Identity]));
Assert.Contains("fail safe", failure.Message, StringComparison.Ordinal);
Assert.True(arena.IsActiveFor(frame.Serial));
Assert.Equal(
bindingBytes / WorldTransformCapacityPolicy.MatrixBytes,
arena.UsedInstances);
Assert.Single(device.OfKind<GpuRecordedRingAllocation>());
}
[Fact]
public void CancelBeforePackRetirementAllowsSameFrameRetailRingPublication()
{
using var device = new RecordingGpuDevice();
device.Clear();
var retained = Assert.IsType<RecordingGpuBuffer>(
device.CreateBuffer(new GpuBufferDescription(
"retained-shadow-slot-0",
WorldTransformCapacityPolicy.InitialBindingSizeBytes,
GpuBufferUsage.Storage | GpuBufferUsage.TransferDestination,
GpuMemoryResidency.HostWritable)));
using IGpuFrame frame = device.BeginFrame();
var arena = new WorldTransformFrameArena();
var retainedPrefix = new WorldTransformFrameSlice(
frame.Serial,
retained,
BaseOffsetBytes: 0,
WorldTransformCapacityPolicy.InitialBindingSizeBytes,
FirstInstance: 0,
InstanceCount: 1);
arena.BeginRetained(frame, in retainedPrefix);
// This is the production late-budget-failure order: release the
// dispatcher's borrow first, then retire the active pack owner.
arena.Cancel(frame);
retained.Dispose();
WorldTransformFrameSlice retail = arena.Begin(
frame,
[Matrix4x4.CreateTranslation(4f, 5f, 6f)]);
Assert.True(retained.IsDisposed);
Assert.NotSame(retained, retail.Buffer);
Assert.Same(device.RingBuffer, retail.Buffer);
Assert.True(arena.IsActiveFor(frame.Serial));
Assert.Single(device.OfKind<GpuRecordedRingAllocation>());
}
[Fact]
public void FrameSerialChangeInvalidatesPriorPublication()
{
using var device = new RecordingGpuDevice();
var arena = new WorldTransformFrameArena();
using (IGpuFrame first = device.BeginFrame())
arena.Begin(first, [Matrix4x4.Identity]);
using IGpuFrame second = device.BeginFrame();
InvalidOperationException failure = Assert.Throws<InvalidOperationException>(
() => arena.Append(second, [Matrix4x4.Identity]));
Assert.Contains("has not been published", failure.Message, StringComparison.Ordinal);
Assert.False(arena.IsActive);
}
}

View file

@ -2,6 +2,7 @@ using System.Numerics;
using System.Reflection;
using AcDream.App.Composition;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Packs;
using AcDream.App.Rendering.Selection;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Streaming;
@ -15,6 +16,94 @@ namespace AcDream.App.Tests.Rendering;
public sealed class WorldSceneRendererTests
{
[Fact]
public void EnhancedPreparation_BuildsCanonicalWorldOnceBeforeExecution()
{
var rig = new Rig(portalVisible: false, waitingForLogin: false, clipRoot: null);
PreparedWorldSceneFrame prepared = rig.Renderer.PrepareEnhanced(default);
WorldRenderFrameOutcome result = rig.Renderer.RenderPreparedEnhanced(
default,
in prepared);
Assert.True(result.NormalWorldDrawn);
Assert.True(prepared.ShouldRender);
Assert.Equal(1, rig.Calls.Count(value => value == "frame:build"));
Assert.True(rig.Calls.IndexOf("frame:build") < rig.Calls.IndexOf("selection:begin"));
Assert.Equal(prepared.World.Camera.Frustum, rig.Selection.PreparedViewFrustum);
Assert.Equal(rig.Entities.ResidentWindow, prepared.World.ResidentStreamingWindow);
Assert.Equal((0, 0), rig.Entities.LastResidentWindowCenter);
}
[Fact]
public void EnhancedPreparation_AttachesTheSelectedAuthoredCelestialSource()
{
var rig = new Rig(portalVisible: false, waitingForLogin: false, clipRoot: null);
rig.DayGroup.SkyObjects =
[
new SkyObjectData
{
GfxObjId = AuthoredCelestialShadowSourceResolver.SunGfxObjId,
AuthoredSortCenter = Vector3.UnitX,
BeginTime = 0f,
EndTime = 0f,
BeginAngle = 90f,
EndAngle = 90f,
},
];
PreparedWorldSceneFrame prepared = rig.Renderer.PrepareEnhanced(default);
Assert.True(prepared.ShouldRender);
Assert.Equal(
AuthoredCelestialShadowSourceKind.Sun,
prepared.World.CelestialShadowSource.Kind);
Assert.Equal(0, prepared.World.CelestialShadowSource.ObjectIndex);
Assert.Equal(
AuthoredCelestialShadowSourceResolver.SunGfxObjId,
prepared.World.CelestialShadowSource.GfxObjId);
Assert.InRange(
Vector3.Distance(
Vector3.UnitZ,
prepared.World.CelestialShadowSource.SurfaceToLightDirection),
0f,
1e-5f);
rig.Renderer.CancelPreparedEnhanced(in prepared);
}
[Fact]
public void EnhancedPreparation_SkippedWorldStillPublishesEmptySelectionFrame()
{
var rig = new Rig(portalVisible: true, waitingForLogin: false, clipRoot: null);
PreparedWorldSceneFrame prepared = rig.Renderer.PrepareEnhanced(default);
WorldRenderFrameOutcome result = rig.Renderer.RenderPreparedEnhanced(
default,
in prepared);
Assert.False(prepared.ShouldRender);
Assert.Equal(default, result);
Assert.Equal(["selection:begin", "selection:complete"], rig.Calls);
}
[Fact]
public void CancelledEnhancedPrepass_DoesNotPoisonTheNextFrame()
{
var rig = new Rig(portalVisible: false, waitingForLogin: false, clipRoot: null);
PreparedWorldSceneFrame failed = rig.Renderer.PrepareEnhanced(default);
// Models an exception in the shadow prepass before a world pass opens.
rig.Renderer.CancelPreparedEnhanced(in failed);
PreparedWorldSceneFrame recovered = rig.Renderer.PrepareEnhanced(default);
WorldRenderFrameOutcome result = rig.Renderer.RenderPreparedEnhanced(
default,
in recovered);
Assert.True(result.NormalWorldDrawn);
Assert.Equal(2, rig.Calls.Count(value => value == "frame:build"));
}
[Fact]
public void PortalViewport_PublishesEmptySelectionFrameAndSkipsWorldOwners()
{
@ -445,20 +534,21 @@ public sealed class WorldSceneRendererTests
clipRoot,
playerSeenOutside ?? clipRoot is not null);
Frames = new FrameBuilder(Calls, frame);
var selection = new SelectionFrame(Calls);
Selection = new SelectionFrame(Calls);
var alpha = new AlphaFrame(Calls);
var visibility = new ParticleVisibility(Calls);
PView = new PViewRenderer(Calls);
Passes = new PassExecutor(Calls);
var diagnostics = new Diagnostics(Calls);
Entities = new EntitySource();
Renderer = new WorldSceneRenderer(
foundation,
login,
sky,
Frames,
new EntitySource(),
selection,
Entities,
Selection,
alpha,
visibility,
PView,
@ -479,6 +569,10 @@ public sealed class WorldSceneRendererTests
public FrameBuilder Frames { get; }
public EntitySource Entities { get; }
public SelectionFrame Selection { get; }
public PViewRenderer PView { get; }
public PassExecutor Passes { get; }
@ -535,6 +629,16 @@ public sealed class WorldSceneRendererTests
private sealed class EntitySource : IWorldSceneEntitySource
{
public ResidentStreamingWindowFact ResidentWindow { get; } = new(
Revision: 77,
CenterX: 0,
CenterY: 0,
CompleteRadiusLandblocks: 2,
PublishedLandblockCount: 25,
HasPublishedCenter: true);
public (int X, int Y)? LastResidentWindowCenter { get; private set; }
public IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries =>
@ -543,11 +647,25 @@ public sealed class WorldSceneRendererTests
public IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds =>
Array.Empty<(uint, Vector3, Vector3)>();
public ResidentStreamingWindowFact CaptureResidentStreamingWindow(
int centerX,
int centerY)
{
LastResidentWindowCenter = (centerX, centerY);
return ResidentWindow;
}
}
private sealed class SelectionFrame(List<string> calls) : IWorldSceneSelectionFrame
{
public void BeginFrame() => calls.Add("selection:begin");
public FrustumPlanes? PreparedViewFrustum { get; private set; }
public void BeginFrame(FrustumPlanes? preparedViewFrustum = null)
{
PreparedViewFrustum = preparedViewFrustum;
calls.Add("selection:begin");
}
public void CompleteFrame() => calls.Add("selection:complete");

View file

@ -166,6 +166,7 @@ public sealed class RuntimeOptionsTests
Assert.False(opts.UiProbeDump);
Assert.Null(opts.UiProbeScript);
Assert.Null(opts.AutomationArtifactDirectory);
Assert.False(opts.ExactAutomationFramebuffer);
Assert.Equal(0.7f, opts.FogStartMultiplier);
Assert.Equal(0.95f, opts.FogEndMultiplier);
Assert.False(opts.UiProbeEnabled);
@ -411,6 +412,81 @@ public sealed class RuntimeOptionsTests
}
}
[Fact]
public void ExactAutomationFramebuffer_IsExplicitAndExactOneOnly()
{
Assert.True(RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_AUTOMATION_EXACT_FRAMEBUFFER"] = "1" }))
.ExactAutomationFramebuffer);
foreach (string value in new[] { "", "0", "true", "yes" })
{
Assert.False(RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_AUTOMATION_EXACT_FRAMEBUFFER"] = value }))
.ExactAutomationFramebuffer);
}
}
[Fact]
public void OrbitDistanceOverride_AcceptsOnlyPositiveFiniteMeters()
{
Assert.Null(
RuntimeOptions.Parse(AnyDatDir, EmptyEnv())
.InitialOrbitDistanceMeters);
Assert.Equal(
120f,
RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_ORBIT_DISTANCE_METERS"] = "120" }))
.InitialOrbitDistanceMeters);
foreach (string rejected in new[] { "0", "-1", "NaN", "Infinity", "near" })
{
Assert.Null(
RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_ORBIT_DISTANCE_METERS"] = rejected }))
.InitialOrbitDistanceMeters);
}
}
[Fact]
public void OrbitAngleOverrides_AcceptFiniteYawAndBoundedPitchDegrees()
{
RuntimeOptions defaults = RuntimeOptions.Parse(AnyDatDir, EmptyEnv());
Assert.Null(defaults.InitialOrbitYawDegrees);
Assert.Null(defaults.InitialOrbitPitchDegrees);
RuntimeOptions parsed = RuntimeOptions.Parse(
AnyDatDir,
Env(new()
{
["ACDREAM_ORBIT_YAW_DEGREES"] = "-135.5",
["ACDREAM_ORBIT_PITCH_DEGREES"] = "7.25",
}));
Assert.Equal(-135.5f, parsed.InitialOrbitYawDegrees);
Assert.Equal(7.25f, parsed.InitialOrbitPitchDegrees);
foreach (string rejected in new[] { "NaN", "Infinity", "angle" })
{
Assert.Null(
RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_ORBIT_YAW_DEGREES"] = rejected }))
.InitialOrbitYawDegrees);
}
foreach (string rejected in new[] { "-90", "90", "NaN", "pitch" })
{
Assert.Null(
RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_ORBIT_PITCH_DEGREES"] = rejected }))
.InitialOrbitPitchDegrees);
}
}
/// <summary>
/// Campaign V slice V7. The sky has two clocks and this pins the one
/// ACDREAM_DAY_GROUP cannot reach — the cloud sheet's UV scroll, which

View file

@ -298,12 +298,13 @@ public sealed class RuntimeSettingsControllerTests
var target = new SilkRuntimeDisplayWindowTarget(
surface, switcher, _ => false);
target.Apply(DisplaySettings.Default with
RuntimeDisplayApplyResult result = target.Apply(DisplaySettings.Default with
{
Fullscreen = true,
Resolution = "1234x777",
});
Assert.False(result.Fullscreen);
Assert.Empty(switcher.Calls);
Assert.Equal(0, surface.Writes);
}
@ -318,12 +319,13 @@ public sealed class RuntimeSettingsControllerTests
var target = new SilkRuntimeDisplayWindowTarget(
surface, switcher, _ => true);
target.Apply(DisplaySettings.Default with
RuntimeDisplayApplyResult result = target.Apply(DisplaySettings.Default with
{
Fullscreen = true,
Resolution = "1920x1080",
});
Assert.False(result.Fullscreen);
Assert.False(switcher.IsFullscreen);
Assert.Equal(0, surface.Writes);
}
@ -879,6 +881,80 @@ public sealed class RuntimeSettingsControllerTests
Assert.Contains(logs, line => line.Contains("display save failed", StringComparison.Ordinal));
}
[Fact]
public void RefusedFullscreenRequest_ReconcilesPersistedAndPublishedState()
{
var events = new List<string>();
var storage = new FakeStorage(events);
var controller = new RuntimeSettingsController(
storage,
static preset => QualitySettings.From(preset),
static _ => { });
storage.ClearEvents();
var targets = new FakeRuntimeTargets(events)
{
DisplayResult = new RuntimeDisplayApplyResult(Fullscreen: false),
};
controller.BindRuntimeTargets(targets);
var observed = new List<DisplaySettings>();
controller.DisplayChanged += observed.Add;
controller.SaveDisplay(controller.Display with
{
Fullscreen = true,
Resolution = "2056x1290",
});
Assert.Equal(2, storage.DisplaySaves);
Assert.False(storage.DisplayValue.Fullscreen);
Assert.False(controller.Display.Fullscreen);
Assert.False(Assert.Single(observed).Fullscreen);
Assert.Equal(
["save-display", "target-display", "save-display", "target-quality"],
events);
}
[Fact]
public void Successful_display_commit_publishes_render_pack_selection_once()
{
var storage = new FakeStorage();
RuntimeSettingsController controller = CreateController(storage);
var observed = new List<DisplaySettings>();
controller.DisplayChanged += observed.Add;
DisplaySettings selected = controller.Display with
{
RenderPack = new RenderPackSelectionSettings(
"acdream.atmospheric",
"1.0.0",
"medium"),
};
controller.SaveDisplay(selected);
Assert.Same(selected, controller.Display);
Assert.Equal([selected], observed);
}
[Fact]
public void Failed_display_commit_does_not_publish_render_pack_selection()
{
var storage = new FakeStorage { ThrowOnDisplaySave = true };
RuntimeSettingsController controller = CreateController(storage);
int observed = 0;
controller.DisplayChanged += _ => observed++;
controller.SaveDisplay(controller.Display with
{
RenderPack = new RenderPackSelectionSettings(
"acdream.atmospheric",
"1.0.0",
"high"),
});
Assert.Equal(0, observed);
Assert.True(controller.Display.RenderPack.IsRetail);
}
[Fact]
public void NonDisplayPersistenceFailuresContinueAndPreserveControllerState()
{
@ -1132,7 +1208,7 @@ public sealed class RuntimeSettingsControllerTests
public int RemainingAudioFailures { get; set; }
public void ApplyDisplay(DisplaySettings display)
public RuntimeDisplayApplyResult ApplyDisplay(DisplaySettings display)
{
events.Add("startup-display");
@ -1141,6 +1217,7 @@ public sealed class RuntimeSettingsControllerTests
RemainingDisplayFailures--;
throw new InvalidOperationException("display startup failed");
}
return new RuntimeDisplayApplyResult(display.Fullscreen);
}
public void ApplyAudio(AudioSettings audio)
@ -1161,15 +1238,18 @@ public sealed class RuntimeSettingsControllerTests
public bool ThrowOnQuality { get; init; }
public RuntimeDisplayApplyResult? DisplayResult { get; init; }
public int RemainingUiLockFailures { get; set; }
public int UiLockCalls { get; private set; }
public void ApplyDisplayWindowState(DisplaySettings display)
public RuntimeDisplayApplyResult ApplyDisplayWindowState(DisplaySettings display)
{
events.Add("target-display");
if (ThrowOnDisplay)
throw new InvalidOperationException("display target failed");
return DisplayResult ?? new RuntimeDisplayApplyResult(display.Fullscreen);
}
public void ApplyQuality(QualitySettings quality)
@ -1235,10 +1315,11 @@ public sealed class RuntimeSettingsControllerTests
{
public int ApplyCount { get; private set; }
public void Apply(DisplaySettings display)
public RuntimeDisplayApplyResult Apply(DisplaySettings display)
{
ApplyCount++;
apply(display);
return new RuntimeDisplayApplyResult(display.Fullscreen);
}
}

View file

@ -0,0 +1,142 @@
using AcDream.App.Streaming;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Tests.Streaming;
public sealed class ResidentStreamingWindowFactTests
{
[Fact]
public void CaptureUsesOnlyLargestCompleteActuallyPublishedWindow()
{
var state = new GpuWorldState();
AddSquare(state, centerX: 100, centerY: 101, radius: 1);
ResidentStreamingWindowFact first =
state.CaptureResidentStreamingWindow(100, 101);
Assert.True(first.HasPublishedCenter);
Assert.Equal(1, first.CompleteRadiusLandblocks);
Assert.Equal(192f, first.MaximumReachMeters);
// A retained outer ring with one unpublished gap is not a safe shadow
// reach even though most of its landblocks are live.
AddRing(state, 100, 101, radius: 2, skipX: 102, skipY: 101);
ResidentStreamingWindowFact incomplete =
state.CaptureResidentStreamingWindow(100, 101);
Assert.Equal(1, incomplete.CompleteRadiusLandblocks);
Assert.Equal(192f, incomplete.MaximumReachMeters);
Add(state, 102, 101);
ResidentStreamingWindowFact complete =
state.CaptureResidentStreamingWindow(100, 101);
Assert.Equal(2, complete.CompleteRadiusLandblocks);
Assert.Equal(384f, complete.MaximumReachMeters);
}
[Fact]
public void NearToFarDemotionRetainsTerrainReachAndDoesNotRepublishTheWindow()
{
var state = new GpuWorldState();
AddSquare(state, centerX: 40, centerY: 50, radius: 1);
ResidentStreamingWindowFact before =
state.CaptureResidentStreamingWindow(40, 50);
GpuLandblockRetirement? retirement = state.DetachNearLayer(
StreamingRegion.EncodeLandblockId(41, 50));
ResidentStreamingWindowFact after =
state.CaptureResidentStreamingWindow(40, 50);
Assert.NotNull(retirement);
Assert.Equal(before.Revision, after.Revision);
Assert.Equal(before.MaximumReachMeters, after.MaximumReachMeters);
Assert.Equal(before.PublishedLandblockCount, after.PublishedLandblockCount);
}
[Fact]
public void RecenterAndPortalGenerationTurnoverCannotReusePriorReach()
{
var state = new GpuWorldState();
AddSquare(state, centerX: 12, centerY: 20, radius: 2);
ResidentStreamingWindowFact oldGeneration =
state.CaptureResidentStreamingWindow(12, 20);
Assert.Equal(384f, oldGeneration.MaximumReachMeters);
_ = state.DetachAllForOriginRecenter();
ResidentStreamingWindowFact betweenGenerations =
state.CaptureResidentStreamingWindow(12, 20);
Assert.False(betweenGenerations.HasPublishedCenter);
Assert.Equal(0f, betweenGenerations.MaximumReachMeters);
Assert.True(betweenGenerations.Revision > oldGeneration.Revision);
Add(state, 220, 221);
ResidentStreamingWindowFact newGeneration =
state.CaptureResidentStreamingWindow(220, 221);
Assert.True(newGeneration.HasPublishedCenter);
Assert.Equal(0, newGeneration.CompleteRadiusLandblocks);
Assert.Equal(0f, newGeneration.MaximumReachMeters);
Assert.True(newGeneration.Revision > betweenGenerations.Revision);
Assert.False(state.CaptureResidentStreamingWindow(12, 20).HasPublishedCenter);
}
[Fact]
public void RemovingOneResidentEdgeImmediatelyShrinksTheReadOnlyReach()
{
var state = new GpuWorldState();
AddSquare(state, centerX: 80, centerY: 90, radius: 2);
ResidentStreamingWindowFact before =
state.CaptureResidentStreamingWindow(80, 90);
state.RemoveLandblock(StreamingRegion.EncodeLandblockId(82, 90));
ResidentStreamingWindowFact after =
state.CaptureResidentStreamingWindow(80, 90);
Assert.True(after.Revision > before.Revision);
Assert.Equal(1, after.CompleteRadiusLandblocks);
Assert.Equal(192f, after.MaximumReachMeters);
}
private static void AddSquare(
GpuWorldState state,
int centerX,
int centerY,
int radius)
{
for (int x = centerX - radius; x <= centerX + radius; x++)
{
for (int y = centerY - radius; y <= centerY + radius; y++)
Add(state, x, y);
}
}
private static void AddRing(
GpuWorldState state,
int centerX,
int centerY,
int radius,
int skipX,
int skipY)
{
for (int x = centerX - radius; x <= centerX + radius; x++)
{
for (int y = centerY - radius; y <= centerY + radius; y++)
{
if (Math.Max(Math.Abs(x - centerX), Math.Abs(y - centerY)) != radius
|| (x == skipX && y == skipY))
{
continue;
}
Add(state, x, y);
}
}
}
private static void Add(GpuWorldState state, int x, int y)
{
uint landblockId = StreamingRegion.EncodeLandblockId(x, y);
if (state.TryGetLandblock(landblockId, out _))
return;
state.AddLandblock(new LoadedLandblock(
landblockId,
new LandBlock(),
Array.Empty<WorldEntity>()));
}
}

View file

@ -4,6 +4,7 @@ using System.Linq;
using System.Numerics;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Plugin.Abstractions.Rendering;
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.Tests.UI.Layout;
@ -285,7 +286,8 @@ public sealed class ConfigOptionsPageControllerTests
public List<CameraTurningSettings> CameraTurningSaves { get; } = new();
public List<ChatSettings> ChatSaves { get; } = new();
public ConfigOptionsPageController.Bindings ToBindings() => new(
public ConfigOptionsPageController.Bindings ToBindings(
ConfigOptionsPageController.RenderPackBindings? renderPacks = null) => new(
LoadDisplay: () => Display,
SaveDisplay: value => { Display = value; DisplaySaves.Add(value); },
LoadAudio: () => Audio,
@ -293,7 +295,10 @@ public sealed class ConfigOptionsPageControllerTests
LoadCameraTurning: () => CameraTurning,
SaveCameraTurning: value => { CameraTurning = value; CameraTurningSaves.Add(value); },
LoadChat: () => Chat,
SaveChat: value => { Chat = value; ChatSaves.Add(value); });
SaveChat: value => { Chat = value; ChatSaves.Add(value); })
{
RenderPacks = renderPacks,
};
}
private static (OptionsPanelController Panel, FakeBindings Bindings, bool Bound) BindReal(
@ -430,6 +435,377 @@ public sealed class ConfigOptionsPageControllerTests
Assert.Equal(39, viewport.Children.Count);
}
[Fact]
public void OptInRenderPackBindings_append_two_menus_without_changing_retail_only_fixture()
{
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
OptionsPanelController controller = OptionsPanelController.Bind(
layout,
new OptionsPanelController.Callbacks(
Toggle: () => { },
RequestExitToCharacterSelection: () => { },
ExitGame: () => { },
UseMouseTurningSettings: () => { },
DisplaySystemMessage: _ => { }))!;
var fake = new FakeBindings();
string? activationFailure = "Last activation failed: shader interface mismatch.";
var renderPacks = new ConfigOptionsPageController.RenderPackBindings(() =>
[
new ConfigOptionsPageController.RenderPackChoice(
"acdream.atmospheric",
"Atmospheric Rendering",
"1.0.0",
true,
null,
[
new ConfigOptionsPageController.RenderPackPresetChoice(
"low", "Low", true, null)
{
MaxResidentGpuBytes = 64L * 1024 * 1024,
MaxIncrementalGpuMillisecondsP50 = 2.0,
MaxIncrementalGpuMillisecondsP99 = 3.0,
MaxIncrementalCpuMillisecondsP50 = 0.15,
MaxIncrementalCpuMillisecondsP99 = 0.50,
},
new("medium", "Medium", true, null),
new("high", "High", false, "High needs more GPU memory."),
])
{
FeatureSummary = "Filmic atmosphere and moving-sun shadows.",
},
new ConfigOptionsPageController.RenderPackChoice(
"test.unsupported",
"Unsupported Test Pack",
"2.0.0",
false,
"Directional depth sampling is unavailable.",
[new("low", "Low", false, "Directional depth sampling is unavailable.")]),
])
{
LoadFailureNotice = () => activationFailure,
};
bool bound = ConfigOptionsPageController.Bind(
layout,
controller.ConfigPage,
MakeTemplateResolver(),
(_, _) => null,
fake.ToBindings(renderPacks));
Assert.True(bound);
var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!;
var listBox = Assert.IsType<UiTemplateListBox>(
UiElement.FindDescendant(configSlot, ConfigOptionsPageController.ListBoxElementId));
Assert.Equal(43, Assert.Single(listBox.Children).Children.Count);
Assert.Equal(32, controller.ConfigPage.Rows.Count);
List<UiMenu> menus = CollectMenus(configSlot);
UiMenu packMenu = menus[^2];
UiMenu presetMenu = menus[^1];
Assert.Equal("acdream default (retail-faithful)", packMenu.Items[0].Label);
Assert.Contains(packMenu.Items, value => Equals(value.Payload, "acdream.atmospheric"));
Assert.False(packMenu.EnabledProvider!("test.unsupported"));
Assert.Equal(
"Last activation failed: shader interface mismatch.",
packMenu.GetTooltipText()!.Split(Environment.NewLine)[0]);
packMenu.OnSelect!("test.unsupported");
Assert.True(fake.Display.RenderPack.IsRetail);
packMenu.OnSelect!("acdream.atmospheric");
Assert.Equal("acdream.atmospheric", fake.Display.RenderPack.PackId);
Assert.Equal("1.0.0", fake.Display.RenderPack.PackVersion);
Assert.Equal("low", fake.Display.RenderPack.PresetId);
activationFailure = null;
Assert.Equal(
"Filmic atmosphere and moving-sun shadows.",
packMenu.GetTooltipText());
presetMenu = CollectMenus(configSlot)[^1];
Assert.Equal(3, presetMenu.Items.Count);
Assert.False(presetMenu.EnabledProvider!("high"));
Assert.Contains(
"GPU p50/p99 ≤ 2/3 ms",
presetMenu.GetTooltipText(),
StringComparison.Ordinal);
Assert.Contains("pack VRAM ≤ 64 MiB", presetMenu.GetTooltipText(), StringComparison.Ordinal);
presetMenu.OnSelect!("medium");
Assert.Equal("medium", fake.Display.RenderPack.PresetId);
Assert.True(fake.DisplaySaves.Count >= 2);
}
[Fact]
public void RenderPackMenu_revision_refresh_removes_withdrawn_schema_and_discovers_reregistration()
{
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
OptionsPanelController controller = OptionsPanelController.Bind(
layout,
new OptionsPanelController.Callbacks(
Toggle: () => { },
RequestExitToCharacterSelection: () => { },
ExitGame: () => { },
UseMouseTurningSettings: () => { },
DisplaySystemMessage: _ => { }))!;
var alpha = new ConfigOptionsPageController.RenderPackChoice(
"pack.alpha", "Alpha", "1.0.0", true, null,
[new("low", "Low", true, null)])
{
Settings =
[
new RenderSettingDeclaration(
"alpha-toggle", "Alpha toggle", RenderSettingKind.Boolean,
"true", null, null, null, []),
],
};
var beta = new ConfigOptionsPageController.RenderPackChoice(
"pack.beta", "Beta", "2.0.0", true, null,
[new("medium", "Medium", true, null)]);
IReadOnlyList<ConfigOptionsPageController.RenderPackChoice> discovered = [alpha];
long revision = 1;
var fake = new FakeBindings
{
Display = DisplaySettings.Default with
{
RenderPack = new RenderPackSelectionSettings(
"pack.alpha", "1.0.0", "low"),
},
};
var renderPacks = new ConfigOptionsPageController.RenderPackBindings(
() => discovered)
{
LoadRevision = () => revision,
};
Assert.True(ConfigOptionsPageController.Bind(
layout,
controller.ConfigPage,
MakeTemplateResolver(),
(_, _) => null,
fake.ToBindings(renderPacks)));
var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!;
var listBox = Assert.IsType<UiTemplateListBox>(
UiElement.FindDescendant(configSlot, ConfigOptionsPageController.ListBoxElementId));
UiMenu packMenu = CollectMenus(configSlot)[^2];
Assert.Equal(44, listBox.ItemCount);
Assert.Contains(packMenu.Items, item => Equals(item.Payload, "pack.alpha"));
discovered = [beta];
revision++;
packMenu.BeforeOpen!();
Assert.DoesNotContain(packMenu.Items, item => Equals(item.Payload, "pack.alpha"));
Assert.Contains(packMenu.Items, item => Equals(item.Payload, "pack.beta"));
Assert.Equal(RenderPackSelectionSettings.RetailPackId, packMenu.Selected);
Assert.Equal(43, listBox.ItemCount);
Assert.Equal(32, controller.ConfigPage.Rows.Count);
controller.ConfigPage.Reset();
Assert.Equal(RenderPackSelectionSettings.RetailPackId, packMenu.Selected);
Assert.DoesNotContain(packMenu.Items, item => Equals(item.Payload, "pack.alpha"));
fake.Display = fake.Display with
{
RenderPack = new RenderPackSelectionSettings(
"pack.beta", "2.0.0", "medium"),
};
revision++;
packMenu.BeforeOpen!();
Assert.Equal("pack.beta", packMenu.Selected);
Assert.Equal(43, listBox.ItemCount);
Assert.Equal("Medium", CollectMenus(configSlot)[^1].Items.Single().Label);
}
[Fact]
public void RenderPackSettings_live_schema_swap_replaces_only_the_optional_tail()
{
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
OptionsPanelController controller = OptionsPanelController.Bind(
layout,
new OptionsPanelController.Callbacks(
Toggle: () => { },
RequestExitToCharacterSelection: () => { },
ExitGame: () => { },
UseMouseTurningSettings: () => { },
DisplaySystemMessage: _ => { }))!;
var fake = new FakeBindings
{
Display = DisplaySettings.Default with
{
RenderPack = new RenderPackSelectionSettings("pack.alpha", "1.0.0", "low"),
},
};
RenderSettingDeclaration[] alphaSettings =
[
new("enabled", "Enabled", RenderSettingKind.Boolean, "false", null, null, null, []),
new("strength", "Strength", RenderSettingKind.Float, "0.5", 0, 1, 0.25, []),
new("samples", "Samples", RenderSettingKind.Integer, "2", 0, 10, 2, []),
new("mode", "Mode", RenderSettingKind.Choice, "low", null, null, null,
["low", "high"]),
];
var alpha = new ConfigOptionsPageController.RenderPackChoice(
"pack.alpha", "Alpha", "1.0.0", true, null,
[
new ConfigOptionsPageController.RenderPackPresetChoice(
"low", "Low", true, null)
{
SettingOverrides =
[
new RenderQualitySettingOverride("strength", "0.75"),
],
},
new ConfigOptionsPageController.RenderPackPresetChoice(
"high", "High", true, null),
])
{
Settings = alphaSettings,
};
var beta = new ConfigOptionsPageController.RenderPackChoice(
"pack.beta", "Beta", "2.0.0", true, null,
[new("default", "Default", true, null)])
{
Settings =
[
new RenderSettingDeclaration(
"beta-enabled", "Beta enabled", RenderSettingKind.Boolean,
"true", null, null, null, []),
],
};
var renderPacks = new ConfigOptionsPageController.RenderPackBindings(() =>
[alpha, beta]);
Assert.True(ConfigOptionsPageController.Bind(
layout,
controller.ConfigPage,
MakeTemplateResolver(),
(_, _) => null,
fake.ToBindings(renderPacks)));
var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!;
var listBox = Assert.IsType<UiTemplateListBox>(
UiElement.FindDescendant(configSlot, ConfigOptionsPageController.ListBoxElementId));
Assert.Equal(47, listBox.ItemCount); // exact retail 39 + 8-row optional tail
Assert.Equal(36, controller.ConfigPage.Rows.Count); // retail 30 + pack/preset + 4 settings
IReadOnlyList<UiElement> items = listBox.ViewportForTest!.Children;
var enabled = Assert.IsType<UiButton>(
UiElement.FindDescendant(items[42], 0x10000219u));
var strength = Assert.IsType<UiScrollbar>(
UiElement.FindDescendant(items[43], 0x1000021Cu));
var samples = Assert.IsType<UiScrollbar>(
UiElement.FindDescendant(items[44], 0x1000021Cu));
List<UiMenu> menus = CollectMenus(configSlot);
UiMenu packMenu = menus[^3];
UiMenu presetMenu = menus[^2];
UiMenu oldModeMenu = menus[^1];
Assert.Equal(0.75f, strength.ScalarPosition, 3); // preset wins declaration default
Assert.Equal(["low", "high"], oldModeMenu.Items.Select(value => value.Label));
enabled.Selected = true;
enabled.OnClick!();
strength.ScalarChanged!(0.62f); // 0.62 snaps to 0.5 on the declared 0.25 step
samples.ScalarChanged!(0.33f); // 3.3 snaps to integer step 4
oldModeMenu.OnSelect!("high");
Assert.Equal("true", fake.Display.RenderPack.SettingOverrides["enabled"]);
Assert.Equal("0.5", fake.Display.RenderPack.SettingOverrides["strength"]);
Assert.Equal("4", fake.Display.RenderPack.SettingOverrides["samples"]);
Assert.Equal("high", fake.Display.RenderPack.SettingOverrides["mode"]);
presetMenu.OnSelect!("high");
Assert.Equal("high", fake.Display.RenderPack.PresetId);
Assert.Equal(4, fake.Display.RenderPack.SettingOverrides.Count);
Assert.Equal(47, listBox.ItemCount);
Assert.Equal(36, controller.ConfigPage.Rows.Count);
int savesBeforeStaleWidget = fake.DisplaySaves.Count;
oldModeMenu.OnSelect!("low");
Assert.Equal(savesBeforeStaleWidget, fake.DisplaySaves.Count);
packMenu.OnSelect!("pack.beta");
Assert.Equal("pack.beta", fake.Display.RenderPack.PackId);
Assert.Equal("2.0.0", fake.Display.RenderPack.PackVersion);
Assert.Equal("default", fake.Display.RenderPack.PresetId);
Assert.Empty(fake.Display.RenderPack.SettingOverrides);
Assert.Equal(44, listBox.ItemCount); // header + pack + preset + one setting + separator
Assert.Equal(33, controller.ConfigPage.Rows.Count);
oldModeMenu.OnSelect!("high");
Assert.Empty(fake.Display.RenderPack.SettingOverrides);
controller.ConfigPage.Reset();
Assert.Equal("pack.alpha", fake.Display.RenderPack.PackId);
Assert.Equal(47, listBox.ItemCount);
Assert.Equal(36, controller.ConfigPage.Rows.Count);
controller.ConfigPage.Defaults();
Assert.True(fake.Display.RenderPack.IsRetail);
Assert.Equal(43, listBox.ItemCount); // header + pack + retail preset + separator
Assert.Equal(32, controller.ConfigPage.Rows.Count);
}
[Fact]
public void RenderPackSettingEdit_sanitizes_unknown_and_invalid_persisted_values()
{
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
OptionsPanelController controller = OptionsPanelController.Bind(
layout,
new OptionsPanelController.Callbacks(
Toggle: () => { },
RequestExitToCharacterSelection: () => { },
ExitGame: () => { },
UseMouseTurningSettings: () => { },
DisplaySystemMessage: _ => { }))!;
var fake = new FakeBindings
{
Display = DisplaySettings.Default with
{
RenderPack = new RenderPackSelectionSettings("pack.alpha", "1.0.0", "low")
{
SettingOverrides = new RenderPackSettingOverrides(
new Dictionary<string, string>
{
["removed"] = "1",
["strength"] = "0.6", // not aligned to 0.25
}),
},
},
};
var pack = new ConfigOptionsPageController.RenderPackChoice(
"pack.alpha", "Alpha", "1.0.0", true, null,
[new("low", "Low", true, null)])
{
Settings =
[
new RenderSettingDeclaration(
"enabled", "Enabled", RenderSettingKind.Boolean,
"false", null, null, null, []),
new RenderSettingDeclaration(
"strength", "Strength", RenderSettingKind.Float,
"0.5", 0, 1, 0.25, []),
],
};
Assert.True(ConfigOptionsPageController.Bind(
layout,
controller.ConfigPage,
MakeTemplateResolver(),
(_, _) => null,
fake.ToBindings(new ConfigOptionsPageController.RenderPackBindings(() => [pack]))));
var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!;
var listBox = Assert.IsType<UiTemplateListBox>(
UiElement.FindDescendant(configSlot, ConfigOptionsPageController.ListBoxElementId));
UiElement enabledRow = listBox.ViewportForTest!.Children[42];
var enabled = Assert.IsType<UiButton>(
UiElement.FindDescendant(enabledRow, 0x10000219u));
enabled.Selected = true;
enabled.OnClick!();
Assert.Single(fake.Display.RenderPack.SettingOverrides);
Assert.Equal("true", fake.Display.RenderPack.SettingOverrides["enabled"]);
Assert.False(fake.Display.RenderPack.SettingOverrides.ContainsKey("removed"));
Assert.False(fake.Display.RenderPack.SettingOverrides.ContainsKey("strength"));
}
[Fact]
public void ToggleRow_SfxEnabled_WritesThroughAudioBindings()
{
@ -1043,7 +1419,7 @@ public sealed class ConfigOptionsPageControllerTests
(24, RowKind.Menu, true, "Environment Texture Detail"), // AP-198
(25, RowKind.Menu, true, "Texture Filtering"), // AP-198
(26, RowKind.Menu, true, "Landscape Draw Distance"), // AP-198
(27, RowKind.Toggle, true, "Building Detail Textures"), // AP-198
(27, RowKind.Toggle, false, "Building Detail Textures"), // LIVE — #226
(28, RowKind.Toggle, true, "Multi-Pass Alpha"), // AP-198
(31, RowKind.Slider, true, "Mouse Look Sensitivity"), // TS-74
(32, RowKind.Toggle, true, "Invert Mouselook Y Axis"), // TS-74

View file

@ -431,4 +431,47 @@ public sealed class OptionPageModelTests
Assert.Equal(0, flushCount); // NO AfterApply publication on a seed
Assert.Equal(1, gatingCount); // Apply/Reset ghosting re-evaluated
}
[Fact]
public void RemoveTail_DetachesRowsAndPageNotifyOwnership()
{
var page = new OptionPage();
var retained = new BoolOptionRow(false, false);
var removed = new BoolOptionRow(false, false);
int notifications = 0;
page.OnOptionChanged = () => notifications++;
page.Register(retained);
page.Register(removed);
page.RemoveTail(1);
notifications = 0;
removed.SetCurrentValue(true);
Assert.Single(page.Rows);
Assert.Same(retained, page.Rows[0]);
Assert.Equal(0, notifications);
Assert.False(page.Changed);
}
[Fact]
public void Defaults_IsSafeWhenAnEarlierRowReplacesTheDynamicTail()
{
var page = new OptionPage();
int removedApplyCount = 0;
var pack = new BoolOptionRow(
initial: true,
defaultValue: false,
apply: _ => page.RemoveTail(1));
var dynamic = new BoolOptionRow(
initial: false,
defaultValue: true,
apply: _ => removedApplyCount++);
page.Register(pack);
page.Register(dynamic);
page.Defaults();
Assert.Single(page.Rows);
Assert.Equal(0, removedApplyCount);
}
}

View file

@ -41,10 +41,65 @@ public sealed class RetailUiAutomationProbeTests
public bool IsWorldReady { get; set; }
public bool IsWorldViewportVisible { get; set; }
public int PortalMaterializationCount { get; set; }
public int RenderPackPerformanceSampleCount { get; set; }
public bool RenderPackFailedToRetail { get; set; }
public RetailUiAutomationRenderPackStatus RenderPackStatus { get; set; } =
RetailUiAutomationRenderPackStatus.Retail;
public int FramebufferWidth { get; set; } = 1280;
public int FramebufferHeight { get; set; } = 720;
public int RenderPackPerformanceResetCount { get; private set; }
public List<string> RenderPackSelections { get; } = [];
public int RenderPackDisableCount { get; private set; }
public int RenderPackReenableCount { get; private set; }
public List<(int Width, int Height)> FramebufferResizes { get; } = [];
public int ClientCloseRequestCount { get; private set; }
public List<string> Checkpoints { get; } = new();
public HashSet<string> ScreenshotRequests { get; } = new();
public HashSet<string> CompletedScreenshots { get; } = new();
public bool TryResetRenderPackPerformance(out string error)
{
RenderPackPerformanceResetCount++;
RenderPackPerformanceSampleCount = 0;
error = string.Empty;
return true;
}
public bool TrySelectRenderPack(string presetId, out string error)
{
RenderPackSelections.Add(presetId);
error = string.Empty;
return true;
}
public bool TryDisableRenderPack(out string error)
{
RenderPackDisableCount++;
error = string.Empty;
return true;
}
public bool TryReenableRenderPack(out string error)
{
RenderPackReenableCount++;
error = string.Empty;
return true;
}
public bool TryResizeFramebuffer(int width, int height, out string error)
{
FramebufferResizes.Add((width, height));
error = string.Empty;
return true;
}
public bool TryRequestClientClose(out string error)
{
ClientCloseRequestCount++;
error = string.Empty;
return true;
}
public bool TryRequestCheckpoint(
string name,
out IRetailUiAutomationCheckpoint? checkpoint,
@ -654,6 +709,199 @@ public sealed class RetailUiAutomationProbeTests
}
}
[Fact]
public void ScriptRunner_resetsThenWaitsForCompleteRenderPackEvidenceWindow()
{
var (root, _, _, _, objects) = RootWithTwoItemLists();
var runtime = new FakeRuntime
{
RenderPackPerformanceSampleCount = 1200,
};
var probe = new RetailUiAutomationProbe(root, objects);
string path = Path.Combine(
Path.GetTempPath(),
Path.GetRandomFileName() + ".ui-probe.txt");
File.WriteAllLines(path,
[
"renderpack reset-performance",
"wait render-pack-samples 2048 1000",
"screenshot complete-window 1000",
]);
try
{
using var runner = new RetailUiAutomationScriptRunner(
probe,
path,
dumpOnStart: false,
runtime: runtime);
runner.Tick(0d);
Assert.Equal(1, runtime.RenderPackPerformanceResetCount);
Assert.Equal(0, runtime.RenderPackPerformanceSampleCount);
Assert.Empty(runtime.ScreenshotRequests);
runtime.RenderPackPerformanceSampleCount = 2047;
runner.Tick(0.5d);
Assert.Empty(runtime.ScreenshotRequests);
runtime.RenderPackPerformanceSampleCount = 2048;
runner.Tick(0.001d);
Assert.Contains("complete-window", runtime.ScreenshotRequests);
Assert.False(runner.Completed);
runtime.CompletedScreenshots.Add("complete-window");
runner.Tick(0.001d);
Assert.True(runner.Completed);
}
finally
{
File.Delete(path);
}
}
[Fact]
public void ScriptRunner_renderPackWaitTerminatesEarlyForFailedToRetailFallback()
{
var (root, _, _, _, objects) = RootWithTwoItemLists();
var runtime = new FakeRuntime
{
RenderPackFailedToRetail = true,
};
var probe = new RetailUiAutomationProbe(root, objects);
string path = Path.Combine(
Path.GetTempPath(),
Path.GetRandomFileName() + ".ui-probe.txt");
File.WriteAllLines(path,
[
"renderpack reset-performance",
"wait render-pack-samples 2048 300000",
"screenshot safe-fallback 1000",
]);
try
{
using var runner = new RetailUiAutomationScriptRunner(
probe,
path,
dumpOnStart: false,
runtime: runtime);
runner.Tick(0d);
Assert.Equal(1, runtime.RenderPackPerformanceResetCount);
Assert.Equal(0, runtime.RenderPackPerformanceSampleCount);
Assert.Contains("safe-fallback", runtime.ScreenshotRequests);
Assert.False(runner.Completed);
runtime.CompletedScreenshots.Add("safe-fallback");
runner.Tick(0.001d);
Assert.True(runner.Completed);
}
finally
{
File.Delete(path);
}
}
[Fact]
public void ScriptRunner_renderPackTransitionsAndResizeWaitForPublishedRuntimeState()
{
var (root, _, _, _, objects) = RootWithTwoItemLists();
var runtime = new FakeRuntime();
var probe = new RetailUiAutomationProbe(root, objects);
string path = Path.Combine(
Path.GetTempPath(),
Path.GetRandomFileName() + ".ui-probe.txt");
File.WriteAllLines(path,
[
"renderpack select high",
"wait render-pack high 1000",
"renderpack disable",
"wait render-pack retail 1000",
"renderpack reenable",
"wait render-pack high 1000",
"resize 1024 768",
"wait framebuffer 1024 768 1000",
]);
try
{
using var runner = new RetailUiAutomationScriptRunner(
probe,
path,
dumpOnStart: false,
runtime: runtime);
runner.Tick(0d);
Assert.Equal(["high"], runtime.RenderPackSelections);
Assert.False(runner.Completed);
runtime.RenderPackStatus = new RetailUiAutomationRenderPackStatus(
RetailUiAutomationRenderPackState.Active,
"acdream.atmospheric",
"high",
ActivationGeneration: 1,
FailureReason: null);
runner.Tick(0.001d);
Assert.Equal(1, runtime.RenderPackDisableCount);
runtime.RenderPackStatus = RetailUiAutomationRenderPackStatus.Retail;
runner.Tick(0.001d);
Assert.Equal(1, runtime.RenderPackReenableCount);
runtime.RenderPackStatus = new RetailUiAutomationRenderPackStatus(
RetailUiAutomationRenderPackState.Active,
"acdream.atmospheric",
"high",
ActivationGeneration: 3,
FailureReason: null);
runner.Tick(0.001d);
Assert.Equal([(1024, 768)], runtime.FramebufferResizes);
Assert.False(runner.Completed);
runtime.FramebufferWidth = 1024;
runtime.FramebufferHeight = 768;
runner.Tick(0.001d);
Assert.True(runner.Completed);
}
finally
{
File.Delete(path);
}
}
[Fact]
public void ScriptRunner_closeClientRequestsNormalRuntimeShutdownExactlyOnce()
{
var (root, _, _, _, objects) = RootWithTwoItemLists();
var runtime = new FakeRuntime();
var probe = new RetailUiAutomationProbe(root, objects);
string path = Path.Combine(
Path.GetTempPath(),
Path.GetRandomFileName() + ".ui-probe.txt");
File.WriteAllText(path, "close-client");
try
{
using var runner = new RetailUiAutomationScriptRunner(
probe,
path,
dumpOnStart: false,
runtime: runtime);
runner.Tick(0d);
runner.Tick(0d);
Assert.True(runner.Completed);
Assert.Equal(1, runtime.ClientCloseRequestCount);
}
finally
{
File.Delete(path);
}
}
[Theory]
[InlineData(RetailUiAutomationCheckpointStatus.Failed, "deferred write failed")]
[InlineData(RetailUiAutomationCheckpointStatus.Cancelled, "shutdown cancelled")]

View file

@ -77,6 +77,29 @@ public sealed class UiTemplateListBoxViewportTests
Assert.True(row2.Visible, "row 2 culled — the #372 blank-tab bug");
}
[Fact]
public void RemoveTail_PreservesPrefixAndRestacksFutureRows()
{
var box = MakeListBox(276f, 560f);
var prefix = new UiText { Width = 260f, Height = 20f };
var removedA = new UiText { Width = 260f, Height = 30f };
var removedB = new UiText { Width = 260f, Height = 40f };
box.AddPrebuiltRow(prefix);
box.AddPrebuiltRow(removedA);
box.AddPrebuiltRow(removedB);
box.RemoveTail(1);
var replacement = new UiText { Width = 260f, Height = 25f };
box.AddPrebuiltRow(replacement);
Assert.Equal(2, box.ItemCount);
Assert.Same(prefix, box.ViewportForTest!.Children[0]);
Assert.Same(replacement, box.ViewportForTest.Children[1]);
Assert.Equal(20f, replacement.Top);
Assert.Null(removedA.Parent);
Assert.Null(removedB.Parent);
}
/// <summary>
/// #412-class regression (2026-08-16, overnight hover/UI round, Batch A bug
/// 2): the Options panel's Config tab escaped past the window frame — the

View file

@ -1,8 +1,9 @@
using AcDream.Plugin.Abstractions;
using AcDream.Plugin.Abstractions.Rendering;
namespace AcDream.Core.Tests.Fixtures.HelloPlugin;
public sealed class HelloPlugin : IAcDreamPlugin
public sealed class HelloPlugin : IAcDreamPlugin, IRenderPackPlugin, IRenderPackAssets
{
public int InitializeCount { get; private set; }
public int EnableCount { get; private set; }
@ -17,4 +18,39 @@ public sealed class HelloPlugin : IAcDreamPlugin
public void Enable() => EnableCount++;
public void Disable() => DisableCount++;
public void Register(IRenderPackRegistry registry)
{
ArgumentNullException.ThrowIfNull(registry);
_ = registry.Register(
new RenderPackDescriptor(
Id: "acdream.test.noop-pack",
DisplayName: "Test no-op pack",
PackVersion: new Version(1, 0, 0),
PackApiVersion: RenderPackApi.Current,
HighestTier: RenderPackTier.Tier1,
RequiredCapabilities: [],
OptionalCapabilities: [],
Resources: [],
Passes: [],
SceneReplays: [],
PipelineVariants: [],
QualityPresets: [],
Settings: [],
AtmospherePolicy: null)
{
FeatureSummary = "Test-only no-op render-pack fixture.",
},
this);
string? directory = Path.GetDirectoryName(typeof(HelloPlugin).Assembly.Location);
if (directory is not null
&& File.Exists(Path.Combine(directory, "throw-after-render-register")))
{
throw new InvalidOperationException(
"fixture render-pack registration failed after publishing a descriptor");
}
}
public Stream OpenRead(string assetKey) =>
new MemoryStream([], writable: false);
}

View file

@ -1,6 +1,7 @@
using AcDream.Core.Plugins;
using AcDream.Core.Selection;
using AcDream.Plugin.Abstractions;
using AcDream.Plugin.Abstractions.Rendering;
namespace AcDream.Core.Tests.Plugins;
@ -69,6 +70,36 @@ public class PluginLoaderTests
}
}
private sealed class RecordingRenderPackRegistry : IRenderPackRegistry, IDisposable
{
private readonly List<Registration> _registrations = [];
public int ActiveCount => _registrations.Count(static item => item.Active);
public RenderPackDescriptor? Descriptor { get; private set; }
public IDisposable Register(
RenderPackDescriptor descriptor,
IRenderPackAssets assets)
{
Descriptor = descriptor;
var registration = new Registration();
_registrations.Add(registration);
return registration;
}
public void Dispose()
{
foreach (Registration registration in _registrations)
registration.Dispose();
}
private sealed class Registration : IDisposable
{
public bool Active { get; private set; } = true;
public void Dispose() => Active = false;
}
}
[Fact]
public void Load_FixtureDll_InstantiatesPluginAndCallsInitialize()
{
@ -89,13 +120,42 @@ public class PluginLoaderTests
manifest: manifest,
host: host);
Assert.True(loaded.Success);
Assert.True(loaded.Success, loaded.Error?.ToString());
Assert.NotNull(loaded.Plugin);
Assert.Equal("HelloPlugin", loaded.Plugin!.GetType().Name);
loaded.Plugin.Disable();
loaded.LoadContext!.Unload();
}
[Fact]
public void Load_RenderPackOnlyFixture_RegistersWithoutGameplayEntrypointRequirement()
{
string dllPath = FixturePluginPath();
var host = new StubHost();
using var registry = new RecordingRenderPackRegistry();
var manifest = new PluginManifest(
Id: "acdream.test.render-pack",
DisplayName: "Render pack",
Version: "1.0.0",
EntryDll: Path.GetFileName(dllPath),
ApiVersion: 1,
Dependencies: [],
Kinds: [PluginKind.RenderPack]);
LoadedPlugin loaded = PluginLoader.Load(
Path.GetDirectoryName(dllPath)!,
manifest,
host,
registry);
Assert.True(loaded.Success, loaded.Error?.ToString());
Assert.Null(loaded.Plugin);
Assert.NotNull(loaded.RenderPackPlugin);
Assert.Equal(1, registry.ActiveCount);
Assert.Equal("acdream.test.noop-pack", registry.Descriptor?.Id);
loaded.LoadContext!.Unload();
}
[Fact]
public void Load_UnsupportedApiVersion_IsRefusedBeforeAnyCodeLoads()
{

View file

@ -24,6 +24,7 @@ public class PluginManifestTests
Assert.Equal("0.1.0", manifest.Version);
Assert.Equal("AcDream.Plugins.Smoke.dll", manifest.EntryDll);
Assert.Equal(1, manifest.ApiVersion);
Assert.Equal([PluginKind.Gameplay], manifest.Kinds);
}
[Fact]
@ -59,4 +60,49 @@ public class PluginManifestTests
var manifest = PluginManifest.Parse(json);
Assert.Empty(manifest.Dependencies);
}
[Fact]
public void Parse_RenderPackAndHybridKinds_AreExplicitAndDeduplicated()
{
const string json = """
{
"id": "x",
"displayName": "X",
"version": "1.0.0",
"entryDll": "x.dll",
"apiVersion": 1,
"kinds": ["renderPack", "gameplay", "RENDERPACK"]
}
""";
PluginManifest manifest = PluginManifest.Parse(json);
Assert.Equal(
[PluginKind.RenderPack, PluginKind.Gameplay],
manifest.Kinds);
Assert.True(manifest.Declares(PluginKind.RenderPack));
Assert.True(manifest.Declares(PluginKind.Gameplay));
}
[Theory]
[InlineData("[]", "kinds must contain at least one entry")]
[InlineData("[\"nativeCode\"]", "unknown plugin kind: nativeCode")]
public void Parse_InvalidKinds_Throws(string kindsJson, string expected)
{
string json = $$"""
{
"id": "x",
"displayName": "X",
"version": "1.0.0",
"entryDll": "x.dll",
"apiVersion": 1,
"kinds": {{kindsJson}}
}
""";
PluginManifestException error = Assert.Throws<PluginManifestException>(
() => PluginManifest.Parse(json));
Assert.Equal(expected, error.Message);
}
}

View file

@ -2,6 +2,7 @@ using System.Text.Json;
using AcDream.Core.Plugins;
using AcDream.Core.Selection;
using AcDream.Plugin.Abstractions;
using AcDream.Plugin.Abstractions.Rendering;
namespace AcDream.Core.Tests.Plugins;
@ -73,11 +74,118 @@ public sealed class PluginSessionTests
Assert.Empty(statuses);
}
[Fact]
public void GraphicalKindSet_RegistersRenderPackAndWithdrawsBeforeUnload()
{
using var temporary = new TemporaryDirectory();
InstallFixture(
temporary.Path,
"render",
"acdream.test.render",
[PluginKind.RenderPack]);
var statuses = new List<PluginSessionStatus>();
var registry = new RecordingRenderPackRegistry();
var plugins = new PluginSession(
new StubHost(),
statuses.Add,
registry,
[PluginKind.Gameplay, PluginKind.RenderPack]);
plugins.Start([temporary.Path], allowList: null);
Assert.Equal(1, plugins.LoadedCount);
Assert.Equal(1, registry.ActiveCount);
Assert.Equal("acdream.test.noop-pack", registry.Descriptor?.Id);
IReadOnlyList<WeakReference> contexts =
plugins.CaptureLoadContextWeakReferences();
plugins.Dispose();
Assert.Equal(0, registry.ActiveCount);
Collect(contexts);
}
[Fact]
public void GameplayOnlyHost_SkipsUnrequestedRenderPackBeforeDllProbe()
{
using var temporary = new TemporaryDirectory();
InstallBroken(
temporary.Path,
"render",
"acdream.test.render",
[PluginKind.RenderPack]);
var statuses = new List<PluginSessionStatus>();
using var plugins = new PluginSession(new StubHost(), statuses.Add);
plugins.Start([temporary.Path], allowList: null);
Assert.Equal(0, plugins.LoadedCount);
Assert.Empty(statuses);
Assert.Empty(plugins.CaptureLoadContextWeakReferences());
}
[Fact]
public void RenderPackRegisterFailure_WithdrawsPartialRegistrationBeforeUnload()
{
using var temporary = new TemporaryDirectory();
InstallFixture(
temporary.Path,
"render",
"acdream.test.render",
[PluginKind.RenderPack]);
File.WriteAllText(
Path.Combine(temporary.Path, "render", "throw-after-render-register"),
string.Empty);
var registry = new RecordingRenderPackRegistry();
var statuses = new List<PluginSessionStatus>();
var plugins = new PluginSession(
new StubHost(),
statuses.Add,
registry,
[PluginKind.Gameplay, PluginKind.RenderPack]);
plugins.Start([temporary.Path], allowList: null);
Assert.Equal(0, plugins.LoadedCount);
Assert.Equal(0, registry.ActiveCount);
PluginSessionStatus status = Assert.Single(statuses);
Assert.Equal(PluginSessionStatusKind.Failed, status.Kind);
Assert.Contains("failed after publishing", status.Error);
IReadOnlyList<WeakReference> contexts =
plugins.CaptureLoadContextWeakReferences();
plugins.Dispose();
Collect(contexts);
}
[Fact]
public void GameplayOnlyHost_ExplicitRenderPackReportsKindWithoutDllProbe()
{
using var temporary = new TemporaryDirectory();
InstallBroken(
temporary.Path,
"render",
"acdream.test.render",
[PluginKind.RenderPack]);
var statuses = new List<PluginSessionStatus>();
using var plugins = new PluginSession(new StubHost(), statuses.Add);
plugins.Start([temporary.Path], ["acdream.test.render"]);
PluginSessionStatus status = Assert.Single(statuses);
Assert.Equal(PluginSessionStatusKind.Failed, status.Kind);
Assert.Contains("does not support", status.Error);
Assert.DoesNotContain("entry dll", status.Error);
Assert.Empty(plugins.CaptureLoadContextWeakReferences());
}
private static void ReleaseAndCollect(PluginSession plugins)
{
IReadOnlyList<WeakReference> contexts =
plugins.CaptureLoadContextWeakReferences();
plugins.Dispose();
Collect(contexts);
}
private static void Collect(IReadOnlyList<WeakReference> contexts)
{
for (int attempt = 0;
attempt < 10 && contexts.Any(static context => context.IsAlive);
attempt++)
@ -90,6 +198,13 @@ public sealed class PluginSessionTests
}
private static void InstallFixture(string root, string folder, string id)
=> InstallFixture(root, folder, id, kinds: null);
private static void InstallFixture(
string root,
string folder,
string id,
IReadOnlyList<PluginKind>? kinds)
{
string source = FixturePluginPath();
Assert.True(File.Exists(source), $"fixture DLL not found: {source}");
@ -97,20 +212,25 @@ public sealed class PluginSessionTests
Directory.CreateDirectory(pluginDirectory);
string fileName = Path.GetFileName(source);
File.Copy(source, Path.Combine(pluginDirectory, fileName));
WriteManifest(pluginDirectory, id, fileName);
WriteManifest(pluginDirectory, id, fileName, kinds);
}
private static void InstallBroken(string root, string folder, string id)
private static void InstallBroken(
string root,
string folder,
string id,
IReadOnlyList<PluginKind>? kinds = null)
{
string pluginDirectory = Path.Combine(root, folder);
Directory.CreateDirectory(pluginDirectory);
WriteManifest(pluginDirectory, id, "missing.dll");
WriteManifest(pluginDirectory, id, "missing.dll", kinds);
}
private static void WriteManifest(
string directory,
string id,
string entryDll) =>
string entryDll,
IReadOnlyList<PluginKind>? kinds = null) =>
File.WriteAllText(
Path.Combine(directory, "plugin.json"),
JsonSerializer.Serialize(new
@ -120,8 +240,46 @@ public sealed class PluginSessionTests
version = "1.0.0",
entryDll,
apiVersion = 1,
kinds = kinds?.Select(static kind => kind.ToString()),
}));
private sealed class RecordingRenderPackRegistry : IRenderPackRegistry
{
private readonly List<Registration> _registrations = [];
internal int ActiveCount => _registrations.Count;
internal RenderPackDescriptor? Descriptor { get; private set; }
public IDisposable Register(
RenderPackDescriptor descriptor,
IRenderPackAssets assets)
{
Descriptor = descriptor;
var registration = new Registration(this, assets);
_registrations.Add(registration);
return registration;
}
private void Remove(Registration registration) =>
_registrations.Remove(registration);
private sealed class Registration(
RecordingRenderPackRegistry owner,
IRenderPackAssets assets) : IDisposable
{
private RecordingRenderPackRegistry? _owner = owner;
private IRenderPackAssets? _assets = assets;
public void Dispose()
{
RecordingRenderPackRegistry? current =
Interlocked.Exchange(ref _owner, null);
_assets = null;
current?.Remove(this);
}
}
}
private static string FixturePluginPath()
{
string configuration = new DirectoryInfo(AppContext.BaseDirectory)

View file

@ -149,4 +149,26 @@ public sealed class TranslucencyFadeManagerTests
Assert.Equal(1f, part0); // part 0's 1s ramp is done
Assert.Equal(0.5f, part1, 5); // part 1's 2s ramp is halfway
}
[Fact]
public void Revision_AdvancesOnlyWhenCommittedCasterOpacityChanges()
{
var mgr = new TranslucencyFadeManager();
ulong initial = mgr.Revision;
mgr.StartPartFade(1, 0, start: 0f, end: 1f, time: 1f);
ulong started = mgr.Revision;
Assert.True(started > initial);
mgr.AdvanceAll(0f);
Assert.Equal(started, mgr.Revision);
mgr.AdvanceAll(0.5f);
Assert.True(mgr.Revision > started);
ulong advanced = mgr.Revision;
mgr.ClearEntity(999);
Assert.Equal(advanced, mgr.Revision);
mgr.ClearEntity(1);
Assert.True(mgr.Revision > advanced);
}
}

View file

@ -124,6 +124,112 @@ public sealed class WbDrawDispatcherIndirectBuilderTests
Assert.Equal(0, result.TransparentCount);
}
[Fact]
public void EveryBuiltMeshMaterialSubsetIsDetailEligible()
{
TranslucencyKind[] kinds =
[
TranslucencyKind.Opaque,
TranslucencyKind.ClipMap,
TranslucencyKind.AlphaBlend,
TranslucencyKind.Additive,
TranslucencyKind.InvAlpha,
];
var groups = kinds.Select((kind, index) => new WbDrawDispatcher.IndirectGroupInput(
IndexCount: 3,
FirstIndex: (uint)(index * 3),
BaseVertex: 0,
InstanceCount: 1,
FirstInstance: index,
TextureIndex: (uint)index,
TextureLayer: 0,
Translucency: kind)).ToList();
var indirect = new DrawElementsIndirectCommand[kinds.Length];
var batches = new WbDrawDispatcher.BatchDataPublic[kinds.Length];
WbDrawDispatcher.BuildIndirectArrays(groups, indirect, batches);
Assert.All(batches, batch => Assert.Equal(1u, batch.Flags));
}
[Fact]
public void DetailCategoryPredicateCoversEveryInstanceInAnIndirectCommand()
{
var command = new DrawElementsIndirectCommand
{
BaseInstance = 2,
InstanceCount = 3,
};
Assert.True(WbDrawDispatcher.CommandContainsDetailCategory(
command,
[0u, 0u, 0u, 1u, 0u]));
Assert.False(WbDrawDispatcher.CommandContainsDetailCategory(
command,
[1u, 1u, 0u, 0u, 0u]));
Assert.Throws<ArgumentOutOfRangeException>(() =>
WbDrawDispatcher.CommandContainsDetailCategory(
command,
[0u, 0u, 0u, 1u]));
}
[Fact]
public void OpaqueDetailRunsSkipNonbuildingCommandsAndKeepMixedCommands()
{
DrawElementsIndirectCommand[] commands =
[
new() { BaseInstance = 0, InstanceCount = 2 },
new() { BaseInstance = 2, InstanceCount = 2 },
new() { BaseInstance = 4, InstanceCount = 1 },
];
// Command 1 is mixed: instance 2 is ordinary and instance 3 is a
// building. It must be submitted once, then mesh_detail filters the
// ordinary instance. Commands 0 and 2 must never reach the detail pipe.
uint[] mixedCategories = [0u, 0u, 0u, 1u, 0u];
Assert.True(WbDrawDispatcher.TryGetNextDetailCommandRun(
commands,
mixedCategories,
searchStart: 0,
exclusiveEnd: commands.Length,
out WbDrawDispatcher.DetailCommandRun mixedRun));
Assert.Equal(new WbDrawDispatcher.DetailCommandRun(1, 1), mixedRun);
Assert.False(WbDrawDispatcher.TryGetNextDetailCommandRun(
commands,
mixedCategories,
searchStart: mixedRun.FirstCommand + mixedRun.CommandCount,
exclusiveEnd: commands.Length,
out _));
Assert.False(WbDrawDispatcher.TryGetNextDetailCommandRun(
commands,
new uint[5],
searchStart: 0,
exclusiveEnd: commands.Length,
out _));
}
[Fact]
public void OpaqueDetailRunsCoalesceConsecutiveEligibleCommands()
{
DrawElementsIndirectCommand[] commands =
[
new() { BaseInstance = 0, InstanceCount = 1 },
new() { BaseInstance = 1, InstanceCount = 1 },
new() { BaseInstance = 2, InstanceCount = 1 },
new() { BaseInstance = 3, InstanceCount = 1 },
];
uint[] categories = [0u, 1u, 1u, 0u];
Assert.True(WbDrawDispatcher.TryGetNextDetailCommandRun(
commands,
categories,
searchStart: 0,
exclusiveEnd: commands.Length,
out WbDrawDispatcher.DetailCommandRun run));
Assert.Equal(new WbDrawDispatcher.DetailCommandRun(1, 2), run);
}
[Fact]
public void BatchDataPublic_LayoutMatchesPrivateBatchData()
{

View file

@ -226,4 +226,148 @@ public class LandblockMeshTests
Assert.Equal(10.0f, atX48Y0.Position.Z);
Assert.Equal(0.0f, atX0Y48.Position.Z);
}
[Fact]
public void Build_NormalsMatchRetailIncidentFaceAverages_NotCentralDifferences()
{
// A deliberately non-planar surface makes retail's split-aware
// incident-plane average observably different from the former
// central-difference approximation.
var block = BuildFlatLandBlock();
for (int x = 0; x < LandblockMesh.HeightmapSide; x++)
for (int y = 0; y < LandblockMesh.HeightmapSide; y++)
block.Height[x * LandblockMesh.HeightmapSide + y] =
(byte)((x * x * 3 + y * y * 5 + x * y * 11 + x * 7 + y * 13) % 96);
const uint landblockX = 0xA9;
const uint landblockY = 0xB4;
var mesh = LandblockMesh.Build(
block,
landblockX,
landblockY,
IdentityHeightTable,
MakeContext(),
new Dictionary<uint, SurfaceInfo>());
// Independent geometry oracle: derive each polygon plane from the
// actual emitted positions/indices, accumulate it at the shared
// position, and normalize only after every incident polygon is seen.
var incidentNormalSums = new Dictionary<Vector3, Vector3>();
for (int i = 0; i < mesh.Indices.Length; i += 3)
{
Vector3 p0 = mesh.Vertices[mesh.Indices[i]].Position;
Vector3 p1 = mesh.Vertices[mesh.Indices[i + 1]].Position;
Vector3 p2 = mesh.Vertices[mesh.Indices[i + 2]].Position;
Vector3 planeNormal = Vector3.Normalize(Vector3.Cross(p1 - p0, p2 - p0));
AddNormal(incidentNormalSums, p0, planeNormal);
AddNormal(incidentNormalSums, p1, planeNormal);
AddNormal(incidentNormalSums, p2, planeNormal);
}
foreach (TerrainVertex vertex in mesh.Vertices)
{
Vector3 expected = Vector3.Normalize(incidentNormalSums[vertex.Position]);
AssertVectorNear(expected, vertex.Normal, 1e-6f);
Assert.InRange(vertex.Normal.Length(), 1f - 1e-6f, 1f + 1e-6f);
}
bool differsFromCentralDifferences = false;
for (int x = 0; x < LandblockMesh.HeightmapSide; x++)
{
for (int y = 0; y < LandblockMesh.HeightmapSide; y++)
{
int xL = Math.Max(x - 1, 0);
int xR = Math.Min(x + 1, LandblockMesh.HeightmapSide - 1);
int yD = Math.Max(y - 1, 0);
int yU = Math.Min(y + 1, LandblockMesh.HeightmapSide - 1);
float dx = (HeightAt(block, xR, y) - HeightAt(block, xL, y)) /
((xR - xL) * LandblockMesh.CellSize);
float dy = (HeightAt(block, x, yU) - HeightAt(block, x, yD)) /
((yU - yD) * LandblockMesh.CellSize);
Vector3 oldApproximation = Vector3.Normalize(new Vector3(-dx, -dy, 1f));
Vector3 position = new(
x * LandblockMesh.CellSize,
y * LandblockMesh.CellSize,
HeightAt(block, x, y));
Vector3 actual = mesh.Vertices.First(vertex => vertex.Position == position).Normal;
differsFromCentralDifferences |= Vector3.Distance(oldApproximation, actual) > 1e-4f;
}
}
Assert.True(
differsFromCentralDifferences,
"Synthetic terrain failed to distinguish retail incident-face averaging from central differences.");
}
[Theory]
[InlineData(0u, 0u)]
[InlineData(0xA9u, 0xB4u)]
public void Build_RetailNormalChange_PreservesExactSplitAwarePositionsAndIndices(
uint landblockX,
uint landblockY)
{
var block = BuildFlatLandBlock();
for (int x = 0; x < LandblockMesh.HeightmapSide; x++)
for (int y = 0; y < LandblockMesh.HeightmapSide; y++)
block.Height[x * LandblockMesh.HeightmapSide + y] =
(byte)((x * 17 + y * 29 + x * y * 3) % 80);
var mesh = LandblockMesh.Build(
block,
landblockX,
landblockY,
IdentityHeightTable,
MakeContext(),
new Dictionary<uint, SurfaceInfo>());
Assert.Equal(
Enumerable.Range(0, LandblockMesh.VerticesPerLandblock).Select(i => (uint)i),
mesh.Indices);
int vertexIndex = 0;
for (int cy = 0; cy < LandblockMesh.CellsPerSide; cy++)
{
for (int cx = 0; cx < LandblockMesh.CellsPerSide; cx++)
{
Vector3 bl = PositionAt(block, cx, cy);
Vector3 br = PositionAt(block, cx + 1, cy);
Vector3 tr = PositionAt(block, cx + 1, cy + 1);
Vector3 tl = PositionAt(block, cx, cy + 1);
Vector3[] expected = TerrainBlending.CalculateSplitDirection(
landblockX, (uint)cx, landblockY, (uint)cy) == CellSplitDirection.SWtoNE
? [bl, br, tr, bl, tr, tl]
: [bl, br, tl, br, tr, tl];
foreach (Vector3 position in expected)
Assert.Equal(position, mesh.Vertices[vertexIndex++].Position);
}
}
Assert.Equal(LandblockMesh.VerticesPerLandblock, vertexIndex);
}
private static float HeightAt(LandBlock block, int x, int y) =>
IdentityHeightTable[block.Height[x * LandblockMesh.HeightmapSide + y]];
private static Vector3 PositionAt(LandBlock block, int x, int y) => new(
x * LandblockMesh.CellSize,
y * LandblockMesh.CellSize,
HeightAt(block, x, y));
private static void AddNormal(
IDictionary<Vector3, Vector3> sums,
Vector3 position,
Vector3 normal)
{
sums.TryGetValue(position, out Vector3 sum);
sums[position] = sum + normal;
}
private static void AssertVectorNear(Vector3 expected, Vector3 actual, float epsilon)
{
Assert.InRange(actual.X, expected.X - epsilon, expected.X + epsilon);
Assert.InRange(actual.Y, expected.Y - epsilon, expected.Y + epsilon);
Assert.InRange(actual.Z, expected.Z - epsilon, expected.Z + epsilon);
}
}

View file

@ -1,8 +1,10 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Content;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Lib.IO;
using DatReaderWriter.Types;
using Xunit;
@ -126,6 +128,55 @@ public sealed class SkyDescLoaderTests
Assert.Equal(0x01004C44u, obj.GfxObjId);
Assert.Equal(0x3300042Cu, obj.PesObjectId);
Assert.True(obj.IsPostScene);
Assert.Equal(Vector3.Zero, obj.AuthoredSortCenter);
}
[Fact]
public void LoadFromRegion_WithDatSourceCarriesDefaultAndReplacementSortCenters()
{
const uint defaultId = 0x01001348u;
const uint replacementId = 0x01001F6Au;
Vector3 defaultCenter = new(1050f, 0f, 0f);
Vector3 replacementCenter = new(2066.82f, 552.99f, 0f);
Region region = MakeRegion(dirBright: 1f, rBgrOrder: 255);
DayGroup group = region.SkyInfo!.DayGroups[0];
group.SkyObjects.Add(new SkyObject
{
DefaultGfxObjectId = defaultId,
});
group.SkyTime[0].SkyObjReplace.Add(new SkyObjectReplace
{
ObjectIndex = 0,
GfxObjId = replacementId,
});
var dats = new FakeDatObjectSource();
dats.Add(defaultId, new GfxObj { SortCenter = defaultCenter });
dats.Add(replacementId, new GfxObj { SortCenter = replacementCenter });
LoadedSkyDesc loaded = Assert.IsType<LoadedSkyDesc>(
SkyDescLoader.LoadFromRegion(region, dats));
Assert.Equal(defaultCenter,
Assert.Single(loaded.DayGroups[0].SkyObjects).AuthoredSortCenter);
Assert.Equal(replacementCenter,
Assert.Single(loaded.DayGroups[0].SkyTimes[0].Replaces).AuthoredSortCenter);
}
[Fact]
public void LoadFromRegion_FailedOptionalSortCenterLookupCannotFailSkyLoading()
{
Region region = MakeRegion(dirBright: 1f, rBgrOrder: 255);
region.SkyInfo!.DayGroups[0].SkyObjects.Add(new SkyObject
{
DefaultGfxObjectId = 0x01001348u,
});
LoadedSkyDesc loaded = Assert.IsType<LoadedSkyDesc>(
SkyDescLoader.LoadFromRegion(region, new ThrowingDatObjectSource()));
Assert.Equal(
Vector3.Zero,
Assert.Single(loaded.DayGroups[0].SkyObjects).AuthoredSortCenter);
}
[Fact]
@ -201,4 +252,31 @@ public sealed class SkyDescLoaderTests
// At begin → begin angle.
Assert.Equal(0f, obj.CurrentAngle(0.25f), precision: 2);
}
private sealed class FakeDatObjectSource : IDatObjectSource
{
private readonly Dictionary<uint, IDBObj> _objects = [];
internal void Add(uint id, IDBObj value) => _objects[id] = value;
public T Get<T>(uint fileId) where T : IDBObj =>
_objects.TryGetValue(fileId, out IDBObj? value) && value is T typed
? typed
: default!;
public bool TryGet<T>(uint fileId, out T value) where T : IDBObj
{
value = Get<T>(fileId);
return value is not null;
}
}
private sealed class ThrowingDatObjectSource : IDatObjectSource
{
public T Get<T>(uint fileId) where T : IDBObj =>
throw new InvalidOperationException("synthetic DAT lookup failure");
public bool TryGet<T>(uint fileId, out T value) where T : IDBObj =>
throw new InvalidOperationException("synthetic DAT lookup failure");
}
}

View file

@ -108,6 +108,45 @@ public sealed class HeadlessPluginSessionTests
Assert.DoesNotContain("fixture-", output.ToString());
}
[Fact]
public void RenderPackOnlyRequest_IsRejectedBeforeHeadlessLoadsItsDll()
{
using var temporary = new TemporaryDirectory();
const string renderPackId = "acdream.test.render-only";
string pluginDirectory = Path.Combine(temporary.Path, "render-only");
Directory.CreateDirectory(pluginDirectory);
File.WriteAllText(
Path.Combine(pluginDirectory, "plugin.json"),
JsonSerializer.Serialize(new
{
id = renderPackId,
displayName = "Render only",
version = "1.0.0",
entryDll = "deliberately-missing.dll",
apiVersion = 1,
kinds = new[] { "renderPack" },
}));
string statusPath = Path.Combine(temporary.Path, "status.jsonl");
var credential = new HeadlessCredentialSecret("fixture", "password");
using var session = new HeadlessSessionHost(
Descriptor([renderPackId], statusPath),
credential,
new HeadlessDiagnosticWriter(new StringWriter()),
new FixtureSessionOperations(),
pluginRoots: [temporary.Path]);
_ = session.Start();
Assert.Equal(0, session.Plugins.LoadedCount);
Assert.Empty(session.Plugins.CaptureLoadContextWeakReferences());
JsonElement failed = ReadStatuses(statusPath)
.Single(static item =>
item.GetProperty("e").GetString() == "pluginFailed");
string error = failed.GetProperty("error").GetString()!;
Assert.Contains("does not support", error);
Assert.DoesNotContain("entry dll", error, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ThrowAfterRegistrationRollsBackEventsAndCollectsContext()
{

View file

@ -28,13 +28,25 @@ if (!string.IsNullOrWhiteSpace(selfUpdateData)
using var http = new HttpClient();
var manager = new LauncherSelfUpdateManager(Paths(selfUpdateData), http);
SelfUpdateStartupResult startup = await LauncherSelfUpdateBootstrap.HandleAsync(
effectiveArgs,
manager,
Path.GetFullPath(AppContext.BaseDirectory),
Path.GetFullPath(
Environment.ProcessPath
?? throw new InvalidOperationException("Process path is unavailable.")));
SelfUpdateStartupResult startup;
try
{
startup = await LauncherSelfUpdateBootstrap.HandleAsync(
effectiveArgs,
manager,
Path.GetFullPath(AppContext.BaseDirectory),
Path.GetFullPath(
Environment.ProcessPath
?? throw new InvalidOperationException("Process path is unavailable.")));
}
catch (LauncherUpdateException)
{
// Refusal is an expected result in the hostile-state process tests.
// Translate it to a stable non-zero code instead of invoking the OS
// unhandled-exception path, which can launch a crash reporter and make
// a process-lifetime test wait on diagnostics rather than our result.
return 74;
}
if (startup.ShouldExit)
{
return startup.ExitCode;
@ -149,11 +161,19 @@ static async Task<int> BootstrapProbeAsync(string[] arguments)
using var http = new HttpClient();
var manager = new LauncherSelfUpdateManager(Paths(arguments[0]), http);
SelfUpdateStartupResult result = await LauncherSelfUpdateBootstrap.HandleAsync(
["ordinary"],
manager,
Path.GetFullPath(arguments[1]),
Path.GetFullPath(arguments[2]));
SelfUpdateStartupResult result;
try
{
result = await LauncherSelfUpdateBootstrap.HandleAsync(
["ordinary"],
manager,
Path.GetFullPath(arguments[1]),
Path.GetFullPath(arguments[2]));
}
catch (LauncherUpdateException)
{
return 74;
}
File.WriteAllText(
Path.GetFullPath(arguments[3]),
result.ShouldExit ? "exit" : string.Join("\n", result.RemainingArguments));

View file

@ -12,6 +12,9 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
private const string TargetEnvironment = "ACDREAM_SELF_UPDATE_FIXTURE_TARGET";
private const string HelperPidEnvironment =
"ACDREAM_SELF_UPDATE_FIXTURE_HELPER_PID";
private static readonly System.Runtime.CompilerServices.ConditionalWeakTable<
Process,
ProcessOutputCapture> ProcessOutput = new();
private readonly string _root = Path.Combine(
Path.GetTempPath(),
@ -361,8 +364,8 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
], environment);
await helper.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
string helperError = await helper.StandardError.ReadToEndAsync();
string helperOutput = await helper.StandardOutput.ReadToEndAsync();
string helperError = await ReadStandardErrorAsync(helper);
string helperOutput = await ReadStandardOutputAsync(helper);
Assert.True(
helper.ExitCode == LauncherSelfUpdateBootstrap.UpdateLeaseBusyExitCode,
$"helper exit {helper.ExitCode}; stdout: {helperOutput}; stderr: {helperError}");
@ -481,7 +484,7 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
{
using Process process = StartProcess(canonicalPath, arguments, environment);
await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
string stderr = await process.StandardError.ReadToEndAsync();
string stderr = await ReadStandardErrorAsync(process);
if (exactExit.HasValue)
{
Assert.True(
@ -699,10 +702,26 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
}
}
return Process.Start(start)
Process process = Process.Start(start)
?? throw new InvalidOperationException($"Could not start '{executable}'.");
ProcessOutput.Add(
process,
new ProcessOutputCapture(
process.StandardOutput.ReadToEndAsync(),
process.StandardError.ReadToEndAsync()));
return process;
}
private static Task<string> ReadStandardOutputAsync(Process process) =>
ProcessOutput.TryGetValue(process, out ProcessOutputCapture? capture)
? capture.StandardOutput
: process.StandardOutput.ReadToEndAsync();
private static Task<string> ReadStandardErrorAsync(Process process) =>
ProcessOutput.TryGetValue(process, out ProcessOutputCapture? capture)
? capture.StandardError
: process.StandardError.ReadToEndAsync();
private static async Task WaitForFileAsync(
string path,
Process? process,
@ -726,9 +745,9 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
{
throw new InvalidOperationException(
$"{failure} Process exited {process.ExitCode}. stdout: "
+ await process.StandardOutput.ReadToEndAsync()
+ await ReadStandardOutputAsync(process)
+ " stderr: "
+ await process.StandardError.ReadToEndAsync());
+ await ReadStandardErrorAsync(process));
}
if (DateTimeOffset.UtcNow >= deadline)
@ -766,6 +785,10 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
private static string GetFixtureDllPath() =>
Path.Combine(GetFixtureDirectory(), FixtureBaseName + ".dll");
private sealed record ProcessOutputCapture(
Task<string> StandardOutput,
Task<string> StandardError);
private static string GetFixtureDirectory()
{
string configuration = new DirectoryInfo(AppContext.BaseDirectory)

View file

@ -117,6 +117,60 @@ public sealed class ApplicationPathSetTests
paths.LegacyConfigDirectory);
}
[Fact]
public void CrossPlatformEnvironmentOverridesIsolateAllMutableUserState()
{
string root = Path.GetFullPath(
Path.Combine(Path.GetTempPath(), "acdream-isolated-automation"));
var platform = new FixtureEnvironment(isWindows: true)
{
CurrentDirectoryValue = root,
ApplicationData = Path.Combine(root, "real-roaming"),
LocalApplicationData = Path.Combine(root, "real-local"),
Variables =
{
["ACDREAM_CONFIG_DIR"] = "capture-config",
["ACDREAM_DATA_DIR"] = "capture-data",
["ACDREAM_CACHE_DIR"] = "capture-cache",
},
};
ApplicationPathSet paths = ApplicationPathSet.Resolve(platform: platform);
Assert.Equal(Path.Combine(root, "capture-config"), paths.ConfigDirectory);
Assert.Equal(Path.Combine(root, "capture-data"), paths.DataDirectory);
Assert.Equal(Path.Combine(root, "capture-cache"), paths.CacheDirectory);
Assert.Null(paths.LegacyConfigDirectory);
}
[Fact]
public void ExplicitArgumentsOverrideAutomationEnvironmentRoots()
{
string root = Path.GetFullPath(
Path.Combine(Path.GetTempPath(), "acdream-explicit-paths"));
var platform = new FixtureEnvironment(isWindows: false)
{
CurrentDirectoryValue = root,
UserProfile = Path.Combine(root, "home"),
Variables =
{
["ACDREAM_CONFIG_DIR"] = "environment-config",
["ACDREAM_DATA_DIR"] = "environment-data",
["ACDREAM_CACHE_DIR"] = "environment-cache",
},
};
ApplicationPathSet paths = ApplicationPathSet.Resolve(
"explicit-config",
"explicit-data",
"explicit-cache",
platform);
Assert.Equal(Path.Combine(root, "explicit-config"), paths.ConfigDirectory);
Assert.Equal(Path.Combine(root, "explicit-data"), paths.DataDirectory);
Assert.Equal(Path.Combine(root, "explicit-cache"), paths.CacheDirectory);
}
private sealed class FixtureEnvironment(bool isWindows)
: IApplicationPathEnvironment
{

View file

@ -1,4 +1,5 @@
using AcDream.Plugin.Abstractions;
using AcDream.Plugin.Abstractions.Rendering;
using System.Runtime.Loader;
namespace AcDream.Plugin.Tests.Fixtures.HostPlugin;
@ -9,7 +10,7 @@ namespace AcDream.Plugin.Tests.Fixtures.HostPlugin;
/// gameplay events. A headless registry must make the UI call harmless without
/// retaining this instance in the default load context.
/// </summary>
public sealed class HostPlugin : IAcDreamPlugin
public sealed class HostPlugin : IAcDreamPlugin, IRenderPackPlugin, IRenderPackAssets
{
private IPluginHost? _host;
private string? _assemblyDirectory;
@ -67,6 +68,63 @@ public sealed class HostPlugin : IAcDreamPlugin
_host = null;
}
public void Register(IRenderPackRegistry registry)
{
ArgumentNullException.ThrowIfNull(registry);
string directory = Path.GetDirectoryName(
typeof(HostPlugin).Assembly.Location)!;
if (File.Exists(Path.Combine(directory, "register-no-render-packs")))
return;
string versionPath = Path.Combine(directory, "render-pack-version.txt");
Version version = File.Exists(versionPath)
? Version.Parse(File.ReadAllText(versionPath).Trim())
: new Version(1, 0, 0);
_ = registry.Register(
new RenderPackDescriptor(
Id: "acdream.test.external-render-pack",
DisplayName: "External graphical render-pack fixture",
PackVersion: version,
PackApiVersion: RenderPackApi.Current,
HighestTier: RenderPackTier.Tier1,
RequiredCapabilities: [],
OptionalCapabilities: [],
Resources: [],
Passes: [],
SceneReplays: [],
PipelineVariants: [],
QualityPresets:
[
Preset("low", "Low"),
Preset("high", "High"),
],
Settings: [],
AtmospherePolicy: null)
{
FeatureSummary = "Collectible external no-op render-pack fixture.",
},
this);
if (File.Exists(Path.Combine(directory, "throw-after-render-pack-register")))
{
throw new InvalidOperationException(
"fixture render-pack registration failed after publishing its descriptor");
}
}
public Stream OpenRead(string assetKey) =>
new MemoryStream([], writable: false);
private static RenderQualityPreset Preset(string id, string displayName) => new(
id,
displayName,
RequiredCapabilities: [],
ResourceOverrides: [],
SettingOverrides: [],
MaxResidentGpuBytes: 0,
MaxIncrementalGpuMillisecondsP50: 0,
MaxIncrementalGpuMillisecondsP99: 0,
MaxIncrementalCpuMillisecondsP50: 0,
MaxIncrementalCpuMillisecondsP99: 0);
private void RegisterHostCallbacks(IPluginHost host)
{
host.Ui.AddMarkupPanel(

View file

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\AcDream.Plugin.Abstractions\AcDream.Plugin.Abstractions.csproj">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</ProjectReference>
</ItemGroup>
</Project>

View file

@ -0,0 +1,8 @@
using AcDream.Plugin.Abstractions.Rendering;
namespace AcDream.Plugin.Tests.Fixtures.InvalidRenderPackInternal;
internal sealed class InternalRenderPackPlugin : IRenderPackPlugin
{
public void Register(IRenderPackRegistry registry) { }
}

View file

@ -0,0 +1,10 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"acdream.plugin.abstractions": {
"type": "Project"
}
}
}
}

View file

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\AcDream.Plugin.Abstractions\AcDream.Plugin.Abstractions.csproj">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</ProjectReference>
</ItemGroup>
</Project>

View file

@ -0,0 +1,13 @@
using AcDream.Plugin.Abstractions.Rendering;
namespace AcDream.Plugin.Tests.Fixtures.InvalidRenderPackMultiple;
public sealed class FirstRenderPackPlugin : IRenderPackPlugin
{
public void Register(IRenderPackRegistry registry) { }
}
public sealed class SecondRenderPackPlugin : IRenderPackPlugin
{
public void Register(IRenderPackRegistry registry) { }
}

View file

@ -0,0 +1,10 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"acdream.plugin.abstractions": {
"type": "Project"
}
}
}
}

View file

@ -0,0 +1,251 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank.Tests;
public sealed class MossTankPanelTests
{
[Fact]
public void RetainedLabelReads_UseUpdateSideSnapshotsWithoutAllocating()
{
var automation = new FakeAutomation
{
CurrentHealth = 90,
MaxHealth = 100,
CurrentStamina = 80,
MaxStamina = 110,
CurrentMana = 70,
MaxMana = 120,
Skills =
[
new PluginSkillInfo(1, "Life Magic", PluginSkillTraining.Trained, 300),
new PluginSkillInfo(2, "War Magic", PluginSkillTraining.Specialized, 350),
new PluginSkillInfo(3, "Run", PluginSkillTraining.Untrained, 100),
],
Attributes =
[
new PluginAttributeInfo(0, "Strength", 100),
new PluginAttributeInfo(1, "Endurance", 100),
],
KnownSelfBuffs =
[
Spell(1, 10, "Increases the caster's Life Magic skill by 10 points."),
],
};
var panel = new MossTankPanel(new FakeHost(automation));
panel.OnTick(0.0);
Assert.Equal("Health 90/100 Stam 80/110 Mana 70/120", panel.Vitals);
Assert.Equal("2 attributes, 2 trained skills, 1 buff lines", panel.Coverage);
string expectedVitals = panel.Vitals;
string expectedCoverage = panel.Coverage;
_ = panel.Vitals;
_ = panel.Coverage;
long before = GC.GetAllocatedBytesForCurrentThread();
bool sameReferences = true;
for (int i = 0; i < 10_000; i++)
{
sameReferences &= ReferenceEquals(expectedVitals, panel.Vitals);
sameReferences &= ReferenceEquals(expectedCoverage, panel.Coverage);
}
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.True(sameReferences);
Assert.Equal(0, allocated);
Assert.Equal(1, automation.KnownSelfBuffReads);
}
[Fact]
public void UpdateTick_RefreshesRareCoverageAndChangedVitals()
{
var automation = new FakeAutomation
{
CurrentHealth = 90,
MaxHealth = 100,
CurrentStamina = 80,
MaxStamina = 110,
CurrentMana = 70,
MaxMana = 120,
Skills =
[
new PluginSkillInfo(1, "Life Magic", PluginSkillTraining.Trained, 300),
],
Attributes = [new PluginAttributeInfo(0, "Strength", 100)],
KnownSelfBuffs =
[
Spell(1, 10, "Increases the caster's Life Magic skill by 10 points."),
],
};
var panel = new MossTankPanel(new FakeHost(automation));
panel.OnTick(0.0);
automation.CurrentHealth = 75;
automation.Skills =
[
new PluginSkillInfo(1, "Life Magic", PluginSkillTraining.Trained, 300),
new PluginSkillInfo(2, "War Magic", PluginSkillTraining.Specialized, 350),
];
panel.OnTick(0.5);
Assert.StartsWith("Health 75/100", panel.Vitals, StringComparison.Ordinal);
Assert.Equal("1 attributes, 1 trained skills, 1 buff lines", panel.Coverage);
panel.OnTick(0.5);
Assert.Equal("1 attributes, 2 trained skills, 1 buff lines", panel.Coverage);
automation.KnownSelfBuffs =
[
Spell(1, 10, "Increases the caster's Life Magic skill by 10 points."),
Spell(2, 11, "Increases the caster's War Magic skill by 10 points."),
];
panel.OnTick(0.0);
Assert.Equal("1 attributes, 2 trained skills, 2 buff lines", panel.Coverage);
}
private static PluginSpellInfo Spell(uint id, uint family, string description) => new(
id,
$"Spell {id}",
family,
Tier: 1,
Difficulty: 10,
ManaCost: 5,
DurationSeconds: 60f,
School: 1,
description,
IsSelfTargeted: true,
IsBeneficial: true);
private sealed class FakeHost(IAutomationSurface automation) : IPluginHost
{
public bool HasUi => false;
public IPluginLogger Log { get; } = new FakeLogger();
public IGameState State { get; } = new FakeState();
public IEvents Events { get; } = new FakeEvents();
public ISelectionService Selection { get; } = new FakeSelection();
public IUiRegistry Ui => NoOpUiRegistry.Instance;
public IAutomationSurface Automation { get; } = automation;
}
private sealed class FakeAutomation
: IAutomationSurface, ICharacterInfo, ISpellCatalog, IMagicCommands, IPluginChat
{
private IReadOnlyList<PluginSpellInfo> _knownSelfBuffs = [];
public bool IsAvailable { get; set; } = true;
public ICharacterInfo Character => this;
public ISpellCatalog Spells => this;
public IMagicCommands Magic => this;
public IPluginChat Chat => this;
public bool IsInWorld => IsAvailable;
public uint ObjectId { get; set; } = 1;
public uint CurrentHealth { get; set; }
public uint MaxHealth { get; set; }
public uint CurrentStamina { get; set; }
public uint MaxStamina { get; set; }
public uint CurrentMana { get; set; }
public uint MaxMana { get; set; }
public IReadOnlyList<PluginSkillInfo> Skills { get; set; } = [];
public IReadOnlyList<PluginAttributeInfo> Attributes { get; set; } = [];
public IReadOnlyList<PluginActiveEnchantment> ActiveEnchantments { get; set; } = [];
public int KnownSelfBuffReads { get; private set; }
public IReadOnlyList<PluginSpellInfo> KnownSelfBuffs
{
get
{
KnownSelfBuffReads++;
return _knownSelfBuffs;
}
set => _knownSelfBuffs = value;
}
public bool TryGetSkill(uint skillId, out PluginSkillInfo skill)
{
foreach (PluginSkillInfo candidate in Skills)
{
if (candidate.SkillId == skillId)
{
skill = candidate;
return true;
}
}
skill = default;
return false;
}
public bool TryGet(uint spellId, out PluginSpellInfo info)
{
foreach (PluginSpellInfo candidate in _knownSelfBuffs)
{
if (candidate.SpellId == spellId)
{
info = candidate;
return true;
}
}
info = default;
return false;
}
public bool IsCasting => false;
public PluginCastGate EvaluateGate(uint spellId) => PluginCastGate.Ready;
public bool Cast(uint spellId) => true;
public void PostSystemMessage(string text) { }
}
private sealed class FakeLogger : IPluginLogger
{
public void Info(string message) { }
public void Warn(string message) { }
public void Error(string message, Exception? exception = null) { }
}
private sealed class FakeState : IGameState
{
public IReadOnlyList<WorldEntitySnapshot> Entities => [];
}
private sealed class FakeEvents : IEvents
{
public event Action<WorldEntitySnapshot> EntitySpawned
{
add { }
remove { }
}
public event Action<double> Tick
{
add { }
remove { }
}
}
private sealed class FakeSelection : ISelectionService
{
public uint? SelectedObjectId { get; private set; }
public uint? PreviousObjectId { get; private set; }
public event Action<SelectionChangedEvent> Changed
{
add { }
remove { }
}
public bool Select(uint objectId)
{
PreviousObjectId = SelectedObjectId;
SelectedObjectId = objectId;
return true;
}
public bool Clear()
{
PreviousObjectId = SelectedObjectId;
SelectedObjectId = null;
return true;
}
}
}

View file

@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\tools\RenderPackValidator\AcDream.Tools.RenderPackValidator.csproj" />
<ProjectReference Include="..\..\samples\AcDream.RenderPacks.NoOp\AcDream.RenderPacks.NoOp.csproj">
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
</ProjectReference>
<ProjectReference Include="..\..\samples\AcDream.RenderPacks.AtmosphericTier2\AcDream.RenderPacks.AtmosphericTier2.csproj">
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
</ProjectReference>
<ProjectReference Include="..\..\samples\AcDream.RenderPacks.ShadowsOnlyTier2\AcDream.RenderPacks.ShadowsOnlyTier2.csproj">
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
</ProjectReference>
</ItemGroup>
</Project>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,107 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"Microsoft.NET.Test.Sdk": {
"type": "Direct",
"requested": "[17.14.1, )",
"resolved": "17.14.1",
"contentHash": "HJKqKOE+vshXra2aEHpi2TlxYX7Z9VFYkr+E5rwEvHC8eIXiyO+K9kNm8vmNom3e2rA56WqxU+/N9NJlLGXsJQ==",
"dependencies": {
"Microsoft.CodeCoverage": "17.14.1",
"Microsoft.TestPlatform.TestHost": "17.14.1"
}
},
"xunit": {
"type": "Direct",
"requested": "[2.9.3, )",
"resolved": "2.9.3",
"contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==",
"dependencies": {
"xunit.analyzers": "1.18.0",
"xunit.assert": "2.9.3",
"xunit.core": "[2.9.3]"
}
},
"xunit.runner.visualstudio": {
"type": "Direct",
"requested": "[3.1.4, )",
"resolved": "3.1.4",
"contentHash": "5mj99LvCqrq3CNi06xYdyIAXOEh+5b33F2nErCzI5zWiDdLHXiPXEWFSUAF8zlIv0ZWqjZNCwHTQeAPYbF3pCg=="
},
"Microsoft.CodeCoverage": {
"type": "Transitive",
"resolved": "17.14.1",
"contentHash": "pmTrhfFIoplzFVbhVwUquT+77CbGH+h4/3mBpdmIlYtBi9nAB+kKI6dN3A/nV4DFi3wLLx/BlHIPK+MkbQ6Tpg=="
},
"Microsoft.TestPlatform.ObjectModel": {
"type": "Transitive",
"resolved": "17.14.1",
"contentHash": "xTP1W6Mi6SWmuxd3a+jj9G9UoC850WGwZUps1Wah9r1ZxgXhdJfj1QqDLJkFjHDCvN42qDL2Ps5KjQYWUU0zcQ=="
},
"Microsoft.TestPlatform.TestHost": {
"type": "Transitive",
"resolved": "17.14.1",
"contentHash": "d78LPzGKkJwsJXAQwsbJJ7LE7D1wB+rAyhHHAaODF+RDSQ0NgMjDFkSA1Djw18VrxO76GlKAjRUhl+H8NL8Z+Q==",
"dependencies": {
"Microsoft.TestPlatform.ObjectModel": "17.14.1",
"Newtonsoft.Json": "13.0.3"
}
},
"Newtonsoft.Json": {
"type": "Transitive",
"resolved": "13.0.3",
"contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ=="
},
"xunit.abstractions": {
"type": "Transitive",
"resolved": "2.0.3",
"contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg=="
},
"xunit.analyzers": {
"type": "Transitive",
"resolved": "1.18.0",
"contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ=="
},
"xunit.assert": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA=="
},
"xunit.core": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==",
"dependencies": {
"xunit.extensibility.core": "[2.9.3]",
"xunit.extensibility.execution": "[2.9.3]"
}
},
"xunit.extensibility.core": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==",
"dependencies": {
"xunit.abstractions": "2.0.3"
}
},
"xunit.extensibility.execution": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==",
"dependencies": {
"xunit.extensibility.core": "[2.9.3]"
}
},
"acdream.plugin.abstractions": {
"type": "Project"
},
"acdream.tools.renderpackvalidator": {
"type": "Project",
"dependencies": {
"AcDream.Plugin.Abstractions": "[1.0.0, )"
}
}
}
}
}

View file

@ -28,6 +28,8 @@ public sealed class DisplaySettingsTests
Assert.Equal(90f, d.FieldOfView);
Assert.Equal(1.0f, d.Gamma);
Assert.False(d.ShowFps);
Assert.Equal(RenderPackSelectionSettings.Retail, d.RenderPack);
Assert.True(d.RenderPack.IsRetail);
}
[Fact]
@ -68,6 +70,46 @@ public sealed class DisplaySettingsTests
Assert.False(d.ShowFps);
}
[Fact]
public void Render_pack_selection_uses_stable_logical_ids()
{
var selection = new RenderPackSelectionSettings(
"acdream.atmospheric",
"1.0.0",
"medium");
DisplaySettings changed = DisplaySettings.Default with
{
RenderPack = selection,
};
Assert.Equal("acdream.atmospheric", changed.RenderPack.PackId);
Assert.Equal("1.0.0", changed.RenderPack.PackVersion);
Assert.Equal("medium", changed.RenderPack.PresetId);
Assert.False(changed.RenderPack.IsRetail);
Assert.Equal(RenderPackSelectionSettings.Retail, DisplaySettings.Default.RenderPack);
}
[Fact]
public void Render_pack_setting_overrides_are_value_equal_and_case_insensitive_by_id()
{
var first = new RenderPackSelectionSettings("pack", "1.0.0", "high")
{
SettingOverrides = new RenderPackSettingOverrides(
new Dictionary<string, string> { ["Exposure"] = "1.25" }),
};
var second = new RenderPackSelectionSettings("pack", "1.0.0", "high")
{
SettingOverrides = new RenderPackSettingOverrides(
new Dictionary<string, string> { ["exposure"] = "1.25" }),
};
Assert.Equal(first, second);
Assert.Equal(first.GetHashCode(), second.GetHashCode());
Assert.True(first.SettingOverrides.TryGetValue("EXPOSURE", out string? value));
Assert.Equal("1.25", value);
Assert.Empty(RenderPackSelectionSettings.Retail.SettingOverrides);
}
private static int ParseWidth(string res)
{
int x = res.IndexOf('x');

View file

@ -46,12 +46,28 @@ public sealed class SettingsStoreTests : System.IDisposable
Gamma: 1.4f,
ShowFps: true,
Quality: AcDream.UI.Abstractions.Settings.QualityPreset.Ultra,
ParticleRange: ParticleRange.Extended);
ParticleRange: ParticleRange.Extended)
{
RenderPack = new RenderPackSelectionSettings(
"acdream.atmospheric",
"1.0.0",
"high")
{
SettingOverrides = new RenderPackSettingOverrides(
new Dictionary<string, string>
{
["exposure"] = "1.25",
["sun-rays"] = "true",
}),
},
};
store.SaveDisplay(original);
var loaded = store.LoadDisplay();
Assert.Equal(original, loaded);
Assert.Equal("1.25", loaded.RenderPack.SettingOverrides["exposure"]);
Assert.Equal("true", loaded.RenderPack.SettingOverrides["sun-rays"]);
}
[Fact]
@ -85,6 +101,27 @@ public sealed class SettingsStoreTests : System.IDisposable
Assert.Equal(DisplaySettings.Default.VSync, loaded.VSync);
Assert.Equal(DisplaySettings.Default.FieldOfView, loaded.FieldOfView);
Assert.Equal(ParticleRange.Extended, loaded.ParticleRange);
Assert.Equal(RenderPackSelectionSettings.Retail, loaded.RenderPack);
Assert.Empty(loaded.RenderPack.SettingOverrides);
}
[Fact]
public void LoadDisplay_upgrades_pre_pack_file_to_retail_without_a_write()
{
File.WriteAllText(_tempPath, """
{
"version": 3,
"display": {
"resolution": "1920x1080",
"fullscreen": false
}
}
""");
DisplaySettings loaded = new SettingsStore(_tempPath).LoadDisplay();
Assert.Equal("1920x1080", loaded.Resolution);
Assert.Equal(RenderPackSelectionSettings.Retail, loaded.RenderPack);
}
// ── #389 schema-v3 fieldOfView migration ────────────────────────────