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.
150 lines
5 KiB
Markdown
150 lines
5 KiB
Markdown
# 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`:
|
|
|
|
```text
|
|
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:
|
|
|
|
```text
|
|
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:
|
|
|
|
```text
|
|
(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.
|
|
|
|
```text
|
|
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.
|