Commit graph

2950 commits

Author SHA1 Message Date
Erik
1f25a60999 fix(diag): Campaign V slice V7 commit 2 - pin the world clock, because the route never did
THE ROUTE'S TIME PIN NEVER HELD, AND EVERY V7 NUMBER SO FAR WAS TAKEN THROUGH IT.

connected-backend-differential.route.txt opened by pressing
AcdreamCycleTimeOfDay three times, on the stated theory that the cycle walks
live -> 0.00 -> 0.25 -> 0.50 and lands on noon. The mechanism underneath is
WorldTimeService.SetDebugTime, and SyncFromServer clears it -- deliberately,
because that setter is the /time slash command and the command is meant to be a
look-at-dusk-for-a-moment affordance rather than a mode. There is even a test
pinning that behaviour: WorldTimeDebugTests.SyncFromServer_ClearsDebugOverride.
ACE sends TimeSync every few seconds. The clock was therefore un-pinned again
long before the route reached its first stop, on every run this campaign has
taken, including V6m's smoke pair.

The Dereth clock does not only move the sky. It moves the SUN, so it moves the
directional term of every lit surface in the scene.

MEASURED, rather than argued. A probe route captured each stop TWICE, 45 seconds
apart, in the same run on the same backend:

    GL,     Holtburg,      capture 1 vs capture 2:  205,772 px   22.33%
    Vulkan, Holtburg,      capture 1 vs capture 2:  218,732 px   23.73%
    GL,     Facility Hub,  capture 1 vs capture 2:  108,795 px   11.81%
    Vulkan, Facility Hub,  capture 1 vs capture 2:  130,206 px   14.13%

One backend, one stop, nothing moving, and a fifth of the frame changes while
you watch. No cross-backend number means anything against that noise floor, and
the cross-backend numbers taken during that probe run were duly absurd -- 56% at
Holtburg, where the two launches happened to be at different times of Dereth day.

THE FIX IS A PIN THAT OUTRANKS THE SERVER CLOCK AND SURVIVES SYNC.

WorldTimeService.PinnedDayFraction is a nullable day fraction that wins over both
Calendar.DayFraction(NowTicks) and SetDebugTime, and that SyncFromServer does not
touch. ACDREAM_WORLD_TIME -> RuntimeOptions.PinnedWorldDayFraction ->
WorldEnvironmentController, which writes it once: the Runtime environment owner
and its clock are session-scoped, so one write outlives every teleport and every
reveal generation. Values outside [0, 1) are REJECTED rather than clamped -- a
day fraction of 12.5 is a typo, and silently pinning the world at it would be
worse than ignoring it.

Unset is the default and every ordinary run. The calendar DATE still advances,
which is intentional: the date drives day-group selection, and ACDREAM_DAY_GROUP
already pins that. The differential gate forces the pin at 0.5 -- noon, which is
what the three presses were aiming at -- on both launches, and the route's
presses are deleted rather than left in as decoration.

This is instrument determinism on the footing of ACDREAM_DAY_GROUP and V7's
ACDREAM_SKY_PHASE_SECONDS, not a workaround: it is off by default, nothing in the
shipping client reads it, and the alternative was to keep measuring two backends
through a fifth of a frame of sunlight.

WHAT IT MOVED. The same three-stop route, same commit otherwise, before and after:

    holtburg_town           9.05%  ->  2.86%      (83,438 -> 26,330 px)
    facility_hub_interior  12.16%  ->  0.78%      (112,075 -> 7,176 px)
    aerlinthe_island       23.09%  ->  6.82%      (212,824 -> 62,892 px)

The interior stop is the headline. V6m recorded it as a route defect on the
theory that the indoor spring-arm camera settles to different distances in two
runs; that theory is now refuted. The camera was fine. The interior was lit
differently because the sun had moved, and with the sun held still the stop drops
by a factor of 15 to 0.78% -- close enough to the 0.001 threshold that its
remaining population is worth naming rather than guessing at. No route change was
needed and none was made.

WHAT REMAINS, per the difference maps, all of it now attributable by eye:
the animated portal beside the Holtburg stop; distant scenery foliage; wandering
NPCs and a chimney smoke plume, which are animation and emitter phase; the vitals
readouts, whose stamina and mana genuinely regenerate at different rates across
two logins minutes apart; and, at Aerlinthe, a dense low-magnitude speckle in a
scene whose mean luminance is 28/255 -- half of its differing pixels are exactly
delta 3, one step over a tolerance that is absolute rather than relative.

Gates. Release build green. App tests 4,134 passed / 3 skipped (one new: the
day-fraction range check); AcDream.Core.Tests WorldTimeDebugTests 6/6, including
the two new ones that assert the pin survives a sync and outranks the transient
override. GL offline pixel gate against the pre-slice tree: 2.66e-05, 15 pixels
of 563,200, inside the documented 9-31 band -- GL did not move.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 19:34:13 +02:00
Erik
ad5f8b68dc fix(render): Campaign V slice V7 commit 1 - world anisotropy, and the sky's second clock
Two changes, one measurement. The V6m smoke pair put GL versus Vulkan at
Holtburg at 18.52% of the frame differing at tolerance 2 with MSAA off. The same
stop on the same instrument now measures 9.05%, and the two populations these
address are gone from the difference map rather than merely smaller.

1. THE WORLD ATLASES WERE SAMPLED WITHOUT ANISOTROPY ON VULKAN, AND WITH THE
DEVICE MAXIMUM ON GL.

RhiWorldTextureArray -- the backend-neutral shared object/material atlas, and
the only IWorldTextureArray the Vulkan arm ever constructs -- registered its
clamp and repeat slots with GpuSamplerDescription.WorldClamp/WorldRepeat as
written, which carry MaxAnisotropy 1. The GL arm asks for the driver's own
GL_MAX_TEXTURE_MAX_ANISOTROPY twice over: ManagedGLTextureArray sets
GL_TEXTURE_MAX_ANISOTROPY on the image, and the two sampler objects its resident
bindless handles are built from (OpenGLGraphicsDevice.WrapSampler/ClampSampler)
set it again, which is the one that actually wins.

V6i-2 knew it was asking for 1 and said so in a comment -- "the world arm that
draws through these arrays is the next slice, and it is the one that can gate a
filtering change visually." That slice was V6j, the gate is V7, and this is it.

Retail settles the question rather than the GL arm settling it.
RenderDeviceD3D::SetDefaultD3DStates (0x005a3800) loops all sixteen sampler
stages and issues SetSamplerState(stage, 0xA, this->m_D3DCaps.MaxAnisotropy) at
0x005a4230. 0xA is D3DSAMP_MAXANISOTROPY and the argument is the device's
reported cap, not a setting -- so "as much anisotropy as this device has" is
retail's own rule, the GL arm is faithful to it, and asking for 1 diverged from
retail as well as from the shipping backend. No divergence-register row is owed
in either direction: this retires a Vulkan-only gap and lands on retail's value.

The fix asks for a ceiling rather than reading a limit back, because the pinned
RHI contract (plan section 3.3) carries no anisotropy field and is frozen. It
does not need one: VulkanGpuSampler already clamps MaxAnisotropy to
VkPhysicalDeviceLimits.maxSamplerAnisotropy, Vulkan guarantees that limit is at
least 16 wherever the samplerAnisotropy feature is supported -- which this
backend requires -- and 16 is where every desktop driver caps. The request and
the GL arm's read therefore land on the same number.

What it was worth, from the difference map at the same stop: the roof shingles
of both Holtburg cottages, which had been dense hatching across the whole
surface, and the stone courses of the near building are now black. Measured as
high-frequency energy (mean absolute neighbour difference, GL versus Vulkan) the
right-hand roof went from visibly blurred to a ratio of 0.999 and the wall to
1.023; every other textured region in the frame is between 0.99 and 1.02.
Grazing-angle surfaces are where anisotropy is the whole difference, which is
why a roof was the loudest thing in the frame.

2. THE SKY HAS TWO CLOCKS AND ONLY ONE OF THEM WAS PINNABLE.

ACDREAM_DAY_GROUP and the route's AcdreamCycleTimeOfDay presses pin the Dereth
date, which chooses the day group, the keyframe and the sun angle. The cloud
sheet does not read that clock: SkyRenderer accumulates TexVelocityX/Y against
DateTime.UtcNow minus its own construction time, by design, because retail's
clouds drift with real time regardless of the date. Two launches minutes apart
therefore cannot agree about where the clouds are no matter what the route does,
and the V6m smoke measured the cost -- 89% of its 18.52% sat in the top 240 rows.

ACDREAM_SKY_PHASE_SECONDS (RuntimeOptions.SkyAnimationPhaseSeconds ->
SkyRenderer.AnimationPhaseSecondsOverride) replaces that elapsed-seconds value
with a fixed one. Unset -- the default, and every ordinary run -- keeps the wall
clock, so nothing a user or the offline gate sees changes. The differential gate
forces it on both launches alongside MSAA and the day group; the offline gate
keeps its top-280 mask, because a same-commit GL pair still has the sun to
disagree about.

This is instrument determinism on the same footing as ACDREAM_DAY_GROUP, not a
workaround: it is one input to a UV offset, it is off by default, and no shipping
path reads it. The alternative on the table was -MaskTopPixels, which would have
permanently blinded the campaign's strictest instrument to the entire sky -- one
of the five surfaces the offline gate already cannot see. Rows 0-32 of the
Holtburg pair went from 23,090 differing pixels to 1,211, and what remains up
there is roof and portal rather than cloud.

WHAT THE SAME PAIR STILL SHOWS, unattributed and carried to the next commit: the
distant treeline, the player and the NPCs, and the animated portal. The portal is
phase and expected. The treeline is not filtering -- sharpness now matches within
5% and a shift search finds no sub-pixel offset -- and the two runs entered the
world at different last-logout positions (0xC95B0001 versus 0x09040008), so the
far-tier streaming history differed. That is the next thing to prove or refute.

Gates. Release build green. App tests 4,133 passed / 3 skipped against the
4,132/3 baseline (one new: the sky-phase parse). GL offline pixel gate against
the pre-change tree: 2.31e-05, 13 pixels of 563,200, inside the documented 9-31
band -- GL did not move. One offline Vulkan run with VK_LAYER_KHRONOS_validation
proven inserted by the loader: zero validation errors, zero warnings. Full
three-stop differential recorded at artifacts/v7-diff-c1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 19:14:08 +02:00
Erik
a7529a975a test(app): serialize the classes sharing camera/render process globals (#252)
A full Release App run failed once at Issue181WallPressEquilibriumTests
.Diagnostic_WallPressedCamera_EyeWanderAndViewerCellStability. It passed in
isolation and did not recur across five further whole-suite runs, and the diff
under test touched only the world texture-creation stack -- nothing in camera,
visibility or physics. A cross-class parallelism race was the only plausible
mechanism, not a regression.

Ten App test classes share three process-global mutable statics, and xUnit runs
distinct test classes in parallel by default:

  - CameraDiagnostics: AlignToSlope, CollideCamera, TranslationStiffness,
    RotationStiffness, UseRetailChaseCamera. These are not merely written, they
    are written AWAY from their defaults -- RetailChaseCameraTests sets
    AlignToSlope and CollideCamera to false, and three classes set
    UseRetailChaseCamera to false -- while RetailChaseCamera.Update,
    CameraController.Active, CameraFrameController, WorldRenderFrameBuilder and
    MouseLookController read them.
  - RenderingDiagnostics.ProbeFlapEnabled, written by CornerFloodReplayTests
    and Issue181WallPressEquilibriumTests.
  - System.Console.Out, redirected by those same two classes to capture probe
    output.

Every one of these classes already saved and restored in try/finally. That is
correct within a class and remains necessary, but it was never sufficient. A
finally bounds a mutation in TIME along its own thread; it cannot stop another
class from reading the static inside that window. Worse, two overlapping
save/restore pairs can interleave so the second restore writes back the FIRST
one's temporary value, leaving the global permanently wrong for the rest of the
run. The Console.Out case is the sharpest instance: an interleaved restore can
install a DISPOSED StringWriter as the process-wide Console.Out, which then
throws in unrelated tests. Serializing the sharers is what makes each class's
existing finally sufficient.

The fix is a marker CollectionDefinition applied to the ten sharing classes,
following the WorldEnvironmentControllerCollection precedent. No collection
fixture: several members are [Theory] cases that need different knob values per
case, so a fixture cannot own the save/restore without rewriting every member's
internals, and it would not help the read side at all. Because every member
references the same compile-time const for the collection name, the grouping
cannot silently drift via a typo.

Membership is deliberately narrow. It covers the eight writers plus two classes
that drive production code which READS a knob another member moves off its
default (HouseExitWalkReplayTests and CameraFrameControllerTests both run
RetailChaseCamera.Update and assert on the resulting eye). Classes that merely
construct a CameraController without a retail chase camera are NOT members --
their reads fall through the null branch and are insensitive.

No production code changed; no assertion was weakened, and no retry, sleep or
tolerance was added.

Verification. Base commit f6275f45 measured empirically at 3,763 passed / 3
skipped. Post-fix: 136 whole-suite Release runs. Every failure observed was in
the pre-existing zero-allocation family tracked as #250 (an Expected 0 / Actual
N bytes assertion), and none was in any collection member. A matched 55-run
baseline at f6275f45 reproduced that same family, confirming it predates this
change. Serialization cost is inside run-to-run noise: the suite is ~3 s of a
~4.5 s wall-clock dotnet test, and the ten serialized classes are a small
fraction of it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:46:43 +02:00
Erik
3b62b9bfa3 docs(render): record V6m - portal space crosses, and V7 gets its instrument
Adds section 5.5.18, a V6m row to the slice table and to section 5.1's
user-gate-debt table, and corrects section 5.1's "no connected route visits a
dungeon" claim - which was wrong in two directions.
connected-world-lifecycle.route.txt has carried a Facility Hub stop all along,
so the lifecycle gate did reach an interior even though nothing compared its
pixels; and reaching one turns out not to be the same as being able to compare
it.

The V7 list moves in three ways. Item 1 closes: every production renderer draws
on both arms. The appraisal-viewport half-discharge that V6k opened and V6l
carried closes too - the view was driven on both backends and inspected. And a
new item 7 opens, which is the honest result of firing the instrument once: with
the sky band and the animated portal masked, the smoke pair still differs in
1.17% of the frame, about 12x the pinned threshold, while the chat panel on its
own differs in 42 pixels of 106,560 - inside the threshold. So the 2-D retained
UI is already at parity and the residual lives in the 3-D pass, at silhouette
edges. That is a better starting position for V7 than a single aggregate number
would have been, and it is why the smoke was attributed rather than just
reported.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:43:16 +02:00
Erik
a99f517ec2 feat(tools): Campaign V slice V6m commit 2 - the backend differential gate
tools/run-backend-differential-gate.ps1 is V7's instrument, plus the route it
runs. It executes the SAME connected route twice - ACDREAM_RENDER_BACKEND=gl
then =vulkan - pairs the screenshots by name, compares each pair with
compare-screenshots at the plan's pinned tolerance 2 / fraction 0.001, and
prints one verdict table with a JSON report beside it.

ACDREAM_MSAA_SAMPLES=0 IS FORCED ON BOTH LAUNCHES. Plan section 5.5.16 measured
what leaving it on costs - 8.83% of the frame at 4x, essentially all of it
hugging foliage and silhouette edges, which is two drivers' sample patterns and
not a renderer divergence. Forcing it on one side only would be worse than
either. ACDREAM_DAY_GROUP is pinned on both, and the route additionally pins the
client-only time-of-day override so the Dereth clock cannot drift between two
launches minutes apart.

