merge: bring main (A7 lighting Fix A–D + UN-7 + #140 Fix D) into the D.5 branch
Integrates main's 19 commits (A7 outdoor/indoor torch lighting Fix A/B/C/D, GlobalLightPacker, shader updates, UN-7) under the D.5 toolbar/item-model stack (D.5.1/D.5.2/D.5.4/D.5.3a). Auto-merged cleanly except docs/ISSUES.md. Conflict resolved: both lineages used #140 for different issues. Kept main's #140 = "A7 Fix D" (resolved); renumbered the toolbar/selected-object issue to #141 (note added; this branch's commits/spec still reference #140 — immutable). The register auto-merged (AP-46 cites file:line, not #140; UN-7 keeps #140=Fix D). Build + full suite green on the merged tree (2,713 passed / 4 skipped). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
31d7ffd253
27 changed files with 2327 additions and 103 deletions
|
|
@ -46,9 +46,9 @@ Copy this block when adding a new issue:
|
|||
|
||||
---
|
||||
|
||||
## #140 — Toolbar interactivity — selected-object display
|
||||
## #141 — Toolbar interactivity — selected-object display
|
||||
|
||||
**Status:** IN PROGRESS (D.5.3a health + name + flash — DONE & visually confirmed 2026-06-20; mana + stack slider still deferred)
|
||||
**Status:** IN PROGRESS (D.5.3a health + name + flash — DONE & visually confirmed 2026-06-20; mana + stack slider still deferred). Renumbered from #140 on the 2026-06-20 main merge — A7 Fix D held #140 on main; this branch's commits/spec still reference #140.
|
||||
**Severity:** MEDIUM
|
||||
**Filed:** 2026-06-17
|
||||
**Component:** ui — D.5 toolbar / selection
|
||||
|
|
@ -57,7 +57,7 @@ Copy this block when adding a new issue:
|
|||
|
||||
**Root cause / status:** The selection-state wire was deferred out of D.5.1 scope; the meter/slider elements are present in LayoutDesc 0x21000016 but hidden (no backing data). D.5.3 is the planned port.
|
||||
- **D.5.3a (2026-06-18):** the Health meter (0x100001A1) + the object-name line (0x1000019F) + the overlay state (0x100001A0) are wired via `SelectedObjectController` (port of `gmToolbarUI::HandleSelectionChanged`); `SelectionChanged` event on `GameWindow`; `QueryHealth (0x01BF)` sent on select. Spec/plan: `docs/superpowers/specs|plans/2026-06-18-d53a-*`. **Still deferred:** the Mana meter (0x100001A2 — owned-item-only; no remote-target mana path yet) and the stack entry/slider (0x100001A3/A4 — stack-split UI). Divergence row AP-46.
|
||||
- **D.5.3a visual gate PASSED (2026-06-20):** name top-aligned in the bar sprite's black band, friendly NPCs/Doors name-only, players/monsters get the bar (gated on PWD BF_ATTACKABLE/BF_PLAYER), bar appears on assess/damage (UpdateHealth-driven, AP-47 retired), brief green selection flash. Fixed during the gate: the two magenta end-lines (UiMeter.DrawHBar resolved slice id 0 → 1x1 magenta placeholder → 1px caps), the stack-entry black box (hid 0x100001A3), and the flash being eaten by a framebuffer-dump diagnostic. Commits `8f627cc` (fixes), `0796585` (CLI apparatus). **Remaining for #140:** Mana meter (0x100001A2) + stack entry/slider (0x100001A3/A4).
|
||||
- **D.5.3a visual gate PASSED (2026-06-20):** name top-aligned in the bar sprite's black band, friendly NPCs/Doors name-only, players/monsters get the bar (gated on PWD BF_ATTACKABLE/BF_PLAYER), bar appears on assess/damage (UpdateHealth-driven, AP-47 retired), brief green selection flash. Fixed during the gate: the two magenta end-lines (UiMeter.DrawHBar resolved slice id 0 → 1x1 magenta placeholder → 1px caps), the stack-entry black box (hid 0x100001A3), and the flash being eaten by a framebuffer-dump diagnostic. Commits `8f627cc` (fixes), `0796585` (CLI apparatus). **Remaining for #141:** Mana meter (0x100001A2) + stack entry/slider (0x100001A3/A4).
|
||||
|
||||
**Files:** `src/AcDream.App/UI/Layout/ToolbarController.cs` + the selection/WorldPicker state (see `claude-memory/project_interaction_pipeline.md`).
|
||||
|
||||
|
|
@ -67,6 +67,27 @@ Copy this block when adding a new issue:
|
|||
|
||||
---
|
||||
|
||||
## #140 — A7 "Fix D": outdoor objects too bright near torches
|
||||
|
||||
**Status:** RESOLVED (`b7d655b`, 2026-06-19 — user-confirmed side-by-side at the Holtburg meeting hall)
|
||||
**Severity:** MEDIUM (visible — buildings blow out warm near torches vs retail; ambient/sun itself is correct after Fix C)
|
||||
**Filed:** 2026-06-18
|
||||
**Component:** render — point lighting on outdoor objects
|
||||
|
||||
**RESOLUTION (2026-06-19, round 2):** The "bake vs D3D-FF" framing below was the WRONG question — neither lights the building exterior. Retail's per-object torch binder `minimize_object_lighting` (0x0054d480) runs ONLY `if (Render::useSunlight == 0)` (`DrawMeshInternal` 0x0059f398), and the OUTDOOR landscape stage runs `useSunlightSet(1)` (`PView::DrawCells` 0x005a485a before `LScape::draw`). So retail lights outdoor objects (building exterior shells, scenery, outdoor creatures) with the **sun + ambient ONLY — never wall torches**. acdream was torch-lighting them. Fix: `WbDrawDispatcher.ComputeEntityLightSet` now gates torch selection on the object being indoor (`ParentCellId` is an EnvCell) via `IndoorObjectReceivesTorches`; outdoor objects get the sun only. acdream reads the dat falloffs faithfully (the orange torch is genuinely `Falloff 6`; the "reach too long" theory was a red herring). Register **AP-43**; the indoor-vs-outdoor *sun* half uses a per-frame player-inside global (residual logged in AP-43). Full handoff: `docs/research/2026-06-19-lighting-a7-fixD-round2-torch-reach-CHECKPOINT.md` (RESOLVED banner). Indoor-lighting follow-ups the user raised at the gate (windowed-building interior regime; portal swirl as a dynamic light) are SEPARATE M1.5 work, not part of this issue.
|
||||
|
||||
**Description (user):** Outdoor buildings (e.g. the Holtburg meeting hall) read much brighter near torches in acdream than in retail — the walls blow out warm where retail stays dim. The general ambient/sun is correct after Fix C (`57c1135`); this is specifically the per-object point-light *contribution*.
|
||||
|
||||
**Root cause / status:** GROUNDED but BLOCKED on one capture. Retail's object point-light path (`config_hardware_light` 0x0059ad30): `Diffuse=color×intensity`, `Attenuation=(0,1,0)`⇒1/d, `Range=falloff×rangeAdjust` (`rangeAdjust=1.5`⇒9 m), `material.diffuse=(1,1,1)`. CONTRADICTION: by that math a torch 3 m away = color×33 ⇒ retail walls should blow to WHITE — but they're DIM. Material/range/intensity all captured + ruled out. So the scaling is in the building's RENDER PATH (unknown). Leading hypothesis: static buildings DON'T use D3D hardware lighting — they use the `SetStaticLightingVertexColors` BAKE (`calc_point_light`, like cells), and the captured `intensity=100` light was a different object (player/portal). **DO NOT port the D3D-FF model — the math says it would make objects brighter, not dimmer.**
|
||||
|
||||
**Files:** `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution`/`accumulateLights`); `src/AcDream.Core/Lighting/LightManager.cs` (`SelectForObject`); `LightBake.cs` (verbatim calc_point_light, unwired).
|
||||
|
||||
**Research:** `docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md` (full grounding + cdb cheat-sheet + the next capture); `claude-memory/reference_retail_ambient_values.md`.
|
||||
|
||||
**Acceptance:** Determine the building's actual render path (bake vs D3D-FF; is `SetStaticLightingVertexColors` 0x0059cfe0 called for it / is `D3DRS_LIGHTING` on), then make the object torch contribution match retail — user side-by-side sign-off (meeting hall stays dim near torches).
|
||||
|
||||
---
|
||||
|
||||
## #139 — D.2b retail UI polish: chat text colors + buttons
|
||||
|
||||
**Status:** OPEN
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ accepted-divergence entries (#96, #49, #50).
|
|||
| AP-13 | `ComputeDamage` is a simplified retail damage formula (no augmentations/ratings) — verified DEAD CODE as of 2026-06-04, M2 scaffolding | `src/AcDream.Core/Combat/CombatModel.cs:184` | Not on the critical path; stubbed from r02 §5 + ACE CombatManager for the future M2 predictive display | If wired into the M2 attack-bar estimate as-is, predicted numbers diverge whenever augs/ratings apply | r02 §5; ACE CombatManager |
|
||||
| AP-14 | Encumbrance multiplier is a rough piecewise-linear stand-in (1.0→50%, ~0.7@100%, 0.1@300%) for retail's exact curve | `src/AcDream.Core/Items/ItemInstance.cs:187` | Hand-fit segments capture the curve's shape for scaffolding | Client-side burden-scaled effects (speed prediction) differ from retail at most burden ratios when loaded | r06 §6 (retail encumbered multiplier curve) |
|
||||
| AP-15 | WeenieError translation table covers only ~30 common codes (from ACE enum docs, not retail string_table.bin); unknown codes render raw hex | `src/AcDream.Core/Chat/WeenieErrorMessages.cs:26` | Untranslated codes are rare, fall back losslessly, 30-second add when reported | Server messages outside the table show as raw hex instead of the retail sentence | retail string_table.bin; ACE WeenieError*.cs |
|
||||
| AP-16 | Global nearest-8 viewer-distance light selection (own r13 design); retail bound D3D lights per object/cell. NO viewer-range candidacy filter — each light's range cutoff is applied per-surface in the shader (the earlier `Range²×1.1` slack filter was removed; it dropped torches the viewer stood outside, the #133 "lighting off" report) | `src/AcDream.Core/Lighting/LightManager.cs:10` | Honors retail's 8-hardware-light constraint while fitting a global-uniform shader; nearest-8 is an allocation-free partial-select (no per-frame list/sort) | With >7 nearby lights, different objects are lit than retail would light (retail's per-object pick can light a far object by ITS nearest lights) | r13 §12.2 (acdream design); retail D3D 8-light constraint |
|
||||
| AP-16 | Point/spot lights selected per-object / per-cell as the **8 nearest reaching lights** (sphere-overlap, nearest-first) via `LightManager.SelectForObject`, capped at `MaxLightsPerObject=8`; called from `WbDrawDispatcher.ComputeEntityLightSet` (objects) and `EnvCellRenderer.GetCellLightSet` (cell shells). Retail's bake (`SetStaticLightingVertexColors`) sums ALL reaching static lights per vertex with no count cap. Retail's *hardware* path (`minimize_object_lighting` 0x0054d480) DOES cap at 8 per object, so the cap is faithful to retail's hardware path — not to its bake path. The `LightManager.Tick` UBO path survives for DIRECTIONAL (sun) lights only; `mesh_modern.vert`'s UBO loop skips point/spot entries (`posAndKind.w != 0 → continue`) — point lights reach the shader exclusively via the per-object SSBO (binding 5) | `src/AcDream.Core/Lighting/LightManager.cs:234` (`SelectForObject`); `MaxLightsPerObject` ~line 174; call sites `WbDrawDispatcher.ComputeEntityLightSet` + `EnvCellRenderer.GetCellLightSet` | Matches retail's hardware constraint (8 lights per object/cell); selection is nearest-sphere-overlap which faithfully allocates lights to the surfaces that actually see them | Surfaces reached by >8 point lights are dimmer than retail's uncapped bake — rare (a dungeon room has a handful of torches), but real; see AP-35 for the bake-vs-GPU-evaluate architecture difference | `minimize_object_lighting` 0x0054d480 (retail's 8-light hardware cap); `SetStaticLightingVertexColors` 0x0059cfe0 (retail's bake, no count cap) |
|
||||
| AP-17 | Spell metadata from third-party CSV (3,956 rows, bad rows silently skipped), not the portal.dat SpellTable; Family feeds stacking decisions | `src/AcDream.Core/Spells/SpellTable.cs:10` | The dat spell-table port (obfuscated/encrypted aspects) wasn't done; CSV closed #11 fast and unblocked #6 stacking | Any CSV↔dat drift (wrong Family, missing rows) silently produces wrong buff-stacking winners and wrong panel info | portal.dat SpellTable 0x0E00000E |
|
||||
| AP-18 | Radar/indicator RGBA hand-tuned from screenshots; dispatch order ports `GetBlipColor` exactly but the real `RGBAColor_Radar*` static data is unrecovered | `src/AcDream.Core/Ui/RadarBlipColors.cs:33` | Color constants live in retail static data not yet extracted; comment invites tightening when recovered | Blip/indicator hues differ subtly from retail color cues | `gmRadarUI::GetBlipColor` 0x004d76f0; RGBAColor_Radar* (unrecovered) |
|
||||
| AP-19 | `PortalSideEpsilon` 0.01 (≈1 cm) instead of retail F_EPSILON ≈ 0.0002 — a documented render-root-lag tolerance, NOT a retail constant. DO-NOT-RETRY: T2 (BR-4) tried the retail value; CornerFloodReplay refuted it | `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:49` | Retail's tight epsilon only works with eye-exact swept curr_cell tracking; our viewer cell lags the eye by up to ~1 cm at pressed corners. Tighten after the #108-membership family + cdstW near-clip pin land | A 1 cm misclassification band at portal planes can flood or cull a portal the eye hasn't crossed — one-frame leaks / grey flashes at knife-edge doorway/corner positions | F_EPSILON @0x007c8c70; `PView::InitCell` 0x005a4b70 |
|
||||
|
|
@ -136,7 +136,8 @@ accepted-divergence entries (#96, #49, #50).
|
|||
| AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 |
|
||||
| AP-34 | Landscape-stage alpha deferral is a TWO-PHASE slice split (statics-early / dynamics+particles+weather-late around the **#124** look-ins) + outdoor-root attached scene emitters moved to the post-frame pass, not retail's single deferred alpha flush. Residual: building exteriors' / outside-stage dynamics' own translucent MESH batches still draw within their stage draw call (before later stage content) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` late loop) + `GameWindow` post-frame Scene pass | The MDI dispatcher draws translucency inside each Draw call; a faithful FlushAlphaList port needs a global deferred alpha list across all landscape draws — the split covers the user-visible cases (#131 portal swirl, #132 candle flame indoors + outdoors) | Translucent landscape content drawn early and screen-overlapped by content drawn later in the stage gets overpainted (no depth self-protection) — the portal-swirl/candle-flame class re-appears in the residual configurations | `D3DPolyRender::FlushAlphaList` (DrawCells pc:432722) |
|
||||
| AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp) | `src/AcDream.App/Rendering/GameWindow.cs:6895` (per-frame predicate) + `:IsSealedDungeonCell` + `:OnLiveEntitySpawnedLocked`/`:OnLivePositionUpdated` (login/teleport pre-collapse hooks) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277 |
|
||||
| AP-35 | Point/spot lights use a single PER-PIXEL accumulation that ports `calc_point_light`'s `(1 − dist/falloff_eff)` LINEAR ramp (falloff_eff = Falloff × static_light_factor 1.3) + standard Lambert `N·L`; retail's path is PER-VERTEX Gouraud and additionally applies a half-Lambert wrap (`0.5·dist + N·L_vec`, lights surfaces down to `N·L ≥ −0.5`) and an x87-obscured normalization factor, neither ported | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag:52` (+ `mesh.frag`; `LightInfoLoader.cs:81` folds 1.3 into Range) | The linear ramp is the user-visible fix (kills the hard-disc "spotlight" edge, #133 A7); the dropped wrap/normalization only re-shade the gradient slightly, and per-pixel vs per-vertex Gouraud chiefly differs on coarse geometry. Half-Lambert wrap + factor are an x87-decompile refinement (same artifact class as GetPowerBarLevel AP-24) | Surfaces facing slightly away from a torch (`−0.5 ≤ N·L < 0`) stay dark where retail's wrap lights them faintly; near-light gradient shading differs subtly from retail's per-vertex bake | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); static_light_factor 0x00820e24 |
|
||||
| AP-43 | Per-object torch (point/spot) lighting is gated on the OBJECT's own cell: an object selects the static wall-torches ONLY when its `ParentCellId` is an EnvCell (`(id & 0xFFFF) >= 0x0100`); outdoor objects (building exterior shells with null ParentCellId, outdoor scenery, outdoor creatures) get the SUN + ambient and NO torches. This is the faithful port of retail's `useSunlight` gate — `DrawMeshInternal` (0x0059f398) calls `minimize_object_lighting` only `if (Render::useSunlight == 0)`, and the outdoor landscape stage runs `useSunlightSet(1)` (`PView::DrawCells` 0x005a485a, before `LScape::draw`) so outdoor objects are never torch-lit (closes the Holtburg meeting-hall facade torch-flood — A7 Fix D round 2, the dat's intensity-100 `falloff 6` orange torches were washing the exterior shell). **Residual approximation:** the sun/no-sun half is a per-FRAME global keyed on the PLAYER being inside a cell (`UpdateSunFromSky` zeroes the sun when `playerInsideCell`), NOT retail's per-draw-STAGE `useSunlight` toggle. So a mixed-stage frame (standing in a doorway / look-in) lights through-aperture objects with the player's regime, not the object's stage regime | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`IndoorObjectReceivesTorches` + `ComputeEntityLightSet`); sun half `src/AcDream.App/Rendering/GameWindow.cs:10421` (`UpdateSunFromSky`) | The visible case — player OUTSIDE, looking at an outdoor building/scenery — is now exactly faithful (sun+ambient, no torches); player INSIDE a cell gets torches with the sun globally killed = faithful indoor regime. Cross-aperture mismatch only affects objects seen THROUGH a doorway from the other lighting context | A through-aperture interior object viewed from outside gets the sun (player outside) instead of retail's indoor torches-no-sun; an outdoor object viewed from inside gets no sun (player inside) instead of retail's sunlit outside stage — doorway/look-in frames only, not the standalone outdoor or indoor case | `useSunlight` gate `DrawMeshInternal` 0x0059f398; `useSunlightSet` 0x0054d450; outside stage `PView::DrawCells` 0x005a485a (`useSunlightSet(1)`); `minimize_object_lighting` 0x0054d480; `config_hardware_light` Range=falloff×`rangeAdjust`(1.5) 0x0059ad30 |
|
||||
| AP-35 | Point/spot lights are now PER-VERTEX Gouraud (`pointContribution` ~line 153 of `mesh_modern.vert`) matching retail's `SetStaticLightingVertexColors` bake path. Half-Lambert wrap (`(1/1.5)·(N·D + 0.5·d)`) AND norm distance attenuation (`distsq>1 ? distsq·d : d`) ARE ported (A7 Fix A, `aa94ced`). Point-light sum clamped to [0,1] on its own accumulator before adding ambient+sun (A7 Fix D D-1, mirrors retail's per-vertex bake clamp). CPU oracle: `src/AcDream.Core/Lighting/LightBake.cs`, locked by `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`. **Residual (two parts):** (a) acdream lights in-shader each frame (per-frame GPU evaluate); retail bakes into the vertex buffer ONCE — an architecture/performance difference; the wrap + norm + clamp formula is the same, but bake-once is cheaper for static geometry; (b) acdream's `SelectForObject` keeps only the 8 NEAREST reaching point/spot lights per object/cell (`MaxLightsPerObject=8`, see AP-16), whereas retail's bake sums ALL reaching static lights per vertex — a surface reached by >8 point lights is dimmer in acdream than retail's bake result (rare in practice; a room has a handful of torches) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution` ~line 153; wrap ~line 163; norm ~line 167; point-sum clamp line 210) | Per-vertex Gouraud + wrap + norm + clamp all match retail. The two residuals are: (a) per-frame GPU vs bake-once — architecture/perf only; (b) 8-light cap dimming when >8 lights reach one surface — rare. `LightInfoLoader.cs:81` folds static_light_factor 1.3 into Range | (a) A new frame-time consumer bypassing `accumulateLights` would need to replicate the wrap + norm formula; per-frame GPU re-evaluate has higher per-frame cost than bake for static geometry. (b) A densely lit scene (>8 torches reaching one wall) renders dimmer than retail — see AP-16 for the 8-cap ownership | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); `SetStaticLightingVertexColors` 0x0059cfe0; static_light_factor 0x00820e24 |
|
||||
| AP-37 | LayoutDesc importer collapses the dat's nested meter structure (Type-7 meter → two Type-3 container children → three Type-3 image-slice grandchildren each) into `UiMeter`'s programmatic 3-slice fields (`BackLeft..FrontRight`) + reuses `UiMeter.DrawHBar`'s scissor-fill, instead of building those child nodes generically and porting `UIElement_Meter::DrawChildren`. Vitals number elements are meter children (not recursed); `VitalsController` attaches a centered `UiText` child for the cur/max number (Task 8 landed — retail `gmVitalsUI` uses `UIElement_Text`), so `UiMeter.Label` is no longer used for vitals (`UiText.Centered` reuses the meter's former centering formula → pixel-identical, user-confirmed). The inheritance `Merge` treats Width/Height==0 as "inherit from base", diverging from format-doc §12 rule 2 (documented inline in `ElementReader.cs`) | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`) + `src/AcDream.App/UI/Layout/LayoutImporter.cs` (`BuildWidget` meter-child skip) | Reuses the tested `UiMeter` render that already visually matches retail's stacked vitals bars; the full nested-element + `DrawChildren` scissor port is deferred to Plan 2. Locked by the conformance fixture (`tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json`) | A LayoutDesc whose meter structure differs from the vitals 2-container/3-slice shape renders an empty/wrong meter — no oracle diff until the Plan-2 port lands | `UIElement_Meter::DrawChildren` @0x46fbd0; `docs/research/2026-06-15-layoutdesc-format.md` |
|
||||
| AP-38 | Chat transcript renders pre-split `ChatLog` lines 1:1; no in-element word-wrap at the panel's current pixel width | `src/AcDream.App/UI/UiText.cs` | Retail does in-element wrap via `UIElement_Text::SizeToFit`; our pre-split lines are always shorter than 440 px in practice; a line that overflows clips at the edge rather than wrapping | Very long server system messages (server shutdowns, broadcast announcements) clip rather than wrapping — no information loss, just visual truncation | `UIElement_Text::SizeToFit` @0x467980; `gmMainChatUI` layout |
|
||||
| AP-39 | Chat lines carry one color per `ChatKind` (per-line solid color); retail `UIElement_Text` supports per-glyph styled runs (bold, different hue per segment) | `src/AcDream.App/UI/UiText.cs:13` | Retail glyph-run parsing lives inside keystone.dll with no PDB/decomp; per-line per-kind coloring is the correct tonal palette and covers all existing chat types | Chat lines retail renders with multiple colors or bold names (e.g. "PlayerName says: text") render as one flat color; subtle visual difference but functionally complete | `UIElement_Text` glyph-run styling (keystone.dll, no decomp) |
|
||||
|
|
@ -200,6 +201,7 @@ equivalence argument (promote to AD/AP) or a fix.
|
|||
| UN-4 | GfxObj double-sided/negative-surface handling keeps WB's legacy logic (cull-mode double-siding, no reversed-winding duplicate, different neg-surface predicate) while the CellStruct path follows the retail-cited `ConstructMesh` reading | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1059` (CellStruct contrast :1396-1410) | No recorded justification on the GfxObj side — it is the unmodified WB extraction; the retail citation was added only to the CellStruct path | GfxObj models retail draws via duplicated-reversed-winding get wrong back-face lighting (normals not inverted) or missing/extra negative faces — dark or absent faces from behind | `D3DPolyRender::ConstructMesh` 0x0059dfa0 |
|
||||
| UN-5 | Run multiplier applied to backward (and strafe) speed while the wire reports speed 1.0; the 0.65 backward factor IS retail's, the runMul on top is justified only by feel ("~2.4× ratio felt wrong"); strafe cites holtburger, backward cites nothing | `src/AcDream.App/Input/PlayerMovementController.cs:909` | Feel fix (K-fix3); no retail citation for run-scaling backward movement | If retail does NOT run-scale backward, the local body moves up to ~2.4× faster backward than the wire declares — observers dead-reckon slower and see lag/teleport when backing up at run | adjust_motion FUN_00528010 (0.65 only); holtburger common.rs (sidestep) |
|
||||
| UN-6 | Fixed 200 ms sleep between ConnectRequest and ConnectResponse; retail inserts no delay. Annotated only as "with 200ms race delay"; the 2026-06-04 audit flagged it, the follow-up refuted "forbidden workaround" but wrote no fuller rationale back | `src/AcDream.Core.Net/WorldSession.cs:484` | Presumed ACE port+1 listener race guard — four words, no citation | Every login eats a flat 200 ms; if the race needs longer on a loaded server, the handshake fails intermittently (ConnectResponse ignored → CharacterList never arrives, exit-29 shape) with no retry — a timing constant masking an unconfirmed root cause | (none recorded) |
|
||||
| UN-7 | Outdoor OBJECT point lighting uses `calc_point_light` (wrap/norm + per-channel cap, `~1/d²`) for ALL meshes including static buildings, but retail's object path is unconfirmed — `config_hardware_light` (0x0059ad30) sets D3D-FF point lights (`Diffuse=color×intensity`, `Attenuation=(0,1,0)`⇒`1/d`, `Range=falloff×1.5`, `material.diffuse=white`) yet that math would blow walls WHITE while retail stays DIM, so static buildings may instead use the `SetStaticLightingVertexColors` bake. Model + the brightness-scaling factor both UNRESOLVED (issue #140 / Fix D) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution`); `src/AcDream.Core/Lighting/LightManager.cs` (`SelectForObject`) | Fix A/B ported calc_point_light + per-object selection for objects without confirming retail uses that model for static buildings; cdb captured the D3D-FF path but it contradicts the observed dim result | Outdoor buildings blow out warm near torches (the #140 meeting-hall symptom); whichever model is wrong, the object torch contribution is too strong | `config_hardware_light` 0x0059ad30; `SetStaticLightingVertexColors` 0x0059cfe0; `rangeAdjust=1.5` 0x00820cc4 — see docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,140 @@
|
|||
# A7 Lighting — Fix A/B/C SHIPPED, Fix D (object torch over-brightness) HANDOFF
|
||||
|
||||
**Date:** 2026-06-18 **Branch:** claude/thirsty-goldberg-51bb9b (merged to main)
|
||||
**Companion memory:** `claude-memory/reference_retail_ambient_values.md` (all captured
|
||||
values + cdb recipes) and `reference_retail_chat_colors.md` (cdb method).
|
||||
|
||||
This session made acdream's outdoor + ambient lighting retail-faithful by grounding
|
||||
everything in **live cdb on the retail client** (no guessing). Three fixes shipped;
|
||||
a fourth (Fix D — outdoor objects too bright near torches) is fully grounded but
|
||||
**deliberately NOT implemented** because the math contradicts the observed result —
|
||||
one more capture is needed first.
|
||||
|
||||
## SHIPPED this session (all on `main`)
|
||||
|
||||
| Fix | Commit | What | Result |
|
||||
|---|---|---|---|
|
||||
| **A** | `aa94ced` | point-light SHAPE: per-vertex Gouraud + faithful `calc_point_light` (wrap + norm), per-channel cap | killed the "spotlight" disc — user "way better" |
|
||||
| **B** | `4345e77` | per-OBJECT light selection (`minimize_object_lighting`): each object picks its own ≤8 lights by its AABB sphere, camera-independent | killed "building lights up as you approach"; a Holtburg view has **129** point lights vs the old global cap of 8 |
|
||||
| **C** | `57c1135` | sun-vector magnitude: ambient + sun were **~32% too bright** | ambient now matches retail within ~2%; user "general ambient better outside" |
|
||||
|
||||
**Fix B mechanism** (for context): two new SSBOs in `mesh_modern.vert` — binding=4
|
||||
GLOBAL light array (`LightManager.PointSnapshot`), binding=5 per-instance 8-int
|
||||
light set (mirrors the U.3 clip-slot SSBO). `LightManager.SelectForObject` +
|
||||
`BuildPointLightSnapshot` (pure, TDD). `WbDrawDispatcher` computes each entity's
|
||||
light set once per entity (like `_currentEntitySlot`), threads it parallel to the
|
||||
matrices.
|
||||
|
||||
**Fix C mechanism:** `SkyStateProvider.RetailSunVector` had `y = cos(P)` (≈1) — the
|
||||
PRE-transform value `SkyDesc::GetLighting` writes to its arg5 (0x00500ac9), before
|
||||
`LScape::set_sky_position`'s world transform. cdb read retail's actual
|
||||
`LScape::sunlight = (0.2238, ~0, 0.00352)`, magnitude = DirBright. Corrected to the
|
||||
world-space spherical form `DirBright × (cos P·sin H, cos P·cos H, sin P)`,
|
||||
`|sunVec| == DirBright`. Feeds BOTH the ambient boost AND the sun colour, so it
|
||||
dims **terrain + objects + sky** (all read the shared SceneLighting UBO). 18/18 sky
|
||||
tests green (old tests pinned the inflated magnitude — updated to cdb-verified).
|
||||
|
||||
## KEY LESSON: the "too purple" was NEVER a bug
|
||||
|
||||
The user's side-by-side ("acdream too purple, retail neutral") was a comparison
|
||||
**across different times of day**. Live cdb at the SAME game time + DayGroup proved
|
||||
acdream's time, weather (DayGroup selection), AND ambient COLOR all match retail
|
||||
exactly — the purple `AmbColor=(200,100,255)` is authored per-time-of-day in the
|
||||
sky dat (twilight = purple, midday = neutral `(230,230,255)`). Only the *brightness*
|
||||
was wrong (Fix C). Don't re-investigate the purple.
|
||||
|
||||
---
|
||||
|
||||
## RESOLVED — Fix D: outdoor walls too bright near torches (contradiction settled 2026-06-18)
|
||||
|
||||
**Symptom (user):** Holtburg meeting-hall walls blow out **warm**/bright in acdream
|
||||
vs dim in retail. The contradiction ("D3D-FF math says color×100 should blow WHITE,
|
||||
yet retail is DIM") is **resolved**: the D3D-FF model was the WRONG ORACLE for these
|
||||
walls. Settled by a 5-thread decomp workflow (`wf_f660eb88`) + adversarial verify +
|
||||
4 live cdb captures. **⚠ The "DO NOT port the D3D-FF model" warning still stands** —
|
||||
not because it'd be too bright, but because it's the wrong path entirely.
|
||||
|
||||
### Render path (Ghidra xrefs — unambiguous, two SEPARATE light systems)
|
||||
- **STATIC lights → CPU vertex BAKE.** `RenderDeviceD3D::DrawEnvCell` (0x0059F170) →
|
||||
`D3DPolyRender::SetStaticLightingVertexColors` (0x0059CFE0) → `calc_point_light`
|
||||
(0x0059C8B0, its SOLE caller). Wall torches are STATIC objects → baked into vertex
|
||||
colours. AC town buildings are EnvCell structures, so their walls take this path.
|
||||
- **DYNAMIC lights → D3D hardware FF.** `add_dynamic_light` → `insert_light` (0x0054D1B0)
|
||||
→ `config_hardware_light` (0x0059AD30); `minimize_envcell_lighting` (0x0054C170)
|
||||
enables ONLY the dynamic subset (class 2) for the cell — statics are NEVER hardware-
|
||||
enabled for the cell. (`minimize_object_lighting` 0x0054D480 enables both, for free
|
||||
GfxObjs.) So `config_hardware_light` — where last session's `intensity=100` was seen —
|
||||
carries DYNAMIC lights for cells, not the wall torches.
|
||||
|
||||
### Why retail stays warm-but-DIM (the bake is triple-clamped — `calc_point_light`)
|
||||
Per light: `range = falloff×1.3` hard gate; half-Lambert wrap `(1/1.5)(N·D + 0.5·d)`;
|
||||
`norm = (distsq>1)? distsq·d : d` (~1/d²); `scale = (1−d/range)·intensity·(wrap/norm)`;
|
||||
then the **decisive per-channel cap `result = min(scale·color, color)`** — one light adds
|
||||
**at most its own (sub-1.0, warm) colour**, however large intensity is. Caller sums from
|
||||
**BLACK** (no ambient/sun in the accumulator) over all static lights, then **clamps the
|
||||
sum to [0,1]** per channel before packing `vertex.diffuse`. White needs many in-range
|
||||
lights stacking past 1.0; a hall has a handful, each warm-capped.
|
||||
|
||||
### Live cdb ground truth (4 captures; scripts in `tools/cdb/a7-fixd-*.cdb`)
|
||||
`Render::world_lights` @ **0x008672a0** (LightParms): `num_static_lights` @ +0x104,
|
||||
`sorted_static_lights[]` (RenderLight*, info @ RL+0x70) @ +0x3498, `num_dynamic_lights`
|
||||
@ +0x3588. Captured standing in Holtburg:
|
||||
- **num_static_lights = 38**, **num_dynamic_lights = 2.**
|
||||
- **2 DYNAMIC** (`add_dynamic_light`, d3dIdx 1–2): viewer light `intensity=2.25 falloff=10
|
||||
color=(1,1,1)` white; **PORTAL** `intensity=100 falloff=6 color=(0.784,0,0.784)` MAGENTA.
|
||||
→ **the `intensity=100` light is the purple PORTAL (dynamic/hardware), NOT a wall torch.**
|
||||
- **38 STATIC** wall torches, all `type=0 intensity=100`, **WARM**: orange
|
||||
`(1.0, 0.588, 0.314)` falloff 4, and cream `(0.980, 0.843, 0.612)` falloff 3–5
|
||||
(→ bake range ~3.9–6.5 m). Torches DO carry intensity=100, but the per-channel cap
|
||||
pins each to its warm colour ⇒ retail walls go warm, not white.
|
||||
|
||||
### acdream's actual bug — TWO real causes (both verified in source)
|
||||
- **D-1 (math, primary): unclamped accumulator folding ambient+sun+torches.**
|
||||
`mesh_modern.vert` `accumulateLights` starts `lit = uCellAmbient.xyz` (:184), ADDS
|
||||
sun (:196), ADDS each capped torch (:206), returns UNCLAMPED (:208); the ONLY clamp is
|
||||
one `min(lit,1.0)` in `mesh_modern.frag:92` AFTER a lightning bump (:89). The per-light
|
||||
cap (:180) IS faithful. But pouring ambient + sun + up-to-8 intensity-100 WARM torches
|
||||
into ONE bucket and trimming only at the end overflows to warm-white. Retail clamps the
|
||||
torch sum on its OWN (from black); ambient/sun are a separate term.
|
||||
- **D-2 (state, compounding): EnvCell shell SSBO binding leak.**
|
||||
`EnvCellRenderer.cs:1225-1230` (RenderModernMDIInternal) binds SSBO 0/1/2/3 only, NEVER
|
||||
4 (`gLights`) or 5 (`instanceLightIdx`) — which the shared `mesh_modern.vert` reads at
|
||||
:204-206. Only `WbDrawDispatcher` binds 4/5. Indoor DrawInside interleaves the two, so a
|
||||
cell shell reads whatever LEAKED light set the last WbDrawDispatcher draw left bound —
|
||||
a different entity's torches, wrong per-instance indices ⇒ wrong/over-bright walls.
|
||||
- `LightBake.cs` (verbatim CPU port) exists but is UNWIRED (zero callers); the live path is
|
||||
the in-shader version missing the clamp shape.
|
||||
|
||||
### Fix plan (REPORT-ONLY — implement in a separate session, with the no-workaround rule)
|
||||
- **D-1:** accumulate point/spot into a LOCAL `pointAcc`, `saturate` it to [0,1] BEFORE
|
||||
adding ambient + sun — mirrors `SetStaticLightingVertexColors` (sum-from-black, clamp the
|
||||
point sum). Keep the per-light `min(scale·baseCol, baseCol)` (vert:180). Files:
|
||||
`mesh_modern.vert` (split accumulator + clamp), `mesh_modern.frag` (reorder/drop the
|
||||
single clamp). Conformance golden: a wall ≤~5 m from an orange `(1,0.588,0.314)` torch
|
||||
bakes warm-but-≤[0,1], NOT white.
|
||||
- **D-2:** EnvCell shell must bind binding 4 (global lights) + 5 (per-instance light set)
|
||||
for ITS OWN instances — compute a per-shell set like `WbDrawDispatcher.ComputeEntityLightSet`
|
||||
(LightManager.SelectForObject); option (b) all-(-1) fallback = NO point lights is a STOPGAP
|
||||
(needs approval + a divergence-register row). File: `EnvCellRenderer.cs` RenderModernMDIInternal.
|
||||
- **Stale doc to fix in the D-1 commit:** divergence-register `AP-35` still describes the
|
||||
point-light path as per-pixel `mesh_modern.frag:52` with the wrap "NOT ported"; Fix A
|
||||
(`aa94ced`) moved it to per-vertex `mesh_modern.vert:163` WITH the wrap.
|
||||
- **Do NOT port the D3D-FF hardware model for the walls** (config_hardware_light's
|
||||
color×intensity / (0,1,0)=1/d / Range=falloff×1.5) — it lights GfxObjs/dynamics, not the
|
||||
baked walls.
|
||||
|
||||
---
|
||||
|
||||
## cdb cheat-sheet (all verified this session; binary MATCHES refs/acclient.pdb)
|
||||
- `bp acclient!SmartBox::SetWorldAmbientLight` (0x004530a0) — arg2=level `[esp+4]`, arg3=color32 `[esp+8]`
|
||||
- `bp acclient!SkyDesc::GetLighting` (0x00500a80) — arg2=dayFraction `[esp+4]`; `dt acclient!SkyDesc @ecx present_day_group`
|
||||
- `LScape::sunlight` global @ **0x00841940** (Vector3); `LScape::ambient_level` @ 0x00841770
|
||||
- `bp acclient!PrimD3DRender::config_hardware_light` (0x0059ad30) — arg4=LIGHTINFO `[esp+0x10]`; `dt acclient!LIGHTINFO dwo(@esp+0x10) type intensity falloff color`
|
||||
- `rangeAdjust = 1.5` @ 0x00820cc4; `D3DPolyRender::SetStaticLightingVertexColors` @ 0x0059cfe0
|
||||
- Pattern: `.formats poi(<addr>)` for floats, `dwo(<addr>)` for dwords, `qd` after N hits to auto-detach (keeps retail alive). User must have retail in-world first.
|
||||
- acdream probes: `ACDREAM_PROBE_LIGHT=1` (`[light]` ambient+sun line), `ACDREAM_DUMP_SKY=1` (keyframes + dayFraction + DayGroup).
|
||||
|
||||
## Build / run
|
||||
`dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` (green). Standard
|
||||
`ACDREAM_LIVE` launch env in CLAUDE.md. Close the client before rebuilding (it locks
|
||||
the DLLs). 18/18 sky tests + 17/17 LightManager + 36/36 dispatcher clip-slot green.
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
# A7 Fix D round 2 — REAL cause found (object sun+ambient + torch REACH), CHECKPOINT
|
||||
|
||||
**Date:** 2026-06-19 **Branch:** `claude/thirsty-goldberg-51bb9b` (NOT merged — held at the visual gate)
|
||||
**Predecessor:** `docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md`
|
||||
**Status:** checkpointed at user request after pinning the root cause. D-1..D-4 are committed +
|
||||
correct but **did NOT fix the visible symptom** — they were the wrong subsystem.
|
||||
|
||||
---
|
||||
|
||||
## ✅ RESOLVED 2026-06-19 (second session) — the "torch REACH" theory was WRONG; real cause = retail does NOT torch-light OUTDOOR objects at all
|
||||
|
||||
**The open question is settled, and it overturns this checkpoint's own hypothesis. The fix is NOT
|
||||
"shorten torch reach" — it is "outdoor objects receive NO torches."**
|
||||
|
||||
**Empirical (acdream side, headless dat dump `HoltburgTorchFalloffProbeTests`):** the Holtburg
|
||||
neighbourhood has **27 static lights, raw dat Falloff ∈ {3,5,6}** — the dominant orange entrance
|
||||
torch (setup `0x020005D8`, colour `(1,0.588,0.314)`) is **Falloff 6** (17 of 27). acdream reads
|
||||
this **faithfully** — `LightInfoLoader` just copies `info.Falloff`, no stray ×1.5. There is **NO
|
||||
Falloff-4 torch anywhere in Holtburg**, so the predecessor's "retail orange = falloff 4" could not
|
||||
be a *different* falloff-4 torch. Both clients read the same dat float → acdream's reach is NOT
|
||||
inflated. So "acdream 6 vs retail 4" was a red herring.
|
||||
|
||||
**Decomp (retail side, read verbatim + corroborated by an independent adversarial workflow
|
||||
`wf_07289ba4`):** retail's per-object torch binder `minimize_object_lighting` (0x0054d480) is
|
||||
**gated** in `RenderDeviceD3D::DrawMeshInternal` (0x0059f398) by `if (Render::useSunlight == 0)`.
|
||||
The OUTDOOR landscape stage runs `Render::useSunlightSet(1)` (`PView::DrawCells` 0x005a485a, right
|
||||
before `LScape::draw`), so when the building EXTERIOR shell is drawn
|
||||
(`LScape::draw → DrawBlock 0x005a17c0 → DrawSortCell 0x0059f140 → DrawBuilding 0x0059f2a0 →
|
||||
CPhysicsPart::Draw → DrawMeshInternal`), torches are **SKIPPED** — the only active light is the
|
||||
**sun** (`useSunlightSet(1)` enables `add_active_light(0xffffffff, 0)` = sun + ambient only). The
|
||||
static vertex bake (`SetStaticLightingVertexColors` 0x0059cfe0) is **EnvCell-only** (sole caller
|
||||
`DrawEnvCell` 0x0059f1f6). **So retail lights outdoor objects with SUN + ambient ONLY — never the
|
||||
wall torches.** This exactly explains the checkpoint's own isolation result ("object point lights
|
||||
OFF → building matches retail"): retail's outdoor facade gets ZERO torch energy. (Confirming the
|
||||
non-bug nature of reach: retail's free-object *hardware* path `config_hardware_light` 0x0059ad30
|
||||
uses `Range = falloff × rangeAdjust(1.5)` = LONGER than acdream's ×1.3, with `Diffuse = color×100`
|
||||
and att `1/d` — that would blow the facade WHITE if enabled, which is further proof retail never
|
||||
enables it outdoors.)
|
||||
|
||||
**The three retail lighting regimes (now all mapped):**
|
||||
1. **EnvCell walls** → static bake (`calc_point_light`, range `falloff×1.3`, wrap, capped), no sun.
|
||||
→ acdream mode 1 (EnvCell). ✓ already correct.
|
||||
2. **Indoor objects** (`useSunlight==0`) → torches (hardware, no sun). → acdream mode 0 **indoor**.
|
||||
3. **Outdoor objects** (`useSunlight==1`) → sun + ambient, **NO torches**. → acdream mode 0 **outdoor**.
|
||||
acdream's mode-0 path applied sun **AND** torches to ALL objects — wrong for both 2 and 3.
|
||||
|
||||
**THE FIX (shipped this session):** in `WbDrawDispatcher.ComputeEntityLightSet`, gate per-object
|
||||
torch selection on the object being INDOOR (`ParentCellId` is an EnvCell, `(id&0xFFFF)>=0x0100`)
|
||||
via the pure predicate `IndoorObjectReceivesTorches`. Outdoor objects (building shells — ParentCellId
|
||||
null; outdoor scenery; outdoor creatures) keep the all-(-1) light set ⇒ sun + ambient only = retail.
|
||||
The indoor "no sun" half is already handled by the global sun-kill when the player is inside a cell
|
||||
(`UpdateSunFromSky`, intensity 0). Divergence-register row **AP-43** added (documents the residual:
|
||||
acdream keys sun/torch on the object's own cell + a per-frame player-inside sun-kill, vs retail's
|
||||
per-draw-STAGE `useSunlight` — only matters for through-doorway look-ins). Tests:
|
||||
`WbDrawDispatcherTorchGateTests` (7✓), `HoltburgTorchFalloffProbeTests` (dat dump). Build green;
|
||||
App 280✓/1skip, Core 1486✓/2skip. **Awaiting the user visual side-by-side at Holtburg before merge.**
|
||||
|
||||
**DO-NOT-RETRY (this session's corrections to the checkpoint below):** do NOT shorten torch reach /
|
||||
change `Falloff×1.3` — acdream reads the dat faithfully and the bake reach is correct for EnvCells.
|
||||
The building is an OUTDOOR object; retail gives it no torches. The original checkpoint's "tighten reach
|
||||
to ~5m, keep torches ON" plan (below) is SUPERSEDED — keeping torches ON for the outdoor shell at any
|
||||
reach is the bug.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR — what the visible bug actually is (and is NOT)
|
||||
|
||||
The user's symptom (Holtburg meeting-hall facade too bright/warm/washed-out + character backs
|
||||
lit) is **NOT** the EnvCell bake, the per-channel clamp, the half-Lambert wrap, or the SSBO leak.
|
||||
Those are the D-1..D-4 path. **The visible surfaces are mode-0 OBJECTS**, and the cause is:
|
||||
|
||||
1. **Building facade over-bright** = the **torch REACH is too long** (acdream ~7.8 m vs retail
|
||||
~5.2 m), so each entrance torch floods the whole small facade instead of a tight pool.
|
||||
**CONFIRMED by isolation**: gating object (mode-0) point lights OFF made the building match
|
||||
retail ("looks much better", user 2026-06-19).
|
||||
2. **Character backs / slight object over-bright** = the **sun + ambient on objects** (mode 0
|
||||
runs both). Ambient is NOT the culprit (it MATCHES retail exactly — see values). The residual
|
||||
is small for the character (it ~matches retail), so the dominant visible bug is #1 (torches).
|
||||
|
||||
## Render-path facts (source-verified, workflow `wf_c4ad8cf8`)
|
||||
|
||||
- **Building EXTERIOR** = a flat-mesh `WorldEntity` with `IsBuildingShell=true`, `ParentCellId=null`,
|
||||
built from `BuildingInfo.ModelId` (`LandblockLoader.cs:79-90`), drawn by **WbDrawDispatcher**
|
||||
which hard-sets `uLightingMode=0` (`WbDrawDispatcher.cs:898`). It is **NOT an EnvCell** — so
|
||||
**D-4 (EnvCell walls get no sun) never touched it**.
|
||||
- **Characters/creatures/players** = ordinary `WorldEntity` dynamics, also drawn by
|
||||
WbDrawDispatcher at `uLightingMode=0` (plain Lambert + sun). The mode plumbing is CORRECT
|
||||
(mode-0 plain Lambert already zeroes a torch behind a back-face — that part of D-3 works).
|
||||
- **EnvCellRenderer** (`uLightingMode=1`, no-sun, wrap) only ever draws **interior** cell shells
|
||||
from the dat EnvCell list — never `info.Buildings`, never characters.
|
||||
- Render loop: in-world frames go through `RetailPViewRenderer.DrawInside`; the bare
|
||||
`WbDrawDispatcher.Draw` (GameWindow.cs:8230) is the no-viewer-cell fallback. Both share the
|
||||
ONE `_meshShader` (mesh_modern) program (GameWindow.cs:1845-1857), so `uLightingMode` is one
|
||||
shared uniform; each renderer re-sets it before its draws.
|
||||
|
||||
## Ground truth (live cdb retail + acdream probe, SAME-INSTANT)
|
||||
|
||||
- **Ambient MATCHES exactly**: acdream `(0.447,0.447,0.495)` == retail `(0.4465,0.4465,0.4951)`.
|
||||
→ same sky keyframe → **same time of day; NO time desync** (the earlier "retail 0.3 / acdream
|
||||
purple" was sequential-capture drift + acdream's un-synced spawn frame; ignore it).
|
||||
- **retail sun** (`world_lights.sunlight` @ 0x008672a0+0x18) = `(0.573, ~0, 0.445)`, magnitude
|
||||
**0.725**, colour `(0.98,0.84,0.59)` warm. acdream `sun=1` (active, derived from the same sky
|
||||
state via Fix C `|sunVec|=DirBright`). Sun is NOT zero — retail DOES sun-light objects.
|
||||
- **retail torches** (golden, a7-fixd-golden2): static, `intensity=100`, `falloff 3/4/5`, warm
|
||||
`(1,0.588,0.314)` orange + `(0.98,0.843,0.612)` cream. `calc_point_light` makes a BRIGHT TIGHT
|
||||
pool (saturates to full warm to ~4 m, gone by ~5.2 m). Faithful in acdream (LightBake.cs).
|
||||
- **acdream torches** ([light-detail]): `range=7.8` (Falloff 6×1.3) and `range=6.5` (Falloff 5).
|
||||
acdream `Range = info.Falloff * 1.3f` (`LightInfoLoader.cs:90`) — the 1.3 is correct, NO stray 1.5.
|
||||
|
||||
## The OPEN question to resolve FIRST on resume (don't guess)
|
||||
|
||||
acdream's orange torch reads **Falloff 6** (range 7.8); retail's orange torch was captured at
|
||||
**Falloff 4** (range 5.2). `6 = 4 × 1.5` (smells like rangeAdjust) BUT they **might be different
|
||||
torches** (38 static torches, several orange). **Resolve by comparing the SAME torch's Falloff in
|
||||
acdream vs retail, matched by world position** (one focused capture): break/dump acdream's torch
|
||||
Falloff for a specific Holtburg torch and the retail `world_lights.static_lights[i].info.falloff`
|
||||
for the same one. Then:
|
||||
- If acdream reads a **too-large Falloff** for the same torch → fix the dat read / conversion
|
||||
(the DatReaderWriter `LightInfo.Falloff` path) so acdream's reach == retail's.
|
||||
- If the Falloff matches and reach is genuinely ~7.8 → the building-shell-as-one-object spill is
|
||||
the issue; tighten how building shells receive torches (the per-vertex range gate already
|
||||
localises, so this is unlikely — favour the Falloff hypothesis).
|
||||
|
||||
## Proposed fix (after the falloff is confirmed)
|
||||
|
||||
Tighten acdream's torch reach to match retail (≈5 m), keep torches ON. Building facade then shows
|
||||
a tight warm pool by each flame + dark stone elsewhere (retail-faithful). Files: `LightInfoLoader.cs`
|
||||
(the Falloff→Range conversion), possibly the DatReaderWriter light read. Add a divergence-register
|
||||
row if any conversion deviates. Re-verify visually (the diagnostic that confirmed the cause:
|
||||
object point lights OFF == retail-match).
|
||||
|
||||
## State of the committed work (KEEP — all correct, just off-target for the visible bug)
|
||||
|
||||
| Commit | What | Verdict |
|
||||
|---|---|---|
|
||||
| `180b4af` | D-1 clamp point sum on its own | faithful; keep |
|
||||
| `39c70f0` | D-2 prep — LightBake conformance test | keep |
|
||||
| `cf62793` | D-1 shader clamp | keep |
|
||||
| `c62da82` | D-2 EnvCell shell binds own light set (real leak fix) | keep |
|
||||
| `b57a53e`/`156dc45` | register AP-35/AP-16 corrections | keep |
|
||||
| `0980bea` | D-3 objects plain-Lambert / D-4 EnvCell no-sun | keep; correct but doesn't touch the building (it's an object) |
|
||||
|
||||
`tools/cdb/a7-fixd-*.cdb` capture scripts are committed. **Diagnostic shader hack reverted**
|
||||
(working tree clean). Branch NOT merged — finish the torch-reach fix, visual-verify, then merge.
|
||||
|
||||
## DO-NOT-RETRY (cost a lot this session)
|
||||
|
||||
- Don't re-tune the EnvCell bake / per-channel clamp / wrap / SSBO binding for the building — the
|
||||
building is a mode-0 OBJECT, none of that path lights it.
|
||||
- Don't chase a time-of-day / ambient desync — ambient + time MATCH retail exactly (0.446).
|
||||
- Don't "remove the sun" globally — retail DOES sun-light objects (sun 0.725).
|
||||
- The visible building bug is the **torch REACH** (confirmed by isolation); start there.
|
||||
603
docs/superpowers/plans/2026-06-18-a7-fixd-torch-overbright.md
Normal file
603
docs/superpowers/plans/2026-06-18-a7-fixd-torch-overbright.md
Normal file
|
|
@ -0,0 +1,603 @@
|
|||
# A7 Fix D — torch over-brightness on indoor walls — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make outdoor objects and indoor cell walls near torches render warm-but-bounded like retail, instead of blowing out warm-white.
|
||||
|
||||
**Architecture:** Two orthogonal fixes. **D-1**: in `mesh_modern.vert`, accumulate point/spot lights into their own sum and clamp it to `[0,1]` BEFORE adding ambient+sun (mirrors retail `SetStaticLightingVertexColors`). **D-2**: `EnvCellRenderer` binds its OWN per-cell point-light set (SSBO 4+5) instead of reading the light set `WbDrawDispatcher` last left bound. A shared `GlobalLightPacker` (Core, pure) packs the global-light SSBO so the two renderers can't drift. `LightBake.cs` is the C# conformance oracle.
|
||||
|
||||
**Tech Stack:** C# .NET 10, Silk.NET OpenGL (bindless + MDI SSBOs), GLSL 460. Tests: xUnit in `tests/AcDream.Core.Tests`.
|
||||
|
||||
**Spec:** [`docs/superpowers/specs/2026-06-18-a7-fixd-torch-overbright-design.md`](../specs/2026-06-18-a7-fixd-torch-overbright-design.md)
|
||||
|
||||
**Ground-truth golden (live cdb, Holtburg):** wall torches are `LightKind.Point`, `Intensity=100`, `Range = falloff×1.3` (falloff 3–5 → Range 3.9–6.5 m), warm colours `(1.0, 0.588, 0.314)` orange and `(0.980, 0.843, 0.612)` cream. The per-channel cap pins each torch to its colour ⇒ warm, never white.
|
||||
|
||||
**Pre-flight (every task):** worktree is `C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b` (cwd). Build: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`. Core tests: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj`. The retail client locks the DLLs — it must be closed before a build.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Extract `GlobalLightPacker` (shared, pure) + refactor `WbDrawDispatcher`
|
||||
|
||||
Pull the global-light SSBO float packing out of `WbDrawDispatcher.UploadGlobalLights` into a pure Core helper so `EnvCellRenderer` (Task 4) reuses the exact same layout. No behaviour change.
|
||||
|
||||
**Files:**
|
||||
- Create: `src/AcDream.Core/Lighting/GlobalLightPacker.cs`
|
||||
- Create: `tests/AcDream.Core.Tests/Lighting/GlobalLightPackerTests.cs`
|
||||
- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1813-1848` (`UploadGlobalLights`)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/AcDream.Core.Tests/Lighting/GlobalLightPackerTests.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Lighting;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Lighting;
|
||||
|
||||
public class GlobalLightPackerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Pack_WritesSixteenFloatsPerLight_InTheExpectedLayout()
|
||||
{
|
||||
var light = new LightSource
|
||||
{
|
||||
Kind = LightKind.Point,
|
||||
WorldPosition = new Vector3(10f, 20f, 30f),
|
||||
WorldForward = new Vector3(0f, 0f, 1f),
|
||||
ColorLinear = new Vector3(1.0f, 0.588f, 0.314f),
|
||||
Intensity = 100f,
|
||||
Range = 5.2f,
|
||||
ConeAngle = 0f,
|
||||
};
|
||||
float[] buffer = System.Array.Empty<float>();
|
||||
|
||||
int count = GlobalLightPacker.Pack(new[] { light }, ref buffer);
|
||||
|
||||
Assert.Equal(1, count);
|
||||
Assert.True(buffer.Length >= 16);
|
||||
// posAndKind
|
||||
Assert.Equal(10f, buffer[0]); Assert.Equal(20f, buffer[1]); Assert.Equal(30f, buffer[2]);
|
||||
Assert.Equal((float)(int)LightKind.Point, buffer[3]);
|
||||
// dirAndRange
|
||||
Assert.Equal(0f, buffer[4]); Assert.Equal(0f, buffer[5]); Assert.Equal(1f, buffer[6]);
|
||||
Assert.Equal(5.2f, buffer[7]);
|
||||
// colorAndIntensity
|
||||
Assert.Equal(1.0f, buffer[8]); Assert.Equal(0.588f, buffer[9]); Assert.Equal(0.314f, buffer[10]);
|
||||
Assert.Equal(100f, buffer[11]);
|
||||
// coneAngleEtc
|
||||
Assert.Equal(0f, buffer[12]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pack_NullOrEmpty_ReturnsZero_AndBufferHasAtLeastOneSlot()
|
||||
{
|
||||
float[] buffer = System.Array.Empty<float>();
|
||||
int count = GlobalLightPacker.Pack(null, ref buffer);
|
||||
Assert.Equal(0, count);
|
||||
Assert.True(buffer.Length >= GlobalLightPacker.FloatsPerLight);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter GlobalLightPackerTests`
|
||||
Expected: FAIL — `GlobalLightPacker` does not exist (compile error).
|
||||
|
||||
- [ ] **Step 3: Implement `GlobalLightPacker`**
|
||||
|
||||
Create `src/AcDream.Core/Lighting/GlobalLightPacker.cs`:
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.Core.Lighting;
|
||||
|
||||
/// <summary>
|
||||
/// Packs a point-light snapshot into the flat float layout the bindless mesh
|
||||
/// shader reads at SSBO binding=4 (<c>mesh_modern.vert</c> <c>GlobalLight gLights[]</c>):
|
||||
/// 16 floats (4 vec4) per light — posAndKind, dirAndRange, colorAndIntensity,
|
||||
/// coneAngleEtc. Pure (no GL), so both <c>WbDrawDispatcher</c> and
|
||||
/// <c>EnvCellRenderer</c> share ONE layout and cannot drift.
|
||||
/// </summary>
|
||||
public static class GlobalLightPacker
|
||||
{
|
||||
public const int FloatsPerLight = 16;
|
||||
|
||||
/// <summary>
|
||||
/// Fill <paramref name="buffer"/> (grown + zero-cleared as needed) with the
|
||||
/// packed snapshot; returns the light count <c>n</c>. The buffer always has at
|
||||
/// least <see cref="FloatsPerLight"/> floats (so a zero-light frame still
|
||||
/// uploads a non-empty SSBO). Callers upload <c>max(n,1) * FloatsPerLight</c> floats.
|
||||
/// </summary>
|
||||
public static int Pack(IReadOnlyList<LightSource>? snapshot, ref float[] buffer)
|
||||
{
|
||||
int n = snapshot?.Count ?? 0;
|
||||
int floatsNeeded = Math.Max(n, 1) * FloatsPerLight;
|
||||
if (buffer.Length < floatsNeeded)
|
||||
buffer = new float[floatsNeeded + FloatsPerLight * 16];
|
||||
Array.Clear(buffer, 0, floatsNeeded);
|
||||
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var L = snapshot![i];
|
||||
int o = i * FloatsPerLight;
|
||||
buffer[o + 0] = L.WorldPosition.X;
|
||||
buffer[o + 1] = L.WorldPosition.Y;
|
||||
buffer[o + 2] = L.WorldPosition.Z;
|
||||
buffer[o + 3] = (int)L.Kind;
|
||||
buffer[o + 4] = L.WorldForward.X;
|
||||
buffer[o + 5] = L.WorldForward.Y;
|
||||
buffer[o + 6] = L.WorldForward.Z;
|
||||
buffer[o + 7] = L.Range;
|
||||
buffer[o + 8] = L.ColorLinear.X;
|
||||
buffer[o + 9] = L.ColorLinear.Y;
|
||||
buffer[o + 10] = L.ColorLinear.Z;
|
||||
buffer[o + 11] = L.Intensity;
|
||||
buffer[o + 12] = L.ConeAngle;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter GlobalLightPackerTests`
|
||||
Expected: PASS (2 tests).
|
||||
|
||||
- [ ] **Step 5: Refactor `WbDrawDispatcher.UploadGlobalLights` to use the packer**
|
||||
|
||||
In `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`, replace the body of `UploadGlobalLights` (1813-1848) with:
|
||||
|
||||
```csharp
|
||||
private unsafe void UploadGlobalLights()
|
||||
{
|
||||
int n = AcDream.Core.Lighting.GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
|
||||
int count = n > 0 ? n : 1; // never zero-size
|
||||
fixed (float* gp = _globalLightData)
|
||||
UploadSsbo(_globalLightsSsbo, 4, gp,
|
||||
count * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float));
|
||||
}
|
||||
```
|
||||
|
||||
Leave the `_globalLightData` field declaration (line 145) as-is; the packer grows it.
|
||||
|
||||
- [ ] **Step 6: Build and run the full Core test suite**
|
||||
|
||||
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
|
||||
Then: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj`
|
||||
Expected: build green; all tests pass (no regression — the packing is byte-identical).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.Core/Lighting/GlobalLightPacker.cs tests/AcDream.Core.Tests/Lighting/GlobalLightPackerTests.cs src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
|
||||
git commit -m "refactor(lighting): extract GlobalLightPacker (shared binding=4 layout) — A7 Fix D prep
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Lock the bake contract — `LightBake` conformance test on golden torches
|
||||
|
||||
`LightBake.cs` already implements the correct retail math (per-light cap + sum + `[0,1]` clamp, skip directional). This test pins the contract the D-1 shader change must mirror, using the captured golden torch values. It PASSES against the existing `LightBake` (this is a characterization/lock test — there is no failing-first step because the C# oracle is already correct; the bug lives in GLSL, which is verified by review in Task 3 + the user's visual check).
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the conformance test**
|
||||
|
||||
Create `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Lighting;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Lighting;
|
||||
|
||||
/// <summary>
|
||||
/// Golden conformance for the retail bake (calc_point_light + the [0,1] clamp),
|
||||
/// driven by the live-cdb-captured Holtburg wall torches. Pins the contract that
|
||||
/// mesh_modern.vert's pointContribution + the new pointAcc clamp (A7 Fix D, D-1)
|
||||
/// must mirror line-for-line. See docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md.
|
||||
/// </summary>
|
||||
public class LightBakeConformanceTests
|
||||
{
|
||||
private static LightSource OrangeTorch(Vector3 pos) => new()
|
||||
{
|
||||
Kind = LightKind.Point,
|
||||
WorldPosition = pos,
|
||||
ColorLinear = new Vector3(1.0f, 0.588f, 0.314f), // captured orange
|
||||
Intensity = 100f,
|
||||
Range = 4f * 1.3f, // falloff 4 × static_light_factor
|
||||
IsLit = true,
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[InlineData(1f)]
|
||||
[InlineData(2f)]
|
||||
[InlineData(3f)]
|
||||
[InlineData(4f)]
|
||||
[InlineData(5f)]
|
||||
public void SingleOrangeTorch_IsWarmAndBounded_NeverWhite(float dist)
|
||||
{
|
||||
// Wall vertex at the origin, normal facing the torch (+X). Torch out along +X.
|
||||
var vtx = Vector3.Zero;
|
||||
var normal = Vector3.UnitX;
|
||||
var torch = OrangeTorch(new Vector3(dist, 0f, 0f));
|
||||
|
||||
var c = LightBake.ComputeVertexColor(vtx, normal, new[] { torch });
|
||||
|
||||
// Every channel bounded to [0,1] — intensity=100 must NOT blow to white.
|
||||
Assert.InRange(c.X, 0f, 1f);
|
||||
Assert.InRange(c.Y, 0f, 1f);
|
||||
Assert.InRange(c.Z, 0f, 1f);
|
||||
// Warm hue preserved while lit (R ≥ G ≥ B), matching the torch colour ordering.
|
||||
if (c.X > 0f)
|
||||
{
|
||||
Assert.True(c.X >= c.Y, $"R({c.X}) >= G({c.Y}) at d={dist}");
|
||||
Assert.True(c.Y >= c.Z, $"G({c.Y}) >= B({c.Z}) at d={dist}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeyondRange_ContributesNothing()
|
||||
{
|
||||
var torch = OrangeTorch(new Vector3(100f, 0f, 0f)); // far past Range
|
||||
var c = LightBake.ComputeVertexColor(Vector3.Zero, Vector3.UnitX, new[] { torch });
|
||||
Assert.Equal(Vector3.Zero, c);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManyOverlappingIntenseTorches_StillClampToOne()
|
||||
{
|
||||
// Eight near-white intensity-100 torches all 1.5 m from the vertex: the
|
||||
// [0,1] saturate must hold (no overflow past 1.0 per channel).
|
||||
var lights = new List<LightSource>();
|
||||
for (int i = 0; i < 8; i++)
|
||||
lights.Add(new LightSource
|
||||
{
|
||||
Kind = LightKind.Point,
|
||||
WorldPosition = new Vector3(1.5f, 0.1f * i, 0f),
|
||||
ColorLinear = new Vector3(0.98f, 0.95f, 0.9f),
|
||||
Intensity = 100f,
|
||||
Range = 5.2f,
|
||||
IsLit = true,
|
||||
});
|
||||
|
||||
var c = LightBake.ComputeVertexColor(Vector3.Zero, Vector3.UnitX, lights);
|
||||
Assert.InRange(c.X, 0f, 1f);
|
||||
Assert.InRange(c.Y, 0f, 1f);
|
||||
Assert.InRange(c.Z, 0f, 1f);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test — verify it PASSES on existing LightBake**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter LightBakeConformanceTests`
|
||||
Expected: PASS (7 cases). If any case FAILS, stop — `LightBake` (the oracle) diverges from the expected bake contract and that must be understood before changing the shader. (This is the lock; it should be green.)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs
|
||||
git commit -m "test(lighting): lock the bake contract on golden torches (A7 Fix D oracle)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: D-1 — clamp the torch sum on its own in `mesh_modern.vert`
|
||||
|
||||
Give point/spot lights their own accumulator and saturate it to `[0,1]` before it joins ambient+sun. Mirrors `LightBake.ComputeVertexColor` (Task 2) and retail `SetStaticLightingVertexColors`. The per-light cap and `pointContribution` are untouched. GLSL is not unit-testable in-process — correctness is the line-for-line match to `LightBake` (cite it) plus the user's visual check.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/Rendering/Shaders/mesh_modern.vert:183-209` (`accumulateLights`)
|
||||
|
||||
- [ ] **Step 1: Apply the clamp split**
|
||||
|
||||
Replace the body of `accumulateLights` (183-209) with the following. The ambient base and sun loop are byte-identical; only the point loop changes (own accumulator + `min(pointAcc, 1.0)`):
|
||||
|
||||
```glsl
|
||||
vec3 accumulateLights(vec3 N, vec3 worldPos, int instanceIndex) {
|
||||
vec3 lit = uCellAmbient.xyz;
|
||||
|
||||
// SUN / directional — material-lit term (added with ambient, NOT into the
|
||||
// torch sum), unchanged from before.
|
||||
int activeLights = int(uCellAmbient.w);
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
if (i >= activeLights) break;
|
||||
if (int(uLights[i].posAndKind.w) != 0) continue; // directional only
|
||||
vec3 Ldir = -uLights[i].dirAndRange.xyz;
|
||||
float ndl = max(0.0, dot(N, Ldir));
|
||||
lit += uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w * ndl;
|
||||
}
|
||||
|
||||
// POINT / SPOT torches: their OWN accumulator (A7 Fix D, D-1). Retail's
|
||||
// SetStaticLightingVertexColors sums the static point lights from BLACK and
|
||||
// clamps the SUM to [0,1] before anything else (it is a baked emissive term),
|
||||
// so a few warm intensity-100 torches can't push the whole pixel to white the
|
||||
// way folding them into ambient+sun did. Matches LightBake.ComputeVertexColor
|
||||
// (tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests). Per-light cap
|
||||
// inside pointContribution is unchanged.
|
||||
vec3 pointAcc = vec3(0.0);
|
||||
int base = instanceIndex * 8;
|
||||
for (int k = 0; k < 8; ++k) {
|
||||
int gi = instanceLightIdx[base + k];
|
||||
if (gi < 0) continue;
|
||||
pointAcc += pointContribution(N, worldPos, gLights[gi]);
|
||||
}
|
||||
lit += min(pointAcc, vec3(1.0)); // clamp the torch sum on its own (retail baked emissive)
|
||||
|
||||
return lit; // frag still does the final min(lit, 1.0)
|
||||
}
|
||||
```
|
||||
|
||||
(`mesh_modern.frag:92`'s `lit = min(lit, vec3(1.0))` and the lightning bump at `:89` are unchanged — they remain the final pixel clamp.)
|
||||
|
||||
- [ ] **Step 2: Build**
|
||||
|
||||
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
|
||||
Expected: green. (Shaders are loaded at runtime from disk; the build only confirms nothing else broke.)
|
||||
|
||||
- [ ] **Step 3: Review the math against the oracle**
|
||||
|
||||
Confirm by reading both side-by-side that the shader's point path now matches `LightBake`:
|
||||
- `mesh_modern.vert` `pointContribution` ↔ `LightBake.PointContribution` (range gate, wrap, norm, per-channel `min(scale·col, col)`) — already equal.
|
||||
- new `min(pointAcc, vec3(1.0))` ↔ `LightBake.ComputeVertexColor`'s final `Clamp(·,0,1)` over the point sum.
|
||||
No code change expected here — this is the verification step the commit message cites.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/Rendering/Shaders/mesh_modern.vert
|
||||
git commit -m "fix(render): A7 Fix D D-1 — clamp the point-light sum on its own (#140)
|
||||
|
||||
accumulateLights folded ambient+sun+torches into one accumulator clamped only
|
||||
in the frag, so a few warm intensity-100 torches blew walls/objects to white.
|
||||
Mirror retail SetStaticLightingVertexColors: sum point/spot into pointAcc, clamp
|
||||
to [0,1] (the baked emissive), THEN add ambient+sun, frag final-clamps. Matches
|
||||
LightBake.ComputeVertexColor (LightBakeConformanceTests). Per-light cap unchanged.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: D-2 — `EnvCellRenderer` binds its OWN per-cell light set (SSBO 4+5)
|
||||
|
||||
Stop the cell shell from reading the leaked `WbDrawDispatcher` light set. EnvCellRenderer uploads its own binding-4 global lights (from the frame's `PointSnapshot`, via `GlobalLightPacker`) and a binding-5 per-instance light-set buffer, computing each cell's set with `LightManager.SelectForObject` over the cell's world bounds — mirroring the existing `_cellIdToSlot` per-instance pattern.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs` (fields ~70-110; `AllocateMdiBuffers` 207-236; new setter near 262; `RenderModernMDIInternal` 1007-~1234)
|
||||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs:~7777` (wire the snapshot)
|
||||
|
||||
- [ ] **Step 1: Add fields + the per-frame snapshot setter**
|
||||
|
||||
In `EnvCellRenderer.cs`, near the other scratch-buffer fields (after `_clipSlotBuffer`/`_clipSlotData`, ~line 110), add:
|
||||
|
||||
```csharp
|
||||
// A7 Fix D (D-2): this renderer owns its lighting (self-contained GL state,
|
||||
// like uViewProjection) instead of reading the SSBO 4/5 WbDrawDispatcher last
|
||||
// left bound. binding=4 = global point-light snapshot (same data/indices as the
|
||||
// dispatcher, via GlobalLightPacker); binding=5 = 8 int indices per instance.
|
||||
private uint _globalLightsSsbo; // binding=4
|
||||
private float[] _globalLightData = new float[AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * 16];
|
||||
private uint _instLightSetSsbo; // binding=5
|
||||
private int[] _lightSetData = new int[1024 * AcDream.Core.Lighting.LightManager.MaxLightsPerObject];
|
||||
private System.Collections.Generic.IReadOnlyList<AcDream.Core.Lighting.LightSource>? _pointSnapshot;
|
||||
private readonly System.Collections.Generic.Dictionary<uint, int[]> _cellLightSetCache = new();
|
||||
```
|
||||
|
||||
Near `SetClipRouting` (~262) add the per-frame setter:
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// A7 Fix D (D-2): hand the renderer this frame's point-light snapshot
|
||||
/// (LightManager.PointSnapshot). Call once per frame BEFORE Render, alongside
|
||||
/// the WbDrawDispatcher snapshot wire-in. Indices in the per-cell light sets
|
||||
/// reference this snapshot, which is also uploaded to binding=4 here, so the
|
||||
/// pass is self-contained. Null/empty ⇒ shells receive no point lights.
|
||||
/// </summary>
|
||||
public void SetPointSnapshot(
|
||||
System.Collections.Generic.IReadOnlyList<AcDream.Core.Lighting.LightSource>? snapshot)
|
||||
=> _pointSnapshot = snapshot;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Generate the two SSBOs in `AllocateMdiBuffers`**
|
||||
|
||||
In `AllocateMdiBuffers` (207-236), before the final `_gl.BindBuffer(... 0)` calls (line 234), add:
|
||||
|
||||
```csharp
|
||||
// A7 Fix D (D-2): binding=4 global lights + binding=5 per-instance light set.
|
||||
_gl.GenBuffers(1, out _globalLightsSsbo);
|
||||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _globalLightsSsbo);
|
||||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||||
(nuint)(_globalLightData.Length * sizeof(float)), null, GLEnum.DynamicDraw);
|
||||
|
||||
_gl.GenBuffers(1, out _instLightSetSsbo);
|
||||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _instLightSetSsbo);
|
||||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||||
(nuint)(_modernInstanceCapacity * AcDream.Core.Lighting.LightManager.MaxLightsPerObject * sizeof(int)),
|
||||
null, GLEnum.DynamicDraw);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the per-cell light-set helper**
|
||||
|
||||
Add this private method to `EnvCellRenderer` (e.g. just below `RenderModernMDIInternal`). It returns the cached 8-int set for a cell, computing it once per frame from the cell's world bounds + the snapshot via the static `SelectForObject`:
|
||||
|
||||
```csharp
|
||||
// A7 Fix D (D-2): the up-to-8 point lights reaching a cell, by the cell's world
|
||||
// bounding sphere (camera-independent, like WbDrawDispatcher.ComputeEntityLightSet).
|
||||
// Cached per frame; unused slots are -1 (shader adds no point light there).
|
||||
private int[] GetCellLightSet(uint cellId)
|
||||
{
|
||||
if (_cellLightSetCache.TryGetValue(cellId, out var cached)) return cached;
|
||||
|
||||
var set = new int[AcDream.Core.Lighting.LightManager.MaxLightsPerObject];
|
||||
System.Array.Fill(set, -1);
|
||||
|
||||
var snap = _pointSnapshot;
|
||||
if (snap is { Count: > 0 } &&
|
||||
_landblocks.TryGetValue(cellId & 0xFFFF0000u, out var lb) &&
|
||||
lb.EnvCellBounds.TryGetValue(cellId, out var b))
|
||||
{
|
||||
Vector3 center = (b.Min + b.Max) * 0.5f;
|
||||
float radius = (b.Max - b.Min).Length() * 0.5f;
|
||||
AcDream.Core.Lighting.LightManager.SelectForObject(snap, center, radius, set);
|
||||
}
|
||||
_cellLightSetCache[cellId] = set;
|
||||
return set;
|
||||
}
|
||||
```
|
||||
|
||||
(`WbBoundingBox` has public `Vector3 Min` / `Vector3 Max` — confirmed at `WbFrustum.cs:15-16`.)
|
||||
|
||||
- [ ] **Step 4: Upload binding 4, fill + upload binding 5, and bind both in `RenderModernMDIInternal`**
|
||||
|
||||
(a) At the TOP of `RenderModernMDIInternal` (after the `if (drawCalls.Count == 0 ...) return;` guard, ~1014), clear the per-frame cache:
|
||||
|
||||
```csharp
|
||||
_cellLightSetCache.Clear();
|
||||
```
|
||||
|
||||
(b) Where `_clipSlotData` is filled per instance (1195-1206), add a parallel fill of `_lightSetData` right after it:
|
||||
|
||||
```csharp
|
||||
// A7 Fix D (D-2): per-instance 8-int light set, parallel to the transforms,
|
||||
// keyed on the cell each shell instance belongs to (mirrors _clipSlotData).
|
||||
int lightStride = AcDream.Core.Lighting.LightManager.MaxLightsPerObject;
|
||||
if (_lightSetData.Length < uniqueInstanceCount * lightStride)
|
||||
_lightSetData = new int[System.Math.Max(_lightSetData.Length * 2, uniqueInstanceCount * lightStride)];
|
||||
for (int i = 0; i < uniqueInstanceCount; i++)
|
||||
{
|
||||
int[] cellSet = GetCellLightSet(allInstances[i].CellId);
|
||||
System.Array.Copy(cellSet, 0, _lightSetData, i * lightStride, lightStride);
|
||||
}
|
||||
```
|
||||
|
||||
(c) Where the four buffers are uploaded (the `_clipSlotData` upload ends ~1209-1214), add the binding-4 + binding-5 uploads:
|
||||
|
||||
```csharp
|
||||
// A7 Fix D (D-2): upload binding=4 (global lights) + binding=5 (per-instance set).
|
||||
int lightCount = AcDream.Core.Lighting.GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
|
||||
int glUploadCount = lightCount > 0 ? lightCount : 1;
|
||||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _globalLightsSsbo);
|
||||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||||
(nuint)(glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)),
|
||||
null, GLEnum.DynamicDraw);
|
||||
fixed (float* gp = _globalLightData)
|
||||
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
|
||||
(nuint)(glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)), gp);
|
||||
|
||||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _instLightSetSsbo);
|
||||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||||
(nuint)(uniqueInstanceCount * lightStride * sizeof(int)), null, GLEnum.DynamicDraw);
|
||||
fixed (int* lp = _lightSetData)
|
||||
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
|
||||
(nuint)(uniqueInstanceCount * lightStride * sizeof(int)), lp);
|
||||
```
|
||||
|
||||
(d) In the bind block (1225-1230, after `BindClipRegionBinding2();`), add:
|
||||
|
||||
```csharp
|
||||
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 4, _globalLightsSsbo);
|
||||
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 5, _instLightSetSsbo);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Wire the snapshot from GameWindow**
|
||||
|
||||
In `GameWindow.cs`, immediately after the existing `_wbDrawDispatcher?.SetSceneLights(Lighting.PointSnapshot);` (line ~7777), add:
|
||||
|
||||
```csharp
|
||||
_envCellRenderer?.SetPointSnapshot(Lighting.PointSnapshot); // A7 Fix D (D-2)
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Dispose the new buffers**
|
||||
|
||||
In `EnvCellRenderer.Dispose` (search for the existing `_gl.DeleteBuffer(...)` cleanup), add:
|
||||
|
||||
```csharp
|
||||
if (_globalLightsSsbo != 0) _gl.DeleteBuffer(_globalLightsSsbo);
|
||||
if (_instLightSetSsbo != 0) _gl.DeleteBuffer(_instLightSetSsbo);
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Build**
|
||||
|
||||
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
|
||||
Expected: green. Fix any `WbBoundingBox` field-name or namespace mismatches surfaced by the compiler.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs src/AcDream.App/Rendering/GameWindow.cs
|
||||
git commit -m "fix(render): A7 Fix D D-2 — EnvCell shell binds its own per-cell light set (#140)
|
||||
|
||||
The cell shell read whatever light set (SSBO 4/5) WbDrawDispatcher last left
|
||||
bound, lighting walls with a leaked set. EnvCellRenderer now uploads its own
|
||||
binding=4 global lights (frame PointSnapshot via GlobalLightPacker) + a binding=5
|
||||
per-instance set, computed per cell by LightManager.SelectForObject over the
|
||||
cell's world bounds (mirrors _cellIdToSlot + WbDrawDispatcher.ComputeEntityLightSet).
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Divergence register — correct AP-35, reconcile the Fix B row
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/architecture/retail-divergence-register.md` (AP-35 row, line ~134; the Fix B per-object-light-selection row)
|
||||
|
||||
- [ ] **Step 1: Correct AP-35**
|
||||
|
||||
Find the `AP-35` row. It currently describes the point-light path as per-pixel
|
||||
`mesh_modern.frag:52` with the half-Lambert wrap "neither ported". Rewrite the row to
|
||||
reflect reality after Fix A + Fix D D-1:
|
||||
- Path is per-vertex Gouraud in `mesh_modern.vert` (`pointContribution` ~:153, wrap ~:163), not per-pixel `frag`.
|
||||
- The half-Lambert wrap + the `norm` (`distsq·d`) attenuation ARE ported (vert + `LightBake.cs`).
|
||||
- The point-light sum is now clamped to `[0,1]` on its own (D-1), matching `SetStaticLightingVertexColors`.
|
||||
- Update the `file:line` to `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` and cite `LightBake.cs` as the conformance oracle.
|
||||
|
||||
- [ ] **Step 2: Reconcile the Fix B per-object-light-selection row**
|
||||
|
||||
Find the row describing Fix B (per-object 8-light selection by sphere overlap vs
|
||||
retail's per-vertex sum over the full static list — `minimize_object_lighting`
|
||||
0x0054d480). Confirm its wording now covers EnvCell **shells** too (D-2 selects per
|
||||
cell-sphere via the same `SelectForObject`). If it only mentions GfxObjs, extend the
|
||||
"file:line" / description to include `EnvCellRenderer.GetCellLightSet`. Do NOT add a
|
||||
new contradicting row.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/architecture/retail-divergence-register.md
|
||||
git commit -m "docs(register): correct AP-35 (per-vertex+wrap ported, point sum clamped) — A7 Fix D
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final verification (after all tasks)
|
||||
|
||||
- [ ] `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` green.
|
||||
- [ ] `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` green (GlobalLightPacker + LightBakeConformance + no regressions).
|
||||
- [ ] **Visual (user, acceptance gate):** launch the client against live ACE, go to Holtburg. Confirm (a) outdoor objects near torches no longer blow out warm-white, and (b) the meeting-hall walls render warm-but-dim like retail. This is the sign-off the spec requires.
|
||||
- [ ] Update `docs/ISSUES.md` / roadmap if #140 is tracked there (move to Recently closed with the commit SHAs once the user signs off visually).
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- **No D3D-FF port.** Do not touch `config_hardware_light`-style `color×intensity / 1/d / Range×1.5` math — it is the wrong oracle for the baked walls (handoff warning).
|
||||
- **No CPU bake.** `LightBake.cs` stays the test oracle only; the runtime path is the in-shader clamp (chosen approach).
|
||||
- **Self-contained GL state.** EnvCellRenderer must bind binding 4 + 5 ITSELF every draw (per `feedback_render_self_contained_gl_state`); do not assume WbDrawDispatcher left them bound — that leak is the bug.
|
||||
- **Don't touch the purple portal** — confirmed correct.
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
# A7 Fix D — warm torch over-brightness on indoor walls (#140)
|
||||
|
||||
**Date:** 2026-06-18 **Milestone:** M1.5 (Indoor world feels right) → A7 lighting
|
||||
**Status:** design approved (user pre-approved 2026-06-18); ready for implementation plan.
|
||||
**Investigation source of truth:**
|
||||
[`docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md`](../../research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md)
|
||||
(RESOLVED section) + `claude-memory/reference_retail_ambient_values.md`.
|
||||
|
||||
## Problem
|
||||
|
||||
The Holtburg meeting-hall walls (and outdoor objects near torches) blow out
|
||||
**warm/bright** in acdream vs **dim** in retail. Fix A/B/C (shipped) did not touch this.
|
||||
|
||||
The handoff "contradiction" (D3D-FF math `color×100×N·L/d` says walls should go WHITE,
|
||||
yet retail is DIM) is **resolved**: the D3D-FF hardware model is the **wrong oracle**
|
||||
for these walls. Two SEPARATE retail light systems (Ghidra xrefs, unambiguous):
|
||||
|
||||
- **STATIC lights → CPU vertex BAKE**: `DrawEnvCell` (0x0059F170) →
|
||||
`SetStaticLightingVertexColors` (0x0059CFE0) → `calc_point_light` (0x0059C8B0, its
|
||||
SOLE caller). Wall torches are STATIC objects → baked into vertex colours.
|
||||
- **DYNAMIC lights → D3D hardware FF**: `add_dynamic_light` → `config_hardware_light`
|
||||
(0x0059AD30); `minimize_envcell_lighting` (0x0054C170) enables ONLY the dynamic
|
||||
subset for a cell. The previously-captured `intensity=100` light is on THIS path.
|
||||
|
||||
`calc_point_light` is mathematically **bounded**: range gate `d < falloff×1.3`; the
|
||||
decisive **per-channel cap `min(scale·color, color)`** (a torch adds at most its own
|
||||
sub-1.0 colour, any intensity); caller sums from **BLACK** then clamps the sum to
|
||||
`[0,1]` (no ambient/sun in the bake accumulator). White needs many in-range lights;
|
||||
a hall has a handful, each warm-capped.
|
||||
|
||||
### Ground truth (live cdb, `tools/cdb/a7-fixd-*.cdb`; `Render::world_lights` @ 0x008672a0)
|
||||
|
||||
Holtburg: **38 static + 2 dynamic** lights.
|
||||
|
||||
| Light | path | type | intensity | falloff | colour (r,g,b) |
|
||||
|---|---|---|---|---|---|
|
||||
| viewer light | dynamic / HW | point | 2.25 | 10 | (1, 1, 1) white |
|
||||
| **portal** | dynamic / HW | point | **100** | 6 | **(0.784, 0, 0.784) magenta** ← the captured "intensity=100"; NOT a wall torch |
|
||||
| 38× wall torch | static / **bake** | point | 100 | 3–5 | **(1.0, 0.588, 0.314) orange** / (0.980, 0.843, 0.612) cream |
|
||||
|
||||
Torches carry `intensity=100` too, but the per-channel cap pins each to its warm
|
||||
colour ⇒ retail walls go warm, never white.
|
||||
|
||||
## Root cause in acdream (both verified in source)
|
||||
|
||||
Two independent bugs, both touching the meeting-hall walls; this spec fixes both.
|
||||
|
||||
**D-1 (math, primary): unclamped accumulator folding ambient + sun + torches.**
|
||||
[`mesh_modern.vert`](../../../src/AcDream.App/Rendering/Shaders/mesh_modern.vert)
|
||||
`accumulateLights` starts `lit = uCellAmbient.xyz` (:184), adds sun (:196), adds each
|
||||
capped torch (:206), returns UNCLAMPED (:208); the only clamp is `min(lit,1.0)` in
|
||||
`mesh_modern.frag:92` after a lightning bump. The per-light cap (vert:180) is faithful.
|
||||
But pouring ambient + sun + up-to-8 intensity-100 WARM torches into ONE bucket and
|
||||
trimming only at the end overflows to warm-white. Retail clamps the torch sum on its
|
||||
OWN (from black); ambient/sun are a separate material-lit term.
|
||||
|
||||
**D-2 (state, compounding): EnvCell shell SSBO binding leak.**
|
||||
[`EnvCellRenderer.RenderModernMDIInternal`](../../../src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs)
|
||||
binds SSBO 0/1/2/3 only, NEVER **4** (`gLights`) or **5** (`instanceLightIdx`) — which
|
||||
the shared `mesh_modern.vert` reads unconditionally (:204-206). Only `WbDrawDispatcher`
|
||||
binds 4/5. Indoor `DrawInside` interleaves the two, so a cell shell reads whatever
|
||||
LEAKED light set the last `WbDrawDispatcher` draw left bound (a different entity's
|
||||
torches, wrong per-instance indices) ⇒ wrong/over-bright walls.
|
||||
|
||||
`LightBake.cs` (verbatim CPU port of the bake) exists but is UNWIRED (zero callers).
|
||||
|
||||
## Design
|
||||
|
||||
Decisions (user, 2026-06-18): **D-1 = small in-shader clamp split** (not a CPU bake);
|
||||
**D-1 + D-2 land together**, single visual verification.
|
||||
|
||||
### D-1 — clamp the torch sum on its own (mirrors `SetStaticLightingVertexColors`)
|
||||
|
||||
In `mesh_modern.vert` `accumulateLights`, give point/spot lights their own accumulator,
|
||||
saturate it to `[0,1]` BEFORE it joins ambient + sun. The per-light cap and
|
||||
`pointContribution` are unchanged; the only new operation is one `min(pointAcc, 1.0)`.
|
||||
|
||||
```glsl
|
||||
vec3 accumulateLights(vec3 N, vec3 worldPos, int instanceIndex) {
|
||||
// ambient + sun = retail's material-lit term
|
||||
vec3 lit = uCellAmbient.xyz;
|
||||
int activeLights = int(uCellAmbient.w);
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
if (i >= activeLights) break;
|
||||
if (int(uLights[i].posAndKind.w) != 0) continue; // directional only
|
||||
vec3 Ldir = -uLights[i].dirAndRange.xyz;
|
||||
float ndl = max(0.0, dot(N, Ldir));
|
||||
lit += uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w * ndl;
|
||||
}
|
||||
// point/spot torches: their OWN accumulator, clamped to [0,1] (retail baked emissive)
|
||||
vec3 pointAcc = vec3(0.0);
|
||||
int base = instanceIndex * 8;
|
||||
for (int k = 0; k < 8; ++k) {
|
||||
int gi = instanceLightIdx[base + k];
|
||||
if (gi < 0) continue;
|
||||
pointAcc += pointContribution(N, worldPos, gLights[gi]); // per-light cap unchanged
|
||||
}
|
||||
lit += min(pointAcc, vec3(1.0)); // <-- THE FIX
|
||||
return lit; // frag still does final min(lit, 1.0)
|
||||
}
|
||||
```
|
||||
|
||||
Behaviour change is confined to surfaces whose torch sum currently exceeds 1.0 —
|
||||
normally-lit surfaces are byte-identical (no regression). Shared by every mesh using
|
||||
this shader (outdoor objects AND cell walls), matching the issue's scope.
|
||||
`mesh_modern.frag:92`'s final `min(lit, 1.0)` stays as-is (it clamps the total to the
|
||||
retail FF pixel clamp). The lightning bump (frag:89) is unaffected.
|
||||
|
||||
### D-2 — the EnvCell shell binds its OWN light set
|
||||
|
||||
`EnvCellRenderer` must own its lighting like `WbDrawDispatcher` does, instead of reading
|
||||
leaked SSBO state. Mirror `WbDrawDispatcher`'s proven pattern
|
||||
(`ComputeEntityLightSet`/`AppendCurrentLightSet`/`UploadGlobalLights`):
|
||||
|
||||
1. **Wire `LightManager` in** via `Initialize(...)` (alongside `_shader`). Self-contained
|
||||
pass — per `feedback_render_self_contained_gl_state`, EnvCellRenderer already
|
||||
re-uploads its own `uViewProjection`; it now also uploads/binds its own lights.
|
||||
2. **Binding 4 (global lights):** upload `LightManager.PointSnapshot` itself, packed
|
||||
identically to `WbDrawDispatcher.UploadGlobalLights` (the `GlobalLight` SSBO layout:
|
||||
`posAndKind`, `dirAndRange`, `colorAndIntensity`, `coneAngleEtc`). Same snapshot →
|
||||
same indices both renderers reference. `BuildPointLightSnapshot` is already called
|
||||
once per frame before rendering. **Extract the packing into a shared helper** so the
|
||||
two renderers cannot drift (a `GlobalLightPacker` in `AcDream.App/Rendering/Wb/` or a
|
||||
static on the snapshot type) — do not copy-paste the struct layout.
|
||||
3. **Binding 5 (per-instance light set):** per **cell** (keyed on `allInstances[i].CellId`),
|
||||
compute the set ONCE with `LightManager.SelectForObject(snapshot, cellCenter,
|
||||
cellRadius, set)` (camera-independent; cache per CellId, reuse for all that cell's
|
||||
part-instances — like `WbDrawDispatcher` reuses one set per entity). Write the 8-int
|
||||
set per instance into a new buffer parallel to `_gpuInstanceTransforms` (same shape
|
||||
as `_clipSlotData`); bind at binding 5. On a no-lights frame, fill -1 (shader adds no
|
||||
point light) and still bind a ≥1-element buffer so the SSBO is never unbound.
|
||||
4. **Cell centre/radius:** world-space bounding sphere of the cell geometry — reuse the
|
||||
cell's existing visibility bound (the BSP/AABB sphere already computed for culling).
|
||||
The exact field is pinned during planning by reading the cell-storage structs in
|
||||
`EnvCellRenderer` / `EnvCellLandblock`; fallback = centre from the cell-part transform
|
||||
translation, radius from the cell vertex AABB. **This is the one detail to confirm
|
||||
against code in the plan.**
|
||||
|
||||
Order independence: D-1 and D-2 are orthogonal (shader math vs buffer binding) and can
|
||||
be implemented in either order, but ship together.
|
||||
|
||||
## Testing (TDD)
|
||||
|
||||
`LightBake.cs` already encodes the correct math: `PointContribution` = per-light capped
|
||||
(matches `mesh_modern.vert` pointContribution line-for-line), `ComputeVertexColor` = sum
|
||||
reaching point lights → clamp `[0,1]`, skip directional. The new shader `pointAcc` clamp
|
||||
mirrors `ComputeVertexColor`'s final clamp exactly.
|
||||
|
||||
New conformance test in `tests/AcDream.Core.Tests/` (e.g. `LightBakeConformanceTests`):
|
||||
|
||||
- **Golden warm torch, bounded:** an orange `(1, 0.588, 0.314)` `intensity=100`
|
||||
`falloff=4` (Range = 4×1.3 = 5.2 m) torch lighting a wall vertex (facing it) at
|
||||
d = 1, 2, 3, 4, 5 m → result is warm (R ≥ G ≥ B, hue preserved) and **every channel
|
||||
≤ 1.0** (never white); at d ≥ Range the contribution is 0 (range gate).
|
||||
- **No-blowout under stacking:** 8 overlapping `intensity=100` near-white torches summed
|
||||
via `ComputeVertexColor` → each channel clamps to ≤ 1.0 (the `[0,1]` saturate holds).
|
||||
- **Hue preserved:** a single orange torch's bounded result keeps B < G < R (warm), not
|
||||
desaturated toward white.
|
||||
|
||||
These pin the contract the shader must match. GLSL is not unit-testable in-process
|
||||
(standard for this project per the render digest); the shader `pointContribution` +
|
||||
`pointAcc` clamp are matched to `LightBake` by **line-for-line review** with the C#
|
||||
oracle as the pinned reference (call it out in the implementation commit).
|
||||
|
||||
## Bookkeeping — divergence register
|
||||
|
||||
- **Correct stale row AP-35** (`docs/architecture/retail-divergence-register.md`): it
|
||||
describes the point-light path as per-pixel `mesh_modern.frag:52` with the half-Lambert
|
||||
wrap "NOT ported". Reality since Fix A (`aa94ced`): per-vertex Gouraud in
|
||||
`mesh_modern.vert:163` WITH the wrap ported. Update the row to match; the D-1 clamp
|
||||
makes the accumulator MORE faithful (no new deviation introduced).
|
||||
- **EnvCell shell per-cell 8-light selection** (D-2) inherits Fix B's existing
|
||||
per-object approximation (retail bakes per-VERTEX over the full static list; acdream
|
||||
selects up to 8 per cell-sphere then gates per-vertex in-shader). Confirm Fix B's
|
||||
register row covers EnvCell shells; extend that row if needed — do NOT add a
|
||||
contradicting row.
|
||||
|
||||
## Files
|
||||
|
||||
- `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` — D-1 clamp split.
|
||||
- `src/AcDream.App/Rendering/Shaders/mesh_modern.frag` — verify final clamp stays correct
|
||||
(expected no change).
|
||||
- `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs` — D-2: `LightManager` ref, per-cell
|
||||
light sets, bind SSBO 4 + 5, per-instance light-set buffer.
|
||||
- `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (+ a shared `GlobalLightPacker`) —
|
||||
extract the binding-4 global-lights packing so both renderers share it.
|
||||
- `src/AcDream.App/Rendering/GameWindow.cs` — wire `LightManager` into
|
||||
`EnvCellRenderer.Initialize` (minimal).
|
||||
- `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs` — new.
|
||||
- `docs/architecture/retail-divergence-register.md` — AP-35 update.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `dotnet build` green; `dotnet test` green including the new conformance test.
|
||||
- Conformance test passes on the captured golden torch values (warm, bounded, hue-preserved).
|
||||
- Shader `pointContribution` + new `pointAcc` clamp reviewed line-for-line against
|
||||
`LightBake` (cited in the commit).
|
||||
- AP-35 corrected; any D-2 register note reconciled with Fix B's row.
|
||||
- **Visual (user):** outdoor objects near torches no longer blow out warm-white, and the
|
||||
Holtburg meeting-hall walls render warm-but-dim like retail.
|
||||
|
||||
## Out of scope (explicit)
|
||||
|
||||
- **Do NOT port the D3D-FF hardware model** (`config_hardware_light`'s
|
||||
`color×intensity`, `(0,1,0)=1/d`, `Range=falloff×1.5`) — it lights GfxObjs/dynamics,
|
||||
not the baked walls. Wrong oracle (handoff warning stands).
|
||||
- **Do NOT** wire the CPU vertex bake (`LightBake.cs` as the runtime path) — chosen
|
||||
approach is the in-shader clamp split. `LightBake.cs` stays the test oracle.
|
||||
- Sun handling on indoor walls is unchanged (kept in the material-lit term as today);
|
||||
any "should indoor walls receive sun at all" refinement is a separate question.
|
||||
- The purple portal is correct — do not touch it.
|
||||
Loading…
Add table
Add a link
Reference in a new issue