fix(render): repair particle CYpt preparation

Retain the GfxObj-authored SortCenter independently from the rendered AABB center and use its scaled, oriented world point for billboard/mode-2-5 CYpt. Keep the visual center unchanged.

Reserve prepared source payload only at Append, roll back the exact tail once on false or exception, cap retained preparation at 3000 CLIP plus 3000 ALPHA, preserve immediate duplicates, account the bounded scratch, and correct the static-record ordering comment. The lead-authorized plan edit records candidate 44e2bc227b's retail FAIL and this section 19 result.

Gates: Release solution build 0 warnings/0 errors; focused particle/driver/queue/order/bounds 95/95; register boundary/count 1/1; real allocations 2/2 at 0 B; shader/manifest 32/32; one-shot hermetic 16751/16751; InstalledDat 385 pass/10 documented fail/1 skip; diff-check pass. No graphical client.

Mutations: rendered pos first failed distance expected 40 actual 116; AABB center expected 40 actual 116; dropped size expected 40 actual 65; dropped orientation expected 40 actual 160; eager reservation first failed Assert.Empty with one payload; omitted false rollback first failed Assert.Single with two payloads; omitted exception rollback first failed Assert.Empty with one payload; removed caps first failed prepared count expected 6000 actual 6002.

Evidence note: the first ordinary InstalledDat filter produced 385 pass/2 expected fail/1 skip but excluded the eight required KnownFailure rows; the separate inclusive no-retry artifact produced the exact documented 10-failure set. Initial test fixture compilation/analyzer setup failures are recorded in packet section 19.5.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-04 13:57:19 +02:00
parent 8e0c6fb145
commit 01674bcc78
7 changed files with 727 additions and 32 deletions

View file

@ -678,7 +678,7 @@ Update immediately when a slice changes state. Chat is not the ledger.
| S4-c2 stop | **STOPPED UNLANDED 2026-09-04** — lead final-round contract `ac74fbd84`; evidence commit `c4cbc1d0d` in `s4-c2-impl` on `cc8e5677a`/`048d5b12f`; campaign renderer remains S4-c1 `766f9e749` | G3/G4 UNPASSED | Both sequential final lenses FAIL, so the required repair is the forbidden THIRD round. EnvCell transparent/ClipMap batches still draw in the opaque pass before FIFO submission and then redraw; CLIP/detail-immediate replay binds StraightAlpha with depth-write off rather than retail clip-test/depth-writing state; tests omit the preceding opaque call and pipeline descriptors. Source-owned EnvCell lists are unbounded under queue rejection, the claimed scan/filter/RHI 0-B evidence bypasses those paths, and AP-238/AP-240/result truth is false. Full file:line and gate record: S4 packet §13. No chunk cherry-pick, graphical gate, or soak. Resume only on explicit owner process change. |
| S4-c2 owner repair | **STOPPED UNLANDED 2026-09-04** — owner-authorized implementation `a094bf2b7` is clean in `s4-c2-impl`; packet §15 is the review record | G3/G4 UNPASSED | The repaired renderer behavior passed the retail lens: exact whole-leaf pass membership, `0x08`/`0x09` CLIP, row-3 override, blend/reference/depth state, sentinel separation, detail state, rollback, allocation, AP-238/AP-240. The lens still returned FAIL: commit body omitted the required per-mutation first failures, packet mutation 5 names the wrong first assertion/address, and four source/test comments are stale. Sequential production lens therefore did not run. Correcting those items is a post-review fourth repair, which §14 does not authorize. Nothing landed or graphically gated; resume only by explicit owner process decision. |
| S4-c2 attempts 15 | **ATTEMPT 1/5 CONSUMED; ATTEMPT 2/5 PASSED AND LANDED 2026-09-04** — reviewed implementation `daaeab0ba`; campaign stack `89f1e2676``9ccb61a8e``0aa166aa0``252886e84`; packet §17 | G3/G4 UNPASSED | Sequential retail/evidence then production/gate-honesty PASS. Fresh: Release 0W/0E; shader 32/32; affected 37/37; real allocation 2/2 at 0 B; production 239/239. Official no-retry hermetic 16,734/1: sole unchanged global `Console.Out` capture race, exact theory 2/2 isolated. InstalledDat 255/10 documented/1. Validation routes `selfgate-20260904-115818-s4c2-landed-route` and `selfgate-20260904-120025-s4c2-landed-g3b` PASS provisional. First soak failed on known #461/#462; retry and S4-c1 control both 9/9 graceful; stationary averages CPU p95 0.01 ms, GPU p95 +0.16 ms, alloc p50 +1.2 KiB, update p95 0.12 ms. Three attempts remain unused. |
| S4-c3a | **STOPPED UNLANDED 2026-09-04 — owner attempts 35 exhausted; packet §19.** Scratch stack `44e2bc227b``b6bf6c131``eea5793d2``14397b14c``359061b82`; campaign renderer remains S4-c2 through `252886e84` and only c3a's contract `5c9da6fc2` is landed | G3/G4 UNPASSED | Attempt 3 retail FAIL: billboard particles used rendered/AABB center, plus stale prose. Attempt 4 retail re-review FAIL: real GfxObj with no Surface lost SortCenter. Attempt 5 retail PASS and production behavior/lifecycle/allocation/scope all clean, but gate-honesty FAIL: mutation #9 falsely says missing AP-241 first fails `Assert.Single`; the earlier AP-boundary `Assert.True` fails first. Correcting it is the forbidden third c3a fix round. No landing, graphical gate, c3b, or G3; resume only after explicit owner process change. |
| S4-c3a | **ATTEMPT 3 RETAIL FAIL; §19 FIX ROUND 1 IMPLEMENTED — REVIEW PENDING.** Candidate `44e2bc227b` used the billboard's rendered/AABB-derived center instead of GfxObj's authored sort center, reserved prepared payload before queue visibility, did not bound the new retained preparation list, and misstated static-record ordering. The §19 repair now keeps authored and visual centers distinct, applies scale/orientation to the authored CYpt point, reserves and exactly rolls back at Append, caps preparation at 3,000 CLIP + 3,000 ALPHA, preserves immediate duplicates, and corrects the comment. Return gates: Release 0W/0E; focused 95/95; register pin 1/1; real allocation 2/2 at 0 B; shader/manifest 32/32; hermetic 16,751/16,751; InstalledDat 385 pass / 10 expected fail / 1 skip with the exact documented set; eight discriminating mutations recorded in packet §19.5. | G3/G4 UNPASSED | Retail re-review F1/paired bytes/F3 and unchanged S4-c2 behavior, then production/gate-honesty review of the full c3a stack with special attention to F2. One bounded fix round remains after §19; a further finding stops the chunk. c3b remains locked. |
| S5 | — | G4 | fill |
---

