acdream/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs
Erik b16f820643 feat(render): Campaign V slice V6h — the Vulkan composition host
ACDREAM_RENDER_BACKEND=vulkan now runs the real GameWindow composition rather
than a second main(). All nine phases execute: DAT load, streaming, camera,
entity table, session, and the real retained UiHost drawing through the RHI.
No world renderers — they are raw GL until V4t and the world arm behind it.

The offline log is the client's own (acdream.pak opened, 6266 spells, Region
0x13000000, "loading world view centered on 0xA9B4FFFF", fourteen retail
LayoutDesc lines, streaming radii), and the captured frame is the retail
retained UI: vitals, combat/spell bar with DAT scarab icons, the nine-slot
toolbar, chat with tabs and Send, radar/compass with dat-font glyphs. Sampled
against the GL capture the widgets agree — chat interior RGBA (25,24,27,158)
vs (22,21,23,158), vitals bar (117,1,0) and toolbar slot (0,11,17) identical.

Three seams, as §5.5.9 specified:

1. Platform acquisition — already generic — publishes GameWindowGraphics
   instead of a bare GL. Phases that still speak raw GL read Graphics.Gl and
   take their Vulkan arm when it is null; each branch names the slice that
   removes it.
2. VulkanHostInputCameraCompositionFactory is a new file and the whole of the
   Phase-1 fork: four graphics members differ, input/camera/pointer delegate.
   The default factory is chosen inside the phase from the platform result.
   HostInputCameraResult gained backend-neutral Retirement and FrameSlots.
3. The frame root forks on one condition. The GL world-scene assembly is
   unchanged, wrapped in `if (gl is not null)`; the Vulkan arm's graph is one
   backbuffer clear pass computing the same RenderFrameFoundation from the same
   clock and weather owners, then private presentation over it.

§5.5.9's three TextureCache couplings are unpicked: the constructor takes GL?
and rejects bindless without one, world entry points route through a Gl
property that throws naming V4t, and the (GlGpuTexture) VRAM-accounting cast
became a backend test. That cast's stated reason — DrawSprite's texture-unit
binding — was already stale, deleted at V6d.

VulkanBringUpHost is reduced to the capability-probe harness it is named for:
the instance/surface/device/swapchain sequence moved into VulkanGraphicsContext,
which the composition host and the harness now share. It is reached only with
ACDREAM_VULKAN_PROBE=1.

One latent Vulkan defect surfaced and is fixed here. The first composition-host
frame died with ErrorDeviceLost; validation named VUID-vkCmdDraw-None-08600 —
descriptor set 2 never bound. VulkanGpuPassEncoder bound sets 0/1/2 only as a
side effect of BindStorageBuffer/BindUniformBuffer, so a pass sampling the
texture table while binding no buffer — every retained-UI and debug-line pass —
drew with the table unbound. It survived V6c-V6g because the bring-up host
always drew VulkanRhiScene first and the UI pass inherited its binds; the
composition host has no 3-D scene. The fix is one line in the encoder's
constructor beside the viewport and scissor defaults, which exist for exactly
the same reason: a pass opens with complete binding state rather than depending
on what preceded it.

Gates: strict GL offline pixel gate against 46d893f7 measures 1.24e-05 (7 of
563,200 pixels), inside the documented 15-23 px / 4.1e-05 band, so GL behaviour
did not move. App tests 4,075/3 skips; complete Release suite 9,138/5 skips.
One full Vulkan run with VK_LAYER_KHRONOS_validation: zero errors, zero
warnings. Both Vulkan runs converged the ownership ledger — no [shutdown]
diagnostic on either stream. The reduced probe harness presented 34,811
validation-clean frames.

No divergence-register row: GL is the shipping backend and the pixel gate proves
it unmoved; the Vulkan arm is not a retail deviation but a backend under
construction.

Next is V4t, the texture stack, which the world arm cannot be written without.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 11:47:37 +02:00

411 lines
15 KiB
C#

