test(overhaul): integrate reviewed consumed-light checkpoint observation

This commit is contained in:
Erik 2026-09-05 13:56:49 +02:00
parent ad866b7f65
commit 59b0b6e72f
9 changed files with 703 additions and 1 deletions

View file

@ -0,0 +1,71 @@
# #478 checkpoint observation — lead verification
Contract6c3ffd79e. OpenAI return in `s5-478-light-observation`, seven files,
no shader/RHI/selector/runtime changes. This is an observation, not a lighting
repair, a retail-parity closure, an FPS result or G4 acceptance.
## Independent checks
Lead read the complete source/test delta and the actual consumption chain:
WorldRenderFrameBuilder568570 produces and lends one PointSnapshot;
EnvCellRenderer.GetCellLightSet has only SubmitRhi as production caller;
SubmitRhi copies that set to binding5 and its packed snapshot to binding4.
Both arrays remain retained. RenderFrameResourceController begins the existing
EnvCell generation every opened frame. RenderFrameOrchestrator does not run
postdiagnostics after failure, and zero-area returns before opening a frame.
Source gating rejects zero-area/non-world outcomes; generation filtering
rejects prior-frame cell sets. Private entity viewports do not change the
EnvCell buffers. No selector, packer, or snapshot rebuild occurs on capture.
The added fixture compares actual RecordingGpuDevice ring bytes, not a second
packing/selection oracle. Two nonuniform cells are submitted in reverse order
to the output's sorted cell IDs; their distinct eight-index sets still match
their own binding5 slices. Binding4 is compared bit-for-bit, including the
one-zero-light dummy upload. Copied source identity, scalar/vector bits,
local pose, ambient and checkpoint JSON are covered. These observations prove
CPU-submitted inputs, not GPU execution, a particular vertex contribution, or
pixel brightness. Copies/allocations occur only at requested checkpoints.
During implementation the lead required the contract's optional appended DTO
field and tightened the canonical composition witness: mere parameter types
or any earlier getter call are not an argument-ownership proof. The final IL
test pins adjacent DrawDispatcher→EnvCellRenderer→Lighting→FrameProfiler
getter calls at the actual constructor invocation.
Lead actual negative controls, together in one targeted run:
- Replacing only the composed EnvCellRenderer argument with null fails the
canonical-argument test (expected getter index217, actual153).
- Removing the current-generation filter fails the no-draw stale-frame
assertion (HasCurrentEnvCellInput expectedfalse, actualtrue).
Both failed as intended, 0 passed/2 failed; exact raw source hashes restored.
Then affected Release tests: **60/60 passed, zero skips/failures**. No clients
were running before either invocation. Agent's final full Release build:
0 warnings/errors; agent focused60/60. Fresh scratch initially had missing
assets (NETSDK1004), restored successfully before compilation; not a code bug.
## Exact return / test seals
Paths below relative to the scratch worktree:
| File | SHA256 |
| --- | --- |
| src/AcDream.App/Composition/FrameRootComposition.cs | DF536DC96FB075792D3E1E14A2022BB19AAB9546FB0E2860A3E4D86A68524071 |
| src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs | D23C952AC064A54D027A9EC20F10611CA3232F48C63150789AB53E685EA0F0E0 |
| src/AcDream.App/Diagnostics/WorldLifecycleResourceSnapshotSource.cs | 413DBF21A466B399B97E90DC87A05CFAC9CA3696708C3871524CA799558DAEF9 |
| src/AcDream.App/Rendering/Wb/EnvCellRenderer.LightingObservation.cs | 13761B2248291A28B408BCA6F7F67A1C2CDCA860B62ECDB90914F8FD2DD53FAD |
| tests/AcDream.App.Tests/Composition/FrameRootCompositionTests.cs | 6E6A87490A87FCCD377AC659B03237FF90F90841A53D2FD3DCCA029A8241CD2A |
| tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs | F58D2E13578F78EBBE3F3F03EC22F377465F5FD1CB8C3C395AB862554DA7E205 |
| tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs | 35ABB9273F741C699B7623248FF0A74072F498C8CD7A8C2A9B7551978F26FB15 |
| artifacts/478-consumed-light/focused-final2/focused-final2.trx | 6E59ABCF93AA56013EA25A6D4D04114118C21EA2D76494ED29BCF9E9A16DEB08 |
| artifacts/478-consumed-light/root-mutations/root-mutations.trx | 4E9D5B378D7160567C9A14C45DE940EAA9C5EF770AE8220CDFA853DBD3AEC921 |
| artifacts/478-consumed-light/root-restored60/root-restored60.trx | 57E74670066B34721BF30E9349D83FF355C4AA7CCFFA931383B53A4BADAB9761 |
Wegener's one narrow production/lifetime review PASS, no blockers; no builds
or tests by the reviewer. Lead committed the exact seven-file return as
`41128d8164821fd186a1e2d36e042e2dea425270` and integrates those blobs unchanged.
No new retail deviation is introduced by this diagnostic. Next: fresh
integrated Release and exact-binary Town Network High/off/High with all PNGs retained.
Remove temporary observation with the eventual evidence-backed #478 repair.
FPS/#481/C2 remain deferred. No main merge; G4FAIL.