The desktop witness and its three guards are lifted from the repeat-run gate
rather than reinvented, because that gate learned them the hard way: a blank
frame reads back as a valid PNG (section 5.5.2), so a second instrument that
shares nothing with the renderer decides whether a run is worth comparing at
all; a locked screen or an overlapping window is caught by the grab's size; and
stray input into a foregrounded window moves the camera off the pinned stop, so
the client log is scanned for camera-affecting actions and the run is aborted
rather than reported. Both runs close through WM_CLOSE - a hard kill leaves ACE
holding the session about three minutes and the second launch is the one that
pays for it.

connected-backend-differential.route.txt is written for two-launch determinism,
which is stricter than either existing route needed to be: identity quaternions
on every teleloc so heading is pinned, no movement between arrival and capture,
and no checkpoint verb (the ownership ledger has its own gate). Three stops -
Holtburg town, an interior EnvCell at the Facility Hub, Aerlinthe's island and
water edge. The interior stop is the durable fix section 5.1 names for the
campaign's oldest coverage gap: no connected route has ever visited one, which
is why EnvCellRenderer had no automated coverage on either backend.

ONE SMOKE PAIR was run, on the first stop, and it is reported honestly rather
than tuned. GL versus Vulkan at Holtburg with MSAA off: 170,697 of 921,600
pixels differ, 18.52%, maximum channel delta 255. Attributed with a difference
map:

  * 89% of it is in the top 240 rows. Masking the offline gate's top 280 rows
    takes the pair to 11,480 of 563,200, 2.04%. That band is the scrolling
    cloud sheet, which advances with WALL time and not with the pinned Dereth
    clock, plus the treeline behind it.
  * Masking the animated portal beside the character as well takes it to 6,190
    of 529,450, 1.17%, mean channel delta 0.49. A portal's scrolling texture is
    phase, like an emitter's age.
  * What remains is thin outlines on silhouette edges throughout the frame,
    plus the vitals readouts, whose stamina and mana genuinely advanced between
    the two logins.

So the distance to V7's 0.001 is about 12x with the two known phase populations
removed, and V7 owns closing it. -MaskTopPixels exists for that conversation and
DEFAULTS TO 0: the gate is a strict identity check on the whole frame unless
someone deliberately asks otherwise, per section 7.1 rule 2.

Both files are ASCII with CRLF, matching the other tools. That is not cosmetic:
PowerShell 5.1 reads a BOM-less .ps1 as ANSI, so a UTF-8 em dash inside a
double-quoted string is a parse error, which is how the first version failed.

Gates. Release build green; App tests 4,132 / 3 skips. No product code changed,
so commit 1's pixel, connected and validation gates stand unchanged - and the
smoke run is this commit's own evidence, since it drove a complete connected
Vulkan session end to end with a graceful exit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:34:43 +02:00
Erik
59c6b2ae94 feat(render): Campaign V slice V6m commit 1 - portal space draws on Vulkan
PortalTunnelPresentation was the last raw-GL world-adjacent renderer. It now
draws on both arms, and the composition that used to hand the Vulkan arm a
portal-less teleport presentation is gone with it.

Nothing about the scene changed. Same synthetic DAT Setup resolved through the
same client-enum mapping, same 40 fps CSequence, same retail rotation cadence,
same distant light, drawn through the same already-dual-arm WbDrawDispatcher.
What forked is only where the draw is recorded:

  * GL keeps its GLStateScope, its viewport/scissor/depth/cull/blend statements
    and its depth-only glClear, untouched.
  * The RHI arm opens a backbuffer pass of its own and publishes it on
    IWorldPassScope for the span of the draw - the shape V6l gave the two
    offscreen viewports, and required for the same reason: the dispatcher's RHI
    arm borrows its pass rather than opening one. Publication comes after
    BeginPass and before UploadRetailLight, because publishing resets the
    frame-global sections and this scene wants its own light, not the world's.

The one substantive decision is the pass's COLOUR load op, and it is a Clear
rather than a Load. Retail preserves the colour target and only clears depth
(UIViewportObject::DrawContent @ 0x006950A5 -> Clear(4) = D3DCLEAR_ZBUFFER), and
so does the GL arm. A Vulkan pass cannot inherit an image the way a bound
framebuffer can: under MSAA the frame's world pass RESOLVES into the swapchain
image and stores DontCare into the multisampled scratch, so a second
multisampled pass declaring Load would load undefined contents - plan section
5.5.12 item 5, the same hazard that merged the clear into the world pass.

Re-clearing is exact rather than approximate because of an invariant the frame
graph already enforces. RenderFrameFoundation.PortalViewportVisible and this
scene's IsVisible are the same value, read once at the top of the frame, and
WorldSceneRenderer returns without drawing when it is set. So whenever portal
space draws, the backbuffer holds exactly the opaque black
SceneTool::BeginScene @ 0x0043DAD0 establishes and nothing else, and clearing to
that same black changes no pixel. The alternative - a single-sampled Load pass
over the resolved image - would have been both a silent MSAA divergence and
invalid, since the backbuffer's depth attachment is multisampled.

The pass takes IWorldPassScope.SampleCount, so WbDrawDispatcher's sample-count
pipeline variants (V6l) select the backbuffer set, and depth matches the
attachment.

CreateRequired becomes internal: its two new seams are internal RHI contracts
and composition is its only caller. The TYPE keeps its visibility - plan section
7.1 rule 3.

Gates. Release build green. App tests 4,132 / 3 skips against the 4,129
baseline (three new: the retail black constant, the RHI arm's composition
precondition, and the both-arms composition assertion). Complete Release suite
9,195 / 5; one AcDream.Content failure in the solution-wide run that passes
124/124 rerun alone - the documented rerun-singly flake class, not carried
forward as a claim. Strict GL offline pixel gate against 280f3b3f: 28 px of
563,200, fraction 4.97e-05, inside the documented 9-31 band, with a same-commit
control pair at 20 px / 3.55e-05 taken immediately afterwards. 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.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:25:49 +02:00
Erik
280f3b3fe9 fix(test): stop the streaming priority-apply tests reading a warm JIT
Five tests in StreamingControllerPriorityApplyTests passed only when a
sibling ran first in the same process. Run alone, they failed on
assertions about world-state residency and completion backlog:
DungeonCollapseBeforePromotionBase (line 355),
InFlightNearLoad_DemotedBeforeFirstCompletion (536),
HardRecenter_RejectsOldOverlappingLoadAndUnloadGenerations (582),
HardRecenter_DropsStaleOutboxThroughBoundedAdmission (626), and
DeferredCompaction_ApplyFailureRetainsExactResult.

The state a sibling supplied was not data. It was compiled code.
StreamingController meters each Tick against a wall-clock ceiling and
StreamingWorkBudgetOptions.Default allows 2 ms per frame; these tests
took that default. A cold first Tick has to JIT the whole publication
path, and the meter's own diagnostics measured it at 10.55 ms with
LastLimit=Time and one yield at stage publication-spatial-commit. The
frame's first operation is admitted unconditionally through
ensureProgress, so applyTerrain ran and the terrain assertion passed;
the very next reservation, the GpuWorldState spatial commit, was
refused, so the landblock never became resident in that frame. Any
sibling that publishes a landblock first (DuplicateNearCompletions, for
instance) warms that path and the same Tick then fits inside 2 ms.
Pairing the failing test with that sibling passed; pairing it with
DestinationReservation_StaleGenerationCannotClearReplacement, which
drains no completions and therefore JITs nothing, still failed.

Yielding mid-publication and resuming next frame is correct production
behavior and other tests in this file assert exactly that. The defect
was the setup: these tests assert which results publish, in what order,
and under which generation, yet left the elapsed-time dimension at a
value that made every assertion a function of machine speed and test
order. Every controller in the class now takes a budget whose time
ceiling cannot bind, applied uniformly so the next test added here does
not reacquire the dependency. Count and byte ceilings keep their real
values, including the deliberately small MaxCompletionAdmissions of
ForceReloadWindow_DiscardsBufferedCompletionsFromOldWindow and the
MaxCompletionsPerFrame scaling of the two tests that use it, so the
bounded-admission behavior under test is untouched. No assertion was
relaxed and no production code changed.

All fourteen tests in the class now pass individually and together;
Core is 3295 passed / 2 skipped, and two consecutive full-solution
Release runs are 8826 passed / 5 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:09:08 +02:00
Erik
6aee01cf72 docs(render): record V6l - three amendments, and the last three renderers cross
Section 5.5.17 records the slice: the instanced-vertex-input amendment and
particles (b1ad1d48), the stencil dimension and the portal mask (eced67d0), and
the offscreen viewports (2e8b8b91). The V4e row is no longer blocked and the V4g
row is no longer half-landed; the slice table gains a V6l row and section 5.1's
accumulated-debt table gains one for the two connected captures the offline gate
cannot reach.

Four defects are recorded as found by RUNNING rather than by validation, which
is the pattern this campaign keeps paying for: the standalone particle texture
cache and the entity-appearance composite cache were both bindless-only, so the
Vulkan arm could draw neither a textured particle nor any entity with a palette
override; a pipeline bakes one depth/stencil format, so an offscreen target's
depth had to take the device's; and the paperdoll rendered upside down because a
GL framebuffer's origin is bottom-left and a Vulkan image's is not.

The V7 list is rewritten. Nothing on it is blocked on a contract decision any
more. What is left is one absent renderer (PortalTunnelPresentation has no
Vulkan arm), EnvCellRenderer's arm narrowed from unproven to proven-by-one-frame
after a Marketplace interior rendered on Vulkan, the MSAA-off requirement, the
per-draw descriptor writes, the portal mask's two shader sources, and the
appraisal viewport's carried-forward half-discharge.

AP-92 is narrowed rather than retired: the private viewports are backend-neutral
targets on both arms and the blit's V origin is derived rather than assumed, so
the origin half of that row's risk column is closed. The rest of it - retail
renders each CreatureMode directly against a cloned CPhysicsObj - is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:08:04 +02:00
Erik
2e8b8b91ad feat(render): Campaign V slice V6l commit 3 - the offscreen viewports draw on Vulkan
Amendment 3 of three: the paperdoll and creature-appraisal views render on the
Vulkan arm. Plan section 5.5.16 defect 3 named two backend fixes as the
precondition; both are here, and running it found two more the note could not
have known about.

Fix 1: a layered sampled view per render target. An ATTACHMENT view must be
VK_IMAGE_VIEW_TYPE_2D and the global texture table's descriptor array is
sampler2DArray, so the attachment view cannot legally be registered into it -
section 5.5.7 recorded that as invalid usage rather than a mismatch that samples
oddly, and V6k made RegisterTexture refuse it loudly and name this fix.
VulkanGpuTexture now creates a SECOND, layered view over the same image for a
colour render target: one image, one allocation, two ways of looking at it,
legal without any creation flag. SampledView is what the table registers for
every texture, so the question disappears rather than being answered.

Fix 2: sample-count pipeline variants for WbDrawDispatcher. Vulkan requires a
pipeline's rasterizationSamples to equal the pass it draws in, and this
dispatcher draws in two passes with different counts - the multisampled
backbuffer world pass and the single-sampled offscreen target, which the
contract fixes at one sample. Its five pipelines became a MeshPipelineSet with
two instances, selected at bind time from the live pass rather than from the
scope, which is the same shape section 5.5.8 gave the depth-format problem. When
the backbuffer is single-sampled the two sets are one object, so nothing is
built twice and nothing is freed twice. The offscreen target's DEPTH attachment
also had to take the device's own combined depth/stencil format rather than the
contract enum's literal D24_UNORM_S8_UINT: a pipeline bakes one depth/stencil
format under dynamic rendering and the same pipelines draw in both passes, so a
second format would make one of the two undefined.

Fix 3, which running it found: entity APPEARANCE composites were still
bindless-only, so no entity with a palette override could be drawn on the Vulkan
arm at all - the doll being one, and every creature and player besides. The
backend that serves it has existed since V6i-2 and had no production consumer;
it has one now. TextureCache builds the composite cache on both arms, and
EnsureCompositeTexturesAvailable stops asking about bindless. Nothing about the
cache itself changed: the sharing, the bounded unowned LRU, the metered upload
budget and the retirement fence were already backend-neutral.

Fix 4, which the first successful capture found: the doll rendered upside down.
UiViewport has flipped V since V4a because a GL framebuffer's origin is
bottom-left, so its colour texture samples bottom-up. A Vulkan image's origin is
top-left and the backend's negative viewport height stores the rendered image
that way round, so the same flip stands the doll on its head. That is a property
of the backend that made the texture, not of the widget that draws it, so
IUiViewportRenderer answers TextureIsBottomUp and UiViewport asks. The line this
replaces had predicted exactly this failure since it was written.

The seam. WbDrawDispatcher's RHI arm borrows its pass from IWorldPassScope
rather than opening one, so a viewport that opens a pass of its own has to
publish it there for the span of the draw. Publish is on the interface now for
that. It does not nest: the world phase has closed its own pass by the time
private presentation runs, which is where these viewports have always drawn.

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 the App suite alone or in a second solution-wide run - the
documented rerun-singly flake class; the failing test name was not surfaced by
the runner and is not carried forward as a claim). Strict GL offline pixel gate
against 08ffe141: 3.55e-05, 20 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.

And the two captures the offline gate cannot reach, both connected and both
inspected. The Vulkan paperdoll (artifacts/v6l-vk-paperdoll3) renders the doll
upright, in armour, at the right scale, over a transparent background, and is
indistinguishable from the same capture on GL taken minutes later
(artifacts/v6l-gl-paperdoll) - which is also the no-regression check for the V
change. Particles (artifacts/v6l-vk-poi versus artifacts/v6l-gl-poi, cropped
4x at artifacts/crop-vk-glow.png and crop-gl-glow.png): Holtburg's forge plume
and its field of glint sprites draw in the same places with the same alpha
compositing on both backends, the puffs differing only in phase because two
launches cannot agree on an emitter's age.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:05:24 +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
b1ad1d481b feat(render): Campaign V slice V6l commit 1 - particles draw on Vulkan
Contract amendment 1 of three, and V4e's content behind it. Plan section 5.5.16
recorded that both particle pipelines draw with per-instance VERTEX attributes
and that the pinned contract could express instanced DRAWING but not instanced
vertex INPUT: one stride, no divisor, one buffer at VertexInputRate.VERTEX. That
is what stopped V4e. This takes the reviewed option (i) - a second vertex
binding with a per-instance rate.

The amendment. GpuVertexLayout grows a per-binding notion (binding index,
stride, input rate) and GpuVertexAttribute names the binding it is fed from,
defaulting to 0; IGpuPassEncoder.BindVertexBuffer takes a binding index. Every
layout written before this slice keeps its exact meaning through
GpuVertexLayout.Interleaved, which is one vertex-rate binding 0 - and
GpuContractTests asserts that as a requirement rather than trusting it. Both
backends carry the rate natively and at no cost: VK_VERTEX_INPUT_RATE_INSTANCE
on the pipeline, glVertexAttribDivisor recorded once into the pipeline's VAO
where it survives every later attribute rebind.

GpuVertexFormat.UInt1 comes with it, and is necessary to it: particle.vert
declares `layout(location = 6) in uint aTextureIndex` and the amendment's whole
premise is that no shader is edited. Same kind-distinction UByte4UInt was added
for at V4d - GL needs glVertexAttribIPointer, Vulkan needs R32_UINT, and the
float path would reinterpret the value's bits rather than approximate them.