View file

@ -1546,3 +1546,86 @@ unchanged S4-c2 behavior first. Only a retail PASS unlocks the sequential
production/gate-honesty lens over the entire c3a stack, with special attention
to F2. A failed re-review gets the one remaining bounded fix round; a further
finding after that would require a third round and stops the chunk.
### 19.5 Fix-round-1 implementation result (Sonnet, 2026-09-04)
**IMPLEMENTED — RETAIL RE-REVIEW REQUIRED.** This fix commit retains the
GfxObj-authored `SortCenter` separately from the rendered AABB-derived
`CenterOffset`. Billboard/mode-25 CYpt now uses the authored point after the
particle's scale and orientation, while the captured draw center remains the
existing visual center. The prepared-cell seam now reserves source payload
only from `Append`, rolls back the exact tail once on either a false return or
an exception, retains at most the first 3,000 CLIP and 3,000 ALPHA candidates,
and accounts for the bounded scratch list in its retained-byte diagnostic.
The row-2 immediate duplicate remains independent of delayed retention. The
static-record comment now describes the later ordinary-cell combined CYpt
sort.
The lead separately authorized one plan-ledger edit so the S4-c3a row records
candidate `44e2bc227b`'s retail FAIL and this §19 fix result. That file is
ledger currency only; it does not widen production scope. AP-241/AP-242/AP-243,
the active physical count of 162, both retail queues and their capacities,
router decisions, FIFO order, CLIP-then-ALPHA drain, row-5 immediate state,
pipelines, shaders, manifest, Content model, serializer, package format,
building shells, EnvCells, and PView behavior remain unchanged.
Automated return:
- `dotnet build AcDream.slnx -c Release --no-restore --nologo` — PASS, 0
warnings / 0 errors.
- focused `ParticleRendererRouteTests|WalkFrameDriverTests|RetailAlphaQueueTests|RetailParticleGeometryClassifierTests`
lane — PASS, 95/95, 0 skipped.
- `WorldAlphaCyptDocumentationAndRegister_PinPerCellTruthAndThreeResiduals`
— PASS, 1/1; all three residual rows remain unique inside the AP section and
the physical count remains 162.
- real warmed `ProductionCellObjectParticleMerge_WarmedPathAllocatesZeroBytes|ProductionImmediateMesh_WarmedDrawImmediateParticleSubmissionRhiDoesNotAllocate`
lane — PASS, 2/2; both production-path pins observed 0 managed bytes.
- `VulkanShaderDescriptorContractTests|VulkanShaderManifestTests|RenderPackSpirvValidatorTests`
— PASS, 32/32, 0 skipped.
- official one-shot 14-assembly hermetic gate — PASS, 16,751 passed / 0
skipped / 0 failed. Evidence:
`%TEMP%/acdream-s4-c3a-fix1-20260904-2215`.
- InstalledDat with explicit `C:\Turbine\Asheron's Call` and the documented
ordinary set plus TowerAscent, #458 `Oh_doorway_still_first_frame_diff`, and
the six retained `AlphaFlushCounts_*` — 385 passed / 10 expected failed / 1
skipped. The failures are exactly TowerAscent, the two #383 layout sweeps,
#458, and the six count rows; no new failure appeared. Evidence:
`%TEMP%/acdream-s4-c3a-fix1-installed-inclusive-20260904-2222`.
- `git diff --check` — PASS (only the repository's LF-to-CRLF working-copy
notices).
Mutation ledger (each mutation was applied alone, its named test was run once,
and the production source was restored before the next mutation):
1. Use rendered `pos` for billboard CYpt: `CellTurn_GfxObjBillboardUsesScaledOrientedAuthoredSortCenterButKeepsVisualCenter`
first failed its distance assertion, expected `40`, actual `116`.
2. Use the AABB-derived `CenterOffset`: the same test first failed its distance
assertion, expected `40`, actual `116`.
3. Drop particle size from authored SortCenter: the same test first failed its
distance assertion, expected `40`, actual `65`.
4. Drop particle orientation from authored SortCenter: the same test first
failed its distance assertion, expected `40`, actual `160`.
5. Restore eager source-payload reservation during preparation:
`PreparedCellAlpha_ImmediateExceptionAfterDelayedCandidateDoesNotReservePayload`
first failed `Assert.Empty`; the collection contained one
`DeferredParticleDraw`.
6. Omit rollback after `TryAppend` returns false:
`PreparedCellAlpha_AcceptsStableTokenAndRollsBackRejectedTailOnAbort` first
failed `Assert.Single`; the collection contained two payloads.
7. Omit rollback after `TryAppend` throws:
`PreparedCellAlpha_AppendExceptionRollsBackExactTailToken` first failed
`Assert.Empty`; the collection contained one payload.
8. Remove the two per-list preparation caps:
`PreparedCellAlpha_CapsBothListsAndRejectStormRetainsNoPayloadOrUnboundedScratch`
first failed the prepared-count assertion, expected `6000`, actual `6002`.
Before the return matrix, fixture construction exposed and corrected two
test-only setup issues: missing `DatReaderWriter.Lib`/`Types` imports plus a
nonexistent vector comparer caused the first compile attempt to fail, and
xUnit2013 rejected two count-shaped assertions. Neither failure reached a
production positive assertion. The first InstalledDat invocation used the
release guide's ordinary `Status!=KnownFailure` filter and honestly returned
385 passed / 2 expected failed / 1 skipped; that was incomplete §18.6 evidence
because it excluded TowerAscent, #458, and the six count rows. It was not
retried or rewritten: the separate inclusive artifact above is the required
documented-set gate. No graphical client was launched.

