fix(overhaul): integrate reviewed room-light selection repair

Exact26 code/test/architecture/register blobs from621b41fa3; campaign ledger and lead verification included. Independent retail and production/lifetime/ABI reviews PASS. Lead69Core/176App/2actualshader pixels, viewer/clear/NaN negative controls fail as intended, exact restoration69PASS. AP68retired; AP16/35/85 residuals honest. Fresh campaign Release and graphical lighting proof still owed; temporary observer cleanup contract conditional. FPS deferred; no G4 or main merge.
This commit is contained in:
Erik 2026-09-05 14:57:52 +02:00
parent e8efe1131f
commit a5debaca2b
30 changed files with 928 additions and 399 deletions

View file

@ -1195,6 +1195,17 @@ no reconstructed fallback. Login and portal-space retain
last-frame point-light feedback seam are deleted. Directional shadows remain
the next typed landscape consumer (S5-c2).
Point-light retention follows retail's two independent products: seven dynamic
and forty static candidates, with POINT lights ranked from the light owner's
explicit root origin using stable forward insertion; SPOT/non-point lights rank
at zero. Authored offsets affect the final light position but not the rank. The viewer light is offered to the dynamic product
first even when its registration is late. EnvCell shells consume the complete
47-entry product through their mode-specific binding-5 stride, while ordinary
Gfx/building objects retain the separate eight-entry hardware selection and
stride. Both ordinary and atmospheric mesh shaders select that stride from the
existing lighting mode; the attenuation, ambient, packing, and per-object
selection formulas are otherwise shared and unchanged.
WorldBuilder contributes DAT decoding, mesh preparation, residency, and Vulkan
batch mechanics only; it makes no visibility or ordering decision. Forced merge
breaks at walk event boundaries preserve retail order while compatible draws

File diff suppressed because one or more lines are too long

View file

@ -797,8 +797,14 @@ separation design follows exact retail ranking-frame trace; no FPS work.
**#478 repair contract:** `478-room-light-selection-repair-contract.md`
authorizes independent default7/40 root-ranked light products and complete
47-slot EnvCell consumption, ordinary objects8 unchanged. Same-pose evidence
precedes temporary observer removal. Ambient/formulas/FPS untouched; not yet
implemented or accepted. G4FAIL/no merge.
precedes temporary observer removal. Reviewed scratch621b41fa3 now integrates
all26 blobs unchanged: independent retail and production/lifetime/ABI PASS,
lead69Core/176App/2native pixels, three additional discriminating mutations
and exact restoration69PASS. AP68retired; AP16/35/85 and architecture current.
See `478-room-lights-lead-verification.md`. Fresh campaign Release and exact
Town/control graphical evidence remain owed; observer removal contract is
prepared but inactive until those frames pass. Ambient/formulas/FPS untouched.
G4FAIL/no merge.
## 14. Risk register (condensed)

View file

@ -0,0 +1,32 @@
# #478 temporary lighting observer removal
Prepared only; activate after the repaired candidate's unchanged Town
High/off/High and ordinary-dungeon control PNGs are inspected. Name that
candidate and its evidence before dispatch. A failed visual check returns to
the lighting chunk's bounded fix-round rule; this cleanup cannot hide failure.
Remove only the temporary checkpoint observer introduced at59b0b6e72 and its
#478 shape adaptation: EnvCellRenderer.LightingObservation.cs, the optional
resource snapshot field, the snapshot-source renderer/LightManager arguments,
capture helper and composition wiring. Remove only corresponding observer
assertions/tests from FrameRootCompositionTests, WorldLifecycleAutomation-
ControllerTests and EnvCellRendererTests. Preserve unrelated lifecycle and
render-scene checkpoint evidence.
Keep all permanent #478 correctness coverage, especially
SubmitRhi_BindsTwoCompleteFortySevenIndexCellSetsInCurrentGeneration and its
required seeding helpers, ordinary eight-index upload, both committed-shader
pixel witnesses, root/order/7+40 fixtures and actual Town DAT pin. No light
selection, formula, shader, generated binary, pack, geometry or FPS changes.
One bounded OpenAI implementer; no client launches or commits. Return exact
diff plus affected Release tests and full Release build, checking both client
names before each command. Lead checks removal against59b0b6e72 and reviews
the retained regression paths; no new retail algorithm is introduced. No new
deviation is expected; disclose any actual deviation before proceeding.
Lead commits, builds Release green and repeats the unchanged lighting/control
routes on the observer-free binary. Record PNG PASS/FAIL provisionally. The
earlier candidate's consumed-light membership remains evidence for THAT
candidate, never relabeled as a measurement of the observer-free binary.
No main merge before G4; FPS remains deferred.

View file

@ -0,0 +1,127 @@
# #478 room-light repair — lead verification
REVIEW-CLOSED: scratch621b41fa3 independently checked; all26 reviewed blobs
integrate unchanged with this record. Not graphically accepted yet.
Contract4bc208b57, scratch `s5-478-room-lights`;
campaign route-only follow-up e8efe1131. FPS deferred; G4FAIL/no merge.
## Independently established inputs and boundaries
The contract and `478-480-readonly-diagnosis.md` seal the named-retail / paired
binary proof for separate40static/7dynamic caps, every-insertion ordering,
root-frame ranking before authored offset composition, viewer-first insertion,
and EnvCell all-dynamic plus independent all-static consumption. The activated
Town Network defect and exact bound inputs are in
`478-479-graphical-observations.md` R2. These are not matched-retail pixel proof.
Lead separately traced the modern production inputs: static publication calls
LightInfoLoader with entity.Position/Rotation; LiveEntityLightController does
the same and LightingHookSink refreshes attached lights from the canonical
root pose. The only production LightSource constructors are that loader,
the viewer and the sun. WorldRenderFrameBuilder calls UpdateViewerLight,
then Tick(camera), then BuildPointLightSnapshot(player) before lending the
same snapshot to the dispatcher and EnvCell renderer. Thus Build may safely
replace DistSq with the retained root rank before selection/observation.
No shader reads DistSq; final light position still feeds contribution math.
EnvCell RHI uses LightingMode1 and TextureIndexB0; ordinary Wb paths remain
MaxLightsPerObject8. Shader internal light-array stride is a real changed
contract and is pinned together with its CPU upload. Set numbers, buffer
element format and push/uniform layouts do not change. The built-in pack's
declared mesh receiver is WorldMesh and ordinary mode remains8; no public
RenderPipelineBaseSemantic.EnvCell production use was found in src/tests.
Both built-in vertex families must nevertheless pass the explicit mode1/mode0
compiled-module witness. No general pack API expansion is part of this repair.
## Existing authored-type projection limitation (not repaired here)
Lead inspected the installed Chorizite.DatReaderWriter2.1.7 assembly metadata
and actual Setup.Unpack IL. Setup.Lights is Dictionary<int,LightInfo>:
IL0236 ReadInt32 → IL0243 ReadItem<LightInfo> → IL0254 dictionary Add.
LightInfo exposes only ViewSpaceLocation, Color, Intensity, Falloff, ConeAngle.
Named CSetup::UnPack00521307 stores that leading word in LIGHTINFO.type,
then00521316 unpacks Frame at+4. Native span00521301/raw00121301/77bytes has
SHA2564A45A458A56C043ECA0B8CC824843D632E2A4E67698BA6194B1499549CA97EC7;
its leading bytes72488B0E8B11895500 implement the guarded word read/store.
Parser DLL SHA2565DF9E0220EB5674FE8AC7C9477C9DF85DB10875FAD3726FAA9BEDB9D26A58F7E.
The existing loader derives modern Kind from ConeAngle instead of that key.
The #478 contract deliberately leaves that projection unchanged; AP-85 must
disclose it alongside numerical/cell-offset and residency/degrade residuals.
POINT-versus-SPOT rank tests prove behavior for supplied modern kinds, NOT
faithful projection of every authored type. The actual Town fixture must
report key and cone; no speculative extra kind/type port is authorized.
## Review and execution pending
Lead read the initial source delta and inspected both compiled modules with
the installed spirv-dis: mode1 selects constant47, otherwise8; that selected
value supplies instance multiplication. At that inspection the module hashes
were9909CA4729DBE4FC7FBFFB11C73481977D8F593D37F130FCDD44A7803B47944F
(modern) andAE5DD5F4B9B2891A669340FD9712169BD1EC58BCF8C596E1ABDAF71997420231
(atmospheric). Final hashes and complete frozen diff still need rechecking.
This static inspection is not the pending actual-pixel result.
Before native shader mutations, lead required padding the synthetic ordinary
light-index witness to94 entries with -1 sentinels. Correct indices remain7/15;
the wrong47 mutation must fail by color/index behavior, not execute an
out-of-bounds GPU read. Production ordinary upload extent is checked separately.
Permanent RHI regression tests must survive removal of the temporary observer.
## Frozen return and initial lead evidence corrections
The implementer froze26 files at14:47+02. Lead verified all26 against
`artifacts/478-room-lights/final-source-hashes.json`, checked the complete
source/generated/test/doc diff, and independently counted159 active AP rows.
Only AP-68 retires; AP-16/AP-35/AP-85 carry the disclosed limitations.
Both final SPIR-V hashes match the values above. No contribution-formula,
ambient, scene-specific, package, or FPS edits are present.
Initial lead review corrected evidence, not the lighting algorithm: a purported
viewer-root tie actually used different roots; Clear's assertion was delayed
until after a rebuild; a NaN-first fixture did not discriminate forward from
backward insertion. The final tests now use a genuine root tie, assert empty
immediately after Clear, and pin finite-far/NaN/finite-near to near/far/NaN.
The table-walker citation was corrected to CEnvCell::add_dynamic_lights0052D410
(which calls the per-cell feeder0052B390); architecture now states POINT-only
distance rank. No frozen production fix round has been required.
Actual final implementer artifacts: full Release0W0E; Core69/69 (all Lighting
plus installed Town fixture), App176/176, committed-module native pixels2/2.
The first solution build lacked project.assets.json in the fresh scratch;
normal restore resolved39NETSDK1004 errors. No compilation repair was hidden.
Seven intended mutations each failed their named assertion. The first
mutation4/5 invocations discovered zero tests due to typoed filters; only
their r2 failures count. Mutation6 used94 in-bounds sentinel entries and failed
by wrong color, not unsafe out-of-bounds GPU access.
Lead independently reran the frozen positives, both clients absent before
each invocation: `root-positive-core/root-core.trx`69/69,
`root-positive-app/root-app.trx`176/176, and
`root-positive-pixels/root-shader-pixels.trx`2/2, all under the scratch's
`artifacts/478-room-lights/`. These are narrow correctness suites, not full
solution or FPS gates. Native pixels exercise actual committed shaders on
synthetic quads; they are not an installed-world visual acceptance.
Wegener's independent production/lifetime/ABI review PASS: no findings,
all26seals checked, no reviewer executions or edits. Independent retail
review PASS: all26seals, paired executable and ten native spans independently
matched, no findings. The stale prior retail-agent continuation was unavailable;
one fresh bounded OpenAI retail reviewer took that same lens, not an extra
implementation or review round.
Lead additionally executed three independent negative controls on the final
source: ordinary-registry viewer insertion failed the actual equal-root test
(`Assert.NotSame`); omitting Clear's snapshot clearing failed the immediate
`Assert.Empty`; backward insertion failed the finite-far/NaN/finite-near exact
order. Each ran1test/1intended failure, with exact raw source restoration
SHA256C141B2B2234BB94D25663B588A0142A3D520859276CE70A41F9F3659D989C0D5
after EACH control. Artifacts: `root-mutation-viewer`, `root-mutation-clear`,
`root-mutation-nan` under the same scratch artifact directory. Final rebuilt
`root-restored-core/root-restored-core.trx`69/69; all26file seals rechecked.
Lead did not re-execute the implementer's seven other mutations: their actual
TRX failure text and source restoration evidence were inspected independently.
Pending: fresh integration Release,
unchanged Town High/off/High graphical proof, ordinary dungeon controls,
observer cleanup and final observer-free re-gate. No PASS inferred in advance.

