Commit graph

1938 commits

Author SHA1 Message Date
Erik
534bacbc23 diag(net): #260 outbound/command-gate probe + corrected issue framing
The two-agent investigation refuted #260's as-filed hypotheses: every
UseWithTarget was acked (the J5.2 use gate never latched), and the LOH
leak is bounded sawtooth churn - the real climb is ~2.25 GB of native/
GPU memory (WS 3,261 vs managed 1,015 MiB at wedge). The wedge evidence
also showed why it could hide: the live combat toggle routes through the
generation-gated runtime command seam, and every rejection exit in that
chain (Disposed / StaleGeneration / !IsInWorld at Validate, plus the
operations slot reading IsInWorld=false when unbound) is COMPLETELY
silent - no log, no event.

ACDREAM_PROBE_NET=1 (NetDiagnostics owner, PhysicsDiagnostics pattern)
now arms three probe families, all zero-cost when off:

- [net-out] per reliable send at the SendGameMessage chokepoint: opcode,
  GameAction type+sequence, fragment/packet sequence, managed thread id
  (two tids would prove the cross-thread ISAAC-desync hypothesis alone),
  and state; [net-out-EX] via an exception FILTER that logs without
  catching, so propagation is unchanged.
- [net-tick] 1 Hz cadence from WorldSession.Tick: inbound/s, queue
  depth, budget breaks, worst inter-tick gap (frame-stall witness),
  out/s, acks/s.
- [cmd-gate] every silent runtime-command rejection with expected-vs-
  view generation, lifecycle, and IsInWorld, plus the combat toggle
  result (whose Inactive exit reads a DIFFERENT IsInWorld source).

One walked-portal repro session with this probe distinguishes all
remaining #260 wedge hypotheses. ISSUES.md #260 rewritten to the
corrected two-root framing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 08:53:58 +02:00
Erik
39c1737bda feat(core): adopt retail's SoundType catalog; retire AC2D
SoundId was not a subset of retail's table, the way its comment claimed.
It was an invention: 23 acdream-local names on acdream-local values, and
the values were wrong in the way that matters. FootstepDefault = 0x02 is
retail's Random. SwingSword = 0x10 is retail's Death2. Death = 0x60 is
retail's Explode. Anyone who reached for one of those names to compare
against a wire or dat value would have got a different sound.

Nothing referenced any of them by name -- grep for `SoundId.` across src
and tests returns nothing -- so this was a trap rather than a live defect,
the same shape the enum campaign found in DamageType. All 22 invented names
are deleted and retail's 205 replace them.

Three oracles agree exactly, on every name and every value: retail
acclient.h:4569 enum SoundType, ACE's Sound, and DatReaderWriter's Sound.
The third matters most. AudioHookSink already resolves SoundTable lookups
through DatReaderWriter.Enums.Sound, so that is the enum acdream actually
reads at runtime; our catalog now agrees with the values already flowing
through the dat path, and a conformance test pins the two so they cannot
drift apart.

On the "206 sounds" figure: retail's block holds 207 entries, being 205
sounds followed by NUM_SOUND_TYPES = 0xCD and FORCE_SoundType_32_BIT. The
first is a count and the second a width pin. Counting the former is where
206 came from. Neither is a member here, matching how the campaign treated
NUM_ATTACK_HEIGHTS and Num_HoldKeys -- a count is not a value the wire can
carry.

Behaviour is unchanged and could not be otherwise: the enum had no
consumers. IAudioEngine's three SoundId overloads are no-op stubs and the
live path takes wave ids and DatReaderWriter values.

The user's separate report that sound is "not working that good" is a
triggering, selection and attenuation question rather than a catalog one,
and is filed as its own Bucket B row in the post-Vulkan intake.

Also in this commit, by user decision: AC2D is retired as a reference. Its
clone and directory are gone and it must not be re-cloned. Everything we
took from it still stands and is written down -- the FSplitNESW terrain
split constants, the 0xF61C movement packet layout, the finding that a
client need not compute terrain Z itself -- so CLAUDE.md's reference list,
its hierarchy table, and the architecture doc's protocol row now point at
docs/research/2026-04-12-movement-deep-dive.md rather than erasing the
history. The reference count drops from six to five.

Core tests 3907 passed / 2 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 07:38:56 +02:00
Erik
f27ad9ee43 feat(core): adopt retail's full WeenieError code table
acdream carried 16 status codes, curated by hand out of the CMotionInterp
and MoveToManager decompilation passes. The other 362 were unnamed, which
made every one of them a cast site waiting to happen. This slice takes the
whole table: 372 values under 378 names.

The oracle set is finally complete. All six vendored reference repos were
empty when the 2026-07-29 enum campaign ran, which is why it deferred this
decision; they are re-cloned now, so ACE's WeenieError could be read
directly instead of leaning on the UtilityBelt catalog alone.

The two agree without a single conflict. ACE has 369 members, no internal
value collisions. The catalog has 372, shares all 369 ACE names, and
disagrees on none of their values. Its three extras -- IsNowOpenFellowship
(0x050B), IsNowClosedFellowship (0x050C), LockedFellowshipCannotRecruit
(0x0518) -- each turn up in ACE's separate WeenieErrorWithString enum with
a `_` marking the interpolated name, so the catalog is just the less-split
view of the same client enum. All three are adopted on agreement between
two oracles, not on one.

Retail cannot arbitrate any of this. acclient.h has no counterpart enum;
its charError (26) is character-creation only. Recorded, not guessed
around.

Six values keep two names. acdream's NotGrounded, CrouchInCombatStance,
SitInCombatStance, SleepInCombatStance, ChatEmoteOutsideNonCombat and
ActionDepthExceeded are each anchored to a retail decompilation site, where
ACE's names for those values are server-side coinages. Rather than pick,
both are declared, acdream's first so ToString() is untouched.

Behaviour is unchanged, and there is no way for it not to be: nothing in
the tree branches on a WeenieError member. MotionInterpreter's switch is on
a motion type and merely returns one of these; WeenieErrorText.For switches
on a raw uint; the chat translation table WeenieErrorMessages is keyed on
uint throughout, so naming a code does not make it render. The one site
that moved is RemoteTeleportHook, where the (WeenieError)0x3Cu cast becomes
the now-named WeenieError.ITeleported at the same value.

Register row AP-15 is narrowed rather than retired. Its code-catalog caveat
is superseded -- an unnamed code is no longer a way for it to bite -- but
the sentences are still ACE's doc comments rather than retail's
string_table.bin, and that part stands.

The enum moved out of MotionInterpreter.cs into its own file at the same
namespace. At 372 members it does not belong inside a physics class file.

Core tests 3903 passed / 2 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 07:33:27 +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
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
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
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
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
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
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
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
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
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
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
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
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
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
Erik
eb7e6b4e5c feat(render): Campaign V slice V6k commit 2 - the viewports name their own target, and section 5.4 is discharged
V4g's first half, and the V7 blocker section 5.4 named.

What moved. PrivateEntityViewportRenderer - the paperdoll and creature-appraisal
viewports - stops hand-rolling an FBO, a colour texture and a depth renderbuffer
and asks the device for an IGpuRenderTarget. The pass it opens DECLARES that
target rather than binding one behind the RHI's back, and the colour attachment
is registered into the global texture table through RegisterTexture like any
other texture. Render() returns the UiTextureTableHandle the retained UI already
speaks instead of a raw GL name.

That deletes the V4a pre-approved transitional seam. GlGpuDevice's
RegisterExternalColorTexture / TryResolveExternalColorTexture existed so the UI
could blit a texture whose owner the RHI knew nothing about; plan section 7.1's
final paragraph gave them exactly this slice as their end, and both are gone
along with the GlGpuDevice casts in RetailPaperdollFrameView and
RetailCreatureAppraisalFrameView. Those two views are now backend-neutral: they
decode a handle instead of registering one.

Section 5.4, stated precisely, because the answer is not what the section
predicts. The divergence it describes - GL's BeginPass refusing to bind
framebuffer 0 for a null target - is NOT on the tree and has not been since the
V4c revert at 543bc79f, which took that hunk with it. GL's BeginPass binds the
declared target today, so the two backends already agree about what
Target: null means. What the revert did not undo was the REASON the divergence
existed: this renderer bound a framebuffer no pass had declared. It now names its
target, and PortalTunnelPresentation - the other renderer section 5.4 names -
draws into the active viewport rather than an offscreen buffer, which is the
backbuffer, which is what a null target literally means. The obligation is
therefore discharged on both halves and V7's second defect is closed.

PortalDepthMaskRenderer is NOT ported and is not blocking. Its two-pass punch is
built on glStencilFunc/glStencilOp/glStencilMask, and GpuPipelineDescription has
no stencil dimension to express them with. That is a pinned-contract question,
reported rather than worked around.

The section 5.5.7 re-check, which was asked for explicitly and does not come back
clean. That note recorded that "the render-target-view-in-table usage from V6c
did not fire" and asked that it not be carried forward as accepted. It still does
not fire, and now for a reason worth writing down: a Vulkan render-target image
is viewed as VK_IMAGE_VIEW_TYPE_2D because that is what an attachment needs,
while the texture table's descriptor array is declared sampler2DArray, so
registering one is invalid usage rather than a mismatch that samples oddly. It
has never fired because the only renderer with an offscreen target is composed on
GL alone. VulkanGpuDevice.RegisterTexture now refuses it loudly and names the fix
- a second, layered sampled view per render target - so the slice that gives the
Vulkan arm a viewport finds a precondition instead of a driver-level fault.

Gates. Release build green. App tests 4,109 passed / 3 skipped, unchanged from
commit 1. Strict GL offline pixel gate against 22aa2edc: 4.08e-05, 23 differing
pixels of 563,200, inside the documented 9-31 band, maximumChannelDelta 48. GL
connected repeat gate at 3 runs: 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 surface the automated gates cannot see was checked rather than banked.
The offline scene never opens the inventory, so the pixel gate is a tripwire for
this change and nothing more - plan section 5.1's debt table has said so since
V6d. A connected run that presses ToggleInventoryPanel and captures the result is
in artifacts/v6k-paperdoll: the doll renders through the new render target with
the correct pose, orientation and alpha, which is the row that table has been
carrying since V4c.

No divergence-register row: no retail-facing behaviour changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:46:01 +02:00
Erik
22aa2edc65 feat(render): Campaign V slice V6k commit 1 - the sky draws on Vulkan
V4f's content, landed as a SECOND arm per section 5.5.6: GL keeps its raw world
path through to V10 and the RHI world path ships on Vulkan. Every GL statement in
SkyRenderer is the one it always issued; the encoder arm lives in SkyRenderer.Rhi.cs
and runs only when there is no GL context.

What it produces. ACDREAM_RENDER_BACKEND=vulkan renders the sky: the dome
quadrants, the horizon band, the cloud sheet and the fog gradient, in the same
place and the same colours as the GL capture of the same scene (within a few
units on the channels sampled, which is the day-fraction drift between two
launches). Section 5.5.15's first V7 defect - "the sky is flat fog" - is closed.

