Second instance of the #429 (ad695589) boxing-comparer defect class, this
time in the measurement harness rather than production:
ACDREAM_AUTOMATION_ARTIFACT_DIR (with retained-UI screenshots) constructs
CurrentRenderSceneOracle, whose presence as partition observer re-enables
the G5-retired legacy InteriorEntityPartition every frame with per-entity
fingerprinting. Complete() then sorts one fingerprint per RESIDENT entity
(~60k across the streaming window), and the comparer's first key bound
x.ProjectionClass.CompareTo(y.ProjectionClass) to Enum.CompareTo(object),
boxing both operands. The 3-value enum almost always ties, so the boxing
ran on essentially every comparison: a measured ~6.2 MB and ~14 ms per
frame, everywhere — not town-specific and not view-triggered, which is
also why it appeared to "latch" (the resident set drives it, not the view).
Comparing the underlying integral value keeps the identical order. Hermetic
gate: one warmed observed partition of 20,000 entities allocated 15,876,088
bytes before, and passes a <64 KiB bound after
(OracleObservedPartitionAllocationTests).
Ordinary play never constructs the oracle, so no player-visible behavior
changes; what changes is that captures taken with the automation artifact
directory set are no longer taxed. The #429 acceptance data is unaffected
(owner drives and the deciding A/B arms ran with the artifact dir null).
The temporary [pview-alloc] attribution probe that localized this is
retired in the same commit; the gate test now guards the defect.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The directional-shadow topology rebuilt on every streaming-churn frame
and was the measured body of the run-hitch stalls (701 of 708 baseline
stalls alloc-correlated):
- The draw sort comparer's enum-vs-enum CompareTo bound to
Enum.CompareTo(object) and boxed BOTH operands on every comparison —
a constant ~38.9 MB of garbage per topology rebuild (~4M boxes),
handing the GC a forced gen0 collection mid-frame. The full ~100k-draw
sort is replaced outright: draws hash-group by exact DrawKey in one
O(n) pass over retained chained-index arrays, and only the
few-thousand DISTINCT group keys sort (order-preserving packed
material|cull|firstIndex|baseVertex + count|slot|layer|foliage keys,
first-appearance tie-break) — bit-identical emission order to the old
stable sort, near-zero allocation, and no per-draw comparisons at all.
- The caster frame sorts 4-byte indices keyed on SortKey.Value instead
of shuffling multi-hundred-byte records through a boxing comparer.
- Owner-approved pipelining: on a frame whose shadow inputs just changed
(the same frame already paying frame-view/landscape rebuilds), the
caster-frame and prepared-draws topology rebuilds defer to the next
quieter frame, capped at two consecutive deferrals — inside the GPU
fence depth, so retained draws never reference a released arena range.
First build, generation change, caster BuildSequence change, and
journal overflow force the immediate path; deferred refreshes skip
identity-mismatched journal rows.
Owner-accepted in both presentation modes: stall frames 5.8/s -> ~0.45/s
uncapped (0.49/s capped), median stall 20.3 -> 13.7 ms, >25 ms frames
near zero, 275 fps uncapped baseline restored. Allocation gate: a warmed
topology rebuild must allocate <2 KiB (DirectionalShadowPreparedDrawTests).
docs/ISSUES.md carries the full evidence trail; the residual
content-proportional rebuild milliseconds are filed as the
incremental-topology successor, and the pre-existing town-view scaling
latch is filed as #432.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two halves of the felt run-hitch (the visible one-frame player lurch):
- The presentation lerp normalized the pending object-clock time by the
fixed 30 Hz MinQuantum, but retail's object clock simulates
VARIABLE-length quanta (CPhysicsObj::update_object 0x00515D10: capped
at MaxQuantum, everything above MinQuantum runs as ONE step). After a
long frame the view froze for the quantum and then fast-replayed it.
ComputeRenderPosition now spans the ACTUAL last quantum
(_lastQuantumSeconds), and PresentedDeltaSeconds accounts continuous
presented time across quantum boundaries.
- The chase camera damped toward the presented player using wall dt
while the player presents on the object clock, so a long frame
stepped the camera far past the under-advanced player — measured up
to ~1 m of camera/player decoherence in a single frame. Retail ties
camera update to the physics-update callback
(SmartBox::PlayerPhysicsUpdatedCallback 0x00452d60), i.e. the same
clock as the body; both chase cameras now integrate
PresentedDeltaSeconds. Manual zoom/pitch adjustment stays on wall dt
(a user-input rate, not target chasing).
Owner gate: camera-vs-player boom-length change fell from ~1 m spikes
to 0.2-1.2 cm median on long frames; teleports settle clean. Two
Runtime tests updated to pin the continuous-rate contract. The
temporary PlayerPresentationProbe apparatus that measured this is
retired with the fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
UploadGfxObjMeshData built every completed mesh's index data three-plus
times over in LINQ transients (per-batch Indices.ToArray copies plus an
unsized SelectMany growth) on the render thread, up to the per-frame
upload budget. The conversion now fills one exact-size retained
CPUIndices array (the same one the B.4b pick path keeps) and hands the
shared arena (offset, count) segments of it; CPUPositions fills by a
direct pre-sized loop; the Sum/Any/FirstOrDefault transients are gone.
GlobalMeshBuffer.UploadMesh takes the segment form — the staged bytes
per batch are unchanged. Gate: a warmed completion must allocate near
its retained-copy size (MeshPipelineDeviceSeamTests).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the TEMPORARY PlayerPresentationProbe (ACDREAM_PROBE_PLAYER_PRESENT)
per the #429 apparatus plan and records the measured two-arm evidence in
the issue. Probe strips with the fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The owner measured 63% GPU at night vs 28% by day: the starfield
lattice was the whole difference. Three lossless cuts:
- one packed-multiply hash round per cell instead of ~9 chained PCG
rounds (position/presence bit-sliced from one result, brightness/tint
from a second);
- the 3x3x3 neighbourhood sweep becomes the 2x2x2 block around the
sample — a star's visible footprint (a few px) is far smaller than a
lattice cell on screen, so any star outside that block is over a full
cell (>=15 px) away and contributes nothing;
- background mottle drops its invisible third octave.
Same densities, sizes, colours, and look; star positions reshuffle
(procedural layout, not authored). sky.frag.spv re-pinned.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rotation (user-directed): the procedural starfield rotates once per
Dereth day (~2 real hours - constellations visibly wheel through a
night) about a celestial pole ~41 deg above the northern horizon, plus
dayOfYear/360 of seasonal drift so the 360-day year changes the night
sky. One SkyParams float (272-byte block, layout test re-pinned)
carries dayFraction + dayOfYear/360 from the world clock; sky.frag
applies a Rodrigues rotation to the sample direction so stars and
mottle turn together. Impossible with retail's static stretched layer.
Fade rework (the 2026-08-23 two-screenshot gate finding): the
per-vertex vTint signal carried the sun-facing product and blanked
stars across the entire twilight half of the sky. The fade now reads
the UNIFORM ambient term - identical star visibility in every compass
direction, same dusk-to-dawn schedule - with one deliberate exception:
a thin suppression band hugging the low sky toward the sun's azimuth
while the sun term is strong, so stars still wash out inside the
actual twilight glow.
Guards updated (rotation anchor, uniform-fade anchor, 272-byte layout);
both sky SPIR-V hashes re-pinned.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The enhanced sky multiplied the full 4-tier star lattice by a night
factor of 0.0 all day long. Early-out when the star layer's lighting
product says daytime, so the pack's night sky costs nothing outside
dusk-to-dawn. sky.frag.spv re-pinned.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The first night-sky gate (2026-08-23 screenshot) showed three defects:
glowing beams along the cube-face boundaries (fwidth blowup where
adjacent pixels land on different faces lights every neighbourhood star
solid), diffraction-spiked standouts the user rejects ('that is in a
photo only, not in real sky'), and ellipse-stretched stars from scalar
length(fwidth) sizing at oblique view angles.
One rewrite removes all three: stars now live on a seamless 3D lattice
over the unit sphere (no faces, so no seams by construction), each star
resolved through an exact tangent-plane -> screen-pixel 2x2 solve of
the direction derivatives (perfectly round, true pixel sizing at every
view angle, sharper cores), spikes deleted in favour of a soft round
halo on the bright tiers. Guard test updated to pin the new anchors and
forbid both fwidth-face grids and spikes; sky.frag.spv re-pinned.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
User-directed enhancement ('I want the night sky to look very good'):
retail's star layer is one small texture stretched over a 10-poly dome
cap, so stars smear regardless of source-image quality. With the
Atmospheric render pack active, sky.frag now renders the star layer
(GfxObj 0x010015EF, identical in all 20 Dereth day groups) as a fully
procedural sky computed from the view direction: hash-derived stars on
a cube-face grid in three density tiers plus sparse diffraction-spiked
standouts, sized in SCREEN pixels via derivatives so they stay crisp at
any resolution and FOV, over the user-approved 0.4-1.3% cool mottle
(gen_starfield2.py seed 11, approved 2026-08-23). The draw is forced
additive; the day/night fade rides the star layer's existing retail
lighting product so the schedule matches the authored keyframes. Pack
inactive = retail look byte-untouched.
EnhancedNightSkyRuleTests pins the uParamA gate, the exact star-layer
id, the forced-additive draw, and the pack-runtime wiring; sky shader
SPIR-V recompiled and re-pinned. Hermetic App suite green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The aurora was never missing data — it was a missing mechanism plus a
misread. New decompile evidence closes the April-2026 contradiction:
retail plays the sky carriers' PES through the Setup's own DefaultScript
(GameSky::MakeObject @0x00506EE0 -> CPhysicsObj::makeObject @0x00513970
sets state|=0x80000; animate_static_object @0x00513DF0 ticks
ScriptManager + ParticleManager). The pes_id column stays dead — that
half of the April finding stands; the ids are byte-equal mirrors.
- SkyPesFrameController is now the production owner (ACDREAM_ENABLE_SKY_PES
deleted): script ids resolve from the Setup DefaultScript
(SkyObjectData.DefaultScriptId; the pes_id column is a one-time-logged
cross-check), slots persist by (index, gfx id, properties) per
CreateDeletePhysicsObjects @0x005073C0 — a day-group swap keeping the
carrier no longer restarts its emitters — and stale slots stop before
replacements claim the slot-derived owner id.
- RetailParticleFacing ports calc_draw_frame @0x0050DFA0: degrade mode 2
faces the viewer roll-free (set_vector_heading) instead of the camera
plane; modes 3/4/5 spin the authored frame around one local axis
(rotate_around_axis_to_vector) — Dereth authors 54 mode-5 emitters that
previously got no facing at all; 1,583 mode-2 emitters get the exact
law; authored/mode-1 paths are unchanged.
- The 2026-08-23 'whole-sky tint' was the Rainy-group lightning/thunder
PES playing at the debug anchor inside their 0.03-0.19 window, not the
aurora: the aurora is nine faint viewer-facing glows pulsing on
6.7/15/55-minute rebirth cycles, in every day group, all day.
Research: docs/research/2026-08-23-sky-default-script-port.md.
Register: AD-112 filed (camera-anchored synthetic owners vs sky-cell
physics objects). ISSUES #2 corrected (the playback ban is lifted by the
new evidence); #28 fix landed pending the connected night gate.
Tests: RetailParticleFacingTests (16), SkyPesFrameControllerTests (6);
hermetic suites App 6,076/0, Core 4,905/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two April stand-ins, neither registered, met at the horizon from altitude as
a hard line between the dome's rim and the fog-coloured clear:
1. sky.frag fogged every non-additive sky layer with a 0.2 floor. Retail's
GameSky::Draw @0x00506FF0 disables fixed-function fog around the whole
sky draw unless an AdminEnvirons fog override is active (SetFFFogEnable(
LScape::m_override_enabled ? 1 : 0)); additive layers stay unfogged via
SetFFFogAlphaDisabled(1) at D3DPolyRender::SetSurface 0x59c882. The sky
pass now sets ApplyFog only for (override active && !additive), with no
floor.
2. WorldRenderFrameBuilder overwrote the authored fog range with one derived
from the streaming window (538..2189 m always). Retail sets FOGSTART/
FOGEND straight from the keyframe's MinWorldFog/MaxWorldFog
(SkyDesc::GetWorldFog @0x00500CE0 -> SetFFFogProperties @0x005A2F70) with
no draw-distance scaling; zfar is a constant 4000 m. The builder now
leaves SceneLightingUbo.Build's values alone; ACDREAM_FOG_START_MULT /
_END_MULT are deleted from RuntimeOptions.
Guards: SkyFogRuleTests (source-level, the sky renderer has no hermetic
harness); sky.frag.spv re-pinned in VulkanShaderManifestTests with the
reason. Research note 2026-04-23-sky-fog.md carries a correction banner.
App hermetic 6,070/0, Core 4,707/0 (Release). Owner look gate owed: night
and rain fog are now retail's shorter authored ranges.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Holtburg windmill axle (GfxObj 0x010010CE, 8 polygons, all
Stippling.NoPos + SurfaceType.Base1Solid) extracted to a 0-vertex mesh.
NoPos ("NO_POS_UVS", acclient.h:7380-7388) means "this side has no
texture coordinates" — true of every solid-colour polygon, since
nothing samples them — not "there is no positive face". Extraction read
it as the latter and dropped the polygon entirely, client-wide, for
every untextured polygon on every object.
Retail's D3DPolyRender::DrawMesh (@0x0059d4a0, named-retail decomp
~line 426048) draws an untextured subset on an ordinary object exactly
like a textured one; the only retail cases that skip an untextured
subset are a building shell (RenderDeviceD3D::DrawBuilding @0x0059f2a0
sets ObjBuildingOrBuildingPart=1) or an EnvCell interior
(RenderDeviceD3D::DrawEnvCell @0x0059f170, arg4=1). The #119
investigation's "retail's skipNoTexture never draws them either"
conclusion was itself wrong as a general rule.
- MeshExtractor.PrepareGfxObjMeshData / GfxObjMesh.Build: emit the
positive side whenever PosSurface is a valid index, regardless of
NoPos; the existing UV-index-0 fallback already produces zero
texcoords for a NoPos polygon with no UVs on the wire.
- RetailUntexturedSurfacePolicy.IsUntextured(SurfaceType): the one
place that answers "is this surface textured"
((type & (Base1Image|Base1ClipMap)) == 0), replacing the old
`isSolid = NoPos || Base1Solid` (which also mis-classified a NEG-side
batch by the POS-side's NoPos flag).
- RetailUntexturedSubsetPolicy.Draws(isBuildingShell, isUntextured):
the shared draw-time gate wired into WbDrawDispatcher.ClassifyBatches,
.PackedOracle.ClassifyPackedBatches, and
.DirectionalShadows.AddDirectionalShadowBatches — one predicate so the
three walks cannot drift (Campaign VM VM6 lesson).
- CellMesh.cs / MeshExtractor.PrepareCellStructMeshData deliberately
KEEP their NoPos-gated skip for cell-wall geometry — retail's
DrawEnvCell really does skip untextured subsets there; register row
AP-234 documents the NoPos-vs-Surface.Type approximation.
- PakFormat.CurrentBakeToolVersion 4->5 (LauncherInstallRecordStore in
lockstep): a pak baked by an older tool is missing every untextured
face. No bake was run as part of this commit.
Also fixed: WorldBuilder's own upstream ObjectMeshManager.cs has the
identical NoPos bug (ObjectMeshManager.cs:959,984) — our port had
faithfully carried it over, and our own conformance test
(Build_NoPosFlag_OnlyEmitsNegSide) asserted the bug as correct WB
conformance. Renamed/reworded to Build_NoPosFlag_EmitsBothPosAndNegSide
with a citation for why retail decomp overrides WB here.
Issue119UpNullGfxObjDumpTests re-run against the installed DAT:
#119's own two objects (0x010002B4 9/9 polys, 0x010008A8 1/1 poly) now
gate DRAWS on every polygon instead of extracting to nothing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Live Holtburg at 2560x1440: the Low preset needed 67,368,164 resident bytes
(screen-sized HDR/depth/ray targets are 44 MB of that) against an absolute
64 MiB ceiling that had only been validated at 1080p, so Options -> Apply
fell back to the default path; every later Apply was then refused by the
controller's failure memo, which treated the user's deliberate choice like
automatic re-activation.
RenderPackResidentBudget.Effective scales the declared 1080p figure by the
viewport's pixel-count ratio (never below 1), still capped by the hardware
MaxPackResidentBytes; both pack graphs use it and the performance-matrix
tool judges its resident column by the same rule (contract test updated).
RenderPackController.Request gains explicitUserChoice, which clears the
memo for that selection; RenderPackSelectionBinding passes it on every
display edge (Apply, including resolution changes) and keeps the memo for
the startup request.
Tests: RenderPackResidentBudgetTests (1080p/720p keep the declared
ceiling, 1440p = 16/9x, 4K = 4x, hardware cap wins, zero extent rejected);
controller explicit-retry; the binding test now proves the user's next
Apply activates once the cause is gone. App hermetic lane 6,068/0 (Release).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GLFW auto-iconifies an exclusive-fullscreen window on focus loss; for one
frame the window size reads 0x0 while the swapchain is still created, so
GameWindow.OnRender's PrepareFrame guard let a zero-area RenderFrameInput
through. The retail path tolerated it silently; the render-pack controller
correctly rejects a zero activation extent, which surfaced the latent frame
as an unhandled ArgumentOutOfRangeException during the owner's VM6/VM3 gate.
RenderFrameOrchestrator.Render now returns RenderFrameOutcome.ZeroArea
before BeginFrame when either dimension is <= 0 (no GPU frame, phase,
measurement, diagnostics or recovery runs) and GameWindow skips
NoteFrameClosed for it. Test: ZeroAreaViewport_SkipsTheFrameBeforeAnyGpuWork.
Verify: App hermetic lane 6,059/0 (Release).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round-5 narrow re-review of 754d59d9: APPROVE (verified to the SPIR-V
disassembly; shadows-on path proven opcode-identical). Four
non-blocking nits.
N1 (test robustness): T3 (ReceiverVertexShadersFallBackToThePlainLightDirectionWhenTheShadowFlagIsClear)
was satisfied by comment prose -- round 5's own explanatory comments
quote the exact plain-pipeline direction substring
("-uLights[i].dirAndRange.xyz, ... matched bit-for-bit"), so the
substring-only assertion passed even with the code mutated. Fixed:
comment lines are now stripped (new StripLineComments helper) before
any assertion, and a new ordered regex per shader asserts the exact
branch SHAPE -- shadowGatedOff ? -uLights[...] : normalize(uShadow...)
-- not just substring presence. Mutation-tested locally against the
rewritten test: (1) swapping the ternary's true/false operands --
FAILED (previously passed); (2) deleting the fallback entirely,
collapsing to the pre-round-5 buggy expression -- FAILED (previously
passed). Original file restored and reverified passing after each
mutation.
N2 (doc accuracy): "numerically the plain pipeline" overstated the
round-5 fix in three places (plan doc, mesh_atmospheric.vert,
terrain_atmospheric.vert). The direction expression is bit-for-bit;
the SUM is not, because the atmospheric shaders' split ambient+point
vs directional accumulation (and terrain's two varyings vs the plain
pipeline's one) reassociates float summation order by ~1 ulp -- which
is exactly the measured mean |Delta| 0.007 the coordinator's own
pixel-proof evidence already recorded (well under the 65 px noise
floor). All three rewritten to say the receiver "matches the plain
pipeline to within float summation-order rounding (measured mean
|Delta| 0.007 on the offline scene)."
N3 (coverage): RenderPrepared's own cascadeCount == 0 exit (the F2
fix) had no direct test even though RenderPrepared already has 8
direct call sites in this file. Added one: ResidentMaximumReachMeters
at/below CameraNearMeters, passed straight to RenderPrepared with an
otherwise-fully-valid environment (so the fitter, not the environment
gate, is what returns zero cascades), asserting IsBindableFor true /
IsValidFor false.
N4 (latent): RenderPrepared's OWN "if (!environment.ShouldRender)"
exit is a fourth bufferless-disabled path -- unreachable via Render
(whose own gate already validates ShouldRender first) but the same
shape, and RenderPrepared is called directly by tests and any future
caller. Took the preferred fix: publishes the disabled binding there
too, via the same helper, so every exit on a frame that draws the
world publishes when the pack's AtmosphericFrame is bound. Also made
EvaluateGateAndPublishDisabledBinding self-contained: it now resets
_currentFrameBinding to Disabled on its own entry instead of relying
on Render having done so first (idempotent with Render's own reset).
Regenerated SPIR-V: mesh_atmospheric.vert and terrain_atmospheric.vert
are comment-only changes (N2), so only the manifest's source hashes
changed -- compiled .spv bytes are unchanged, consistent with round 3's
precedent for comment-only shader edits.
Verify: Release build 0 warnings/0 errors. App hermetic-lane filter
6,055/0 failed. Full hermetic-filtered solution: 15,283/0 failed
across 15 projects.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Round 4 (eec95535) fixed wind but introduced a new lighting bug: the
receiver VERTEX shaders source the sun direction from the shadow block,
not only the shadow visibility term. mesh_atmospheric.vert's
accumulateLights read uShadowLightDirectionAndSource unconditionally for
every directional light; terrain_atmospheric.vert did the same for its
single sun term. Round 4's PublishDisabledReceiverBinding writes
direction (0,0,1) into that block on every shadow-gated-off frame (user
sun-shadow-strength 0 in daylight, indoor/portal cover, night), so every
such frame was lighting outdoor terrain and objects from straight
overhead instead of the authored sun. Publishing the environment's real
direction would not have restored parity either -- the celestial shadow
source direction (sun/moon disc) is not the authored light direction.
F1 (BLOCKER): fixed in the shaders themselves, exact parity with the
plain pipeline. Both receiver verts now branch on the same flag bit
acdreamDirectionalShadowVisibility already reads
((uShadowTextureAndFlags.w & 1u) == 0u) and, when clear, use the EXACT
plain-pipeline expression instead of the shadow block's direction:
-uLights[i].dirAndRange.xyz in mesh_atmospheric.vert (matching
mesh_modern.vert, hoisted out of the light loop as a uniform branch);
-uLights[0].dirAndRange.xyz in terrain_atmospheric.vert (matching
terrain_modern.vert's sunDir/-sunDir form). The (0,0,1) word in the
disabled block stays as the documented normalize()-cannot-NaN guard; its
comment now says so explicitly since it is no longer read as a light
direction when the flag is clear.
F2: RenderPrepared's cascadeCount == 0 return is a third bufferless-
disabled path reachable from a frame that already passed Render's own
two gates (the cascade fitter can still find zero usable cascades) --
publishes the same disabled binding now, via the same
PublishDisabledReceiverBinding helper (re-signatured to take a bare
AtmosphericFrameBufferBinding so all three call sites -- Render's two
early-outs plus this one -- share it).
F3: removed a stray duplicated " -- Closeout and merge" fragment under
the plan's VM7 heading.
F4: corrected the false "the flag bit makes it numerically the plain
lighting sum" claim in the plan's round-4 paragraph and in
WbDrawDispatcher.DirectionalShadowReceivers.cs -- the flag bit alone
only fixed the shadow VISIBILITY term (already correct before round 4);
it took both that AND round 5's light-DIRECTION fallback to actually
match the plain pipeline.
T1: extracted Render's gate prologue (environment evaluate -> two
early-outs -> PublishDisabledReceiverBinding) into internal
EvaluateGateAndPublishDisabledBinding(frame, in input, out environment,
out environmentGateTicks), behaviour-preserving, called by Render before
it touches world/terrain -- the ArgumentNullException.ThrowIfNull(world)/
ThrowIfNull(terrain) calls keep their exact position relative to the
gate. No test in this suite constructs a real WbDrawDispatcher +
TerrainModernRenderer pair (still true), so this extraction is what
makes the gate itself testable; two new tests drive it directly with
PlayerInsideCell: true and with ResidentMaximumReachMeters <=
CameraNearMeters, asserting TryGetCurrentFrameBinding true / IsValidFor
false for both.
T2: proves the actual composition WbDrawDispatcher.PipelinesFor and
TerrainModernRenderer both use -- TryGetCurrentFrameBinding feeding
ShouldSelectReceiverPipeline -- selects the receiver pipeline for the
atmospheric world pass once a disabled binding is published, and still
refuses a non-atmospheric pass name.
T3: shader-source guard (same style as AtmosphericPostProcessGraphTests'
existing shader-text tests) pinning that both receiver verts contain the
flag-gated fallback and reference the same uLights expression the plain
verts use, so a future edit that drops the fallback fails this test
instead of only showing up in a pixel capture.
T4: the (0,0,1) test's doc comment and an inline assertion comment now
say the value is a NaN guard, not a light direction.
Regenerated SPIR-V: mesh_atmospheric.vert and terrain_atmospheric.vert
recompiled to different bytes this time (a real code change, not a
comment); manifest updated to match.
Verify: Release build 0 warnings/0 errors. App hermetic-lane filter
6,054/0 failed. Core.Tests 4,695/0 failed. RenderPackValidator 30/30.
Full hermetic-filtered solution: 15,282/0 failed across 15 projects.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The reviewer's offline pixel apparatus found a real design defect, not a
test artefact: foliage wind was welded to "directional shadows rendered
this frame." Evidence: offline High preset, sun-shadow-strength=0,
wind-strength 2 + lean/branch 1 m — wind-on vs wind-off at the same
pinned clock differed by only 49-65 px, inside the apparatus's own 22 px
run-to-run noise floor (no measurable motion). A CPU probe independently
confirmed ResolveFoliageWind was correct (first advance snaps to Clear
0.25/0.15, gate 1, one graph) — the correct uniform never reached the
world pass.
Root cause: DirectionalSunShadowRenderer.Render's two early-out paths
(!environment.ShouldRender, ResidentWindowUnavailable) left
_currentFrameBinding at its pure Disabled (no-buffer) default.
WbDrawDispatcher.PipelinesFor and TerrainModernRenderer's matching
selection logic only choose the atmospheric receiver pipeline
(mesh_atmospheric, the only pipeline that #includes foliage_wind.glsl)
when TryGetCurrentFrameBinding returns true; with no buffer it always
returned false, so the world pass silently fell back to the plain
mesh_modern pipeline, which has no wind code at all. Because the shadow
gate is ActiveDayGroupMultiplier = dayGroupPolicy x elevationResponse x
strength, this killed wind every night (elevation response -> 0), at
user sun-shadow-strength 0, and under the portal/login cover.
Fix (decouple, not patch): DirectionalShadowFrameBinding gained
IsBindableFor ("a real current-frame allocation exists") separate from
IsValidFor ("...and it is Enabled with real shadow content" -- kept
exactly as VolumetricShaftRenderer's own gate needs it).
TryGetCurrentFrameBinding now returns IsBindableFor. When the built-in
pack supplies an AtmosphericFrame binding (declared packs never do, so
their receiver shaders -- which never declare set 3 binding 5 -- are
unaffected), Render's two early-out paths call a new
PublishDisabledReceiverBinding: it allocates one real ring slice and
writes a DISABLED DirectionalShadowUniforms block -- every matrix
Identity, every control/bias term zero, TextureAndFlags all zero (bit 0
clear is exactly what directional_shadow_receiver.glsl's
acdreamDirectionalShadowVisibility already reads as "no shadow, full
visibility" via its existing early return 1.0), and a unit light
direction (0,0,1) so a fragment shader's normalize() can never produce
NaN. BindDirectionalShadowReceiver and TerrainModernRenderer's
shadow-buffer bind now check Buffer is not null instead of Enabled, so
the disabled block actually gets bound once it is selected.
PublishDisabledReceiverBinding is internal (not private) specifically so
it is testable without standing up a real WbDrawDispatcher/
TerrainModernRenderer pair -- no test in this suite constructs either.
New tests: (a)/(b) PublishDisabledReceiverBinding is bindable-not-valid
with a bound AtmosphericFrame and a genuine no-op with an unbound one;
(c) BindDirectionalShadowReceiver emits both UniformDirectionalShadow and
UniformAtmosphericFrame binds for a disabled binding; (d)
VolumetricShaftRenderer's gate still reports NoCurrentDirectionalShadow
for a disabled binding. ShouldSelectReceiverPipeline itself is untouched
and its existing tests (parametrized directly on bindingValid) remain
valid; no existing test asserted the old "disabled shadows -> plain
pipeline / no binding" behaviour in a way this fix invalidates -- every
existing caller either bypasses Render (calls RenderPrepared directly)
or uses a stale-serial binding IsBindableFor still correctly rejects.
Verify: Release build 0 warnings/0 errors. App hermetic-lane filter
6,050/0 failed. Core.Tests 4,695/0 failed. Full hermetic-filtered
solution: 15,278/0 failed across 15 projects.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Narrow re-review of a82959f1: APPROVE, with follow-ups. All items landed.
N2 (structural): (a) extracted the ONE shared InstanceGroup-from-key
construction seam, WbDrawDispatcher.CreateGroupFromKey(key, registration,
frame) — before this there were two near-identical `new InstanceGroup
{ ... }` initializers (GetOrCreateInstanceGroup and GetOrCreatePackedGroup)
that had already drifted once (the round-2 F1 bug). Both routes call it now;
CreateGroupFromKey's own `new()` is the only production InstanceGroup
construction site repo-wide, same precedent as AppendPackedInstance. (b)
GroupKey.FoliageFlags lost its `= 0u` default and moved before CullMode in
the declaration (CullMode keeps its default, C# requires optional params to
trail required ones), so a `new GroupKey(...)` that omits it is a compile
error. Fixed every real construction site the reorder/requirement touched:
the 2 production sites, ToKey (a reconstruction from InstanceGroup the
review didn't count but the reorder broke), and 5 test sites (one more than
the review's "4" — InstanceGroupClearTests had a second, implicit
target-typed `MakeKey` factory the original count missed). Verified by a
full solution build.
N1: added CreateGroupFromKey_CopiesFoliageFlagsFromTheKey
(InstanceGroupClearTests) — a key carrying FoliageFlags 0x2 in, the created
group's FoliageFlags 0x2 out. That test plus N2b's required field are what
actually guard the round-2 F1 blocker; reworded PackedDispatcherOracleTests'
existing test comment to say what IT proves (the classification-to-
BuildIndirectArrays-to-BatchData.flags path), not that it guards the
classifier.
N3: corrected the plan's round-2 paragraph — folding FoliageFlags into the
G2/G3 digest is correct and symmetric, but CompareClassifiedOutput only
runs from RenderScenePViewFrameProductController.BuildAndCompare, which has
no production caller anywhere in src/AcDream.App/, and both of
RenderScenePViewFrameProductTests's own callers construct the controller
without the optional dispatcher argument — so the fold catches nothing
until that oracle is wired to an actual caller.
N4: the plan's F6 note now names both classification caches — the classic
route's EntityClassificationCache.EntityCacheEntry (self-heals per entity
on its own next eviction) and the packed route's
PackedProjectionClassificationEntry/PackedClassifiedBatch.Key
(PackedProjectionClassificationCache.BeginFrame clears its entire cache in
one shot on a RenderSceneGeneration change) — and notes neither mechanism
is keyed to a pack switch specifically.
N5: deleted the now-unused single-generic ComputeEntityHasCutoutSubset<T>
overload; its 4 test call sites now use the two-generic, zero-alloc
overload with an unused int context and a static (_, value) => value
lambda, so there is exactly one ComputeEntityHasCutoutSubset to keep
correct.
A6 (reviewer-filed): ResolveFoliageWind's _windMean/_windGust started at 0
and always eased toward the weather target by clock delta, with no
distinction for a graph's first-ever advance. A pinned clock
(ACDREAM_SKY_PHASE_SECONDS, the offline pixel gate's determinism pin) never
advances between calls, so the wind reached only whatever fraction the
first (1-second-clamped) step produced and sat there forever; live, the
first 10 s after a graph is constructed (pack selection / login) spun up
from dead calm even though the weather already IS what it is. Fixed at the
root: the first advance (_windFrameSerial == -1, the constructor sentinel)
now snaps _windMean/_windGust straight to the target; every later advance
eases over WeatherSystem.TransitionSeconds exactly as before. Added a
SetWindClockSecondsOverrideForTesting seam (_windClockSecondsOverride is no
longer readonly) so a hermetic test can advance the pinned clock by an
exact amount between two resolves without a real-time Thread.Sleep; two new
tests prove the first-advance snap is exact and a second advance still
eases at the normal rate. The three existing indoor/wind-disabled/amplitude
gate tests pass unchanged.
Verify: Release build 0 warnings/0 errors. App hermetic-lane filter
6,046/0 failed. Core.Tests 4,695/0 failed. Full hermetic-filtered solution:
15,274/0 failed across 15 projects.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Narrow re-review of 43e3abed found every A1-A5/A7/A8 item resolved but
one new blocker in the production packed classifier.
F1 (BLOCKER): RetailPViewPassExecutor.DrawPackedProductionRoute — the
route production world geometry actually draws from — never computed
FoliageFlags at all. WbDrawDispatcher.PackedOracle.cs's
ClassifyPackedBatches built its GroupKey with the field defaulting to
0u, and GetOrCreatePackedGroup never copied it onto the created
InstanceGroup, so production BatchData.flags bits 1/2 were always zero
for every scenery entity: the world geometry never swayed even though
the independently-classified shadow caster did, so shadows visibly
swayed under rigid trees. Both classifier call sites now compute
FoliageFlags via the identical FoliageWindClassification.Classify call
and entity-scoped HasCutoutSubset OR the classic (non-packed) path
uses, and GetOrCreatePackedGroup copies it exactly like
GetOrCreateInstanceGroup always has. The G2/G3 classified-output
digest (AddOpaqueSubmissionGroup/BuildTransparentSubmissionDigest) now
also folds GroupKey.FoliageFlags into its hash — present in the key
since round 1 but never actually read by either digest function, so a
content-level (not just group-count-level) classic-vs-packed
divergence is now caught.
F2 (medium): the delayed-alpha replay path (PrepareDeferredAlphaDraws)
hardcoded Flags = 1, dropping bits 1/2 for any group replayed through
it — a trunk instance promoted into the alpha-blend group mid-fade
(the #188 translucency-promotion case) would stop swaying for the
duration of its fade. Now 1u | key.FoliageFlags.
F3 (nit): ComputeEntityHasCutoutSubset's three call sites (classic,
caster, and the newly-fixed packed classifier) each allocated a
closure over _meshAdapter per Setup entity per frame. A new
context-taking overload passes the mesh adapter as an explicit
argument to a static lambda instead, letting the compiler cache one
delegate for the method's lifetime rather than allocating fresh ones.
A3 test gap: WbDrawDispatcher.BindDirectionalShadowReceiver is now
internal so DirectionalShadowGpuTests can drive it directly with a
bare RecordingGpuDevice pass encoder, proving it emits
UniformAtmosphericFrame with the exact buffer/offset/size a
DirectionalShadowFrameBinding carries — paired with the existing test
proving that binding carries the caster's real bind forward untouched.
F1's missing test: PackedDispatcherOracleTests chains
FoliageWindClassification.Classify (called with the packed
classifier's exact argument shape) for a real 0x8... scenery entity id
through BuildIndirectArrays — the same shared, already-tested
production step both classic and packed group lists feed into BatchData —
proving the resulting flags word carries bit 0x2. Driving
ClassifyPackedBatches/GetOrCreatePackedGroup directly was not a "cheap
test": both are private instance methods reachable only through the
full RetailPViewPassExecutor route, which needs a real IGpuDevice,
world-pass scope, mesh manager, and compiled pipelines to construct —
no test anywhere in the App test project stands one up.
Nits: F4 corrects foliage_wind.glsl's header comment from "bit 31" to
the top-nibble test; F5 documents at the receiver bind site that the
caster's own AtmosphericFrameBufferBinding has its seven ABI v1
members zero/Identity by construction (only the two v2 wind members
are valid) — safe today because mesh_atmospheric.vert reads that
binding solely for wind displacement, flagged as a footgun for a
future v1-reading addition to that shader; F6 notes in the plan
(rather than fixes) that EntityCacheEntry does not proactively
invalidate when FoliageWindExclusions changes on a pack switch —
harmless with the pack off, self-heals on the entry's next natural
eviction.
foliage_wind.glsl's F4 comment-only change updated the SPIR-V
manifest's source hashes for the five includers (mesh_atmospheric.vert
+ four directional_shadow_world_* casters); the compiled .spv bytes
are byte-identical since comments do not affect bytecode.
Verify: Release build 0 warnings/0 errors. App hermetic-lane filter
6,043/0 failed. Core.Tests 4,695/0 failed. RenderPackValidator 30/30.
Full hermetic-filtered solution: 15,271/0 failed across 15 projects.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Opus dual-lens review of the three VM6 commits (0930c35d, 39e8408c,
6cc5e183) found two blockers and two should-fix issues; all landed here
along with the review's nits and documentation corrections.
Blockers:
- A1: the procedural-scenery classifier tested bit 31 alone instead of
the full top nibble (0xF000_0000 == 0x8000_0000), so it also matched
LandblockStaticEntityIdAllocator's 0xC... namespace (fences/gates/
building shells with a cutout subset), the 0xDA11_D0xx paperdoll id,
and the 0xFFFF_FF01 portal-tunnel id as procedural scenery — all
three would have swayed. ProceduralSceneryIdAllocator.IsInNamespace
now does the exact top-nibble test; FoliageWindClassification
delegates to it.
- A2: GroupKey (the receiver's instance-batching key) did not carry
FoliageFlags while the caster's dedup key already did, so a scenery
instance and a non-scenery instance sharing a mesh subset coalesced
into one receiver InstanceGroup whose flags were last-writer-wins —
disagreeing with the correctly-keyed caster. GroupKey now carries
FoliageFlags, computed before key construction and set exactly once
at group creation; the imperative re-stamp is gone, and CachedBatch's
now-redundant FoliageFlags field is removed.
Should-fix:
- A3: the world receiver pass bound UniformAtmosphericFrame only by
accident (leftover from the caster pass, which runs first each
frame, since Vulkan binding state isn't reset between passes).
DirectionalShadowFrameBinding now carries the caster's exact
AtmosphericFrameBufferBinding and BindDirectionalShadowReceiver binds
it explicitly.
- A4: a Setup-composed tree's opaque trunk part never got the trunk
flag because HasCutoutSubset is cached per GfxObj part, not per
entity. FoliageWindClassification.ComputeEntityHasCutoutSubset now
ORs HasCutoutSubset across an entity's resolved sibling parts once
per entity, threaded into ClassifyBatches/AddDirectionalShadowBatches
via a new optional override parameter.
Nits: A5 hashes the per-vertex flutter seed relative to the instance
origin instead of absolute world XY (fp32 sin() precision loss at far
landblock corners), mirrored in both foliage_wind.glsl and
FoliageWindModel; A7 documents the max(maxHeight, 0.5) divide-guard as
a deliberate pseudocode divergence; A8 switches FoliageWindExclusions'
construction to ToFrozenSet() and softens the "never stale" doc
comment to "no slower than one frame behind."
Tests added: top-nibble classification (0xFFFFFFFFu now correctly
false), GroupKey inequality across entity-driven scenery/landblock-
static classification, a caster-batch test proving the same pairing
never coalesces, ComputeEntityHasCutoutSubset unit + end-to-end
two-part-Setup tests, the caster→receiver AtmosphericFrame binding
carry-through, flutter-hash translation invariance relative to
instance origin, and a Storm-wind mid-height displacement floor
guarding against a "no motion" regression.
Docs: plan VM6 body corrected to the five-row WeatherKind table, "bits
1 and 2", "all four" caster shaders, and top-nibble wording throughout;
the owner gate checklist's Rain/Storm step; the stale v1-only shader-
interface compatibility entry; semantic-bindings-v1.md's v2 members
folded into the main 192-byte block; the IA-25 register row's top-
nibble wording; AtmosphericFrameInputs.cs's ABI size reference.
foliage_wind.glsl's A5 change recompiled exactly the five shaders that
include it (mesh_atmospheric.vert, the four directional_shadow_world_*
casters) plus the manifest; no other .spv changed.
Verify: Release build 0 warnings/0 errors. App hermetic-lane filter
6,041/0 failed (no environment-specific failures this run).
RenderPackValidator 30/30. Full hermetic-filtered solution: 15,269/0
failed across 15 projects.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
FoliageWindByDayGroup / FoliageWindDayGroupPoint(int ActiveDayGroup, ...)
becomes FoliageWindByWeather / FoliageWindWeatherPoint(string WeatherKind,
...) in AtmospherePolicyDeclaration (Plugin.Abstractions is BCL-only, so
the key is the exact member name of AcDream.Core.World.WeatherKind rather
than the enum itself). The raw activeDayGroup index carries no weather
meaning by itself; WeatherState.cs already classifies each day group's
authored DAT name into one of five real weather kinds, and that fact was
already threaded through AtmosphericFrameInputs.Weather / uAtmosphereWeather.x
— this reuses it instead of guessing an index-to-category mapping.
Built-in table (BuiltInAtmosphericRenderPack.AtmospherePolicy()): Clear
0.25/0.15, Overcast 0.60/0.35, Rain 0.85/0.60, Snow 0.35/0.20, Storm
1.00/0.75 — all five WeatherKind members declared, the invented "Cloudy"
row dropped. RenderPackAtmospherePolicyEvaluation.FoliageWind now takes a
WeatherKind and matches by weather.ToString() (ordinal) against each
declared point's name; a kind absent from the table falls back to the
declared Clear row, then to (0,0) if Clear itself is undeclared. The
delta-seconds EMA interpolation (EaseTowardTarget) is unchanged.
AtmosphericPostProcessGraph.ResolveFoliageWind and its two callers
(RenderPostProcess via inputs.Weather; RenderDirectionalShadows via
foundation.Atmosphere.Kind) now pass WeatherKind instead of the day-group
int.
RenderPackValidation.ValidateAtmosphere (runs for every pack declaring an
AtmospherePolicy, not gated to Tier2/shadow packs) now rejects an unknown
or non-exact-case weather-kind name and a repeated kind, mirroring the
existing ActiveDayGroupMultiplier duplicate-key check.
Tests: RenderPackAtmospherePolicyEvaluationTests rewritten for the
kind-keyed API (all five kinds resolve to their declared row, an unlisted
kind falls back to Clear, ordinal exact-case matching, null-table
handling); RenderPackSpirvValidatorTests gains four descriptor-validation
cases (unknown name, wrong case, duplicate kind, the five-kind table
accepted); AtmosphericPostProcessGraphTests' three foliage-wind cases now
select WeatherKind.Storm via `with` instead of an assumed day-group index.
Spot-check (per the coordinator's ask, not changed here): yes —
ActiveDayGroupMultiplier / EvaluateDayGroupPolicy (pre-existing, Campaign
AR/VM3-era — BuiltInAtmosphericRenderPack.AtmospherePolicy()'s three rows
`new ActiveDayGroupMultiplier(0, 1.0), (1, 0.35), (2, 0.20)`) key the
sun-ray/shadow/volumetric day-group strength multiplier by the same raw
activeDayGroup index with an undocumented assumed meaning (0=brightest ...
2=dimmest), the identical class of issue this commit fixes for foliage
wind. Left unchanged per instruction; flagging for the coordinator to file.
Full solution Debug and Release builds green. App hermetic filter
6024/6026 — the same 2 pre-existing failures as VM6a/VM6b. Both were
re-run in isolation per the verification ask: both still fail alone (not
a load-flake in this environment) — confirmed via git stash earlier this
session that both already fail on the unmodified pre-VM6 baseline, so
they are pre-existing and unrelated to this change. Core.Tests hermetic
4697/4697. RenderPackValidator.Tests 30/30. No shader/spv changes in this
commit (pure C#/docs fix).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Procedural-scenery foliage (trees/bushes — entity ids in the
ProceduralSceneryIdAllocator's 0x8XXYYIII namespace) sways with weather in
mesh_atmospheric.vert and all four directional_shadow_world_* caster vertex
shaders, both calling the identical new foliage_wind.glsl include so the
shadow moves with the leaf by construction.
Classification (FoliageWindClassification, AcDream.App.Rendering.Wb): two
new BatchData.flags bits, computed once per (entity, subset) from four
inputs — entity id (bit 31 for procedural scenery), the pack's declared
FoliageExclusions membership, the subset's TranslucencyKind, and
ObjectRenderData.HasCutoutSubset (computed once per mesh at build time, not
per frame). Bit 1 marks an alpha-cutout leaf subset; bit 2 marks an opaque
trunk subset (only when its own mesh also owns a cutout subset, so rocks
stay still). WbDrawDispatcher.ClassifyBatches (world receiver) and
AddDirectionalShadowBatches (caster) call this with the same four inputs, so
casters and receivers classify identically without needing to share state.
Retail's mesh_modern/terrain_modern/mesh_detail pipelines never read these
bits, so pack-off output is unaffected.
Motion model (foliage_wind.glsl, mirrored bit-for-bit in the new
FoliageWindModel for hermetic CPU tests): height-squared-scaled slow lean
for every foliage subset, plus branch swing and per-vertex-hash-decorrelated
flutter for cutout subsets only. AtmosphericPostProcessGraph.ResolveFoliageWind
resolves the wind block once per frame.Serial — advanced by whichever of
RenderDirectionalShadows (which runs first) or RenderPostProcess is called
first that frame, with the second reading the already-advanced state, which
is what keeps the caster and receiver reading byte-identical clock/strength
values. The per-day-group mean/gust target (AtmospherePolicyDeclaration.
FoliageWindByDayGroup, keyed by the same day-group index convention
ActiveDayGroupMultipliers already established: Clear/Cloudy/Overcast/Rainy)
eases toward its target over WeatherSystem.TransitionSeconds (10s) so a
weather change never snaps; wind-enabled off or indoor instead gates the
OUTPUT to an exact zero (not an asymptotic approach) so a settings toggle or
cell transition is immediate. The wind clock is a Stopwatch started at graph
construction (monotonic, session-relative magnitude for GPU sin() accuracy),
overridable by the same ACDREAM_SKY_PHASE_SECONDS pin SkyRenderer already
uses, for deterministic offline gates.
New settings: wind-enabled, wind-strength, wind-direction-degrees (225°
default — no authored retail wind direction exists to read),
wind-lean-metres, wind-branch-metres, wind-flutter-metres (0 on Low),
wind-canopy-height-metres.
Register row IA-25 files this as an intentional, strictly opt-in divergence:
retail applies no per-vertex wind displacement to any geometry. Known,
accepted limitation: classification is per mesh-subset (one BatchData.flags
word per indirect-draw batch), not per entity instance, so the rare case of
one mesh subset being reachable from both a procedural-scenery and a
non-scenery placement would classify all of that subset's instances alike.
Tests: FoliageWindClassificationTests (the full classification matrix),
FoliageWindModelTests (identity on non-foliage/calm-wind/base-vertex,
canopy-top displacement bound, z-never-increases, trunk has no flutter
term), RenderPackAtmospherePolicyEvaluationTests (exact day-group lookup,
no interpolation across day-group ids, easing convergence without overshoot
or discontinuity), AtmosphericShaderAbiTests (each of the five shaders calls
acdreamFoliageDisplace exactly once; mesh_modern/terrain/mesh_detail call it
never), and four AtmosphericPostProcessGraphTests additions (indoor/disabled
exact-zero gating, settings-to-UBO wiring, same-frame-Serial idempotency —
the last proxies the caster/receiver agreement invariant without needing
this hermetic harness's WbDrawDispatcher/TerrainModernRenderer dependency
chain to exercise RenderDirectionalShadows directly).
App hermetic filter: 6015/6017 (the same 2 pre-existing failures as VM6a,
confirmed unrelated). Core.Tests hermetic: 4697/4697. RenderPackValidator.Tests:
30/30. Full solution Debug and Release builds green. Shader recompile
touched exactly the 5 edited files' .spv (plus manifest); the retail oracle
set and every other pack shader are byte-identical.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
AtmosphericFrame (set 3/binding 5) grows additively from 160 to 192 bytes:
two appended vec4 members, uAtmosphereClockWind and uAtmosphereWindAmplitude,
carry the foliage-wind clock/weather and amplitude inputs VM6b's shader
displacement will read. RenderPackShaderAbi renames the old constant to
AtmosphericFrameSizeBytesV1 (160), adds AtmosphericFrameSizeBytesV2 (192),
keeps AtmosphericFrameSizeBytes pointing at the current (v2) size, and adds
ShaderAbiVersion = 2. RenderPackSpirvValidator.ValidateAtmosphericFrame
accepts either the v1 (seven-member, 160-byte) or v2 (nine-member, 192-byte)
shape and rejects anything else naming both — this is why the frozen
external sample packs under samples/*/Shaders/*.spv, whose GLSL sources are
not in this tree, need no rebuild: a v1 shader bound to the 192-byte buffer
still reads correctly, since a bound range only needs to be >= the block's
own declared size.
DirectionalSunShadowRenderer's caster pass now binds AtmosphericFrame too
(both the multiview and per-cascade sites), through a new
AtmosphericFrameBufferBinding the graph owns and supplies via
DirectionalSunShadowRenderInput. AtmosphericPostProcessGraph.RenderDirectionalShadows
builds its own 192-byte ring allocation for this, separate from the world
receiver's frame block, because the caster pass runs before RenderPostProcess
constructs that block within the same frame. The four world caster pipeline
variants (opaque/cutout, base/multiview) are now allowed to declare binding
5 in the validator; terrain casters are untouched.
This commit is plumbing only: the two new members are always written but
never read by any shader yet (zero placeholders), so pack-on and pack-off
output are both pixel-identical to before. VM6b wires the real weather-driven
values and the shader-side displacement.
App hermetic filter: 5972/5974 (2 pre-existing failures unrelated to this
change, confirmed against the unmodified baseline). Core.Tests hermetic:
4697/4697. RenderPackValidator.Tests: 30/30. VulkanShaderManifestTests
(retail oracle set): 7/7, byte-identical.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Opus narrow re-review of 51178f7c: APPROVE. Residuals closed: the AR plan
no longer says '<=1 LSB' unqualified (99.99% of pixels; 95 foliage-
silhouette pixels up to 73 LSB, 58 isolated); the campaign doc says the
same; AtmosphericColorPipelineTests now read the SHIPPED exposure/vignette
defaults from BuiltInAtmosphericRenderPack.Descriptor and the graph's named
DefaultVignetteStrengthFallback instead of literals.
Measured for the gate (offline Holtburg hillside, High defaults vs pack
off): mean luminance -17% noon, -44% dusk, p95 unchanged, clip 0.06% both;
neutral High vs pack off: 110,561 px at |d|=1, 95 at >=5 (foliage edges).
Evidence images under docs/research/evidence/vm3/.
#422 filed: one High-default offline capture exited with
STATUS_HEAP_CORRUPTION after a clean managed shutdown; 1 in 8 runs, never
under validation layers. VM7 gate item.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fix round from Opus review of 87677f9c (APPROVE WITH FIXES):
1. (A1/A3) Hardened the atmospheric_filmic.frag / atmospheric_bloom_
downsample.frag shader-source pinning test: asserts the exact decode
call count (6 - three in lowFusedScene, three in main's non-fused
branch), that the 2.2 display-gamma exponent and 1/2.2 inverse in
atmospheric_common.glsl are formatted FROM AtmosphericColorPipeline
.DisplayGamma (so shader literal and CPU-tested value cannot drift),
that the 0.18 contrast pivot in atmospheric_filmic.frag is formatted
from AtmosphericColorPipeline.LinearMidGrey, and pins
AtmosphericPostProcessGraph.BloomKneeLinear/BloomThresholdLinear as
exact literals (0.73f / 1f). Also removed a stray trailing "()" from
an existing comment in atmospheric_filmic.frag that was inflating the
decode-call count to 7.
2. (B1) Re-derived the "vignette-strength" default for the linear-light
post stack. The vignette multiply now happens on linear colour before
the final encode, so a corner factor of (1 - strength) displays as
(1 - strength)^(1/2.2), not (1 - strength) directly. The accepted
look was strength 0.12 under the OLD gamma-space pipeline: a 12%
on-screen corner darkening. Under strength 0.12 in the new linear
pipeline that same 0.88 corner multiplier would only display as
0.88^(1/2.2) ~= 0.9435 (5.6% darkening - visibly weaker). Solving
(1 - strength)^(1/2.2) = 0.88 gives strength = 1 - 0.88^2.2 ~= 0.245,
which reproduces the accepted 12% corner darkening. Since
RenderPackSettingValueCodec requires every declared default to be
step-aligned from the minimum and 0.245 is not a multiple of the old
0.01 step, the step also moves to 0.005 (a finer slider, not
coarser) so the exact derived default is a valid grid point -
verified by running the ExternalTierTwoPackCanRenameEveryOwnedId
AndShaderAsset validation test, which failed with "invalid default
value" before this correction. Also updated the matching fallback in
AtmosphericPostProcessGraph.FromDescriptor (0.12f -> 0.245f) for
consistency, and added
AtmosphericColorPipelineTests.VignetteDefaultReproducesTheAccepted
TwelvePercentCornerDarkening pinning encode(1-0.245) ~= 0.88.
3. (A4) Renamed VolumetricShaftFrameParameters.LinearSunColor ->
AuthoredSunColor in VolumetricShaftQuality.cs (internal, 2 references,
both in that file - safe). Left LightSource.ColorLinear unrenamed:
grep shows 13 files depend on it (GlobalLightPacker, SceneLightingUbo,
LightBake, LightManager, EnvCellRenderer, RenderingDiagnostics, and
several Core tests) across the shared retail default-path lighting
UBO pipeline - renaming it is out of VM3's pack-only scope and would
touch the mandatory-unchanged default path. Added a pointer comment
on the field in LightSource.cs (and a one-line note at its
WorldRenderFrameBuilder.cs call site) documenting the same
display-space-not-linear fact and explaining why the rename is
deferred to its own default-path colour-space pass.
4. (B4) Added a citation beside acesFitted in both atmospheric_filmic
.frag and its C# mirror (AtmosphericColorPipeline.AcesFitted):
Krzysztof Narkowicz, "ACES Filmic Tone Mapping Curve" (2016). The fit
takes linear scene light in and returns linear display light in
[0,1] - it does not itself gamma-encode. Evidence: acesFitted(0.80 *
decode(0.46)) = 0.2064 un-encoded versus the accepted 0.51 on screen.
5. (B2) Rewrote the VM3 section of docs/plans/2026-08-22-visualmaster-
campaign.md with the shipped truth in place of the pre-implementation
guess: exposure stays 0.80 (at exposure 1.0 the linear pipeline maps
gamma-0.5 to 0.6017, essentially the same 0.6163 the owner called too
bright), bloom threshold stays 1.0 (a fixed point of both exponents),
knee moves 0.45 -> 0.73, vignette-strength moves 0.12 -> 0.245. Added
the old-vs-new curve table at exposure 0.80 across ten gamma inputs.
Replaced the acceptance criteria's "new automated test on the
recording RHI" with the CPU mirror + shader-source pins actually
used, and recorded that the real-frame masked capture WAS run
(retail vs High-with-every-effect-neutral, artifacts/vm3):
independently re-verified by re-running the pixel diff against the
checked-in screenshots - 110,561 px at |delta|=1 and exactly 95
pixels at |delta|>=5, confined to foliage-canopy silhouette edges
against sky with nothing on any ground/building/water surface. Noted
the Stage-1 luminance table re-capture is still owed at the owner
gate.
6. (B3) Corrected docs/plans/2026-08-21-atmospheric-rendering.md's VM3
summary sentence: the bloom intermediate is already linear after
extraction (no separate "bloom read" decode), and the neutral-preset
claim is now phrased as a measured numerical identity (<=1 LSB on a
real frame) rather than an unqualified "is" statement.
7. (A5) Corrected toolchain attribution: tools/compile-shaders.ps1 used
the managed Silk.NET.Shaderc path (shaderc_shared.dll) to compile in
both this round and the original VM3 commit - a Vulkan SDK glslc was
detected and its path recorded, but the managed compiler is what
actually ran. Regenerating this round only changed the atmospheric_
filmic frag stage's manifest hash (comment-only edits); the compiled
.spv bytes are unchanged, and every retail-oracle shader
(mesh_modern, terrain_modern, mesh_detail, etc.) remains untouched.
8. Replaced an invented motive in the atmospheric_filmic.frag contrast-
pivot comment ("rounded up for a stronger gamma-space contrast
feel") with the actual reason: the previous 0.5 was simply the [0,1]
midpoint of the standard contrast formula, not a deliberately chosen
value; in linear the perceptual mid-grey is 0.18.
Verify: Release build 0 warnings / 0 errors. App hermetic-filter tests:
5970 passed / 0 failed / 0 skipped. VulkanShaderManifestTests: 7/7 pass
(retail-oracle SPIR-V byte-identical; only the atmospheric_filmic frag
manifest hash changed, no .spv bytes changed).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Closes review finding F4 (docs/research/2026-08-22-campaign-ar-review.md):
retail's main-world colour, sun rays and volumetric shafts are all
gamma-encoded display-space values (the 2013 client has no linear
lighting pipeline), but bloom thresholding, ACES (Narkowicz fit),
Rec.709 luma saturation, the contrast pivot and the vignette were all
operating directly on those gamma values, then writing the result to
the UNORM swapchain without re-encoding.
- atmospheric_common.glsl gains acdreamDecodeDisplay/acdreamEncodeDisplay
(pow(c, 2.2) / pow(c, 1/2.2)). 2.2 is the retail-era CRT/early-LCD
display-gamma assumption, deliberately not the sRGB piecewise curve,
which would claim a precision retail's authoring pipeline never had.
uAtmosphereSunColor's comment is corrected from "authored linear rgb"
to "authored display-space rgb (retail has no linear pipeline)".
- atmospheric_bloom_downsample.frag, atmospheric_filmic.frag (both the
fused-Low and non-fused paths) decode every world/ray/volumetric read
before summing/thresholding; atmospheric_bloom_blur.frag is unchanged
(it already reads the now-linear bloom buffer); atmospheric_sun_rays.frag
and atmospheric_volumetric.frag are documented as writing display-space
colour that the consumers decode.
- The contrast pivot moves from 0.5 (a gamma-space midpoint) to 0.18
(linear mid-grey, the standard 18%-grey-card exposure convention).
The final filmic output is clamped in linear, then re-encoded before
the UNORM write.
- Bloom threshold/knee are re-derived for linear light: the pre-VM3
gamma-space pair was threshold 1.0 / knee 0.45, i.e. a soft range of
[0.55, 1.0] in gamma. Decoding both ends with the same 2.2 assumption
gives decode(1.0) = 1.0 (threshold unchanged) and
decode(0.55) = 0.55^2.2 ~= 0.27, so linear knee = 1.0 - 0.27 ~= 0.73.
Replaced the inline 0.45f literals with named constants
BloomThresholdLinear = 1f / BloomKneeLinear = 0.73f on
AtmosphericPostProcessGraph. bloom-strength's 0.65 default is
untouched.
- Exposure stays at its accepted 0.80 default: in linear,
encode(acesFitted(0.80 * decode(0.46))) ~= 0.50, reproducing the same
accepted midtone the old gamma-space pipeline produced as 0.51 for the
same 0.46 input (0.46 * 0.80 fed straight into acesFitted, no
decode/encode). Highlights now retain more (gamma 0.9 input moves from
~0.74 to ~0.85 through the full pipeline) and blacks deepen slightly
(gamma 0.1 moves from ~0.09 to ~0.05) — the owner's visual gate judges.
- Added AtmosphericColorPipeline, a CPU mirror of the GLSL decode/encode/
ACES/grade/filmic math (line-for-line, with a header comment requiring
it stay mirrored), and AtmosphericColorPipelineTests: neutral-preset
identity within half an 8-bit step for a 0..255 grey sweep (proving the
neutral preset is numerically the pack-off image), decode/encode
round-trip within 1e-6, monotonic-in-exposure, the pinned midtone/
highlight/shadow numbers above, and the bloom-knee derivation.
- Added a shader-source pinning test so a future edit cannot silently
drop the colour-space conversions: atmospheric_filmic.frag must
contain exactly one acdreamEncodeDisplay( call in main()'s output,
atmospheric_bloom_downsample.frag must contain at least three
acdreamDecodeDisplay( calls.
- Regenerated SPIR-V (tools/compile-shaders.ps1, glslc from the
installed Vulkan SDK). Only atmospheric_bloom_downsample.frag.spv and
atmospheric_filmic.frag.spv changed in bytes; every other pack shader
that includes atmospheric_common.glsl recompiled to a byte-identical
binary (the new decode/encode helpers are unreferenced dead code for
them). VulkanShaderManifestTests' retail-oracle SHA-256 set
(mesh_modern, terrain_modern, mesh_detail, etc.) is untouched and
still passes — the retail default path did not change.
- Docs: noted the linear-light move in the AR plan's Slice 1 section,
and added a "Colour space" section to the render-pack ABI doc
(docs/render-packs/semantic-bindings-v1.md) naming which inputs are
display-space and pointing at atmospheric_common.glsl as the
reference implementation. No ABI version bump — the binding layout
is unchanged.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fixed step-count volumetric ray marches (Low/Medium presets, e.g. 40 steps)
sample the same t values at every pixel, so a shadow-cascade edge crossing
lands at the same march step across a whole neighbourhood of pixels and
reads as visible concentric banding around light shafts. Offset each
pixel's march phase with interleaved gradient noise (Jimenez 2014,
fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056,
0.00583715))))) instead of the fixed 0.5 sub-step center: t = (step + ign)
/ steps. This decorrelates the step boundaries across pixels, turning the
banding into fine per-pixel noise that the existing quarter/half-res
upsample already averages away. Pack-on only — atmospheric_volumetric is
never used on the retail default path; extinction, colour, and early-outs
are unchanged.
Regenerated SPIR-V via tools/compile-shaders.ps1: only
atmospheric_volumetric.frag.spv and shaders.manifest.json's source hash
for that stage changed.
Also files #421 (docs/ISSUES.md) for Campaign AR review finding F7: the
directional-shadow pass's DirectionalShadowTransformBufferSet duplicates
the main pass's N.5 instance SSBO instead of binding it, to be resolved
once the planned GPU-culling step for shadow cascades lands. Updates the
VM5 ledger row and outcome note in
docs/plans/2026-08-22-visualmaster-campaign.md.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Opus narrow re-review of ae651312: APPROVE. Closes its three residuals:
- AP-232 filed: retail's single-pass stage-1 OUTPUT alpha
(MODULATE(TEXTURE, CURRENT) @0x0059c549) is the blend weight for a
translucent subset; acdream's two-draw model is exact for opaque
subsets (fog identity pinned) and a bounded weight difference on
translucent ones. Distinct from AP-34 (queue order). Owed since
05970306.
- TerrainAtlas.DetailSamplerDescription names the production sampler
(WRAP/LINEAR x3 per ACRender::SetDetailSurfaceInternal @0x006b6280);
the test now asserts that constant's properties instead of a
test-local copy.
- Plan VM1 section: fragment now described as fogged; VM1 marked CLOSED
with the Holtburg measurement (+2.17/+0.57/+0.16 vs predicted
+2.2/+0.66/+0.16) and the detail-on cost (+0.3-0.5 ms CPU at Arwic).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Opus dual-lens review of 05970306 + 388457a7 (APPROVE WITH FIXES). Four
items, all landed:
1. FOG (behavioural). Retail's D3D fixed-function fog stage runs AFTER the
texture-stage pipeline, so the detail contribution must be fogged, not
just the base. mesh_modern.frag already fogs the base colour
(applyFog(rgb, vWorldPos)) before mesh_detail's replay draws over it;
mesh_detail.frag previously emitted raw detail.rgb, understating fog by
f*a*(fog-detail). Fix: mesh_detail.vert now outputs vWorldPos (mirroring
mesh_modern.vert); mesh_detail.frag declares the identical SceneLighting
UBO and applyFog function (copied verbatim, same binding/std140/math) and
fogs detail.rgb before emitting it. This collapses algebraically to
retail's fog-after-combine order:
(1-a)*mix(base,fog,f) + a*mix(detail,fog,f) = mix(lerp(base,detail,a),fog,f)
RetailDetailTextureContract gains ExpectedFogged(base,detail,opacity,fog,
fogFactor); RetailDetailTextureContractTests pins the identity across 200
random samples within 1e-6.
2. EnvCellRenderer.Rhi.cs's DrawEnvCell-category comment still said "apply
the 10-50 m positive-view-depth fade" — a stale claim from before VM1
removed the fade. Replaced with the mip-chain attenuation statement that
mesh_detail.vert's header comment already carries.
3. Added the test the VM1 contract required but never had: TerrainAtlas
.TryCreateDetailTexture uploads a full mip chain (MipLevelCount ==
RhiWorldTextureArray.MipLevelsFor(w,h), GenerateMipChain called) and
registers with the repeat/linear world sampler, not single-level or
clamped. Drives the private method directly (reflection) against a
synthetic PFID_A8R8G8B8 RenderSurface through a minimal in-memory
IDatReaderWriter fake, so the lane stays hermetic (no installed DAT).
4. #226 pseudocode note: noted that retail's stage-1 OUTPUT alpha
(MODULATE(TEXTURE, CURRENT), 0x0059c549) — the framebuffer blend weight a
delayed-alpha subset composites with — is not modelled; acdream instead
draws a second pass weighted by detail.a*diffuseAlpha. Identical for
opaque subsets, a bounded difference on translucent building/EnvCell
subsets already covered by the existing AP-34 shared-alpha-queue
divergence row. Also qualified the tmpmaterial.Diffuse.a = 1f (0x0059cb99)
citation to name its exact branch (burnedInStaticLights < 0 &&
*(render_device+0x7e4) == 0); the other branch leaves diffuse FromVertex,
but the opaque->1 / fading->opacity mapping still holds either way.
Nit also folded in: EnvCellRendererTests' new SubmitRhi instance-alpha test
is now a [Theory] over WbRenderPass.Opaque and .Transparent, pinning the
bind-before-first-draw invariant on both passes.
Regenerated mesh_detail's committed SPIR-V and the shader manifest
(tools/compile-shaders.ps1); no other shader pair changed.
Verified: dotnet build AcDream.slnx -c Release (0 warnings, 0 errors);
dotnet test on AcDream.App.Tests (Release, hermetic lanes) green, including
the shader manifest tests explicitly; AcDream.Core.Tests unaffected/green.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
EnvCellRenderer.Rhi's SubmitRhi bound StorageInstances/StorageBatches/
StorageClipSlots/StorageGlobalLights/StorageInstanceLightSets every frame
but never GpuBindingModel.StorageInstanceAlpha (binding 7) — the SSBO
mesh_modern.vert reads as instanceAlpha[instanceIndex] (vOpacityMultiplier,
#188) and, as of Campaign VM VM1 (05970306), mesh_detail.vert now reads the
same way (vDetailOpacity). Without a bind of its own, both the interior
shell pass and the interior detail replay read whatever section
WbDrawDispatcher's own SubmitRhi last bound in the same pass — an unrelated
object's opacity array, indexed by these EnvCell instance ids.
This predates VM1 (6c79d35c has the same omission on the mesh_modern side);
VM1 must not widen a latent defect by adding a second unconditional reader
of the same unbound slot.
Fix, root cause, no guard: EnvCellRenderer now owns _instanceAlphaData, a
grow-only float[] parallel to _gpuInstanceTransforms (same pattern as
_clipSlotData/_lightSetData), filled with the constant 1.0f every frame —
EnvCell shells have no #188 TransparentPartHook translucency fade (that
mechanism fades object PARTS, never cells) — and bound at
GpuBindingModel.StorageInstanceAlpha alongside the renderer's other
per-frame ring sections, before any draw in the pass.
Test: EnvCellRendererTests.SubmitRhi_BindsConstantOneInstanceAlphaBeforeAnyDrawInThePass
drives SubmitRhi directly (reflection, mirroring the file's existing
private-method test pattern) with N seeded cell instances and one real
draw command, then asserts against RecordingGpuDevice that
StorageInstanceAlpha is bound with exactly N floats all equal to 1.0f, and
that the bind precedes the pass's first MultiDrawIndexedIndirect call.
Verified failing (StorageInstanceAlpha was never bound) with the fix
temporarily reverted, then passing restored.
Verified: dotnet build AcDream.slnx -c Release (0 warnings, 0 errors);
dotnet test on AcDream.App.Tests (Release, hermetic lanes) green,
5960/5960 (5959 baseline + 1 new test).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
VM2's live cdb read against the PDB-paired retail client (GUID
9e847e2f-777c-4bd9-886c-22256bb87f32) proved
m_caps.bCanDoSinglePassDetailing = 1 and trysinglepass = 1 on real hardware,
so D3DPolyRender::RenderMeshSubset (0x0059ca10) never falls back to the
two-pass framebuffer blend the earlier #226 port reproduced. Every loaded
CGfxObj sets use_built_mesh = 1 (CGfxObj::InitLoad 0x005346b0), so buildings
and EnvCells always take the single-pass texture-stage combine set up in
D3DPolyRender::SetSurface (0x0059c4d0):
result = lerp(base * diffuse, detail.rgb, detail.a * diffuse.a)
RenderMeshSubset lights opaque built-mesh subsets with
tmpmaterial.Diffuse.a = 1, so on the live Dereth category texture
0x06006D58 (mean rgb 0.165, mean alpha 0.132) the combine works out to
~0.868 * base + 0.022 — a mild darkening, the opposite sign of the fallback
DstColor blend's brightening.
Also removes the invented 10 m / 50 m distance fade. Retail's
ACRender::get_alpha_for_z (0x006b6230) is only evaluated in
D3DPolyRender::DrawPolyInternal (0x0059d7c0, the immediate-polygon path)
and only when the static noFadeDetail (0x00820e38, initialised to 1) is 0 —
unreachable for built meshes. Attenuation is the sampler's linear mip chain
converging to the texture mean, not a scripted ramp.
Changes:
- mesh_detail.vert/.frag: drop vDetailFade and its distance term; add
vDetailOpacity mirroring mesh_modern.vert's InstanceAlphaBuf (binding 7)
read, and output detail.rgb with alpha = detail.a * vDetailOpacity under
the corrected pipeline blend.
- VulkanViewportMapping.BlendFactorsOf / GpuEnums.GpuBlendMode.RetailDetail:
SrcAlpha + OneMinusSrcAlpha instead of DstColor + OneMinusSrcAlpha.
- RetailDetailTextureContract: replaced the distance-fade constants and
FramebufferFactor with Expected(base, detail, opacity) and IsNeutral,
matching the lerp; contract tests cover zero-alpha/zero-opacity no-ops,
the measured darkening on the live category texture, and full-alpha
replacement.
- Regenerated mesh_detail's committed SPIR-V and the shader manifest
(tools/compile-shaders.ps1); no other shader pair changed.
- Docs: #226's pseudocode note, the docs/ISSUES.md #226 entry, and the
retired TS-52 divergence-register row corrected from the two-pass
DESTCOLOR description to the single-pass path and the darkening
expectation, each citing the VM2 cdb note.
Verified: dotnet build AcDream.slnx -c Release (0 warnings, 0 errors);
dotnet test on AcDream.App.Tests and AcDream.Core.Tests (Release, hermetic
lanes) both green.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
0c552eec wired the SetOmega hook and I called it done. The birds kept flapping
in place, and the user's report — "flapping and moving up and down, not
orbiting" — is what identified the miss: part animation working, root frozen.
BindLiveOwner THROWS on a zero ServerGuid, so owner.Body is only ever assigned
for server-spawned entities. Ambient flyers are DAT scenery with no ServerGuid
and therefore no PhysicsBody at all. The whole
if (owner.Body is { } body) { ... Frame::grotate ... }
block — and the omega application I added inside it — silently skipped every
object the fix was written for. It applied the mechanism to a branch these
objects never take.
So the omega now lives on the scheduler's own Owner record rather than on the
PhysicsBody, because most of this workset has no body, and the same grotate is
applied to entity.Rotation when there is none. That is not a shortcut around
the physics owner: for a DAT static the WorldEntity IS the only root retail
would be rotating.
Verified rather than assumed this time, both halves:
- StaticRenderProjectionJournal.SynchronizeActiveAnimatedSources re-projects
from the live entity every frame through
RenderTransform.FromRoot(entity.Position, entity.Rotation, entity.Scale),
so a rotated root reaches the renderer.
- Compose builds LOCAL part transforms, so the renderer composes root x part
and the offset mesh is carried around its circle.
Why it shipped broken: no test exercised a root rotation on the ServerGuid==0
branch, so applying omega body-only passed everything. The new test asserts the
rotation on the branch these objects actually take, and fails with the exact
production symptom (rotation stays identity) when the branch is disabled. Its
sibling pins the other direction — scenery without a SetOmega hook must never
acquire a spin.
Solution builds clean; 14,475 tests pass on the standard hermetic lane filter,
0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ambient flyers played their wing animation and stayed put.
A Static object whose Setup declares a DefaultAnimation joins retail's
CPhysics::static_animating_objects workset (CPhysicsObj::InitDefaults
@0x00513A7B) and is driven by animate_static_object @0x00513DF0. That function
has exactly one motion step:
CPartArray::Update(part_array, dt, nullptr); // animate
Frame::grotate(&this->m_position.frame, &this->m_omegaVector);
Note the nullptr: unlike UpdatePositionInternal @0x00512C30, which combines the
animation's accumulated frame into the object's position, the static branch
DISCARDS it. These objects cannot move by animation translation at all. The
omega vector is the whole mechanism, and one thing writes it —
SetOmegaHook::Execute @0x00526F30 -> CPhysicsObj::set_omega @0x0050F6D0.
We decoded that hook and then dropped it on the floor: IAnimationHookSink's own
docs list SetOmegaHook among the unwired ones, and PhysicsBody.Omega was
assigned nowhere outside projectiles. The scheduler's GRotate call was already
correct — it was multiplying by a permanent zero.
The hook is now applied to the owning body at process_hooks time. Retail runs
process_hooks AFTER the grotate in the same pass, so a newly-set omega first
takes effect on the following frame; our Tick/ProcessHooks split already had
that order.
Scoped from the data rather than guessed. tools/AnimHookScan (new) walks the
dat: of 2,066 animations exactly 8 contain SetOmega, and all 8 are the
DefaultAnimation of one of the 8 setups that use it. No creature animation uses
it, so this belongs precisely where body.Omega is read and nowhere else.
The same scan is why the fix is believable as FLIGHT rather than a pirouette.
Every authored omega is pure yaw, and the setups' parts sit 5.6m, 4.2m, 12m and
36.8m from the origin they spin about. Rotating a frame whose mesh hangs 12m
off-axis carries it around a 12m circle — that offset IS the flight radius. An
installed-DAT test pins both properties, because the fix is only correct while
they hold and neither is visible from the code.
Also checked and deliberately NOT conflated: CSequence::set_omega @0x005248A0
writes CSequence::omega, a different field from CPhysicsObj::m_omegaVector,
fed by the motion table for creature turning. Only the latter drives grotate.
Solution builds clean; 14,473 tests pass on the standard hermetic lane filter
plus the new installed-DAT test, 0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four defects from the first in-world look, three of them with a definite root
cause rather than a plausible one.
**The Buff button did nothing.** Not a hit-testing problem -- the pointer found
the button perfectly. UiRoot's press handling asks the pressed widget whether
it owns the pointer; a widget that does not claim the press falls through to
"move the ancestor window", and a window drag returns early on release without
ever emitting a Click. UiButton and UiClickablePanel both override
HandlesClick for exactly this reason; UiSimpleButton never did. Latent since
that class was written, and invisible until it was put inside a draggable
window -- which is precisely what a markup plugin panel is.
Found by reproducing it headlessly through the real UiRoot dispatcher rather
than by reasoning about it: MarkupPanelClickTests drives press-and-release over
the button and asserts the bound action ran, with a separate test asserting the
pointer finds the button at all, so a future failure says which half broke.
My earlier guess -- that a modal at character select was swallowing the click
-- was wrong, and the screenshot of the panel live in world disproved it.
**"0 trained skills".** The skill-name table was read in OnLoad *before*
GameWindowCompositionPipeline.Run, which is what publishes the DAT collection,
so _dats was still null, the whole block was skipped, and the surface reported
an empty skill list with nothing to explain it. Bound in PublishDatCollection
instead -- the moment the data exists -- so it cannot run early again whatever
the phase ordering does, and a genuinely missing SkillTable now says so.
**Plugin text used the development bitmap font.** UiLabel and UiSimpleButton
gained a DatFont, and MarkupDocument now takes the retail interface font from
the host, so plugin panels render through the same glyph path (including
retail's two-plane outline) as authored panels.
**MossTank now writes to chat.** New BCL-only IPluginChat routes to retail's
ClientLocal log type (0x1A) -- the channel the client uses for its own notices,
local to this client, so a plugin cannot speak in the player's name. MossTank
announces the start, the finish with a cast count, and a stall.
Not addressed here: the cursor showing blue rather than amber. Traced but not
fixed -- CursorFeedbackController picks the cursor family from combat mode, and
CombatMode.Magic selects the blue Magic cursor where Default is amber. That is
a combat-mode question, unrelated to this change, and worth its own look rather
than a speculative fix folded in here.
Solution builds clean; 14,437 tests pass on the standard hermetic lane filter,
0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reworks MossTank against user feedback and the Virindi Tank feature docs
(virindi.net is reachable again over https with a self-signed cert; the
research doc's "unreachable" note is stale).
VTank's stated default is the spec: "automatically buffs every Attribute and
Skill you have trained", and "all buff spells are recast when they go below 5
minutes". The previous pass buffed the whole spellbook and refreshed at 60s;
both are corrected.
The hard problem was working out WHICH stat each buff raises. The client's
spell table has no such link -- it arrives from the server with the
enchantment -- and the naming is too irregular to infer: Invulnerability
raises Melee Defense, Impregnability raises Missile Defense, Fealty raises
Loyalty, Sprint raises Run, Arcane Enlightenment raises Arcane Lore, and the
line called Willpower raises the attribute named Self. Any name-matching
scheme dies on that last one.
Retail states it outright in each spell's own description ("Increases the
caster's Life Magic skill by 10 points"), so BuffProfile derives the whole
mapping from shipped data at runtime. It also carries the one alias the data
needs: the spell text says "Assess Monster" where the skill table says "Assess
Creature", and without that the skill silently never matches.
Two data facts that would each have caused a real bug, found by dumping the
spell table rather than assuming:
* Family is NOT a spell-line identity in general. Retail groups the
instantaneous vital transfers by SOURCE vital, so family 89 holds both
"Stamina to Health" and "Stamina to Mana". Picking the strongest tier in a
family would convert into the wrong vital about half the time. Buff lines
group by family (correct for duration buffs, which is retail's own stacking
bucket); the conversions are found by name stem instead.
* Instantaneous spells have no duration and must be excluded from buff lines
entirely, or they are treated as buffs that never appear to land.
Tier selection now follows the character's skill in the casting school against
the spell's difficulty (VTank's SpellDiffExcessThreshold-Buff), which is why
PluginSpellInfo gained School as a SKILL id -- MagicSchool is retail's 1-5
school enum, not something a character trains.
Mana upkeep is the loop asked for: convert stamina to mana when mana is low,
Revitalize when that leaves stamina too low to convert, and refuse to drain
stamina past a floor. Unknown vitals read as zero and are treated as "no
information" rather than "empty", so it will not cast on a healthy character.
Panel no longer shows at character select. IsAvailable is now the runtime's
own lifecycle state rather than a proxy, and markup gained visible="{Binding}"
plus UiElement.VisibleSource -- evaluated before the visible gate, because
TickSelfAndChildren returns early when hidden and an element could otherwise
never un-hide itself.
Also: a generated SpellId enum of all 6,266 spells (tools/SpellDump --enum),
generated from portal.dat rather than copied, so it cannot drift and carries
no third-party licence; skill and spell names now come from the retail tables
for display; and the Buff click logs unconditionally, so "nothing happened"
can be told apart from "the click never arrived".
Solution builds clean; 14,433 tests pass on the standard hermetic lane filter,
0 failures, including 21 covering the buff profile, tier selection and mana
loop.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First consumer of acdream's plugin automation surface, and the first slice of
the VTank-class plugin milestone
(docs/research/2026-07-29-vtank-plugin-automation-requirements.md).
MossTank shows a panel with a Buff button; clicking it casts every self-buff
the character is missing, skips what is already in force at an equal or higher
tier, and refreshes what is nearly expired.
The host/plugin line is the load-bearing decision here. The host publishes
spell DATA -- family, tier, difficulty, mana, duration -- plus a cast
primitive with a preflight gate. The plugin owns the POLICY. That is the
architectural conclusion the requirements research reached: VTank's engine
lived in plugin-land, built on Decal's primitives, and baking "best buff for
skill X" into the host would start pulling the engine inward one convenience
at a time.
Why the plan is driven off the spellbook rather than off trained skills, which
is the obvious reading of "buff every trained and specialised skill": the
client cannot honestly make that mapping. The link between a spell and the
stat it modifies arrives from the SERVER in the enchantment message and is
absent from the client's own spell table. What the client does know is which
spells the character has learned -- and a character only learns buffs for the
skills they use, so the spellbook reaches the same set without inventing a
mapping the client has no grounds for.
Surface added, all BCL-only so Plugin.Abstractions keeps its zero project
references:
* ICharacterInfo, ISpellCatalog, IMagicCommands, grouped behind one
IAutomationSurface so IPluginHost grows by one member rather than three.
* IEvents.Tick. Automation is sequences, not single calls -- a buff pass casts
several spells and must wait between them. Without a host tick a plugin
would need its own timer thread re-entering the host off its update thread.
* NoOpAutomationSurface for hosts with no live session, so a plugin keeps one
code path and checks IsAvailable.
Markup gained <button> and <label>; it previously supported only <meter>, with
a comment promising the rest. Buttons bind onclick to an Action property and
FAIL THE PANEL LOAD if it does not resolve -- a silently dead button is worse
than a panel that refuses to load, because the user clicks and there is
nothing to diagnose. Labels bind through a Func so a status line tracks its
binding instead of freezing at build time.
Enchantment reads use EnchantmentsInEffectSnapshot rather than the raw active
set: retail leaves a weaker same-family enchantment in the registry while a
stronger one is in force, and a plugin asking "am I buffed?" means in force.
BuffPlan is a pure function of (known buffs, active enchantments) precisely so
it can be tested without a session; 9 tests cover tier supersede, the
family-0 no-stack bucket that must not be de-duplicated, expiry refresh, and
plan stability across the rebuilds the tick loop performs.
Solution builds clean; 14,421 tests pass on the standard hermetic lane filter,
0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The client shipped with a PE icon Explorer showed and a window that did not:
launched from the launcher it still drew the stock Windows application icon.
Silk's Window.Create only builds the managed object. IWindow.Initialize is
what, in Silk's own words, "creates the window on the underlying platform".
Applying an icon before that throws:
after Window.Create : IsInitialized = False
SetWindowIcon BEFORE Initialize : THREW InvalidOperationException:
Window should be initialized.
after Initialize : IsInitialized = True
SetWindowIcon AFTER Initialize : returned without throwing
What made this quiet rather than obvious is the fallback. GLFW registers its
window class against a resource named GLFW_ICON and, not finding one, uses
IDI_APPLICATION - the generic Windows icon - rather than the executable's own.
So the PE icon kept showing on the file while the live window lost it, which
reads as a packaging problem and is nothing of the kind. The launcher was
unaffected because Avalonia takes a different path entirely, and that
asymmetry was the tell.
Apply now happens in OnLoad, beside the other window-dependent startup work,
and refuses with a message naming the ordering requirement if it is ever
called on an uninitialized window - the previous generic catch reported
"Window should be initialized" to a stderr nobody reads, which said nothing
about icons.
The regression guard reads the compiled call graph, because this is an
ordering edge with no observable return value: OnLoad must call Apply, and no
method that calls Window.Create may. Verified by reintroducing the bug and
watching it fail, then restoring the fix and watching it pass.
Solution builds clean; 14,408 tests pass on the standard hermetic lane filter,
0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
acdream had no application icon on either executable. Two marks now ship,
built from the game's own material rather than drawn freehand:
* Client - the retail mosswart head. Not an illustration of one: the actual
creature mesh (Setup 0x02000B4F part 14, skin atlas 0x05001E11,
ClothingBase 0x10000344) read out of client_portal.dat through acdream's
own GfxObjMesh/SetupMesh port, then smoothed, lit and graded. Palette
values are sampled from that texture, including the mustard belly the
Mosswart lore calls a "foul yellow".
* Launcher - a forged ring enclosing a barbed crescent, rebuilt from
measurements of the retail wordmark and the acclient.exe icon resource.
An original construction in the same visual language, not a copy of the
trademarked logo. Its warm field matches the retail client icon.
Three techniques carry the render quality, all in tools/IconForge:
* PN-triangle tessellation (smooth.py). The retail head is 104 triangles
and renders faceted. Each triangle becomes a cubic Bezier patch built
from its own corner positions and normals, so the silhouette genuinely
rounds rather than merely shading smoothly - and it needs no mesh
connectivity, which matters because UV seams would otherwise pull apart.
Normals are welded across coincident positions first, but only within a
crease angle, so ear fins and tusk edges stay sharp.
* Matcaps (ring.py). A Lambert rasterizer cannot produce chrome, because
chrome is almost entirely reflection and there is nothing here to
reflect. Sampling a lit-sphere image by the camera-space normal is the
standard stand-in for an environment map.
* Distance-transform bevelling (chisel.py). Flat shapes become chiselled
metal by treating distance-to-edge as height. The height field is
blurred before differentiating; without that the medial axis of each
stroke shows through as a hatched ridge.
Two facts worth recording, both discovered the hard way. Creature Setups
define no upright pose in PlacementFrames, so the exporter must be handed
the weenie's MotionTable id or all 17 parts stack on the origin. And a
mosswart's eyes sit on the sides of the skull like a frog's, so a dead-on
frontal turns them edge-on and the face stops reading as a mosswart at all;
the hero angle is az 266 / el 32.
Wiring: <ApplicationIcon> gives each executable its PE icon. The client's
runtime window icon is embedded rather than copied beside the binary - a
window icon has no sensible fallback if the file goes missing, and
embedding survives single-file publish. WindowIconLoaderTests guards the
resource names, which are coupled to LogicalName in the csproj by string
alone and would otherwise fail only as a silently icon-less window.
Both halves of the pipeline are deterministic and reproduce the committed
PNGs byte-for-byte, so an accidental edit shows up as a diff.
Solution builds clean; 14,378 tests pass on the standard hermetic lane
filter, 0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-up to the retail-text fix. "Green under sv-SE" is not the same as "runs
on any locale", so this establishes the latter by running the suite under
cultures chosen to break different things, and fixing what they broke.
ar-SA found a genuine defect the Swedish runner cannot see: the resolution
parser read "1920x-1" through the ambient culture, and ar-SA's negative sign is
not ASCII '-', so the parse failed and the height silently became 0 instead of
-1. Both copies of that parser (App settings targets and the UI settings store)
now parse invariantly.
Audited every remaining culture-sensitive operation in src/ rather than fixing
only what a test happened to catch:
- Numeric Parse/TryParse with no IFormatProvider: 11 sites, all reading
MACHINE-readable input — env vars (ACDREAM_LIGHT_DEBUG, ACDREAM_NET_DROP_*,
streaming/quality knobs), CLI arguments, "1920x1080" settings keys, a chat
command's price argument, and the launcher's bake thread count, which is
handed straight to a child process command line. All pinned to
InvariantCulture.
- ToUpper()/ToLower() with no culture: none. The Turkish-I class was already
clean, and tr-TR confirms it.
- StartsWith/EndsWith/IndexOf(string) with no StringComparison: one —
ChatInputParser's "@" prefix test, which is a culture-sensitive comparison
for a single ASCII character. Now the ordinal char overload.
Verified: 13,958 tests pass identically under the machine default, sv-SE,
tr-TR, ar-SA, and de-DE. (The two launcher test assemblies are excluded from
this run only because a running acdream-launcher.exe holds its own binary; the
one launcher change here is the thread-count parse.)
Dates remain on the current culture by intent, unchanged from the previous
commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Session teardown (PlayerModeController.Exit/ResetSession ->
CameraController.ExitChaseMode) fell back to the dev free-fly camera, and
CameraPointerInputController.ApplyCursorForCameraMode faithfully applies
CursorMode.Raw (GLFW disabled cursor: hidden + captured) for fly mode —
so the character-select screen after an in-world logoff had no mouse.
Fresh boot starts in Orbit and never fires a mode change, which is why
only the post-logout path was affected.
Teardown now lands on Mode.Orbit — the exact state a fresh boot presents
at character select — and always notifies, so the pointer controller
restores CursorMode.Normal even when torn down from the dev fly camera.
The dev fly<->chase flow is untouched (it rides ToggleFly, never
ExitChaseMode).
Proven live both directions with a driven logout (UI probe 0x100000FA ->
dialog accept 0x17) under Win32 GetCursorInfo sampling: before, flags
flipped 1->0 exactly at the roster re-push that re-shows character select
and stayed hidden; after, zero hidden samples across the full timeline.
Files #415: the UI-probe 'wait world-visible' verb reads the reset
transit snapshot and is dead after reveal completion (test apparatus
only).
App tests 5564/3 skips (+3), Runtime 1756/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Retires AD-74 (Exit to Character Selection 'behaves as Exit Game') and
files AD-110 (the composed handoff edge) — register rows in this commit.
Retail derivation (named decomp):
- gmGamePlayUI::UseTime @0x004EA3A0: confirmed Yes drains into
CPlayerSystem::LogOffCharacter(0) when grounded (transient_state &
CONTACT); the grounded three-way branch now also covers the
indicator-bar end-session control (it was Options-only).
- CPlayerSystem::LogOffCharacter @0x00563520: SaveToServer FIRST (the
existing pre-logoff flush hook), then RequestLogOff @0x00562DD0:
'Logging off...' chat (type 0), 0xF653 via Proto_UI::LogOffCharacter
@0x00546A20, logOffRequestTime = now + 3.0 (+20.0 when
IsPlayerKiller @0x0058C910 — PWD bits 0x20|0x2000000), and
CommandInterpreter::HandleLogOff @0x006B3330 -> Disable.
- The log-off ANIMATION is server-driven: ACE broadcasts
MotionCommand.LogOut (0x1000011E, Player.cs:596 SendMotionAsCommands)
and it plays on the local player through the existing inbound
unpack_movement funnel during the 3 s hold — retail plays nothing
locally; Disable() is the whole client-side effect.
- gmSmartBoxUI::UseTime @0x004D6E64: hold elapsed ->
BeginTeleportAnimation(TAS_WORLD_FADE_OUT) @0x004D6E83 (enter cue
@0x004D638E, unconditional) -> TunnelFadeIn -> Tunnel. The tunnel
plays the SAME forward 40 fps animation; nothing renders backwards,
and NO exit cue ever fires on logout (the char-select swap preempts
the TunnelContinue/FadeOut tail).
- Inbound 0xF653 echo (dispatch case 3 @0x0055C963) ->
ExecuteLogOff @0x0055D780: world teardown with the LOGON CONNECTION
KEPT (ExitWorldDisconnect @0x00541E00 removes every connection
except logonRecID_ — one connection against ACE) and
Proto_UI::SetEventCounter(0) @0x00541E79; the fresh CharacterList in
the same batch re-shows character management (gmGamePlayUI::Update
@0x004E9CD0 -> QueueUIMode(0x1000000a)). ACE mirrors it:
SendFinalLogOffMessages (Session.cs:249) sends 0xF653 + CharacterList
+ ServerName >=6 s after the request and leaves the session
AuthConnected — a second EnterWorld needs no re-handshake.
Implementation:
- RuntimeWorldTransitState: the canonical logout lifecycle
(Requested/PresentationActive/Confirmed, retail 3 s/+20 s holds,
cancel/reset/ownership convergence).
- WorldSession: RequestCharacterLogOff (non-blocking 0xF653),
IsCharacterLogOffConfirmed, ReturnToCharacterSelect (InWorld ->
InCharacterSelect + game-action sequence reset; transport untouched).
- LiveSessionController: BeginCharacterLogOff (flush-first request) and
CompleteCharacterLogOff — the return-to-selection transaction
(ReconnectCore minus the transport swap: retire the world
generation's routes, host reset, state flip, fresh generation
re-bind, roster re-applied from the pushed CharacterList; failures
degrade to the full StopCore teardown).
- RuntimeLocalPlayerMovementState.DisableCommandInterpreter +
DispatcherMovementInputSource gate: retail's Disable() — held keys
produce no movement while the server LogOut motion plays; cleared by
the generation reset.
- LocalPlayerTeleportController: the logout pump as the third arm of
the one wormhole machine (request/hold/wormhole/confirmed handoff;
teleport starts refused during logout; the handoff runs the session
transaction whose world reset retires the tunnel as the fresh
selection state re-shows the character screen).
- UI: both end-session surfaces share the retail three-way grounded
gate and now run the REAL flow; Options' Exit Game keeps the app
exit (window close -> the existing graceful-shutdown logoff).
Tests: +5 transit lifecycle, +4 session transaction, +7 logout pump.
Runtime 1756/0 (baseline 1747), App live-DAT 5523/3 (baseline 5512/3
+ 11 this round), Core.Net 1004/0, full solution green (0 failures).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
USER-DIRECTED deviation from retail (register row AD-109, same commit):
retail presents the empty pre-player gameplay screen — black behind the
retained UI — from the Enter click (CPlayerSystem::LogOnCharacter
@0x0055F890 -> CM_Login::SendNotice_BeginEnterWorld @0x006AD810, UI mode
0x10000008) until CreatePlayer raises SmartBox::teleport_in_progress
@0x00451C20 and gmSmartBoxUI::UseTime @0x004D6EAB begins TAS_TUNNEL. The
user prefers the tunnel to cover that whole wait.
- ILocalPlayerTeleportNetworkSink.ArmLoginTunnel: begins the login
wormhole presentation at the Enter click, consuming the sequencer's
begin-edge events SYNCHRONOUSLY (the Enter command blocks the update
thread for the whole ServerReady round trip, so a deferred first tick
would leave exactly the black window this deviation removes). The
enter cue plays at the click: retail's own rule is cue-at-animation-
begin (Sound_UI_EnterPortal @0x004D638E, unconditional inside
BeginTeleportAnimation), and the animation begin moved to the click.
- Armed pre-reveal pump: tunnel animates across the round trip
(worldReady pinned false, sequencer holds in Tunnel); the hold clock
accumulates from the click.
- Adoption: the Runtime login reveal ADOPTS the running presentation
(no re-Begin, no second cue); rejected EnterWorld (lifecycle back to
AwaitingSelection) disarms and retires the tunnel.
- Wired at the ONE host edge every entry route shares:
ILiveSessionLifecycleHost.ApplySelectedCharacter (direct connect,
roster Enter, enter-after-create) via
LiveSessionSelectionBindings.ArmLoginTunnel (default no-op keeps
headless and every existing construction site unchanged).
- ILocalPlayerLoginLifecycleSource: typed seam (not a stored delegate —
the frame-phase owner delegate-field guard) projecting the Runtime
character-selection lifecycle for the disarm edge.
- Frame contract update: [login-frames] over a login is tunnel -> world
from the click — no void, and no black between click and world.
Tests: 4 new armed-tunnel tests (arm/adopt/disarm/frame-shape); App
suite live-DAT 5516 passed / 3 skipped (baseline 5512/3 + 4 new).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The login tunnel now covers from the first world-facing frame (the
sky-void backdrop can never present pre-tunnel) and holds through an
atomic tunnel-to-world swap at reveal completion — the void is
structurally unreachable on both edges, pinned by frame-sequence tests
across WorldSceneRenderer/WorldRevealCoordinator/LocalPlayerTeleport-
Controller/RuntimeWorldTransitState. Vitals detail icons draw at their
authored centered offsets in both stacked and side-by-side layouts.
Implemented and live-probed by the fix agent; finalized by the lead
after the agent parked post-verification (gates re-run green:
App 5512/3, Runtime 1747/0).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>