fix(render): complete S4 chunk 2 final alpha parity round

Final allowed fix round for S4-c2 on cc8e5677a. This lands every item in
the campaign packet section 12 without adding a flush site, shader, distance,
overflow recovery draw, or graphical-client run.

R2-1 particle row 5: ParticleRenderer now constructs and owns the actual
particle-mesh-opaque pipeline using the existing particle_mesh shaders and
layout, Blend=None, depth test/write enabled with WorldCompare, dynamic
per-batch cull, clockwise front face, and no alpha-to-coverage. The production
dispatch selects it for an opaque-classified mesh particle whose clamped
material alpha is 1.0. Nonopaque mesh pipelines remain depth-write-off. The
production route keeps cached reserve/immediate delegates and the warmed
append/immediate paths allocate 0 B.

R2-2 EnvCell exact per-subset routing: ObjectMeshManager carries Content's
TextureBatchData.RetailSurfaceMask onto ObjectRenderBatch at the real upload
boundary. EnvCellRenderer scans the active prepared cell snapshot and feeds
each real transparent batch's exact mask through RetailAlphaMeshRouter. Pure
0x08 Base1ClipMap reaches CLIP, 0x02 alpha-family reaches ALPHA, and table
Immediate subsets draw at the cell turn. Separate fixed-route draw sources
coalesce to at most one token per (cell,list) and filtered replay draws only
that list's subsets; a mixed cell contributes to both lists without duplicate
replay. Detail-on routes eligible subsets immediately with the detail pass.
The warmed dispatch/source allocation pins measure 0 B; the production scan/filter is covered behaviorally and uses only retained scratch/enumerators (static allocation audit).

R2-3 capacity cleanup: RetailAlphaQueue registers a source before the 3,000
entry capacity return. A source whose first append is rejected is therefore
reset by flush, EndFrame, or abort, but its rejected payload is never prepared
or drawn.

R2-4 production proof and prose: both actual Wb submit sites are exercised;
the particle tests call the production dispatcher and inspect the constructed
owner's production pipeline/selector; EnvCell tests upload real Content batch
masks through ObjectMeshManager and drain real filtered MDI calls. The A1
positive proof executes WorldSceneRenderer's real outdoor frame owner through
RetailPViewRenderer.DrawInside and RetailAlphaQueue.EndFrame and observes
[DrawBuilding x N, RenderNormalMode] with no LandscapeFlush. The packet and
register now state the varying retail first-for-list truth and the exact
per-subset EnvCell/AP-238, visible AP-239 compositing, and AP-240 feeder scope.
Physical active register counts remain AP=159 and AD=92.

Final clean-state gates (actual output):
- Release solution build: Build succeeded; 0 Warning(s); 0 Error(s).
- Hermetic solution filter: every project green, 16,728 passed / 0 failed /
  0 skipped total; AcDream.App.Tests 6,875/6,875.
