Commit graph

2894 commits

Author SHA1 Message Date
Erik
b537f3a952 Revert "feat(render): Campaign V slice V4d-1 - converge terrain's view and projection"
This reverts commit 0cb1059765.
2026-07-27 22:38:02 +02:00
Erik
ad61f250fb Revert "feat(render): Campaign V slice V4d-2 - move terrain onto the RHI"
This reverts commit b064668b63.
2026-07-27 22:38:02 +02:00
Erik
b064668b63 feat(render): Campaign V slice V4d-2 - move terrain onto the RHI
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>
2026-07-27 21:31:53 +02:00
Erik
0b12bc5799 docs(render): connected verification is available; convert visual debt to a gate
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>
2026-07-27 21:19:24 +02:00
Erik
0cb1059765 feat(render): Campaign V slice V4d-1 - converge terrain's view and projection
terrain_modern.vert took uView and uProjection as two mat4 uniforms and formed
`uProjection * uView` per vertex. GpuPushConstants carries one ViewProjection,
so terrain could not reach the pinned push-constant block until these became
one uniform. This does that change and nothing else.

The transform is unchanged. System.Numerics is row-vector and stores row-major;
uploaded untransposed, GLSL reads those bytes as column-major, which is the
transpose. So the CPU's camera.View * camera.Projection arrives in the shader as
(View*Proj)^T = Proj^T * View^T - exactly the uProjection * uView it replaces.
The product it now uses is the same one Draw already computed for the per-cell
visibility pass, so no new work is done either.

What genuinely changes is where the multiply happens: per-vertex on the GPU
before, once on the CPU now. Two float32 matrix products with different
association and rounding are not bit-identical, and terrain fills most of the
gate's frame, so this was split into its own commit to make that effect
attributable rather than buried in the V4d-2 plumbing diff.

It is not measurable. The gate run against c7f5f251 reported 32 differing
pixels of 563,200 (fraction 5.68e-05, 17.6x under the 0.001 threshold). Because
32 sits just above the plan's recorded 8-29 noise band, the difference was
characterised rather than accepted: three captures were taken at this commit and
compared every way.

  cross-commit (c7f5f251 vs here):  32, 26, 26
  same-commit  (here vs here):      28, 27,  8

The distributions are the same distribution. Two cross-commit pairs (26, 26)
differ by LESS than two same-commit pairs (27, 28), and a systematic shift
cannot produce that - it would floor every cross-commit comparison above every
same-commit one. maximumChannelDelta is 48-49 in all six comparisons, including
the pure same-commit controls, so the few large-delta pixels are a property of
the capture, not of this change. The 32 was the high draw of a noise
distribution whose floor today spans roughly 8 to 32; a same-commit control
measured 17 at cb0182a0 earlier in the session, so the band drifted on its own,
with an unchanged binary, by more than this change moved anything.

Gates. Release build green with TreatWarningsAsErrors. App tests 3,846 passed /
3 skipped over two consecutive clean runs. A third run failed only
UiDatFontTests.InstanceMeasureWidth_ReusesGlyphTableWithoutAllocating, which is
the known issue #250 flake on an unchanged tree. Two files changed, CRLF and
UTF-8 preserved.

No divergence-register row: the transform is identical and no retail-facing
behaviour changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 21:17:04 +02:00
Erik
c7f5f251f8 feat(render): add integer vertex attributes and the terrain tiling binding
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>
2026-07-27 21:11:48 +02:00
Erik
cb0182a06c docs(render): close Campaign V slice V4c and pin its two obligations
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>
2026-07-27 20:58:07 +02:00
Erik
f353fb53f8 feat(render): Campaign V slice V4c - move the world draw path onto the RHI
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>
2026-07-27 20:55:25 +02:00
Erik
111e72362f feat(render): add GpuBlendMode.InverseAlpha and re-scope Campaign V4c
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>
2026-07-27 20:20:33 +02:00
Erik
a475732587 test(render): fail the offline pixel gate on stray camera input
The offline capture window is minimised but still focusable, so a scroll or key press from whoever is at the keyboard can move the camera mid-capture. That yields two screenshots of the same scene from different camera positions and an enormous, entirely spurious pixel difference - which happened during slice V4b and was correctly discarded rather than interpreted. The gate now detects camera-affecting input in the client log and exits 2, so a perturbed run cannot be mistaken for a rendering regression in either direction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 20:05:25 +02:00
Erik
e946b46f75 feat(render): Campaign V slice V4b - move the mesh arena onto IGpuBuffer
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>
2026-07-27 20:02:50 +02:00
Erik
79ee2361ad docs(render): close Campaign V slice V4a and file #249
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>
2026-07-27 19:41:08 +02:00
Erik
096dd203fa feat(render): Campaign V slice V4a - port TextRenderer/BitmapFont/DebugLineRenderer/TextureCache UI path onto IGpuDevice
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>
2026-07-27 19:37:19 +02:00
Erik
a97e04ae3d docs(render): record the Campaign V4a revert rules
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>
2026-07-27 18:30:40 +02:00
Erik
9aaf97e785 Revert "Campaign V slice V4a" - it lost world multisampling
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>
2026-07-27 18:29:28 +02:00
Erik
ceec3bc440 feat(render): Campaign V slice V4a - port TextRenderer/BitmapFont/DebugLineRenderer/TextureCache onto IGpuDevice
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>
2026-07-27 18:22:08 +02:00
Erik
ec414d60cd docs(render): Campaign V slice V3 - clip-space and sRGB audit
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>
2026-07-27 16:20:34 +02:00
Erik
22b5abba57 docs(render): close Campaign V slice V2
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>
2026-07-27 16:04:50 +02:00
Erik
a85743f70d feat(render): Campaign V slice V2c - particle texture-index migration
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>
2026-07-27 16:02:59 +02:00
Erik
1f1f6c088b feat(render): Campaign V slice V2b - terrain texture-index migration
Continues the V2a mesh-path conversion onto TerrainModernRenderer: its two
per-pass bindless texture handles (the terrain atlas and the alpha-mask
atlas) now travel as table indices instead of raw 64-bit
ARB_bindless_texture handles, with zero pixel change.

Terrain differs structurally from the mesh path: it has no per-batch SSBO at
all, just two handles set once per draw as plain uniforms
(terrain_modern.frag's uTerrainHandle/uAlphaHandle, reconstructed via the
sampler2DArray(handle) macros uTerrain/uAlpha). So instead of a BatchData
struct field, the two uniforms became uTextureIndexA/uTextureIndexB - named
to match the pinned GpuPushConstants.TextureIndexA/B fields (campaign doc
section 3.4) so V4d's eventual move to push constants is a rename, not a
redesign. There is no push-constant plumbing yet, so these stay plain
uniforms for now, set via ProgramUniform1 instead of ProgramUniform2.

TerrainModernRenderer owns its own GlBindlessHandleTable and binding=9 SSBO
(the same GL-only handle-table emulation V2a introduced), independent of
WbDrawDispatcher's and EnvCellRenderer's - nothing requires index agreement
between renderers, and terrain only ever registers two handles per draw
(the atlas's terrain/alpha textures), so its table is dirty only once, on
first draw. Unlike WbDrawDispatcher/EnvCellRenderer, TerrainModernRenderer
already eagerly creates its other GL resources in the constructor with a
ResourceCleanupGroup rollback, so the texture-table SSBO is created there
too rather than lazily.

TerrainAtlas needed no change: GetBindlessHandles() keeps returning the raw
(ulong terrain, ulong alpha) pair unchanged - the table lookup is entirely a
TerrainModernRenderer-side concern, added at the one draw-call site that
already converts those handles into shader state.

Shader-side: terrain_modern.frag's uTerrain/uAlpha macros now expand through
common.glsl's ACDREAM_TEXTURE_HANDLE(idx) lookup; both terrain_modern.vert
and .frag opted into the common.glsl preamble (Shader's
includeCommonPreamble, introduced at V2a) so their SceneLighting UBO
declarations could also pick up the ACDREAM_UBO_SET scaffolding macro -
terrain_modern.vert doesn't touch the texture table itself, but sharing the
same preamble across both stages of a technique is simpler to reason about
than deciding per-stage.

Gate: dotnet build -c Release green, dotnet test tests/AcDream.App.Tests
-c Release green (3843 passed / 3 skipped, matching V2a), and
tools/run-offline-pixel-gate.ps1 passed against the V2a commit's build with
a 2.49e-05 differing-pixel fraction - 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>
2026-07-27 15:55:18 +02:00
Erik
d365476ebb feat(render): Campaign V slice V2a - mesh path texture-index migration
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>
2026-07-27 15:51:26 +02:00
Erik
8b57ba7167 docs(render): correct Campaign V slice V2 texture-table ownership
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>
2026-07-27 15:20:34 +02:00
Erik
86afbe2ecd test(render): add the Campaign V offline pixel gate
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>
2026-07-27 15:19:43 +02:00
Erik
4f94ad7ddd feat(render): Campaign V slice V1 - OpenGL RHI backend (dark)
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>
2026-07-27 15:11:04 +02:00
Erik
90f7c6f2f4 docs(render): drop the ambient-encoder relaxation from Campaign V
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>
2026-07-27 14:28:06 +02:00
Erik
621b16364b feat(render): Campaign V slice V0 — pin the Vulkan-shaped RHI contract
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>
2026-07-27 14:26:26 +02:00
Erik
f6275f4501 docs: reconcile project state and refresh README
Some checks failed
Headless portability / portable-headless (ubuntu-latest) (push) Has been cancelled
Headless portability / portable-headless (windows-latest) (push) Has been cancelled
Headless portability / linux-graphical (push) Has been cancelled
2026-07-27 12:53:31 +02:00
Erik
97a095d628 docs(linux): park Slice L at L1 checkpoint 2026-07-27 12:35:47 +02:00
Erik
11501d52ca feat(linux): gate graphical backends and capabilities 2026-07-27 12:26:58 +02:00
Erik
07bb1c5a74 docs(linux): close Slice L0 2026-07-27 12:00:10 +02:00
Erik
66f114b258 feat(linux): add graphical platform services 2026-07-27 11:54:59 +02:00
Erik
1628d9f587 docs(headless): close modern runtime slice K 2026-07-27 11:31:19 +02:00
Erik
b2ab0956f2 docs(headless): record K4 resource envelope 2026-07-27 11:21:44 +02:00
Erik
776482da82 perf(headless): enforce K4 resource envelope 2026-07-27 11:14:50 +02:00
Erik
bd236ce553 test(headless): cover K4 death and cancellation 2026-07-27 11:08:31 +02:00
Erik
97c174fbb2 test(headless): enforce 30-root K4 endurance 2026-07-27 11:02:44 +02:00
Erik
93c6c54220 docs(headless): record K4 telemetry checkpoint 2026-07-27 10:50:32 +02:00
Erik
cb512fd091 perf(headless): establish K4 resource telemetry 2026-07-27 10:47:31 +02:00
Erik
827a039760 docs(headless): close K3 connected gates 2026-07-27 10:28:53 +02:00
Erik
3f3401257c fix(headless): complete connected movement gate 2026-07-27 10:26:44 +02:00
Erik
fd9559a063 docs(headless): record K3 isolation checkpoint 2026-07-27 09:28:47 +02:00
Erik
b6547ff38c feat(headless): hydrate isolated collision worlds 2026-07-27 09:25:58 +02:00
Erik
9569dadb57 feat(headless): share immutable gameplay content 2026-07-27 09:00:48 +02:00
Erik
12b500d383 feat(headless): share immutable process content 2026-07-27 08:37:24 +02:00
Erik
fbdb58a962 docs(headless): close modern runtime slice K2 2026-07-27 08:26:23 +02:00
Erik
38e83640d9 feat(headless): complete deterministic bot command parity 2026-07-27 08:23:36 +02:00
Erik
7e8acb74dd feat(headless): add deterministic multi-session scheduler 2026-07-27 07:51:49 +02:00
Erik
b299e3738e docs: close modern runtime slice K1 2026-07-27 07:39:56 +02:00
Erik
f8cb840fb1 feat(headless): complete portable single-session host 2026-07-27 07:36:53 +02:00
Erik
fbebb91848 docs(headless): close K0 and activate K1
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>
2026-07-27 07:05:45 +02:00