Commit graph

25 commits

Author SHA1 Message Date
Erik
cc42edc8e3 fix #427: sky drawn without fog like retail; world fog range is the keyframe's authored MinWorldFog/MaxWorldFog
Two April stand-ins, neither registered, met at the horizon from altitude as
a hard line between the dome's rim and the fog-coloured clear:

1. sky.frag fogged every non-additive sky layer with a 0.2 floor. Retail's
   GameSky::Draw @0x00506FF0 disables fixed-function fog around the whole
   sky draw unless an AdminEnvirons fog override is active (SetFFFogEnable(
   LScape::m_override_enabled ? 1 : 0)); additive layers stay unfogged via
   SetFFFogAlphaDisabled(1) at D3DPolyRender::SetSurface 0x59c882. The sky
   pass now sets ApplyFog only for (override active && !additive), with no
   floor.
2. WorldRenderFrameBuilder overwrote the authored fog range with one derived
   from the streaming window (538..2189 m always). Retail sets FOGSTART/
   FOGEND straight from the keyframe's MinWorldFog/MaxWorldFog
   (SkyDesc::GetWorldFog @0x00500CE0 -> SetFFFogProperties @0x005A2F70) with
   no draw-distance scaling; zfar is a constant 4000 m. The builder now
   leaves SceneLightingUbo.Build's values alone; ACDREAM_FOG_START_MULT /
   _END_MULT are deleted from RuntimeOptions.

Guards: SkyFogRuleTests (source-level, the sky renderer has no hermetic
harness); sky.frag.spv re-pinned in VulkanShaderManifestTests with the
reason. Research note 2026-04-23-sky-fog.md carries a correction banner.
App hermetic 6,070/0, Core 4,707/0 (Release). Owner look gate owed: night
and rain fog are now retail's shorter authored ranges.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 13:42:42 +02:00
Erik
1f15124271 test(render): foliage-wind receiver fallback guard asserts branch shape; cascade-0 publish covered (Campaign VM VM6 review 5 nits)
Round-5 narrow re-review of 754d59d9: APPROVE (verified to the SPIR-V
disassembly; shadows-on path proven opcode-identical). Four
non-blocking nits.

N1 (test robustness): T3 (ReceiverVertexShadersFallBackToThePlainLightDirectionWhenTheShadowFlagIsClear)
was satisfied by comment prose -- round 5's own explanatory comments
quote the exact plain-pipeline direction substring
("-uLights[i].dirAndRange.xyz, ... matched bit-for-bit"), so the
substring-only assertion passed even with the code mutated. Fixed:
comment lines are now stripped (new StripLineComments helper) before
any assertion, and a new ordered regex per shader asserts the exact
branch SHAPE -- shadowGatedOff ? -uLights[...] : normalize(uShadow...)
-- not just substring presence. Mutation-tested locally against the
rewritten test: (1) swapping the ternary's true/false operands --
FAILED (previously passed); (2) deleting the fallback entirely,
collapsing to the pre-round-5 buggy expression -- FAILED (previously
passed). Original file restored and reverified passing after each
mutation.

N2 (doc accuracy): "numerically the plain pipeline" overstated the
round-5 fix in three places (plan doc, mesh_atmospheric.vert,
terrain_atmospheric.vert). The direction expression is bit-for-bit;
the SUM is not, because the atmospheric shaders' split ambient+point
vs directional accumulation (and terrain's two varyings vs the plain
pipeline's one) reassociates float summation order by ~1 ulp -- which
is exactly the measured mean |Delta| 0.007 the coordinator's own
pixel-proof evidence already recorded (well under the 65 px noise
floor). All three rewritten to say the receiver "matches the plain
pipeline to within float summation-order rounding (measured mean
|Delta| 0.007 on the offline scene)."

N3 (coverage): RenderPrepared's own cascadeCount == 0 exit (the F2
fix) had no direct test even though RenderPrepared already has 8
direct call sites in this file. Added one: ResidentMaximumReachMeters
at/below CameraNearMeters, passed straight to RenderPrepared with an
otherwise-fully-valid environment (so the fitter, not the environment
gate, is what returns zero cascades), asserting IsBindableFor true /
IsValidFor false.

N4 (latent): RenderPrepared's OWN "if (!environment.ShouldRender)"
exit is a fourth bufferless-disabled path -- unreachable via Render
(whose own gate already validates ShouldRender first) but the same
shape, and RenderPrepared is called directly by tests and any future
caller. Took the preferred fix: publishes the disabled binding there
too, via the same helper, so every exit on a frame that draws the
world publishes when the pack's AtmosphericFrame is bound. Also made
EvaluateGateAndPublishDisabledBinding self-contained: it now resets
_currentFrameBinding to Disabled on its own entry instead of relying
on Render having done so first (idempotent with Render's own reset).

Regenerated SPIR-V: mesh_atmospheric.vert and terrain_atmospheric.vert
are comment-only changes (N2), so only the manifest's source hashes
changed -- compiled .spv bytes are unchanged, consistent with round 3's
precedent for comment-only shader edits.

Verify: Release build 0 warnings/0 errors. App hermetic-lane filter
6,055/0 failed. Full hermetic-filtered solution: 15,283/0 failed
across 15 projects.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 04:19:05 +02:00
Erik
754d59d949 fix(render): shadow-gated-off receiver frames light from the authored sun again (Campaign VM VM6 review 5)
Round 4 (eec95535) fixed wind but introduced a new lighting bug: the
receiver VERTEX shaders source the sun direction from the shadow block,
not only the shadow visibility term. mesh_atmospheric.vert's
accumulateLights read uShadowLightDirectionAndSource unconditionally for
every directional light; terrain_atmospheric.vert did the same for its
single sun term. Round 4's PublishDisabledReceiverBinding writes
direction (0,0,1) into that block on every shadow-gated-off frame (user
sun-shadow-strength 0 in daylight, indoor/portal cover, night), so every
such frame was lighting outdoor terrain and objects from straight
overhead instead of the authored sun. Publishing the environment's real
direction would not have restored parity either -- the celestial shadow
source direction (sun/moon disc) is not the authored light direction.

F1 (BLOCKER): fixed in the shaders themselves, exact parity with the
plain pipeline. Both receiver verts now branch on the same flag bit
acdreamDirectionalShadowVisibility already reads
((uShadowTextureAndFlags.w & 1u) == 0u) and, when clear, use the EXACT
plain-pipeline expression instead of the shadow block's direction:
-uLights[i].dirAndRange.xyz in mesh_atmospheric.vert (matching
mesh_modern.vert, hoisted out of the light loop as a uniform branch);
-uLights[0].dirAndRange.xyz in terrain_atmospheric.vert (matching
terrain_modern.vert's sunDir/-sunDir form). The (0,0,1) word in the
disabled block stays as the documented normalize()-cannot-NaN guard; its
comment now says so explicitly since it is no longer read as a light
direction when the flag is clear.

F2: RenderPrepared's cascadeCount == 0 return is a third bufferless-
disabled path reachable from a frame that already passed Render's own
two gates (the cascade fitter can still find zero usable cascades) --
publishes the same disabled binding now, via the same
PublishDisabledReceiverBinding helper (re-signatured to take a bare
AtmosphericFrameBufferBinding so all three call sites -- Render's two
early-outs plus this one -- share it).

F3: removed a stray duplicated " -- Closeout and merge" fragment under
the plan's VM7 heading.

F4: corrected the false "the flag bit makes it numerically the plain
lighting sum" claim in the plan's round-4 paragraph and in
WbDrawDispatcher.DirectionalShadowReceivers.cs -- the flag bit alone
only fixed the shadow VISIBILITY term (already correct before round 4);
it took both that AND round 5's light-DIRECTION fallback to actually
match the plain pipeline.

T1: extracted Render's gate prologue (environment evaluate -> two
early-outs -> PublishDisabledReceiverBinding) into internal
EvaluateGateAndPublishDisabledBinding(frame, in input, out environment,
out environmentGateTicks), behaviour-preserving, called by Render before
it touches world/terrain -- the ArgumentNullException.ThrowIfNull(world)/
ThrowIfNull(terrain) calls keep their exact position relative to the
gate. No test in this suite constructs a real WbDrawDispatcher +
TerrainModernRenderer pair (still true), so this extraction is what
makes the gate itself testable; two new tests drive it directly with
PlayerInsideCell: true and with ResidentMaximumReachMeters <=
CameraNearMeters, asserting TryGetCurrentFrameBinding true / IsValidFor
false for both.

T2: proves the actual composition WbDrawDispatcher.PipelinesFor and
TerrainModernRenderer both use -- TryGetCurrentFrameBinding feeding
ShouldSelectReceiverPipeline -- selects the receiver pipeline for the
atmospheric world pass once a disabled binding is published, and still
refuses a non-atmospheric pass name.

T3: shader-source guard (same style as AtmosphericPostProcessGraphTests'
existing shader-text tests) pinning that both receiver verts contain the
flag-gated fallback and reference the same uLights expression the plain
verts use, so a future edit that drops the fallback fails this test
instead of only showing up in a pixel capture.

T4: the (0,0,1) test's doc comment and an inline assertion comment now
say the value is a NaN guard, not a light direction.

Regenerated SPIR-V: mesh_atmospheric.vert and terrain_atmospheric.vert
recompiled to different bytes this time (a real code change, not a
comment); manifest updated to match.

Verify: Release build 0 warnings/0 errors. App hermetic-lane filter
6,054/0 failed. Core.Tests 4,695/0 failed. RenderPackValidator 30/30.
Full hermetic-filtered solution: 15,282/0 failed across 15 projects.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 03:58:41 +02:00
Erik
a82959f1b7 fix(render): production packed classifier carries the foliage flags; deferred-alpha replay keeps them (Campaign VM VM6 review 2)
Narrow re-review of 43e3abed found every A1-A5/A7/A8 item resolved but
one new blocker in the production packed classifier.

F1 (BLOCKER): RetailPViewPassExecutor.DrawPackedProductionRoute — the
route production world geometry actually draws from — never computed
FoliageFlags at all. WbDrawDispatcher.PackedOracle.cs's
ClassifyPackedBatches built its GroupKey with the field defaulting to
0u, and GetOrCreatePackedGroup never copied it onto the created
InstanceGroup, so production BatchData.flags bits 1/2 were always zero
for every scenery entity: the world geometry never swayed even though
the independently-classified shadow caster did, so shadows visibly
swayed under rigid trees. Both classifier call sites now compute
FoliageFlags via the identical FoliageWindClassification.Classify call
and entity-scoped HasCutoutSubset OR the classic (non-packed) path
uses, and GetOrCreatePackedGroup copies it exactly like
GetOrCreateInstanceGroup always has. The G2/G3 classified-output
digest (AddOpaqueSubmissionGroup/BuildTransparentSubmissionDigest) now
also folds GroupKey.FoliageFlags into its hash — present in the key
since round 1 but never actually read by either digest function, so a
content-level (not just group-count-level) classic-vs-packed
divergence is now caught.

F2 (medium): the delayed-alpha replay path (PrepareDeferredAlphaDraws)
hardcoded Flags = 1, dropping bits 1/2 for any group replayed through
it — a trunk instance promoted into the alpha-blend group mid-fade
(the #188 translucency-promotion case) would stop swaying for the
duration of its fade. Now 1u | key.FoliageFlags.

F3 (nit): ComputeEntityHasCutoutSubset's three call sites (classic,
caster, and the newly-fixed packed classifier) each allocated a
closure over _meshAdapter per Setup entity per frame. A new
context-taking overload passes the mesh adapter as an explicit
argument to a static lambda instead, letting the compiler cache one
delegate for the method's lifetime rather than allocating fresh ones.

A3 test gap: WbDrawDispatcher.BindDirectionalShadowReceiver is now
internal so DirectionalShadowGpuTests can drive it directly with a
bare RecordingGpuDevice pass encoder, proving it emits
UniformAtmosphericFrame with the exact buffer/offset/size a
DirectionalShadowFrameBinding carries — paired with the existing test
proving that binding carries the caster's real bind forward untouched.

F1's missing test: PackedDispatcherOracleTests chains
FoliageWindClassification.Classify (called with the packed
classifier's exact argument shape) for a real 0x8... scenery entity id
through BuildIndirectArrays — the same shared, already-tested
production step both classic and packed group lists feed into BatchData —
proving the resulting flags word carries bit 0x2. Driving
ClassifyPackedBatches/GetOrCreatePackedGroup directly was not a "cheap
test": both are private instance methods reachable only through the
full RetailPViewPassExecutor route, which needs a real IGpuDevice,
world-pass scope, mesh manager, and compiled pipelines to construct —
no test anywhere in the App test project stands one up.

Nits: F4 corrects foliage_wind.glsl's header comment from "bit 31" to
the top-nibble test; F5 documents at the receiver bind site that the
caster's own AtmosphericFrameBufferBinding has its seven ABI v1
members zero/Identity by construction (only the two v2 wind members
are valid) — safe today because mesh_atmospheric.vert reads that
binding solely for wind displacement, flagged as a footgun for a
future v1-reading addition to that shader; F6 notes in the plan
(rather than fixes) that EntityCacheEntry does not proactively
invalidate when FoliageWindExclusions changes on a pack switch —
harmless with the pack off, self-heals on the entry's next natural
eviction.

foliage_wind.glsl's F4 comment-only change updated the SPIR-V
manifest's source hashes for the five includers (mesh_atmospheric.vert
+ four directional_shadow_world_* casters); the compiled .spv bytes
are byte-identical since comments do not affect bytecode.

Verify: Release build 0 warnings/0 errors. App hermetic-lane filter
6,043/0 failed. Core.Tests 4,695/0 failed. RenderPackValidator 30/30.
Full hermetic-filtered solution: 15,271/0 failed across 15 projects.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 02:28:17 +02:00
Erik
43e3abed4d fix(render): correct foliage-wind classification, receiver/caster desync, and frame binding (Campaign VM VM6 review)
Opus dual-lens review of the three VM6 commits (0930c35d, 39e8408c,
6cc5e183) found two blockers and two should-fix issues; all landed here
along with the review's nits and documentation corrections.

Blockers:
- A1: the procedural-scenery classifier tested bit 31 alone instead of
  the full top nibble (0xF000_0000 == 0x8000_0000), so it also matched
  LandblockStaticEntityIdAllocator's 0xC... namespace (fences/gates/
  building shells with a cutout subset), the 0xDA11_D0xx paperdoll id,
  and the 0xFFFF_FF01 portal-tunnel id as procedural scenery — all
  three would have swayed. ProceduralSceneryIdAllocator.IsInNamespace
  now does the exact top-nibble test; FoliageWindClassification
  delegates to it.