- InstalledDat: 255 passed / 10 failed / 1 skipped / 266 total, exactly the
  allowed identities: TowerAscent_StaircaseStaysConeVisible_EveryStep;
  MainGameUiAndChatInput_MediaBearingChildrenNowBuildAsRealWidgets (#383);
  EveryAuthoredInvisibleWidget_StartsHiddenAcrossAllLayouts (#383);
  Oh_doorway_still_first_frame_diff (#458); and the six
  AlphaFlushCounts_{CathedralArrival,CathedralLeak,CathedralStairArch,
  FoundryDeep,HoltburgDoorwayStill,TerraceEdge}_MatchesRetailFrame2. All six
  AlphaFlushSites_* pass in the same lane.
- VulkanShaderDescriptorContractTests + VulkanShaderManifestTests +
  RenderPackSpirvValidatorTests: 32 passed / 0 failed / 0 skipped.
- Corrected queue/router/walk/driver/particle/Wb/EnvCell/PView production
  filter: 211 passed / 0 failed / 0 skipped.
- Explicit warmed production allocation pins: 2 passed; both measure 0 B.
- Register: physical AP-238/AP-239/AP-240/AD-120/~~AP-34~~ rows each count
  exactly 1; active physical rows AP=159 and AD=92.
- git diff --check: PASS.

Production mutation checks (each applied, run to the named first failure, and
exactly reversed before the final gates):

1. Restoring the particle row-5 throw fails
   OpaqueClassifiedMeshBatch_WithNoMaterialAlpha_DrawsImmediateOnOpaqueDepthState
   first with InvalidOperationException: mutation: row 5 unreachable.

2. Deleting row 5's immediate callback fails that same production-dispatch
   test's first collection assertion: expected [(Mesh, 11, True)], actual [].

3. Constructing the actual owner pipeline with depthWrite:false fails
   ImmediateOpaqueMesh_UsesProductionParticleMeshOpaquePipelineDescription
   first at Assert.True(description.Depth.Write): expected true, actual false.

4. Mapping the production selector back to _meshAlphaPipeline fails that same
   test first at Assert.Same: expected particle-mesh-opaque, actual
   particle-mesh-alpha.

5. Dropping RetailSurfaceMask at the real ObjectMeshManager upload boundary
   fails the mixed-cell production scan first: expected Clip | Alpha, actual
   Immediate.

6. Hardcoding the uploaded EnvCell scan to MaskAlphaFamily fails the mixed-cell
   production scan first: expected Clip | Alpha, actual Alpha; the pure-mask
   pin also reports expected Clip, actual Alpha.

7. Inverting the production detail predicate fails the detail-on production
   pin first: expected Immediate, actual Clip.

8. Removing both EnvCell replay filters fails the mixed production drain's real
   MDI assertions: each call expected DrawCount 1, actual DrawCount 2.

9. Moving RegisterSource below the full-capacity return fails every
   RejectedFirstUseSource_IsCleanedWithoutPrepareOrDraw row (flush, EndFrame,
   abort) at the first ResetCount assertion: expected 1, actual 0.

10. Hardcoding detailSurfaceActive=false at the real Wb dispatch fails both
    production submit-site tests at their first queue-count assertion:
    expected 0, actual 1.

11. Restoring RetailPViewRenderer's removed outdoor LandscapeFlush call fails
    OutdoorProductionPView_DrainsBuildingThenRenderNormalModeWithoutLandscapeFlush
    first at the real drain sequence: expected [DrawBuilding,
    RenderNormalMode], actual [DrawBuilding, LandscapeFlush,
    RenderNormalMode].

12. Deleting WorldSceneRenderer's final EndFrame owner drain fails that same
    full-path A1 test first: expected [DrawBuilding, RenderNormalMode], actual
    [DrawBuilding].

Not done/deferred: none. No retail conflict or infeasible contract item was
found. No graphical client was launched.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-09-04 08:56:49 +02:00
parent 9ccb61a8ec
commit 0aa166aa09
14 changed files with 1779 additions and 224 deletions

View file

@ -1,45 +1,110 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Gpu.Vk;
using AcDream.App.Rendering.Wb;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.Content;
using Chorizite.Core.Render.Enums;
using Microsoft.Extensions.Logging.Abstractions;
using CullMode = DatReaderWriter.Enums.CullMode;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// S4-c2 fix round 1 (M6, contract C4): <see cref="RetailPViewPassExecutor.EnvCellAlphaDrawSource"/>
/// is the queue-facing half of an EnvCell transparent shell's detail-off
/// submission — one token per CELL, replayed through a caller-supplied
/// delegate (production wiring:
/// <see cref="AcDream.App.Rendering.Wb.EnvCellRenderer.RenderTransparentOrdered"/>)
/// at whatever flush site the shared <see cref="RetailAlphaQueue"/> drains
/// at. These tests exercise the REAL production class end-to-end against a
/// real queue — only the render destination is faked (a delegate recording
/// what it was asked to draw), so the GPU-backed <c>EnvCellRenderer</c>
/// never needs to be constructed to pin the queue mechanics.
/// S4-c2 final EnvCell production-dispatch pins: exact retained per-batch mask,
/// one token per (cell,list), separate list-filtered replay sources, immediate
/// detail turns, interleaving, and warmed allocation behavior.
/// </summary>
public sealed class EnvCellAlphaDrawSourceTests
{
/// <summary>Mutation check: if <c>SubmitOrDrawTransparentCellShell</c>
/// drew the cell immediately instead of appending it when detail is off
/// (the M1/M3 bug class this chunk fixes elsewhere), the drawn list
/// would already contain an entry BEFORE <c>Flush</c> runs — the
/// <c>Assert.Empty(drawn)</c> line fails against that mutation.</summary>
/// <summary>
/// Canonical F4180104 surface 08000BFF's pure Base1ClipMap mask 0x08
/// reaches CLIP while alpha-family 0x02 reaches ALPHA. Mutation check:
/// hardcoding MaskAlphaFamily in the production batch classifier makes
/// the first assertion report Alpha instead of Clip.</summary>
[Fact]
public void PendingCellToken_IsDrainedAtTheFlushAndNeverBeforeIt()
public void RetainedPerBatchMask_RoutesClipAndAlphaExactly()
{
var drawn = new List<IReadOnlyList<uint>>();
var source = new RetailPViewPassExecutor.EnvCellAlphaDrawSource(
cells => drawn.Add(cells.ToArray()));
var queue = new RetailAlphaQueue();
var clip = new ObjectRenderBatch { IsTransparent = true, RetailSurfaceMask = 0x08 };
var alpha = new ObjectRenderBatch { IsTransparent = true, RetailSurfaceMask = 0x02 };
queue.BeginFrame();
int token = source.AddPendingCellId(0x1234u);
Assert.True(queue.TryAppend(RetailAlphaList.Alpha, source, token, false));
Assert.Equal(
EnvCellTransparentRoute.Clip,
EnvCellRenderer.RouteTransparentBatch(clip, detailSurfaceActive: false));
Assert.Equal(
EnvCellTransparentRoute.Alpha,
EnvCellRenderer.RouteTransparentBatch(alpha, detailSurfaceActive: false));
}
Assert.Empty(drawn);
/// <summary>
/// A mixed cell creates one token in each list and neither set draws before
/// the real queue drain. CLIP drains first and receives only the CLIP
/// replay filter; ALPHA receives only ALPHA. Mutation checks: inverting the
/// detail-off immediate predicate makes <c>Assert.Empty(log)</c> fail;
/// removing the replay filter makes either route assertion fail.</summary>
[Fact]
public void MixedCell_DefersOneTokenPerList_AndReplayIsListFiltered()
{
using var fixture = new ProductionEnvCellFixture(
detailSurfaceActive: false,
0x08,
0x02);
EnvCellTransparentRoute routes = fixture.Renderer.GetTransparentRoutes(
ProductionEnvCellFixture.CellId,
detailSurfaceActive: false);
queue.Flush(RetailAlphaFlushSite.DrawBuilding, 0f);
fixture.Queue.BeginFrame();
RetailPViewPassExecutor.DispatchTransparentCellShell(
ProductionEnvCellFixture.CellId,
routes,
detailSurfaceActive: false,
fixture.Queue,
fixture.ClipSource,
fixture.AlphaSource,
fixture.ImmediateSink);
IReadOnlyList<uint> singleDraw = Assert.Single(drawn);
Assert.Equal(new uint[] { 0x1234u }, singleDraw);
queue.EndFrame();
Assert.Equal(EnvCellTransparentRoute.Clip | EnvCellTransparentRoute.Alpha, routes);
Assert.Empty(fixture.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>());
Assert.Equal(1, fixture.Queue.ClipCount);
Assert.Equal(1, fixture.Queue.AlphaCount);
fixture.Queue.Flush(RetailAlphaFlushSite.DrawBuilding, 0f);
GpuRecordedMultiDrawIndirect[] draws =
[.. fixture.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>()];
Assert.Equal(2, draws.Length);
Assert.All(draws, static draw => Assert.Equal(1u, draw.DrawCount));
fixture.Queue.EndFrame();
}
[Fact]
public void DetailOn_DrawsAtCellTurnWithDetail_AndQueuesNothing()
{
using var fixture = new ProductionEnvCellFixture(
detailSurfaceActive: true,
0x08);
EnvCellTransparentRoute routes = fixture.Renderer.GetTransparentRoutes(
ProductionEnvCellFixture.CellId,
detailSurfaceActive: true);
fixture.Queue.BeginFrame();
RetailPViewPassExecutor.DispatchTransparentCellShell(
ProductionEnvCellFixture.CellId,
routes,
detailSurfaceActive: true,
fixture.Queue,
fixture.ClipSource,
fixture.AlphaSource,
fixture.ImmediateSink);
Assert.Equal(EnvCellTransparentRoute.Immediate, routes);
Assert.Equal(1, fixture.ImmediateSink.DrawCount);
Assert.True(fixture.ImmediateSink.LastDetailSurfaceActive);
Assert.Equal(2, fixture.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>().Count());
Assert.Equal(0, fixture.Queue.PendingCount);
fixture.Queue.EndFrame();
Assert.Equal(1, fixture.ImmediateSink.DrawCount);
}
/// <summary>Two cell tokens from the SAME <see cref="RetailPViewPassExecutor.EnvCellAlphaDrawSource"/>,
@ -57,20 +122,260 @@ public sealed class EnvCellAlphaDrawSourceTests
public void ParticleAppendedBetweenTwoCellTokens_KeepsItsPositionInTheCombinedDrain()
{
var log = new List<string>();
var cellSource = new RetailPViewPassExecutor.EnvCellAlphaDrawSource(
cells => log.Add($"cell:{string.Join(',', cells)}"));
var clipSource = Source(EnvCellTransparentRoute.Clip, log);
var cellSource = Source(EnvCellTransparentRoute.Alpha, log);
var particleSource = new RecordingSource("particle", log);
var queue = new RetailAlphaQueue();
queue.BeginFrame();
int cell1Token = cellSource.AddPendingCellId(0x100u);
Assert.True(queue.TryAppend(RetailAlphaList.Alpha, cellSource, cell1Token, false));
RetailPViewPassExecutor.DispatchTransparentCellShell(
0x100u, EnvCellTransparentRoute.Alpha, false, queue,
clipSource, cellSource, (_, _, _) => throw new InvalidOperationException());
Assert.True(queue.TryAppend(RetailAlphaList.Alpha, particleSource, 7, false));
int cell2Token = cellSource.AddPendingCellId(0x200u);
Assert.True(queue.TryAppend(RetailAlphaList.Alpha, cellSource, cell2Token, false));
RetailPViewPassExecutor.DispatchTransparentCellShell(
0x200u, EnvCellTransparentRoute.Alpha, false, queue,
clipSource, cellSource, (_, _, _) => throw new InvalidOperationException());
queue.EndFrame();
Assert.Equal(new[] { "cell:256", "particle:7", "cell:512" }, log);
Assert.Equal(new[] { "Alpha:00000100", "particle:7", "Alpha:00000200" }, log);
}
[Fact]
public void ProductionDispatch_WarmedImmediateAndFilteredSourcesDoNotAllocate()
{
var sink = new NoAllocRouteSink();
var clipSource = new RetailPViewPassExecutor.EnvCellAlphaDrawSource(
sink.Render,
EnvCellTransparentRoute.Clip);
var alphaSource = new RetailPViewPassExecutor.EnvCellAlphaDrawSource(
sink.Render,
EnvCellTransparentRoute.Alpha);
RetailPViewPassExecutor.RenderImmediateEnvCellRoute immediate = sink.DrawImmediate;
var queue = new RetailAlphaQueue();
for (int i = 0; i < 64; i++)
RunMixed(queue, clipSource, alphaSource, immediate);
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 1_000; i++)
RunMixed(queue, clipSource, alphaSource, immediate);
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.Equal(0, allocated);
}
private static void RunMixed(
RetailAlphaQueue queue,
RetailPViewPassExecutor.EnvCellAlphaDrawSource clipSource,
RetailPViewPassExecutor.EnvCellAlphaDrawSource alphaSource,
RetailPViewPassExecutor.RenderImmediateEnvCellRoute immediate)
{
queue.BeginFrame();
RetailPViewPassExecutor.DispatchTransparentCellShell(
0xF4180104u,
EnvCellTransparentRoute.Immediate
| EnvCellTransparentRoute.Clip
| EnvCellTransparentRoute.Alpha,
detailSurfaceActive: false,
queue,
clipSource,
alphaSource,
immediate);
queue.EndFrame();
}
private static RetailPViewPassExecutor.EnvCellAlphaDrawSource Source(
EnvCellTransparentRoute expectedRoute,
List<string> log) =>
new(
(cells, route, detail) =>
{
Assert.Equal(expectedRoute, route);
Assert.False(detail);
for (int i = 0; i < cells.Count; i++)
log.Add($"{route}:{cells[i]:X8}");
},
expectedRoute);
/// <summary>
/// Recording-GPU fixture for the production EnvCell scan, dispatch and
/// filtered replay path. The two retained masks enter through the real
/// Content upload boundary, not through hand-built App batches.
/// </summary>
private sealed class ProductionEnvCellFixture : IDisposable
{
public const uint CellId = 0xF4180104u;
private const ulong MeshId = 0x2_F4180104UL;
private readonly GpuDeviceFrameLifetime _frames;
private readonly VulkanWorldPassScope _scope;
private readonly ObjectMeshManager _meshManager;
private readonly IGpuPassEncoder _pass;
private readonly IDisposable _publication;
public ProductionEnvCellFixture(
bool detailSurfaceActive,
params byte[] retainedMasks)
{
Device = new RecordingGpuDevice();
_frames = new GpuDeviceFrameLifetime(Device);
_scope = new VulkanWorldPassScope(sampleCount: 1);
_meshManager = new ObjectMeshManager(
new VulkanMeshPipelineDevice(Device.Retirement),
Device,
new NullPreparedAssetSource(),
NullLogger<ObjectMeshManager>.Instance);
var mesh = new ObjectMeshData
{
ObjectId = MeshId,
Vertices =
[
new VertexPositionNormalTexture { Position = new Vector3(0, 0, 0) },
new VertexPositionNormalTexture { Position = new Vector3(1, 0, 0) },
new VertexPositionNormalTexture { Position = new Vector3(0, 1, 0) },
],
};
var batches = new List<TextureBatchData>(retainedMasks.Length);
for (int i = 0; i < retainedMasks.Length; i++)
{
batches.Add(new TextureBatchData
{
Key = new TextureKey { SurfaceId = 0x08000BFFu + (uint)i },
TextureData = new byte[8 * 8 * 4],
Indices = [0, 1, 2],
IsTransparent = true,
Translucency = AcDream.Core.Meshing.TranslucencyKind.AlphaBlend,
RetailSurfaceMask = retainedMasks[i],
CullMode = CullMode.Clockwise,
IsCellShell = true,
SourceSurfaceIndex = i,
});
}
mesh.TextureBatches[(8, 8, TextureFormat.RGBA8)] = batches;
Assert.NotNull(_meshManager.UploadMeshData(mesh));
Renderer = new EnvCellRenderer(
Device,
_frames,
_scope,
_meshManager,
new WbFrustum(),
detailSurfaceActive
? new TerrainAtlas.RetailDetailTextureBinding(
new GpuTextureSlot(99),
Tiling: 2f,
SurfaceTextureId: 1,
RenderSurfaceId: 2,
Width: 4,
Height: 4)
: default,
detailSurfaceActive ? DetailOn : DetailOff);
typeof(EnvCellRenderer)
.GetField("_activeSnapshot", System.Reflection.BindingFlags.NonPublic
| System.Reflection.BindingFlags.Instance)!
.SetValue(Renderer, new EnvCellVisibilitySnapshot
{
BatchedByCell = new Dictionary<uint, Dictionary<ulong, List<InstanceData>>>
{
[CellId] = new Dictionary<ulong, List<InstanceData>>
{
[MeshId] =
[
new InstanceData
{
Transform = Matrix4x4.Identity,
CellId = CellId,
},
],
},
},
});
Queue = new RetailAlphaQueue();
ClipSource = new RetailPViewPassExecutor.EnvCellAlphaDrawSource(
Renderer.RenderTransparentOrdered,
EnvCellTransparentRoute.Clip);
AlphaSource = new RetailPViewPassExecutor.EnvCellAlphaDrawSource(
Renderer.RenderTransparentOrdered,
EnvCellTransparentRoute.Alpha);
ImmediateSink = new ProductionImmediateSink(Renderer);
_frames.BeginFrame();
IGpuFrame frame = _frames.CurrentFrame!;
_pass = frame.BeginPass(
GpuPassDescription.BackbufferClear(
"s4-c2-envcell-production",
Vector4.Zero,
sampleCount: 1));
_publication = _scope.Publish(_pass);
Renderer.BeginFrame(frameSlot: 0);
Device.Clear();
}
public RecordingGpuDevice Device { get; }
public EnvCellRenderer Renderer { get; }
public RetailAlphaQueue Queue { get; }
public RetailPViewPassExecutor.EnvCellAlphaDrawSource ClipSource { get; }
public RetailPViewPassExecutor.EnvCellAlphaDrawSource AlphaSource { get; }
public ProductionImmediateSink ImmediateSink { get; }
private static bool DetailOn() => true;
private static bool DetailOff() => false;
public void Dispose()
{
_publication.Dispose();
_pass.Dispose();
Renderer.Dispose();
_meshManager.Dispose();
Device.Dispose();
}
}
private sealed class ProductionImmediateSink(EnvCellRenderer renderer) :
IEnvCellImmediateDrawSink
{
public int DrawCount { get; private set; }
public bool LastDetailSurfaceActive { get; private set; }
public void DrawImmediate(
uint cellId,
EnvCellTransparentRoute route,
bool detailSurfaceActive)
{
DrawCount++;
LastDetailSurfaceActive = detailSurfaceActive;
renderer.RenderTransparentOrdered([cellId], route, detailSurfaceActive);
}
}
private sealed class NullPreparedAssetSource : IPreparedAssetSource
{
public PreparedAssetSourceStats Stats => default;
public CacheStats DecodedTextureCacheStats => default;
public PreparedAssetPresence Probe(
AcDream.Content.Pak.PakAssetType type,
uint sourceFileId) =>
PreparedAssetPresence.Missing;
public PreparedAssetReadResult Read(
in PreparedAssetRequest request,
CancellationToken cancellationToken = default) =>
PreparedAssetReadResult.Missing;
public void Dispose()
{
}
}
private sealed class RecordingSource(string name, List<string> log) : IRetailAlphaDrawSource
@ -89,4 +394,21 @@ public sealed class EnvCellAlphaDrawSourceTests
{
}
}
private sealed class NoAllocRouteSink
{
public void Render(
IReadOnlyList<uint> cells,
EnvCellTransparentRoute route,
bool detailSurfaceActive)
{
}
public void DrawImmediate(
uint cellId,
EnvCellTransparentRoute route,
bool detailSurfaceActive)
{
}
}
}

View file

@ -1,16 +1,23 @@
using System.Numerics;
using System.Reflection;
using System.Runtime.CompilerServices;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Gpu.Vk;
using AcDream.App.Rendering.Wb;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.Content;
using AcDream.Core.Meshing;
using AcDream.Core.Vfx;
using Microsoft.Extensions.Logging.Abstractions;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// S4-c2 fix round 1 (M1, blocking): <see cref="ParticleRenderer.RouteParticleSubmission"/>
/// is the pure router-input derivation the blocking fix extracted from
/// <c>DeferToRetailAlphaQueue</c> so it is directly testable without a GPU/
/// mesh-manager harness. Before this round, a mesh-particle batch classified
/// <see cref="TranslucencyKind.Opaque"/> (mask 0x00) reaching <c>DrawMesh</c>
/// row 5 (Immediate) hit an unconditional throw in the render loop — these
/// tests pin the two outcomes the fix made reachable.
/// S4-c2 particle routing and production-dispatch pins. The pure table tests
/// remain narrow coverage; the two principal cases below call the actual
/// <see cref="ParticleRenderer.DeferToRetailAlphaQueue"/> dispatch that the
/// renderer's scene loop uses.
/// </summary>
public sealed class ParticleRendererRouteTests
{
@ -24,16 +31,37 @@ public sealed class ParticleRendererRouteTests
/// derivation to a hardcoded `false` makes this assertion fail because
/// the decision becomes Immediate instead of Append.</summary>
[Fact]
public void OpaqueClassifiedMeshBatch_WithMaterialAlpha_RoutesToAlphaAppend()
public void OpaqueClassifiedMeshBatch_WithMaterialAlpha_DefersAndDrawsOnlyAtFlush()
{
// Top byte (alpha) = 0x80 -> not 0xFF -> materialHasAlpha = true.
const uint colorArgbWithAlpha = 0x80FFFFFFu;
var queue = new RetailAlphaQueue();
var source = new RecordingSource();
int reserveCount = 0;
int immediateCount = 0;
queue.BeginFrame();
RetailAlphaMeshDecision decision = ParticleRenderer.RouteParticleSubmission(
ParticleSubmissionKind.Mesh, TranslucencyKind.Opaque, colorArgbWithAlpha);
const uint colorArgbWithAlpha = 0x80FFFFFFu;
RetailAlphaMeshDecision decision = ParticleRenderer.DeferToRetailAlphaQueue(
ParticleSubmissionKind.Mesh,
TranslucencyKind.Opaque,
colorArgbWithAlpha,
queue,
source,
reserveDeferred: () => reserveCount++,
drawImmediate: (_, _, _, _) => immediateCount++,
Matrix4x4.Identity,
drawIndex: 7);
Assert.Equal(RetailAlphaMeshAction.Append, decision.Action);
Assert.Equal(RetailAlphaList.Alpha, decision.List);
Assert.Equal(1, reserveCount);
Assert.Equal(1, queue.AlphaCount);
Assert.Equal(0, source.DrawCount);
Assert.Equal(0, immediateCount);
queue.EndFrame();
Assert.Equal(1, source.DrawCount);
Assert.Equal(0, immediateCount);
}
/// <summary>M1(d) pin 2: the SAME Opaque-classified mesh-particle batch
@ -47,14 +75,100 @@ public sealed class ParticleRendererRouteTests
/// as a failure (an unhandled exception) rather than an assertion
/// mismatch.</summary>
[Fact]
public void OpaqueClassifiedMeshBatch_WithNoMaterialAlpha_RoutesImmediate()
public void OpaqueClassifiedMeshBatch_WithNoMaterialAlpha_DrawsImmediateOnOpaqueDepthState()
{
const uint fullyOpaqueColorArgb = 0xFFFFFFFFu;
var queue = new RetailAlphaQueue();
var source = new RecordingSource();
int reserveCount = 0;
var immediate = new List<(ParticleSubmissionKind Kind, int Index, bool Opaque)>();
queue.BeginFrame();
RetailAlphaMeshDecision decision = ParticleRenderer.RouteParticleSubmission(
ParticleSubmissionKind.Mesh, TranslucencyKind.Opaque, fullyOpaqueColorArgb);
const uint fullyOpaqueColorArgb = 0xFFFFFFFFu;
RetailAlphaMeshDecision decision = ParticleRenderer.DeferToRetailAlphaQueue(
ParticleSubmissionKind.Mesh,
TranslucencyKind.Opaque,
fullyOpaqueColorArgb,
queue,
source,
reserveDeferred: () => reserveCount++,
drawImmediate: (_, kind, index, opaque) => immediate.Add((kind, index, opaque)),
Matrix4x4.Identity,
drawIndex: 11);
Assert.Equal(RetailAlphaMeshAction.Immediate, decision.Action);
Assert.Equal(0, reserveCount);
Assert.Equal(0, queue.PendingCount);
Assert.Equal(
[(ParticleSubmissionKind.Mesh, 11, true)],
immediate);
ParticleRenderer.MeshParticlePipelineState state =
ParticleRenderer.ResolveMeshParticlePipelineState(
TranslucencyKind.Opaque,
opaqueDepthState: immediate[0].Opaque);
Assert.Equal(GpuBlendMode.None, state.Blend);
Assert.True(state.Depth.Test);
Assert.True(state.Depth.Write);
Assert.Equal(WorldDepthContract.WorldCompare, state.Depth.Compare);
queue.EndFrame();
Assert.Equal(0, source.PrepareCount);
Assert.Equal(0, source.DrawCount);
}
/// <summary>
/// M1 production fixed-state/binding pin. Executes the real private
/// pipeline factory against the recording GPU device and the real
/// <c>PipelineForMeshBlend</c> selector (not a restated expected-state
/// helper). Mutation checks: making the opaque factory depth-write false
/// fails the descriptor assertions; mapping the immediate opaque case to
/// <c>_meshAlphaPipeline</c> fails the identity assertion.
/// </summary>
[Fact]
public void ImmediateOpaqueMesh_UsesProductionParticleMeshOpaquePipelineDescription()
{
using var device = new RecordingGpuDevice();
using var manager = new ObjectMeshManager(
new VulkanMeshPipelineDevice(device.Retirement),
device,
new NullPreparedAssetSource(),
NullLogger<ObjectMeshManager>.Instance);
var adapter = (WbMeshAdapter)RuntimeHelpers.GetUninitializedObject(
typeof(WbMeshAdapter));
typeof(WbMeshAdapter).GetField(
"_meshManager",
BindingFlags.Instance | BindingFlags.NonPublic)!
.SetValue(adapter, manager);
var frames = new GpuDeviceFrameLifetime(device);
var scope = new VulkanWorldPassScope(sampleCount: 1);
using var renderer = new ParticleRenderer(
device,
frames,
scope,
new ParticleSystem(new EmitterDescRegistry(), new Random(42)),
meshAdapter: adapter);
MethodInfo select = typeof(ParticleRenderer).GetMethod(
"PipelineForMeshBlend",
BindingFlags.Instance | BindingFlags.NonPublic)!;
var selected = (IGpuPipeline)select.Invoke(
renderer,
[TranslucencyKind.Opaque, true])!;
Assert.Same(
device.CreatedPipelines.Single(
static pipeline => pipeline.Description.Name == "particle-mesh-opaque"),
selected);
GpuPipelineDescription description = selected.Description;
Assert.Equal("particle_mesh", description.Shaders.Name);
Assert.Equal(GpuBlendMode.None, description.Blend);
Assert.True(description.Depth.Test);
Assert.True(description.Depth.Write);
Assert.Equal(WorldDepthContract.WorldCompare, description.Depth.Compare);
Assert.Equal(GpuCullMode.None, description.Cull);
Assert.Equal(GpuFrontFace.Clockwise, description.FrontFace);
Assert.False(description.AlphaToCoverage);
}
/// <summary>A billboard submission always carries the alpha-family mask
@ -73,4 +187,114 @@ public sealed class ParticleRendererRouteTests
Assert.Equal(RetailAlphaMeshAction.Append, decision.Action);
Assert.Equal(RetailAlphaList.Alpha, decision.List);
}
[Fact]
public void ProductionDispatch_WarmedAppendAndImmediatePathsDoNotAllocate()
{
var queue = new RetailAlphaQueue();
var sink = new NoAllocParticleSink();
ParticleRenderer.ReserveDeferredParticleDraw reserve = sink.Reserve;
ParticleRenderer.DrawImmediateParticle immediate = sink.DrawImmediate;
for (int i = 0; i < 64; i++)
RunPair(queue, sink, reserve, immediate);
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 1_000; i++)
RunPair(queue, sink, reserve, immediate);
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.Equal(0, allocated);
}
private static void RunPair(
RetailAlphaQueue queue,
NoAllocParticleSink sink,
ParticleRenderer.ReserveDeferredParticleDraw reserve,
ParticleRenderer.DrawImmediateParticle immediate)
{
queue.BeginFrame();
ParticleRenderer.DeferToRetailAlphaQueue(
ParticleSubmissionKind.Mesh,
TranslucencyKind.Opaque,
0x80FFFFFFu,
queue,
sink,
reserve,
immediate,
Matrix4x4.Identity,
drawIndex: 0);
ParticleRenderer.DeferToRetailAlphaQueue(
ParticleSubmissionKind.Mesh,
TranslucencyKind.Opaque,
0xFFFFFFFFu,
queue,
sink,
reserve,
immediate,
Matrix4x4.Identity,
drawIndex: 1);
queue.EndFrame();
}
private sealed class RecordingSource : IRetailAlphaDrawSource
{
public int PrepareCount { get; private set; }
public int DrawCount { get; private set; }
public void PrepareAlphaDraws(ReadOnlySpan<int> tokens) => PrepareCount++;
public void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount) =>
DrawCount += drawCount;
public void ResetAlphaSubmissions()
{
}
}
private sealed class NoAllocParticleSink : IRetailAlphaDrawSource
{
private int _nextToken;
public int Reserve() => _nextToken++;
public void DrawImmediate(
Matrix4x4 viewProjection,
ParticleSubmissionKind kind,
int drawIndex,
bool opaqueDepthState)
{
}
public void PrepareAlphaDraws(ReadOnlySpan<int> tokens)
{
}
public void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount)
{
}
public void ResetAlphaSubmissions() => _nextToken = 0;
}
private sealed class NullPreparedAssetSource : IPreparedAssetSource
{
public PreparedAssetSourceStats Stats => default;
public CacheStats DecodedTextureCacheStats => default;
public PreparedAssetPresence Probe(
AcDream.Content.Pak.PakAssetType type,
uint sourceFileId) =>
PreparedAssetPresence.Missing;
public PreparedAssetReadResult Read(
in PreparedAssetRequest request,
CancellationToken cancellationToken = default) =>
PreparedAssetReadResult.Missing;
public void Dispose()
{
}
}
}

