diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index fd4ad29b..5e61d740 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -259,8 +259,22 @@ heading/velocity/parent state, arms no teleport hook, and still acknowledges immediately. The long AP-148 row below is retained only as historical filing research and is no longer active. +**S5-c4 fix-round-1 AP-232 correction (2026-09-05):** AP-244 below raises +the active total to **161**; the historical heading's 160 is superseded. +AP-232's retirement remains valid for the old two-draw/detail-weight +deviation, with this corrected scope: recipe 10 carries extraction-resolved +SetSurface blend, alpha-test/reference, and fog state through both real command +owners. Detail-active Wb and EnvCell select raw Additive, Alpha+Additive, +inverse, inverse-additive, combined/pure ClipMap, late Translucent override, +final-X reference, and raw-Additive fog-disable state exactly. Wb pure Clip +retains AP-240's immediate opaque/A2C pipeline while gaining final-X ParamB; +only EnvCell pure Clip uses `ONE/INVSRCALPHA`. The retired row's recipe-9-only +and `None` residual wording is superseded by this correction and AP-244; +AP-238/AP-239/AP-240 remain separate queue/token/raw-mask/placement residuals. + | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| +| AP-244 | **Filed 2026-09-05 at Campaign OVERHAUL S5-c4 fix round 1.** The new exact resolved SetSurface state is deliberately selected only when a Wb/EnvCell command has active building detail, preserving §23's bounded detail-off behavior. With detail off, Wb still collapses raw Additive and InvAlpha+Additive through `TranslucencyKind.Additive` to `SRCALPHA,ONE`; EnvCell additionally selects raw `IsAdditive` before the late `Translucent\|ClipMap` override and has no detail-off inverse pipeline. Both shader families retain the existing non-detail 0.05 discard rather than every combined ClipMap's palette/DDS reference, and fixed-function fog remains enabled for raw-Additive detail-off rows. AP-240 separately covers Wb pure-Clip placement/A2C. | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs` (`PipelineForBlend`, detail gates); `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs` (`ResolveBatchGroupIndex`); `src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs` (detail-active exact selection versus retained range buckets); `src/AcDream.App/Rendering/Shaders/mesh_modern.frag`; `src/AcDream.App/Rendering/Shaders/mesh_atmospheric.frag` | Fix round 1 was expressly bounded to detail-active one-pass material truth. The immutable state is already present end-to-end, so a later dedicated detail-off correction can select it without DAT lookup or new ownership, but must re-gate FIFO/routing/order and visual behavior together. | Detail-off raw Additive/inverse-additive brightness, inverse EnvCell compositing, combined ClipMap cutout threshold, late Translucent override, and raw-Additive fog can differ from retail. | `D3DPolyRender::SetSurface @0x0059C4D0` lines 425083–425303; Campaign OVERHAUL S5-c4 §23.2 | | AP-235 | **Filed 2026-08-25 at the Campaign CT4 fix round.** Retail resolves gender display text via `AppraisalSystem::InqGenderDisplayName @0x005b47c0` and heritage via `InqHeritageGroupDisplayName @0x005b4710`, both through the static `EnumMapper::GetString(uint32_t enumValue, uint32_t queryId, PStringBase*) @0x0041ac40` overload — `DBObj::GetDIDByEnum(&did, enumValue, 1)` (master map `0x25000000` → category-1 sub-map `0x25000001` → `ClientEnumToID[0x10000001]`/`[0x10000002]` → EnumMapper DIDs `0x2200000A`/`0x2200000B`) — reading each id's `IdToStringMap` entry live, with heritage ids 2/5/0xd hardcoded to `"Gharu'ndim"`/`"Umbraen"`/`"Olthoi"` in place of the raw internal names `"Gharundim"`/`"Shadowbound"`/`"OlthoiAcid"`. `CharacterIdentityText.GenderDisplayName`/`HeritageGroupDisplayName` are hardcoded C# `switch` tables instead — a mechanism divergence (compile-time constant vs. live DAT read), not a content one: `CharacterPanelLiveDatTests.GenderHeritageDisplayNameTables_MatchTheRetailEnumMapperChain` (filed the same round) walks the live EnumMapper chain and asserts every table entry byte-exact, including the two entries (10 "Penumbraen", 12 "Olthoi") the CT4 review had flagged as unverified guesses — both are correct. | `src/AcDream.App/UI/Layout/CharacterIdentityText.cs` (`GenderDisplayName`, `HeritageGroupDisplayName`); `src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs` (`ResolveHeritage` — CT5 fix round 2026-08-25 deleted its independent re-implementation of the same 2/5/13 overrides; it now delegates straight to `CharacterIdentityText.HeritageGroupDisplayName`, so this row's divergence has exactly ONE owner, not two) | `RetailDataIdResolver.Resolve` (`src/AcDream.Content/RetailDataIdResolver.cs`) already ports the generic two-level `GetDIDByEnum` chain (used today for layout/material DIDs); unifying gender/heritage onto it needs only `Resolve(dats, enumValue: 0x10000001u/0x10000002u, enumCategory: 1u)` plus an `EnumMapper.IdToStringMap` read — a live-DAT-only path with no bespoke traversal code to write, which is why the tables stayed hardcoded this round rather than porting live-read on the spot; CT5 is the natural landing slot since it already owns this same DAT-lookup family for the Titles page | A future DAT/game update that renames or reorders a heritage/gender enum entry would silently desync acdream's hardcoded tables from retail's live text with no build-time or runtime signal — the CT5 fix round retired the second-copy drift risk (`ResolveHeritage` now reads the same single table), but the core hardcoded-vs-live-DAT divergence itself remains open | `AppraisalSystem::InqGenderDisplayName @ 0x005B47C0`; `InqHeritageGroupDisplayName @ 0x005B4710`; `EnumMapper::GetString @ 0x0041AC40`; `DBObj::GetDIDByEnum @ 0x004153A0` | | AP-233 | **Filed 2026-08-23 at the Holtburg windmill fix (row owed since the R1-P5 sequencer cutover).** `AnimationSequencer.BuildBlendedFrame` blends each part between `floor(FrameNumber)` and the next frame in the playback direction using the retail slerp (`SlerpRetailClient`). Retail never blends animation frames: `CPartArray::UpdateParts` applies `CSequence::get_curr_animframe` = `get_part_frame(floor(frame_number))`, holding every authored 30 fps frame for its whole interval. Since 2026-08-23 the blend holds the boundary frame at BOTH ends of a node's window — including the cyclic seam — so a cycle's last→first transition is retail's hard cut, not a blend. | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`BuildBlendedFrame`); tests `AnimationSequencerTests.Advance_LinkTailDoesNotBlendIntoLinkFrame0` (#61), `Advance_CyclicSeamHoldsLastFrameInsteadOfBlendingIntoFrame0` (windmill) | The blend only smooths between authored interior frames of one node; at every seam the pose is exactly retail's held frame. Authored cycles that loop by symmetry (the Holtburg windmill's 60-frame quarter turn, `0x0300061B`) or by design read identically at the seam; link tails hold their end pose (#61). The owner chose this over dropping the blend (retail's 30 fps stepping) on 2026-08-23. | Any two adjacent authored frames that are NOT meant to be traversed smoothly (a deliberate authored pop inside a node) would be smoothed where retail pops; none known. A per-frame hitch of one held 33 ms interval at each cycle seam is the price of the cut (1.5° on the windmill). | `CPartArray::UpdateParts @0x005190F0`; `CSequence::get_curr_animframe @0x00524970`; `CSequence::get_curr_frame_number @0x005249D0` | | ~~AP-232~~ | **RETIRED 2026-09-04 at Campaign OVERHAUL S5-c4.** The acdream-only base-plus-detail replay is deleted. `TextureBatchData.SurfaceOpacity` carries exact `1-Surface.Translucency` from `MeshExtractor` through recipe-9 prepared serialization, `ObjectRenderBatch`, and the unchanged 16-byte GPU batch ABI. `mesh_modern` and `mesh_atmospheric` share `retail_detail_material.glsl` and emit one pre-fog fragment per detail-active subset: `w=a*qA`, `C=q*w+(B*D)*(1-w)`, `X=a*qA*qA`, where `a=authoredOpacity*liveFade`; base texture alpha is excluded. The final `X` drives pure CLIP at 100/255 or 200/255 with equality surviving. Classic grouped, walk-ordered (#471), immediate alpha, delayed alpha, and EnvCell paths keep their original draw position and pipeline family; the separate RetailDetail pipelines, replay helpers, shaders, SPIR-V, and manifest entry are gone. | `src/AcDream.Content/MeshExtractor.cs`; `src/AcDream.Content/Pak/ObjectMeshDataSerializer.cs`; `src/AcDream.App/Rendering/Shaders/retail_detail_material.glsl`; `src/AcDream.App/Rendering/Shaders/mesh_modern.frag`; `src/AcDream.App/Rendering/Shaders/mesh_atmospheric.frag`; `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs`; `src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs` | Exact retail single-pass stage arithmetic, authored-alpha provenance, framebuffer-family/CLIP CPU fixtures, shader ABI pins, deterministic recipe-9 round trips and rejection of recipe 8, one-draw command transcripts, shader/SPIR-V validation, and 0-B warmed-path proof. | None for the former weight/draw-count deviation. AP-238/AP-239/AP-240 remain separate queue/token/raw-mask placement residuals and are not changed by this retirement. | `D3DPolyRender::SetSurface @0x0059C4D0`; `RenderMeshSubset @0x0059CA10`; `DrawMesh @0x0059D4A0`; VM2 cdb note `docs/research/2026-08-22-vm2-retail-detail-path-cdb.md`; Campaign OVERHAUL S5-c4 §22 | diff --git a/src/AcDream.App/Rendering/Gpu/GpuEnums.cs b/src/AcDream.App/Rendering/Gpu/GpuEnums.cs index b3b7db65..31ee76b3 100644 --- a/src/AcDream.App/Rendering/Gpu/GpuEnums.cs +++ b/src/AcDream.App/Rendering/Gpu/GpuEnums.cs @@ -132,6 +132,12 @@ internal enum GpuBlendMode /// Additive: SrcAlpha, One. Additive, + /// Retail raw Additive: One, One. + RawAdditive, + + /// Retail inverse-additive: OneMinusSrcAlpha, One. + InverseAdditive, + /// /// Retail's inverse-alpha translucency: OneMinusSrcAlpha, SrcAlpha. /// diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanViewportMapping.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanViewportMapping.cs index ac404025..c81ce023 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanViewportMapping.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanViewportMapping.cs @@ -176,6 +176,8 @@ internal static class VulkanViewportMapping GpuBlendMode.StraightAlpha => (BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha), GpuBlendMode.PremultipliedAlpha => (BlendFactor.One, BlendFactor.OneMinusSrcAlpha), GpuBlendMode.Additive => (BlendFactor.SrcAlpha, BlendFactor.One), + GpuBlendMode.RawAdditive => (BlendFactor.One, BlendFactor.One), + GpuBlendMode.InverseAdditive => (BlendFactor.OneMinusSrcAlpha, BlendFactor.One), // Retail's third mode, found at slice V4c in WbDrawDispatcher.ApplyRetailBlend. GpuBlendMode.InverseAlpha => (BlendFactor.OneMinusSrcAlpha, BlendFactor.SrcAlpha), GpuBlendMode.None => (BlendFactor.One, BlendFactor.Zero), diff --git a/src/AcDream.App/Rendering/RetailDetailTextureContract.cs b/src/AcDream.App/Rendering/RetailDetailTextureContract.cs index 699f7753..55ca4739 100644 --- a/src/AcDream.App/Rendering/RetailDetailTextureContract.cs +++ b/src/AcDream.App/Rendering/RetailDetailTextureContract.cs @@ -34,6 +34,8 @@ namespace AcDream.App.Rendering; /// internal static class RetailDetailTextureContract { + internal const int NoFogRenderPassFlag = 0x200; + internal static bool ShouldRender( bool settingEnabled, TerrainAtlas.RetailDetailTextureBinding binding) => diff --git a/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.frag b/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.frag index 65b81ebb..3eed52e6 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.frag +++ b/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.frag @@ -128,9 +128,12 @@ void main() { alpha = color.a * vOpacityMultiplier; } - if (detailActive ? alpha < alphaCutoff : color.a < alphaCutoff) + if (detailActive + ? isRetailClipReference(uParamB) && alpha < uParamB + : color.a < alphaCutoff) discard; - rgb = applyFog(rgb, vWorldPos); + if (!detailActive || (uRenderPass & 0x200) == 0) + rgb = applyFog(rgb, vWorldPos); FragColor = vec4(rgb, alpha); } diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.frag b/src/AcDream.App/Rendering/Shaders/mesh_modern.frag index 291758a1..804c669b 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_modern.frag +++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.frag @@ -25,6 +25,7 @@ in flat uint vDetailCategory; // discard alpha >= 0.95 (already drawn opaque) and // alpha < 0.05 (skip empty fragments — large // transparent overdraw cost otherwise) +// bit 0x200 = detail-active raw Additive; bypass fixed-function fog uniform int uRenderPass; uniform int uLightDebug; // #176 stripe hunt (see mesh_modern.vert) — mode 3 handled here // S4-c2 F3: only retail's exact pure-ClipMap alpha-test references are @@ -120,9 +121,12 @@ void main() { // '<' spells D3DCMP_GREATEREQUAL: equality passes. Detail-active CLIP // tests retail's final stage-1 alpha; detail-off keeps base-alpha logic. - if (detailActive ? alpha < alphaCutoff : color.a < alphaCutoff) + if (detailActive + ? isRetailClipReference(uParamB) && alpha < uParamB + : color.a < alphaCutoff) discard; - rgb = applyFog(rgb, vWorldPos); + if (!detailActive || (uRenderPass & 0x200) == 0) + rgb = applyFog(rgb, vWorldPos); FragColor = vec4(rgb, alpha); } diff --git a/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.frag.spv index 258dd8c2..0d734a18 100644 Binary files a/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.frag.spv and b/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.frag.spv differ diff --git a/src/AcDream.App/Rendering/Shaders/spv/mesh_modern.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/mesh_modern.frag.spv index 577c041b..d9c5605c 100644 Binary files a/src/AcDream.App/Rendering/Shaders/spv/mesh_modern.frag.spv and b/src/AcDream.App/Rendering/Shaders/spv/mesh_modern.frag.spv differ diff --git a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json index 7e8a63c0..a5277b73 100644 --- a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json +++ b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json @@ -220,7 +220,7 @@ }, { "stage": "frag", - "sourceSha256": "5eb058e92ab27f1f6c245401e05f78a278fe00696b5c77f36ae33f8428f83bf3", + "sourceSha256": "c95e372c557a8a4b252ecf824ad049fe9611b0a1e49b0e6d18938d373d9c6794", "compiled": true } ] @@ -236,7 +236,7 @@ }, { "stage": "frag", - "sourceSha256": "396c382af73505bc2cac7a2db5878a63166730e47096a94630c35ad8376fe565", + "sourceSha256": "e145f32cbd50edcbc5e3196a49f0e2b05548bc0199ce13139e01c443794473b0", "compiled": true } ] diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs index e86aa34b..9a7e0232 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs @@ -2,6 +2,7 @@ using System.Numerics; using System.Runtime.InteropServices; using AcDream.App.Rendering.Gpu; using AcDream.Core.Lighting; +using AcDream.Core.Meshing; using DatReaderWriter.Enums; namespace AcDream.App.Rendering.Wb; @@ -28,6 +29,9 @@ public sealed unsafe partial class EnvCellRenderer private IGpuPipeline? _alphaPipeline; private IGpuPipeline? _clipPipeline; private IGpuPipeline? _additivePipeline; + private IGpuPipeline? _rawAdditivePipeline; + private IGpuPipeline? _inversePipeline; + private IGpuPipeline? _inverseAdditivePipeline; private readonly TerrainAtlas.RetailDetailTextureBinding _environmentDetail; private readonly Func _buildingDetailEnabled; @@ -48,7 +52,7 @@ public sealed unsafe partial class EnvCellRenderer /// /// The RHI arm's constructor. It also completes Initialize's job: the - /// four pipelines ARE this renderer's program, so there is no second step + /// SetSurface pipeline family IS this renderer's program, so there is no second step /// and no Shader to hand in. /// internal EnvCellRenderer( @@ -80,6 +84,12 @@ public sealed unsafe partial class EnvCellRenderer scope.SampleCount); _additivePipeline = CreateShellPipeline( device, "envcell-additive", GpuBlendMode.Additive, depthWrite: false, scope.SampleCount); + _rawAdditivePipeline = CreateShellPipeline( + device, "envcell-raw-additive", GpuBlendMode.RawAdditive, depthWrite: false, scope.SampleCount); + _inversePipeline = CreateShellPipeline( + device, "envcell-inverse", GpuBlendMode.InverseAlpha, depthWrite: false, scope.SampleCount); + _inverseAdditivePipeline = CreateShellPipeline( + device, "envcell-inverse-additive", GpuBlendMode.InverseAdditive, depthWrite: false, scope.SampleCount); _initialized = true; } @@ -272,7 +282,9 @@ public sealed unsafe partial class EnvCellRenderer : isAdditive ? _additivePipeline! : _alphaPipeline!; - if (renderPass == WbRenderPass.Transparent) + if (detailEnabled) + rangeBasePipeline = PipelineForMaterial(drawRange.MaterialState); + if (renderPass == WbRenderPass.Transparent || detailEnabled) { // Blend state is the pipeline's; switching variants mid-pass has // to re-establish the mesh, which is vertex-array state. @@ -289,10 +301,14 @@ public sealed unsafe partial class EnvCellRenderer pushConstants.RenderPass = isAdditive ? (int)renderPass | 0x100 : (int)renderPass; + if (detailEnabled && !drawRange.MaterialState.FogEnabled) + pushConstants.RenderPass |= RetailDetailTextureContract.NoFogRenderPassFlag; pushConstants.DrawIdOffset = drawRange.FirstCommand; - pushConstants.ParamB = isClip - ? groupIndex >= ClipPalettedGroupBase ? 100f / 255f : 200f / 255f - : 0f; + pushConstants.ParamB = detailEnabled + ? drawRange.MaterialState.AlphaTestReference + : isClip + ? groupIndex >= ClipPalettedGroupBase ? 100f / 255f : 200f / 255f + : 0f; pushConstants.TextureIndexA = detailEnabled ? _environmentDetail.TextureSlot.Index : 0u; @@ -311,9 +327,23 @@ public sealed unsafe partial class EnvCellRenderer pushConstants.TextureIndexA = 0; pushConstants.ParamA = 0f; pushConstants.ParamB = 0f; + pushConstants.RenderPass &= ~RetailDetailTextureContract.NoFogRenderPassFlag; encoder.SetPushConstants(in pushConstants); } + private IGpuPipeline PipelineForMaterial(RetailSetSurfaceMaterialState material) => + material.Blend switch + { + RetailSetSurfaceBlend.Opaque => _opaquePipeline!, + RetailSetSurfaceBlend.StraightAlpha => _alphaPipeline!, + RetailSetSurfaceBlend.AlphaAdditive => _additivePipeline!, + RetailSetSurfaceBlend.Additive => _rawAdditivePipeline!, + RetailSetSurfaceBlend.InverseAlpha => _inversePipeline!, + RetailSetSurfaceBlend.InverseAdditive => _inverseAdditivePipeline!, + RetailSetSurfaceBlend.Clip => _clipPipeline!, + _ => throw new ArgumentOutOfRangeException(nameof(material), material, "Unknown SetSurface blend."), + }; + private void BindPipelineWithMesh( IGpuPassEncoder encoder, IGpuPipeline pipeline, @@ -429,5 +459,11 @@ public sealed unsafe partial class EnvCellRenderer _clipPipeline = null; _additivePipeline?.Dispose(); _additivePipeline = null; + _rawAdditivePipeline?.Dispose(); + _rawAdditivePipeline = null; + _inversePipeline?.Dispose(); + _inversePipeline = null; + _inverseAdditivePipeline?.Dispose(); + _inverseAdditivePipeline = null; } } diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs index a397d0df..ec82fdfd 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs @@ -24,6 +24,7 @@ using System.Numerics; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; +using AcDream.Core.Meshing; using DatReaderWriter.Enums; namespace AcDream.App.Rendering.Wb; @@ -162,7 +163,11 @@ public sealed partial class EnvCellRenderer : private readonly List _mdiDrawRanges = new(); private readonly record struct DrawCallRange(int First, int Count); - internal readonly record struct MdiDrawRange(int GroupIndex, int FirstCommand, int CommandCount); + internal readonly record struct MdiDrawRange( + int GroupIndex, + int FirstCommand, + int CommandCount, + RetailSetSurfaceMaterialState MaterialState); // Unfiltered rendering is retained for diagnostic callers only. Build its // global grouping lazily instead of duplicating every prepared gameplay // instance in both a per-cell and global tree each frame. @@ -883,7 +888,7 @@ public sealed partial class EnvCellRenderer : EnvCellTransparentRoute transparentRoute, bool detailSurfaceActive) { - // WB EnvCellRenderManager.cs:400: the RHI arm's four pipelines are built + // WB EnvCellRenderManager.cs:400: the RHI arm's SetSurface pipelines are built // at construction (see EnvCellRenderer.Rhi.cs), so _initialized alone // answers whether this renderer is ready to draw. if (!_initialized) return; @@ -1304,7 +1309,6 @@ public sealed partial class EnvCellRenderer : int groupIndex = _activeCullGroups[activeIndex]; List<(ObjectRenderBatch batch, int instanceCount, int instanceOffset)> group = _batchesByCullGroup[groupIndex]; - int firstCommand = cmdIndex; foreach (var item in group) { _modernBatches[cmdIndex] = new ModernBatchData @@ -1330,22 +1334,14 @@ public sealed partial class EnvCellRenderer : BaseVertex = (int)item.batch.BaseVertex, BaseInstance = (uint)item.instanceOffset, }; + AppendMdiDrawRange( + _mdiDrawRanges, + groupIndex, + cmdIndex, + 1, + item.batch.MaterialState); cmdIndex++; } - - int commandCount = cmdIndex - firstCommand; - if (commandCount == 0) - continue; - - // Adjacent cells frequently resolve to the same cull/blend - // state. Their commands are already contiguous and remain in - // strict cell order, so one MDI call can cover the complete run - // without changing alpha compositing or gl_DrawID indexing. - AppendMdiDrawRange( - _mdiDrawRanges, - groupIndex, - firstCommand, - commandCount); } } @@ -1400,7 +1396,8 @@ public sealed partial class EnvCellRenderer : List ranges, int groupIndex, int firstCommand, - int commandCount) + int commandCount, + RetailSetSurfaceMaterialState materialState) { ArgumentNullException.ThrowIfNull(ranges); if (commandCount <= 0) @@ -1410,6 +1407,7 @@ public sealed partial class EnvCellRenderer : { MdiDrawRange previous = ranges[^1]; if (previous.GroupIndex == groupIndex + && previous.MaterialState == materialState && previous.FirstCommand + previous.CommandCount == firstCommand) { ranges[^1] = previous with @@ -1420,7 +1418,7 @@ public sealed partial class EnvCellRenderer : } } - ranges.Add(new MdiDrawRange(groupIndex, firstCommand, commandCount)); + ranges.Add(new MdiDrawRange(groupIndex, firstCommand, commandCount, materialState)); } // --------------------------------------------------------------------------- diff --git a/src/AcDream.App/Rendering/Wb/GroupKey.cs b/src/AcDream.App/Rendering/Wb/GroupKey.cs index da5fccfe..a1cd2a99 100644 --- a/src/AcDream.App/Rendering/Wb/GroupKey.cs +++ b/src/AcDream.App/Rendering/Wb/GroupKey.cs @@ -51,6 +51,7 @@ internal readonly record struct GroupKey( GpuTextureSlot TextureSlot, uint TextureLayer, TranslucencyKind Translucency, + RetailSetSurfaceMaterialState MaterialState, uint FoliageFlags, float SurfaceOpacity = 1f, CullMode CullMode = CullMode.CounterClockwise); diff --git a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs index aad48c6e..95b3b425 100644 --- a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs +++ b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs @@ -120,6 +120,8 @@ namespace AcDream.App.Rendering.Wb public TextureKey Key { get; set; } public DatReaderWriter.Enums.CullMode CullMode { get; set; } public AcDream.Core.Meshing.TranslucencyKind Translucency { get; set; } + public AcDream.Core.Meshing.RetailSetSurfaceMaterialState MaterialState { get; set; } + = AcDream.Core.Meshing.RetailSetSurfaceMaterialState.Opaque; public bool IsTransparent { get; set; } public bool IsAdditive { get; set; } public bool HasWrappingUVs { get; set; } @@ -2262,6 +2264,7 @@ namespace AcDream.App.Rendering.Wb TextureFormat = format.Format, RetailSurfaceMask = batch.RetailSurfaceMask, Translucency = batch.Translucency, + MaterialState = batch.MaterialState, IsTransparent = batch.IsTransparent, IsAdditive = batch.IsAdditive, HasWrappingUVs = batch.HasWrappingUVs, diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.OrderedStream.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.OrderedStream.cs index da960b03..df8bdd53 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.OrderedStream.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.OrderedStream.cs @@ -488,7 +488,14 @@ public sealed unsafe partial class WbDrawDispatcher if (global is null) return; - MeshPipelineSet pipelines = PipelinesFor(encoder); + IGpuFrame frame = _orderedFrame + ?? throw new InvalidOperationException( + "DrawOrderedRange has no frame to bind clip-region/" + + "scene-lighting sections against — PrepareOrderedStream must run first."); + MeshPipelineSet pipelines = PipelinesFor( + encoder, + frame, + out DirectionalShadowFrameBinding shadowBinding); var pushConstants = new GpuPushConstants { ViewProjection = _orderedViewProjection, @@ -503,11 +510,6 @@ public sealed unsafe partial class WbDrawDispatcher }; { - IGpuFrame frame = _orderedFrame - ?? throw new InvalidOperationException( - "DrawOrderedRange has no frame to bind clip-region/" - + "scene-lighting sections against — PrepareOrderedStream must run first."); - // Bind the SECTIONS on EVERY range call — never latch them // across calls. Between ordered ranges the walk's leaf draws run // (terrain, cell shells, sky, punch fans) and RetailAlphaQueue @@ -521,6 +523,7 @@ public sealed unsafe partial class WbDrawDispatcher // DrawPreparedAlphaBatchRhi does for the same reason. BindPipelineWithMesh(encoder, pipelines.Opaque, global); encoder.SetPushConstants(in pushConstants); + BindDirectionalShadowReceiver(encoder, in shadowBinding); BindSection(encoder, GpuBindingModel.StorageInstances, _orderedInstances); BindSection(encoder, GpuBindingModel.StorageBatches, _orderedBatches); BindSection(encoder, GpuBindingModel.StorageClipSlots, _orderedClipSlots); @@ -567,11 +570,16 @@ public sealed unsafe partial class WbDrawDispatcher ValidateMergeRun(_orderedStream, run); PipelineBucket bucket = BucketFor(_orderedStream.Keys[run.FirstCommand].Translucency); - IGpuPipeline pipeline = PipelineForBucket(pipelines, bucket); + GroupKey key = _orderedStream.Keys[run.FirstCommand]; + bool hasDetail = detailEnabled + && _orderedStream.DetailCategories[run.FirstCommand] != 0u; + IGpuPipeline bucketPipeline = PipelineForBucket(pipelines, bucket); + IGpuPipeline pipeline = hasDetail + ? PipelineForMaterial(pipelines, key.MaterialState, bucketPipeline) + : bucketPipeline; pushConstants.RenderPass = bucket == PipelineBucket.Opaque ? 0 : 1; - if (detailEnabled - && _orderedStream.DetailCategories[run.FirstCommand] != 0u) - ArmBuildingDetail(ref pushConstants); + if (hasDetail) + ArmBuildingDetail(ref pushConstants, key.MaterialState); else ClearDetailPushConstants(ref pushConstants); diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs index b4091101..ffba9af9 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs @@ -52,7 +52,9 @@ public sealed unsafe partial class WbDrawDispatcher IGpuPipeline OpaqueAlphaToCoverage, IGpuPipeline AlphaBlend, IGpuPipeline AlphaAdditive, - IGpuPipeline AlphaInverse); + IGpuPipeline RawAdditive, + IGpuPipeline AlphaInverse, + IGpuPipeline InverseAdditive); private MeshPipelineSet? _backbufferPipelines; private MeshPipelineSet? _offscreenPipelines; @@ -227,7 +229,7 @@ public sealed unsafe partial class WbDrawDispatcher bool usesRenderPackShaderAbi = false) { string suffix = samples > 1 ? string.Empty : "-1x"; - var created = new List(5); + var created = new List(7); try { return new MeshPipelineSet( @@ -252,10 +254,20 @@ public sealed unsafe partial class WbDrawDispatcher shaders: baseShaders, shaderName: baseShaderName, usesRenderPackShaderAbi: usesRenderPackShaderAbi)), + Track(CreateMeshPipeline( + device, $"{namePrefix}-raw-additive{suffix}", GpuBlendMode.RawAdditive, false, false, samples, + shaders: baseShaders, + shaderName: baseShaderName, + usesRenderPackShaderAbi: usesRenderPackShaderAbi)), Track(CreateMeshPipeline( device, $"{namePrefix}-inverse{suffix}", GpuBlendMode.InverseAlpha, false, false, samples, shaders: baseShaders, shaderName: baseShaderName, + usesRenderPackShaderAbi: usesRenderPackShaderAbi)), + Track(CreateMeshPipeline( + device, $"{namePrefix}-inverse-additive{suffix}", GpuBlendMode.InverseAdditive, false, false, samples, + shaders: baseShaders, + shaderName: baseShaderName, usesRenderPackShaderAbi: usesRenderPackShaderAbi))); } catch @@ -370,9 +382,12 @@ public sealed unsafe partial class WbDrawDispatcher encoder, frame, out DirectionalShadowFrameBinding shadowBinding); + IGpuPipeline opaquePipeline = AlphaToCoverage + ? pipelines.OpaqueAlphaToCoverage + : pipelines.Opaque; BindPipelineWithMesh( encoder, - AlphaToCoverage ? pipelines.OpaqueAlphaToCoverage : pipelines.Opaque, + opaquePipeline, mesh); encoder.SetPushConstants(in pushConstants); BindDirectionalShadowReceiver(encoder, in shadowBinding); @@ -436,7 +451,8 @@ public sealed unsafe partial class WbDrawDispatcher using (BeginRhiTimer(encoder, diag, OpaqueTimerScope)) { DrawDetailAwareRangeRhi( - encoder, ref pushConstants, commandBuffer, commandBase, + encoder, mesh, pipelines, opaquePipeline, + ref pushConstants, commandBuffer, commandBase, 0, _opaqueDrawCount, usedDetailCategories, detailEnabled); } } @@ -634,7 +650,10 @@ public sealed unsafe partial class WbDrawDispatcher /// (M3c) precisely because detail-eligible content no longer reaches it. /// private void DrawImmediateAlphaInstanceRhi( - GlobalMeshBuffer mesh, TranslucencyKind blend, Matrix4x4 viewProjection) + GlobalMeshBuffer mesh, + TranslucencyKind blend, + RetailSetSurfaceMaterialState materialState, + Matrix4x4 viewProjection) { if (_alphaCommands.Buffer is null) return; @@ -644,9 +663,12 @@ public sealed unsafe partial class WbDrawDispatcher GpuPushConstants pushConstants = BindAlphaDrawState( encoder, frame, mesh, viewProjection, out MeshPipelineSet pipelines); - BindPipelineWithMesh(encoder, PipelineForBlend(pipelines, blend), mesh); + BindPipelineWithMesh( + encoder, + PipelineForMaterial(pipelines, materialState, pipelines.Opaque), + mesh); encoder.SetPushConstants(in pushConstants); - ArmBuildingDetail(ref pushConstants); + ArmBuildingDetail(ref pushConstants, materialState); DrawIndirectRangeRhi( encoder, ref pushConstants, @@ -673,6 +695,8 @@ public sealed unsafe partial class WbDrawDispatcher while (command < end) { TranslucencyKind blend = _groupInputScratch[command].Translucency; + RetailSetSurfaceMaterialState materialState = + _groupInputScratch[command].MaterialState; bool hasDetail = detailEnabled && CommandContainsDetailCategory( _indirectCommands[command], @@ -691,10 +715,12 @@ public sealed unsafe partial class WbDrawDispatcher BindPipelineWithMesh( encoder, - PipelineForBlend(pipelines, blend), + hasDetail + ? PipelineForMaterial(pipelines, materialState, pipelines.Opaque) + : PipelineForBlend(pipelines, blend), mesh); if (hasDetail) - ArmBuildingDetail(ref pushConstants); + ArmBuildingDetail(ref pushConstants, materialState); else ClearDetailPushConstants(ref pushConstants); DrawIndirectRangeRhi( @@ -712,6 +738,9 @@ public sealed unsafe partial class WbDrawDispatcher private void DrawDetailAwareRangeRhi( IGpuPassEncoder encoder, + GlobalMeshBuffer mesh, + MeshPipelineSet pipelines, + IGpuPipeline opaquePipeline, ref GpuPushConstants pushConstants, IGpuBuffer commandBuffer, uint commandBase, @@ -738,9 +767,20 @@ public sealed unsafe partial class WbDrawDispatcher } if (hasDetail) - ArmBuildingDetail(ref pushConstants); + { + RetailSetSurfaceMaterialState materialState = + _groupInputScratch[command].MaterialState; + BindPipelineWithMesh( + encoder, + PipelineForMaterial(pipelines, materialState, opaquePipeline), + mesh); + ArmBuildingDetail(ref pushConstants, materialState); + } else + { + BindPipelineWithMesh(encoder, opaquePipeline, mesh); ClearDetailPushConstants(ref pushConstants); + } DrawIndirectRangeRhi( encoder, ref pushConstants, @@ -754,10 +794,17 @@ public sealed unsafe partial class WbDrawDispatcher encoder.SetPushConstants(in pushConstants); } - private void ArmBuildingDetail(ref GpuPushConstants pushConstants) + private void ArmBuildingDetail( + ref GpuPushConstants pushConstants, + RetailSetSurfaceMaterialState materialState) { pushConstants.TextureIndexA = _buildingDetail.TextureSlot.Index; pushConstants.ParamA = _buildingDetail.Tiling; + pushConstants.ParamB = materialState.AlphaTestReference; + if (materialState.FogEnabled) + pushConstants.RenderPass &= ~RetailDetailTextureContract.NoFogRenderPassFlag; + else + pushConstants.RenderPass |= RetailDetailTextureContract.NoFogRenderPassFlag; } private static void ClearDetailPushConstants( @@ -766,6 +813,7 @@ public sealed unsafe partial class WbDrawDispatcher pushConstants.TextureIndexA = 0; pushConstants.ParamA = 0f; pushConstants.ParamB = 0f; + pushConstants.RenderPass &= ~RetailDetailTextureContract.NoFogRenderPassFlag; } private static IGpuPipeline PipelineForBlend(MeshPipelineSet pipelines, TranslucencyKind blend) => @@ -776,6 +824,23 @@ public sealed unsafe partial class WbDrawDispatcher _ => pipelines.AlphaBlend, }; + private static IGpuPipeline PipelineForMaterial( + MeshPipelineSet pipelines, + RetailSetSurfaceMaterialState material, + IGpuPipeline opaquePipeline) => material.Blend switch + { + RetailSetSurfaceBlend.Opaque => opaquePipeline, + RetailSetSurfaceBlend.StraightAlpha => pipelines.AlphaBlend, + RetailSetSurfaceBlend.AlphaAdditive => pipelines.AlphaAdditive, + RetailSetSurfaceBlend.Additive => pipelines.RawAdditive, + RetailSetSurfaceBlend.InverseAlpha => pipelines.AlphaInverse, + RetailSetSurfaceBlend.InverseAdditive => pipelines.InverseAdditive, + // AP-240: ordinary Gfx/building pure Clip stays on its selected + // opaque/A2C pipeline; exact final-X ParamB still reaches shader. + RetailSetSurfaceBlend.Clip => opaquePipeline, + _ => throw new ArgumentOutOfRangeException(nameof(material), material, "Unknown SetSurface blend."), + }; + /// /// Reads cull modes from when the caller /// supplies one, or from the shared scratch @@ -1102,7 +1167,9 @@ public sealed unsafe partial class WbDrawDispatcher pipelines.OpaqueAlphaToCoverage.Dispose(); pipelines.AlphaBlend.Dispose(); pipelines.AlphaAdditive.Dispose(); + pipelines.RawAdditive.Dispose(); pipelines.AlphaInverse.Dispose(); + pipelines.InverseAdditive.Dispose(); } private sealed class NullRhiTimerScope : IDisposable diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs index e9dbdba8..a36fc067 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs @@ -240,6 +240,7 @@ public sealed partial class WbDrawDispatcher key = new GroupKey( batch.FirstIndex, (int)batch.BaseVertex, batch.IndexCount, texture.Slot, texture.Layer, translucency, + MaterialState: batch.MaterialState, FoliageFlags: foliageFlags, SurfaceOpacity: batch.SurfaceOpacity, CullMode: batch.CullMode); diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index dd34acb5..68cb51e6 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -2050,6 +2050,7 @@ public sealed partial class WbDrawDispatcher : IDisposable TextureIndex: g.TextureSlot.Index, TextureLayer: g.TextureLayer, Translucency: g.Translucency, + MaterialState: g.MaterialState, SurfaceOpacity: g.SurfaceOpacity, CullMode: g.CullMode, FoliageFlags: g.FoliageFlags); @@ -2123,6 +2124,7 @@ public sealed partial class WbDrawDispatcher : IDisposable g.TextureSlot, g.TextureLayer, g.Translucency, + g.MaterialState, g.FoliageFlags, g.SurfaceOpacity, g.CullMode); @@ -2144,6 +2146,7 @@ public sealed partial class WbDrawDispatcher : IDisposable TextureSlot = key.TextureSlot, TextureLayer = key.TextureLayer, Translucency = key.Translucency, + MaterialState = key.MaterialState, CullMode = key.CullMode, FoliageFlags = key.FoliageFlags, SurfaceOpacity = key.SurfaceOpacity, @@ -2641,7 +2644,11 @@ public sealed partial class WbDrawDispatcher : IDisposable EnsureDeferredAlphaCapacity(1); WriteDeferredAlphaEntrySlot(0, in entry); PrepareRhiAlphaSections(1); - DrawImmediateAlphaInstanceRhi(global, entry.Key.Translucency, viewProjection); + DrawImmediateAlphaInstanceRhi( + global, + entry.Key.Translucency, + entry.Key.MaterialState, + viewProjection); } private void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount) @@ -3437,6 +3444,7 @@ public sealed partial class WbDrawDispatcher : IDisposable uint TextureIndex, uint TextureLayer, TranslucencyKind Translucency, + RetailSetSurfaceMaterialState MaterialState, float SurfaceOpacity = 1f, CullMode CullMode = CullMode.CounterClockwise, // Campaign VM VM6: FoliageWindClassification.CutoutFoliageFlag / @@ -3675,6 +3683,8 @@ public sealed partial class WbDrawDispatcher : IDisposable AcDream.App.Rendering.Gpu.GpuTextureSlot.Unassigned; public uint TextureLayer; // Layer in either the pooled composite array or WB shared atlas. public TranslucencyKind Translucency; + public RetailSetSurfaceMaterialState MaterialState = + RetailSetSurfaceMaterialState.Opaque; public float SurfaceOpacity = 1f; public CullMode CullMode; public int FirstInstance; // offset into the shared instance VBO (in instances, not bytes) diff --git a/src/AcDream.Content/MeshExtractor.cs b/src/AcDream.Content/MeshExtractor.cs index 9d6a0c78..dbbbd36f 100644 --- a/src/AcDream.Content/MeshExtractor.cs +++ b/src/AcDream.Content/MeshExtractor.cs @@ -396,6 +396,7 @@ public sealed class MeshExtractor { bool isSolid = RetailUntexturedSurfacePolicy.IsUntextured(surface.Type); bool isClipMap = surface.Type.HasFlag(SurfaceType.Base1ClipMap); uint paletteId = 0; + bool texturePresent = false; bool isDxt3or5 = false; bool textureDataIsCached = false; DatReaderWriter.Enums.PixelFormat? sourceFormat = null; @@ -410,6 +411,7 @@ public sealed class MeshExtractor { textureDataIsCached = true; } else if (_dats.Portal.TryGet(surface.OrigTextureId, out var surfaceTexture)) { + texturePresent = true; var renderSurfaceId = surfaceTexture.Textures.First(); if (!_dats.Portal.TryGet(renderSurfaceId, out var renderSurface)) { // check highres @@ -603,6 +605,10 @@ public sealed class MeshExtractor { TranslucencyKindExtensions.OpacityFromSurfaceTranslucency( surface.Type, surface.Translucency), + MaterialState = RetailSetSurfaceMaterialState.Resolve( + surface.Type, + texturePresent, + paletteId != 0), IsTransparent = isTransparent, IsAdditive = isAdditive }; @@ -900,6 +906,7 @@ public sealed class MeshExtractor { bool isSolid = RetailUntexturedSurfacePolicy.IsUntextured(surface.Type); bool isClipMap = surface.Type.HasFlag(SurfaceType.Base1ClipMap); uint paletteId = 0; + bool texturePresent = false; bool isDxt3or5 = false; bool textureDataIsCached = false; DatReaderWriter.Enums.PixelFormat? sourceFormat = null; @@ -914,6 +921,7 @@ public sealed class MeshExtractor { textureDataIsCached = true; } else if (_dats.Portal.TryGet(surface.OrigTextureId, out var surfaceTexture)) { + texturePresent = true; var renderSurfaceId = surfaceTexture.Textures.First(); if (!_dats.Portal.TryGet(renderSurfaceId, out var renderSurface)) { if (!_dats.HighRes.TryGet(renderSurfaceId, out var hrRenderSurface)) { @@ -1080,6 +1088,10 @@ public sealed class MeshExtractor { TranslucencyKindExtensions.OpacityFromSurfaceTranslucency( surface.Type, surface.Translucency), + MaterialState = RetailSetSurfaceMaterialState.Resolve( + surface.Type, + texturePresent, + paletteId != 0), IsTransparent = isTransparent, IsAdditive = isAdditive, }; diff --git a/src/AcDream.Content/ObjectMeshData.cs b/src/AcDream.Content/ObjectMeshData.cs index 1966df3f..84899cfa 100644 --- a/src/AcDream.Content/ObjectMeshData.cs +++ b/src/AcDream.Content/ObjectMeshData.cs @@ -181,6 +181,13 @@ public class TextureBatchData { /// public float SurfaceOpacity { get; set; } = 1f; + /// + /// Exact retail SetSurface blend/alpha-test/fog state resolved during + /// extraction from the source Surface and texture palette class. + /// + public RetailSetSurfaceMaterialState MaterialState { get; set; } = + RetailSetSurfaceMaterialState.Opaque; + /// /// Retail source surface-array index this subset was constructed from /// (contract §3.6) — the CellStruct subset/material OWNER, not the diff --git a/src/AcDream.Content/Pak/ObjectMeshDataSerializer.cs b/src/AcDream.Content/Pak/ObjectMeshDataSerializer.cs index f8e740ca..85de9c2f 100644 --- a/src/AcDream.Content/Pak/ObjectMeshDataSerializer.cs +++ b/src/AcDream.Content/Pak/ObjectMeshDataSerializer.cs @@ -228,9 +228,13 @@ public static class ObjectMeshDataSerializer { w.Write(batch.RetailSurfaceMask); w.Write(batch.RawSurfaceType); w.Write(batch.IsCellShell); - // OVERHAUL S5-c4: recipe 9 appends authored material opacity. Recipe + // OVERHAUL S5-c4: recipe 9 appended authored material opacity. Recipe + // 10 appends the extraction-resolved SetSurface state byte. Recipe // identity rejects older payloads before this serializer is entered. w.Write(batch.SurfaceOpacity); + // S5-c4 fix round 1: recipe 10 appends the immutable resolved + // SetSurface state as one validated deterministic byte. + w.Write(batch.MaterialState.ToPackedByte()); } private static TextureBatchData ReadTextureBatchData( @@ -256,6 +260,9 @@ public static class ObjectMeshDataSerializer { batch.RawSurfaceType = r.ReadUInt32(); batch.IsCellShell = r.ReadBoolean(); batch.SurfaceOpacity = r.ReadSingle(); + batch.MaterialState = + AcDream.Core.Meshing.RetailSetSurfaceMaterialState.FromPackedByte( + r.ReadByte()); return batch; } diff --git a/src/AcDream.Content/Pak/PakFormat.cs b/src/AcDream.Content/Pak/PakFormat.cs index 43171d9f..663975a2 100644 --- a/src/AcDream.Content/Pak/PakFormat.cs +++ b/src/AcDream.Content/Pak/PakFormat.cs @@ -45,9 +45,11 @@ public static class PakFormat { /// (Surface.Type & (BASE1_IMAGE|BASE1_CLIPMAP)) != 0 applied after /// surface resolution. Version 9 appends authored surface opacity to /// every prepared mesh texture batch. The binary format remains version - /// 2, but every prepared render record must be regenerated. + /// 2, but every prepared render record must be regenerated. Version 10 + /// appends the exact resolved SetSurface blend, alpha-test, and fog state + /// to every prepared texture batch; recipe 9 cannot be read as recipe 10. /// - public const uint CurrentBakeToolVersion = 9; + public const uint CurrentBakeToolVersion = 10; } /// diff --git a/src/AcDream.Core/Meshing/TranslucencyKind.cs b/src/AcDream.Core/Meshing/TranslucencyKind.cs index d4ab468c..2927dc15 100644 --- a/src/AcDream.Core/Meshing/TranslucencyKind.cs +++ b/src/AcDream.Core/Meshing/TranslucencyKind.cs @@ -38,6 +38,135 @@ public enum TranslucencyKind InvAlpha = 4, } +/// +/// Exact framebuffer blend selected by retail's +/// D3DPolyRender::SetSurface. This is deliberately separate from +/// , which remains the existing queue-membership +/// classification and cannot distinguish the additive factor pairs. +/// +public enum RetailSetSurfaceBlend : byte +{ + Opaque = 0, + StraightAlpha = 1, + AlphaAdditive = 2, + Additive = 3, + InverseAlpha = 4, + InverseAdditive = 5, + Clip = 6, +} + +/// The exact final-alpha comparison selected by SetSurface. +public enum RetailSetSurfaceAlphaTest : byte +{ + Disabled = 0, + Paletted = 1, + Dds = 2, +} + +/// +/// Immutable resolved SetSurface state prepared from the source Surface and +/// its actual texture/palette class. Renderers consume this value directly; +/// they never reconstruct it from queue kind or texture identity. +/// +public readonly record struct RetailSetSurfaceMaterialState( + RetailSetSurfaceBlend Blend, + RetailSetSurfaceAlphaTest AlphaTest, + bool FogEnabled) +{ + public static RetailSetSurfaceMaterialState Opaque { get; } = new( + RetailSetSurfaceBlend.Opaque, + RetailSetSurfaceAlphaTest.Disabled, + FogEnabled: true); + + public bool AlphaTestEnabled => AlphaTest != RetailSetSurfaceAlphaTest.Disabled; + + public float AlphaTestReference => AlphaTest switch + { + RetailSetSurfaceAlphaTest.Disabled => 0f, + RetailSetSurfaceAlphaTest.Paletted => 100f / 255f, + RetailSetSurfaceAlphaTest.Dds => 200f / 255f, + _ => throw new ArgumentOutOfRangeException(nameof(AlphaTest), AlphaTest, null), + }; + + /// One deterministic recipe byte; bits 6-7 remain reserved. + public byte ToPackedByte() + { + if ((uint)Blend > (uint)RetailSetSurfaceBlend.Clip) + throw new InvalidOperationException($"Unknown SetSurface blend {Blend}."); + if ((uint)AlphaTest > (uint)RetailSetSurfaceAlphaTest.Dds) + throw new InvalidOperationException($"Unknown SetSurface alpha test {AlphaTest}."); + return (byte)((byte)Blend | ((byte)AlphaTest << 3) | (FogEnabled ? 0 : 0x20)); + } + + public static RetailSetSurfaceMaterialState FromPackedByte(byte value) + { + if ((value & 0xC0) != 0) + throw new InvalidDataException($"Reserved SetSurface state bits are set: 0x{value:X2}."); + var blend = (RetailSetSurfaceBlend)(value & 0x07); + var alphaTest = (RetailSetSurfaceAlphaTest)((value >> 3) & 0x03); + if ((uint)blend > (uint)RetailSetSurfaceBlend.Clip + || (uint)alphaTest > (uint)RetailSetSurfaceAlphaTest.Dds) + { + throw new InvalidDataException($"Invalid SetSurface state byte: 0x{value:X2}."); + } + return new RetailSetSurfaceMaterialState( + blend, + alphaTest, + FogEnabled: (value & 0x20) == 0); + } + + public static RetailSetSurfaceMaterialState Resolve( + SurfaceType type, + bool texturePresent, + bool textureHasPalette) + { + bool additive = (type & SurfaceType.Additive) != 0; + bool alpha = (type & SurfaceType.Alpha) != 0; + bool inverse = (type & SurfaceType.InvAlpha) != 0; + bool clip = (type & SurfaceType.Base1ClipMap) != 0; + bool translucent = (type & SurfaceType.Translucent) != 0; + + RetailSetSurfaceBlend blend = alpha + ? additive + ? RetailSetSurfaceBlend.AlphaAdditive + : RetailSetSurfaceBlend.StraightAlpha + : inverse + ? additive + ? RetailSetSurfaceBlend.InverseAdditive + : RetailSetSurfaceBlend.InverseAlpha + : additive + ? RetailSetSurfaceBlend.Additive + : RetailSetSurfaceBlend.Opaque; + + RetailSetSurfaceAlphaTest alphaTest = RetailSetSurfaceAlphaTest.Disabled; + if (clip) + { + alphaTest = texturePresent && textureHasPalette + ? RetailSetSurfaceAlphaTest.Paletted + : RetailSetSurfaceAlphaTest.Dds; + if (blend == RetailSetSurfaceBlend.Opaque) + blend = RetailSetSurfaceBlend.Clip; + } + + // Retail's later Translucent arm forces every ClipMap combination and + // the otherwise-opaque/straight cases to straight alpha, disabling the + // ClipMap test. Established additive/inverse families remain intact. + if (translucent + && (clip + || blend is RetailSetSurfaceBlend.Opaque + or RetailSetSurfaceBlend.StraightAlpha)) + { + blend = RetailSetSurfaceBlend.StraightAlpha; + alphaTest = RetailSetSurfaceAlphaTest.Disabled; + } + + return new RetailSetSurfaceMaterialState( + blend, + alphaTest, + FogEnabled: !additive); + } +} + public static class TranslucencyKindExtensions { // Translucent override comes FIRST, then the existing priority chain: diff --git a/src/AcDream.Launcher.Core/Installation/ContentMigrationCatalog.cs b/src/AcDream.Launcher.Core/Installation/ContentMigrationCatalog.cs index 3dae6459..6d236a26 100644 --- a/src/AcDream.Launcher.Core/Installation/ContentMigrationCatalog.cs +++ b/src/AcDream.Launcher.Core/Installation/ContentMigrationCatalog.cs @@ -61,6 +61,10 @@ public static class ContentMigrationCatalog 8, 9, "authored surface opacity in prepared mesh batches"), + [10] = FullRebuild( + 9, + 10, + "exact resolved SetSurface state in prepared mesh batches"), }; public static ContentMigrationPlan Resolve(uint fromRecipeVersion, uint targetRecipeVersion) diff --git a/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs b/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs index 349ef622..cff72adb 100644 --- a/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs +++ b/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs @@ -39,9 +39,10 @@ public sealed class LauncherInstallRecordStore // with retail's authored DrawingBSP view sphere. Version 8 (OH2/S1) keeps // pak format 2 and regenerates every CellStruct/EnvCell render record // with retail's exact surface-array-index subset construction. Version 9 - // carries authored surface opacity in prepared mesh batches. Each is a - // mandatory full rebuild from its predecessor recipe. - public const uint CurrentBakeToolVersion = 9; + // carries authored surface opacity in prepared mesh batches. Version 10 + // adds exact resolved SetSurface state. Each is a mandatory full rebuild + // from its predecessor recipe. + public const uint CurrentBakeToolVersion = 10; private static readonly JsonSerializerOptions SerializerOptions = new() { diff --git a/tests/AcDream.App.Tests/Rendering/EnvCellAlphaDrawSourceTests.cs b/tests/AcDream.App.Tests/Rendering/EnvCellAlphaDrawSourceTests.cs index 3d5b7b6f..42f6c925 100644 --- a/tests/AcDream.App.Tests/Rendering/EnvCellAlphaDrawSourceTests.cs +++ b/tests/AcDream.App.Tests/Rendering/EnvCellAlphaDrawSourceTests.cs @@ -8,7 +8,9 @@ using AcDream.App.Rendering.Gpu.Vk; using AcDream.App.Rendering.Wb; using AcDream.App.Tests.Rendering.Gpu; using AcDream.Content; +using AcDream.Core.Meshing; using Chorizite.Core.Render.Enums; +using DatReaderWriter.Enums; using Microsoft.Extensions.Logging.Abstractions; using CullMode = DatReaderWriter.Enums.CullMode; @@ -21,6 +23,11 @@ namespace AcDream.App.Tests.Rendering; /// public sealed class EnvCellAlphaDrawSourceTests { + private static readonly Vector4 MaterialBase = new(0.31f, 0.57f, 0.83f, 0.19f); + private static readonly Vector3 MaterialDiffuse = new(0.73f, 0.41f, 0.67f); + private static readonly Vector4 MaterialDetail = new(0.91f, 0.23f, 0.49f, 0.62f); + private static readonly Vector4 MaterialDestination = new(0.17f, 0.37f, 0.71f, 0.29f); + /// /// Canonical F4180104 surface 08000BFF's pure Base1ClipMap mask 0x08 /// reaches CLIP while alpha-family 0x02 reaches ALPHA. Mutation check: @@ -75,26 +82,45 @@ public sealed class EnvCellAlphaDrawSourceTests DrawPipelineNames(fixture.Device)); } - [Theory] - [InlineData(0, "envcell-opaque", 0f)] - [InlineData(1, "envcell-alpha", 0f)] - [InlineData(2, "envcell-additive", 0f)] - [InlineData(3, "envcell-clip", 200f / 255f)] - [InlineData(4, "envcell-clip", 100f / 255f)] - public void DetailOn_EveryEnvCellFamilyDrawsOnceInPlaceWithAuthoredOpacity( - int specIndex, - string expectedPipeline, - float expectedReference) + public static TheoryData ExactMaterialRows() => new() { - BatchSpec spec = specIndex switch - { - 0 => BatchSpec.Opaque, - 1 => BatchSpec.Alpha, - 2 => BatchSpec.Additive, - 3 => BatchSpec.ClipDds, - 4 => BatchSpec.ClipPaletted, - _ => throw new ArgumentOutOfRangeException(nameof(specIndex)), - }; + { SurfaceType.Base1Image, false, "envcell-opaque", GpuBlendMode.None, 0f, true }, + { SurfaceType.Base1Image | SurfaceType.Alpha, false, "envcell-alpha", GpuBlendMode.StraightAlpha, 0f, true }, + { SurfaceType.Base1Image | SurfaceType.Alpha | SurfaceType.Additive, false, "envcell-additive", GpuBlendMode.Additive, 0f, false }, + { SurfaceType.Base1Image | SurfaceType.Additive, false, "envcell-raw-additive", GpuBlendMode.RawAdditive, 0f, false }, + { SurfaceType.Base1Image | SurfaceType.InvAlpha, false, "envcell-inverse", GpuBlendMode.InverseAlpha, 0f, true }, + { SurfaceType.Base1Image | SurfaceType.InvAlpha | SurfaceType.Additive, false, "envcell-inverse-additive", GpuBlendMode.InverseAdditive, 0f, false }, + { SurfaceType.Translucent, false, "envcell-alpha", GpuBlendMode.StraightAlpha, 0f, true }, + { SurfaceType.Translucent | SurfaceType.Additive, false, "envcell-raw-additive", GpuBlendMode.RawAdditive, 0f, false }, + { SurfaceType.Translucent | SurfaceType.InvAlpha, false, "envcell-inverse", GpuBlendMode.InverseAlpha, 0f, true }, + { SurfaceType.Base1Image | SurfaceType.Base1ClipMap, false, "envcell-clip", GpuBlendMode.PremultipliedAlpha, 200f / 255f, true }, + { SurfaceType.Base1Image | SurfaceType.Base1ClipMap, true, "envcell-clip", GpuBlendMode.PremultipliedAlpha, 100f / 255f, true }, + { SurfaceType.Base1Image | SurfaceType.Alpha | SurfaceType.Base1ClipMap, false, "envcell-alpha", GpuBlendMode.StraightAlpha, 200f / 255f, true }, + { SurfaceType.Base1Image | SurfaceType.Alpha | SurfaceType.Base1ClipMap, true, "envcell-alpha", GpuBlendMode.StraightAlpha, 100f / 255f, true }, + { SurfaceType.Base1Image | SurfaceType.Alpha | SurfaceType.Additive | SurfaceType.Base1ClipMap, false, "envcell-additive", GpuBlendMode.Additive, 200f / 255f, false }, + { SurfaceType.Base1Image | SurfaceType.Alpha | SurfaceType.Additive | SurfaceType.Base1ClipMap, true, "envcell-additive", GpuBlendMode.Additive, 100f / 255f, false }, + { SurfaceType.Base1Image | SurfaceType.Additive | SurfaceType.Base1ClipMap, false, "envcell-raw-additive", GpuBlendMode.RawAdditive, 200f / 255f, false }, + { SurfaceType.Base1Image | SurfaceType.Additive | SurfaceType.Base1ClipMap, true, "envcell-raw-additive", GpuBlendMode.RawAdditive, 100f / 255f, false }, + { SurfaceType.Base1Image | SurfaceType.InvAlpha | SurfaceType.Base1ClipMap, false, "envcell-inverse", GpuBlendMode.InverseAlpha, 200f / 255f, true }, + { SurfaceType.Base1Image | SurfaceType.InvAlpha | SurfaceType.Base1ClipMap, true, "envcell-inverse", GpuBlendMode.InverseAlpha, 100f / 255f, true }, + { SurfaceType.Base1Image | SurfaceType.InvAlpha | SurfaceType.Additive | SurfaceType.Base1ClipMap, false, "envcell-inverse-additive", GpuBlendMode.InverseAdditive, 200f / 255f, false }, + { SurfaceType.Base1Image | SurfaceType.InvAlpha | SurfaceType.Additive | SurfaceType.Base1ClipMap, true, "envcell-inverse-additive", GpuBlendMode.InverseAdditive, 100f / 255f, false }, + { SurfaceType.Translucent | SurfaceType.Base1ClipMap | SurfaceType.Additive, false, "envcell-alpha", GpuBlendMode.StraightAlpha, 0f, false }, + { SurfaceType.Translucent | SurfaceType.Base1ClipMap | SurfaceType.Additive, true, "envcell-alpha", GpuBlendMode.StraightAlpha, 0f, false }, + }; + + [Theory] + [MemberData(nameof(ExactMaterialRows))] + public void DetailOn_EveryEnvCellFamilyDrawsOnceInPlaceWithAuthoredOpacity( + SurfaceType surfaceType, + bool paletted, + string expectedPipeline, + object expectedBlendValue, + float expectedReference, + bool expectedFog) + { + var expectedBlend = (GpuBlendMode)expectedBlendValue; + BatchSpec spec = new(surfaceType, paletted, PositiveStippling: false); using var fixture = new ProductionEnvCellFixture( detailSurfaceActive: true, spec); @@ -113,6 +139,20 @@ public sealed class EnvCellAlphaDrawSourceTests DrawPushConstants(fixture.Device), constants => constants.ParamA != 0f); Assert.NotEqual(0u, armed.TextureIndexA); + Assert.Equal( + !expectedFog, + (armed.RenderPass & RetailDetailTextureContract.NoFogRenderPassFlag) != 0); + RetailSetSurfaceMaterialState resolved = RetailSetSurfaceMaterialState.Resolve( + surfaceType, texturePresent: true, textureHasPalette: paletted); + GpuPipelineDescription selected = fixture.Device.CreatedPipelines + .Single(pipeline => pipeline.Description.Name == expectedPipeline) + .Description; + Assert.Equal(expectedBlend, selected.Blend); + Assert.True(selected.Depth.Test); + Assert.Equal( + resolved.Blend is RetailSetSurfaceBlend.Opaque or RetailSetSurfaceBlend.Clip, + selected.Depth.Write); + Assert.Equal(WorldDepthContract.WorldCompare, selected.Depth.Compare); Assert.Equal(0, fixture.Queue.PendingCount); GpuRecordedStorageBind batchBind = fixture.Device.Calls .OfType() @@ -120,10 +160,77 @@ public sealed class EnvCellAlphaDrawSourceTests ReadOnlySpan gpuBatches = MemoryMarshal.Cast( fixture.Device.RingBytes.Slice((int)batchBind.OffsetBytes, (int)batchBind.SizeBytes)); Assert.Equal(0.25f, Assert.Single(gpuBatches.ToArray()).SurfaceOpacity); + Vector4 source = RetailDetailTextureContract.Combine( + MaterialBase, MaterialDiffuse, MaterialDetail, 0.75f, 0.4f); + AssertVector(new Vector4(0.3534682f, 0.2330118f, 0.5438054f, 0.11532f), source); + RetailDetailTextureContract.FramebufferFamily family = expectedBlend switch + { + GpuBlendMode.None => RetailDetailTextureContract.FramebufferFamily.Opaque, + GpuBlendMode.StraightAlpha => RetailDetailTextureContract.FramebufferFamily.Alpha, + GpuBlendMode.Additive => RetailDetailTextureContract.FramebufferFamily.AlphaAdditive, + GpuBlendMode.RawAdditive => RetailDetailTextureContract.FramebufferFamily.Additive, + GpuBlendMode.InverseAlpha => RetailDetailTextureContract.FramebufferFamily.InverseAlpha, + GpuBlendMode.InverseAdditive => RetailDetailTextureContract.FramebufferFamily.InverseAlphaAdditive, + GpuBlendMode.PremultipliedAlpha => RetailDetailTextureContract.FramebufferFamily.Clip, + _ => throw new ArgumentOutOfRangeException(nameof(expectedBlend)), + }; + AssertVector( + IndependentComposite(source, MaterialDestination, family), + RetailDetailTextureContract.Composite(source, MaterialDestination, family)); + if (resolved.AlphaTestEnabled) + { + Assert.False(RetailDetailTextureContract.SurvivesClip( + MathF.BitDecrement(expectedReference), expectedReference)); + Assert.True(RetailDetailTextureContract.SurvivesClip(expectedReference, expectedReference)); + Assert.True(RetailDetailTextureContract.SurvivesClip( + MathF.BitIncrement(expectedReference), expectedReference)); + } fixture.Queue.EndFrame(); Assert.Single(fixture.Device.Calls.OfType()); } + public static TheoryData DetailOffRows() => new() + { + { SurfaceType.Base1Image, false, "envcell-opaque", 0f }, + { SurfaceType.Alpha, false, "envcell-alpha", 0f }, + { SurfaceType.Alpha | SurfaceType.Additive, false, "envcell-additive", 0f }, + { SurfaceType.Additive, false, "envcell-additive", 0f }, + { SurfaceType.InvAlpha, false, "envcell-alpha", 0f }, + { SurfaceType.InvAlpha | SurfaceType.Additive, false, "envcell-additive", 0f }, + { SurfaceType.Base1ClipMap, false, "envcell-clip", 200f / 255f }, + { SurfaceType.Base1ClipMap, true, "envcell-clip", 100f / 255f }, + { SurfaceType.Alpha | SurfaceType.Base1ClipMap, true, "envcell-alpha", 0f }, + { SurfaceType.InvAlpha | SurfaceType.Base1ClipMap, false, "envcell-alpha", 0f }, + { SurfaceType.Translucent, false, "envcell-alpha", 0f }, + { SurfaceType.Translucent | SurfaceType.Additive, false, "envcell-additive", 0f }, + { SurfaceType.Translucent | SurfaceType.InvAlpha, false, "envcell-alpha", 0f }, + { SurfaceType.Translucent | SurfaceType.Base1ClipMap | SurfaceType.Additive, true, "envcell-additive", 0f }, + }; + + [Theory] + [MemberData(nameof(DetailOffRows))] + public void DetailOff_EveryRawStateRetainsThePreFixLogicalPath( + SurfaceType surfaceType, + bool paletted, + string expectedPipeline, + float expectedReference) + { + using var fixture = new ProductionEnvCellFixture( + detailSurfaceActive: false, + new BatchSpec(surfaceType, paletted, PositiveStippling: false)); + fixture.Queue.BeginFrame(); + fixture.Leaf.DrawCellShell(ProductionEnvCellFixture.CellId); + fixture.Queue.EndFrame(); + + Assert.Single(fixture.Device.Calls.OfType()); + Assert.Equal([expectedPipeline], DrawPipelineNames(fixture.Device)); + GpuPushConstants constants = Assert.Single(DrawPushConstants(fixture.Device)); + Assert.Equal(0u, constants.TextureIndexA); + Assert.Equal(0f, constants.ParamA); + Assert.Equal(expectedReference, constants.ParamB); + Assert.Equal(0, constants.RenderPass & RetailDetailTextureContract.NoFogRenderPassFlag); + } + /// Two cell tokens from the SAME , /// with an unrelated source's entry appended between them, must still /// produce TWO separate single-cell draw calls around the interposed @@ -258,9 +365,10 @@ public sealed class EnvCellAlphaDrawSourceTests foreach (string shader in new[] { modern, atmospheric }) { Assert.Contains( - "if (detailActive ? alpha < alphaCutoff : color.a < alphaCutoff)", + "? isRetailClipReference(uParamB) && alpha < uParamB", shader, StringComparison.Ordinal); + Assert.Contains("(uRenderPass & 0x200) == 0", shader, StringComparison.Ordinal); Assert.DoesNotContain("alpha <= alphaCutoff", shader, StringComparison.Ordinal); Assert.DoesNotContain("color.a <= alphaCutoff", shader, StringComparison.Ordinal); Assert.Contains("isRetailClipReference(uParamB) ? uParamB : 0.05", shader, StringComparison.Ordinal); @@ -409,6 +517,38 @@ public sealed class EnvCellAlphaDrawSourceTests return [.. constants]; } + private static void AssertVector(Vector4 expected, Vector4 actual) + { + Assert.Equal(expected.X, actual.X, 6); + Assert.Equal(expected.Y, actual.Y, 6); + Assert.Equal(expected.Z, actual.Z, 6); + Assert.Equal(expected.W, actual.W, 6); + } + + private static Vector4 IndependentComposite( + Vector4 source, + Vector4 destination, + RetailDetailTextureContract.FramebufferFamily family) + { + float x = source.W; + return family switch + { + RetailDetailTextureContract.FramebufferFamily.Opaque => source, + RetailDetailTextureContract.FramebufferFamily.Alpha => + source * x + destination * (1f - x), + RetailDetailTextureContract.FramebufferFamily.AlphaAdditive => + source * x + destination, + RetailDetailTextureContract.FramebufferFamily.Additive => source + destination, + RetailDetailTextureContract.FramebufferFamily.InverseAlpha => + source * (1f - x) + destination * x, + RetailDetailTextureContract.FramebufferFamily.InverseAlphaAdditive => + source * (1f - x) + destination, + RetailDetailTextureContract.FramebufferFamily.Clip => + source + destination * new Vector4(1f - x), + _ => throw new ArgumentOutOfRangeException(nameof(family)), + }; + } + private static string RepositoryRoot() { DirectoryInfo? cursor = new(AppContext.BaseDirectory); @@ -419,46 +559,32 @@ public sealed class EnvCellAlphaDrawSourceTests } private readonly record struct BatchSpec( - byte Mask, - bool IsTransparent, - AcDream.Core.Meshing.TranslucencyKind Translucency, - uint PaletteId) + SurfaceType Type, + bool Paletted, + bool PositiveStippling) { internal static BatchSpec Opaque { get; } = new( - 0x00, - IsTransparent: false, - AcDream.Core.Meshing.TranslucencyKind.Opaque, - PaletteId: 0); + SurfaceType.Base1Image, Paletted: false, PositiveStippling: false); internal static BatchSpec ClipDds { get; } = new( - RetailAlphaMeshRouter.MaskClipMap, - IsTransparent: true, - AcDream.Core.Meshing.TranslucencyKind.ClipMap, - PaletteId: 0); + SurfaceType.Base1Image | SurfaceType.Base1ClipMap, + Paletted: false, PositiveStippling: false); internal static BatchSpec ClipPaletted { get; } = new( - RetailAlphaMeshRouter.MaskClipMap, - IsTransparent: true, - AcDream.Core.Meshing.TranslucencyKind.ClipMap, - PaletteId: 0x04000001u); + SurfaceType.Base1Image | SurfaceType.Base1ClipMap, + Paletted: true, PositiveStippling: false); internal static BatchSpec ClipPositiveStippleDds { get; } = new( - RetailAlphaMeshRouter.MaskClipMap | RetailAlphaMeshRouter.MaskPositiveStipple, - IsTransparent: true, - AcDream.Core.Meshing.TranslucencyKind.ClipMap, - PaletteId: 0); + SurfaceType.Base1Image | SurfaceType.Base1ClipMap, + Paletted: false, PositiveStippling: true); internal static BatchSpec Alpha { get; } = new( - RetailAlphaMeshRouter.MaskAlphaFamily, - IsTransparent: true, - AcDream.Core.Meshing.TranslucencyKind.AlphaBlend, - PaletteId: 0); + SurfaceType.Base1Image | SurfaceType.Alpha, + Paletted: false, PositiveStippling: false); internal static BatchSpec Additive { get; } = new( - RetailAlphaMeshRouter.MaskAlphaFamily, - IsTransparent: true, - AcDream.Core.Meshing.TranslucencyKind.Additive, - PaletteId: 0); + SurfaceType.Base1Image | SurfaceType.Additive, + Paletted: false, PositiveStippling: false); } /// @@ -512,27 +638,45 @@ public sealed class EnvCellAlphaDrawSourceTests for (int i = 0; i < specs.Length; i++) { BatchSpec spec = specs[i]; + TranslucencyKind translucency = + TranslucencyKindExtensions.FromSurfaceType(spec.Type); + bool alphaFamily = (spec.Type + & (SurfaceType.Alpha | SurfaceType.InvAlpha | SurfaceType.Additive)) != 0; + byte mask = RetailAlphaMeshRouter.ConstructSubsetMask( + alphaFamily, + (spec.Type & SurfaceType.Base1ClipMap) != 0, + (spec.Type & SurfaceType.Translucent) != 0, + spec.PositiveStippling); + uint paletteId = spec.Paletted ? 0x04000001u : 0u; batches.Add(new TextureBatchData { Key = new TextureKey { SurfaceId = 0x08000BFFu + (uint)i, - PaletteId = spec.PaletteId, + PaletteId = paletteId, }, TextureData = new byte[8 * 8 * 4], Indices = [0, 1, 2], - IsTransparent = spec.IsTransparent, - IsAdditive = spec.Translucency == AcDream.Core.Meshing.TranslucencyKind.Additive, - Translucency = spec.Translucency, + IsTransparent = translucency != TranslucencyKind.Opaque, + IsAdditive = (spec.Type & SurfaceType.Additive) != 0, + Translucency = translucency, + MaterialState = RetailSetSurfaceMaterialState.Resolve( + spec.Type, + texturePresent: true, + textureHasPalette: spec.Paletted), SurfaceOpacity = 0.25f, - RetailSurfaceMask = spec.Mask, + RetailSurfaceMask = mask, CullMode = CullMode.Clockwise, IsCellShell = true, SourceSurfaceIndex = i, }); } mesh.TextureBatches[(8, 8, TextureFormat.RGBA8)] = batches; - Assert.NotNull(_meshManager.UploadMeshData(mesh)); + ObjectRenderData uploaded = Assert.IsType( + _meshManager.UploadMeshData(mesh)); + Assert.Equal( + batches.Select(static batch => batch.MaterialState), + uploaded.Batches.Select(static batch => batch.MaterialState)); Renderer = new EnvCellRenderer( Device, diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs index c42b9cd7..1082c086 100644 --- a/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs @@ -80,12 +80,14 @@ public sealed class GpuContractTests // pure Base1ClipMap with override=false (SetSurface @0x0059c72a), while // detail is now computed inside those same pipelines. Collapsing any of // these modes changes the corresponding subset's composition. - Assert.Equal(5, Enum.GetValues().Length); + Assert.Equal(7, Enum.GetValues().Length); Assert.Contains(GpuBlendMode.None, Enum.GetValues()); Assert.Contains(GpuBlendMode.StraightAlpha, Enum.GetValues()); Assert.Contains(GpuBlendMode.PremultipliedAlpha, Enum.GetValues()); Assert.Contains(GpuBlendMode.Additive, Enum.GetValues()); + Assert.Contains(GpuBlendMode.RawAdditive, Enum.GetValues()); Assert.Contains(GpuBlendMode.InverseAlpha, Enum.GetValues()); + Assert.Contains(GpuBlendMode.InverseAdditive, Enum.GetValues()); } [Fact] diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs index 2a1f7c09..17da0f34 100644 --- a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs @@ -45,7 +45,9 @@ public sealed class VulkanShaderManifestTests // single-pass building detail material now runs inside the base // fragment. Final alpha is authoredOpacity*liveFade*detail.a^2; // CLIP tests that value while detail-off retains base-alpha logic. - ["mesh_modern.frag.spv"] = "576af229b96a2a5e9b3b1ff16ab686d96578c2d9d1762b0e225df31bfd7aa86f", + // Fix round 1 additionally carries the exact SetSurface alpha-test + // reference and raw-additive fog suppression into this same pass. + ["mesh_modern.frag.spv"] = "bd47fe8a33e0f1d025fe48fd95b034ba6fe591a6d20e88e636c86238d8773db1", // S5-c4 replaces the padding word at batch offset 4 with authored // surface opacity and forwards it plus exact detail category/flags // flat to the fragment shader. The 16-byte ABI is unchanged. diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanViewportMappingTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanViewportMappingTests.cs index a19c0c79..8b760695 100644 --- a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanViewportMappingTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanViewportMappingTests.cs @@ -143,6 +143,12 @@ public sealed class VulkanViewportMappingTests Assert.Equal( (BlendFactor.SrcAlpha, BlendFactor.One), VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.Additive)); + Assert.Equal( + (BlendFactor.One, BlendFactor.One), + VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.RawAdditive)); + Assert.Equal( + (BlendFactor.OneMinusSrcAlpha, BlendFactor.One), + VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.InverseAdditive)); // Retail's third mode, found at slice V4c. Mapping it onto straight // alpha would have silently changed how every inverse-alpha surface // composites. diff --git a/tests/AcDream.App.Tests/Rendering/RetailDetailTextureContractTests.cs b/tests/AcDream.App.Tests/Rendering/RetailDetailTextureContractTests.cs index e2ef0eac..fd391e69 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailDetailTextureContractTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailDetailTextureContractTests.cs @@ -14,6 +14,45 @@ public sealed class RetailDetailTextureContractTests private static readonly Vector4 Detail = new(0.91f, 0.23f, 0.49f, 0.62f); private static readonly Vector4 Destination = new(0.17f, 0.37f, 0.71f, 0.29f); + public static TheoryData ResolvedRows() => new() + { + { SurfaceType.Base1Image, false, RetailSetSurfaceBlend.Opaque, RetailSetSurfaceAlphaTest.Disabled, true }, + { SurfaceType.Alpha, false, RetailSetSurfaceBlend.StraightAlpha, RetailSetSurfaceAlphaTest.Disabled, true }, + { SurfaceType.Alpha | SurfaceType.Additive, false, RetailSetSurfaceBlend.AlphaAdditive, RetailSetSurfaceAlphaTest.Disabled, false }, + { SurfaceType.Additive, false, RetailSetSurfaceBlend.Additive, RetailSetSurfaceAlphaTest.Disabled, false }, + { SurfaceType.InvAlpha, false, RetailSetSurfaceBlend.InverseAlpha, RetailSetSurfaceAlphaTest.Disabled, true }, + { SurfaceType.InvAlpha | SurfaceType.Additive, false, RetailSetSurfaceBlend.InverseAdditive, RetailSetSurfaceAlphaTest.Disabled, false }, + { SurfaceType.Base1ClipMap, false, RetailSetSurfaceBlend.Clip, RetailSetSurfaceAlphaTest.Dds, true }, + { SurfaceType.Base1ClipMap, true, RetailSetSurfaceBlend.Clip, RetailSetSurfaceAlphaTest.Paletted, true }, + { SurfaceType.Alpha | SurfaceType.Base1ClipMap, true, RetailSetSurfaceBlend.StraightAlpha, RetailSetSurfaceAlphaTest.Paletted, true }, + { SurfaceType.InvAlpha | SurfaceType.Additive | SurfaceType.Base1ClipMap, false, RetailSetSurfaceBlend.InverseAdditive, RetailSetSurfaceAlphaTest.Dds, false }, + { SurfaceType.Translucent, false, RetailSetSurfaceBlend.StraightAlpha, RetailSetSurfaceAlphaTest.Disabled, true }, + { SurfaceType.Translucent | SurfaceType.Additive, false, RetailSetSurfaceBlend.Additive, RetailSetSurfaceAlphaTest.Disabled, false }, + { SurfaceType.Translucent | SurfaceType.InvAlpha, false, RetailSetSurfaceBlend.InverseAlpha, RetailSetSurfaceAlphaTest.Disabled, true }, + { SurfaceType.Translucent | SurfaceType.Base1ClipMap | SurfaceType.Additive, true, RetailSetSurfaceBlend.StraightAlpha, RetailSetSurfaceAlphaTest.Disabled, false }, + }; + + [Theory] + [MemberData(nameof(ResolvedRows))] + public void SetSurfaceResolver_PreservesEveryRawFamilyClipAndLateOverride( + SurfaceType type, + bool paletted, + RetailSetSurfaceBlend expectedBlend, + RetailSetSurfaceAlphaTest expectedAlphaTest, + bool expectedFog) + { + RetailSetSurfaceMaterialState state = RetailSetSurfaceMaterialState.Resolve( + type, + texturePresent: true, + textureHasPalette: paletted); + + Assert.Equal(expectedBlend, state.Blend); + Assert.Equal(expectedAlphaTest, state.AlphaTest); + Assert.Equal(expectedFog, state.FogEnabled); + Assert.Equal(state, RetailSetSurfaceMaterialState.FromPackedByte(state.ToPackedByte())); + } + public static TheoryData PolicyRows() { var rows = new TheoryData(); @@ -65,27 +104,22 @@ public sealed class RetailDetailTextureContractTests Assert.Equal(["source-subset"], PhysicalDrawSequence(detailEnabled)); PipelineState state = ExpectedPipelineState( - consumer, expectedKind, paletted, expectedReference); - Assert.Equal(expectedReference, state.AlphaReference); - Assert.Equal( - expectedKind == TranslucencyKind.ClipMap - ? paletted ? 100f / 255f : 200f / 255f - : 0.05f, - state.AlphaReference); - Assert.True(state.AlphaTest); - Assert.Equal(expectedKind is TranslucencyKind.Opaque or TranslucencyKind.ClipMap, - state.DepthWrite); - Assert.Equal(expectedKind switch + consumer, surfaceType, expectedKind, paletted, detailEnabled); + RetailSetSurfaceMaterialState resolved = RetailSetSurfaceMaterialState.Resolve( + surfaceType, + texturePresent: true, + textureHasPalette: paletted); + if (detailEnabled) { - TranslucencyKind.Opaque => GpuBlendMode.None, - TranslucencyKind.AlphaBlend => GpuBlendMode.StraightAlpha, - TranslucencyKind.Additive => GpuBlendMode.Additive, - TranslucencyKind.InvAlpha => GpuBlendMode.InverseAlpha, - TranslucencyKind.ClipMap when consumer == "EnvCell" => - GpuBlendMode.PremultipliedAlpha, - TranslucencyKind.ClipMap => GpuBlendMode.None, - _ => throw new ArgumentOutOfRangeException(nameof(expectedKind)), - }, state.Blend); + Assert.Equal(resolved.AlphaTestReference, state.AlphaReference); + Assert.Equal(resolved.AlphaTestEnabled, state.AlphaTest); + Assert.Equal(resolved.FogEnabled, state.FogEnabled); + } + else + { + Assert.Equal(expectedReference, state.AlphaReference); + Assert.True(state.AlphaTest); + } Assert.Equal(detailEnabled, DetailIsArmed(detailEnabled)); if (consumer == "Building" && expectedKind == TranslucencyKind.ClipMap) Assert.Equal("AP-240 immediate A2C", state.Placement); @@ -263,11 +297,38 @@ public sealed class RetailDetailTextureContractTests private static PipelineState ExpectedPipelineState( string consumer, + SurfaceType surfaceType, TranslucencyKind kind, bool paletted, - float reference) + bool detailEnabled) { - _ = paletted; + RetailSetSurfaceMaterialState resolved = RetailSetSurfaceMaterialState.Resolve( + surfaceType, + texturePresent: true, + textureHasPalette: paletted); + if (detailEnabled) + { + GpuBlendMode exactBlend = resolved.Blend switch + { + RetailSetSurfaceBlend.Opaque => GpuBlendMode.None, + RetailSetSurfaceBlend.StraightAlpha => GpuBlendMode.StraightAlpha, + RetailSetSurfaceBlend.AlphaAdditive => GpuBlendMode.Additive, + RetailSetSurfaceBlend.Additive => GpuBlendMode.RawAdditive, + RetailSetSurfaceBlend.InverseAlpha => GpuBlendMode.InverseAlpha, + RetailSetSurfaceBlend.InverseAdditive => GpuBlendMode.InverseAdditive, + RetailSetSurfaceBlend.Clip => GpuBlendMode.PremultipliedAlpha, + _ => throw new ArgumentOutOfRangeException(), + }; + return new PipelineState( + exactBlend, + resolved.Blend is RetailSetSurfaceBlend.Opaque or RetailSetSurfaceBlend.Clip, + resolved.AlphaTestEnabled, + resolved.AlphaTestReference, + resolved.FogEnabled, + consumer == "Building" && kind == TranslucencyKind.ClipMap + ? "AP-240 immediate A2C" + : "source FIFO position"); + } GpuBlendMode blend = kind switch { TranslucencyKind.Opaque => GpuBlendMode.None, @@ -283,7 +344,10 @@ public sealed class RetailDetailTextureContractTests blend, kind is TranslucencyKind.Opaque or TranslucencyKind.ClipMap, AlphaTest: true, - reference, + kind == TranslucencyKind.ClipMap + ? paletted ? 100f / 255f : 200f / 255f + : 0.05f, + FogEnabled: true, consumer == "Building" && kind == TranslucencyKind.ClipMap ? "AP-240 immediate A2C" : "source FIFO position"); @@ -310,6 +374,7 @@ public sealed class RetailDetailTextureContractTests bool DepthWrite, bool AlphaTest, float AlphaReference, + bool FogEnabled, string Placement); private static Vector4 IndependentComposite( diff --git a/tests/AcDream.App.Tests/Rendering/Walk/OrderPreservingSubmitterTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/OrderPreservingSubmitterTests.cs index 9e5419ba..1782c9c4 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/OrderPreservingSubmitterTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/OrderPreservingSubmitterTests.cs @@ -32,7 +32,8 @@ public sealed class OrderPreservingSubmitterTests WalkDrawStage stage = WalkDrawStage.Terrain, TranslucencyKind translucency = TranslucencyKind.Opaque, CullMode cullMode = CullMode.CounterClockwise, - uint detailCategory = 0) => + uint detailCategory = 0, + RetailSetSurfaceMaterialState? materialState = null) => new( Key: new GroupKey( FirstIndex: (uint)index * 3, @@ -41,6 +42,7 @@ public sealed class OrderPreservingSubmitterTests TextureSlot: new GpuTextureSlot((uint)index), TextureLayer: 0, Translucency: translucency, + MaterialState: materialState ?? RetailSetSurfaceMaterialState.Opaque, FoliageFlags: 0, CullMode: cullMode), Transform: Matrix4x4.CreateTranslation(index, index * 2, index * 3), @@ -284,8 +286,12 @@ public sealed class OrderPreservingSubmitterTests Assert.Equal([(0, 1), (1, 1)], DecodeDrawRanges(fx.Device)); } - [Fact] - public void PrepareThenDraw_OrdinaryBuildingClipBuildingOrdinary_ArmsOnePassInPlace() + [Theory] + [InlineData(false, 200f / 255f)] + [InlineData(true, 100f / 255f)] + public void PrepareThenDraw_OrdinaryBuildingClipBuildingOrdinary_ArmsOnePassInPlace( + bool paletted, + float expectedReference) { using var fx = new DispatcherFixture(detailAvailable: true, detailEnabled: true); using DrawScope draw = fx.BeginDraw(); @@ -293,7 +299,14 @@ public sealed class OrderPreservingSubmitterTests OrderedDrawStream stream = StreamOf( MakeCommand(0), MakeCommand(1, detailCategory: 1), - MakeCommand(2, translucency: TranslucencyKind.ClipMap, detailCategory: 1), + MakeCommand( + 2, + translucency: TranslucencyKind.ClipMap, + detailCategory: 1, + materialState: RetailSetSurfaceMaterialState.Resolve( + SurfaceType.Base1ClipMap, + texturePresent: true, + textureHasPalette: paletted)), MakeCommand(3)); PrepareAndDrawWhole(fx.Dispatcher, draw, stream); @@ -317,8 +330,90 @@ public sealed class OrderPreservingSubmitterTests .Select(call => call.PipelineName) .ToArray()); Assert.Equal(4, fx.Device.Calls.OfType().Count()); + Assert.Equal(expectedReference, runs[2].Constants.ParamB); + GpuPipelineDescription a2c = fx.Device.CreatedPipelines + .Single(pipeline => pipeline.Description.Name == "wb-mesh-opaque-a2c-1x") + .Description; + Assert.Equal(GpuBlendMode.None, a2c.Blend); + Assert.True(a2c.AlphaToCoverage); + Assert.True(a2c.Depth.Test); + Assert.True(a2c.Depth.Write); + Assert.Equal(WorldDepthContract.WorldCompare, a2c.Depth.Compare); } + [Fact] + public void PrepareThenDraw_PureClipBuildingDetailOffRetainsAp240A2cAndNeutralDetailState() + { + using var fx = new DispatcherFixture(detailAvailable: true, detailEnabled: false); + using DrawScope draw = fx.BeginDraw(); + OrderedDrawStream stream = StreamOf( + MakeCommand(0), + MakeCommand( + 1, + translucency: TranslucencyKind.ClipMap, + detailCategory: 1, + materialState: RetailSetSurfaceMaterialState.Resolve( + SurfaceType.Base1ClipMap, + texturePresent: true, + textureHasPalette: true)), + MakeCommand(2)); + + PrepareAndDrawWhole(fx.Dispatcher, draw, stream); + + List<(GpuPushConstants Constants, int Start, int Count)> runs = DecodeRuns(fx.Device); + Assert.Equal([(0, 1), (1, 1), (2, 1)], + runs.Select(run => (run.Start, run.Count)).ToList()); + Assert.Equal(0u, runs[1].Constants.TextureIndexA); + Assert.Equal(0f, runs[1].Constants.ParamA); + Assert.Equal(0f, runs[1].Constants.ParamB); + Assert.Equal(0, runs[1].Constants.RenderPass & RetailDetailTextureContract.NoFogRenderPassFlag); + Assert.Contains(fx.Device.Calls.OfType(), + call => call.PipelineName == "wb-mesh-opaque-a2c-1x"); + } + + [Fact] + public void AtmosphericReceiver_AdjacentOrdinaryDetailOrdinaryCommandsDoNotLeakExactState() + { + using var fx = new DispatcherFixture( + detailAvailable: true, + detailEnabled: true, + atmospheric: true); + using DrawScope draw = fx.BeginDraw(); + OrderedDrawStream stream = StreamOf( + MakeCommand(0), + MakeCommand( + 1, + translucency: TranslucencyKind.Additive, + detailCategory: 1, + materialState: RetailSetSurfaceMaterialState.Resolve( + SurfaceType.Additive, + texturePresent: true, + textureHasPalette: false)), + MakeCommand(2)); + + PrepareAndDrawWhole(fx.Dispatcher, draw, stream); + + List<(GpuPushConstants Constants, int Start, int Count)> runs = DecodeRuns(fx.Device); + Assert.Equal([(0, 1), (1, 1), (2, 1)], + runs.Select(run => (run.Start, run.Count)).ToList()); + Assert.Equal((0u, 0f, 0f, 0), DetailState(runs[0].Constants)); + Assert.Equal((77u, 3.5f, 0f, RetailDetailTextureContract.NoFogRenderPassFlag), + DetailState(runs[1].Constants)); + Assert.Equal((0u, 0f, 0f, 0), DetailState(runs[2].Constants)); + Assert.Contains(fx.Device.Calls.OfType(), + call => call.PipelineName == "wb-mesh-atmospheric-raw-additive-1x"); + GpuPipelineDescription selected = fx.Device.CreatedPipelines + .Single(pipeline => pipeline.Description.Name == "wb-mesh-atmospheric-raw-additive-1x") + .Description; + Assert.Equal("mesh_atmospheric", selected.Shaders.Name); + Assert.Equal(GpuBlendMode.RawAdditive, selected.Blend); + } + + private static (uint Slot, float Tiling, float Reference, int NoFog) DetailState( + GpuPushConstants constants) => + (constants.TextureIndexA, constants.ParamA, constants.ParamB, + constants.RenderPass & RetailDetailTextureContract.NoFogRenderPassFlag); + [Fact] public void PrepareThenDraw_OpaqueRunUsesRenderPassZeroAndAlphaBlendRunUsesRenderPassOne() { @@ -609,7 +704,10 @@ public sealed class OrderPreservingSubmitterTests private readonly WbMeshAdapter _meshAdapter; private readonly TextureCache _textures; - public DispatcherFixture(bool detailAvailable = false, bool detailEnabled = false) + public DispatcherFixture( + bool detailAvailable = false, + bool detailEnabled = false, + bool atmospheric = false) { Device = new RecordingGpuDevice(); FrameLifetime = new GpuDeviceFrameLifetime(Device); @@ -640,6 +738,15 @@ public sealed class OrderPreservingSubmitterTests new GpuTextureSlot(77), 3.5f, 0x05000001, 0x08000001, 16, 16) : default, buildingDetailEnabled: () => detailEnabled); + if (atmospheric) + { + var source = new BindableAtmosphericSource(); + WbDrawDispatcher.DirectionalShadowReceiverPipelineState candidate = + Assert.IsType( + Dispatcher.PrepareDirectionalShadowReceiver(source, sampleCount: 1)); + Assert.Null(Dispatcher.SwapDirectionalShadowReceiver(candidate)); + } + Atmospheric = atmospheric; } public RecordingGpuDevice Device { get; } @@ -650,6 +757,8 @@ public sealed class OrderPreservingSubmitterTests public WbDrawDispatcher Dispatcher { get; } + public bool Atmospheric { get; } + /// Opens a frame and a backbuffer pass, publishes it on /// , then clears the recorded calls so a test only /// sees what its own SubmitOrderedStream call produced. @@ -659,7 +768,11 @@ public sealed class OrderPreservingSubmitterTests IGpuFrame frame = FrameLifetime.CurrentFrame!; IGpuPassEncoder pass = frame.BeginPass( GpuPassDescription.BackbufferClear( - "fw2-ordered-stream-test", Vector4.Zero, sampleCount: 1)); + Atmospheric + ? DirectionalShadowReceiverPolicy.AtmosphericWorldPassName + : "fw2-ordered-stream-test", + Vector4.Zero, + sampleCount: 1)); IDisposable publication = Scope.Publish(pass); Device.Clear(); return new DrawScope(frame, pass, publication); @@ -674,6 +787,30 @@ public sealed class OrderPreservingSubmitterTests } } + private sealed class BindableAtmosphericSource : IDirectionalShadowReceiverSource + { + public DirectionalShadowPipelineShaders PipelineShaders => + DirectionalShadowPipelineShaders.Local; + + public bool TryGetCurrentFrameBinding( + IGpuFrame frame, + out DirectionalShadowFrameBinding binding) + { + GpuRingAllocation allocation = frame.AllocateRing( + checked((int)DirectionalShadowUniforms.SizeInBytes), + GpuRingUsage.Uniform); + binding = new DirectionalShadowFrameBinding( + frame.Serial, + Enabled: false, + allocation.Buffer, + allocation.OffsetBytes, + DirectionalShadowUniforms.SizeInBytes, + GpuTextureSlot.Unassigned, + CascadeCount: 0); + return true; + } + } + private sealed class NullPreparedAssetSource : IPreparedAssetSource { public PreparedAssetSourceStats Stats => default; diff --git a/tests/AcDream.App.Tests/Rendering/Walk/OrderedDrawStreamTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/OrderedDrawStreamTests.cs index b79158a3..2fed6f4f 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/OrderedDrawStreamTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/OrderedDrawStreamTests.cs @@ -28,6 +28,7 @@ public sealed class OrderedDrawStreamTests TextureSlot: new GpuTextureSlot((uint)index), TextureLayer: 0, Translucency: translucency, + MaterialState: RetailSetSurfaceMaterialState.Opaque, FoliageFlags: 0, CullMode: cullMode), Transform: Matrix4x4.CreateTranslation(index, index * 2, index * 3), diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs index 77add57c..68b1f646 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs @@ -105,11 +105,13 @@ public sealed class WalkStaticStreamPopulatorTests int indexCount, uint textureSlotIndex, uint textureLayer = 0, - CullMode cullMode = CullMode.CounterClockwise) => + CullMode cullMode = CullMode.CounterClockwise, + RetailSetSurfaceMaterialState? materialState = null) => new() { Key = new TextureKey { SurfaceId = surfaceId, IsSolid = false }, Translucency = translucency, + MaterialState = materialState ?? RetailSetSurfaceMaterialState.Opaque, FirstIndex = firstIndex, BaseVertex = (uint)baseVertex, IndexCount = indexCount, @@ -316,14 +318,14 @@ public sealed class WalkStaticStreamPopulatorTests Assert.DoesNotContain( "one stable far-to-near stream keyed", architecture, StringComparison.OrdinalIgnoreCase); - Assert.Contains("160 active rows", register, StringComparison.Ordinal); + Assert.Contains("active total to **161**", register, StringComparison.Ordinal); int apSectionStart = register.IndexOf( "## 3. Documented approximation (AP)", StringComparison.Ordinal); int apSectionEnd = register.IndexOf( "## 4. Temporary stopgap (TS)", StringComparison.Ordinal); Assert.True(apSectionStart >= 0, "AP section heading must exist."); Assert.True(apSectionEnd > apSectionStart, "TS heading must follow the AP section."); - foreach (string id in new[] { "AP-241", "AP-242", "AP-243" }) + foreach (string id in new[] { "AP-241", "AP-242", "AP-243", "AP-244" }) { int rowIndex = register.IndexOf($"| {id} |", StringComparison.Ordinal); Assert.True( @@ -337,8 +339,10 @@ public sealed class WalkStaticStreamPopulatorTests line.StartsWith("| AP-242 |", StringComparison.Ordinal)); Assert.Single(File.ReadLines(registerPath), static line => line.StartsWith("| AP-243 |", StringComparison.Ordinal)); + Assert.Single(File.ReadLines(registerPath), static line => + line.StartsWith("| AP-244 |", StringComparison.Ordinal)); Assert.Equal( - 160, + 161, File.ReadLines(registerPath).Count(static line => line.StartsWith("| AP-", StringComparison.Ordinal))); Assert.Contains("greater than 50 m", register, StringComparison.Ordinal); @@ -417,7 +421,12 @@ public sealed class WalkStaticStreamPopulatorTests InjectRenderData(fx.Manager, opaqueGfxObj, MakeFlatMesh( MakeBatch(0x08000001u, TranslucencyKind.Opaque, firstIndex: 0, baseVertex: 0, indexCount: 3, textureSlotIndex: 1))); InjectRenderData(fx.Manager, alphaGfxObj, MakeFlatMesh( - MakeBatch(0x08000002u, TranslucencyKind.AlphaBlend, firstIndex: 3, baseVertex: 4, indexCount: 6, textureSlotIndex: 2))); + MakeBatch(0x08000002u, TranslucencyKind.AlphaBlend, firstIndex: 3, baseVertex: 4, + indexCount: 6, textureSlotIndex: 2, + materialState: RetailSetSurfaceMaterialState.Resolve( + SurfaceType.Alpha | SurfaceType.Additive, + texturePresent: true, + textureHasPalette: false)))); var meshRefs = new[] { @@ -448,6 +457,7 @@ public sealed class WalkStaticStreamPopulatorTests Assert.Equal(3u, translucent.Key.FirstIndex); Assert.Equal(6, translucent.Key.IndexCount); Assert.Equal(2u, translucent.Key.TextureSlot.Index); + Assert.Equal(RetailSetSurfaceBlend.AlphaAdditive, translucent.Key.MaterialState.Blend); Assert.Equal(meshRefs[1].PartTransform * record.Transform.LocalToWorld, translucent.Transform); Assert.Equal(2, selectionParts.Count); @@ -1095,7 +1105,8 @@ public sealed class WalkStaticStreamPopulatorTests using var fx = new DispatcherFixture(withAlphaQueue: true); fx.AlphaQueue!.BeginFrame(); - var key = new GroupKey(10, 2, 6, new GpuTextureSlot(3), 1, TranslucencyKind.AlphaBlend, FoliageFlags: 0); + var key = new GroupKey(10, 2, 6, new GpuTextureSlot(3), 1, TranslucencyKind.AlphaBlend, + MaterialState: RetailSetSurfaceMaterialState.Opaque, FoliageFlags: 0); Vector3 localSortCenter = new(1, 2, 3); Matrix4x4 model = Matrix4x4.CreateTranslation(4, 5, 6); var batch = new WbDrawDispatcher.WalkClassifiedBatch( @@ -1221,13 +1232,35 @@ public sealed class WalkStaticStreamPopulatorTests } [Theory] - [InlineData(TranslucencyKind.AlphaBlend, "wb-mesh-alpha-1x")] - [InlineData(TranslucencyKind.Additive, "wb-mesh-additive-1x")] - [InlineData(TranslucencyKind.InvAlpha, "wb-mesh-inverse-1x")] - public void ImmediateBuildingDetail_RetainsOriginalFramebufferFamily( - TranslucencyKind kind, - string expectedPipeline) + [InlineData(SurfaceType.Alpha, false, "wb-mesh-alpha-1x", GpuBlendMode.StraightAlpha, 0f, true)] + [InlineData(SurfaceType.Alpha | SurfaceType.Additive, false, "wb-mesh-additive-1x", GpuBlendMode.Additive, 0f, false)] + [InlineData(SurfaceType.Additive, false, "wb-mesh-raw-additive-1x", GpuBlendMode.RawAdditive, 0f, false)] + [InlineData(SurfaceType.InvAlpha, false, "wb-mesh-inverse-1x", GpuBlendMode.InverseAlpha, 0f, true)] + [InlineData(SurfaceType.InvAlpha | SurfaceType.Additive, false, "wb-mesh-inverse-additive-1x", GpuBlendMode.InverseAdditive, 0f, false)] + [InlineData(SurfaceType.Translucent, false, "wb-mesh-alpha-1x", GpuBlendMode.StraightAlpha, 0f, true)] + [InlineData(SurfaceType.Translucent | SurfaceType.Additive, false, "wb-mesh-raw-additive-1x", GpuBlendMode.RawAdditive, 0f, false)] + [InlineData(SurfaceType.Translucent | SurfaceType.InvAlpha, false, "wb-mesh-inverse-1x", GpuBlendMode.InverseAlpha, 0f, true)] + [InlineData(SurfaceType.Alpha | SurfaceType.Base1ClipMap, false, "wb-mesh-alpha-1x", GpuBlendMode.StraightAlpha, 200f / 255f, true)] + [InlineData(SurfaceType.Alpha | SurfaceType.Base1ClipMap, true, "wb-mesh-alpha-1x", GpuBlendMode.StraightAlpha, 100f / 255f, true)] + [InlineData(SurfaceType.Alpha | SurfaceType.Additive | SurfaceType.Base1ClipMap, false, "wb-mesh-additive-1x", GpuBlendMode.Additive, 200f / 255f, false)] + [InlineData(SurfaceType.Alpha | SurfaceType.Additive | SurfaceType.Base1ClipMap, true, "wb-mesh-additive-1x", GpuBlendMode.Additive, 100f / 255f, false)] + [InlineData(SurfaceType.Additive | SurfaceType.Base1ClipMap, false, "wb-mesh-raw-additive-1x", GpuBlendMode.RawAdditive, 200f / 255f, false)] + [InlineData(SurfaceType.Additive | SurfaceType.Base1ClipMap, true, "wb-mesh-raw-additive-1x", GpuBlendMode.RawAdditive, 100f / 255f, false)] + [InlineData(SurfaceType.InvAlpha | SurfaceType.Base1ClipMap, false, "wb-mesh-inverse-1x", GpuBlendMode.InverseAlpha, 200f / 255f, true)] + [InlineData(SurfaceType.InvAlpha | SurfaceType.Base1ClipMap, true, "wb-mesh-inverse-1x", GpuBlendMode.InverseAlpha, 100f / 255f, true)] + [InlineData(SurfaceType.InvAlpha | SurfaceType.Additive | SurfaceType.Base1ClipMap, false, "wb-mesh-inverse-additive-1x", GpuBlendMode.InverseAdditive, 200f / 255f, false)] + [InlineData(SurfaceType.InvAlpha | SurfaceType.Additive | SurfaceType.Base1ClipMap, true, "wb-mesh-inverse-additive-1x", GpuBlendMode.InverseAdditive, 100f / 255f, false)] + [InlineData(SurfaceType.Translucent | SurfaceType.Base1ClipMap | SurfaceType.Additive, + true, "wb-mesh-alpha-1x", GpuBlendMode.StraightAlpha, 0f, false)] + public void ImmediateBuildingDetail_UsesExactResolvedSetSurfaceState( + SurfaceType type, + bool paletted, + string expectedPipeline, + object expectedBlendValue, + float expectedReference, + bool expectedFog) { + var expectedBlend = (GpuBlendMode)expectedBlendValue; using var fx = new DispatcherFixture( withAlphaQueue: true, detailAvailable: true, @@ -1235,7 +1268,8 @@ public sealed class WalkStaticStreamPopulatorTests using DrawScope draw = fx.BeginDraw(beginAlpha: true); WbDrawDispatcher.WalkClassifiedBatch building = AlphaWalkBatch( detailCategory: 1u, - translucency: kind); + surfaceType: type, + paletted: paletted); fx.Dispatcher.SubmitWalkAlphaInstance(in building, Matrix4x4.Identity); @@ -1243,11 +1277,121 @@ public sealed class WalkStaticStreamPopulatorTests Assert.Single(fx.Device.Calls.OfType()); Assert.Contains(fx.Device.Calls.OfType(), call => call.PipelineName == expectedPipeline); + GpuPushConstants armed = fx.Device.Calls + .OfType() + .Last(call => call.Constants.ParamA != 0f) + .Constants; + Assert.Equal(99u, armed.TextureIndexA); + Assert.Equal(2f, armed.ParamA); + Assert.Equal(expectedReference, armed.ParamB); + Assert.Equal(!expectedFog, + (armed.RenderPass & RetailDetailTextureContract.NoFogRenderPassFlag) != 0); + RetailSetSurfaceMaterialState resolved = RetailSetSurfaceMaterialState.Resolve( + type, texturePresent: true, textureHasPalette: paletted); + GpuPipelineDescription selected = fx.Device.CreatedPipelines + .Single(pipeline => pipeline.Description.Name == expectedPipeline) + .Description; + Assert.Equal(expectedBlend, selected.Blend); + Assert.True(selected.Depth.Test); + Assert.Equal( + resolved.Blend is RetailSetSurfaceBlend.Opaque or RetailSetSurfaceBlend.Clip, + selected.Depth.Write); + Assert.Equal(WorldDepthContract.WorldCompare, selected.Depth.Compare); + Vector4 source = RetailDetailTextureContract.Combine( + new Vector4(0.31f, 0.57f, 0.83f, 0.19f), + new Vector3(0.73f, 0.41f, 0.67f), + new Vector4(0.91f, 0.23f, 0.49f, 0.62f), + authoredOpacity: 0.75f, + liveOpacity: 0.4f); + Assert.Equal(0.3534682f, source.X, 6); + Assert.Equal(0.2330118f, source.Y, 6); + Assert.Equal(0.5438054f, source.Z, 6); + Assert.Equal(0.11532f, source.W, 6); + Vector4 destination = new(0.17f, 0.37f, 0.71f, 0.29f); + float x = source.W; + RetailDetailTextureContract.FramebufferFamily family = expectedBlend switch + { + GpuBlendMode.StraightAlpha => RetailDetailTextureContract.FramebufferFamily.Alpha, + GpuBlendMode.Additive => RetailDetailTextureContract.FramebufferFamily.AlphaAdditive, + GpuBlendMode.RawAdditive => RetailDetailTextureContract.FramebufferFamily.Additive, + GpuBlendMode.InverseAlpha => RetailDetailTextureContract.FramebufferFamily.InverseAlpha, + GpuBlendMode.InverseAdditive => RetailDetailTextureContract.FramebufferFamily.InverseAlphaAdditive, + _ => throw new ArgumentOutOfRangeException(nameof(expectedBlend)), + }; + Vector4 expectedFramebuffer = family switch + { + RetailDetailTextureContract.FramebufferFamily.Alpha => source * x + destination * (1f - x), + RetailDetailTextureContract.FramebufferFamily.AlphaAdditive => source * x + destination, + RetailDetailTextureContract.FramebufferFamily.Additive => source + destination, + RetailDetailTextureContract.FramebufferFamily.InverseAlpha => source * (1f - x) + destination * x, + RetailDetailTextureContract.FramebufferFamily.InverseAlphaAdditive => source * (1f - x) + destination, + _ => throw new ArgumentOutOfRangeException(nameof(family)), + }; + Vector4 framebuffer = RetailDetailTextureContract.Composite(source, destination, family); + Assert.Equal(expectedFramebuffer.X, framebuffer.X, 6); + Assert.Equal(expectedFramebuffer.Y, framebuffer.Y, 6); + Assert.Equal(expectedFramebuffer.Z, framebuffer.Z, 6); + Assert.Equal(expectedFramebuffer.W, framebuffer.W, 6); + if (resolved.AlphaTestEnabled) + { + Assert.False(RetailDetailTextureContract.SurvivesClip( + MathF.BitDecrement(expectedReference), expectedReference)); + Assert.True(RetailDetailTextureContract.SurvivesClip(expectedReference, expectedReference)); + Assert.True(RetailDetailTextureContract.SurvivesClip( + MathF.BitIncrement(expectedReference), expectedReference)); + } + } + + [Theory] + [InlineData(SurfaceType.Alpha, "wb-mesh-alpha-1x")] + [InlineData(SurfaceType.Alpha | SurfaceType.Additive, "wb-mesh-additive-1x")] + [InlineData(SurfaceType.Additive, "wb-mesh-additive-1x")] + [InlineData(SurfaceType.InvAlpha, "wb-mesh-inverse-1x")] + [InlineData(SurfaceType.InvAlpha | SurfaceType.Additive, "wb-mesh-additive-1x")] + [InlineData(SurfaceType.Alpha | SurfaceType.Base1ClipMap, "wb-mesh-alpha-1x")] + [InlineData(SurfaceType.Alpha | SurfaceType.Additive | SurfaceType.Base1ClipMap, "wb-mesh-additive-1x")] + [InlineData(SurfaceType.Additive | SurfaceType.Base1ClipMap, "wb-mesh-additive-1x")] + [InlineData(SurfaceType.InvAlpha | SurfaceType.Base1ClipMap, "wb-mesh-inverse-1x")] + [InlineData(SurfaceType.InvAlpha | SurfaceType.Additive | SurfaceType.Base1ClipMap, "wb-mesh-additive-1x")] + [InlineData(SurfaceType.Translucent, "wb-mesh-alpha-1x")] + [InlineData(SurfaceType.Translucent | SurfaceType.Additive, "wb-mesh-additive-1x")] + [InlineData(SurfaceType.Translucent | SurfaceType.InvAlpha, "wb-mesh-inverse-1x")] + [InlineData(SurfaceType.Translucent | SurfaceType.Base1ClipMap | SurfaceType.Additive, "wb-mesh-alpha-1x")] + public void BuildingDetailOff_EveryRawStateRetainsThePreFixLogicalPath( + SurfaceType type, + string expectedPipeline) + { + using var fx = new DispatcherFixture( + withAlphaQueue: true, + detailAvailable: true, + detailEnabled: false); + using DrawScope draw = fx.BeginDraw(beginAlpha: true); + WbDrawDispatcher.WalkClassifiedBatch building = AlphaWalkBatch( + detailCategory: 1u, + surfaceType: type); + + fx.Dispatcher.SubmitWalkAlphaInstance(in building, Matrix4x4.Identity); + + Assert.Equal(1, fx.AlphaQueue!.PendingCount); + Assert.Empty(fx.Device.Calls.OfType()); + fx.AlphaQueue.EndFrame(); + Assert.Single(fx.Device.Calls.OfType()); + Assert.Contains(fx.Device.Calls.OfType(), + call => call.PipelineName == expectedPipeline); + GpuPushConstants constants = fx.Device.Calls + .OfType() + .Last() + .Constants; + Assert.Equal(0u, constants.TextureIndexA); + Assert.Equal(0f, constants.ParamA); + Assert.Equal(0f, constants.ParamB); + Assert.Equal(0, constants.RenderPass & RetailDetailTextureContract.NoFogRenderPassFlag); } private static WbDrawDispatcher.WalkClassifiedBatch AlphaWalkBatch( uint detailCategory, - TranslucencyKind translucency = TranslucencyKind.AlphaBlend) => + SurfaceType surfaceType = SurfaceType.Alpha, + bool paletted = false) => new( new GroupKey( 0, @@ -1255,7 +1399,11 @@ public sealed class WalkStaticStreamPopulatorTests 3, new GpuTextureSlot(1), 0, - translucency, + TranslucencyKindExtensions.FromSurfaceType(surfaceType), + RetailSetSurfaceMaterialState.Resolve( + surfaceType, + texturePresent: true, + textureHasPalette: paletted), FoliageFlags: 0), Matrix4x4.Identity, ClipSlot: 0, @@ -1277,6 +1425,10 @@ public sealed class WalkStaticStreamPopulatorTests TextureSlot = new GpuTextureSlot(1), TextureLayer = 0, Translucency = TranslucencyKind.AlphaBlend, + MaterialState = RetailSetSurfaceMaterialState.Resolve( + SurfaceType.Alpha, + texturePresent: true, + textureHasPalette: false), }; group.Matrices.Add(Matrix4x4.Identity); group.SubmissionOrders.Add(0); @@ -1320,7 +1472,8 @@ public sealed class WalkStaticStreamPopulatorTests using var fx = new DispatcherFixture(withAlphaQueue: true); fx.AlphaQueue!.BeginFrame(); - var key = new GroupKey(0, 0, 3, new GpuTextureSlot(1), 0, TranslucencyKind.AlphaBlend, FoliageFlags: 0); + var key = new GroupKey(0, 0, 3, new GpuTextureSlot(1), 0, TranslucencyKind.AlphaBlend, + MaterialState: RetailSetSurfaceMaterialState.Opaque, FoliageFlags: 0); var batch = new WbDrawDispatcher.WalkClassifiedBatch( key, Matrix4x4.Identity, 0, WbDrawDispatcher.InstanceLightSet.Disabled, 0, 1f, Vector2.Zero, 0, IsOpaque: false, LocalSortCenter: new Vector3(0, 0, 10)); @@ -1354,7 +1507,8 @@ public sealed class WalkStaticStreamPopulatorTests var stream = new OrderedDrawStream(); stream.Append(new OrderedDrawCommand( - new GroupKey(0, 0, 3, new GpuTextureSlot(1), 0, TranslucencyKind.Opaque, FoliageFlags: 0, CullMode: CullMode.Clockwise), + new GroupKey(0, 0, 3, new GpuTextureSlot(1), 0, TranslucencyKind.Opaque, + MaterialState: RetailSetSurfaceMaterialState.Opaque, FoliageFlags: 0, CullMode: CullMode.Clockwise), Matrix4x4.Identity, WalkDrawStage.Terrain, 0, 0, WbDrawDispatcher.InstanceLightSet.Disabled, 0, 1f, Vector2.Zero, 0)); diff --git a/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs index f95917ed..2f1465b4 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs @@ -28,6 +28,7 @@ using AcDream.App.Rendering.Gpu.Vk; using AcDream.App.Rendering.Wb; using AcDream.App.Tests.Rendering.Gpu; using AcDream.Content; +using AcDream.Core.Meshing; using Microsoft.Extensions.Logging.Abstractions; using Xunit; using CullMode = DatReaderWriter.Enums.CullMode; @@ -186,7 +187,8 @@ public class EnvCellRendererTests "_mdiDrawRanges", BindingFlags.NonPublic | BindingFlags.Instance)!; var ranges = (List)rangesField.GetValue(renderer)!; ranges.Clear(); - EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 0, firstCommand: 0, commandCount: 1); + EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 0, firstCommand: 0, commandCount: 1, + RetailSetSurfaceMaterialState.Opaque); var allInstances = new List(); for (int i = 0; i < instanceCount; i++) @@ -239,7 +241,7 @@ public class EnvCellRendererTests } [Fact] - public void DetailUsesOnlyTheOriginalFourShellPipelines() + public void DetailUsesOnlySetSurfaceShellPipelinesAndNoReplayPipeline() { using var device = new RecordingGpuDevice(); using var meshManager = CreateMeshManager(device); @@ -250,7 +252,7 @@ public class EnvCellRendererTests meshManager, new WbFrustum()); - Assert.Equal(4, device.CreatedPipelines.Count); + Assert.Equal(7, device.CreatedPipelines.Count); Assert.DoesNotContain(device.CreatedPipelines, pipeline => pipeline.Description.Name.Contains("detail", StringComparison.Ordinal)); } @@ -260,11 +262,14 @@ public class EnvCellRendererTests { var ranges = new List(); - EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 0, commandCount: 3); - EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 3, commandCount: 4); + EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 0, commandCount: 3, + RetailSetSurfaceMaterialState.Opaque); + EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 3, commandCount: 4, + RetailSetSurfaceMaterialState.Opaque); Assert.Equal( - [new EnvCellRenderer.MdiDrawRange(GroupIndex: 2, FirstCommand: 0, CommandCount: 7)], + [new EnvCellRenderer.MdiDrawRange(GroupIndex: 2, FirstCommand: 0, CommandCount: 7, + RetailSetSurfaceMaterialState.Opaque)], ranges); } @@ -273,17 +278,21 @@ public class EnvCellRendererTests { var ranges = new List(); - EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 0, commandCount: 3); - EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 6, firstCommand: 3, commandCount: 2); - EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 5, commandCount: 1); - EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 9, commandCount: 2); + EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 0, commandCount: 3, + RetailSetSurfaceMaterialState.Opaque); + EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 6, firstCommand: 3, commandCount: 2, + RetailSetSurfaceMaterialState.Opaque); + EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 5, commandCount: 1, + RetailSetSurfaceMaterialState.Opaque); + EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 9, commandCount: 2, + RetailSetSurfaceMaterialState.Opaque); Assert.Equal( [ - new EnvCellRenderer.MdiDrawRange(2, 0, 3), - new EnvCellRenderer.MdiDrawRange(6, 3, 2), - new EnvCellRenderer.MdiDrawRange(2, 5, 1), - new EnvCellRenderer.MdiDrawRange(2, 9, 2), + new EnvCellRenderer.MdiDrawRange(2, 0, 3, RetailSetSurfaceMaterialState.Opaque), + new EnvCellRenderer.MdiDrawRange(6, 3, 2, RetailSetSurfaceMaterialState.Opaque), + new EnvCellRenderer.MdiDrawRange(2, 5, 1, RetailSetSurfaceMaterialState.Opaque), + new EnvCellRenderer.MdiDrawRange(2, 9, 2, RetailSetSurfaceMaterialState.Opaque), ], ranges); } @@ -293,7 +302,8 @@ public class EnvCellRendererTests { var ranges = new List(); - EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 0, commandCount: 0); + EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 0, commandCount: 0, + RetailSetSurfaceMaterialState.Opaque); Assert.Empty(ranges); } diff --git a/tests/AcDream.App.Tests/Rendering/Wb/FoliageWindClassificationTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/FoliageWindClassificationTests.cs index 25cfeadc..64ac8f9c 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/FoliageWindClassificationTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/FoliageWindClassificationTests.cs @@ -162,6 +162,7 @@ public sealed class FoliageWindClassificationTests TextureSlot: new AcDream.App.Rendering.Gpu.GpuTextureSlot(42), TextureLayer: 0, Translucency: TranslucencyKind.ClipMap, + MaterialState: RetailSetSurfaceMaterialState.Opaque, CullMode: DatReaderWriter.Enums.CullMode.CounterClockwise, FoliageFlags: sceneryFlags); var landblockStaticKey = sceneryKey with { FoliageFlags = landblockStaticFlags }; diff --git a/tests/AcDream.App.Tests/Rendering/Wb/InstanceGroupClearTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/InstanceGroupClearTests.cs index 7925bdaf..2110d579 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/InstanceGroupClearTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/InstanceGroupClearTests.cs @@ -270,6 +270,7 @@ public class InstanceGroupClearTests TextureSlot: Slot(0xAA), TextureLayer: 0, Translucency: TranslucencyKind.Opaque, + MaterialState: RetailSetSurfaceMaterialState.Opaque, FoliageFlags: 0u); var cached = new CachedBatch( key, @@ -312,6 +313,7 @@ public class InstanceGroupClearTests TextureSlot: Slot(0x77), TextureLayer: 0, Translucency: TranslucencyKind.ClipMap, + MaterialState: RetailSetSurfaceMaterialState.Opaque, FoliageFlags: 0x2u, CullMode: DatReaderWriter.Enums.CullMode.CounterClockwise); @@ -416,6 +418,7 @@ public class InstanceGroupClearTests TextureSlot: Slot(textureSlot), TextureLayer: 0, Translucency: TranslucencyKind.Opaque, + MaterialState: RetailSetSurfaceMaterialState.Opaque, FoliageFlags: 0u); private static WbDrawDispatcher.InstanceGroup MakeGroup( diff --git a/tests/AcDream.Content.Tests/MeshExtractorSolidFaceExtractionTests.cs b/tests/AcDream.Content.Tests/MeshExtractorSolidFaceExtractionTests.cs index 6600f621..62bc48b9 100644 --- a/tests/AcDream.Content.Tests/MeshExtractorSolidFaceExtractionTests.cs +++ b/tests/AcDream.Content.Tests/MeshExtractorSolidFaceExtractionTests.cs @@ -4,6 +4,7 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Numerics; using AcDream.Content; +using AcDream.Core.Meshing; using Chorizite.Core.Render.Enums; using DatReaderWriter; using DatReaderWriter.DBObjs; @@ -255,6 +256,60 @@ public sealed class MeshExtractorSolidFaceExtractionTests BitConverter.SingleToInt32Bits(batch.SurfaceOpacity)); } + public static TheoryData SetSurfaceExtractionRows() => new() + { + { SurfaceType.Base1Image, false, RetailSetSurfaceBlend.Opaque, RetailSetSurfaceAlphaTest.Disabled, true }, + { SurfaceType.Base1Image | SurfaceType.Alpha, false, RetailSetSurfaceBlend.StraightAlpha, RetailSetSurfaceAlphaTest.Disabled, true }, + { SurfaceType.Base1Image | SurfaceType.Alpha | SurfaceType.Additive, false, RetailSetSurfaceBlend.AlphaAdditive, RetailSetSurfaceAlphaTest.Disabled, false }, + { SurfaceType.Base1Image | SurfaceType.Additive, false, RetailSetSurfaceBlend.Additive, RetailSetSurfaceAlphaTest.Disabled, false }, + { SurfaceType.Base1Image | SurfaceType.InvAlpha, false, RetailSetSurfaceBlend.InverseAlpha, RetailSetSurfaceAlphaTest.Disabled, true }, + { SurfaceType.Base1Image | SurfaceType.InvAlpha | SurfaceType.Additive, false, RetailSetSurfaceBlend.InverseAdditive, RetailSetSurfaceAlphaTest.Disabled, false }, + { SurfaceType.Base1Image | SurfaceType.Base1ClipMap, false, RetailSetSurfaceBlend.Clip, RetailSetSurfaceAlphaTest.Dds, true }, + { SurfaceType.Base1Image | SurfaceType.Base1ClipMap, true, RetailSetSurfaceBlend.Clip, RetailSetSurfaceAlphaTest.Paletted, true }, + { SurfaceType.Base1Image | SurfaceType.Alpha | SurfaceType.Base1ClipMap, true, RetailSetSurfaceBlend.StraightAlpha, RetailSetSurfaceAlphaTest.Paletted, true }, + { SurfaceType.Base1Image | SurfaceType.Translucent | SurfaceType.Base1ClipMap | SurfaceType.Additive, true, RetailSetSurfaceBlend.StraightAlpha, RetailSetSurfaceAlphaTest.Disabled, false }, + }; + + [Theory] + [MemberData(nameof(SetSurfaceExtractionRows))] + public void OrdinaryAndCellExtraction_CarryIdenticalResolvedSetSurfaceState( + SurfaceType type, + bool paletted, + RetailSetSurfaceBlend expectedBlend, + RetailSetSurfaceAlphaTest expectedAlphaTest, + bool expectedFog) + { + var dats = new FakeMeshExtractorDats(); + uint paletteId = paletted ? 0x04000001u : 0u; + RegisterTexturedQuad( + dats, + type, + PixelFormat.PFID_A8R8G8B8, + new byte[4 * 4 * 4], + paletteId: paletteId); + var extractor = new MeshExtractor(dats, NullLogger.Instance, sideStagedSink: null); + + ObjectMeshData ordinary = Assert.IsType( + extractor.PrepareMeshData(GfxObjId, isSetup: false)); + ObjectMeshData cell = Assert.IsType( + extractor.PrepareCellStructMeshData( + id: 1, + BuildQuadCellStruct(RetailCullMode.Landblock), + surfaceOverrides: [2], + Matrix4x4.Identity, + CancellationToken.None)); + RetailSetSurfaceMaterialState ordinaryState = + Assert.Single(Assert.Single(ordinary.TextureBatches).Value).MaterialState; + RetailSetSurfaceMaterialState cellState = + Assert.Single(Assert.Single(cell.TextureBatches).Value).MaterialState; + + Assert.Equal(ordinaryState, cellState); + Assert.Equal(expectedBlend, ordinaryState.Blend); + Assert.Equal(expectedAlphaTest, ordinaryState.AlphaTest); + Assert.Equal(expectedFog, ordinaryState.FogEnabled); + } + /// /// OH2/S1 chunk-2 (2026-09-02): these two winding tests used to register /// an UNTEXTURED (Base1Solid) surface. Under the exact contract, an @@ -800,7 +855,8 @@ public sealed class MeshExtractorSolidFaceExtractionTests SurfaceType surfaceType, PixelFormat pixelFormat, byte[] sourceData, - float translucency = 0.0f) + float translucency = 0.0f, + uint paletteId = 0) { dats.RegisterRootGfxObj(GfxObjId, BuildQuadGfxObj(TexturedSurfaceId, noPos: false)); dats.Register(TexturedSurfaceId, new Surface @@ -818,6 +874,7 @@ public sealed class MeshExtractorSolidFaceExtractionTests Width = 4, Height = 4, Format = pixelFormat, + DefaultPaletteId = paletteId, SourceData = sourceData, }); } diff --git a/tests/AcDream.Content.Tests/ObjectMeshDataEquality.cs b/tests/AcDream.Content.Tests/ObjectMeshDataEquality.cs index 2acda9ae..070cd31b 100644 --- a/tests/AcDream.Content.Tests/ObjectMeshDataEquality.cs +++ b/tests/AcDream.Content.Tests/ObjectMeshDataEquality.cs @@ -97,6 +97,8 @@ public static class ObjectMeshDataEquality { Assert.True(expected.CullMode == actual.CullMode, $"{path}.CullMode: expected {expected.CullMode}, got {actual.CullMode}"); Assert.True(expected.Translucency == actual.Translucency, $"{path}.Translucency: expected {expected.Translucency}, got {actual.Translucency}"); + Assert.True(expected.MaterialState == actual.MaterialState, + $"{path}.MaterialState: expected {expected.MaterialState}, got {actual.MaterialState}"); Assert.True(expected.IsTransparent == actual.IsTransparent, $"{path}.IsTransparent: expected {expected.IsTransparent}, got {actual.IsTransparent}"); Assert.True(expected.IsAdditive == actual.IsAdditive, $"{path}.IsAdditive: expected {expected.IsAdditive}, got {actual.IsAdditive}"); Assert.True(expected.HasWrappingUVs == actual.HasWrappingUVs, $"{path}.HasWrappingUVs: expected {expected.HasWrappingUVs}, got {actual.HasWrappingUVs}"); diff --git a/tests/AcDream.Content.Tests/ObjectMeshDataSerializerTests.cs b/tests/AcDream.Content.Tests/ObjectMeshDataSerializerTests.cs index 863958d5..e538aac6 100644 --- a/tests/AcDream.Content.Tests/ObjectMeshDataSerializerTests.cs +++ b/tests/AcDream.Content.Tests/ObjectMeshDataSerializerTests.cs @@ -192,6 +192,10 @@ public class ObjectMeshDataSerializerTests { Indices = new List { 0, 1, 2 }, CullMode = CullMode.Clockwise, Translucency = TranslucencyKind.Opaque, + MaterialState = new RetailSetSurfaceMaterialState( + RetailSetSurfaceBlend.InverseAdditive, + RetailSetSurfaceAlphaTest.Paletted, + FogEnabled: false), IsTransparent = false, IsAdditive = false, HasWrappingUVs = false, @@ -290,6 +294,37 @@ public class ObjectMeshDataSerializerTests { BitConverter.SingleToInt32Bits(read.SurfaceOpacity)); } + [Theory] + [InlineData(0, 0, true)] + [InlineData(1, 0, true)] + [InlineData(2, 1, true)] + [InlineData(3, 2, false)] + [InlineData(4, 0, true)] + [InlineData(5, 1, false)] + [InlineData(6, 2, true)] + public void SetSurfaceState_RoundTripsEveryPackedFieldDeterministically( + int blendValue, + int alphaTestValue, + bool fogEnabled) { + ObjectMeshData data = WithCellShellSubset(); + TextureBatchData batch = Assert.Single(Assert.Single(data.TextureBatches).Value); + batch.MaterialState = new RetailSetSurfaceMaterialState( + (RetailSetSurfaceBlend)blendValue, + (RetailSetSurfaceAlphaTest)alphaTestValue, + fogEnabled); + + using var first = new MemoryStream(); + ObjectMeshDataSerializer.Write(data, first); + using var second = new MemoryStream(); + ObjectMeshDataSerializer.Write(data, second); + + Assert.Equal(first.ToArray(), second.ToArray()); + TextureBatchData read = Assert.Single(Assert.Single( + ObjectMeshDataSerializer.Read(first.ToArray()).TextureBatches).Value); + Assert.Equal(batch.MaterialState, read.MaterialState); + Assert.Equal(batch.MaterialState.ToPackedByte(), read.MaterialState.ToPackedByte()); + } + // ---- determinism ----------------------------------------------------- [Fact] diff --git a/tests/AcDream.Content.Tests/PakFormatTests.cs b/tests/AcDream.Content.Tests/PakFormatTests.cs index fa663f4c..918aec2b 100644 --- a/tests/AcDream.Content.Tests/PakFormatTests.cs +++ b/tests/AcDream.Content.Tests/PakFormatTests.cs @@ -10,13 +10,14 @@ public class PakFormatTests { } /// - /// S5-c4: recipe 9 carries authored surface opacity in every prepared - /// texture batch. It remains a payload recipe change, not pak framing. + /// S5-c4 fix round 1: recipe 10 carries exact SetSurface state after + /// recipe 10's resolved SetSurface state after recipe 9's authored opacity. + /// It remains a payload recipe change. /// [Fact] public void FormatVersion_StaysAtTwo_AcrossTheS5C4RecipeBump() { Assert.Equal(2u, PakFormat.CurrentFormatVersion); - Assert.Equal(9u, PakFormat.CurrentBakeToolVersion); + Assert.Equal(10u, PakFormat.CurrentBakeToolVersion); } [Fact] diff --git a/tests/AcDream.Content.Tests/PreparedAssetSourceTests.cs b/tests/AcDream.Content.Tests/PreparedAssetSourceTests.cs index 02c99918..78eac707 100644 --- a/tests/AcDream.Content.Tests/PreparedAssetSourceTests.cs +++ b/tests/AcDream.Content.Tests/PreparedAssetSourceTests.cs @@ -136,7 +136,7 @@ public sealed class PreparedAssetSourceTests : IDisposable } /// - /// S5-c4: an older recipe-8 package must be rejected by a recipe-9 + /// S5-c4 fix round 1: an older recipe-9 package must be rejected by a recipe-10 /// consumer through the catalog-identity check /// alone, BEFORE any TextureBatchData payload field is ever /// deserialized -- so an older pak can never be silently misread as @@ -160,7 +160,7 @@ public sealed class PreparedAssetSourceTests : IDisposable fs.Position = 36; Span buf = stackalloc byte[4]; System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian( - buf, 8u); + buf, 9u); fs.Write(buf); } @@ -168,7 +168,7 @@ public sealed class PreparedAssetSourceTests : IDisposable () => new PakPreparedAssetSource(path, Identity)); Assert.Contains("does not match the installed DAT set", error.Message); Assert.Contains("Re-bake", error.Message); - Assert.Contains("bake tool 8 != 9", error.Message); + Assert.Contains("bake tool 9 != 10", error.Message); } [Fact] diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs index 7e57619a..4ab64c84 100644 --- a/tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs @@ -337,6 +337,7 @@ public class EntityClassificationCacheTests TextureSlot: new AcDream.App.Rendering.Gpu.GpuTextureSlot(texSlot), TextureLayer: 0, Translucency: TranslucencyKind.Opaque, + MaterialState: RetailSetSurfaceMaterialState.Opaque, FoliageFlags: 0u); return new CachedBatch(key, new AcDream.App.Rendering.Gpu.GpuTextureSlot(texSlot), Matrix4x4.Identity); } diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs index 446b56d4..f6d9c447 100644 --- a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs @@ -426,6 +426,7 @@ public sealed class WbDrawDispatcherBucketingTests TextureSlot: new AcDream.App.Rendering.Gpu.GpuTextureSlot(texSlot), TextureLayer: 0, Translucency: TranslucencyKind.Opaque, + MaterialState: RetailSetSurfaceMaterialState.Opaque, FoliageFlags: 0u); return new CachedBatch( key, diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherIndirectBuilderTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherIndirectBuilderTests.cs index 5ae679d4..eace8c62 100644 --- a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherIndirectBuilderTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherIndirectBuilderTests.cs @@ -20,9 +20,9 @@ public sealed class WbDrawDispatcherIndirectBuilderTests // Arrange — three groups: 2 opaque (12+1 instances) + 1 transparent (12 instances) var groups = new List { - new(IndexCount: 100, FirstIndex: 0, BaseVertex: 0, InstanceCount: 12, FirstInstance: 0, TextureIndex: 0xAA, TextureLayer: 0, Translucency: TranslucencyKind.Opaque, SurfaceOpacity: 0.75f), - new(IndexCount: 200, FirstIndex: 100, BaseVertex: 0, InstanceCount: 12, FirstInstance: 12, TextureIndex: 0xBB, TextureLayer: 0, Translucency: TranslucencyKind.AlphaBlend, SurfaceOpacity: 0.25f), - new(IndexCount: 50, FirstIndex: 300, BaseVertex: 100, InstanceCount: 1, FirstInstance: 24, TextureIndex: 0xCC, TextureLayer: 0, Translucency: TranslucencyKind.Opaque, SurfaceOpacity: 0.5f), + new(IndexCount: 100, FirstIndex: 0, BaseVertex: 0, InstanceCount: 12, FirstInstance: 0, TextureIndex: 0xAA, TextureLayer: 0, Translucency: TranslucencyKind.Opaque, MaterialState: RetailSetSurfaceMaterialState.Opaque, SurfaceOpacity: 0.75f), + new(IndexCount: 200, FirstIndex: 100, BaseVertex: 0, InstanceCount: 12, FirstInstance: 12, TextureIndex: 0xBB, TextureLayer: 0, Translucency: TranslucencyKind.AlphaBlend, MaterialState: RetailSetSurfaceMaterialState.Opaque, SurfaceOpacity: 0.25f), + new(IndexCount: 50, FirstIndex: 300, BaseVertex: 100, InstanceCount: 1, FirstInstance: 24, TextureIndex: 0xCC, TextureLayer: 0, Translucency: TranslucencyKind.Opaque, MaterialState: RetailSetSurfaceMaterialState.Opaque, SurfaceOpacity: 0.5f), }; var indirect = new DrawElementsIndirectCommand[16]; @@ -75,12 +75,15 @@ public sealed class WbDrawDispatcherIndirectBuilderTests { new(IndexCount: 10, FirstIndex: 0, BaseVertex: 0, InstanceCount: 1, FirstInstance: 0, TextureIndex: 0x1, TextureLayer: 0, Translucency: TranslucencyKind.Opaque, + MaterialState: RetailSetSurfaceMaterialState.Opaque, CullMode: CullMode.Clockwise), new(IndexCount: 20, FirstIndex: 10, BaseVertex: 0, InstanceCount: 1, FirstInstance: 1, TextureIndex: 0x2, TextureLayer: 0, Translucency: TranslucencyKind.AlphaBlend, + MaterialState: RetailSetSurfaceMaterialState.Opaque, CullMode: CullMode.None), new(IndexCount: 30, FirstIndex: 30, BaseVertex: 0, InstanceCount: 1, FirstInstance: 2, TextureIndex: 0x3, TextureLayer: 0, Translucency: TranslucencyKind.ClipMap, + MaterialState: RetailSetSurfaceMaterialState.Opaque, CullMode: CullMode.Landblock), }; var indirect = new DrawElementsIndirectCommand[4]; @@ -116,7 +119,7 @@ public sealed class WbDrawDispatcherIndirectBuilderTests // because the discard handles transparency, not blending. var groups = new List { - new(IndexCount: 10, FirstIndex: 0, BaseVertex: 0, InstanceCount: 1, FirstInstance: 0, TextureIndex: 0x1, TextureLayer: 0, Translucency: TranslucencyKind.ClipMap), + new(IndexCount: 10, FirstIndex: 0, BaseVertex: 0, InstanceCount: 1, FirstInstance: 0, TextureIndex: 0x1, TextureLayer: 0, Translucency: TranslucencyKind.ClipMap, MaterialState: RetailSetSurfaceMaterialState.Opaque), }; var indirect = new DrawElementsIndirectCommand[4]; var batch = new WbDrawDispatcher.BatchDataPublic[4]; @@ -146,7 +149,8 @@ public sealed class WbDrawDispatcherIndirectBuilderTests FirstInstance: index, TextureIndex: (uint)index, TextureLayer: 0, - Translucency: kind)).ToList(); + Translucency: kind, + MaterialState: RetailSetSurfaceMaterialState.Opaque)).ToList(); var indirect = new DrawElementsIndirectCommand[kinds.Length]; var batches = new WbDrawDispatcher.BatchDataPublic[kinds.Length]; diff --git a/tests/AcDream.Launcher.Core.Tests/Installation/ContentMigrationCatalogTests.cs b/tests/AcDream.Launcher.Core.Tests/Installation/ContentMigrationCatalogTests.cs index ee8c347d..07bf4dc1 100644 --- a/tests/AcDream.Launcher.Core.Tests/Installation/ContentMigrationCatalogTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Installation/ContentMigrationCatalogTests.cs @@ -77,4 +77,28 @@ public sealed class ContentMigrationCatalogTests Assert.Contains("CellStruct", plan.Reason, StringComparison.OrdinalIgnoreCase); Assert.Contains("surface opacity", plan.Reason, StringComparison.OrdinalIgnoreCase); } + + [Fact] + public void RecipeNineToTenRequiresOneExplicitFullRebuild() + { + ContentMigrationPlan plan = ContentMigrationCatalog.Resolve(9, 10); + + Assert.Equal(ContentWorkKind.FullRebuild, plan.Kind); + Assert.Equal(9u, plan.FromRecipeVersion); + Assert.Equal(10u, plan.TargetRecipeVersion); + Assert.Contains("SetSurface", plan.Reason, StringComparison.OrdinalIgnoreCase); + Assert.Empty(plan.EffectiveDatIds); + Assert.Empty(plan.EffectiveLandblocks); + } + + [Fact] + public void AnyOlderRecipeToTenCollapsesToOneFullRebuild() + { + ContentMigrationPlan plan = ContentMigrationCatalog.Resolve(1, 10); + + Assert.Equal(ContentWorkKind.FullRebuild, plan.Kind); + Assert.Equal(10u, plan.TargetRecipeVersion); + Assert.Contains("surface opacity", plan.Reason, StringComparison.OrdinalIgnoreCase); + Assert.Contains("SetSurface", plan.Reason, StringComparison.OrdinalIgnoreCase); + } }