Commit graph

173 commits

Author SHA1 Message Date
Erik
54d17eb446 perf(rendering): retain packed classification
Retain appearance classification per projection while refreshing transforms, clip slots, lights, selection lighting, and group instance payloads each frame. Incomplete resources and active animation remain on the live classification path, and only an exact full packed comparison acknowledges scene dirtiness.
2026-07-25 02:46:40 +02:00
Erik
f9829d5f9e fix(rendering): normalize empty dispatcher evidence 2026-07-25 02:17:42 +02:00
Erik
06e7754619 fix(rendering): match empty packed submissions
The production dispatcher forces transparent deferral off when no top-level VAO was resolved, even if the frame alpha queue is active. Carry the same per-draw VAO sentinel into the compare-only packed classifier so its empty-submission state is exact rather than sampling only the later queue state.
2026-07-25 01:48:02 +02:00
Erik
29195fb255 feat(rendering): prove packed dispatcher output parity
Build a compare-only dispatcher classifier from RenderFrameView and compare complete opaque, alpha, selection, clip, light, texture, transform, and draw payloads against the accepted path. Preserve retail's stable equal-CYpt ordering with explicit draw-local submission ordinals so material-group history cannot affect alpha ties.
2026-07-25 01:31:56 +02:00
Erik
58b712c6ec refactor(rendering): isolate dispatcher candidate values 2026-07-25 01:06:57 +02:00
Erik
f9b68f8f2a feat(rendering): complete current-path render referee
Extend the non-drawing oracle through ordered PView routes, dispatcher visibility and final instance payloads, and accepted retail selection parts. Lifecycle artifacts can now referee the later shadow scene without influencing production visibility or draw decisions.
2026-07-24 21:28:12 +02:00
Erik
91e82c3c68 fix(streaming): prepare quiesced destination entities
Separate loaded spatial residency from the world presentation gate so destination live objects acquire mesh owners behind portal space while drawing and simulation stay quiesced. Prevent unowned CPU-cache hits from recreating stale GPU staging work.

Co-authored-by: Erik Nilsson <erikn@users.noreply.github.com>
2026-07-24 20:21:57 +02:00
Erik
e991eeca34 fix(render): make reveal warmup mutation-resumable
Retain composite candidate progress across live membership generations and run a stable follow-up pass after churn, preventing ACE object-stream updates from resetting portal readiness forever.

Co-authored-by: Erik Nilsson <erikn@users.noreply.github.com>
2026-07-24 19:54:40 +02:00
Erik
98f1ac8934 perf(streaming): cursor publication across frame budgets 2026-07-24 19:10:18 +02:00
Erik
1853a57c12 feat(diagnostics): complete residency pressure ledger
Complete Slice D4 by adding aggregate lifecycle occupancy and traffic facts, validating physical source reports, and including decoded audio under the typed startup budget. Exercise every domain under forced pressure and retain the real cache/fence convergence gates.
2026-07-24 16:40:17 +02:00
Erik
f2644d42c2 perf(render): bound animation and alpha scratch residency
Complete Slice D3 by replacing the unbounded animation dictionary with a concurrent byte/count LRU and by putting the three retail alpha scratch owners behind one typed aggregate budget. Preserve immediate growth and draw order while reclaiming one-frame density spikes after sustained under-use. Close stale bounds-cache issue evidence without inventing a cache.
2026-07-24 16:34:28 +02:00
Erik
3e18fc2730 feat(render): unify physical residency accounting 2026-07-24 16:20:48 +02:00
Erik
f05afc07c1 perf(render): consume prepared mesh package at runtime 2026-07-24 15:07:25 +02:00
Erik
b7b9aaa9dd fix(render): eliminate EnvCell identity collisions 2026-07-24 13:44:56 +02:00
Erik
86fadf8661 refactor(content): share EnvCell geometry identity 2026-07-24 13:32:03 +02:00
Erik
7b456b49d6 perf(diag): per-frame history export + checkpoint LOH/cache counters + soak capped mode (2026-07-24 audit review)
An adversarial performance review found our own instruments cannot
measure the project's own performance gates:

- FrameProfiler aggregated CPU/GPU/alloc/stage samples into ~5-second
  windows and reset the ring buffers after each report, so route-wide
  p50/p95/p99 distributions across a whole soak could not be
  reconstructed after the fact. ACDREAM_FRAME_HISTORY=<path> now opts
  into a separate per-frame history (one record per frame, ~72
  bytes/record, accumulated in memory with zero frame-thread I/O) that
  a shutdown-only Dispose() writes as CSV. The aggregated [frame-prof]
  report format and its existing metrics are unchanged.

- The canonical checkpoint JSON tracked cache residency (entry/byte
  counts) but never LOH size/fragmentation, process-wide allocated
  bytes, or cache hit/miss/eviction traffic — a committed audit JSON
  showed 65% LOH fragmentation that no tracked instrument recorded,
  and "does a revisit portal hit or miss the caches" was unanswerable
  from an artifact alone. WorldLifecycleResourceSnapshot now carries
  loh_size_bytes/loh_fragmentation_bytes (GCMemoryInfo.GenerationInfo
  index 3), process_total_allocated_bytes (GC.GetTotalAllocatedBytes),
  and Interlocked hit/miss/eviction counters for the CPU mesh cache,
  decoded-texture cache, and the four bounded DAT-object caches
  (portal/cell/highRes/language, aggregated).

