acdream/docs/research/2026-06-11-holistic-map/wf2-indoor-lighting.md
Erik 5e2f99d08e docs: Phase A comparison + Phase B port plan (holistic building-render investigation)
Deliverable 1: docs/research/2026-06-11-building-render-acdream-vs-retail-
comparison.md - the acdream-vs-retail architecture comparison synthesized
from two ultracode mapping fan-outs (11/12 areas, ~90 agents, every retail
claim Ghidra/pc-cited, every acdream claim file:line, 40/76 divergences
adversarially verified so far; raw per-area evidence committed under
docs/research/2026-06-11-holistic-map/).

Headline findings: (1) retail flattens GfxObjs/cells at load exactly like
us (ConstructMesh + RemoveNonPortalNodes) - the MDI pipeline survives;
(2) the phantom/door mechanism is the skipNoTexture draw-time surface gate
(dat-confirmed); (3) retail never geometrically clips world geometry -
aperture exactness is a DEPTH discipline (punch maxZ1 / seal maxZ2 / gated
clear + far-to-near whole-mesh draws) - reframes #114; (4) flood admission
is already faithful, the trigger/depth/multi-view/cone-culling layers are
missing; (5) #115 root cause verified (boom damping severed from the
published collided viewer); collision A6.P4 design verified with
corrections (signed other_portal_id >= 0 gate).

Deliverable 2: docs/plans/2026-06-11-building-render-port-plan.md - the
phased port plan (BR-1 surface gate, BR-2 depth punch/seal, BR-3 delete
the shell chop, BR-4 draw-driven floods, BR-5 viewconeCheck, BR-6 one
gate, BR-7 collision A6.P4, BR-8 camera/lighting/LOD) with per-phase
acceptance criteria, bug closures, keep-list, and a playable-after-every-
phase migration order. AWAITING USER APPROVAL - no implementation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 05:54:12 +02:00