- A2: GroupKey (the receiver's instance-batching key) did not carry
  FoliageFlags while the caster's dedup key already did, so a scenery
  instance and a non-scenery instance sharing a mesh subset coalesced
  into one receiver InstanceGroup whose flags were last-writer-wins —
  disagreeing with the correctly-keyed caster. GroupKey now carries
  FoliageFlags, computed before key construction and set exactly once
  at group creation; the imperative re-stamp is gone, and CachedBatch's
  now-redundant FoliageFlags field is removed.

Should-fix:
- A3: the world receiver pass bound UniformAtmosphericFrame only by
  accident (leftover from the caster pass, which runs first each
  frame, since Vulkan binding state isn't reset between passes).
  DirectionalShadowFrameBinding now carries the caster's exact
  AtmosphericFrameBufferBinding and BindDirectionalShadowReceiver binds
  it explicitly.
- A4: a Setup-composed tree's opaque trunk part never got the trunk
  flag because HasCutoutSubset is cached per GfxObj part, not per
  entity. FoliageWindClassification.ComputeEntityHasCutoutSubset now
  ORs HasCutoutSubset across an entity's resolved sibling parts once
  per entity, threaded into ClassifyBatches/AddDirectionalShadowBatches
  via a new optional override parameter.

Nits: A5 hashes the per-vertex flutter seed relative to the instance
origin instead of absolute world XY (fp32 sin() precision loss at far
landblock corners), mirrored in both foliage_wind.glsl and
FoliageWindModel; A7 documents the max(maxHeight, 0.5) divide-guard as
a deliberate pseudocode divergence; A8 switches FoliageWindExclusions'
construction to ToFrozenSet() and softens the "never stale" doc
comment to "no slower than one frame behind."

Tests added: top-nibble classification (0xFFFFFFFFu now correctly
false), GroupKey inequality across entity-driven scenery/landblock-
static classification, a caster-batch test proving the same pairing
never coalesces, ComputeEntityHasCutoutSubset unit + end-to-end
two-part-Setup tests, the caster→receiver AtmosphericFrame binding
carry-through, flutter-hash translation invariance relative to
instance origin, and a Storm-wind mid-height displacement floor
guarding against a "no motion" regression.

Docs: plan VM6 body corrected to the five-row WeatherKind table, "bits
1 and 2", "all four" caster shaders, and top-nibble wording throughout;
the owner gate checklist's Rain/Storm step; the stale v1-only shader-
interface compatibility entry; semantic-bindings-v1.md's v2 members
folded into the main 192-byte block; the IA-25 register row's top-
nibble wording; AtmosphericFrameInputs.cs's ABI size reference.

foliage_wind.glsl's A5 change recompiled exactly the five shaders that
include it (mesh_atmospheric.vert, the four directional_shadow_world_*
casters) plus the manifest; no other .spv changed.

Verify: Release build 0 warnings/0 errors. App hermetic-lane filter
6,041/0 failed (no environment-specific failures this run).
RenderPackValidator 30/30. Full hermetic-filtered solution: 15,269/0
failed across 15 projects.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 01:54:36 +02:00
Erik
39e8408c7d feat(render): weather-driven foliage wind for procedural scenery, shadows follow (Campaign VM VM6b)
Procedural-scenery foliage (trees/bushes — entity ids in the
ProceduralSceneryIdAllocator's 0x8XXYYIII namespace) sways with weather in
mesh_atmospheric.vert and all four directional_shadow_world_* caster vertex
shaders, both calling the identical new foliage_wind.glsl include so the
shadow moves with the leaf by construction.

Classification (FoliageWindClassification, AcDream.App.Rendering.Wb): two
new BatchData.flags bits, computed once per (entity, subset) from four
inputs — entity id (bit 31 for procedural scenery), the pack's declared
FoliageExclusions membership, the subset's TranslucencyKind, and
ObjectRenderData.HasCutoutSubset (computed once per mesh at build time, not
per frame). Bit 1 marks an alpha-cutout leaf subset; bit 2 marks an opaque
trunk subset (only when its own mesh also owns a cutout subset, so rocks
stay still). WbDrawDispatcher.ClassifyBatches (world receiver) and
AddDirectionalShadowBatches (caster) call this with the same four inputs, so
casters and receivers classify identically without needing to share state.
Retail's mesh_modern/terrain_modern/mesh_detail pipelines never read these
bits, so pack-off output is unaffected.

Motion model (foliage_wind.glsl, mirrored bit-for-bit in the new
FoliageWindModel for hermetic CPU tests): height-squared-scaled slow lean
for every foliage subset, plus branch swing and per-vertex-hash-decorrelated
flutter for cutout subsets only. AtmosphericPostProcessGraph.ResolveFoliageWind
resolves the wind block once per frame.Serial — advanced by whichever of
RenderDirectionalShadows (which runs first) or RenderPostProcess is called
first that frame, with the second reading the already-advanced state, which
is what keeps the caster and receiver reading byte-identical clock/strength
values. The per-day-group mean/gust target (AtmospherePolicyDeclaration.
FoliageWindByDayGroup, keyed by the same day-group index convention
ActiveDayGroupMultipliers already established: Clear/Cloudy/Overcast/Rainy)
eases toward its target over WeatherSystem.TransitionSeconds (10s) so a
weather change never snaps; wind-enabled off or indoor instead gates the
OUTPUT to an exact zero (not an asymptotic approach) so a settings toggle or
cell transition is immediate. The wind clock is a Stopwatch started at graph
construction (monotonic, session-relative magnitude for GPU sin() accuracy),
overridable by the same ACDREAM_SKY_PHASE_SECONDS pin SkyRenderer already
uses, for deterministic offline gates.

New settings: wind-enabled, wind-strength, wind-direction-degrees (225°
default — no authored retail wind direction exists to read),
wind-lean-metres, wind-branch-metres, wind-flutter-metres (0 on Low),
wind-canopy-height-metres.

Register row IA-25 files this as an intentional, strictly opt-in divergence:
retail applies no per-vertex wind displacement to any geometry. Known,
accepted limitation: classification is per mesh-subset (one BatchData.flags
word per indirect-draw batch), not per entity instance, so the rare case of
one mesh subset being reachable from both a procedural-scenery and a
non-scenery placement would classify all of that subset's instances alike.

Tests: FoliageWindClassificationTests (the full classification matrix),
FoliageWindModelTests (identity on non-foliage/calm-wind/base-vertex,
canopy-top displacement bound, z-never-increases, trunk has no flutter
term), RenderPackAtmospherePolicyEvaluationTests (exact day-group lookup,
no interpolation across day-group ids, easing convergence without overshoot
or discontinuity), AtmosphericShaderAbiTests (each of the five shaders calls
acdreamFoliageDisplace exactly once; mesh_modern/terrain/mesh_detail call it
never), and four AtmosphericPostProcessGraphTests additions (indoor/disabled
exact-zero gating, settings-to-UBO wiring, same-frame-Serial idempotency —
the last proxies the caster/receiver agreement invariant without needing
this hermetic harness's WbDrawDispatcher/TerrainModernRenderer dependency
chain to exercise RenderDirectionalShadows directly).

App hermetic filter: 6015/6017 (the same 2 pre-existing failures as VM6a,
confirmed unrelated). Core.Tests hermetic: 4697/4697. RenderPackValidator.Tests:
30/30. Full solution Debug and Release builds green. Shader recompile
touched exactly the 5 edited files' .spv (plus manifest); the retail oracle
set and every other pack shader are byte-identical.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 00:56:34 +02:00
Erik
0930c35d1d feat(render): shader ABI v2 - AtmosphericFrame gains clock/wind blocks; caster pass binds it (Campaign VM VM6a)
AtmosphericFrame (set 3/binding 5) grows additively from 160 to 192 bytes:
two appended vec4 members, uAtmosphereClockWind and uAtmosphereWindAmplitude,
carry the foliage-wind clock/weather and amplitude inputs VM6b's shader
displacement will read. RenderPackShaderAbi renames the old constant to
AtmosphericFrameSizeBytesV1 (160), adds AtmosphericFrameSizeBytesV2 (192),
keeps AtmosphericFrameSizeBytes pointing at the current (v2) size, and adds
ShaderAbiVersion = 2. RenderPackSpirvValidator.ValidateAtmosphericFrame
accepts either the v1 (seven-member, 160-byte) or v2 (nine-member, 192-byte)
shape and rejects anything else naming both — this is why the frozen
external sample packs under samples/*/Shaders/*.spv, whose GLSL sources are
not in this tree, need no rebuild: a v1 shader bound to the 192-byte buffer
still reads correctly, since a bound range only needs to be >= the block's
own declared size.

DirectionalSunShadowRenderer's caster pass now binds AtmosphericFrame too
(both the multiview and per-cascade sites), through a new
AtmosphericFrameBufferBinding the graph owns and supplies via
DirectionalSunShadowRenderInput. AtmosphericPostProcessGraph.RenderDirectionalShadows
builds its own 192-byte ring allocation for this, separate from the world
receiver's frame block, because the caster pass runs before RenderPostProcess
constructs that block within the same frame. The four world caster pipeline
variants (opaque/cutout, base/multiview) are now allowed to declare binding
5 in the validator; terrain casters are untouched.

This commit is plumbing only: the two new members are always written but
never read by any shader yet (zero placeholders), so pack-on and pack-off
output are both pixel-identical to before. VM6b wires the real weather-driven
values and the shader-side displacement.

App hermetic filter: 5972/5974 (2 pre-existing failures unrelated to this
change, confirmed against the unmodified baseline). Core.Tests hermetic:
4697/4697. RenderPackValidator.Tests: 30/30. VulkanShaderManifestTests
(retail oracle set): 7/7, byte-identical.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 00:30:02 +02:00
Erik
51178f7c77 fix(render): VM3 review fixes - pin the filmic decodes, re-derive vignette for linear, name the authored sun colour
Fix round from Opus review of 87677f9c (APPROVE WITH FIXES):

1. (A1/A3) Hardened the atmospheric_filmic.frag / atmospheric_bloom_
   downsample.frag shader-source pinning test: asserts the exact decode
   call count (6 - three in lowFusedScene, three in main's non-fused
   branch), that the 2.2 display-gamma exponent and 1/2.2 inverse in
   atmospheric_common.glsl are formatted FROM AtmosphericColorPipeline
   .DisplayGamma (so shader literal and CPU-tested value cannot drift),
   that the 0.18 contrast pivot in atmospheric_filmic.frag is formatted
   from AtmosphericColorPipeline.LinearMidGrey, and pins
   AtmosphericPostProcessGraph.BloomKneeLinear/BloomThresholdLinear as
   exact literals (0.73f / 1f). Also removed a stray trailing "()" from
   an existing comment in atmospheric_filmic.frag that was inflating the
   decode-call count to 7.

2. (B1) Re-derived the "vignette-strength" default for the linear-light
   post stack. The vignette multiply now happens on linear colour before
   the final encode, so a corner factor of (1 - strength) displays as
   (1 - strength)^(1/2.2), not (1 - strength) directly. The accepted
   look was strength 0.12 under the OLD gamma-space pipeline: a 12%
   on-screen corner darkening. Under strength 0.12 in the new linear
   pipeline that same 0.88 corner multiplier would only display as
   0.88^(1/2.2) ~= 0.9435 (5.6% darkening - visibly weaker). Solving
   (1 - strength)^(1/2.2) = 0.88 gives strength = 1 - 0.88^2.2 ~= 0.245,
   which reproduces the accepted 12% corner darkening. Since
   RenderPackSettingValueCodec requires every declared default to be
   step-aligned from the minimum and 0.245 is not a multiple of the old
   0.01 step, the step also moves to 0.005 (a finer slider, not
   coarser) so the exact derived default is a valid grid point -
   verified by running the ExternalTierTwoPackCanRenameEveryOwnedId
   AndShaderAsset validation test, which failed with "invalid default
   value" before this correction. Also updated the matching fallback in
   AtmosphericPostProcessGraph.FromDescriptor (0.12f -> 0.245f) for
   consistency, and added
   AtmosphericColorPipelineTests.VignetteDefaultReproducesTheAccepted
   TwelvePercentCornerDarkening pinning encode(1-0.245) ~= 0.88.

3. (A4) Renamed VolumetricShaftFrameParameters.LinearSunColor ->
   AuthoredSunColor in VolumetricShaftQuality.cs (internal, 2 references,
   both in that file - safe). Left LightSource.ColorLinear unrenamed:
   grep shows 13 files depend on it (GlobalLightPacker, SceneLightingUbo,
   LightBake, LightManager, EnvCellRenderer, RenderingDiagnostics, and
   several Core tests) across the shared retail default-path lighting
   UBO pipeline - renaming it is out of VM3's pack-only scope and would
   touch the mandatory-unchanged default path. Added a pointer comment
   on the field in LightSource.cs (and a one-line note at its
   WorldRenderFrameBuilder.cs call site) documenting the same
   display-space-not-linear fact and explaining why the rename is
   deferred to its own default-path colour-space pass.

4. (B4) Added a citation beside acesFitted in both atmospheric_filmic
   .frag and its C# mirror (AtmosphericColorPipeline.AcesFitted):
   Krzysztof Narkowicz, "ACES Filmic Tone Mapping Curve" (2016). The fit
   takes linear scene light in and returns linear display light in
   [0,1] - it does not itself gamma-encode. Evidence: acesFitted(0.80 *
   decode(0.46)) = 0.2064 un-encoded versus the accepted 0.51 on screen.

5. (B2) Rewrote the VM3 section of docs/plans/2026-08-22-visualmaster-
   campaign.md with the shipped truth in place of the pre-implementation
   guess: exposure stays 0.80 (at exposure 1.0 the linear pipeline maps
   gamma-0.5 to 0.6017, essentially the same 0.6163 the owner called too
   bright), bloom threshold stays 1.0 (a fixed point of both exponents),
   knee moves 0.45 -> 0.73, vignette-strength moves 0.12 -> 0.245. Added
   the old-vs-new curve table at exposure 0.80 across ten gamma inputs.
   Replaced the acceptance criteria's "new automated test on the
   recording RHI" with the CPU mirror + shader-source pins actually
   used, and recorded that the real-frame masked capture WAS run
   (retail vs High-with-every-effect-neutral, artifacts/vm3):
   independently re-verified by re-running the pixel diff against the
   checked-in screenshots - 110,561 px at |delta|=1 and exactly 95
   pixels at |delta|>=5, confined to foliage-canopy silhouette edges
   against sky with nothing on any ground/building/water surface. Noted
   the Stage-1 luminance table re-capture is still owed at the owner
   gate.

6. (B3) Corrected docs/plans/2026-08-21-atmospheric-rendering.md's VM3
   summary sentence: the bloom intermediate is already linear after
   extraction (no separate "bloom read" decode), and the neutral-preset
   claim is now phrased as a measured numerical identity (<=1 LSB on a
   real frame) rather than an unqualified "is" statement.

7. (A5) Corrected toolchain attribution: tools/compile-shaders.ps1 used
   the managed Silk.NET.Shaderc path (shaderc_shared.dll) to compile in
   both this round and the original VM3 commit - a Vulkan SDK glslc was
   detected and its path recorded, but the managed compiler is what
   actually ran. Regenerating this round only changed the atmospheric_
   filmic frag stage's manifest hash (comment-only edits); the compiled
   .spv bytes are unchanged, and every retail-oracle shader
   (mesh_modern, terrain_modern, mesh_detail, etc.) remains untouched.

8. Replaced an invented motive in the atmospheric_filmic.frag contrast-
   pivot comment ("rounded up for a stronger gamma-space contrast
   feel") with the actual reason: the previous 0.5 was simply the [0,1]
   midpoint of the standard contrast formula, not a deliberately chosen
   value; in linear the perceptual mid-grey is 0.18.

Verify: Release build 0 warnings / 0 errors. App hermetic-filter tests:
5970 passed / 0 failed / 0 skipped. VulkanShaderManifestTests: 7/7 pass
(retail-oracle SPIR-V byte-identical; only the atmospheric_filmic frag
manifest hash changed, no .spv bytes changed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 23:40:48 +02:00
Erik
87677f9c4f feat(render): Atmospheric post stack runs in linear light; neutral preset is numerically the pack-off image (Campaign VM VM3)
Closes review finding F4 (docs/research/2026-08-22-campaign-ar-review.md):
retail's main-world colour, sun rays and volumetric shafts are all
gamma-encoded display-space values (the 2013 client has no linear
lighting pipeline), but bloom thresholding, ACES (Narkowicz fit),
Rec.709 luma saturation, the contrast pivot and the vignette were all
operating directly on those gamma values, then writing the result to
the UNORM swapchain without re-encoding.

- atmospheric_common.glsl gains acdreamDecodeDisplay/acdreamEncodeDisplay
  (pow(c, 2.2) / pow(c, 1/2.2)). 2.2 is the retail-era CRT/early-LCD
  display-gamma assumption, deliberately not the sRGB piecewise curve,
  which would claim a precision retail's authoring pipeline never had.
  uAtmosphereSunColor's comment is corrected from "authored linear rgb"
  to "authored display-space rgb (retail has no linear pipeline)".
- atmospheric_bloom_downsample.frag, atmospheric_filmic.frag (both the
  fused-Low and non-fused paths) decode every world/ray/volumetric read
  before summing/thresholding; atmospheric_bloom_blur.frag is unchanged
  (it already reads the now-linear bloom buffer); atmospheric_sun_rays.frag
  and atmospheric_volumetric.frag are documented as writing display-space
  colour that the consumers decode.
- The contrast pivot moves from 0.5 (a gamma-space midpoint) to 0.18
  (linear mid-grey, the standard 18%-grey-card exposure convention).
  The final filmic output is clamped in linear, then re-encoded before
  the UNORM write.
- Bloom threshold/knee are re-derived for linear light: the pre-VM3
  gamma-space pair was threshold 1.0 / knee 0.45, i.e. a soft range of
  [0.55, 1.0] in gamma. Decoding both ends with the same 2.2 assumption
  gives decode(1.0) = 1.0 (threshold unchanged) and
  decode(0.55) = 0.55^2.2 ~= 0.27, so linear knee = 1.0 - 0.27 ~= 0.73.
  Replaced the inline 0.45f literals with named constants
  BloomThresholdLinear = 1f / BloomKneeLinear = 0.73f on
  AtmosphericPostProcessGraph. bloom-strength's 0.65 default is
  untouched.
- Exposure stays at its accepted 0.80 default: in linear,
  encode(acesFitted(0.80 * decode(0.46))) ~= 0.50, reproducing the same
  accepted midtone the old gamma-space pipeline produced as 0.51 for the
  same 0.46 input (0.46 * 0.80 fed straight into acesFitted, no
  decode/encode). Highlights now retain more (gamma 0.9 input moves from
  ~0.74 to ~0.85 through the full pipeline) and blacks deepen slightly
  (gamma 0.1 moves from ~0.09 to ~0.05) — the owner's visual gate judges.
- Added AtmosphericColorPipeline, a CPU mirror of the GLSL decode/encode/
  ACES/grade/filmic math (line-for-line, with a header comment requiring
  it stay mirrored), and AtmosphericColorPipelineTests: neutral-preset
  identity within half an 8-bit step for a 0..255 grey sweep (proving the
  neutral preset is numerically the pack-off image), decode/encode
  round-trip within 1e-6, monotonic-in-exposure, the pinned midtone/
  highlight/shadow numbers above, and the bloom-knee derivation.
- Added a shader-source pinning test so a future edit cannot silently
  drop the colour-space conversions: atmospheric_filmic.frag must
  contain exactly one acdreamEncodeDisplay( call in main()'s output,
  atmospheric_bloom_downsample.frag must contain at least three
  acdreamDecodeDisplay( calls.
- Regenerated SPIR-V (tools/compile-shaders.ps1, glslc from the
  installed Vulkan SDK). Only atmospheric_bloom_downsample.frag.spv and
  atmospheric_filmic.frag.spv changed in bytes; every other pack shader
  that includes atmospheric_common.glsl recompiled to a byte-identical
  binary (the new decode/encode helpers are unreferenced dead code for
  them). VulkanShaderManifestTests' retail-oracle SHA-256 set
  (mesh_modern, terrain_modern, mesh_detail, etc.) is untouched and
  still passes — the retail default path did not change.
- Docs: noted the linear-light move in the AR plan's Slice 1 section,
  and added a "Colour space" section to the render-pack ABI doc
  (docs/render-packs/semantic-bindings-v1.md) naming which inputs are
  display-space and pointing at atmospheric_common.glsl as the
  reference implementation. No ABI version bump — the binding layout
  is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 23:10:57 +02:00
Erik
b1e9912535 fix(render): jitter the volumetric march per pixel (IGN); file #421 for the shadow transform buffer (Campaign VM VM5)
Fixed step-count volumetric ray marches (Low/Medium presets, e.g. 40 steps)
sample the same t values at every pixel, so a shadow-cascade edge crossing
lands at the same march step across a whole neighbourhood of pixels and
reads as visible concentric banding around light shafts. Offset each
pixel's march phase with interleaved gradient noise (Jimenez 2014,
fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056,
0.00583715))))) instead of the fixed 0.5 sub-step center: t = (step + ign)
/ steps. This decorrelates the step boundaries across pixels, turning the
banding into fine per-pixel noise that the existing quarter/half-res
upsample already averages away. Pack-on only — atmospheric_volumetric is
never used on the retail default path; extinction, colour, and early-outs
are unchanged.

Regenerated SPIR-V via tools/compile-shaders.ps1: only
atmospheric_volumetric.frag.spv and shaders.manifest.json's source hash
for that stage changed.

Also files #421 (docs/ISSUES.md) for Campaign AR review finding F7: the
directional-shadow pass's DirectionalShadowTransformBufferSet duplicates
the main pass's N.5 instance SSBO instead of binding it, to be resolved
once the planned GPU-culling step for shadow cascades lands. Updates the
VM5 ledger row and outcome note in
docs/plans/2026-08-22-visualmaster-campaign.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 22:56:03 +02:00
Erik
ae6513126e fix(render): detail overlay is fogged after the combine like retail; VM1 review fixes
Opus dual-lens review of 05970306 + 388457a7 (APPROVE WITH FIXES). Four
items, all landed:

1. FOG (behavioural). Retail's D3D fixed-function fog stage runs AFTER the
   texture-stage pipeline, so the detail contribution must be fogged, not
   just the base. mesh_modern.frag already fogs the base colour
   (applyFog(rgb, vWorldPos)) before mesh_detail's replay draws over it;
   mesh_detail.frag previously emitted raw detail.rgb, understating fog by
   f*a*(fog-detail). Fix: mesh_detail.vert now outputs vWorldPos (mirroring
   mesh_modern.vert); mesh_detail.frag declares the identical SceneLighting
   UBO and applyFog function (copied verbatim, same binding/std140/math) and
   fogs detail.rgb before emitting it. This collapses algebraically to
   retail's fog-after-combine order:
     (1-a)*mix(base,fog,f) + a*mix(detail,fog,f) = mix(lerp(base,detail,a),fog,f)
   RetailDetailTextureContract gains ExpectedFogged(base,detail,opacity,fog,
   fogFactor); RetailDetailTextureContractTests pins the identity across 200
   random samples within 1e-6.

2. EnvCellRenderer.Rhi.cs's DrawEnvCell-category comment still said "apply
   the 10-50 m positive-view-depth fade" — a stale claim from before VM1
   removed the fade. Replaced with the mip-chain attenuation statement that
   mesh_detail.vert's header comment already carries.

3. Added the test the VM1 contract required but never had: TerrainAtlas
   .TryCreateDetailTexture uploads a full mip chain (MipLevelCount ==
   RhiWorldTextureArray.MipLevelsFor(w,h), GenerateMipChain called) and
   registers with the repeat/linear world sampler, not single-level or
   clamped. Drives the private method directly (reflection) against a
   synthetic PFID_A8R8G8B8 RenderSurface through a minimal in-memory
   IDatReaderWriter fake, so the lane stays hermetic (no installed DAT).

4. #226 pseudocode note: noted that retail's stage-1 OUTPUT alpha
   (MODULATE(TEXTURE, CURRENT), 0x0059c549) — the framebuffer blend weight a
   delayed-alpha subset composites with — is not modelled; acdream instead
   draws a second pass weighted by detail.a*diffuseAlpha. Identical for
   opaque subsets, a bounded difference on translucent building/EnvCell
   subsets already covered by the existing AP-34 shared-alpha-queue
   divergence row. Also qualified the tmpmaterial.Diffuse.a = 1f (0x0059cb99)
   citation to name its exact branch (burnedInStaticLights < 0 &&
   *(render_device+0x7e4) == 0); the other branch leaves diffuse FromVertex,
   but the opaque->1 / fading->opacity mapping still holds either way.

Nit also folded in: EnvCellRendererTests' new SubmitRhi instance-alpha test
is now a [Theory] over WbRenderPass.Opaque and .Transparent, pinning the
bind-before-first-draw invariant on both passes.

Regenerated mesh_detail's committed SPIR-V and the shader manifest
(tools/compile-shaders.ps1); no other shader pair changed.

Verified: dotnet build AcDream.slnx -c Release (0 warnings, 0 errors);
dotnet test on AcDream.App.Tests (Release, hermetic lanes) green, including
the shader manifest tests explicitly; AcDream.Core.Tests unaffected/green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 22:39:58 +02:00
Erik
059703066f fix(render): #226 detail overlay uses retail's single-pass combine; drop the dead distance fade (Campaign VM VM1)
VM2's live cdb read against the PDB-paired retail client (GUID
9e847e2f-777c-4bd9-886c-22256bb87f32) proved
m_caps.bCanDoSinglePassDetailing = 1 and trysinglepass = 1 on real hardware,
so D3DPolyRender::RenderMeshSubset (0x0059ca10) never falls back to the
two-pass framebuffer blend the earlier #226 port reproduced. Every loaded
CGfxObj sets use_built_mesh = 1 (CGfxObj::InitLoad 0x005346b0), so buildings
and EnvCells always take the single-pass texture-stage combine set up in
D3DPolyRender::SetSurface (0x0059c4d0):

    result = lerp(base * diffuse, detail.rgb, detail.a * diffuse.a)

RenderMeshSubset lights opaque built-mesh subsets with
tmpmaterial.Diffuse.a = 1, so on the live Dereth category texture
0x06006D58 (mean rgb 0.165, mean alpha 0.132) the combine works out to
~0.868 * base + 0.022 — a mild darkening, the opposite sign of the fallback
DstColor blend's brightening.

Also removes the invented 10 m / 50 m distance fade. Retail's
ACRender::get_alpha_for_z (0x006b6230) is only evaluated in
D3DPolyRender::DrawPolyInternal (0x0059d7c0, the immediate-polygon path)
and only when the static noFadeDetail (0x00820e38, initialised to 1) is 0 —
unreachable for built meshes. Attenuation is the sampler's linear mip chain
converging to the texture mean, not a scripted ramp.

Changes:
- mesh_detail.vert/.frag: drop vDetailFade and its distance term; add
  vDetailOpacity mirroring mesh_modern.vert's InstanceAlphaBuf (binding 7)
  read, and output detail.rgb with alpha = detail.a * vDetailOpacity under
  the corrected pipeline blend.
- VulkanViewportMapping.BlendFactorsOf / GpuEnums.GpuBlendMode.RetailDetail:
  SrcAlpha + OneMinusSrcAlpha instead of DstColor + OneMinusSrcAlpha.
- RetailDetailTextureContract: replaced the distance-fade constants and
  FramebufferFactor with Expected(base, detail, opacity) and IsNeutral,
  matching the lerp; contract tests cover zero-alpha/zero-opacity no-ops,
  the measured darkening on the live category texture, and full-alpha
  replacement.
- Regenerated mesh_detail's committed SPIR-V and the shader manifest
  (tools/compile-shaders.ps1); no other shader pair changed.
- Docs: #226's pseudocode note, the docs/ISSUES.md #226 entry, and the
  retired TS-52 divergence-register row corrected from the two-pass
  DESTCOLOR description to the single-pass path and the darkening
  expectation, each citing the VM2 cdb note.

Verified: dotnet build AcDream.slnx -c Release (0 warnings, 0 errors);
dotnet test on AcDream.App.Tests and AcDream.Core.Tests (Release, hermetic
lanes) both green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 21:58:48 +02:00
Erik
7a5f96ede5 feat(render): implement Campaign AR and terrain fidelity 2026-08-22 13:13:29 +02:00
Erik
bc43fb1d1d fix(ui,runtime): OP4 review fixes — live re-seed, enable-gating, Combat panel re-point, universal timestamps
Both OP4 reviews converged on one headline bug (Character-tab rows never
re-read live server truth after their pre-login constructor-word seed) plus
overlapping MUST-FIXes. All ten converged/consolidated findings land here:

MUST-FIX:
- BoolOptionRow.SaveCurrentValue now re-reads its live binding (retail's
  GetValue()-into-SaveCurrentValue) on every OnShown — panel open, tab
  switch in, initial activation — instead of trusting the pre-login
  constructor word it was built with. Reset/tab-switch can now only
  restore values that were actually live at the last show. LockUI's
  host.Root.UiLocked one-shot mount seed now also converges on every
  PlayerDescription via the existing OnCharacterOptionsChanged hook.
- Apply/Reset are wired to OptionPage.OnOptionChanged in production
  (Ghosted when nothing changed, Normal when dirty, run once at bind so
  both start disabled per retail's PostInit); Defaults stays ungated.
- The Combat panel's three LEDs (Repeat Attacks/Auto Target/Keep in View)
  now read/write the same RuntimeCharacterOptionsState seam the Character
  tab uses instead of a disconnected client-local GameplaySettings copy —
  closes the "two writable copies" divergence. The three now-orphaned
  GameplaySettings fields and RuntimeSettingsController's mirror
  properties/SetCombatGameplay are deleted outright; the headless host's
  hardcoded AutoRepeatAttack/AutoTarget now read the live option bit.
- RuntimeSettingsController.SetUiLocked's convergence guard now compares
  against the last value actually applied to the runtime target instead
  of the persisted GameplaySettings.LockUI snapshot, which could already
  match a server-derived request without ever having been pushed.

SHOULD-FIX:
- DisplayTimeStamps now prefixes every chat producer (ChatLog.Append is
  the one seam all of them funnel through), not just AddText's own
  callers — heard speech, emotes, Turbine channels, and combat text were
  previously missed. The prefix format escapes its colons and forces
  InvariantCulture instead of the culture-dependent TimeSeparator
  placeholder.
- sky.frag now honors uFogParams.w (fog mode) like the mesh/terrain
  shaders, so Disable Distance Fog stops the sky dome's horizon band from
  blending toward fog color too.
- Corrected the "byte-verified" overclaim on the timestamp format string
  doc comment (BN-sourced, wire doc U6) and the AP-194 anchor-column
  class-name typo; the RunAsDefaultMovement doc comments now cite retail's
  actual acclient.h enumerator name.
- Added: DispatcherMovementInputSource's option x modifier truth table
  (incl. || AutoRunActive with the option off), the per-page Apply/Reset
  enable-gate tests, a real checkbox.OnClick/ToggleBehavior-driven click
  test, and hash-pins for the six header string keys.
- Gate script step 8 corrected for the logout-flush false-failure
  (closing the panel before relogging is load-bearing); a new step
  documents the enable-gate sequence and the Combat-panel/Character-tab
  cross-check.

Register: AP-196 (the Group-C default-source change + GameplaySettings
retirement) and AP-197 (the ignored per-character timestamp format
override) filed in this commit.

Full Release suite: 13,044 passed / 4 skipped / 0 failed (was 13,008/4/0;
net +36 tests from new coverage and legitimate assertion updates from the
GameplaySettings retirement).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 05:30:26 +02:00
Erik
7a0227c12e feat(render): Vulkan campaign V11 step 3 — drop the GL packages and shaders
Commit 2 deleted the GL rendering backend's implementations; this step
removes the package references and shader vocabulary they leave behind,
so nothing in the App project still spells Silk.NET.OpenGL.

Silk.NET.OpenGL and Silk.NET.OpenGL.Extensions.ARB are dropped from
AcDream.App.csproj. Chorizite.Core stays — the audit is NOT clean: its
Render.Enums (TextureFormat, BufferUsage) and Lib.BoundingBox types are
used directly and extensively across the Wb texture/mesh pipeline,
independent of the deleted GL IUniformBuffer implementers the package
comment used to cite. The stale comment is corrected in place.

IMeshPipelineDevice.Gl is removed along with the GL? gl parameter
threaded through WbMeshAdapter's four constructors, WorldRenderComposition's
CreateMeshAdapter, and VulkanMeshPipelineDevice's Gl => null
implementation — nothing read any of them once the legacy per-mesh
upload bodies were gone (confirmed by grep: the sole non-doc-comment hit
was a test assertion). While in WbMeshAdapter.Dispose(), found and fixed
a real bug along the way: its teardown still pattern-matched the deleted
GL GpuFrameFlightController to decide whether to wait for submitted work,
which VulkanFrameFlightController replaced at slice V6a without this site
being updated — so the wait had been silently dead on every Vulkan run
since then. Retargeted to VulkanFrameFlightController, which carries the
same WaitForSubmittedWork().

The GL pixel-format vocabulary (Silk.NET.OpenGL.PixelFormat/PixelType) that
WorldTextureArray/TextureFormatExtensions/TextureAtlasManager used for
upload validation is replaced by AcDream.Content's existing Silk.NET-free
UploadPixelFormat/UploadPixelType enums (added at MP1a to keep the bake
tool GL-free); two new members (Rgb, Red, Float) extend that enum with
their GL ABI constants to cover the full vocabulary WorldTextureArray
needs, since MP1a's original set only covered what the extractor itself
emits. ObjectMeshManager's App-boundary cast
`(Silk.NET.OpenGL.PixelFormat?)batch.UploadPixelFormat` becomes a direct
pass-through now that both sides share the type.

GpuBindingModel.StorageTextureTable (the GL-only binding=9 emulation of
the Vulkan texture table) is deleted and StorageBindingCount drops from
10 to 9; the descriptor-set-layout code that builds from that count
(VulkanPipelineLayouts, VulkanFrameBindings) is untouched and just
allocates one fewer always-dummy-seeded, always-unused binding.

Several fully dead GL-only classes came along for the ride, confirmed by
zero construction sites: SilkFramebufferViewportTarget
(NullFramebufferViewportTarget is the sole production
IFramebufferViewportTarget), SilkRenderGlStateReader
(NullRenderGlStateReader.Instance is the sole IRenderGlStateReader),
RuntimeRenderFrameClearPhase (VulkanRenderFrameClearPhase is the sole
IRenderFrameClearPhase, expressing the same atmosphere-clear logic as a
pass load-op instead), and GpuFrameTimer plus FrameProfiler's
GL-owning FrameBoundary(GL) overload and BeginGpuFrame/EndGpuFrame
bracket (RecordGpuSample is the only GPU-timing path any backend uses
now — the ACDREAM_WB_DIAG nested-query exclusion these existed for no
longer applies, since WbDrawDispatcher's own diagnostic GPU sampling
already moved to the device's Vulkan timer pool). GpuFrameFlightController
itself stays (never constructed with a real fence API in production, but
its retirement-ledger/serial-ring logic is backend-neutral and still
covered by its own unit tests) — only its GL-specific parts (the public
GL constructor overload, SilkGpuFenceApi) are deleted, since removing the
whole class would mean restructuring the frozen Slice-8 composition
shape's GpuFrameFlightController? threading, which is out of this
commit's scope. TextureParameters.cs and BufferUsageExtensions.cs
(zero callers each) are deleted outright.

common.glsl is deleted: nothing in the actual Vulkan .spv build reads
it. tools/ShaderCompiler/Program.cs compiles each .vert/.frag pair
directly and tools/ShaderCompiler/VulkanGlslPreamble.cs injects its own
complete self-contained preamble per file; common.glsl's textual
concatenation was exclusively Shader.cs's GL-only mechanism, deleted at
Commit 2. The five shader files that named it in comments
(mesh_modern.vert, particle.vert, particle.frag, sky.frag,
terrain_modern.frag) are corrected to point at VulkanGlslPreamble.cs
instead. mesh.vert/mesh.frag — the pre-N.5 legacy shader pair the
mandatory modern path already made unreachable, with zero C# consumers
and no compiled .spv — are deleted too. Regenerated via
tools/compile-shaders.ps1: 9/9 remaining shader pairs compile
(previously 9/10, with mesh the sole failure — the VulkanShaderManifestTests
doc comment's "nine of ten are not Vulkan-expressible" was already
stale before this commit).

Test fallout: dead-subject test methods/files are deleted rather than
patched (TextRendererFailureSafetyTests.cs, ClipFrameUploadTests.cs,
GpuResourceRetirementTransactionTests.cs's GL queue tests, one
WorldRenderDiagnosticsTests source-order test, one
RenderFrameResourceControllerTests clear-phase-order test); tests whose
subject moved or was renamed are updated in place rather than deleted
(GpuContractTests, VulkanCapabilityGateTests, MeshPipelineDeviceSeamTests'
pinned seven-member surface now reads six, ParticleBindlessInstanceTests'
cross-dialect check now covers the one surviving dialect,
WbMeshAdapterTests' misleadingly-named null-gl test — gpuDevice was
always the parameter that actually threw).

Build: `dotnet build AcDream.slnx -c Release` — 0 warnings, 0 errors,
with the Silk.NET.OpenGL/.Extensions.ARB package references physically
removed from the csproj (not just unreferenced in code).
Tests: full-solution `dotnet test` green across every project.
Zero remaining `using Silk.NET.OpenGL` anywhere in src/ or tests/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 02:58:15 +02:00
Erik
eced67d038 feat(render): Campaign V slice V6l commit 2 - the portal mask draws on Vulkan
Contract amendment 2 of three, and V4g's remaining half behind it. Plan section
5.5.16 defect 2: PortalDepthMaskRenderer's two-pass punch (#117) is built on
glStencilFunc/glStencilOp/glStencilMask, GpuPipelineDescription carried no
stencil state at all, and nothing else can express it - so the renderer stayed
raw GL, invisible to the Vulkan arm, and V4g's "stencil/depth-mask pipelines"
row could not be written.

The amendment splits the way core Vulkan 1.3 splits. The ENABLE and the
attachment intent are baked: GpuPipelineDescription.StencilTest, false by
default so no pipeline in the tree changed. The per-draw compare, three outcome
ops, reference and both masks are a GpuStencilState that the pipeline carries as
a DEFAULT and IGpuPassEncoder.SetStencil overrides - exactly the split cull
mode, front face and depth write already have, and exactly what
VK_DYNAMIC_STATE_STENCIL_OP/_COMPARE_MASK/_WRITE_MASK/_REFERENCE make dynamic.
The four stencil dynamic states are declared ONLY by a pipeline that tests
stencil: declaring a dynamic state obliges every draw with the pipeline to have
set it, so adding them unconditionally would make every existing pipeline depend
on a call none of them make. GpuStencilOp carries three values because the punch
uses three - Replace marks, Equal gates, Zero self-cleans - and a fourth would
be a facility with no consumer.

The arm. Three pipelines, not one, because depth COMPARE is not dynamic in the
contract and the punch's two passes differ in it: mark tests LEQUAL and writes
no depth, punch tests ALWAYS and writes, seal is ALWAYS + write with no stencil.
All three write no colour, which is what retail's "COLOR-INVISIBLE triangle fan"
means. The fan is expanded to a triangle LIST on the CPU - the contract has no
fan topology and Vulkan's is not portable - which is exact: triangle i is
(v0, v[i+1], v[i+2]), the same triangles in the same order.

portal_depth.{vert,frag} is a new committed shader pair, and this is the ONE
renderer in the campaign whose two arms do not share a source. Its clip planes
have to travel in the TerrainClip uniform block at binding 2, which is already
precisely this shape and already read by terrain_modern.vert and sky.vert - but
on GL that binding is held globally by ClipFrame for terrain, so a portal draw
that rebound it would leave every later terrain draw in the frame reading the
wrong region. The GL arm therefore keeps its inline program.
PortalDepthShaderParityTests is the tripwire: retail's far-Z constant
(0.99999988, from DrawPortalPolyInternal 0x0059bc90), #129's capped mark-bias
expression and the eight-half-plane loop are asserted to appear in both. Both
are deleted at V11. 9/10 shader pairs now compile to SPIR-V.

Two GL-side gaps closed while the state was being extended, both of section 7.1
rule 1's class rather than new work. GlAmbientCapabilityState now saves and
restores the stencil test, function, ops and both masks - the portal punch draws
mid-frame among renderers that are still raw GL and assume the test is off - and
the COLOUR MASK, which had no consumer until a colour-invisible pipeline existed
and whose absence would have blacked out every raw-GL renderer after such a
pass.

PortalTunnelPresentation was re-read and confirmed as V6k left it: it clears
depth and draws into the active viewport, binds no framebuffer of its own, and
needs no port for section 5.4's sake. It remains unported on the Vulkan arm -
the composition uses NullLocalPlayerTeleportPresentation there - which is an
absence on the V7 list, not a defect.

Gates. Release build green. App tests 4,129/3 skips; complete Release suite
9,192/5 (one solution-wide run reported a single App failure that did not
reproduce in two subsequent runs, solution-wide or alone - the documented
rerun-singly flake class). Strict GL offline pixel gate against 08ffe141:
2.31e-05, 13 differing pixels of 563,200, inside the documented 9-31 band. GL
connected -Runs 3: 3/3 RENDERED on the desktop witness and 3/3 on the client
capture. One offline Vulkan run with VK_LAYER_KHRONOS_validation proven inserted
by the loader: zero validation errors, zero warnings, a captured world frame.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:36:45 +02:00
Erik
f7344758f8 fix(render): Campaign V slice V6i-2 commit 1 — the terrain clip block reaches set 1
Plan §5.5.12 finding 2, measured on the committed SPIR-V rather than inferred:
terrain_modern.vert declared

    layout(std140, binding = 2) uniform TerrainClip { ... }

with no ACDREAM_UBO_SET, so under the Vulkan dialect the block landed in set 0
binding 2 — which set 0's layout declares as a STORAGE buffer. Any terrain
pipeline built against the shared pipeline layout was therefore malformed.
Nothing had caught it: GL expands the macro to nothing and keeps its UBO and
SSBO namespaces separate, the shader compiled cleanly for both backends, and no
terrain pipeline has ever been created on Vulkan. sky.vert declares the SAME
block correctly and is the precedent, so this is a one-word omission, not a
numbering question.

spirv-dis on spv/terrain_modern.vert.spv, before and after:

    before   %372 = OpVariable %_ptr_Uniform__struct_370 Uniform
             OpDecorate %372 DescriptorSet 0 / Binding 2
    after    OpDecorate %372 DescriptorSet 1 / Binding 2

with %_struct_370 = OpTypeStruct %int %_arr_v4float_uint_8 — TerrainClip's
{ int uTerrainClipCount; vec4 uTerrainClipPlanes[8]; } — in both.

The same commit closes §5.5.8's second recorded gap. Set 1's layout declared
only bindings 1 and 3, so it was missing BOTH the terrain clip block and
UniformSkyParams at binding 4, which sky.vert and sky.frag have compiled to
SPIR-V since V6e. Both are now declared, all four dynamic, which is half
Vulkan's guaranteed maxDescriptorSetUniformBuffersDynamic of 8 and is asserted
by the capability gate as before.

Membership and ORDER now come from one predicate — IsDeclaredUniformBinding —
that the layout, the descriptor writes and vkCmdBindDescriptorSets's
dynamic-offset array are all built from, the same shape V6g gave set 0. The
three had been restated separately, which is exactly how a fifth binding would
have gone wrong the same way.

Both gaps were found by hand, months apart, and neither could fail on the
shipping backend. VulkanShaderDescriptorContractTests reads the committed .spv
and asserts the partition instead: every uniform block at a declared set-1
binding, every storage block inside set 0's declared range, every sampled
resource in the one texture table. Checked out against the pre-fix .spv, two of
its four tests fail.

Gates: Release build; App tests 4,090 / 3 skips (4,086 baseline plus four);
strict GL offline pixel gate vs 0ca802cd 3.02e-05 (17 px of 563,200, inside the
documented 9–31 px control band, 33x under threshold) — expected, since GL
executes not one changed statement; one Vulkan composition-host run with
VK_LAYER_KHRONOS_validation proven inserted by the loader at zero errors, zero
warnings and no [shutdown] diagnostic on either stream.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 13:31:13 +02:00
Erik
30e94da607 feat(render): Campaign V slice V6f-3 - terrain's atlas reads cross the dialect
The last thing keeping terrain_modern out of SPIR-V was how it named its two
atlases:

    #define uTerrain sampler2DArray(ACDREAM_TEXTURE_HANDLE(uTextureIndexA))
    #define uAlpha   sampler2DArray(ACDREAM_TEXTURE_HANDLE(uTextureIndexB))

`sampler2DArray(handle)` is a GL_ARB_bindless_texture construction with no
Vulkan equivalent. Vulkan's table is an opaque descriptor array in set 2; there
is no handle, so there is nothing to construct a sampler from. The ten sample
sites now go through ACDREAM_SAMPLE_ARRAY, the dialect-neutral read V6e
introduced for mesh_modern, wrapped in two shader-local macros that keep the
call sites reading as "sample the terrain atlas" rather than "index the table".

They are SAMPLING macros, not sampler-returning ones, and that is not
cosmetic. Under Vulkan the expansion carries `nonuniformEXT` on the indexing
expression, and binding the result to a local sampler2DArray first is exactly
where an implementation may drop it. The old `#define uTerrain
sampler2DArray(...)` was textually that shape, so preserving it would have
reintroduced the hazard at every use site.

On GL nothing about the sampled result changes: the same slot resolves through
the same binding=9 table to the same handle to the same texel, and the macro
expands to the identical expression the shader wrote by hand.

With this, terrain_modern compiles for Vulkan and the manifest reads 8/9. The
remaining pair is `mesh`, which the campaign doc records as having no consumer
at all - so every production shader acdream actually draws with is now
Vulkan-expressible. That closes the obligation §5 recorded against V6e ("the one
production pair still not Vulkan-expressible after V6e is terrain_modern") and
it closes the shader half of V4d's parked content.

What this does NOT do is give the Vulkan backend a world to draw. That is
reported separately with the rest of slice V6f; the shaders were the part that
could be finished, gated and landed on GL today.

Gates. Release build clean. App tests 4,073 passed / 3 skipped over four
consecutive runs. A fifth run failed only
CurrentRenderSceneOracleTests.SurfaceOverrideFingerprint_DictionaryHotPathAllocatesNothing,
an allocation-counting test over a CPU dictionary path that touches nothing in
this diff; it passes in isolation and passed in every other full run. That is
the known #250 flake class on an otherwise unchanged tree. Offline pixel gate
against fac09407: 12 differing pixels of 563,200 compared (fraction 2.13e-05) -
below even the floor of the documented 15-23 pixel band. Cumulatively, across
all three V6f commits against 7faaaa34: 22 pixels (3.91e-05, maximumChannelDelta
52), which is the same number the first commit measured on its own. Three
changes to the shader terrain draws with, and the drift has not accumulated.

No divergence-register row: the sampled result is unchanged on GL and no
retail-facing behaviour moves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 10:14:37 +02:00
Erik
fac0940711 feat(render): Campaign V slice V6f-2 - terrain's tiling table becomes a buffer
terrain_modern.frag declared `uniform float uTexTiling[36]` - the per-layer
tiling factors retail passes to TexMerge::CopyAndTile / TexMerge::Merge, one per
terrain atlas layer. Vulkan GLSL has no default uniform block, so a loose array
is unspellable there, and 144 bytes of payload cannot ride the pinned 96-byte
push-constant block. GpuBindingModel reserved UniformTerrainTiling (binding 3)
for exactly this at slice V4d. The array now lives in that block.

The ELEMENT TYPE is deliberately unchanged. std140 pads every array element out
to 16 bytes, so the block is 576 bytes rather than 144, and packing four values
per vec4 would be tighter - but it would also rewrite the accessor and every use
site, and this commit's whole value is that its pixel gate measures the move to
a uniform buffer and nothing else. `uTexTiling[int(layer)]` reads exactly as it
did.

That padding is the hazard the change introduces, so it is pinned twice. The CPU
writer walks TerrainTextureTilingTable.UniformElementStrideBytes and zero-fills
the dead words rather than blitting 36 packed floats, and a new test asserts the
stride is 16, the block is 576, and the two are consistent with LayerCapacity. A
tightly-packed writer would not crash or even look obviously wrong: the shader
would read layer 0's factor for layers 0-3, layer 4's for 4-7, and in a scene
where most layers tile at 1 the error stays invisible until a layer that does
not appears. Nothing else in the suite could see that.

The buffer is allocated once in the constructor, through the same
TrackedGlResource + ResourceCleanupGroup rollback path every other terrain
buffer uses, written on the first bound draw - preserving the upload-once
property the linked program's uniform had for free - and released through the
dispose ledger. It is REBOUND every draw rather than once: GL's uniform-buffer
binding points are global and shared with SceneLighting at 1 and the sky's
params at 4, so a renderer running between two terrain draws can take binding 3
out from under us. Self-contained render state, per the standing rule.

Gates. Release build clean. App tests 4,073 passed / 3 skipped - the baseline
4,072 plus the new layout test. Offline pixel gate against 5e13b45f: 21 differing
pixels of 563,200 compared (fraction 3.73e-05), inside the documented 15-23
pixel band and ~27x under the 0.001 threshold. This gate is a real test of the
layout rather than a formality: terrain blending, road overlays and the water
edge are most of the captured frame, and every one of those samples goes through
terrainTiling(), so a stride mismatch would have shown as a wholesale retexture
rather than as noise. The gate run's client log has zero exceptions and an empty
stderr.

Manifest regenerated in the same commit. terrain_modern's remaining Vulkan error
moved from `'uTexTiling' : undeclared identifier` to the frag's direct
`sampler2DArray(...)` construction, which is the same dialect migration V6e ran
for mesh_modern and which lands next. The pair count is unchanged at 7/9.

No divergence-register row: the tiling values, their source and their use are
unchanged, and no retail-facing behaviour moves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 10:11:00 +02:00
Erik
5e13b45fae feat(render): Campaign V slice V6f-1 - converge terrain's view and projection
terrain_modern.vert combined two loose mat4 uniforms per vertex:

    gl_Position = uProjection * uView * vec4(terrainPos, 1.0);

Vulkan GLSL cannot express that. There is no default uniform block, so a loose
`uniform mat4` is unspellable however it is written, and the two matrices are
128 bytes against a pinned 96-byte push-constant block (and against Vulkan's
guaranteed 128-byte ceiling). GpuPushConstants already carries exactly one
uViewProjection, which is the shape every other ported shader reads. So the
product moves to the CPU and the shader reads the single matrix.

The transform is identical. System.Numerics uses row-vector convention and
Shader.SetMatrix4 uploads untransposed, so GLSL reads each uploaded matrix as
its transpose. The old expression evaluated Proj^T * View^T; the new one
evaluates (View*Proj)^T, and those are the same matrix. The renderer already had
that product in hand - `viewProjection` at line 422, computed for the visibility
pass - so nothing new is multiplied. It is multiplied once per frame on the CPU
instead of once per vertex on the GPU.

That last sentence is the whole reason this is its own commit. Moving a float
product from GPU to CPU is a real numeric change: different hardware, possibly
different fused-multiply-add behaviour, certainly a different rounding order.
The plan's V4d row requires its pixel effect be attributable alone rather than
folded into a plumbing change, and terrain fills most of the offline gate's
scene, so this is the strongest measurement that gate can make.

Gates. Release build clean. App tests 4,072 passed / 3 skipped, matching the
baseline exactly. Offline pixel gate against 7faaaa34: 22 differing pixels of
563,200 compared (fraction 3.91e-05, maximumChannelDelta 52). A same-commit
control captured immediately afterwards: 15 pixels (2.66e-05, maximumChannelDelta
46). Both sit inside the documented 15-23 pixel noise band and ~26x under the
0.001 threshold, and the change and its own control are drawn from the same
distribution - which is what "no systematic shift" looks like at this
instrument's resolution. The gate run's client log has zero exceptions and an
empty stderr.

The shader manifest is regenerated in the same commit, as its freshness test
requires. terrain_modern.vert now compiles to SPIR-V for the first time; the
pair stays vulkanReady:false and emits no .spv because terrain_modern.frag still
declares `uniform float uTexTiling[36]`, which is the other half of this
shader's port and lands next as the UniformTerrainTiling buffer that
GpuBindingModel already reserves binding 3 for. Per-pair the count is unchanged
at 7/9; per-stage it is 15/18.

No divergence-register row: the transform is identical and no retail-facing
behaviour changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 10:05:16 +02:00
Erik
7faaaa347b feat(render): V6e — the sky's uniforms become a buffer and its texture a table slot
Campaign V slice V6e, last of three. Sky was the hardest of the four pairs
because it was the only one that still worked the way a 2004 shader works: a
dozen loose uniforms pushed one glUniform call at a time, and a texture bound to
unit 0 with a sampler object chosen per submesh. Vulkan GLSL has neither a
default uniform block nor a way to declare a bare sampler, so both had to move —
and the second one had a sting in it.

The uniforms go into a `SkyParams` std140 block at uniform binding 4, the new
pre-authorized constant in GpuBindingModel (1, 2 and 3 are SceneLighting, the
terrain clip block and terrain tiling; the contract test now proves the three
constants and that literal 2 do not collide). Three matrices are 192 bytes on
their own, so the 96-byte push-constant block was never in the running. The
block's member order IS its layout: std140 aligns a vec3 to 16 bytes while using
12, so each of the three lighting vectors is followed by the float that rides in
its pad word, which is why colours and per-surface scalars interleave rather
than grouping by meaning. SkyParamsLayoutTests asserts all twelve offsets and
the 256-byte size, because getting one member wrong would read the sun direction
as a colour with no compile error, no link error and no GL error to say so.

The texture is the interesting half. sky.frag now reads through the shared table
(ACDREAM_SAMPLE_2D), and a bindless handle BAKES its sampler — so the
per-submesh Repeat-versus-ClampToEdge choice, which used to be a glBindSampler
on unit 0, becomes which slot the submesh asks for. SkyRenderer interns one
handle per (texture, wrap) pair, exactly as ManagedGLTextureArray has done since
the world path went bindless, and exactly the shape Vulkan's table has, where an
entry is a combined image sampler. Same two SamplerCache objects, same wrap
behaviour, consulted once at interning instead of once per draw. A pleasant
consequence: the sky no longer touches texture unit 0, so the load-bearing
`BindSampler(0, 0)` restore at the end of the pass — there because the binding
was global state that would otherwise force ClampToEdge on the next renderer —
has nothing left to undo and is gone.

Gates. Release build clean; App tests 4,072 passed / 3 skipped (4,057 baseline,
plus the sentinel guard from the previous commit and fourteen sky-layout
assertions). Offline pixel gate against 95f8c25f: 18 px of 563,200 compared
(3.20e-05), inside the documented 15–23 px band.

That gate masks the sky for determinism, so it proves nothing about this commit
and the sky renderer has no automated pixel coverage at all. What was done
instead: a base-versus-head offline capture at ALL SEVEN day groups, built by
stashing the change and rebuilding so the two runs differ only in this commit.
Every pair matches in gradient, cloud sheet, horizon band and fog — including
day group 2's salmon cloud band and day group 6's green one, which between them
exercise texture sampling, per-vertex tint, blend mode and fog. Then 3/3
RENDERED on the desktop-witness repeat-connected gate.

That bounds the risk; it does not close it. The offline camera is fixed and
looks down, so a thin band of dome is all it ever sees: the sun and moon
(additive, high) and the rain cylinder (the one sky mesh that surrounds the
camera, and the one whose REPEAT wrap is most visible) remain unproven. Recorded
as user-gate debt in §5.1 alongside V2c's and V4e's particles — check it by
standing outside at dawn or dusk, and by standing in rain.

Manifest: 8/9 pairs compile. `terrain_modern` is the last production pair, and
it is blocked on V4d's content rather than on dialect — details in §5.5's slice
table. `mesh` has no consumer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 09:54:13 +02:00
Erik
602bc9dddb feat(render): V6e — both particle shaders cross the dialect
Campaign V slice V6e, second of three. Billboard particles and mesh particles
are the last two pairs blocked on the texture-table shape; sky follows.

particle takes the same treatment mesh_modern took: the `flat uvec2` handle
varying becomes a `flat uint` slot and the fragment stage samples through
ACDREAM_SAMPLE_ARRAY. What is different here is the untextured particle. The
shader used to ask "is the handle I was given zero", which GL can answer because
its emulated table stores handles; Vulkan cannot, because set 2 is an opaque
descriptor array and reading an element nobody wrote is undefined rather than
zero. So the question moves to the index: the CPU writes ACDREAM_TEXTURE_NONE
for a particle with no texture instead of interning the null handle as a table
slot, and both dialects test the same value. GL renders identically — the same
particles take the same branch to the same procedural blob — and the handle
table simply stops carrying an entry that never named a texture. A test pins the
sentinel across all three declarations of it, because a silent disagreement here
would sample slot 0xFFFFFFFF instead of drawing the blob.

particle_mesh needed no restructuring, only names. Vulkan GLSL has no default
uniform block, so `uniform uint uTextureIndex;` is not unsupported but
unspellable, and the two values are per-pass — one texture and one layer for a
whole sub-batch — which is exactly what the shared push-constant block is for.
uTextureIndex becomes uTextureIndexA; uTextureLayer becomes uParamA, which was
the spare scalar and is a natural fit because the shader converted the layer to
float anyway. The widening moved from the shader to the CPU; layers are small
integers, so the sampled value is bit-identical.

Gates: Release build clean; App tests 4,058 passed / 3 skipped (baseline 4,057
plus the sentinel drift guard). Offline pixel gate against 95f8c25f: two
captures, 29 px and 21 px of 563,200 compared (3.73e-05 and 5.15e-05), with a
same-commit control between them of 13 px and this commit measuring 14 px
against its own parent. The scene draws no particles, so this gate is a tripwire
that the world path is undisturbed, not evidence about particles.

Particles remain user-gate debt — the same debt V2c and V4e already carry, to be
paid by casting a spell in a connected session.

Manifest: 6/9 pairs compile. Remaining: mesh (legacy, no consumer), sky (next
commit), terrain_modern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 09:26:13 +02:00
Erik
935f4dc3d9 feat(render): V6e — move the world mesh's texture lookup to where Vulkan can express it
Campaign V slice V6e, first of three. mesh_modern is the shader every world
static, every piece of scenery and every EnvCell surface draws through, and it
was one of the four production pairs the SPIR-V toolchain still refused.

The blocker was a varying. Since V2 the vertex stage looked a batch's table slot
up in the binding=9 handle table and forwarded the resulting 64-bit
GL_ARB_bindless_texture handle to the fragment stage as a `flat uvec2`. That
works on GL because a bindless handle is just a number a shader may carry
anywhere. It cannot work on Vulkan at all: the equivalent object is a descriptor
in set 2, and a descriptor is not a value a stage can hand to another stage. So
what travels between the stages is now the SLOT — a `flat uint` — and the
fragment stage does the lookup at the point of sampling.

That relocation needs one shared idea, because the two backends disagree about
what the lookup IS. `ACDREAM_SAMPLE_ARRAY(slot, uvw)` asks the dialect-neutral
question — "sample table slot N" — and expands to
`texture(sampler2DArray(gTextureTable[slot]), uvw)` under GL and to
`texture(uTextures[nonuniformEXT(slot)], uvw)` under Vulkan. It is deliberately
a SAMPLING macro rather than a sampler-returning one: `nonuniformEXT` belongs on
the indexing expression itself, and binding the result to a local
`sampler2DArray` first is exactly where an implementation is free to drop it.
That is the same shape V6d already used for the retained UI's 2-D reads, and it
now covers the array reads the world path needs.

`ACDREAM_TEXTURE_NONE` lands alongside it, unused here and used by the next
commit. GL can ask "does this slot hold a texture" of the payload, because an
unregistered slot holds the null handle; Vulkan cannot, because set 2 is opaque
and reading an unwritten element of a partially-bound array is undefined rather
than zero. The sentinel moves that answer into the index, where both dialects
test it identically.

On GL nothing about the sampled result changes — the same slot resolves to the
same handle to the same texel. The SSBO read simply happens one stage later,
and `flat` keeps it one scalar load per primitive rather than per fragment.

Also: RenderBootstrap has been loading mesh_modern without common.glsl since V2,
which cannot have linked — `ACDREAM_UBO_SET` sits inside a layout qualifier
there. The UI Studio path is the only caller. One argument, same pair, same way
WorldRenderComposition has always loaded it.

Gates: Release build clean; App tests 4,057 passed / 3 skipped (baseline);
offline pixel gate against 95f8c25f differing fraction 3.37e-05 (~19 px of
563,200), inside the documented 15–23 px same-commit noise band and ~30x under
the 0.001 threshold. mesh_modern is the shader that gate covers most heavily,
so this is the strongest automated evidence any V6e commit gets.

Manifest: 4/9 pairs compile (debug_line, mesh_modern, ui_text, vk_probe).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 09:21:22 +02:00
Erik
f6f58a12db feat(render): put the retained UI and debug lines on both backends
Campaign V slice V6d, commit 2 of 3. TextRenderer and DebugLineRenderer were the only two renderers speaking the RHI, and both refused any device that was not a GlGpuDevice. They now refuse nothing: this is the first production rendering acdream can do on Vulkan.

Three things had to go.

The loose uniforms. debug_line declared uView and uProjection separately and DebugLineRenderer set them straight against the compiled GL program, because the pinned push-constant block carries one combined matrix and IGpuPassEncoder has no verb for arbitrary named uniforms. That was never portable — Vulkan has no default uniform block at all — so the shader converged on uViewProjection and Flush multiplies on the CPU. System.Numerics is row-vector convention while GLSL reads the floats column-major, which transposes, so the CPU equivalent of the old per-vertex uProjection * uView is view * projection. The product now rounds once per frame rather than once per vertex; these lines only draw when collision wireframes are switched on, so the offline gate sees nothing of it. ui_text's uScreenSize became the block's two spare scalars, uParamA and uParamB, with the same two divisions and the same NDC mapping around them.

The sampling mode. uUseTexture selected between font coverage, RGBA modulate and flat colour, and no field of the 96-byte block means that. It did not need one: which of the two texture-table slots is assigned IS the mode. uTextureIndexB assigned means a single-channel coverage source, uTextureIndexA assigned means an RGBA colour source, neither assigned means the vertex colour alone. GpuTextureSlot.Unassigned is already a loud sentinel for exactly this kind of question, and both branches guard so it never reaches a sampler. That also retired the 1x1 white fill texture: DrawFill routed solid quads through the sprite bucket relying on white times colour, and the untextured branch produces the same value with no texture at all. Multiplying by 1.0 changes no bits, and the gate agrees.

The texture binding. The classic glActiveTexture/glBindTexture path survived V4a because DrawSprite takes an arbitrary texture from sixty-odd widget call sites. But TextureCache had already registered every one of those into the device's table — the classic path was consuming the raw GL name that registration also produced. The UI's currency is now UiTextureTableHandle, a one-based table index whose zero is the same "no texture" every widget already guards on; a raw slot index would have turned all of those guards into silent false negatives, since slot 0 is perfectly valid. One-based rather than the slot itself because GpuTextureSlot is internal to the pinned contract while UiRenderContext.DrawSprite, TextureCache.GetOrUploadRenderSurface and a dozen widget properties are public, and neither publishing a contract type nor converting the retained UI to internal belongs in this slice.

Two consequences worth stating. The two backends disagree about what a 2-D table entry is — GL reconstructs a sampler2D from the bindless handle, Vulkan reads layer 0 of its sampler2DArray descriptor array — and ACDREAM_SAMPLE_2D is the one place that lives. Keeping GL on sampler2D is what leaves the UI's textures exactly as they are, including the paperdoll/appraisal FBO colour texture, which is an externally-owned GL_TEXTURE_2D from the §7.1 transitional seam and cannot become an array before V4g. On the Vulkan side, sampled views are now always layered, which also removes a latent invalid usage V6c shipped: it registered a Type2D offscreen view into a descriptor array whose element type is sampler2DArray.

And one real fix. Sampling through the table means a bound sampler object overrides the texture's own parameters. Nearest-requested UI art used to get its point filtering from a glTexParameter applied before the bindless handle went resident, so registering it with the stock WorldRepeat sampler would have made every retail icon and dat-font glyph silently bilinear. Those now register with a nearest-and-repeat sampler.

Supporting moves: GlGpuDevice.CreatePipeline splices common.glsl the same way Shader does, since an RHI shader that reads the table needs the table declared; GlGpuPassEncoder binds the device's table with the pipeline, which is the GL analogue of Vulkan binding descriptor set 2 per draw, and has to be per-bind because every raw-GL world renderer puts its own privately-numbered table at that binding; and the encoder derives GL_MULTISAMPLE from the pass's SampleCount, which is where the retained UI's hand-rolled glDisable belonged all along. TextRenderGlStateScope is deleted — the encoder's ambient capture restored a strict superset of it — and its failure-safety test follows the guarantee to GlAmbientCapabilityState, which gains a fakeable seam and, with it, the multisample-dimension coverage #249 recorded as missing.

App tests 4,057 passed / 3 skipped, unchanged from commit 1. Offline pixel gate against 871c406b: differing fraction 2.31e-05, 13 pixels of 563,200 compared — below the documented 15-23 pixel same-commit noise band, on a change that redraws every pixel of the retained UI through a different sampling path. The capture was inspected: vitals, spell bar, radar, toolbar icons and slot digits, chat window and Send button all present and correctly placed. Both new .spv pairs compile; the manifest records ui_text and debug_line as Vulkan-ready, leaving six pairs blocked on the world-renderer slices.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 08:51:32 +02:00
Erik
234fe91d3b feat(render): Campaign V slice V6c - SPIR-V, pipelines, passes, and a Vulkan frame that draws
The last of V6's three commits, and the one that makes the backend render.
Plan sections: 4.5 (pipelines and the persisted cache), 4.6 (shaders and the
committed .spv), 4.7 and 3.3 (clip space, the Y flip and winding), 4.9 and 4.10
(swapchain format and the scissor convention), 4.11 (the probe shader V5
deferred), 5.4 (Target: null means the swapchain image, literally).

WHAT RUNS. ACDREAM_RENDER_BACKEND=vulkan now renders a real scene through the
whole RHI on the RX 9070 XT: 60,000-plus frames per twelve-second run, 4x MSAA
resolving into a B8G8R8A8_UNORM swapchain, GPU timer scopes resolving, a
screenshot taken through IGpuDevice.CaptureBackbuffer, and a clean
CloseMainWindow exit with the allocator reporting three device-memory objects.

WHAT IT DRAWS, AND WHY IT IS NOT THE GAME. V6's milestone is "a full game frame
on Vulkan" and on this branch that cannot be the game's own frame. V4c and V4d
are parked by 5.5.5 so the world renderers are still raw GL; and the two
renderers that DO speak the RHI - TextRenderer and DebugLineRenderer, ported at
V4a - both throw for any device that is not a GlGpuDevice, because their loose
uniforms and their classic texture-unit sprite binding have no home in the
pinned contract yet. Converting them is a V4-class change with its own GL pixel
gate, outside this slice's file list.

So the backend is exercised through the contract by a scene of our own, and it
is not a toy. It uses a device-local mesh arena filled through the staging ring,
instance and batch data written straight into mapped ring memory, an offscreen
render target whose colour is registered into the global texture table and
sampled by a later pass, a BC1 texture with a CPU-built mip chain beside an
uncompressed one with a vkCmdBlitImage chain, one multi-draw-indirect covering
five quads with gl_DrawID selecting per-draw batch data, a second pipeline with
line-list topology bound mid-pass, dynamic cull/front-face/depth-write, push
constants, timer scopes, and an MSAA colour attachment resolving into the
swapchain image.

ORIENTATION, BY INSPECTION. Slice V5's screenshot was a uniform clear and its
orientation was right "by construction" - which a uniform clear cannot show. The
scene is therefore deliberately asymmetric in both axes: a quadrant card that is
red top-left, green top-right, blue bottom-left and white bottom-right, four
differently tinted markers at four different corners, and an open L of lines
whose short stub rises at its right end. The captured PNG reads correctly in
every one of those, including a miniature of the same card in the bottom-right
whose own quadrants are also the right way up. The negative viewport height, the
front-face inversion and the capture path agree.

THE SHADER TOOLCHAIN, AND WHAT IT FOUND. tools/compile-shaders.ps1 drives
tools/ShaderCompiler, a small out-of-solution .NET tool over Silk.NET.Shaderc -
the same shaderc glslc is built on, through the already-pinned Silk 2.23.0
family. glslc is preferred when a Vulkan SDK is present and reported when it is;
neither this machine nor CI has one, and requiring a 500 MB manual install
between a contributor and a working checkout is not a reasonable price for a
build step. The GLSL sources stay the single source of truth: the Vulkan dialect
arrives as a preamble injected after the #version line - ACDREAM_UBO_SET becomes
"set = 1,", the texture table becomes a set-2 descriptor array with a required
nonuniformEXT accessor, and the shared 96-byte push block is declared with each
loose uniform name defined onto its member. The only edits to a shader BODY are
mechanical and dialect-level: dropping default-block uniform declarations, which
Vulkan GLSL has no such thing as, and assigning explicit varying locations BY
NAME across a pair, because ordinal assignment would look identical today and
silently swap varyings the first time an author reordered a line.

Run over the eight production pairs, exactly one thing happened: none of them
compiled, and every failure is a specific source-level fact belonging to a
renderer-port slice that has not landed. debug_line needs uView/uProjection
converged into one uViewProjection - two matrices are 128 bytes and the shared
block is 96. mesh_modern and particle still pass a uvec2 bindless handle as a
varying, which is V4t's GpuTextureSlot retype. sky has ten loose uniforms and
wants a UBO. ui_text needs uScreenSize/uUseTexture/uTex. particle_mesh needs
uTextureIndex to become uTextureIndexA. terrain_modern needs V4d-1's matrix
convergence. mesh is the legacy pair with no RHI consumer at all. That inventory
is committed as shaders.manifest.json, with each source's SHA-256 and the
compiler's own message, and a test re-hashes it so an edited shader that never
got recompiled fails a build rather than shipping a stale binary.

vk_probe is the pair that does compile, and it is the shader 4.11 already asked
for: V5 recorded "build one real pipeline from the committed .spv" as its single
deliberate deviation because no toolchain existed. It is Vulkan-dialect only and
no GL renderer draws with it, so it forks nothing; it retires when the ported
world renderers become the backend's own proof.

DESCRIPTORS. Sets 0 and 1 are DYNAMIC buffer descriptors bound per flight slot,
so a per-draw range change costs a dynamic offset in vkCmdBindDescriptorSets
rather than a vkUpdateDescriptorSets in the hot path - which is what keeps 4.4's
zero-writes-per-frame property true for buffers as well as for textures. Ten
dynamic storage descriptors is above Vulkan's guaranteed minimum of four, so it
is a real requirement rather than a free choice, it fails loudly at layout
creation on a device that cannot serve it, and V9's lavapipe row must confirm
it. Unused bindings point at a shared dummy range so there is ONE set layout and
one pipeline layout; that is why binding a second pipeline mid-pass costs
nothing and disturbs neither the descriptors nor the push constants.

THE ONE MAPPING FUNCTION. VulkanViewportMapping holds the whole coordinate
reconciliation: negative viewport height, the front-face inversion that pairs
with it, and - separately - the scissor flip, which the viewport sign does NOT
perform. The V3 audit flagged that as a concrete V6 acceptance item and it is
the subtle one: vkCmdSetScissor is always top-left-origin, NdcScissorRect emits
GL bottom-left rectangles, and getting it wrong clips a doorway aperture from
the wrong edge in a scene that has one. Clip space needs nothing, as 4.7
concluded: the cameras already build [0,1]-convention projections.

CONTRACT GAP, RECORDED NOT PAPERED OVER. GpuPipelineDescription cannot name its
colour-attachment format, and Vulkan bakes that into a pipeline. Offscreen
targets therefore adopt the swapchain's B8G8R8A8_UNORM rather than a literal
RGBA order - invisible above the API, because an image is sampled through its
format's component mapping and the one CPU readback swizzles explicitly. The
honest fix is a colour-format field added in a reviewed contract commit, exactly
as GpuBlendMode.InverseAlpha and GpuVertexFormat.UByte4UInt were added when V4c
and V4d met the same wall. It is documented at
VulkanTextureFormatMapping.CanonicalColorAttachmentFormat.

The pipeline cache is persisted to the cache directory and validated by its
32-byte header against this device's vendor, device and cache UUID before use.
Drivers are required to ignore incompatible blobs, but "required to" is a poor
foundation for something that runs before anything else in the process, and the
check costs 32 bytes of comparison. Two consecutive launches report "cold" then
"reused".

Gates: Release build clean; App suite 4056 passed / 3 skipped (4037 at V6b plus
19 new); offline pixel gate PASS at a differing fraction of 5.15e-05 with a
same-commit control immediately after it at 2.84e-05 - 29 and 16 pixels of
563,200, the same class of ambient variation the campaign's 15-23 band records,
and roughly 19x under the 0.001 threshold on a commit that changes no GL code
path.

Validation layers could not be run: this machine has no Vulkan SDK, no
HKLM\SOFTWARE\Khronos\Vulkan\ExplicitLayers key, no VK_LAYER_PATH and no
VkLayer_khronos_validation.json anywhere on disk. Plan 7 already requires one
validation-clean run at V7; it needs the SDK installed first and is reported
rather than assumed here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 08:17:24 +02:00