Options (ii) and (iii) were rejected on the record: all ten storage bindings are
spoken for and reusing binding 0 would have the GL particle draw clobber
WbDrawDispatcher's instance array mid-frame (section 5.5.8's hazard in its GL
form); CPU-expanding instances is 5x billboard bandwidth and does not scale to
mesh particles at all.

The arm. ParticleRenderer.Rhi.cs is a SECOND arm per section 5.5.6, not a
replacement - every GL statement in the sibling file is the one it always
issued. Five pipelines replace the imperative glBlendFunc switch (two billboard
blends, three mesh blends) because core Vulkan 1.3 does not make blend dynamic.
The per-flight VAO/VBO pool disappears because every ring allocation inside a
frame is already distinct memory that lives until the frame retires. The
binding-9 table is not bound at all - the device owns the table and the encoder
binds set 2. The pass is BORROWED from IWorldPassScope. Depth tests but does not
write, compare is Less and alpha-to-coverage is off, which is the ambient GL
state particles have always drawn under rather than a choice. Everything above
the submission seam - emitter iteration, retail distance ordering, the
deferred-alpha handoff, billboard axis construction, blend resolution - is the
same CPU code on both arms.

The first Vulkan particle frame threw rather than drew, which is the second
defect of the compiles-clean class this slice found by running:
TextureCache.AcquireParticleTexture is bindless-only, so the standalone particle
texture cache did not exist on a backend without GL. It exists on both arms now.
Everything about it that matters - sharing equivalent surfaces between emitter
owners, the bounded unowned LRU, retirement behind the frame-flight fence - is
already backend-neutral; only how one entry is created and destroyed differs,
which is what IStandaloneBindlessTextureBackend is for. The RHI arm creates the
image through IGpuDevice.CreateTexture with a real sampler and releases the
table slot before the image, which is the GL arm's order and for the same
reason. The composite cache stays GL-only: it serves entity appearance, not
particles.

The durability fix V6k earned. That slice found the sky declaring a 32-byte
stride against a 36-byte AcDream.Core.Terrain.Vertex - the record carries a
TerrainLayer no sky attribute names - and noted that every .Rhi.cs arm restates
a CPU record's footprint from memory while only sky had a test.
RhiVertexLayoutStrideTests is that test for the rest: world mesh, terrain, sky,
retained-UI sprite, debug line, and both particle bindings, each asserted
against the record or the producer's own float count, plus two sweeps over all
seven for attributes that reach past their stride or name an undeclared binding.
Four private layouts became internal to be assertable; nothing else about them
moved.

Gates. Release build green. App tests 4,121/3 skips (4,109 baseline plus three
contract tests and nine layout tests); complete Release suite 9,184/5. Strict GL
offline pixel gate against 08ffe141: 3.20e-05, 18 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 that still draws terrain,
blending, roads, water, statics, scenery, sky and the complete retained UI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:20:59 +02:00
Erik
08ffe141a0 docs(render): record V6k - the sky lands, section 5.4 is discharged, particles hit the contract
Section 5.5.16 reports both landed commits and the one that did not land.

The sky: V4f's content as a Vulkan arm, and the fourth defect of the
compiles-clean class - a 32-byte vertex stride declared for a 36-byte record,
found by capture rather than by validation, because AcDream.Core.Terrain.Vertex
carries a TerrainLayer member no sky attribute names. The generalisation is
written down: every .Rhi.cs arm restates a CPU record's footprint from memory and
only one of them now has a test.

Section 5.4 is marked DISCHARGED, with the distinction it turns on spelled out.
The divergence it describes has not been on the tree since the V4c revert took
that hunk with it; what the revert did not undo was the reason it existed, and
that is what V6k commit 2 closed. V7 is no longer blocked on it.

Particles are recorded as BLOCKED rather than deferred, with the measurement
behind it: GpuVertexLayout has one stride and no divisor and BindVertexBuffer
binds one buffer at vertex rate, so the contract can express instanced drawing
but not instanced vertex input - which is what both particle pipelines are built
on. Three ways out are stated, two of them contract changes and the third a
five-fold bandwidth amplification that does not scale to mesh particles. The
choice belongs to whoever owns section 3.3.

The V7 defect list is rewritten around what is now true, including two items the
slice found rather than inherited: PortalDepthMaskRenderer cannot be expressed
without a stencil dimension in GpuPipelineDescription, and a Vulkan viewport needs
sample-count pipeline variants as well as the layered sampled view the section
5.5.7 re-check turned into a loud precondition. The MSAA item stops being a
prediction and becomes a measurement: 8.83% of the frame at 4x, essentially all of
it alpha-to-coverage edges on foliage, with rows 300-720 contributing 980 of
81,359 differing pixels.

Section 5.1's debt table gains a V6k row saying which uncovered surfaces were
checked - the sky band across seven day groups, the paperdoll through a connected
inventory capture - and which two remain: the appraisal viewport, and the sun,
moon and rain cylinder a fixed outdoor camera cannot see.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:50:51 +02:00
Erik
eb7e6b4e5c feat(render): Campaign V slice V6k commit 2 - the viewports name their own target, and section 5.4 is discharged
V4g's first half, and the V7 blocker section 5.4 named.

What moved. PrivateEntityViewportRenderer - the paperdoll and creature-appraisal
viewports - stops hand-rolling an FBO, a colour texture and a depth renderbuffer
and asks the device for an IGpuRenderTarget. The pass it opens DECLARES that
target rather than binding one behind the RHI's back, and the colour attachment
is registered into the global texture table through RegisterTexture like any
other texture. Render() returns the UiTextureTableHandle the retained UI already
speaks instead of a raw GL name.

That deletes the V4a pre-approved transitional seam. GlGpuDevice's
RegisterExternalColorTexture / TryResolveExternalColorTexture existed so the UI
could blit a texture whose owner the RHI knew nothing about; plan section 7.1's
final paragraph gave them exactly this slice as their end, and both are gone
along with the GlGpuDevice casts in RetailPaperdollFrameView and
RetailCreatureAppraisalFrameView. Those two views are now backend-neutral: they
decode a handle instead of registering one.

Section 5.4, stated precisely, because the answer is not what the section
predicts. The divergence it describes - GL's BeginPass refusing to bind
framebuffer 0 for a null target - is NOT on the tree and has not been since the
V4c revert at 543bc79f, which took that hunk with it. GL's BeginPass binds the
declared target today, so the two backends already agree about what
Target: null means. What the revert did not undo was the REASON the divergence
existed: this renderer bound a framebuffer no pass had declared. It now names its
target, and PortalTunnelPresentation - the other renderer section 5.4 names -
draws into the active viewport rather than an offscreen buffer, which is the
backbuffer, which is what a null target literally means. The obligation is
therefore discharged on both halves and V7's second defect is closed.

PortalDepthMaskRenderer is NOT ported and is not blocking. Its two-pass punch is
built on glStencilFunc/glStencilOp/glStencilMask, and GpuPipelineDescription has
no stencil dimension to express them with. That is a pinned-contract question,
reported rather than worked around.

The section 5.5.7 re-check, which was asked for explicitly and does not come back
clean. That note recorded that "the render-target-view-in-table usage from V6c
did not fire" and asked that it not be carried forward as accepted. It still does
not fire, and now for a reason worth writing down: a Vulkan render-target image
is viewed as VK_IMAGE_VIEW_TYPE_2D because that is what an attachment needs,
while the texture table's descriptor array is declared sampler2DArray, so
registering one is invalid usage rather than a mismatch that samples oddly. It
has never fired because the only renderer with an offscreen target is composed on
GL alone. VulkanGpuDevice.RegisterTexture now refuses it loudly and names the fix
- a second, layered sampled view per render target - so the slice that gives the
Vulkan arm a viewport finds a precondition instead of a driver-level fault.

Gates. Release build green. App tests 4,109 passed / 3 skipped, unchanged from
commit 1. Strict GL offline pixel gate against 22aa2edc: 4.08e-05, 23 differing
pixels of 563,200, inside the documented 9-31 band, maximumChannelDelta 48. GL
connected repeat gate at 3 runs: 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.

And the surface the automated gates cannot see was checked rather than banked.
The offline scene never opens the inventory, so the pixel gate is a tripwire for
this change and nothing more - plan section 5.1's debt table has said so since
V6d. A connected run that presses ToggleInventoryPanel and captures the result is
in artifacts/v6k-paperdoll: the doll renders through the new render target with
the correct pose, orientation and alpha, which is the row that table has been
carrying since V4c.

No divergence-register row: no retail-facing behaviour changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:46:01 +02:00
Erik
22aa2edc65 feat(render): Campaign V slice V6k commit 1 - the sky draws on Vulkan
V4f's content, landed as a SECOND arm per section 5.5.6: GL keeps its raw world
path through to V10 and the RHI world path ships on Vulkan. Every GL statement in
SkyRenderer is the one it always issued; the encoder arm lives in SkyRenderer.Rhi.cs
and runs only when there is no GL context.

What it produces. ACDREAM_RENDER_BACKEND=vulkan renders the sky: the dome
quadrants, the horizon band, the cloud sheet and the fog gradient, in the same
place and the same colours as the GL capture of the same scene (within a few
units on the channels sampled, which is the day-fraction drift between two
launches). Section 5.5.15's first V7 defect - "the sky is flat fog" - is closed.

Three things differ from the GL arm, each because Vulkan bakes what GL sets. The
per-submesh blend function becomes two PIPELINES, additive for sun/moon/stars and
straight alpha for everything else, because core Vulkan 1.3 does not make blend
dynamic. The SkyParams block becomes a ring slice taken per draw rather than one
buffer rewritten per draw, because a descriptor's contents are read at execution
time, not record time. And the pass is borrowed from IWorldPassScope, because the
frame's one backbuffer pass resolves and a second pass could not load what it
left.

The sky is the first Vulkan consumer of set 1 binding 4. Section 5.5.8 recorded
that UniformSkyParams was missing from the uniform set layout and V6i-2 added it;
until now nothing had ever bound it.

The stride bug, which is the fourth of its class this campaign. The first Vulkan
sky frame drew the dome as a field of blue-white noise. The RHI vertex layout
declared a 32-byte stride - position, normal, texcoord, exactly what sky.vert
reads - while AcDream.Core.Terrain.Vertex is 36 bytes: it carries a fourth
member, TerrainLayer, that no sky attribute names and that the GL arm never
described to a glVertexAttribPointer but did count, because it says
sizeof(Vertex). Nothing else in the frame looked wrong, no validation rule was
violated, and the offline pixel gate masks the sky band, so only a side-by-side
capture found it. SkyVertexLayoutTests now asserts the REQUIREMENT - the stride
is the uploaded record's footprint - rather than today's number.

The last interim handle table is gone. V4t retired the private
GlBindlessHandleTable in WbDrawDispatcher, EnvCellRenderer, TerrainModernRenderer
and ParticleRenderer and deliberately left the sky's, because the sky is the one
world path that mints its own resident handles from TextureCache's raw GL texture
names rather than interning someone else's. It now registers those handles
through V4t's RegisterWorldTextureHandle seam instead, which is the same
mechanical change the other four took, and the class and its tests are deleted
because nothing else ever used them.

TextureCache gains RegisterWorldSurface(surfaceId, repeat), the sky's RHI texture
source: the same DecodeFromDats the GL path uses, created through
IGpuDevice.CreateTexture and paired with a real sampler object rather than baked
into a bindless handle. Keyed by (surface, wrap) for the same reason the GL arm
keys its handles that way - a table entry is a combined image sampler, so the
dome sampled CLAMP_TO_EDGE and a scrolling cloud sheet sampled REPEAT are two
entries over one decoded texture.

Gates. Release build green. App tests 4,109 passed / 3 skipped - the 4,112
baseline less the six GlBindlessHandleTable tests that went with the class, plus
three vertex-layout tests. Strict GL offline pixel gate against 7ae796a1:
4.43e-05, 25 differing pixels of 563,200, inside the documented 9-31 band, with
maximumChannelDelta 48 in the same 46-52 range every control pair reports. GL
connected repeat gate at 3 runs: 3/3 RENDERED on the desktop witness and 3/3 on
the client capture. Seven-day-group before-and-after comparison on GL - the
method V6e used, because the pixel gate masks the sky band - matching in
gradient, cloud sheet, horizon band and fog on every group, including day group
2's salmon cloud band and day group 6's green band. One offline Vulkan run with
VK_LAYER_KHRONOS_validation proven inserted by the loader: zero validation
errors, zero warnings, a captured sky frame, graceful close.

Coverage gap, stated rather than assumed. The offline scene is a fixed outdoor
view at one time of day, so the sun, the moon and the rain cylinder are drawn by
neither arm's gate. They join the accumulated user-gate debt in plan section 5.1,
where V6e already filed them.

No divergence-register row: no retail-facing behaviour changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:31:37 +02:00
Erik
7ae796a110 docs(render): record V6j - the world arm, the winding measurement, and the V7 list
Section 5.5.15 and the slice-table row. Three things worth having written down
rather than rediscovered.

The winding inversion V6c wrote was wrong and had never been asked a question:
every Vulkan consumer through V6i sets Cull = None, so the mapping had not decided
a fragment until the world arm arrived. That makes three defects this campaign has
found in a path that compiled, validated clean, and had a test - the descriptor
layouts and the TerrainClip set were the first two - and all three share the shape
of a test that asserts the behaviour rather than the requirement.

The A8 CullMode.Landblock override, which sections 5.5.13 and 5.5.14 both flagged
as due for an answer the moment world materials drew on a second backend, has one:
it carried over verbatim and is still load-bearing, so it is now a divergence with
two consumers rather than one. The dungeon pass in section 5.1's checklist is what
settles it, not this slice.

And the V7 defect list, so the differential is not run against a target that
cannot pass it: section 5.4's null-target divergence is still live on GL,
EnvCellRenderer's Vulkan arm and the deferred-alpha and doorway-scissor paths are
unexercised by the offline scene, and MSAA must come off.

The pixel-gate figures are recorded as a distribution rather than a number,
because 31 differing pixels sits at the documented band's top and a single value
there is not evidence either way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:49:46 +02:00
Erik
f84eef3256 feat(render): Campaign V slice V6j commit 2 - Dereth draws on Vulkan
The three world renderers' submission arms, both pass executors, and the
composition that reaches them. This is the unit three predecessors stopped at.

What it produces. ACDREAM_RENDER_BACKEND=vulkan on the offline scene renders
terrain with blended textures and road overlays, the water edge, static world
meshes, procedural scenery, and the complete retained UI - the same frame the GL
pixel gate captures, from the same camera, minus the sky. artifacts/v6j-vk2.

The shape, and why it is not V4c's. Section 5.5.6 chose option (B) after NVIDIA
rendered the V4c binary 10/10 where AMD's GL stack did not: GL keeps its raw
world path through to V10 as a documented fork confined to the submission seam,
and the RHI world path ships on Vulkan. So V4c's and V4d-2's content returns as a
SECOND arm rather than a replacement. The GL arm issues the same GL statements in
the same order against the same objects; the encoder arm lives in three .Rhi.cs
partials and is entered by one branch per submission site.

Three differences from V4c, each because the tree moved under it. There is no
binding-9 texture table - V4t put the slot on the device and Vulkan binds set 2,
so the arm that used to intern bindless handles simply has nothing to do. The
pipelines carry the device's sample count rather than 1, because Vulkan requires
rasterizationSamples to match the pass and alpha-to-coverage is a no-op at one
sample. And no renderer opens a pass.

That last one is structural, not tidiness. Under MSAA the frame's one backbuffer
pass resolves into the swapchain image and stores DONT_CARE into the multisampled
scratch, so a second pass declaring Load would load undefined contents; the
backend also permits one open pass per frame. VulkanWorldScenePhase therefore
opens the pass, publishes the encoder on VulkanWorldPassScope for exactly the
span of the inner WorldSceneRenderer, and every renderer borrows it.

Three sections are frame-global on GL and cannot be on Vulkan: the SceneLighting
UBO, the per-cell clip regions, and the terrain clip block. GL binds each to a
global binding point and every consumer inherits it. Vulkan binds a descriptor
set per draw, and a renderer's own binds are what select the scope those sections
must land in - so their writers PUBLISH into WorldFrameSections and each renderer
binds them inside the pass, after its own binds. SceneLightingUboBinding's
per-flight-slot buffer pool disappears with it: a ring allocation is already
distinct memory that lives until the frame retires, which is the property the
pool existed to provide.

Both pass executors became backend-neutral rather than gaining twins. Everything
they do is delegation to a renderer except four concerns - the clip-frame
publication, the doorway scissor, gl_ClipDistance enablement, and retail's
interior depth clear - so those four move behind IWorldPassSurface and retail's
ordering, which is what these classes are actually for, is written once. The GL
implementation issues the statements the executors used to issue inline.

Clip distances are no-ops on the Vulkan arm, and that is safe rather than a
divergence: Vulkan activates every element the shader declares, and all three
world vertex shaders already write 1.0 into every slot past the active count.
The interior depth clear becomes vkCmdClearAttachments, reached through the scope
so the pinned contract stays frozen and the backend-only verb stays in the
backend. The hook for it was already committed at V6i-3 with a cref to a type
that did not exist yet; it exists now.

The collision-wireframe DebugLineRenderer is composed as null on the Vulkan arm.
DrawAndPublish flushes it INSIDE the world phase and it opens its own pass, which
the one-pass rule forbids. The toggle is DevTools-only and DevTools is not
composed there, so nothing is lost - composing it would throw on the first
wireframe frame rather than silently misdraw.

Two seams widened rather than invented. GameWindowGraphics answers whether the
backend has a world-pass seam, because the three composition phases that need it
already borrow that handle and "does this backend work that way" is what the type
exists to answer. And MeshSourceReady replaces the anyVao != 0 gate with the same
question in backend-neutral form - V6i-3 published HasStores for exactly this -
so the predicate evaluates identically on GL.

What is NOT here, and is expected. Sky and weather are still raw GL (V4f), so the
Vulkan frame's sky is the atmosphere fog clear. Particles (V4e), the paperdoll and
appraisal viewports and the portal depth mask (V4g) likewise. The executors
already accepted all of them as absent.

Gates. Release build green. App tests 4,112 passed / 3 skipped, the unchanged
baseline; complete Release suite 9,175 / 5. Strict GL offline pixel gate against
847f14ae: 5.50e-05, 31 differing pixels of 563,200, inside the documented 9-31
band and 18x under the threshold. Characterised rather than accepted, because 31
is the band's top: cross-commit pairs measured 21, 29 and 31 while same-commit
controls measured 12 and 20, and maximumChannelDelta is 46-52 in every comparison
INCLUDING the pure controls - so the few large-delta pixels are a property of the
capture, and a cross-commit pair at 21 against a same-commit pair at 20 is not
what a systematic shift looks like. GL connected repeat gate at 3 runs: 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, and a graceful close.

Coverage gap, stated rather than assumed. The offline scene is a fixed outdoor
view, so EnvCellRenderer's Vulkan arm draws nothing in it - dungeon interiors are
half of this slice and are unproven by anything automated, exactly as they were
for V4c. The deferred-alpha path and the doorway scissor are likewise untouched
by this scene. They join the accumulated user-gate debt in plan section 5.1.

No divergence-register row: no retail-facing behaviour changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:46:54 +02:00
Erik
81fe5e1b63 fix(render): Campaign V slice V6j commit 1 - the Vulkan winding needs no inversion
VulkanViewportMapping has inverted the front face since V6c, on the standard
argument that rendering with a negative viewport height mirrors framebuffer
space and therefore reverses triangle orientation. The world arm is the first
consumer that culls anything, and it falsified the inversion twice over on one
frame.

Nothing exercised it before now. Every Vulkan consumer through V6i - TextRenderer,
DebugLineRenderer and the bring-up scene - declares Cull = GpuCullMode.None, so
the mapping had never decided a single fragment. That is why a wrong answer
survived four slices and a validation-clean run: an unexercised path.

What the world arm measured, on the same offline scene the GL pixel gate captures.
Terrain is the one single-sided surface acdream draws - FrontFace(Ccw) plus
Cull(Back), matching ACRender::landPolysDraw's per-triangle eye-side predicate -
and under the inversion it vanished completely, 190 multi-draw commands issuing
against 625 loaded landblocks with nothing on screen. Every closed building shell
rendered inside-out in the same frame: the front wall culled and the interior
beams visible through the gap, which is what a back-face-front cull looks like on
geometry that is only nearly convex. Declaring the GL winding verbatim restores
both at once - terrain draws single-sided from above, and the shells close.

Two independent surfaces, one change, and the correction is the identity mapping.
Recorded here rather than worked around in the renderers, because a renderer that
compensates for its backend is exactly the shape this file exists to prevent: the
contract says renderers speak GL and the backend translates, and the backend was
translating wrongly.

The viewport flip itself is untouched and still correct - it is what puts
GL-authored geometry the right way up with no shader or matrix change. What goes
is the claim that a winding inversion has to travel with it. The scissor's
explicit flip is a separate correction with a separate justification and is
likewise untouched.

The test suite says so now rather than describing the old behaviour: the
pass-through is asserted directly, and the exact-inverses test becomes a
travels-alone test, so a later change that reintroduces the inversion fails here
first and on any single-sided surface second.

Gates. Release build green. App tests 4,112 passed / 3 skipped, the unchanged
baseline. GL offline pixel gate unaffected by construction - this file has no GL
arm - and measured with the world arm in commit 2.

No divergence-register row: this corrects a backend translation error rather than
introducing a deviation from retail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:45:53 +02:00
Erik
847f14aeef docs(render): record V6i-3, and enumerate what the world arm still needs
The slice brief was a Dereth PNG on Vulkan. There is not one, and §5.5.14 says
so in its first paragraph rather than at the end. What landed is the two
prerequisites — the mesh pipeline running on both arms, and the frame having a
world pass to record into — plus the seven measured findings the world arm needs
and that are cheaper to read than to re-derive.

Three of those correct earlier text rather than extending it, which is the part
worth reading:

- §5.5.8 offered to promote bindings 6-8 back to dynamic and said there were
  "four unused dynamic slots to promote into". There are not. Vulkan's
  guaranteed maxDescriptorSetStorageBuffersDynamic is 4, which is exactly what
  V6g already spends, so the four bindings the world arm re-points per draw cost
  a descriptor write each. That is bounded and correct, and it is why commit 2's
  draw-time descriptor bind matters: without it the cost is ten scopes per draw
  rather than one.
- V4c created every world pipeline with SampleCount = 1 because the GL backend
  ignores it. Vulkan requires the pipeline to match the pass, and
  alpha-to-coverage requires MSAA at all.
- The world renderers cannot each open their own pass on Vulkan, which is the
  shape difference from V4c and follows directly from the MSAA resolve.

The slice table gains its V6i-3 row with the gate numbers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 14:46:49 +02:00
Erik
887de4aec2 feat(render): Campaign V slice V6i-3 commit 2 — the Vulkan frame gets a world pass, and descriptors bind at draw time
Two structural prerequisites for the world renderers' submission arms. Both are
in Gpu/Vk only; the GL backend executes not one changed statement, and the
frame this commit produces is bit-identical to the one before it.

1. The clear merges into the world pass (plan §5.5.12 item 5).

V6h gave the clear phase a backbuffer pass of its own: clear, resolve, close.
Under MSAA that is a trap for whatever comes next. A multisampled backbuffer
pass renders into a scratch image and RESOLVES it into the acquired swapchain
image, and the scratch's store op is DONT_CARE — so a world pass that followed
and declared Load would load undefined contents and lose the clear entirely.
The world renderers cannot work around it by each opening their own pass, for
the same reason: every pass after the first would load a discarded scratch.

So the clear phase now computes the same RenderFrameFoundation from the same
world clock and weather owners and publishes only the COLOUR, through
VulkanBackbufferClearState; VulkanWorldScenePhase opens the one backbuffer pass
and clears as its load op, with Store=Resolve when the backbuffer is
multisampled. That makes it the frame's one clear and its one resolve. The
retained UI's pass is single-sampled and targets the swapchain image directly,
so it composites over the resolved result exactly as it did.

The clear stays unconditional because the frame graph makes it so rather than
because anything asserts it: RenderFrameOrchestrator runs resource preparation,
then the world phase, then private presentation, with no branch between. A frame
with no world still opens the pass and leaves a cleared backbuffer — which is
precisely the frame captured below, since nothing draws into the pass yet.

2. Descriptor sets bind at DRAW time, not at bind time.

V6i-1 derives a descriptor-set scope from the descriptor state itself, which is
what closes §5.5.8's one-binding-two-buffers hazard. But the encoder issued
vkCmdBindDescriptorSets from inside BindStorageBuffer/BindUniformBuffer, so the
arena resolved after EVERY bind. For the retained UI's one or two binds that is
free. For a world renderer binding ten buffers it materialises up to ten scopes
per draw — nine of them PARTIAL states no draw ever uses, each claiming a real
descriptor-set pair out of a fixed-size pool and each paying a full round of
vkUpdateDescriptorSets. Recording the state and resolving it once, where the
draw needs it, yields exactly one scope per renderer, which is what the arena
was designed to produce.

It is legal because descriptor-set binding is independent of pipeline binding
when the layouts are compatible, and acdream has ONE pipeline layout by design
(§4.4) — the same property that lets a bucketed pass change pipeline for free.
The pass still opens with all three sets bound, which is V6h's fix for
VUID-vkCmdDraw-None-08600 and stays exactly as it was.

Gates. Release build green. App tests 4,112 passed / 3 skipped, unchanged from
commit 1. Strict GL offline pixel gate against commit 1: 3.73e-05 (21 differing
pixels of 563,200), inside the documented 9-31 px control band — expected, since
no GL file is touched. One offline Vulkan run with VK_LAYER_KHRONOS_validation
proven inserted by the loader: zero validation errors, zero warnings, and no
[shutdown] diagnostic on either stream. The captured Vulkan frame is compared
against commit 1's rather than merely eyeballed: 0 differing pixels of 921,600,
maximum channel delta 0 — bit-identical across the merge, which is the strongest
available evidence that moving the clear into the world pass changed nothing
about what is drawn.

What this does NOT do: draw a world. See the report for the enumerated remainder.

No divergence-register row: no retail-facing behaviour changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 14:43:12 +02:00
Erik
fe8abacfc6 feat(render): Campaign V slice V6i-3 commit 1 — the mesh pipeline's upload bodies cross the seam
V6i-2 cut IMeshPipelineDevice at the measured surface and proved the mesh
pipeline could be CONSTRUCTED without naming a backend. It said plainly what it
did not claim: "the mesh pipeline does not RUN on Vulkan. Its upload bodies are
still raw GL — GlobalMeshBuffer, the VAO/IBO construction, the layer transfers."
This moves them, and gives the interface its second implementation.

GlobalMeshBuffer takes GL?. The two backing stores were already IGpuBuffer
(V4b); what still needed a context was the vertex array and the attribute
pointers, which have no RHI verb because Vulkan bakes vertex input into the
pipeline. So a backend with none builds the stores and nothing else, publishes
0 for VAO/VBO/IBO, and publishes VertexStore/IndexStore — the same buffers,
named the way a pass encoder binds them. HasStores is the backend-neutral form
of the VAO != 0 readiness test the raw-GL draw paths make. Two bodies fork on
the context and nothing else does: InitBuffers skips the vertex array, and
CommitMigration skips the rebind — on the encoder arm the field swap IS the
atomic publication, because the next pass reads whatever the field then holds.
The store deletion likewise splits: GL keeps its immediate DeleteRetired,
because the arena's own flight gate has already proven no submitted frame can
reference the store, while the other arm has no second deferral to skip and
Dispose is its retirement-queued release.

ObjectMeshManager's RequireGl narrowed to the LEGACY per-mesh upload. Its three
call sites were one modern-path constructor argument and two bodies whose every
GL statement sits inside `if (!_useModernRendering)`. The constructor now hands
the arena the nullable context; the two bodies resolve one lazily inside the
legacy branch. That branch is unreachable in every shipping configuration —
missing bindless or draw-parameters throws at startup under the N.5 ship
amendment — so the accessor survives as the guard on dead code rather than as a
blocker, and it is deleted with that code.

VulkanMeshPipelineDevice is the second implementation, and it is four
properties and two no-ops. Two things about it are worth stating rather than
leaving to be inferred. HasBindless and HasOpenGL43 answer TRUE: their names are
GL-shaped because the seam was cut from a GL device, but what they gate is the
MODERN path — one shared arena, table texture indexing, multi-draw indirect —
which Vulkan supplies unconditionally and the capability gate rejects a device
for lacking, so answering false would disable the only path that exists.
HasPendingWork answers false because the GL device's queue exists to defer work
onto the thread holding the context, and Vulkan resource work is recorded into
the frame's command buffer or routed through the retirement queue.

WbMeshAdapter selects between them once, in the one place the mesh pipeline
still names a backend. The GL arm is unchanged, including the queue-drain
guarantee its construction rollback asserts.

So composition builds the mesh pipeline on BOTH arms, and NullWbMeshAdapter is
deleted — it existed for exactly the gap this closes, and the landblock spawn
ledger now registers against the real adapter. Streaming's publication into GPU
state stops being a no-op there: the Vulkan run below builds real render data,
including the [up-null] zero-vertex caching path.

Gates. Release build green. App tests 4,112 passed / 3 skipped, against a 4,109
baseline plus the three added here. Strict GL offline pixel gate against
579e0b7f: 4.44e-05 (25 differing pixels of 563,200), inside the documented 9-31
px control band and 22x under the 0.001 threshold. One offline Vulkan run with
VK_LAYER_KHRONOS_validation proven inserted by the loader (VK_LOADER_DEBUG=layer
reports `Insert instance layer "VK_LAYER_KHRONOS_validation"`): zero validation
errors, zero warnings, a captured frame, and no [shutdown] diagnostic on either
stream.

What this does NOT claim: nothing draws the world on Vulkan yet. The three
world renderers' submission arms, the two pass executors, and the pass-structure
merge are the next commit's.

No divergence-register row: no retail-facing behaviour changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 14:31:20 +02:00
Erik
579e0b7f60 docs(render): correct V6i-2's pixel-gate figures to commit order
The three measurements were recorded out of order. Commit 1 measured 3.02e-05
(17 px), commit 2 measured 3.20e-05 (18 px) and commit 3 measured 1.60e-05
(9 px) — not the ascending sequence §5.5.13 listed. The band and the verdicts are
unchanged; what was wrong is which commit each number belongs to, which is
exactly what the figures exist to say.

Also records the suite totals in the V6i row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 14:10:25 +02:00
Erik
5b3d72a90c refactor(render): Campaign V slice V6i-2 commit 3 — the mesh pipeline stops naming a backend
Plan §5.5.10 recorded the blocker as a fact about types: "WbMeshAdapter owns an
OpenGLGraphicsDevice, so it is not constructible on Vulkan until slice V4t" —
which is the entire reason NullWbMeshAdapter exists. §5.5.12 item 6 then measured
how wide that dependency really is, and the answer is seven members out of a
760-line class: a GL context, the retirement queue, the shared instance VBO, and
two capability flags.

IMeshPipelineDevice is exactly that surface. OpenGLGraphicsDevice declares it and
every member already existed under a GL-specific name, so the shipping backend
executes not one changed statement — these are aliases, not behaviour.

Two casts moved, and they are what actually blocked construction:

- ObjectMeshManager downcast IGpuDevice to GlGpuDevice in its CONSTRUCTOR, so a
  Vulkan-composed pipeline threw before running a statement. V4t put it there
  because the class registered bindless handles itself; commit 2 moved that into
  the array, leaving the field a pass-through for the raw-GL renderers' handle
  table. The cast now lives on that one property and names the backend it was
  composed against instead of reporting a failed cast.
- The atlas array factory is selected by IWorldTextureArrayFactory.For, which is
  the one place the texture stack branches on a backend.

MeshPipelineDeviceSeamTests proves the decoupling rather than describing it: it
builds ObjectMeshManager against a device whose Gl is null, asserts it constructs,
asserts construction built no GL object, asserts the handle table refuses by name,
and asserts the factory picks the RHI arm. A reflection test pins the seam's
member set so a later slice cannot quietly widen it back out — the whole value
here is that it is narrow.

What this does NOT claim: the mesh pipeline does not RUN on Vulkan. Its upload
bodies are still raw GL — GlobalMeshBuffer, the VAO/IBO construction, the layer
transfers — and they now fail through one RequireGl() accessor that names the
slice that owns porting them, instead of failing at construction. WbMeshAdapter
still creates an OpenGLGraphicsDevice in its GL constructor, because there is no
second implementation to create yet. Those bodies are items 3–5 of §5.5.12's
remainder list, along with RetailPViewPassExecutor and the three world renderers'
submission arms.

§5.5.13 reports the whole of V6i-2 and the slice table gains its V6i row.

Gates: Release build; App tests 4,109 / 3 skips (the 4,086 baseline plus 23 across
the three commits); complete Release suite 9,172 / 5; strict GL offline pixel gate
vs 0ca802cd 1.60e-05 (9 px of 563,200 — the low end of the documented 9–31 px
control band, and fewer than a same-commit control has measured); GL connected
tools/run-repeat-connected-gate.ps1 -Runs 3 at 3/3 RENDERED on the desktop witness
and 3/3 on the client capture; one Vulkan composition-host run with
VK_LAYER_KHRONOS_validation proven inserted by the loader at zero errors, zero
warnings, no [shutdown] diagnostic, and a captured frame.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 14:09:48 +02:00
Erik
c8d0f70bbe feat(render): Campaign V slice V6i-2 commit 2 — world texture creation crosses to IGpuTexture
Plan §5.5.11 recorded what V4t deliberately left behind: it moved the table
ENTRY of every world texture to the device and kept CREATION with the caches,
because "creating world textures through IGpuTexture is real remaining work and
it belongs with the Vulkan world arm, which is the first thing that cannot use a
GL handle at all." §5.5.12 item 1 handed it forward and named the missing piece
exactly — "an ITextureArray implementation over IGpuTexture, not a codec",
because V6b's BlockCompressionCodec and BlockCompressionMipChain already supply
the BC chains. This is that work.

IWorldTextureArray is the seam, and the slot is what crosses it. Before this
commit ObjectMeshManager read BindlessWrapHandle/BindlessClampHandle off the
concrete GL array and interned them into the device table itself. A 64-bit
ARB_bindless_texture handle has no Vulkan spelling, so the array now answers the
question the caller was really asking — ResolveSlot(wrapping) — and each arm gets
there its own way: ManagedGLTextureArray makes the same idempotent interning call
one level down, and RhiWorldTextureArray returns a pair it registered at
construction. ReleaseTextureSlots replaces the snapshot dictionary the manager
kept for the same reason, and still runs only once physical retirement completes.

Which implementation exists is decided ONCE, by the IWorldTextureArrayFactory
composition builds — plan §3.1's no-runtime-fork rule. Everything above the seam
(capacity policy, slot allocation, ref counting, layer retirement, empty-atlas
eviction, and the whole of ObjectMeshManager's atlas policy) is written once and
branches on nothing.

Three things the RHI array does differently, each because the backends genuinely
differ rather than by choice: BC mip chains are CPU-built through
BlockCompressionMipChain, since Vulkan cannot blit into a compressed image, while
RGBA8 uses the device's blit; filtering lives in an immutable sampler rather than
a texture parameter, so both address modes are registered up front exactly as the
GL array holds two resident handles; and RGB8/A8/Rgba32f are refused at creation
with the reason named. A8 is the interesting refusal — the GL array serves it by
swizzling R into A, and a Vulkan swizzle lives in the image VIEW, which the pinned
GpuTextureDescription does not describe. A silent substitution would render wrong
and look like a shader bug.

TerrainAtlas gains the second construction path V6i drafted and reverted. The
decode is factored out and shared, so both arms read the same DATs, in the same
order, with the same resize-to-max policy; only the upload forks.
ICompositeTextureArrayBackend gains its RHI arm, which is four small methods
because that seam was already a seam.

The Vulkan arm is EXERCISED, not merely present. That is the whole reason the
V6i draft was reverted rather than landed — "built then reverted because nothing
exercised it" — and it is the same failure §5.5.12 measured twice in the
descriptor layouts. So the composition host now builds the real terrain atlas
through IGpuDevice.CreateTexture on the arm with no GL context, and creates and
releases one shared array of each format family plus one composite array at
startup. Creation only; nothing draws them. Releasing them in the same statement
covers one thing a retained bundle would not — that both slot pairs come back and
the images route through the retirement queue.

Gates: Release build; App tests 4,104 / 3 skips; strict GL offline pixel gate vs
0ca802cd 3.20e-05 (18 px of 563,200, inside the documented 9–31 px control band);
GL connected tools/run-repeat-connected-gate.ps1 -Runs 3 at 3/3 RENDERED on the
desktop witness AND 3/3 on the client capture; one Vulkan composition-host run
with VK_LAYER_KHRONOS_validation proven inserted by the loader at zero errors,
zero warnings, no [shutdown] diagnostic, and a captured frame. That run built
terrain-atlas 512x512x33 with 10 mip levels, terrain-alpha-atlas 512x512x8, RGBA8
64x64x32 (slots 3/4, 174,720 mip bytes blitted), BC1 64x64x32 (slots 5/6, 696 mip
bytes encoded) and composite 32x32x8 (slot 7).

One whole-suite run failed Issue181WallPressEquilibriumTests once; it passed
alone and did not recur in five further runs. Seven test classes mutate the same
process-global CameraDiagnostics switches with no xUnit collection isolation, and
this diff touches no camera, visibility or physics code. A separate run of the
UNCHANGED parent tree failed a different zero-allocation test, which is `#250`'s
documented class. Both are filed rather than attributed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 13:57:43 +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
0ca802cd7f docs(render): measure the terrain clip block's set, and close the shader audit
Slice V6i-1 recorded terrain_modern.vert's TerrainClip block as landing in the
wrong descriptor set under the Vulkan dialect. That was reasoning from the
missing ACDREAM_UBO_SET macro; it is now measured. Disassembling the committed
spv/terrain_modern.vert.spv shows the block as OpVariable ... Uniform decorated
DescriptorSet 0 / Binding 2, where set 0's layout declares a storage buffer.

The audit the section asked for is also done rather than deferred. Every
compiled .spv was disassembled: mesh_modern.vert's nine set-0 entries are all
StorageBuffer, correctly, and every other uniform block in every other shader
already carries the macro. terrain_modern.vert is the only one. sky.vert is the
precedent - it declares the SAME shared clip block as ACDREAM_UBO_SET binding =
2 - so there is no numbering question for the world arm to settle, only a
one-word omission to fix, plus declaring set 1 bindings 2 and 4 in the uniform
set layout. Binding 4 is section 5.5.8's still-open UniformSkyParams, which
sky.vert and sky.frag both declare.

Documentation only; no source, shader or .spv is touched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 13:19:13 +02:00
Erik
df6e2a7918 feat(render): Campaign V slice V6i-1 - one descriptor set per renderer scope
Plan section 5.5.8 recorded, and deliberately did not fix, that pointing one
binding at a second buffer within a frame silently corrupts the draws already
recorded against it: the backend rewrote the descriptor in place, and a
descriptor set's contents are read when the command buffer EXECUTES, not when it
was recorded. Nothing fired it while the Vulkan frame held only the retained UI.
Section 5.5.11 handed it forward as the first thing the world arm would hit,
because WbDrawDispatcher, EnvCellRenderer and TerrainModernRenderer each own
their own instance, batch and indirect buffers and all three bind set 0 in one
frame.

It is closed here, as its own commit and BEFORE the world arm, so that a blank or
corrupt first Vulkan world frame cannot be this defect wearing another face. That
sequencing is the point: sections 5.5.1 to 5.5.3 cost this campaign three days
because an instrument that was "usually right" sat underneath the thing being
measured.

What changed. There is no longer one (set 0, set 1) pair per flight slot; there
is an arena of them. VulkanBindingScopeArena - pure bookkeeping, no Vulkan
handles, nine unit tests - answers two questions per bind: which pair, and do its
descriptors need writing. VulkanFrameBindings keeps the Vulkan half: allocating
pairs from a growable pool list and writing the twelve descriptors when told to.

The scope key is the descriptor state itself - the ten storage buffer identities
and ranges, the plain bindings' offsets, and the two uniform buffer identities
and ranges. Deriving it is a decision, not an economy. The pinned contract has
nowhere to name a scope: BindStorageBuffer takes a buffer, an offset and a size,
and section 3.3 is frozen. Deriving also gives two properties a declared scope
would not: a renderer cannot forget to declare one, and two renderers that
genuinely share every buffer correctly share one pair rather than being told to
differ. A renderer's buffers are stable for its lifetime, so "distinct descriptor
state" is exactly "renderer scope".

Dynamic offsets stay free. A ring allocation moving between draws rides
vkCmdBindDescriptorSets's dynamic-offset array, so it costs neither a new pair
nor a descriptor write - section 4.4's "zero descriptor writes per frame"
property survives a frame having more than one binding state in it. Entries are
not invalidated at BeginFrame either, because the slot's previous submission has
retired and its descriptors still say what this frame is about to say; a steady
frame therefore rewrites nothing at all. An entry matched from the previous frame
is swapped below the live cursor so the rest of the frame cannot take it for a
different state - the ordering property the sixth test pins, where two renderers
swap submission order between frames.

What this does NOT do is draw a world. The captured Vulkan frame is still V6h's
retained UI over the fog clear, so the arena's multi-scope path is exercised by
its tests and not yet by a frame. That is recorded in the plan rather than
implied.

The plan's section 5.5.12 also records two blockers measured while scoping the
world arm and not fixed here: terrain_modern.vert declares TerrainClip without
ACDREAM_UBO_SET, so under the Vulkan dialect it lands at set 0 binding 2 where
the layout declares a storage buffer - the same class of gap 5.5.8 recorded for
UniformSkyParams, invisible until a terrain pipeline is created; and the offline
gate's scene takes the retail PView path rather than the flat safety path,
because ClipRoot falls back to Buildings.OutdoorNode, which puts
RetailPViewPassExecutor on the critical path to the first Vulkan Dereth frame and
makes the "terrain only" intermediate no cheaper than the whole arm.

Gates. Strict GL offline pixel gate against b9ab5890: 1.60e-05, 9 differing
pixels of 563,200, at the low end of the documented 9-31 px band and 62x under
the threshold - expected, since no GL file is touched. GL connected
run-repeat-connected-gate.ps1 -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 errors, zero warnings, captured frame, no
[shutdown] diagnostic on either stream. App tests 4,086 / 3 skips (baseline 4,077
plus nine); complete Release suite 9,149 / 5. Issue #250's
SurfaceOverrideFingerprint_DictionaryHotPathAllocatesNothing failed once in a
whole-suite run and passed run alone, as that issue documents.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 13:17:25 +02:00
Erik
b9ab5890af docs(render): record V4t at the committed tree, and correct one stale comment
Marks V4t done in the slice table with its two commits and per-commit gate
numbers, adds §5.5.11, and ticks off step 3 of §5.5.9's corrected sequence.

§5.5.11 records four things the world arm needs and one it should not
re-derive: why §5.2's "the device's table is unreachable" argument expired
rather than being worked around (its premise was that V4c would move the world
renderers onto the encoder; §5.5.6 closed that, so the block became
indefinite); that V4t landed narrower than §5.3 sized it, keeping texture
creation and residency with the caches and moving only the table entry, with
`IGpuTexture` creation deferred to the world arm that actually cannot use a GL
handle; why `GroupKey`'s ordering survives a retype of one of its fields; and
the pixel-gate control measurement, because one capture read 5.50e-05 against a
documented 15–23 px band and the honest response to that is a control, not a
paragraph. The decisive number is the 9-px difference between captures at the
two V4t commits — two different commits, fewer differing pixels than the
same-commit control.

`GlBindlessHandleTable`'s own doc comment claimed four renderers own instances.
Three of them no longer do. It now names its one remaining owner, `SkyRenderer`,
says why that one is different (its textures come from the raw GL-name path V4t
did not retype), and names V4f as the slice that deletes both the table and the
class.

`common.glsl`'s binding-9 comment carries the same stale list. It is left alone
deliberately: the file is a shader source with compiled `.spv` artifacts and a
V9 freshness gate, so a comment-only edit there is not free, and what the
comment says about what binding 9 IS remains correct.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:45:55 +02:00
Erik
565c351f93 feat(render): Campaign V slice V4t-2 — the world texture stack crosses to GpuTextureSlot
The rest of V4t. The composite, particle and shared-atlas texture paths now
hand out the device's GpuTextureSlot instead of a raw 64-bit
ARB_bindless_texture handle, and GroupKey, CachedBatch and ObjectRenderBatch
carry that slot. WbDrawDispatcher, EnvCellRenderer and ParticleRenderer retire
their interim GlBindlessHandleTable instances and share the device's one
table, exactly as V4t-1 did for terrain. Nothing about world submission
changes otherwise: these three renderers are still raw GL, still bind binding
9 themselves, and still draw the same geometry in the same order.

**What produces a slot now.** CompositeTextureArrayCache's GL backend interns
each array's handle when it makes it resident and retires the entry when it
makes it non-resident, so the pair is created and destroyed together and the
cache above it never learns a device exists — the fake backend its tests use
mints a stand-in slot. TextureCache.AcquireParticleTexture does the same for
the one-layer particle arrays it owns, including on its rollback path.
ObjectMeshManager registers each shared atlas's wrap/clamp handles at batch
upload; registration is idempotent by handle, so the many batches sharing an
atlas share its entry.

**Slot release is stricter than what it replaces, not looser.** The interim
tables never released anything — the class comment said so — and they grew
without bound. The device's table has a fixed 16,384-slot capacity, so an
unreleased entry is now a leak with an end. Every producer therefore retires
its entry: the composite backend at MakeNonResident, the particle backend at
MakeNonResident, and ObjectMeshManager when a retiring atlas's PHYSICAL
retirement completes — the point at which its handles are already non-resident
and its texture already deleted. That last one needs the handles snapshotted
at eviction, because ManagedGLTextureArray.Dispose zeroes its own copies as
its first act. Teardown deliberately does not release: the device is being torn
down alongside its callers, so there is nothing left to recycle a slot into,
and deferring work through a possibly-disposed retirement queue would turn a
clean shutdown into a throw.

**The default value became load-bearing, and that is the one real hazard here.**
BindlessTextureLocation could say "not resolved" with handle 0, because no
texture has handle 0. A slot index has no spare value — default(GpuTextureSlot)
is real slot 0 — so a positional record would have turned every
budget-rejected or still-uploading composite into a silent read of whichever
texture registered first. That is the magenta-placeholder failure shape one
layer down. The type is now a struct storing the slot one-based, so default IS
Unresolved, with a test pinning both halves: default is unresolved, and a
location naming slot 0 is resolved and distinguishable from it. Elsewhere the
sentinel is already exact — GpuTextureSlot.Unassigned is 0xFFFFFFFF, which is
common.glsl's ACDREAM_TEXTURE_NONE — so the classify path's "no texture yet"
test and the particle billboard's untextured branch are unchanged in meaning.

**GroupKey ordering is preserved because the key never ordered anything.**
Handle→slot is a bijection (the device interns one slot per resident handle),
so the same (entity, batch) pairs bucket together as before. The key reaches
equality, hashing and the scene-digest fingerprints — never a comparator:
opaque and translucent groups sort by cull mode then camera distance, the
delayed-alpha path by viewer distance then submission ordinal, and group
enumeration follows the persistent dictionary's insertion order, which a
changed hash does not disturb. The digests hash the slot index where they
hashed the handle; both sides of the render-shadow comparison compute them the
same way, so the value changing is invisible to it. Read
CompareOpaqueSubmissionOrder, CompareTransparentSubmissionOrder and
AlphaFingerprintComparer before doubting this — sort-order drift is a
pixel-visible regression class this project has hit, and it is why the check
was made before the retype rather than after.

**One visibility change, forced rather than chosen.** BindlessTextureLocation
was public and now holds an internal contract type, so it is internal;
ObjectRenderBatch.TextureSlot is internal on an otherwise public class for the
same reason. Nothing outside this assembly and its InternalsVisibleTo test
assemblies named either.

**SkyRenderer keeps its interim table**, and the report should say why: the
sky's textures are minted by SkyRenderer itself from TextureCache's raw GL
texture names, which this slice does not retype, so it would be the one
consumer registering handles it produced — a different shape from the world
stack. The offline gate also masks the sky band, so the one automated
instrument here cannot see a sky regression. V4f owns that renderer.

**Gates.** GL offline pixel gate vs cb2a70b8, measured twice: 31 and 22
differing pixels of 563,200 (5.50e-05, 3.91e-05). The first is above the
plan's documented 15-23 px band, so a control was measured rather than
assumed: two same-commit captures at this tree differ by 19 px, and — the
decisive number — a capture at V4t-1 and a capture at this commit differ by
9 px, fewer than the same-commit control. Maximum channel delta is 41-52 in
every pair including the controls, i.e. the differing pixels are drawn from
one flickering population, not from moved geometry. tools/run-repeat-connected-gate.ps1
-Runs 3: 3/3 RENDERED on both the desktop witness and the client capture. One
Vulkan composition-host run with VK_LAYER_KHRONOS_validation proven inserted
by the loader: zero errors, zero warnings, converged ownership ledger. App
tests 4,077 / 3 skips and the complete Release suite 9,140 / 5 — both the
4,075 and 9,138 baselines plus the two tests added here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:43:50 +02:00
Erik
b8bcaa3ef2 feat(render): Campaign V slice V4t-1 — terrain crosses to GpuTextureSlot
V4t moves the world texture stack off the raw 64-bit ARB_bindless_texture
handle and onto GpuTextureSlot. This first commit does terrain only, because
terrain is the one branch of that stack whose producer and consumer are a
single pair — TerrainAtlas and TerrainModernRenderer — so it can carry the
new device seam on its own pixel gate before the mesh/composite/particle
retype lands on top of it.

Why the device's table can now be reached, when §5.2 said it could not.
That paragraph's reason was the flush: GlGpuDevice drains its dirty table
runs inside FlushBeforeDraw, which only an encoder-recorded draw reaches,
so a raw-GL renderer would sample a stale table. §5.5.6 then closed the GL
re-land of V4c/V4d, which means the world renderers stay raw GL through to
V10 — so "wait for the encoder" stopped being a plan and became an
indefinite block on V4t, which the Vulkan world arm cannot be written
without. The resolution is the smallest one that keeps the seam honest: the
drain is factored out as GlGpuDevice.FlushTextureTable, and a raw-GL
renderer calls it and binds TextureTableGlName at binding 9 itself,
immediately before its own draw — the same shape its retired private
GlBindlessHandleTable had, against a table that is now the device's. Nothing
else of the backend is exposed, and both members are deleted with the raw-GL
world path.

Residency ownership deliberately does NOT move. RegisterWorldTextureHandle
interns an already-resident handle and owns only the table entry; the atlas
still creates, makes resident and destroys its own textures. That is what
separates it from RegisterTexture, which owns the residency it creates, and
it is why this slice can retype the data model without also porting GL
texture creation onto IGpuTexture.

TerrainAtlas.GetBindlessHandles becomes GetTextureSlots(GlGpuDevice).
Registration is idempotent by handle, so the per-draw call is two dictionary
lookups — the cadence GetOrAdd already had. It is conditional on the handle
having changed because SetAnisotropic makes both textures non-resident and
re-acquires them: without that check a quality-preset change would strand a
slot holding a non-resident handle, so the superseded entry is retired in
the same step through the device's retirement queue.

Ordering is unaffected. Terrain's two slots travel as loose uniforms
(uTextureIndexA/B) and enter no sort and no bucket key, so a different slot
NUMBER changes nothing about what is drawn or in what order — only which
table index resolves to the same handle.

Gates. GL offline pixel gate vs cb2a70b8: 3.02e-05 (17 of 563,200 pixels),
exactly a same-commit control value and inside the documented 15-23 px /
<=4.1e-05 band. tools/run-repeat-connected-gate.ps1 -Runs 3: 3/3 RENDERED on
both the desktop witness and the client capture. One Vulkan composition-host
run with VK_LAYER_KHRONOS_validation proven inserted by the loader: zero
errors, zero warnings, empty validation log, converged ownership ledger. App
tests 4,075 / 3 skips (#250's zero-allocation test reran green singly).

One connected run of an earlier 3-run attempt died in the render loop with
"OpenGL returned unexpected fence wait status NoError (0x0)" from
GpuFrameFlightController.RetireFence. It did not reproduce in the following
three runs at this tree nor in three interleaved runs at cb2a70b8, and this
diff creates, deletes and waits on no fence. Filed as #251 rather than
attributed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:22:45 +02:00
Erik
cb2a70b867 docs(render): record V6h's gate numbers at the committed tree
The strict GL offline pixel gate re-run at b16f8206 measures 1.78e-05 (10 of
563,200 pixels) against 46d893f7, and the Vulkan validation run at that exact
tree is clean with an empty stderr and a UI frame identical to the pre-commit
capture. Also records a pre-existing test-isolation defect found while gating:
StreamingControllerPriorityApplyTests.DungeonCollapseBeforePromotionBase_RetiresProvisionalTerrainAndPendingStatics
fails alone under --filter and passes with its project, identically at the parent
commit, so it is an order dependency in the test rather than a regression.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 11:51:47 +02:00
Erik
b16f820643 feat(render): Campaign V slice V6h — the Vulkan composition host
ACDREAM_RENDER_BACKEND=vulkan now runs the real GameWindow composition rather
than a second main(). All nine phases execute: DAT load, streaming, camera,
entity table, session, and the real retained UiHost drawing through the RHI.
No world renderers — they are raw GL until V4t and the world arm behind it.

The offline log is the client's own (acdream.pak opened, 6266 spells, Region
0x13000000, "loading world view centered on 0xA9B4FFFF", fourteen retail
LayoutDesc lines, streaming radii), and the captured frame is the retail
retained UI: vitals, combat/spell bar with DAT scarab icons, the nine-slot
toolbar, chat with tabs and Send, radar/compass with dat-font glyphs. Sampled
against the GL capture the widgets agree — chat interior RGBA (25,24,27,158)
vs (22,21,23,158), vitals bar (117,1,0) and toolbar slot (0,11,17) identical.

Three seams, as §5.5.9 specified:

1. Platform acquisition — already generic — publishes GameWindowGraphics
   instead of a bare GL. Phases that still speak raw GL read Graphics.Gl and
   take their Vulkan arm when it is null; each branch names the slice that
   removes it.
2. VulkanHostInputCameraCompositionFactory is a new file and the whole of the
   Phase-1 fork: four graphics members differ, input/camera/pointer delegate.
   The default factory is chosen inside the phase from the platform result.
   HostInputCameraResult gained backend-neutral Retirement and FrameSlots.
3. The frame root forks on one condition. The GL world-scene assembly is
   unchanged, wrapped in `if (gl is not null)`; the Vulkan arm's graph is one
   backbuffer clear pass computing the same RenderFrameFoundation from the same
   clock and weather owners, then private presentation over it.

§5.5.9's three TextureCache couplings are unpicked: the constructor takes GL?
and rejects bindless without one, world entry points route through a Gl
property that throws naming V4t, and the (GlGpuTexture) VRAM-accounting cast
became a backend test. That cast's stated reason — DrawSprite's texture-unit
binding — was already stale, deleted at V6d.

VulkanBringUpHost is reduced to the capability-probe harness it is named for:
the instance/surface/device/swapchain sequence moved into VulkanGraphicsContext,
which the composition host and the harness now share. It is reached only with
ACDREAM_VULKAN_PROBE=1.

One latent Vulkan defect surfaced and is fixed here. The first composition-host
frame died with ErrorDeviceLost; validation named VUID-vkCmdDraw-None-08600 —
descriptor set 2 never bound. VulkanGpuPassEncoder bound sets 0/1/2 only as a
side effect of BindStorageBuffer/BindUniformBuffer, so a pass sampling the
texture table while binding no buffer — every retained-UI and debug-line pass —
drew with the table unbound. It survived V6c-V6g because the bring-up host
always drew VulkanRhiScene first and the UI pass inherited its binds; the
composition host has no 3-D scene. The fix is one line in the encoder's
constructor beside the viewport and scissor defaults, which exist for exactly
the same reason: a pass opens with complete binding state rather than depending
on what preceded it.

Gates: strict GL offline pixel gate against 46d893f7 measures 1.24e-05 (7 of
563,200 pixels), inside the documented 15-23 px / 4.1e-05 band, so GL behaviour
did not move. App tests 4,075/3 skips; complete Release suite 9,138/5 skips.
One full Vulkan run with VK_LAYER_KHRONOS_validation: zero errors, zero
warnings. Both Vulkan runs converged the ownership ledger — no [shutdown]
diagnostic on either stream. The reduced probe harness presented 34,811
validation-clean frames.

No divergence-register row: GL is the shipping backend and the pixel gate proves
it unmoved; the Vulkan arm is not a retail deviation but a backend under
construction.

Next is V4t, the texture stack, which the world arm cannot be written without.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 11:47:37 +02:00
Erik
46d893f7c3 docs(render): scope the Vulkan composition host, and correct where V4t blocks
§5.5.7 asked for "a Vulkan composition host — a slice the plan has never
scoped." This scopes it and stops there, for the same reason V6f stopped short
of the world fork: the honest measurement is worth more than a half-built second
path, and §3.1 and §7.1 rule 3 exist to prevent exactly the unexercised arm this
would otherwise have landed.

Three findings, all verified against source rather than inferred.

The fork seam mostly already exists. GameWindowPlatformResult<TGraphics, TInput>
is fully generic — only its call sites pin GL — and the host phase already has
IHostInputCameraCompositionFactory with a single Retail implementation, so the
Vulkan device arm is a new file rather than a modification. What does not exist
is a Vulkan frame root: FrameRootComposition assembles the clear phase, both
pass executors and the GPU profiler measurement from a GL handle plus six raw-GL
world renderers, none of which exist on Vulkan. That second assembly is the
slice's centre of gravity, and it is most of V4h.

The retained UI is NOT blocked on V4t. V6f wrote that the retail widget tree's
chrome comes from a GL-only TextureCache, which is true of the type but not of
the path: V4a and V6d already moved UploadUiTexture onto IGpuDevice, and the
public UploadRgba8 that IconComposer composes retail icons with routes into it.
The raw-_gl uploads that remain are the world's Texture2D/Texture2DArray paths,
which no UI draw reaches. Three small things stand in the way — a non-null GL in
TextureCache's constructor, one ((GlGpuTexture)texture).GlName cast for VRAM
accounting, and the UI-probe screenshot controller — and none of them is V4t.
So V4t is a hard prerequisite for the WORLD arm, not for the host, which moves
the host ahead of it in the sequence rather than behind.

That reordering is the point of the commit. The corrected sequence puts the
composition host at step 2, before V4t, because its acceptance criterion — the
real UI renders — makes it the first frame acdream draws on Vulkan that is the
client's own frame rather than a scene written to prove the backend.

Also records the argument for keeping VulkanBringUpHost as a capability-probe
harness: its window/instance/surface/device/swapchain sequence is what the
composition arm needs, and deleting it would mean writing that twice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 10:51:26 +02:00
Erik
24834a6478 fix(render): Campaign V slice V6g — the Vulkan frame stops lying to the driver
V6f ran the bring-up host once under VK_LAYER_KHRONOS_validation and found
seven VUIDs, every one of them on the path any world frame takes (plan
§5.5.7). This closes all of them, plus a fourth defect in the same log that
§5.5.7 did not call out. The host now runs validation-clean: zero errors and
zero warnings over 39,855 frames.

Nothing outside Gpu/Vk/ is touched, so the GL backend executes not one changed
statement. The offline pixel gate says so too — 4.08e-05 differing fraction
against f8dbe2ee, which is exactly the value the campaign recorded as its own
same-commit control (§5.1's 15–23 pixel band).

The dynamic-descriptor limit was a decision, not a patch. V6b declared all ten
of set 0's bindings STORAGE_BUFFER_DYNAMIC on the reasoning that the contract
lets a renderer bind any range per draw. That is true and still cost nothing to
honour for four of them: a dynamic descriptor buys exactly one thing, the
ability to address the SAME buffer at a DIFFERENT offset without a descriptor
write, which is the shape of a ring allocation and of nothing else. So the
ring-fed bindings — instances, batches, clip slots, instance light sets — stay
dynamic, and the ones pointing at a long-lived buffer written whole and bound
once per pass carry their offset in the descriptor instead. Binding 9 is the
clearest of those: it is the GL-only uvec2 handle table, which the Vulkan
backend never binds at all.

That lands on four dynamic storage descriptors. The RX 9070 XT allows eight, so
eight would have worked here — but four is Vulkan's GUARANTEED minimum, which
means no conformant device can fail this layout, and V9's lavapipe row and the
deferred physical Linux row both depend on that. The count is asserted against
maxDescriptorSetStorageBuffersDynamic in the capability record, so a device that
cannot serve it is rejected at startup in the report under the same exit-code-4
contract as every other requirement, rather than failing silently at
vkCreatePipelineLayout the way this one did.

Depth-off pipelines were malformed in any pass that has depth. Dynamic rendering
bakes the depth/stencil attachment format into the pipeline and requires it to
equal the pass's; V6c set it only when the pipeline itself tested or wrote
depth. Debug lines, the retained UI and the sky are all depth-off and all
composite over the main pass, so this was not an edge case. The same
GpuPipelineDescription is legitimately used both ways — ui-text opens its own
depth-less pass — so the description cannot answer the question and the backend
builds both variants, binding whichever matches what vkCmdBeginRendering was
actually handed rather than what the pass asked for. Both are built at startup
against the persisted cache, so no frame compiles one. A slice entitled to
change the contract should add a depth-format field the way V6d added
ColorFormat; this is the honest expression of the gap until then.

vk-backbuffer-depth and vk-backbuffer-msaa-color were created UNDEFINED and
never moved. Both now barrier on every backbuffer pass — from UNDEFINED on the
first use after Configure, from attachment-optimal with a write-after-write
dependency thereafter. The dependency matters on its own account, not just the
layout: two passes in one frame write both images and so does the next frame,
and Vulkan orders nothing between render-pass instances.

The fourth defect is the one worth reading twice. CaptureBackbuffer transitioned
the LAST PRESENTED swapchain image to TRANSFER_SRC and copied out of it. After
vkQueuePresentKHR that image belongs to the presentation engine and its contents
are not ours to read — and the pixels were usually right, which is precisely the
problem. This campaign spent three sections of its own plan (§5.5.1–§5.5.3)
discovering how much a capture instrument that is "usually right" can cost, and
shipping that shape on the new backend would have made every Vulkan PNG, and the
V7 differential built on them, formally undefined. The frame now copies its own
output into a host-readable buffer while it still owns the image, and the
capture reads that. Retention is opt-in, armed when an artifact directory
exists: one full-resolution copy per frame is worth nothing to a player and is
the entire instrument to a gate. The old one-shot command pool, device-idle wait
and per-capture readback buffer go with it.

Two gaps found and recorded in §5.5.8 rather than fixed, both outside this
slice's brief. UniformSkyParams (set 1, binding 4) is not in the uniform set
layout, so whoever first draws sky on Vulkan must add it. And a binding pointed
at two different buffers within one frame silently corrupts the earlier draws,
on dynamic and plain descriptors alike, because descriptor contents are read at
execution time — no consumer does that today, but WbDrawDispatcher and
EnvCellRenderer each own their own instance and batch buffers and both bind
bindings 0, 1, 3, 4 and 5 in one frame, so the Vulkan world arm has to know
before it is written.

Gates: Release build; App tests 4,075 passed / 3 skipped (baseline 4,073 + the
two new capability cases); GL offline pixel gate PASS at 4.08e-05; one
validation-layer Vulkan run, clean, with the captured PNG inspected and correct
in orientation, colour and glyph coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 10:47:52 +02:00
Erik
f8dbe2ee4a docs(render): close V6f's shader obligation; record why the Vulkan world path is blocked
Two things to write down after slice V6f.

The obligation §5 recorded against V6e is discharged. terrain_modern was "the one
production pair still not Vulkan-expressible"; its three blockers - the two loose
matrices, the loose tiling array, and the GL-only sampler-from-handle
construction - are gone, and 8/9 pairs now compile. The ninth is `mesh`, which
the plan already records as having no consumer at all, so every shader acdream
actually draws with is Vulkan-expressible. V6f gets its own slice row and its own
line in the user-gate debt table: terrain through a doorway clip region is the
one terrain path the offline gate cannot see, and it now has a second UBO binding
beside the clip block, so a bind-order mistake would surface exactly there.

The larger entry is §5.5.7, which records a measurement rather than an opinion.
§5.5.6 selected option (B) - V4c/V4d's content returning as the Vulkan world path
behind a fork at the thin submission seam - and V6f set out to build that fork.
It cannot be built yet, for a reason the plan had not stated: the Vulkan path
constructs no game state at all. GameWindow.Run returns at :695, before
Window.Create and therefore before OnLoad, which is the only caller of the
composition pipeline. A capture confirms it (artifacts/vk-world/): what the
Vulkan backend draws today is V6c's verification scene and V6d's generated UI
sprite, correctly and completely, and nothing else. A backend-selected fork would
therefore have a GL arm that runs and a Vulkan arm nothing can reach - the
unexercised second path §3.1 and §7.1 exist to prevent.

Worse for sequencing, the parked V4c/V4d code could not drive Vulkan even if it
were reached: it binds GL bindless handles as a storage buffer because §5.3
deferred the real port to V4t, and GroupKey carries the raw ulong. V4t is a hard
prerequisite, and it rewrites exactly the code the fork's Vulkan arm would
contain. Landing the fork first means writing that arm twice.

One validation-layer run is recorded with it, and it found two defects that
outlive the slice, both pre-existing and both on the path any world frame takes.
The pipeline layout declares all ten storage bindings as STORAGE_BUFFER_DYNAMIC
against a device limit of eight - the pinned binding model meeting a real limit,
wanting a decision rather than a patch. And any depth-off pipeline in a pass that
carries depth declares VK_FORMAT_UNDEFINED where the attachment's real format is
required. Also noted: the render-target-view-in-table usage V6f was told to
expect did NOT fire, so it should be re-checked rather than carried forward as
known-and-accepted.

The section closes with the recommended order - composition host, the validation
defects, V4t, then the fork - and with a cheaper intermediate milestone worth
considering: terrain, water and sky only, for which V6f's work is the whole
shader prerequisite.

Also: the roadmap's Campaign V paragraph gains a shipped-so-far line, and #250
gains a third test of the same class. One full App run during this slice reported
2,752 bytes against an expected 0 in
CurrentRenderSceneOracleTests.SurfaceOverrideFingerprint_DictionaryHotPathAllocatesNothing,
on a diff that touches only GLSL and terrain's uniform plumbing; it passed alone
and in four other full runs of the same binary.

Documentation only - no code, no gates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 10:22:57 +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
95f8c25f31 feat(render): draw the retained UI on Vulkan, and fix the pass it exposed
Campaign V slice V6d, commit 3 of 3 — the evidence commit, which turned out to also be a bug-fix commit.

VulkanBringUpHost now builds a real UiHost and DebugLineRenderer on the Vulkan device and draws them after the V6c verification scene, in their own single-sampled load/store passes against the backbuffer — the same shape the GL client's HUD phase has. Nothing in the retained stack is backend-aware: UiRoot walks a real widget tree, each widget draws through UiRenderContext, and UiHost.Draw brackets it with TextRenderer.Begin/Flush. What it cannot be is the game's own UI, because the retail tree is built from LayoutDesc and DAT chrome by TextureCache, which stays a GL type until V4t; the sprites here are generated instead. The widget rectangles are authored at known pixel offsets from the top-left and nothing is mirror-symmetric, so a wrong Y flip would put the title bar at the bottom.

The frame this produced was wrong, and usefully so. Whole runs of the debug-line figure were missing. Vulkan's rasterization-order guarantees are scoped to one render-pass instance; between two instances writing the same attachment there is no implicit ordering, and that includes a multisample RESOLVE, which is part of the render pass and therefore equally unordered against what follows. TransitionBackbufferForRendering emitted its acquire barrier once per frame and returned for every pass after the first, so the second and third passes raced the first one's resolve. V6c's frame had exactly one backbuffer pass and could not see this; V6d's has three. A later backbuffer pass now gets a colour-attachment dependency instead of nothing, and keeps ColorAttachmentOptimal as its old layout rather than Undefined, which would have licensed discarding everything drawn so far. Every line renders continuously afterwards.

Inspection of artifacts/vk-ui/vulkan-bringup.png against the authored layout, by pixel probe:

The header panel is authored at (24,18), 420x96. Its tiled chrome fills exactly x 24..443 and y 18..113 — one pixel outside on any edge is the clear colour. The tile's lit edge appears at the top and left of every cell, so texture row 0 lands at the top and the V axis is not flipped. Both labels read left to right, right side up, through the font-coverage branch. The nested panel's border samples exactly (153,191,255) against an authored (0.6,0.75,1.0), unblended — the untextured branch is bit-exact. The badge sprite is authored at (460,58), 64x64, and its gradient starts at x=460 with the clear colour at 455 — the RGBA-modulate branch, sampling a table slot. The two debug-line segments land on their computed screen coordinates. All three fragment branches, the pixel-to-NDC mapping, the top-left origin, straight-alpha blending and table sampling are therefore all confirmed on Vulkan, which is everything the offline GL gate confirms about the same code on GL.

The plan's V6 milestone is amended rather than claimed: "full game frame on Vulkan" is not reachable while V4c/V4d are parked and the world renderers and TextureCache are still raw GL, so V6 delivers the backend plus the two renderers that can use it today. The accumulated user-gate table gains a V6d row for the paperdoll/appraisal viewport sprite — the one retained-UI texture the offline scene never draws, on a slice that changed how every UI texture is sampled.

App tests 4,057 passed / 3 skipped, unchanged. Offline pixel gate against f6f58a12: differing fraction 3.20e-05, 18 pixels of 563,200, inside the documented 15-23 pixel noise band — as expected, since this commit touches only Vulkan files and the campaign doc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 09:03:58 +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
871c406b99 feat(render): let a pipeline name the colour format it renders into
Campaign V slice V6d, commit 1 of 3. The third contract amendment of the campaign, in the same shape as GpuBlendMode.InverseAlpha (V4c) and GpuVertexFormat.UByte4UInt (V4d): a slice met a wall the pinned contract could not express, and the fix is a reviewed field rather than a backend working around it.

Vulkan's dynamic rendering bakes the colour-attachment format into the pipeline. VkPipelineRenderingCreateInfo has to name it at creation, and a pipeline whose declared format disagrees with the attachment it is used with is undefined. GpuPipelineDescription named SampleCount and nothing else about the target, so slice V6c had no way to ask the question and hard-coded VulkanTextureFormatMapping.CanonicalColorAttachmentFormat for every pipeline it built. It recorded that as a real expressiveness gap rather than hiding it, and named this commit as the honest fix.

GpuPipelineDescription.ColorFormat defaults to Rgba8UnormRenderTarget, which the Vulkan backend already maps to the swapchain's B8G8R8A8_UNORM, so every pipeline written before the field existed keeps exactly the format it was getting. GL ignores the field entirely: a GL framebuffer carries its own attachment formats and a program binds to whatever is attached, so there is nothing for the GL backend to declare. The substitution that makes an offscreen Rgba8UnormRenderTarget resolve to the swapchain's byte order stays — it is what lets a backbuffer pipeline and an offscreen pipeline share one description, and it is invisible above the API because an image is sampled through its format's component mapping.

The contract test asserts both halves that matter: the default is the render-target format (so nothing moves), and the field is really settable (so naming it is not decoration).

App tests 4,057 passed / 3 skipped, up one from the 4,056 baseline. Offline pixel gate against 234fe91d: differing fraction 2.84e-05, 16 pixels of 563,200 compared, inside the documented 15-23 pixel same-commit noise band and about 35x under the 0.001 threshold.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 08:36:06 +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
Erik
9eae496301 feat(render): Campaign V slice V6b - Vulkan textures, mips, samplers and the descriptor table
The second of V6's three commits: everything the fragment stage samples. Plan
sections 4.3 (textures and mip generation) and 4.4 (descriptors).

The descriptor table is the piece that retires GL_ARB_bindless_texture. One
update-after-bind, partially-bound, variable-count combined-image-sampler array
of 16384; registration appends exactly one vkUpdateDescriptorSets and nothing is
written at draw time, so steady state is zero descriptor writes per frame. A
slot is a (view, sampler) pair, exactly like a bindless handle, which is why the
CPU data model needs no change at all - GpuTextureSlot already carries the index
and V2 already moved every batch onto it.

Eviction is retirement-gated and the slot is scrubbed on the way out. Returning
a slot the moment a texture is deleted would let the LRU alias a live draw onto
a new texture, so the release is filed through the ledger; and when it runs the
slot is first overwritten with the default 1x1 white. A stale view descriptor
sitting in a partially-bound array is legal right up until something reads it,
at which point it is a use-after-free with no error attached. Writing the dummy
makes that impossible rather than unlikely.

The CPU block-compression codec is the slice's other substantial piece, and it
exists because Vulkan cannot blit into a compressed image. DAT surfaces arrive
as DXT1/3/5 with no mips, so the chain has to be decoded, box filtered and
re-encoded here. That is not merely a substitute for the missing blit: the GL
path calls glGenerateMipmap on compressed array textures, whose result is
explicitly implementation-defined, so this is the first time that part of the
pipeline has had a defined answer.

Two properties matter more than quality, and both are tested. It is
deterministic - integer arithmetic end to end, endpoints from the block's
bounding box, nearest-palette selection, no dithering and no iterative fit -
because the offline pixel gate compares captures from separate processes and a
chain that varied run to run would make every textured surface look like a
regression. And it preserves BC1's one-bit cut-out: a block containing any texel
below the alpha threshold is encoded in three-colour mode, because retail's
foliage and grates ARE that mode and quantising those texels to an opaque colour
would fill in every leaf. Plan 4.3's escape hatch stands if quality ever trips a
gate: store the affected textures as RGBA8 and blit their mips.

Uncompressed images do take the blit chain, added to the upload queue. Each
source level moves to TRANSFER_SRC for its blit and back to TRANSFER_DST
afterwards; leaving the chain in mixed layouts would be one barrier cheaper and
would then force the batch's final shader-read transition to name a different
old layout per level, so ending every level the same way is what keeps that
transition one barrier per image.

The upload queue now records the layout each image is in on ENTRY to a batch
rather than always naming UNDEFINED. UNDEFINED lets the driver discard existing
contents, which is right for a fresh image and wrong for the incremental
array-layer fills that mirror ManagedGLTextureArray - discarding there would
erase every layer uploaded earlier.

Render targets are single-sampled per the contract and carry SAMPLED usage
alongside COLOR_ATTACHMENT, so a paperdoll or appraisal view can be registered
into the table and drawn by the retained UI the moment its pass ends.
VulkanBackbufferAttachments owns the two attachments the swapchain does not: the
multisampled colour scratch that resolves into the swapchain image, and the
transient depth/stencil. Both are TRANSIENT_ATTACHMENT because nothing reads
either after the frame. Stencil is not optional - issue #117's portal punch
needs the aspect, which is why the V5 gate prefers D32_SFLOAT_S8_UINT over a
depth-only format.

Every format stays UNORM, and that is the V3 audit's finding rather than a
default. The plan previously specified an sRGB swapchain "matching the GL
FramebufferSrgb contract"; that contract does not exist, the renderer is plain
UNORM end to end, and shipping _SRGB would have brightened every frame and
passed silently until V7.

VulkanPipelineLayouts is extracted from V5's capability probe rather than
written beside it, and the probe now calls it. The probe's whole value is
proving the layouts the live backend builds can be built on this device; two
similar-looking definitions would have quietly ended that the first time one of
them changed.

Gates: Release build clean, App suite 4037 passed / 3 skipped (4014 at V6a plus
23 new), offline pixel gate PASS against the parent baseline at a differing
fraction of 4.26e-05 - 24 pixels of 563,200, one above the campaign's recorded
15-23 same-commit noise band and about 23x under the 0.001 threshold, on a
commit that changes no GL code path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 07:49:40 +02:00
Erik
37f3ed0498 docs(render): record the 10/10 NVIDIA cross-vendor verdict; adopt the driver-defect conclusion
The exact V4c binary - verified by its embedded wb-mesh pipeline literals - rendered ten of ten repeat-gate cycles on a separate NVIDIA PC against the same ACE, same scene, same account, while the AMD box fails 30%+ of identical runs with the defect pinned to this binary at p=0.024. Two GL drivers, one failure. Option B is adopted: GL keeps the legacy world path to V10, the RHI world path ships on Vulkan, and the V4c/V4d GL re-land is closed rather than parked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 07:45:24 +02:00
Erik
fb9c6693dc feat(render): Campaign V slice V6a - Vulkan memory, buffers, rings and the frame timeline
The first of V6's three commits, and the half of the Vulkan backend that has
nothing to do with drawing: where memory comes from, how per-frame data reaches
the GPU, and what makes it safe to reuse either.

Plan sections: 4.2 (bindings layer and the no-VMA decision), 4.3 (memory:
arena, staging ring, per-frame data), 4.8 (sync and the frame).

The allocator is hand-rolled, roughly as 4.2 sizes it. Silk ships no VMA, and a
third-party binding would be a native binary to carry across win-x64, linux-x64
and CI lavapipe for an allocation profile that is genuinely tame: two mesh arena
buffers, one staging ring, a per-flight ring buffer each, a few render targets
and a texture pool. What a custom allocator buys instead is exact accounting -
every byte is attributable to a memory type and a block - which is what
GpuMemoryTracker will want and what VMA would obscure.

Placement, block policy and heap choice are pure types with no Vulkan handle in
sight: VulkanMemoryBlockFreeList is first-fit with coalescing on release,
VulkanMemoryTypePool decides when a request is large enough to warrant a block
of its own, and VulkanMemoryTypeSelection maps each GpuMemoryResidency onto a
preference order of property masks. VulkanDeviceMemoryAllocator turns their
answers into vkAllocateMemory and one persistent vkMapMemory per host-visible
block. That split is deliberate: an allocator's real failure modes are
arithmetic - a mis-coalesced neighbour, an alignment that eats a block's tail, a
double release that quietly corrupts the used-byte count - and arithmetic does
not need a GPU to be wrong. Twenty-two tests cover exactly those.

The HostWritable row of the selection table is the campaign's CPU win stated as
data. It prefers a memory type that is both DEVICE_LOCAL and HOST_VISIBLE -
resizable BAR, present on the RX 9070 XT - so per-frame data is written once,
straight into memory the GPU reads, and falls back to ordinary host-visible
coherent memory when no such type exists. GpuCapabilityRecord's
SupportsPersistentlyMappedRings is the first capability that is true on this
backend and false on GL.

Mapping is per block, never per allocation, because Vulkan permits a memory
object to be mapped once - mapping per buffer would need one VkDeviceMemory per
buffer, which is precisely the allocation-count explosion the design exists to
avoid.

VulkanRingBufferState is markedly simpler than its GL sibling, and the
difference IS the point. GlRingBufferState has to track a dirty watermark and
prove its upload never overlaps an in-flight read, because a ring allocation
there writes into a managed array that is later copied into a GL buffer. Here
the allocation hands back memory the GPU reads directly: there is no upload step
to track. What is left is a cursor.

VulkanUploadQueue accumulates transfers rather than issuing them, for two
reasons that both come from Vulkan rather than from taste: copies must be
recorded into a command buffer, and they must be recorded outside a
dynamic-rendering block. So requests queue and drain at the one moment both hold
- immediately before a pass begins - which is the direct analogue of the GL
backend's flush-before-every-draw discipline at the granularity Vulkan needs.
The drain emits one batched buffer barrier for the whole batch, one of the four
to six 4.8 budgets per frame.

Staging exhaustion falls back to a temporary dedicated buffer retired through
the ledger. Section 4.3 already specifies that for oversized uploads; extending
it to "the ring is full of unretired frames" is the same shape and is a policy
rather than a workaround - the transfer stays correct and ordered, it just costs
one allocation.

VulkanFrameFlightController is the mechanical port 4.8 promised. GL's array of
fences becomes one timeline semaphore whose value is the frame serial, "has this
slot retired?" becomes "is the counter at least serial minus two?", and the
SortedDictionary retirement ledger keeps its keys because those keys were
already frame serials. One subtlety is worth stating: a release is filed against
the frame currently being RECORDED, not the last one completed, because commands
already recorded into the open frame may still read the resource. A test pins
that, since getting it wrong frees memory a pending command buffer reads and the
symptom would appear somewhere else entirely.

Frame acquire ordering is the other subtlety. TryBeginFrame waits on the flight
slot BEFORE acquiring its swapchain image, so the slot's acquire semaphore is
provably idle - signalling a semaphore a pending submit still waits on is the
classic Vulkan deadlock. When the acquire fails the serial is still signalled
through an empty submit, because a serial that never completes makes every later
frame wait forever.

The device is a partial class split along the V6 commit boundary: everything
here is memory and frames, while textures and the descriptor table (V6b) and
pipelines, passes and readback (V6c) throw with the slice named rather than
returning something that fails later and further away. Nothing constructs this
device yet - VulkanBringUpHost still presents its clear colour - so the GL path
executes not one new statement.

VK_EXT_debug_utils naming arrives with the allocator rather than at V6c, because
every resource wants a name from birth and the campaign has already spent days
on defects only visible from outside the API. It stays optional: absent
extension means every call is a no-op and no call site checks.

Gates: Release build clean, App suite 4014 passed / 3 skipped (3981 baseline
plus 33 new). One Issue181WallPressEquilibriumTests failure in the full run is
the known #250 zero-allocation flake and passes on a single run. Offline pixel
gate against the parent is a tripwire here - the backend is dark and no GL code
path changed - and is reported with the slice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 07:36:15 +02:00
Erik
78b0e14214 docs(render): park the GL re-land of V4c/V4d, bring V6 forward
Records the decision that §5.5.4 recommended and the V5 bring-up now makes
actionable: option (C). No further GL-side attempt is made to re-land V4c or V4d
until the same ported world path has been measured on Vulkan.

The grounds are the three investigation sections read together. A blank run
rasterizes its first world frame correctly - frame 43's occlusion counters,
1,692,830 terrain and 317,561 entity samples, byte-match the parent build - and
then one irreversible event kills every GPU→CPU return channel at once:
readbacks come back RGBA(0,0,0,0) over UI pixels the desktop witness shows on
screen, a guarded glGetQueryObject deadlocks the render thread inside the
driver, and a GPU-timeline query-buffer write never lands on its sentinel.
Present and fences keep running at 5.5 ms throughout, on NO_ERROR from
glGetError and a clean glGetGraphicsResetStatus across 1,814 samples. Five
mechanisms are falsified and four independent instrument faults have turned up,
all of them below the API, all on one driver on one GPU.

That is not a shape any further GL-side bisect is well placed to resolve, so the
document now states the decision rule rather than leaving option (C) as a
recommendation. If the identical RHI world path renders correctly on Vulkan on
this GPU, the driver defect is proven and option (B) is adopted deliberately:
GL keeps the legacy world path through V10 as a documented, scoped exception to
§3.1's no-fork rule, confined to the thin submission seam. If it fails on Vulkan
too, the trigger is in code we own and the hunt resumes against a much smaller
haystack.

Two knock-on edits keep the plan self-consistent rather than leaving the reorder
stated in one place and contradicted in another. The slice table marks V4c and
V4d parked, V4t and V4e-V4h re-sequenced pending the verdict, and V5 shipped.
§5.4's sequencing invariants no longer claim V0→V4h is strictly sequential, and
they now carry the consequence that matters: V6 arrives before V4g and V4h, so
the Vulkan backend honours the contract's literal null target while GL still
carries the transitional inheritance. That makes §5.4's two removal obligations
more binding, not less, and V7's differential must not run until the removal has
happened - otherwise it would surface the divergence as a viewport rendering to
the wrong surface, which post-decision is indistinguishable from the fork option
(B) permits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 07:14:41 +02:00
Erik
e8a4c1af3f feat(render): Campaign V slice V5 - Vulkan bring-up, dark
Instance, physical-device selection, logical device, queues, swapchain, and the
three-layer capability gate, behind ACDREAM_RENDER_BACKEND=vulkan. Nothing of
the game renders through it. OpenGL stays the default and the only live backend
until V10, and with the variable unset or set to gl the GL path executes not one
new statement.

The shape of the slice. Plan §4.11 asks the Vulkan gate to mirror the GL one
exactly - passive record, active probes, an Evaluate producing operator-facing
sentences, NotSupportedException into Program.cs's exit-code-4 contract, and an
atomic JSON report. The harder question was where to put the seam, because a
capability gate is precisely the code you cannot exercise on the machine that
already passes it: this box has one discrete GPU, so device ranking, the split-
queue path, an sRGB-only surface, a minimised window and a device missing
descriptorBindingVariableDescriptorCount are all unreachable by running the
client. So every decision the gate makes is a pure function over plain records,
and the Silk interop layer only has to be right about which Vulkan field feeds
which property. VulkanPhysicalDeviceSelection ranks candidates,
VulkanExtensionSelection does the required-versus-optional set arithmetic,
VulkanSwapchainConfigurationFactory chooses format, present mode, image count,
extent, usage, transform and composite alpha, VulkanSwapchainRecreationPolicy
classifies every acquire and present result, and
VulkanCapabilityRequirements.Evaluate turns a captured record into failure
sentences. All of it is unit-tested with no driver, no device and no window.

This commit is the integration of that work onto the post-revert tree. The V5
branch was written on b064668b, before V4c/V4d were reverted, so GameWindow.cs
had to be merged rather than taken: the file here is eb2ba4e5's GameWindow plus
V5's fifteen-line backend branch, and it keeps _terrainModernShader, which the
revert restored and which the V5 branch never had. Every other file is byte-
identical to the branch - git diff e1ef4313 over Rendering/Gpu/Vk,
tests/.../Gpu/Vk and RenderBackendKind.cs is empty, no BOM was introduced, and
CRLF is uniform across all seventeen files.

Gate results, recorded verbatim.

Release build: succeeded, 0 warnings, 0 errors.

App tests, Release: Failed 0, Passed 3981, Skipped 3, Total 3984 - the 3,866
baseline plus V5's 115 new tests, exactly.

Offline pixel gate against eb2ba4e5: PASS world-offline.png, differing fraction
1.06534090909091E-05, which is 6 differing pixels out of the 563,200 compared
after the top 280 sky rows are masked. §5.1's re-measured same-commit control
band is 15-23 pixels at fraction <= 4.1e-05, so this sits below the noise floor
rather than merely inside it - the expected result for a slice that adds no
statement to the GL path.

Vulkan check (a), ACDREAM_RENDER_BACKEND=vulkan on the RX 9070 XT with an
automation artifact directory:

  vulkan: capability gate passed (Windows, AMD Radeon RX 9070 XT, Vulkan
  1.4.349, vendor 0x1002, device 0x7550, driver 2.0.395 (raw 0x0080018B));
  swapchain B8G8R8A8Unorm/PresentModeImmediateKhr 1280x720 x3
  vulkan: device selection - automatic: 'AMD Radeon RX 9070 XT' (DiscreteGpu,
  15.92 GiB device-local) ranked first of 2 enumerated device(s).
  [world-gate] screenshot-complete name=vulkan-bringup path=...
  artifacts\vk-bringup\vulkan-bringup.png size=1280x720
  vulkan: presented 64609 clear-colour frame(s); shutting down.

Exit code 0 on CloseMainWindow. The PNG is 5,238 bytes, 1280x720, and uniformly
RGBA(11,19,39,255) - exactly ClearColor [0.043, 0.075, 0.153, 1] scaled to
UNORM. Orientation is right-side-up by construction rather than by inspection,
which a uniform clear could not show: VulkanBackbufferSwizzle.ToGlOriginRgba
writes source row y into destination row height-1-y precisely because
FrameScreenshotController flips again on the way to the PNG, so the two
cancel. That double-flip is unit-tested.

Vulkan check (b), ACDREAM_VULKAN_FORCE_UNSUPPORTED=timelineSemaphore:

  [ERR] acdream's Vulkan renderer is unsupported by the selected device.
  Platform: win-x64, Windows, AMD Radeon RX 9070 XT (DiscreteGpu), Vulkan
  1.4.349, vendor 0x1002, device 0x7550, driver 2.0.395 (raw 0x0080018B)
   - timelineSemaphore is required; the frame serial is the semaphore value.
  Full capability report: ...\diagnostics\graphical-capabilities-vulkan.json

Exit code 4. The report records ForcedUnsupportedFeature timelineSemaphore,
TimelineSemaphore false against an otherwise complete feature set, and the
matching SupportFailures sentence, so the injected rejection is distinguishable
from a genuinely absent feature. Both enumerated devices, all five surface
formats, all four present modes and a clean FunctionProbe with no failures are
recorded beside it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 07:11:50 +02:00
Erik
eb2ba4e5f0 docs(render): the occlusion-query verdict on the V4c blank world, and the options
Runs the instrument section 5.5.2 asked for, on a V4c tree staged from
`git revert --no-commit 543bc79f` and never committed: GL_SAMPLES_PASSED around
the raw-GL terrain draw, the dispatcher's entity draws, and the retained-UI
flush, collected outside the frame that issued them, with the desktop witness as
the verdict. All probe code is stripped; what survives here is the two gate
scripts and section 5.5.3/5.5.4.

Building it found a fourth instrument fault. Reading a query result on the CPU
timeline - glGetQueryObject guarded by RESULT_AVAILABLE, one frame late -
deadlocks V4c at the first frame that draws the world: 4/4 runs, and five
dotnet-stack samples four seconds apart all show the render thread inside the
driver in that call. Not a probe defect - the same probe ran 4,420 clean frames
on the V4c parent, and instrumenting only the UI flush reproduces the wedge while
creating the query objects and never beginning one does not.

Routing the result into a persistently-mapped GL_QUERY_BUFFER instead - the
driver writes it on the GPU timeline, so no client wait is possible, and a
sentinel separates "reported zero" from "never reached" - does not wedge, and
gives the answer. On blank runs no query result is ever produced at any site for
the whole run, including the UI, in the same frames where the desktop grab plainly
shows the UI on screen. On the rendered run of the same binary, 1,068 frames, not
one missing result.

So the mission's fork resolves to "never completes", but not as a stall: frame
time holds at 5.5 ms for ~3,700 frames, the frame-flight fences keep retiring,
and present keeps working. Every channel that carries a result back from the GPU
is dead - pixel readback, CPU query read, GPU-timeline query write - and every
channel that carries none is fine. The transition is one sharp event at the first
world frame and never reverses, and that frame rasterizes correctly: 1,692,830
terrain and 317,561 entity samples, the same two numbers the parent reports for
its own first world frame.

Section 5.5.4 lays out the three options with their costs and recommends (C):
bring Vulkan up first and decide V4c afterwards, because running the identical
ported world path on the Vulkan backend on this GPU is both the cheapest test of
the driver-defect reading and work the campaign owes anyway. (B), accepting the
GL-side fork, is probably the right conclusion but should be adopted on a
measurement rather than an inference. No fix was attempted and V4c is not
re-landed.

Apparatus: run-repeat-connected-gate.ps1 and run-blank-world-ab-probe.ps1 now
assert on the desktop grab and record the client's own capture as a second
column, which is the re-arming section 5.5.2 required before re-land condition 2
can mean anything. Both verified end-to-end.

Gates: Release build clean; App tests 3,866 passed / 3 skipped; offline pixel
gate PASS at 3.37e-05 differing fraction (19 px of 563,200), inside the
documented 15-23 px band.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 01:44:34 +02:00