View file

@ -4917,3 +4917,10 @@ and JSON serialization are the tests; no new selector or per-frame recorder.
No lighting fix or FPS work. Register AP-16/AP-35/AP-85 remains unchanged.
Lead owns the contract/integration; one OpenAI implementer and one narrow
production/lifetime review. The next live result determines the repair scope.
Seven-file return41128d816 accepted: agent full Release0W0E/60PASS;
root both actual wiring/generation mutations fail, exact restore/60PASS;
Wegener narrow production/lifetime PASS/no blockers. Lead verification and
seals in478-observation-lead-verification.md. No per-frame path/algorithm
change or new divergence. Integrate exact blobs, then fresh build/tests and
unchanged Town Network run. FPS deferral and G4FAIL remain.

View file

@ -596,6 +596,8 @@ internal sealed class FrameRootCompositionPhase
foundation.MeshAdapter,
foundation.TextureCache,
live.DrawDispatcher,
live.EnvCellRenderer,
d.Lighting,
d.FrameProfiler,
content.Dats,
foundation.Residency,

View file

@ -2,6 +2,7 @@ using System.Text.Json;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Residency;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Wb;
using AcDream.App.Streaming;
using AcDream.App.UI.Testing;
using AcDream.Core.Physics;
@ -96,7 +97,8 @@ internal sealed record WorldLifecycleResourceSnapshot(
ResidencySnapshot Residency,
double Fps,
double FrameMilliseconds,
string? LastFrameProfile);
string? LastFrameProfile,
EnvCellLightingConsumptionSnapshot? EnvCellLighting = null);
internal sealed record WorldLifecycleCheckpoint(
int Sequence,

View file

@ -7,6 +7,7 @@ using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Content;
using AcDream.Core.Physics;
using AcDream.Core.Lighting;
using AcDream.Core.Vfx;
using DatReaderWriter.Lib.IO;
@ -39,6 +40,8 @@ internal sealed class WorldLifecycleResourceSnapshotSource
private readonly WbMeshAdapter? _meshes;
private readonly TextureCache _textures;
private readonly WbDrawDispatcher? _dispatcher;
private readonly EnvCellRenderer? _envCellRenderer;
private readonly LightManager _lighting;
private readonly FrameProfiler _frameProfiler;
private readonly IDatReaderWriter _dats;
private readonly ResidencyManager _residency;
@ -62,6 +65,8 @@ internal sealed class WorldLifecycleResourceSnapshotSource
WbMeshAdapter? meshes,
TextureCache textures,
WbDrawDispatcher? dispatcher,
EnvCellRenderer? envCellRenderer,
LightManager lighting,
FrameProfiler frameProfiler,
IDatReaderWriter dats,
ResidencyManager residency,
@ -88,6 +93,8 @@ internal sealed class WorldLifecycleResourceSnapshotSource
_meshes = meshes;
_textures = textures ?? throw new ArgumentNullException(nameof(textures));
_dispatcher = dispatcher;
_envCellRenderer = envCellRenderer;
_lighting = lighting ?? throw new ArgumentNullException(nameof(lighting));
_frameProfiler = frameProfiler
?? throw new ArgumentNullException(nameof(frameProfiler));
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
@ -156,6 +163,10 @@ internal sealed class WorldLifecycleResourceSnapshotSource
ParticleOwners: _particleSink.TrackedOwnerCount,
EffectOwners: _effects.ReadyOwnerCount,
LightOwners: _lights.TrackedOwnerCount,
EnvCellLighting: CaptureEnvCellLighting(
outcome,
_envCellRenderer,
_lighting),
ScriptOwners: _scripts.ActiveOwnerCount,
ActiveScripts: _scripts.ActiveScriptCount,
MeshRenderData: mesh.RenderData,
@ -203,4 +214,17 @@ internal sealed class WorldLifecycleResourceSnapshotSource
FrameMilliseconds: render.FrameMilliseconds,
LastFrameProfile: _frameProfiler.LastReport);
}
internal static EnvCellLightingConsumptionSnapshot? CaptureEnvCellLighting(
RenderFrameOutcome outcome,
EnvCellRenderer? renderer,
LightManager lighting)
{
ArgumentNullException.ThrowIfNull(lighting);
return renderer?.CaptureLightingConsumption(
lighting,
currentWorldFrame:
!outcome.SkippedZeroArea
&& outcome.World.NormalWorldDrawn);
}
}

View file

@ -0,0 +1,176 @@
using AcDream.Core.Lighting;
namespace AcDream.App.Rendering.Wb;
internal sealed record EnvCellLightingVectorBits(
uint X,
uint Y,
uint Z);
internal sealed record EnvCellLightingAmbientSnapshot(
EnvCellLightingVectorBits AmbientColor,
EnvCellLightingVectorBits SunColor,
EnvCellLightingVectorBits SunDirection);
internal sealed record EnvCellLightingSourceSnapshot(
int Index,
uint OwnerId,
uint CellId,
int Kind,
bool IsDynamic,
bool IsLit,
bool TracksOwnerPose,
EnvCellLightingVectorBits Position,
EnvCellLightingVectorBits Forward,
EnvCellLightingVectorBits Color,
uint Intensity,
uint Range,
uint ConeAngle,
uint DistSq,
uint[] LocalPose);
internal sealed record EnvCellLightingCellSetSnapshot(
uint CellId,
int[] Indices);
/// <summary>
/// Checkpoint-only copy of the point-light inputs the EnvCell RHI submission
/// already consumed. PackedGlobalLightBits is the authoritative binding-4
/// payload; Sources records its identity/provenance and CellSets records the
/// exact cached eight-index values copied to binding 5.
/// </summary>
internal sealed record EnvCellLightingConsumptionSnapshot(
bool HasCurrentEnvCellInput,
int FrameGeneration,
int FrameSlot,
int RegisteredLightCount,
int LogicalGlobalLightCount,
int UploadedGlobalLightCount,
uint[] PackedGlobalLightBits,
EnvCellLightingSourceSnapshot[] Sources,
EnvCellLightingCellSetSnapshot[] CellSets,
EnvCellLightingAmbientSnapshot Ambient);
public sealed partial class EnvCellRenderer
{
/// <summary>
/// Copies the retained inputs from this frame's completed EnvCell submission
/// for an explicitly requested lifecycle checkpoint. This method never
/// rebuilds a light snapshot or invokes either cell selector.
/// </summary>
internal EnvCellLightingConsumptionSnapshot CaptureLightingConsumption(
LightManager lighting,
bool currentWorldFrame)
{
ArgumentNullException.ThrowIfNull(lighting);
EnvCellLightingAmbientSnapshot ambient = CaptureAmbient(
lighting.CurrentAmbient);
if (!currentWorldFrame)
return EmptyLightingConsumption(lighting.RegisteredCount, ambient);
List<KeyValuePair<uint, CachedCellLightSet>> currentCells = [];
foreach (KeyValuePair<uint, CachedCellLightSet> entry in _cellLightSetCache)
{
if (entry.Value.FrameGeneration == _lightFrameGeneration)
currentCells.Add(entry);
}
currentCells.Sort(static (left, right) => left.Key.CompareTo(right.Key));
if (currentCells.Count == 0)
return EmptyLightingConsumption(lighting.RegisteredCount, ambient);
IReadOnlyList<LightSource>? pointSnapshot = _pointSnapshot;
int logicalCount = pointSnapshot?.Count ?? 0;
int uploadedCount = Math.Max(logicalCount, 1);
int packedFloatCount = checked(
uploadedCount * GlobalLightPacker.FloatsPerLight);
var packedBits = new uint[packedFloatCount];
for (int index = 0; index < packedFloatCount; index++)
packedBits[index] = Bits(_globalLightData[index]);
var sources = new EnvCellLightingSourceSnapshot[logicalCount];
for (int index = 0; index < logicalCount; index++)
sources[index] = CaptureSource(index, pointSnapshot![index]);
var cellSets = new EnvCellLightingCellSetSnapshot[currentCells.Count];
for (int index = 0; index < currentCells.Count; index++)
{
KeyValuePair<uint, CachedCellLightSet> entry = currentCells[index];
cellSets[index] = new EnvCellLightingCellSetSnapshot(
entry.Key,
(int[])entry.Value.Indices.Clone());
}
return new EnvCellLightingConsumptionSnapshot(
HasCurrentEnvCellInput: true,
FrameGeneration: _lightFrameGeneration,
FrameSlot: _dynamicFrameSlot,
RegisteredLightCount: lighting.RegisteredCount,
LogicalGlobalLightCount: logicalCount,
UploadedGlobalLightCount: uploadedCount,
PackedGlobalLightBits: packedBits,
Sources: sources,
CellSets: cellSets,
Ambient: ambient);
}
private EnvCellLightingConsumptionSnapshot EmptyLightingConsumption(
int registeredLightCount,
EnvCellLightingAmbientSnapshot ambient) =>
new(
HasCurrentEnvCellInput: false,
FrameGeneration: _lightFrameGeneration,
FrameSlot: _dynamicFrameSlot,
RegisteredLightCount: registeredLightCount,
LogicalGlobalLightCount: 0,
UploadedGlobalLightCount: 0,
PackedGlobalLightBits: [],
Sources: [],
CellSets: [],
Ambient: ambient);
private static EnvCellLightingSourceSnapshot CaptureSource(
int index,
LightSource source) =>
new(
Index: index,
OwnerId: source.OwnerId,
CellId: source.CellId,
Kind: (int)source.Kind,
IsDynamic: source.IsDynamic,
IsLit: source.IsLit,
TracksOwnerPose: source.TracksOwnerPose,
Position: CaptureVector(source.WorldPosition),
Forward: CaptureVector(source.WorldForward),
Color: CaptureVector(source.ColorLinear),
Intensity: Bits(source.Intensity),
Range: Bits(source.Range),
ConeAngle: Bits(source.ConeAngle),
DistSq: Bits(source.DistSq),
LocalPose:
[
Bits(source.LocalPose.M11), Bits(source.LocalPose.M12),
Bits(source.LocalPose.M13), Bits(source.LocalPose.M14),
Bits(source.LocalPose.M21), Bits(source.LocalPose.M22),
Bits(source.LocalPose.M23), Bits(source.LocalPose.M24),
Bits(source.LocalPose.M31), Bits(source.LocalPose.M32),
Bits(source.LocalPose.M33), Bits(source.LocalPose.M34),
Bits(source.LocalPose.M41), Bits(source.LocalPose.M42),
Bits(source.LocalPose.M43), Bits(source.LocalPose.M44),
]);
private static EnvCellLightingAmbientSnapshot CaptureAmbient(
CellAmbientState ambient) =>
new(
CaptureVector(ambient.AmbientColor),
CaptureVector(ambient.SunColor),
CaptureVector(ambient.SunDirection));
private static EnvCellLightingVectorBits CaptureVector(
System.Numerics.Vector3 value) =>
new(Bits(value.X), Bits(value.Y), Bits(value.Z));
private static uint Bits(float value) =>
BitConverter.SingleToUInt32Bits(value);
}

View file

@ -7,6 +7,7 @@ using AcDream.App.Rendering.Wb;
using AcDream.App.Tests.Architecture;
using AcDream.App.Update;
using AcDream.App.World;
using AcDream.Core.Lighting;
namespace AcDream.App.Tests.Composition;
@ -77,6 +78,52 @@ public sealed class FrameRootCompositionTests
Assert.True(secondTransfer > firstTransfer);
}
[Fact]
public void ProductionLightingCheckpointReceivesCanonicalRendererAndManager()
{
MethodInfo compose = typeof(FrameRootCompositionPhase).GetMethod(
"ComposeCore",
BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new MissingMethodException(
typeof(FrameRootCompositionPhase).FullName,
"ComposeCore");
IReadOnlyList<CompiledCall> calls = CompiledCallGraph.Read(compose);
int constructor = CallIndex(
calls,
typeof(WorldLifecycleResourceSnapshotSource),
".ctor");
int renderer = calls
.Select((call, index) => (call, index))
.Last(pair => pair.index < constructor
&& pair.call.Target.DeclaringType == typeof(LivePresentationResult)
&& pair.call.Target.Name == "get_EnvCellRenderer")
.index;
int lighting = calls
.Select((call, index) => (call, index))
.Last(pair => pair.index < constructor
&& pair.call.Target.DeclaringType == typeof(FrameRootDependencies)
&& pair.call.Target.Name == "get_Lighting")
.index;
int dispatcher = calls
.Select((call, index) => (call, index))
.Last(pair => pair.index < constructor
&& pair.call.Target.DeclaringType == typeof(LivePresentationResult)
&& pair.call.Target.Name == "get_DrawDispatcher")
.index;
int profiler = calls
.Select((call, index) => (call, index))
.Last(pair => pair.index < constructor
&& pair.call.Target.DeclaringType == typeof(FrameRootDependencies)
&& pair.call.Target.Name == "get_FrameProfiler")
.index;
Assert.Equal(dispatcher + 1, renderer);
Assert.Equal(renderer + 1, lighting);
Assert.Equal(lighting + 1, profiler);
Assert.True(profiler < constructor);
}
[Fact]
public void ProductionWiresTheOptionalScreenshotAdapterIntoTheOuterRenderOwner()
{
@ -149,6 +196,19 @@ public sealed class FrameRootCompositionTests
BindingFlags.Instance | BindingFlags.NonPublic),
field => field.FieldType == typeof(GameWindow));
FieldInfo[] snapshotFields = typeof(WorldLifecycleResourceSnapshotSource)
.GetFields(BindingFlags.Instance | BindingFlags.NonPublic);
Assert.Contains(snapshotFields, field => field.FieldType == typeof(EnvCellRenderer));
Assert.Contains(snapshotFields, field => field.FieldType == typeof(LightManager));
ParameterInfo[] snapshotParameters = typeof(WorldLifecycleResourceSnapshotSource)
.GetConstructors().Single().GetParameters();
Assert.Contains(snapshotParameters,
parameter => parameter.ParameterType == typeof(EnvCellRenderer)
&& !parameter.HasDefaultValue);
Assert.Contains(snapshotParameters,
parameter => parameter.ParameterType == typeof(LightManager)
&& !parameter.HasDefaultValue);
MethodInfo capture = typeof(WorldLifecycleResourceSnapshotSource)
.GetMethod(nameof(WorldLifecycleResourceSnapshotSource.Capture))!;
Assert.Equal(
@ -167,6 +227,12 @@ public sealed class FrameRootCompositionTests
calls,
call => call.Target.DeclaringType == typeof(FrameProfiler)
&& call.Target.Name == "get_LastReport");
Assert.Contains(
calls,
call => call.Target.DeclaringType
== typeof(WorldLifecycleResourceSnapshotSource)
&& call.Target.Name == nameof(
WorldLifecycleResourceSnapshotSource.CaptureEnvCellLighting));
Assert.Contains(
calls,
call => call.Target.DeclaringType == typeof(WorldRenderFrameOutcome)

View file

@ -4,6 +4,7 @@ using AcDream.App.Rendering;
using AcDream.App.Rendering.Packs;
using AcDream.App.Rendering.Residency;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Wb;
using AcDream.App.Streaming;
using AcDream.App.UI.Testing;
using AcDream.Runtime;
@ -412,6 +413,60 @@ public sealed class WorldLifecycleAutomationControllerTests
{
LoadedLandblocks = 1,
WorldEntities = 42,
EnvCellLighting = new EnvCellLightingConsumptionSnapshot(
HasCurrentEnvCellInput: true,
FrameGeneration: 31,
FrameSlot: 2,
RegisteredLightCount: 214,
LogicalGlobalLightCount: 1,
UploadedGlobalLightCount: 1,
PackedGlobalLightBits:
[
0x3F800000u, 0x40000000u, 0x40400000u, 0x3F800000u,
0u, 0u, 0x3F800000u, 0x41200000u,
0x3DCCCCCDu, 0x3E4CCCCDu, 0x3E99999Au, 0x40000000u,
0u, 0x3F800000u, 0u, 0u,
],
Sources:
[
new EnvCellLightingSourceSnapshot(
Index: 0,
OwnerId: 0x5000000Au,
CellId: 0x00070143u,
Kind: 1,
IsDynamic: true,
IsLit: true,
TracksOwnerPose: true,
Position: new EnvCellLightingVectorBits(
0x3F800000u, 0x40000000u, 0x40400000u),
Forward: new EnvCellLightingVectorBits(
0u, 0u, 0x3F800000u),
Color: new EnvCellLightingVectorBits(
0x3DCCCCCDu, 0x3E4CCCCDu, 0x3E99999Au),
Intensity: 0x40000000u,
Range: 0x41200000u,
ConeAngle: 0u,
DistSq: 0x42F60000u,
LocalPose:
[
0x3F800000u, 0u, 0u, 0u,
0u, 0x3F800000u, 0u, 0u,
0u, 0u, 0x3F800000u, 0u,
0u, 0u, 0u, 0x3F800000u,
]),
],
CellSets:
[
new EnvCellLightingCellSetSnapshot(
0x00070143u,
[0, -1, -1, -1, -1, -1, -1, -1]),
],
Ambient: new EnvCellLightingAmbientSnapshot(
new EnvCellLightingVectorBits(
0x3E4CCCCDu, 0x3E4CCCCDu, 0x3E4CCCCDu),
new EnvCellLightingVectorBits(0u, 0u, 0u),
new EnvCellLightingVectorBits(
0xBF800000u, 0x3F000000u, 0x3E800000u))),
RenderSceneOracle = new CurrentRenderSceneOracleSnapshot(
Enabled: true,
CompletedFrameSequence: 19,
@ -530,6 +585,28 @@ public sealed class WorldLifecycleAutomationControllerTests
Assert.True(json.RootElement.GetProperty("render").GetProperty("presentation")
.GetProperty("screenshotCaptured").GetBoolean());
Assert.Equal(42, json.RootElement.GetProperty("resources").GetProperty("worldEntities").GetInt32());
JsonElement envCellLighting = json.RootElement
.GetProperty("resources")
.GetProperty("envCellLighting");
Assert.True(envCellLighting
.GetProperty("hasCurrentEnvCellInput").GetBoolean());
Assert.Equal(31, envCellLighting.GetProperty("frameGeneration").GetInt32());
Assert.Equal(214, envCellLighting
.GetProperty("registeredLightCount").GetInt32());
Assert.Equal(0x3F800000u, envCellLighting
.GetProperty("sources")[0]
.GetProperty("position")
.GetProperty("x").GetUInt32());
Assert.Equal(0x00070143u, envCellLighting
.GetProperty("cellSets")[0]
.GetProperty("cellId").GetUInt32());
Assert.Equal(-1, envCellLighting
.GetProperty("cellSets")[0]
.GetProperty("indices")[7].GetInt32());
Assert.Equal(0xBF800000u, envCellLighting
.GetProperty("ambient")
.GetProperty("sunDirection")
.GetProperty("x").GetUInt32());
JsonElement renderSceneOracle = json.RootElement
.GetProperty("resources")
.GetProperty("renderSceneOracle");

View file

@ -18,16 +18,19 @@
// over the same RecordingGpuDevice, plus a no-op IPreparedAssetSource.
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Numerics;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Threading;
using AcDream.App.Rendering;
using AcDream.App.Diagnostics;
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.Lighting;
using AcDream.Core.Meshing;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
@ -37,6 +40,201 @@ namespace AcDream.App.Tests.Rendering.Wb;
public class EnvCellRendererTests
{
[Fact]
public void LightingCheckpoint_ReportsActualBoundInputsAndRejectsStaleFrames()
{
const uint landblockId = 0x8C04FFFFu;
const uint cellA = 0x8C040101u;
const uint cellB = 0x8C040102u;
using var device = new RecordingGpuDevice();
using var meshManager = CreateMeshManager(device);
var frameLifetime = new GpuDeviceFrameLifetime(device);
var scope = new VulkanWorldPassScope(sampleCount: 1);
using var renderer = new EnvCellRenderer(
device,
frameLifetime,
scope,
meshManager,
new WbFrustum());
renderer.CommitLandblock(new EnvCellLandblockBuild(
landblockId,
[],
[
LightingShell(cellA, 1, new Vector3(-1f), new Vector3(1f)),
LightingShell(cellB, 2, new Vector3(99f, -1f, -1f),
new Vector3(101f, 1f, 1f)),
]));
var lighting = new LightManager
{
CurrentAmbient = new CellAmbientState(
new Vector3(0.2f, 0.3f, 0.4f),
new Vector3(0.5f, 0.6f, 0.7f),
new Vector3(-0.1f, 0.8f, -0.9f)),
};
var dynamicLight = new LightSource
{
Kind = LightKind.Spot,
WorldPosition = new Vector3(50f, 60f, 70f),
WorldForward = new Vector3(0.25f, -0.5f, 0.75f),
ColorLinear = new Vector3(0.11f, 0.22f, 0.33f),
Intensity = 4.5f,
Range = 1.25f,
ConeAngle = 0.625f,
OwnerId = 0x50000001u,
CellId = cellA,
IsDynamic = true,
IsLit = true,
TracksOwnerPose = true,
DistSq = 123.5f,
LocalPose = new Matrix4x4(
1f, 2f, 3f, 4f,
5f, 6f, 7f, 8f,
9f, 10f, 11f, 12f,
13f, 14f, 15f, 16f),
};
var staticA = new LightSource
{
Kind = LightKind.Point,
WorldPosition = Vector3.Zero,
WorldForward = Vector3.UnitX,
ColorLinear = new Vector3(0.9f, 0.1f, 0.2f),
Intensity = 2f,
Range = 4f,
OwnerId = 0x50000002u,
CellId = cellA,
IsLit = true,
};
var staticB = new LightSource
{
Kind = LightKind.Point,
WorldPosition = new Vector3(100f, 0f, 0f),
WorldForward = Vector3.UnitY,
ColorLinear = new Vector3(0.2f, 0.8f, 0.4f),
Intensity = 3f,
Range = 4f,
OwnerId = 0x50000003u,
CellId = cellB,
IsLit = true,
};
lighting.Register(dynamicLight);
lighting.Register(staticA);
lighting.Register(staticB);
lighting.BuildPointLightSnapshot(Vector3.Zero);
renderer.SetPointSnapshot(lighting.PointSnapshot);
frameLifetime.BeginFrame();
IGpuFrame frame = frameLifetime.CurrentFrame!;
using IGpuPassEncoder pass = frame.BeginPass(
GpuPassDescription.BackbufferClear(
"envcell-lighting-observation",
Vector4.Zero,
sampleCount: 1));
using IDisposable publication = scope.Publish(pass);
renderer.BeginFrame(frame.SlotIndex);
SeedLightingSubmission(renderer, instanceCount: 2);
// Deliberately submit in the opposite order from deterministic JSON's
// outer cell-id ordering. Each exported set must still match the exact
// binding-5 block associated with its submitted cell.
var instances = new List<InstanceData>
{
new() { Transform = Matrix4x4.Identity, CellId = cellB },
new() { Transform = Matrix4x4.Identity, CellId = cellA },
};
device.Clear();
InvokeSubmitRhi(renderer, instances, instanceCount: 2);
EnvCellLightingConsumptionSnapshot snapshot =
renderer.CaptureLightingConsumption(lighting, currentWorldFrame: true);
Assert.True(snapshot.HasCurrentEnvCellInput);
Assert.Equal(frame.SlotIndex, snapshot.FrameSlot);
Assert.Equal(3, snapshot.RegisteredLightCount);
Assert.Equal(3, snapshot.LogicalGlobalLightCount);
Assert.Equal(3, snapshot.UploadedGlobalLightCount);
Assert.Equal([cellA, cellB], snapshot.CellSets.Select(set => set.CellId));
GpuRecordedStorageBind globalBind = Assert.Single(
device.Calls.OfType<GpuRecordedStorageBind>(),
bind => bind.Binding == GpuBindingModel.StorageGlobalLights);
uint[] actualGlobalBits = MemoryMarshal.Cast<byte, uint>(
device.RingBytes.Slice(
(int)globalBind.OffsetBytes,
(int)globalBind.SizeBytes)).ToArray();
Assert.Equal(actualGlobalBits, snapshot.PackedGlobalLightBits);
GpuRecordedStorageBind setBind = Assert.Single(
device.Calls.OfType<GpuRecordedStorageBind>(),
bind => bind.Binding == GpuBindingModel.StorageInstanceLightSets);
int[] actualSets = MemoryMarshal.Cast<byte, int>(
device.RingBytes.Slice(
(int)setBind.OffsetBytes,
(int)setBind.SizeBytes)).ToArray();
Assert.Equal(
actualSets.AsSpan(0, LightManager.MaxLightsPerObject).ToArray(),
Assert.Single(snapshot.CellSets, set => set.CellId == cellB).Indices);
Assert.Equal(
actualSets.AsSpan(
LightManager.MaxLightsPerObject,
LightManager.MaxLightsPerObject).ToArray(),
Assert.Single(snapshot.CellSets, set => set.CellId == cellA).Indices);
Assert.Equal([0, 1, -1, -1, -1, -1, -1, -1],
Assert.Single(snapshot.CellSets, set => set.CellId == cellA).Indices);
Assert.Equal([0, 2, -1, -1, -1, -1, -1, -1],
Assert.Single(snapshot.CellSets, set => set.CellId == cellB).Indices);
EnvCellLightingSourceSnapshot source = snapshot.Sources[0];
Assert.Equal(0, source.Index);
Assert.Equal(dynamicLight.OwnerId, source.OwnerId);
Assert.Equal(dynamicLight.CellId, source.CellId);
Assert.Equal((int)LightKind.Spot, source.Kind);
Assert.True(source.IsDynamic);
Assert.True(source.IsLit);
Assert.True(source.TracksOwnerPose);
Assert.Equal(BitConverter.SingleToUInt32Bits(50f), source.Position.X);
Assert.Equal(BitConverter.SingleToUInt32Bits(-0.5f), source.Forward.Y);
Assert.Equal(BitConverter.SingleToUInt32Bits(0.33f), source.Color.Z);
Assert.Equal(BitConverter.SingleToUInt32Bits(16f), source.LocalPose[15]);
RenderFrameOutcome nonWorld = new(
new WorldRenderFrameOutcome(0, 0, NormalWorldDrawn: false),
default);
AssertNoCurrentLighting(WorldLifecycleResourceSnapshotSource
.CaptureEnvCellLighting(nonWorld, renderer, lighting)!);
AssertNoCurrentLighting(WorldLifecycleResourceSnapshotSource
.CaptureEnvCellLighting(RenderFrameOutcome.ZeroArea, renderer, lighting)!);
// A real zero-light submission still binds one all-zero dummy light.
var emptyLighting = new LightManager();
renderer.BeginFrame(frame.SlotIndex);
renderer.SetPointSnapshot(emptyLighting.PointSnapshot);
device.Clear();
InvokeSubmitRhi(renderer, [instances[0]], instanceCount: 1);
EnvCellLightingConsumptionSnapshot empty =
renderer.CaptureLightingConsumption(emptyLighting, currentWorldFrame: true);
Assert.True(empty.HasCurrentEnvCellInput);
Assert.Equal(0, empty.LogicalGlobalLightCount);
Assert.Equal(1, empty.UploadedGlobalLightCount);
Assert.Equal(GlobalLightPacker.FloatsPerLight, empty.PackedGlobalLightBits.Length);
Assert.All(empty.PackedGlobalLightBits, bits => Assert.Equal(0u, bits));
GpuRecordedStorageBind emptyGlobalBind = Assert.Single(
device.Calls.OfType<GpuRecordedStorageBind>(),
bind => bind.Binding == GpuBindingModel.StorageGlobalLights);
Assert.Equal(
MemoryMarshal.Cast<byte, uint>(device.RingBytes.Slice(
(int)emptyGlobalBind.OffsetBytes,
(int)emptyGlobalBind.SizeBytes)).ToArray(),
empty.PackedGlobalLightBits);
// BeginFrame advances the existing cache generation. Without a draw,
// the following checkpoint cannot reuse the preceding frame's cell set.
renderer.BeginFrame(frame.SlotIndex);
AssertNoCurrentLighting(
renderer.CaptureLightingConsumption(emptyLighting, currentWorldFrame: true));
}
[Theory]
[InlineData(CullMode.Landblock)]
[InlineData(CullMode.None)]
@ -555,6 +753,85 @@ public class EnvCellRendererTests
Assert.Empty(b);
}
private static EnvCellShellPlacement LightingShell(
uint cellId,
ulong geometryId,
Vector3 minimum,
Vector3 maximum) =>
new(
CellId: cellId,
GeometryId: geometryId,
EnvironmentId: 0x0D000001u,
CellStructure: 1,
Surfaces: ImmutableArray<ushort>.Empty,
WorldPosition: Vector3.Zero,
Rotation: Quaternion.Identity,
Transform: Matrix4x4.Identity,
LocalBounds: new WbBoundingBox(minimum, maximum),
WorldBounds: new WbBoundingBox(minimum, maximum));
private static void SeedLightingSubmission(
EnvCellRenderer renderer,
int instanceCount)
{
Type rendererType = typeof(EnvCellRenderer);
rendererType.GetField(
"_commands", BindingFlags.NonPublic | BindingFlags.Instance)!
.SetValue(renderer, new[]
{
new DrawElementsIndirectCommand
{
Count = 3,
InstanceCount = (uint)instanceCount,
FirstIndex = 0,
BaseVertex = 0,
BaseInstance = 0,
},
});
rendererType.GetField(
"_modernBatches", BindingFlags.NonPublic | BindingFlags.Instance)!
.SetValue(renderer, new ModernBatchData[] { default });
var ranges = (List<EnvCellRenderer.MdiDrawRange>)rendererType.GetField(
"_mdiDrawRanges", BindingFlags.NonPublic | BindingFlags.Instance)!
.GetValue(renderer)!;
ranges.Clear();
EnvCellRenderer.AppendMdiDrawRange(
ranges,
groupIndex: 0,
firstCommand: 0,
commandCount: 1,
RetailSetSurfaceMaterialState.Opaque);
}
private static void InvokeSubmitRhi(
EnvCellRenderer renderer,
List<InstanceData> instances,
int instanceCount)
{
typeof(EnvCellRenderer).GetMethod(
"SubmitRhi", BindingFlags.NonPublic | BindingFlags.Instance)!
.Invoke(
renderer,
new object[]
{
instances,
WbRenderPass.Opaque,
1,
instanceCount,
});
}
private static void AssertNoCurrentLighting(
EnvCellLightingConsumptionSnapshot snapshot)
{
Assert.False(snapshot.HasCurrentEnvCellInput);
Assert.Equal(0, snapshot.LogicalGlobalLightCount);
Assert.Equal(0, snapshot.UploadedGlobalLightCount);
Assert.Empty(snapshot.PackedGlobalLightBits);
Assert.Empty(snapshot.Sources);
Assert.Empty(snapshot.CellSets);
}
// -----------------------------------------------------------------------
// Prepare gate (2026-07-24) — pure camera-tolerance half.
// The tolerance must swallow the ~36 µm eye rest jitter (RetailPViewRenderer