View file

@ -4957,3 +4957,18 @@ same Town route remains unchanged. Prepared `route-478-dungeon-controls.txt`
uses the already selected0125/8C04 poses, High/off still frames and Holtburg
return after the repair. This is a lighting/reveal smoke, not full dungeon
stairs/camera-seam coverage and not an FPS benchmark. No run/result yet.
Reviewed return621b41fa3 now integrates with all26 blobs unchanged. Separate
independent OpenAI retail and production/lifetime/ABI lenses PASS; lead
checked every source/generated/doc change and native identity, independently
repeated69Core/176App/2committed-shader pixels. Seven implementer controls
have actual intended failures (two zero-test typo-filter precursors excluded).
Lead additionally mutated viewer-first tie, immediate Clear and forward NaN
ordering: each1/1intended failure, exact raw restoration, rebuilt69PASS.
Full scratch Release0W0E after normal fresh-worktree restore. Evidence:
`478-room-lights-lead-verification.md`. AP68retired,159active AP rows;
AP16/35/85 preserve ordinary-selection/bake/residency/degrade/numerical/type
projection limits. No ambient/formula/FPS edits or graphical PASS yet.
Fresh integration Release and unchanged Town/control runs next. Prepared
`478-observer-removal-contract.md` activates only after inspected candidate
frames; then final observer-free re-gate. G4FAIL/no merge.

View file

@ -50,7 +50,10 @@ internal static class GpuBindingModel
/// <summary>A7 Fix B global point/spot light array.</summary>
public const uint StorageGlobalLights = 4;
/// <summary>A7 Fix B per-instance light set: 8 indices into the global light array, -1 = unused.</summary>
/// <summary>
/// Per-instance indices into the global light array: object submissions use
/// stride 8; EnvCell submissions use stride 47. Unused entries are -1.
/// </summary>
public const uint StorageInstanceLightSets = 5;
/// <summary>#142 per-instance indoor flag (1 = parented to an EnvCell, skip the sun).</summary>
@ -195,4 +198,7 @@ internal static class GpuBindingModel
/// <summary>Lights selected per object by retail's <c>minimize_object_lighting</c>.</summary>
public const int MaxLightsPerObject = 8;
/// <summary>Complete retained dynamic + static products consumed by EnvCells.</summary>
public const int MaxLightsPerEnvCell = 47;
}

View file