using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using AcDream.App.Composition;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Wb;
using AcDream.App.Rendering.Residency;
using AcDream.App.World;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.Content;
using AcDream.Core.Physics;
using AcDream.Core.Terrain;
using AcDream.UI.Abstractions.Settings;
using DatReaderWriter.DBObjs;
using Silk.NET.Input;
using Silk.NET.OpenGL;
using Shader = AcDream.App.Rendering.Shader;
namespace AcDream.App.Tests.Composition;
public sealed class WorldRenderCompositionTests
{
[Fact]
public void SuccessPublishesExactModernFoundationInFrozenOrder()
{
ResidencyBudgetOptions budgets =
ResidencyBudgetOptions.Default with
{
ObjectMeshGpuBytes = 321,
CompositePhysicalBytes = 654,
};
var fixture = new Fixture(budgets: budgets);
WorldRenderResult result = fixture.Compose();
Assert.Equal(Enum.GetValues<WorldRenderCompositionPoint>(), fixture.Points);
Assert.Same(fixture.Publication.Terrain, result.Foundation.Terrain);
Assert.Same(fixture.Publication.MeshAdapter, result.Foundation.MeshAdapter);
Assert.Same(fixture.Publication.TextureCache, result.Foundation.TextureCache);
Assert.Same(fixture.Lifetime.Atlas, result.Foundation.TerrainAtlas);
Assert.Equal(QualitySettings.From(QualityPreset.High).AnisotropicLevel,
fixture.Factory.AnisotropicLevel);
Assert.Same(budgets, result.Foundation.Residency.Budgets);
Assert.Same(budgets, fixture.Factory.MeshBudgets);
Assert.Same(budgets, fixture.Factory.TextureBudgets);
Assert.Same(result.Foundation.Residency, fixture.Factory.RegisteredResidency);
Assert.Equal(1, fixture.Lifetime.AcquireCalls);
Assert.Empty(fixture.Factory.Releases);
}
[Fact]
public void MissingDebugFontSkipsOnlyTheOptionalHudPrefix()
{
var fixture = new Fixture(hasFont: false);
WorldRenderResult result = fixture.Compose();
Assert.Null(result.Foundation.DebugFont);
Assert.Null(result.Foundation.TextRenderer);
Assert.DoesNotContain(WorldRenderCompositionPoint.DebugFontCreated, fixture.Points);
Assert.DoesNotContain(WorldRenderCompositionPoint.TextRendererCreated, fixture.Points);
Assert.DoesNotContain(WorldRenderCompositionPoint.HudResourcesPublished, fixture.Points);
Assert.Contains(WorldRenderCompositionPoint.HudResourcesCompleted, fixture.Points);
}
[Theory]
[MemberData(nameof(FailurePoints))]
public void FaultAfterEachBoundaryStopsTheExactSuffix(int pointValue)
{
var point = (WorldRenderCompositionPoint)pointValue;
var fixture = new Fixture(failurePoint: point);
Assert.Throws<InvalidOperationException>(fixture.Compose);
Assert.Equal(
Enum.GetValues<WorldRenderCompositionPoint>()
.TakeWhile(candidate => candidate <= point),
fixture.Points);
}
public static TheoryData<int> FailurePoints()
{
var data = new TheoryData<int>();
foreach (WorldRenderCompositionPoint point in
Enum.GetValues<WorldRenderCompositionPoint>())
{
data.Add((int)point);
}
return data;
}
[Theory]
[InlineData("terrain shader", "terrain shader")]
[InlineData("scene lighting", "scene lighting")]
[InlineData("debug lines", "debug lines")]
[InlineData("HUD", "text renderer|debug font")]
[InlineData("terrain", "terrain")]
[InlineData("mesh shader", "mesh shader")]
[InlineData("WB mesh adapter", "WB mesh adapter")]
[InlineData("texture cache", "texture cache")]
[InlineData("sampler cache", "sampler cache")]
public void FailedPublicationRollsBackOnlyItsUnpublishedResourcePrefix(
string publication,
string expectedReleaseOrder)
{
var fixture = new Fixture(publicationFailure: publication);
Assert.Throws<InvalidOperationException>(fixture.Compose);
Assert.Equal(
expectedReleaseOrder.Split('|'),
fixture.Factory.Releases);
}
[Fact]
public void PartialHudConstructionRollsBackFontWhenTextCreationFails()
{
var fixture = new Fixture(
failurePoint: WorldRenderCompositionPoint.DebugFontCreated);
Assert.Throws<InvalidOperationException>(fixture.Compose);
Assert.Equal(["debug font"], fixture.Factory.Releases);
}
[Fact]
public void GameWindowUsesPhaseAndNoLongerBuildsWorldFoundationInline()
{
string source = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Rendering",
"GameWindow.cs"));
Assert.Contains("new WorldRenderCompositionPhase(", source,
StringComparison.Ordinal);
Assert.DoesNotContain("BindlessSupport.TryCreate(_gl", source,
StringComparison.Ordinal);
Assert.DoesNotContain("_terrainModernShader = new Shader", source,
StringComparison.Ordinal);
Assert.DoesNotContain("_wbMeshAdapter = new", source,
StringComparison.Ordinal);
Assert.DoesNotContain("_textureCache = new TextureCache", source,
StringComparison.Ordinal);
}
private sealed class Fixture
{
private readonly WorldRenderCompositionPoint? _failurePoint;
private readonly ResidencyBudgetOptions _budgets;
private readonly IGpuDevice _gpuDevice = new RecordingGpuDevice();
public Fixture(
bool hasFont = true,
WorldRenderCompositionPoint? failurePoint = null,
string? publicationFailure = null,
ResidencyBudgetOptions? budgets = null)
{
_failurePoint = failurePoint;
_budgets = budgets ?? ResidencyBudgetOptions.Default;
Factory = new Factory(hasFont);
Publication = new Publication(publicationFailure);
Lifetime = new RenderLifetime(Factory.Atlas);
var content = (ContentEffectsAudioResult)
RuntimeHelpers.GetUninitializedObject(typeof(ContentEffectsAudioResult));
Content = content;
}
public Factory Factory { get; }
public Publication Publication { get; }
public RenderLifetime Lifetime { get; }
public List<WorldRenderCompositionPoint> Points { get; } = [];
public ContentEffectsAudioResult Content { get; }
public WorldRenderResult Compose() =>
new WorldRenderCompositionPhase(
new WorldRenderDependencies(
new WorldEnvironmentController(),
Lifetime,
ImmediateGpuResourceRetirementQueue.Instance,
_budgets,
0xA9B4FFFFu,
Path.Combine(Path.GetTempPath(), "acdream-tests"),
_ => { },
_gpuDevice,
new GpuDeviceFrameLifetime(_gpuDevice)),
Publication,
Factory,
point =>
{
Points.Add(point);
if (point == _failurePoint)
throw new InvalidOperationException($"fault at {point}");
}).Compose(
new GameWindowPlatformResult<GameWindowGraphics, IInputContext>(TestGameWindowGraphics.OpenGl, null!),
Content,
new SettingsDevToolsResult(
QualitySettings.From(QualityPreset.High),
null));
}
private sealed class RenderLifetime(TerrainAtlas atlas)
: IGameRenderResourceLifetime
{
public TerrainAtlas Atlas { get; } = atlas;
public int AcquireCalls { get; private set; }
public TerrainAtlas AcquireTerrainAtlas(Func<TerrainAtlas> factory)
{
AcquireCalls++;
return Atlas;
}
}
private sealed class Factory(bool hasFont) : IWorldRenderCompositionFactory
{
private readonly Dictionary<IDisposable, string> _names =
new(ReferenceEqualityComparer.Instance);
public TerrainAtlas Atlas { get; } = Stub<TerrainAtlas>();
public int AnisotropicLevel { get; private set; }
public List<string> Releases { get; } = [];
public ResidencyBudgetOptions? MeshBudgets { get; private set; }
public ResidencyBudgetOptions? TextureBudgets { get; private set; }
public ResidencyManager? RegisteredResidency { get; private set; }
public void InitializeGlState(GL gl) { }
public WorldRegionData LoadRegion(IDatReaderWriter dats) =>
new(Stub<Region>(), new float[256]);
public void InitializeEnvironment(
WorldEnvironmentController environment,
Region region) { }
public BindlessSupport RequireBindless(GL gl, Action<string> log) =>
Stub<BindlessSupport>();
public TerrainAtlas AcquireTerrainAtlas(
IGameRenderResourceLifetime lifetime,
GL gl,
IDatReaderWriter dats,
BindlessSupport bindless) =>
lifetime.AcquireTerrainAtlas(() => Atlas);
public void SetTerrainAnisotropic(TerrainAtlas atlas, int level) =>
AnisotropicLevel = level;
public Shader CreateTerrainShader(GL gl, string shadersDirectory) =>
Resource<Shader>("terrain shader");
public SceneLightingUboBinding CreateSceneLighting(GL gl) =>
Resource<SceneLightingUboBinding>("scene lighting");
public DebugLineRenderer CreateDebugLines(
IGpuDevice device, ICurrentGpuFrameSource frameSource, string shadersDirectory) =>
Resource<DebugLineRenderer>("debug lines");
public byte[]? TryLoadDebugFont() => hasFont ? [1] : null;
public BitmapFont CreateDebugFont(IGpuDevice device, byte[] bytes) =>
Resource<BitmapFont>("debug font");
public TextRenderer CreateTextRenderer(
IGpuDevice device, ICurrentGpuFrameSource frameSource, string shadersDirectory) =>
Resource<TextRenderer>("text renderer");
public TerrainModernRenderer CreateTerrain(
GL gl,
BindlessSupport bindless,
Shader shader,
TerrainAtlas atlas,
IGpuResourceRetirementQueue retirement) =>
Resource<TerrainModernRenderer>("terrain");
public WorldTerrainBuildContext CreateTerrainBuildContext(
uint initialCenterLandblockId,
float[] heightTable,
TerrainAtlas atlas) =>
new(
initialCenterLandblockId,
0xA9,
0xB4,
heightTable,
Stub<TerrainBlendingContext>(),
new ConcurrentDictionary<uint, SurfaceInfo>());
public Shader CreateMeshShader(GL gl, string shadersDirectory) =>
Resource<Shader>("mesh shader");
public WbMeshAdapter CreateMeshAdapter(
GL gl,
IGpuDevice device,
IDatReaderWriter dats,
IPreparedAssetSource preparedAssets,
IGpuResourceRetirementQueue retirement,
ResidencyBudgetOptions budgets)
{
MeshBudgets = budgets;
return Resource<WbMeshAdapter>("WB mesh adapter");
}
public TextureCache CreateTextureCache(
GL gl,
IGpuDevice device,
IDatReaderWriter dats,
BindlessSupport bindless,
IGpuResourceRetirementQueue retirement,
string diagnosticsDirectory,
ResidencyBudgetOptions budgets)
{
TextureBudgets = budgets;
return Resource<TextureCache>("texture cache");
}
public void RegisterResidencySources(
ResidencyManager manager,
WbMeshAdapter meshes,
TextureCache textures,
IPreparedAssetSource preparedAssets,
IAnimationLoader animations,
AcDream.Core.Audio.DatSoundCache? audio)
{
RegisteredResidency = manager;
}
public SamplerCache CreateSamplerCache(GL gl) =>
Resource<SamplerCache>("sampler cache");
public void Release(IDisposable resource)
{
Releases.Add(_names[resource]);
}
private T Resource<T>(string name)
where T : class, IDisposable
{
T value = Stub<T>();
_names.Add(value, name);
return value;
}
}
private sealed class Publication(string? failure) : IGameWindowWorldRenderPublication
{
public TerrainModernRenderer? Terrain { get; private set; }
public WbMeshAdapter? MeshAdapter { get; private set; }
public TextureCache? TextureCache { get; private set; }
public void PublishBindlessSupport(BindlessSupport value) =>
Fail("bindless");
public void PublishTerrainShader(Shader value) =>
Fail("terrain shader");
public void PublishSceneLighting(SceneLightingUboBinding value) =>
Fail("scene lighting");
public void PublishDebugLines(DebugLineRenderer value) =>
Fail("debug lines");
public void PublishHudResources(BitmapFont font, TextRenderer text) =>
Fail("HUD");
public void PublishTerrain(TerrainModernRenderer value)
{
Fail("terrain");
Terrain = value;
}
public void PublishTerrainBuildState(
float[] heightTable,
TerrainBlendingContext blending,
ConcurrentDictionary<uint, SurfaceInfo> surfaceCache) =>
Fail("terrain build state");
public void PublishMeshShader(Shader value) => Fail("mesh shader");
public void PublishWbMeshAdapter(WbMeshAdapter value)
{
Fail("WB mesh adapter");
MeshAdapter = value;
}
public void PublishTextureCache(TextureCache value)
{
Fail("texture cache");
TextureCache = value;
}
public void PublishSamplerCache(SamplerCache value) =>
Fail("sampler cache");
private void Fail(string point)
{
if (string.Equals(failure, point, StringComparison.Ordinal))
throw new InvalidOperationException($"publication failed at {point}");
}
}
private static T Stub<T>() where T : class =>
(T)RuntimeHelpers.GetUninitializedObject(typeof(T));
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.");
}
}