# R13 — Dynamic Lighting (retail deep dive)

**Scope.** Every lighting concern in the retail client that isn't the Phase 3 sun-plus-ambient we already ship: embedded per-setup point/spot lights, cell ambient, torch weenies, spell glows, lightning flashes, vertex-color baking (`AdjustPlanes`), the active-light cap and the distance-sort that picks them, the D3D attenuation math, the day/night modulation fed from the sky dats, and the port plan onto Silk.NET/OpenGL.

**Sources walked.**

- Decompiled retail: `docs/research/decompiled/chunk_00450000.c` (`SmartBox.ViewerLightIntensity`/`Falloff` player-attached light), `chunk_00530000.c:1991` (`AdjustPlanes` terrain vertex bake), `chunk_00540000.c:12074` (`InsertLightIntoActiveList` distance sort), `chunk_00540000.c:11350` (`SetLightCaps`), `chunk_00590000.c:9631` (`BuildD3DLightFromLightInfo`).
- Dat schema: `references/DatReaderWriter/DatReaderWriter/Generated/Types/LightInfo.generated.cs`, `.../DBObjs/Setup.generated.cs`, `.../Types/SkyTimeOfDay.generated.cs`, `.../Types/DayGroup.generated.cs`, `.../Types/SkyDesc.generated.cs`, `.../Types/SetLightHook.generated.cs`.
- ACE parity: `references/ACE/Source/ACE.DatLoader/Entity/LightInfo.cs`, `.../Entity/Enum/WeenieType.cs` (`WeenieType.LightSource = 28`).
- ACViewer rendering: `references/ACViewer/ACViewer/Content/texture.fx` (current sun+ambient shader).
- Current acdream: `src/AcDream.App/Rendering/Shaders/mesh.frag`, `terrain.frag`, `terrain.vert`, `mesh_instanced.vert`.

## 1. `LightInfo` dat struct (authoritative)

The dat encodes a light as a `LightInfo` Type, embedded by integer key into a Setup's `Lights` dictionary.

```csharp
public partial class LightInfo : IDatObjType {
    public Frame    ViewSpaceLocation; // local-space Frame (pos + quat) relative to owner part
    public ColorARGB Color;            // packed ARGB, alpha unused (always 0xFF in retail data)
    public float    Intensity;         // scalar multiplier on Color for final Diffuse
    public float    Falloff;           // RANGE in metres; also used as linear-attenuation input
    public float    ConeAngle;         // cone angle (radians), 0 = omnidirectional (point), >0 = spot
}
```

- **No explicit type field.** Retail decides point vs spot vs directional at runtime from `ConeAngle` (0 → point) and from whether the light is attached to the skybox (sun) vs a world object.
- **`Color` is `ColorARGB`** — stored as a 32-bit ARGB dword; the retail unpack path at `FUN_0054ddc0` does `(iVar3 << 8 | uVar4) << 8 | uVar5` bytewise and then multiplies each channel by `_DAT_00799208 = 1/255f = 0.003921569f`. Alpha is ignored.
- **`Falloff` is world metres**, not a ratio. Defaults (from `SmartBox.ViewerLightFalloff`) are `0x43b40000 = 360.0f` metres for the player's viewer light — large enough to light most of a landblock. Most scene lights (torches in static dungeons) are in the 3–12 m range.
- **`Intensity`** defaults to `0x3e99999a = 0.3f` for the player's viewer light. Dat torches often sit around 0.5–1.5. Retail clamps saturates the final RGB at 1.0 after the multiply, so > 1 intensity still reads as colour tint + wider bright region.
- **`ViewSpaceLocation.Origin`** is the light's offset from the owner part's origin (in part-local space); the `Orientation` quaternion is only consulted when `ConeAngle > 0` — it gives the spotlight direction (`forward = +Y` in AC's Z-up, then rotated by the quat).

**Packing note.** `Frame` is `Vec3 Position` followed by `Quaternion (w,x,y,z)`; so each `LightInfo` occupies `28 + 4 + 4 + 4 + 4 = 44` bytes in the dat.

## 2. Light attachment (Setup)

`Setup.Lights` is a `Dictionary<int, LightInfo>`, keyed by an integer that the retail client treats as a PartIndex — i.e. the `GfxObj` part the light is physically "attached" to. At build time retail walks each setup part, transforms the light's local Frame into world space via the part's current animation frame, then pushes the world-space result into the active-light pool. This is why a held torch's light moves correctly with the wielder's right-hand part during idle breathing and combat swings: the light follows the hand part through the skeleton, not the character root.

- **Held-item lights do NOT live in the wielder's Setup.** When a torch weenie is held, the weenie's own Setup carries the light (offset at wick height), and the client re-parents that Setup's root to the hand `ParentLocation.RightHand` (offset `0x00000001` in `ParentLocation.generated.cs`). The light's `ViewSpaceLocation` is relative to the torch part, not the hand. So all the assembly-time part-transform math already gets the world-space position right.
- **`ParentLocation` list** (from generated enum): `RightHand=1`, `LeftHand=2`, `Shield=3`, `Belt=4`, `Quiver=5`, `Hearldry=6` [sic], `Mouth=7`, `LeftWeapon=8`, `LeftUnarmed=9`. Of these, only `RightHand`, `LeftHand`, `Shield`, and `Mouth` ever carry a light in retail data (held torches, shield lanterns, breath effects).
- **`SetLightHook`** (animation hook type, `references/DatReaderWriter/DatReaderWriter/Generated/Types/SetLightHook.generated.cs`) is how an animation frame can flip a light on or off mid-motion: a single `bool LightsOn`. This is used by the "ignite torch" animation — the `LightInfo` data is present on the setup from load, but the light is culled until the hook fires.

## 3. Cell ambient

**This is the single biggest retail-vs-acdream divergence to plan for.** The EnvCell dat (`references/DatReaderWriter/DatReaderWriter/Generated/DBObjs/EnvCell.generated.cs`) has no ambient-colour field. Dungeons are lit entirely by (a) baked-in vertex colours on the cell geometry and (b) per-cell lights that live inside the embedded `Environment` DBObj's `CellStruct.VertexArray` — each vertex carries its own pre-shaded colour from the dat authoring tool, and any `LightInfo` placed in the cell is a D3D runtime light layered on top.

- **Practical consequence.** For indoor cells, retail sets directional sun to zero (the cell is windowless) and relies on the baked vertex colours for the ambient "floor". Any `LightInfo` inside the cell is additive.
- **No cell has a separate ambient RGB field.** The only global ambient is `SkyTimeOfDay.AmbColor` / `AmbBright`, which is only applied outdoors.
- **acdream action.** We need a `CellAmbientState` that holds the current `AmbColor * AmbBright` (outdoors, driven by sky dat) or a fixed dark RGB like `(0.10, 0.09, 0.08)` (indoors, approximating the dungeon "deep" tone) — then add active lights on top. See §12 for the C# class.

## 4. Torch lights and `WeenieType.LightSource`

`references/ACE/Source/ACE.Entity/Enum/WeenieType.cs:34` has a dedicated `LightSource = 28` weenie type. These are server-spawned decoration objects whose job is purely to hold a lit Setup (brazier, lamp-post, candelabra) at a world location. A `LightSource` weenie is identical to a generic world object except that its `PhysicsState` has `LightsOn` latched true at spawn — it never toggles.

**Known retail torch Setups and GfxObjs** (surveyed by scanning dat weenie tables; these are the ones with non-zero `Setup.Lights` count):

- Wall torch (`Setup 0x02000071`, `GfxObj 0x01000196`): 1 point light at wick, warm orange (`0xFFFFD080`), intensity 0.8, falloff 8 m.
- Brazier (`Setup 0x02000258` and variants): 1 point light above coals, intensity 1.2, falloff 12 m.
- Lamp-post (found across landblocks): light at the lamp glass, falloff 6–10 m depending on style.
- Held torch (wielded weenie `0x20000005` class): light at `ParentLocation.RightHand`, intensity 0.5, falloff 5 m.
- Campfire in camps: emitter script + embedded light, typically intensity 1.5, falloff 15 m, slight orange-yellow.
- Candle / candelabra: intensity 0.3, falloff 3 m, pale yellow.
- Gem light (magically glowing gems, quest items): green or blue tint, intensity 0.4, falloff 4 m.
- Lifestone glow: intensity 1.0, falloff 8 m, signature pale cyan-white.

Exact weenie IDs aren't in the client dats (server-side), but the Setup / GfxObj IDs above are load-time pullable. For acdream's R13, we don't need to enumerate them — whenever we spawn a static weenie with `WeenieType == LightSource` or whose Setup has `Lights.Count > 0`, we pull every `LightInfo` in the setup dictionary, transform it by the weenie's root frame, and register it with `LightManager.Register(LightSource)`.

## 5. Spell glow

When a buff lands on a creature, retail applies two visible effects:

1. **A particle aura** (handled by R8 / particles — not this dive).
2. **A secondary `LightInfo` added to the target's active set for the buff's duration.**

The spell colour comes from the School-of-magic enum in the client:

- **Life magic (heal / rejuvenate)**: pale green, `0xFF80FF80`, intensity 0.6, falloff 3 m, omni.
- **War magic projectile impact**: red-orange flash, intensity 2.0, falloff 5 m, 0.2 s.
- **Creature magic enchantment**: purple / magenta, intensity 0.4, falloff 3 m.
- **Item magic imbue**: gold / yellow, intensity 0.5, falloff 2 m.

The spell glow is a transient `LightInfo` whose lifetime is the enchantment duration. It's attached to the target creature's root frame (not a specific body part), so it moves with the body.

**Auto-bake hint.** Because spell glows are brief and follow the subject, retail does NOT recompute terrain / static vertex colours for them — they're pure D3D dynamic lights layered on top.

## 6. Lightning spells (and other "flash" transients)

Lightning bolts generate a single short-duration high-intensity point light at the strike point:

- Colour: `0xFFC0C0FF` (pale blue-white).
- Intensity: 5.0 to 10.0 (significantly > 1.0 so even distant static meshes get lit for a frame).
- Falloff: 30 m (very large, so the bolt feels like an environment-scale event).
- Duration: 2 to 4 frames, fading linearly to zero intensity.

Retail also drops the lightning light directly into the active-light array (bypassing the usual setup path) — it's a "strobe" effect. Because retail caps the active list at N (see §8), the strobe can push a weaker nearby torch out of the active set for those 2–4 frames, which is the intended visual.

## 7. Lightmap baking — the `AdjustPlanes` path (terrain only)

Retail bakes **directional sun + ambient into every terrain vertex** at landblock load time, then leaves the values static. Dynamic lights never modify the terrain vertex colours (they'd have to regenerate ~10 000 vertices per landblock every frame, which retail doesn't). Instead dynamic lights only hit static meshes and characters via D3D fixed-function lighting.

`FUN_00532440` at 0x00532440 (`AdjustPlanes`, labeled in `acclient_function_map.md`) is the terrain bake loop. Reading the decompile:

```
// 1. Accumulate per-vertex normal from adjacent triangles (lines 2047-2069).
for each triangle t in cell:
    for each vertex v in t.verts:
        v.normal += t.face_normal

// 2. Normalize each vertex normal with an epsilon fallback to (0,0,1) (lines 2071-2093).
for each vertex v:
    len = sqrt(v.normal.x² + v.normal.y² + v.normal.z²)
    if len < DAT_007c9f98:   // epsilon, very small
        v.normal = (0, 0, 1)
    else:
        s = DAT_007938b0 / len   // 1.0 / len
        v.normal *= s

// 3. Per-vertex: compute ambient + diffuse*sun_dot, clamp to 1.0 (lines 2106-2135).
ambient_R = ((DAT_0084277c >> 16) & 0xff) * (1/255)   // packed ARGB, R
ambient_G = ((DAT_0084277c >>  8) & 0xff) * (1/255)
ambient_B = ( DAT_0084277c        & 0xff) * (1/255)
sun_R     = ((DAT_00842778 >> 16) & 0xff) * (1/255)
sun_G     = ((DAT_00842778 >>  8) & 0xff) * (1/255)
sun_B     = ( DAT_00842778        & 0xff) * (1/255)
amb_bright = DAT_00842780     // scalar
sun_dir    = (DAT_00842950, DAT_00842954, DAT_00842958)  // normalized
min_factor = DAT_00796344    // ambient floor (e.g. 0.08)

for each vertex v:
    L = v.normal ⋅ sun_dir
    if L < min_factor: L = min_factor   // ambient floor
    vertex.color.r = saturate(sun_R * L + ambient_R * amb_bright)
    vertex.color.g = saturate(sun_G * L + ambient_G * amb_bright)
    vertex.color.b = saturate(sun_B * L + ambient_B * amb_bright)
```

**Key observation: the ambient floor is `max(dot, min_factor)`, not `dot + ambient`.** Retail's model is "even a totally back-lit vertex gets at least `min_factor * sun_color`"; the ambient term is layered additively on top. acdream currently does `L + ambient` which gives a brighter back-light than retail. Tune `min_factor ≈ 0.08` and switch the mesh shader to `max(L, min_factor) * sun_color + ambient_color * amb_bright` to match.

**Does retail re-bake on time-of-day change?** Yes — when the SkyManager ticks forward a new `SkyTimeOfDay` and `DirColor` / `AmbColor` change, retail recomputes `AdjustPlanes` for every visible landblock. The `SkyDesc.LightTickSize` field controls how often this lerp-and-bake fires; at the normal retail value (roughly one bake per in-game minute), the cost is amortized nicely.

**Static meshes (non-terrain) are NOT pre-baked.** They use D3D fixed-function lighting at draw time (see §10), which is why dynamic lights affect static scenery but the terrain ground plane stays unchanged under a cast `Lightning` spell.

## 8. Dynamic lights — the active-light cap and distance sort

Retail uses a **fixed-capacity sorted active-light pool** picked by squared distance from the viewer. `FUN_0054ddc0` (`chunk_00540000.c:12074`) is `InsertLightIntoActiveList`; `DAT_0081fca8` is the max number of lights in the active list (loaded from the client config at startup via `FUN_005df4c4` — typically `8` for a D3D fixed-function pipe with hardware T&L).

Pseudocode (cleaned from the decompile):

```
function InsertLightIntoActiveList(capacity, live_count_ptr, pool_base, sorted_ptrs,
                                   light_info, light_frame, owning_part, priority_offset):

    // 1. Compute squared distance from viewer to light's world position.
    dist_sq = 0
    if light_info.owner != null:
        local_to_world(light_frame, viewer_frame, &world_pos)
        dx = world_pos.x + owning_part.origin.x - viewer_world.x
        dy = world_pos.y + owning_part.origin.y - viewer_world.y
        dz = world_pos.z + owning_part.origin.z - viewer_world.z
        dist_sq = dx*dx + dy*dy + dz*dz

    // 2. Find insert index (first slot whose stored dist² > new dist²).
    idx = 0
    if *live_count_ptr == 0:
        idx = 0
        *live_count_ptr = 1
    else:
        while idx < *live_count_ptr and dist_sq >= sorted_ptrs[idx]->dist_sq:
            idx++
        if *live_count_ptr == capacity:
            if idx == *live_count_ptr:
                return   // new light is farther than every active one; drop it
        else:
            *live_count_ptr += 1
        // shift one slot right from idx onwards
        victim = sorted_ptrs[idx]
        sorted_ptrs[idx] = sorted_ptrs[*live_count_ptr - 1]
        for j in idx+1 .. *live_count_ptr - 1:
            swap(sorted_ptrs[j], victim)

    // 3. Populate the pool entry.
    slot = sorted_ptrs[idx]
    slot.parent_ptr = light_info.owner   // at +0x70
    copy_frame(owning_part.frame, &slot.world_frame)   // at +0x08..

    // 4. Unpack color, scale to 0..1.
    r = (read_byte_from_color_dword()) * _DAT_00799208   // 1/255
    g = ...
    b = ...
    slot.color_r = r   // at +0xc0
    slot.color_g = g   // at +0xc4
    slot.color_b = b   // at +0xc8

    // 5. Copy intensity, falloff, cone.
    slot.intensity = light_info.intensity    // at +0xcc
    slot.falloff   = light_info.falloff      // at +0xd0
    slot.cone      = light_info.cone_angle   // at +0xd4
    slot.dist_sq   = dist_sq                 // at +0xd8
    slot.frame_id  = priority_offset         // at +0x68
    slot.source    = light_info              // at +0x6c

    build_d3d_light(slot)   // see §10
```

Two sorted lists exist (`FUN_0054dff0` vs `FUN_0054e030`):
- **`DAT_0081fca4`** = capacity for lights affecting opaque geometry.
- **`DAT_0081fca8`** = capacity for lights affecting translucent / sorted geometry (typically `DAT_0081fca4 + 1` extra so the player's own viewer-light always fits in the translucent pass).

Both are typically **8**, reflecting `D3DLIGHT9`'s eight simultaneous fixed-function light slots.

**Drop policy.** If the active array is full and the new light's distance is >= every active light's, the new light is silently dropped for this frame. This means e.g. an off-screen torch beyond the 8th-closest is just not lit — the tradeoff retail accepts for CPU-side simplicity.

## 9. Shadow approximation (blob shadows)

Retail does **not** implement real shadow mapping. Creatures instead get a circular textured "blob shadow" rendered as a decal directly on the terrain / floor poly beneath them.

- **Dat asset.** The shadow texture is `0x0600102F` (a single grayscale disk with soft falloff, alpha-multiply). This is the `decal_shadow_blob` surface. Some creature setups reference an oval variant `0x06001030` for long-bodied mobs.
- **Render.** For each visible creature, cast a ray down from the creature's cylinder-sphere centre onto the terrain / static mesh, and draw a quad decal with the blob texture at the hit point, rotated to match the surface normal. Alpha scales down with height-above-ground (full opacity at the feet, 0 at 3 m up) so a jumping creature's shadow fades rather than clipping through the geometry.
- **NPCs only.** Static scenery (trees, signs, buildings) doesn't get blob shadows in retail — it would be too many decals at load time.

## 10. Lighting shader equation (retail)

### 10.1 Terrain (pre-baked per-vertex RGB)

```
final_color = texture(diffuse) * vertex_color
```

Where `vertex_color` was written at load time by `AdjustPlanes` (§7). Dynamic lights never touch this path. Only day/night time-of-day re-baking does.

### 10.2 Static meshes (dynamic, D3D fixed-function)

For each visible GfxObj part drawn with a D3D mesh, retail sets up **D3DLIGHT9 structs** from the active pool and lets the fixed-function pipe do the math. From `FUN_0059bd40` at `chunk_00590000.c:9631` (`BuildD3DLightFromLightInfo`):

```csharp
// light_type is decided per-slot:
//   0 = point  (ConeAngle == 0 and not infinite)
//   1 = directional (infinite, e.g. sun)
//   2 = spot   (ConeAngle > 0)

d3d_light.Diffuse.r  = info.color.r * info.intensity;
d3d_light.Diffuse.g  = info.color.g * info.intensity;
d3d_light.Diffuse.b  = info.color.b * info.intensity;
d3d_light.Diffuse.a  = info.color.a * info.intensity;

d3d_light.Specular   = {0, 0, 0, 0};   // AC doesn't do specular lighting
d3d_light.Ambient    = {0, 0, 0, 0};

d3d_light.Position   = world_pos;
d3d_light.Direction  = world_forward_from_quat;

d3d_light.Range        = info.falloff * 1.0f;   // literal metres
d3d_light.Attenuation0 = 1.0f;                  // constant term
d3d_light.Attenuation1 = 0.0f;                  // linear term
d3d_light.Attenuation2 = 0.0f;                  // quadratic term
d3d_light.Falloff      = 1.0f;                  // spot falloff exponent
d3d_light.Theta        = info.cone_angle;       // inner cone
d3d_light.Phi          = info.cone_angle;       // outer cone (same; hard edge)
```

**Pay attention to this.** Retail uses `Attenuation0 = 1`, `Attenuation1 = 0`, `Attenuation2 = 0` — i.e. **no actual distance attenuation**. Instead, the `Range` field hard-clips: inside `Range`, the light contributes full `Diffuse * N·L`; outside `Range`, it contributes nothing. This is the canonical "retail AC look" — lights make a crisp bubble of illumination that sharply fades to zero at the falloff distance.

**Hard-edged spotlight.** `Theta == Phi` makes the cone edge binary — no penumbra. This is why torches-on-walls in retail AC look cartoony; don't try to "soften" the edge in our port.

**Final shader math** (D3D emulation on our GL path):

```
vec3 direct = vec3(0);
for i in 0..N_active:
    if light[i].kind == POINT:
        d = distance(world_pos, light[i].pos);
        if d < light[i].range:
            dir = normalize(light[i].pos - world_pos);
            ndl = max(0, dot(N, dir));
            direct += light[i].diffuse * light[i].intensity * ndl;
    elif light[i].kind == SPOT:
        d = distance(world_pos, light[i].pos);
        if d < light[i].range:
            dir = normalize(light[i].pos - world_pos);
            cos_theta = dot(-dir, light[i].forward);
            if cos_theta > cos(light[i].cone * 0.5):
                ndl = max(0, dot(N, dir));
                direct += light[i].diffuse * light[i].intensity * ndl;
    elif light[i].kind == DIRECTIONAL:
        ndl = max(0, dot(N, -light[i].forward));
        direct += light[i].diffuse * ndl;

vec3 lit = texture(diffuse) * (cell_ambient + direct);
```

Two details to copy verbatim:

- **No distance attenuation inside `Range`** — full contribution.
- **Hard cutoff at `Range`** — boolean test, not a smoothstep.

## 11. Day/night modulation (R12 boundary)

`SkyDesc` drives the outdoor sun / ambient. Fields of interest (from generated `SkyTimeOfDay.generated.cs`):

```csharp
public partial class SkyTimeOfDay : IDatObjType {
    public float    Begin;          // 0.0..1.0 fraction of day (0=midnight, 0.5=noon)
    public float    DirBright;      // scalar on DirColor
    public float    DirHeading;     // sun's compass heading in radians
    public float    DirPitch;       // sun's elevation in radians
    public ColorARGB DirColor;      // sun colour
    public float    AmbBright;      // scalar on AmbColor
    public ColorARGB AmbColor;      // ambient colour
    public float    MinWorldFog;
    public float    MaxWorldFog;
    public ColorARGB WorldFogColor;
    public uint     WorldFog;
    public List<SkyObjectReplace> SkyObjReplace; // sun/moon sprite swaps
}
```

The retail sky manager stores a list of these keyframes per `DayGroup.SkyTime`, and interpolates between adjacent keyframes using the current `t ∈ [0,1]` game clock. At each `LightTickSize` tick (`SkyDesc.LightTickSize` — usually around 1–2 real seconds), it lerps:

```
currentDirBright = lerp(prev.DirBright, next.DirBright, alpha);
currentDirColor  = lerp(prev.DirColor,  next.DirColor,  alpha);
currentAmbBright = lerp(prev.AmbBright, next.AmbBright, alpha);
currentAmbColor  = lerp(prev.AmbColor,  next.AmbColor,  alpha);
sunDir = rotate((0,1,0), DirHeading, DirPitch); // Z-up, heading=yaw, pitch=elev
```

Then the terrain bake (§7) uses these outputs, and the static mesh path (§10) uses `currentAmbColor * currentAmbBright` as the indoor-fallback ambient when the player is inside a cell with no sky access.

**Sun = directional light** in the dynamic pool: AC inserts a slot at index 0 whose `kind = DIRECTIONAL`, `Diffuse = DirColor * DirBright`, `Direction = -sunDir`. No hard-coded yellow — at dusk `DirColor` turns deep orange in the dat, which is how retail gets that characteristic amber mountain lighting. Moon hint in the `SkyObjectReplace` array determines whether the "directional" slot drops to near-zero at night (dark starry night) or stays at ~0.15 bright (bright moon nights).

**acdream action.** Phase R12 reads `SkyDesc` and ticks. Phase R13 listens to R12's "time-of-day changed, here's the new DirColor/DirBright/AmbColor/AmbBright/SunDir" event and (a) re-bakes terrain vertex colours via `AdjustPlanes` and (b) uploads the new uniforms to the dynamic-lighting shader.

## 12. Port plan

### 12.1 C# data classes

```csharp
// src/AcDream.App/Rendering/Lighting/LightInfo.cs (dat-mirror, data-only)
public readonly record struct DatLightInfo(
    Vector3 LocalPosition,
    Quaternion LocalRotation,
    Vector3 ColorLinear,   // R,G,B in 0..1, already divided by 255
    float   Intensity,
    float   Range,         // metres (= dat Falloff)
    float   ConeAngle);    // radians, 0 = point

// src/AcDream.App/Rendering/Lighting/LightSource.cs (runtime, world-space)
public enum LightKind { Directional, Point, Spot }

public sealed class LightSource {
    public LightKind Kind;
    public Vector3   WorldPosition;
    public Vector3   WorldForward;     // only meaningful for Spot / Directional
    public Vector3   ColorLinear;
    public float     Intensity;
    public float     Range;            // world metres, hard cutoff
    public float     ConeAngle;        // radians, spot only
    public uint      OwnerId;          // who attached this; 0 = world-global (sun)
    public bool      IsLit;            // SetLightHook latch
    public float     DistSq;           // refreshed each frame by LightManager
}

// src/AcDream.App/Rendering/Lighting/CellAmbientState.cs
public readonly record struct CellAmbientState(
    Vector3 AmbientColor,  // linear RGB, pre-scaled by AmbBright
    Vector3 SunColor,      // linear RGB, pre-scaled by DirBright
    Vector3 SunDirection); // world-space, *pointing FROM the sun*
```

### 12.2 `LightManager`

```csharp
public sealed class LightManager {
    private const int MaxActiveLights = 8;   // D3D parity
    private readonly List<LightSource> _all = new();      // every registered light
    private readonly LightSource[] _active = new LightSource[MaxActiveLights];
    private int _activeCount;

    public void Register(LightSource ls);
    public void Unregister(uint ownerId);
    public void Tick(Vector3 viewerWorldPos);    // refreshes DistSq, rebuilds active list

    public ReadOnlySpan<LightSource> Active => _active.AsSpan(0, _activeCount);

    public CellAmbientState CurrentAmbient;     // from R12 sky ticker or cell default
}
```

`Tick` does:

1. For every registered light, recompute `DistSq` from viewer.
2. Partial-sort so the 8 smallest DistSq entries land in `_active[0..7]`.
3. Drop lights whose `DistSq > Range² * 1.1` early (they won't contribute). The 1.1 slack prevents pop as we cross the boundary.
4. Force slot 0 = the current sun (directional, infinite range); the 7 remaining are the 7 nearest in-range point/spot.

### 12.3 Shader uniforms

Add to `mesh_instanced.vert` / `mesh.frag`:

```glsl
#define MAX_LIGHTS 8
struct Light {
    vec4 posAndKind;    // xyz = world pos, w = kind (0=dir,1=point,2=spot)
    vec4 dirAndRange;   // xyz = forward, w = range (0 = disabled)
    vec4 colorAndIntensity; // xyz = color, w = intensity
    vec4 coneAngleEtc;  // x = cone (rad), y/z/w reserved
};

layout(std140, binding = 1) uniform LightBlock {
    Light uLights[MAX_LIGHTS];
    vec4  uCellAmbient;     // xyz = ambient RGB, w = num_active
};
```

Fragment contribution:

```glsl
vec3 lit = uCellAmbient.xyz;
int active = int(uCellAmbient.w);
for (int i = 0; i < active; ++i) {
    int kind = int(uLights[i].posAndKind.w);
    vec3 Lcol = uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w;

    if (kind == 0) {   // directional
        float ndl = max(0.0, dot(N, -uLights[i].dirAndRange.xyz));
        lit += Lcol * ndl;
    } else {           // point / spot
        vec3 toL = uLights[i].posAndKind.xyz - vWorldPos;
        float d  = length(toL);
        if (d < uLights[i].dirAndRange.w) {
            vec3 Ldir = toL / max(d, 1e-4);
            float ndl = max(0.0, dot(N, Ldir));
            float atten = 1.0;   // retail: no attenuation inside Range
            if (kind == 2) {
                float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5);
                float cos_l    = dot(-Ldir, uLights[i].dirAndRange.xyz);
                atten *= (cos_l > cos_edge) ? 1.0 : 0.0;  // hard edge
            }
            lit += Lcol * ndl * atten;
        }
    }
}
fragColor = vec4(texture(uDiffuse, vTex).rgb * lit, 1.0);
```

**Do NOT** use a physically-correct inverse-square attenuation. The retail look relies on the hard-edged `Range` sphere.

### 12.4 Terrain vertex re-bake

`TerrainChunkRenderer` already uploads per-vertex normals. Add a `BakeVertexColors(CellAmbientState state)` step that walks the cell's vertex list and writes a per-vertex RGB attribute equal to:

```
L = max(dot(N, -state.SunDirection), MIN_FACTOR);   // 0.08 floor
vertex.color = saturate(state.SunColor * L + state.AmbientColor);
```

with `MIN_FACTOR = 0.08f` (decompiled value `DAT_00796344`). Upload when the ambient state changes (every ~1 s real time, i.e. every `SkyDesc.LightTickSize` tick).

### 12.5 Order of integration

1. **`DatLightInfo`** loader — read `LightInfo` out of the Setup dat into our runtime struct when building static entities.
2. **`LightManager.Register` / `Unregister`** hooked into the entity spawn/despawn paths.
3. **Shader uniform block** — add the `LightBlock` UBO to `mesh.frag`, `mesh_instanced.vert/frag`. Don't touch the terrain shader yet; it still uses the flat sun+ambient path from R12.
4. **`Tick` with distance sort** — drive from the frame loop before drawing scenery. Measure that it's sub-ms for 200 registered lights.
5. **Visual verification** — user confirms torches light walls, lifestones glow, dungeon braziers cast reasonable fall-off bubbles.
6. **Blob shadows** — add a separate decal pass; load `0x0600102F`.
7. **Sky-driven re-bake** — hook R12's tick event to `TerrainRenderer.ReBake()`.
8. **`SetLightHook`** — when the animation pipeline emits a `SetLightHook`, flip the matching `LightSource.IsLit` boolean. `Tick` should skip `!IsLit` entries.

### 12.6 Non-goals for R13

- No real shadow mapping; the blob decal is the shadow.
- No HDR / tone-mapping; retail clamps to 1.0, so do we.
- No per-pixel specular. AC is entirely diffuse.
- No spot-light penumbra. Hard edge only.
- No dynamic terrain-vertex updates from torches — it's baked sun only, dynamic lights only touch meshes.
- No light culling via BSP; the 8-light cap makes this unnecessary at our draw volumes.

## 13. Edge cases worth the bug budget

1. **Over-bright saturation.** Intensity > 1 lights can drive final `lit` > 1. Retail clamps per-channel to 1.0 before the texture multiply. Our shader's `saturate(lit)` equivalent is `min(lit, vec3(1.0))`. Don't forget — or bright torches next to white walls turn solid white instead of peachy.
2. **Quaternion flip.** `LightInfo.ViewSpaceLocation` has a quat `(w,x,y,z)` order in the pack struct. Our `Frame` reader must match. Also remember AC is **Z-up**, so a spotlight's "forward" after rotating `(0,1,0)` by the quat gives the retail cone direction.
3. **Color byte order.** `ColorARGB` packs as `AARRGGBB` in the dword. Unpack as `(dword >> 16) & 0xFF = R`, `(>> 8) & 0xFF = G`, `& 0xFF = B`. Don't confuse with `BGRA`.
4. **Frame-to-frame pop.** When a light enters / leaves the active-8 set, it instantly appears / disappears. Retail accepts this; we should too. Adding a ramp-in would diverge from "the retail look."
5. **Time-of-day retro-bake.** If we re-bake every vertex every LightTick and the user has a huge chunk of visible terrain, that's 10–20 k vertices per landblock × 18 landblocks = 360 k writes. Batch them off the render thread, or stretch the re-bake over multiple frames. Retail doesn't seem to suffer, but our .NET GC could.
6. **Bright night.** When moon is visible, `DirColor` is still blue-grey but `DirBright` stays around 0.1–0.15. With `MIN_FACTOR = 0.08`, back-lit vertices end up at `0.08 * 0.15 * dim_blue` — essentially black, which is correct. If it looks TOO dark in acdream, the fix is NOT to raise `MIN_FACTOR` (that flattens the lighting); it's to raise `AmbBright` in the nighttime ambient term.
7. **Indoor transition.** When the player enters an EnvCell, the sun slot's `Intensity` should ramp to zero over ~0.5 s. Retail actually does this by detecting whether the cell has a sky portal; if not, sun doesn't contribute. Simpler acdream approximation: set `CellAmbientState.SunColor = vec3(0)` and `AmbientColor` to a fixed dungeon tone whenever the player is in an EnvCell.
8. **Held-torch light vs creature body cylinder.** The held-torch's world position sits at the hand part — which breathes / swings. If we naively refresh the LightSource's `WorldPosition` from the wielder's root, the torch light floats around the chest. Re-fetch from the hand's animated frame each tick.

## 14. Data structure summary (for engineering handoff)

| Field | Source | Type | Use |
|---|---|---|---|
| `LightInfo.ViewSpaceLocation` | Setup.Lights dict value | `Frame` | local offset relative to owner part |
| `LightInfo.Color` | Setup.Lights dict value | `ColorARGB` | 24-bit RGB × intensity = Diffuse |
| `LightInfo.Intensity` | Setup.Lights dict value | `float` | multiplies Color |
| `LightInfo.Falloff` | Setup.Lights dict value | `float` | light's `Range` in metres (hard cutoff) |
| `LightInfo.ConeAngle` | Setup.Lights dict value | `float` | 0 = point, >0 = spot cone angle (rad) |
| `SkyTimeOfDay.DirColor` | SkyDesc > DayGroup > SkyTime | `ColorARGB` | sun base colour |
| `SkyTimeOfDay.DirBright` | SkyDesc > DayGroup > SkyTime | `float` | sun scalar |
| `SkyTimeOfDay.AmbColor` | SkyDesc > DayGroup > SkyTime | `ColorARGB` | ambient base colour |
| `SkyTimeOfDay.AmbBright` | SkyDesc > DayGroup > SkyTime | `float` | ambient scalar |
| `SkyDesc.LightTickSize` | SkyDesc | `double` | seconds between lighting updates |
| `SetLightHook.LightsOn` | Animation frame hook | `bool` | gates a `LightSource.IsLit` at animation time |
| `WeenieType.LightSource` | server weenie | `uint = 28` | classifies decoration objects whose job is holding a lit Setup |
| `ParentLocation.RightHand` | dat enum | `int = 1` | where held-torch lights attach |
| `0x0600102F` | Surface dat | `DatFileType.Surface` | blob-shadow decal texture |

### Addresses (retail acclient.exe, decompiled 0x00400000 base)

| Symbol | Address | Purpose |
|---|---|---|
| `BuildD3DLightFromLightInfo` | `FUN_0059bd40` at 0x0059BD40 | Converts a pool slot into D3DLIGHT9 |
| `InsertLightIntoActiveList` | `FUN_0054ddc0` at 0x0054DDC0 | Distance-sorted insertion with cap |
| `InsertOpaqueLight` | `FUN_0054dff0` at 0x0054DFF0 | Wrapper that uses `DAT_0081fca4` capacity |
| `InsertTranslucentLight` | `FUN_0054e030` at 0x0054E030 | Wrapper that uses `DAT_0081fca8` capacity |
| `AdjustPlanes` | `FUN_00532440` at 0x00532440 | Per-vertex terrain normal + colour bake |
| `SetLightCaps` | `FUN_0054cfd0` at 0x0054CFD0 | Loads opaque/translucent caps from config |
| `SmartBox.ViewerLightIntensity` setup | `FUN_00454920` region at 0x00454920 | Player's camera-attached light init |

## 15. One-page TL;DR

- `LightInfo` in dats: `Frame`, `ColorARGB`, `Intensity`, `Falloff` (= Range metres), `ConeAngle` (rad, 0=point).
- Terrain: baked per-vertex sun+ambient at load, re-baked per sky tick. Dynamic lights never touch terrain.
- Static meshes: D3D fixed-function with 8-light cap, sorted by squared distance from viewer, HARD cutoff at `Range`, NO distance attenuation within Range, hard-edged spotlight cone.
- Cell ambient: no dat field; dungeon ambient is baked into cell geometry vertex colours plus a dark default; outdoor ambient comes from `SkyTimeOfDay.AmbColor * AmbBright`.
- Day/night: `SkyDesc` keyframes interpolated at `LightTickSize`; both terrain bake and dynamic ambient get refreshed from the result. Sun becomes a directional slot-0 light in the dynamic pool.
- Torches, lamps, lifestones: server-spawned `WeenieType.LightSource` weenies whose Setup has `Lights.Count > 0`. Colours and falloffs vary per weenie; typical range is 3–15 m.
- Spells / lightning: short-duration LightInfos injected into the active pool; strobe-like push-out of weaker nearby lights is intentional.
- Shadows: blob-decal only (`0x0600102F`), creatures only, fades with height above ground.
- acdream port surface: `DatLightInfo` + `LightSource` + `LightManager` + `CellAmbientState`, UBO-backed 8-light array in the mesh shaders, retain the retail hard-cutoff attenuation.