@ -85,7 +85,7 @@ layout(std430, binding = 4) readonly buffer GlobalLightBuf {
GlobalLight gLights[];
};
layout(std430, binding = 5) readonly buffer InstanceLightSetBuf {
int instanceLightIdx[]; // 8 per instance; -1 = unused
int instanceLightIdx[]; // object: 8; EnvCell: 47; -1 = unused
};
// #142: LOCAL per-submission "indoor" flag, 1 per instance. 1 = object
@ -274,8 +274,9 @@ vec3 accumulateAmbientLocalLights(
// folding them into ambient+sun did. Mirrors LightBake.ComputeVertexColor
// (LightBakeConformanceTests). Per-light cap inside pointContribution is unchanged.
vec3 pointAcc = vec3(0.0);
int base = instanceIndex * 8;
for (int k = 0; k < 8; ++k) {
int lightStride = (uLightingMode == 1) ? 47 : 8;
int base = instanceIndex * lightStride;
for (int k = 0; k < lightStride; ++k) {
int gi = instanceLightIdx[base + k];
if (gi < 0) continue;
pointAcc += pointContribution(N, worldPos, gLights[gi]);

View file

@ -81,7 +81,7 @@ layout(std430, binding = 4) readonly buffer GlobalLightBuf {
GlobalLight gLights[];
};
layout(std430, binding = 5) readonly buffer InstanceLightSetBuf {
int instanceLightIdx[]; // 8 per instance; -1 = unused
int instanceLightIdx[]; // object: 8; EnvCell: 47; -1 = unused
};
// #142: LOCAL per-submission "indoor" flag, 1 per instance. 1 = object
@ -257,8 +257,9 @@ vec3 accumulateLights(vec3 N, vec3 worldPos, int instanceIndex) {
// folding them into ambient+sun did. Mirrors LightBake.ComputeVertexColor
// (LightBakeConformanceTests). Per-light cap inside pointContribution is unchanged.
vec3 pointAcc = vec3(0.0);
int base = instanceIndex * 8;
for (int k = 0; k < 8; ++k) {
int lightStride = (uLightingMode == 1) ? 47 : 8;
int base = instanceIndex * lightStride;
for (int k = 0; k < lightStride; ++k) {
int gi = instanceLightIdx[base + k];
if (gi < 0) continue;
pointAcc += pointContribution(N, worldPos, gLights[gi]);

View file

@ -215,7 +215,7 @@
"stages": [
{
"stage": "vert",
"sourceSha256": "b1e8e52b0b3dedec6c7dfd29ce3e70deb44d6b18197d6bd312e1a34e543f8337",
"sourceSha256": "16c29168aedead79da28151e2cec863634b7818d28c72619f9905b81dabd8571",
"compiled": true
},
{
@ -231,7 +231,7 @@
"stages": [
{
"stage": "vert",
"sourceSha256": "d5af0cbe995bcd10acea92ac7140363f0941105452a89039a7b54c5217c505a2",
"sourceSha256": "33d5563db8bcd7dc74e4836df2c6064cbd5112d074da5380b0bab333f1d7ece7",
"compiled": true
},
{

View file

@ -20,6 +20,7 @@ internal sealed record EnvCellLightingSourceSnapshot(
bool IsDynamic,
bool IsLit,
bool TracksOwnerPose,
EnvCellLightingVectorBits RankingOrigin,
EnvCellLightingVectorBits Position,
EnvCellLightingVectorBits Forward,
EnvCellLightingVectorBits Color,
@ -37,7 +38,7 @@ internal sealed record EnvCellLightingCellSetSnapshot(
/// Checkpoint-only copy of the point-light inputs the EnvCell RHI submission
/// already consumed. PackedGlobalLightBits is the authoritative binding-4
/// payload; Sources records its identity/provenance and CellSets records the
/// exact cached eight-index values copied to binding 5.
/// exact cached 47-index values copied to binding 5.
/// </summary>
internal sealed record EnvCellLightingConsumptionSnapshot(
bool HasCurrentEnvCellInput,
@ -141,6 +142,7 @@ public sealed partial class EnvCellRenderer
IsDynamic: source.IsDynamic,
IsLit: source.IsLit,
TracksOwnerPose: source.TracksOwnerPose,
RankingOrigin: CaptureVector(source.RankingOrigin),
Position: CaptureVector(source.WorldPosition),
Forward: CaptureVector(source.WorldForward),
Color: CaptureVector(source.ColorLinear),

View file

@ -205,9 +205,8 @@ public sealed unsafe partial class EnvCellRenderer
}
Array.Fill(_instanceAlphaData, 1f, 0, uniqueInstanceCount);
// A7 Fix D (D-2): per-instance 8-int light set, keyed on the cell each
// shell instance belongs to.
int lightStride = LightManager.MaxLightsPerObject;
// Complete retained 7-dynamic + 40-static product per EnvCell instance.
int lightStride = LightManager.MaxLightsPerEnvCell;
if (_lightSetData.Length < uniqueInstanceCount * lightStride)
{
_lightSetData = new int[Math.Max(

View file

@ -108,14 +108,15 @@ public sealed partial class EnvCellRenderer :
// A7 Fix D (D-2): this renderer owns its lighting (self-contained state,
// like uViewProjection) instead of reading whatever WbDrawDispatcher last
// bound. Global point-light snapshot (same data/indices as the dispatcher,
// via GlobalLightPacker) plus 8 int indices per instance.
// via GlobalLightPacker) plus the complete 47-index retained product per
// EnvCell instance. Ordinary objects retain their separate 8-index stride.
private float[] _globalLightData = new float[AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * 16];
private int[] _lightSetData = new int[1024 * AcDream.Core.Lighting.LightManager.MaxLightsPerObject];
private int[] _lightSetData = new int[1024 * AcDream.Core.Lighting.LightManager.MaxLightsPerEnvCell];
private System.Collections.Generic.IReadOnlyList<AcDream.Core.Lighting.LightSource>? _pointSnapshot;
private sealed class CachedCellLightSet
{
public int FrameGeneration;
public readonly int[] Indices = new int[AcDream.Core.Lighting.LightManager.MaxLightsPerObject];
public readonly int[] Indices = new int[AcDream.Core.Lighting.LightManager.MaxLightsPerEnvCell];
}
private readonly System.Collections.Generic.Dictionary<uint, CachedCellLightSet> _cellLightSetCache = new();
@ -1101,13 +1102,11 @@ public sealed partial class EnvCellRenderer :
// ---------------------------------------------------------------------------
// GetCellLightSet (A7 Fix D D-2 helper)
// Per-cell up-to-8 point lights, cached per frame. Camera-independent, like
// WbDrawDispatcher.ComputeEntityLightSet — keyed on the cell's world bounds.
// Complete per-cell retained point-light product, cached per frame.
// ---------------------------------------------------------------------------
// A7 Fix D (D-2): the up-to-8 point lights reaching a cell, by the cell's world
// bounding sphere (camera-independent, like WbDrawDispatcher.ComputeEntityLightSet).
// Cached per frame; unused slots are -1 (shader adds no point light there).
// A7 Fix D (D-2): all retained 7 dynamic + 40 static candidates. The shader
// applies the unchanged per-vertex range cutoff. Unused slots are -1.
private int[] GetCellLightSet(uint cellId)
{
if (!_cellLightSetCache.TryGetValue(cellId, out CachedCellLightSet? cached))
@ -1122,24 +1121,8 @@ public sealed partial class EnvCellRenderer :
System.Array.Fill(set, -1);
var snap = _pointSnapshot;
// Landblocks are keyed by the streaming landblock id 0xXXYYFFFF
// (GameWindow: (x<<24)|(y<<16)|0xFFFF), NOT 0xXXYY0000 — so the landblock
// key is (cellId & 0xFFFF0000) | 0xFFFF. The old `cellId & 0xFFFF0000` key
// (0xXXYY0000) NEVER matched a registered landblock, so this lookup always
// missed: SelectForObject never ran and every EnvCell wall received ZERO
// point lights (the entire "indoor torches/lanterns don't light the room"
// bug — confirmed by the [cell-light] probe: inBounds=False for every cell).
if (snap is { Count: > 0 } &&
_landblocks.TryGetValue((cellId & 0xFFFF0000u) | 0xFFFFu, out var lb) &&
lb.EnvCellBounds.TryGetValue(cellId, out var b))
{
Vector3 center = (b.Min + b.Max) * 0.5f;
float radius = (b.Max - b.Min).Length() * 0.5f;
// #176 flap fix: cells use SelectForCell (retail minimize_envcell_lighting) — ALL
// dynamic lights on every cell (stable), not the per-object sphere-overlap cull that
// let the portal set flip as the flood shifted → floor-lighting flap.
AcDream.Core.Lighting.LightManager.SelectForCell(snap, center, radius, set);
}
if (snap is { Count: > 0 })
AcDream.Core.Lighting.LightManager.SelectForCell(snap, set);
cached.FrameGeneration = _lightFrameGeneration;
return set;
}

View file

@ -76,6 +76,7 @@ public static class LightInfoLoader
{
Kind = info.ConeAngle > 0f ? LightKind.Spot : LightKind.Point,
WorldPosition = worldPos,
RankingOrigin = entityPosition,
WorldForward = forward,
ColorLinear = new Vector3(
(info.Color?.Red ?? 255) / 255f,

View file

@ -5,10 +5,10 @@ using System.Numerics;
namespace AcDream.Core.Lighting;
/// <summary>
/// Manages the registered dynamic lights in the world and picks the 8
/// most relevant ones each frame for the shader to consume. Matches
/// retail's fixed-function-era "8 hardware lights" constraint (r13
/// §12.2).
/// Owns the registered world-light set. <see cref="Tick"/> retains the legacy
/// directional-plus-eight UBO view, while <see cref="BuildPointLightSnapshot"/>
/// builds retail's independent seven-dynamic and forty-static point-light
/// products for binding 4/5 consumers.
///
/// <para>
/// Active-light selection algorithm (r13 §12.2), as implemented by
@ -65,6 +65,10 @@ public sealed class LightManager
public void Register(LightSource light)
{
ArgumentNullException.ThrowIfNull(light);
if (light.Kind != LightKind.Directional && !light.HasRankingOrigin)
throw new ArgumentException(
"A nondirectional light requires an explicit authored ranking origin.",
nameof(light));
foreach (var existing in _all)
if (ReferenceEquals(existing, light)) return;
_all.Add(light);
@ -88,6 +92,9 @@ public sealed class LightManager
Array.Clear(_active);
_activeCount = 0;
_viewerLight = null; // re-created + re-registered by the next UpdateViewerLight
_pointSnapshot.Clear();
_dynamicPointSnapshot.Clear();
_staticPointSnapshot.Clear();
}
/// <summary>
@ -175,30 +182,20 @@ public sealed class LightManager
/// is global, not part of an object's per-object set, so all 8 are point/spot.</summary>
public const int MaxLightsPerObject = 8;
/// <summary>Hard cap on the per-frame global point-light snapshot the shader
/// indexes. #176 root-cause history (2026-07-06, corrected): retail's pool is
/// collected from ALL RESIDENT EnvCells (<c>CEnvCell::add_dynamic_lights</c>
/// 0x0052d410 walks the static <c>CEnvCell::visible_cell_table</c> — the
/// loaded-cell registry that <c>add_visible_cell</c> 0x0052de40 fills from each
/// activated cell + its dat visible-cell list; NOT the per-frame portal flood)
/// and capped nearest-THE-PLAYER (<c>Render::insert_light</c> 0x0054d1b0 sorts
/// by distance to <c>Render::player_pos</c>) with small caps (7 dynamic + 40
/// static, <c>0x0081ec94/98</c>). Two prior acdream models both flickered
/// because their pool was CAMERA-coupled: (1) nearest-CAMERA-128 over all
/// registered lights (chase-boom swing churned the eviction boundary), then
/// (2) frame-FLOOD scoping `c500912b` (gaze-dependent: the under-room portal
/// purples entered/left the pool as the camera turned — the seam-floor
/// blink). Current model: all registered
/// (=resident) lit lights, then dynamics-first nearest-player, capped here.
/// A later last-frame drawable-cell filter was removed after the Facility Hub
/// zoom trace proved it recreated the same camera-root coupling: the pool
/// collapsed from five lights to one without the player moving. 128 is wider
/// than retail's 40+7 — a documented backstop
/// that in a properly cell-scoped room only ever evicts far-out-of-range
/// statics; adopting retail's exact dual-pool caps + degrade levels is A7-arc
/// work. The 1024 uncap remains refuted (striped-floor artifact + the unported
/// static 1/d³ fixture curve, A7 fix #2). Register row AP-85.</summary>
public const int MaxGlobalLights = 128;
/// <summary>Retail's independent retained dynamic-light cap.</summary>
public const int MaxDynamicPointLights = 7;
/// <summary>Retail's independent retained static-light cap.</summary>
public const int MaxStaticPointLights = 40;
/// <summary>Combined GPU-index transport capacity: dynamics, then statics.</summary>
public const int MaxGlobalLights = MaxDynamicPointLights + MaxStaticPointLights;
/// <summary>
/// EnvCell shells consume the complete retained dynamic and static products.
/// Ordinary objects retain <see cref="MaxLightsPerObject"/>.
/// </summary>
public const int MaxLightsPerEnvCell = MaxGlobalLights;
private readonly List<LightSource> _pointSnapshot = new();
@ -209,27 +206,23 @@ public sealed class LightManager
/// <see cref="BuildPointLightSnapshot"/>.
/// </summary>
public IReadOnlyList<LightSource> PointSnapshot => _pointSnapshot;
internal bool LastPointSnapshotUsedBoundedSelection { get; private set; }
internal bool LastPointSnapshotUsedTieFallback { get; private set; }
// Slice H-b: keep only the best MaxGlobalLights entries in a retained
// max-heap. Rank includes qualifying registration order because retail
// insert_light (0x0054D1B0) advances past equal-distance residents.
private readonly List<RankedLight> _pointSelectionHeap =
new(MaxGlobalLights);
private Comparison<RankedLight>? _rankComparison;
private Vector3 _legacyPoolAnchor;
private Comparison<LightSource>? _legacyPoolComparison;
private readonly List<LightSource> _dynamicPointSnapshot =
new(MaxDynamicPointLights);
private readonly List<LightSource> _staticPointSnapshot =
new(MaxStaticPointLights);
/// <summary>
/// Rebuild <see cref="PointSnapshot"/> from ALL registered lit point/spot
/// lights — retail's per-frame collection over the RESIDENT-cell registry.
/// The sun and unlit lights are excluded (the sun is global ambient-path;
/// unlit torches contribute nothing).
/// Rebuild <see cref="PointSnapshot"/> from all registered lit point/spot
/// residents as independent seven-dynamic and forty-static products.
/// Each product is stable insertion-ranked from the player using the
/// authored root origin for points; spots have rank zero. The viewer is
/// inserted before ordinary dynamics. The combined snapshot is dynamics
/// followed by statics so binding-5 indices are deterministic.
/// <para>
/// Retail anchors (#176 corrected reading, 2026-07-06):
/// <c>CEnvCell::add_dynamic_lights</c> (0x0052d410) walks the WHOLE static
/// <c>CEnvCell::visible_cell_table</c> — the resident-EnvCell registry that
/// <c>CEnvCell::add_dynamic_lights</c> (0x0052D410) walks the WHOLE static
/// <c>CEnvCell::visible_cell_table</c> — the resident-EnvCell registry — and calls
/// <c>CObjCell::add_dynamic_to_global_lights</c> (0x0052B390) for each entry. The table is the one
/// <c>CEnvCell::add_visible_cell</c> (0x0052de40) populates from each activated
/// cell plus its dat visible-cell list (it <c>DBObj::Get</c>-loads absent cells;
/// entries leave only via the flush machinery). It is NOT the per-frame portal
@ -240,18 +233,10 @@ public sealed class LightManager
/// them to every Hub cell) — the faceted purple wedge is faithful.
/// </para>
/// <para>
/// When more than <see cref="MaxGlobalLights"/> qualify, DYNAMICS are kept
/// first (retail's dynamic lights live in their own 7-slot pool —
/// <c>Render::add_dynamic_light</c> 0x0054d420 — and never compete with
/// statics), then the nearest THE PLAYER (<c>Render::insert_light</c>
/// 0x0054d1b0 insertion-sorts by squared distance to <c>Render::player_pos</c>,
/// set from <c>player-&gt;m_position</c>, SmartBox 0x00453d3a, with the
/// viewer-cell fallback 0x00455ab6). The distance SORT is therefore a function
/// of PLAYER position and light registration ONLY — camera rotation/position
/// cannot change it. Both camera-anchored pools and last-frame drawable-cell
/// scoping produced gaze/zoom-dependent membership changes, so neither is an
/// input to this retail resident-cell collection. Call once per frame before
/// per-object selection.
/// Retail's <c>insert_light</c> (0x0054d1b0) uses a strict forward
/// <c>newDistance &lt; existingDistance</c> insertion. Equal and unordered
/// comparisons therefore advance, which preserves registration order without
/// <c>float.CompareTo</c> or an unstable sort.
/// </para>
/// </summary>
/// <param name="playerWorldPos">The player's world position (render position;
@ -260,200 +245,58 @@ public sealed class LightManager
public void BuildPointLightSnapshot(Vector3 playerWorldPos)
{
_pointSnapshot.Clear();
_pointSelectionHeap.Clear();
int qualifyingOrdinal = 0;
bool overflow = false;
LastPointSnapshotUsedBoundedSelection = false;
LastPointSnapshotUsedTieFallback = false;
_dynamicPointSnapshot.Clear();
_staticPointSnapshot.Clear();
// SmartBox inserts the viewer light before collecting ordinary dynamic
// residents. Processing it first also preserves its equal-root tie even
// though the retained registry may contain earlier registrations.
if (_viewerLight is { IsLit: true } viewer)
InsertRetained(viewer, playerWorldPos, _dynamicPointSnapshot, MaxDynamicPointLights);
foreach (var light in _all)
{
if (!light.IsLit || light.Kind == LightKind.Directional) continue;
var ranked = new RankedLight(
if (ReferenceEquals(light, _viewerLight)
|| !light.IsLit
|| light.Kind == LightKind.Directional)
continue;
InsertRetained(
light,
qualifyingOrdinal++,
Vector3.DistanceSquared(
light.WorldPosition,
playerWorldPos));
_pointSnapshot.Add(light);
if (_pointSnapshot.Count <= MaxGlobalLights)
continue;
if (!overflow)
{
for (int index = 0;
index < MaxGlobalLights;
index++)
{
LightSource existing = _pointSnapshot[index];
_pointSelectionHeap.Add(new RankedLight(
existing,
index,
Vector3.DistanceSquared(
existing.WorldPosition,
playerWorldPos)));
}
HeapifyWorstFirst(_pointSelectionHeap);
overflow = true;
}
// Root is the currently-worst selected rank. A later light at the
// same distance ranks after an earlier resident, matching retail.
if (CompareRankedLights(ranked, _pointSelectionHeap[0]) < 0)
{
_pointSelectionHeap[0] = ranked;
SiftWorstDown(_pointSelectionHeap, 0);
}
}
if (overflow)
{
_rankComparison ??= CompareRankedLights;
_pointSelectionHeap.Sort(_rankComparison);
bool comparatorTie =
SelectedRanksContainObservableTie(playerWorldPos);
if (comparatorTie)
{
LastPointSnapshotUsedTieFallback = true;
// The previous List.Sort comparator intentionally returned zero
// for equal pool/distance ranks. List.Sort is unstable, so its
// exact tie permutation is observable in shader indices. Keep
// that legacy oracle for tied frames; H-b is performance-only.
// AP-85's eventual dual-pool port can adopt retail's stable tie
// insertion as a separately visual-gated behavior change.
_legacyPoolAnchor = playerWorldPos;
_legacyPoolComparison ??= CompareLegacyPoolLights;
_pointSnapshot.Sort(_legacyPoolComparison);
_pointSnapshot.RemoveRange(
MaxGlobalLights,
_pointSnapshot.Count - MaxGlobalLights);
}
else
{
LastPointSnapshotUsedBoundedSelection = true;
_pointSnapshot.Clear();
for (int index = 0;
index < _pointSelectionHeap.Count;
index++)
{
_pointSnapshot.Add(
_pointSelectionHeap[index].Light);
}
}
playerWorldPos,
light.IsDynamic ? _dynamicPointSnapshot : _staticPointSnapshot,
light.IsDynamic ? MaxDynamicPointLights : MaxStaticPointLights);
}
_pointSnapshot.AddRange(_dynamicPointSnapshot);
_pointSnapshot.AddRange(_staticPointSnapshot);
}
private static int CompareRankedLights(
RankedLight left,
RankedLight right)
private static void InsertRetained(
LightSource light,
Vector3 playerWorldPos,
List<LightSource> product,
int capacity)
{
if (left.Light.IsDynamic != right.Light.IsDynamic)
return left.Light.IsDynamic ? -1 : 1;
// Retail ranks only POINT. Non-point lights have rank zero. The strict
// forward comparison is intentional: equal and NaN comparisons advance,
// preserving insertion order and retail's unordered behavior.
float rank = light.Kind == LightKind.Point
? Vector3.DistanceSquared(light.RankingOrigin, playerWorldPos)
: 0f;
light.DistSq = rank;
int distance = left.DistanceSq.CompareTo(right.DistanceSq);
return distance != 0
? distance
: left.QualifyingOrdinal.CompareTo(right.QualifyingOrdinal);
int index = 0;
while (index < product.Count && !(rank < product[index].DistSq))
index++;
if (index >= capacity)
return;
product.Insert(index, light);
if (product.Count > capacity)
product.RemoveAt(capacity);
}
private bool SelectedRanksContainObservableTie(Vector3 playerWorldPos)
{
for (int index = 1;
index < _pointSelectionHeap.Count;
index++)
{
if (HaveSameLegacyRank(
_pointSelectionHeap[index - 1],
_pointSelectionHeap[index]))
{
return true;
}
}
// A tie may straddle the cap with only one copy in the selected heap.
// Such a tie can change which light the old unstable List.Sort kept.
RankedLight cutoff = _pointSelectionHeap[^1];
int cutoffMatches = 0;
for (int index = 0; index < _pointSnapshot.Count; index++)
{
LightSource light = _pointSnapshot[index];
if (light.IsDynamic != cutoff.Light.IsDynamic)
continue;
float distance = Vector3.DistanceSquared(
light.WorldPosition,
playerWorldPos);
if (distance.CompareTo(cutoff.DistanceSq) != 0)
continue;
if (++cutoffMatches > 1)
return true;
}
return false;
}
private static bool HaveSameLegacyRank(
RankedLight left,
RankedLight right) =>
left.Light.IsDynamic == right.Light.IsDynamic
&& left.DistanceSq.CompareTo(right.DistanceSq) == 0;
private int CompareLegacyPoolLights(
LightSource left,
LightSource right)
{
if (left.IsDynamic != right.IsDynamic)
return left.IsDynamic ? -1 : 1;
float leftDistance = Vector3.DistanceSquared(
left.WorldPosition,
_legacyPoolAnchor);
float rightDistance = Vector3.DistanceSquared(
right.WorldPosition,
_legacyPoolAnchor);
return leftDistance.CompareTo(rightDistance);
}
private static void HeapifyWorstFirst(List<RankedLight> heap)
{
for (int index = heap.Count / 2 - 1;
index >= 0;
index--)
{
SiftWorstDown(heap, index);
}
}
private static void SiftWorstDown(
List<RankedLight> heap,
int index)
{
while (true)
{
int left = checked(index * 2 + 1);
if (left >= heap.Count)
return;
int right = left + 1;
int worse = right < heap.Count
&& CompareRankedLights(heap[right], heap[left]) > 0
? right
: left;
if (CompareRankedLights(heap[worse], heap[index]) <= 0)
return;
(heap[index], heap[worse]) =
(heap[worse], heap[index]);
index = worse;
}
}
private readonly record struct RankedLight(
LightSource Light,
int QualifyingOrdinal,
float DistanceSq);
// ── Viewer light — retail SmartBox::set_viewer (0x00452c40) ──────────────
// Retail adds a white fill light pinned to the player EVERY frame via
// Render::add_dynamic_light. It is the dominant INTERIOR fill: the outdoor
@ -495,6 +338,7 @@ public sealed class LightManager
_viewerLight = new LightSource
{
Kind = LightKind.Point,
LocalPose = Matrix4x4.CreateTranslation(0f, 0f, 2f),
ColorLinear = Vector3.One, // white (1,1,1)
Intensity = ViewerLightIntensity,
Range = ViewerLightFalloff * 1.5f, // dynamic rangeAdjust 1.5
@ -504,6 +348,7 @@ public sealed class LightManager
};
_all.Add(_viewerLight);
}
_viewerLight.RankingOrigin = playerWorldPos;
_viewerLight.WorldPosition = playerWorldPos + new Vector3(0f, 0f, 2f);
}
@ -575,73 +420,22 @@ public sealed class LightManager
}
/// <summary>
/// Per-CELL light selection — retail <c>minimize_envcell_lighting</c> (0x0054c170).
/// Unlike <see cref="SelectForObject"/> (per-object sphere-overlap cull), retail enables
/// the ENTIRE dynamic subset for EVERY EnvCell it draws (verified by a live cdb trace of
/// <c>config_hardware_light</c>: the same 4 intensity-100 portal lights are applied to
/// every Facility Hub cell, every frame). So here: ALL dynamic lights are added
/// unconditionally (the shader's per-light range cutoff zeroes ones that don't reach —
/// same as D3D's hardware range), THEN remaining slots fill with the nearest STATIC lights
/// that reach the cell sphere. This is what makes a cell's floor lighting STABLE as the
/// portal flood shifts — a per-cell sphere-overlap cull of the dynamics is what made the
/// floor lighting FLAP (#176). Objects keep <see cref="SelectForObject"/>
/// (retail minimize_object_lighting).
/// Per-CELL light selection: every retained dynamic and static candidate.
/// Retail enables the complete dynamic product and separately bakes every
/// retained static into EnvCell vertices. Our documented GPU-evaluation path
/// supplies both products and leaves the unchanged shader range cutoff to
/// decide each vertex contribution. Ordinary objects keep
/// <see cref="SelectForObject"/> and its eight-light hardware cap.
/// </summary>
public static int SelectForCell(
IReadOnlyList<LightSource> snapshot,
Vector3 center,
float radius,
Span<int> outIndices)
{
int cap = Math.Min(outIndices.Length, MaxLightsPerObject);
if (cap <= 0) return 0;
int count = 0;
// 1) ALL dynamic lights, unconditionally (retail applies the whole dynamic subset to
// every cell — stable regardless of the cell's relation to each light).
for (int li = 0; li < snapshot.Count && count < cap; li++)
if (snapshot[li].IsDynamic)
outIndices[count++] = li;
// 2) Fill remaining slots with the nearest STATIC lights that reach the cell sphere,
// insertion-sorted among the static slots only (dynamic slots [0..staticStart) are fixed).
int staticStart = count;
Span<float> keptDistSq = stackalloc float[MaxLightsPerObject];
for (int li = 0; li < snapshot.Count; li++)
{
var light = snapshot[li];
if (light.IsDynamic) continue; // dynamics already added
float reach = light.Range + radius;
float dsq = (light.WorldPosition - center).LengthSquared();
if (dsq >= reach * reach) continue;
if (count < cap)
{
int j = count;
while (j > staticStart && keptDistSq[j - 1] > dsq)
{
keptDistSq[j] = keptDistSq[j - 1];
outIndices[j] = outIndices[j - 1];
j--;
}
keptDistSq[j] = dsq;
outIndices[j] = li;
count++;
}
else if (staticStart < cap && dsq < keptDistSq[cap - 1])
{
int j = cap - 1;
while (j > staticStart && keptDistSq[j - 1] > dsq)
{
keptDistSq[j] = keptDistSq[j - 1];
outIndices[j] = outIndices[j - 1];
j--;
}
keptDistSq[j] = dsq;
outIndices[j] = li;
}
}
int count = Math.Min(
Math.Min(snapshot.Count, outIndices.Length),
MaxLightsPerEnvCell);
for (int index = 0; index < count; index++)
outIndices[index] = index;
return count;
}
}

View file

@ -15,7 +15,7 @@ public enum LightKind
/// <summary>
/// Per-frame light record. Used by <see cref="LightManager"/> and fed to
/// the shader UBO on every draw call.
/// the directional UBO or retained point-light storage buffer as appropriate.
///
/// <para>
/// Retail semantics (r13 §10.2):
@ -40,6 +40,25 @@ public sealed class LightSource
{
public LightKind Kind;
public Vector3 WorldPosition;
private Vector3 _rankingOrigin;
/// <summary>
/// Authored root-frame origin used by retail's retained-light insertion
/// rank. This is deliberately independent of <see cref="WorldPosition"/>,
/// which includes the authored LIGHTINFO local offset.
/// </summary>
public Vector3 RankingOrigin
{
get => _rankingOrigin;
set
{
_rankingOrigin = value;
HasRankingOrigin = true;
}
}
/// <summary>True only after the nondirectional ranking root was supplied.</summary>
public bool HasRankingOrigin { get; private set; }
public Vector3 WorldForward; // for Spot/Directional
// R,G,B in [0,1], pre-brightness. NOTE (Campaign VM VM3, 2026-08-22
// review item A4): despite the name, this is retail's authored

View file

@ -150,6 +150,7 @@ public sealed class LightingHookSink : IAnimationHookSink
{
LightSource light = lights[i];
Matrix4x4 lightWorld = light.LocalPose * rootWorld;
light.RankingOrigin = rootWorld.Translation;
light.WorldPosition = lightWorld.Translation;
Vector3 forward = Vector3.TransformNormal(Vector3.UnitY, lightWorld);
if (forward.LengthSquared() > 1e-8f)

View file

@ -437,6 +437,8 @@ public sealed class WorldLifecycleAutomationControllerTests
IsDynamic: true,
IsLit: true,
TracksOwnerPose: true,
RankingOrigin: new EnvCellLightingVectorBits(
0x3F000000u, 0x3F800000u, 0x40000000u),
Position: new EnvCellLightingVectorBits(
0x3F800000u, 0x40000000u, 0x40400000u),
Forward: new EnvCellLightingVectorBits(
@ -459,7 +461,7 @@ public sealed class WorldLifecycleAutomationControllerTests
[
new EnvCellLightingCellSetSnapshot(
0x00070143u,
[0, -1, -1, -1, -1, -1, -1, -1]),
[0, .. Enumerable.Repeat(-1, 46)]),
],
Ambient: new EnvCellLightingAmbientSnapshot(
new EnvCellLightingVectorBits(

View file

@ -0,0 +1,176 @@
using System.Numerics;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Gpu.Vk;
using AcDream.App.Rendering.Packs;
using AcDream.Core.Lighting;
namespace AcDream.App.Tests.Rendering.Gpu.Vk;
public sealed unsafe partial class MeshModernSharedIndexOffscreenTests
{
[Trait("Lane", "Vulkan")]
[Theory]
[InlineData("mesh_modern", false)]
[InlineData("mesh_atmospheric", true)]
public void CommittedProductionWorldShaders_UseEnvCell47AndOrdinary8LightStrides(
string shaderName,
bool atmospheric)
{
lock (VulkanLock)
{
string shaderDirectory = Path.Combine(
RepositoryRoot(), "src", "AcDream.App", "Rendering", "Shaders", "spv");
using var host = HeadlessVulkanHost.Create(shaderDirectory);
(Pixel envFirst, Pixel envSecond) = RenderStridePair(
host,
shaderName,
atmospheric,
lightingMode: 1,
firstSlot: 8,
secondSlot: 46,
stride: 47);
(Pixel objectFirst, Pixel objectSecond) = RenderStridePair(
host,
shaderName,
atmospheric,
lightingMode: 0,
firstSlot: 7,
secondSlot: 7,
stride: 8);
AssertRed(envFirst, $"{shaderName} EnvCell slot 8");
AssertGreen(envSecond, $"{shaderName} EnvCell slot 46 / instance stride 47");
AssertRed(objectFirst, $"{shaderName} ordinary slot 7");
AssertGreen(objectSecond, $"{shaderName} ordinary instance stride 8");
}
}
private static (Pixel First, Pixel Second) RenderStridePair(
HeadlessVulkanHost host,
string shaderName,
bool atmospheric,
int lightingMode,
int firstSlot,
int secondSlot,
int stride)
{
VulkanGpuDevice device = host.Device;
using IGpuBuffer vertices = device.CreateBuffer(new GpuBufferDescription(
$"478-{shaderName}-vertices",
4 * Marshal.SizeOf<Vertex>(),
GpuBufferUsage.Vertex | GpuBufferUsage.TransferDestination,
GpuMemoryResidency.DeviceLocal));
using IGpuBuffer indices = device.CreateBuffer(new GpuBufferDescription(
$"478-{shaderName}-indices",
6 * sizeof(ushort),
GpuBufferUsage.Index | GpuBufferUsage.TransferDestination,
GpuMemoryResidency.DeviceLocal));
Vertex[] vertexData =
[
new(new Vector3(-0.30f, -0.30f, 0f), Vector3.UnitZ, Vector2.Zero),
new(new Vector3( 0.30f, -0.30f, 0f), Vector3.UnitZ, Vector2.UnitX),
new(new Vector3( 0.30f, 0.30f, 0f), Vector3.UnitZ, Vector2.One),
new(new Vector3(-0.30f, 0.30f, 0f), Vector3.UnitZ, Vector2.UnitY),
];
vertices.Upload(0, MemoryMarshal.AsBytes<Vertex>(vertexData));
indices.Upload(0, MemoryMarshal.AsBytes<ushort>([0, 1, 2, 2, 3, 0]));
using IGpuRenderTarget target = CreateTarget(device, $"478-{shaderName}-{lightingMode}");
using IGpuPipeline pipeline = device.CreatePipeline(new GpuPipelineDescription
{
Name = $"478-{shaderName}-{lightingMode}",
Shaders = new GpuShaderSet(shaderName),
VertexLayout = GpuVertexLayout.WorldMesh,
Topology = GpuPrimitiveTopology.TriangleList,
Blend = GpuBlendMode.None,
Depth = GpuDepthState.Disabled,
Cull = GpuCullMode.None,
UsesRenderPackShaderAbi = atmospheric,
SampleCount = 1,
});
// Pad both modes to 94 entries. The ordinary path still reads only
// offsets 7/15, while its stride-47 sabotage deterministically reads
// in-bounds wrong data rather than invoking driver-dependent OOB access.
var lightIndices = Enumerable.Repeat(
-1,
LightManager.MaxLightsPerEnvCell * 2).ToArray();
lightIndices[firstSlot] = 0;
lightIndices[stride + secondSlot] = 1;
GlobalLight[] globalLights =
[
DynamicLight(new Vector3(-0.48f, 0f, 1f), Vector3.UnitX),
DynamicLight(new Vector3( 0.48f, 0f, 1f), Vector3.UnitY),
];
using (IGpuFrame frame = device.BeginFrame())
{
using IGpuPassEncoder encoder = BeginPass(frame, target, $"478-{shaderName}-stride");
encoder.BindPipeline(pipeline);
GpuPushConstants constants = GpuPushConstants.Default;
constants.LightingMode = lightingMode;
constants.LightDebug = 3;
encoder.SetPushConstants(constants);
BindStorage(frame, encoder, GpuBindingModel.StorageInstances,
[
Matrix4x4.CreateTranslation(-0.48f, 0f, 0f),
Matrix4x4.CreateTranslation( 0.48f, 0f, 0f),
]);
BindStorage(frame, encoder, GpuBindingModel.StorageBatches,
[new BatchData(device.DefaultTextureSlot.Index, 1f, 0u, 0u)]);
BindStorage(frame, encoder, GpuBindingModel.StorageClipSlots, [0u, 0u]);
BindStorage(frame, encoder, GpuBindingModel.StorageGlobalLights, globalLights);
BindStorage(frame, encoder, GpuBindingModel.StorageInstanceLightSets, lightIndices);
BindStorage(frame, encoder, GpuBindingModel.StorageInstanceIndoor, [1u, 1u]);
BindStorage(frame, encoder, GpuBindingModel.StorageInstanceAlpha, [1f, 1f]);
BindStorage(frame, encoder, GpuBindingModel.StorageInstanceSelectionLighting,
[new Vector2(0f, 1f), new Vector2(0f, 1f)]);
BindStorage(frame, encoder, GpuBindingModel.StorageInstanceDetailCategory, [0u, 0u]);
BindUniform(frame, encoder, GpuBindingModel.UniformSceneLighting, default(SceneLightingUbo));
if (atmospheric)
{
BindUniform(frame, encoder, GpuBindingModel.UniformAtmosphericFrame,
default(AtmosphericFrameUniforms));
BindUniform(frame, encoder, GpuBindingModel.UniformDirectionalShadow,
ShadowUniforms(device.DefaultTextureSlot, enabled: false, -Vector3.UnitY));
}
encoder.BindVertexBuffer(0, vertices, 0);
encoder.BindIndexBuffer(indices, 0, GpuIndexType.UInt16);
encoder.DrawIndexed(6, 2, 0, 0, 0);
}
device.WaitIdle();
VulkanGpuRenderTarget vkTarget = Assert.IsType<VulkanGpuRenderTarget>(target);
byte[] pixels = ReadBack(
host.Vk,
host.PhysicalDevice,
host.LogicalDevice,
host.Queue,
host.QueueFamily,
vkTarget.ColorResult.Image);
return (PixelAt(pixels, 16, 32), PixelAt(pixels, 48, 32));
}
private static GlobalLight DynamicLight(Vector3 position, Vector3 color) => new(
new Vector4(position, (float)LightKind.Point),
new Vector4(0f, 0f, 1f, 10f),
new Vector4(color, 1f),
new Vector4(0f, 1f, 0f, 0f));
private static void AssertRed(Pixel pixel, string label)
{
// Vulkan's selected offscreen format is copied in BGRA byte order.
Assert.True(pixel.R < 8 && pixel.G < 8 && pixel.B > 180,
$"{label}: rgba={pixel.R},{pixel.G},{pixel.B},{pixel.A}");
}
private static void AssertGreen(Pixel pixel, string label)
{
Assert.True(pixel.R < 8 && pixel.G > 180 && pixel.B < 8,
$"{label}: rgba={pixel.R},{pixel.G},{pixel.B},{pixel.A}");
}
}

View file

@ -51,7 +51,9 @@ public sealed class VulkanShaderManifestTests
// S5-c4 replaces the padding word at batch offset 4 with authored
// surface opacity and forwards it plus exact detail category/flags
// flat to the fragment shader. The 16-byte ABI is unchanged.
["mesh_modern.vert.spv"] = "6194193764616bc88bf967d1fdc2bea5f6f2d1ec2ef94fd492922137503f56e4",
// Re-pinned 2026-09-05 (#478): binding 5 keeps object stride 8
// while EnvCell mode consumes the complete retained 47-light set.
["mesh_modern.vert.spv"] = "9909ca4729dbe4fc7fbffb11c73481977d8f593d37f130fcdd44a7803b47944f",
["particle.frag.spv"] = "680da227704e0b3afa9b5226a7d73dd65aa9d8759d081cf4d5009d30e148726b",
// Re-pinned 2026-08-27: portal-view clip slots now travel with
// deferred billboard particles, matching retail PortalList draws.

View file

@ -934,6 +934,9 @@ public sealed class OrderPreservingSubmitterTests
AssertLocalSection(
GpuBindingModel.StorageInstanceLightSets,
[0, 2, 4, 6, -1, -1, -1, -1, 1, 3, 5, 7, -1, -1, -1, -1]);
Assert.Equal(
(uint)(2 * AcDream.Core.Lighting.LightManager.MaxLightsPerObject * sizeof(int)),
LastBind(GpuBindingModel.StorageInstanceLightSets).SizeBytes);
AssertLocalSection(GpuBindingModel.StorageInstanceIndoor, [0u, 1u]);
AssertLocalSection(GpuBindingModel.StorageInstanceAlpha, [0.25f, 0.75f]);
AssertLocalSection(

View file

@ -78,6 +78,7 @@ public class EnvCellRendererTests
{
Kind = LightKind.Spot,
WorldPosition = new Vector3(50f, 60f, 70f),
RankingOrigin = new Vector3(5f, 6f, 7f),
WorldForward = new Vector3(0.25f, -0.5f, 0.75f),
ColorLinear = new Vector3(0.11f, 0.22f, 0.33f),
Intensity = 4.5f,
@ -99,6 +100,7 @@ public class EnvCellRendererTests
{
Kind = LightKind.Point,
WorldPosition = Vector3.Zero,
RankingOrigin = Vector3.Zero,
WorldForward = Vector3.UnitX,
ColorLinear = new Vector3(0.9f, 0.1f, 0.2f),
Intensity = 2f,
@ -111,6 +113,7 @@ public class EnvCellRendererTests
{
Kind = LightKind.Point,
WorldPosition = new Vector3(100f, 0f, 0f),
RankingOrigin = new Vector3(100f, 0f, 0f),
WorldForward = Vector3.UnitY,
ColorLinear = new Vector3(0.2f, 0.8f, 0.4f),
Intensity = 3f,
@ -173,16 +176,17 @@ public class EnvCellRendererTests
(int)setBind.OffsetBytes,
(int)setBind.SizeBytes)).ToArray();
Assert.Equal(
actualSets.AsSpan(0, LightManager.MaxLightsPerObject).ToArray(),
actualSets.AsSpan(0, LightManager.MaxLightsPerEnvCell).ToArray(),
Assert.Single(snapshot.CellSets, set => set.CellId == cellB).Indices);
Assert.Equal(
actualSets.AsSpan(
LightManager.MaxLightsPerObject,
LightManager.MaxLightsPerObject).ToArray(),
LightManager.MaxLightsPerEnvCell,
LightManager.MaxLightsPerEnvCell).ToArray(),
Assert.Single(snapshot.CellSets, set => set.CellId == cellA).Indices);
Assert.Equal([0, 1, -1, -1, -1, -1, -1, -1],
int[] expectedSet = [0, 1, 2, .. Enumerable.Repeat(-1, 44)];
Assert.Equal(expectedSet,
Assert.Single(snapshot.CellSets, set => set.CellId == cellA).Indices);
Assert.Equal([0, 2, -1, -1, -1, -1, -1, -1],
Assert.Equal(expectedSet,
Assert.Single(snapshot.CellSets, set => set.CellId == cellB).Indices);
EnvCellLightingSourceSnapshot source = snapshot.Sources[0];
@ -193,6 +197,7 @@ public class EnvCellRendererTests
Assert.True(source.IsDynamic);
Assert.True(source.IsLit);
Assert.True(source.TracksOwnerPose);
Assert.Equal(BitConverter.SingleToUInt32Bits(5f), source.RankingOrigin.X);
Assert.Equal(BitConverter.SingleToUInt32Bits(50f), source.Position.X);
Assert.Equal(BitConverter.SingleToUInt32Bits(-0.5f), source.Forward.Y);
Assert.Equal(BitConverter.SingleToUInt32Bits(0.33f), source.Color.Z);
@ -235,6 +240,98 @@ public class EnvCellRendererTests
renderer.CaptureLightingConsumption(emptyLighting, currentWorldFrame: true));
}
[Fact]
public void SubmitRhi_BindsTwoCompleteFortySevenIndexCellSetsInCurrentGeneration()
{
const uint cellA = 0x8C040101u;
const uint cellB = 0x8C040102u;
using var device = new RecordingGpuDevice();
using var meshManager = CreateMeshManager(device);
var frameLifetime = new GpuDeviceFrameLifetime(device);
var scope = new VulkanWorldPassScope(sampleCount: 1);
using var renderer = new EnvCellRenderer(
device,
frameLifetime,
scope,
meshManager,
new WbFrustum());
LightSource[] lights = Enumerable.Range(0, LightManager.MaxLightsPerEnvCell)
.Select(index => new LightSource
{
Kind = LightKind.Point,
WorldPosition = new Vector3(index, 0f, 0f),
RankingOrigin = new Vector3(index, 0f, 0f),
Range = 10f,
IsDynamic = index < LightManager.MaxDynamicPointLights,
})
.ToArray();
renderer.SetPointSnapshot(lights);
frameLifetime.BeginFrame();
IGpuFrame frame = frameLifetime.CurrentFrame!;
IGpuPassEncoder pass = frame.BeginPass(
GpuPassDescription.BackbufferClear(
"envcell-47-index-binding",
Vector4.Zero,
sampleCount: 1));
IDisposable publication = scope.Publish(pass);
renderer.BeginFrame(frame.SlotIndex);
SeedLightingSubmission(renderer, instanceCount: 2);
device.Clear();
InvokeSubmitRhi(renderer,
[
new InstanceData { Transform = Matrix4x4.Identity, CellId = cellA },
new InstanceData { Transform = Matrix4x4.Identity, CellId = cellB },
], instanceCount: 2);
GpuRecordedStorageBind bind = Assert.Single(
device.Calls.OfType<GpuRecordedStorageBind>(),
call => call.Binding == GpuBindingModel.StorageInstanceLightSets);
Assert.Equal((uint)(2 * LightManager.MaxLightsPerEnvCell * sizeof(int)), bind.SizeBytes);
int[] actual = MemoryMarshal.Cast<byte, int>(device.RingBytes.Slice(
(int)bind.OffsetBytes,
(int)bind.SizeBytes)).ToArray();
int[] expected = Enumerable.Range(0, LightManager.MaxLightsPerEnvCell).ToArray();
Assert.Equal(expected, actual.AsSpan(0, 47).ToArray());
Assert.Equal(expected, actual.AsSpan(47, 47).ToArray());
Assert.Equal(8, GpuBindingModel.MaxLightsPerObject);
Assert.Equal(47, GpuBindingModel.MaxLightsPerEnvCell);
publication.Dispose();
pass.Dispose();
frameLifetime.EndFrame();
// A shorter next-generation snapshot must overwrite every binding-5
// slot. No index from the prior 47-entry generation may survive.
renderer.SetPointSnapshot(lights.Take(2).ToArray());
frameLifetime.BeginFrame();
frame = frameLifetime.CurrentFrame!;
using IGpuPassEncoder nextPass = frame.BeginPass(
GpuPassDescription.BackbufferClear(
"envcell-47-index-binding-next-generation",
Vector4.Zero,
sampleCount: 1));
using IDisposable nextPublication = scope.Publish(nextPass);
renderer.BeginFrame(frame.SlotIndex);
SeedLightingSubmission(renderer, instanceCount: 2);
device.Clear();
InvokeSubmitRhi(renderer,
[
new InstanceData { Transform = Matrix4x4.Identity, CellId = cellA },
new InstanceData { Transform = Matrix4x4.Identity, CellId = cellB },
], instanceCount: 2);
bind = Assert.Single(
device.Calls.OfType<GpuRecordedStorageBind>(),
call => call.Binding == GpuBindingModel.StorageInstanceLightSets);
actual = MemoryMarshal.Cast<byte, int>(device.RingBytes.Slice(
(int)bind.OffsetBytes,
(int)bind.SizeBytes)).ToArray();
int[] expectedShort = [0, 1, .. Enumerable.Repeat(-1, 45)];
Assert.Equal(expectedShort, actual.AsSpan(0, 47).ToArray());
Assert.Equal(expectedShort, actual.AsSpan(47, 47).ToArray());
}
[Theory]
[InlineData(CullMode.Landblock)]
[InlineData(CullMode.None)]

View file

@ -263,11 +263,13 @@ public sealed class WorldRenderFrameBuilderTests
var visibleLight = new LightSource
{
Kind = LightKind.Point,
RankingOrigin = Vector3.Zero,
CellId = visibleCell,
};
var hiddenLight = new LightSource
{
Kind = LightKind.Point,
RankingOrigin = Vector3.Zero,
CellId = hiddenCell,
};
lighting.Register(visibleLight);

View file

@ -395,11 +395,13 @@ public sealed class LandblockConcretePresentationPipelineTests
{
OwnerId = staticEntity.Id,
Kind = LightKind.Point,
RankingOrigin = Vector3.Zero,
});
fixture.Lighting.RegisterOwnedLight(new LightSource
{
OwnerId = liveEntity.Id,
Kind = LightKind.Point,
RankingOrigin = Vector3.Zero,
});
fixture.Translucency.StartPartFade(staticEntity.Id, 0u, 0f, 1f, 2f);
fixture.Translucency.StartPartFade(liveEntity.Id, 0u, 0f, 1f, 2f);
@ -448,11 +450,13 @@ public sealed class LandblockConcretePresentationPipelineTests
{
OwnerId = staticEntity.Id,
Kind = LightKind.Point,
RankingOrigin = Vector3.Zero,
});
fixture.Lighting.RegisterOwnedLight(new LightSource
{
OwnerId = liveEntity.Id,
Kind = LightKind.Point,
RankingOrigin = Vector3.Zero,
});
fixture.Translucency.StartPartFade(staticEntity.Id, 0u, 0f, 1f, 2f);
fixture.Translucency.StartPartFade(liveEntity.Id, 0u, 0f, 1f, 2f);

View file

@ -12,6 +12,7 @@ public sealed class LightManagerTests
{
Kind = LightKind.Point,
WorldPosition = pos,
RankingOrigin = pos,
Range = range,
IsLit = lit,
OwnerId = ownerId,
@ -23,6 +24,7 @@ public sealed class LightManagerTests
{
Kind = LightKind.Point,
WorldPosition = pos,
RankingOrigin = pos,
Range = range,
IsLit = true,
IsDynamic = true,
@ -53,6 +55,21 @@ public sealed class LightManagerTests
Assert.Equal(1, mgr.RegisteredCount);
}
[Fact]
public void Register_NondirectionalWithoutExplicitRankingOrigin_Throws()
{
var mgr = new LightManager();
var light = new LightSource
{
Kind = LightKind.Point,
WorldPosition = new Vector3(3f, 4f, 5f),
};
ArgumentException error = Assert.Throws<ArgumentException>(() => mgr.Register(light));
Assert.Contains("ranking origin", error.Message, StringComparison.OrdinalIgnoreCase);
Assert.Equal(0, mgr.RegisteredCount);
}
[Fact]
public void Tick_SelectsByDistance_Top8()
{
@ -175,18 +192,148 @@ public sealed class LightManagerTests
}
[Fact]
public void BuildPointLightSnapshot_IndexStable_InBudget()
public void BuildPointLightSnapshot_UnderCap_SortsByRootDistance()
{
var mgr = new LightManager();
// Registration order preserved when under MaxGlobalLights (no sort).
mgr.Register(MakePoint(new Vector3(100, 0, 0), 5f)); // far
mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f)); // near
mgr.BuildPointLightSnapshot(Vector3.Zero);
Assert.Equal(2, mgr.PointSnapshot.Count);
Assert.Equal(100f, mgr.PointSnapshot[0].WorldPosition.X, 3); // index 0 = first registered
Assert.Equal(1f, mgr.PointSnapshot[1].WorldPosition.X, 3);
Assert.Equal(1f, mgr.PointSnapshot[0].WorldPosition.X, 3);
Assert.Equal(100f, mgr.PointSnapshot[1].WorldPosition.X, 3);
}
[Fact]
public void BuildPointLightSnapshot_RanksRootBeforeAuthoredOffsetFinalPosition()
{
var manager = new LightManager();
LightSource nearRootFarFinal = MakePoint(new Vector3(100f, 0f, 0f), 20f, ownerId: 1);
nearRootFarFinal.RankingOrigin = new Vector3(1f, 0f, 0f);
LightSource farRootNearFinal = MakePoint(new Vector3(2f, 0f, 0f), 20f, ownerId: 2);
farRootNearFinal.RankingOrigin = new Vector3(50f, 0f, 0f);
manager.Register(farRootNearFinal);
manager.Register(nearRootFarFinal);
manager.BuildPointLightSnapshot(Vector3.Zero);
Assert.Equal(new[] { nearRootFarFinal, farRootNearFinal }, manager.PointSnapshot);
Assert.Equal(1f, manager.PointSnapshot[0].DistSq);
}
[Fact]
public void BuildPointLightSnapshot_StrictForwardInsertion_PreservesEqualAndNaNOrder()
{
var manager = new LightManager();
LightSource nan = MakePoint(Vector3.Zero, 20f, ownerId: 1);
nan.RankingOrigin = new Vector3(float.NaN, 0f, 0f);
LightSource equalA = MakePoint(new Vector3(10f, 0f, 0f), 20f, ownerId: 2);
equalA.RankingOrigin = new Vector3(2f, 0f, 0f);
LightSource equalB = MakePoint(new Vector3(20f, 0f, 0f), 20f, ownerId: 3);
equalB.RankingOrigin = new Vector3(-2f, 0f, 0f);
manager.Register(nan);
manager.Register(equalA);
manager.Register(equalB);
manager.BuildPointLightSnapshot(Vector3.Zero);
Assert.Equal(new[] { nan, equalA, equalB }, manager.PointSnapshot);
}
[Fact]
public void BuildPointLightSnapshot_StrictForwardInsertion_AdvancesPastNaN()
{
var manager = new LightManager();
LightSource far = MakePoint(new Vector3(10f, 0f, 0f), 20f, ownerId: 1);
LightSource nan = MakePoint(Vector3.Zero, 20f, ownerId: 2);
nan.RankingOrigin = new Vector3(float.NaN, 0f, 0f);
LightSource near = MakePoint(Vector3.One, 20f, ownerId: 3);
manager.Register(far);
manager.Register(nan);
manager.Register(near);
manager.BuildPointLightSnapshot(Vector3.Zero);
Assert.Equal(new[] { near, far, nan }, manager.PointSnapshot);
Assert.Equal(new[] { 3f, 100f }, manager.PointSnapshot.Take(2).Select(light => light.DistSq));
Assert.True(float.IsNaN(manager.PointSnapshot[2].DistSq));
}
[Fact]
public void BuildPointLightSnapshot_SpotUsesZeroRank_PointUsesRootDistance()
{
var manager = new LightManager();
LightSource point = MakePoint(new Vector3(1f, 0f, 0f), 20f, ownerId: 1);
LightSource spot = MakePoint(new Vector3(100f, 0f, 0f), 20f, ownerId: 2);
spot.Kind = LightKind.Spot;
manager.Register(point);
manager.Register(spot);
manager.BuildPointLightSnapshot(Vector3.Zero);
Assert.Equal(new[] { spot, point }, manager.PointSnapshot);
Assert.Equal(0f, spot.DistSq);
}
[Fact]
public void BuildPointLightSnapshot_IndependentSevenAndFortyProducts_DoNotCrossEvict()
{
var manager = new LightManager();
var dynamics = new List<LightSource>();
var statics = new List<LightSource>();
for (int i = 0; i < 10; i++)
{
LightSource light = MakeDynamic(new Vector3(100f + i, 0f, 0f), 10f);
light.OwnerId = checked((uint)(100 + i));
dynamics.Add(light);
manager.Register(light);
}
for (int i = 0; i < 50; i++)
{
LightSource light = MakePoint(new Vector3(i, 0f, 0f), 10f, checked((uint)(200 + i)));
statics.Add(light);
manager.Register(light);
}
manager.BuildPointLightSnapshot(Vector3.Zero);
Assert.Equal(LightManager.MaxGlobalLights, manager.PointSnapshot.Count);
Assert.Equal(dynamics.Take(7), manager.PointSnapshot.Take(7));
Assert.Equal(statics.Take(40), manager.PointSnapshot.Skip(7));
}
[Fact]
public void BuildPointLightSnapshot_ViewerWinsEqualRootTieThoughRegisteredLast()
{
var manager = new LightManager();
LightSource ordinary = MakeDynamic(new Vector3(0f, 0f, 2f), 15f);
ordinary.RankingOrigin = Vector3.Zero;
manager.Register(ordinary);
manager.UpdateViewerLight(Vector3.Zero);
manager.BuildPointLightSnapshot(Vector3.Zero);
Assert.Equal(2, manager.PointSnapshot.Count);
Assert.NotSame(ordinary, manager.PointSnapshot[0]);
Assert.Equal(new Vector3(0f, 0f, 2f), manager.PointSnapshot[0].WorldPosition);
Assert.Equal(Vector3.Zero, manager.PointSnapshot[0].RankingOrigin);
Assert.Same(ordinary, manager.PointSnapshot[1]);
}
[Fact]
public void BuildPointLightSnapshot_ClearRemovesRetainedProducts()
{
var manager = new LightManager();
manager.Register(MakePoint(Vector3.One, 5f));
manager.BuildPointLightSnapshot(Vector3.Zero);
Assert.NotEmpty(manager.PointSnapshot);
manager.Clear();
Assert.Empty(manager.PointSnapshot);
manager.BuildPointLightSnapshot(Vector3.Zero);
Assert.Empty(manager.PointSnapshot);
}
// ── Resident collection (#176 corrected reading, 2026-07-06) ───────────────
@ -280,8 +427,6 @@ public sealed class LightManagerTests
manager.BuildPointLightSnapshot(Vector3.Zero);
Assert.Equal(expected, manager.PointSnapshot);
Assert.True(manager.LastPointSnapshotUsedTieFallback);
Assert.False(manager.LastPointSnapshotUsedBoundedSelection);
manager.BuildPointLightSnapshot(Vector3.Zero);
long before = GC.GetAllocatedBytesForCurrentThread();
@ -369,8 +514,6 @@ public sealed class LightManagerTests
Assert.Equal(LightManager.MaxGlobalLights, manager.PointSnapshot.Count);
Assert.Equal(expected, manager.PointSnapshot);
Assert.True(manager.LastPointSnapshotUsedBoundedSelection);
Assert.False(manager.LastPointSnapshotUsedTieFallback);
}
[Fact]
@ -391,8 +534,6 @@ public sealed class LightManagerTests
}
manager.BuildPointLightSnapshot(Vector3.Zero);
manager.BuildPointLightSnapshot(Vector3.Zero);
Assert.True(manager.LastPointSnapshotUsedBoundedSelection);
Assert.False(manager.LastPointSnapshotUsedTieFallback);
long before = GC.GetAllocatedBytesForCurrentThread();
for (int iteration = 0; iteration < 100; iteration++)
@ -498,14 +639,14 @@ public sealed class LightManagerTests
Assert.Equal(a[0], b[0]);
}
// ── SelectForCell — retail minimize_envcell_lighting (all dynamics on every cell) ──
// ── SelectForCell — complete retained 7-dynamic + 40-static products ──
[Fact]
public void SelectForCell_AppliesAllDynamicLights_EvenOutOfReach()
{
// Retail enables the WHOLE dynamic subset for every cell (cdb-verified: the same
// portal lights on every Facility Hub cell) — including ones that don't reach it,
// since the shader's range cutoff zeroes those. Static lights still cull by reach.
// Retail enables the whole dynamic subset and bakes the whole retained static
// product. The GPU path supplies both unchanged; per-vertex range cutoff decides
// which entries contribute.
var snapshot = new[]
{
MakePoint(new Vector3(1, 0, 0), range: 5f), // 0: static, reaches
@ -513,8 +654,8 @@ public sealed class LightManagerTests
MakeDynamic(new Vector3(2, 0, 0), range: 5f), // 2: dynamic, near
MakePoint(new Vector3(50, 0, 0), range: 5f), // 3: static, far (out of reach)
};
Span<int> sel = stackalloc int[LightManager.MaxLightsPerObject];
int n = LightManager.SelectForCell(snapshot, Vector3.Zero, radius: 1f, sel);
Span<int> sel = stackalloc int[LightManager.MaxLightsPerEnvCell];
int n = LightManager.SelectForCell(snapshot, sel);
bool d1 = false, d2 = false, s0 = false, s3 = false;
for (int i = 0; i < n; i++)
@ -527,7 +668,7 @@ public sealed class LightManagerTests
Assert.True(d1, "the FAR dynamic light must still be applied — retail enables all dynamics");
Assert.True(d2, "the near dynamic light is applied");
Assert.True(s0, "the near static light reaches the cell → selected");
Assert.False(s3, "the far static light doesn't reach → not selected");
Assert.True(s3, "the complete retained static product is supplied; the shader applies range");
}
[Fact]
@ -541,15 +682,35 @@ public sealed class LightManagerTests
MakeDynamic(new Vector3(0, 0, 0), range: 5f),
MakeDynamic(new Vector3(100, 0, 0), range: 5f),
};
Span<int> a = stackalloc int[8];
Span<int> b = stackalloc int[8];
int na = LightManager.SelectForCell(snapshot, new Vector3(0, 0, 0), 1f, a);
int nb = LightManager.SelectForCell(snapshot, new Vector3(500, 0, 0), 1f, b);
Span<int> a = stackalloc int[LightManager.MaxLightsPerEnvCell];
Span<int> b = stackalloc int[LightManager.MaxLightsPerEnvCell];
int na = LightManager.SelectForCell(snapshot, a);
int nb = LightManager.SelectForCell(snapshot, b);
Assert.Equal(2, na); // both dynamics on the near cell
Assert.Equal(2, nb); // both dynamics on the far cell too — identical, no flap
}
[Fact]
public void SelectForCell_CarriesAllSevenDynamicsAndFortyStatics_WhileObjectStaysEight()
{
var snapshot = new List<LightSource>();
for (int i = 0; i < LightManager.MaxDynamicPointLights; i++)
snapshot.Add(MakeDynamic(new Vector3(i, 0f, 0f), 100f));
for (int i = 0; i < LightManager.MaxStaticPointLights; i++)
snapshot.Add(MakePoint(new Vector3(i + 10f, 0f, 0f), 100f));
Span<int> cell = stackalloc int[LightManager.MaxLightsPerEnvCell];
int cellCount = LightManager.SelectForCell(snapshot, cell);
Span<int> obj = stackalloc int[LightManager.MaxLightsPerEnvCell];
int objectCount = LightManager.SelectForObject(snapshot, Vector3.Zero, 100f, obj);
Assert.Equal(47, cellCount);
for (int index = 0; index < cellCount; index++)
Assert.Equal(index, cell[index]);
Assert.Equal(8, objectCount);
}
/// <summary>
/// #176/#177 (2026-07-06, corrected same day) — the end-state pin. The pool is
/// retail's RESIDENT collection anchored at the PLAYER: a light in range of an
@ -599,7 +760,7 @@ public sealed class LightManagerTests
"an in-range light beside the player was evicted from the pool — " +
"per-cell lighting would pop (the #176/#177 mechanism)");
Assert.True(torchSelected2, "consecutive same-player builds must select identically");
Assert.Equal(LightManager.MaxGlobalLights, mgr.PointSnapshot.Count); // cap applied to the far cluster
Assert.Equal(LightManager.MaxStaticPointLights, mgr.PointSnapshot.Count);
static bool SelectedContains(
System.Collections.Generic.IReadOnlyList<LightSource> snapshot,
@ -623,22 +784,34 @@ public sealed class LightManagerTests
continue;
ranked.Add(new OracleRank(
light,
Vector3.DistanceSquared(light.WorldPosition, player)));
light.Kind == LightKind.Point
? Vector3.DistanceSquared(light.RankingOrigin, player)
: 0f));
}
if (ranked.Count <= LightManager.MaxGlobalLights)
return ranked.Select(static item => item.Light).ToArray();
var dynamics = ranked.Where(static item => item.Light.IsDynamic).ToList();
var statics = ranked.Where(static item => !item.Light.IsDynamic).ToList();
StableRetailInsertion(dynamics, LightManager.MaxDynamicPointLights);
StableRetailInsertion(statics, LightManager.MaxStaticPointLights);
return dynamics.Concat(statics).Select(static item => item.Light).ToArray();
ranked.Sort(static (left, right) =>
static void StableRetailInsertion(List<OracleRank> values, int cap)
{
if (left.Light.IsDynamic != right.Light.IsDynamic)
return left.Light.IsDynamic ? -1 : 1;
return left.DistanceSq.CompareTo(right.DistanceSq);
});
return ranked
.Take(LightManager.MaxGlobalLights)
.Select(static item => item.Light)
.ToArray();
var selected = new List<OracleRank>(cap);
foreach (OracleRank value in values)
{
int index = 0;
while (index < selected.Count && !(value.DistanceSq < selected[index].DistanceSq))
index++;
if (index >= cap)
continue;
selected.Insert(index, value);
if (selected.Count > cap)
selected.RemoveAt(cap);
}
values.Clear();
values.AddRange(selected);
}
}
private readonly record struct OracleRank(

View file

@ -14,9 +14,9 @@ public sealed class LightingHookSinkTests
var mgr = new LightManager();
var sink = new LightingHookSink(mgr, new MutablePoseSource());
var light1 = new LightSource { Kind = LightKind.Point, OwnerId = 42, IsLit = true };
var light2 = new LightSource { Kind = LightKind.Point, OwnerId = 42, IsLit = true };
var other = new LightSource { Kind = LightKind.Point, OwnerId = 99, IsLit = true };
var light1 = new LightSource { Kind = LightKind.Point, OwnerId = 42, IsLit = true, RankingOrigin = Vector3.Zero };
var light2 = new LightSource { Kind = LightKind.Point, OwnerId = 42, IsLit = true, RankingOrigin = Vector3.Zero };
var other = new LightSource { Kind = LightKind.Point, OwnerId = 99, IsLit = true, RankingOrigin = Vector3.Zero };
sink.RegisterOwnedLight(light1);
sink.RegisterOwnedLight(light2);
sink.RegisterOwnedLight(other);
@ -35,8 +35,8 @@ public sealed class LightingHookSinkTests
var mgr = new LightManager();
var sink = new LightingHookSink(mgr, new MutablePoseSource());
sink.RegisterOwnedLight(new LightSource { OwnerId = 7 });
sink.RegisterOwnedLight(new LightSource { OwnerId = 7 });
sink.RegisterOwnedLight(new LightSource { OwnerId = 7, RankingOrigin = Vector3.Zero });
sink.RegisterOwnedLight(new LightSource { OwnerId = 7, RankingOrigin = Vector3.Zero });
Assert.Equal(2, mgr.RegisteredCount);
sink.UnregisterOwner(7);
@ -48,7 +48,7 @@ public sealed class LightingHookSinkTests
{
var mgr = new LightManager();
var sink = new LightingHookSink(mgr, new MutablePoseSource());
var light = new LightSource { OwnerId = 1, IsLit = true };
var light = new LightSource { OwnerId = 1, IsLit = true, RankingOrigin = Vector3.Zero };
sink.RegisterOwnedLight(light);
// Should not crash or change state for non-SetLight hooks.
@ -71,6 +71,7 @@ public sealed class LightingHookSinkTests
var light = new LightSource
{
OwnerId = 42u,
RankingOrigin = Vector3.Zero,
LocalPose = Matrix4x4.CreateTranslation(1, 0, 2),
TracksOwnerPose = true,
};
@ -81,6 +82,7 @@ public sealed class LightingHookSinkTests
Assert.InRange(light.WorldPosition.X, 9.99f, 10.01f);
Assert.InRange(light.WorldPosition.Y, 20.99f, 21.01f);
Assert.InRange(light.WorldPosition.Z, 31.99f, 32.01f);
Assert.Equal(new Vector3(10f, 20f, 30f), light.RankingOrigin);
Assert.Equal(0x01010002u, light.CellId);
}
@ -95,6 +97,7 @@ public sealed class LightingHookSinkTests
{
OwnerId = 42u,
WorldPosition = new Vector3(7, 8, 9),
RankingOrigin = new Vector3(7, 8, 9),
LocalPose = Matrix4x4.CreateTranslation(1, 0, 0),
TracksOwnerPose = false,
};
@ -114,7 +117,7 @@ public sealed class LightingHookSinkTests
sink.InitializeOwnerLighting(7u, enabled: true);
sink.SetOwnerLighting(7u, enabled: false);
sink.UnregisterOwner(7u, forgetState: false);
var replacement = new LightSource { OwnerId = 7u };
var replacement = new LightSource { OwnerId = 7u, RankingOrigin = Vector3.Zero };
sink.RegisterOwnedLight(replacement);
@ -160,6 +163,7 @@ public sealed class LightInfoLoaderTests
Assert.Equal(10.4f, light.Range, 3); // Falloff 8 × static_light_factor 1.3 (calc_point_light 0x00820e24)
Assert.Equal(0.8f, light.Intensity);
Assert.Equal(new Vector3(101, 202, 303), light.WorldPosition);
Assert.Equal(new Vector3(100, 200, 300), light.RankingOrigin);
Assert.Equal(new Vector3(1, 2, 3), light.LocalPose.Translation);
Assert.InRange(light.ColorLinear.X, 0.99f, 1.01f);
}

View file

@ -30,6 +30,7 @@ public sealed class SceneLightingUboTests
{
Kind = LightKind.Point,
WorldPosition = new Vector3(1, 2, 3),
RankingOrigin = new Vector3(1, 2, 3),
ColorLinear = new Vector3(1f, 0.5f, 0.25f),
Intensity = 0.8f,
Range = 6f,
@ -84,6 +85,7 @@ public sealed class SceneLightingUboTests
{
Kind = LightKind.Point,
WorldPosition = new Vector3(i, 0, 0),
RankingOrigin = new Vector3(i, 0, 0),
Range = 200f, // all in range
});
}

View file

@ -1,6 +1,9 @@
using System;
using System.IO;
using System.Linq;
using System.Numerics;
using AcDream.Content;
using AcDream.Core.Lighting;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
@ -111,7 +114,7 @@ public class Issue93TownNetworkFountainRoomLightInspectionTests
_out.WriteLine($"=== Setup 0x{setupId:X8}: Parts={setup!.Parts.Count} PlacementFrames={setup.PlacementFrames.Count} Lights={setup.Lights.Count} ===");
foreach (var kvp in setup.Lights)
_out.WriteLine($" light[{kvp.Key}] Color=({kvp.Value.Color?.Red},{kvp.Value.Color?.Green},{kvp.Value.Color?.Blue}) Intensity={kvp.Value.Intensity} Falloff={kvp.Value.Falloff} ConeAngle={kvp.Value.ConeAngle}");
_out.WriteLine($" light[{kvp.Key}] Color=({kvp.Value.Color?.Red},{kvp.Value.Color?.Green},{kvp.Value.Color?.Blue}) Intensity={kvp.Value.Intensity} Falloff={kvp.Value.Falloff} ConeAngle={kvp.Value.ConeAngle} LocalOrigin=({kvp.Value.ViewSpaceLocation?.Origin.X:R},{kvp.Value.ViewSpaceLocation?.Origin.Y:R},{kvp.Value.ViewSpaceLocation?.Origin.Z:R})");
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
_out.WriteLine($" SetupMesh.Flatten -> {flat.Count} MeshRefs");
@ -139,6 +142,68 @@ public class Issue93TownNetworkFountainRoomLightInspectionTests
$"=> GameWindow.cs:7324 would {(survivors == 0 ? "DROP" : "KEEP")} this entity");
}
[Fact]
public void TownCeilingFixture_AuthoredTypeKeyAndRootRankOrigin_ArePinnedFromInstalledDat()
{
var datDir = ResolveDatDir();
if (datDir is null)
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
using var dats = new DatCollection(datDir, DatAccessType.Read);
const uint cellId = 0x00070144u;
const uint setupId = 0x02000365u;
EnvCell cell = Assert.IsType<EnvCell>(dats.Get<EnvCell>(cellId));
var fixture = Assert.Single(cell.StaticObjects, entry => entry.Id == setupId);
Setup setup = Assert.IsType<Setup>(dats.Get<Setup>(setupId));
var authored = Assert.Single(setup.Lights);
// The dictionary key is native LIGHTINFO.type. The current loader's
// cone-derived Point/Spot projection deliberately remains unchanged.
Assert.Equal(0, authored.Key);
Assert.Equal(0xCDCDCDCDu, BitConverter.SingleToUInt32Bits(authored.Value.ConeAngle));
Assert.Equal(0.000759337f, authored.Value.ViewSpaceLocation.Origin.X);
Assert.Equal(0.00675148f, authored.Value.ViewSpaceLocation.Origin.Y);
Assert.Equal(0.0277f, authored.Value.ViewSpaceLocation.Origin.Z);
Vector3 root = new(
fixture.Frame.Origin.X,
fixture.Frame.Origin.Y,
fixture.Frame.Origin.Z);
Quaternion rotation = new(
fixture.Frame.Orientation.X,
fixture.Frame.Orientation.Y,
fixture.Frame.Orientation.Z,
fixture.Frame.Orientation.W);
Assert.Equal(new Vector3(69.875f, -69.916f, 5.005f), root);
Assert.Equal(new Quaternion(0f, 0f, -0.94372f, 0.330745f), rotation);
LightSource light = Assert.Single(LightInfoLoader.Load(
setup,
ownerId: 0x4000712Fu,
entityPosition: root,
entityRotation: rotation,
isDynamic: false,
cellId: cellId));
Assert.True(light.HasRankingOrigin);
Assert.Equal(root, light.RankingOrigin);
Assert.Equal(LightKind.Point, light.Kind);
Assert.NotEqual(root, light.WorldPosition);
var localFrame = authored.Value.ViewSpaceLocation;
var localOffset = new Vector3(
localFrame.Origin.X,
localFrame.Origin.Y,
localFrame.Origin.Z);
var localRotation = new Quaternion(
localFrame.Orientation.X,
localFrame.Orientation.Y,
localFrame.Orientation.Z,
localFrame.Orientation.W);
Matrix4x4 expectedWorld = (Matrix4x4.CreateFromQuaternion(localRotation)
* Matrix4x4.CreateTranslation(localOffset))
* (Matrix4x4.CreateFromQuaternion(rotation) * Matrix4x4.CreateTranslation(root));
Assert.Equal(expectedWorld.Translation, light.WorldPosition);
}
/// <summary>
/// Follow-up (same session, 2026-07-09): user confirmed lighting improved but
/// reported missing candle flames + fountain water particles. Hypothesis tested: