Commit graph

10 commits

Author SHA1 Message Date
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
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
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
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
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
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
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