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

27 KiB
Raw Blame History

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).