diff --git a/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md b/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md index 8dee3384..6ba77d51 100644 --- a/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md +++ b/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md @@ -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 1–5 | **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 3–5 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 | --- diff --git a/docs/research/2026-09-01-overhaul/s4-depth-alpha-packet.md b/docs/research/2026-09-01-overhaul/s4-depth-alpha-packet.md index 36687326..8d77ea00 100644 --- a/docs/research/2026-09-01-overhaul/s4-depth-alpha-packet.md +++ b/docs/research/2026-09-01-overhaul/s4-depth-alpha-packet.md @@ -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-2–5 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. diff --git a/src/AcDream.App/Rendering/ParticleRenderer.cs b/src/AcDream.App/Rendering/ParticleRenderer.cs index 536f2531..2e8046e9 100644 --- a/src/AcDream.App/Rendering/ParticleRenderer.cs +++ b/src/AcDream.App/Rendering/ParticleRenderer.cs @@ -177,7 +177,18 @@ public sealed unsafe partial class ParticleRenderer : IDisposable internal long RetainedAlphaScratchBytes => checked( (long)_deferredAlpha.Capacity * Unsafe.SizeOf() + (long)_preparedAlpha.Length * Unsafe.SizeOf() - + (long)_preparedInstanceOffsets.Length * sizeof(uint)); + + (long)_preparedInstanceOffsets.Length * sizeof(uint) + + (long)_preparedCellAlphaScratch.Capacity + * Unsafe.SizeOf()); + + internal (int Count, int Capacity, long RetainedBytes) + PreparedCellAlphaScratchDiagnostics => + ( + _preparedCellAlphaScratch.Count, + _preparedCellAlphaScratch.Capacity, + checked((long)_preparedCellAlphaScratch.Capacity + * Unsafe.SizeOf()) + ); 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); + + /// + /// 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. + /// + 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(); } /// @@ -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, diff --git a/src/AcDream.App/Rendering/ParticleSubmissionOrdering.cs b/src/AcDream.App/Rendering/ParticleSubmissionOrdering.cs index 8b75a845..4108a1b5 100644 --- a/src/AcDream.App/Rendering/ParticleSubmissionOrdering.cs +++ b/src/AcDream.App/Rendering/ParticleSubmissionOrdering.cs @@ -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 WalkFrameDriver merge this retained CYpt /// key with ordinary object parts before either source enters retail's FIFO -/// list. The source payload is reserved during preparation; 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; +/// reserves it at the queue visibility edge and rolls +/// back the exact tail reservation when the queue rejects or throws. /// 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); + } } /// diff --git a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs index 8e1c5c7e..23313aa4 100644 --- a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs +++ b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs @@ -14,9 +14,11 @@ namespace AcDream.App.Rendering.Walk; /// 's doc comment). /// /// Already-classified s -/// for this turn's cell/building, in the SAME order they must enter the walk -/// stream (never re-sorted downstream — 's -/// own contract). Campaign FW3.4a: a segment INTO 's +/// for this turn's cell/building, retaining authored traversal/registration +/// order. For ordinary cells, 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 's /// per-frame arena, not a freshly allocated array — see that type's own doc /// comment. /// The clip-slot-resolving landblock id diff --git a/tests/AcDream.App.Tests/Rendering/ParticleRendererRouteTests.cs b/tests/AcDream.App.Tests/Rendering/ParticleRendererRouteTests.cs index 941ca515..42d02a24 100644 --- a/tests/AcDream.App.Tests/Rendering/ParticleRendererRouteTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ParticleRendererRouteTests.cs @@ -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() { diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs index e09fdb3c..defef610 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs @@ -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; /// 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(); } + /// + /// 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. + /// + [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 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 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(() => 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 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(() => + 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 QueueAlphaEntries(RetailAlphaQueue queue) => (List)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(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(uint fileId) where T : IDBObj => default; + public T Get(uint fileId) where T : IDBObj => + _objects.TryGetValue((typeof(T), fileId), out IDBObj? value) + ? (T)value + : default; public bool TryGet( 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; }