- run-connected-r6-soak.ps1 unconditionally forced
  ACDREAM_UNCAPPED_RENDER=1 with no capped mode, while its sibling
  lifecycle-gate script correctly gated it behind a switch. Added
  -Uncapped (default capped, matching the sibling script's pattern),
  fixed the stationary dwell (12s -> 26s, past the 25s
  LiveEntityLivenessController deadline the adjacent comment already
  cited), and now write an env-disclosure.json into the automation
  artifact directory before every launch listing every ACDREAM_* var
  the script sets plus -Uncapped, since the prior audit could only see
  ACDREAM_DUMP_MOVE_TRUTH and nothing else was ever recorded anywhere.

Cache counters are wired via the existing composition path
(ObjectMeshManager already owns the CPU mesh cache and the mesh
extractor directly; content.Dats is threaded into
WorldLifecycleResourceSnapshotSource the same way every other
composition consumer receives it). The DAT-object cache lives behind
IDatReaderWriter, a third-party interface from the DatReaderWriter
package that cannot be extended; RuntimeDatCollection (the one
production implementation) exposes the aggregate stats directly and a
pattern match reads them, degrading to zero for any test double —
no new static registry was introduced (GpuMemoryTracker remains the
one precedented process-wide static).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 1da2c33c875b41fa383dd79694ee2765f0e21896)
2026-07-24 12:00:30 +02:00
Erik
a4b1214e0f chore(render): delete dead ParticleBatcher chain (2026-07-24 audit review)
ParticleBatcher/ParticleEmitterRenderer/ActiveParticleEmitter (src/AcDream.App/Rendering/Wb/)
are an earlier WorldBuilder-derived particle-preview design with zero live call
sites: ParticleBatcher.Begin/AddParticle/Flush/End are only called from
ParticleEmitterRenderer.Render, which is only called from
ActiveParticleEmitter.Render, and `new ActiveParticleEmitter` has zero call
sites anywhere in the repo (verified by grep across src/tests/tools). The only
wiring was OpenGLGraphicsDevice.ParticleBatcher (property + null-init + Dispose)
and a single assignment in WbMeshAdapter.cs.

ParticleBatcher's constructor unconditionally allocated a ParticleInstance[65536]
managed array (~3.5 MiB) plus a matching GPU instance buffer, a shader, a VAO,
and 3 more GL buffers, none tracked by GpuMemoryTracker or ever drawn from.

ParticleEmitterRenderer's per-particle-type physics (Particle::Init vector-space
resolution, CalculatePosition integration) duplicates, without retail address
citations, what src/AcDream.Core/Vfx/ParticleSystem.cs already implements with
Particle::Init (0x0051c930) / Particle::Update (0x0051c290) citations and the
same ParticleType switch — the production path (ParticleSystem.cs +
AcDream.App/Rendering/ParticleRenderer.cs) supersedes it. No unique retail
knowledge is lost.

EmbeddedResourceReader.cs and the wb_particle.vert/frag shader sources are
deleted alongside it: EmbeddedResourceReader's own doc comment says it exists
solely so ParticleBatcher/ParticleEmitterRenderer can load WB-style shader
resource names, and GetEmbeddedResource had no other caller in the repo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 4afedafd085a5fadd1867418d3ec71610d9d15a7)
2026-07-24 11:49:46 +02:00
Erik
c2cb83d11f perf(render): gate EnvCellRenderer visibility-snapshot rebuilds on real input changes (2026-07-24 audit review)
PrepareRenderBatches rebuilt the full EnvCell visibility snapshot every
frame any indoor/building root was resolved: a Parallel.ForEach dispatch
over every GpuReady EnvCell landblock (with per-landblock locks), a fresh
outer dictionary plus one fresh inner dictionary per visible cell, a new
snapshot object, and a complete transparency rescan - all while standing
perfectly still. The NeedsPrepare flag existed since Phase A8 as the
intended rebuild gate but was never read by production code.

This wires the gate on the snapshot's actual inputs:
- landblock commits/removals (the existing NeedsPrepare flag),
- the visible-cell filter (content-compared; the caller reuses one
  scratch HashSet across frames),
- the trim window (center/radius),
- mesh render-data availability - new
  ObjectMeshManager.RenderDataAvailabilityVersion, bumped at publish,
  pending-release hide, release completion, and teardown, because the
  snapshot bakes per-cell transparency from TryGetRenderData and a
  late-arriving transparent shell must reclassify its cell,
- the camera: eye position under a 1 mm ABSOLUTE epsilon (swallows the
  documented ~36 um rest jitter, dirties on any real movement; the VP
  translation row scales with AC's ~5e4 world coordinates where a
  relative tolerance would mask sub-meter motion) plus rows 1-3 of
  view*projection (position-independent rotation x projection) under
  relative 1e-5.

Skipping is pool-safe: RenderCore re-anchors _poolIndex to the active
snapshot's PostPreparePoolIndex on every call, so consecutive Renders
without an intervening Prepare reuse scratch lists past the snapshot's
owned region exactly as within-frame passes already do. The empty-filter
branch is now also idempotent instead of allocating a fresh empty
snapshot per frame. Render still receives the current frame's
view-projection every frame (the U.4 stale-matrix rule) - only the
snapshot rebuild is gated.

Pixels must be identical; needs the standard user visual pass (dungeon +
town-near-buildings) before the change is considered accepted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 11:49:40 +02:00
Erik
6a2fe98cc4 refactor(app): compose world rendering startup
Move Region/environment, mandatory modern rendering, terrain, WB, texture, and sampler construction behind the typed Phase-4 composition boundary. Give every fallible GL constructor prefix retryable ownership so partial startup failure cannot leak or replay resource deletion while preserving the accepted render path and DAT inputs.

Co-authored-by: Codex <codex@openai.com>
2026-07-22 16:46:05 +02:00
Erik
c87b15303d refactor(lifetime): own render resource prefixes
Give terrain, sky, retained UI, portal preparation, and the update/render frame pair explicit single owners. Make shader, texture, text, bindless, and GL construction prefixes checked and retryable so partial failure cannot lose or replay resource ownership.

Co-authored-by: Codex <codex@openai.com>
2026-07-22 14:42:14 +02:00
Erik
fec0d94148 refactor(settings): own two-phase runtime settings
Move pre-window loading, startup application, live settings mutation, toon context, quality reapply, and SettingsVM loans behind one RuntimeSettingsController. Preserve retail command behavior, ordered target publication, draft semantics, and retryable failure convergence while removing duplicate GameWindow state and feature bodies.

Co-authored-by: Codex <codex@openai.com>
2026-07-22 13:30:22 +02:00
Erik
047a4c83b5 fix(interaction): bind selection to live incarnations
Carry local WorldEntity identity through render hits, lighting pulses, and deferred movement actions so GUID reuse cannot target a replacement. Reset all session-owned selection and ItemHolder state and prevent combat auto-target during teardown.
2026-07-21 07:07:58 +02:00
Erik
3718e341be fix #225: stabilize render pacing and frame CPU
Replace scheduler-quantized software sleeps with a reusable Windows high-resolution deadline timer, expose pacing in the frame profiler, and make shutdown wake every persistent mesh worker without losing the shared signal.

Preserve retail alpha order while using a stable radix, skip duplicate deferred-alpha SSBO packing, pack light sets, cache static selection descriptors, and retire historical material groups at the whole-frame boundary. The fixed dense-Caul sample improved from roughly 9-12 ms CPU to 5.3-6.2 ms without reducing visual quality.

Release build succeeds with zero warnings and all 6,300 tests pass with five intentional skips. Three independent retail, architecture, and adversarial reviews are clean; the post-review connected route remains pending because local ACE is offline.

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-07-19 06:29:30 +02:00
Erik
749e8ceeb1 fix(rendering): bound portal resource lifetime
Separate logical ownership, render publication, and GPU retirement across live entities, landblocks, particles, textures, mesh arenas, portal/UI teardown, and per-frame scratch storage. Add bounded DAT/texture caches, upload budgets, three-frame fence retirement, exact-incarnation appearance reconciliation, frame pacing, and extensive lifetime conformance coverage.\n\nThe seven-destination connected route now cuts peak working/private memory roughly in half, returns Caul to 125-153 FPS locally, and produces no WER or AMD reset.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>
2026-07-18 21:35:16 +02:00
Erik
3971997689 fix(streaming): retire stale portal-region entities
Port retail's 25-second leave-visibility lifetime over canonical live records, retaining spatially resident and owned entities while using the conservative ACE visibility envelope for nonresident records. Route expiry through the normal generation-safe F747 teardown so animations, effects, physics, and render owners unwind symmetrically.

Replace the append-only modern mesh buffer with coalescing vertex/index ranges and upload each mesh's vertices once instead of once per material. Released zero-reference meshes can now reuse GPU ranges after portal and cache churn.

A connected five-region round trip returned animation ownership to baseline, recreated the starting region on revisit, and held normal FPS. Release build succeeds and all 5,927 tests pass with five intentional skips.

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-07-18 10:02:15 +02:00
Erik
6b0472ee32 fix(rendering): port retail shared alpha list
Queue translucent world GfxObj batches and scene particles in one stable far-to-near stream using transformed DAT sort centers, then drain it at retail's landscape and final-world boundaries. Preserve authored blend, cull, lighting, opacity, and adjacent-only batching so particles behind lifestones are composited through the crystal instead of overpainting it.

Release build succeeds and all 5,914 tests pass with five intentional skips.

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-07-18 08:14:05 +02:00
Erik
ea4f52ec51 fix(selection): port SmartBox click lighting pulse
Port the retail high/low material-lighting cadence for successful world clicks, keep it instance-scoped in the modern renderer, and restore authored lighting after 0.8 seconds. Correct the selection oracle and pin timing plus per-frame buffer lifecycle with tests.

Co-authored-by: Codex <codex@openai.com>
2026-07-17 23:00:04 +02:00
Erik
146a963aeb feat(selection): port retail polygon picking and vivid marker
Replace the projected Setup-sphere rectangle and independent physics-wall ray with retail's render-coupled picker: only visible server-object parts participate, each exact drawing sphere broad-phases the camera-eye ray, and first-in-DAT-order visual polygon hits globally outrank sphere fallbacks.

Replace the devtools-only procedural triangles with the retained gameplay VividTargetIndicator using retail client-enum surfaces 1..4, radar-blip colorization, Setup selection-sphere framing, and the exact eight-pixel viewport clamp.