Three things differ from the GL arm, each because Vulkan bakes what GL sets. The
per-submesh blend function becomes two PIPELINES, additive for sun/moon/stars and
straight alpha for everything else, because core Vulkan 1.3 does not make blend
dynamic. The SkyParams block becomes a ring slice taken per draw rather than one
buffer rewritten per draw, because a descriptor's contents are read at execution
time, not record time. And the pass is borrowed from IWorldPassScope, because the
frame's one backbuffer pass resolves and a second pass could not load what it
left.

The sky is the first Vulkan consumer of set 1 binding 4. Section 5.5.8 recorded
that UniformSkyParams was missing from the uniform set layout and V6i-2 added it;
until now nothing had ever bound it.

The stride bug, which is the fourth of its class this campaign. The first Vulkan
sky frame drew the dome as a field of blue-white noise. The RHI vertex layout
declared a 32-byte stride - position, normal, texcoord, exactly what sky.vert
reads - while AcDream.Core.Terrain.Vertex is 36 bytes: it carries a fourth
member, TerrainLayer, that no sky attribute names and that the GL arm never
described to a glVertexAttribPointer but did count, because it says
sizeof(Vertex). Nothing else in the frame looked wrong, no validation rule was
violated, and the offline pixel gate masks the sky band, so only a side-by-side
capture found it. SkyVertexLayoutTests now asserts the REQUIREMENT - the stride
is the uploaded record's footprint - rather than today's number.

The last interim handle table is gone. V4t retired the private
GlBindlessHandleTable in WbDrawDispatcher, EnvCellRenderer, TerrainModernRenderer
and ParticleRenderer and deliberately left the sky's, because the sky is the one
world path that mints its own resident handles from TextureCache's raw GL texture
names rather than interning someone else's. It now registers those handles
through V4t's RegisterWorldTextureHandle seam instead, which is the same
mechanical change the other four took, and the class and its tests are deleted
because nothing else ever used them.

TextureCache gains RegisterWorldSurface(surfaceId, repeat), the sky's RHI texture
source: the same DecodeFromDats the GL path uses, created through
IGpuDevice.CreateTexture and paired with a real sampler object rather than baked
into a bindless handle. Keyed by (surface, wrap) for the same reason the GL arm
keys its handles that way - a table entry is a combined image sampler, so the
dome sampled CLAMP_TO_EDGE and a scrolling cloud sheet sampled REPEAT are two
entries over one decoded texture.

Gates. Release build green. App tests 4,109 passed / 3 skipped - the 4,112
baseline less the six GlBindlessHandleTable tests that went with the class, plus
three vertex-layout tests. Strict GL offline pixel gate against 7ae796a1:
4.43e-05, 25 differing pixels of 563,200, inside the documented 9-31 band, with
maximumChannelDelta 48 in the same 46-52 range every control pair reports. GL
connected repeat gate at 3 runs: 3/3 RENDERED on the desktop witness and 3/3 on
the client capture. Seven-day-group before-and-after comparison on GL - the
method V6e used, because the pixel gate masks the sky band - matching in
gradient, cloud sheet, horizon band and fog on every group, including day group
2's salmon cloud band and day group 6's green band. One offline Vulkan run with
VK_LAYER_KHRONOS_validation proven inserted by the loader: zero validation
errors, zero warnings, a captured sky frame, graceful close.

Coverage gap, stated rather than assumed. The offline scene is a fixed outdoor
view at one time of day, so the sun, the moon and the rain cylinder are drawn by
neither arm's gate. They join the accumulated user-gate debt in plan section 5.1,
where V6e already filed them.

No divergence-register row: no retail-facing behaviour changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:31:37 +02:00
Erik
f84eef3256 feat(render): Campaign V slice V6j commit 2 - Dereth draws on Vulkan
The three world renderers' submission arms, both pass executors, and the
composition that reaches them. This is the unit three predecessors stopped at.

What it produces. ACDREAM_RENDER_BACKEND=vulkan on the offline scene renders
terrain with blended textures and road overlays, the water edge, static world
meshes, procedural scenery, and the complete retained UI - the same frame the GL
pixel gate captures, from the same camera, minus the sky. artifacts/v6j-vk2.

The shape, and why it is not V4c's. Section 5.5.6 chose option (B) after NVIDIA
rendered the V4c binary 10/10 where AMD's GL stack did not: GL keeps its raw
world path through to V10 as a documented fork confined to the submission seam,
and the RHI world path ships on Vulkan. So V4c's and V4d-2's content returns as a
SECOND arm rather than a replacement. The GL arm issues the same GL statements in
the same order against the same objects; the encoder arm lives in three .Rhi.cs
partials and is entered by one branch per submission site.

Three differences from V4c, each because the tree moved under it. There is no
binding-9 texture table - V4t put the slot on the device and Vulkan binds set 2,
so the arm that used to intern bindless handles simply has nothing to do. The
pipelines carry the device's sample count rather than 1, because Vulkan requires
rasterizationSamples to match the pass and alpha-to-coverage is a no-op at one
sample. And no renderer opens a pass.

That last one is structural, not tidiness. Under MSAA the frame's one backbuffer
pass resolves into the swapchain image and stores DONT_CARE into the multisampled
scratch, so a second pass declaring Load would load undefined contents; the
backend also permits one open pass per frame. VulkanWorldScenePhase therefore
opens the pass, publishes the encoder on VulkanWorldPassScope for exactly the
span of the inner WorldSceneRenderer, and every renderer borrows it.

Three sections are frame-global on GL and cannot be on Vulkan: the SceneLighting
UBO, the per-cell clip regions, and the terrain clip block. GL binds each to a
global binding point and every consumer inherits it. Vulkan binds a descriptor
set per draw, and a renderer's own binds are what select the scope those sections
must land in - so their writers PUBLISH into WorldFrameSections and each renderer
binds them inside the pass, after its own binds. SceneLightingUboBinding's
per-flight-slot buffer pool disappears with it: a ring allocation is already
distinct memory that lives until the frame retires, which is the property the
pool existed to provide.

Both pass executors became backend-neutral rather than gaining twins. Everything
they do is delegation to a renderer except four concerns - the clip-frame
publication, the doorway scissor, gl_ClipDistance enablement, and retail's
interior depth clear - so those four move behind IWorldPassSurface and retail's
ordering, which is what these classes are actually for, is written once. The GL
implementation issues the statements the executors used to issue inline.

Clip distances are no-ops on the Vulkan arm, and that is safe rather than a
divergence: Vulkan activates every element the shader declares, and all three
world vertex shaders already write 1.0 into every slot past the active count.
The interior depth clear becomes vkCmdClearAttachments, reached through the scope
so the pinned contract stays frozen and the backend-only verb stays in the
backend. The hook for it was already committed at V6i-3 with a cref to a type
that did not exist yet; it exists now.

The collision-wireframe DebugLineRenderer is composed as null on the Vulkan arm.
DrawAndPublish flushes it INSIDE the world phase and it opens its own pass, which
the one-pass rule forbids. The toggle is DevTools-only and DevTools is not
composed there, so nothing is lost - composing it would throw on the first
wireframe frame rather than silently misdraw.

Two seams widened rather than invented. GameWindowGraphics answers whether the
backend has a world-pass seam, because the three composition phases that need it
already borrow that handle and "does this backend work that way" is what the type
exists to answer. And MeshSourceReady replaces the anyVao != 0 gate with the same
question in backend-neutral form - V6i-3 published HasStores for exactly this -
so the predicate evaluates identically on GL.

What is NOT here, and is expected. Sky and weather are still raw GL (V4f), so the
Vulkan frame's sky is the atmosphere fog clear. Particles (V4e), the paperdoll and
appraisal viewports and the portal depth mask (V4g) likewise. The executors
already accepted all of them as absent.

Gates. Release build green. App tests 4,112 passed / 3 skipped, the unchanged
baseline; complete Release suite 9,175 / 5. Strict GL offline pixel gate against
847f14ae: 5.50e-05, 31 differing pixels of 563,200, inside the documented 9-31
band and 18x under the threshold. Characterised rather than accepted, because 31
is the band's top: cross-commit pairs measured 21, 29 and 31 while same-commit
controls measured 12 and 20, and maximumChannelDelta is 46-52 in every comparison
INCLUDING the pure controls - so the few large-delta pixels are a property of the
capture, and a cross-commit pair at 21 against a same-commit pair at 20 is not
what a systematic shift looks like. GL connected repeat gate at 3 runs: 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, and a graceful close.

Coverage gap, stated rather than assumed. The offline scene is a fixed outdoor
view, so EnvCellRenderer's Vulkan arm draws nothing in it - dungeon interiors are
half of this slice and are unproven by anything automated, exactly as they were
for V4c. The deferred-alpha path and the doorway scissor are likewise untouched
by this scene. They join the accumulated user-gate debt in plan section 5.1.

No divergence-register row: no retail-facing behaviour changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:46:54 +02:00
Erik
81fe5e1b63 fix(render): Campaign V slice V6j commit 1 - the Vulkan winding needs no inversion
VulkanViewportMapping has inverted the front face since V6c, on the standard
argument that rendering with a negative viewport height mirrors framebuffer
space and therefore reverses triangle orientation. The world arm is the first
consumer that culls anything, and it falsified the inversion twice over on one
frame.

Nothing exercised it before now. Every Vulkan consumer through V6i - TextRenderer,
DebugLineRenderer and the bring-up scene - declares Cull = GpuCullMode.None, so
the mapping had never decided a single fragment. That is why a wrong answer
survived four slices and a validation-clean run: an unexercised path.

What the world arm measured, on the same offline scene the GL pixel gate captures.
Terrain is the one single-sided surface acdream draws - FrontFace(Ccw) plus
Cull(Back), matching ACRender::landPolysDraw's per-triangle eye-side predicate -
and under the inversion it vanished completely, 190 multi-draw commands issuing
against 625 loaded landblocks with nothing on screen. Every closed building shell
rendered inside-out in the same frame: the front wall culled and the interior
beams visible through the gap, which is what a back-face-front cull looks like on
geometry that is only nearly convex. Declaring the GL winding verbatim restores
both at once - terrain draws single-sided from above, and the shells close.

Two independent surfaces, one change, and the correction is the identity mapping.
Recorded here rather than worked around in the renderers, because a renderer that
compensates for its backend is exactly the shape this file exists to prevent: the
contract says renderers speak GL and the backend translates, and the backend was
translating wrongly.

The viewport flip itself is untouched and still correct - it is what puts
GL-authored geometry the right way up with no shader or matrix change. What goes
is the claim that a winding inversion has to travel with it. The scissor's
explicit flip is a separate correction with a separate justification and is
likewise untouched.

The test suite says so now rather than describing the old behaviour: the
pass-through is asserted directly, and the exact-inverses test becomes a
travels-alone test, so a later change that reintroduces the inversion fails here
first and on any single-sided surface second.

Gates. Release build green. App tests 4,112 passed / 3 skipped, the unchanged
baseline. GL offline pixel gate unaffected by construction - this file has no GL
arm - and measured with the world arm in commit 2.

No divergence-register row: this corrects a backend translation error rather than
introducing a deviation from retail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:45:53 +02:00
Erik
887de4aec2 feat(render): Campaign V slice V6i-3 commit 2 — the Vulkan frame gets a world pass, and descriptors bind at draw time
Two structural prerequisites for the world renderers' submission arms. Both are
in Gpu/Vk only; the GL backend executes not one changed statement, and the
frame this commit produces is bit-identical to the one before it.

1. The clear merges into the world pass (plan §5.5.12 item 5).

V6h gave the clear phase a backbuffer pass of its own: clear, resolve, close.
Under MSAA that is a trap for whatever comes next. A multisampled backbuffer
pass renders into a scratch image and RESOLVES it into the acquired swapchain
image, and the scratch's store op is DONT_CARE — so a world pass that followed
and declared Load would load undefined contents and lose the clear entirely.
The world renderers cannot work around it by each opening their own pass, for
the same reason: every pass after the first would load a discarded scratch.

So the clear phase now computes the same RenderFrameFoundation from the same
world clock and weather owners and publishes only the COLOUR, through
VulkanBackbufferClearState; VulkanWorldScenePhase opens the one backbuffer pass
and clears as its load op, with Store=Resolve when the backbuffer is
multisampled. That makes it the frame's one clear and its one resolve. The
retained UI's pass is single-sampled and targets the swapchain image directly,
so it composites over the resolved result exactly as it did.

The clear stays unconditional because the frame graph makes it so rather than
because anything asserts it: RenderFrameOrchestrator runs resource preparation,
then the world phase, then private presentation, with no branch between. A frame
with no world still opens the pass and leaves a cleared backbuffer — which is
precisely the frame captured below, since nothing draws into the pass yet.

2. Descriptor sets bind at DRAW time, not at bind time.

V6i-1 derives a descriptor-set scope from the descriptor state itself, which is
what closes §5.5.8's one-binding-two-buffers hazard. But the encoder issued
vkCmdBindDescriptorSets from inside BindStorageBuffer/BindUniformBuffer, so the
arena resolved after EVERY bind. For the retained UI's one or two binds that is
free. For a world renderer binding ten buffers it materialises up to ten scopes
per draw — nine of them PARTIAL states no draw ever uses, each claiming a real
descriptor-set pair out of a fixed-size pool and each paying a full round of
vkUpdateDescriptorSets. Recording the state and resolving it once, where the
draw needs it, yields exactly one scope per renderer, which is what the arena
was designed to produce.

It is legal because descriptor-set binding is independent of pipeline binding
when the layouts are compatible, and acdream has ONE pipeline layout by design
(§4.4) — the same property that lets a bucketed pass change pipeline for free.
The pass still opens with all three sets bound, which is V6h's fix for
VUID-vkCmdDraw-None-08600 and stays exactly as it was.

Gates. Release build green. App tests 4,112 passed / 3 skipped, unchanged from
commit 1. Strict GL offline pixel gate against commit 1: 3.73e-05 (21 differing
pixels of 563,200), inside the documented 9-31 px control band — expected, since
no GL file is touched. One offline Vulkan run with VK_LAYER_KHRONOS_validation
proven inserted by the loader: zero validation errors, zero warnings, and no
[shutdown] diagnostic on either stream. The captured Vulkan frame is compared
against commit 1's rather than merely eyeballed: 0 differing pixels of 921,600,
maximum channel delta 0 — bit-identical across the merge, which is the strongest
available evidence that moving the clear into the world pass changed nothing
about what is drawn.

What this does NOT do: draw a world. See the report for the enumerated remainder.

No divergence-register row: no retail-facing behaviour changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 14:43:12 +02:00
Erik
fe8abacfc6 feat(render): Campaign V slice V6i-3 commit 1 — the mesh pipeline's upload bodies cross the seam
V6i-2 cut IMeshPipelineDevice at the measured surface and proved the mesh
pipeline could be CONSTRUCTED without naming a backend. It said plainly what it
did not claim: "the mesh pipeline does not RUN on Vulkan. Its upload bodies are
still raw GL — GlobalMeshBuffer, the VAO/IBO construction, the layer transfers."
This moves them, and gives the interface its second implementation.

GlobalMeshBuffer takes GL?. The two backing stores were already IGpuBuffer
(V4b); what still needed a context was the vertex array and the attribute
pointers, which have no RHI verb because Vulkan bakes vertex input into the
pipeline. So a backend with none builds the stores and nothing else, publishes
0 for VAO/VBO/IBO, and publishes VertexStore/IndexStore — the same buffers,
named the way a pass encoder binds them. HasStores is the backend-neutral form
of the VAO != 0 readiness test the raw-GL draw paths make. Two bodies fork on
the context and nothing else does: InitBuffers skips the vertex array, and
CommitMigration skips the rebind — on the encoder arm the field swap IS the
atomic publication, because the next pass reads whatever the field then holds.
The store deletion likewise splits: GL keeps its immediate DeleteRetired,
because the arena's own flight gate has already proven no submitted frame can
reference the store, while the other arm has no second deferral to skip and
Dispose is its retirement-queued release.

ObjectMeshManager's RequireGl narrowed to the LEGACY per-mesh upload. Its three
call sites were one modern-path constructor argument and two bodies whose every
GL statement sits inside `if (!_useModernRendering)`. The constructor now hands
the arena the nullable context; the two bodies resolve one lazily inside the
legacy branch. That branch is unreachable in every shipping configuration —
missing bindless or draw-parameters throws at startup under the N.5 ship
amendment — so the accessor survives as the guard on dead code rather than as a
blocker, and it is deleted with that code.

VulkanMeshPipelineDevice is the second implementation, and it is four
properties and two no-ops. Two things about it are worth stating rather than
leaving to be inferred. HasBindless and HasOpenGL43 answer TRUE: their names are
GL-shaped because the seam was cut from a GL device, but what they gate is the
MODERN path — one shared arena, table texture indexing, multi-draw indirect —
which Vulkan supplies unconditionally and the capability gate rejects a device
for lacking, so answering false would disable the only path that exists.
HasPendingWork answers false because the GL device's queue exists to defer work
onto the thread holding the context, and Vulkan resource work is recorded into
the frame's command buffer or routed through the retirement queue.

WbMeshAdapter selects between them once, in the one place the mesh pipeline
still names a backend. The GL arm is unchanged, including the queue-drain
guarantee its construction rollback asserts.

So composition builds the mesh pipeline on BOTH arms, and NullWbMeshAdapter is
deleted — it existed for exactly the gap this closes, and the landblock spawn
ledger now registers against the real adapter. Streaming's publication into GPU
state stops being a no-op there: the Vulkan run below builds real render data,
including the [up-null] zero-vertex caching path.

Gates. Release build green. App tests 4,112 passed / 3 skipped, against a 4,109
baseline plus the three added here. Strict GL offline pixel gate against
579e0b7f: 4.44e-05 (25 differing pixels of 563,200), inside the documented 9-31
px control band and 22x under the 0.001 threshold. One offline Vulkan run with
VK_LAYER_KHRONOS_validation proven inserted by the loader (VK_LOADER_DEBUG=layer
reports `Insert instance layer "VK_LAYER_KHRONOS_validation"`): zero validation
errors, zero warnings, a captured frame, and no [shutdown] diagnostic on either
stream.

What this does NOT claim: nothing draws the world on Vulkan yet. The three
world renderers' submission arms, the two pass executors, and the pass-structure
merge are the next commit's.

No divergence-register row: no retail-facing behaviour changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 14:31:20 +02:00
Erik
5b3d72a90c refactor(render): Campaign V slice V6i-2 commit 3 — the mesh pipeline stops naming a backend
Plan §5.5.10 recorded the blocker as a fact about types: "WbMeshAdapter owns an
OpenGLGraphicsDevice, so it is not constructible on Vulkan until slice V4t" —
which is the entire reason NullWbMeshAdapter exists. §5.5.12 item 6 then measured
how wide that dependency really is, and the answer is seven members out of a
760-line class: a GL context, the retirement queue, the shared instance VBO, and
two capability flags.

IMeshPipelineDevice is exactly that surface. OpenGLGraphicsDevice declares it and
every member already existed under a GL-specific name, so the shipping backend
executes not one changed statement — these are aliases, not behaviour.

Two casts moved, and they are what actually blocked construction:

- ObjectMeshManager downcast IGpuDevice to GlGpuDevice in its CONSTRUCTOR, so a
  Vulkan-composed pipeline threw before running a statement. V4t put it there
  because the class registered bindless handles itself; commit 2 moved that into
  the array, leaving the field a pass-through for the raw-GL renderers' handle
  table. The cast now lives on that one property and names the backend it was
  composed against instead of reporting a failed cast.
- The atlas array factory is selected by IWorldTextureArrayFactory.For, which is
  the one place the texture stack branches on a backend.

MeshPipelineDeviceSeamTests proves the decoupling rather than describing it: it
builds ObjectMeshManager against a device whose Gl is null, asserts it constructs,
asserts construction built no GL object, asserts the handle table refuses by name,
and asserts the factory picks the RHI arm. A reflection test pins the seam's
member set so a later slice cannot quietly widen it back out — the whole value
here is that it is narrow.

What this does NOT claim: the mesh pipeline does not RUN on Vulkan. Its upload
bodies are still raw GL — GlobalMeshBuffer, the VAO/IBO construction, the layer
transfers — and they now fail through one RequireGl() accessor that names the
slice that owns porting them, instead of failing at construction. WbMeshAdapter
still creates an OpenGLGraphicsDevice in its GL constructor, because there is no
second implementation to create yet. Those bodies are items 3–5 of §5.5.12's
remainder list, along with RetailPViewPassExecutor and the three world renderers'
submission arms.

§5.5.13 reports the whole of V6i-2 and the slice table gains its V6i row.

Gates: Release build; App tests 4,109 / 3 skips (the 4,086 baseline plus 23 across
the three commits); complete Release suite 9,172 / 5; strict GL offline pixel gate
vs 0ca802cd 1.60e-05 (9 px of 563,200 — the low end of the documented 9–31 px
control band, and fewer than a same-commit control has measured); GL connected
tools/run-repeat-connected-gate.ps1 -Runs 3 at 3/3 RENDERED on the desktop witness
and 3/3 on the client capture; one Vulkan composition-host run with
VK_LAYER_KHRONOS_validation proven inserted by the loader at zero errors, zero
warnings, no [shutdown] diagnostic, and a captured frame.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 14:09:48 +02:00
Erik
c8d0f70bbe feat(render): Campaign V slice V6i-2 commit 2 — world texture creation crosses to IGpuTexture
Plan §5.5.11 recorded what V4t deliberately left behind: it moved the table
ENTRY of every world texture to the device and kept CREATION with the caches,
because "creating world textures through IGpuTexture is real remaining work and
it belongs with the Vulkan world arm, which is the first thing that cannot use a
GL handle at all." §5.5.12 item 1 handed it forward and named the missing piece
exactly — "an ITextureArray implementation over IGpuTexture, not a codec",
because V6b's BlockCompressionCodec and BlockCompressionMipChain already supply
the BC chains. This is that work.

IWorldTextureArray is the seam, and the slot is what crosses it. Before this
commit ObjectMeshManager read BindlessWrapHandle/BindlessClampHandle off the
concrete GL array and interned them into the device table itself. A 64-bit
ARB_bindless_texture handle has no Vulkan spelling, so the array now answers the
question the caller was really asking — ResolveSlot(wrapping) — and each arm gets
there its own way: ManagedGLTextureArray makes the same idempotent interning call
one level down, and RhiWorldTextureArray returns a pair it registered at
construction. ReleaseTextureSlots replaces the snapshot dictionary the manager
kept for the same reason, and still runs only once physical retirement completes.

Which implementation exists is decided ONCE, by the IWorldTextureArrayFactory
composition builds — plan §3.1's no-runtime-fork rule. Everything above the seam
(capacity policy, slot allocation, ref counting, layer retirement, empty-atlas
eviction, and the whole of ObjectMeshManager's atlas policy) is written once and
branches on nothing.

Three things the RHI array does differently, each because the backends genuinely
differ rather than by choice: BC mip chains are CPU-built through
BlockCompressionMipChain, since Vulkan cannot blit into a compressed image, while
RGBA8 uses the device's blit; filtering lives in an immutable sampler rather than
a texture parameter, so both address modes are registered up front exactly as the
GL array holds two resident handles; and RGB8/A8/Rgba32f are refused at creation
with the reason named. A8 is the interesting refusal — the GL array serves it by
swizzling R into A, and a Vulkan swizzle lives in the image VIEW, which the pinned
GpuTextureDescription does not describe. A silent substitution would render wrong
and look like a shader bug.

TerrainAtlas gains the second construction path V6i drafted and reverted. The
decode is factored out and shared, so both arms read the same DATs, in the same
order, with the same resize-to-max policy; only the upload forks.
ICompositeTextureArrayBackend gains its RHI arm, which is four small methods
because that seam was already a seam.

The Vulkan arm is EXERCISED, not merely present. That is the whole reason the
V6i draft was reverted rather than landed — "built then reverted because nothing
exercised it" — and it is the same failure §5.5.12 measured twice in the
descriptor layouts. So the composition host now builds the real terrain atlas
through IGpuDevice.CreateTexture on the arm with no GL context, and creates and
releases one shared array of each format family plus one composite array at
startup. Creation only; nothing draws them. Releasing them in the same statement
covers one thing a retained bundle would not — that both slot pairs come back and
the images route through the retirement queue.

Gates: Release build; App tests 4,104 / 3 skips; strict GL offline pixel gate vs
0ca802cd 3.20e-05 (18 px of 563,200, inside the documented 9–31 px control band);
GL connected tools/run-repeat-connected-gate.ps1 -Runs 3 at 3/3 RENDERED on the
desktop witness AND 3/3 on the client capture; one Vulkan composition-host run
with VK_LAYER_KHRONOS_validation proven inserted by the loader at zero errors,
zero warnings, no [shutdown] diagnostic, and a captured frame. That run built
terrain-atlas 512x512x33 with 10 mip levels, terrain-alpha-atlas 512x512x8, RGBA8
64x64x32 (slots 3/4, 174,720 mip bytes blitted), BC1 64x64x32 (slots 5/6, 696 mip
bytes encoded) and composite 32x32x8 (slot 7).

One whole-suite run failed Issue181WallPressEquilibriumTests once; it passed
alone and did not recur in five further runs. Seven test classes mutate the same
process-global CameraDiagnostics switches with no xUnit collection isolation, and
this diff touches no camera, visibility or physics code. A separate run of the
UNCHANGED parent tree failed a different zero-allocation test, which is `#250`'s
documented class. Both are filed rather than attributed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 13:57:43 +02:00
Erik
f7344758f8 fix(render): Campaign V slice V6i-2 commit 1 — the terrain clip block reaches set 1
Plan §5.5.12 finding 2, measured on the committed SPIR-V rather than inferred:
terrain_modern.vert declared

    layout(std140, binding = 2) uniform TerrainClip { ... }

with no ACDREAM_UBO_SET, so under the Vulkan dialect the block landed in set 0
binding 2 — which set 0's layout declares as a STORAGE buffer. Any terrain
pipeline built against the shared pipeline layout was therefore malformed.
Nothing had caught it: GL expands the macro to nothing and keeps its UBO and
SSBO namespaces separate, the shader compiled cleanly for both backends, and no
terrain pipeline has ever been created on Vulkan. sky.vert declares the SAME
block correctly and is the precedent, so this is a one-word omission, not a
numbering question.

spirv-dis on spv/terrain_modern.vert.spv, before and after:

    before   %372 = OpVariable %_ptr_Uniform__struct_370 Uniform
             OpDecorate %372 DescriptorSet 0 / Binding 2
    after    OpDecorate %372 DescriptorSet 1 / Binding 2

with %_struct_370 = OpTypeStruct %int %_arr_v4float_uint_8 — TerrainClip's
{ int uTerrainClipCount; vec4 uTerrainClipPlanes[8]; } — in both.

The same commit closes §5.5.8's second recorded gap. Set 1's layout declared
only bindings 1 and 3, so it was missing BOTH the terrain clip block and
UniformSkyParams at binding 4, which sky.vert and sky.frag have compiled to
SPIR-V since V6e. Both are now declared, all four dynamic, which is half
Vulkan's guaranteed maxDescriptorSetUniformBuffersDynamic of 8 and is asserted
by the capability gate as before.

Membership and ORDER now come from one predicate — IsDeclaredUniformBinding —
that the layout, the descriptor writes and vkCmdBindDescriptorSets's
dynamic-offset array are all built from, the same shape V6g gave set 0. The
three had been restated separately, which is exactly how a fifth binding would
have gone wrong the same way.

Both gaps were found by hand, months apart, and neither could fail on the
shipping backend. VulkanShaderDescriptorContractTests reads the committed .spv
and asserts the partition instead: every uniform block at a declared set-1
binding, every storage block inside set 0's declared range, every sampled
resource in the one texture table. Checked out against the pre-fix .spv, two of
its four tests fail.

Gates: Release build; App tests 4,090 / 3 skips (4,086 baseline plus four);
strict GL offline pixel gate vs 0ca802cd 3.02e-05 (17 px of 563,200, inside the
documented 9–31 px control band, 33x under threshold) — expected, since GL
executes not one changed statement; one Vulkan composition-host run with
VK_LAYER_KHRONOS_validation proven inserted by the loader at zero errors, zero
warnings and no [shutdown] diagnostic on either stream.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 13:31:13 +02:00
Erik
df6e2a7918 feat(render): Campaign V slice V6i-1 - one descriptor set per renderer scope
Plan section 5.5.8 recorded, and deliberately did not fix, that pointing one
binding at a second buffer within a frame silently corrupts the draws already
recorded against it: the backend rewrote the descriptor in place, and a
descriptor set's contents are read when the command buffer EXECUTES, not when it
was recorded. Nothing fired it while the Vulkan frame held only the retained UI.
Section 5.5.11 handed it forward as the first thing the world arm would hit,
because WbDrawDispatcher, EnvCellRenderer and TerrainModernRenderer each own
their own instance, batch and indirect buffers and all three bind set 0 in one
frame.

It is closed here, as its own commit and BEFORE the world arm, so that a blank or
corrupt first Vulkan world frame cannot be this defect wearing another face. That
sequencing is the point: sections 5.5.1 to 5.5.3 cost this campaign three days
because an instrument that was "usually right" sat underneath the thing being
measured.

What changed. There is no longer one (set 0, set 1) pair per flight slot; there
is an arena of them. VulkanBindingScopeArena - pure bookkeeping, no Vulkan
handles, nine unit tests - answers two questions per bind: which pair, and do its
descriptors need writing. VulkanFrameBindings keeps the Vulkan half: allocating
pairs from a growable pool list and writing the twelve descriptors when told to.

The scope key is the descriptor state itself - the ten storage buffer identities
and ranges, the plain bindings' offsets, and the two uniform buffer identities
and ranges. Deriving it is a decision, not an economy. The pinned contract has
nowhere to name a scope: BindStorageBuffer takes a buffer, an offset and a size,
and section 3.3 is frozen. Deriving also gives two properties a declared scope
would not: a renderer cannot forget to declare one, and two renderers that
genuinely share every buffer correctly share one pair rather than being told to
differ. A renderer's buffers are stable for its lifetime, so "distinct descriptor
state" is exactly "renderer scope".

Dynamic offsets stay free. A ring allocation moving between draws rides
vkCmdBindDescriptorSets's dynamic-offset array, so it costs neither a new pair
nor a descriptor write - section 4.4's "zero descriptor writes per frame"
property survives a frame having more than one binding state in it. Entries are
not invalidated at BeginFrame either, because the slot's previous submission has
retired and its descriptors still say what this frame is about to say; a steady
frame therefore rewrites nothing at all. An entry matched from the previous frame
is swapped below the live cursor so the rest of the frame cannot take it for a
different state - the ordering property the sixth test pins, where two renderers
swap submission order between frames.

What this does NOT do is draw a world. The captured Vulkan frame is still V6h's
retained UI over the fog clear, so the arena's multi-scope path is exercised by
its tests and not yet by a frame. That is recorded in the plan rather than
implied.

The plan's section 5.5.12 also records two blockers measured while scoping the
world arm and not fixed here: terrain_modern.vert declares TerrainClip without
ACDREAM_UBO_SET, so under the Vulkan dialect it lands at set 0 binding 2 where
the layout declares a storage buffer - the same class of gap 5.5.8 recorded for
UniformSkyParams, invisible until a terrain pipeline is created; and the offline
gate's scene takes the retail PView path rather than the flat safety path,
because ClipRoot falls back to Buildings.OutdoorNode, which puts
RetailPViewPassExecutor on the critical path to the first Vulkan Dereth frame and
makes the "terrain only" intermediate no cheaper than the whole arm.

Gates. Strict GL offline pixel gate against b9ab5890: 1.60e-05, 9 differing
pixels of 563,200, at the low end of the documented 9-31 px band and 62x under
the threshold - expected, since no GL file is touched. GL connected
run-repeat-connected-gate.ps1 -Runs 3: 3/3 RENDERED on the desktop witness and
3/3 on the client capture. One offline Vulkan run with VK_LAYER_KHRONOS_validation
proven inserted by the loader: zero errors, zero warnings, captured frame, no
[shutdown] diagnostic on either stream. App tests 4,086 / 3 skips (baseline 4,077
plus nine); complete Release suite 9,149 / 5. Issue #250's
SurfaceOverrideFingerprint_DictionaryHotPathAllocatesNothing failed once in a
whole-suite run and passed run alone, as that issue documents.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 13:17:25 +02:00
Erik
b9ab5890af docs(render): record V4t at the committed tree, and correct one stale comment
Marks V4t done in the slice table with its two commits and per-commit gate
numbers, adds §5.5.11, and ticks off step 3 of §5.5.9's corrected sequence.

§5.5.11 records four things the world arm needs and one it should not
re-derive: why §5.2's "the device's table is unreachable" argument expired
rather than being worked around (its premise was that V4c would move the world
renderers onto the encoder; §5.5.6 closed that, so the block became
indefinite); that V4t landed narrower than §5.3 sized it, keeping texture
creation and residency with the caches and moving only the table entry, with
`IGpuTexture` creation deferred to the world arm that actually cannot use a GL
handle; why `GroupKey`'s ordering survives a retype of one of its fields; and
the pixel-gate control measurement, because one capture read 5.50e-05 against a
documented 15–23 px band and the honest response to that is a control, not a
paragraph. The decisive number is the 9-px difference between captures at the
two V4t commits — two different commits, fewer differing pixels than the
same-commit control.

`GlBindlessHandleTable`'s own doc comment claimed four renderers own instances.
Three of them no longer do. It now names its one remaining owner, `SkyRenderer`,
says why that one is different (its textures come from the raw GL-name path V4t
did not retype), and names V4f as the slice that deletes both the table and the
class.

`common.glsl`'s binding-9 comment carries the same stale list. It is left alone
deliberately: the file is a shader source with compiled `.spv` artifacts and a
V9 freshness gate, so a comment-only edit there is not free, and what the
comment says about what binding 9 IS remains correct.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:45:55 +02:00
Erik
565c351f93 feat(render): Campaign V slice V4t-2 — the world texture stack crosses to GpuTextureSlot
The rest of V4t. The composite, particle and shared-atlas texture paths now
hand out the device's GpuTextureSlot instead of a raw 64-bit
ARB_bindless_texture handle, and GroupKey, CachedBatch and ObjectRenderBatch
carry that slot. WbDrawDispatcher, EnvCellRenderer and ParticleRenderer retire
their interim GlBindlessHandleTable instances and share the device's one
table, exactly as V4t-1 did for terrain. Nothing about world submission
changes otherwise: these three renderers are still raw GL, still bind binding
9 themselves, and still draw the same geometry in the same order.

**What produces a slot now.** CompositeTextureArrayCache's GL backend interns
each array's handle when it makes it resident and retires the entry when it
makes it non-resident, so the pair is created and destroyed together and the
cache above it never learns a device exists — the fake backend its tests use
mints a stand-in slot. TextureCache.AcquireParticleTexture does the same for
the one-layer particle arrays it owns, including on its rollback path.
ObjectMeshManager registers each shared atlas's wrap/clamp handles at batch
upload; registration is idempotent by handle, so the many batches sharing an
atlas share its entry.

**Slot release is stricter than what it replaces, not looser.** The interim
tables never released anything — the class comment said so — and they grew
without bound. The device's table has a fixed 16,384-slot capacity, so an
unreleased entry is now a leak with an end. Every producer therefore retires
its entry: the composite backend at MakeNonResident, the particle backend at
MakeNonResident, and ObjectMeshManager when a retiring atlas's PHYSICAL
retirement completes — the point at which its handles are already non-resident
and its texture already deleted. That last one needs the handles snapshotted
at eviction, because ManagedGLTextureArray.Dispose zeroes its own copies as
its first act. Teardown deliberately does not release: the device is being torn
down alongside its callers, so there is nothing left to recycle a slot into,
and deferring work through a possibly-disposed retirement queue would turn a
clean shutdown into a throw.

**The default value became load-bearing, and that is the one real hazard here.**
BindlessTextureLocation could say "not resolved" with handle 0, because no
texture has handle 0. A slot index has no spare value — default(GpuTextureSlot)
is real slot 0 — so a positional record would have turned every
budget-rejected or still-uploading composite into a silent read of whichever
texture registered first. That is the magenta-placeholder failure shape one
layer down. The type is now a struct storing the slot one-based, so default IS
Unresolved, with a test pinning both halves: default is unresolved, and a
location naming slot 0 is resolved and distinguishable from it. Elsewhere the
sentinel is already exact — GpuTextureSlot.Unassigned is 0xFFFFFFFF, which is
common.glsl's ACDREAM_TEXTURE_NONE — so the classify path's "no texture yet"
test and the particle billboard's untextured branch are unchanged in meaning.

**GroupKey ordering is preserved because the key never ordered anything.**
Handle→slot is a bijection (the device interns one slot per resident handle),
so the same (entity, batch) pairs bucket together as before. The key reaches
equality, hashing and the scene-digest fingerprints — never a comparator:
opaque and translucent groups sort by cull mode then camera distance, the
delayed-alpha path by viewer distance then submission ordinal, and group
enumeration follows the persistent dictionary's insertion order, which a
changed hash does not disturb. The digests hash the slot index where they
hashed the handle; both sides of the render-shadow comparison compute them the
same way, so the value changing is invisible to it. Read
CompareOpaqueSubmissionOrder, CompareTransparentSubmissionOrder and
AlphaFingerprintComparer before doubting this — sort-order drift is a
pixel-visible regression class this project has hit, and it is why the check
was made before the retype rather than after.

**One visibility change, forced rather than chosen.** BindlessTextureLocation
was public and now holds an internal contract type, so it is internal;
ObjectRenderBatch.TextureSlot is internal on an otherwise public class for the
same reason. Nothing outside this assembly and its InternalsVisibleTo test
assemblies named either.

**SkyRenderer keeps its interim table**, and the report should say why: the
sky's textures are minted by SkyRenderer itself from TextureCache's raw GL
texture names, which this slice does not retype, so it would be the one
consumer registering handles it produced — a different shape from the world
stack. The offline gate also masks the sky band, so the one automated
instrument here cannot see a sky regression. V4f owns that renderer.

**Gates.** GL offline pixel gate vs cb2a70b8, measured twice: 31 and 22
differing pixels of 563,200 (5.50e-05, 3.91e-05). The first is above the
plan's documented 15-23 px band, so a control was measured rather than
assumed: two same-commit captures at this tree differ by 19 px, and — the
decisive number — a capture at V4t-1 and a capture at this commit differ by
9 px, fewer than the same-commit control. Maximum channel delta is 41-52 in
every pair including the controls, i.e. the differing pixels are drawn from
one flickering population, not from moved geometry. tools/run-repeat-connected-gate.ps1
-Runs 3: 3/3 RENDERED on both the desktop witness and the client capture. One
Vulkan composition-host run with VK_LAYER_KHRONOS_validation proven inserted
by the loader: zero errors, zero warnings, converged ownership ledger. App
tests 4,077 / 3 skips and the complete Release suite 9,140 / 5 — both the
4,075 and 9,138 baselines plus the two tests added here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:43:50 +02:00
Erik
b8bcaa3ef2 feat(render): Campaign V slice V4t-1 — terrain crosses to GpuTextureSlot
V4t moves the world texture stack off the raw 64-bit ARB_bindless_texture
handle and onto GpuTextureSlot. This first commit does terrain only, because
terrain is the one branch of that stack whose producer and consumer are a
single pair — TerrainAtlas and TerrainModernRenderer — so it can carry the
new device seam on its own pixel gate before the mesh/composite/particle
retype lands on top of it.

Why the device's table can now be reached, when §5.2 said it could not.
That paragraph's reason was the flush: GlGpuDevice drains its dirty table
runs inside FlushBeforeDraw, which only an encoder-recorded draw reaches,
so a raw-GL renderer would sample a stale table. §5.5.6 then closed the GL
re-land of V4c/V4d, which means the world renderers stay raw GL through to
V10 — so "wait for the encoder" stopped being a plan and became an
indefinite block on V4t, which the Vulkan world arm cannot be written
without. The resolution is the smallest one that keeps the seam honest: the
drain is factored out as GlGpuDevice.FlushTextureTable, and a raw-GL
renderer calls it and binds TextureTableGlName at binding 9 itself,
immediately before its own draw — the same shape its retired private
GlBindlessHandleTable had, against a table that is now the device's. Nothing
else of the backend is exposed, and both members are deleted with the raw-GL
world path.

Residency ownership deliberately does NOT move. RegisterWorldTextureHandle
interns an already-resident handle and owns only the table entry; the atlas
still creates, makes resident and destroys its own textures. That is what
separates it from RegisterTexture, which owns the residency it creates, and
it is why this slice can retype the data model without also porting GL
texture creation onto IGpuTexture.

TerrainAtlas.GetBindlessHandles becomes GetTextureSlots(GlGpuDevice).
Registration is idempotent by handle, so the per-draw call is two dictionary
lookups — the cadence GetOrAdd already had. It is conditional on the handle
having changed because SetAnisotropic makes both textures non-resident and
re-acquires them: without that check a quality-preset change would strand a
slot holding a non-resident handle, so the superseded entry is retired in
the same step through the device's retirement queue.

Ordering is unaffected. Terrain's two slots travel as loose uniforms
(uTextureIndexA/B) and enter no sort and no bucket key, so a different slot
NUMBER changes nothing about what is drawn or in what order — only which
table index resolves to the same handle.

Gates. GL offline pixel gate vs cb2a70b8: 3.02e-05 (17 of 563,200 pixels),
exactly a same-commit control value and inside the documented 15-23 px /
<=4.1e-05 band. tools/run-repeat-connected-gate.ps1 -Runs 3: 3/3 RENDERED on
both the desktop witness and the client capture. One Vulkan composition-host
run with VK_LAYER_KHRONOS_validation proven inserted by the loader: zero
errors, zero warnings, empty validation log, converged ownership ledger. App
tests 4,075 / 3 skips (#250's zero-allocation test reran green singly).

One connected run of an earlier 3-run attempt died in the render loop with
"OpenGL returned unexpected fence wait status NoError (0x0)" from
GpuFrameFlightController.RetireFence. It did not reproduce in the following
three runs at this tree nor in three interleaved runs at cb2a70b8, and this
diff creates, deletes and waits on no fence. Filed as #251 rather than
attributed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:22:45 +02:00
Erik
b16f820643 feat(render): Campaign V slice V6h — the Vulkan composition host
ACDREAM_RENDER_BACKEND=vulkan now runs the real GameWindow composition rather
than a second main(). All nine phases execute: DAT load, streaming, camera,
entity table, session, and the real retained UiHost drawing through the RHI.
No world renderers — they are raw GL until V4t and the world arm behind it.

The offline log is the client's own (acdream.pak opened, 6266 spells, Region
0x13000000, "loading world view centered on 0xA9B4FFFF", fourteen retail
LayoutDesc lines, streaming radii), and the captured frame is the retail
retained UI: vitals, combat/spell bar with DAT scarab icons, the nine-slot
toolbar, chat with tabs and Send, radar/compass with dat-font glyphs. Sampled
against the GL capture the widgets agree — chat interior RGBA (25,24,27,158)
vs (22,21,23,158), vitals bar (117,1,0) and toolbar slot (0,11,17) identical.

Three seams, as §5.5.9 specified:

1. Platform acquisition — already generic — publishes GameWindowGraphics
   instead of a bare GL. Phases that still speak raw GL read Graphics.Gl and
   take their Vulkan arm when it is null; each branch names the slice that
   removes it.
2. VulkanHostInputCameraCompositionFactory is a new file and the whole of the
   Phase-1 fork: four graphics members differ, input/camera/pointer delegate.
   The default factory is chosen inside the phase from the platform result.
   HostInputCameraResult gained backend-neutral Retirement and FrameSlots.
3. The frame root forks on one condition. The GL world-scene assembly is
   unchanged, wrapped in `if (gl is not null)`; the Vulkan arm's graph is one
   backbuffer clear pass computing the same RenderFrameFoundation from the same
   clock and weather owners, then private presentation over it.

§5.5.9's three TextureCache couplings are unpicked: the constructor takes GL?
and rejects bindless without one, world entry points route through a Gl
property that throws naming V4t, and the (GlGpuTexture) VRAM-accounting cast
became a backend test. That cast's stated reason — DrawSprite's texture-unit
binding — was already stale, deleted at V6d.

VulkanBringUpHost is reduced to the capability-probe harness it is named for:
the instance/surface/device/swapchain sequence moved into VulkanGraphicsContext,
which the composition host and the harness now share. It is reached only with
ACDREAM_VULKAN_PROBE=1.

One latent Vulkan defect surfaced and is fixed here. The first composition-host
frame died with ErrorDeviceLost; validation named VUID-vkCmdDraw-None-08600 —
descriptor set 2 never bound. VulkanGpuPassEncoder bound sets 0/1/2 only as a
side effect of BindStorageBuffer/BindUniformBuffer, so a pass sampling the
texture table while binding no buffer — every retained-UI and debug-line pass —
drew with the table unbound. It survived V6c-V6g because the bring-up host
always drew VulkanRhiScene first and the UI pass inherited its binds; the
composition host has no 3-D scene. The fix is one line in the encoder's
constructor beside the viewport and scissor defaults, which exist for exactly
the same reason: a pass opens with complete binding state rather than depending
on what preceded it.

Gates: strict GL offline pixel gate against 46d893f7 measures 1.24e-05 (7 of
563,200 pixels), inside the documented 15-23 px / 4.1e-05 band, so GL behaviour
did not move. App tests 4,075/3 skips; complete Release suite 9,138/5 skips.
One full Vulkan run with VK_LAYER_KHRONOS_validation: zero errors, zero
warnings. Both Vulkan runs converged the ownership ledger — no [shutdown]
diagnostic on either stream. The reduced probe harness presented 34,811
validation-clean frames.

No divergence-register row: GL is the shipping backend and the pixel gate proves
it unmoved; the Vulkan arm is not a retail deviation but a backend under
construction.

Next is V4t, the texture stack, which the world arm cannot be written without.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 11:47:37 +02:00
Erik
24834a6478 fix(render): Campaign V slice V6g — the Vulkan frame stops lying to the driver
V6f ran the bring-up host once under VK_LAYER_KHRONOS_validation and found
seven VUIDs, every one of them on the path any world frame takes (plan
§5.5.7). This closes all of them, plus a fourth defect in the same log that
§5.5.7 did not call out. The host now runs validation-clean: zero errors and
zero warnings over 39,855 frames.

Nothing outside Gpu/Vk/ is touched, so the GL backend executes not one changed
statement. The offline pixel gate says so too — 4.08e-05 differing fraction
against f8dbe2ee, which is exactly the value the campaign recorded as its own
same-commit control (§5.1's 15–23 pixel band).

The dynamic-descriptor limit was a decision, not a patch. V6b declared all ten
of set 0's bindings STORAGE_BUFFER_DYNAMIC on the reasoning that the contract
lets a renderer bind any range per draw. That is true and still cost nothing to
honour for four of them: a dynamic descriptor buys exactly one thing, the
ability to address the SAME buffer at a DIFFERENT offset without a descriptor
write, which is the shape of a ring allocation and of nothing else. So the
ring-fed bindings — instances, batches, clip slots, instance light sets — stay
dynamic, and the ones pointing at a long-lived buffer written whole and bound
once per pass carry their offset in the descriptor instead. Binding 9 is the
clearest of those: it is the GL-only uvec2 handle table, which the Vulkan
backend never binds at all.

That lands on four dynamic storage descriptors. The RX 9070 XT allows eight, so
eight would have worked here — but four is Vulkan's GUARANTEED minimum, which
means no conformant device can fail this layout, and V9's lavapipe row and the
deferred physical Linux row both depend on that. The count is asserted against
maxDescriptorSetStorageBuffersDynamic in the capability record, so a device that
cannot serve it is rejected at startup in the report under the same exit-code-4
contract as every other requirement, rather than failing silently at
vkCreatePipelineLayout the way this one did.

Depth-off pipelines were malformed in any pass that has depth. Dynamic rendering
bakes the depth/stencil attachment format into the pipeline and requires it to
equal the pass's; V6c set it only when the pipeline itself tested or wrote
depth. Debug lines, the retained UI and the sky are all depth-off and all
composite over the main pass, so this was not an edge case. The same
GpuPipelineDescription is legitimately used both ways — ui-text opens its own
depth-less pass — so the description cannot answer the question and the backend
builds both variants, binding whichever matches what vkCmdBeginRendering was
actually handed rather than what the pass asked for. Both are built at startup
against the persisted cache, so no frame compiles one. A slice entitled to
change the contract should add a depth-format field the way V6d added
ColorFormat; this is the honest expression of the gap until then.

vk-backbuffer-depth and vk-backbuffer-msaa-color were created UNDEFINED and
never moved. Both now barrier on every backbuffer pass — from UNDEFINED on the
first use after Configure, from attachment-optimal with a write-after-write
dependency thereafter. The dependency matters on its own account, not just the
layout: two passes in one frame write both images and so does the next frame,
and Vulkan orders nothing between render-pass instances.

The fourth defect is the one worth reading twice. CaptureBackbuffer transitioned
the LAST PRESENTED swapchain image to TRANSFER_SRC and copied out of it. After
vkQueuePresentKHR that image belongs to the presentation engine and its contents
are not ours to read — and the pixels were usually right, which is precisely the
problem. This campaign spent three sections of its own plan (§5.5.1–§5.5.3)
discovering how much a capture instrument that is "usually right" can cost, and
shipping that shape on the new backend would have made every Vulkan PNG, and the
V7 differential built on them, formally undefined. The frame now copies its own
output into a host-readable buffer while it still owns the image, and the
capture reads that. Retention is opt-in, armed when an artifact directory
exists: one full-resolution copy per frame is worth nothing to a player and is
the entire instrument to a gate. The old one-shot command pool, device-idle wait
and per-capture readback buffer go with it.

Two gaps found and recorded in §5.5.8 rather than fixed, both outside this
slice's brief. UniformSkyParams (set 1, binding 4) is not in the uniform set
layout, so whoever first draws sky on Vulkan must add it. And a binding pointed
at two different buffers within one frame silently corrupts the earlier draws,
on dynamic and plain descriptors alike, because descriptor contents are read at
execution time — no consumer does that today, but WbDrawDispatcher and
EnvCellRenderer each own their own instance and batch buffers and both bind
bindings 0, 1, 3, 4 and 5 in one frame, so the Vulkan world arm has to know
before it is written.

Gates: Release build; App tests 4,075 passed / 3 skipped (baseline 4,073 + the
two new capability cases); GL offline pixel gate PASS at 4.08e-05; one
validation-layer Vulkan run, clean, with the captured PNG inspected and correct
in orientation, colour and glyph coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 10:47:52 +02:00
Erik
30e94da607 feat(render): Campaign V slice V6f-3 - terrain's atlas reads cross the dialect
The last thing keeping terrain_modern out of SPIR-V was how it named its two
atlases:

    #define uTerrain sampler2DArray(ACDREAM_TEXTURE_HANDLE(uTextureIndexA))
    #define uAlpha   sampler2DArray(ACDREAM_TEXTURE_HANDLE(uTextureIndexB))

`sampler2DArray(handle)` is a GL_ARB_bindless_texture construction with no
Vulkan equivalent. Vulkan's table is an opaque descriptor array in set 2; there
is no handle, so there is nothing to construct a sampler from. The ten sample
sites now go through ACDREAM_SAMPLE_ARRAY, the dialect-neutral read V6e
introduced for mesh_modern, wrapped in two shader-local macros that keep the
call sites reading as "sample the terrain atlas" rather than "index the table".

They are SAMPLING macros, not sampler-returning ones, and that is not
cosmetic. Under Vulkan the expansion carries `nonuniformEXT` on the indexing
expression, and binding the result to a local sampler2DArray first is exactly
where an implementation may drop it. The old `#define uTerrain
sampler2DArray(...)` was textually that shape, so preserving it would have
reintroduced the hazard at every use site.

On GL nothing about the sampled result changes: the same slot resolves through
the same binding=9 table to the same handle to the same texel, and the macro
expands to the identical expression the shader wrote by hand.

With this, terrain_modern compiles for Vulkan and the manifest reads 8/9. The
remaining pair is `mesh`, which the campaign doc records as having no consumer
at all - so every production shader acdream actually draws with is now
Vulkan-expressible. That closes the obligation §5 recorded against V6e ("the one
production pair still not Vulkan-expressible after V6e is terrain_modern") and
it closes the shader half of V4d's parked content.

What this does NOT do is give the Vulkan backend a world to draw. That is
reported separately with the rest of slice V6f; the shaders were the part that
could be finished, gated and landed on GL today.

Gates. Release build clean. App tests 4,073 passed / 3 skipped over four
consecutive runs. A fifth run failed only
CurrentRenderSceneOracleTests.SurfaceOverrideFingerprint_DictionaryHotPathAllocatesNothing,
an allocation-counting test over a CPU dictionary path that touches nothing in
this diff; it passes in isolation and passed in every other full run. That is
the known #250 flake class on an otherwise unchanged tree. Offline pixel gate
against fac09407: 12 differing pixels of 563,200 compared (fraction 2.13e-05) -
below even the floor of the documented 15-23 pixel band. Cumulatively, across
all three V6f commits against 7faaaa34: 22 pixels (3.91e-05, maximumChannelDelta
52), which is the same number the first commit measured on its own. Three
changes to the shader terrain draws with, and the drift has not accumulated.

No divergence-register row: the sampled result is unchanged on GL and no
retail-facing behaviour moves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 10:14:37 +02:00
Erik
fac0940711 feat(render): Campaign V slice V6f-2 - terrain's tiling table becomes a buffer
terrain_modern.frag declared `uniform float uTexTiling[36]` - the per-layer
tiling factors retail passes to TexMerge::CopyAndTile / TexMerge::Merge, one per
terrain atlas layer. Vulkan GLSL has no default uniform block, so a loose array
is unspellable there, and 144 bytes of payload cannot ride the pinned 96-byte
push-constant block. GpuBindingModel reserved UniformTerrainTiling (binding 3)
for exactly this at slice V4d. The array now lives in that block.

The ELEMENT TYPE is deliberately unchanged. std140 pads every array element out
to 16 bytes, so the block is 576 bytes rather than 144, and packing four values
per vec4 would be tighter - but it would also rewrite the accessor and every use
site, and this commit's whole value is that its pixel gate measures the move to
a uniform buffer and nothing else. `uTexTiling[int(layer)]` reads exactly as it
did.

That padding is the hazard the change introduces, so it is pinned twice. The CPU
writer walks TerrainTextureTilingTable.UniformElementStrideBytes and zero-fills
the dead words rather than blitting 36 packed floats, and a new test asserts the
stride is 16, the block is 576, and the two are consistent with LayerCapacity. A
tightly-packed writer would not crash or even look obviously wrong: the shader
would read layer 0's factor for layers 0-3, layer 4's for 4-7, and in a scene
where most layers tile at 1 the error stays invisible until a layer that does
not appears. Nothing else in the suite could see that.

The buffer is allocated once in the constructor, through the same
TrackedGlResource + ResourceCleanupGroup rollback path every other terrain
buffer uses, written on the first bound draw - preserving the upload-once
property the linked program's uniform had for free - and released through the
dispose ledger. It is REBOUND every draw rather than once: GL's uniform-buffer
binding points are global and shared with SceneLighting at 1 and the sky's
params at 4, so a renderer running between two terrain draws can take binding 3
out from under us. Self-contained render state, per the standing rule.

Gates. Release build clean. App tests 4,073 passed / 3 skipped - the baseline
4,072 plus the new layout test. Offline pixel gate against 5e13b45f: 21 differing
pixels of 563,200 compared (fraction 3.73e-05), inside the documented 15-23
pixel band and ~27x under the 0.001 threshold. This gate is a real test of the
layout rather than a formality: terrain blending, road overlays and the water
edge are most of the captured frame, and every one of those samples goes through
terrainTiling(), so a stride mismatch would have shown as a wholesale retexture
rather than as noise. The gate run's client log has zero exceptions and an empty
stderr.

Manifest regenerated in the same commit. terrain_modern's remaining Vulkan error
moved from `'uTexTiling' : undeclared identifier` to the frag's direct
`sampler2DArray(...)` construction, which is the same dialect migration V6e ran
for mesh_modern and which lands next. The pair count is unchanged at 7/9.

No divergence-register row: the tiling values, their source and their use are
unchanged, and no retail-facing behaviour moves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 10:11:00 +02:00
Erik
5e13b45fae feat(render): Campaign V slice V6f-1 - converge terrain's view and projection
terrain_modern.vert combined two loose mat4 uniforms per vertex:

    gl_Position = uProjection * uView * vec4(terrainPos, 1.0);

Vulkan GLSL cannot express that. There is no default uniform block, so a loose
`uniform mat4` is unspellable however it is written, and the two matrices are
128 bytes against a pinned 96-byte push-constant block (and against Vulkan's
guaranteed 128-byte ceiling). GpuPushConstants already carries exactly one
uViewProjection, which is the shape every other ported shader reads. So the
product moves to the CPU and the shader reads the single matrix.

The transform is identical. System.Numerics uses row-vector convention and
Shader.SetMatrix4 uploads untransposed, so GLSL reads each uploaded matrix as
its transpose. The old expression evaluated Proj^T * View^T; the new one
evaluates (View*Proj)^T, and those are the same matrix. The renderer already had
that product in hand - `viewProjection` at line 422, computed for the visibility
pass - so nothing new is multiplied. It is multiplied once per frame on the CPU
instead of once per vertex on the GPU.

That last sentence is the whole reason this is its own commit. Moving a float
product from GPU to CPU is a real numeric change: different hardware, possibly
different fused-multiply-add behaviour, certainly a different rounding order.
The plan's V4d row requires its pixel effect be attributable alone rather than
folded into a plumbing change, and terrain fills most of the offline gate's
scene, so this is the strongest measurement that gate can make.

Gates. Release build clean. App tests 4,072 passed / 3 skipped, matching the
baseline exactly. Offline pixel gate against 7faaaa34: 22 differing pixels of
563,200 compared (fraction 3.91e-05, maximumChannelDelta 52). A same-commit
control captured immediately afterwards: 15 pixels (2.66e-05, maximumChannelDelta
46). Both sit inside the documented 15-23 pixel noise band and ~26x under the
0.001 threshold, and the change and its own control are drawn from the same
distribution - which is what "no systematic shift" looks like at this
instrument's resolution. The gate run's client log has zero exceptions and an
empty stderr.

The shader manifest is regenerated in the same commit, as its freshness test
requires. terrain_modern.vert now compiles to SPIR-V for the first time; the
pair stays vulkanReady:false and emits no .spv because terrain_modern.frag still
declares `uniform float uTexTiling[36]`, which is the other half of this
shader's port and lands next as the UniformTerrainTiling buffer that
GpuBindingModel already reserves binding 3 for. Per-pair the count is unchanged
at 7/9; per-stage it is 15/18.

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 10:05:16 +02:00
Erik
7faaaa347b feat(render): V6e — the sky's uniforms become a buffer and its texture a table slot
Campaign V slice V6e, last of three. Sky was the hardest of the four pairs
because it was the only one that still worked the way a 2004 shader works: a
dozen loose uniforms pushed one glUniform call at a time, and a texture bound to
unit 0 with a sampler object chosen per submesh. Vulkan GLSL has neither a
default uniform block nor a way to declare a bare sampler, so both had to move —
and the second one had a sting in it.

The uniforms go into a `SkyParams` std140 block at uniform binding 4, the new
pre-authorized constant in GpuBindingModel (1, 2 and 3 are SceneLighting, the
terrain clip block and terrain tiling; the contract test now proves the three
constants and that literal 2 do not collide). Three matrices are 192 bytes on
their own, so the 96-byte push-constant block was never in the running. The
block's member order IS its layout: std140 aligns a vec3 to 16 bytes while using
12, so each of the three lighting vectors is followed by the float that rides in
its pad word, which is why colours and per-surface scalars interleave rather
than grouping by meaning. SkyParamsLayoutTests asserts all twelve offsets and
the 256-byte size, because getting one member wrong would read the sun direction
as a colour with no compile error, no link error and no GL error to say so.

The texture is the interesting half. sky.frag now reads through the shared table
(ACDREAM_SAMPLE_2D), and a bindless handle BAKES its sampler — so the
per-submesh Repeat-versus-ClampToEdge choice, which used to be a glBindSampler
on unit 0, becomes which slot the submesh asks for. SkyRenderer interns one
handle per (texture, wrap) pair, exactly as ManagedGLTextureArray has done since
the world path went bindless, and exactly the shape Vulkan's table has, where an
entry is a combined image sampler. Same two SamplerCache objects, same wrap
behaviour, consulted once at interning instead of once per draw. A pleasant
consequence: the sky no longer touches texture unit 0, so the load-bearing
`BindSampler(0, 0)` restore at the end of the pass — there because the binding
was global state that would otherwise force ClampToEdge on the next renderer —
has nothing left to undo and is gone.

Gates. Release build clean; App tests 4,072 passed / 3 skipped (4,057 baseline,
plus the sentinel guard from the previous commit and fourteen sky-layout
assertions). Offline pixel gate against 95f8c25f: 18 px of 563,200 compared
(3.20e-05), inside the documented 15–23 px band.

That gate masks the sky for determinism, so it proves nothing about this commit
and the sky renderer has no automated pixel coverage at all. What was done
instead: a base-versus-head offline capture at ALL SEVEN day groups, built by
stashing the change and rebuilding so the two runs differ only in this commit.
Every pair matches in gradient, cloud sheet, horizon band and fog — including
day group 2's salmon cloud band and day group 6's green one, which between them
exercise texture sampling, per-vertex tint, blend mode and fog. Then 3/3
RENDERED on the desktop-witness repeat-connected gate.

That bounds the risk; it does not close it. The offline camera is fixed and
looks down, so a thin band of dome is all it ever sees: the sun and moon
(additive, high) and the rain cylinder (the one sky mesh that surrounds the
camera, and the one whose REPEAT wrap is most visible) remain unproven. Recorded
as user-gate debt in §5.1 alongside V2c's and V4e's particles — check it by
standing outside at dawn or dusk, and by standing in rain.

Manifest: 8/9 pairs compile. `terrain_modern` is the last production pair, and
it is blocked on V4d's content rather than on dialect — details in §5.5's slice
table. `mesh` has no consumer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 09:54:13 +02:00
Erik
602bc9dddb feat(render): V6e — both particle shaders cross the dialect
Campaign V slice V6e, second of three. Billboard particles and mesh particles
are the last two pairs blocked on the texture-table shape; sky follows.

particle takes the same treatment mesh_modern took: the `flat uvec2` handle
varying becomes a `flat uint` slot and the fragment stage samples through
ACDREAM_SAMPLE_ARRAY. What is different here is the untextured particle. The
shader used to ask "is the handle I was given zero", which GL can answer because
its emulated table stores handles; Vulkan cannot, because set 2 is an opaque
descriptor array and reading an element nobody wrote is undefined rather than
zero. So the question moves to the index: the CPU writes ACDREAM_TEXTURE_NONE
for a particle with no texture instead of interning the null handle as a table
slot, and both dialects test the same value. GL renders identically — the same
particles take the same branch to the same procedural blob — and the handle
table simply stops carrying an entry that never named a texture. A test pins the
sentinel across all three declarations of it, because a silent disagreement here
would sample slot 0xFFFFFFFF instead of drawing the blob.

particle_mesh needed no restructuring, only names. Vulkan GLSL has no default
uniform block, so `uniform uint uTextureIndex;` is not unsupported but
unspellable, and the two values are per-pass — one texture and one layer for a
whole sub-batch — which is exactly what the shared push-constant block is for.
uTextureIndex becomes uTextureIndexA; uTextureLayer becomes uParamA, which was
the spare scalar and is a natural fit because the shader converted the layer to
float anyway. The widening moved from the shader to the CPU; layers are small
integers, so the sampled value is bit-identical.

Gates: Release build clean; App tests 4,058 passed / 3 skipped (baseline 4,057
plus the sentinel drift guard). Offline pixel gate against 95f8c25f: two
captures, 29 px and 21 px of 563,200 compared (3.73e-05 and 5.15e-05), with a
same-commit control between them of 13 px and this commit measuring 14 px
against its own parent. The scene draws no particles, so this gate is a tripwire
that the world path is undisturbed, not evidence about particles.

Particles remain user-gate debt — the same debt V2c and V4e already carry, to be
paid by casting a spell in a connected session.

Manifest: 6/9 pairs compile. Remaining: mesh (legacy, no consumer), sky (next
commit), terrain_modern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 09:26:13 +02:00
Erik
935f4dc3d9 feat(render): V6e — move the world mesh's texture lookup to where Vulkan can express it
Campaign V slice V6e, first of three. mesh_modern is the shader every world
static, every piece of scenery and every EnvCell surface draws through, and it
was one of the four production pairs the SPIR-V toolchain still refused.

The blocker was a varying. Since V2 the vertex stage looked a batch's table slot
up in the binding=9 handle table and forwarded the resulting 64-bit
GL_ARB_bindless_texture handle to the fragment stage as a `flat uvec2`. That
works on GL because a bindless handle is just a number a shader may carry
anywhere. It cannot work on Vulkan at all: the equivalent object is a descriptor
in set 2, and a descriptor is not a value a stage can hand to another stage. So
what travels between the stages is now the SLOT — a `flat uint` — and the
fragment stage does the lookup at the point of sampling.

That relocation needs one shared idea, because the two backends disagree about
what the lookup IS. `ACDREAM_SAMPLE_ARRAY(slot, uvw)` asks the dialect-neutral
question — "sample table slot N" — and expands to
`texture(sampler2DArray(gTextureTable[slot]), uvw)` under GL and to
`texture(uTextures[nonuniformEXT(slot)], uvw)` under Vulkan. It is deliberately
a SAMPLING macro rather than a sampler-returning one: `nonuniformEXT` belongs on
the indexing expression itself, and binding the result to a local
`sampler2DArray` first is exactly where an implementation is free to drop it.
That is the same shape V6d already used for the retained UI's 2-D reads, and it
now covers the array reads the world path needs.

`ACDREAM_TEXTURE_NONE` lands alongside it, unused here and used by the next
commit. GL can ask "does this slot hold a texture" of the payload, because an
unregistered slot holds the null handle; Vulkan cannot, because set 2 is opaque
and reading an unwritten element of a partially-bound array is undefined rather
than zero. The sentinel moves that answer into the index, where both dialects
test it identically.

On GL nothing about the sampled result changes — the same slot resolves to the
same handle to the same texel. The SSBO read simply happens one stage later,
and `flat` keeps it one scalar load per primitive rather than per fragment.

Also: RenderBootstrap has been loading mesh_modern without common.glsl since V2,
which cannot have linked — `ACDREAM_UBO_SET` sits inside a layout qualifier
there. The UI Studio path is the only caller. One argument, same pair, same way
WorldRenderComposition has always loaded it.

Gates: Release build clean; App tests 4,057 passed / 3 skipped (baseline);
offline pixel gate against 95f8c25f differing fraction 3.37e-05 (~19 px of
563,200), inside the documented 15–23 px same-commit noise band and ~30x under
the 0.001 threshold. mesh_modern is the shader that gate covers most heavily,
so this is the strongest automated evidence any V6e commit gets.

Manifest: 4/9 pairs compile (debug_line, mesh_modern, ui_text, vk_probe).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 09:21:22 +02:00
Erik
95f8c25f31 feat(render): draw the retained UI on Vulkan, and fix the pass it exposed
Campaign V slice V6d, commit 3 of 3 — the evidence commit, which turned out to also be a bug-fix commit.

VulkanBringUpHost now builds a real UiHost and DebugLineRenderer on the Vulkan device and draws them after the V6c verification scene, in their own single-sampled load/store passes against the backbuffer — the same shape the GL client's HUD phase has. Nothing in the retained stack is backend-aware: UiRoot walks a real widget tree, each widget draws through UiRenderContext, and UiHost.Draw brackets it with TextRenderer.Begin/Flush. What it cannot be is the game's own UI, because the retail tree is built from LayoutDesc and DAT chrome by TextureCache, which stays a GL type until V4t; the sprites here are generated instead. The widget rectangles are authored at known pixel offsets from the top-left and nothing is mirror-symmetric, so a wrong Y flip would put the title bar at the bottom.

The frame this produced was wrong, and usefully so. Whole runs of the debug-line figure were missing. Vulkan's rasterization-order guarantees are scoped to one render-pass instance; between two instances writing the same attachment there is no implicit ordering, and that includes a multisample RESOLVE, which is part of the render pass and therefore equally unordered against what follows. TransitionBackbufferForRendering emitted its acquire barrier once per frame and returned for every pass after the first, so the second and third passes raced the first one's resolve. V6c's frame had exactly one backbuffer pass and could not see this; V6d's has three. A later backbuffer pass now gets a colour-attachment dependency instead of nothing, and keeps ColorAttachmentOptimal as its old layout rather than Undefined, which would have licensed discarding everything drawn so far. Every line renders continuously afterwards.

Inspection of artifacts/vk-ui/vulkan-bringup.png against the authored layout, by pixel probe:

The header panel is authored at (24,18), 420x96. Its tiled chrome fills exactly x 24..443 and y 18..113 — one pixel outside on any edge is the clear colour. The tile's lit edge appears at the top and left of every cell, so texture row 0 lands at the top and the V axis is not flipped. Both labels read left to right, right side up, through the font-coverage branch. The nested panel's border samples exactly (153,191,255) against an authored (0.6,0.75,1.0), unblended — the untextured branch is bit-exact. The badge sprite is authored at (460,58), 64x64, and its gradient starts at x=460 with the clear colour at 455 — the RGBA-modulate branch, sampling a table slot. The two debug-line segments land on their computed screen coordinates. All three fragment branches, the pixel-to-NDC mapping, the top-left origin, straight-alpha blending and table sampling are therefore all confirmed on Vulkan, which is everything the offline GL gate confirms about the same code on GL.

The plan's V6 milestone is amended rather than claimed: "full game frame on Vulkan" is not reachable while V4c/V4d are parked and the world renderers and TextureCache are still raw GL, so V6 delivers the backend plus the two renderers that can use it today. The accumulated user-gate table gains a V6d row for the paperdoll/appraisal viewport sprite — the one retained-UI texture the offline scene never draws, on a slice that changed how every UI texture is sampled.

App tests 4,057 passed / 3 skipped, unchanged. Offline pixel gate against f6f58a12: differing fraction 3.20e-05, 18 pixels of 563,200, inside the documented 15-23 pixel noise band — as expected, since this commit touches only Vulkan files and the campaign doc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 09:03:58 +02:00
Erik
f6f58a12db feat(render): put the retained UI and debug lines on both backends
Campaign V slice V6d, commit 2 of 3. TextRenderer and DebugLineRenderer were the only two renderers speaking the RHI, and both refused any device that was not a GlGpuDevice. They now refuse nothing: this is the first production rendering acdream can do on Vulkan.

Three things had to go.

The loose uniforms. debug_line declared uView and uProjection separately and DebugLineRenderer set them straight against the compiled GL program, because the pinned push-constant block carries one combined matrix and IGpuPassEncoder has no verb for arbitrary named uniforms. That was never portable — Vulkan has no default uniform block at all — so the shader converged on uViewProjection and Flush multiplies on the CPU. System.Numerics is row-vector convention while GLSL reads the floats column-major, which transposes, so the CPU equivalent of the old per-vertex uProjection * uView is view * projection. The product now rounds once per frame rather than once per vertex; these lines only draw when collision wireframes are switched on, so the offline gate sees nothing of it. ui_text's uScreenSize became the block's two spare scalars, uParamA and uParamB, with the same two divisions and the same NDC mapping around them.

The sampling mode. uUseTexture selected between font coverage, RGBA modulate and flat colour, and no field of the 96-byte block means that. It did not need one: which of the two texture-table slots is assigned IS the mode. uTextureIndexB assigned means a single-channel coverage source, uTextureIndexA assigned means an RGBA colour source, neither assigned means the vertex colour alone. GpuTextureSlot.Unassigned is already a loud sentinel for exactly this kind of question, and both branches guard so it never reaches a sampler. That also retired the 1x1 white fill texture: DrawFill routed solid quads through the sprite bucket relying on white times colour, and the untextured branch produces the same value with no texture at all. Multiplying by 1.0 changes no bits, and the gate agrees.

The texture binding. The classic glActiveTexture/glBindTexture path survived V4a because DrawSprite takes an arbitrary texture from sixty-odd widget call sites. But TextureCache had already registered every one of those into the device's table — the classic path was consuming the raw GL name that registration also produced. The UI's currency is now UiTextureTableHandle, a one-based table index whose zero is the same "no texture" every widget already guards on; a raw slot index would have turned all of those guards into silent false negatives, since slot 0 is perfectly valid. One-based rather than the slot itself because GpuTextureSlot is internal to the pinned contract while UiRenderContext.DrawSprite, TextureCache.GetOrUploadRenderSurface and a dozen widget properties are public, and neither publishing a contract type nor converting the retained UI to internal belongs in this slice.

Two consequences worth stating. The two backends disagree about what a 2-D table entry is — GL reconstructs a sampler2D from the bindless handle, Vulkan reads layer 0 of its sampler2DArray descriptor array — and ACDREAM_SAMPLE_2D is the one place that lives. Keeping GL on sampler2D is what leaves the UI's textures exactly as they are, including the paperdoll/appraisal FBO colour texture, which is an externally-owned GL_TEXTURE_2D from the §7.1 transitional seam and cannot become an array before V4g. On the Vulkan side, sampled views are now always layered, which also removes a latent invalid usage V6c shipped: it registered a Type2D offscreen view into a descriptor array whose element type is sampler2DArray.

And one real fix. Sampling through the table means a bound sampler object overrides the texture's own parameters. Nearest-requested UI art used to get its point filtering from a glTexParameter applied before the bindless handle went resident, so registering it with the stock WorldRepeat sampler would have made every retail icon and dat-font glyph silently bilinear. Those now register with a nearest-and-repeat sampler.

Supporting moves: GlGpuDevice.CreatePipeline splices common.glsl the same way Shader does, since an RHI shader that reads the table needs the table declared; GlGpuPassEncoder binds the device's table with the pipeline, which is the GL analogue of Vulkan binding descriptor set 2 per draw, and has to be per-bind because every raw-GL world renderer puts its own privately-numbered table at that binding; and the encoder derives GL_MULTISAMPLE from the pass's SampleCount, which is where the retained UI's hand-rolled glDisable belonged all along. TextRenderGlStateScope is deleted — the encoder's ambient capture restored a strict superset of it — and its failure-safety test follows the guarantee to GlAmbientCapabilityState, which gains a fakeable seam and, with it, the multisample-dimension coverage #249 recorded as missing.

App tests 4,057 passed / 3 skipped, unchanged from commit 1. Offline pixel gate against 871c406b: differing fraction 2.31e-05, 13 pixels of 563,200 compared — below the documented 15-23 pixel same-commit noise band, on a change that redraws every pixel of the retained UI through a different sampling path. The capture was inspected: vitals, spell bar, radar, toolbar icons and slot digits, chat window and Send button all present and correctly placed. Both new .spv pairs compile; the manifest records ui_text and debug_line as Vulkan-ready, leaving six pairs blocked on the world-renderer slices.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 08:51:32 +02:00