acdream/docs/research/2026-07-25-slice-h-b-light-top-k-pseudocode.md
Erik a2a1e5916d perf(lighting): bound global light selection
Replace over-cap full sorting with a retained exact top-k heap while preserving the accepted tie-order fallback. Differential tests lock randomized and Town Network-scale output, and the measured 463-light path cuts selector CPU by 29 percent without warmed allocations.
2026-07-25 05:40:32 +02:00

5 KiB

Slice H-b — retail light insertion and bounded top-k

Scope

This slice changes only the overflow-selection mechanism inside acdream's existing approved point-light snapshot. It preserves:

  • the resident _all registry;
  • the optional last-rendered-visible-cell candidate filter;
  • dynamic-before-static priority;
  • squared distance from the player;
  • MaxGlobalLights = 128;
  • final nearest-first snapshot order and stable shader indices.

It does not claim to port retail's separate 7-dynamic/40-static pools or its DBObj-resident cell lifecycle. Those known differences remain AP-85.

Retail oracle

Named source:

  • CEnvCell::add_dynamic_lights 0x0052D410
  • Render::insert_light 0x0054D1B0
  • Render::add_static_light 0x0054D3E0
  • Render::add_dynamic_light 0x0054D420
  • cap globals 0x0081EC94 / 0x0081EC98

CEnvCell::add_dynamic_lights walks the resident CEnvCell::visible_cell_table and submits each light in registry traversal order. Static and dynamic callers feed separate bounded pools.

Readable pseudocode for Render::insert_light:

distanceSq = point-light distance from Render::player_pos

insertIndex = 0
while insertIndex < count
      and sorted[insertIndex].distanceSq <= distanceSq:
    insertIndex++

if count < capacity:
    count++
else if insertIndex == count:
    return                              # new light ranks beyond the cap

reuse the prior farthest storage record
shift sorted pointers [insertIndex .. count-2] one place right
sorted[insertIndex] = reused record
populate the reused record from LIGHTINFO

Equal-distance ordering was ambiguous in the pseudo-C because Binary Ninja left test ah, 0x5 untranslated. The exact matching retail executable (check_exe_pdb.py GUID 9e847e2f-777c-4bd9-886c-22256bb87f32) disassembles at 0x0054D253 as:

fld   newDistanceSq
fcomp existingDistanceSq
fnstsw ax
test  ah, 5
jnp   break

For equality, x87 sets C3 while the mask reads C2/C0 as zero; parity is even, so jnp is not taken and traversal continues. A later equal-distance light therefore follows earlier residents. Retail's semantic total rank is:

(pool priority, player distance squared, qualifying registration ordinal)

The ACE server, ACViewer, Holtburger, and extracted WorldBuilder paths do not implement this retail client-side bounded render-light selection. They provide no competing algorithm; named retail plus the matching executable are the oracle.

The accepted acdream implementation used .NET List.Sort with a comparator that returned zero for equal pool/distance ranks. List.Sort is unstable, so its tie permutation differs from retail registration order and is observable through snapshot/shader indices. Slice H-b is explicitly performance-only: a tied overflow frame therefore retains that exact full-sort oracle. AP-85's eventual dual-pool visual gate is the correct place to change tie behavior to retail.

Modern bounded implementation

Retail's capacities are tiny enough for insertion. Acdream's adaptation keeps 128 lights, so repeated insertion would do more movement than a bounded max-heap.

snapshot = empty
heap = empty
snapshot temporarily retains all candidates, as before
ordinal = 0
overflow = false

for each registered light:
    if unlit or directional or outside optional cell filter:
        continue

    rank = (dynamic first, squared player distance, ordinal++)
    snapshot.add(light)

    if snapshot.count <= cap:
        continue

    if first overflow:
        convert the first cap snapshot items to ranked heap entries
        heapify with WORST rank at root
        overflow = true

    if rank is better than heap.root:
        heap.root = rank
        sift root down

if overflow:
    sort only the cap heap entries by the total rank
    comparatorTie =
        any equal (pool, distance) ranks within the selected heap
        OR more than one complete candidate has the cutoff rank

if overflow and comparatorTie:
    run the prior complete List.Sort comparator over snapshot
    keep its first cap entries exactly
else if overflow:
    replace snapshot contents with their lights

Complexity changes from O(N log N) to O(N log K + K log K), where K = 128, for the ordinary no-tie path. Tie detection is post-selection: equal ranks that are entirely below the cutoff cannot affect the submitted snapshot and therefore do not force a fallback. Equal ranks within the selected set or straddling its cutoff intentionally fall back to O(N log N) so this optimization cannot alter accepted presentation. Retained lists and cached comparison delegates make both warmed paths allocation-free.

Mandatory equivalence

  • randomized differential comparison against the accepted complete-sort comparator;
  • all-static, all-dynamic, mixed, filtered, equal-distance, and cap-boundary cases;
  • a deterministic 463-light Town Network scale fixture;
  • identical final object references and order, not only an equal set;
  • zero stable allocations after retained scratch reaches capacity.