Release build succeeds with zero warnings and all 5,886 tests pass with five intentional skips.

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-07-17 21:32:51 +02:00
Erik
e95f55f25b fix(portal): synchronize destination presentation state 2026-07-16 21:17:13 +02:00
Erik
1e98d81448 feat(vfx): port retail hidden and teleport presentation
Preserve canonical live-object ownership across Hidden transitions and remote teleport placement so effects, collision, streaming, and targeting remain synchronized.
2026-07-14 14:59:48 +02:00
Erik
542dcfc384 feat(vfx): bind effects to live animated poses 2026-07-14 10:56:01 +02:00
Erik
b0c175afc0 fix(streaming): commit EnvCell landblocks atomically (#214)
Carry one complete cell payload with each streaming completion and publish visibility, physics, and render state on the render thread. Remove the obsolete post-readiness login reload that triggered a duplicate dungeon build.

Co-Authored-By: Codex <noreply@openai.com>
2026-07-13 18:45:24 +02:00
Erik
119a2326be fix(#193): clear InstanceGroup.Opacities each frame — stop the ~1 GB/min LOH leak
Root cause (measured, not guessed): the OOM was a MANAGED Large-Object-Heap leak
(~5 GB in 6 min of roaming; working set 1.6 GB -> 7.6 GB), dominated by ~3.8 GB of
live System.Single[]. A heap-retention analysis of a captured gcdump traced 241 of
242 giant float arrays to a single List<float>: WbDrawDispatcher.InstanceGroup.Opacities.

#188 (2026-07-09, fading doors) added Opacities as a 5th per-instance parallel list
alongside Matrices/Slots/LightSets/IndoorFlags, but left it out of the per-frame
clear loop (WbDrawDispatcher.cs:959). So Opacities.Add(1.0f) fired once per drawn
instance per frame and the list never reset — growing forever; List<float> capacity
doubling produced the observed ~128 MB / ~512 MB power-of-two arrays and OOM after
~50 min. Hit both UI builds because it's core render, and started only recently
because Opacities did not exist before #188.

Fix: extract the per-frame reset into InstanceGroup.ClearPerInstanceData() (promoted
InstanceGroup private->internal) that clears ALL FIVE parallel lists in one place, so
a future 6th list can't silently drift out of the frame lifecycle again. Line 959 now
calls it. TDD: InstanceGroupClearTests asserts every parallel list resets (RED with
the old 4-list clear, GREEN after adding Opacities.Clear()).

Static-audit note: this is exactly why we measured before fixing — the audit ranked
GlobalMeshBuffer (GPU) #1 and the entity-dict leak #3; both were wrong about the
dominant cause. The dotnet-counters (managed vs native) + gcdump retention graph
named the real one-line culprit.

Secondary (NOT this commit): _groups dictionary is never pruned (bounded by distinct
group keys, empty groups are cheap) — filed as a follow-up, not the multi-GB driver.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 14:15:10 +02:00
Erik
3284dd0aed feat(#188): fading-wall + sliding-door translucency; hold open past animation settle
Lands the fading-secret-door feature and fixes the door "flip-back" that
surfaced while testing it.

#188 — fading-wall doors (e.g. "Pedestal Weak Spot") fade their wall part
out via TransparentPartHook instead of swinging:
  - TranslucencyHookSink consumes TransparentPartHook -> TranslucencyFadeManager
    (per-(entity,part) linear translucency ramp; holds at End frame).
  - WbDrawDispatcher: new per-instance alpha SSBO (binding 7); ClassifyBatches
    takes opacityMultiplier (1 - translucency, per CMaterial::SetTranslucencySimple
    0x005396f0) forcing AlphaBlend; fully-invisible parts skipped.
  - mesh_modern.vert/.frag: binding-7 InstanceAlphaBuf -> vOpacityMultiplier ->
    FragColor.a *= vOpacityMultiplier.
  - Register AP-89: the fade multiplies sampled texture alpha, not a separate
    D3D9 material alpha channel (observably identical for texture-alpha==1 surfaces).

Door flip-back fix (affected BOTH #188 fading walls AND #187 sliding doors): a
door/wall that finished opening holds a single unchanging frame, so the
uncommitted IsEntityCurrentlyMoving cache-bypass narrowing dropped it onto the
Tier-1 static cache -- which only remembers the REST pose + opacity 1.0 --
snapping it visually shut/opaque while physics stayed open. Reverted that
narrowing: every Sequencer entity stays on the per-frame path (live pose + live
fade opacity), the known-good pre-optimization behavior. The per-frame CPU cost
that narrowing chased was a Debug-build artifact -- Release is GPU-bound
(~200 fps in Sawato, measured), so the unconditional add is free where it
matters. Left a code comment barring re-introduction.

Tests: full Core suite green (2649 passed, 2 skipped). Live visual gate PASSED --
both fading-wall and sliding doors hold open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 09:17:50 +02:00
Erik
6aabe0b5ac diag #176/#180: isolation toggles pin the residual stripes on the camera sweep, not the render
The #176 gate-2 failure ("stripes/triangles flickering when the camera
is pushed into walls; nothing when zoomed out") is NOT a render defect.
Isolation apparatus added this commit:

- ACDREAM_LIGHT_DEBUG shader modes (mesh_modern.vert/frag + uLightDebug
  upload in EnvCellRenderer/WbDrawDispatcher): 1 = ambient-only,
  2 = dynamics killed, 3 = raw vLit field (texture ignored). The
  pattern SURVIVES mode 3 -> not texture; lives above the light data.
- ACDREAM_CLIP_DEBUG=1 (RenderingDiagnostics.ClipDebugNoShellTrim +
  the EnvCellRenderer slot-fill gate): shell pass draws cells WHOLE
  (retail's shape). Pattern survives -> the per-cell clip trim is
  exonerated.

With every render suspect dead, an autonomous visual loop (synthetic
back-into-wall input + GDI window captures + the [flap-sweep] probe)
pinned the mechanism numerically: at a compressed moving boom the
camera-collision sweep is BISTABLE - consecutive sweeps with ~1.4 mm
input drift flip the first-contact solution 0.27 m along the boom
(pulledIn 0.27<->0.53, every ~5-10 frames, all 368k sweeps ok=True),
and at ~1700 fps unsynced every monitor refresh tear-interleaves the
two views = the stripe/hatch patterns. Filed as #180 with the retail
anchor: viewer_sought_position is STATEFUL (SmartBox 0x00452d75 feeds
the CURRENT swept viewer into CameraManager::UpdateCamera 0x00456660
and assigns the return to the sought, 0x00452d84) - the target
converges to the collided position instead of re-rolling the full
knife-edge ray per frame like our RetailChaseCamera does. SweepEye
itself ports update_viewer 0x00453ce0 faithfully and is exonerated.

Also recorded in #176: the site-A weenie light-registration leak (a
portal's I100 light stacked x2->x4 over one session as re-CreateObject
re-registered it under fresh entity ids).

The #176 lighting fix (d8984e87) remains live-verified; #176 re-gates
after #180 lands. ISSUES: #180 filed, #176 updated. Suites: Core
2599+2skip; toggles inert by default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 16:19:38 +02:00
Erik
d8984e877f fix #176: light pool tracked the camera via flood scoping - collect residents, anchor at player
The seam-floor purple flicker was NOT a draw z-fight. The in-engine
[seam-*] probe (ACDREAM_PROBE_SEAMDRAW - built because RenderDoc cannot
capture this pipeline: it hides GL_ARB_bindless_texture and the
mandatory-modern startup gate throws; AMD GPU rules out Nsight) killed
every double-draw suspect: ONE shell instance per seam cell at the
lifted z, no floor-coincident entity (portal entities sit at z=-12.05),
zero portal depth fans in the sealed Hub. What it caught instead: the
corridor floor's applied light set flipping wholesale with the flood.

Root cause: c500912b scoped BuildPointLightSnapshot by the per-frame
portal flood, on the research doc's gloss of CEnvCell::visible_cell_table
as "the portal-flood visible set". The named decomp refutes the gloss:
add_visible_cell (0x0052de40) DBObj-LOADS absent cells and inserts them;
a cell activation adds itself + its whole dat visible-cell list
(0x0052e228/0x0052e24a); entries leave only via the flush machinery.
It is the RESIDENT-cell registry - gaze can never remove a cell.
add_dynamic_lights (0x0052d410) walks the WHOLE table per frame
(caller 0x00452d30), and insert_light (0x0054d1b0) caps the pool by
distance to Render::player_pos (0x0054d1dd). Retail's pool is a function
of player position only. Ours followed the camera: turning changed the
flood (probe: 8..41 cells across one turn), the six intensity-100
under-room portal purples entered/left the pool, and the wedge blinked.

Fix: BuildPointLightSnapshot(playerWorldPos) collects ALL registered
(=resident) lit lights; over cap keeps dynamics FIRST (retail's separate
7-slot dynamic pool never competes with statics) then nearest-the-player;
the RebuildScopedLights callback is deleted. Live-verified with the probe:
full-circle turn, flood churning 8..41, the floor set held the same 8
identities on every post-spawn frame. The purple wedge SHAPE stays - it
is cdb-proven retail-faithful.

Residual deviation (AP-85 rewritten): single 128 pool vs retail's
7-dynamic/40-static degrade-scaled dual pools - the Hub now shows
7 purples + viewer where retail's cdb showed 4 + viewer + fixture slots;
if the gate reads the wedge as too purple, the A7 dual-pool cap is the
faithful trim.

Pins: PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant
(rewritten to the corrected model),
PointSnapshot_OverCap_DynamicsNeverEvictedByNearerStatics,
PointSnapshot_OverCap_KeepsNearestThePlayer,
PointSnapshot_ResidentCollection_CellTagDoesNotFilter.
Suites: Core 2599+2skip / App 726+2skip / UI 425 / Net 385.

The [seam-*] probes stay until the visual gate passes, then strip.
Correction banner added to 2026-07-06-a7-per-cell-lighting-pseudocode.md;
outcome banner on the z-fight handoff; ISSUES #176 updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 15:12:31 +02:00
Erik
8cb3176daa feat(lighting): SelectForCell (all dynamic lights per EnvCell) + #176 handoff — residual is a runtime z-fight
cdb trace of LIVE retail (tools/cdb/issue176-floor-light.cdb, binary<->PDB MATCH) PROVED retail
applies ALL its dynamic lights — 4 intensity-100 magenta portal lights (d3dIdx 3-6, falloff 6) +
the viewer fill (d3dIdx 1, 2.25) — as D3D hardware lights to EVERY Facility Hub cell, every frame,
stable. So the faceted purple wedges on the floor are retail-FAITHFUL. acdream did a per-cell
SelectForObject sphere-overlap 8-cap for cells, so the portal set could differ/flip per cell.

- LightManager.SelectForCell (retail minimize_envcell_lighting 0x0054c170): ALL dynamic lights
  applied unconditionally (shader range cutoff zeroes non-reaching = D3D hardware range), then
  nearest static torches fill remaining slots. Wired into EnvCellRenderer.GetCellLightSet.
  Objects keep SelectForObject (minimize_object_lighting). Pins:
  SelectForCell_AppliesAllDynamicLights_EvenOutOfReach + _SameDynamicSet_ForCellsFarApart_NoFlap.
- Apparatus: [light-detail] gains owner/cell/dyn (pinned the culprit = 2 portal weenies
  0x000F4247/48 in 0x8A020118/19, intensity=100 magenta); CellVertexNormals_SmoothOrFaceted_Dump
  (corridor floor uses SMOOTH per-vertex dat normals, not flat); tools/cdb/issue176-floor-light.cdb.

#176 RESIDUAL is NOT this fix. It's a RUNTIME draw z-fight in the seam floor. Eliminated (evidence):
NOT lighting (per-light cap + this both no-change), NOT membership (render cell 0x8A020164 stable
100% of 188k frames / 526 angles, res=None), NOT dat geometry (coplanar sweep empty at z=-6 floor
incl. cell 0164). NEXT = RenderDoc pixel-history. Full handoff + DO-NOT-RETRY:
docs/research/2026-07-06-176-seam-floor-zfight-handoff.md. Suites green: Core 2599 + 2 skip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 13:55:16 +02:00
Erik
859cf5ec02 refactor(pipeline): MP1b review - unify DatCollectionAdapter + TOC/log test gaps
Adversarially-verified review findings 7 and 8:

(7) The DatCollection->IDatReaderWriter adapter existed as THREE
near-identical copies (App-internal original, Bake's copy, Content.
Tests' copy) — a structure where adapter drift is exactly what the
live-vs-pak equivalence suite cannot detect (both sides would only
drift together if they shared one implementation). Now ONE public
AcDream.Content.DatCollectionAdapter next to IDatReaderWriter (GL-free
home established in MP1a), carrying App's FULL behavior including the
[dat-miss] TryGet tripwire log (which now also covers the bake tool
and the equivalence suite) and the caching/locking. All three copies
deleted; WbMeshAdapter (App), BakeRunner (Bake), and
PakEquivalenceTests (Content.Tests) resolve the shared class.
Iteration properties return the REAL dat iterations — the App copy's
hardcoded 0 was a stub nothing read; the unification intentionally
keeps truth (noted in the doc comment). Verified post-move: no
Silk.NET anywhere in Content / Bake / Content.Tests / Bake.Tests
resolved dependency graphs.

(8) Two test gaps closed in PakRoundTripTests: (a) direct on-disk TOC
sortedness — blobs added in DESCENDING key order, then the raw file
bytes parsed (not through the reader) and every TOC entry asserted
strictly ascending; (b) corrupt-blob logging — five repeated reads
through both public paths (TryReadObjectMeshData + ContainsKey) with
stderr captured, asserting exactly ONE [pak-corrupt] line for the
victim key.

Full suite: 4120 tests, 0 failures (Content.Tests 56, Bake.Tests 1,
plus the pre-existing 4 skips).
2026-07-05 22:18:30 +02:00
Erik
b0758d772b refactor(pipeline): MP1a cleanup - narrow public surface, csproj parity, move residue
Coordinator-directed final cleanup before the user gate; none behavioral:

1. MeshExtractor public surface narrowed to the cross-assembly entry
   points App actually calls (PrepareMeshData, PrepareCellStructMeshData,
   CollectParts, ComputeBounds); PrepareSetupMeshData,
   CollectEmittersFromScript, PrepareGfxObjMeshData,
   PrepareEnvCellMeshData, PrepareCellStructEdgeLineData back to private
   (internal dispatch, only reached via PrepareMeshData).
2. sideStagedSink constructor parameter is now REQUIRED (no default;
   type stays nullable for a conscious null): a bake tool that forgot
   the sink would silently lose particle-preload meshes.
3. AcDream.Content.csproj gains TreatWarningsAsErrors + LangVersion
   latest (parity with AcDream.Core.csproj). Surfaced zero warnings.
4. Dead usings removed from ObjectMeshManager.cs (BCnEncoder.*,
   SixLabors.*) — the inline decode moved out in Task 4.
5. Doc fixes: ObjectMeshData.cs cross-assembly <see cref> ->
   plain text (Content can't resolve App types); IDatReaderWriter.cs
   stale Phase O-T7 'both in this namespace' sentence rewritten.
6. Stale test doc comments updated to MeshExtractor.PrepareGfxObjMeshData
   (StipplingSurfaceEquivalenceTests, Issue119UpNullGfxObjDumpTests) —
   comments only, no code/assertion changes.

dotnet build green (0 warnings in Content under warnings-as-errors);
full test suite 4059 passed / 0 failed / 4 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:52:01 +02:00
Erik
932f904e00 fix(pipeline): MP1a - sink delegate restores immediate side-stage enqueue (exception-path faithfulness)
Coordinator-directed follow-up. The buffer-and-drain seam diverged from
the original on the exception path: pre-MP1a, CollectEmittersFromScript
enqueued particle-preload meshes DIRECTLY into _stagedMeshData
mid-Prepare, so preloads staged before a later throw in the same
Prepare* call (reachable via PrepareEnvCellMeshData side-staging during
its StaticObjects loop, then PrepareCellStructMeshData throwing on a
malformed-dat texture decode) were already safely enqueued. The drain
version only flushed after a successful return — on throw, entries
stranded on the shared extractor until an unrelated successful call
flushed them, and were silently dropped on dispose.

Fix: MeshExtractor takes an Action<ObjectMeshData>? sideStagedSink
constructor parameter; the two CollectEmittersFromScript sites become
_sideStagedSink?.Invoke(meshData) — the original code shape (immediate
hand-off) at those exact lines. ObjectMeshManager wires the sink to
_stagedMeshData.Enqueue, restoring the original immediate-enqueue
semantics including on mid-Prepare throw. _sideStaged buffer,
DrainSideStaged(), and the ProcessQueueAsync drain loop are deleted.
The MP1b bake tool passes its own collector.

Inventory doc updated: MP1a note now records the sink seam and the
Content-owned upload enums, so its no-behavior-change claim is accurate.

dotnet build green; full test suite 4059 passed / 0 failed / 4 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:39:42 +02:00
Erik
1477cda60a refactor(pipeline): MP1a - Content-owned upload enums; drop Silk.NET from Content
Coordinator-directed follow-up: AcDream.Content must stay Silk.NET-free
(the MP1b bake tool must not ship GL binaries). The Silk.NET.OpenGL
PackageReference added for the PixelFormat?/PixelType? upload hints is
replaced by Content-owned UploadPixelFormat/UploadPixelType enums
(UploadFormats.cs) whose underlying values are the GL ABI constants
(Rgba = 0x1908, UnsignedByte = 0x1401), verified numerically identical
to the Silk.NET.OpenGL members against 2.23.0. This is the one
sanctioned edit to the verbatim-moved Prepare* bodies: enum literal for
enum literal, numeric value identical, behavior unchanged. App casts at
the single upload boundary (AddTexture call in UploadGfxObjMeshData)
via lifted nullable enum conversion — value- and null-preserving.

Also hardens the MP1a _sideStaged hand-off seam: List -> ConcurrentQueue.
One MeshExtractor is shared by up to MaxParallelLoads (4) decode workers;
the original code enqueued to the thread-safe _stagedMeshData directly,
so the hand-off buffer must be thread-safe too. Drain ordering verified:
side-staged entries enqueue BEFORE the top-level result, preserving the
original mid-Prepare FIFO order.

Verified: grep -i silk on the csproj -> no matches; deps.json has zero
Silk entries; dotnet build 0 errors; full test suite green (4059 passed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:27:38 +02:00
Erik
30cc1e282e refactor(pipeline): MP1a - MeshExtractor extracted to AcDream.Content (verbatim move) 2026-07-05 20:19:52 +02:00
Erik
e3376d4734 refactor(pipeline): MP1a - ObjectMeshData family moved to AcDream.Content 2026-07-05 20:07:31 +02:00
Erik
d778930853 refactor(pipeline): MP1a - lift TextureKey out of the GL atlas class 2026-07-05 20:01:56 +02:00
Erik
a9d06a613a chore: strip throwaway dense-town FPS profiling apparatus (plan Task 5)
The FPS deep-dive landed (dense Arwic 75 -> ~165 fps via the cell-object
batching + cell-particle consolidation, both already committed). Remove the
throwaway diagnostic apparatus now that it has served its purpose:

- delete FrameProfiler.cs (whole-frame TimeElapsed + [PASS-GPU] glFinish +
  [CPU-PHASE]/[GPU-PHASE] timers + the =1/=2 ACDREAM_FPS_PROF modes)
- GameWindow: _fpsProf/_frameProfiler/_msaaSamples fields, the BeginFrame/
  EndFrame/MarkUpdateStart hooks, the terrain glFinish, and the landscape
  sub-phase LsMark instrumentation
- RetailPViewRenderer: the DrawInside per-phase Phase()/MarkGpu markers
- ParticleRenderer / PortalDepthMaskRenderer / EnvCellRenderer: the per-pass
  glFinish brackets
- delete DegradeCoverageProbeTests.cs (the dead distance-degrade probe)

KEPT (the real fixes): RetailPViewRenderer cell-object batching + consolidated
cell-particle pass; EnvCellRenderer.CellHasTransparent. Build + full test suite
green (468 App incl. pview replay tests; 1566 Core; 317 Net; 425 UI).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 00:10:00 +02:00
Erik
f72f7ce1f4 feat(envcell): CellHasTransparent predicate (shell-batching prep)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:50:31 +02:00
Erik
e27923b4b5 chore(diag): dense-town FPS profiling apparatus (ACDREAM_FPS_PROF) [throwaway]
FrameProfiler (frame/update/render/present/gpu split + per-renderer glFinish
attribution) + OnRender/OnUpdate hooks + terrain & EnvCell glFinish timers +
the degrade-coverage probe test. Used to root-cause the dense-town FPS; STRIP
when the fix lands (mirrors 92e95be).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:49:18 +02:00
Erik
0d8b827721 fix(lighting): indoor interiors now lit — landblock-key bug + viewer light + weenie fixtures
The whole "indoor interiors read dark/flat vs retail" saga was ONE root-cause
bug: EnvCellRenderer.GetCellLightSet derived the landblock key as
`cellId & 0xFFFF0000` (0xXXYY0000), but landblocks are keyed by the streaming
id 0xXXYYFFFF. The lookup missed for EVERY cell, so SelectForObject never ran
and every EnvCell wall received ZERO point lights — torches, lanterns, the
viewer light, all of it. Confirmed by a [cell-light] probe: inBounds=False
selected=0 across 1M+ cell draws; after the fix inBounds=True selected=3-4.
User-confirmed the interiors now look like retail.

Three faithful additions that were blocked by the key bug (and only show now):
- Viewer light (LightManager.UpdateViewerLight): retail's SmartBox::set_viewer
  (0x00452c40) adds a white fill light at the player every frame via
  add_dynamic_light — the dominant interior fill (no sun indoors). acdream had
  NO dynamic lights at all. Params from the cdb capture: intensity 2.25,
  falloff 10, white, offset (0,0,2). Indoor-only via the AP-43 gate.
- Weenie fixture lights (OnLiveEntitySpawnedLocked): server-spawned lanterns/
  braziers carry Setup.Lights but the dat-static registration never saw
  CreateObject entities. Register on spawn; unregister on despawn
  (UnregisterOwner made unconditional). Register row AP-44.
- IndoorObjectReceivesTorches now excludes the 0xFFFF landblock marker (it is
  not an EnvCell) — fixes WbDrawDispatcherIndoorFlagTests.LandblockId_OutdoorFlag0
  (a #142 verification miss).

Divergence register: AP-44 (weenie light spawn-position, no movement tracking),
AP-47 (acdream's 128-light/camera-independent cap keeps interiors always-lit vs
retail's 40-nearest-to-player budget that pops in on approach — intentional,
user-preferred).

Investigation: docs/research/2026-06-20-indoor-torch-lantern-lighting-investigation.md

Core 1505 / App 476 green. Visual gate: user-confirmed "looks like retail now."

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 15:51:55 +02:00
Erik
ef5049fd58 fix(lighting): #142 — per-instance sun gate for windowed-building interiors
Standing inside (or looking into) a windowed building like the Agent of
Arcanum, interior objects (furniture, NPCs, the player) were lit by the
directional sun because acdream's sun gate was per-FRAME (keyed on the
player being in a sealed cell), not per-DRAW as retail does it.

Retail's PView::DrawCells (0x005a4840) runs two stages per frame:
  outdoor stage   → useSunlightSet(1) (0x005a485a): sun ON
  interior stage  → useSunlightSet(0) (0x005a49f3): sun OFF
DrawMeshInternal (0x0059f398) then calls minimize_object_lighting only
when useSunlight==0, so indoor objects ALWAYS skip the sun regardless of
whether the player's cell is windowed or sealed.

Fix: add a per-instance uint SSBO (binding=6 instanceIndoor[]) whose value
is IndoorObjectReceivesTorches(ParentCellId) — the same predicate AP-43
already uses for the torch gate. In mesh_modern.vert, nest the sun loop
inside an additional `if (instanceIndoor[instanceIndex] == 0u)` check
inside the existing `if (uLightingMode == 0)` block. Indoor objects get
torches (unchanged) but now skip the sun; outdoor objects keep the sun and
still get no torches. The ambient regime (UpdateSunFromSky: 0.2 sealed /
sky otherwise) is untouched — it was already correct.

Mechanically: _currentEntityIndoor set once per entity in
ComputeEntityLightSet; appended to InstanceGroup.IndoorFlags in
AppendCurrentLightSet; grown/packed/uploaded in the same cursor loop as
_clipSlotData and _lightSetData; deleted in Dispose. Mode-1 draws
(EnvCellRenderer) never read binding=6 — the sun loop is inside the
uLightingMode==0 uniform-control-flow branch.

AP-43 divergence register updated: the sun half is now per-draw (no
longer a residual). Residual narrowed to the unaudited ebp_2 test in
CellManager::ChangePosition (no observed impact).

Tests: WbDrawDispatcherIndoorFlagTests pins IndoorObjectReceivesTorches
for the spec §5 representative ids: 0xA9B40172 (Agent of Arcanum EnvCell)
→ 1; 0xA9B40031 (land sub-cell) → 0; 0xA9B4FFFF (landblock) → 0; null
(outdoor shell) → 0; plus the boundary cases 0x0100/0x00FF.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:55:56 +02:00
Erik
c83fd02642 merge: bring main (UN-7, #140 filing, D.2b UI rows) into A7 Fix D round-2 branch
Resolves the divergence-register conflict: kept the accurate per-VERTEX AP-35
(Fix A shipped per-vertex; main's row was the stale pre-Fix-A per-pixel text),
kept main's UI rows AP-37..AP-42, and renumbered this branch's torch-gate row
AP-37 -> AP-43 (AP-37 was taken by main's LayoutDesc row). AP count 41 -> 42.
Retargeted the AP-37 references in WbDrawDispatcher + the CHECKPOINT to AP-43.
Marked ISSUES #140 RESOLVED (b7d655b) with the corrected root cause.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 09:29:53 +02:00