Commit graph

2989 commits

Author SHA1 Message Date
Erik
b023ac95f0 docs: the overnight consolidation report, open questions first
Adds `docs/reports/2026-07-29-overnight-report.md` and brings the Campaign V
report page's status section in line with what is actually true.

The report leads with what needs the user, because most of it is genuinely
blocked rather than merely unfinished: the #259 reboot and the five V11 runtime
gates behind it, the enum campaign's three decisions (adopt `WeenieError`
wholesale? adopt retail's `SoundId` table or record our subset as a divergence?
re-clone the reference repos), the wire audit's one instrumented session, and
the walked-portal follow-up for #256/#257. Then what landed, with numbers, then
Campaign V's final state.

Two notes worth surfacing rather than burying. `references/` is empty **in the
main checkout**, not just in a worktree — which is why 456 of the enum
campaign's 864 property members are single-sourced, and why CLAUDE.md's
"cross-reference at least two of them" is currently unexecutable as written.
And two `MEMORY.md` entries index research documents that exist nowhere: not in
the tree, not under any ref, and the memory directory has no `research/` folder
at all. Two separate sessions hit that wall tonight and both regenerated from
scratch. User-level memory is out of my reach, so it is recorded as a
needs-user item with the two documents that supersede them.

The Campaign V page said V8 was running, V9 pending and V10/V11 ahead. All four
have landed. Its status section now records the deletion (204 files, +1,870 /
-27,607), the three findings worth keeping — Chorizite survives on the pak
format rather than on `IUniformBuffer`, `Studio/SampleData.cs` was production
code, and `WbMeshAdapter.Dispose()`'s GPU-work wait had been silently dead since
V6a — and, plainly, that the runtime gates did not run because this machine
cannot create a Win32 Vulkan surface for any process. A gate that could not run
is not a gate that passed. The #248 and #250 entries are updated to DONE with
their outstanding halves named.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 03:54:43 +02:00
Erik
200f19ce47 test(app): put every strict-zero site on the probe (#250)
The first commit converted the four members the issue named and left the other
sites alone, reasoning that none had been observed failing. A 20-run
complete-solution baseline disproved that within minutes:

  run  2  LiveEntityRuntimeTests.AnimationView_HotSpatialTraversal…
  run 14  StaticRenderProjectionJournalTests.ActiveAnimatedSynchronization…
  run 18  StaticRenderProjectionJournalTests.ActiveAnimatedSynchronization…
  run 19  CurrentRenderSceneOracleTests.SurfaceOverrideFingerprint…

Both new names are the same shape as the four — one warm call, then a
thousand-iteration loop inside the measured window — and neither had been
recorded anywhere. "Not observed failing" only ever meant "not yet observed",
and leaving known-shape sites in place would have guaranteed the acceptance gate
failed. Run 19 is the sharper lesson: the issue named
`SurfaceOverrideFingerprint_DictionaryHotPathAllocatesNothing`, and the first
commit converted a *different* test in that same file, so the actually-named
member was still on the old shape. Matching by file was not matching by test.

Every strict-zero site in the assembly is now on the probe — ten tests. Two came
out stricter rather than merely steadier:

`StaticRenderProjectionJournalTests` was measuring a synchronise whose journal
does **not** coalesce. Repeating it grew the journal by 1,000 entries per call —
192,000 by the end of a probe run — so the steady state the test claimed to
measure did not exist and the single-call window had been hiding it. Its step is
now the whole frame cycle, synchronise *and* drain, which puts `DrainTo` inside
the measured window for the first time and asserts the journal ends empty.

`RetailInboundEventDispatcherTests` asserted a hard-coded 1,001 callbacks. It
now counts its own dispatches and pins the callback count against that, so the
assertion still proves the fast path ran the callback every time without being
coupled to a loop bound that no longer exists.

Left alone deliberately: the four sites asserting a tolerance rather than zero —
`CellViewDedupTests` and `PortalProjectionTests`. Their ceilings already absorb
this noise and none has flaked; changing a bound in either direction is a
separate decision from fixing a measurement. Worth noting that
`PortalProjectionTests`' ceiling exists explicitly to tolerate "a
tiered-JIT/ArrayPool bookkeeping transition ... to the first measured batch",
which is exactly what the probe removes, so it could probably be tightened to
zero now — recorded in the issue rather than done here.

Solution build 0 warnings / 0 errors; App suite 3,941 passed / 3 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 03:49:13 +02:00
Erik
1d73ce524c test(app): measure the warmed path, not the path being warmed (#250)
The zero-allocation family failed about one full-suite run in three, on
unchanged trees, and had been dismissed as inherent noise in
`GC.GetAllocatedBytesForCurrentThread` three separate times. It is not noise.
Reading the four members side by side, they share one root: **the measured
window was never the warmed path.**

  UiDatFontTests            1 warm call, then a 10,000-iteration loop inline
  RenderFrameProductTests   8 warm calls, then a 1,000-iteration loop inline
  OracleTests               1 warm call, 1 measured call
  ArchRenderSceneTests      warms Apply(registrations), measures Apply(updates)

Two mechanisms come out of that table. A test method is JIT-compiled at tier 0
like anything else, and a long-running loop in tier-0 code gets replaced
mid-flight by on-stack replacement — which compiles on the thread running the
loop, so its bookkeeping is charged to the window being measured. That is the
first two. And `ArchRenderSceneTests` warmed one arm of a switch and measured
the other, so the measured call was the first ever into `ApplyUpdate` and paid
that arm's JIT, type loads and static initialisation inside the window;
`RenderFrameProductTests` warmed 8 times, below the tier-0 call-counting
threshold of 30, so promotion was still pending when measurement began.

That also explains the signature nobody could account for. Alone, the process is
quiet and the runtime has finished before the assertion arrives. Alongside eight
other test assemblies, tier-0 compilation never stops, the call-counting delay is
re-armed continually, and the work slides into the window. Clean in isolation,
failing under load, on a tree that changed nothing.

`ZeroAllocationProbe` invokes the step many times before measuring anything, then
measures windows that run the same already-warmed loop over the same
already-taken path. Each window is a batch of 32 invocations and it reports the
minimum across 4 of them. Both halves are load-bearing: the minimum is what
excludes a one-time cost, and the batch is what keeps the assertion as strong as
the loops it replaces — minimising over *single* invocations would report zero
for a path that allocates every tenth call, which is a real regression made
invisible. I had written it that way first and the apparatus test caught it.

**The bound is untouched: exactly zero, no tolerance, no retry, no assertion
relaxed.** `ZeroAllocationProbeTests` proves the apparatus can still fail — a
step allocating every call reads above zero and does throw, a first-invocation
cost reads as zero, a cost every tenth call is caught, and the one stated limit
(the batch must cover the period) is pinned as a test rather than left as prose.
Without those, a later edit could quietly make the whole family unfailable.

Twelve further sites in this assembly still use the hand-rolled shape. None has
been observed failing, and each needs its own repeatability analysis — several
mutate state or consume monotonic sequences — so they are listed in the issue
for adoption when next touched rather than converted blind at scale.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 03:42:08 +02:00
Erik
ce9445b270 fix(render): the near plane is col3, not col4 + col3 (#248)
`FrustumPlanes.FromViewProjection` extracted the near plane with the
Gribb-Hartmann form written for OpenGL's `[-1,1]` clip-space z range. Every
acdream projection comes from `Matrix4x4.CreatePerspectiveFieldOfView` or
`CreateOrthographic`, whose range is `[0,1]`. Under `[-1,1]` the near plane is
the locus of `clip.z = -clip.w`, which is `col4 + col3`; under `[0,1]` it is
`clip.z = 0`, which is `col3` alone.

Concretely, the mismatch put the effective near threshold at `-n·f/(2f-n)` —
about 0.5 m where the retail chase camera asks for 1.0 m. That error only ever
kept geometry the true frustum would have dropped, never the reverse, which is
why it produced no visible defect and was filed instead of hot-fixed during
Campaign V. It is still wrong, and it is the same mistake that *was* visible in
`PortalProjection`, where it culled the cell behind a doorway the camera stood
close to.

The far plane is `col4 - col3` under both conventions and is untouched. A test
pins it anyway, so that a future edit to this function cannot drift it while
nobody is looking.

The acceptance criterion asked for a unit test pinning the extracted near
distance to the camera's near value, and that is what landed: a theory over four
near/far pairs asserting the plane is unit-length, faces down -Z, and stands off
the eye by exactly `nearDistance`, plus a kept/dropped pair straddling it. The
test was checked against the old formula before commit and fails all four cases
there — it measures the fix rather than merely accompanying it.

The other half of the acceptance criterion — unchanged culling in the offline
pixel gate and the connected route — could not be run: #259 has Win32 surface
creation failing machine-wide, so no gate that needs a window is available
tonight. Recorded as outstanding rather than assumed.

Solution build 0 errors; `AcDream.Core.Tests` 3,898 passed / 2 skipped / 3,900.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 03:33:20 +02:00
Erik
22ae7944b6 merge(net): the wire-stack audit, and one reconciled #255
Brings `github/overnight/wire-audit` (`41f74fcd`) forward onto the V11 tree.
Like the enum branch it was cut at `b70b9832`, and like the enum branch its
subject is disjoint from the deletion: the audit lives in `AcDream.Core.Net`
and its tests, V11 emptied `AcDream.App`. One conflict, in `docs/ISSUES.md`,
resolved below.

What it carries: three real parser fixes — ranged speech was carrying a range
float the parser ate, a chat type that is never sent was silently dropping every
transient string on it, and `xpSpent` is a dword on the wire where we were
writing eight bytes. Plus the transport flag word pinned against ACE across all
twenty-three bits, golden fixtures generated from ACE's own writer instead of
hand-typed hex, and the audit document covering all three hundred forty-nine
opcodes.

**The conflict, and how it was resolved.** Both this branch and V11's closeout
reopened #255 — the RetailDatLoader concurrency tests that measure the thread
pool rather than the loader — on the same day, from different trees, without
knowing about each other. Neither reopening is a duplicate of the other: the
V11 gate saw 2 failures in 5 complete-solution Release runs on the
post-deletion tree, the audit session saw 2 in 4 on the pre-deletion tree, and
both saw 124/124 in isolation every time. They independently reached the same
conclusion, that `TaskCreationOptions.LongRunning` is a hint rather than a
guarantee, and independently proposed the same fix, a rendezvous inside the read
stub.

So the two notes are merged into one issue with both evidence sets kept as
labelled subsections rather than one overwriting the other. Four failures across
nine runs on two trees is a materially stronger case than either half, and the
agreement between two blind observations is the part worth preserving. No
assertion was weakened and no retry was added; the fix itself remains open.

Verified on the merge result: Release build 0 errors, and
`AcDream.Core.Net.Tests` at 659 passed / 0 skipped, up exactly the 59 the branch
claimed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 03:31:25 +02:00
Erik
cd2f3feae2 merge(core): the enum verification campaign, onto the post-deletion tree
Brings `github/overnight/enums` (`c19680fd`) forward onto the V11 tree. The
branch was cut at `b70b9832`, before the OpenGL deletion, and the two lines of
work turned out to be disjoint: the enum campaign lives entirely in
`AcDream.Core` and its tests, while V11 emptied `AcDream.App`. The merge is
clean — no conflicting file on either side.

What it carries: names for AC's seven property tables verified against two
oracles, a correction to `DamageType`'s rotated bits and `ItemType`'s shifted
craft ladder, the retail members the equipment and physics enums were missing,
and names for `AmmoType`, `CombatUse` and `ItemUseable`. Five commits, seventeen
files, +3,767 / -27 lines.

Verified on the merge result rather than on the branch: Release build 0 errors,
no new warning attributable to any file the branch touches, and
`AcDream.Core.Tests` at 3,893 passed / 2 skipped / 3,895. The campaign's
claimed +597 is exact — the `Properties` namespace alone runs 597 tests, all
passing.

The campaign's open decision items — whether to adopt `WeenieError` wholesale,
whether the `SoundId` subset is the right cut, and the re-clone of the ACE and
Chorizite references that `references/` no longer holds — are not settled here.
They are carried into the morning report as questions for the user.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 03:30:11 +02:00
Erik
d312bd2ff1 docs(render): Campaign V V11 status — deleted and statically green, runtime gates blocked
Records what V11 actually achieved and, more importantly, what it did not.

The deletion landed: 204 files, +1,870 / -27,607 lines across five commits.
Section 5.5.24 keeps the three findings that outlive the diff.

  * Chorizite could NOT be dropped, and not for the reason section 6 predicted.
    The risk register assumed the package survived only because the ManagedGL
    types implemented IUniformBuffer from it. The audit found TextureFormat in
    the IWorldTextureArray signature the VULKAN path implements, and
    BoundingBox serialized into the pak format. Dropping it is a slice that
    touches the on-disk format, not a V11 cleanup.

  * Two traps the V11 row did not know about. Studio/SampleData.cs is
    production code behind the character sheet's fallback, so it moved rather
    than died; ACDREAM_DEVTOOLS also gates Vulkan debug-utils, so the flag
    survives and now says out loud that its UI is gone.

  * Deleting GL surfaced a real bug: WbMeshAdapter.Dispose() was still
    pattern-matching the GpuFrameFlightController that V6a replaced, so its
    wait for submitted GPU work had been silently dead on every Vulkan run
    since. Removing the type turned a no-op into a compile error.

The runtime gates did not run, and the honest reason is written down rather
than smoothed over. The client dies at vkGetPhysicalDeviceSurfaceCapabilitiesKHR
in files V11 never touched. Bisecting put the failure at the PRE-V11 commit
whose Vulkan soak had passed 91 checkpoints three hours earlier, and
`vulkaninfo --summary` -- a Khronos tool with none of our code -- fails at the
same call. Win32 surface creation is broken machine-wide; Vulkan itself is
fine. That is issue #259, with the one-line diagnosis at the top so the next
person checks the machine before bisecting the tree.

So the row reads DELETED AND STATICALLY GREEN, RUNTIME GATES BLOCKED. Release
build is 0/0 and the complete Release suite is 8,999 / 5 skipped (-218 against
V10, every one a test that lost its subject). Nothing was relaxed to
manufacture a pass: section 7.1 rule 2 cuts both ways, and a gate that could
not run is not a gate that passed. The rerun list is in 5.5.24, and the
pre-deletion pixel baseline was captured BEFORE the deletion, so the
self-differential is still available whenever a window can be made again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 03:24:34 +02:00
Erik
c265b52d4b docs(render): V11 closeout — register, architecture, code structure, issues
Retires the GL framing from the documents that described a two-backend,
two-UI-stack client, and files what the deletion left behind.

Divergence register:
  * AD-46 (anisotropic tap pattern in dense alpha scenery) is REFRAMED rather
    than retired. Its substance survives -- distant foliage may read denser
    than retail's -- but it was measured GL-vs-Vulkan, and with GL gone it is
    a Vulkan-vs-retail question against the D3D oracle it already cited. The
    measurement is kept as the evidence that the residual is a driver tap
    pattern; the row now records that it is no longer falsifiable by
    self-differential, which is a real loss the deletion causes.
  * AD-47 and AD-48 are NEW, and the campaign's own risk register scheduled
    them here: MSAA sample positions (measured at 8.83% of the frame at 4x,
    which is why every strict gate runs MSAA off -- and therefore why a
    regression confined to the multisample path would not be caught) and
    present pacing (#235 is the live instance).
  * AD-17's justification moves from a GL clip-plane citation to Vulkan's
    maxClipDistances floor, which is the same 8, so the divergence is
    unchanged and only its authority moves.
  * AP-92 keeps IUiViewportRenderer.TextureIsBottomUp rather than folding it
    flat, because it is what let the origin question be answered by data.

Architecture and code structure: the layer diagram, the frame order, the
residency vocabulary and the reference table all said OpenGL. The UI section
said two stacks. Rule 3's rationale is rewritten around what actually
happened -- ImGui was deleted and not one panel, ViewModel or command had to
change, because none of them had ever imported ImGuiNET. That is the rule
paying for itself, so it is recorded as evidence rather than removed as
obsolete.

Issues: #258 files the dev-panel host as a decision rather than an accident,
and #255 is REOPENED. Its TaskCreationOptions.LongRunning fix asks the
scheduler for a thread but does not promise two callbacks overlap; under nine
concurrent test assemblies it still failed 2 of 5 whole-suite runs. The
earlier evidence tested a narrower pool, not a contended one. The fix it
needs is a rendezvous inside the read stub -- not a weakened assertion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 03:20:04 +02:00
Erik
5852bdb877 feat(render): Vulkan campaign V11 step 4 — retire GL from CI and gate scripts
Closes out the GL deletion by fixing the CI workflow and developer gate
scripts that still assumed a GL arm existed to compare against, build,
or select via ACDREAM_RENDER_BACKEND.

.github/workflows/headless-portability.yml: linux-graphical's "Verify
actionable unsupported-driver gate" step is deleted outright — it ran
the deleted `ui-studio` CLI verb (Studio was removed at Commit 1) to
prove the GL capability gate rejects Mesa's llvmpipe driver, and there
is no more GL capability gate for any driver to pass or fail. Its test
filter dropped two dead entries (GraphicalCapabilityRequirementsTests,
deleted at Commit 2; StudioWindowTests, already gone). Its package
contract check dropped the libcimgui.so assertion (ImGui's native
bridge, deleted at Commit 1). linux-vulkan's explanatory comment, which
described GL's rejection as the reason no cross-backend pixel diff runs
in CI, is rewritten to explain there is no GL arm left at all. Two dead
src/AcDream.UI.ImGui/** path triggers (that project no longer exists)
are removed from both the pull_request and push filters.

tools/run-backend-differential-gate.ps1 and its dedicated route file
tools/connected-backend-differential.route.txt are deleted: the whole
script's purpose was comparing a GL launch against a Vulkan launch of
the same route, and there is no second arm left to compare. Single-arm
regression checking already exists via run-offline-pixel-gate.ps1's
-Baseline mechanism.

tools/run-portal-churn-soak.ps1 is simplified rather than deleted: its
repeated-portal-churn methodology (within-arm capture comparison,
memory/entity/GPU trend analysis) has value independent of the
GL-versus-Vulkan question it was built to answer for issues #256/#257
before V11. -Backends now defaults to @('vulkan') alone; the doc
comments are rewritten from "step 0 discriminator, run before V11" to
an ongoing single-arm regression soak.

tools/run-offline-pixel-gate.ps1 drops its now-nonfunctional -Backend
parameter (ACDREAM_RENDER_BACKEND has read zero call sites since
RuntimeOptions.RenderBackend was removed at Commit 2 — passing -Backend
gl silently launched Vulkan anyway) along with its GL-escape-hatch
example and every comment that referenced the now-deleted differential
gate. tools/run-connected-world-lifecycle-gate.ps1,
tools/run-offline-vulkan-capture.ps1, and
tools/run-repeat-connected-gate.ps1 keep their (harmless, already
no-op) ACDREAM_RENDER_BACKEND set/clear lines but have their
now-inaccurate "escape hatch" / "GL run" comments corrected to state
plainly that the variable is unread and the line is kept only for the
historical record.

No .cs files touched; `dotnet build AcDream.slnx -c Release` unaffected
(0 warnings, 0 errors, matching the prior commit's build).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 03:06:45 +02:00
Erik
7a0227c12e feat(render): Vulkan campaign V11 step 3 — drop the GL packages and shaders
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>
2026-07-29 02:58:15 +02:00
Erik
8a7a0837e1 feat(render): Vulkan campaign V11 step 2 — delete the OpenGL backend
Vulkan is the sole, user-signed-off backend (V10 landed) and step 1
already removed ImGui/Studio/DevTools. This step deletes the GL
rendering backend itself: every Gpu/Gl/** implementation, the Wb
ManagedGL*/GLHelpers/GLSLShader/GLStateScope/RenderStateCache/
BindlessSupport family, Shader/ShaderProgramConstruction/SamplerCache,
RenderBootstrap, and RenderFrameGlStateController.

GameWindow.cs's Run()/CreateGraphics()/CreateBackbufferReader()/
OnLoad() collapse to their Vulkan-only arm; GameWindowGraphics loses
its OpenGlGameWindowGraphics subclass. RuntimeOptions.RenderBackend and
RenderBackendKind (incl. the Gl member of GpuBackendKind) are gone —
there is nothing left to select between. The five world-draw dual-arm
renderers (WbDrawDispatcher, EnvCellRenderer, TerrainModernRenderer,
ParticleRenderer, SkyRenderer) and the composition roots
(WorldRenderComposition, HostInputCameraComposition,
LivePresentationComposition, FrameRootComposition) collapse to their
RHI-only arm. GL-only diagnostic properties with a live external reader
(DynamicBufferCount and friends) simplify to a documented `=> 0`/no-op
rather than disappearing, since the reader is out of this commit's
scope.

A few GL-flavored mechanisms turned out to be backend-neutral once
isolated: GlConstructionCleanupLedger is renamed
ResourceConstructionCleanupLedger (exception-chain walking has nothing
to do with GL), and GlfwNativePlatformProbe moved out of the otherwise
GL-only GraphicalCapabilityRecord.cs into
GraphicalWindowBackendSelection.cs before the rest of that file was
deleted.

Test files with no surviving subject are deleted outright
(GraphicalCapabilityRequirementsTests, ShaderProgramConstructionTests,
PortalDepthShaderParityTests, TextureCacheBindlessTests,
TextRendererFailureSafetyTests, ClipFrameUploadTests, every
Gpu/Gl/*Tests, GlTextureOwnershipTests, RenderFrameGlStateControllerTests);
others get their dead GL-only members trimmed while their live
assertions stay (ClipFrameLayoutTests' MeshClipSsboBinding check now
reads GpuBindingModel.StorageClipRegions, the same binding index under
its new backend-neutral name; GpuResourceRetirementTransactionTests
drops its OpenGLGraphicsDevice-subclassing test double and the two GL
queue tests it existed for). EnvCellRendererTests' construction helper
now builds a real ObjectMeshManager via VulkanMeshPipelineDevice
instead of passing null through a null-forgiving operator, since the
RHI constructor never tolerated a null mesh manager and the old GL
constructor (which did) is gone.

Deferred to the next two steps, deliberately not touched here: the
Silk.NET.OpenGL/.Extensions.ARB package references, IMeshPipelineDevice.Gl
(WbMeshAdapter's GL? threading stays in place), Chorizite.Core's stale
csproj comment (the package itself is still load-bearing —
TextureFormat and friends are used well beyond the deleted
ManagedGLUniformBuffer), and the CI/gate scripts.

Build: `dotnet build AcDream.slnx -c Release` — 0 warnings, 0 errors.
Tests: full-solution `dotnet test` green across every project
(App.Tests 3937/3940 + 3 skips, Core.Tests 3296/3298 + 2 skips, all
others 100%); the 2 App.Tests names that flake under full-suite
parallel execution (#250-family, documented pre-existing) pass in
isolation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 02:19:53 +02:00
Erik
41f74fcd2b docs: reopen #255, LongRunning is a hint and the test still flakes
The wire-stack audit's final full-suite gate turned up
AnimationCache_CoalescesSameDidAndAllowsUnrelatedReadsInParallel failing twice
in four runs on Windows, on a tree that already carries #255's LongRunning fix.
Run alone the project passes 124 of 124 every time. Both failures were
--no-build runs, which start faster and so crowd the machine harder, which is
the original diagnosis rather than a new one.

TaskCreationOptions.LongRunning asks for a dedicated thread. It does not
promise one, and it does nothing about the other eight test projects saturating
the box while these two forty-millisecond sleeps are supposed to overlap. The
assertion on MaxConcurrentReads is therefore still measuring the host.

The note suggests the shape that would actually close it: gate both threads on
a barrier so each is provably inside the read before either is released. Then
the count is a property of the loader instead of the scheduler and no amount of
load can move it. Filed rather than fixed because this session's diff is
confined to Core.Net, its tests, and docs, and the audit had no business
editing content loaders on its way past.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 02:07:19 +02:00
Erik
081c2009d1 docs(net): the wire-stack audit, all three hundred forty-nine opcodes
Bucket B item 2 of the post-Campaign-V intake, delivered as a ledger. Every
entry in ACE's three opcode enums gets a row grounded in acdream source read
during the audit, with a file:line citation, compared field by field against
ACE's serializer or handler. The prior status columns were deliberately not
used as input; they are compared against the result instead.

Two blockers lead the document because they outlast it. The vendored reference
repos are empty directories in the main checkout, not just in worktrees, so
CLAUDE.md's instruction to cross-reference at least two of them is currently
unexecutable as written; working copies were recovered elsewhere on disk and
Chorizite.ACProtocol was not found anywhere. And the 2026-06-04 wire catalog
this audit was meant to diff against was never committed and does not exist on
disk, though MEMORY.md still links to it. The row universe therefore comes from
ACE's enums, which is more complete than the missing catalog's 256, and drift
is measured against the in-repo Phase-M matrix instead.

The corpus could not support the frequency ranking the mission asked for.
ACDREAM_DUMP_OPCODES was not set for any recorded session, so there are zero
unhandled-opcode lines across every log. That is reported rather than papered
over, and it is why no new parsers were written for unobserved gaps: the bar
was observed plus two oracles, and the first half was unsatisfiable. One
instrumented re-run would fix this, and it leads the open questions.

The most useful thing the audit found is a pattern rather than a single bug.
Three confirmed wire divergences each had a passing test built on the same
wrong assumption as the parser it was defending. The Phase-M matrix had marked
all three rows complete, and for ranged speech it even recorded the mistake as
a design note, "same parser as 0x02BB". A status column can be wrong on the day
it is written.

Counts: 130 parsed or built-and-wired, 11 partial, 75 stub or dead-builder, 128
missing, 5 server-side, across 349 rows. The stub block is concentrated in
fellowship, housing, trade, allegiance, and vendor, which is exactly the surface
the intake's bucket-C items will need.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 02:05:12 +02:00
Erik
f57db35cec fix(net): xpSpent is a dword on the wire, and we were sending eight bytes
RaiseAttribute, RaiseVital, and RaiseSkill each wrote a 64-bit xpSpent,
producing a 24-byte action where the server expects 20. ACE's
GameActionRaiseAttribute and its Vital and Skill siblings read
message.Payload.ReadUInt32(); holtburger's RaiseAttributeData declares
xp_spent: u32 and advances the offset by four. Both oracles agree, and the
four extra bytes were tail the server never reads.

These three are live-wired, from the character sheet through the command
router to SendRaiseAttribute, so this was shipping on every attribute, vital,
and skill raise. It has not caused a visible failure because ACE reads the low
dword and stops, and a single raise cost has never approached the dword
ceiling. That is luck about value ranges, not correctness about layout.

Worth noting the shape of the miss: the sibling builder BuildTrainSkill had
already been corrected to a 20-byte, 32-bit credits field, and its test is even
named U32CreditsNotU64. The same class of bug was found and fixed once in this
file and the other three cases were left behind.

The parameter stays ulong because the cost comes from 64-bit server XP tables
several layers up in the App and Runtime command chain; narrowing that end to
end is a separate change and is filed in the audit's open questions. Nothing is
lost at the wire: a cost that does not fit in a dword was never expressible
here.

The existing test asserted the 24-byte shape and is corrected, joined by a
theory that sweeps zero, one, a realistic cost, and uint.MaxValue across both
remaining builders.

Core.Net tests go 655 to 659.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 01:59:47 +02:00
Erik
f416c577d6 fix(net): stop dropping every transient string on a chat type that isn't sent
CommunicationTransientString (0x02EB) required a trailing u32 chat type after
the message. The server does not send one. Because the string is padded to a
four-byte boundary, the remaining length after reading it was always zero, the
guard tripped, and the parser returned null for every transient string the
server has ever sent. Not most. Every one.

Three oracles agree there is no such field. ACE's
GameEventCommunicationTransientString writes exactly one WriteString16L and
stops. Retail's ClientCommunicationSystem::Handle_Communication__TransientString
at 0x0057d460 takes a single PStringBase<char> argument. holtburger carries no
type field for the event either.

ParseTransient now returns the string. The wiring supplies chat type 0, which
is ACE's ChatMessageType.Broadcast and which ACE's own LogTextTypeEnumMapper
comment names "Default" — the honest stand-in for a message the server sends
untyped. What retail's transient strings should actually look like is a
rendering question and belongs with the chat colour work, not here.

The existing round-trip test was itself appending the phantom trailing dword,
which is exactly why the wrong guard looked correct for as long as it did. It
is corrected to the real payload and joined by a case sweeping string lengths
zero through four, so no future padding-residue assumption can hide here again.

Core.Net tests go 654 to 655.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 01:58:02 +02:00
Erik
6119364306 test(net): pin the transport flag word against ACE, all twenty-three bits
The wire-stack audit checked the transport layer by hand and found it clean:
PacketHeader's seven fields match ACE's Pack order exactly, the optional-header
sections are parsed in ACE's order, and PacketHeaderFlags is a twenty-three of
twenty-three value match including the sparse gaps between 0x04 and 0x100 and
between 0x00800000 and 0x01000000.

Clean is worth freezing. These bits are not design choices; each one gates an
optional-header section, so a single wrong value shifts every following
section's offset and takes the packet checksum with it. The failure would not
look like a wrong flag, it would look like a corrupt connection. The enum is
small enough to pin exhaustively, so this transcribes ACE's declaration and
asserts both directions: every ACE flag exists here with ACE's value, and we
declare nothing ACE does not.

Core.Net tests go 630 to 654.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 01:54:56 +02:00
Erik
7e95c45ece fix(net): ranged speech carries a range float our parser was eating
HearSpeech decoded 0x02BB and 0x02BC with one layout. They do not share one.
ACE's GameMessageHearRangedSpeech writes senderID, range, chatMessageType
where GameMessageHearSpeech writes only senderID, chatMessageType, and
holtburger's HearRangedSpeechData declares the same range: f32 that
HearSpeechData lacks. Two oracles, no ambiguity.

The consequence was quiet rather than loud. The tail is twelve bytes, our
guard demanded eight, so nothing ever failed to parse. We read the guid
correctly, then read range's float bits as the chat type and discarded the
real one. A shout at range 60.0f arrived with a chat type of 0x42700000
instead of 0x0B. Nothing downstream consumes ChatType for local speech today,
which is why this survived, but the record is public and any future consumer
would have inherited garbage.

TryParse now branches its tail size on the opcode and Parsed gains Range,
which stays zero for local speech because there is no such field on that
wire. The existing ChatTests ranged case was itself built on the misreading,
constructing a local-shaped tail; it is corrected to the oracle layout and
now asserts both range and chat type rather than only the ranged flag.

New golden tests drive both opcodes through AceWireWriter in ACE's write
order, covering empty strings, string lengths one through four so every
residue of the four-byte padding rule is exercised, CP1252 accented names,
and a regression pin asserting the chat type is not the range float's bits.
A ranged body four bytes short is now rejected instead of silently decoded.

Core.Net tests go 617 to 630, all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 01:51:34 +02:00
Erik
d5b0765ea8 test(net): generate wire fixtures from ACE's own writer, not hand-typed hex
The golden-byte tests we had proved that a parser agreed with whoever typed
the hex literal. That is a weaker claim than it looks: if the author misread
the oracle, the test cements the misreading. This adds AceWireWriter, a
line-for-line mirror of ACE's Extensions.cs writers, so a fixture is produced
by the same algorithm the authoritative server uses. Each primitive cites the
ACE line it ports, including the string16L padding rule whose comment in ACE
reads "client expects string length to be a multiple of 4 including the 2
bytes for length".

On top of that harness, two inbound families get field-exact coverage they
had none of. VectorUpdate (0xF74E) is driven in GameMessageVectorUpdate.cs's
write order and pinned at ACE's declared 36-byte length, with cases for the
remote-jump +Z velocity, planar velocity plus yaw omega, rest, and
all-negative components so a sign or field-order slip cannot pass. The two
script-playback messages follow GameMessageScript.cs: PlayScriptId (0xF754)
as guid plus script DID, and PlayEffect (0xF755) as guid, type, and a free
intensity float. The NaN case documents the parser's deliberate choice to
retain non-finite intensities for the resolver to reject rather than coercing
them at parse time, which is behavior worth locking down.

Core.Net tests go 600 to 617, all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 01:49:15 +02:00
Erik
c19680fd69 docs(enums): the 2026-07-29 verification campaign, end to end
The ledger the campaign owed: which oracles were actually available, what each
family's end state is, what got fixed and why, and - the part that matters most
for whoever picks this up - the twelve things that could not be settled from an
oracle and are therefore recorded as open questions rather than guessed.

Two findings deserve to survive past the morning report.

The first is that five of the six vendored reference repos named in CLAUDE.md are
empty directories in this environment. ACE, Chorizite, holtburger, ACViewer, AC2D
and DatReaderWriter contain nothing, so the campaign re-anchored on the retail
header itself - which CLAUDE.md ranks above ACE anyway - with the UtilityBelt
enum catalog and the 38,985-file ACE weenie corpus as cross-checks. That turned
out to be the more rigorous arrangement rather than a compromise, because of the
second finding: the catalog is wrong about CraftFletchingBase, where retail and
acdream agree. Trusting any single source, including the one the brief nominated,
would have introduced a bug. Retail's header decided every disagreement and the
weenie corpus broke ties.

Also recorded: the 2026-06-04 property-enum divergence note that this work was
supposed to build on does not exist - not in the tree, not under any ref, not in
the memory directory, which has no research/ subfolder at all. The MEMORY.md index
points at both it and a magic-number audit that is equally absent. The sweep was
regenerated from scratch instead, and landed on 864 property members against the
missing note's remembered 929. Someone should repoint those index entries.

The Bucket B intake row is marked done and points here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 01:33:50 +02:00
Erik
3efa266a61 feat(core): name AmmoType, CombatUse, and ItemUseable
Three more fields acdream already pulls off the wire and then carries as bare
numbers. AmmoType and MaterialType ride PublicWeenieDesc through CreateObject and
land on ClientObject as ushort/uint; ItemUseable and CombatUse arrive as
PropertyInt 16 and 51. Nothing named them, so every site that reasoned about them
did it in hex.

AmmoType (acclient.h:4221) and CombatUse (acclient.h:6523) are small and
unsurprising. ItemUseable (acclient.h:6478) is neither: it is two 16-bit halves,
low for where the used object must be and high for where its target must be, and
retail names roughly thirty specific combinations rather than expecting callers to
compose them. They are transcribed rather than composed because at least one is
not the union it looks like - SOURCE_CONTAINED_TARGET_OBJSELF_OR_CONTAINED is
0x880008, where composing ObjSelf|Contained|(Contained shifted 16) gives 0x800088.
A test asserts that specific non-equality so the shortcut cannot be reintroduced.

ItemAppraisalTextFormatter's ammunition sentence now reads through AmmoType instead
of matching 0x08/0x40/0x10/0x80/0x20/0x100 literals. The fold it performs - crystal
and chorizite variants collapsing to their base arrow/bolt/atlatl kind - was
already exactly right against retail's bit layout; this only gives it vocabulary.
No behavior change, and the appraisal tests confirm it.

Core tests 3,836 -> 3,894. Full suite 9,759 passed / 5 skipped, no failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 01:31:01 +02:00
Erik
8ccaf72ae7 feat(core): adopt the retail members the equipment and physics enums were missing
With the two wrong enums corrected, the remaining wire-adjacent families diff
cleanly against the retail header - same values everywhere they overlap, just
fewer members on our side. This adopts the gaps.

EquipMask gains retail's eleven INVENTORY_LOC composite slot groups (acclient.h:
3193). The 32 primitive slots were already exact and stay pinned by EquipMaskTests;
what was missing were the groups the wire and the UI actually reason in - Armor,
Jewelry, ReadySlot, Weapon, WeaponReadySlot, the wrist/finger/sigil pairs, and
All. These are transcribed as literals, not derived, for the reason the previous
commit documents at length.

That transcription immediately earned itself. A type remark on EquipMask claimed
retail's CLOTHING_LOC composite "also sets bit 31, 0x80000000, which is not a
named INVENTORY_LOC primitive". It does not. CLOTHING_LOC is 0x080001FF: the nine
wear slots plus bit 27, which is the perfectly well-named Cloak slot. No
INVENTORY_LOC member touches bit 31 at all - ALL_LOC stops at bit 30. The remark
is corrected and a test now asserts the actual decomposition.

TransientStateFlags gains WaterContact (0x8) and CheckEthereal (0x100), the two
retail bits acdream's transition never declared. Neither is produced or consumed
yet; they are named so those slots cannot be quietly reused for an acdream-local
flag and then collide.

PhysicsStateFlags gains ReservedUnused1 (0x2) and ReservedUnused2 (0x2000), which
retail declares as UNUSED1_PS/UNNUSED2_PS. Same reasoning: reserved is a fact
worth recording.

AttackHeight gains Undef = 0. The three real heights are 1-based and were already
right; retail reserves 0 and the wire sends it, so it is now named instead of
arriving as an undefined cast. The numeric values are unchanged, so this renames
nothing at runtime.

Also checked and found already correct, so left alone: ObjectInfoState (matches
ObjectInfoEnum exactly, None being DEFAULT_OI), AttackType (every primitive plus
both composites - Unarmed 0x19 and MultiStrike 0x79E0 - land on retail's
literals), RadarBlipShape, RadarBehavior, MovementType, HoldKey, ParticleType, and
PhysicsDescriptionFlag. AttackType is worth calling out because the campaign's
extraction tooling reported it as a conflict; the tool reads one line per member
and had truncated a multi-line composite. The enum was fine.

RetailEnumConformanceTests grows tables for each of the above, each citing its
acclient.h line.

Core tests 3,785 -> 3,836. Full suite 9,701 passed / 5 skipped, no failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 01:27:57 +02:00
Erik
f3e95a3ebd fix(core): correct DamageType's rotated bits and ItemType's shifted craft ladder
Two enums disagreed with the retail client, and both disagreements were the
quiet kind - nothing read the wrong members, so nothing was visibly broken. They
were traps armed for the first person to write a comparison against them.

DamageType had its four drain/restore bits rotated. acdream assigned
Nether/Mana/Health/Stamina to 0x80/0x100/0x200/0x400; retail's DAMAGE_TYPE
(acclient.h:3788) assigns Health/Stamina/Mana/Nether. The ACE weenie corpus
attests retail's order independently - 0x100 Stamina, 0x200 Mana, 0x400 Nether -
and so does the vendored client-side enum catalog. Tellingly, both of acdream's
live damage-type name tables, CombatChatTranslator.FormatDamageType (ported from
holtburger) and ItemAppraisalTextFormatter.TryDamageTypeName, already used
retail's order reading the raw wire uint directly. The enum was the only thing in
the tree that was wrong. Retail's BASE_DAMAGE_TYPE (0x10000000) was also missing;
CombatChatTranslator already knew about it.

ItemType had two separate problems. The craft ladder was shifted one bit:
CraftAlchemyIntermediate sat on 0x02000000, which retail leaves unused, and an
invented CraftCookingIntermediate occupied 0x04000000, which is retail's real
alchemy-intermediate bit. The weenie corpus attests 0x04000000 as
Craft_Alchemy_Intermediate 235 times and contains no cooking-intermediate at all -
there is no such item type. Separately, the composite masks were recomputed
locally from the bits above them instead of transcribed, which is exactly how the
ladder drifted in the first place. That made Weapon (retail 0x101, melee|missile)
an exact alias of WeaponOrCaster (0x8101), and left Item at 0x830F where retail's
TYPE_ITEM is 0x2DFBEF - a mask two orders of magnitude broader. The composites are
now transcribed as literals with retail's value, not derived, and the five
retail-only masks acdream never had (portal/lockable magic targets, the
enchantable and redirectable targets, and the two vendor masks) come along.

Note for the reader wondering why the campaign trusted retail over the catalog
here: on CraftFletchingBase the catalog is the one that is wrong (it says
0x02000000; retail and acdream both say 0x01000000). No single oracle was assumed
correct - retail's header decided, with the weenie corpus as the tiebreak.

Behavior: no production code reads any changed member. The only reference in the
tree is a test that wants a nonzero HookItemTypes and does not care which. So no
branch changes and no wire behavior moves - but the values did change, which is
why this is a fix commit and not a data commit. No divergence-register row: these
were unintentional errors, now retired, not deviations we chose.

RetailEnumConformanceTests pins both enums to the acclient.h tables, asserts
acdream declares nothing retail does not, and calls out the two specific traps -
that 0x02000000 stays unclaimed, and that Weapon and WeaponOrCaster are no longer
the same value.

Core tests 3,726 -> 3,785.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 01:21:10 +02:00
Erik
251dd68a92 feat(core): give AC's seven property tables names, verified against two oracles
acdream has carried property IDs as bare uints since the beginning. The wire
parsers read `u32 property` and hand it to a `Dictionary<uint, int>`, and every
call site that cared re-derived the meaning from a comment - `EncumbranceVal`
was spelled `private const uint EncumbranceValProperty = 5u` in two different
files, `UiEffects` lived as "ACE enum value 18" in a doc comment, and
`AetheriaBitfield` as "322 / 0x142". That is 864 pieces of vocabulary the
codebase was expected to remember in prose.

This adds the seven enums - PropertyInt, PropertyInt64, PropertyBool,
PropertyFloat, PropertyString, PropertyDataId, PropertyInstanceId - under
AcDream.Core.Properties.

Every member is transcribed from an oracle; none is invented. Two independent
sources were extracted and diffed against each other: the vendored client-side
enum catalog at references/acclientlib/UtilityBelt.Common/Enums/Enums.cs (which
names these tables IntId/BoolId/FloatId/...), and the 38,985-file ACE weenie
export corpus at references/weenies/, whose every stat entry carries the numeric
key beside the enum member name in its `_comment`. The corpus attests 408 of the
864 members directly. Across all seven tables the two oracles produced zero
value conflicts, and the corpus contained no key the catalog was missing - the
catalog is a strict superset of everything 38,985 weenies actually set.

Three members disagree on spelling, never on value: the catalog says
ObjectType/HookObjectType/MerchandiseObjectTypes where ACE says
ItemType/HookItemType/MerchandiseItemTypes. acdream takes ACE's spelling, which
is what the weenie corpus emits (37,329 attestations for ItemType alone) and
what acdream's own ItemType enum already calls it. The catalog's alias is
recorded on each member.

This commit is vocabulary only - no parser reads these enums yet, so no branch
changes and no wire behavior moves. The bundles stay `Dictionary<uint, ...>`
precisely because an unknown key must still round-trip untouched; the enums
describe the keys we know, they do not constrain the ones we receive.

PropertyEnumConformanceTests pins the result: the full name/value table per
family, the uint underlying type, no two members sharing a value, and a separate
408-case theory asserting each weenie-attested pairing individually. A hand edit
to any enum now fails loudly instead of quietly mis-reading the wire.

Core tests 3,297 -> 3,726.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 01:11:16 +02:00
Erik
b70b9832ff docs: capture the post-Campaign-V work intake, sorted
Twelve user-provided forward items sorted into three buckets: three land on already-staged work (equipped-child picking and vendor slices are the world-interaction program's own next steps; the Settings tab is the filed V11 dev-panels follow-up), three are verification campaigns whose research already exists (property-enum divergence doc, wire-message catalog, the retail physics workflow - starting with the observed long-jump landing bounce), and six are new feature bodies for milestone sequencing (login/char creation, summoning, fellowship/allegiance tabs, in-game map, chat color/text fidelity from the cdb-captured retail values, missing slash commands). Intake ledger only; sequencing happens in the roadmap after V11 closes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 00:30:01 +02:00
Erik
844cf092a1 feat(render): Campaign V slice V11 commit 1 - delete ImGui, Studio, and the DevTools frontend
The ImGui developer-tools stack (AcDream.UI.ImGui), UI Studio
(src/AcDream.App/Studio), and the DevToolsFramePresenter/
SettingsDevToolsCompositionPhase ImGui composition machinery are removed.
Vulkan never composed a DevTools frontend (DevToolsEnabled already forced
false whenever the backend was Vulkan); this commit makes that permanent by
deleting the only implementation rather than leaving a dead branch behind.

What moved: Studio/SampleData.cs is a live production dependency
(InteractionRetainedUiComposition's character-sheet fallback, plus three
UI.Layout test files) - git mv'd to src/AcDream.App/UI/Layout/SampleData.cs,
namespace AcDream.App.UI.Layout, and trimmed to the SampleCharacter API that
is actually still called (BuildObjectTable/AddItem/AddEquipped/the item-guid
and icon constants had zero callers left once the Studio fixture provider
that used them was deleted).

What survives as backend-neutral seams, per the tests that still exercise
them: IDevToolsFrameLifecycle (moved into RenderFramePreparationController.cs,
now always bound to null), IFramebufferDevToolsTarget/FramebufferDevToolsBinding
in FramebufferResizeController.cs (its concrete DevToolsFramebufferTarget
adapter is deleted), and IDevToolsGameplayCommands in
GameplayInputCommandController.cs (DevToolsGameplayCommands becomes a
documented no-op instead of forwarding to the deleted presenter). A follow-up
re-homes Settings/Debug onto the retained UI through IPanelRenderer; until
then keybind remapping falls back to editing keybinds.json.

DevToolsEnabled is now `private const bool DevToolsEnabled = false`.
RuntimeOptions.DevTools is unchanged and still reaches VulkanGraphicsContext
for the optional debug-utils extensions; Program.cs now logs one line when
ACDREAM_DEVTOOLS=1 explaining that the ImGui UI is gone and the flag is
Vulkan-only now.

Removed: AcDream.UI.ImGui (project + ImGui.NET/Silk.NET.OpenGL.Extensions.ImGui
package refs), src/AcDream.App/Studio (minus SampleData.cs),
DevToolsFramePresenter.cs and everything only it constructed
(ISettingsDevToolsCompositionFactory, RetailSettingsDevToolsCompositionFactory,
DevToolsCompositionOwner, IGameWindowSettingsDevToolsPublication,
SettingsDevToolsOptionalDependencies, the "developer tools" shutdown-ledger
stage and its DevTools-typed fields on IngressShutdownRoots/
RenderShutdownRoots), the ui-studio Program.cs verb, and the cimgui native
manifest entries in GraphicalHostPlatformServices. GameWindow.cs's DevTools
composition branch, its _vitalsVm/_debugVm/_devToolsComposition/
_devToolsFramePresenter/_devToolsCommandBus fields, and every settingsDevTools
.DevTools?.* access across FrameRootComposition.cs/SessionPlayerComposition.cs
are gone with it.

Build green; complete Release solution suite 8,830 / 5 skips (App Tests
4,097/3 skips run standalone - one #250-family zero-allocation test flakes
under the full parallel `dotnet test AcDream.slnx` run, a pre-existing,
documented class unrelated to this change).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 23:56:04 +02:00
Erik
db4426d5ef test(render): Campaign V slice V11 step 0 — the #256/#257 discriminator, both arms
Issues #256 (server-spawned signs and portals go invisible after repeated
portal runs, while staying interactive) and #257 (working set grows to
~1.5 GB over the same session) were observed together in one long live
Vulkan session, and both filings demanded the same thing before V11 deletes
the OpenGL backend: run the churn on GL too. Vulkan-only growth or drift
would mean the new arm's resource lifecycle is broken, and deleting its only
reference implementation while that was true would be wrong even with the
cutover signed.

So the discriminator is built and run first, and it can stop the slice.

tools/run-portal-churn-soak.ps1 generates a route of N cycles over three
portal-bearing stops taken from the two existing connected routes, runs it
once per backend from one binary, and measures three things the existing
instruments do not measure together:

  * working set and private bytes, sampled from the OS every two seconds and
    joined to each checkpoint by timestamp -- the client's own snapshot has
    no view of its own working set, which is exactly #257's quantity;
  * the published-versus-live pair already in the checkpoint JSON, because
    "alive in the object table, gone from the presentation" is #256's whole
    symptom and a drift between those halves at the SAME stop across cycles
    is what would show it;
  * a within-arm capture comparison -- cycle 1 against cycles 10, 20 and 30
    at a pinned viewpoint -- plus a difference map and a row histogram,
    because a number cannot tell an absent object from a walking NPC and the
    map can.

Every teleloc carries the identity quaternion so the heading repeats, and
the four determinism levers the differential gate forces are forced here for
the same reason: an unpinned sun would swamp the signal.

Result at 90 transits per arm, 91 checkpoints, zero errors, graceful exits:
neither arm reproduces either symptom. Working set means agree to 1 MiB
(GL 1864, VK 1863) and warm-half drift is NEGATIVE on both (-48.0, -27.0).
GPU accounting is exactly constant per arm. worldEntities holds 10,382 at
all thirty cycles on both. The difference maps show every building, the
portal, the statue and the treeline still drawn at cycle 30.

That refutes the one outcome that would have blocked V11, and it does not
identify the pre-existing bug -- so both issues stay OPEN with the negative
recorded, and the follow-up named: walked portal transits rather than
/teleloc, which do not take the same path into the transit state machine.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 23:24:45 +02:00
Erik
122fe8a7e2 feat(render): Campaign V slice V10 — Vulkan becomes the default backend
THIS CUTOVER AWAITS THE USER'S VISUAL SIGN-OFF. It is not complete. Section 7
of the campaign plan names the V10 sign-off as the only required user stop
besides gate failures, and it has not been given. This commit flips the default
and runs the battery so that the sign-off has evidence in front of it.

ROLLBACK, one line: `git revert` of this commit. It restores the GL default,
the pre-V10 escape-hatch polarity and the gate scripts' inherited backend
together; nothing else has to move with it.

An unset, empty or unrecognised ACDREAM_RENDER_BACKEND now yields
RenderBackendKind.Vulkan. Only `gl` or `opengl`, case-insensitive, selects
OpenGL. The polarity of the typo case flipped with the default and on purpose:
before V10 an unrecognised token had to land on GL because Vulkan was dark and a
typo must never silently start a backend that cannot draw; after V10 it has to
land on Vulkan for the same reason read the other way, because GL is the backend
V11 deletes. `opengl` is honoured beside `gl` because an escape hatch exists to
be found.

Three gate scripts follow the flip. run-offline-pixel-gate.ps1 gains -Backend
(default vulkan) and now FORCES all four determinism levers — backend, day
group, world day fraction, sky phase — plus ACDREAM_MSAA_SAMPLES=0, instead of
inheriting any of them. run-repeat-connected-gate.ps1 and
run-connected-world-lifecycle-gate.ps1 CLEAR ACDREAM_RENDER_BACKEND rather than
setting it, so what they exercise is the process default and an ambient override
in a caller's shell cannot make a GL run wear the default's report.

TEST PIN UPDATED, flagged as required: RenderBackend_DefaultsToGl becomes
RenderBackend_DefaultsToVulkan, and RenderBackend_AnythingElseStaysOnGl splits
into RenderBackend_SelectsGlOnlyForTheEscapeHatchTokens and
RenderBackend_AnythingElseStaysOnVulkan. Five cases replace two. No other test
is touched, weakened or deleted.

AD-46's divergence-register row moves from "dormant until the V10 cutover" to
live, in this commit, per the same-commit register rule.

Battery, all on the new default:

  complete Release suite    9,222 passed / 5 skipped / 0 failed (9 projects)
                            +5 against the pre-flip 9,217; the +5 are this
                            slice's own escape-hatch cases
  #250 family, singly       4/4 pass (none failed in the whole-suite run)
  repeat connected gate     PASS 3/3 on both columns
  world-lifecycle route     PASS, 0 failures, both sessions graceful at exit 0
  validation layer          inserted at instance AND device level by the loader,
                            zero errors and zero warnings, real frame captured
  GL escape hatch           verified by two offline launches: 4.3.0 Core Profile
                            Context, bindless present, exit 0

Every connected launch in the battery reached Vulkan with no environment
variable set, which is the flip itself under test rather than an assertion
about it.

THE PIXEL GATE IS NOT MET, AND WAS NOT RELAXED. Vulkan against a GL-era capture
taken at this commit through the escape hatch, MSAA off and both clocks pinned:
1.099e-03 masked / 3.764e-02 whole-frame, against a 0.001 threshold. 97.9% of
the difference is in the treeline band, and the masked residual of 619 px — set
against a same-backend control of 10 px — sits entirely on the silhouettes of
distant alpha-blended scenery. That is AD-46's registered population; section
5.5.19 measured the same quantity at 497 px / 8.8e-04. Below the band the two
backends are photometrically identical: mean luminance differs by 0.01 of 255.
No baseline was regenerated and no mask or tolerance was widened.

Two instrument findings are recorded in section 5.5.23. The offline gate's sky
mask is still load-bearing — this slice tried retiring it on the reasoning that
V7's clock pins had made it obsolete, and the control refuted that: two launches
of the same binary still differ by 1,011 px on GL and 482 px on Vulkan, almost
all of it in the band. The default went back to 280 with the measurement written
into the script's help. And the repeat gate's desktop witness needs an
uncontested primary monitor: a first attempt reported 1/3, and the two failing
grabs turn out to be a web browser and Discord composited over the client rect,
not a blank frame — the client's Vulkan capture rendered in all six runs.

Nothing GL, ImGui or Studio is deleted. That is V11's scope and it is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:32:25 +02:00
Erik
49a309aaaf docs: file #256 and #257 from the first live-server session; V10 signed off
The user played a full session on Coldeve (production ACE fork) on the Vulkan backend and accepted V10: it felt good, graceful logout confirmed, two findings filed rather than blocking. #256: server-spawned signs and portals in the town portal network went invisible after repeated transits while remaining interactive - presentation-half, not wire. #257: working set ballooned to ~1.5 GB over the session against V8's ~880 MiB stationary measurement - a per-transit accumulation profile V8's vehicles never exercised. Both carry the same pre-V11 discriminator: run the identical portal-churn route on the GL backend while it still exists, because after V11 deletes it, backend attribution costs far more.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:09:12 +02:00
Erik
2e29de2e08 docs(render): close V4h as absorbed; its remainder is V11's by construction
The fork at 5.5.5 dissolved V4h: the Vulkan arm got real declared passes at V6h/V6i-3/V6m, the frame plumbing crossed at V4a/V6g/V8, and the GL spine deliberately keeps its legacy shape until V11 deletes it. What is left - OpenGLGraphicsDevice retirement, the Chorizite audit, the architecture test that goes vacuous at deletion - was always V11 work. Closing the row stops the slice table implying outstanding seam work that no longer exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:55:37 +02:00
Erik
8a18d90220 docs(render): mark V9 green with the run that proved it
The row has been carrying "implemented, first CI run pending" since the
slice landed, because a job that has never run is not evidence of
anything. Run 30393357552 is that evidence: all four jobs green, lavapipe
reporting llvmpipe (Cpu) at Vulkan 1.4.318 on Mesa 25.2.8, a 1280x720
35,594-byte captured frame, and the freshness step printing "all
committed .spv match a fresh compile" on the second operating system.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:53:35 +02:00
Erik
c7861020e1 test(content): give the loader concurrency tests real concurrency
Fixing the two failures that were stopping portable-headless earlier let
the job reach AcDream.Content.Tests for the first time on either
operating system - the test loop exits on the first failing project, so
the windows leg had never got past the apt step and the ubuntu leg had
never got past Core.Net. Two RetailDatLoaderTests cases were waiting
there, and they failed on both.

Both assert on RawDatabase.MaxConcurrentReads after issuing two Task.Run
reads that each block 40 ms in Thread.Sleep. A pair of pool work items is
not a guarantee of two workers in flight: on a low-core or saturated pool
the second queues behind the first, the reads run back to back,
MaxConcurrentReads stays 1, and the assertion fails for a reason that has
nothing to do with the loader. Pinning the suite to two CPUs on Ubuntu
reproduces it 5 times in 6; Windows is clean 6 of 6 at sixteen cores,
which is why nobody had seen it.

The pairs now start with TaskCreationOptions.LongRunning on the default
scheduler, which asks for a thread each. No assertion is changed - they
still fail if the loader serialises. The two coalescing cases moved onto
the same helper on purpose: two callers genuinely in flight is the
situation coalescing exists for, and a sequential pair was only ever
exercising a cache hit. Ten of ten clean under the same pin.

Release build green. App tests 4,152 / 3 skipped. Content 124 / 124.
Filed as #255.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:52:53 +02:00
Erik
777f60708d ci(render): make V9's first CI run green on both operating systems
The lavapipe job did the thing it was built to do on its first attempt.
It accepted a Cpu device at API 1.4, created a device and read pixels
back, captured a real frame, and exited 4 when a feature was forced
unsupported. Three other things were red, and none of them were the
Vulkan backend.

The shader-freshness step aborted for two separate Linux faults in the
compiler tool. Disposing the Silk.NET API container unloads the native
module, and dlclose-ing libshaderc_shared.so leaves glslang's
process-level teardown running against unmapped code. Bisected with a
four-mode probe on Ubuntu 24.04: GetApi, CompilerInitialize and
CompilerRelease each exit 0, and adding only the container Dispose turns
the exit into SIGSEGV. That is the 134 CI reported. shaderc's own handles
are still released; the container is not, because the module's lifetime
is the process's and the process is one statement from returning.
Separately, a portable dotnet build leaves the native under
runtimes/linux-x64/native/ and makes reaching it Silk.NET's probing
problem, which it solved on a local Ubuntu 24.04 and did not solve on the
runner. The script now publishes the tool for the host RID, so the native
sits beside the assembly where AppContext.BaseDirectory finds it, and
checks for it by name so a regression says which file is missing rather
than which names failed.

With both fixed, the question section 5.5.20 left open has an answer:
Linux shaderc and Windows shaderc agree byte-for-byte at the pinned Silk
2.23.0. Eighteen of eighteen .spv identical, manifest identical. The byte
comparison stays a byte comparison.

The Windows leg of portable-headless was running sudo apt-get. That step
is older than this campaign - it is red in the 2026-07-27 main run too -
and it was misplaced rather than mis-conditioned. Nothing in that job
opens a display or links GL, and the graphical jobs that do call xvfb-run
take it from the runner image, so the step is deleted rather than
guarded. Every remaining step in the two-operating-system matrix is pwsh;
every bash step now lives in an ubuntu-only job.

The last failure was ours in a quieter way. WaitForCharacterLogOff-
Confirmation expressed its deadline only as a CancellationTokenSource,
whose timeout is published from a thread-pool timer callback, so on a
saturated pool the token stays unsignalled past the deadline while the
loop keeps draining items that are already queued. That is the case the
method exists to bound. Reproduced by pinning the suite to two CPUs on
Linux, which failed 2 of 6 where four CPUs and sixteen were clean, and
where CI failed 3 of 3. The drain now reads the deadline off the
monotonic clock as well; the token still bounds the asynchronous wait.
Ten of ten clean under the same pin. The test is untouched. Filed as

Release build green. App tests 4,152 / 3 skipped against the same 4,152 /
3 measured at base 32f9bcfa. Core.Net 600 / 600.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:52:53 +02:00
Erik
22a157225c docs(render): Campaign V slice V8 - the perf gate, measured in three configurations
The verdict splits, and not where anyone expected. Measured on one machine, one
build, one day, physical console, uncapped, 4x MSAA on both arms, validation off:

  Stationary LIGHT scene (6,675 entities, 780-870 FPS)
    CPU  p50   GL 1.127 ms   -> VK 1.294 ms    MISS  +14.8%
    CPU  p99   GL 1.407 ms   -> VK 1.531 ms    MISS   +8.8%
    GPU  p50   GL 0.651 ms   -> VK 0.160 ms    PASS  -75.4%
    Alloc/frm  GL 77,664 B   -> VK 11,440 B    PASS  -85.3%
    Working    GL 943.7 MiB  -> VK 877.1 MiB   PASS   -7.0%

  Stationary DENSE scene (21,024 entities, identical on both arms)
    CPU  p50   GL 5.934 ms   -> VK 5.775 ms    PASS   -2.7%
    CPU  p99   GL 8.867 ms   -> VK 7.354 ms    PASS  -17.1%
    GPU  p50   GL 1.673 ms   -> VK 0.909 ms    PASS  -45.7%
    Alloc/frm  GL 82,016 B   -> VK 15,752 B    PASS  -80.8%
    Process CPU GL 1.246 cores -> VK 1.016     PASS  -18.5%   (Windows, not ours)

  Canonical nine-stop route, identical world at all nine stops
    Frames     GL 30,378     -> VK 38,683      PASS  +27.3%
    CPU  p50   GL 11.718 ms  -> VK 9.166 ms    PASS  -21.8%
    GPU  p99   GL 2.325 ms   -> VK 1.193 ms    PASS  -48.7%

Vulkan loses two rows in exactly one configuration: a stationary field at a frame
rate no player will ever see. The reason is measured rather than argued. A
temporary probe on BOTH arms, now stripped, attributes 0.148 ms/frame to required
Vulkan WSI and synchronisation calls - vkQueuePresentKHR 0.070, vkQueueSubmit2
0.027, the timeline wait 0.026, vkAcquireNextImageKHR 0.025 - against roughly
0.014 ms for GL's whole SwapBuffers. That cost is FIXED per frame, so it is 12%
of a 1.13 ms frame, 2.5% of a 5.9 ms one and under 1% of a dense-town frame,
while the GPU and allocation savings scale with the work. The sign of the CPU
comparison flips as soon as the frame contains a town.

The campaign's named cost centre is closed rather than carried a fourth time.
Bindings 4, 6, 7 and 8 costing a descriptor write per draw - forward-carried
since V6i-3 as the thing to fix if CPU were short - measures 0.031 ms for ALL
~216 draws of the frame, about 140 ns each and 2.4% of it. No Vulkan code was
changed to chase the miss: every lever the V8 row named was already taken
(coherent rings, one submit per frame), irrelevant to p50 (pipeline pre-warm),
measured and small (descriptors), or would have traded real memory for nothing
(a fourth swapchain image, when acquire is call cost and not waiting).

The methodological finding is worth reading before the numbers. The R6 soak is
NOT the vehicle the founding numbers came from - the G5 production profile states
its own conditions and they exclude the probe, the artifact owner and the
screenshot oracle - and it is biased AGAINST Vulkan, because
VulkanGraphicsContext arms retainBackbufferCapture exactly when
ACDREAM_AUTOMATION_ARTIFACT_DIR is set, making every Vulkan frame copy the whole
swapchain image while GL reads on demand. On one binary in one hour the soak
reports CPU p50 7.3 ms and 2,531 KiB/frame where the ordinary profile reports
1.13 ms and 77 KiB. The route table above is therefore conservative in Vulkan's
favour: it wins on the vehicle that charges it extra.

Gates: Release build green; App tests 4,152 / 3 skipped, the pre-slice baseline,
no #250-family failure; strict GL offline pixel gate against 13c8733d at 1.95e-05
(11 px of 563,200), inside the 9-31 band, so GL did not move; one connected
Vulkan run with VK_LAYER_KHRONOS_validation proven inserted by the loader at zero
errors and zero warnings; and BOTH R6 soaks green - Vulkan 506.6 s and GL 506.8 s,
zero failures, graceful exits - which discharges the soak half of V7's
outstanding list. RenderDoc is not installed on this machine, so that capture
carries to V10 with a cause rather than as an omission.

The recommendation: proceed to V10 and amend the acceptance table rather than
waive it, naming the scene and pacing the floor is judged at. Two natural
candidates are already in the evidence and Vulkan passes both outright. The
opposite reading - that the light-scene rows disqualify the cutover - is
available and has been given the same measurement space. That call is the user's
and this slice does not make it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:35:16 +02:00
Erik
32f9bcfa60 docs: publish the Campaign V report
Synthesizes the OpenGL-to-Vulkan migration for viewing on github.com: the
screenshot sequence from first Vulkan content through the Dereth world, sky,
portal tunnel and paperdoll; the six-cycle blank-world investigation that
falsified five mechanisms and broke four instruments before the interleaved
A/B probe and the 10/10 NVIDIA cross-vendor run pinned the defect to the AMD
GL driver; the V7 differential verdict tables, including the offline
GL-versus-Vulkan pair at 8.82e-04 inside threshold; the five compiled-clean-
but-still-wrong defects the migration's own pixel gates caught; and the
current V8/V9/V10/V11 status. Images are copied and renamed from the
untracked artifacts/ tree under docs/reports/assets/campaign-v/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 20:51:13 +02:00
Erik
00e1b32177 perf(render): Campaign V slice V8 commit 1 - the Vulkan arm gets an instrument
V8 is the performance gate, and it opened on a plain fact: the Vulkan arm
emitted no [frame-prof] line at all. V6h wired it to
NullRenderFrameGpuMeasurement, whose BeginFrame does nothing - and that method
is the ONLY caller of FrameProfiler.FrameBoundary. So a Vulkan run produced no
CPU frame distribution, no allocation-per-frame column, no frame-history CSV
and no GPU sample. The campaign's own performance vehicle,
tools/run-connected-r6-soak.ps1, waits on [frame-prof] boundaries to time its
samples, so it could not be pointed at the backend V8 exists to judge. You
cannot measure what you have not instrumented, so the instrument lands first.

The bracket is deliberately identical on both arms. On GL,
FrameProfilerGpuMeasurement begins a TimeElapsed query at BeginFrame and ends
it at EndFrame, spanning resource preparation, the world scene and private
presentation, and NOT the swapchain present. VulkanFrameGpuMeasurement opens
and closes a Vulkan timestamp scope at exactly those two points. Two
differently-bracketed numbers in one comparison table would have been worse
than reporting none.

Vulkan timestamps resolve two or three frames late, so the sample carries the
profiler frame index that ISSUED it rather than being credited to the frame
that happened to read it - the pairing GpuFrameTimer already performs
internally on GL. VulkanGpuDevice opens the whole-frame scope tagged with that
index, reads the previous use of the slot back in BeginFrameResources BEFORE
the tag is overwritten, and hands completed (tag, microseconds) pairs to the
adapter through a bounded queue that never blocks.

VulkanGpuTimerPool gains TryTakeResolved, which consumes the value it reports.
TryResolve deliberately reports the last known measurement forever, which is
right for a diagnostic readout and wrong for a percentile: counting one
measurement into the distribution twice is how an instrument flatters itself.

FrameProfiler gains a GL-free FrameBoundary() overload and RecordGpuSample.
Every other line of its bookkeeping - the CPU delta, the per-thread allocation
delta, the stage buffers, the history row, the five-second report - is the same
code the GL arm runs. The GL path is byte-for-byte unchanged in behaviour:
FrameBoundary(GL) still owns and creates the query ring.

Gates: Release build green. App tests 4,152 passed / 3 skipped, exactly the
pre-slice baseline. Strict GL offline pixel gate against 13c8733d:
1.95e-05 (11 px of 563,200), inside the documented 9-31 px band, so GL did not
move. An offline Vulkan run now reports gpu_ms in [frame-prof] and fills gpu_us
in the frame-history CSV, where before this commit it reported neither.

No divergence-register row is owed: this is diagnostic apparatus and no
rendered pixel depends on it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 20:35:59 +02:00
Erik
13c8733d90 docs: record the mid-campaign visual verdict and file #253
The user's connected session on the Vulkan backend passed all six targeted checks with merits: the AD-46 treeline (no visible shimmer or moire - the register row's human-visibility question is answered as invisible), dyed gear composites, doorway look-out and threshold crossing, stacked translucency, icon/text crispness, and motion feel. This was the first human play session on the Vulkan renderer.

One finding, filed as #253: attribute/skill icons in the character sheet sit off-center in their cells and render fully opaque where retail uses vitae-window-like translucency. The V7 differential holds the retained UI identical between backends, so this is suspected pre-existing D.2b fidelity rather than a campaign regression - one GL-side look confirms, then it is post-campaign scope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 20:21:30 +02:00
Erik
a13cff884f ci(render): Campaign V slice V9 - the Vulkan gate runs on lavapipe
The first CI job in this project's history that renders a frame.

The whole row rests on a decision V6g already made and paid for. When
section 5.5.8 cut set 0 from ten dynamic storage descriptors to four, four
was not merely under the RX 9070 XT's eight - it is Vulkan's guaranteed
minimum, so no conformant device can fail the layout. That is what makes a
software-device row possible at all. Every other requirement was then
checked against Mesa's lvp_device.c rather than assumed, and all seventeen
features the gate demands are true on lavapipe - including
samplerAnisotropy, which V7 made load-bearing eight commits ago and which a
software rasterizer would have been entirely within its rights to decline.

Three things had to exist before the job could:

1. The harness could not stop. VulkanBringUpHost presents until its window
   closes, which is right at a desk and impossible in CI, where nothing ever
   closes a window. ACDREAM_VULKAN_PROBE_FRAMES gives it a budget; unset or
   malformed is zero, which keeps the interactive behaviour, so no existing
   invocation changes. The budget never cuts the capture short - the loop
   stays open until the screenshot has been attempted - because a run whose
   entire product is a PNG must not be able to exit green with an empty
   artifact directory. The decision is a pure static method, tested without
   a window or a driver.

2. tools/compile-shaders.ps1 was Windows-only and nobody had noticed,
   because nothing had ever run it anywhere else. It built its paths from
   embedded 'src\AcDream.App\...' literals; a backslash is a separator on
   Windows and an ordinary filename character everywhere else, so on Linux
   that is one long nonexistent file name.

3. The report's jq paths were invisible to the compiler. Renaming a record
   property or swapping the enum converter would have left every test green
   and turned CI red on someone else's branch days later, with a failure
   that reads like a driver problem. VulkanCapabilityReportContractTests
   pins the exact strings the job greps and pins its packed-version
   arithmetic against VulkanApiVersion's own unpacking.

The job, eleven steps: install lavapipe and Xvfb; record vulkaninfo as
evidence; publish linux-x64; run the Gpu.Vk tests on a second operating
system; probe the gate under a 24-bit Xvfb screen (the default is 8-bit,
which leaves the X11 WSI without a usable visual) and assert an accepting
verdict on a Cpu device at API >= 1.3 with a clean active probe; assert the
captured PNG is a real frame by IHDR dimensions and byte count; re-run with
ACDREAM_VULKAN_FORCE_UNSUPPORTED=timelineSemaphore and assert exit 4 with an
actionable refusal; recompile the shaders and compare. Artifacts upload on
always(), so a red run ships its own diagnosis.

The .spv step is what ties the committed binaries to their sources. The
existing App test hashes GLSL against the manifest, which catches "edited a
shader, forgot to recompile"; nothing caught a stale or hand-edited .spv.
Verified on Windows before shipping: 19/19 artifacts byte-identical to a
fresh compile, zero drift.

No GL-versus-Vulkan pixel compare, for two independent reasons recorded in
section 5.5.20: linux-graphical asserts exit 4, so there is no left-hand
side, and the probe renders synthetic scenes rather than the DAT world CI
cannot have. The two jobs now say something sharper than a pixel diff would
have - on the same software Mesa stack, GL is refused and Vulkan is accepted
and draws. Physical Linux GPU and Wayland rows stay deferred on the Slice L
precedent; no hosted runner offers either.

Gates: Release build green, zero errors. App tests 4,152 / 3 skipped against
a 4,134 / 3 baseline at this branch's base (9b7f4343) - eighteen new, all
from this slice. Workflow validated by a real YAML parse plus an Actions
schema check and bash -n over all nine extracted run blocks; no actionlint
was available locally and none was downloaded. The job itself has not run:
its first execution is the CI run this commit triggers, and the V9 row stays
partial until that is green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 20:10:01 +02:00
Erik
9b7f434376 docs: record ArchRenderSceneTests as a fourth member of the #250 flake family
TransformUpdateBatch_ReusesRetainedStorage failed in two of six consecutive
whole-suite Release runs on a docs-only tree during Campaign V slice V7, and
passed 12/12 with its class run alone. Its assertion is the same shape as the
three already listed -- a GC.GetAllocatedBytesForCurrentThread() delta asserted
equal to zero across a warmed call -- so it is the known family rather than a new
defect. Recorded rather than re-investigated, which is the whole point of the
issue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 19:46:37 +02:00
Erik
45f58ac72b docs(render): record V7 - the instrument was measuring itself, and AD-46
Plan section 5.5.19, the V7 slice row, two rows in the section 5.1 uncovered
table, and one divergence-register row. No product code changes.

WHAT V7 TURNED OUT TO BE. Three of V6m's four numbers were taken through an
instrument that was not holding the world still. The route pinned the Dereth
clock by pressing AcdreamCycleTimeOfDay, whose mechanism is the transient
/time override that SyncFromServer clears -- so ACE un-pinned it seconds into
every run this campaign has taken. Two captures 45 s apart at ONE stop on ONE
backend differ in 22.3% of the frame because the sun keeps moving. Pinning it
(commit 2) took the interior stop from 12.16% to 0.78% on its own.

THE THREE LEADS, ANSWERED.

Lead 3 was WRONG and the section says so. V6m recorded the interior stop as a
route defect on the theory that the indoor spring-arm camera settles to different
distances in two runs. It does not. The interior was lit differently because the
sun had moved. With the sun held still the stop drops by a factor of fifteen and
its entire remaining difference map is the player character -- the EnvCell's
walls, floor, doorway and per-cell ambient are black. EnvCellRenderer's Vulkan arm
has its numeric pair, V6m's defect-list item 2 is discharged, and no route change
was needed or made.

Lead 2 is closed by pinning the cloud phase rather than masking the band, so the
gate keeps the sky under strict comparison.

Lead 1 is half fix, half finding. The residual was predominantly the anisotropy
gap (commit 1). What survives is one population -- dense alpha-blended distant
scenery -- and isolating it needed a better instrument than the connected route.

THE INSTRUMENT V7 RECOMMENDS FORWARD. An offline GL-versus-Vulkan pair, which is
just the two existing capture scripts run with ACDREAM_WORLD_TIME and
ACDREAM_SKY_PHASE_SECONDS set in the invoking shell. No session, no server, no
entities, no camera settle, no wandering NPCs, and unattended:

    GL vs GL   same commit (control)                       1,966 px   2.13e-03
    VK vs VK   same commit (control)                       1,039 px   1.13e-03
    GL vs VK   whole frame                                28,807 px   3.13e-02
    GL vs VK   everything below the tree band (rows 280+)     497 px   8.82e-04

Terrain, blending, roads, the water edge, fog, statics, scenery below the horizon
and the entire retained UI are at parity, inside the campaign's 0.001 threshold.

AD-46, FILED WITH ITS REFUTATIONS RATHER THAN ITS THEORY. The treeline band is an
anisotropic tap-pattern divergence between AMD's GL and Vulkan drivers. Three
competing explanations were tested and refuted, and section 4.7's predicted class
is one of them:

  - not a sub-pixel offset -- an integer shift search finds (0, 0);
  - not sharpness or LOD scale -- high-frequency energy matches within 5%;
  - NOT DEPTH PRECISION. Forcing the Vulkan viewport's window-depth range to
    [0.5, 1.0], which reproduces GL's compressed mapping exactly, moved the
    whole-frame number by 3% (28,807 -> 27,852). The experiment was reverted. The
    one pre-approved divergence class is not what this is, and the row says so
    rather than borrowing its approval.
  - It IS anisotropy, and there is no knob left: 41,509 differing pixels in the
    band at anisotropy 1, 22,266 at 16, which is GL's value and retail's.

THE VERDICT TABLE. Full route, both backends, tolerance 2, MSAA off, day group 0,
world time 0.5, sky phase 0 (artifacts/v7-diff-c2):

    holtburg_town           26,330 px   2.86e-02   EXCEPTION -- phase + AD-46
    facility_hub_interior    7,176 px   7.79e-03   EXCEPTION -- phase
    aerlinthe_island        62,892 px   6.82e-02   EXCEPTION -- AD-46 + dark floor

No stop passes and none of the three exceptions is a renderer defect; each is
named individually in the section, because "phase" is not an excuse unless it is
specific. Aerlinthe's is partly the instrument rather than either renderer: the
scene's mean luminance is 28/255 and half its differing pixels are exactly delta
3, one step over a tolerance that is absolute rather than relative. Changing that
tolerance is not V7's call.

CARRIED FORWARD, recorded in the section 5.1 table and the V7 row: a passing
connected stop needs authored per-stop masks that the gate script does not have
(it still has only the global -MaskTopPixels, deliberately defaulted to 0); the
portal depth mask has now gone three slices without drawing a pixel in an
automated run, and HouseExitWalkReplayTests names the cheapest target for it
(the Holtburg corner building, cell 0xA9B40170); whether AD-46 is visible to a
human is a user-stop question nobody has asked yet; and the R6 soak and RenderDoc
capture on Vulkan were not run.

Gates for this commit: docs only, so the code gates of commits 1 and 2 stand.
Complete Release suite 9,195 passed / 5 skipped with zero failures, and the GL
connected repeat gate at 3/3 RENDERED on both the desktop witness and the client
capture, both taken at this tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 19:44:33 +02:00
Erik
1f25a60999 fix(diag): Campaign V slice V7 commit 2 - pin the world clock, because the route never did
THE ROUTE'S TIME PIN NEVER HELD, AND EVERY V7 NUMBER SO FAR WAS TAKEN THROUGH IT.

connected-backend-differential.route.txt opened by pressing
AcdreamCycleTimeOfDay three times, on the stated theory that the cycle walks
live -> 0.00 -> 0.25 -> 0.50 and lands on noon. The mechanism underneath is
WorldTimeService.SetDebugTime, and SyncFromServer clears it -- deliberately,
because that setter is the /time slash command and the command is meant to be a
look-at-dusk-for-a-moment affordance rather than a mode. There is even a test
pinning that behaviour: WorldTimeDebugTests.SyncFromServer_ClearsDebugOverride.
ACE sends TimeSync every few seconds. The clock was therefore un-pinned again
long before the route reached its first stop, on every run this campaign has
taken, including V6m's smoke pair.

The Dereth clock does not only move the sky. It moves the SUN, so it moves the
directional term of every lit surface in the scene.

MEASURED, rather than argued. A probe route captured each stop TWICE, 45 seconds
apart, in the same run on the same backend:

    GL,     Holtburg,      capture 1 vs capture 2:  205,772 px   22.33%
    Vulkan, Holtburg,      capture 1 vs capture 2:  218,732 px   23.73%
    GL,     Facility Hub,  capture 1 vs capture 2:  108,795 px   11.81%
    Vulkan, Facility Hub,  capture 1 vs capture 2:  130,206 px   14.13%

One backend, one stop, nothing moving, and a fifth of the frame changes while
you watch. No cross-backend number means anything against that noise floor, and
the cross-backend numbers taken during that probe run were duly absurd -- 56% at
Holtburg, where the two launches happened to be at different times of Dereth day.

THE FIX IS A PIN THAT OUTRANKS THE SERVER CLOCK AND SURVIVES SYNC.

WorldTimeService.PinnedDayFraction is a nullable day fraction that wins over both
Calendar.DayFraction(NowTicks) and SetDebugTime, and that SyncFromServer does not
touch. ACDREAM_WORLD_TIME -> RuntimeOptions.PinnedWorldDayFraction ->
WorldEnvironmentController, which writes it once: the Runtime environment owner
and its clock are session-scoped, so one write outlives every teleport and every
reveal generation. Values outside [0, 1) are REJECTED rather than clamped -- a
day fraction of 12.5 is a typo, and silently pinning the world at it would be
worse than ignoring it.

Unset is the default and every ordinary run. The calendar DATE still advances,
which is intentional: the date drives day-group selection, and ACDREAM_DAY_GROUP
already pins that. The differential gate forces the pin at 0.5 -- noon, which is
what the three presses were aiming at -- on both launches, and the route's
presses are deleted rather than left in as decoration.

This is instrument determinism on the footing of ACDREAM_DAY_GROUP and V7's
ACDREAM_SKY_PHASE_SECONDS, not a workaround: it is off by default, nothing in the
shipping client reads it, and the alternative was to keep measuring two backends
through a fifth of a frame of sunlight.

WHAT IT MOVED. The same three-stop route, same commit otherwise, before and after:

    holtburg_town           9.05%  ->  2.86%      (83,438 -> 26,330 px)
    facility_hub_interior  12.16%  ->  0.78%      (112,075 -> 7,176 px)
    aerlinthe_island       23.09%  ->  6.82%      (212,824 -> 62,892 px)

The interior stop is the headline. V6m recorded it as a route defect on the
theory that the indoor spring-arm camera settles to different distances in two
runs; that theory is now refuted. The camera was fine. The interior was lit
differently because the sun had moved, and with the sun held still the stop drops
by a factor of 15 to 0.78% -- close enough to the 0.001 threshold that its
remaining population is worth naming rather than guessing at. No route change was
needed and none was made.

WHAT REMAINS, per the difference maps, all of it now attributable by eye:
the animated portal beside the Holtburg stop; distant scenery foliage; wandering
NPCs and a chimney smoke plume, which are animation and emitter phase; the vitals
readouts, whose stamina and mana genuinely regenerate at different rates across
two logins minutes apart; and, at Aerlinthe, a dense low-magnitude speckle in a
scene whose mean luminance is 28/255 -- half of its differing pixels are exactly
delta 3, one step over a tolerance that is absolute rather than relative.

Gates. Release build green. App tests 4,134 passed / 3 skipped (one new: the
day-fraction range check); AcDream.Core.Tests WorldTimeDebugTests 6/6, including
the two new ones that assert the pin survives a sync and outranks the transient
override. GL offline pixel gate against the pre-slice tree: 2.66e-05, 15 pixels
of 563,200, inside the documented 9-31 band -- GL did not move.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 19:34:13 +02:00
Erik
ad5f8b68dc fix(render): Campaign V slice V7 commit 1 - world anisotropy, and the sky's second clock
Two changes, one measurement. The V6m smoke pair put GL versus Vulkan at
Holtburg at 18.52% of the frame differing at tolerance 2 with MSAA off. The same
stop on the same instrument now measures 9.05%, and the two populations these
address are gone from the difference map rather than merely smaller.

1. THE WORLD ATLASES WERE SAMPLED WITHOUT ANISOTROPY ON VULKAN, AND WITH THE
DEVICE MAXIMUM ON GL.

RhiWorldTextureArray -- the backend-neutral shared object/material atlas, and
the only IWorldTextureArray the Vulkan arm ever constructs -- registered its
clamp and repeat slots with GpuSamplerDescription.WorldClamp/WorldRepeat as
written, which carry MaxAnisotropy 1. The GL arm asks for the driver's own
GL_MAX_TEXTURE_MAX_ANISOTROPY twice over: ManagedGLTextureArray sets
GL_TEXTURE_MAX_ANISOTROPY on the image, and the two sampler objects its resident
bindless handles are built from (OpenGLGraphicsDevice.WrapSampler/ClampSampler)
set it again, which is the one that actually wins.

V6i-2 knew it was asking for 1 and said so in a comment -- "the world arm that
draws through these arrays is the next slice, and it is the one that can gate a
filtering change visually." That slice was V6j, the gate is V7, and this is it.

Retail settles the question rather than the GL arm settling it.
RenderDeviceD3D::SetDefaultD3DStates (0x005a3800) loops all sixteen sampler
stages and issues SetSamplerState(stage, 0xA, this->m_D3DCaps.MaxAnisotropy) at
0x005a4230. 0xA is D3DSAMP_MAXANISOTROPY and the argument is the device's
reported cap, not a setting -- so "as much anisotropy as this device has" is
retail's own rule, the GL arm is faithful to it, and asking for 1 diverged from
retail as well as from the shipping backend. No divergence-register row is owed
in either direction: this retires a Vulkan-only gap and lands on retail's value.

The fix asks for a ceiling rather than reading a limit back, because the pinned
RHI contract (plan section 3.3) carries no anisotropy field and is frozen. It
does not need one: VulkanGpuSampler already clamps MaxAnisotropy to
VkPhysicalDeviceLimits.maxSamplerAnisotropy, Vulkan guarantees that limit is at
least 16 wherever the samplerAnisotropy feature is supported -- which this
backend requires -- and 16 is where every desktop driver caps. The request and
the GL arm's read therefore land on the same number.

What it was worth, from the difference map at the same stop: the roof shingles
of both Holtburg cottages, which had been dense hatching across the whole
surface, and the stone courses of the near building are now black. Measured as
high-frequency energy (mean absolute neighbour difference, GL versus Vulkan) the
right-hand roof went from visibly blurred to a ratio of 0.999 and the wall to
1.023; every other textured region in the frame is between 0.99 and 1.02.
Grazing-angle surfaces are where anisotropy is the whole difference, which is
why a roof was the loudest thing in the frame.

2. THE SKY HAS TWO CLOCKS AND ONLY ONE OF THEM WAS PINNABLE.

ACDREAM_DAY_GROUP and the route's AcdreamCycleTimeOfDay presses pin the Dereth
date, which chooses the day group, the keyframe and the sun angle. The cloud
sheet does not read that clock: SkyRenderer accumulates TexVelocityX/Y against
DateTime.UtcNow minus its own construction time, by design, because retail's
clouds drift with real time regardless of the date. Two launches minutes apart
therefore cannot agree about where the clouds are no matter what the route does,
and the V6m smoke measured the cost -- 89% of its 18.52% sat in the top 240 rows.

ACDREAM_SKY_PHASE_SECONDS (RuntimeOptions.SkyAnimationPhaseSeconds ->
SkyRenderer.AnimationPhaseSecondsOverride) replaces that elapsed-seconds value
with a fixed one. Unset -- the default, and every ordinary run -- keeps the wall
clock, so nothing a user or the offline gate sees changes. The differential gate
forces it on both launches alongside MSAA and the day group; the offline gate
keeps its top-280 mask, because a same-commit GL pair still has the sun to
disagree about.

This is instrument determinism on the same footing as ACDREAM_DAY_GROUP, not a
workaround: it is one input to a UV offset, it is off by default, and no shipping
path reads it. The alternative on the table was -MaskTopPixels, which would have
permanently blinded the campaign's strictest instrument to the entire sky -- one
of the five surfaces the offline gate already cannot see. Rows 0-32 of the
Holtburg pair went from 23,090 differing pixels to 1,211, and what remains up
there is roof and portal rather than cloud.

WHAT THE SAME PAIR STILL SHOWS, unattributed and carried to the next commit: the
distant treeline, the player and the NPCs, and the animated portal. The portal is
phase and expected. The treeline is not filtering -- sharpness now matches within
5% and a shift search finds no sub-pixel offset -- and the two runs entered the
world at different last-logout positions (0xC95B0001 versus 0x09040008), so the
far-tier streaming history differed. That is the next thing to prove or refute.

Gates. Release build green. App tests 4,133 passed / 3 skipped against the
4,132/3 baseline (one new: the sky-phase parse). GL offline pixel gate against
the pre-change tree: 2.31e-05, 13 pixels of 563,200, inside the documented 9-31
band -- GL did not move. One offline Vulkan run with VK_LAYER_KHRONOS_validation
proven inserted by the loader: zero validation errors, zero warnings. Full
three-stop differential recorded at artifacts/v7-diff-c1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 19:14:08 +02:00
Erik
a7529a975a test(app): serialize the classes sharing camera/render process globals (#252)
A full Release App run failed once at Issue181WallPressEquilibriumTests
.Diagnostic_WallPressedCamera_EyeWanderAndViewerCellStability. It passed in
isolation and did not recur across five further whole-suite runs, and the diff
under test touched only the world texture-creation stack -- nothing in camera,
visibility or physics. A cross-class parallelism race was the only plausible
mechanism, not a regression.

Ten App test classes share three process-global mutable statics, and xUnit runs
distinct test classes in parallel by default:

  - CameraDiagnostics: AlignToSlope, CollideCamera, TranslationStiffness,
    RotationStiffness, UseRetailChaseCamera. These are not merely written, they
    are written AWAY from their defaults -- RetailChaseCameraTests sets
    AlignToSlope and CollideCamera to false, and three classes set
    UseRetailChaseCamera to false -- while RetailChaseCamera.Update,
    CameraController.Active, CameraFrameController, WorldRenderFrameBuilder and
    MouseLookController read them.
  - RenderingDiagnostics.ProbeFlapEnabled, written by CornerFloodReplayTests
    and Issue181WallPressEquilibriumTests.
  - System.Console.Out, redirected by those same two classes to capture probe
    output.

Every one of these classes already saved and restored in try/finally. That is
correct within a class and remains necessary, but it was never sufficient. A
finally bounds a mutation in TIME along its own thread; it cannot stop another
class from reading the static inside that window. Worse, two overlapping
save/restore pairs can interleave so the second restore writes back the FIRST
one's temporary value, leaving the global permanently wrong for the rest of the
run. The Console.Out case is the sharpest instance: an interleaved restore can
install a DISPOSED StringWriter as the process-wide Console.Out, which then
throws in unrelated tests. Serializing the sharers is what makes each class's
existing finally sufficient.

The fix is a marker CollectionDefinition applied to the ten sharing classes,
following the WorldEnvironmentControllerCollection precedent. No collection
fixture: several members are [Theory] cases that need different knob values per
case, so a fixture cannot own the save/restore without rewriting every member's
internals, and it would not help the read side at all. Because every member
references the same compile-time const for the collection name, the grouping
cannot silently drift via a typo.

Membership is deliberately narrow. It covers the eight writers plus two classes
that drive production code which READS a knob another member moves off its
default (HouseExitWalkReplayTests and CameraFrameControllerTests both run
RetailChaseCamera.Update and assert on the resulting eye). Classes that merely
construct a CameraController without a retail chase camera are NOT members --
their reads fall through the null branch and are insensitive.

No production code changed; no assertion was weakened, and no retry, sleep or
tolerance was added.

Verification. Base commit f6275f45 measured empirically at 3,763 passed / 3
skipped. Post-fix: 136 whole-suite Release runs. Every failure observed was in
the pre-existing zero-allocation family tracked as #250 (an Expected 0 / Actual
N bytes assertion), and none was in any collection member. A matched 55-run
baseline at f6275f45 reproduced that same family, confirming it predates this
change. Serialization cost is inside run-to-run noise: the suite is ~3 s of a
~4.5 s wall-clock dotnet test, and the ten serialized classes are a small
fraction of it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:46:43 +02:00
Erik
3b62b9bfa3 docs(render): record V6m - portal space crosses, and V7 gets its instrument
Adds section 5.5.18, a V6m row to the slice table and to section 5.1's
user-gate-debt table, and corrects section 5.1's "no connected route visits a
dungeon" claim - which was wrong in two directions.
connected-world-lifecycle.route.txt has carried a Facility Hub stop all along,
so the lifecycle gate did reach an interior even though nothing compared its
pixels; and reaching one turns out not to be the same as being able to compare
it.

The V7 list moves in three ways. Item 1 closes: every production renderer draws
on both arms. The appraisal-viewport half-discharge that V6k opened and V6l
carried closes too - the view was driven on both backends and inspected. And a
new item 7 opens, which is the honest result of firing the instrument once: with
the sky band and the animated portal masked, the smoke pair still differs in
1.17% of the frame, about 12x the pinned threshold, while the chat panel on its
own differs in 42 pixels of 106,560 - inside the threshold. So the 2-D retained
UI is already at parity and the residual lives in the 3-D pass, at silhouette
edges. That is a better starting position for V7 than a single aggregate number
would have been, and it is why the smoke was attributed rather than just
reported.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:43:16 +02:00
Erik
a99f517ec2 feat(tools): Campaign V slice V6m commit 2 - the backend differential gate
tools/run-backend-differential-gate.ps1 is V7's instrument, plus the route it
runs. It executes the SAME connected route twice - ACDREAM_RENDER_BACKEND=gl
then =vulkan - pairs the screenshots by name, compares each pair with
compare-screenshots at the plan's pinned tolerance 2 / fraction 0.001, and
prints one verdict table with a JSON report beside it.

ACDREAM_MSAA_SAMPLES=0 IS FORCED ON BOTH LAUNCHES. Plan section 5.5.16 measured
what leaving it on costs - 8.83% of the frame at 4x, essentially all of it
hugging foliage and silhouette edges, which is two drivers' sample patterns and
not a renderer divergence. Forcing it on one side only would be worse than
either. ACDREAM_DAY_GROUP is pinned on both, and the route additionally pins the
client-only time-of-day override so the Dereth clock cannot drift between two
launches minutes apart.

The desktop witness and its three guards are lifted from the repeat-run gate
rather than reinvented, because that gate learned them the hard way: a blank
frame reads back as a valid PNG (section 5.5.2), so a second instrument that
shares nothing with the renderer decides whether a run is worth comparing at
all; a locked screen or an overlapping window is caught by the grab's size; and
stray input into a foregrounded window moves the camera off the pinned stop, so
the client log is scanned for camera-affecting actions and the run is aborted
rather than reported. Both runs close through WM_CLOSE - a hard kill leaves ACE
holding the session about three minutes and the second launch is the one that
pays for it.

connected-backend-differential.route.txt is written for two-launch determinism,
which is stricter than either existing route needed to be: identity quaternions
on every teleloc so heading is pinned, no movement between arrival and capture,
and no checkpoint verb (the ownership ledger has its own gate). Three stops -
Holtburg town, an interior EnvCell at the Facility Hub, Aerlinthe's island and
water edge. The interior stop is the durable fix section 5.1 names for the
campaign's oldest coverage gap: no connected route has ever visited one, which
is why EnvCellRenderer had no automated coverage on either backend.

ONE SMOKE PAIR was run, on the first stop, and it is reported honestly rather
than tuned. GL versus Vulkan at Holtburg with MSAA off: 170,697 of 921,600
pixels differ, 18.52%, maximum channel delta 255. Attributed with a difference
map:

  * 89% of it is in the top 240 rows. Masking the offline gate's top 280 rows
    takes the pair to 11,480 of 563,200, 2.04%. That band is the scrolling
    cloud sheet, which advances with WALL time and not with the pinned Dereth
    clock, plus the treeline behind it.
  * Masking the animated portal beside the character as well takes it to 6,190
    of 529,450, 1.17%, mean channel delta 0.49. A portal's scrolling texture is
    phase, like an emitter's age.
  * What remains is thin outlines on silhouette edges throughout the frame,
    plus the vitals readouts, whose stamina and mana genuinely advanced between
    the two logins.

So the distance to V7's 0.001 is about 12x with the two known phase populations
removed, and V7 owns closing it. -MaskTopPixels exists for that conversation and
DEFAULTS TO 0: the gate is a strict identity check on the whole frame unless
someone deliberately asks otherwise, per section 7.1 rule 2.

Both files are ASCII with CRLF, matching the other tools. That is not cosmetic:
PowerShell 5.1 reads a BOM-less .ps1 as ANSI, so a UTF-8 em dash inside a
double-quoted string is a parse error, which is how the first version failed.

Gates. Release build green; App tests 4,132 / 3 skips. No product code changed,
so commit 1's pixel, connected and validation gates stand unchanged - and the
smoke run is this commit's own evidence, since it drove a complete connected
Vulkan session end to end with a graceful exit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:34:43 +02:00
Erik
59c6b2ae94 feat(render): Campaign V slice V6m commit 1 - portal space draws on Vulkan
PortalTunnelPresentation was the last raw-GL world-adjacent renderer. It now
draws on both arms, and the composition that used to hand the Vulkan arm a
portal-less teleport presentation is gone with it.

Nothing about the scene changed. Same synthetic DAT Setup resolved through the
same client-enum mapping, same 40 fps CSequence, same retail rotation cadence,
same distant light, drawn through the same already-dual-arm WbDrawDispatcher.
What forked is only where the draw is recorded:

  * GL keeps its GLStateScope, its viewport/scissor/depth/cull/blend statements
    and its depth-only glClear, untouched.
  * The RHI arm opens a backbuffer pass of its own and publishes it on
    IWorldPassScope for the span of the draw - the shape V6l gave the two
    offscreen viewports, and required for the same reason: the dispatcher's RHI
    arm borrows its pass rather than opening one. Publication comes after
    BeginPass and before UploadRetailLight, because publishing resets the
    frame-global sections and this scene wants its own light, not the world's.

The one substantive decision is the pass's COLOUR load op, and it is a Clear
rather than a Load. Retail preserves the colour target and only clears depth
(UIViewportObject::DrawContent @ 0x006950A5 -> Clear(4) = D3DCLEAR_ZBUFFER), and
so does the GL arm. A Vulkan pass cannot inherit an image the way a bound
framebuffer can: under MSAA the frame's world pass RESOLVES into the swapchain
image and stores DontCare into the multisampled scratch, so a second
multisampled pass declaring Load would load undefined contents - plan section
5.5.12 item 5, the same hazard that merged the clear into the world pass.

Re-clearing is exact rather than approximate because of an invariant the frame
graph already enforces. RenderFrameFoundation.PortalViewportVisible and this
scene's IsVisible are the same value, read once at the top of the frame, and
WorldSceneRenderer returns without drawing when it is set. So whenever portal
space draws, the backbuffer holds exactly the opaque black
SceneTool::BeginScene @ 0x0043DAD0 establishes and nothing else, and clearing to
that same black changes no pixel. The alternative - a single-sampled Load pass
over the resolved image - would have been both a silent MSAA divergence and
invalid, since the backbuffer's depth attachment is multisampled.

The pass takes IWorldPassScope.SampleCount, so WbDrawDispatcher's sample-count
pipeline variants (V6l) select the backbuffer set, and depth matches the
attachment.

CreateRequired becomes internal: its two new seams are internal RHI contracts
and composition is its only caller. The TYPE keeps its visibility - plan section
7.1 rule 3.

Gates. Release build green. App tests 4,132 / 3 skips against the 4,129
baseline (three new: the retail black constant, the RHI arm's composition
precondition, and the both-arms composition assertion). Complete Release suite
9,195 / 5; one AcDream.Content failure in the solution-wide run that passes
124/124 rerun alone - the documented rerun-singly flake class, not carried
forward as a claim. Strict GL offline pixel gate against 280f3b3f: 28 px of
563,200, fraction 4.97e-05, inside the documented 9-31 band, with a same-commit
control pair at 20 px / 3.55e-05 taken immediately afterwards. GL connected
-Runs 3: 3/3 RENDERED on the desktop witness and 3/3 on the client capture. One
offline Vulkan run with VK_LAYER_KHRONOS_validation proven inserted by the
loader: zero validation errors, zero warnings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:25:49 +02:00
Erik
280f3b3fe9 fix(test): stop the streaming priority-apply tests reading a warm JIT
Five tests in StreamingControllerPriorityApplyTests passed only when a
sibling ran first in the same process. Run alone, they failed on
assertions about world-state residency and completion backlog:
DungeonCollapseBeforePromotionBase (line 355),
InFlightNearLoad_DemotedBeforeFirstCompletion (536),
HardRecenter_RejectsOldOverlappingLoadAndUnloadGenerations (582),
HardRecenter_DropsStaleOutboxThroughBoundedAdmission (626), and
DeferredCompaction_ApplyFailureRetainsExactResult.

The state a sibling supplied was not data. It was compiled code.
StreamingController meters each Tick against a wall-clock ceiling and
StreamingWorkBudgetOptions.Default allows 2 ms per frame; these tests
took that default. A cold first Tick has to JIT the whole publication
path, and the meter's own diagnostics measured it at 10.55 ms with
LastLimit=Time and one yield at stage publication-spatial-commit. The
frame's first operation is admitted unconditionally through
ensureProgress, so applyTerrain ran and the terrain assertion passed;
the very next reservation, the GpuWorldState spatial commit, was
refused, so the landblock never became resident in that frame. Any
sibling that publishes a landblock first (DuplicateNearCompletions, for
instance) warms that path and the same Tick then fits inside 2 ms.
Pairing the failing test with that sibling passed; pairing it with
DestinationReservation_StaleGenerationCannotClearReplacement, which
drains no completions and therefore JITs nothing, still failed.

Yielding mid-publication and resuming next frame is correct production
behavior and other tests in this file assert exactly that. The defect
was the setup: these tests assert which results publish, in what order,
and under which generation, yet left the elapsed-time dimension at a
value that made every assertion a function of machine speed and test
order. Every controller in the class now takes a budget whose time
ceiling cannot bind, applied uniformly so the next test added here does
not reacquire the dependency. Count and byte ceilings keep their real
values, including the deliberately small MaxCompletionAdmissions of
ForceReloadWindow_DiscardsBufferedCompletionsFromOldWindow and the
MaxCompletionsPerFrame scaling of the two tests that use it, so the
bounded-admission behavior under test is untouched. No assertion was
relaxed and no production code changed.

All fourteen tests in the class now pass individually and together;
Core is 3295 passed / 2 skipped, and two consecutive full-solution
Release runs are 8826 passed / 5 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:09:08 +02:00
Erik
6aee01cf72 docs(render): record V6l - three amendments, and the last three renderers cross
Section 5.5.17 records the slice: the instanced-vertex-input amendment and
particles (b1ad1d48), the stencil dimension and the portal mask (eced67d0), and
the offscreen viewports (2e8b8b91). The V4e row is no longer blocked and the V4g
row is no longer half-landed; the slice table gains a V6l row and section 5.1's
accumulated-debt table gains one for the two connected captures the offline gate
cannot reach.

Four defects are recorded as found by RUNNING rather than by validation, which
is the pattern this campaign keeps paying for: the standalone particle texture
cache and the entity-appearance composite cache were both bindless-only, so the
Vulkan arm could draw neither a textured particle nor any entity with a palette
override; a pipeline bakes one depth/stencil format, so an offscreen target's
depth had to take the device's; and the paperdoll rendered upside down because a
GL framebuffer's origin is bottom-left and a Vulkan image's is not.

The V7 list is rewritten. Nothing on it is blocked on a contract decision any
more. What is left is one absent renderer (PortalTunnelPresentation has no
Vulkan arm), EnvCellRenderer's arm narrowed from unproven to proven-by-one-frame
after a Marketplace interior rendered on Vulkan, the MSAA-off requirement, the
per-draw descriptor writes, the portal mask's two shader sources, and the
appraisal viewport's carried-forward half-discharge.

AP-92 is narrowed rather than retired: the private viewports are backend-neutral
targets on both arms and the blit's V origin is derived rather than assumed, so
the origin half of that row's risk column is closed. The rest of it - retail
renders each CreatureMode directly against a cloned CPhysicsObj - is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:08:04 +02:00
Erik
2e8b8b91ad feat(render): Campaign V slice V6l commit 3 - the offscreen viewports draw on Vulkan
Amendment 3 of three: the paperdoll and creature-appraisal views render on the
Vulkan arm. Plan section 5.5.16 defect 3 named two backend fixes as the
precondition; both are here, and running it found two more the note could not
have known about.

Fix 1: a layered sampled view per render target. An ATTACHMENT view must be
VK_IMAGE_VIEW_TYPE_2D and the global texture table's descriptor array is
sampler2DArray, so the attachment view cannot legally be registered into it -
section 5.5.7 recorded that as invalid usage rather than a mismatch that samples
oddly, and V6k made RegisterTexture refuse it loudly and name this fix.
VulkanGpuTexture now creates a SECOND, layered view over the same image for a
colour render target: one image, one allocation, two ways of looking at it,
legal without any creation flag. SampledView is what the table registers for
every texture, so the question disappears rather than being answered.

Fix 2: sample-count pipeline variants for WbDrawDispatcher. Vulkan requires a
pipeline's rasterizationSamples to equal the pass it draws in, and this
dispatcher draws in two passes with different counts - the multisampled
backbuffer world pass and the single-sampled offscreen target, which the
contract fixes at one sample. Its five pipelines became a MeshPipelineSet with
two instances, selected at bind time from the live pass rather than from the
scope, which is the same shape section 5.5.8 gave the depth-format problem. When
the backbuffer is single-sampled the two sets are one object, so nothing is
built twice and nothing is freed twice. The offscreen target's DEPTH attachment
also had to take the device's own combined depth/stencil format rather than the
contract enum's literal D24_UNORM_S8_UINT: a pipeline bakes one depth/stencil
format under dynamic rendering and the same pipelines draw in both passes, so a
second format would make one of the two undefined.

Fix 3, which running it found: entity APPEARANCE composites were still
bindless-only, so no entity with a palette override could be drawn on the Vulkan
arm at all - the doll being one, and every creature and player besides. The
backend that serves it has existed since V6i-2 and had no production consumer;
it has one now. TextureCache builds the composite cache on both arms, and
EnsureCompositeTexturesAvailable stops asking about bindless. Nothing about the
cache itself changed: the sharing, the bounded unowned LRU, the metered upload
budget and the retirement fence were already backend-neutral.

Fix 4, which the first successful capture found: the doll rendered upside down.
UiViewport has flipped V since V4a because a GL framebuffer's origin is
bottom-left, so its colour texture samples bottom-up. A Vulkan image's origin is
top-left and the backend's negative viewport height stores the rendered image
that way round, so the same flip stands the doll on its head. That is a property
of the backend that made the texture, not of the widget that draws it, so
IUiViewportRenderer answers TextureIsBottomUp and UiViewport asks. The line this
replaces had predicted exactly this failure since it was written.

The seam. WbDrawDispatcher's RHI arm borrows its pass from IWorldPassScope
rather than opening one, so a viewport that opens a pass of its own has to
publish it there for the span of the draw. Publish is on the interface now for
that. It does not nest: the world phase has closed its own pass by the time
private presentation runs, which is where these viewports have always drawn.

Gates. Release build green. App tests 4,129/3 skips; complete Release suite
9,192/5 (one solution-wide run reported a single App failure that did not
reproduce in the App suite alone or in a second solution-wide run - the
documented rerun-singly flake class; the failing test name was not surfaced by
the runner and is not carried forward as a claim). Strict GL offline pixel gate
against 08ffe141: 3.55e-05, 20 differing pixels of 563,200, inside the
documented 9-31 band. GL connected -Runs 3: 3/3 RENDERED on the desktop witness
and 3/3 on the client capture. One offline Vulkan run with
VK_LAYER_KHRONOS_validation proven inserted by the loader: zero validation
errors, zero warnings.

And the two captures the offline gate cannot reach, both connected and both
inspected. The Vulkan paperdoll (artifacts/v6l-vk-paperdoll3) renders the doll
upright, in armour, at the right scale, over a transparent background, and is
indistinguishable from the same capture on GL taken minutes later
(artifacts/v6l-gl-paperdoll) - which is also the no-regression check for the V
change. Particles (artifacts/v6l-vk-poi versus artifacts/v6l-gl-poi, cropped
4x at artifacts/crop-vk-glow.png and crop-gl-glow.png): Holtburg's forge plume
and its field of glint sprites draw in the same places with the same alpha
compositing on both backends, the puffs differing only in phase because two
launches cannot agree on an emitter's age.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:05:24 +02:00
Erik
eced67d038 feat(render): Campaign V slice V6l commit 2 - the portal mask draws on Vulkan
Contract amendment 2 of three, and V4g's remaining half behind it. Plan section
5.5.16 defect 2: PortalDepthMaskRenderer's two-pass punch (#117) is built on
glStencilFunc/glStencilOp/glStencilMask, GpuPipelineDescription carried no
stencil state at all, and nothing else can express it - so the renderer stayed
raw GL, invisible to the Vulkan arm, and V4g's "stencil/depth-mask pipelines"
row could not be written.

The amendment splits the way core Vulkan 1.3 splits. The ENABLE and the
attachment intent are baked: GpuPipelineDescription.StencilTest, false by
default so no pipeline in the tree changed. The per-draw compare, three outcome
ops, reference and both masks are a GpuStencilState that the pipeline carries as
a DEFAULT and IGpuPassEncoder.SetStencil overrides - exactly the split cull
mode, front face and depth write already have, and exactly what
VK_DYNAMIC_STATE_STENCIL_OP/_COMPARE_MASK/_WRITE_MASK/_REFERENCE make dynamic.
The four stencil dynamic states are declared ONLY by a pipeline that tests
stencil: declaring a dynamic state obliges every draw with the pipeline to have
set it, so adding them unconditionally would make every existing pipeline depend
on a call none of them make. GpuStencilOp carries three values because the punch
uses three - Replace marks, Equal gates, Zero self-cleans - and a fourth would
be a facility with no consumer.

The arm. Three pipelines, not one, because depth COMPARE is not dynamic in the
contract and the punch's two passes differ in it: mark tests LEQUAL and writes
no depth, punch tests ALWAYS and writes, seal is ALWAYS + write with no stencil.
All three write no colour, which is what retail's "COLOR-INVISIBLE triangle fan"
means. The fan is expanded to a triangle LIST on the CPU - the contract has no
fan topology and Vulkan's is not portable - which is exact: triangle i is
(v0, v[i+1], v[i+2]), the same triangles in the same order.

portal_depth.{vert,frag} is a new committed shader pair, and this is the ONE
renderer in the campaign whose two arms do not share a source. Its clip planes
have to travel in the TerrainClip uniform block at binding 2, which is already
precisely this shape and already read by terrain_modern.vert and sky.vert - but
on GL that binding is held globally by ClipFrame for terrain, so a portal draw
that rebound it would leave every later terrain draw in the frame reading the
wrong region. The GL arm therefore keeps its inline program.
PortalDepthShaderParityTests is the tripwire: retail's far-Z constant
(0.99999988, from DrawPortalPolyInternal 0x0059bc90), #129's capped mark-bias
expression and the eight-half-plane loop are asserted to appear in both. Both
are deleted at V11. 9/10 shader pairs now compile to SPIR-V.

Two GL-side gaps closed while the state was being extended, both of section 7.1
rule 1's class rather than new work. GlAmbientCapabilityState now saves and
restores the stencil test, function, ops and both masks - the portal punch draws
mid-frame among renderers that are still raw GL and assume the test is off - and
the COLOUR MASK, which had no consumer until a colour-invisible pipeline existed
and whose absence would have blacked out every raw-GL renderer after such a
pass.

PortalTunnelPresentation was re-read and confirmed as V6k left it: it clears
depth and draws into the active viewport, binds no framebuffer of its own, and
needs no port for section 5.4's sake. It remains unported on the Vulkan arm -
the composition uses NullLocalPlayerTeleportPresentation there - which is an
absence on the V7 list, not a defect.

Gates. Release build green. App tests 4,129/3 skips; complete Release suite
9,192/5 (one solution-wide run reported a single App failure that did not
reproduce in two subsequent runs, solution-wide or alone - the documented
rerun-singly flake class). Strict GL offline pixel gate against 08ffe141:
2.31e-05, 13 differing pixels of 563,200, inside the documented 9-31 band. GL
connected -Runs 3: 3/3 RENDERED on the desktop witness and 3/3 on the client
capture. One offline Vulkan run with VK_LAYER_KHRONOS_validation proven inserted
by the loader: zero validation errors, zero warnings, a captured world frame.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:36:45 +02:00
Erik
b1ad1d481b feat(render): Campaign V slice V6l commit 1 - particles draw on Vulkan
Contract amendment 1 of three, and V4e's content behind it. Plan section 5.5.16
recorded that both particle pipelines draw with per-instance VERTEX attributes
and that the pinned contract could express instanced DRAWING but not instanced
vertex INPUT: one stride, no divisor, one buffer at VertexInputRate.VERTEX. That
is what stopped V4e. This takes the reviewed option (i) - a second vertex
binding with a per-instance rate.

The amendment. GpuVertexLayout grows a per-binding notion (binding index,
stride, input rate) and GpuVertexAttribute names the binding it is fed from,
defaulting to 0; IGpuPassEncoder.BindVertexBuffer takes a binding index. Every
layout written before this slice keeps its exact meaning through
GpuVertexLayout.Interleaved, which is one vertex-rate binding 0 - and
GpuContractTests asserts that as a requirement rather than trusting it. Both
backends carry the rate natively and at no cost: VK_VERTEX_INPUT_RATE_INSTANCE
on the pipeline, glVertexAttribDivisor recorded once into the pipeline's VAO
where it survives every later attribute rebind.

GpuVertexFormat.UInt1 comes with it, and is necessary to it: particle.vert
declares `layout(location = 6) in uint aTextureIndex` and the amendment's whole
premise is that no shader is edited. Same kind-distinction UByte4UInt was added
for at V4d - GL needs glVertexAttribIPointer, Vulkan needs R32_UINT, and the
float path would reinterpret the value's bits rather than approximate them.

Options (ii) and (iii) were rejected on the record: all ten storage bindings are
spoken for and reusing binding 0 would have the GL particle draw clobber
WbDrawDispatcher's instance array mid-frame (section 5.5.8's hazard in its GL
form); CPU-expanding instances is 5x billboard bandwidth and does not scale to
mesh particles at all.

The arm. ParticleRenderer.Rhi.cs is a SECOND arm per section 5.5.6, not a
replacement - every GL statement in the sibling file is the one it always
issued. Five pipelines replace the imperative glBlendFunc switch (two billboard
blends, three mesh blends) because core Vulkan 1.3 does not make blend dynamic.
The per-flight VAO/VBO pool disappears because every ring allocation inside a
frame is already distinct memory that lives until the frame retires. The
binding-9 table is not bound at all - the device owns the table and the encoder
binds set 2. The pass is BORROWED from IWorldPassScope. Depth tests but does not
write, compare is Less and alpha-to-coverage is off, which is the ambient GL
state particles have always drawn under rather than a choice. Everything above
the submission seam - emitter iteration, retail distance ordering, the
deferred-alpha handoff, billboard axis construction, blend resolution - is the
same CPU code on both arms.

The first Vulkan particle frame threw rather than drew, which is the second
defect of the compiles-clean class this slice found by running:
TextureCache.AcquireParticleTexture is bindless-only, so the standalone particle
texture cache did not exist on a backend without GL. It exists on both arms now.
Everything about it that matters - sharing equivalent surfaces between emitter
owners, the bounded unowned LRU, retirement behind the frame-flight fence - is
already backend-neutral; only how one entry is created and destroyed differs,
which is what IStandaloneBindlessTextureBackend is for. The RHI arm creates the
image through IGpuDevice.CreateTexture with a real sampler and releases the
table slot before the image, which is the GL arm's order and for the same
reason. The composite cache stays GL-only: it serves entity appearance, not
particles.

The durability fix V6k earned. That slice found the sky declaring a 32-byte
stride against a 36-byte AcDream.Core.Terrain.Vertex - the record carries a
TerrainLayer no sky attribute names - and noted that every .Rhi.cs arm restates
a CPU record's footprint from memory while only sky had a test.
RhiVertexLayoutStrideTests is that test for the rest: world mesh, terrain, sky,
retained-UI sprite, debug line, and both particle bindings, each asserted
against the record or the producer's own float count, plus two sweeps over all
seven for attributes that reach past their stride or name an undeclared binding.
Four private layouts became internal to be assertable; nothing else about them
moved.

Gates. Release build green. App tests 4,121/3 skips (4,109 baseline plus three
contract tests and nine layout tests); complete Release suite 9,184/5. Strict GL
offline pixel gate against 08ffe141: 3.20e-05, 18 differing pixels of 563,200,
inside the documented 9-31 band. GL connected -Runs 3: 3/3 RENDERED on the
desktop witness and 3/3 on the client capture. One offline Vulkan run with
VK_LAYER_KHRONOS_validation proven inserted by the loader: zero validation
errors, zero warnings, a captured world frame that still draws terrain,
blending, roads, water, statics, scenery, sky and the complete retained UI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:20:59 +02:00