Plan section 5.5.19, the V7 slice row, two rows in the section 5.1 uncovered
table, and one divergence-register row. No product code changes.
WHAT V7 TURNED OUT TO BE. Three of V6m's four numbers were taken through an
instrument that was not holding the world still. The route pinned the Dereth
clock by pressing AcdreamCycleTimeOfDay, whose mechanism is the transient
/time override that SyncFromServer clears -- so ACE un-pinned it seconds into
every run this campaign has taken. Two captures 45 s apart at ONE stop on ONE
backend differ in 22.3% of the frame because the sun keeps moving. Pinning it
(commit 2) took the interior stop from 12.16% to 0.78% on its own.
THE THREE LEADS, ANSWERED.
Lead 3 was WRONG and the section says so. V6m recorded the interior stop as a
route defect on the theory that the indoor spring-arm camera settles to different
distances in two runs. It does not. The interior was lit differently because the
sun had moved. With the sun held still the stop drops by a factor of fifteen and
its entire remaining difference map is the player character -- the EnvCell's
walls, floor, doorway and per-cell ambient are black. EnvCellRenderer's Vulkan arm
has its numeric pair, V6m's defect-list item 2 is discharged, and no route change
was needed or made.
Lead 2 is closed by pinning the cloud phase rather than masking the band, so the
gate keeps the sky under strict comparison.
Lead 1 is half fix, half finding. The residual was predominantly the anisotropy
gap (commit 1). What survives is one population -- dense alpha-blended distant
scenery -- and isolating it needed a better instrument than the connected route.
THE INSTRUMENT V7 RECOMMENDS FORWARD. An offline GL-versus-Vulkan pair, which is
just the two existing capture scripts run with ACDREAM_WORLD_TIME and
ACDREAM_SKY_PHASE_SECONDS set in the invoking shell. No session, no server, no
entities, no camera settle, no wandering NPCs, and unattended:
GL vs GL same commit (control) 1,966 px 2.13e-03
VK vs VK same commit (control) 1,039 px 1.13e-03
GL vs VK whole frame 28,807 px 3.13e-02
GL vs VK everything below the tree band (rows 280+) 497 px 8.82e-04
Terrain, blending, roads, the water edge, fog, statics, scenery below the horizon
and the entire retained UI are at parity, inside the campaign's 0.001 threshold.
AD-46, FILED WITH ITS REFUTATIONS RATHER THAN ITS THEORY. The treeline band is an
anisotropic tap-pattern divergence between AMD's GL and Vulkan drivers. Three
competing explanations were tested and refuted, and section 4.7's predicted class
is one of them:
- not a sub-pixel offset -- an integer shift search finds (0, 0);
- not sharpness or LOD scale -- high-frequency energy matches within 5%;
- NOT DEPTH PRECISION. Forcing the Vulkan viewport's window-depth range to
[0.5, 1.0], which reproduces GL's compressed mapping exactly, moved the
whole-frame number by 3% (28,807 -> 27,852). The experiment was reverted. The
one pre-approved divergence class is not what this is, and the row says so
rather than borrowing its approval.
- It IS anisotropy, and there is no knob left: 41,509 differing pixels in the
band at anisotropy 1, 22,266 at 16, which is GL's value and retail's.
THE VERDICT TABLE. Full route, both backends, tolerance 2, MSAA off, day group 0,
world time 0.5, sky phase 0 (artifacts/v7-diff-c2):
holtburg_town 26,330 px 2.86e-02 EXCEPTION -- phase + AD-46
facility_hub_interior 7,176 px 7.79e-03 EXCEPTION -- phase
aerlinthe_island 62,892 px 6.82e-02 EXCEPTION -- AD-46 + dark floor
No stop passes and none of the three exceptions is a renderer defect; each is
named individually in the section, because "phase" is not an excuse unless it is
specific. Aerlinthe's is partly the instrument rather than either renderer: the
scene's mean luminance is 28/255 and half its differing pixels are exactly delta
3, one step over a tolerance that is absolute rather than relative. Changing that
tolerance is not V7's call.
CARRIED FORWARD, recorded in the section 5.1 table and the V7 row: a passing
connected stop needs authored per-stop masks that the gate script does not have
(it still has only the global -MaskTopPixels, deliberately defaulted to 0); the
portal depth mask has now gone three slices without drawing a pixel in an
automated run, and HouseExitWalkReplayTests names the cheapest target for it
(the Holtburg corner building, cell 0xA9B40170); whether AD-46 is visible to a
human is a user-stop question nobody has asked yet; and the R6 soak and RenderDoc
capture on Vulkan were not run.
Gates for this commit: docs only, so the code gates of commits 1 and 2 stand.
Complete Release suite 9,195 passed / 5 skipped with zero failures, and the GL
connected repeat gate at 3/3 RENDERED on both the desktop witness and the client
capture, both taken at this tree.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two changes, one measurement. The V6m smoke pair put GL versus Vulkan at
Holtburg at 18.52% of the frame differing at tolerance 2 with MSAA off. The same
stop on the same instrument now measures 9.05%, and the two populations these
address are gone from the difference map rather than merely smaller.
1. THE WORLD ATLASES WERE SAMPLED WITHOUT ANISOTROPY ON VULKAN, AND WITH THE
DEVICE MAXIMUM ON GL.
RhiWorldTextureArray -- the backend-neutral shared object/material atlas, and
the only IWorldTextureArray the Vulkan arm ever constructs -- registered its
clamp and repeat slots with GpuSamplerDescription.WorldClamp/WorldRepeat as
written, which carry MaxAnisotropy 1. The GL arm asks for the driver's own
GL_MAX_TEXTURE_MAX_ANISOTROPY twice over: ManagedGLTextureArray sets
GL_TEXTURE_MAX_ANISOTROPY on the image, and the two sampler objects its resident
bindless handles are built from (OpenGLGraphicsDevice.WrapSampler/ClampSampler)
set it again, which is the one that actually wins.
V6i-2 knew it was asking for 1 and said so in a comment -- "the world arm that
draws through these arrays is the next slice, and it is the one that can gate a
filtering change visually." That slice was V6j, the gate is V7, and this is it.
Retail settles the question rather than the GL arm settling it.
RenderDeviceD3D::SetDefaultD3DStates (0x005a3800) loops all sixteen sampler
stages and issues SetSamplerState(stage, 0xA, this->m_D3DCaps.MaxAnisotropy) at
0x005a4230. 0xA is D3DSAMP_MAXANISOTROPY and the argument is the device's
reported cap, not a setting -- so "as much anisotropy as this device has" is
retail's own rule, the GL arm is faithful to it, and asking for 1 diverged from
retail as well as from the shipping backend. No divergence-register row is owed
in either direction: this retires a Vulkan-only gap and lands on retail's value.
The fix asks for a ceiling rather than reading a limit back, because the pinned
RHI contract (plan section 3.3) carries no anisotropy field and is frozen. It
does not need one: VulkanGpuSampler already clamps MaxAnisotropy to
VkPhysicalDeviceLimits.maxSamplerAnisotropy, Vulkan guarantees that limit is at
least 16 wherever the samplerAnisotropy feature is supported -- which this
backend requires -- and 16 is where every desktop driver caps. The request and
the GL arm's read therefore land on the same number.
What it was worth, from the difference map at the same stop: the roof shingles
of both Holtburg cottages, which had been dense hatching across the whole
surface, and the stone courses of the near building are now black. Measured as
high-frequency energy (mean absolute neighbour difference, GL versus Vulkan) the
right-hand roof went from visibly blurred to a ratio of 0.999 and the wall to
1.023; every other textured region in the frame is between 0.99 and 1.02.
Grazing-angle surfaces are where anisotropy is the whole difference, which is
why a roof was the loudest thing in the frame.
2. THE SKY HAS TWO CLOCKS AND ONLY ONE OF THEM WAS PINNABLE.
ACDREAM_DAY_GROUP and the route's AcdreamCycleTimeOfDay presses pin the Dereth
date, which chooses the day group, the keyframe and the sun angle. The cloud
sheet does not read that clock: SkyRenderer accumulates TexVelocityX/Y against
DateTime.UtcNow minus its own construction time, by design, because retail's
clouds drift with real time regardless of the date. Two launches minutes apart
therefore cannot agree about where the clouds are no matter what the route does,
and the V6m smoke measured the cost -- 89% of its 18.52% sat in the top 240 rows.
ACDREAM_SKY_PHASE_SECONDS (RuntimeOptions.SkyAnimationPhaseSeconds ->
SkyRenderer.AnimationPhaseSecondsOverride) replaces that elapsed-seconds value
with a fixed one. Unset -- the default, and every ordinary run -- keeps the wall
clock, so nothing a user or the offline gate sees changes. The differential gate
forces it on both launches alongside MSAA and the day group; the offline gate
keeps its top-280 mask, because a same-commit GL pair still has the sun to
disagree about.
This is instrument determinism on the same footing as ACDREAM_DAY_GROUP, not a
workaround: it is one input to a UV offset, it is off by default, and no shipping
path reads it. The alternative on the table was -MaskTopPixels, which would have
permanently blinded the campaign's strictest instrument to the entire sky -- one
of the five surfaces the offline gate already cannot see. Rows 0-32 of the
Holtburg pair went from 23,090 differing pixels to 1,211, and what remains up
there is roof and portal rather than cloud.
WHAT THE SAME PAIR STILL SHOWS, unattributed and carried to the next commit: the
distant treeline, the player and the NPCs, and the animated portal. The portal is
phase and expected. The treeline is not filtering -- sharpness now matches within
5% and a shift search finds no sub-pixel offset -- and the two runs entered the
world at different last-logout positions (0xC95B0001 versus 0x09040008), so the
far-tier streaming history differed. That is the next thing to prove or refute.
Gates. Release build green. App tests 4,133 passed / 3 skipped against the
4,132/3 baseline (one new: the sky-phase parse). GL offline pixel gate against
the pre-change tree: 2.31e-05, 13 pixels of 563,200, inside the documented 9-31
band -- GL did not move. One offline Vulkan run with VK_LAYER_KHRONOS_validation
proven inserted by the loader: zero validation errors, zero warnings. Full
three-stop differential recorded at artifacts/v7-diff-c1.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds section 5.5.18, a V6m row to the slice table and to section 5.1's
user-gate-debt table, and corrects section 5.1's "no connected route visits a
dungeon" claim - which was wrong in two directions.
connected-world-lifecycle.route.txt has carried a Facility Hub stop all along,
so the lifecycle gate did reach an interior even though nothing compared its
pixels; and reaching one turns out not to be the same as being able to compare
it.
The V7 list moves in three ways. Item 1 closes: every production renderer draws
on both arms. The appraisal-viewport half-discharge that V6k opened and V6l
carried closes too - the view was driven on both backends and inspected. And a
new item 7 opens, which is the honest result of firing the instrument once: with
the sky band and the animated portal masked, the smoke pair still differs in
1.17% of the frame, about 12x the pinned threshold, while the chat panel on its
own differs in 42 pixels of 106,560 - inside the threshold. So the 2-D retained
UI is already at parity and the residual lives in the 3-D pass, at silhouette
edges. That is a better starting position for V7 than a single aggregate number
would have been, and it is why the smoke was attributed rather than just
reported.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Section 5.5.17 records the slice: the instanced-vertex-input amendment and
particles (b1ad1d48), the stencil dimension and the portal mask (eced67d0), and
the offscreen viewports (2e8b8b91). The V4e row is no longer blocked and the V4g
row is no longer half-landed; the slice table gains a V6l row and section 5.1's
accumulated-debt table gains one for the two connected captures the offline gate
cannot reach.
Four defects are recorded as found by RUNNING rather than by validation, which
is the pattern this campaign keeps paying for: the standalone particle texture
cache and the entity-appearance composite cache were both bindless-only, so the
Vulkan arm could draw neither a textured particle nor any entity with a palette
override; a pipeline bakes one depth/stencil format, so an offscreen target's
depth had to take the device's; and the paperdoll rendered upside down because a
GL framebuffer's origin is bottom-left and a Vulkan image's is not.
The V7 list is rewritten. Nothing on it is blocked on a contract decision any
more. What is left is one absent renderer (PortalTunnelPresentation has no
Vulkan arm), EnvCellRenderer's arm narrowed from unproven to proven-by-one-frame
after a Marketplace interior rendered on Vulkan, the MSAA-off requirement, the
per-draw descriptor writes, the portal mask's two shader sources, and the
appraisal viewport's carried-forward half-discharge.
AP-92 is narrowed rather than retired: the private viewports are backend-neutral
targets on both arms and the blit's V origin is derived rather than assumed, so
the origin half of that row's risk column is closed. The rest of it - retail
renders each CreatureMode directly against a cloned CPhysicsObj - is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Section 5.5.16 reports both landed commits and the one that did not land.
The sky: V4f's content as a Vulkan arm, and the fourth defect of the
compiles-clean class - a 32-byte vertex stride declared for a 36-byte record,
found by capture rather than by validation, because AcDream.Core.Terrain.Vertex
carries a TerrainLayer member no sky attribute names. The generalisation is
written down: every .Rhi.cs arm restates a CPU record's footprint from memory and
only one of them now has a test.
Section 5.4 is marked DISCHARGED, with the distinction it turns on spelled out.
The divergence it describes has not been on the tree since the V4c revert took
that hunk with it; what the revert did not undo was the reason it existed, and
that is what V6k commit 2 closed. V7 is no longer blocked on it.
Particles are recorded as BLOCKED rather than deferred, with the measurement
behind it: GpuVertexLayout has one stride and no divisor and BindVertexBuffer
binds one buffer at vertex rate, so the contract can express instanced drawing
but not instanced vertex input - which is what both particle pipelines are built
on. Three ways out are stated, two of them contract changes and the third a
five-fold bandwidth amplification that does not scale to mesh particles. The
choice belongs to whoever owns section 3.3.
The V7 defect list is rewritten around what is now true, including two items the
slice found rather than inherited: PortalDepthMaskRenderer cannot be expressed
without a stencil dimension in GpuPipelineDescription, and a Vulkan viewport needs
sample-count pipeline variants as well as the layered sampled view the section
5.5.7 re-check turned into a loud precondition. The MSAA item stops being a
prediction and becomes a measurement: 8.83% of the frame at 4x, essentially all of
it alpha-to-coverage edges on foliage, with rows 300-720 contributing 980 of
81,359 differing pixels.
Section 5.1's debt table gains a V6k row saying which uncovered surfaces were
checked - the sky band across seven day groups, the paperdoll through a connected
inventory capture - and which two remain: the appraisal viewport, and the sun,
moon and rain cylinder a fixed outdoor camera cannot see.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Section 5.5.15 and the slice-table row. Three things worth having written down
rather than rediscovered.
The winding inversion V6c wrote was wrong and had never been asked a question:
every Vulkan consumer through V6i sets Cull = None, so the mapping had not decided
a fragment until the world arm arrived. That makes three defects this campaign has
found in a path that compiled, validated clean, and had a test - the descriptor
layouts and the TerrainClip set were the first two - and all three share the shape
of a test that asserts the behaviour rather than the requirement.
The A8 CullMode.Landblock override, which sections 5.5.13 and 5.5.14 both flagged
as due for an answer the moment world materials drew on a second backend, has one:
it carried over verbatim and is still load-bearing, so it is now a divergence with
two consumers rather than one. The dungeon pass in section 5.1's checklist is what
settles it, not this slice.
And the V7 defect list, so the differential is not run against a target that
cannot pass it: section 5.4's null-target divergence is still live on GL,
EnvCellRenderer's Vulkan arm and the deferred-alpha and doorway-scissor paths are
unexercised by the offline scene, and MSAA must come off.
The pixel-gate figures are recorded as a distribution rather than a number,
because 31 differing pixels sits at the documented band's top and a single value
there is not evidence either way.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The slice brief was a Dereth PNG on Vulkan. There is not one, and §5.5.14 says
so in its first paragraph rather than at the end. What landed is the two
prerequisites — the mesh pipeline running on both arms, and the frame having a
world pass to record into — plus the seven measured findings the world arm needs
and that are cheaper to read than to re-derive.
Three of those correct earlier text rather than extending it, which is the part
worth reading:
- §5.5.8 offered to promote bindings 6-8 back to dynamic and said there were
"four unused dynamic slots to promote into". There are not. Vulkan's
guaranteed maxDescriptorSetStorageBuffersDynamic is 4, which is exactly what
V6g already spends, so the four bindings the world arm re-points per draw cost
a descriptor write each. That is bounded and correct, and it is why commit 2's
draw-time descriptor bind matters: without it the cost is ten scopes per draw
rather than one.
- V4c created every world pipeline with SampleCount = 1 because the GL backend
ignores it. Vulkan requires the pipeline to match the pass, and
alpha-to-coverage requires MSAA at all.
- The world renderers cannot each open their own pass on Vulkan, which is the
shape difference from V4c and follows directly from the MSAA resolve.
The slice table gains its V6i-3 row with the gate numbers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The three measurements were recorded out of order. Commit 1 measured 3.02e-05
(17 px), commit 2 measured 3.20e-05 (18 px) and commit 3 measured 1.60e-05
(9 px) — not the ascending sequence §5.5.13 listed. The band and the verdicts are
unchanged; what was wrong is which commit each number belongs to, which is
exactly what the figures exist to say.
Also records the suite totals in the V6i row.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Plan §5.5.10 recorded the blocker as a fact about types: "WbMeshAdapter owns an
OpenGLGraphicsDevice, so it is not constructible on Vulkan until slice V4t" —
which is the entire reason NullWbMeshAdapter exists. §5.5.12 item 6 then measured
how wide that dependency really is, and the answer is seven members out of a
760-line class: a GL context, the retirement queue, the shared instance VBO, and
two capability flags.
IMeshPipelineDevice is exactly that surface. OpenGLGraphicsDevice declares it and
every member already existed under a GL-specific name, so the shipping backend
executes not one changed statement — these are aliases, not behaviour.
Two casts moved, and they are what actually blocked construction:
- ObjectMeshManager downcast IGpuDevice to GlGpuDevice in its CONSTRUCTOR, so a
Vulkan-composed pipeline threw before running a statement. V4t put it there
because the class registered bindless handles itself; commit 2 moved that into
the array, leaving the field a pass-through for the raw-GL renderers' handle
table. The cast now lives on that one property and names the backend it was
composed against instead of reporting a failed cast.
- The atlas array factory is selected by IWorldTextureArrayFactory.For, which is
the one place the texture stack branches on a backend.
MeshPipelineDeviceSeamTests proves the decoupling rather than describing it: it
builds ObjectMeshManager against a device whose Gl is null, asserts it constructs,
asserts construction built no GL object, asserts the handle table refuses by name,
and asserts the factory picks the RHI arm. A reflection test pins the seam's
member set so a later slice cannot quietly widen it back out — the whole value
here is that it is narrow.
What this does NOT claim: the mesh pipeline does not RUN on Vulkan. Its upload
bodies are still raw GL — GlobalMeshBuffer, the VAO/IBO construction, the layer
transfers — and they now fail through one RequireGl() accessor that names the
slice that owns porting them, instead of failing at construction. WbMeshAdapter
still creates an OpenGLGraphicsDevice in its GL constructor, because there is no
second implementation to create yet. Those bodies are items 3–5 of §5.5.12's
remainder list, along with RetailPViewPassExecutor and the three world renderers'
submission arms.
§5.5.13 reports the whole of V6i-2 and the slice table gains its V6i row.
Gates: Release build; App tests 4,109 / 3 skips (the 4,086 baseline plus 23 across
the three commits); complete Release suite 9,172 / 5; strict GL offline pixel gate
vs 0ca802cd 1.60e-05 (9 px of 563,200 — the low end of the documented 9–31 px
control band, and fewer than a same-commit control has measured); GL connected
tools/run-repeat-connected-gate.ps1 -Runs 3 at 3/3 RENDERED on the desktop witness
and 3/3 on the client capture; one Vulkan composition-host run with
VK_LAYER_KHRONOS_validation proven inserted by the loader at zero errors, zero
warnings, no [shutdown] diagnostic, and a captured frame.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Slice V6i-1 recorded terrain_modern.vert's TerrainClip block as landing in the
wrong descriptor set under the Vulkan dialect. That was reasoning from the
missing ACDREAM_UBO_SET macro; it is now measured. Disassembling the committed
spv/terrain_modern.vert.spv shows the block as OpVariable ... Uniform decorated
DescriptorSet 0 / Binding 2, where set 0's layout declares a storage buffer.
The audit the section asked for is also done rather than deferred. Every
compiled .spv was disassembled: mesh_modern.vert's nine set-0 entries are all
StorageBuffer, correctly, and every other uniform block in every other shader
already carries the macro. terrain_modern.vert is the only one. sky.vert is the
precedent - it declares the SAME shared clip block as ACDREAM_UBO_SET binding =
2 - so there is no numbering question for the world arm to settle, only a
one-word omission to fix, plus declaring set 1 bindings 2 and 4 in the uniform
set layout. Binding 4 is section 5.5.8's still-open UniformSkyParams, which
sky.vert and sky.frag both declare.
Documentation only; no source, shader or .spv is touched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Plan section 5.5.8 recorded, and deliberately did not fix, that pointing one
binding at a second buffer within a frame silently corrupts the draws already
recorded against it: the backend rewrote the descriptor in place, and a
descriptor set's contents are read when the command buffer EXECUTES, not when it
was recorded. Nothing fired it while the Vulkan frame held only the retained UI.
Section 5.5.11 handed it forward as the first thing the world arm would hit,
because WbDrawDispatcher, EnvCellRenderer and TerrainModernRenderer each own
their own instance, batch and indirect buffers and all three bind set 0 in one
frame.
It is closed here, as its own commit and BEFORE the world arm, so that a blank or
corrupt first Vulkan world frame cannot be this defect wearing another face. That
sequencing is the point: sections 5.5.1 to 5.5.3 cost this campaign three days
because an instrument that was "usually right" sat underneath the thing being
measured.
What changed. There is no longer one (set 0, set 1) pair per flight slot; there
is an arena of them. VulkanBindingScopeArena - pure bookkeeping, no Vulkan
handles, nine unit tests - answers two questions per bind: which pair, and do its
descriptors need writing. VulkanFrameBindings keeps the Vulkan half: allocating
pairs from a growable pool list and writing the twelve descriptors when told to.
The scope key is the descriptor state itself - the ten storage buffer identities
and ranges, the plain bindings' offsets, and the two uniform buffer identities
and ranges. Deriving it is a decision, not an economy. The pinned contract has
nowhere to name a scope: BindStorageBuffer takes a buffer, an offset and a size,
and section 3.3 is frozen. Deriving also gives two properties a declared scope
would not: a renderer cannot forget to declare one, and two renderers that
genuinely share every buffer correctly share one pair rather than being told to
differ. A renderer's buffers are stable for its lifetime, so "distinct descriptor
state" is exactly "renderer scope".
Dynamic offsets stay free. A ring allocation moving between draws rides
vkCmdBindDescriptorSets's dynamic-offset array, so it costs neither a new pair
nor a descriptor write - section 4.4's "zero descriptor writes per frame"
property survives a frame having more than one binding state in it. Entries are
not invalidated at BeginFrame either, because the slot's previous submission has
retired and its descriptors still say what this frame is about to say; a steady
frame therefore rewrites nothing at all. An entry matched from the previous frame
is swapped below the live cursor so the rest of the frame cannot take it for a
different state - the ordering property the sixth test pins, where two renderers
swap submission order between frames.
What this does NOT do is draw a world. The captured Vulkan frame is still V6h's
retained UI over the fog clear, so the arena's multi-scope path is exercised by
its tests and not yet by a frame. That is recorded in the plan rather than
implied.
The plan's section 5.5.12 also records two blockers measured while scoping the
world arm and not fixed here: terrain_modern.vert declares TerrainClip without
ACDREAM_UBO_SET, so under the Vulkan dialect it lands at set 0 binding 2 where
the layout declares a storage buffer - the same class of gap 5.5.8 recorded for
UniformSkyParams, invisible until a terrain pipeline is created; and the offline
gate's scene takes the retail PView path rather than the flat safety path,
because ClipRoot falls back to Buildings.OutdoorNode, which puts
RetailPViewPassExecutor on the critical path to the first Vulkan Dereth frame and
makes the "terrain only" intermediate no cheaper than the whole arm.
Gates. Strict GL offline pixel gate against b9ab5890: 1.60e-05, 9 differing
pixels of 563,200, at the low end of the documented 9-31 px band and 62x under
the threshold - expected, since no GL file is touched. GL connected
run-repeat-connected-gate.ps1 -Runs 3: 3/3 RENDERED on the desktop witness and
3/3 on the client capture. One offline Vulkan run with VK_LAYER_KHRONOS_validation
proven inserted by the loader: zero errors, zero warnings, captured frame, no
[shutdown] diagnostic on either stream. App tests 4,086 / 3 skips (baseline 4,077
plus nine); complete Release suite 9,149 / 5. Issue #250's
SurfaceOverrideFingerprint_DictionaryHotPathAllocatesNothing failed once in a
whole-suite run and passed run alone, as that issue documents.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Marks V4t done in the slice table with its two commits and per-commit gate
numbers, adds §5.5.11, and ticks off step 3 of §5.5.9's corrected sequence.
§5.5.11 records four things the world arm needs and one it should not
re-derive: why §5.2's "the device's table is unreachable" argument expired
rather than being worked around (its premise was that V4c would move the world
renderers onto the encoder; §5.5.6 closed that, so the block became
indefinite); that V4t landed narrower than §5.3 sized it, keeping texture
creation and residency with the caches and moving only the table entry, with
`IGpuTexture` creation deferred to the world arm that actually cannot use a GL
handle; why `GroupKey`'s ordering survives a retype of one of its fields; and
the pixel-gate control measurement, because one capture read 5.50e-05 against a
documented 15–23 px band and the honest response to that is a control, not a
paragraph. The decisive number is the 9-px difference between captures at the
two V4t commits — two different commits, fewer differing pixels than the
same-commit control.
`GlBindlessHandleTable`'s own doc comment claimed four renderers own instances.
Three of them no longer do. It now names its one remaining owner, `SkyRenderer`,
says why that one is different (its textures come from the raw GL-name path V4t
did not retype), and names V4f as the slice that deletes both the table and the
class.
`common.glsl`'s binding-9 comment carries the same stale list. It is left alone
deliberately: the file is a shader source with compiled `.spv` artifacts and a
V9 freshness gate, so a comment-only edit there is not free, and what the
comment says about what binding 9 IS remains correct.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The strict GL offline pixel gate re-run at b16f8206 measures 1.78e-05 (10 of
563,200 pixels) against 46d893f7, and the Vulkan validation run at that exact
tree is clean with an empty stderr and a UI frame identical to the pre-commit
capture. Also records a pre-existing test-isolation defect found while gating:
StreamingControllerPriorityApplyTests.DungeonCollapseBeforePromotionBase_RetiresProvisionalTerrainAndPendingStatics
fails alone under --filter and passes with its project, identically at the parent
commit, so it is an order dependency in the test rather than a regression.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ACDREAM_RENDER_BACKEND=vulkan now runs the real GameWindow composition rather
than a second main(). All nine phases execute: DAT load, streaming, camera,
entity table, session, and the real retained UiHost drawing through the RHI.
No world renderers — they are raw GL until V4t and the world arm behind it.
The offline log is the client's own (acdream.pak opened, 6266 spells, Region
0x13000000, "loading world view centered on 0xA9B4FFFF", fourteen retail
LayoutDesc lines, streaming radii), and the captured frame is the retail
retained UI: vitals, combat/spell bar with DAT scarab icons, the nine-slot
toolbar, chat with tabs and Send, radar/compass with dat-font glyphs. Sampled
against the GL capture the widgets agree — chat interior RGBA (25,24,27,158)
vs (22,21,23,158), vitals bar (117,1,0) and toolbar slot (0,11,17) identical.
Three seams, as §5.5.9 specified:
1. Platform acquisition — already generic — publishes GameWindowGraphics
instead of a bare GL. Phases that still speak raw GL read Graphics.Gl and
take their Vulkan arm when it is null; each branch names the slice that
removes it.
2. VulkanHostInputCameraCompositionFactory is a new file and the whole of the
Phase-1 fork: four graphics members differ, input/camera/pointer delegate.
The default factory is chosen inside the phase from the platform result.
HostInputCameraResult gained backend-neutral Retirement and FrameSlots.
3. The frame root forks on one condition. The GL world-scene assembly is
unchanged, wrapped in `if (gl is not null)`; the Vulkan arm's graph is one
backbuffer clear pass computing the same RenderFrameFoundation from the same
clock and weather owners, then private presentation over it.
§5.5.9's three TextureCache couplings are unpicked: the constructor takes GL?
and rejects bindless without one, world entry points route through a Gl
property that throws naming V4t, and the (GlGpuTexture) VRAM-accounting cast
became a backend test. That cast's stated reason — DrawSprite's texture-unit
binding — was already stale, deleted at V6d.
VulkanBringUpHost is reduced to the capability-probe harness it is named for:
the instance/surface/device/swapchain sequence moved into VulkanGraphicsContext,
which the composition host and the harness now share. It is reached only with
ACDREAM_VULKAN_PROBE=1.
One latent Vulkan defect surfaced and is fixed here. The first composition-host
frame died with ErrorDeviceLost; validation named VUID-vkCmdDraw-None-08600 —
descriptor set 2 never bound. VulkanGpuPassEncoder bound sets 0/1/2 only as a
side effect of BindStorageBuffer/BindUniformBuffer, so a pass sampling the
texture table while binding no buffer — every retained-UI and debug-line pass —
drew with the table unbound. It survived V6c-V6g because the bring-up host
always drew VulkanRhiScene first and the UI pass inherited its binds; the
composition host has no 3-D scene. The fix is one line in the encoder's
constructor beside the viewport and scissor defaults, which exist for exactly
the same reason: a pass opens with complete binding state rather than depending
on what preceded it.
Gates: strict GL offline pixel gate against 46d893f7 measures 1.24e-05 (7 of
563,200 pixels), inside the documented 15-23 px / 4.1e-05 band, so GL behaviour
did not move. App tests 4,075/3 skips; complete Release suite 9,138/5 skips.
One full Vulkan run with VK_LAYER_KHRONOS_validation: zero errors, zero
warnings. Both Vulkan runs converged the ownership ledger — no [shutdown]
diagnostic on either stream. The reduced probe harness presented 34,811
validation-clean frames.
No divergence-register row: GL is the shipping backend and the pixel gate proves
it unmoved; the Vulkan arm is not a retail deviation but a backend under
construction.
Next is V4t, the texture stack, which the world arm cannot be written without.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
§5.5.7 asked for "a Vulkan composition host — a slice the plan has never
scoped." This scopes it and stops there, for the same reason V6f stopped short
of the world fork: the honest measurement is worth more than a half-built second
path, and §3.1 and §7.1 rule 3 exist to prevent exactly the unexercised arm this
would otherwise have landed.
Three findings, all verified against source rather than inferred.
The fork seam mostly already exists. GameWindowPlatformResult<TGraphics, TInput>
is fully generic — only its call sites pin GL — and the host phase already has
IHostInputCameraCompositionFactory with a single Retail implementation, so the
Vulkan device arm is a new file rather than a modification. What does not exist
is a Vulkan frame root: FrameRootComposition assembles the clear phase, both
pass executors and the GPU profiler measurement from a GL handle plus six raw-GL
world renderers, none of which exist on Vulkan. That second assembly is the
slice's centre of gravity, and it is most of V4h.
The retained UI is NOT blocked on V4t. V6f wrote that the retail widget tree's
chrome comes from a GL-only TextureCache, which is true of the type but not of
the path: V4a and V6d already moved UploadUiTexture onto IGpuDevice, and the
public UploadRgba8 that IconComposer composes retail icons with routes into it.
The raw-_gl uploads that remain are the world's Texture2D/Texture2DArray paths,
which no UI draw reaches. Three small things stand in the way — a non-null GL in
TextureCache's constructor, one ((GlGpuTexture)texture).GlName cast for VRAM
accounting, and the UI-probe screenshot controller — and none of them is V4t.
So V4t is a hard prerequisite for the WORLD arm, not for the host, which moves
the host ahead of it in the sequence rather than behind.
That reordering is the point of the commit. The corrected sequence puts the
composition host at step 2, before V4t, because its acceptance criterion — the
real UI renders — makes it the first frame acdream draws on Vulkan that is the
client's own frame rather than a scene written to prove the backend.
Also records the argument for keeping VulkanBringUpHost as a capability-probe
harness: its window/instance/surface/device/swapchain sequence is what the
composition arm needs, and deleting it would mean writing that twice.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
V6f ran the bring-up host once under VK_LAYER_KHRONOS_validation and found
seven VUIDs, every one of them on the path any world frame takes (plan
§5.5.7). This closes all of them, plus a fourth defect in the same log that
§5.5.7 did not call out. The host now runs validation-clean: zero errors and
zero warnings over 39,855 frames.
Nothing outside Gpu/Vk/ is touched, so the GL backend executes not one changed
statement. The offline pixel gate says so too — 4.08e-05 differing fraction
against f8dbe2ee, which is exactly the value the campaign recorded as its own
same-commit control (§5.1's 15–23 pixel band).
The dynamic-descriptor limit was a decision, not a patch. V6b declared all ten
of set 0's bindings STORAGE_BUFFER_DYNAMIC on the reasoning that the contract
lets a renderer bind any range per draw. That is true and still cost nothing to
honour for four of them: a dynamic descriptor buys exactly one thing, the
ability to address the SAME buffer at a DIFFERENT offset without a descriptor
write, which is the shape of a ring allocation and of nothing else. So the
ring-fed bindings — instances, batches, clip slots, instance light sets — stay
dynamic, and the ones pointing at a long-lived buffer written whole and bound
once per pass carry their offset in the descriptor instead. Binding 9 is the
clearest of those: it is the GL-only uvec2 handle table, which the Vulkan
backend never binds at all.
That lands on four dynamic storage descriptors. The RX 9070 XT allows eight, so
eight would have worked here — but four is Vulkan's GUARANTEED minimum, which
means no conformant device can fail this layout, and V9's lavapipe row and the
deferred physical Linux row both depend on that. The count is asserted against
maxDescriptorSetStorageBuffersDynamic in the capability record, so a device that
cannot serve it is rejected at startup in the report under the same exit-code-4
contract as every other requirement, rather than failing silently at
vkCreatePipelineLayout the way this one did.
Depth-off pipelines were malformed in any pass that has depth. Dynamic rendering
bakes the depth/stencil attachment format into the pipeline and requires it to
equal the pass's; V6c set it only when the pipeline itself tested or wrote
depth. Debug lines, the retained UI and the sky are all depth-off and all
composite over the main pass, so this was not an edge case. The same
GpuPipelineDescription is legitimately used both ways — ui-text opens its own
depth-less pass — so the description cannot answer the question and the backend
builds both variants, binding whichever matches what vkCmdBeginRendering was
actually handed rather than what the pass asked for. Both are built at startup
against the persisted cache, so no frame compiles one. A slice entitled to
change the contract should add a depth-format field the way V6d added
ColorFormat; this is the honest expression of the gap until then.
vk-backbuffer-depth and vk-backbuffer-msaa-color were created UNDEFINED and
never moved. Both now barrier on every backbuffer pass — from UNDEFINED on the
first use after Configure, from attachment-optimal with a write-after-write
dependency thereafter. The dependency matters on its own account, not just the
layout: two passes in one frame write both images and so does the next frame,
and Vulkan orders nothing between render-pass instances.
The fourth defect is the one worth reading twice. CaptureBackbuffer transitioned
the LAST PRESENTED swapchain image to TRANSFER_SRC and copied out of it. After
vkQueuePresentKHR that image belongs to the presentation engine and its contents
are not ours to read — and the pixels were usually right, which is precisely the
problem. This campaign spent three sections of its own plan (§5.5.1–§5.5.3)
discovering how much a capture instrument that is "usually right" can cost, and
shipping that shape on the new backend would have made every Vulkan PNG, and the
V7 differential built on them, formally undefined. The frame now copies its own
output into a host-readable buffer while it still owns the image, and the
capture reads that. Retention is opt-in, armed when an artifact directory
exists: one full-resolution copy per frame is worth nothing to a player and is
the entire instrument to a gate. The old one-shot command pool, device-idle wait
and per-capture readback buffer go with it.
Two gaps found and recorded in §5.5.8 rather than fixed, both outside this
slice's brief. UniformSkyParams (set 1, binding 4) is not in the uniform set
layout, so whoever first draws sky on Vulkan must add it. And a binding pointed
at two different buffers within one frame silently corrupts the earlier draws,
on dynamic and plain descriptors alike, because descriptor contents are read at
execution time — no consumer does that today, but WbDrawDispatcher and
EnvCellRenderer each own their own instance and batch buffers and both bind
bindings 0, 1, 3, 4 and 5 in one frame, so the Vulkan world arm has to know
before it is written.
Gates: Release build; App tests 4,075 passed / 3 skipped (baseline 4,073 + the
two new capability cases); GL offline pixel gate PASS at 4.08e-05; one
validation-layer Vulkan run, clean, with the captured PNG inspected and correct
in orientation, colour and glyph coverage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two things to write down after slice V6f.
The obligation §5 recorded against V6e is discharged. terrain_modern was "the one
production pair still not Vulkan-expressible"; its three blockers - the two loose
matrices, the loose tiling array, and the GL-only sampler-from-handle
construction - are gone, and 8/9 pairs now compile. The ninth is `mesh`, which
the plan already records as having no consumer at all, so every shader acdream
actually draws with is Vulkan-expressible. V6f gets its own slice row and its own
line in the user-gate debt table: terrain through a doorway clip region is the
one terrain path the offline gate cannot see, and it now has a second UBO binding
beside the clip block, so a bind-order mistake would surface exactly there.
The larger entry is §5.5.7, which records a measurement rather than an opinion.
§5.5.6 selected option (B) - V4c/V4d's content returning as the Vulkan world path
behind a fork at the thin submission seam - and V6f set out to build that fork.
It cannot be built yet, for a reason the plan had not stated: the Vulkan path
constructs no game state at all. GameWindow.Run returns at :695, before
Window.Create and therefore before OnLoad, which is the only caller of the
composition pipeline. A capture confirms it (artifacts/vk-world/): what the
Vulkan backend draws today is V6c's verification scene and V6d's generated UI
sprite, correctly and completely, and nothing else. A backend-selected fork would
therefore have a GL arm that runs and a Vulkan arm nothing can reach - the
unexercised second path §3.1 and §7.1 exist to prevent.
Worse for sequencing, the parked V4c/V4d code could not drive Vulkan even if it
were reached: it binds GL bindless handles as a storage buffer because §5.3
deferred the real port to V4t, and GroupKey carries the raw ulong. V4t is a hard
prerequisite, and it rewrites exactly the code the fork's Vulkan arm would
contain. Landing the fork first means writing that arm twice.
One validation-layer run is recorded with it, and it found two defects that
outlive the slice, both pre-existing and both on the path any world frame takes.
The pipeline layout declares all ten storage bindings as STORAGE_BUFFER_DYNAMIC
against a device limit of eight - the pinned binding model meeting a real limit,
wanting a decision rather than a patch. And any depth-off pipeline in a pass that
carries depth declares VK_FORMAT_UNDEFINED where the attachment's real format is
required. Also noted: the render-target-view-in-table usage V6f was told to
expect did NOT fire, so it should be re-checked rather than carried forward as
known-and-accepted.
The section closes with the recommended order - composition host, the validation
defects, V4t, then the fork - and with a cheaper intermediate milestone worth
considering: terrain, water and sky only, for which V6f's work is the whole
shader prerequisite.
Also: the roadmap's Campaign V paragraph gains a shipped-so-far line, and #250
gains a third test of the same class. One full App run during this slice reported
2,752 bytes against an expected 0 in
CurrentRenderSceneOracleTests.SurfaceOverrideFingerprint_DictionaryHotPathAllocatesNothing,
on a diff that touches only GLSL and terrain's uniform plumbing; it passed alone
and in four other full runs of the same binary.
Documentation only - no code, no gates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Campaign V slice V6e, last of three. Sky was the hardest of the four pairs
because it was the only one that still worked the way a 2004 shader works: a
dozen loose uniforms pushed one glUniform call at a time, and a texture bound to
unit 0 with a sampler object chosen per submesh. Vulkan GLSL has neither a
default uniform block nor a way to declare a bare sampler, so both had to move —
and the second one had a sting in it.
The uniforms go into a `SkyParams` std140 block at uniform binding 4, the new
pre-authorized constant in GpuBindingModel (1, 2 and 3 are SceneLighting, the
terrain clip block and terrain tiling; the contract test now proves the three
constants and that literal 2 do not collide). Three matrices are 192 bytes on
their own, so the 96-byte push-constant block was never in the running. The
block's member order IS its layout: std140 aligns a vec3 to 16 bytes while using
12, so each of the three lighting vectors is followed by the float that rides in
its pad word, which is why colours and per-surface scalars interleave rather
than grouping by meaning. SkyParamsLayoutTests asserts all twelve offsets and
the 256-byte size, because getting one member wrong would read the sun direction
as a colour with no compile error, no link error and no GL error to say so.
The texture is the interesting half. sky.frag now reads through the shared table
(ACDREAM_SAMPLE_2D), and a bindless handle BAKES its sampler — so the
per-submesh Repeat-versus-ClampToEdge choice, which used to be a glBindSampler
on unit 0, becomes which slot the submesh asks for. SkyRenderer interns one
handle per (texture, wrap) pair, exactly as ManagedGLTextureArray has done since
the world path went bindless, and exactly the shape Vulkan's table has, where an
entry is a combined image sampler. Same two SamplerCache objects, same wrap
behaviour, consulted once at interning instead of once per draw. A pleasant
consequence: the sky no longer touches texture unit 0, so the load-bearing
`BindSampler(0, 0)` restore at the end of the pass — there because the binding
was global state that would otherwise force ClampToEdge on the next renderer —
has nothing left to undo and is gone.
Gates. Release build clean; App tests 4,072 passed / 3 skipped (4,057 baseline,
plus the sentinel guard from the previous commit and fourteen sky-layout
assertions). Offline pixel gate against 95f8c25f: 18 px of 563,200 compared
(3.20e-05), inside the documented 15–23 px band.
That gate masks the sky for determinism, so it proves nothing about this commit
and the sky renderer has no automated pixel coverage at all. What was done
instead: a base-versus-head offline capture at ALL SEVEN day groups, built by
stashing the change and rebuilding so the two runs differ only in this commit.
Every pair matches in gradient, cloud sheet, horizon band and fog — including
day group 2's salmon cloud band and day group 6's green one, which between them
exercise texture sampling, per-vertex tint, blend mode and fog. Then 3/3
RENDERED on the desktop-witness repeat-connected gate.
That bounds the risk; it does not close it. The offline camera is fixed and
looks down, so a thin band of dome is all it ever sees: the sun and moon
(additive, high) and the rain cylinder (the one sky mesh that surrounds the
camera, and the one whose REPEAT wrap is most visible) remain unproven. Recorded
as user-gate debt in §5.1 alongside V2c's and V4e's particles — check it by
standing outside at dawn or dusk, and by standing in rain.
Manifest: 8/9 pairs compile. `terrain_modern` is the last production pair, and
it is blocked on V4d's content rather than on dialect — details in §5.5's slice
table. `mesh` has no consumer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Campaign V slice V6d, commit 3 of 3 — the evidence commit, which turned out to also be a bug-fix commit.
VulkanBringUpHost now builds a real UiHost and DebugLineRenderer on the Vulkan device and draws them after the V6c verification scene, in their own single-sampled load/store passes against the backbuffer — the same shape the GL client's HUD phase has. Nothing in the retained stack is backend-aware: UiRoot walks a real widget tree, each widget draws through UiRenderContext, and UiHost.Draw brackets it with TextRenderer.Begin/Flush. What it cannot be is the game's own UI, because the retail tree is built from LayoutDesc and DAT chrome by TextureCache, which stays a GL type until V4t; the sprites here are generated instead. The widget rectangles are authored at known pixel offsets from the top-left and nothing is mirror-symmetric, so a wrong Y flip would put the title bar at the bottom.
The frame this produced was wrong, and usefully so. Whole runs of the debug-line figure were missing. Vulkan's rasterization-order guarantees are scoped to one render-pass instance; between two instances writing the same attachment there is no implicit ordering, and that includes a multisample RESOLVE, which is part of the render pass and therefore equally unordered against what follows. TransitionBackbufferForRendering emitted its acquire barrier once per frame and returned for every pass after the first, so the second and third passes raced the first one's resolve. V6c's frame had exactly one backbuffer pass and could not see this; V6d's has three. A later backbuffer pass now gets a colour-attachment dependency instead of nothing, and keeps ColorAttachmentOptimal as its old layout rather than Undefined, which would have licensed discarding everything drawn so far. Every line renders continuously afterwards.
Inspection of artifacts/vk-ui/vulkan-bringup.png against the authored layout, by pixel probe:
The header panel is authored at (24,18), 420x96. Its tiled chrome fills exactly x 24..443 and y 18..113 — one pixel outside on any edge is the clear colour. The tile's lit edge appears at the top and left of every cell, so texture row 0 lands at the top and the V axis is not flipped. Both labels read left to right, right side up, through the font-coverage branch. The nested panel's border samples exactly (153,191,255) against an authored (0.6,0.75,1.0), unblended — the untextured branch is bit-exact. The badge sprite is authored at (460,58), 64x64, and its gradient starts at x=460 with the clear colour at 455 — the RGBA-modulate branch, sampling a table slot. The two debug-line segments land on their computed screen coordinates. All three fragment branches, the pixel-to-NDC mapping, the top-left origin, straight-alpha blending and table sampling are therefore all confirmed on Vulkan, which is everything the offline GL gate confirms about the same code on GL.
The plan's V6 milestone is amended rather than claimed: "full game frame on Vulkan" is not reachable while V4c/V4d are parked and the world renderers and TextureCache are still raw GL, so V6 delivers the backend plus the two renderers that can use it today. The accumulated user-gate table gains a V6d row for the paperdoll/appraisal viewport sprite — the one retained-UI texture the offline scene never draws, on a slice that changed how every UI texture is sampled.
App tests 4,057 passed / 3 skipped, unchanged. Offline pixel gate against f6f58a12: differing fraction 3.20e-05, 18 pixels of 563,200, inside the documented 15-23 pixel noise band — as expected, since this commit touches only Vulkan files and the campaign doc.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The exact V4c binary - verified by its embedded wb-mesh pipeline literals - rendered ten of ten repeat-gate cycles on a separate NVIDIA PC against the same ACE, same scene, same account, while the AMD box fails 30%+ of identical runs with the defect pinned to this binary at p=0.024. Two GL drivers, one failure. Option B is adopted: GL keeps the legacy world path to V10, the RHI world path ships on Vulkan, and the V4c/V4d GL re-land is closed rather than parked.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Records the decision that §5.5.4 recommended and the V5 bring-up now makes
actionable: option (C). No further GL-side attempt is made to re-land V4c or V4d
until the same ported world path has been measured on Vulkan.
The grounds are the three investigation sections read together. A blank run
rasterizes its first world frame correctly - frame 43's occlusion counters,
1,692,830 terrain and 317,561 entity samples, byte-match the parent build - and
then one irreversible event kills every GPU→CPU return channel at once:
readbacks come back RGBA(0,0,0,0) over UI pixels the desktop witness shows on
screen, a guarded glGetQueryObject deadlocks the render thread inside the
driver, and a GPU-timeline query-buffer write never lands on its sentinel.
Present and fences keep running at 5.5 ms throughout, on NO_ERROR from
glGetError and a clean glGetGraphicsResetStatus across 1,814 samples. Five
mechanisms are falsified and four independent instrument faults have turned up,
all of them below the API, all on one driver on one GPU.
That is not a shape any further GL-side bisect is well placed to resolve, so the
document now states the decision rule rather than leaving option (C) as a
recommendation. If the identical RHI world path renders correctly on Vulkan on
this GPU, the driver defect is proven and option (B) is adopted deliberately:
GL keeps the legacy world path through V10 as a documented, scoped exception to
§3.1's no-fork rule, confined to the thin submission seam. If it fails on Vulkan
too, the trigger is in code we own and the hunt resumes against a much smaller
haystack.
Two knock-on edits keep the plan self-consistent rather than leaving the reorder
stated in one place and contradicted in another. The slice table marks V4c and
V4d parked, V4t and V4e-V4h re-sequenced pending the verdict, and V5 shipped.
§5.4's sequencing invariants no longer claim V0→V4h is strictly sequential, and
they now carry the consequence that matters: V6 arrives before V4g and V4h, so
the Vulkan backend honours the contract's literal null target while GL still
carries the transitional inheritance. That makes §5.4's two removal obligations
more binding, not less, and V7's differential must not run until the removal has
happened - otherwise it would surface the divergence as a viewport rendering to
the wrong surface, which post-decision is indistinguishable from the fork option
(B) permits.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Runs the instrument section 5.5.2 asked for, on a V4c tree staged from
`git revert --no-commit 543bc79f` and never committed: GL_SAMPLES_PASSED around
the raw-GL terrain draw, the dispatcher's entity draws, and the retained-UI
flush, collected outside the frame that issued them, with the desktop witness as
the verdict. All probe code is stripped; what survives here is the two gate
scripts and section 5.5.3/5.5.4.
Building it found a fourth instrument fault. Reading a query result on the CPU
timeline - glGetQueryObject guarded by RESULT_AVAILABLE, one frame late -
deadlocks V4c at the first frame that draws the world: 4/4 runs, and five
dotnet-stack samples four seconds apart all show the render thread inside the
driver in that call. Not a probe defect - the same probe ran 4,420 clean frames
on the V4c parent, and instrumenting only the UI flush reproduces the wedge while
creating the query objects and never beginning one does not.
Routing the result into a persistently-mapped GL_QUERY_BUFFER instead - the
driver writes it on the GPU timeline, so no client wait is possible, and a
sentinel separates "reported zero" from "never reached" - does not wedge, and
gives the answer. On blank runs no query result is ever produced at any site for
the whole run, including the UI, in the same frames where the desktop grab plainly
shows the UI on screen. On the rendered run of the same binary, 1,068 frames, not
one missing result.
So the mission's fork resolves to "never completes", but not as a stall: frame
time holds at 5.5 ms for ~3,700 frames, the frame-flight fences keep retiring,
and present keeps working. Every channel that carries a result back from the GPU
is dead - pixel readback, CPU query read, GPU-timeline query write - and every
channel that carries none is fine. The transition is one sharp event at the first
world frame and never reverses, and that frame rasterizes correctly: 1,692,830
terrain and 317,561 entity samples, the same two numbers the parent reports for
its own first world frame.
Section 5.5.4 lays out the three options with their costs and recommends (C):
bring Vulkan up first and decide V4c afterwards, because running the identical
ported world path on the Vulkan backend on this GPU is both the cheapest test of
the driver-defect reading and work the campaign owes anyway. (B), accepting the
GL-side fork, is probably the right conclusion but should be adopted on a
measurement rather than an inference. No fix was attempted and V4c is not
re-landed.
Apparatus: run-repeat-connected-gate.ps1 and run-blank-world-ab-probe.ps1 now
assert on the desktop grab and record the client's own capture as a second
column, which is the re-arming section 5.5.2 required before re-land condition 2
can mean anything. Both verified end-to-end.
Gates: Release build clean; App tests 3,866 passed / 3 skipped; offline pixel
gate PASS at 3.37e-05 differing fraction (19 px of 563,200), inside the
documented 15-23 px band.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
The V4c blank-world regression was intermittent - roughly one launch in three at the worst location, zero in seven at the parent - so a single connected capture passes the broken binary most of the time and gates nothing. The new gate runs N full connect-teleloc-render-screenshot cycles with graceful logout and a per-run verdict by screenshot content size, refuses to start if a client is already using the shared test account, and pins the teleloc because the failure rate is location-sensitive. Ten clean runs bound a one-in-three defect below roughly four percent.
Campaign doc 5.5 records the revert evidence and the binding re-land conditions: the GL ring write path moves to mapped unsynchronized writes, and V4c/V4d re-land only at 10/10 rendered plus a passing offline gate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The user confirmed the local ACE server is always up and they will verify on request, so the accumulated visual debt becomes a real gate rather than something banked to V7. A defect checked while the change is fresh costs minutes; the same defect found at the GL-vs-Vulkan differential is a bisect across a dozen commits.
Records the checklist in the order that exercises the most per minute, and notes the underlying gap: no existing connected route visits a dungeon. Every stop in the soak and lifecycle routes is outdoor, which is why EnvCell coverage was missing from the automated gates as well as the offline one. Adding an interior stop to those routes is the durable fix.
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>
V4c verified independently: offline pixel gate passing at 26 differing pixels of 563,200 against a 17-pixel same-commit control, App suite 3,844/3, 8 files, contract untouched, no tests removed, encoding clean.
Two things the slice surfaced are now written down rather than left in a report. First, GL BeginPass had to stop binding framebuffer 0 for a null target, because the viewport and portal renderers bind their own FBO before calling the dispatcher - correct today, but it makes GL diverge from the contract, and Vulkan must honour a null target literally as the swapchain image. V4h has to restore it or the V7 differential will show an entire viewport rendering to the wrong surface. Second, the offline gate exercised the dispatcher hard and EnvCellRenderer not at all, so dungeon interiors are half of V4c and remain unproven; the accumulated user-gate debt across V2c, V4c and the upcoming V4e/V4f/V4g is now tabulated with the connected route that clears it.
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>
V4a landed on the second attempt at 096dd203, verified independently: offline pixel gate passing at 22 differing pixels against an 8-26 same-commit noise band, App suite at exactly the 3,843/3 baseline, 26 files touched, no encoding damage. Three audits of the reverted first attempt found defects that outlive it - resident bindless handles never released, no test coverage for the Multisample state dimension, and no encoding guard - now tracked as #249.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three rules binding on every remaining slice: an RHI pass must restore GL capability state while raw-GL renderers coexist; a failing gate blocks the commit rather than being explained away; and slices stay inside their file list. Also pre-approves the external-texture bridge for the paperdoll viewport so a slice does not invent one mid-implementation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
V3 exists to verify the assumptions the whole Vulkan design rests on, before
V6 builds on them. The central claim held. One plan assumption did not, and
catching it here is the slice paying for itself.
Confirmed: every projection reaching a shader is built by
Matrix4x4.CreatePerspectiveFieldOfView, so NDC z is already [0,1] - Vulkan's
own convention - and no projection rework is needed. There are no orthographic
projections in production at all; the retained UI converts pixel coordinates
straight to NDC with a constant z, so V4a has no matrix to convert. Phase U.3's
clip planes are derived and compared entirely in clip space with plane.z always
zero, making them insensitive to both the depth convention and the viewport Y
flip. SkyProjection.WithDepthRange is the only hand-written matrix edit and it
re-derives the same D3D-convention mapping rather than a GL-style depth scale.
Corrected: the plan specified a B8G8R8A8_SRGB swapchain "matching the GL
FramebufferSrgb contract." That contract does not exist. FramebufferSrgb is
enabled only inside the throwaway capability probe and disabled immediately,
never on the real backbuffer; no texture uses an sRGB internal format; no
shader converts gamma. The renderer is UNORM end to end, so the correct
swapchain format is B8G8R8A8_UNORM. Shipping _SRGB would have applied an encode
to already-display-space values - a global brightening on every frame that
nothing before V7 would have caught.
Two acceptance items carried forward to V6/V7: the Vulkan encoder must flip
scissor rectangles itself, because vkCmdSetScissor is top-left-origin and the
negative viewport height does not affect it; and the V7 differential must
launch both backends with ACDREAM_MSAA_SAMPLES=0, since MSAA is fixed at window
creation and cannot be toggled mid-session.
Filed #248 for FrustumCuller's near-plane extraction, which uses the GL
[-1,1] Gribb-Hartmann formula against [0,1] matrices. It is provably
over-inclusive rather than over-culling, and it is pure CPU math untouched by
the backend swap, so it is tracked rather than fixed inside this campaign.
No code changed, so the pixel gate is trivially satisfied.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Records V2's landing (V2a d365476e, V2b 1f1f6c08, V2c a85743f7) in the
campaign doc's status banner and slice table, alongside the actual
per-renderer table-ownership shape (each of WbDrawDispatcher, EnvCellRenderer,
TerrainModernRenderer, and ParticleRenderer owns its own GlBindlessHandleTable
rather than one shared TextureCache-owned instance) and the measured
pixel-gate differing-pixel fractions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The device flushes its texture table before draws it records, but at V2 the draws still go through raw GL in WbDrawDispatcher, so the device would never flush and the table would be stale on the GPU. V2 therefore keeps the handle table inside the existing texture caches; V4c deletes it once the dispatcher moves onto the encoder and the device table becomes reachable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Seven of the campaign slices (V2, V4a-V4g) are renderer ports whose entire
acceptance criterion is that no pixel changed, and the existing connected
lifecycle route needs both a live ACE server and the user watching. That would
have made the campaign advance only when someone is at the keyboard.
The client renders the world from the DATs without ACDREAM_LIVE, so the existing
UI automation probe can capture a settled frame with no session created and no
ACE state to disturb. The gate wraps that: capture at the parent commit, capture
at slice HEAD, compare through the existing compare-screenshots CLI at the
project's pinned tolerance 2 / 0.001.
Determinism was measured rather than assumed, and the first measurement failed:
two captures at the same commit differed in 0.29% of pixels. The differences
were confined to the top ~180 rows, which is correct behavior rather than a bug
- the sky animates and the Dereth clock advances with wall time, so two launches
cannot agree there. Below the horizon everything was stable. Masking the top 280
rows brings two independent same-commit pairs to 15 and 17 differing pixels out
of 563,200 compared, a ~33x margin under the threshold. Masking the animated
band keeps the rest a strict identity check; relaxing the tolerance instead
would have hidden real regressions everywhere else.
Covers terrain and blending, scenery, static meshes, water, fog, and the whole
retained UI. Does not cover sky (masked), EnvCell interiors, particles, or the
paperdoll viewports, since the offline scene is a fixed outdoor view - so V4e,
V4f and V4g keep a user visual gate on top of this one.
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>
The transitional path for slices V4a-V4g needs no special API after all.
On GL, BeginPass binds the target framebuffer and applies load ops but
deliberately leaves viewport and scissor to the encoder, so a renderer
being ported mid-campaign opens a Load/Store pass against the backbuffer
and gets exactly today's behavior while the frame spine still owns clears.
One less contract concept, and one less thing for V4h to unwind.
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>
Pin the tested Windows/Linux portability boundary, exact rollback, dependency audit, and synchronized architecture and roadmap state before starting the production single-session host.
Co-authored-by: Codex <noreply@openai.com>
Record the shared graphical/no-window reset architecture, deterministic lifecycle evidence, exact rollback point, and synchronized project guidance before beginning the Linux headless host.
Co-authored-by: Codex <noreply@openai.com>