Round-6 research pass on dynamic lights in The New Colossus: which C++ classes and decl fields make up a light, which budgets and limits the engine enforces, and whether a new light can light the new geometry of the custom kit map. idLight turns out to be the one class behind every light type, falloff and projection both come from one lightMaterial, and the GPU light list is bound once per frame with view scope, so a new light should add to the baked lightmap of kit pieces rather than replace it — inferred from the decl/renderParm/cvar architecture, not proven at instruction level, and never seen in the running game. The kit map's 12 lights are already built and installed; a cheaper console test (addLight through the resourceExec route) is proposed at the end. Everything below was read-only; no game was launched.
Track dynlights, 2026-10-01, round 2 (verifier fixes + follow-ups).
Read-only against
<Steam library>/steamapps/common/Wolfenstein.II.The.New.Colossus. New
material lives under re_probes/dynlights/ (survey_lights.py,
survey_out.txt, and this round's round2_checks.py/round2_out.txt) and
this report; nothing under wolfsdk/, tools/, docs/, or any other
track's re_probes/* folder was written to (re_probes/materials/
dump_renderparm.py and tools/decomp.py were reused read-only, not
modified). No game launch, no process attach, no rebuild of the kit map (the
existing tools/build_kitmap*.py/tools/design_kitmap.py pipeline was read
and its already-built output was read and hashed, never re-run, since
running it writes under re_probes/kitmap/, another track's folder).
Pass 1 errors (verifier round, fixed here)
The verifier REFUTED two numeric claims and flagged one methodology gap and
one transcription slip; all four are fixed in place below, independently
re-measured this round with re_probes/dynlights/round2_checks.py (new) and
re_probes/materials/dump_renderparm.py (reused, not rewritten):
- REFUTED, fixed: "only two light cvars are not
CHEAT-flagged" (Sec.3) contradicted Sec.5's own table two paragraphs later. Re-census: 9 genuinely light-system non-CHEATcvars exist (r_imageAtlasLightWidth/Height,r_lightClassThreshold,r_lightDistanceFadeMultiplier,r_lightScatteringQuality,r_particlesLightAtlasWidth/Height/Quality,r_shadowMaxStaleFrames). Sec.3 and Sec.5 both corrected; Sec.5's table gained the two rows it had omitted even though they answer "budgets/limits in code" directly. - REFUTED, fixed:
idLensFlarehas 35 distinctedit.*fields, not 25 — pass 1 undercounted by eye instead of counting its ownsurvey_out.txttable;round2_checks.pyrecomputes it mechanically (len(Counter), not a printed-line eyeball) and gets 35, matching a manual recount of the same file. Sec.2 and PROVEN fixed. - Minor transcription slip, fixed: the Sec.3 code block showed
lightFalloffMapandlightProjectMapboth defaulting tolights/squareLight1.tga(capital L). Re-dumped live withdump_renderparm.py:lightfalloffmapreally islights/squareLight1.tga, butlightprojectmapdefaults tolights/squarelight1.tga(lowercase l). Fixed in Sec.3. - Methodology gap, fixed: Sec.3's "0/11,469 materials override
lightMask" evidence searched the wrong corpus by the report's own cited scope rule (no scope keyword -> instance scope, not material scope).round2_checks.pynow runs the right check — raw-text search forlightmaskacross every entity class, all 39.entitiesmaps — and independently gets 0/0 hits too, so the conclusion survives with the correct justification. Also folded in the verifier's missed item:skiplightprobe(a second instance-scope opt-out renderParm, probe-only) gets the same check, also 0/0.
The verifier's other missed items (console commands addLight/
testLight/etc., build_custom_map.py's light_color/lights_near
primitives, writeDebugLightmapImage) are chased in the new Sec.8 and in
Sec.7's recipe below.
Answer in one paragraph
idLight is the one C++ class behind every dynamic light type (point,
spot, probe, area, scattering, and — once, corpus-wide — parallel); the
light/point/light/spot/light/probe decls set nothing but lightType,
so every other default (whether a light casts shadows, which falloff image
it uses, …) is a C++-side default, not a decl default. A light's falloff
and its projected "cookie" texture are both named in one material
decl via lightMaterial (74% of corpus lights set it explicitly; 133
distinct retail lights/... materials exist to reuse, each naming a
lightfalloffmap and a lightprojectmap, both packed into one shared
light image atlas — idLightAtlasResource, sized by r_imageAtlasLightWidth
x r_imageAtlasLightHeight, default 2048x1024). Whether a new light can
light the kit's new geometry: the GPU-side light list (lights, a
StructuredBuffer<light_t>, and its per-cluster index buffers
clusterLightsId/clusterNumLights) is declared with view scope —
bound once per frame, not per-material — and lightmask (the one renderParm
that could exempt a surface from it) is never set away from its default
(1) by any of the 11,469 census'd retail materials. That is code-level
(renderParm-scope) evidence, not a guess, that dynamic lights are a
frame-global resource every forward-shaded surface samples, independent of
which "ambientprogram" variant (lightmapped "Unique" or not) a material
picked — so a new idLight placed near the kit's idStaticInstanceEntity
pieces should light them on top of whatever ambient/lightmap term
r2_maplightstairs.md's variant H gives them, not instead of it; nothing
found disables dynamic lighting for lightmapped statics. This is INFERRED
from decl/renderParm/cvar architecture, not instruction-level proven — no
shader bytecode decompiler exists in this toolkit (same limit
r4_materials.md already recorded) and it has never been seen in the
running game. The recipe already exists and has already been executed:
tools/design_kitmap.py's LIGHTS list + tools/build_kitmap.py's
donor-transplant light authoring placed 12 idLight entities (9 point, 3
spot) into the kit map by cloning two real retail c2v1 lights — which means
each new light inherits a complete, retail-precedented lightMaterial/
castShadows/shadowZControl configuration, not a blank one — and
tools/build_kitmap_variant.py H folds that .entities text together with
the lightmap/stairs/collision fixes into one container that is currently
installed live at dlc/dlc_0/base/chunk_22.resources
(sha256 cd7e9340…, byte-identical to
re_probes/kitmap/variants/H/chunk_22.resources, independently re-hashed
this round). Nobody has loaded it in the game yet.
1. Entity classes (RTTI-confirmed, corpus-measured)
docs/ghidra/rtti_tnc.json (vtable-per-class, from the TNC EXE's own RTTI,
no guessing) lists, among light-shaped classes: idLight, idLight2,
idLensFlare, idGodRays, idLightAtlasResource, idFuncShadowCaster,
idShadowOcclusion/idShadowOcclusion_Umbra, idStaticShadowGeom,
idInfoStaticShadowGeo. A fresh corpus census this round
(re_probes/dynlights/survey_lights.py, reusing tools/survey_tnc.walk/
unwrap for the archive+compfile layer and tools/build_custom_map.parse
for the .entities text — both already proven byte-exact elsewhere, no new
parser) over 39 .entities maps (base/ + dlc_1/2/3/5/base, 28
archives — the same "retail, dlc_0/dlc_4 excluded" set
r4_materials.md's census already uses, for the same reason: this
install's dlc_0 currently holds the non-retail kit-map container H, see
"Recipe" below) finds:
| class | instances | inherit values seen |
|---|---|---|
idLight |
13,406 | light/point 10,149 · light/spot 2,474 · light/probe 783 |
idLensFlare |
7,739 | func/flare 7,739 |
idGodRays |
0 | — (RTTI class exists, never placed in these 39 maps) |
idLight2 |
0 (not even an inherit/class string; RTTI-only) |
— |
lightType (set on every one of the 13,406 idLight instances) distribution:
| lightType | count |
|---|---|
LIGHT_SPOT |
5,903 |
LIGHT_POINT |
4,413 |
LIGHT_PROBE |
2,529 |
LIGHT_SCATTERING |
523 |
LIGHT_AREA |
37 |
LIGHT_PARALLEL |
1 |
So point/spot/probe/area/scattering/parallel are all the same C++ class
and the same two decl bases (light/point, light/spot — plus
light/probe for probes), distinguished purely by the lightType string
and which sub-block (spotLight/areaLight) is present. The single
LIGHT_PARALLEL instance (c3v3_lights_loading_bay_light_point_2116865654)
still carries a spotLight cone block, i.e. it is not the sun/directional
key light — that almost certainly comes from envSettings (the world
block's own decl, outside any entity), not from an idLight entity; not
traced further this round (open question).
A LIGHT_AREA sample (lwint_light_light2_1_758357695, 1 of 37) shows the
area-specific shape: areaLight = { lightTarget/lightRight/lightUp (a 3x3
basis), shape = "LIGHTAREA_RECT" }, still with its own lightMaterial and
castShadows.
2. Fields, corpus-wide
idLight: 117 distinct edit.* field paths across the 13,406
instances. Top ones (full table: re_probes/dynlights/survey_out.txt):
| field | hits / 13,406 | role |
|---|---|---|
lightType |
13,406 | point/spot/probe/area/scattering/parallel |
spawnPosition.{x,y,z} |
~13,400 | placement (as every entity) |
maxVisibleRange |
11,008 | distance culling |
lightMaterial |
9,873 (74%) | falloff + projected ("cookie") image, see Sec.3 |
lightColor.{r,g,b} |
3,519–9,367 (uneven per channel) | tint; which channel(s) a given instance omits was not resolved to a single rule this round (open question) |
lightIntensity |
9,000 | brightness scalar |
lightRadius.{x,y,z} |
~8,400 | point-light range/falloff box |
spotLight.lightTarget/lightRight/lightUp/lightConeLength/lightConeSize/lightStart/lightWidthRatio/lightHeightRatio |
1,593–6,902 | spot cone geometry (basis vectors + derived length/size, see Sec.5) |
renderModelInfo.scale.{x,y,z} |
6,277 | the light's own gizmo/flare-card render model scale |
castShadows |
5,344 explicit (4,984 true / 360 false) |
see Sec.4 |
maxShadowVisibleRange |
4,768 | shadow distance cutoff (values seen: 2,4,5,6,8,10,12,15,16,18,20,25,30,35,40) |
fadeVisibilityOver |
4,063 | visibility fade band |
advancedLightData.scatteringMultiplier |
3,400 | volumetric-scattering contribution scalar |
staticLightMapScale |
2,770 | how strongly this light contributes to baked lightmaps (bake-time only, see Sec.6) |
shadowZControl.shadowMinNear |
2,525 | shadow depth-bias control (values 0.05–1) |
lightCenter.{x,y,z} |
up to 2,268 | falloff-origin offset, independent of spawnPosition |
advancedLightData.clippingType/clipBoxSize.*/clipBoxPosition.*/clipPlanesEnabled/clipPlanePosition.* |
361–1,067 | a light can be clipped to a box or plane (matches light_t.boxMin/boxMax in the GPU struct, Sec.3) |
lightProbeInnerFalloff |
681 | probe-specific |
colorCurves.{intensity,duration} |
469–656 | animated color/intensity curve (references a table decl, e.g. "flaretable_01") |
contributesToLightProbeGen |
508 | whether this light is baked into light probes (bake-time) |
particleLightEffect/particleLightScale |
1 / 413 | couples a light to the particle-light-atlas system (r_particlesLightAtlas* cvars, Sec.5) |
radiosityShadows |
34 | |
backFaceShadows |
13 | |
shadowRate/shadowResolutionQuality/shadowMipBias |
1–6 | per-light shadow-map quality overrides |
shadowFlags |
1 ("SHADOWFLAGS_NO_ADAPTIVE_MIP") |
an actual named shadow-flags field, rarely used |
advancedLightData.lightClass |
8 ("LIGHT_CLASS_2" x2, "LIGHT_CLASS_3" x6; LIGHT_CLASS_1 never spelled out) |
ties to r_lightClassThreshold, Sec.5 |
staticLightBakeSpecular |
6 | bake-time |
dynamicLightSpecularScale |
seen on the one LIGHT_PARALLEL sample |
|
clipModelInfo.clipModelName |
8 | a light can carry its own named clip model |
targets.num/targets.item[] |
1,079/1,235 | a light can trigger/be triggered like any entity |
idLensFlare (35 distinct fields, re-measured mechanically this round
after pass 1's by-eye miscount of 25 — see "Pass 1 errors"; donor's own data
is in Sec.7):
declFlare (7,739/7,739, always set — references a flares/... decl, a
different asset family from lightMaterial), renderModelInfo.{color,
colorScale,scale} (the flare's visual card), fadeStart/fadeEnd
(6,884/7,000), quadSize (5,777), lensFlareZBias (3,449),
bindInfo.bindParent (249 — flares commonly ride on another entity, e.g. a
flashlight prop in the sample below).
Neither class has a field that references the baked lightmap directly —
that is .proc's job (lightmapScaleBias, r2_maplightstairs.md), a
completely separate, per-static-instance mechanism from anything an
idLight carries.
3. Light materials and falloff/projection images
A lightMaterial decl is an ordinary material decl (same text format as
matpipe.md/r4_materials.md already solved), just with a different set of
renderParm keys. Example, read whole this round
(lights/analytical/point/point_frac80, one of 133 distinct lights/...
materials found across the 28-archive census):
lightfalloff2d 1.000000
lightfalloffatlasscalebias { 0.030273, 0.029297, 0.000488, 0.000977 }
lightfalloffmap "lights/analytical/falloff_aniso0_r64_frac80_gamma2.tga$pad2"
lightprojatlasscalebias { 0.006836, 0.013672, 0.000488, 0.000977 }
lightprojectmap "lights/proj_white.tga$pad2"
lightsatlasmap _lightimageatlas
So one material names two images: lightfalloffmap (the radial/aniso
falloff gradient) and lightprojectmap (the projected "cookie" — most
lights use a plain white square, lights/proj_white.tga or
lights/squarelight1.tga, but 133 distinct materials include real gobo
patterns too: lights/projections/spot_leafy,
lights/projections/circular_spot_grate/...,
lights/projections/venus_audition_projection_slide_1_row, window-shaped
cookies lights/spot/c01p1_farm_window01/02, etc.). Both images are packed
into one shared atlas (lightsatlasmap = _lightimageatlas, the
idLightAtlasResource RTTI class, sized by r_imageAtlasLightWidth x
r_imageAtlasLightHeight — default 2k x 1k, i.e. 2048x1024; these are 2 of
9 light-system cvars in the whole corpus that are not CHEAT-flagged
(full list and table in Sec.5 — fixed this round, see "Pass 1 errors"),
addressed per-material by the ...atlasscalebias rect — the
same atlas-rect encoding r2_maplightstairs.md already proved for
lightmaps, just a different atlas.
For modding: reusing one of the 133 existing lightMaterial values on a
new idLight is a plain text edit (same category of change as every
other proven .entities edit in entitypipe.md). Authoring a genuinely
new falloff/cookie image would mean adding to the shared atlas — not
attempted or needed this round (the kit's 12 lights all reuse two retail
materials verbatim via donor transplant, see Sec.7) and not evidenced
either way; flagged as an open question, same shape as r4_materials.md's
open question about the virtual-texture/megatexture append path.
The GPU-side shape a light actually becomes, read from its renderParm
declaration this round:
renderParm lights { StructuredBuffer light_t view }
renderParm clusterLightsId { StructuredBuffer uint view }
renderParm clusterNumLights { StructuredBuffer clusterNumLights_t view }
renderParm numLights { Vec 0 }
renderParm clusteredLighting { Vec 0 view }
renderParm lightMask { Vec 1 } -- no scope keyword given
renderParm lightFalloffMap { String lights/squareLight1.tga } -- capital L
renderParm lightProjectMap { String lights/squarelight1.tga } -- lowercase l (fixed this round, was mistranscribed as squareLight1.tga)
renderParm lightsAtlasMap { Tex2D lights/squarelight1.tga view }
renderParm skiplightprobe { Vec 0 bool } -- no scope keyword -> instance scope; probe-bake opt-out only
struct light_t {
float3 pos; uint lightParms; float4 posShadow;
float4 falloffR; float4 projS; float4 projT; float4 projQ;
uint4 scaleBias;
float4 boxMin; float4 boxMax; float4 areaPlane;
uint lightParms2; uint colorPacked; float specMultiplier;
uint shadowBleedReduce_lightScattering;
}
lights/clusterLightsId/clusterNumLights/clusteredLighting are all
declared with view scope — the same scope keyword
r2_maplightstairs.md used to prove lightmapScaleBias is instance-scoped
(set per draw call from the .proc table). view scope means these
buffers are bound once per frame/view, independent of which material or
which "ambientprogram" variant (outside, outsideUnique, …,
r4_materials.md Sec.1.3) a given surface's shader uses — any forward-shaded
pixel shader that declares lights/clusterLightsId reads the same
frame-global light list and cluster-index buffers. lightMask (Vec 1 =
"affected by all lights" by default) and skiplightprobe (Vec 0 bool =
probe-bake-only opt-out, affects LIGHT_PROBE sampling, not direct
point/spot lights) are the two renderParms that could, in principle, let a
surface opt out of dynamic lighting. Fixed this round (see "Pass 1
errors"): pass 1's evidence for "nothing opts out" was 0/11,469 retail
materials overriding lightMask — but neither renderParm carries a scope
keyword in its decl, which by r2_maplightstairs.md's own cited rule means
both default to instance scope, i.e. per-entity data, not material text
— so materials were the wrong corpus to search. The right corpus is entity
data: round2_checks.py greps the raw text of every entity, all classes,
across all 39 retail .entities maps, case-insensitive, for lightmask and
skiplightprobe — 0/0 hits for both, independently reproducing and
correcting pass 1's conclusion with the corpus its own scope rule actually
calls for.
Not decompiled (no shader bytecode disassembler in this toolkit, the
same limit r4_materials.md recorded for ambientProgram/stageProgram):
exactly how a given pixel shader combines the light_t buffer lookup with
a sampled lightmap texel inside one shader invocation. The view-scope
argument shows the data is available to every program; it does not show
the arithmetic that combines it.
4. castShadows and the shadow pipeline
5,344/13,406 (40%) of corpus idLight instances set castShadows
explicitly (4,984 true, 360 false); the rest rely on a C++-side default
that is not visible in any decl (light/point/light/spot only set
lightType, confirmed by reading all four base decls fresh this round —
see Sec.7's donor evidence: the retail point-light donor used by the kit
build never sets castShadows at all, while the retail spot-light donor
sets it true). Not decompiled: the actual C++ default when the field
is absent. Shadow-quality/budget levers found as entity fields:
maxShadowVisibleRange (2–40, no single dominant value), shadowZControl.
shadowMinNear (0.05–1), shadowRate, shadowResolutionQuality,
shadowMipBias, shadowFlags (one value seen corpus-wide,
SHADOWFLAGS_NO_ADAPTIVE_MIP), radiosityShadows, backFaceShadows.
5. Budgets/limits found in code (cvars; re_probes/cvartable/cvars_tnc_v2.json, 3,256 cvars, live-read flags/defaults/ranges)
TNC uses clustered forward shading (confirmed by the clusterLightsId/
clusterNumLights/clusteredLighting renderParms in Sec.3, not just
inferred from cvar names): the view frustum is divided into
r_clusteredLightingTilesX x Y x Z = 16 x 8 x 24 = 3,072 cells
(all three CHEAT+SHADERSYNC, not settable at runtime without a shader
recompile sync), r_clusteredLightingNearZ/FarZ = 2.0/256.0 pseudo-depth
range for the Z-slice distribution. No single "max N lights total" or "max
lights per cluster" cvar was found (3,256 cvars checked by regex over
name+description for light, 122 hits this round, re-run fresh — absence
noted, not a claim that no such cap exists in code — a fixed-size GPU buffer
almost certainly has some upper bound, just not one exposed as a cvar).
Fixed this round (verifier's missed item): pass 1's "only the tile grid + 2 Umbra caps are quantized budgets" undersold what the same 122-hit sweep
actually turned up — real per-frame culling/throttling levers beyond the
static tile grid: r_skipLightRangeCulling/CPUCulling/GPUCulling
(lights are distance-, CPU-MVP- and GPU-occlusion-culled every frame, each
switch defaulting to on, i.e. culling active), r_shadowMaxStaleFrames
(throttles how often a spot/point light's shadow map re-renders, per mip
level), r_shadowOcclusionMinRadius (10 — above this radius a light's
shadow updates get occlusion-culled), and r_shadowDirtyWarningTreshold/
r_shadowTopOffenders (soft-cap diagnostics for "too many lights touch one
model" and "most expensive shadow casters"). All rows now in the table
below.
| cvar | default | range | flags | exposed/settable in retail |
|---|---|---|---|---|
r_clusteredLightingTilesX/Y/Z |
16/8/24 | — | CHEAT, SHADERSYNC | no (needs resourceExec … -s) |
r_lightClassThreshold |
2 | — | NOCHEAT, LOCAL_SETTING, EXPOSE | yes, plain console |
r_skipLights |
0 | — | CHEAT | no — bitmask: 1 all, 2 points, 4 spots, 8 area, 16 parallel, 32 probes |
r_umbraMaxShadowQueriesVisible |
6 | 0–512 | CHEAT | no — visible-light shadow-occlusion jobs/frame |
r_umbraMaxShadowQueriesInvisible |
1 | 0–512 | CHEAT | no |
r_shadowForceDynLights |
1 (on) | — | CHEAT | no — always updates dynamic-light shadows |
r_shadowUpdateDynamicOnly |
1 | — | CHEAT | no |
r_imageAtlasLightWidth |
2 (k) | 1–16 | NOCHEAT, EXPOSE | yes |
r_imageAtlasLightHeight |
1 (k) | 1–16 | NOCHEAT, EXPOSE | yes |
r_particlesLightAtlasWidth/Height |
4096/2048 | — | NOCHEAT, LOCAL_SETTING, EXPOSE | yes |
r_particlesLightAtlasQuality |
1 | — | NOCHEAT, LOCAL_SETTING, EXPOSE | yes — 0 low/1 med/2 high/3 ultra |
r_lightDistanceFadeMultiplier |
1.0 | — | NOCHEAT, LOCAL_SETTING, EXPOSE | yes |
r_lightScatteringQuality |
1 | — | NOCHEAT, LOCAL_SETTING, EXPOSE | yes — scattering-volume resolution, 0–3 |
r_shadowMaxStaleFrames |
0,0,1,2,2 |
— | NOCHEAT, LOCAL_SETTING, EXPOSE | yes — per-mip-level shadow update-rate throttle for spot/point lights (0=60Hz…4=lowest); a real per-frame shadow-update budget, missed in pass 1's table |
r_shadowDirtyWarningTreshold |
1,000,000 | — | CHEAT | no — warns if a model intersects more lights than this |
r_shadowOcclusionMinRadius |
10 | — | CHEAT | no — lights above this radius get occlusion-culled for shadow updates |
r_skipLightRangeCulling/CPUCulling/GPUCulling |
0/0/0 (off) | — | CHEAT | no — lights ARE actively culled by distance/CPU-MVP/GPU-occlusion every frame; a real per-frame culling budget, missed in pass 1's framing |
r_shadowTopOffenders |
0 | — | CHEAT | no — "List the top N most expensive shadow casting lights (disables soft caps)" — the description itself names "soft caps" elsewhere in the budget system |
r_dynamicLightingScale |
1 | — | CHEAT | no — scales "approximated lighting on dynamic models" |
r_enableApproximateLightingForPoint |
1 | — | CHEAT | no — 0 off / 1 amb+lights / 2 amb only / 3 lights only (proves ambient and direct are independently switchable terms) |
r_useLightPVS |
0 (off) | — | CHEAT | no — would use PVS to cut which areas a shadow light reaches; off by default, and skeleton.md already established the kit map ships with no usable PVS/Tome anyway |
r_singleLight |
-1 | — | CHEAT | no — isolate one light by index, useful for an in-game debug pass if ever done |
r_showLights/r_showLightSpecific/r_showShadowsName |
0/""/"" | 0–3 | CHEAT | no — debug visualization, would be the first thing to run in-game if a light looks wrong |
resourceExec route: every CHEAT-flagged cvar above is, per memory
tnc-console-unlock, only reachable through the already-proven
resourceExec <modded cfile> -s carrier (plain +cvar value on the
command line is rejected). The handful marked yes above
(r_lightClassThreshold, the two light-atlas sizes, r_lightDistanceFade
Multiplier, the particle-light-atlas sizes) are EXPOSE+NOCHEAT and
should be settable from the normal in-game console without that carrier —
not verified this round (no game launch), but consistent with every
other EXPOSE/NOCHEAT cvar already confirmed reachable in prior rounds.
6. Dynamic light vs. baked lightmap: relationship, not replacement
r2_maplightstairs.md already proved (code, 123/123 anchors) that every
idStaticInstanceEntity in a kind-2 world — which is what every kit piece
is — gets the lightmapped "Unique" program unconditionally, and that
the only way to avoid that is three hard-coded model-name string
compares, unreachable for new content. That round's whole fix (variant H,
927 .proc records) is about making the baked/ambient term plausible.
This round's job was the other axis: the direct term. Two cvars make
the split explicit as independently-scaled quantities:
r_ambientChannelScale ("Scales the ambient light contribution") and
r_directChannelScale ("Scales the direct light contribution") — if they
were the same pipeline stage, one scale would do. staticLightMapScale
(2,770/13,406 idLight instances set it) is the one field that explicitly
names lightmap in a dynamic-light's own data — but by name and by its
co-occurrence with other clearly bake-time-only fields
(contributesToLightProbeGen, staticLightBakeSpecular), it reads as "how
much this light contributes when the level is re-baked", not a runtime
toggle; this project has no lightmap baker and does not claim to invoke
one, so this field is inert for any kit-map light that is only ever loaded,
never baked. Put together: a new idLight near the kit should add its
direct contribution on top of whatever ambient/baked term the
surface already has (0/(1,1,0,0)-sampled before H, a real donor texel after
H), through the exact same frame-global lights/clusterLightsId buffers
every other forward-shaded surface in the game reads (Sec.3) — inferred
from decl/renderParm/cvar architecture, not instruction-level proven, and
never seen in the running game.
7. Recipe: the kit map's 12 lights, via the existing builders
This is already built, not merely designed. The chain, read this round, file by file:
tools/design_kitmap.py(lines ~399–415):LIGHT_DONORSnames two real retail c2v1 light entities —c2v1_lights_lights_light_point_1603252612(point) andc2v1_lights_lights_light_spot_1557246881(spot) — andLIGHTS, a 12-entry table(name, kind, pos, rgb, intensity, radius_or_aim_point, room, why)covering every room of the kit (hall, Windfang, galleries, corridors, two offices, courtyard). 9 arepoint, 3 arespot(kitmap_light_monolith_w/e,kitmap_light_hof_mond).tools/build_kitmap.py'sdonor_entity()(line ~258) does a full donor transplant — the same proven techniqueentitypipe.mdalready validated for enemies/pickups: deep-copy the donor's wholeentityDefblock, rename it, then overwrite onlyspawnPosition,spawnOrientation,lightColor,lightIntensity, and (point only)lightRadiusor (spot only) the cone viaspot_range(). Everything else rides along from the donor, read fresh this round from retaildlc_2/base/chunk_22.resources:- point donor:
lightMaterial = "lights/analytical/point/point_frac40",lightRadius = 6,6,6, nocastShadows(so none of the 9 kit point lights cast shadows either). - spot donor:
lightMaterial = "lights/analytical/spot/p100/spot_focus0_ frac80",castShadows = true,shadowZControl.shadowMinNear = 0.19,staticLightMapScale = 0.5(bake-time, Sec.6) — so all 3 kit spot lights (including the two "red streak light on the monolith" ones) do cast shadows, inherited, not authored. spot_range()(build_kitmap.py line 244) rescales the donor's cone to reach the design's aim point while holding the donor's cone angle fixed:length = ceil(dist(pos, aim) * 2) / 2(rounded up to 0.5 m), then rewriteslightTarget.x/lightRight.y/lightUp.z/lightConeLength/lightConeSizetogether, preserving the retail invariantbuild_kitmap.pyitself checks corpus-wide:lightConeLength == lightTarget.xandlightConeSize == 2*lightUp.z(gated, re-verified structurally present in H's.entitiesthis round).
- point donor:
tools/build_kitmap_variant.py H=G(texture/material fix) + one.proclightmap record per rendered kit static-instance piece (r2_maplightstairs.md). It only ever touches the.proccompfile; the.entitiestext — including the 12 lights from step 2 — passes through unchanged from the base kit build all the way to H.
Verified fresh this round (read-only, no rebuild):
re_probes/kitmap/variants/H/chunk_22.resources sha256
cd7e93400bee3bed4ed1ce164bd46ca0702f3b77a958377380738fc4e4a2e3e4, and the
file currently installed at
<Steam library>/.../dlc/dlc_0/base/chunk_22.resources hashes to the exact
same value — matching r4_materials.md's Sec.1.5 finding. Reading H's own
c2v1.entities payload out of that container this round: 976 entity
blocks total, exactly 12 are class = "idLight", and all 12 are named
kitmap_light_* — i.e. the skeleton's rule R6 ("remove all 549 retail
idLight/idLensFlare") and the 12 new authored lights are both still
intact in the currently-installed file, with zero retail lights left over
to confuse the picture.
For a future light, concretely: add a tuple to LIGHTS in
tools/design_kitmap.py (room, position, color, intensity, radius or aim
point) and re-run tools/design_kitmap.py then tools/build_kitmap.py
then tools/build_kitmap_variant.py H — no new code needed, the mechanism
already generalizes to any count/placement. To give a light its own
character instead of the two donors' two lightMaterials, pick any of the
133 corpus lights/... materials found this round (Sec.3) and set
lightMaterial explicitly after the transplant — same one-line decl.py-
style edit entitypipe.md already proved safe for lightColor/
lightIntensity.
A lower-effort alternative, missed in pass 1 (verifier's missed item):
tools/build_custom_map.py (already imported by build_kitmap.py as b,
so already "the existing builders" the brief asked about) has its own
pre-existing, simpler light-editing primitives that don't need donor
transplant at all, for a map (like c2v1) that already has retail lights in
it: light_color(doc, name, rgb, min_intensity) recolors an existing light
in place and raises its intensity to at least min_intensity (a one-line
edit to lightColor/lightIntensity on a light that already has a full,
working lightMaterial/castShadows config), and lights_near(doc, point,
radius) finds the names of nearby emitting lights (LIGHT_POINT/
LIGHT_SPOT only — the function's own comment explicitly excludes
LIGHT_PROBE, since a probe is a reflection/ambient capture, not a lamp).
That file's own comments also carry a pre-existing, independent light census
for c2v1 specifically (408 lights: 219 LIGHT_SPOT/84 LIGHT_POINT/102
LIGHT_PROBE/3 LIGHT_SCATTERING) — consistent in shape with this round's
corpus-wide lightType split (Sec.1) and worth cross-referencing for any
future single-map light work. The kit map itself has no retail lights left
to recolor (12/12 are kitmap_light_*, Sec.7 above), so this primitive
doesn't apply there, but it is the cheaper route for "tint an existing
retail room's lighting" in any other map.
8. A much cheaper in-game test: console light commands (new this round)
Missed in pass 1: re_probes/cmdtable/commands.json (234 TNC commands,
in the task brief's own "available" list) has 8 light-related commands,
none mentioned in pass 1's 531 lines: addLight, clearLights, popLight,
popClosestLight, testLight, testPointLight, testParallelLight,
writeDebugLightmapImage. All 8 are exposed: false (plain-console
unreachable in retail), so — per memory tnc-console-unlock — reachable
only via the already-proven resourceExec <loader-modded cfile> -s carrier,
the same route already confirmed live for God/g_infiniteAmmo.
Not run this round (no game launch; the task's read-only rule also
forbids writing a new test cfile under mods/), but decompiled this round
for its exact behavior:
python tools/decomp.py cmd_addLight --strings Addlight
cmd_addLight (rva 0xf3d260) is a tail-call stub into
FUN_7ff62df567c0; decompiling that (python tools/decomp.py 0x1d467c0)
turned out to be a dead end — it disassembles to a generic MSVC
__chkstk-style stack-probe helper (zeroes guard pages for a large stack
frame), not addLight's actual argument parser; Ghidra found no further
cross-references to walk from there. Dead end, 1 try, recorded, not
pursued further this round (matches idLight::Init's same "no symbol to
start from" shape from pass 1's open questions). What is fully recovered —
every one of addLight's help-text strings has a stable address and prints
verbatim regardless of which function reads it — is its complete usage
contract, pulled via --strings against several flag-name patterns:
Addlight Usage:
Note: (arg) is required [arg] is optional. Omit the () and []. run addLight -examples for examples
-point spawn a point light, defaults to spot
-spot spawn a spot light, defaults to this but at least you know what's happening
-dist ( n ) spot distance, defaults to 5000, only applicable for spot lights
-radius ( n ) defaults to 300, only applicable for point lights
-fov ( n ) defaults to player ( 85 ) fov, only applicable to spot lights
-intensity ( n ) defaults to 1.0, multiplied into color
-color ( r g b ) defaults to 1.0 1.0 1.0
-shadows turn on shadow casting for this light -- i.e. OFF unless passed
-mat ( material ) defaults to nothing, addLight -listMats to see a list of light materials
-listmats [ filter ] prints lists of light materials
examples:
addLight -point -color 1 0 0 -radius 150 (tiny red point light)
addLight -point -color 0 0 1 -radius 500 (huge blue shadowed point light -- shown without -shadows in the string dump, read as illustrative, not literal)
addLight -spot -dist 5000 -intensity 2 -fov 70 -shadows -color 0 1 .5 -mat lights/gaus_flicker_01
This is directly useful evidence, not just a recipe:
-shadowsis opt-in, off by default — a runtime-level data point (notidLight::Inititself, but the command's own spawn path) consistent with pass 1's open question aboutcastShadows's unset-field default; still not proof of the C++ class default, sinceaddLightcould be choosing its own spawn-time default independently of the class's — flagged as INFERRED, not PROVEN, below.-matdefaults to nothing — anaddLight-spawned light with no-mathas nolightMaterialoverride, which (per Sec.3's renderParm defaults) should fall back to the engine's own hardcoded default falloff/ project images (lights/squareLight1.tga/lights/squarelight1.tga), not one of the 133 corpus materials — makes a bareaddLight -point -color 1 0 0 -radius 150a clean, minimal-variable test.- The default spawn type is spot, not point (
-spot: "defaults to this"), opposite of what the brief's own FIRST PASS example commands might suggest — anyone testing this in game should pass-pointor-spotexplicitly rather than relying on the default. writeDebugLightmapImage("writes a debug image of the lightmap") is a cheap diagnostic the in-game test should run first, on any kit static instance, to confirm the kit's new geometry actually has a non-trivial baked lightmap rect before worrying about whether dynamic lights land on top of it — the verifier's own suggestion, carried into the test plan below.
Why this is cheaper than pass 1's test: standing next to any
lightmapped "Unique"-program surface in any already-loaded retail level
and running addLight -point -color 1 0 0 -radius 150 through the
resourceExec carrier tests this report's central open question (does a
new dynamic light reach lightmapped static geometry) directly, without
installing or loading the whole rebuilt kit map first. It is the new
in-game test's step 1, below.
PROVEN vs INFERRED
Proven (data/corpus, re-measured fresh this round, or file hashes):
idLightis the sole class for point/spot/probe/area/scattering light types (13,406 corpus instances, onlylight/point/light/spot/light/probeas decl bases,lightTypethe sole discriminator); exactly oneLIGHT_PARALLELinstance exists corpus-wide.idLensFlare(7,739 instances, alwaysdeclFlareset) is a separate class/asset family fromidLight, not a light subtype.idGodRaysandidLight2are RTTI classes with 0 placed instances across 39 maps / 28 archives.- 117 distinct
idLightedit.*fields and 35 distinctidLensFlarefields exist corpus-wide (full tables inre_probes/dynlights/ survey_out.txt; the 35 re-measured mechanically this round withround2_checks.py— pass 1's "25" was a by-eye miscount, see "Pass 1 errors");light/point/light/spot/light/probe/func/flaredecls set nothing butlightType(or nothing at all forfunc/flare). lightMaterial(amaterialdecl) names bothlightfalloffmapandlightprojectmap, both atlas-packed via...atlasscalebiasinto one shared_lightimageatlas; 133 distinctlights/...materials exist to reuse without authoring new images. The two images' defaults differ in case (lightfalloffmap->lights/squareLight1.tga,lightprojectmap->lights/squarelight1.tga), re-dumped live this round, fixing a transcription slip in pass 1.lights/clusterLightsId/clusterNumLights/clusteredLightingrenderParms are declaredview-scope (frame-global);lightMask(Vec 1) andskiplightprobe(Vec 0 bool, probe-bake-only) both default off and are never set away from default in the correct corpus — raw text of every entity, all classes, across all 39 retail.entitiesmaps, not materials (0/0 hits for both, fixed this round, see "Pass 1 errors"; the old 0/11,469-materials figure was also correct but was the wrong corpus per the report's own scope rule).- Exactly 9 light-system cvars in the 3,256-cvar table are not
CHEAT-flagged:r_imageAtlasLightWidth/Height,r_lightClassThreshold,r_lightDistanceFadeMultiplier,r_lightScatteringQuality,r_particlesLightAtlasWidth/Height/Quality,r_shadowMaxStaleFrames(fixed this round from pass 1's wrong claim of "only two"). - The console commands
addLight/testLight/testPointLight/testParallelLight/clearLights/popLight/popClosestLight/writeDebugLightmapImageexist (allexposed: false);addLight's full usage/flag-default text was recovered by decompile this round (Sec.8) — missed entirely in pass 1 despitecommands.jsonbeing in the task's own tool list. tools/build_custom_map.pyalready haslight_color()/lights_near(), simpler pre-existing primitives for recoloring/finding lights in a map that already has retail lights in it — missed in pass 1's recipe section.castShadowsis explicit on only 40% of corpus lights (4,984 true/360 false); the retail point-light donor the kit build clones never sets it, the retail spot-light donor sets ittrue.- The kit map's 12
idLightentities are full donor transplants of two real retail c2v1 lights, inheritinglightMaterial/castShadows/shadowZControl/staticLightMapScaleunmodified; only placement, color, intensity and (spots) cone range are authored per-light. re_probes/kitmap/variants/H/chunk_22.resourcesand the file currently installed atdlc/dlc_0/base/chunk_22.resourcesare byte-identical (sha256cd7e9340…, independently re-hashed this round); that file's ownc2v1.entitiescontains exactly 12idLightblocks, allkitmap_light_*, 0 leftover retail lights.- No cvar in the 3,256-cvar table caps total light count or lights/cluster; the only quantized budgets found are the 16x8x24 clustered-lighting tile grid and the two Umbra shadow-occlusion job caps (6 visible/1 invisible per frame).
Inferred (architecture-grounded, not instruction-level proven, never runtime-observed):
- That a new
idLight's direct-lighting contribution actually reaches a lightmapped (idStaticInstanceEntity/"Unique"-program) surface in the same way it reaches an ordinary surface. Grounded in theview-scope buffer declarations and the universal-defaultlightMask, but not in a decompiled shader that shows the two terms being summed. - The C++-side default for
castShadowswhen a light omits it (true or false) — still not decompiled at theidLightclass level this round either (cmd_addLight's tail-call target turned out to be a generic stack-probe helper, not its arg parser — Sec.8, dead end). New this round:addLight's own-shadowsconsole flag is explicitly opt-in ("turn on shadow casting for this light", off unless passed) — a same-direction runtime signal, not proof of the class constructor's default, since the command's spawn path could set its own default independently ofidLight's. - Whether the two donor
lightMaterials (and 131 other untried ones) look visually right on the kit's new geometry, or whether the 3 shadow-casting kit spot lights shadow the new kit meshes correctly — nothing here has been loaded in the game.
Failed hypotheses / dead ends (this round)
- "
r_skipLights's bitmask or a similar cvar directly exposes a total light-count cap" — checked (full description text read): it is a type-exclusion bitmask (points/spots/area/parallel/probes), not a count limit. No count-limit cvar found anywhere in the 3,256-entry table by name or description regex on "light". - "
lightMask/skiplightprobeare used somewhere in retail to keep dynamic light off a surface" — checked both the (wrong-corpus) 11,469 census'd materials and, this round, the right corpus — raw entity text, all classes, all 39 maps: 0/0 hits either way, always the compiled default. - A single "the" light-cvar budget number — there isn't one; the real
budget is the 3,072-cell clustered grid, per-frame range/CPU/GPU light
culling (on by default), and per-light shadow-update throttling
(
r_shadowMaxStaleFrames) plus the two Umbra job caps — several independent levers, not one scalar. - "Decompiling
cmd_addLight's tail-call target recovers its argument parser /castShadows's compiled default" (the verifier's own suggested open lead) — tried once this round:FUN_7ff62df567c0is a generic MSVC stack-probe helper (zeroes guard pages for a large frame), not addLight-specific code, and Ghidra found no further call-site cross-references to walk from it. 1 try, recorded, not pursued further.
Open questions
- Whether
LIGHT_PARALLEL/the sun is ever placed via anidLightentity in practice, or always comes fromenvSettings— the one corpusLIGHT_PARALLELinstance looks more like a mislabeled spot-shaped effect than the level's actual sun; not traced toenvSettings's own decl this round. - The exact C++ default for an unset
castShadows, and for every other field (lightMaterial,lightRadius, …) a light never sets — would needidLight::Init/the class's constructor decompiled; this round could not find a named function to start from (no symbol, no curated name, no RTTI function — only the vtable address — and a plain-text search viatools/tnc_runtime_code.py --xref lightMaterialfound the string but no code cross-reference through that lower-fidelity tool). - Whether authoring a brand-new falloff/cookie image (not reusing one of
the 133 existing
lights/...materials) is reachable without a shared light-atlas writer this toolkit does not have — open, same shape asr4_materials.md's own open question about the virtual-texture append path. - Whether a fixed array size backs the
clusterLightsId/lightsstructured buffers (almost certainly yes for any real GPU buffer) — not found as a cvar and not decompiled.
Re-runnable checks
python re_probes/dynlights/survey_lights.py # full field/class/lightType census, ~1 min
python re_probes/dynlights/round2_checks.py # round-2: idLensFlare field recount + correct-corpus lightmask/skiplightprobe check, ~1 min
# round-2 cvar re-census (ad hoc, same source re_probes/cvartable/cvars_tnc_v2.json)
python -c "import json,re; d=json.load(open('re_probes/cvartable/cvars_tnc_v2.json',encoding='utf-8')); hits=[c for c in d['cvars'] if re.search('light',(c['name'] or ''),re.I) or re.search('light',(c['description'] or ''),re.I)]; print(len(hits)); [print(c['name']) for c in hits if not c['cheat']]"
# renderParm casing re-check (reused, not rewritten)
python re_probes/materials/dump_renderparm.py lightfalloffmap lightprojectmap lightmask skiplightprobe
# addLight usage-text recovery (Sec.8)
python tools/decomp.py cmd_addLight --strings Addlight
python tools/decomp.py --strings "\t -point|\t -spot|\t -dist|\t -radius|\t -fov|\t -intensity|\t -color|\t -shadows|-listmats \["
# cross-checks used this round (ad hoc, not saved as flags on the tool above)
python -c "import hashlib; print(hashlib.sha256(open('re_probes/kitmap/variants/H/chunk_22.resources','rb').read()).hexdigest())"
python -c "import hashlib; print(hashlib.sha256(open(r'<Steam library>/steamapps/common/Wolfenstein.II.The.New.Colossus/dlc/dlc_0/base/chunk_22.resources','rb').read()).hexdigest())"
# both: cd7e9340... (byte-identical; H is what is currently installed)
(The .entities-content checks — 12/12 kitmap_light_*, 0 leftover retail
lights, lightMaterial/castShadows donor inheritance — were run as
one-off Python snippets against re_probes/kitmap/variants/H/chunk_22.
resources and retail dlc_2/base/chunk_22.resources, shown inline above
rather than saved as a tool, since they reuse survey_lights.py's imports
directly.)
In-game test for the maintainer
(Translated from German.)
New this round (cheaper, try it first): The core question of this report — does a new dynamic light affect a surface with a baked lightmap ("Unique" program, i.e. every wall/every piece of furniture in every retail level) — can be tested without loading the whole kit map:
- As with the already-proven
resourceExecroute (project memory notetnc-console-unlock, same technique asGod/g_infiniteAmmo): run a cfile replaced by the loader withresourceExec <cfile> -s, now with a line likeaddLight -point -color 1 0 0 -radius 150(ortestPointLight) in it. - Stand next to any wall/piece of furniture somewhere in an already running
retail level (no kit map needed) and first run
writeDebugLightmapImage— this cheaply confirms that this surface really has a non-trivial lightmap before dynamic light comes into it. - Then trigger
addLight -point -color 1 0 0 -radius 150(red light, no-mat, so the built-in default falloff — the simplest variant): does the wall next to it become noticeably red/brighter, or does it stay exactly as before (counter-evidence to the main claim)? Note: without-point/-spotthe default is spot, not point.clearLights/popLightcleans up again afterwards.
Second, more detailed test (as planned so far): The 12 new lights
(and the H lightmap for the old kit pieces) are already installed in
dlc/dlc_0/base/chunk_22.resources — the same file that
r2_maplightstairs.md described as variant H, only now with a
confirmed hash against the installation. Nothing needs to be copied, just
played:
- Start the c2v1 mission (kit building = old Gestapo office, now with kit geometry).
- Look at each of the twelve rooms (hall, vestibule, two galleries, two corridors, two offices, inner courtyard): is a light burning there in the described colour (e.g. red grazing light on the monolith in the hall, green light in the "interrogation corridor")? Does the light visibly affect the new furniture/walls (brighter/darker depending on distance), or do the surfaces look completely flat/one amount of light despite the light (that would be the counter-evidence to this report's main claim)?
- With the three spot lights (two on the monolith, one in the inner courtyard as "moonlight") pay specific attention to shadows: do the new kit furniture/walls cast a shadow from these lights, or does everything stay shadowless (a less conspicuous, but possible fault)?
- Save a
condumpafterwards (likekitmap1.txtin the previous tests) in case something crashes or looks wrong — especially if the game noticeably stutters when entering a room with many new lights (a hint at the missing cluster/shadow budget from section 5, now with the additional culling/throttling cvars from there).
Related: Research Report: R2 Maplightstairs, Research Report: R4 Materials, Research Report: Matpipe, Research Report: Entitypipe, TNC Console Unlock, Kitmap Design Report.