View file

@ -289,11 +289,77 @@ public sealed class RetailAlphaQueueTests
Assert.Equal(RetailAlphaQueue.ListCapacity, queue.PendingCount);
queue.Flush(RetailAlphaFlushSite.RenderNormalMode, 0f);
// The dropped 3001st token (3000) never reaches the source at all —
// only the 3000 accepted entries drew.
// The dropped 3001st token (3000) is never prepared or drawn — only
// the 3000 accepted entries draw. Its source still participates in
// cleanup; the two-source pin below discriminates that case.
Assert.Equal(RetailAlphaQueue.ListCapacity, source.LastDrawCount);
}
/// <summary>
/// S4-c2 final fix R2-3: production callers reserve source-owned payload
/// before <c>TryAppend</c>. If source A already fills ALPHA, source B's
/// FIRST append is rejected but B must still be registered for cleanup.
/// It is never prepared/drawn, is reset exactly once by every terminal
/// path, and starts the next frame empty. Mutation check: moving
/// <c>RegisterSource(source)</c> below the capacity return makes the first
/// <c>Assert.Equal(1, rejected.ResetCount)</c> fail with actual 0.</summary>
[Theory]
[InlineData("flush")]
[InlineData("end")]
[InlineData("abort")]
public void RejectedFirstUseSource_IsCleanedWithoutPrepareOrDraw(string terminal)
{
var filling = new CountingSource();
var rejected = new RetainedPayloadSource();
var queue = new RetailAlphaQueue();
queue.BeginFrame();
for (int i = 0; i < RetailAlphaQueue.ListCapacity; i++)
Assert.True(queue.TryAppend(RetailAlphaList.Alpha, filling, i, false));
int rejectedToken = rejected.Reserve(91);
Assert.False(queue.TryAppend(
RetailAlphaList.Alpha,
rejected,
rejectedToken,
overrideClipmap: false));
switch (terminal)
{
case "flush":
queue.Flush(RetailAlphaFlushSite.DrawBuilding, 0f);
queue.AbortFrame();
break;
case "end":
queue.EndFrame();
break;
case "abort":
queue.AbortFrame();
break;
default:
throw new ArgumentOutOfRangeException(nameof(terminal));
}
Assert.Equal(0, rejected.PrepareCount);
Assert.Equal(0, rejected.DrawCount);
Assert.Equal(1, rejected.ResetCount);
Assert.Equal(0, rejected.PendingCount);
queue.BeginFrame();
int acceptedToken = rejected.Reserve(92);
Assert.True(queue.TryAppend(
RetailAlphaList.Alpha,
rejected,
acceptedToken,
overrideClipmap: false));
queue.EndFrame();
Assert.Equal(1, rejected.PrepareCount);
Assert.Equal(1, rejected.DrawCount);
Assert.Equal(2, rejected.ResetCount);
Assert.Equal(0, rejected.PendingCount);
}
/// <summary>The ALPHA list has its own independent capacity — filling
/// CLIP to capacity must not affect ALPHA appends.</summary>
[Fact]
@ -513,6 +579,45 @@ public sealed class RetailAlphaQueueTests
public void ResetAlphaSubmissions() => ResetCount++;
}
private sealed class RetainedPayloadSource : IRetailAlphaDrawSource
{
private readonly List<int> _pending = new();
private int[] _prepared = [];
public int PrepareCount { get; private set; }
public int DrawCount { get; private set; }
public int ResetCount { get; private set; }
public int PendingCount => _pending.Count;
public int Reserve(int value)
{
int token = _pending.Count;
_pending.Add(value);
return token;
}
public void PrepareAlphaDraws(ReadOnlySpan<int> tokens)
{
PrepareCount++;
_prepared = new int[tokens.Length];
for (int i = 0; i < tokens.Length; i++)
_prepared[i] = _pending[tokens[i]];
}
public void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount)
{
Assert.InRange(firstPreparedDraw, 0, _prepared.Length - drawCount);
DrawCount += drawCount;
}
public void ResetAlphaSubmissions()
{
ResetCount++;
_pending.Clear();
_prepared = [];
}
}
private sealed class FailureSource(
string? drawFailure,
string? resetFailure) : IRetailAlphaDrawSource