76 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 2.2 — Lighting discipline for buildings/interiors (sun gating, per-cell lights, fog, shells, dynamic objects)
## RETAIL
RETAIL LIGHTING — data structures first. A light source in retail is a LIGHTINFO: { type (0=point, 1=directional), Frame offset, viewerspace_location, RGBColor color, intensity, falloff (= hard range in meters), cone_angle } (acclient.h:31688-31697). Lights do NOT live in the EnvCell dat: CEnvCell::UnPack (Ghidra 0x0052d470) reads flags/surfaces/portals/stab-list/static-objects/restriction and reads NO lights; its `RGBColor *light_array` field (acclient.h:32084) is allocated at load as `new RGBColor[structure->vertex_array.num_vertices]` — one color PER VERTEX, computed, not dat-sourced (Ghidra 0x0052d470 end). The actual light sources come from SETUP files: CSetup has `num_lights` + `LIGHTINFO *lights` (acclient.h:31137-31138) — torches, braziers, lamps are Setup objects whose dat carries the lights. At object init, CPartArray::InitLights (pc:287036 @0x518c00) wraps them in a LIGHTLIST and CPartArray::AddLightsToCell (pc:285959 @0x517ea0) registers each LIGHTOBJ {lightinfo, Frame global_offset, int state} (acclient.h ~31265) into the owning CELL's light_list via CObjCell::add_light (pc:308535 @0x52b1d0). So lights are cell-resident.
Per-frame global list: each cell pushes its lights into a global, distance-sorted pool — CObjCell::add_static_to_global_lights (pc:308636 @0x52b350; LIGHTOBJ.state&1 → Render::add_static_light(lightinfo, cell m_DID.id, frame)) and add_dynamic_to_global_lights (pc:308656 @0x52b390). Render::add_static_light/add_dynamic_light (pc:343907/343915 @0x54d3e0/0x54d420) call insert_light which keeps `world_lights.sorted_static_lights` / `sorted_dynamic_lights` sorted by distancesq; capacity defaults: max_static_lights=40 (pc:1101871 @0x81ec94), max_dynamic_lights=7 (pc:1101872 @0x81ec98). Each pooled entry is a RenderLight {_D3DLIGHT9, d3dLightIndex, cellID, LIGHTINFO info, distancesq} (acclient.h:38944-38951). The pool is rebuilt around the player: CellManager::ChangePosition (pc:94660-94670 @0x455a98) zeroes num_static/num_dynamic on every player cell change, and SmartBox::set_viewer calls CObjCell::add_dynamic_lights() each frame (pc:91828 @0x452d30).
Per-DRAW selection — the heart of the discipline. The hardware has 8 fixed-function light slots (Render::curLightUsage[0..7], reset_active_lights_state pc:342626 @0x54be00). Three selectors: (a) Render::useSunlightSet(1) (pc:343923 @0x54d450) makes the SUN the sole active light (special index 0xffffffff, lightClass 0); the sun's D3D light is rebuilt lazily in PrimD3DRender::config_hardware_light (@0x59ad30; rebuild at pc:424103-424120 @0x59b4ed: Diffuse = sunlight_color × |sunlight vector|, Direction = sunlight, gated by m_bSunlightValid). (b) Render::minimize_object_lighting (pc:343939 @0x54d480) — per OBJECT: fills ≤8 slots with dynamic lights first (filtered by remove_object_light's sphere test: light falloff sphere vs the object's bounding sphere, pc:342820 @0x54c1b0), then static lights whose falloff sphere intersects the object (local_object_center/radius test pc:343985-344000). (c) Render::minimize_envcell_lighting (pc:342794 @0x54c170) — per CELL: enables ALL dynamic lights only (statics excluded — see burn-in below). enable_active_lights (pc:342746 @0x54c080) then issues device SetFFLight/SetFFLightEnable per slot.
Q1 — how interior geometry is lit: RenderDeviceD3D::DrawEnvCell (pc:427877-427910 @0x59f170) calls minimize_envcell_lighting(cell pos, drawing-BSP sphere radius) then, on the built-mesh path (use_built_mesh=1 at runtime, set in UnPack), calls D3DPolyRender::SetStaticLightingVertexColors(constructed_mesh, &cell->pos) (pc:425771-425935 @0x59cfe0). That function LOCKS the cell's vertex buffer and, for EVERY vertex, accumulates the contribution of EVERY static light in world_lights.sorted_static_lights (no 8-light cap!) — each light converted into cell-local space (LIGHTINFO::convert_to_local) and evaluated as a point light (calc_point_light) or directional, clamped to [0,1] per channel, and WRITTEN INTO THE VERTEX DIFFUSE COLORS. It is cached via MeshBuffer.burnedInStaticLights (pc:425933 @0x59d2ca) and re-burned only when num_static_lights changes. At draw, D3DPolyRender::DrawMesh switches the fixed-function ambient source to FromVertex for burned meshes (pc:425691 @0x59cea2) vs FromMaterial (pc:425535 @0x59cbc4) — i.e. the burned per-vertex static lighting acts as the ambient term, with the ≤7 dynamic FF lights added in hardware on top. So: interior static lighting = CPU per-vertex burn-in of ALL static lights; dynamic = real-time FF lights; sun = NEVER (below).
Q2 — sun/ambient gating, two independent gates. GATE A (per-draw-class, every frame): PView::DrawCells (@0x5a4840, Ghidra-verified decompile) — if the flood reached outside (outside_view.view_count != 0): useSunlightSet(1) → LScape::draw (landscape+buildings through the portals, sun ON); then unconditionally useSunlightSet(0) + restore_all_lighting; loop DrawEnvCell (interior geometry, sun OFF); loop DrawObjCellForDummies (objects per cell, sun OFF → each object goes through minimize_object_lighting per DrawMeshInternal pc:427983 @0x59f398 `if (useSunlight == 0) minimize_object_lighting()`); finally useSunlightSet(1) restore. SmartBox::RenderNormalMode (pc:92635-92686 @0x453aa0): outdoor viewer → set_default_view + useSunlightSet(1) + LScape::draw; indoor viewer → DrawInside(viewer_cell) with no sun-set (interiors get the DrawCells discipline). NET: interior cell geometry and interior objects are NEVER sun-lit, even when the player stands outside looking in; landscape seen through a doorway from inside IS sun-lit, in the same frame. GATE B (per player-cell change): CellManager::ChangePosition (pc:94600-94720 @0x4559b0) — if the new player cell is outdoor OR seen_outside: copy LScape::sunlight(+color) into world_lights and SetWorldAmbientLight(LScape::calc_object_light(), LScape::ambient_color) where calc_object_light = sqrt(|sunlight|)×0.2 + region ambient_level (pc:94400-94406 @0x455730); if the cell is SEALED (seen_outside==0): SetWorldAmbientLight(0.2f, 0xffffffff) — flat 0.2 white ambient (pc:94711 @0x455af4). SetWorldAmbientLight stores ambient_color = color×level into world_lights (pc:91995-92011 @0x4530a0).
Q3 — fog indoors: distance fog is a single global fixed-function state (GraphicsStatesType.DistanceFogColor/Near/Far, acclient.h:38975-38990). Parameters come from the region + weather in LScape::UseTime (@0x505880; override fog color/levels pc:267378-267447) and the enable is the player option (PlayerModule::DisableDistanceFog → LScape::m_fFogEnabled pc:423368-423372 @0x59a6c2; SetFFFogEnable pc:267436 @0x505ada). NOTHING in the interior path toggles fog — the Ghidra decompiles of DrawCells/DrawEnvCell/DrawInside contain no fog calls — so the SAME outdoor distance fog applies to interior fragments; interiors are simply too close to the camera for it to matter. One per-surface exception: surface-type bit 0x10000 disables fog-alpha for that surface (pc:425295 @0x59c882).
Q4 — building shells from outside: drawn during the sun-ON pass — RenderDeviceD3D::DrawBuilding (pc:427930-427960 @0x59f2a0) → CPhysicsPart::Draw → DrawMeshInternal with useSunlight==1, so minimize_object_lighting is SKIPPED and the shell is lit by the directional sun FF light + the global ambient. No baked lighting on shells. Additionally every surface's diffuse scalar is tinted by the sun color when the sun is on: Render::diffuse = surface.diffuse × sunlight_color, and Render::luminosity = surface.luminosity (emissive) — pc:425150-425175 @0x59c64c-0x59c699. (Terrain, for contrast, IS precomputed per-vertex: CLandBlockStruct::calc_lighting @0x531700, pc:315648+, sun+ambient.) Day/night on shells = the sun light's diffuse magnitude (|sunlight| changes over the day) + ambient.
Q5 — dynamic objects inside: drawn via DrawObjCellForDummies inside DrawCells (sun OFF) → per-part DrawMeshInternal → minimize_object_lighting: up to 8 FF lights chosen vs the OBJECT's bounding sphere — all reaching dynamic lights first, then reaching static (cell torch) lights. Plus the global flat/region ambient from Gate B. Retail ALSO adds a per-frame "viewer light": SmartBox::set_viewer (pc:91781-91835 @0x452c40) adds a white point light (init pc:93342-93356 @0x4547e8: type=point, color 0xffffffff, cone 360; falloff default 10 m pc:1088428 @0x818610; intensity from the registry option "SmartBox.ViewerLightIntensity", set to 0.5×4.5=2.25 at pc:761995 @0x6e9a7c) positioned 2 m above the player, registered as a dynamic light EVERY frame — the classic "personal light bubble" that keeps dungeons readable.
## ACDREAM
ACDREAM LIGHTING — one global 576-byte UBO per frame, one lighting state for everything. Data: LightSource {Kind, WorldPosition, ColorLinear, Intensity, Range (hard cutoff), ConeAngle, OwnerId, IsLit} (src/AcDream.Core/Lighting/LightSource.cs:39-53). LightManager (src/AcDream.Core/Lighting/LightManager.cs:37-137) is a flat global registry — NO cell association — whose Tick(viewerWorldPos) filters point/spot lights by Range²×1.1², sorts by distance-to-VIEWER, reserves slot 0 for the Sun, and takes the next 7. SceneLightingUbo.Build (src/AcDream.Core/Lighting/SceneLightingUbo.cs:103-134) packs those ≤8 lights + CellAmbient + FogParams/FogColor + camera into a std140 block uploaded once per frame by SceneLightingUboBinding at binding=1 (src/AcDream.App/Rendering/SceneLightingUboBinding.cs:23-59; upload at GameWindow.cs:7378).
Light SOURCES are read from the dat correctly in shape: LightInfoLoader.Load (src/AcDream.Core/Lighting/LightInfoLoader.cs:35-91) converts Setup.Lights (LightInfo: color/intensity/falloff/cone) into LightSources at the entity root (no per-part chain yet). Registration happens once per landblock load in GameWindow.ApplyLoadedTerrainLocked over lb.Entities (GameWindow.cs:6082-6108) — and the merged entity list includes interior EnvCell statics (merged.AddRange(BuildInteriorEntitiesForStreaming) at GameWindow.cs:5272-5275), so inn torches/fireplaces DO register. LightingHookSink (src/AcDream.Core/Lighting/LightingHookSink.cs:41-77) tracks owner→lights and flips IsLit on SetLightHook. Server-spawned entities (EntitySpawnAdapter path) and held items never register lights — LightInfoLoader has exactly one call site (GameWindow.cs:6099).
Indoor/outdoor switch (the GameWindow write site): the lighting root is the PLAYER cell (GameWindow.cs:7291-7296 — playerRoot from CellGraph.CurrCell, playerSeenOutside = playerRoot?.SeenOutside ?? true); playerInsideCell = playerRoot != null && !playerSeenOutside (GameWindow.cs:7337). UpdateSunFromSky(kf, playerInsideCell) (GameWindow.cs:9741-9785): if playerInsideCell — Sun zeroed (ColorLinear=0, Intensity=0) and CurrentAmbient = flat (0.2, 0.2, 0.2) (GameWindow.cs:9752-9763, explicitly citing retail ChangePosition @0x4559B0); else — Sun = sky-keyframe SunColor and CurrentAmbient = kf.AmbientColor (GameWindow.cs:9772-9783). Then Lighting.Tick(camPos) (GameWindow.cs:7353) and the single UBO build/upload (7354-7378). FogParams are overridden with streaming-radius-derived distances (N₁×192×0.7 / N₂×192×0.95, GameWindow.cs:7364-7376, deliberate A.5 T22).
Shading: ONE lighting model for every mesh — building shells, interior cell geometry, statics, NPCs, the player — all through mesh_modern.frag's accumulateLights (src/AcDream.App/Rendering/Shaders/mesh_modern.frag:26-63): lit = uCellAmbient + Σ active lights (directional N·L for kind 0 — the sun; hard-range point/spot otherwise), clamp 1.0, × texture, then distance fog (65-74, 109-120). The EnvCellRenderer (interior cell geometry) explicitly SHARES this shader (src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:50-55, wired at GameWindow.cs:1835-1841), and neither RetailPViewRenderer.cs nor InteriorRenderer.cs touches any lighting state (grep: zero light/ambient/sun references). Terrain uses per-vertex sun N·L + ambient from the same UBO (terrain_modern.vert:146-149). Surface Luminosity/Diffuse scalars are extracted into AcSurfaceMetadata (src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs:277) but consumed ONLY by SkyRenderer (src/AcDream.App/Rendering/Sky/SkyRenderer.cs:264, 339-340) — the world mesh path ignores them. There is no per-vertex static burn-in, no per-cell or per-object light selection, no viewer light, and no per-draw sun gating: whatever the player-cell switch decided applies to every fragment drawn that frame.
## DIVERGENCES
### [CRITICAL] interior-sun-bleed (UNVERIFIED (verifier hit token limit)) — Interior cell geometry is sun-lit whenever the player-cell gate says 'outside' — retail NEVER sun-lights interiors
- blastRadius: The single biggest 'indoor world feels right' (M1.5 mandate) lighting gap. In acdream, standing outdoors or inside any seen_outside interior (ALL surface-building interiors: Holtburg inn, cottages), interior walls/floors receive full directional sun N·L through the roof — sun-facing interior walls bright, others dark, and the whole interior re-shades as the day passes. It also flattens the retail outside-looking-in depth cue (interiors should read distinctly darker/flatter than the sun-lit exterior through a doorway — feeds the #114 doorway-quality complaint). In retail the same wall is lit only by burned-in torch light + ambient, regardless of where the player stands.
- retailEvidence: PView::DrawCells @0x5a4840 (Ghidra-verified): useSunlightSet(1) ONLY around LScape::draw for the outside view, then unconditionally useSunlightSet(0) before the DrawEnvCell loop and the DrawObjCellForDummies loop, useSunlightSet(1) restored at the end — interior geometry and interior objects are never drawn with the sun active, even in the same frame where the outdoors is. SmartBox::RenderNormalMode pc:92635-92686 @0x453aa0 shows the outdoor branch is the only place sun is set ON at top level. The player-cell switch (CellManager::ChangePosition pc:94600-94720 @0x4559b0) gates only the AMBIENT level and sun VALIDITY, not whether interiors receive sun — that is gated per draw-class by Render::useSunlight (DrawMeshInternal pc:427983 @0x59f398).
- acdreamEvidence: One global UBO per frame: UpdateSunFromSky keys the sun on playerInsideCell = player cell !seenOutside (GameWindow.cs:7337, 9741-9785) — seen_outside interiors keep FULL sun (comment at GameWindow.cs:7334 says so explicitly). EnvCellRenderer shares mesh_modern.frag (EnvCellRenderer.cs:50-55, GameWindow.cs:1835-1841) whose accumulateLights applies the slot-0 directional sun to every fragment (mesh_modern.frag:34-44). No per-pass lighting state exists (RetailPViewRenderer.cs / InteriorRenderer.cs: zero lighting references).
- portShape: Split the lighting state per draw-class, not per frame — the modern equivalent of useSunlightSet. Keep ONE UBO but upload it (or a second UBO/uniform toggle) twice per frame: outdoor pass = sun + outdoor ambient; interior pass (EnvCellRenderer + interior-partition entities) = sun slot zeroed + Gate-B ambient (outdoor-formula ambient for seen_outside player cells, 0.2 flat for sealed). The flood already knows which draws are interior (EnvCellRenderer + InteriorEntityPartition), so the seam is small: re-upload the 576-byte UBO before/after the interior block in the frame, exactly mirroring DrawCells' useSunlightSet(1)→LScape / useSunlightSet(0)→cells+objects ordering.
### [HIGH] no-static-light-burnin (UNVERIFIED (verifier hit token limit)) — No per-vertex static-light burn-in for interior cells — interiors capped at the 8 viewer-nearest lights instead of ALL static lights
- blastRadius: Interior light quality/coverage: retail interiors accumulate EVERY reaching static light (torches, fireplaces, lamps — no 8-light cap) into vertex colors, so a many-torch inn is evenly lit. acdream funnels ALL lighting through 8 global slots ranked by distance-to-camera: in a room with >7 point lights some torches simply don't light; lights also pop in/out as the camera moves and re-ranks the global list. Directly the 'interiors look uneven/flat/wrong' component of M1.5.
- retailEvidence: D3DPolyRender::SetStaticLightingVertexColors pc:425771-425935 @0x59cfe0: locks the cell mesh vertex buffer and per-vertex accumulates ALL world_lights.sorted_static_lights (loop bound = num_static_lights, capacity 40 per pc:1101871 @0x81ec94) via LIGHTINFO::convert_to_local + calc_point_light, clamped [0,1], written as vertex diffuse; cached by burnedInStaticLights (pc:425933 @0x59d2ca). Called from RenderDeviceD3D::DrawEnvCell pc:427904 @0x59f1f6. At draw the FF ambient source switches to FromVertex (pc:425691 @0x59cea2). Dynamic lights ride on top via minimize_envcell_lighting (pc:342794 @0x54c170, dynamic-only).
- acdreamEvidence: Interior cells draw with the same global-8 UBO as everything else (EnvCellRenderer.cs:50-55 sharing mesh_modern; accumulateLights mesh_modern.frag:34-63 reads uLights[8] only). LightManager.Tick picks 7 points by distance-to-viewer (LightManager.cs:95-137). No burn-in, no per-cell color buffer anywhere in the pipeline.
- portShape: Port the burn-in: at cell registration (EnvCellRenderer.RegisterCell / mesh build) compute per-vertex RGB from all registered static lights reaching the cell (same point-light falloff math as calc_point_light), store as a per-vertex color attribute on the cell mesh, and have the cell shader use it as the ambient/base term (retail FromVertex). Re-burn when the static light set changes (retail's burnedInStaticLights == num_static_lights cache key; acdream equivalent: a registry generation counter). Dynamic lights stay in the UBO path. This also removes interiors' dependence on the global 8-slot budget.
### [MEDIUM] no-per-object-light-selection (UNVERIFIED (verifier hit token limit)) — Light selection is per-FRAME from the camera, not per-OBJECT against object bounds
- blastRadius: Objects/NPCs away from the camera get the camera's lights, not their own: an NPC at the dark end of a long hall is lit by the torches near the camera (or by none), and lights pop as the camera's nearest-8 ranking churns. Retail picks lights per object via falloff-sphere vs object-bounds tests, so each object is lit by what actually reaches it. Visible in any interior with more than ~7 lights or large rooms; part of the M1.5 feel gap (no numbered issue yet).
- retailEvidence: Render::minimize_object_lighting pc:343939-344012 @0x54d480: per object, ≤8 slots, dynamic lights first (filtered by remove_object_light's sphere-overlap test vs local_object_center/local_object_radius pc:342820 @0x54c1b0), then static lights whose falloff sphere intersects the object (pc:343975-344000). Invoked per mesh draw when sun is off: DrawMeshInternal pc:427983 @0x59f398.
- acdreamEvidence: LightManager.Tick(camPos) runs once per frame from the camera position (GameWindow.cs:7353; LightManager.cs:95-137); every draw call reads the same uLights[8] (mesh_modern.frag:26-32). No per-object or per-cell selection exists.
- portShape: Per-instance light indices: keep the global pool (sorted, ~40 statics + 7 dynamics like retail), and per entity (or per cell for the cell-object partition) select ≤8 reaching lights by the retail sphere tests on the CPU, writing 8 light indices into the per-instance SSBO (InstanceData has a documented extension hook). Shader indexes a larger light array (e.g. 64-entry UBO/SSBO) by those per-instance indices. The burn-in divergence above removes most of the pressure; this one covers objects/NPCs.
### [MEDIUM] no-viewer-light (UNVERIFIED (verifier hit token limit)) — Retail's per-frame viewer light (white point light above the player) is missing
- blastRadius: Dungeon/dark-interior readability: retail always adds a white point light (falloff 10 m, intensity from the 'ViewerLightIntensity' option, ~2.25 at default slider) 2 m above the player, re-registered as a dynamic light every frame — the personal light bubble. acdream sealed interiors get only flat 0.2 ambient + whatever cell torches exist; dark dungeons will read much darker/deader than retail. Directly an 'indoor world feels right' item for dungeon milestones.
- retailEvidence: SmartBox::set_viewer pc:91781-91835 @0x452c40: viewer_light intensity/falloff loaded from SmartBox::s_fViewerLightIntensity/Falloff, offset z=+2 m above the player (pc:91817-91819), Render::add_dynamic_light(&viewer_light, player objcell_id, player frame) + CObjCell::add_dynamic_lights() every frame (pc:91827-91828). Init: type=point, color 0xffffffff, cone 360 (pc:93342-93356 @0x4547e8); falloff default 10 (pc:1088428 @0x818610); intensity set 0.5×4.5=2.25 via the registry option (pc:761995 @0x6e9a7c).
- acdreamEvidence: No viewer/player light anywhere: LightManager has only Sun + registered Setup lights (LightManager.cs:37-137); the only LightSource creation sites are LightInfoLoader.Load (GameWindow.cs:6099) and UpdateSunFromSky's sun (GameWindow.cs:9752, 9772).
- portShape: One LightSource (Point, white, Range 10 m, Intensity 2.25 default — future user option) owned by GameWindow, repositioned to player position +2 m Z every frame before Lighting.Tick, registered as a permanent dynamic light. ~20 lines; pairs naturally with the per-object selection work but is independently shippable.
### [MEDIUM] surface-luminosity-diffuse-ignored (UNVERIFIED (verifier hit token limit)) — Per-surface luminosity (emissive) and diffuse scalars ignored in the world mesh path; sun-tint of surface diffuse missing
- blastRadius: Glowing surfaces — lamp panes, lava, light-fixture textures, glowing runes — render dark indoors because their dat Luminosity never becomes emissive light; interior light FIXTURES look off even where the light they cast is right. Outdoors, retail also tints every surface's diffuse by the current sun color (warm dawn/dusk cast on buildings) which acdream approximates only via the sun light's color in N·L, losing the flat diffuse-scalar modulation.
- retailEvidence: Surface setup @0x59c64c-0x59c699 (pc:425150-425175): Render::luminosity = surface.luminosity (r=g=b, the emissive term), and Render::diffuse = surface.diffuse × sunlight_color when useSunlight==1, else surface.diffuse flat. Same pattern confirmed in the sky path (SkyRenderer.cs:340 cites retail FUN_0059da60 'surface.Luminosity → D3DMATERIAL.Emissive').
- acdreamEvidence: WbMeshAdapter extracts Translucency/Luminosity/Diffuse into AcSurfaceMetadata (src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs:277, AcSurfaceMetadata.cs:17) but the only consumer is SkyRenderer (SkyRenderer.cs:264, 339-340); mesh_modern.frag has no luminosity/diffuse-scalar input (whole file — lighting is texture × accumulateLights only).
- portShape: Extend the per-batch SSBO (uvec2 handle, layer, flags) with luminosity+diffuse floats from the already-populated AcSurfaceMetadataTable; in mesh_modern.frag, lit = max(lit, luminosity) or lit += luminosity (match retail: emissive floors the lighting term), and multiply the diffuse scalar (×sun color in the outdoor pass) into the diffuse contribution. Localized to WbDrawDispatcher/EnvCellRenderer batch upload + one shader edit.
### [LOW] dynamic-entity-lights-unregistered (UNVERIFIED (verifier hit token limit)) — Server-spawned and held entities never register Setup lights (and lights don't follow animated parts)
- blastRadius: A server-spawned lamp/brazier weenie casts no light; a player- or NPC-held torch casts no light (and when LightInfoLoader is used, lights sit at the entity root, not the hand part — acknowledged in-code). Limited M1.5 impact since inn/dungeon lighting is mostly dat statics, but it diverges from retail where ANY physics object with Setup lights lights its cell.
- retailEvidence: CPartArray::InitLights pc:287036 @0x518c00 runs for any part array (creatures included — note SmartArray<LIGHTINFO*> creature_mode_lights acclient.h:52564); CPartArray::AddLightsToCell pc:285959 @0x517ea0 registers into whatever cell the object occupies; LIGHTLIST::set_frame pc:285756 @0x517c60 re-frames lights as the object moves, add/remove per cell crossing (pc:285976/286005).
- acdreamEvidence: LightInfoLoader.Load has exactly one call site — the landblock-load loop over lb.Entities (GameWindow.cs:6082-6108); the server-spawn path (EntitySpawnAdapter) and equip/hold paths register nothing. LightInfoLoader.cs:30-33 documents the missing per-part transform.
- portShape: Call LightInfoLoader.Load + LightingHookSink.RegisterOwnedLight at server-entity spawn (the EntitySpawnAdapter / GpuWorldState add sites) and UnregisterOwner on despawn (sink already supports it, LightingHookSink.cs:54-59); reposition owned lights from the entity tick (GetOwnedLights exists for exactly this, LightingHookSink.cs:65-68). Per-part placement waits for the animation part-chain work.
## OPEN QUESTIONS
- Who WRITES CEnvCell::light_array (the per-vertex RGBColor buffer)? Ghidra confirms allocation in CEnvCell::UnPack (0x0052d470: new RGBColor[num_vertices]) and the destructor frees it (pc:311207-311213 @0x52d9e2), but I could not locate the writer/consumer by name — likely the legacy non-built-mesh (immediate poly) path's equivalent of the burn-in. At EoR runtime use_built_mesh=1 (set in UnPack under DBCache::IsRunTime), so SetStaticLightingVertexColors on the constructed mesh is the live path and light_array appears vestigial — but a faithful-port plan should confirm before declaring it dead.
- Exact data flow from world_lights.ambient_color to the D3D ambient render state per draw: SetFFAmbientColor32 is called at 0x0059b165 (pc:423997) inside the PrimD3DRender lighting region with a packed ARGB I did not trace to its source registers; the shape (global ambient applied per draw from world_lights) is solid but the precise call chain (and whether ambient differs between the sun-on and sun-off passes beyond Gate B) is unverified.
- Render::restore_all_lighting (called in DrawCells between the sun-off switch and the DrawEnvCell loop, Ghidra decompile) was not decompiled — assumed to restore the FF slot state cleared by useSunlightSet(0); worth one decompile during the port to make sure it doesn't re-enable lights the port should replicate.
- Default/typical value of SmartBox.ViewerLightIntensity in practice: the static initializer is 0 (pc:1144644 @0x83cc10) and 0x006e9a7c sets 0.5×4.5=2.25 — I did not identify whether that site is the options-default path (always runs) or only when the user touches a slider; affects how bright the ported viewer light should default.
- Whether retail's Gate-B outdoor ambient formula (sqrt(|sunlight|)×0.2 + region ambient_level, × region ambient color) materially differs from acdream's sky-keyframe kf.AmbientColor at representative times of day — acdream's outdoor ambient was visual-verified, but a side-by-side at dawn/noon/midnight would settle whether the formula needs porting for interior seen_outside cells (where it becomes the dominant light after the sun-bleed fix).
- LightSource.cs:58's comment claims 'for indoor cells the EnvCell dat carries a per-cell ambient override (r13 §3)' — CEnvCell::UnPack shows no such field; the r13 research doc appears wrong on this point and the comment should be corrected during the port (no per-cell ambient exists in retail; ambient is the global Gate-B switch).