Commit graph

4 commits

Author SHA1 Message Date
Erik
777f60708d ci(render): make V9's first CI run green on both operating systems
The lavapipe job did the thing it was built to do on its first attempt.
It accepted a Cpu device at API 1.4, created a device and read pixels
back, captured a real frame, and exited 4 when a feature was forced
unsupported. Three other things were red, and none of them were the
Vulkan backend.

The shader-freshness step aborted for two separate Linux faults in the
compiler tool. Disposing the Silk.NET API container unloads the native
module, and dlclose-ing libshaderc_shared.so leaves glslang's
process-level teardown running against unmapped code. Bisected with a
four-mode probe on Ubuntu 24.04: GetApi, CompilerInitialize and
CompilerRelease each exit 0, and adding only the container Dispose turns
the exit into SIGSEGV. That is the 134 CI reported. shaderc's own handles
are still released; the container is not, because the module's lifetime
is the process's and the process is one statement from returning.
Separately, a portable dotnet build leaves the native under
runtimes/linux-x64/native/ and makes reaching it Silk.NET's probing
problem, which it solved on a local Ubuntu 24.04 and did not solve on the
runner. The script now publishes the tool for the host RID, so the native
sits beside the assembly where AppContext.BaseDirectory finds it, and
checks for it by name so a regression says which file is missing rather
than which names failed.

With both fixed, the question section 5.5.20 left open has an answer:
Linux shaderc and Windows shaderc agree byte-for-byte at the pinned Silk
2.23.0. Eighteen of eighteen .spv identical, manifest identical. The byte
comparison stays a byte comparison.

The Windows leg of portable-headless was running sudo apt-get. That step
is older than this campaign - it is red in the 2026-07-27 main run too -
and it was misplaced rather than mis-conditioned. Nothing in that job
opens a display or links GL, and the graphical jobs that do call xvfb-run
take it from the runner image, so the step is deleted rather than
guarded. Every remaining step in the two-operating-system matrix is pwsh;
every bash step now lives in an ubuntu-only job.

The last failure was ours in a quieter way. WaitForCharacterLogOff-
Confirmation expressed its deadline only as a CancellationTokenSource,
whose timeout is published from a thread-pool timer callback, so on a
saturated pool the token stays unsignalled past the deadline while the
loop keeps draining items that are already queued. That is the case the
method exists to bound. Reproduced by pinning the suite to two CPUs on
Linux, which failed 2 of 6 where four CPUs and sixteen were clean, and
where CI failed 3 of 3. The drain now reads the deadline off the
monotonic clock as well; the token still bounds the asynchronous wait.
Ten of ten clean under the same pin. The test is untouched. Filed as

Release build green. App tests 4,152 / 3 skipped against the same 4,152 /
3 measured at base 32f9bcfa. Core.Net 600 / 600.

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

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

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

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

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

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

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

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

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

Three things had to go.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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