Commit 2 deleted the GL rendering backend's implementations; this step
removes the package references and shader vocabulary they leave behind,
so nothing in the App project still spells Silk.NET.OpenGL.
Silk.NET.OpenGL and Silk.NET.OpenGL.Extensions.ARB are dropped from
AcDream.App.csproj. Chorizite.Core stays — the audit is NOT clean: its
Render.Enums (TextureFormat, BufferUsage) and Lib.BoundingBox types are
used directly and extensively across the Wb texture/mesh pipeline,
independent of the deleted GL IUniformBuffer implementers the package
comment used to cite. The stale comment is corrected in place.
IMeshPipelineDevice.Gl is removed along with the GL? gl parameter
threaded through WbMeshAdapter's four constructors, WorldRenderComposition's
CreateMeshAdapter, and VulkanMeshPipelineDevice's Gl => null
implementation — nothing read any of them once the legacy per-mesh
upload bodies were gone (confirmed by grep: the sole non-doc-comment hit
was a test assertion). While in WbMeshAdapter.Dispose(), found and fixed
a real bug along the way: its teardown still pattern-matched the deleted
GL GpuFrameFlightController to decide whether to wait for submitted work,
which VulkanFrameFlightController replaced at slice V6a without this site
being updated — so the wait had been silently dead on every Vulkan run
since then. Retargeted to VulkanFrameFlightController, which carries the
same WaitForSubmittedWork().
The GL pixel-format vocabulary (Silk.NET.OpenGL.PixelFormat/PixelType) that
WorldTextureArray/TextureFormatExtensions/TextureAtlasManager used for
upload validation is replaced by AcDream.Content's existing Silk.NET-free
UploadPixelFormat/UploadPixelType enums (added at MP1a to keep the bake
tool GL-free); two new members (Rgb, Red, Float) extend that enum with
their GL ABI constants to cover the full vocabulary WorldTextureArray
needs, since MP1a's original set only covered what the extractor itself
emits. ObjectMeshManager's App-boundary cast
`(Silk.NET.OpenGL.PixelFormat?)batch.UploadPixelFormat` becomes a direct
pass-through now that both sides share the type.
GpuBindingModel.StorageTextureTable (the GL-only binding=9 emulation of
the Vulkan texture table) is deleted and StorageBindingCount drops from
10 to 9; the descriptor-set-layout code that builds from that count
(VulkanPipelineLayouts, VulkanFrameBindings) is untouched and just
allocates one fewer always-dummy-seeded, always-unused binding.
Several fully dead GL-only classes came along for the ride, confirmed by
zero construction sites: SilkFramebufferViewportTarget
(NullFramebufferViewportTarget is the sole production
IFramebufferViewportTarget), SilkRenderGlStateReader
(NullRenderGlStateReader.Instance is the sole IRenderGlStateReader),
RuntimeRenderFrameClearPhase (VulkanRenderFrameClearPhase is the sole
IRenderFrameClearPhase, expressing the same atmosphere-clear logic as a
pass load-op instead), and GpuFrameTimer plus FrameProfiler's
GL-owning FrameBoundary(GL) overload and BeginGpuFrame/EndGpuFrame
bracket (RecordGpuSample is the only GPU-timing path any backend uses
now — the ACDREAM_WB_DIAG nested-query exclusion these existed for no
longer applies, since WbDrawDispatcher's own diagnostic GPU sampling
already moved to the device's Vulkan timer pool). GpuFrameFlightController
itself stays (never constructed with a real fence API in production, but
its retirement-ledger/serial-ring logic is backend-neutral and still
covered by its own unit tests) — only its GL-specific parts (the public
GL constructor overload, SilkGpuFenceApi) are deleted, since removing the
whole class would mean restructuring the frozen Slice-8 composition
shape's GpuFrameFlightController? threading, which is out of this
commit's scope. TextureParameters.cs and BufferUsageExtensions.cs
(zero callers each) are deleted outright.
common.glsl is deleted: nothing in the actual Vulkan .spv build reads
it. tools/ShaderCompiler/Program.cs compiles each .vert/.frag pair
directly and tools/ShaderCompiler/VulkanGlslPreamble.cs injects its own
complete self-contained preamble per file; common.glsl's textual
concatenation was exclusively Shader.cs's GL-only mechanism, deleted at
Commit 2. The five shader files that named it in comments
(mesh_modern.vert, particle.vert, particle.frag, sky.frag,
terrain_modern.frag) are corrected to point at VulkanGlslPreamble.cs
instead. mesh.vert/mesh.frag — the pre-N.5 legacy shader pair the
mandatory modern path already made unreachable, with zero C# consumers
and no compiled .spv — are deleted too. Regenerated via
tools/compile-shaders.ps1: 9/9 remaining shader pairs compile
(previously 9/10, with mesh the sole failure — the VulkanShaderManifestTests
doc comment's "nine of ten are not Vulkan-expressible" was already
stale before this commit).
Test fallout: dead-subject test methods/files are deleted rather than
patched (TextRendererFailureSafetyTests.cs, ClipFrameUploadTests.cs,
GpuResourceRetirementTransactionTests.cs's GL queue tests, one
WorldRenderDiagnosticsTests source-order test, one
RenderFrameResourceControllerTests clear-phase-order test); tests whose
subject moved or was renamed are updated in place rather than deleted
(GpuContractTests, VulkanCapabilityGateTests, MeshPipelineDeviceSeamTests'
pinned seven-member surface now reads six, ParticleBindlessInstanceTests'
cross-dialect check now covers the one surviving dialect,
WbMeshAdapterTests' misleadingly-named null-gl test — gpuDevice was
always the parameter that actually threw).
Build: `dotnet build AcDream.slnx -c Release` — 0 warnings, 0 errors,
with the Silk.NET.OpenGL/.Extensions.ARB package references physically
removed from the csproj (not just unreferenced in code).
Tests: full-solution `dotnet test` green across every project.
Zero remaining `using Silk.NET.OpenGL` anywhere in src/ or tests/.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The rest of V4t. The composite, particle and shared-atlas texture paths now
hand out the device's GpuTextureSlot instead of a raw 64-bit
ARB_bindless_texture handle, and GroupKey, CachedBatch and ObjectRenderBatch
carry that slot. WbDrawDispatcher, EnvCellRenderer and ParticleRenderer retire
their interim GlBindlessHandleTable instances and share the device's one
table, exactly as V4t-1 did for terrain. Nothing about world submission
changes otherwise: these three renderers are still raw GL, still bind binding
9 themselves, and still draw the same geometry in the same order.
**What produces a slot now.** CompositeTextureArrayCache's GL backend interns
each array's handle when it makes it resident and retires the entry when it
makes it non-resident, so the pair is created and destroyed together and the
cache above it never learns a device exists — the fake backend its tests use
mints a stand-in slot. TextureCache.AcquireParticleTexture does the same for
the one-layer particle arrays it owns, including on its rollback path.
ObjectMeshManager registers each shared atlas's wrap/clamp handles at batch
upload; registration is idempotent by handle, so the many batches sharing an
atlas share its entry.
**Slot release is stricter than what it replaces, not looser.** The interim
tables never released anything — the class comment said so — and they grew
without bound. The device's table has a fixed 16,384-slot capacity, so an
unreleased entry is now a leak with an end. Every producer therefore retires
its entry: the composite backend at MakeNonResident, the particle backend at
MakeNonResident, and ObjectMeshManager when a retiring atlas's PHYSICAL
retirement completes — the point at which its handles are already non-resident
and its texture already deleted. That last one needs the handles snapshotted
at eviction, because ManagedGLTextureArray.Dispose zeroes its own copies as
its first act. Teardown deliberately does not release: the device is being torn
down alongside its callers, so there is nothing left to recycle a slot into,
and deferring work through a possibly-disposed retirement queue would turn a
clean shutdown into a throw.
**The default value became load-bearing, and that is the one real hazard here.**
BindlessTextureLocation could say "not resolved" with handle 0, because no
texture has handle 0. A slot index has no spare value — default(GpuTextureSlot)
is real slot 0 — so a positional record would have turned every
budget-rejected or still-uploading composite into a silent read of whichever
texture registered first. That is the magenta-placeholder failure shape one
layer down. The type is now a struct storing the slot one-based, so default IS
Unresolved, with a test pinning both halves: default is unresolved, and a
location naming slot 0 is resolved and distinguishable from it. Elsewhere the
sentinel is already exact — GpuTextureSlot.Unassigned is 0xFFFFFFFF, which is
common.glsl's ACDREAM_TEXTURE_NONE — so the classify path's "no texture yet"
test and the particle billboard's untextured branch are unchanged in meaning.
**GroupKey ordering is preserved because the key never ordered anything.**
Handle→slot is a bijection (the device interns one slot per resident handle),
so the same (entity, batch) pairs bucket together as before. The key reaches
equality, hashing and the scene-digest fingerprints — never a comparator:
opaque and translucent groups sort by cull mode then camera distance, the
delayed-alpha path by viewer distance then submission ordinal, and group
enumeration follows the persistent dictionary's insertion order, which a
changed hash does not disturb. The digests hash the slot index where they
hashed the handle; both sides of the render-shadow comparison compute them the
same way, so the value changing is invisible to it. Read
CompareOpaqueSubmissionOrder, CompareTransparentSubmissionOrder and
AlphaFingerprintComparer before doubting this — sort-order drift is a
pixel-visible regression class this project has hit, and it is why the check
was made before the retype rather than after.
**One visibility change, forced rather than chosen.** BindlessTextureLocation
was public and now holds an internal contract type, so it is internal;
ObjectRenderBatch.TextureSlot is internal on an otherwise public class for the
same reason. Nothing outside this assembly and its InternalsVisibleTo test
assemblies named either.
**SkyRenderer keeps its interim table**, and the report should say why: the
sky's textures are minted by SkyRenderer itself from TextureCache's raw GL
texture names, which this slice does not retype, so it would be the one
consumer registering handles it produced — a different shape from the world
stack. The offline gate also masks the sky band, so the one automated
instrument here cannot see a sky regression. V4f owns that renderer.
**Gates.** GL offline pixel gate vs cb2a70b8, measured twice: 31 and 22
differing pixels of 563,200 (5.50e-05, 3.91e-05). The first is above the
plan's documented 15-23 px band, so a control was measured rather than
assumed: two same-commit captures at this tree differ by 19 px, and — the
decisive number — a capture at V4t-1 and a capture at this commit differ by
9 px, fewer than the same-commit control. Maximum channel delta is 41-52 in
every pair including the controls, i.e. the differing pixels are drawn from
one flickering population, not from moved geometry. tools/run-repeat-connected-gate.ps1
-Runs 3: 3/3 RENDERED on both the desktop witness and the client capture. One
Vulkan composition-host run with VK_LAYER_KHRONOS_validation proven inserted
by the loader: zero errors, zero warnings, converged ownership ledger. App
tests 4,077 / 3 skips and the complete Release suite 9,140 / 5 — both the
4,075 and 9,138 baselines plus the two tests added here.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The shared vertex/index arena is the largest single GPU allocation acdream
makes (384 MiB + 128 MiB) and the one the Vulkan backend has the most specific
plan for (campaign doc section 4.3). This slice swaps the resource handle type
underneath it and changes nothing else: the reclaimable-range allocator, the
growth quanta, the budgeted incremental grow-and-copy, the retirement-ledger
gating, the abort ticket, the LRU that drives eviction, and the 896 MiB
dual-generation physical ceiling are all untouched. That is deliberate - those
are the semantics section 4.3 says the Vulkan arena must mirror exactly, so
preserving them is the point of the slice rather than an incidental constraint.
What moved:
- GlobalMeshBuffer's two GL buffer objects became IGpuBuffer, allocated through
IGpuDevice.CreateBuffer with DeviceLocal residency and Vertex-or-Index plus
both transfer usages (the arena is simultaneously a draw source and both ends
of its own migration, which is exactly why GpuBufferUsage is a flags enum).
- UploadMesh's two hand-rolled BufferSubData sites became IGpuBuffer.Upload.
The old code staged indices through GL_COPY_WRITE_BUFFER specifically so an
upload could not mutate whichever VAO a preceding render pass left bound;
Upload stages through a neutral binding point of the backend's choosing, so
that property now comes for free instead of by hand.
- AdvanceMigration's CopyBufferSubData became IGpuBuffer.CopyTo - a device-side
copy, which the Vulkan backend will record as vkCmdCopyBuffer. The live
prefix still never round-trips through system memory.
- BeginMigration/CommitMigration/AbortMigration/Dispose now carry IGpuBuffer in
the migration record and the abort ticket instead of raw uint names, so the
ticket's identity check is a resource identity rather than a number that goes
stale the moment the buffer is deleted.
What deliberately did not move. A VAO has no RHI verb - Vulkan bakes vertex
input into the pipeline - and WbDrawDispatcher, EnvCellRenderer and
ParticleRenderer still bind VAO/VBO/IBO with raw GL until V4c hands them the
pass encoder. So GlobalMeshBuffer keeps its GL handle for the vertex array and
its attribute layout, and VBO/IBO became computed properties that publish the
backing GL name of the buffer the arena now owns as an IGpuBuffer. One private
RequireGlBuffer helper is the single place that reaches through the interface,
and it disappears with those consumers. ObjectMeshManager therefore needed no
upload-path change at all - it reads those same three properties.
Two decisions worth recording.
First, arena deletes do not route through IGpuBuffer.Dispose. The arena already
gates every delete behind its own GpuRetirementLedger and decrements its
physical-capacity accounting in the same retirement stage; Dispose would defer
the physical free through the device queue a second time, so the accounting
would run ahead of real GPU residency and could admit a migration that breaches
the 896 MiB ceiling. GlGpuBuffer gains DeleteRetired for callers that have
already proved flight safety, and GlobalMeshBuffer composes it into a release
whose four stages match TrackedGlResource.CreateRetryableBufferDeletion exactly
- precondition, mutation-with-validation, byte accounting, resource-count
accounting - so a driver failure re-issues only the delete and never
double-counts.
Second, two corrections in the GL backend, both required to keep this port
behaviour-preserving rather than merely compiling. GlGpuBuffer's glBufferData
usage hint now follows residency (DeviceLocal -> StaticDraw), which is what the
arena has always requested; the host-writable rings and texture table keep
DynamicDraw and are unaffected. And a failed allocation now releases the GL
name it had already created - GL_OUT_OF_MEMORY is a real outcome for a 384 MiB
growth destination, and the previous code leaked the name on that path.
Plumbing: the device reaches the arena through WbMeshAdapter and
ObjectMeshManager. Their constructors became internal because IGpuDevice is an
internal type by the pinned contract, matching what V4a did for BitmapFont,
DebugLineRenderer and TextRenderer; both classes stay public and every caller
already lives inside AcDream.App or its InternalsVisibleTo test assemblies. The
unused public GlobalMeshBuffer(GL) convenience constructor is gone - it could
not supply a device and had no callers.
Gates. Release build green with TreatWarningsAsErrors. App tests 3,843 passed /
3 skipped, exactly the slice baseline; complete Release suite 8,906 passed / 5
skipped. Offline pixel gate against 79ee2361: 25 differing pixels of 563,200
(fraction 4.44e-05), against a same-commit control captured immediately
afterwards of 24 - the change is indistinguishable from capture noise and sits
40x under the 0.001 threshold. An earlier gate run was discarded rather than
interpreted: its client log showed real ScrollUp/ScrollDown input reaching the
offline window, which zoomed the camera, and a camera-motion difference is not
a rendering result.
No divergence-register row: this slice changes no retail-facing behaviour.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
Queue translucent world GfxObj batches and scene particles in one stable far-to-near stream using transformed DAT sort centers, then drain it at retail's landscape and final-world boundaries. Preserve authored blend, cull, lighting, opacity, and adjacent-only batching so particles behind lifestones are composited through the crystal instead of overpainting it.
Release build succeeds and all 5,914 tests pass with five intentional skips.
Co-authored-by: OpenAI Codex <codex@openai.com>
Preserve canonical live-object ownership across Hidden transitions and remote teleport placement so effects, collision, streaming, and targeting remain synchronized.
Introduce LiveEntityRuntime as the canonical owner of each accepted server-object incarnation, stable local identity, timestamped state, parent relations, runtime components, and exactly-once teardown. Split logical registration from rebucketing so pending landblocks, equipment attachment, pickup re-entry, and GUID replacement reuse the same entity and effect owners.
Keep canonical materialized and visible target/radar views distinct, preserve retail leave_world versus exit_world semantics, gate root simulation while cell-less, and track transitional pre-Create F754 owners through delete and session reset. Remove stale-spawn rehydration and make GpuWorldState spatial-only for live objects.
Add lifecycle, generation, pending, unload, attachment, event-publication, local-ID, rollback, and effect-cleanup coverage; update architecture, milestones, memory, and the divergence register.
Co-Authored-By: Codex <noreply@openai.com>
The decisive probe (3cf6bcc) caught it live in ONE session: a 43-part
staircase entity (src=0x020003F2, healthy MeshRefs tZ=[0.35..15.15])
drew with cache=hit:3 restZero=3 - THREE batches belonging to a 1-part
entity - then under a different hint the correct hit:119. Two
compounding bugs:
1. interiorIdBase = 0x40000000 | (landblockId & 0x00FFFF00) resolved to
0x40YYFF00 for landblock keys 0xXXYYFFFF - the landblock X byte
DISCARDED. Every landblock in a map Y-row shared one id space:
Holtburg town A9B3's 9th interior stab == the AAB3 tower's spiral
staircase, both 0x40B3FF09. Fixed to 0x40000000|(lbX<<16)|(lbY<<8)
(the scenery 0x80XXYY## scheme).
2. The Tier-1 classification cache's #53 tuple key (EntityId,
LandblockHint) was fed the PLAYER's landblock at bucket-draw time
(RetailPViewRenderer.DrawEntityBucket fabricates its tuple with
ctx.PlayerLandblockId), so colliding ids from different landblocks
shared a key: whichever entity classified first under a hint won,
and the loser wore its batches all session (static fast path never
re-classifies). Also: bucket-hinted entries were never swept by
InvalidateLandblock(owner) - stale entries survived owner unload.
Fixed: ResolveCacheLandblockHint derives the hint from the entity's
owning cell (ParentCellId landblock, canonical 0xXXYYFFFF), falling
back to the tuple id for ownerless paths (outdoor stabs/scenery,
where the tuple IS the owner).
Explains the session-shaped repro exactly: town-login + run to the
tower hydrates/classifies town interiors first -> the tower staircase
cache-hits the town twin's batches (stairs missing/partial + a wrong
object near the floor - the "water barrel"); login-inside classifies
the tower first -> usually clean. meshMissing=0 / entSeen==entDrawn
both ways (everything draws, wrong batches). Likely also feeds #113's
distance-dependent phantom staircase (the town twin wearing the
tower's staircase batches).
3 new cache tests pin the collision contract + hint derivation.
Suites: App green / Core 1430+2skip / UI 420 / Net 294.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Remove IndoorCellStencilPipeline + portal_stencil shaders, RenderInsideOutAcdream,
RenderOutsideInAcdream, the A8-perf instrumentation, the cameraInsideBuilding /
ACDREAM_A8_INDOOR_BRANCH branch, and the dead EntitySet partition values. Collapse
the render branch to the default Draw(All) path (U.4a replaces it with the gated
unified pass). Keep all audited EnvCellRenderer / BuildingLoader / CellVisibility /
camera-collision fixes.
Also deleted with the partition: the two test-only walk helpers
(WbDrawDispatcher.WalkEntitiesForTest / WalkEntitiesForTestByCellIds) and their
test files (WbDrawDispatcherEntitySetTests, WbDrawDispatcherCellIdsOverloadTests),
which existed solely to exercise the removed IndoorPass/OutdoorScenery/
BuildingShells/LiveDynamic partition. EntityMatchesSet / IsShellScopedSet collapse
to the All-path constants; the set: parameter is retained as a seam for the
unified pass.
Note: the depth-clear-if-inside default-path workaround was removed per the
U.1 task list — any current indoor-wall degradation persists until a later
Phase U task lands the unified pass (expected, not a regression introduced here).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lands the working A8 indoor-rendering and streaming fixes accumulated this
session. User has verified these visually to some degree (e.g. lifestone /
translucent meshes confirmed fine under the FrontFace flip; bridge / wall /
collision regressions confirmed fixed after travel); not every path has been
exhaustively gated. The cellar-flap defect remains OPEN and will be solved
the retail-faithful way via a dedicated brainstorm (see handoff docs).
Rendering core (reviewed, high confidence):
- EnvCellRenderer SSBO stride fix: upload packed Matrix4x4[] (64B) instead of
the 80B CPU InstanceData struct the shader never expected — fixes the
transform/texture "explosion" for any draw with >1 instance (cells that
dedupe to a shared cellGeomId). Real root cause.
- WB-style global FrontFace(CW) + per-batch CullMode carried through the MDI
layout (GroupKey + BuildIndirectArrays + DrawIndirectRange split into
same-cull runs with absolute uDrawIDOffset per run).
- EntitySet partitioning (IndoorPass / OutdoorScenery / LiveDynamic) +
WorldEntity.BuildingShellAnchorCellId so building shells scope to their
dat-derived building cell instead of rendering everywhere.
- RenderOutsideInAcdream (look into buildings from outside) +
CollectVisiblePortalBuildings frustum cull of portal bounds.
- Sky-when-inside-building + per-cell audit probe + GL-state probe.
Streaming / perf (test-covered; not independently code-reviewed this session):
- Near/far priority queues so near work wins over far; PromoteToNear carries
full landblock + mesh data; LandblockEntriesWithoutAnimatedIndex avoids
rebuilding the animated-lookup dict in the hot draw path. Fixes the
bridge-not-appearing / missing-walls / broken-collision-after-travel
regressions and improves post-transition FPS.
Tooling + docs:
- tools/A8CellAudit: offline dat cell/portal/building dumper (portals +
buildings modes) — reproduces the cellar-flap investigation with no launch.
- docs/research cellar-flap root-cause + option-2 handoff (the didInsideStencil
double-duty finding + the WB-recursive design decision + brainstorm prompt),
entity-taxonomy, replan, issue-78 visibility investigation.
Diagnostics retained on purpose: ACDREAM_A8_DIAG_* gates, portal_stencil.vert
provisional pos.w clamp, and the probe families are kept (env-var gated, zero
cost when off) because the pending option-2 cellar-flap brainstorm needs them.
Strip in the option-2 ship commit.
Indoor branch stays behind ACDREAM_A8_INDOOR_BRANCH=1 (default off = pre-A8
visual). Build green; App tests + Core (streaming/dispatcher/loader) tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a new public overload accepting an explicit IReadOnlyCollection<uint>
cellIds (the camera-buildings' EnvCellIds) instead of a BFS-derived
visibility set. Used by RR7's IndoorPass to scope indoor rendering to the
camera-buildings' cells, not the full portal BFS (which causes Issues A+C).
Pure-data test helper WalkEntitiesForTestByCellIds added alongside the
production overload, mirroring the WalkEntitiesForTest pattern.
The overload internally delegates to the existing visibleCellIds path —
the dispatcher's semantic stays the same; only the caller's intent differs
(explicit cell list vs visibility-derived).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reshapes the dormant EntitySet enum from binary IndoorOnly/OutdoorOnly to
a three-way taxonomy-aware partition:
IndoorPass — cell mesh + cell statics + building shells
(ParentCellId.HasValue OR IsBuildingShell), live-dynamic
excluded
OutdoorScenery — outdoor scenery only (ParentCellId == null AND
!IsBuildingShell), live-dynamic excluded
LiveDynamic — ServerGuid != 0 (player, NPCs, dropped items)
Centralizes the membership predicate in EntityMatchesSet to keep the three
call sites (two in WalkEntitiesInto, one in WalkEntitiesForTest) DRY.
R1's IsBuildingShell flag is now consumed at render time. Integration into
the render frame ships in R3.
Tests rebuilt from scratch — 7 cases cover the new partition truth table.
Existing dispatcher tests (Tier 1 cache, etc.) continue to pass under the
default EntitySet.All.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Visual verification of A8 (commit 41c2e67) surfaced a showstopper:
player + NPCs disappeared when the camera entered a building. Root
cause: live server-spawned entities (animated player/NPCs/monsters)
have ParentCellId == null. The EntitySet partition classified them
as "outdoor" and stencil-gated them in the OutdoorOnly pass — so
they only rendered where stencil bit 1 was set (portal silhouettes),
producing partial-body and head-backwards artifacts at doorway
transits and full invisibility everywhere else inside.
Fix: animatedEntityIds overrides the ParentCellId-based partition.
Animated entities always belong in the IndoorOnly pass (stencil OFF),
never in OutdoorOnly. Three changes:
- WalkEntitiesInto full-walk path: compute isAnimated up front, use
it in both partition checks
- WalkEntitiesInto animated-only path: skip the entire path on
OutdoorOnly (every iterated entity is animated by definition)
- WalkEntitiesForTest: add optional animatedEntityIds parameter,
mirror the new partition logic
Two new tests cover:
- EntitySet_IndoorOnly_IncludesAnimatedEntitiesEvenWithNullParentCellId
- EntitySet_OutdoorOnly_ExcludesAnimatedEntities
Known remaining limitation: dropped items / static-but-live objects
have ParentCellId == null AND are NOT in animatedEntityIds, so they
still classify as outdoor scenery and stencil-gate. Addressing this
requires a "live entity" flag on WorldEntity — deferred.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds EntitySet { All, IndoorOnly, OutdoorOnly } and a Draw parameter to
partition the per-entity walk by ParentCellId presence. EntitySet.All
preserves pre-A8 behavior; IndoorOnly drops null-ParentCellId entities;
OutdoorOnly drops ParentCellId.HasValue entities. The visibleCellIds
filter is still applied on top.
Used by Task 7 to split the render frame's single Draw call into two
(indoor stencil-OFF, outdoor stencil-gated).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five small post-cleanup items from T7 code review:
I1: Removed dead `datDir` parameter from WbMeshAdapter ctor (parameter
was unused after _wbDats removal; ArgumentNullException.ThrowIfNull
was misleading). Updated call sites in GameWindow.cs and
WbMeshAdapterTests.cs.
I2: Updated stale GameWindow.cs comment that still described
WbMeshAdapter as opening its own dat handles. Now reflects Phase O
state: shared DatCollection via DatCollectionAdapter.
I3: Documented thread-safety contract on RenderStateCache (render-thread
only — required for the mutable-static GL sentinel pattern).
M1: Added comment on IDatReaderWriter's write-path methods noting they
are preserved for verbatim compatibility but unused in acdream.
M3: Added comment on Chorizite.Core PackageReference in Core.csproj
explaining the previously-transitive dependency.
Also excluded SplitFormulaDivergenceTest.cs from the test build via
<Compile Remove>: this N.5b one-time data-collection test referenced
WorldBuilder.Shared types directly; after Phase O-T7 dropped that
project reference it no longer compiles. The sweep data it produced
already informed the N.5b Path-C decision and the file is retained
in the tree for historical reference.
Build green; tests green (1146 + 8 pre-existing failures baseline
maintained).
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>
User reported: the drudge statue on top of the Foundry (a multi-part
live-spawned entity with AnimPartChange + texChanges) renders only
PARTIALLY — some parts visible, some missing.
Root cause: the dispatcher's slow path skips a MeshRef when
_meshAdapter.TryGetRenderData returns null (mesh still async-decoding
via ObjectMeshManager.PrepareMeshDataAsync). The classified-batches
collector accumulates only the MeshRefs that DID resolve. At entity
boundary, the cache populates with the PARTIAL set. Frame-2 cache hits
serve that partial entry forever — even after the missing mesh loads,
the cache continues to skip those parts because classification never
reruns for cached entities.
Fix: track currentEntityIncomplete during the foreach. Set it true on
any null renderData. At entity boundary (and at end-of-loop), if the
flag is set, DROP the accumulated populate scratch instead of writing
it to the cache. The slow path retries on the next frame; once all
meshes have loaded, the populate fires correctly with the complete
classification.
Adds a regression test pinning the contract — incomplete entities
produce zero cache entries.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User confirmed via A/B test (ACDREAM_DISABLE_TIER1_CACHE=1) that the
visual bug — buildings rendering up in the air outside Holtburg — is in
the cache wiring, not elsewhere. The matrix math (restPose * entityWorld
== model) was provably correct, so the bug had to be cache key collision.
Stabs were namespaced in commit 71d0edc, but scenery (0x80LLBB00 +
localIndex) and interior (0x40LLBB00 + localCounter) still have the
same 256-overflow risk. Dense LBs outside Holtburg (forest, urban) push
localIndex past 255, wrapping into the lbY byte and creating cross-LB
collisions.
Fix: change the cache key from uint entityId to (uint, uint) tuple of
(EntityId, LandblockHint). The cache is now correct-by-construction
regardless of any hydration path's Id-generation strategy. Defensive
against future regressions in any ID namespace.
InvalidateEntity becomes a sweep (was O(1)), but it's called rarely
(only on live-entity despawn). InvalidateLandblock was already a sweep.
Updated 14 existing cache tests + 1 dispatcher integration test to thread
landblockHint through TryGet / DebugCrossCheck calls.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds EntityClassificationCache.DebugCrossCheck(entityId, liveBatches) that
asserts cached state matches a live re-classification. Wires a simpler
predicate assert into WbDrawDispatcher's cache-hit branch (asserts
isAnimated == false on cache hit). Tests #13a and #13b cover the
batch-count mismatch and clean-match cases via a custom TraceListener
that captures Debug.Assert calls.
Zero cost in Release. In DEBUG, the assert fires immediately if a future
regression mutates static-entity state outside the audit's known write
sites — the same failure mode that bit the prior Tier 1 attempt.
Phase 4 complete. Cache + invalidation + safety net all in place.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GameWindow.RemoveLiveEntityByServerGuid now invalidates the entity's
cache entry next to the existing _animatedEntities.Remove(). Fires for
DeleteObject (0xF747) and the dedup leg of ObjDescEvent (0xF625).
Adds test #15 (despawn-respawn under reused id repopulates fresh) per
spec section 7.5 — pins the audit's ObjDescEvent-as-despawn-respawn contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 10 (commit 0cbef3c) called ApplyCacheHit inside the per-(entity, partIdx)
foreach loop, but cachedEntry.Batches is flat across all MeshRefs of the
entity. For a 3-MeshRef static building on frame 2: 3 tuples times 6 cached
batches per call = 18 instances drawn instead of 6. Severe Z-fighting and
3x perf hit on every multi-part static entity (buildings, statues, multi-
MeshRef NPCs).
This is the symmetric mirror of the Task 9 bug fixed at 00fa8ae. Both
spec section 5.2 and the plan describe the foreach as per-entity, but
_walkScratch has been per-tuple since Task 6. The implementation
faithfully ported the buggy spec.
Fix: track lastHitEntityId; the cache-hit fast path fires only on the
first tuple of each entity, and subsequent tuples skip the iteration
body via continue. Adds a regression test pinning the per-entity
amplification invariant.
Caught by code review (subagent-driven-development) before Phase 3
dispatched. The bug was invisible in the no-multi-frame-test 1702/8
baseline; would have manifested as visible Z-fighting on every multi-
part building on second-and-subsequent frames once Task 13 perf gate
captured live runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WbDrawDispatcher.Draw now branches on cache hit before running classification:
on hit, walks the cached flat batch list and appends RestPose times entityWorld
to the matching groups; on miss, runs today's classification and populates
the cache (Task 9). Animated entities skip the cache entirely.
Adds dispatcher integration tests #11 (static entity populates + reuses)
and #12 (animated bypasses) per spec test plan section 7.2, plus the
multi-MeshRef regression test that would have caught the bug fixed in
commit 00fa8ae (cache populate must flush at entity boundary, not per-tuple).
Phase 2 (dispatcher integration) complete. End-to-end caching now live.
Invalidation hooks (Phase 3) ensure correctness across despawns + LB demotes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Idempotent removal of a cached entry by entity id. Tests #4 and #5 from
spec section 7.1 lock in the contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements Populate (insert-or-overwrite) and adds 5 tests covering the
populate->TryGet round-trip including the Setup pre-flatten shape. Per
spec test plan section 7.1 tests #2, #3, #9, #10, #14.
Tests use xUnit Assert.* (not FluentAssertions) to match the Task 2
implementer's choice and the existing 149 sibling assertions in the Wb
test directory.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds CachedBatch, EntityCacheEntry, and EntityClassificationCache with
just TryGet (returns false on empty). The skeleton compiles and the first
test (TryGet_EmptyCache_ReturnsFalse) passes. Subsequent tasks add
Populate, InvalidateEntity, InvalidateLandblock, and the dispatcher
integration. Per spec design Section 6.1.
Note: CachedBatch / EntityCacheEntry / EntityClassificationCache are
internal (not public as the plan snippet showed). Their members
transitively reference the internal GroupKey type, so promoting them to
public produces CS0051 inconsistent-accessibility errors. The cache is
dispatcher-internal coordination state anyway, and the AcDream.App
csproj already exposes internals to AcDream.Core.Tests via
InternalsVisibleTo, so the test sees everything it needs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per Phase A.5 spec §4.6 Change #1: when an LB is invisible AND
animatedEntityIds is non-empty, the inner loop walked every entity
in the LB just to find the few animated ones. At ~10.7K entities
(N1=4) that is wasted iteration cost per frame.
Extracted a pure-CPU internal static WalkEntities helper. When LB
is invisible: iterate animatedEntityIds directly and look each up
in a per-LB AnimatedById dictionary (typically <50 animated vs
~10K total). When LB is visible: walk all entities as before.
GpuWorldState.LandblockEntries now yields an AnimatedById map as a
5th tuple field alongside the AABB tuple. Dictionary is built on
each yield (cheap — ~132 entities/LB max). A caching layer is out
of A.5 scope.
WbDrawDispatcher.Draw signature updated to consume the 5-tuple.
GameWindow.cs call site passes _worldState.LandblockEntries which
now yields the 5-tuple — no change needed there.
8 new tests in WbDrawDispatcherBucketingTests cover T17 Change #1
(invisible LB / animated set / neverCull / null frustum) and
T18 Change #2 guard tests (cached AABB / dirty flag / animated bypass).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final cross-cutting review of N.5 found that Task 15's deletion of
mesh_instanced.vert/.frag left InstancedMeshRenderer orphaned —
ACDREAM_USE_WB_FOUNDATION=0 silently rendered terrain+sky only with
no entities. The SHIP commit's "[x] ACDREAM_USE_WB_FOUNDATION=0 still
works" claim was inaccurate.
Resolution: formal retirement of the legacy renderer path within N.5
instead of deferring to N.6.
Deleted:
- src/AcDream.App/Rendering/InstancedMeshRenderer.cs
- src/AcDream.App/Rendering/StaticMeshRenderer.cs
- src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs
GameWindow simplified — capability detection is unconditional, missing
bindless throws NotSupportedException with a clear message at startup.
WbDrawDispatcher + mesh_modern shader load are mandatory after init.
No escape hatch.
GpuWorldState simplified — WbFoundationFlag.IsEnabled guards on
AddLandblock/RemoveLandblock removed; adapter calls are unconditional
when the adapter is non-null.
PendingSpawnIntegrationTests updated — WbFoundationFlag.ForTestsOnly_ForceEnable
static ctor removed (flag is gone; adapter calls are unconditional).
The ApplyLoadedTerrain physics-data loop was also simplified: the
EnsureUploaded sub-loop that fed InstancedMeshRenderer is gone;
_pendingCellMeshes is now explicitly cleared to prevent unbounded
accumulation (the worker thread still populates it, but WB handles
EnvCell geometry through its own pipeline).
Spec §2 Decision 5 + §10 Out-of-Scope updated. Plan ship-amendment
section added. Roadmap updated (N.5 ships with retirement; N.6 scope
narrowed to perf-only). CLAUDE.md "WB integration cribs" updated.
Perf baseline doc updated. WbDrawDispatcher class summary docstring
corrected to describe the as-shipped SSBO + multi-draw-indirect path.
ISSUES.md #51 updated (terrain not in N.5 scope; deferred to N.7).
Bindless support is now a hard requirement. Modern desktop GPUs
universally expose GL_ARB_bindless_texture + GL_ARB_shader_draw_parameters;
if a user hits the NotSupportedException, that's a real bug report
worth investigating, not a silent fallback.
Build: 0 errors, 0 warnings. Tests: 71/71 (Wb+MatrixComposition+TextureCacheBindless filter).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Locks in Decision 2 (Opaque + ClipMap → opaque indirect; AlphaBlend +
Additive + InvAlpha → transparent indirect). Catches future refactors
that drift the partition — silent visual regression otherwise (groups
rendered in the wrong pass with the wrong blend state).
Adds public static IsOpaquePublic shim on WbDrawDispatcher; the
underlying IsOpaque stays private.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code quality review caught:
- sizeofDEIC was a local; promoted to public const DrawCommandStride
so tests can reference it symbolically.
- BatchDataPublic layout invariant (size + field offsets) wasn't
asserted in tests. Added BatchDataPublic_LayoutMatchesPrivateBatchData
+ DrawCommandStride_MatchesStructSize tests to gate Task 10's
MemoryMarshal.Cast<BatchData, BatchDataPublic> safety.
- Plan doc updated: BatchDataPublic spec was Pack=4 (wrong — must
match private BatchData's Pack=8 for the cast to work). Implementation
was already correct; plan now matches.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure CPU helper that lays out a group list into a contiguous indirect
buffer (DrawElementsIndirectCommand[]) and parallel BatchData[] —
opaque section first, transparent section second. Returns counts +
byte offset for the transparent section.
Tests cover: spec §5 walk-through layout; empty group list edge case;
ClipMap classification (treated as opaque, not transparent).
Static + public so tests can exercise without a GL context. Task 10
wires it into the rewritten Draw() method.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WbDrawDispatcher draws all entities through WB's ObjectRenderData
(VAO/VBO per GfxObj, per-batch IBO) using acdream's TextureCache for
texture resolution. Two-pass rendering (opaque+ClipMap, then
translucent) matching the existing InstancedMeshRenderer pattern.
Per-entity single-instance drawing for N.4 simplicity — true
instancing grouping deferred to N.6.
Atlas-tier entities: mesh from WB, texture from TextureCache via
batch SurfaceId. Per-instance-tier entities: AnimatedEntityState
drives part overrides + hidden-parts, palette/surface overrides
resolve through TextureCache's composite-key caches.
Side-table population (Task 23 folded in): WbMeshAdapter now takes
DatCollection and populates AcSurfaceMetadataTable on first
IncrementRefCount per GfxObj. The side-table provides TranslucencyKind
(critical for ClipMap alpha-test on vegetation) plus Luminosity,
Diffuse, SurfOpacity, NeedsUvRepeat, DisableFog for sky-pass and
lighting.
GameWindow wiring: when WbFoundationFlag is enabled, WbDrawDispatcher
draws everything and InstancedMeshRenderer is skipped. Flag-off path
is unchanged.
Matrix composition: restPose * animOverride * entityWorld, matching
the spec. Three MatrixCompositionTests verify the contract.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Routes server-spawned (CreateObject) entities through the per-instance
rendering path. Filter: ServerGuid != 0. Atlas-tier entities (procedural,
ServerGuid == 0) flow through LandblockSpawnAdapter (Task 11) instead.
For entities with PaletteOverride set, walks each MeshRef.SurfaceOverrides
map and calls TextureCache.GetOrUploadWithPaletteOverride to pre-warm the
palette-composed GL texture before the first draw. Surfaces not in the
SurfaceOverrides map (i.e. whose ids are only known after opening the GfxObj
dat) are decoded lazily by the draw dispatcher on first use, consistent with
StaticMeshRenderer.
Builds AnimatedEntityState per server-guid via injected sequencer factory
(Func<WorldEntity, AnimationSequencer>). The factory decouples the adapter
from DatCollection so tests pass a stub lambda without a GL context.
OnRemove releases per-entity state. Unknown guids no-op.
Introduces ITextureCachePerInstance: thin seam interface over the palette
decode path so EntitySpawnAdapter tests can use a CapturingTextureCache
mock without constructing a GL context. TextureCache implements it.
Adjustment 4 documented in source comments: WorldEntity does not currently
expose HiddenPartsMask or AnimPartChanges (they are consumed upstream in the
network layer before the WorldEntity is built). HideParts / SetPartOverride
calls are placeholder TODO'd for when those fields are promoted.
Wired into GpuWorldState.AppendLiveEntity (OnCreate) and
RemoveEntityByServerGuid (OnRemove). Constructed in GameWindow under the
ACDREAM_USE_WB_FOUNDATION flag alongside LandblockSpawnAdapter. Sequencer
factory captures _dats + _animLoader at construction time; falls back to an
empty Setup + MotionTable via NullAnimLoader when dats are unavailable.
10 new tests: server-spawn routing, atlas-tier skip, palette decode pre-warm
(with and without surface overrides), OnRemove lifecycle, unknown-guid noop,
multi-entity isolation. All pass; 8 pre-existing failures unchanged.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Per-entity render state for the per-instance rendering tier
(server-spawned characters / creatures / equipped items). Holds:
- partGfxObjOverrides: Dictionary<int, ulong> — AnimPartChange swaps
(e.g. wielding a weapon replaces a hand-part's GfxObj).
- hiddenMask: ulong — HiddenParts bitmask. Bit i set hides part i.
- AnimationSequencer reference — N.4 doesn't touch the sequencer;
this just exposes it for the draw dispatcher.
Public API: HideParts / IsPartHidden / SetPartOverride /
TryGetPartOverride / ResolvePartGfxObj. Bounds-checked
(partIdx < 0 or >= 64 → IsPartHidden returns false).
Twelve tests covering the type, the AnimPartChange resolution helper,
and the HiddenParts bitmask edge cases (theories for 0b0/0b1/MSB/all-ones,
plus negative-index + out-of-range guards).
Consumed by Task 17's EntitySpawnAdapter (creates one per CreateObject)
and Task 22's WbDrawDispatcher (reads via per-part draw loop).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Without this, ObjectMeshManager.StagedMeshData and
OpenGLGraphicsDevice._glThreadQueue grow unbounded as background
workers prep mesh data + queue GL actions. Visual stress test of
flag-on at radius 7 showed real FPS drop and rising frame latency
from this leak.
Tick() drains both queues:
1. _graphicsDevice.ProcessGLQueue() applies pending GL state.
2. Loop _meshManager.StagedMeshData.TryDequeue -> UploadMeshData
to materialize VAO/VBO/IBO for each prepared mesh.
Wired into GameWindow's render loop before draw work begins.
No-op when adapter is uninitialized or disposed.
Pattern matches WB's reference ObjectRenderManagerBase.ProcessUploads
without the prioritization heuristics (we're not yet drawing the
results — Task 22's WbDrawDispatcher will add prioritization when
visual budget matters).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Verifies Task 12's GpuWorldState wiring preserves the pending-spawn
list mechanism:
1. Live entity parked before its landblock loads — pending count = 1,
adapter not called yet.
2. Landblock arrives with its own atlas-tier entity AND drains the
pending live entity. Adapter sees ONLY the atlas-tier GfxObj
(server-spawned drained entity is filtered by ServerGuid != 0).
3. Live entity arriving AFTER landblock load goes straight to flat
view; adapter is not re-invoked.
4. Landblock unload decrements match load increments.
Three integration tests confirm the existing pending-spawn drain
semantics work correctly with the new adapter, and per-instance-tier
entities (server-spawned) never leak into WB's atlas pipeline.
To exercise the adapter code path (which GpuWorldState gates on
WbFoundationFlag.IsEnabled) without requiring the env var set before
process startup, WbFoundationFlag gains an internal
ForTestsOnly_ForceEnable() method and AcDream.App exposes internals
to AcDream.Core.Tests via InternalsVisibleTo.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Bridges LoadedLandblock load/unload events to IWbMeshAdapter ref counts.
Tier-aware by design: walks WorldEntity collection filtered by
ServerGuid == 0 (procedural / atlas-tier only). Server-spawned entities
are skipped — those will go through EntitySpawnAdapter (Task 17).
Per-landblock id-set snapshot ensures unload pairs 1:1 with load even
when underlying data is released. Duplicate-load idempotency for
defensive resilience to streaming-controller bugs.
Six tests: registers per unique id; dedups across entities; skips
server-spawned; unload matches load; unknown landblock no-ops;
duplicate load no-ops.
Wiring into GpuWorldState lands in Task 12.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
WbMeshAdapter now actually constructs the WB pipeline:
- OpenGLGraphicsDevice(gl, logger, DebugRenderSettings)
- DefaultDatReaderWriter(datDir) — opens its own file handles for now
(memory cost ~50-100MB of duplicate index caches, acceptable for
foundation work per plan Adjustment 1)
- ObjectMeshManager(graphicsDevice, dats, NullLogger)
InstancedMeshRenderer.EnsureUploaded routes through the adapter when
ACDREAM_USE_WB_FOUNDATION=1 is set; uses a WbManagedSentinel entry
in the local cache to mark "this GfxObj lives in WB now". CollectGroups
skips sentinel entries; both Draw passes skip them; Dispose skips them
(no GL resources to free — ObjectMeshManager owns those). Task 22's
WbDrawDispatcher will eventually draw WB-managed objects. With flag
off, behavior is byte-identical to before.
WbMeshAdapter constructor signature changed from (GL, DatCollection,
Logger) to (GL, string datDir, Logger). Updated tests to use
CreateUninitialized() for behavior tests and single null-GL guard test
for constructor validation. GameWindow updated to pass _datDir and to
wire _wbMeshAdapter into InstancedMeshRenderer.
AcDream.App.csproj gets direct ProjectReferences to WorldBuilder.Shared
and Chorizite.OpenGLSDLBackend — project refs are not transitive in
.NET, so AcDream.App must list them explicitly even though AcDream.Core
already references them.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Stub adapter that validates constructor args and exposes the public
shape (IncrementRefCount / DecrementRefCount / GetRenderData / Dispose).
Real ObjectMeshManager init is deferred to Task 9 — for now methods
no-op so call sites can wire the adapter without behavioral effect.
IWbMeshAdapter interface enables mocking in subsequent tasks
(LandblockSpawnAdapter tests in Task 11 need it).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Mesh extraction (4 tests): quad output, double-sided via Stippling.Both,
double-sided via SidesType=Clockwise (AC's NoNeg-clear convention),
NoPos-only emission. Pins GfxObjMesh.Build's behavior.
Setup flatten (5 tests): identity (no frames), Default frame, Resting
beats Default, motion override beats Resting, DefaultScale per part.
Pins SetupMesh.Flatten's placement-frame fallback chain.
These run BEFORE substitution per N.1/N.3 pattern — they prove
equivalence, not test the substitution.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Holds Translucency / Luminosity / Diffuse / SurfOpacity / NeedsUvRepeat /
DisableFog keyed by (gfxObjId, surfaceIdx). Populated at extraction time,
queried by the draw dispatcher. ConcurrentDictionary because mesh
extraction happens on background workers.
No fork patches required — keeps WB's MeshBatchData pristine.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>