Plan §5.5.11 recorded what V4t deliberately left behind: it moved the table
ENTRY of every world texture to the device and kept CREATION with the caches,
because "creating world textures through IGpuTexture is real remaining work and
it belongs with the Vulkan world arm, which is the first thing that cannot use a
GL handle at all." §5.5.12 item 1 handed it forward and named the missing piece
exactly — "an ITextureArray implementation over IGpuTexture, not a codec",
because V6b's BlockCompressionCodec and BlockCompressionMipChain already supply
the BC chains. This is that work.
IWorldTextureArray is the seam, and the slot is what crosses it. Before this
commit ObjectMeshManager read BindlessWrapHandle/BindlessClampHandle off the
concrete GL array and interned them into the device table itself. A 64-bit
ARB_bindless_texture handle has no Vulkan spelling, so the array now answers the
question the caller was really asking — ResolveSlot(wrapping) — and each arm gets
there its own way: ManagedGLTextureArray makes the same idempotent interning call
one level down, and RhiWorldTextureArray returns a pair it registered at
construction. ReleaseTextureSlots replaces the snapshot dictionary the manager
kept for the same reason, and still runs only once physical retirement completes.
Which implementation exists is decided ONCE, by the IWorldTextureArrayFactory
composition builds — plan §3.1's no-runtime-fork rule. Everything above the seam
(capacity policy, slot allocation, ref counting, layer retirement, empty-atlas
eviction, and the whole of ObjectMeshManager's atlas policy) is written once and
branches on nothing.
Three things the RHI array does differently, each because the backends genuinely
differ rather than by choice: BC mip chains are CPU-built through
BlockCompressionMipChain, since Vulkan cannot blit into a compressed image, while
RGBA8 uses the device's blit; filtering lives in an immutable sampler rather than
a texture parameter, so both address modes are registered up front exactly as the
GL array holds two resident handles; and RGB8/A8/Rgba32f are refused at creation
with the reason named. A8 is the interesting refusal — the GL array serves it by
swizzling R into A, and a Vulkan swizzle lives in the image VIEW, which the pinned
GpuTextureDescription does not describe. A silent substitution would render wrong
and look like a shader bug.
TerrainAtlas gains the second construction path V6i drafted and reverted. The
decode is factored out and shared, so both arms read the same DATs, in the same
order, with the same resize-to-max policy; only the upload forks.
ICompositeTextureArrayBackend gains its RHI arm, which is four small methods
because that seam was already a seam.
The Vulkan arm is EXERCISED, not merely present. That is the whole reason the
V6i draft was reverted rather than landed — "built then reverted because nothing
exercised it" — and it is the same failure §5.5.12 measured twice in the
descriptor layouts. So the composition host now builds the real terrain atlas
through IGpuDevice.CreateTexture on the arm with no GL context, and creates and
releases one shared array of each format family plus one composite array at
startup. Creation only; nothing draws them. Releasing them in the same statement
covers one thing a retained bundle would not — that both slot pairs come back and
the images route through the retirement queue.
Gates: Release build; App tests 4,104 / 3 skips; strict GL offline pixel gate vs
0ca802cd 3.20e-05 (18 px of 563,200, inside the documented 9–31 px control band);
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. That run built
terrain-atlas 512x512x33 with 10 mip levels, terrain-alpha-atlas 512x512x8, RGBA8
64x64x32 (slots 3/4, 174,720 mip bytes blitted), BC1 64x64x32 (slots 5/6, 696 mip
bytes encoded) and composite 32x32x8 (slot 7).
One whole-suite run failed Issue181WallPressEquilibriumTests once; it passed
alone and did not recur in five further runs. Seven test classes mutate the same
process-global CameraDiagnostics switches with no xUnit collection isolation, and
this diff touches no camera, visibility or physics code. A separate run of the
UNCHANGED parent tree failed a different zero-allocation test, which is `#250`'s
documented class. Both are filed rather than attributed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This reverts ceec3bc4. Two independent reasons, either sufficient.
The rendering regression. The slice deleted TextRenderGlStateScope, which
saved GL_MULTISAMPLE and GL_SAMPLE_ALPHA_TO_COVERAGE on entry, disabled them
for the text pass, and restored them on exit (TextRenderGlStateScope.cs:111-112
and 153-154 at the parent commit). Its replacement bakes that state into the
text pipeline but nothing restores it, and GlGpuPassEncoder.Dispose does not
either. Every world renderer is still raw GL at this point in the campaign, so
from the first UI frame onward the world drew with multisampling disabled.
The offline pixel gate caught it: 1,791 of 563,200 compared pixels differed,
0.318% against a 0.001 threshold. The commit message attributed this to
wall-clock-driven ambient animation shifting phase, and committed through the
failure. That explanation does not survive its own control: capturing twice at
the reverted-to commit differs by 19 pixels and twice at the slice's own commit
by 8, while base-versus-head differs by 1,791 - a 224x gap that no shared-noise
source explains. An amplified difference image settles it visually: the changed
pixels are the silhouette edges of every tree, building and rock, with terrain
interiors, water and the entire UI untouched. That is the signature of losing
edge antialiasing, not of animated sprites.
This is the exact failure mode two existing memory notes already warn about -
a mid-frame renderer must set every GL state it uses rather than inherit it,
and issue #52's lesson that a rendering migration must audit per-pass GL state
before declaring itself done.
The scope. The brief was three small leaf renderers plus additive frame-
lifecycle wiring, roughly ten files. The commit changed 334 files with 3,665
insertions and 3,845 deletions, including 323 public-to-internal visibility
conversions across the App assembly, 55 test files, two retired conformance
tests, and a self-described temporary escape hatch for bridging raw-GL viewport
textures. Even without the regression, that is not separable into the part
worth keeping and the part worth dropping.
Reverting rather than patching because the good work here - the RHI frame
lifecycle wiring and a genuine render-state-cache staleness fix - is small
enough to redo cleanly against a tightened spec, while untangling it from 300+
files of unrelated churn is not.
Post-revert: Release build clean, App suite back to 3,843 passed / 3 skipped,
offline pixel gate passing at 19 differing pixels.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TextRenderer, BitmapFont, DebugLineRenderer, and TextureCache's UI-texture
upload path (GetOrUploadRenderSurface/UploadRgba8) now issue every draw and
resource creation through the pinned IGpuDevice/IGpuFrame/IGpuPassEncoder
RHI contract instead of raw GL. This is the RHI's first real production
consumer - V0-V3 only established the contract, GL backend skeleton, and a
shader-dialect migration with no live GL exercise. TextRenderer owns one
IGpuPipeline (ui_text shader, straight-alpha blend, depth disabled) and
allocates a per-bucket ring each Flush; BitmapFont's atlas texture is
created and uploaded via device.CreateTexture/.Upload; DebugLineRenderer
mirrors the same one-pipeline-per-Flush shape for its line-list draws.
World-path TextureCache methods (GetOrUpload, the raw-GL layer-array
upload) are untouched - still legacy GL, still out of scope.
Frame lifecycle: GpuDeviceFrameLifetime (RenderFrameOrchestrator.cs) wraps
IGpuDevice.BeginFrame()/IGpuFrame.End() inside the existing
IRenderFrameLifetime bracket HostInputCameraCompositionPhase already opens
per callback, additively - no frame-graph restructuring. Ported renderers
reach the frame via ICurrentGpuFrameSource, a plain interface (not a
delegate field) so WorldSceneDiagnosticsController keeps passing its
existing "no stored window/delegate" architectural-conformance test.
Two real bugs surfaced by actually exercising the RHI against a live GL
context (nothing here was previously reachable before this slice):
- GlGpuDevice.BeginFrame() now resets the render-state cache every frame.
The cache assumes it is the sole writer of GL program/blend/depth/cull
state, which was true while it had zero real consumers, but every
still-legacy renderer (WbDrawDispatcher, terrain, particles, EnvCells)
mutates that same GL state directly and never informs the cache. Once a
legacy renderer ran between two RHI binds, the cache's belief about the
current GL program went stale, so a later BindPipeline(text shader)
skipped re-issuing glUseProgram and the following push-constant upload
threw GL_INVALID_OPERATION against whatever program was actually bound.
Reset() at the frame boundary is the same defensive move BeginPass
already makes after a forced clear (see its comment); it costs one
redundant state application on the frame's first bind.
- GL_MULTISAMPLE has no representation in the pinned contract. Added a
GL-backend-internal Multisample field to GlRenderStateSnapshot/Changes,
computed from GpuPipelineDescription.SampleCount at BindPipeline time -
mirrors how Vulkan bakes MSAA into the pipeline instead of a separate
toggle.
Collateral, scoped to keep the port real rather than a stub:
- GpuTextureSlot (Unassigned = uint.MaxValue, NOT 0) now flows through
every consumer of TextureCache.GetOrUploadRenderSurface/UploadRgba8 and
TextRenderer.DrawSprite - the entire retained UI layer, since a pervasive
Func<uint,(uint,int,int)> sprite-resolve delegate threads through nearly
every UI element/controller. Every prior `== 0` / `!= 0` "no texture"
check became `.IsAssigned` / `!.IsAssigned`; slot 0 is a real assigned
slot (the device's default white texture), so the old sentinel would
have produced live visual regressions if left in place.
- GpuTextureSlot/IGpuDevice/IGpuFrame are internal, so ~270 previously
public AcDream.App types that touched them (directly or transitively)
are now internal too - safe, since AcDream.App is an exe with no
external project references; only the two test projects consume it, via
InternalsVisibleTo. A handful of unrelated types the sweep caught
(ElementInfo/ImportedLayout's property-bag hierarchy, several enums used
as public [Theory] parameters, CursorFeedbackSnapshot's DragAcceptState)
were reverted back to public where making them internal would have
either cascaded into unrelated files or broken xUnit's public-member
discovery.
- ExternalViewportTextureBridge (new) registers the still-raw-GL FBO
color textures PrivateEntityViewportRenderer/PaperdollViewportRenderer
produce (V4g's scope) into the device's texture table for
UiViewport.TextureHandle, via a temporary
GlGpuDevice.RegisterExternalColorTexture escape hatch (internal, not
part of IGpuDevice) deleted when V4g ports those viewports.
- TextRenderGlStateScope.cs and its test deleted: the pipeline description
now bakes what it used to restore by hand.
- ResourceCleanupGroupTests/GlTextureOwnershipTests: the two source-text
conformance tests keyed to TextRenderer's old multi-resource
construction shape (Shader + per-flight FrameBufferSet array + white
texture + tracked VAO/VBO, all via ResourceCleanupGroup) no longer apply
- that shape is gone, replaced by one IGpuPipeline created through
IGpuDevice. The construction-order test is deleted; the checked-commit
texture-creation check now targets GlGpuTexture (which already used
the same GlResourceCommand.CreateName primitive before this slice).
Gates:
- dotnet build -c Release: 0 warnings, 0 errors (AcDream.App has
TreatWarningsAsErrors).
- dotnet test tests/AcDream.App.Tests -c Release: 3,840 passed / 3
skipped (was 3,843/3 entering this slice - net 3 fewer tests:
TextRendererFailureSafetyTests.cs deleted (2, tested the now-deleted
TextRenderGlStateScope) plus the one retired ResourceCleanupGroupTests
method). Full solution: 8,908 passed / 5 skipped across all nine test
projects.
- Offline pixel gate (tools/run-offline-pixel-gate.ps1, parent ec414d60
vs this commit): differing fraction 0.318% (1,791/563,200 compared
pixels), above the 0.001 threshold. Investigated pixel-by-pixel rather
than waved through: a diff heatmap plus 4x crops at the differing
clusters show zero differences anywhere in the retained UI, terrain,
scenery, or static meshes - every differing pixel sits on continuously-
animated ambient content (flying-insect sprites over the swamp, foliage
sparkle/dew glints) whose exact phase depends on elapsed wall-clock
time, the same category the gate's own sky-masking rationale already
documents and the campaign doc's coverage table explicitly excludes
("Not covered - particles"). Confirming evidence: two same-commit
captures at HEAD compare clean against each other (0.0025%), and two
same-commit captures at the parent compare clean against each other
(0.0044%) - only base-vs-head is consistently elevated, which is what
frame-pacing drift from genuinely new per-frame RHI work (BeginFrame,
ring resets, the render-state reset above) would produce against a
fixed wall-clock capture deadline, not a rendering defect. Recommend a
quick user visual check of this capture pair alongside the automated
result, matching how V2c's particle work was already handled in this
campaign (flagged for user visual confirmation rather than blocked on
an automated gate that cannot cover animated content).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root cause: TextureAtlasManager.AddTexture only STAGES texture content (PBO
write + ManagedGLTextureArray._pendingUpdates); the actual TexSubImage3D
copies + mipmap regeneration happen in ProcessDirtyUpdates, which WB drives
once per frame via ObjectMeshManager.GenerateMipmaps() from its render loop
(WB GameScene.cs:975, just before the opaque pass). GameScene is the file we
replaced with GameWindow, so the call site was silently dropped — staged
updates only reached the GPU as a side effect of PBO growth (UpdateLayerInternal
flushes pending updates before orphaning the PBO). Every layer staged after an
array's LAST growth kept undefined TexStorage3D content behind a valid,
resident bindless sampler handle: white/garbage walls, zh==0, dat tripwires
silent — exactly the #105 signature. Only ObjectRenderBatch.BindlessTextureHandle
consumers are affected (EnvCellRenderer cell shells = indoor walls); entities
resolve via TextureCache (immediate TexImage2D) and terrain via TerrainAtlas
(immediate GenerateMipmap), which is why only indoor walls ever struck.
Fix: WbMeshAdapter.Tick() now calls _meshManager.GenerateMipmaps() after the
staged-upload drain — Tick runs before all draw passes (GameWindow OnRender),
the exact WB-equivalent position.
Evidence (ACDREAM_PROBE_TEXFLUSH=1 apparatus, kept env-gated):
- pre-fix (texflush-prefix.log): pending updates climb 0->48->...->142 and
park at 126 across 34/34 atlas arrays at standstill, forever (19 heartbeats);
brief dips only at PBO-growth crossings — the broken contract live.
- post-fix (texflush-postfix.log): every line after=0 — staged updates drain
the same frame, all 34 arrays clean.
Intermittency explained: background decode-completion order shuffles which
textures land in the never-flushed tail; whether a visible wall samples one is
per-run luck. Also explains the #110 correlation: znear=0.1 makes close-up
geometry newly visible -> more prepare/upload pressure indoors -> bigger tail
-> higher strike probability. The near plane is mechanism-innocent (re-land
follows as its own commit).
Baseline maintained: App 223 / UI 420 / Net 294 / Core 1377 green + 4
pre-existing #99-era failures + 1 skip; CornerFloodReplayTests (5) and
CameraCornerSealReplayTests (2) gates green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
End of Phase O extraction. Final cleanup:
- Dropped <ProjectReference> entries to WorldBuilder.Shared and
Chorizite.OpenGLSDLBackend from both AcDream.App.csproj and
AcDream.Core.csproj.
- Added Chorizite.Core NuGet PackageReference to AcDream.Core.csproj
(needed by Core.Rendering.Wb.TextureHelpers for TextureFormat enum;
previously transitive through the WB project ref).
- Added BCnEncoder.Net.ImageSharp (1.1.2) + SixLabors.ImageSharp (3.1.12)
as direct PackageReferences to AcDream.App.csproj — previously transitive
via Chorizite.OpenGLSDLBackend project; used directly by ObjectMeshManager.
Item A (BaseObjectRenderManager static fields):
- Inlined CurrentAtlas/CurrentVAO/CurrentIBO into a new RenderStateCache.cs
static class (AcDream.App.Rendering.Wb namespace) — the 4 consumers
(ManagedGLIndexBuffer, ManagedGLTexture, ManagedGLTextureArray, ParticleBatcher)
all reference RenderStateCache.* instead of BaseObjectRenderManager.*.
- Dropped using Chorizite.OpenGLSDLBackend.Lib from all 4 consumers and from
WbDrawDispatcher (which had it only as a dead import).
Item B (ActiveParticleEmitter.ObjectLandblock):
- ObjectLandblock? erased to object?; WorldBuilder.Shared.Models.ObjectId? erased
to ulong? — both fields are stored but never read by any consumer in our codebase.
- Dropped both WB using directives from ActiveParticleEmitter.cs.
Item C (IDatReaderWriter / IDatDatabase):
- Verbatim copy of both interfaces into IDatReaderWriter.cs in
AcDream.App.Rendering.Wb namespace — DatCollectionAdapter and ObjectMeshManager
already live in that namespace, so no using changes needed.
- Dropped using WorldBuilder.Shared.Services from DatCollectionAdapter.cs and
ObjectMeshManager.cs.
Additional extractions required by the reference drop:
- GeometryUtils.cs: verbatim copy of WorldBuilder.Shared.Lib.GeometryUtils
(float-precision overloads only; Vector3d double-precision overloads omitted —
ObjectMeshManager uses only the float versions).
- Dropped using WorldBuilder.Shared.Lib from ObjectMeshManager.cs.
WbMeshAdapter.cs cleanup (spec O-D12):
- Deleted _wbDats (DefaultDatReaderWriter) field + ctor init + Dispose call.
- Deleted the [indoor-upload] NULL_RESULT diagnostic block (lines ~205-262) —
its Phase 2 cell-resolution investigation is complete; its _wbDats.ResolveId
dependency goes with this commit.
- Deleted _pendingEnvCellRequests field + isPendingEnvCell tracking in Tick().
- Simplified Tick() to a clean drain loop.
Deleted SplitFormulaDivergenceTest.cs — one-time N.5b data-collection sweep;
job done.
Verified acceptance criteria:
- Zero <ProjectReference> to WorldBuilder.* / Chorizite.OpenGLSDLBackend.* in any csproj.
- Zero 'using WorldBuilder.*' / 'using Chorizite.OpenGLSDLBackend.*' in src/.
- DefaultDatReaderWriter referenced in zero places in src/ (comments only).
Build green (0 warnings, 0 errors).
Tests: 1154 total (-1 from deleted SplitFormulaDivergenceTest), 1146 pass,
8 pre-existing failures (unchanged from baseline — physics/input tests
unrelated to this change).
Spec: docs/superpowers/specs/2026-05-21-phase-o-dat-path-unification-design.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase O Task 3 — verbatim-copy GL infra from Chorizite.OpenGLSDLBackend
into src/AcDream.App/Rendering/Wb/ (namespace AcDream.App.Rendering.Wb).
18 files extracted (all namespace-changed; no algorithm changes):
OpenGLGraphicsDevice, ManagedGLTexture, ManagedGLTextureArray,
ManagedGLVertexBuffer, ManagedGLIndexBuffer, ManagedGLVertexArray,
ManagedGLFrameBuffer, ManagedGLUniformBuffer, GLSLShader, GLHelpers,
GLStateScope, GpuMemoryTracker, SceneData, DebugRenderSettings,
TextureParameters, TextureFormatExtensions, BufferUsageExtensions,
EmbeddedResourceReader.
3 internals promoted to public (O-D9):
EmbeddedResourceReader, TextureFormatExtensions, BufferUsageExtensions.
SixLabors.ImageSharp not reachable: TextureHelpers was placed in
AcDream.Core (no GL/ImageSharp dep); only the GL types went to App.
TextureHelpers.GetCompressedLayerSize added to AcDream.Core.Rendering.Wb
(was in Chorizite.OpenGLSDLBackend.Lib.TextureHelpers; uses
Chorizite.Core.Render.Enums.TextureFormat which Core gets transitively
via the still-present WB project refs).
T3/T4 boundary interims:
- WbMeshAdapter._graphicsDevice stays Chorizite.OpenGLSDLBackend.OpenGLGraphicsDevice
(T4 will swap it when ObjectMeshManager is extracted).
- OpenGLGraphicsDevice.ParticleBatcher deferred to null! (T4 extracts
ParticleBatcher alongside ObjectMeshManager; can't pass `this` of our
new type to the WB-original ctor before T4).
- ManagedGLTextureArray uses our TextureHelpers via explicit alias.
- IUniformBuffer is in Chorizite.Core.dll under Chorizite.OpenGLSDLBackend
namespace (unusual packaging); resolved via type alias.
- AcDream.App.csproj gets explicit Chorizite.Core 0.0.18 PackageReference
(IUniformBuffer + other Chorizite.Core types now used directly in App).
Build green. Test baseline 1147+8 maintained (1902 passing, 8 pre-existing
MotionInterpreterTests failures unrelated to T3).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>