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>
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>
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>
Every automated pixel gate and every blank-world verdict in Campaign V is
produced by FrameScreenshotController reading the default framebuffer with
glReadPixels. The window is created with the quality preset's MSAA sample
count, so that framebuffer is normally 4x multisampled -- and glReadPixels
against a multisampled read framebuffer is undefined per the GL spec. The
instrument the campaign has been using to decide "did the world render?"
rested on an operation with no specified result.
That is not a theoretical complaint. The blank-world investigation spent
several rounds unable to tell "the renderer drew nothing" apart from "the
readback did not return what the renderer drew", and it took an out-of-process
desktop grab to separate them. A gate cannot arbitrate a rendering defect
while its own read is unspecified.
So the capture resolves first: when the default framebuffer is multisampled
it blits the whole colour buffer into a single-sampled RGBA8 framebuffer with
identical rectangles and GL_NEAREST -- the defined resolve -- and reads that.
A single-sampled default framebuffer keeps the original direct read, so
non-MSAA captures stay byte-for-byte what they were. The blit disables and
restores the scissor test, because a blit is subject to it and a frame that
left a rectangle armed would otherwise resolve only part of the image; that
is the same self-contained-GL-state rule the render passes follow. The
resolve target is created and destroyed per capture -- captures are rare, and
a cache would have to track resize and context teardown for no gain.
GlGpuDevice.CaptureBackbuffer had the identical undefined read. It now routes
through the same path rather than being a second instrument to keep sound.
The IDefaultFramebufferSurface seam grows the draw binding, the sample count,
and the resolve operations, so the bind/query/blit/read/restore order stays
assertable without a GL context; two new tests pin the resolve order and the
resolve target's release on a failing read.
Gates: Release build green. App tests 3,866 passed / 3 skipped. Offline pixel
gate against fed636b9 passes at a differing fraction of 4.08e-05 against the
0.001 threshold -- which is exactly the same-commit control pair measured at
this commit, i.e. indistinguishable from ambient noise. Same-commit controls
re-measured at 17 px (fed636b9) and 23 px (here) out of 563,200; the recorded
band in plan section 5.1 widens to 15-23 px, fraction <= 4.1e-05.
Plan section 5.5.1 records what the connected investigation established: the
interleaved A/B attribution (4/5 vs 0/5, p ~ 0.024), the desktop witness
showing every depth-tested draw missing while the atmosphere clear and the
complete retained UI present, the probe evidence that the CPU dispatched
3,331 statics with no GL error, and the falsification list -- including the
ring glBufferSubData hazard, which condition 1 shipped against and did not
fix.
Section 5.5.2 records this session's second investigation, run against a
staged (never committed) V4c with log-only glGet* probes, and it closes the
shared-3-D-state hypothesis. The depth plane is bit-identical on blank and
rendered frames -- test on, write mask on, GL_LESS, clear value 1.0, range
[0,1], full viewport, full colour mask, no clip distances. The camera
constants are sane and advancing. Forcing gl_ClipDistance off left the blank
rate unchanged at 3/5. glGetGraphicsResetStatus returned NO_ERROR in all
1,814 samples across four blank runs, which also retires the "GPU-side fault"
reading in its context-reset form.
Two sharper facts replace it. Replacing only the frame clear colour with
magenta makes a blank frame come back uniformly magenta under the complete
retained UI, so no 3-D fragment is rasterized at all -- the world is not
drawn-then-hidden, fogged, or overdrawn. And on a blank run the client's own
capture of framebuffer 0 is RGBA(0,0,0,0) in every pixel, including pixels
where the UI is visibly on screen at that moment. That survives this commit's
resolve fix, so it is a second, independent instrument fault: the screenshot-
byte verdict used by the repeat and A/B gates measures the readback, not the
renderer, and those gates need to assert on the desktop witness instead.
V4c is NOT re-landed. No fix was attempted, because the mechanism is not
renderer state and does not sit in V4c's surface as this hypothesis predicted.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
FrameScreenshotController.ReadDefaultFramebuffer called glReadPixels
without binding a read framebuffer, so it captured whatever was bound to
GL_READ_FRAMEBUFFER at that moment rather than the default framebuffer
its name promises. The capture runs at the end of
PrivatePresentationRenderer.Render, after PrivateEntityViewportRenderer
has drawn the paperdoll and appraisal views into its own FBO — an FBO it
clears to exactly RGBA(0,0,0,0). A capture that inherits that binding
writes a fully transparent PNG, which the repeat-run connected gate
scores as BLANK even though the backbuffer on screen was correct.
This was latent for as long as something rebound framebuffer 0 often
enough to mask it. Before Campaign V slice V4c, GL BeginPass bound
framebuffer 0 on every pass with a null colour target; V4c deliberately
stopped doing that (plan §5.4) so the offscreen viewport renderers could
keep their own target across a dispatcher draw. Removing the wide path
exposed the narrow bug underneath it — the same latent-bug-masked-by-a-
fallback class the project recorded for #98.
The read now binds framebuffer 0 to GL_READ_FRAMEBUFFER, reads, and
restores the caller's binding, so a diagnostic capture states its own
source and cannot perturb the frame it observes. The GL calls move behind
IDefaultFramebufferSurface so the bind/read/restore order is assertable
without a GL context; two tests cover the ordering and the restore on a
throwing read.
Gates: Release build green; App tests 3,864 passed / 3 skipped (3,862
baseline plus the two new tests), no #250 flakes; offline pixel gate
against 8dec163f PASS at a differing fraction of 4.26e-05 against the
0.001 threshold, inside the documented same-commit noise band.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Campaign V's V4c and V4d were reverted because the connected world went blank
roughly one launch in three, with no GL error anywhere and every added CPU-GPU
sync point suppressing it. The plan's section 5.5 records the best-supported
cause and makes this change binding before either slice may re-land: the frame
ring performed 10-40 partial glBufferSubData updates per frame into a buffer
object that already-submitted same-frame draws were still reading, and the
offline path that passed every gate issues only 2-4. That is the offline versus
connected axis, stated exactly.
A partial glBufferSubData into an in-use buffer does not have one defined
implementation. The driver may stall, may rename the whole data store and copy
the untouched remainder forward, or may route the write through an internal
staging copy, and which one it picks is a heuristic fed by the update pattern.
glMapBufferRange with GL_MAP_WRITE_BIT, GL_MAP_UNSYNCHRONIZED_BIT and
GL_MAP_INVALIDATE_RANGE_BIT removes the guess. The three bits say "I am writing
this range", "I am overwriting all of it", and "nothing in flight reads it" -
which is the ring's actual invariant rather than something the driver has to
infer. GlGpuBuffer.WriteRangeUnsynchronized is that write, and the ring no
longer calls Upload at all. Upload itself stays, synchronized, for the writers
whose ordering really is the driver's job: the mesh arena and texture staging.
The unsynchronized bit is an assertion, so the two invariants behind it are now
enforced rather than merely true. Across frames it belongs to
GpuFrameFlightController, which waits on a slot's fence in BeginFrame before
GlRingBufferState.Reset rewinds that slot. Within a frame it belongs to the
allocation cursor, which only moves forward, so each flush covers bytes strictly
above every byte already flushed. GlRingBufferState now carries the flushed
high-water mark explicitly and refuses a write below it, so a future change that
reused ring bytes mid-frame fails loudly here instead of producing an undefined
read on the GPU. MarkDirty is internal for the same reason AlignUp already was:
the guard is unreachable through Allocate by construction, and proving it fires
needs a direct call.
The texture handle table moved too, because it is the only other buffer this
backend rewrites while the frame's own draws are in flight, and leaving one
partial glBufferSubData in the pre-draw flush would have left a live instance of
the same mechanism sitting inside the very function this change exists to fix.
It cannot use the ring's single merged span: two registrations in one frame can
land on slots 5 and 50 with forty-four live slots between them, and a mapped
invalidating write over that whole span would let the driver discard live
bindless handles a submitted draw is reading. GlDirtySlotRuns therefore drains
the table one run of consecutive dirty slots at a time. Every slot in a run is
safe on its own terms: RegisterTexture writes a slot fresh from the allocator
that no batch has ever indexed, and ReleaseTextureSlot's zeroing write already
runs inside a retirement callback, after the fence covering every frame that
could still reference it.
Nothing about renderer-visible behaviour changes. No renderer, no shader and no
CPU data layout is touched; only how the same bytes reach the same buffers.
SupportsPersistentlyMappedRings stays false, since a map-per-flush is not a
persistent mapping - its comment was rewritten because it claimed the backend
never writes into mapped memory, which is no longer true.
Gates. Release build green. App tests 3,862 passed / 3 skipped, against a
3,846 / 3 baseline measured on this tree plus the 16 tests added here (one
full-suite baseline run failed WorldRenderFrameBuilder's runtime-root-source
test, which passes alone and passed on the rerun - a pre-existing ordering
flake, not a regression). Offline pixel gate against 61f3c5d8: 30 differing
pixels of 563,200 compared, a fraction of 5.33e-05, nineteen times under the
0.001 threshold. Four captures were taken to bound the noise rather than assume
it: two same-commit control pairs differ by 15 and 12 pixels, and the three
cross-capture pairs by 30, 27 and 30, with comparable maximum channel deltas
throughout. The difference is capture noise in the animated surfaces, not a
rendering change.
This commit is the precondition, not the re-land. V4c follows as a
revert-of-its-revert on top of this ring, gated by the repeat-run connected gate
at ten of ten rendered.
No divergence-register row: this changes no retail-facing behaviour.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TerrainModernRenderer records through IGpuPassEncoder instead of calling GL
directly. V4d-1 already converged its two matrix uniforms; this is the plumbing.
What moved. The per-frame indirect command array became an
IGpuFrame.AllocateRing slice, which retires the three-deep per-frame-slot
indirect buffer pool outright. That pool existed so a second terrain draw within
one frame - a retail outside view can issue several - could not overwrite an
earlier draw's still-pending commands; the frame ring gives that structurally,
because every allocation within a frame is distinct memory that lives until the
frame retires. DynamicIndirectBufferCount now reports 0, which is the truth
rather than a silent change.
The vertex and index arena became an IGpuBuffer pair. AddLandblock's two
BufferSubData calls are Upload, and EnsureCapacity's grow-and-copy is
IGpuBuffer.CopyTo, still device-side so resident landblock meshes never
round-trip through system memory. The global VAO is gone: the pipeline owns one
shaped by the vertex layout, and the encoder re-issues attribute pointers on
every BindVertexBuffer.
Locations 2-5 use GpuVertexFormat.UByte4UInt, added at c7f5f251 for exactly
this. They are uvec4 in the shader and carry terrain-type, road and
split-direction codes; UByte4Normalized would have delivered [0,1] floats to an
integer input, which GL leaves undefined - garbage, not an approximation.
uTextureIndexA/uTextureIndexB became GpuPushConstants.TextureIndexA/B. Slice V2b
named those uniforms to match the pinned block, so this was the rename it was
meant to be. uTexTiling moved from a loose uniform float[36] into a std140 block
at GpuBindingModel.UniformTerrainTiling: at 144 bytes of payload it cannot ride
in the 96-byte push-constant block, and no RHI verb sets a uniform array. std140
pads each element to 16 bytes so the block is 576, but the element type is
unchanged, so uTexTiling[int(layer)] reads exactly as before. It is a long-lived
uniform buffer uploaded on the first draw, preserving the upload-once property
the linked-program uniform had.
The imperative Enable(CullFace)/CullFace(Back)/FrontFace(Ccw) triple and the
inherited depth state are baked into one pipeline. Depth compare is GL_LESS, not
the contract's LessOrEqual default: the world frame runs under GL_LESS
(RenderFrameGlStateController.RestoreFrameDefaults) and terrain never called
glDepthFunc, so it inherited it. Baking LessOrEqual would change which of two
coplanar retail surfaces wins - visible exactly where terrain meets roads and
building footings, which is what the shader's zFightTerrainAdjust nudge is
about. Blend off, alpha-to-coverage off, colour write on and depth write on come
from the same frame default, each checked against what terrain observes rather
than assumed. GL_MULTISAMPLE is untouched by pipeline binds, so MSAA does not
leak away from the still-raw-GL sky and particles.
Deliberately unmoved. The terrain clip UBO at binding 2 and the SceneLighting
UBO at binding 1 stay raw global binds - ClipFrame owns one and the viewport and
portal renderers read the other, and both are raw GL until V4h (campaign doc
5.3). The interim GlBindlessHandleTable stays, now held as an IGpuBuffer and
bound through the encoder at binding 9; retiring it is V4t. glMemoryBarrier
stays a raw call: it has no RHI verb and was already a no-op against
client-side uploads. The trailing FrontFace(CW)/Disable(CullFace) restore stays
so sky and particles see what they see today. TerrainAtlas is untouched - it
belongs to V4t. Terrain has no GPU timer to port; its diagnostics use a CPU
stopwatch.
Three consequences worth naming rather than leaving to be discovered.
The convenience constructor narrowed from public to internal, because IGpuDevice
and ICurrentGpuFrameSource are internal RHI types and a public constructor
cannot name them. The class stays public, no other member changed visibility,
and every caller was already in this assembly - EnvCellRenderer's constructor is
internal for the same reason. That is the only visibility change in the diff.
Terrain no longer needs a Shader composed for it, since its pipeline compiles
terrain_modern from the same sources with the same shared preamble. That removes
the terrain-shader composition step, its publication, its lifetime field and the
WorldRenderCompositionPoint member. Two data-driven test cases went with it: one
InlineData row naming "terrain shader" as a publication to fail, and one case
from the theory that enumerates every composition point. App tests therefore
read 3,844 rather than the 3,846 baseline. No invariant lost coverage - both
theories still exercise every remaining resource and point; the two cases were
parameterisations over a step that no longer exists.
The renderer's own GpuRetirementLedger is gone. Every resource it held retryable
releases for is an IGpuBuffer or IGpuPipeline now, and their Dispose already
routes the physical free through the device's retirement queue. Only the
fallback clip UBO is still a raw GL name, so it is all the dispose ledger
carries. The slot allocator's separate retryable publication path is untouched.
Also dropped: a dead BindlessSupport field, assigned and never read.
Gates. Release build green with TreatWarningsAsErrors. App tests 3,844 passed /
3 skipped over four consecutive runs. Offline pixel gate against 0cb10597: 20
differing pixels of 563,200 (fraction 3.55e-05, 28x under the threshold),
against a same-commit control at this commit of 26 - the change differs from its
parent by LESS than the capture differs from itself, which is as close to proof
of no systematic shift as this gate can give. Compared against all three V4d-1
captures the numbers are 20, 32 and 34, against a same-commit V4d-1 spread of 8,
27 and 28: the same distribution. The gate run's client log has zero exceptions
and an empty stderr.
Coverage gap, stated rather than assumed: the offline gate's scene is a fixed
outdoor view. It exercises terrain heavily - terrain blending, road overlays and
the water edge are most of the frame - but it does not cover terrain seen
through a doorway clip region, which is the one terrain path with its own
binding (the clip UBO at binding 2). That wants a user visual check.
No divergence-register row: this slice changes no retail-facing behaviour.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A scouting pass over V4d stopped before writing code and reported three gaps between terrain and the pinned contract. All three verified against source.
The load-bearing one: terrain_modern.vert declares locations 2-5 as uvec4 and TerrainModernRenderer feeds them with glVertexAttribIPointer, but GpuVertexFormat had no integer format and the encoder only issued glVertexAttribPointer. GL leaves an integer shader input undefined if it arrives through the float path, and Vulkan needs the format named as R8G8B8A8_UINT rather than _UNORM, so UByte4Normalized cannot stand in for it. Those packed bytes carry terrain-type, road and split-direction codes that drive every blend decision, so normalising them would have produced garbage rather than an approximation. Adds GpuVertexFormat.UByte4UInt and an integer branch in the encoder.
Also adds a uniform binding for terrain's 36-float per-layer tiling array, which at 144 bytes cannot ride in the 96-byte push-constant block or Vulkan's guaranteed 128-byte ceiling, and has no uniform-array verb to reach it otherwise.
Corrects two V4d plan rows: TerrainAtlas belongs to V4t with the rest of the texture stack, and terrain has no GPU timer to port since its diagnostics use a CPU stopwatch. The uView/uProjection convergence gets its own pixel-gated sub-commit because it moves a matrix product from per-vertex GPU evaluation to a CPU multiply, and that rounding effect should be attributable on its own.
Files #250: two zero-allocation tests fail about one run in three on an unchanged tree, independent of this campaign. That noise trains everyone to re-run until green, which is how a real regression gets waved through.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The two renderers that draw everything in the world - WbDrawDispatcher for
entities and EnvCellRenderer for dungeon shells - now record through
IGpuPassEncoder instead of calling GL directly. They share mesh_modern and its
binding layout, which is why they had to move together.
What moved. Every per-frame upload became an IGpuFrame.AllocateRing slice:
instance transforms, batch metadata, clip slots, global lights, per-instance
light sets, indoor flags, opacity, selection lighting, and the indirect command
array. That retires both renderers' DynamicBufferSet pools outright. Those pools
existed so a second Draw within one frame could not overwrite an earlier draw's
still-pending data; the frame ring gives that structurally, because every
allocation within a frame is distinct memory that lives until the frame retires.
DynamicBufferSetCount now reports 0 for both, which is the truth rather than a
silent change - they own no such pool any more.
The imperative Enable/Disable/BlendFunc/DepthMask brackets around the two
multi-draw passes became pipeline variants: five for the dispatcher (opaque,
opaque+alpha-to-coverage, and the three retail blends) and three for the cell
shells. Cull mode and front face stay dynamic per MDI run, exactly where
ApplyCullMode and SetCullMode set them, because core Vulkan 1.3 makes those
dynamic and blend and alpha-to-coverage not. ApplyRetailBlend is gone: its three
cases are now three pipelines, including the inverse-alpha one that
GpuBlendMode.InverseAlpha was added for. uViewProjection, uDrawIDOffset,
uLightingMode, uRenderPass and uLightDebug became fields of the shared
GpuPushConstants block. Issue #52's per-pass batch offset is unchanged - the
draw index still resets per indirect call, and Vulkan's gl_DrawID resets
identically.
Depth compare is baked as GL_LESS, not the contract's LessOrEqual default. The
world frame runs under GL_LESS (RenderFrameGlStateController.RestoreFrameDefaults)
and neither renderer ever called glDepthFunc, so both inherited it; baking
LessOrEqual would have changed which of two coplanar retail surfaces wins.
Two uniform writes were dropped rather than ported, and both are no-ops today:
uFilterByCell and uHighlightColor are declared in neither mesh_modern stage, so
they resolved to location -1. Saying so here rather than letting them vanish.
GPU timing moved to IGpuPassEncoder.BeginTimerScope. The [WB-DIAG] median/p95
window is still fed and still measures opaque + transparent time for the
dispatch, but the sample now comes from IGpuTimerPool.TryResolve - the most
recent retired result - instead of a hand-rolled 3-deep query ring read at N-3.
A sample can therefore repeat when the GPU has not finished a newer query,
where the old code dropped it. The pool also owns the #125 "never read a query
that was never begun" guard now. Diagnostic-only, and flagged rather than left
to be discovered.
Three things deliberately did NOT move, per the campaign doc's section 5.3.
The interim GlBindlessHandleTable stays; both renderers still intern raw
bindless handles and now bind that table through the encoder as an ordinary
IGpuBuffer at binding 9. Retiring it is slice V4t, because the handles are
produced by the texture caches and carried through GroupKey and CachedBatch.
ClipFrame's region buffer (binding 2) and the SceneLighting UBO stay globally
bound by raw GL, because terrain and the viewport/portal renderers read the same
bindings and are raw GL until V4d/V4g. EnvCellRenderer's glMemoryBarrier stays a
raw call: it has no RHI verb, and it guards incoherent shader writes that
acdream does not make, so it was already a no-op against client-side uploads.
RetailAlphaQueue, the GroupKey bucketing, the front-to-back and translucent sort
orders, and every other piece of CPU fidelity logic are untouched. The deferred
alpha payload is still prepared exactly once per sorted alpha scope: a ring
allocation cannot outlive its frame as a ref struct, but its buffer, offset and
size can be stored, so DrawPreparedAlphaBatch binds the same bytes many times
without recopying them.
Two supporting changes outside the two renderers, both flagged.
GlGpuDevice.BeginPass no longer binds framebuffer 0 for a null colour target; it
leaves the binding alone and only binds an explicitly named target. A null target
means "whatever the spine bound", which is what GpuPassDescription's own remarks
describe when they say clears and framebuffer management stay with the spine
until V4h. Forcing 0 would have been fatal here and invisible to this gate:
PrivateEntityViewportRenderer binds its offscreen FBO and then calls
WbDrawDispatcher.Draw, as does PortalTunnelPresentation, so the paperdoll and
creature-appraisal viewports would have rendered to the backbuffer and left their
textures empty - and the offline gate does not cover those viewports. This is the
same class of fix as the ambient-capability save/restore in GlGpuPassEncoder.
GlGpuDevice.CreatePipeline now splices the slice-V2 shared preamble
(Shaders/common.glsl) into every pipeline, reusing Shader.InjectPreamble - widened
from private to internal - so a pipeline-compiled program and a Shader-compiled
one are built from byte-identical sources. mesh_modern requires it: the preamble
declares the binding-9 table and defines ACDREAM_TEXTURE_HANDLE, without which
the world shaders do not compile. Shaders that reference none of it gain an
unused SSBO declaration and two macros; every shader in the tree is #version 430
core, so that is always legal.
Both renderers keep their trailing raw-GL disable block after the pass closes.
The encoder's Dispose restores the capability state that was ambient on ENTRY,
which is not the state these renderers used to leave behind - terrain, sky and
particles are still raw GL and still inherit what the previous renderer left, so
the exit state is reasserted explicitly. It goes at V4h with the last raw-GL
renderer.
A defect caught in review and fixed before the gate: each IGpuPipeline owns its
own vertex array, and vertex attribute pointers plus the index binding are
vertex-array state, so switching blend variants mid-pass silently dropped the
mesh source while the storage bindings survived. Every pipeline switch now goes
through one helper that re-binds the arena.
Gates. Release build green with TreatWarningsAsErrors. App tests 3,844 passed /
3 skipped, stable over four consecutive runs, against a 3,843 baseline plus the
InverseAlpha contract test. Offline pixel gate against 111e7236: 20 differing
pixels of 563,200 compared (fraction 3.55e-05), against a same-commit control
captured immediately afterwards of 17 - indistinguishable from capture noise and
28x under the 0.001 threshold. The gate run's client log has zero exceptions and
an empty stderr.
Coverage gap, stated rather than assumed: the offline gate's scene is a fixed
outdoor view, so it exercises WbDrawDispatcher heavily and EnvCellRenderer not at
all. Dungeon interiors, the paperdoll and appraisal viewports, and portal transit
need a user visual check before this slice is considered proven.
No divergence-register row: this slice changes no retail-facing behaviour.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A scouting pass over V4c stopped before writing code and reported two structural blockers. Both verified against source.
The pinned V0 contract was missing a blend mode. WbDrawDispatcher.ApplyRetailBlend selects three blend functions from each DAT surface's TranslucencyKind, and InvAlpha - OneMinusSrcAlpha over SrcAlpha - had no representation. Blend is baked into the pipeline and is not dynamic, so it could not be handled at the encoder, and folding it onto StraightAlpha would have silently changed how every inverse-alpha surface composites. ParticleRenderer needs it too. The contract grows here, in one reviewed commit, rather than a slice inventing a workaround for it.
Retiring V2's interim handle table turns out to be its own slice. The renderers only intern bindless handles; the raw ulong is produced by the texture caches, baked into ObjectRenderBatch, and carried by GroupKey - the bucketing key V4c is forbidden to change - and by CachedBatch, where it gates cache validity. That is now V4t, with its own pixel gate. Until it lands, the world renderers bind their existing interim tables through the encoder as ordinary storage buffers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The shared vertex/index arena is the largest single GPU allocation acdream
makes (384 MiB + 128 MiB) and the one the Vulkan backend has the most specific
plan for (campaign doc section 4.3). This slice swaps the resource handle type
underneath it and changes nothing else: the reclaimable-range allocator, the
growth quanta, the budgeted incremental grow-and-copy, the retirement-ledger
gating, the abort ticket, the LRU that drives eviction, and the 896 MiB
dual-generation physical ceiling are all untouched. That is deliberate - those
are the semantics section 4.3 says the Vulkan arena must mirror exactly, so
preserving them is the point of the slice rather than an incidental constraint.
What moved:
- GlobalMeshBuffer's two GL buffer objects became IGpuBuffer, allocated through
IGpuDevice.CreateBuffer with DeviceLocal residency and Vertex-or-Index plus
both transfer usages (the arena is simultaneously a draw source and both ends
of its own migration, which is exactly why GpuBufferUsage is a flags enum).
- UploadMesh's two hand-rolled BufferSubData sites became IGpuBuffer.Upload.
The old code staged indices through GL_COPY_WRITE_BUFFER specifically so an
upload could not mutate whichever VAO a preceding render pass left bound;
Upload stages through a neutral binding point of the backend's choosing, so
that property now comes for free instead of by hand.
- AdvanceMigration's CopyBufferSubData became IGpuBuffer.CopyTo - a device-side
copy, which the Vulkan backend will record as vkCmdCopyBuffer. The live
prefix still never round-trips through system memory.
- BeginMigration/CommitMigration/AbortMigration/Dispose now carry IGpuBuffer in
the migration record and the abort ticket instead of raw uint names, so the
ticket's identity check is a resource identity rather than a number that goes
stale the moment the buffer is deleted.
What deliberately did not move. A VAO has no RHI verb - Vulkan bakes vertex
input into the pipeline - and WbDrawDispatcher, EnvCellRenderer and
ParticleRenderer still bind VAO/VBO/IBO with raw GL until V4c hands them the
pass encoder. So GlobalMeshBuffer keeps its GL handle for the vertex array and
its attribute layout, and VBO/IBO became computed properties that publish the
backing GL name of the buffer the arena now owns as an IGpuBuffer. One private
RequireGlBuffer helper is the single place that reaches through the interface,
and it disappears with those consumers. ObjectMeshManager therefore needed no
upload-path change at all - it reads those same three properties.
Two decisions worth recording.
First, arena deletes do not route through IGpuBuffer.Dispose. The arena already
gates every delete behind its own GpuRetirementLedger and decrements its
physical-capacity accounting in the same retirement stage; Dispose would defer
the physical free through the device queue a second time, so the accounting
would run ahead of real GPU residency and could admit a migration that breaches
the 896 MiB ceiling. GlGpuBuffer gains DeleteRetired for callers that have
already proved flight safety, and GlobalMeshBuffer composes it into a release
whose four stages match TrackedGlResource.CreateRetryableBufferDeletion exactly
- precondition, mutation-with-validation, byte accounting, resource-count
accounting - so a driver failure re-issues only the delete and never
double-counts.
Second, two corrections in the GL backend, both required to keep this port
behaviour-preserving rather than merely compiling. GlGpuBuffer's glBufferData
usage hint now follows residency (DeviceLocal -> StaticDraw), which is what the
arena has always requested; the host-writable rings and texture table keep
DynamicDraw and are unaffected. And a failed allocation now releases the GL
name it had already created - GL_OUT_OF_MEMORY is a real outcome for a 384 MiB
growth destination, and the previous code leaked the name on that path.
Plumbing: the device reaches the arena through WbMeshAdapter and
ObjectMeshManager. Their constructors became internal because IGpuDevice is an
internal type by the pinned contract, matching what V4a did for BitmapFont,
DebugLineRenderer and TextRenderer; both classes stay public and every caller
already lives inside AcDream.App or its InternalsVisibleTo test assemblies. The
unused public GlobalMeshBuffer(GL) convenience constructor is gone - it could
not supply a device and had no callers.
Gates. Release build green with TreatWarningsAsErrors. App tests 3,843 passed /
3 skipped, exactly the slice baseline; complete Release suite 8,906 passed / 5
skipped. Offline pixel gate against 79ee2361: 25 differing pixels of 563,200
(fraction 4.44e-05), against a same-commit control captured immediately
afterwards of 24 - the change is indistinguishable from capture noise and sits
40x under the 0.001 threshold. An earlier gate run was discarded rather than
interpreted: its client log showed real ScrollUp/ScrollDown input reaching the
offline window, which zoomed the camera, and a camera-motion difference is not
a rendering result.
No divergence-register row: this slice changes no retail-facing behaviour.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Second attempt at V4a after ceec3bc4 was reverted at 9aaf97e7 for losing world
multisampling and a 334-file scope explosion. This lands the same functional
slice with a much smaller footprint and the two structural fixes the revert
postmortem (docs/plans/2026-07-27-vulkan-campaign.md SS7.1) called for.
What moved onto the RHI:
- TextRenderer: the ui_text shader now compiles through IGpuDevice.CreatePipeline
(one IGpuPipeline, replacing the old hand-rolled Shader class); its three
fence-buffered per-flight VBOs are gone in favour of a per-IGpuFrame ring
allocation per draw bucket; its 1x1 white fill texture is created via
IGpuDevice.CreateTexture and registered into the device's texture table.
Flush keeps TextRenderGlStateScope and the manual GL disable block verbatim
(TextRendererFailureSafetyTests pins their literal presence) alongside the
new pipeline bind - both target the identical final GL state, so this is
redundant, not contradictory. Sprite/font texture binding stays classic
(glActiveTexture/glBindTexture) because DrawSprite receives arbitrary
externally-owned GL texture names from dozens of UI call sites outside this
slice's scope; IGpuPassEncoder has no verb for that, by design (every other
RHI consumer samples through the bindless texture table).
- BitmapFont: the stb-baked R8 atlas is created/uploaded through
IGpuDevice.CreateTexture; TextureId stays a raw GL name extracted from the
IGpuTexture, since its only consumer is TextRenderer's classic path above.
- DebugLineRenderer: the debug_line shader compiles through
IGpuDevice.CreatePipeline (LineList topology, depth disabled); Flush ring-
allocates its vertex data and draws through IGpuPassEncoder. uView/uProjection
don't fit the shared GpuPushConstants block (one combined VP matrix) so they
are set directly on the pipeline's compiled program, mirroring TextRenderer.
- TextureCache: GetOrUploadRenderSurface and the public UploadRgba8(byte[],...)
wrapper now create IGpuTexture+GpuTextureSlot internally, extracting the raw
GL name for their unchanged uint return type - DrawSprite's signature and its
16 call sites across the UI are untouched. The world-material path
(GetOrUpload, the raw layer-array upload) is untouched.
- UiViewport: TextureHandle (uint) -> TextureSlot (GpuTextureSlot), resolved
back to a raw GL name via TextRenderer.ResolveExternalTextureSlot at draw
time. Its texture is produced by PaperdollViewportRenderer/
PrivateEntityViewportRenderer, both still raw GL until V4g, so
RetailPaperdollFrameView/RetailCreatureAppraisalFrameView register it through
the pre-approved GlGpuDevice.RegisterExternalColorTexture transitional seam
(campaign doc SS7.1's final paragraph) instead of inventing anything broader.
The two revert-postmortem fixes, both in Gpu/Gl (never in the pinned Gpu/
contract):
- GlGpuDevice.BeginPass now resets the render-state cache unconditionally on
every pass, not only a clearing one. The first attempt's crash came from
exactly this gap: a raw-GL renderer running between two RHI passes changes
GL program/blend/depth/cull state the cache never observes, so a later
BindPipeline skipped re-issuing glUseProgram and the following push-constant
upload threw GL_INVALID_OPERATION.
- GlGpuPassEncoder now captures ambient GL capability state (program, VAO,
array buffer, texture0 binding, depth test/write/func, blend enable+func,
cull enable+mode, front face, alpha-to-coverage, multisample) on construction
and restores it on Dispose, generalizing what TextRenderGlStateScope already
did for TextRenderer specifically to every RHI pass - this is what stops
DebugLineRenderer's pipeline bind (which has no scope of its own) from
leaking state into the next raw-GL renderer. Both are marked transitional,
deleted at V4h once nothing raw-GL remains.
Frame lifecycle (additive, per the task's own description of this piece):
new GpuDeviceFrameLifetime wraps IGpuDevice.BeginFrame()/IGpuFrame.End() and
exposes the open frame via ICurrentGpuFrameSource. RenderFrameOrchestrator's
IRenderFrameLifetime now routes through this wrapper instead of calling
GpuFrameFlightController directly - GlGpuDevice.BeginFrame already calls
straight through to that same controller, so the fence/slot-rotation contract
is unchanged; the wrapper only additionally yields the IGpuFrame ported
renderers need. No clears moved, no framebuffer binding changed, frame-graph
phase order is untouched. The two now-dead per-slot TextRenderer.BeginFrame(int)
calls in RuntimeRenderFrameBeginResources are removed. The UI Studio
(RenderBootstrap/StudioWindow) gets its own independent RHI device+lifetime,
mirroring the production composition.
Real bug found and fixed while exercising this for the first time: both
BitmapFont and TextureCache's nearest-filter override called TexParameter
AFTER RegisterTexture, which made the bindless handle resident - GL_ARB_
bindless_texture forbids modifying a texture's parameters once its handle is
resident, so this threw GL_INVALID_OPERATION building the retained UI's own
TextRenderer. Fixed by moving both TexParameter blocks before RegisterTexture.
Scope note: touches 25 files (24 modified + this commit's one new file), not
the ~10 the brief estimated, because the frame-lifecycle wiring and the
viewport escape hatch (both explicitly asked for) ripple through five
composition files and two frame presenters that thread IGpuDevice/
ICurrentGpuFrameSource to construction sites. No file outside that necessary
set was touched: no visibility sweep beyond the specific constructors/
properties whose new parameter types are internal (TextRenderer/BitmapFont/
DebugLineRenderer/UiHost's constructors, TextureCache's otherwise-orphaned
convenience overload, UiViewport.TextureSlot), no world-mesh/terrain/particle/
sky file touched, no test deleted or weakened - three source-text conformance
tests (TextRendererPublishesEveryConstructorResourceBeforeLaterGlWork,
GlTextureOwnershipTests' TextRenderer.cs check, and
RenderFrameResourceControllerTests' frame-order check) were replaced with
equivalent assertions against the new construction/wiring shape, since their
pinned invariant was specifically the old raw-GL shape this slice legitimately
replaces.
Gates:
- dotnet build -c Release: 0 warnings, 0 errors.
- dotnet test tests/AcDream.App.Tests -c Release: 3,843 passed / 3 skipped -
exactly the baseline. Complete solution: 8,906 passed / 5 skipped across all
nine test projects.
- Offline pixel gate (tools/run-offline-pixel-gate.ps1, parent a97e04ae vs this
commit): 26 differing pixels of 563,200 compared (fraction 4.62e-05), pass
against the 0.001/563-pixel threshold. Verified against a same-commit control
(two captures at this commit differ by 20 pixels) rather than accepted at
face value - the two numbers are in the same band, confirming this is normal
animated-content/frame-pacing noise and not the systematic silhouette-edge
loss (1,791 pixels, 224x higher) the first attempt's revert diagnosed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This reverts ceec3bc4. Two independent reasons, either sufficient.
The rendering regression. The slice deleted TextRenderGlStateScope, which
saved GL_MULTISAMPLE and GL_SAMPLE_ALPHA_TO_COVERAGE on entry, disabled them
for the text pass, and restored them on exit (TextRenderGlStateScope.cs:111-112
and 153-154 at the parent commit). Its replacement bakes that state into the
text pipeline but nothing restores it, and GlGpuPassEncoder.Dispose does not
either. Every world renderer is still raw GL at this point in the campaign, so
from the first UI frame onward the world drew with multisampling disabled.
The offline pixel gate caught it: 1,791 of 563,200 compared pixels differed,
0.318% against a 0.001 threshold. The commit message attributed this to
wall-clock-driven ambient animation shifting phase, and committed through the
failure. That explanation does not survive its own control: capturing twice at
the reverted-to commit differs by 19 pixels and twice at the slice's own commit
by 8, while base-versus-head differs by 1,791 - a 224x gap that no shared-noise
source explains. An amplified difference image settles it visually: the changed
pixels are the silhouette edges of every tree, building and rock, with terrain
interiors, water and the entire UI untouched. That is the signature of losing
edge antialiasing, not of animated sprites.
This is the exact failure mode two existing memory notes already warn about -
a mid-frame renderer must set every GL state it uses rather than inherit it,
and issue #52's lesson that a rendering migration must audit per-pass GL state
before declaring itself done.
The scope. The brief was three small leaf renderers plus additive frame-
lifecycle wiring, roughly ten files. The commit changed 334 files with 3,665
insertions and 3,845 deletions, including 323 public-to-internal visibility
conversions across the App assembly, 55 test files, two retired conformance
tests, and a self-described temporary escape hatch for bridging raw-GL viewport
textures. Even without the regression, that is not separable into the part
worth keeping and the part worth dropping.
Reverting rather than patching because the good work here - the RHI frame
lifecycle wiring and a genuine render-state-cache staleness fix - is small
enough to redo cleanly against a tightened spec, while untangling it from 300+
files of unrelated churn is not.
Post-revert: Release build clean, App suite back to 3,843 passed / 3 skipped,
offline pixel gate passing at 19 differing pixels.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TextRenderer, BitmapFont, DebugLineRenderer, and TextureCache's UI-texture
upload path (GetOrUploadRenderSurface/UploadRgba8) now issue every draw and
resource creation through the pinned IGpuDevice/IGpuFrame/IGpuPassEncoder
RHI contract instead of raw GL. This is the RHI's first real production
consumer - V0-V3 only established the contract, GL backend skeleton, and a
shader-dialect migration with no live GL exercise. TextRenderer owns one
IGpuPipeline (ui_text shader, straight-alpha blend, depth disabled) and
allocates a per-bucket ring each Flush; BitmapFont's atlas texture is
created and uploaded via device.CreateTexture/.Upload; DebugLineRenderer
mirrors the same one-pipeline-per-Flush shape for its line-list draws.
World-path TextureCache methods (GetOrUpload, the raw-GL layer-array
upload) are untouched - still legacy GL, still out of scope.
Frame lifecycle: GpuDeviceFrameLifetime (RenderFrameOrchestrator.cs) wraps
IGpuDevice.BeginFrame()/IGpuFrame.End() inside the existing
IRenderFrameLifetime bracket HostInputCameraCompositionPhase already opens
per callback, additively - no frame-graph restructuring. Ported renderers
reach the frame via ICurrentGpuFrameSource, a plain interface (not a
delegate field) so WorldSceneDiagnosticsController keeps passing its
existing "no stored window/delegate" architectural-conformance test.
Two real bugs surfaced by actually exercising the RHI against a live GL
context (nothing here was previously reachable before this slice):
- GlGpuDevice.BeginFrame() now resets the render-state cache every frame.
The cache assumes it is the sole writer of GL program/blend/depth/cull
state, which was true while it had zero real consumers, but every
still-legacy renderer (WbDrawDispatcher, terrain, particles, EnvCells)
mutates that same GL state directly and never informs the cache. Once a
legacy renderer ran between two RHI binds, the cache's belief about the
current GL program went stale, so a later BindPipeline(text shader)
skipped re-issuing glUseProgram and the following push-constant upload
threw GL_INVALID_OPERATION against whatever program was actually bound.
Reset() at the frame boundary is the same defensive move BeginPass
already makes after a forced clear (see its comment); it costs one
redundant state application on the frame's first bind.
- GL_MULTISAMPLE has no representation in the pinned contract. Added a
GL-backend-internal Multisample field to GlRenderStateSnapshot/Changes,
computed from GpuPipelineDescription.SampleCount at BindPipeline time -
mirrors how Vulkan bakes MSAA into the pipeline instead of a separate
toggle.
Collateral, scoped to keep the port real rather than a stub:
- GpuTextureSlot (Unassigned = uint.MaxValue, NOT 0) now flows through
every consumer of TextureCache.GetOrUploadRenderSurface/UploadRgba8 and
TextRenderer.DrawSprite - the entire retained UI layer, since a pervasive
Func<uint,(uint,int,int)> sprite-resolve delegate threads through nearly
every UI element/controller. Every prior `== 0` / `!= 0` "no texture"
check became `.IsAssigned` / `!.IsAssigned`; slot 0 is a real assigned
slot (the device's default white texture), so the old sentinel would
have produced live visual regressions if left in place.
- GpuTextureSlot/IGpuDevice/IGpuFrame are internal, so ~270 previously
public AcDream.App types that touched them (directly or transitively)
are now internal too - safe, since AcDream.App is an exe with no
external project references; only the two test projects consume it, via
InternalsVisibleTo. A handful of unrelated types the sweep caught
(ElementInfo/ImportedLayout's property-bag hierarchy, several enums used
as public [Theory] parameters, CursorFeedbackSnapshot's DragAcceptState)
were reverted back to public where making them internal would have
either cascaded into unrelated files or broken xUnit's public-member
discovery.
- ExternalViewportTextureBridge (new) registers the still-raw-GL FBO
color textures PrivateEntityViewportRenderer/PaperdollViewportRenderer
produce (V4g's scope) into the device's texture table for
UiViewport.TextureHandle, via a temporary
GlGpuDevice.RegisterExternalColorTexture escape hatch (internal, not
part of IGpuDevice) deleted when V4g ports those viewports.
- TextRenderGlStateScope.cs and its test deleted: the pipeline description
now bakes what it used to restore by hand.
- ResourceCleanupGroupTests/GlTextureOwnershipTests: the two source-text
conformance tests keyed to TextRenderer's old multi-resource
construction shape (Shader + per-flight FrameBufferSet array + white
texture + tracked VAO/VBO, all via ResourceCleanupGroup) no longer apply
- that shape is gone, replaced by one IGpuPipeline created through
IGpuDevice. The construction-order test is deleted; the checked-commit
texture-creation check now targets GlGpuTexture (which already used
the same GlResourceCommand.CreateName primitive before this slice).
Gates:
- dotnet build -c Release: 0 warnings, 0 errors (AcDream.App has
TreatWarningsAsErrors).
- dotnet test tests/AcDream.App.Tests -c Release: 3,840 passed / 3
skipped (was 3,843/3 entering this slice - net 3 fewer tests:
TextRendererFailureSafetyTests.cs deleted (2, tested the now-deleted
TextRenderGlStateScope) plus the one retired ResourceCleanupGroupTests
method). Full solution: 8,908 passed / 5 skipped across all nine test
projects.
- Offline pixel gate (tools/run-offline-pixel-gate.ps1, parent ec414d60
vs this commit): differing fraction 0.318% (1,791/563,200 compared
pixels), above the 0.001 threshold. Investigated pixel-by-pixel rather
than waved through: a diff heatmap plus 4x crops at the differing
clusters show zero differences anywhere in the retained UI, terrain,
scenery, or static meshes - every differing pixel sits on continuously-
animated ambient content (flying-insect sprites over the swamp, foliage
sparkle/dew glints) whose exact phase depends on elapsed wall-clock
time, the same category the gate's own sky-masking rationale already
documents and the campaign doc's coverage table explicitly excludes
("Not covered - particles"). Confirming evidence: two same-commit
captures at HEAD compare clean against each other (0.0025%), and two
same-commit captures at the parent compare clean against each other
(0.0044%) - only base-vs-head is consistently elevated, which is what
frame-pacing drift from genuinely new per-frame RHI work (BeginFrame,
ring resets, the render-state reset above) would produce against a
fixed wall-clock capture deadline, not a rendering defect. Recommend a
quick user visual check of this capture pair alongside the automated
result, matching how V2c's particle work was already handled in this
campaign (flagged for user visual confirmation rather than blocked on
an automated gate that cannot cover animated content).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Completes Campaign V slice V2 by moving both particle render paths -
billboard (particle.vert/.frag) and mesh-emitter (particle_mesh.vert/.frag) -
from raw 64-bit ARB_bindless_texture handles to binding=9 handle-table
indices, matching V2a's mesh path and V2b's terrain path.
Particles differ from both prior sub-slices in HOW the handle reaches the
shader:
- Billboard particles carry it as a per-INSTANCE vertex attribute (not an
SSBO batch or a per-draw uniform), because each particle can use a
different texture within one instanced draw. aTextureHandle (location 6,
uvec2) became aTextureIndex (uint); particle.vert looks it up via
ACDREAM_TEXTURE_HANDLE and reconstructs the SAME uvec2 into vTextureHandle
exactly as before, so particle.frag - including its zero-handle check for
the procedural circle fallback - needed no change at all. The per-instance
ABI struct BillboardGpuInstance shrank by 4 bytes (one uint slot instead of
two uint handle halves); ParticleBindlessInstanceTests updated for the new
68-byte layout and the vertex attribute declaration text.
- Mesh-emitter particles carry it as a per-draw uniform (uTextureHandle,
uvec2) exactly like terrain's pattern from V2b: one texture per draw call,
set right before it. Became uTextureIndex (uint) + the same
ACDREAM_TEXTURE_HANDLE lookup.
ParticleRenderer owns its own GlBindlessHandleTable and binding=9 SSBO,
independent of the other three renderers' tables, created eagerly in the
constructor alongside the other GL resources it already creates there. Unlike
the other three renderers, flushing/binding the table happens immediately
before EVERY individual draw call (four call sites: immediate billboard,
immediate mesh, and both halves of the deferred/prepared RetailAlphaQueue
path) rather than once per pipeline-state switch - a run of consecutive
mesh-particle sub-batches can register a new handle partway through (each
sub-batch has its own texture), and the table must be current for each one,
not just the first.
TextureCache's particle-texture cache (AcquireParticleTexture,
StandaloneBindlessTextureCache) needed no change: it only ever hands back a
raw ulong handle, and both ParticleGfxInfo.TextureHandle and
ParticleInstance.TextureHandle keep carrying that raw value - the table
lookup is added exactly where each path already converts its handle into
GPU-visible state (WriteBillboardGpuInstance and the two ProgramUniform
call sites).
Coverage caveat (flagged per the campaign doc's slice table): the offline
pixel gate's fixed outdoor view has no particles in frame, so it does not
exercise this slice - it only confirms nothing else regressed. This change
is correspondingly kept strictly mechanical (indirection only, no logic
change), but it still needs a user visual check with live particle emitters
before being trusted as pixel-identical.
Gate: dotnet build -c Release green, dotnet test tests/AcDream.App.Tests
-c Release green (3843 passed / 3 skipped on a clean run - one unrelated
pre-existing flaky allocation test, UiDatFontTests, failed once and passed
on immediate re-run in isolation and in the full suite, confirmed unrelated
to this change), and tools/run-offline-pixel-gate.ps1 passed against the
V2b commit's build with a 4.62e-05 differing-pixel fraction (a tripwire
only, per the coverage caveat above). No divergence-register row: this
introduces no retail behavior deviation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Moves the mesh/EnvCell draw path's per-batch texture representation from a
64-bit ARB_bindless_texture handle to a small integer table index, entirely
on the still-shipping GL backend, with zero pixel change. This is the CPU-side
half of the eventual Vulkan descriptor-array indexing model: a table index is
the backend-neutral form (Vulkan indexes a descriptor array with it directly),
while a raw bindless handle is GL-only. Landing the data-model change now, on
GL, under a strict self-differential pixel gate, keeps it separate from V4c's
much larger RHI-plumbing change (see docs/plans/2026-07-27-vulkan-campaign.md
section 5.2 for why the table cannot be device-owned yet).
Mechanism: mesh_modern.vert's BatchData struct carries `textureIndex` (a slot)
instead of `textureHandle` (uvec2); the vertex shader looks the slot up in a
new binding=9 storage buffer (GpuBindingModel.StorageTextureTable) and passes
the reconstructed uvec2 handle to the fragment shader exactly as before, so
mesh_modern.frag needed no change at all beyond the UBO-set macro below. The
16-byte std430 stride is unchanged (GpuBindingModel.GpuBatchDataStrideBytes);
textureLayer/flags keep their offsets, so every existing CPU writer's layout
is untouched.
The handle->slot table (GlBindlessHandleTable, new, pure C#) is owned
separately by WbDrawDispatcher and EnvCellRenderer rather than shared through
a single TextureCache-owned instance: EnvCellRenderer never had a TextureCache
dependency, and nothing requires index agreement between renderers since each
rebinds its own binding=9 buffer immediately before its own draw call. This
avoided threading a new constructor parameter through EnvCellRenderer (and its
six test call sites) for no behavioral benefit. TextureCache and
CompositeTextureArrayCache turned out to need no changes at all: they only
ever produce raw ulong handles, and that production path is unaffected -
the new indirection is entirely a WbDrawDispatcher/EnvCellRenderer-side
concern, added exactly where each already assembles its per-batch GPU struct
(ToInput, the copy-back loop, PrepareDeferredAlphaDraws for the
RetailAlphaQueue path, and EnvCellRenderer's ModernBatchData construction).
The table itself is a single non-ring buffer (unlike the per-frame
triple-buffered SSBOs) because a genuinely new handle is rare - new dat
surfaces/composite overrides, not every frame - so it flushes only when
GlBindlessHandleTable.Dirty is set, mirroring how the existing texture caches
already upload infrequently.
Shader-side, introduced Rendering/Shaders/common.glsl as the shared preamble
GL has no #include for: Shader.cs gained an `includeCommonPreamble` overload
that splices the file's text in after the leading #version/#extension block
(GLSL requires #version first). It declares the binding=9 table plus the
ACDREAM_TEXTURE_HANDLE(idx) lookup macro, and a scaffolding ACDREAM_UBO_SET
macro (a no-op under GL today, redefined to `set = 1,` when the Vulkan
toolchain compiles this same source at V6+, per the campaign doc's set-1 UBO
note) applied to both SceneLighting UBO declarations now so no later slice
needs to touch them again.
Tests: WbDrawDispatcherIndirectBuilderTests updated for the renamed
IndirectGroupInput/BatchDataPublic fields; new ModernBatchDataLayoutTests
(mirrors ClipFrameLayoutTests' role, but for EnvCellRenderer's GPU struct) and
GlBindlessHandleTableTests (pure-CPU allocator behavior, including the
zero-handle case, which is registered like any other handle rather than
special-cased, since that's what reproduces the pre-V2 sampling result
bit-for-bit).
Gate: dotnet build -c Release green, dotnet test
tests/AcDream.App.Tests -c Release green (3843 passed / 3 skipped, +9 over
the 3834/3 baseline), and tools/run-offline-pixel-gate.ps1 passed with a
2.84e-05 differing-pixel fraction against the parent commit - within the
documented ~33x same-commit noise margin. No divergence-register row: this
introduces no retail behavior deviation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implements GlGpuDevice and the rest of AcDream.App.Rendering.Gpu.Gl,
filling the V0-pinned IGpuDevice contract on OpenGL 4.3. This is the
first of the port slices described in
docs/plans/2026-07-27-vulkan-campaign.md: every later renderer port
(V2 onward) needs a real, driver-proven GL implementation of the RHI
to port onto, and the GL backend is deliberately built to be
behaviour-preserving rather than optimal, because that is what turns
each subsequent slice's pixel gate into a strict identity check
instead of a moving target. The Vulkan backend (V5+) is where the
actual efficiency gains land.
GlGpuDevice is a fresh root, not derived from Chorizite's
BaseGraphicsDevice/OpenGLGraphicsDevice - shedding that inheritance is
one of the things this campaign explicitly does. It owns its own
BindlessSupport instance rather than sharing the legacy WB render
path's, which is what lets it be constructed the moment a GL context
and a GpuFrameFlightController exist, with no dependency on when
WorldRenderCompositionPhase happens to detect bindless support later
in startup. The ring buffer keeps a managed staging array plus a real
GL buffer per flight slot and flushes with one BufferSubData
immediately before each Draw/DrawIndexed/MultiDrawIndexedIndirect
(never at bind time, since a renderer may still write after binding);
V1 throws on an over-capacity ring request rather than growing it,
since nothing consumes the device yet and a silent grow would hide a
future renderer's real working set. The texture table is a bump/free-
list allocator over a managed uvec2 handle array, gated through the
frame-flight retirement queue so a released slot cannot be reused
while a submitted frame might still read it. Push constants are
applied by uniform name on the currently-bound program, cached per
program, and explicitly re-applied whenever BindPipeline switches
programs - GL uniforms are per-program state, so the "survives
pipeline changes within a pass" guarantee the interface documents (a
freebie on Vulkan's shared pipeline layout) has to be emulated here.
BindlessSupport gained one additive method,
GetResidentHandle(texture, sampler), calling the same
ArbBindlessTexture.GetTextureSamplerHandle entry point
ManagedGLTextureArray already uses through a different path. The
existing GetResidentHandle(texture) cannot express
IGpuDevice.RegisterTexture's documented pair semantics ("the same
texture registered with two samplers occupies two slots"), so this
was the minimal change needed rather than a workaround.
The pure bookkeeping - ring watermark/alignment arithmetic, the
texture-slot allocator, render-state diffing, the push-constant field-
to-uniform-name table, and GL format mapping - lives in small GL-free
classes so it is unit-testable without a live context, following the
same seam pattern GpuFrameFlightController already uses for its fence
API. GlGpuTimerPool follows suit with an injectable timer-query API.
The device is constructed in HostInputCameraCompositionPhase
immediately after the frame-flight controller (the same phase that
already builds GpuFrameFlightController), rather than in
WorldRenderCompositionPhase as first considered: GlGpuDevice's self-
contained bindless detection means it has no ordering dependency on
the legacy WB path's BindlessSupport, so it can be proven against the
real driver as early as possible while keeping the composition change
to one phase. Composition, publication, and shutdown wiring follow
the existing acquire/publish/fault-injection pattern exactly, and GPU
device disposal is scheduled through the frame-flight retirement queue
before that queue itself is torn down. Nothing consumes the device
yet - that starts at V4a - so this slice's pixel gate is trivially a
tripwire.
App tests: 3834 passed / 3 skipped (V0 baseline 3785 + 49 new: ring,
texture-slot, render-state, push-constant, format-mapping, enum-
mapping, and timer-pool tests, plus one new fault-injection point in
the existing composition theory).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Campaign V migrates the renderer from OpenGL 4.3+extensions to a single
Vulkan 1.3 backend on Windows x64 and Linux x64, then deletes the GL path.
Motivation is compatibility and efficiency, not rescue: mandatory
GL_ARB_bindless_texture is the exact floor that parked Slice L (Mesa
D3D12/llvmpipe lack it) while Vulkan descriptor indexing is core, and
per-frame data can be written straight into mapped memory rather than
copied through BufferSubData.
V0 pins the contract every later slice codes against. Nothing consumes it
yet, so this commit changes no runtime behavior.
The seam is a minimal Vulkan-shaped RHI implemented FIRST on GL. That
ordering is the point: the twelve renderers then port one at a time under a
strict pixel gate on the still-shipping backend, so a divergence is
attributed to one slice instead of surfacing at a big-bang integration.
Duplicating renderers per backend was rejected because WbDrawDispatcher is
4,449 lines holding only ~62 GL call sites — the API surface is small and
the retail-fidelity CPU logic is large, and forking the latter is how subtle
regressions enter.
Contract highlights:
- GpuBindingModel pins set/binding numbers dual-legal for GL and Vulkan
GLSL. Storage bindings 0-8 keep today's shader numbering; UBOs move to
their own set, which resolves the binding=1 collision GL only tolerates
because it keeps SSBO and UBO tables separate.
- GpuRingAllocation is a ref struct replacing every per-frame
BufferSubData; the compiler forbids outliving the owning frame.
- GpuTextureSlot replaces bindless handles. Unassigned is a loud
uint.MaxValue sentinel rather than a silent resolve to slot 0 — the
failure mode behind the magenta 1x1 UI placeholder bug. Renderers
needing a fallback take the device's really-registered default slot.
- Renderers always speak GL winding/viewport conventions; the Vulkan
backend compensates with a negative viewport height in exactly one
mapping function.
Verified while writing the plan: acdream's cameras already build
[0,1]-NDC projections (PortalProjection.cs:12-13), which is Vulkan's
convention. No projection rework is needed and depth precision improves,
at the cost of shifted z-fight patterns — the one pre-approved divergence
class, registered per instance at V7.
Gate: Release build green; App suite 3,785 passed / 3 skipped (3,763
baseline plus 22 new contract tests). Note for later slices, recorded in
the plan: run the suite in Release. LandblockBuildOriginTests'
far-strip test asserts behavior that LandblockStreamer.cs:505 deliberately
turns into a loud Debug.Assert in Debug builds, so a Debug run shows one
pre-existing failure that is not a regression.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add the presentation-free acdream-headless executable, strict no-connect configuration validation, dependency and assembly guards, and a Windows/Ubuntu CI lane that builds and tests only the portable runtime closure.
Co-authored-by: Codex <noreply@openai.com>
Move canonical per-session teardown into one retryable Runtime transaction, reduce App reset to projection acknowledgements, and prove the same GameRuntime graph through deterministic no-window lifecycle, gameplay, portal, fault, reconnect, and isolation gates.\n\nCo-authored-by: Codex <noreply@openai.com>
Make every App composition phase borrow one GameRuntime, retire the duplicate view/event adapters, and dispose the root only after its graphical borrowers release. This preserves synchronous UI commands while giving shutdown one exact ownership ledger.
Co-authored-by: OpenAI Codex <codex@openai.com>
Move remote-motion construction, CreateObject vector initialization, final simulation-component retirement, and the combined J5 ownership ledger into Runtime. Delete App compatibility views and moved-state reconstruction while preserving the existing graphical projection and retail update order.
Move projectile component identity, prediction invalidation, spatial worksets, authoritative corrections, and the retail physics step into AcDream.Runtime. Keep App as the DAT-shape and presentation adapter so ACE outcomes and visible behavior remain unchanged.
Move the sole PhysicsEngine, production cache, collision admissions, canonical bodies and hosts, remote components, ordinary/remote worksets, simulation, cell commits, and shadow synchronization under RuntimeEntityObjectLifetime. Keep App as the prepared-asset, animation-input, and render-projection adapter while preserving the named-retail update and collision order.
Add exact-incarnation, object-clock, callback-reentrancy, GUID-reuse, two-runtime isolation, source ownership, collision publication, and graphical projection coverage. Release build and the complete 8,588-test solution pass.
Co-authored-by: Codex <noreply@openai.com>
Move the canonical local movement controller, body/motion managers, object clock, movement wire data, and MTS/jump/AP sender into AcDream.Runtime. Replace process skill defaults with typed Runtime character options, make graphical and direct commands borrow one autorun owner, retain the construction-time PartArray seam, and include movement in terminal ownership convergence.
Preserve the accepted pre-inbound movement/jump and post-inbound autonomous-position order while moving the exact packet/cadence fixtures into Runtime tests. Add graphical/direct parity, two-instance isolation, teardown, allocation, architecture, and divergence-path coverage.
Co-authored-by: Codex <noreply@openai.com>
Update the stale readiness fixture to preserve Slice E's cursor-budgeted Near retirement boundary before publishing the replacement Far base. The exact committed baseline deterministically failed because the test still assumed both transactions could cross the same frame.
Co-authored-by: Codex <noreply@openai.com>