View file

@ -177,7 +177,18 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
internal long RetainedAlphaScratchBytes => checked(
(long)_deferredAlpha.Capacity * Unsafe.SizeOf<DeferredParticleDraw>()
+ (long)_preparedAlpha.Length * Unsafe.SizeOf<DeferredParticleDraw>()
+ (long)_preparedInstanceOffsets.Length * sizeof(uint));
+ (long)_preparedInstanceOffsets.Length * sizeof(uint)
+ (long)_preparedCellAlphaScratch.Capacity
* Unsafe.SizeOf<PreparedParticleAlphaSubmission>());
internal (int Count, int Capacity, long RetainedBytes)
PreparedCellAlphaScratchDiagnostics =>
(
_preparedCellAlphaScratch.Count,
_preparedCellAlphaScratch.Capacity,
checked((long)_preparedCellAlphaScratch.Capacity
* Unsafe.SizeOf<PreparedParticleAlphaSubmission>())
);
private sealed class AlphaDrawSource(ParticleRenderer owner) : IRetailAlphaDrawSource
{
@ -366,6 +377,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
ParticleSubmissionOrdering.Sort(_submissionScratch);
Matrix4x4 viewProjection = camera.View * camera.Projection;
RetailAlphaQueue queue = _alphaQueue!;
int retainedClipCount = 0;
int retainedAlphaCount = 0;
for (int i = 0; i < _submissionScratch.Count; i++)
{
ParticleSubmission submission = _submissionScratch[i];
@ -377,34 +390,27 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
: default;
RetailAlphaMeshDecision decision = RouteParticleSubmission(
submission.Kind, translucency, colorArgb);
PreparedCellAlphaActions actions = ResolvePreparedCellAlphaActions(
decision,
ref retainedClipCount,
ref retainedAlphaCount);
if (decision.Action is RetailAlphaMeshAction.Append
or RetailAlphaMeshAction.AppendClipAndImmediate)
if (actions.Retain)
{
_dispatchDeferredParticle = submission.Kind == ParticleSubmissionKind.Billboard
? new DeferredParticleDraw(
submission.Kind,
_drawListScratch[submission.DrawIndex],
default,
viewProjection)
: new DeferredParticleDraw(
submission.Kind,
default,
_meshDrawListScratch[submission.DrawIndex],
viewProjection);
int token = ReserveDispatchDeferredParticle();
_preparedCellAlphaScratch.Add(new PreparedParticleAlphaSubmission(
queue,
decision.List,
_alphaSource,
token,
this,
submission.Kind,
submission.DrawIndex,
viewProjection,
decision.OverrideClipmap,
submission.DistanceSq,
submission.Sequence));
}
if (decision.Action is RetailAlphaMeshAction.Immediate
or RetailAlphaMeshAction.AppendClipAndImmediate)
if (actions.DrawImmediate)
{
_drawImmediateParticle(
viewProjection,
@ -417,6 +423,46 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
return CollectionsMarshal.AsSpan(_preparedCellAlphaScratch);
}
internal readonly record struct PreparedCellAlphaActions(
bool Retain,
bool DrawImmediate);
/// <summary>
/// Applies the physical 3,000-entry bound independently to the prepared
/// CLIP and ALPHA lists. Row 2's immediate duplicate is deliberately
/// independent of retention: even candidate 3,001 still draws now.
/// </summary>
internal static PreparedCellAlphaActions ResolvePreparedCellAlphaActions(
RetailAlphaMeshDecision decision,
ref int retainedClipCount,
ref int retainedAlphaCount)
{
bool requestsRetention = decision.Action is RetailAlphaMeshAction.Append
or RetailAlphaMeshAction.AppendClipAndImmediate;
bool retain = false;
if (requestsRetention)
{
if (decision.List == RetailAlphaList.Clip)
{
if (retainedClipCount < RetailAlphaQueue.ListCapacity)
{
retainedClipCount++;
retain = true;
}
}
else if (retainedAlphaCount < RetailAlphaQueue.ListCapacity)
{
retainedAlphaCount++;
retain = true;
}
}
return new PreparedCellAlphaActions(
retain,
decision.Action is RetailAlphaMeshAction.Immediate
or RetailAlphaMeshAction.AppendClipAndImmediate);
}
private void FinishDraw(ICamera camera, ParticleRenderPass renderPass)
{
if (_submissionScratch.Count == 0)
@ -584,6 +630,39 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
return token;
}
internal int ReservePreparedDispatchDeferredParticle(
ParticleSubmissionKind kind,
int drawIndex,
Matrix4x4 viewProjection)
{
DeferredParticleDraw deferred = kind == ParticleSubmissionKind.Billboard
? new DeferredParticleDraw(
kind,
_drawListScratch[drawIndex],
default,
viewProjection)
: new DeferredParticleDraw(
kind,
default,
_meshDrawListScratch[drawIndex],
viewProjection);
int token = _deferredAlpha.Count;
_deferredAlpha.Add(deferred);
return token;
}
internal void RollbackPreparedDispatchDeferredParticle(int token)
{
int tail = _deferredAlpha.Count - 1;
if (token != tail)
{
throw new InvalidOperationException(
$"Prepared particle rollback must target tail token {tail}, not {token}.");
}
_deferredAlpha.RemoveAt(tail);
}
private void DrawOrdered(ICamera camera)
{
DrawOrderedRhi(camera);
@ -612,6 +691,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
_deferredAlpha.Count,
_preparedAlphaCount);
_deferredAlpha.Clear();
_preparedCellAlphaScratch.Clear();
_preparedAlphaCount = 0;
int currentCapacity = Math.Max(
_deferredAlpha.Capacity,
@ -718,6 +798,12 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
gfxInfo = ResolveParticleGfxInfo(em);
gfxInfoResolved = true;
}
Quaternion orientation = ParticleOrientation(em, p);
Vector3 authoredSortPoint = p.Position
+ Vector3.Transform(gfxInfo.SortCenter * p.Size, orientation);
float distSq = Vector3.DistanceSquared(
authoredSortPoint,
cameraWorldPos);
bool additive = gfxInfo.HasMaterial
? gfxInfo.Additive
: (em.Desc.Flags & EmitterFlags.Additive) != 0;
@ -761,7 +847,6 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
}
else
{
Quaternion orientation = ParticleOrientation(em, p);
if (RetailParticleFacing.Faces(gfxInfo.DegradeMode)
&& toViewerLength > 1e-3f)
{
@ -795,8 +880,6 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
}
}
float distSq = Vector3.DistanceSquared(pos, cameraWorldPos);
int drawIndex = draws.Count;
draws.Add(new ParticleDraw(
key,
@ -1013,6 +1096,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
_textures.AcquireParticleTexture(emitter.Handle, desc.TextureSurfaceId),
Vector2.One,
Vector3.Zero,
Vector3.Zero,
additive: (desc.Flags & EmitterFlags.Additive) != 0,
hasMaterial: false,
surfaceId: desc.TextureSurfaceId);
@ -1086,6 +1170,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
texture,
Vector2.One,
Vector3.Zero,
gfx.SortCenter,
additive,
hasMaterial,
surfaceId);
@ -1108,6 +1193,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
texture,
new Vector2(sx, sy),
center,
gfx.SortCenter,
additive,
hasMaterial,
surfaceId);
@ -1170,6 +1256,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
axisX,
axisY,
center,
gfx.SortCenter,
false,
additive,
hasMaterial,
@ -1271,6 +1358,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
_firstDegradeModeByGfxObj.Clear();
_meshBlendBySurface.Clear();
_deferredAlpha.Clear();
_preparedCellAlphaScratch.Clear();
}
/// <summary>
@ -1289,6 +1377,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
Vector3 AxisX,
Vector3 AxisY,
Vector3 CenterOffset,
Vector3 SortCenter,
bool IsBillboard,
bool Additive,
bool HasMaterial,
@ -1300,6 +1389,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
AcDream.App.Rendering.Gpu.GpuTextureSlot.Unassigned,
Vector2.One,
Vector3.Zero,
Vector3.Zero,
additive: false,
hasMaterial: false,
surfaceId: 0);
@ -1308,6 +1398,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
AcDream.App.Rendering.Gpu.GpuTextureSlot textureSlot,
Vector2 size,
Vector3 centerOffset,
Vector3 sortCenter,
bool additive,
bool hasMaterial,
uint surfaceId) =>
@ -1317,6 +1408,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
Vector3.UnitX,
Vector3.UnitY,
centerOffset,
sortCenter,
true,
additive,
hasMaterial,

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering.Wb;
namespace AcDream.App.Rendering;
@ -20,21 +21,42 @@ internal readonly record struct ParticleSubmission(
/// One scene-particle alpha record prepared at its owning cell turn but not
/// yet appended. S4-c3a lets <c>WalkFrameDriver</c> merge this retained CYpt
/// key with ordinary object parts before either source enters retail's FIFO
/// list. The source payload is reserved during preparation; <see
/// cref="RetailAlphaQueue.TryAppend"/> remains the sole visibility edge and
/// still owns capacity-drop cleanup by registering the source before a drop.
/// list. The source payload is retained here as reconstruction inputs only;
/// <see cref="Append"/> reserves it at the queue visibility edge and rolls
/// back the exact tail reservation when the queue rejects or throws.
/// </summary>
internal readonly record struct PreparedParticleAlphaSubmission(
RetailAlphaQueue Queue,
RetailAlphaList List,
IRetailAlphaDrawSource Source,
int Token,
ParticleRenderer Owner,
ParticleSubmissionKind Kind,
int DrawIndex,
Matrix4x4 ViewProjection,
bool OverrideClipmap,
float DistanceSq,
int Sequence)
{
internal void Append() =>
Queue.TryAppend(List, Source, Token, OverrideClipmap);
internal void Append()
{
int token = Owner.ReservePreparedDispatchDeferredParticle(
Kind,
DrawIndex,
ViewProjection);
bool accepted;
try
{
accepted = Queue.TryAppend(List, Source, token, OverrideClipmap);
}
catch
{
Owner.RollbackPreparedDispatchDeferredParticle(token);
throw;
}
if (!accepted)
Owner.RollbackPreparedDispatchDeferredParticle(token);
}
}
/// <summary>

View file

@ -14,9 +14,11 @@ namespace AcDream.App.Rendering.Walk;
/// <see cref="IWalkFrameWorldData"/>'s doc comment).
/// </summary>
/// <param name="Records">Already-classified <see cref="RenderProjectionRecord"/>s
/// for this turn's cell/building, in the SAME order they must enter the walk
/// stream (never re-sorted downstream — <see cref="WalkStaticStreamPopulator"/>'s
/// own contract). Campaign FW3.4a: a segment INTO <see cref="WalkProductionWorldData"/>'s
/// for this turn's cell/building, retaining authored traversal/registration
/// order. For ordinary cells, <see cref="WalkStaticStreamPopulator"/> builds
/// the combined static/dynamic part list and performs retail's per-cell CYpt
/// sort; these records are not final stream order. Campaign FW3.4a: a segment
/// INTO <see cref="WalkProductionWorldData"/>'s
/// per-frame arena, not a freshly allocated array — see that type's own doc
/// comment.</param>
/// <param name="TupleLandblockId">The clip-slot-resolving landblock id

View file

@ -190,6 +190,28 @@ public sealed class ParticleRendererRouteTests
Assert.Equal(RetailAlphaList.Alpha, decision.List);
}
[Fact]
public void PreparedCellCap_DropsOnlyRetentionAndPreservesRow2ImmediateDuplicate()
{
int retainedClip = RetailAlphaQueue.ListCapacity;
int retainedAlpha = 0;
var decision = new RetailAlphaMeshDecision(
RetailAlphaMeshAction.AppendClipAndImmediate,
RetailAlphaList.Clip,
OverrideClipmap: true);
ParticleRenderer.PreparedCellAlphaActions actions =
ParticleRenderer.ResolvePreparedCellAlphaActions(
decision,
ref retainedClip,
ref retainedAlpha);
Assert.False(actions.Retain);
Assert.True(actions.DrawImmediate);
Assert.Equal(RetailAlphaQueue.ListCapacity, retainedClip);
Assert.Equal(0, retainedAlpha);
}
[Fact]
public void ProductionImmediateMesh_WarmedDrawImmediateParticleSubmissionRhiDoesNotAllocate()
{

View file

@ -18,7 +18,9 @@ using AcDream.Core.World;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Lib;
using DatReaderWriter.Lib.IO;
using DatReaderWriter.Types;
using Microsoft.Extensions.Logging.Abstractions;
namespace AcDream.App.Tests.Rendering.Walk;
@ -37,6 +39,22 @@ namespace AcDream.App.Tests.Rendering.Walk;
/// </summary>
public sealed partial class WalkFrameDriverTests
{
[Fact]
public void WalkFrameStaticRecordsComment_StatesAuthoredInputOrderAndDownstreamCellSort()
{
string source = File.ReadAllText(Path.Combine(
WalkOracleTraceRepoRoot.Find(),
"src",
"AcDream.App",
"Rendering",
"Walk",
"WalkFrameDriver.cs"));
Assert.Contains("retaining authored traversal/registration", source, StringComparison.Ordinal);
Assert.Contains("not final stream order", source, StringComparison.Ordinal);
Assert.DoesNotContain("never re-sorted downstream", source, StringComparison.Ordinal);
}
// ── Shared ordered log: BOTH the fake leaf renderer and the fake trace
// write into ONE list, so a single sequence assertion proves the FULL
// interleave (stream flushes interleaved with sky/terrain/shell/punch/
@ -1953,6 +1971,144 @@ public sealed partial class WalkFrameDriverTests
fx.AlphaQueue.AbortFrame();
}
/// <summary>
/// S4-c3a fix round 1 F1: the real DAT-backed mode-2 path keeps the GfxObj
/// SortCenter distinct from its vertex-AABB visual center. The particle is
/// at (10,0,0), sized 2 and rotated +90 degrees around Z: authored
/// SortCenter (1,2,0) therefore lands at (6,2,0), distance² 40. The AABB
/// center (2,0,0) remains the viewer-facing draw center at (10,4,0).
/// The ordinary object at distance² 49 must win the cross-source merge.
/// </summary>
[Fact]
public void CellTurn_GfxObjBillboardUsesScaledOrientedAuthoredSortCenterButKeepsVisualCenter()
{
using var fx = new DispatcherFixture();
const uint cellId = 0x8C0400A1u;
const uint objectGfx = 0x02000CA1u;
const uint particleGfx = 0x01000CA1u;
const uint particleSurface = 0x08000CA1u;
const uint degradeId = 0x11000CA1u;
InjectRenderData(fx.Manager, objectGfx, MakeFlatMesh(
MakeBatch(0x08100CA1u, TranslucencyKind.AlphaBlend, 0, 0, 3, 1)));
var worldData = new FakeWorldData();
worldData.OutdoorStaticsByCell[cellId] = new WalkFrameStaticRecords(
new[] { MakeRecord(1, 0, new Vector3(7, 0, 0),
[new MeshRef(objectGfx, Matrix4x4.Identity)]) },
0x8C04u);
var gfx = new GfxObj
{
Id = particleGfx,
Flags = GfxObjFlags.HasDIDDegrade,
DIDDegrade = degradeId,
SortCenter = new Vector3(1, 2, 0),
Surfaces = { particleSurface },
VertexArray = new VertexArray
{
Vertices =
{
[0] = new SWVertex { Origin = new Vector3(1, 0, -1) },
[1] = new SWVertex { Origin = new Vector3(3, 0, 1) },
},
},
};
var degrade = new GfxObjDegradeInfo
{
Id = degradeId,
Degrades =
{
new GfxObjInfo
{
Id = particleGfx,
DegradeMode = 2u,
MaxDist = float.MaxValue,
},
},
};
var surface = new Surface
{
Id = particleSurface,
Type = SurfaceType.Base1Solid,
ColorValue = new DatReaderWriter.Types.ColorARGB
{
Alpha = 255,
Red = 255,
Green = 255,
Blue = 255,
},
};
using var dats = new NoopDatReaderWriter();
dats.Add(particleGfx, gfx);
dats.Add(degradeId, degrade);
dats.Add(particleSurface, surface);
using var textures = new TextureCache(fx.Device, dats);
var particles = new ParticleSystem(new EmitterDescRegistry(), new Random(42));
Quaternion orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f);
int handle = particles.SpawnEmitter(
new EmitterDesc
{
DatId = 0x32000CA1u,
Type = AcDream.Core.Vfx.ParticleType.Still,
GfxObjId = particleGfx,
MaxParticles = 1,
InitialParticles = 1,
LifetimeMin = 100f,
LifetimeMax = 100f,
StartAlpha = 1f,
EndAlpha = 1f,
StartSize = 2f,
EndSize = 2f,
Gravity = Vector3.Zero,
},
new Vector3(10, 0, 0),
orientation);
particles.UpdateEmitterOwnerCell(handle, cellId);
using var renderer = new ParticleRenderer(
fx.Device,
fx.FrameLifetime,
fx.Scope,
particles,
textures,
dats,
fx.MeshAdapter,
fx.AlphaQueue);
var leaf = new ProductionParticleLeaf(
particles, renderer, new IdentityCamera(), Vector3.Zero);
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, worldData);
var ctx = new TestContext();
IWalkEventSink sink = driver;
using DrawScope draw = fx.BeginDraw(beginAlpha: true);
renderer.BeginFrame(frameSlot: 0);
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
var activeViews = new WalkPortalView();
WalkCopyView.AppendFullViewportQuad(
activeViews, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
sink.OnLandscapeViews(activeViews);
sink.OnLandscapeCellTurn(cellId);
driver.EndFrame();
driver.Replay(draw.Frame, draw.Pass);
IList payload = DeferredParticlePayload(renderer);
object deferred = Assert.Single(payload.Cast<object>());
object billboard = deferred.GetType().GetProperty("Billboard")!.GetValue(deferred)!;
object instance = billboard.GetType().GetProperty("Instance")!.GetValue(billboard)!;
float authoredDistanceSq = (float)instance.GetType().GetField("DistanceSq")!.GetValue(instance)!;
Vector3 visualCenter = (Vector3)instance.GetType().GetField("Position")!.GetValue(instance)!;
Assert.Equal(40f, authoredDistanceSq, precision: 4);
Assert.True(
Vector3.Distance(new Vector3(10, 4, 0), visualCenter) < 1e-4f,
$"Visual center was {visualCenter}, expected <10, 4, 0>.");
Assert.NotEqual(new Vector3(6, 2, 0), visualCenter);
Assert.Equal(
[typeof(WbDrawDispatcher), typeof(ParticleRenderer)],
QueueAlphaEntries(fx.AlphaQueue)
.Select(static entry => entry.Source.GetType().DeclaringType));
fx.AlphaQueue.AbortFrame();
}
[Fact]
public void SeparateCellTurnsRemainCellMajorWhenLaterCellIsFarther()
{
@ -2073,11 +2229,316 @@ public sealed partial class WalkFrameDriverTests
Assert.Equal(0, allocated);
}
[Fact]
public void PreparedCellAlpha_AcceptsStableTokenAndRollsBackRejectedTailOnAbort()
{
using var fx = new DispatcherFixture();
const uint cellId = 0x8C0400B1u;
var particles = new ParticleSystem(new EmitterDescRegistry(), new Random(42));
int handle = particles.SpawnEmitter(
new EmitterDesc
{
DatId = 0x32000CB1u,
Type = AcDream.Core.Vfx.ParticleType.Still,
MaxParticles = 2,
InitialParticles = 2,
LifetimeMin = 100f,
LifetimeMax = 100f,
StartAlpha = 1f,
EndAlpha = 1f,
Gravity = Vector3.Zero,
},
new Vector3(20, 0, 0));
particles.UpdateEmitterOwnerCell(handle, cellId);
using var renderer = new ParticleRenderer(
fx.Device,
fx.FrameLifetime,
fx.Scope,
particles,
meshAdapter: fx.MeshAdapter,
alphaQueue: fx.AlphaQueue);
var dummy = new DummyAlphaSource();
fx.AlphaQueue.BeginFrame();
for (int i = 0; i < RetailAlphaQueue.ListCapacity - 1; i++)
{
Assert.True(fx.AlphaQueue.TryAppend(
RetailAlphaList.Alpha, dummy, i, overrideClipmap: false));
}
ReadOnlySpan<PreparedParticleAlphaSubmission> prepared =
renderer.PrepareForCellAlpha(
new IdentityCamera(),
Vector3.Zero,
ParticleRenderPass.Scene,
cellId);
Assert.Equal(2, prepared.Length);
Assert.Empty(DeferredParticlePayload(renderer));
prepared[0].Append();
prepared[1].Append();
Assert.Single(DeferredParticlePayload(renderer));
RetailAlphaEntry accepted = Assert.Single(
QueueAlphaEntries(fx.AlphaQueue),
entry => entry.Source.GetType().DeclaringType == typeof(ParticleRenderer));
Assert.Equal(0, accepted.Token);
Assert.Equal(RetailAlphaQueue.ListCapacity, fx.AlphaQueue.AlphaCount);
fx.AlphaQueue.AbortFrame();
Assert.Empty(DeferredParticlePayload(renderer));
Assert.True(renderer.PreparedCellAlphaScratchDiagnostics.Count == 0);
}
[Fact]
public void PreparedCellAlpha_AppendExceptionRollsBackExactTailToken()
{
using var fx = new DispatcherFixture();
const uint cellId = 0x8C0400B5u;
var particles = new ParticleSystem(new EmitterDescRegistry(), new Random(42));
int handle = particles.SpawnEmitter(
new EmitterDesc
{
DatId = 0x32000CB7u,
Type = AcDream.Core.Vfx.ParticleType.Still,
MaxParticles = 1,
InitialParticles = 1,
LifetimeMin = 100f,
LifetimeMax = 100f,
StartAlpha = 1f,
EndAlpha = 1f,
Gravity = Vector3.Zero,
},
new Vector3(20, 0, 0));
particles.UpdateEmitterOwnerCell(handle, cellId);
using var renderer = new ParticleRenderer(
fx.Device,
fx.FrameLifetime,
fx.Scope,
particles,
meshAdapter: fx.MeshAdapter,
alphaQueue: fx.AlphaQueue);
fx.AlphaQueue.BeginFrame();
PreparedParticleAlphaSubmission prepared = renderer.PrepareForCellAlpha(
new IdentityCamera(), Vector3.Zero, ParticleRenderPass.Scene, cellId)[0];
fx.AlphaQueue.AbortFrame();
Assert.Throws<InvalidOperationException>(() => prepared.Append());
Assert.Empty(DeferredParticlePayload(renderer));
renderer.PrepareForCellAlpha(
new IdentityCamera(), Vector3.Zero, ParticleRenderPass.Scene, 0xDEAD0002u);
Assert.True(renderer.PreparedCellAlphaScratchDiagnostics.Count == 0);
}
[Fact]
public void PreparedCellAlpha_FlushAndEndResetAcceptedPayloadAndPreparedScratch()
{
using var fx = new DispatcherFixture();
const uint cellId = 0x8C0400B2u;
var particles = new ParticleSystem(new EmitterDescRegistry(), new Random(42));
int handle = particles.SpawnEmitter(
new EmitterDesc
{
DatId = 0x32000CB2u,
Type = AcDream.Core.Vfx.ParticleType.Still,
MaxParticles = 1,
InitialParticles = 1,
LifetimeMin = 100f,
LifetimeMax = 100f,
StartAlpha = 1f,
EndAlpha = 1f,
Gravity = Vector3.Zero,
},
new Vector3(20, 0, 0));
particles.UpdateEmitterOwnerCell(handle, cellId);
using var renderer = new ParticleRenderer(
fx.Device,
fx.FrameLifetime,
fx.Scope,
particles,
meshAdapter: fx.MeshAdapter,
alphaQueue: fx.AlphaQueue);
using DrawScope draw = fx.BeginDraw();
renderer.BeginFrame(frameSlot: 0);
fx.AlphaQueue.BeginFrame();
renderer.PrepareForCellAlpha(
new IdentityCamera(), Vector3.Zero, ParticleRenderPass.Scene, cellId)[0].Append();
Assert.Single(DeferredParticlePayload(renderer));
fx.AlphaQueue.Flush(RetailAlphaFlushSite.RenderNormalMode, 0f);
Assert.Empty(DeferredParticlePayload(renderer));
Assert.True(renderer.PreparedCellAlphaScratchDiagnostics.Count == 0);
renderer.PrepareForCellAlpha(
new IdentityCamera(), Vector3.Zero, ParticleRenderPass.Scene, cellId)[0].Append();
Assert.Single(DeferredParticlePayload(renderer));
fx.AlphaQueue.EndFrame();
Assert.Empty(DeferredParticlePayload(renderer));
Assert.True(renderer.PreparedCellAlphaScratchDiagnostics.Count == 0);
}
[Fact]
public void PreparedCellAlpha_CapsBothListsAndRejectStormRetainsNoPayloadOrUnboundedScratch()
{
using var fx = new DispatcherFixture();
const uint cellId = 0x8C0400B3u;
const uint clipGfx = 0x01000CB3u;
InjectRenderData(fx.Manager, clipGfx, MakeFlatMesh(
MakeBatch(0x08000CB3u, TranslucencyKind.ClipMap, 0, 0, 3, 1)));
var particles = new ParticleSystem(new EmitterDescRegistry(), new Random(42));
int alphaHandle = particles.SpawnEmitter(
new EmitterDesc
{
DatId = 0x32000CB3u,
Type = AcDream.Core.Vfx.ParticleType.Still,
MaxParticles = RetailAlphaQueue.ListCapacity + 1,
InitialParticles = RetailAlphaQueue.ListCapacity + 1,
LifetimeMin = 100f,
LifetimeMax = 100f,
StartAlpha = 1f,
EndAlpha = 1f,
Gravity = Vector3.Zero,
},
new Vector3(30, 0, 0));
int clipHandle = particles.SpawnEmitter(
new EmitterDesc
{
DatId = 0x32000CB4u,
Type = AcDream.Core.Vfx.ParticleType.Still,
GfxObjId = clipGfx,
MaxParticles = RetailAlphaQueue.ListCapacity + 1,
InitialParticles = RetailAlphaQueue.ListCapacity + 1,
LifetimeMin = 100f,
LifetimeMax = 100f,
StartAlpha = 1f,
EndAlpha = 1f,
Gravity = Vector3.Zero,
},
new Vector3(20, 0, 0));
particles.UpdateEmitterOwnerCell(alphaHandle, cellId);
particles.UpdateEmitterOwnerCell(clipHandle, cellId);
using var renderer = new ParticleRenderer(
fx.Device,
fx.FrameLifetime,
fx.Scope,
particles,
meshAdapter: fx.MeshAdapter,
alphaQueue: fx.AlphaQueue);
var dummy = new DummyAlphaSource();
fx.AlphaQueue.BeginFrame();
for (int i = 0; i < RetailAlphaQueue.ListCapacity; i++)
{
Assert.True(fx.AlphaQueue.TryAppend(
RetailAlphaList.Alpha, dummy, i, overrideClipmap: false));
Assert.True(fx.AlphaQueue.TryAppend(
RetailAlphaList.Clip, dummy, i, overrideClipmap: false));
}
ReadOnlySpan<PreparedParticleAlphaSubmission> prepared =
renderer.PrepareForCellAlpha(
new IdentityCamera(), Vector3.Zero, ParticleRenderPass.Scene, cellId);
int alphaCount = 0;
int clipCount = 0;
for (int i = 0; i < prepared.Length; i++)
{
if (prepared[i].List == RetailAlphaList.Clip)
clipCount++;
else
alphaCount++;
}
Assert.Equal(RetailAlphaQueue.ListCapacity * 2, prepared.Length);
Assert.Equal(RetailAlphaQueue.ListCapacity, clipCount);
Assert.Equal(RetailAlphaQueue.ListCapacity, alphaCount);
Assert.Empty(DeferredParticlePayload(renderer));
Assert.InRange(renderer.PreparedCellAlphaScratchDiagnostics.Capacity, 6000, 8192);
Assert.True(renderer.PreparedCellAlphaScratchDiagnostics.RetainedBytes > 0);
Assert.True(
renderer.PreparedCellAlphaScratchDiagnostics.RetainedBytes
<= renderer.RetainedAlphaScratchBytes);
for (int i = 0; i < prepared.Length; i++)
prepared[i].Append();
Assert.Empty(DeferredParticlePayload(renderer));
Assert.Equal(RetailAlphaQueue.ListCapacity * 2, fx.AlphaQueue.PendingCount);
fx.AlphaQueue.AbortFrame();
Assert.True(renderer.PreparedCellAlphaScratchDiagnostics.Count == 0);
Assert.InRange(renderer.PreparedCellAlphaScratchDiagnostics.Capacity, 6000, 8192);
Assert.Empty(DeferredParticlePayload(renderer));
}
[Fact]
public void PreparedCellAlpha_ImmediateExceptionAfterDelayedCandidateDoesNotReservePayload()
{
using var fx = new DispatcherFixture();
const uint cellId = 0x8C0400B4u;
const uint immediateGfx = 0x01000CB4u;
InjectRenderData(fx.Manager, immediateGfx, MakeFlatMesh(
MakeBatch(0x08000CB4u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
var particles = new ParticleSystem(new EmitterDescRegistry(), new Random(42));
int delayedHandle = particles.SpawnEmitter(
new EmitterDesc
{
DatId = 0x32000CB5u,
Type = AcDream.Core.Vfx.ParticleType.Still,
MaxParticles = 1,
InitialParticles = 1,
LifetimeMin = 100f,
LifetimeMax = 100f,
StartAlpha = 1f,
EndAlpha = 1f,
Gravity = Vector3.Zero,
},
new Vector3(100, 0, 0));
int immediateHandle = particles.SpawnEmitter(
new EmitterDesc
{
DatId = 0x32000CB6u,
Type = AcDream.Core.Vfx.ParticleType.Still,
GfxObjId = immediateGfx,
MaxParticles = 1,
InitialParticles = 1,
LifetimeMin = 100f,
LifetimeMax = 100f,
StartAlpha = 1f,
EndAlpha = 1f,
Gravity = Vector3.Zero,
},
new Vector3(10, 0, 0));
particles.UpdateEmitterOwnerCell(delayedHandle, cellId);
particles.UpdateEmitterOwnerCell(immediateHandle, cellId);
using var renderer = new ParticleRenderer(
fx.Device,
fx.FrameLifetime,
fx.Scope,
particles,
meshAdapter: fx.MeshAdapter,
alphaQueue: fx.AlphaQueue);
fx.AlphaQueue.BeginFrame();
Assert.Throws<InvalidOperationException>(() =>
renderer.PrepareForCellAlpha(
new IdentityCamera(), Vector3.Zero, ParticleRenderPass.Scene, cellId));
Assert.Empty(DeferredParticlePayload(renderer));
Assert.True(renderer.PreparedCellAlphaScratchDiagnostics.Count == 1);
renderer.PrepareForCellAlpha(
new IdentityCamera(), Vector3.Zero, ParticleRenderPass.Scene, 0xDEAD0001u);
Assert.True(renderer.PreparedCellAlphaScratchDiagnostics.Count == 0);
fx.AlphaQueue.AbortFrame();
}
private static List<RetailAlphaEntry> QueueAlphaEntries(RetailAlphaQueue queue) =>
(List<RetailAlphaEntry>)typeof(RetailAlphaQueue).GetField(
"_alpha", BindingFlags.Instance | BindingFlags.NonPublic)!
.GetValue(queue)!;
private static IList DeferredParticlePayload(ParticleRenderer renderer) =>
(IList)typeof(ParticleRenderer).GetField(
"_deferredAlpha", BindingFlags.Instance | BindingFlags.NonPublic)!
.GetValue(renderer)!;
// ── F4(b) (S3 chunk 3 fix round 1 §9.6): an interior root with one
// surviving exit view — the SAME fixture as
// RunFrame_InteriorFloodWithExitView_FreshDriverSkipsTheGatedClearThenDrawsSealsAndFloodCells
@ -2547,6 +3008,7 @@ public sealed partial class WalkFrameDriverTests
private sealed class NoopDatReaderWriter : IDatReaderWriter
{
private readonly Dictionary<(Type Type, uint Id), IDBObj> _objects = new();
private readonly StubDatabase _portal = new();
private readonly StubDatabase _highRes = new();
private readonly StubDatabase _language = new();
@ -2578,6 +3040,9 @@ public sealed partial class WalkFrameDriverTests
public int LanguageIteration => 0;
public void Add<T>(uint id, T value) where T : IDBObj =>
_objects[(typeof(T), id)] = value;
public bool TryGetFileBytes(
uint regionId,
uint fileId,
@ -2604,12 +3069,21 @@ public sealed partial class WalkFrameDriverTests
throw new NotSupportedException();
[return: MaybeNull]
public T Get<T>(uint fileId) where T : IDBObj => default;
public T Get<T>(uint fileId) where T : IDBObj =>
_objects.TryGetValue((typeof(T), fileId), out IDBObj? value)
? (T)value
: default;
public bool TryGet<T>(
uint fileId,
[MaybeNullWhen(false)] out T value) where T : IDBObj
{
if (_objects.TryGetValue((typeof(T), fileId), out IDBObj? found))
{
value = (T)found;
return true;
}
value = default;
return false;
}