View file

@ -160,23 +160,17 @@ public sealed class RetailPViewPassExecutorTests
}
/// <summary>
/// S4-c2 fix round 1 (M6, contract C4): a cell's transparent shell's
/// immediate-vs-append decision must come from the SAME
/// <see cref="RetailAlphaMeshRouter"/> table every other alpha submitter
/// uses — never a hand-coded <c>if (detailEnabled)</c> branch that
/// bypasses it. Pins the STRUCTURE: <see cref="EnvCellRenderer.TransparentDetailEnabled"/>
/// is read to feed <see cref="RetailAlphaMeshRouter.Route"/>'s own
/// <c>detailSurfaceActive</c> argument BEFORE the route call, and the
/// route call precedes BOTH possible outcomes
/// (<see cref="EnvCellRenderer.RenderTransparentOrdered"/> for
/// Immediate, <see cref="RetailAlphaQueue.TryAppend"/> for Append).
/// Mutation check: deleting the <c>Route</c> call and branching directly
/// on <c>TransparentDetailEnabled</c> instead removes the
/// <see cref="RetailAlphaMeshRouter"/> call entirely — <c>RequiredCallIndex</c>
/// fails its <c>index &gt;= 0</c> assertion for the route call.
/// S4-c2 fix round 2 (M6): supplemental structure pin linking the real
/// cell submitter to the retained-batch scan and the functional production
/// dispatcher proved by <c>EnvCellAlphaDrawSourceTests</c>. The per-subset
/// <see cref="RetailAlphaMeshRouter"/> calls now correctly live inside
/// <see cref="EnvCellRenderer.GetTransparentRoutes"/> (one call per real
/// retained batch), not here once per whole cell. Mutation check: bypassing
/// that scan for a hand-coded cell-wide mask removes the required call and
/// fails before the dispatch-order assertion.
/// </summary>
[Fact]
public void SubmitOrDrawTransparentCellShell_RoutesThroughTheSharedTableBeforeEitherOutcome()
public void SubmitOrDrawTransparentCellShell_ScansRetainedBatchesBeforeProductionDispatch()
{
MethodInfo method = typeof(RetailPViewPassExecutor).GetMethod(
"SubmitOrDrawTransparentCellShell",
@ -185,16 +179,13 @@ public sealed class RetailPViewPassExecutorTests
int detailProbe = RequiredCallIndex(
calls, typeof(EnvCellRenderer), "get_TransparentDetailEnabled");
int route = RequiredCallIndex(
calls, typeof(RetailAlphaMeshRouter), nameof(RetailAlphaMeshRouter.Route));
int renderImmediate = RequiredCallIndex(
calls, typeof(EnvCellRenderer), nameof(EnvCellRenderer.RenderTransparentOrdered));
int append = RequiredCallIndex(
calls, typeof(RetailAlphaQueue), nameof(RetailAlphaQueue.TryAppend));
int scan = RequiredCallIndex(
calls, typeof(EnvCellRenderer), nameof(EnvCellRenderer.GetTransparentRoutes));
int dispatch = RequiredCallIndex(
calls, typeof(RetailPViewPassExecutor), nameof(RetailPViewPassExecutor.DispatchTransparentCellShell));
Assert.True(detailProbe < route);
Assert.True(route < renderImmediate);
Assert.True(route < append);
Assert.True(detailProbe < scan);
Assert.True(scan < dispatch);
}
/// <summary>

View file

@ -89,15 +89,15 @@ public static class WalkAlphaDepthTrace
// (alphaedMeshCountAlpha/List) — Ghidra-verified 2026-09-04, see
// RetailAlphaList's own doc comment. "new" is retail's per-entry
// first-for-list flag (param_4); "clip" (param_5, unused here)
// is overrideClipmap. S4-c2 fix round 1 (M4/A6): IsNew is parsed
// and RETAINED purely as a captured/parsed FACT (fidelity to the
// capture's own format) but is never compared against acdream's
// own routing — M4 established that flag is trivially true for
// EVERY subset retail appends (one DrawMesh invocation owns one
// subset per list), so a per-subset comparison would be
// meaningless at acdream's coarser per-INSTANCE granularity (the
// same content-volume mismatch M5's KnownFailure count gate
// documents) — no dead-parse-as-evidence claim is made here.
// is overrideClipmap. S4-c2 fix round 2: DrawMesh sets one flag
// per list at entry and clears that list's flag after its first
// successful append; later subsets in the same call can therefore
// carry new=0 (the capture has 9,685 true and 871 false). IsNew is
// retained as a capture fact but is not compared to acdream: its
// retained Wb/particle/EnvCell submission units are not one-to-one
// DrawMesh invocations, and Vulkan has no consumer for retail's
// material-capture optimization bit. The KnownFailure count gate
// below remains about zero replay content, not this parsed field.
Match am = AmPattern.Match(line);
if (am.Success)
{

View file

@ -594,6 +594,151 @@ public sealed class WalkStaticStreamPopulatorTests
fx.AlphaQueue.AbortFrame();
}
/// <summary>
/// S4-c2 final M3 production pin for <c>SubmitWalkAlphaInstance</c>:
/// building shell + detail ON executes the immediate base+detail draws at
/// the submit mark and queues nothing; detail OFF queues, then drains the
/// base draw without a detail pass; an ordinary batch remains deferred
/// even while the global detail option is on. Mutation check: hardcoding
/// <c>detailSurfaceActive=false</c> at the production submit makes the
/// first case's pending-count assertion fail with 1.</summary>
[Fact]
public void SubmitWalkAlphaInstance_DetailOnOffAndOrdinaryUseProductionOutcomes()
{
using (var on = new DispatcherFixture(
withAlphaQueue: true,
detailAvailable: true,
detailEnabled: true))
using (DrawScope draw = on.BeginDraw(beginAlpha: true))
{
WbDrawDispatcher.WalkClassifiedBatch building = AlphaWalkBatch(detailCategory: 1u);
on.Dispatcher.SubmitWalkAlphaInstance(
in building,
Vector3.Zero,
Matrix4x4.Identity);
Assert.Equal(0, on.AlphaQueue!.PendingCount);
Assert.Contains(
on.Device.Calls.OfType<GpuRecordedPipelineBind>(),
call => call.PipelineName.Contains("alpha", StringComparison.Ordinal)
&& !call.PipelineName.Contains("detail", StringComparison.Ordinal));
Assert.Contains(
on.Device.Calls.OfType<GpuRecordedPipelineBind>(),
call => call.PipelineName.Contains("retail-detail-alpha", StringComparison.Ordinal));
}
using (var off = new DispatcherFixture(
withAlphaQueue: true,
detailAvailable: true,
detailEnabled: false))
using (DrawScope draw = off.BeginDraw(beginAlpha: true))
{
WbDrawDispatcher.WalkClassifiedBatch building = AlphaWalkBatch(detailCategory: 1u);
off.Dispatcher.SubmitWalkAlphaInstance(
in building,
Vector3.Zero,
Matrix4x4.Identity);
Assert.Equal(1, off.AlphaQueue!.PendingCount);
Assert.Empty(off.Device.Calls.OfType<GpuRecordedPipelineBind>());
off.AlphaQueue.EndFrame();
Assert.DoesNotContain(
off.Device.Calls.OfType<GpuRecordedPipelineBind>(),
call => call.PipelineName.Contains("retail-detail", StringComparison.Ordinal));
}
using (var ordinary = new DispatcherFixture(
withAlphaQueue: true,
detailAvailable: true,
detailEnabled: true))
using (DrawScope draw = ordinary.BeginDraw(beginAlpha: true))
{
WbDrawDispatcher.WalkClassifiedBatch batch = AlphaWalkBatch(detailCategory: 0u);
ordinary.Dispatcher.SubmitWalkAlphaInstance(
in batch,
Vector3.Zero,
Matrix4x4.Identity);
Assert.Equal(1, ordinary.AlphaQueue!.PendingCount);
Assert.Empty(ordinary.Device.Calls.OfType<GpuRecordedPipelineBind>());
}
}
/// <summary>
/// The sibling grouped-production site executes the same detail-on row 1,
/// not merely the walk submit site. This invokes the real private
/// <c>DeferTransparentGroups</c> after seeding its real retained group
/// scratch. The same hardcoded-false mutation leaves one queued token and
/// makes the pending-count assertion fail.</summary>
[Fact]
public void DeferTransparentGroups_BuildingDetailOnDrawsImmediateWithDetail()
{
using var fx = new DispatcherFixture(
withAlphaQueue: true,
detailAvailable: true,
detailEnabled: true);
using DrawScope draw = fx.BeginDraw(beginAlpha: true);
WbDrawDispatcher.InstanceGroup group = AlphaInstanceGroup(detailCategory: 1u);
FieldInfo field = typeof(WbDrawDispatcher).GetField(
"_translucentDraws",
BindingFlags.Instance | BindingFlags.NonPublic)!;
var groups = (List<WbDrawDispatcher.InstanceGroup>)field.GetValue(fx.Dispatcher)!;
groups.Add(group);
MethodInfo defer = typeof(WbDrawDispatcher).GetMethod(
"DeferTransparentGroups",
BindingFlags.Instance | BindingFlags.NonPublic)!;
defer.Invoke(fx.Dispatcher, [Vector3.Zero, Matrix4x4.Identity]);
Assert.Equal(0, fx.AlphaQueue!.PendingCount);
Assert.Contains(
fx.Device.Calls.OfType<GpuRecordedPipelineBind>(),
call => call.PipelineName.Contains("retail-detail-alpha", StringComparison.Ordinal));
}
private static WbDrawDispatcher.WalkClassifiedBatch AlphaWalkBatch(uint detailCategory) =>
new(
new GroupKey(
0,
0,
3,
new GpuTextureSlot(1),
0,
TranslucencyKind.AlphaBlend,
FoliageFlags: 0),
Matrix4x4.Identity,
ClipSlot: 0,
WbDrawDispatcher.InstanceLightSet.Disabled,
IndoorFlag: 0,
Alpha: 1f,
SelectionLighting: Vector2.Zero,
DetailCategory: detailCategory,
IsOpaque: false,
LocalSortCenter: Vector3.Zero);
private static WbDrawDispatcher.InstanceGroup AlphaInstanceGroup(uint detailCategory)
{
var group = new WbDrawDispatcher.InstanceGroup
{
FirstIndex = 0,
BaseVertex = 0,
IndexCount = 3,
TextureSlot = new GpuTextureSlot(1),
TextureLayer = 0,
Translucency = TranslucencyKind.AlphaBlend,
};
group.Matrices.Add(Matrix4x4.Identity);
group.LocalSortCenters.Add(Vector3.Zero);
group.SubmissionOrders.Add(0);
group.Slots.Add(0);
group.LightSets.Add(WbDrawDispatcher.InstanceLightSet.Disabled);
group.IndoorFlags.Add(0);
group.DetailCategories.Add(detailCategory);
group.Opacities.Add(1f);
group.SelectionLighting.Add(Vector2.Zero);
return group;
}
[Fact]
public void SubmitWalkAlphaInstance_RejectsAMismatchedViewProjectionInTheSameScope()
{
@ -685,7 +830,9 @@ public sealed class WalkStaticStreamPopulatorTests
public DispatcherFixture(
bool withAlphaQueue = false,
IRetailSelectionRenderSink? selectionSink = null)
IRetailSelectionRenderSink? selectionSink = null,
bool detailAvailable = false,
bool detailEnabled = false)
{
Device = new RecordingGpuDevice();
FrameLifetime = new GpuDeviceFrameLifetime(Device);
@ -712,7 +859,17 @@ public sealed class WalkStaticStreamPopulatorTests
new EntityClassificationCache(),
new AcDream.Core.Rendering.TranslucencyFadeManager(),
selectionSink: selectionSink,
alphaQueue: AlphaQueue);
alphaQueue: AlphaQueue,
buildingDetail: detailAvailable
? new TerrainAtlas.RetailDetailTextureBinding(
new GpuTextureSlot(99),
Tiling: 2f,
SurfaceTextureId: 1,
RenderSurfaceId: 2,
Width: 4,
Height: 4)
: default,
buildingDetailEnabled: detailEnabled ? DetailOn : DetailOff);
}
public RecordingGpuDevice Device { get; }
@ -727,8 +884,13 @@ public sealed class WalkStaticStreamPopulatorTests
public ObjectMeshManager Manager => _meshAdapter.MeshManager!;
public DrawScope BeginDraw()
public DrawScope BeginDraw(bool beginAlpha = false)
{
if (beginAlpha)
{
Dispatcher.BeginFrame(frameSlot: 0);
AlphaQueue!.BeginFrame();
}
FrameLifetime.BeginFrame();
IGpuFrame frame = FrameLifetime.CurrentFrame!;
IGpuPassEncoder pass = frame.BeginPass(
@ -739,6 +901,10 @@ public sealed class WalkStaticStreamPopulatorTests
return new DrawScope(frame, pass, publication);
}
private static bool DetailOn() => true;
private static bool DetailOff() => false;
public void Dispose()
{
Dispatcher.Dispose();

View file

@ -1,16 +1,30 @@
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using System.Numerics;
using System.Reflection;
using AcDream.App.Composition;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Gpu.Vk;
using AcDream.App.Rendering.Packs;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Selection;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Rendering.Walk;
using AcDream.App.Rendering.Wb;
using AcDream.App.Streaming;
using AcDream.App.Tests.Architecture;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.Content;
using AcDream.Core.Physics;
using AcDream.Core.Rendering;
using AcDream.Core.World;
using AcDream.Runtime;
using AcDream.Runtime.World;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Lib.IO;
using Microsoft.Extensions.Logging.Abstractions;
namespace AcDream.App.Tests.Rendering;
@ -513,6 +527,38 @@ public sealed class WorldSceneRendererTests
}
}
/// <summary>
/// S4-c2 fix round 2 (A1): this is the positive, concrete owner-path
/// proof. It opens the real recording GPU world scope, executes
/// <see cref="RetailPViewRenderer.DrawInside(RetailPViewFrameInput,
/// RetailPViewPassExecutor)"/> for an outdoor root containing one real
/// walk building, then lets <see cref="WorldSceneRenderer"/> close the
/// SAME <see cref="RetailAlphaQueue"/> through <c>EndFrame</c>. Only
/// drains that pass retail's threshold gate are observed. The production
/// order is therefore the building's own zero-threshold barrier followed
/// by the final RenderNormalMode drain; an outdoor LandscapeFlush is
/// forbidden. Restoring the removed outdoor
/// <c>passes.FlushLandscapeAlpha()</c> call inserts LandscapeFlush between
/// those two entries. Moving/deleting <c>_alpha.EndFrame()</c> removes or
/// reorders RenderNormalMode. The IL pin in
/// <c>RetailPViewRendererOutdoorFlushTests</c> remains secondary only.
/// </summary>
[Fact]
public void OutdoorProductionPView_DrainsBuildingThenRenderNormalModeWithoutLandscapeFlush()
{
var drains = new List<RetailAlphaFlushSite>();
using var fixture = new OutdoorAlphaOwnerFixture(drains.Add);
WorldRenderFrameOutcome outcome = fixture.Renderer.Render(default);
Assert.True(outcome.NormalWorldDrawn);
Assert.Equal(1, fixture.PView.DrawCount);
Assert.Equal(
[RetailAlphaFlushSite.DrawBuilding, RetailAlphaFlushSite.RenderNormalMode],
drains);
Assert.DoesNotContain(RetailAlphaFlushSite.LandscapeFlush, drains);
}
private sealed class Rig
{
public Rig(
@ -603,6 +649,241 @@ public sealed class WorldSceneRendererTests
public WorldSceneRenderer Renderer { get; }
}
private sealed class OutdoorAlphaOwnerFixture : IDisposable
{
private readonly RecordingGpuDevice _device;
private readonly TextureCache _textures;
private readonly WbMeshAdapter _meshAdapter;
private readonly WbDrawDispatcher _dispatcher;
private readonly EnvCellRenderer _envCells;
private readonly ClipFrame _clipFrame;
private readonly RenderSceneShadowRuntime _renderScene;
private readonly IGpuPassEncoder _pass;
private readonly IDisposable _scopePublication;
public OutdoorAlphaOwnerFixture(Action<RetailAlphaFlushSite> observeDrain)
{
_device = new RecordingGpuDevice();
var frames = new GpuDeviceFrameLifetime(_device);
var scope = new FixedWorldPassScope();
var dat = new NoopDatReaderWriter();
var prepared = new NullPreparedAssetSource();
_textures = new TextureCache(_device, dat);
_meshAdapter = new WbMeshAdapter(
_device,
dat,
prepared,
NullLogger<WbMeshAdapter>.Instance,
_device.Retirement);
var spawns = new EntitySpawnAdapter(
_textures,
_ => throw new NotSupportedException("The empty owner-path fixture never spawns entities."));
var alpha = new RetailAlphaQueue(drainObserver: observeDrain);
_dispatcher = new WbDrawDispatcher(
_device,
frames,
scope,
_textures,
_meshAdapter,
spawns,
new EntityClassificationCache(),
new AcDream.Core.Rendering.TranslucencyFadeManager(),
alphaQueue: alpha);
_envCells = new EnvCellRenderer(
_device,
frames,
scope,
_meshAdapter.MeshManager!,
new WbFrustum());
_clipFrame = ClipFrame.NoClip();
var glDiagnostics = new WorldRenderDiagnostics(
new DefaultGlStateReader(),
new NullDiagnosticLog());
var terrainDiagnostics = new TerrainDrawDiagnosticsController(
enabled: false,
glDiagnostics,
new EmptyFrameFacts(),
new NullDiagnosticLog());
var pviewPasses = new RetailPViewPassExecutor(
new NullWorldPassSurface(),
NullRenderFrameGlState.Instance,
_clipFrame,
terrain: null,
_envCells,
_dispatcher,
sky: null,
particles: null,
particleRenderer: null,
portalDepthMask: null,
alpha,
glDiagnostics,
terrainDiagnostics);
const uint landblockId = 0xF4180000u;
var building = new WalkBuilding
{
PositionCellId = landblockId | 1u,
DrawingBsp = new WalkBspNode { InPortals = [] },
};
var entry = new WalkBuildingFactory.Entry(
building,
Matrix4x4.Identity,
Matrix4x4.Identity);
var buildings = new WalkBuildingRegistry();
buildings.Publish(landblockId, [entry]);
var landscape = new WalkLandscapeAssembler();
landscape.PublishLandblock(landblockId, maxZ: 10f, minZ: -1f, [entry]);
var cells = new CellVisibility();
_renderScene = new RenderSceneShadowRuntime(RenderSceneGeneration.FromRaw(1));
var retailPView = new RetailPViewRenderer(
_renderScene,
buildings,
landscape,
cells,
new ShadowObjectRegistry());
PView = new CountingPView(new WorldScenePViewRenderer(retailPView, pviewPasses));
frames.BeginFrame();
IGpuFrame frame = frames.CurrentFrame!;
_pass = frame.BeginPass(
GpuPassDescription.BackbufferClear(
"s4-c2-outdoor-owner-path",
Vector4.Zero,
sampleCount: 1));
_scopePublication = scope.Publish(_pass);
_dispatcher.BeginFrame(frameSlot: 0);
_envCells.BeginFrame(frameSlot: 0);
var calls = new List<string>();
var root = new LoadedCell
{
CellId = landblockId | 1u,
IsOutdoorNode = true,
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
};
var day = new DayGroupData { Name = "s4-c2-owner" };
var foundation = new RenderFrameFoundation(
PortalViewportVisible: false,
Sky: default,
Atmosphere: default);
Renderer = new WorldSceneRenderer(
new FoundationSource(foundation),
new LoginSource(false),
new SkySource(day, dayFraction: 0f),
new FrameBuilder(calls, CreateFrame(root, playerSeenOutside: true)),
new EntitySource(),
selection: null,
alpha,
new ParticleVisibility(calls),
PView,
new PViewCells(),
new PassExecutor(calls),
new WorldRenderRangeState(nearRadius: 4, farRadius: 12),
new Diagnostics(calls));
}
public CountingPView PView { get; }
public WorldSceneRenderer Renderer { get; }
public void Dispose()
{
_scopePublication.Dispose();
_pass.Dispose();
_envCells.Dispose();
_dispatcher.Dispose();
_meshAdapter.Dispose();
_textures.Dispose();
_renderScene.Dispose();
_clipFrame.Dispose();
_device.Dispose();
}
}
private sealed class CountingPView(IWorldScenePViewRenderer inner) : IWorldScenePViewRenderer
{
public int DrawCount { get; private set; }
public RetailPViewFrameResult DrawInside(RetailPViewFrameInput input)
{
DrawCount++;
return inner.DrawInside(input);
}
public void AbortFrame() => inner.AbortFrame();
}
private sealed class NullWorldPassSurface : IWorldPassSurface
{
public void PrepareClipFrame() { }
public void EnableClipDistances() { }
public void DisableClipDistances() { }
public void ClearInteriorDepth() =>
throw new InvalidOperationException("An outdoor root cannot clear interior depth.");
}
private sealed class FixedWorldPassScope : IWorldPassScope
{
private IGpuPassEncoder? _encoder;
public int SampleCount => 1;
public IGpuPassEncoder? CurrentEncoder => _encoder;
public int AttachmentWidth => 1024;
public int AttachmentHeight => 720;
public WorldFrameSections Sections { get; } = new();
public IGpuPassEncoder RequireEncoder() =>
_encoder ?? throw new InvalidOperationException("No recording world pass is open.");
public void ClearInteriorDepth() =>
throw new InvalidOperationException("An outdoor root cannot clear interior depth.");
public IDisposable Publish(IGpuPassEncoder encoder)
{
Assert.Null(_encoder);
_encoder = encoder;
Sections.Reset();
return new Publication(this);
}
private sealed class Publication(FixedWorldPassScope owner) : IDisposable
{
public void Dispose()
{
owner._encoder = null;
owner.Sections.Reset();
}
}
}
private sealed class DefaultGlStateReader : IRenderGlStateReader
{
public RenderGlStateSnapshot CaptureState() => default;
public RenderGlScissorSnapshot CaptureScissor() => default;
}
private sealed class NullDiagnosticLog : IRenderFrameDiagnosticLog
{
public void WriteLine(string message) { }
}
private sealed class EmptyFrameFacts : IFramePipelineDiagnosticFactsSource
{
public TerrainRenderDiagnosticFacts CaptureTerrain() => default;
public FramePipelineDiagnosticFacts CaptureFrame() => default;
}
private sealed class FoundationSource(RenderFrameFoundation foundation) :
IRenderFrameFoundationSource
{
@ -909,6 +1190,137 @@ public sealed class WorldSceneRendererTests
}
}
private sealed class NullPreparedAssetSource : IPreparedAssetSource
{
public PreparedAssetSourceStats Stats => default;
public CacheStats DecodedTextureCacheStats => default;
public PreparedAssetPresence Probe(
AcDream.Content.Pak.PakAssetType type,
uint sourceFileId) =>
PreparedAssetPresence.Missing;
public PreparedAssetReadResult Read(
in PreparedAssetRequest request,
CancellationToken cancellationToken = default) =>
PreparedAssetReadResult.Missing;
public void Dispose() { }
}
private sealed class NoopDatReaderWriter : IDatReaderWriter
{
private readonly StubDatabase _portal = new();
private readonly StubDatabase _highRes = new();
private readonly StubDatabase _language = new();
private readonly StubDatabase _cell = new();
public string SourceDirectory => string.Empty;
public IDatDatabase Portal => _portal;
public IDatDatabase Cell => _cell;
public ReadOnlyDictionary<uint, IDatDatabase> CellRegions { get; } =
new(new Dictionary<uint, IDatDatabase>());
public IDatDatabase HighRes => _highRes;
public IDatDatabase Language => _language;
public IDatDatabase Local => _language;
public ReadOnlyDictionary<uint, uint> RegionFileMap { get; } =
new(new Dictionary<uint, uint>());
public int PortalIteration => 0;
public int CellIteration => 0;
public int HighResIteration => 0;
public int LanguageIteration => 0;
public bool TryGetFileBytes(
uint regionId,
uint fileId,
ref byte[] bytes,
out int bytesRead)
{
bytesRead = 0;
return false;
}
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj =>
Array.Empty<uint>();
public IEnumerable<IDatReaderWriter.IdResolution> ResolveId(uint id) =>
Array.Empty<IDatReaderWriter.IdResolution>();
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
throw new NotSupportedException();
public bool TrySave<T>(
uint regionId,
T obj,
int iteration = 0) where T : IDBObj =>
throw new NotSupportedException();
[return: MaybeNull]
public T Get<T>(uint fileId) where T : IDBObj => default;
public bool TryGet<T>(
uint fileId,
[MaybeNullWhen(false)] out T value) where T : IDBObj
{
value = default;
return false;
}
public void Dispose() { }
private sealed class StubDatabase : IDatDatabase
{
public DatDatabase Db => throw new NotSupportedException();
public int Iteration => 0;
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj =>
Array.Empty<uint>();
public bool TryGet<T>(
uint fileId,
[MaybeNullWhen(false)] out T value) where T : IDBObj
{
value = default;
return false;
}
public bool TryGetFileBytes(
uint fileId,
[MaybeNullWhen(false)] out byte[] value)
{
value = null;
return false;
}
public bool TryGetFileBytes(
uint fileId,
ref byte[] bytes,
out int bytesRead)
{
bytesRead = 0;
return false;
}
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
throw new NotSupportedException();
public void Dispose() { }
}
}
private static WorldRenderFrame CreateFrame(
LoadedCell? clipRoot,
bool playerSeenOutside)