Wolfenstein Modloader Buy me a coffeeKaffee spendieren

Wiki / Latest research roundsNeueste Forschungsrunden

TNC Dynamic Lights: Classes, Fields, Budgets, and Whether They Can Light New Kit Geometry

Partly solvedTeilweise gelöst Original page in the GitHub wikiOriginalseite im GitHub-Wiki

In plain wordsIn einfachen Worten

This report studied the dynamic lights of Wolfenstein II: The New Colossus: one engine class covers every light type (point, spot, probe, area and others), and the look of a light comes from one reusable light material. Reading the game's data and settings suggests that a new light adds its glow on top of the baked lighting of walls and furniture instead of being ignored by it. The custom kit map already has 12 new lights built and installed, and a much cheaper test with the hidden addLight command is proposed. Everything here is static analysis: the counts and file checks are proven, but the main claim is only inferred and no light was seen in the running game.

  • All 13,406 placed lights on 39 maps use one engine class; the light type is just one text field (5,903 spot, 4,413 point, 2,529 probe lights).
  • 133 existing light materials can be reused with a plain text edit; whether a brand-new light image can be added is still open.
  • No setting caps the total number of lights; the budgets are a light grid of 3,072 cells (16 x 8 x 24), per-frame culling and throttled shadow updates. Correction: 9 light settings, not 2, have no cheat flag.
  • The kit map's 12 lights (9 point, 3 spot) are copies of two real game lights; the built file is installed but has not been loaded in the game yet.
  • Open: whether a new light really reaches surfaces with baked lighting; the hidden addLight command, run via resourceExec in any level, is a cheap first test that has not been run.

Dieser Bericht hat die dynamischen Lichter von Wolfenstein II: The New Colossus untersucht: Eine einzige Engine-Klasse deckt jeden Lichttyp ab (Punkt, Spot, Probe, Fläche und andere), und das Aussehen eines Lichts kommt aus einem wiederverwendbaren Licht-Material. Das Lesen der Spieldaten und Einstellungen spricht dafür, dass ein neues Licht seinen Schein zusätzlich auf die vorberechnete Beleuchtung von Wänden und Möbeln legt, statt von ihr ignoriert zu werden. Die eigene Kit-Karte hat bereits 12 neue Lichter, gebaut und installiert, und ein viel günstigerer Test mit dem versteckten Befehl addLight wird vorgeschlagen. Alles hier ist statische Analyse: Die Zählungen und Dateiprüfungen sind bewiesen, die Hauptaussage ist aber nur abgeleitet, und im laufenden Spiel wurde kein Licht gesehen.

  • Alle 13.406 platzierten Lichter auf 39 Karten nutzen eine Engine-Klasse; der Lichttyp ist nur ein Textfeld (5.903 Spot-, 4.413 Punkt-, 2.529 Probe-Lichter).
  • 133 vorhandene Lichtmaterialien lassen sich per einfacher Textänderung wiederverwenden; ob sich ein ganz neues Lichtbild hinzufügen lässt, ist noch offen.
  • Keine Einstellung begrenzt die Gesamtzahl der Lichter; die Budgets sind ein Lichtraster aus 3.072 Zellen (16 x 8 x 24), Aussortieren pro Bild und gedrosselte Schattenupdates. Korrektur: 9 Lichteinstellungen, nicht 2, haben kein Cheat-Flag.
  • Die 12 Lichter der Kit-Karte (9 Punkt, 3 Spot) sind Kopien von zwei echten Spiellichtern; die gebaute Datei ist installiert, wurde im Spiel aber noch nicht geladen.
  • Offen: ob ein neues Licht Flächen mit vorberechneter Beleuchtung wirklich erreicht; der versteckte Befehl addLight, über resourceExec in einem beliebigen Level ausgeführt, ist ein günstiger erster Test, der noch nicht gelaufen ist.

Technical detailsTechnische Details

Der technische Teil ist auf Englisch – so, wie er im Wiki steht. Unterstrichene Begriffe haben eine Erklärung auf Deutsch.

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):

  1. 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-CHEAT cvars 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.
  2. REFUTED, fixed: idLensFlare has 35 distinct edit.* fields, not 25 — pass 1 undercounted by eye instead of counting its own survey_out.txt table; round2_checks.py recomputes 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.
  3. Minor transcription slip, fixed: the Sec.3 code block showed lightFalloffMap and lightProjectMap both defaulting to lights/squareLight1.tga (capital L). Re-dumped live with dump_renderparm.py: lightfalloffmap really is lights/squareLight1.tga, but lightprojectmap defaults to lights/squarelight1.tga (lowercase l). Fixed in Sec.3.
  4. 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.py now runs the right check — raw-text search for lightmask across every entity class, all 39 .entities maps — 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:

  1. tools/design_kitmap.py (lines ~399–415): LIGHT_DONORS names two real retail c2v1 light entities — c2v1_lights_lights_light_point_1603252612 (point) and c2v1_lights_lights_light_spot_1557246881 (spot) — and LIGHTS, 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 are point, 3 are spot (kitmap_light_monolith_w/e, kitmap_light_hof_mond).
  2. tools/build_kitmap.py's donor_entity() (line ~258) does a full donor transplant — the same proven technique entitypipe.md already validated for enemies/pickups: deep-copy the donor's whole entityDef block, rename it, then overwrite only spawnPosition, spawnOrientation, lightColor, lightIntensity, and (point only) lightRadius or (spot only) the cone via spot_range(). Everything else rides along from the donor, read fresh this round from retail dlc_2/base/chunk_22.resources:
    • point donor: lightMaterial = "lights/analytical/point/point_frac40", lightRadius = 6,6,6, no castShadows (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 rewrites lightTarget.x/lightRight.y/lightUp.z/ lightConeLength/lightConeSize together, preserving the retail invariant build_kitmap.py itself checks corpus-wide: lightConeLength == lightTarget.x and lightConeSize == 2*lightUp.z (gated, re-verified structurally present in H's .entities this round).
  3. tools/build_kitmap_variant.py H = G (texture/material fix) + one .proc lightmap record per rendered kit static-instance piece (r2_maplightstairs.md). It only ever touches the .proc compfile; the .entities text — 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:

  • -shadows is opt-in, off by default — a runtime-level data point (not idLight::Init itself, but the command's own spawn path) consistent with pass 1's open question about castShadows's unset-field default; still not proof of the C++ class default, since addLight could be choosing its own spawn-time default independently of the class's — flagged as INFERRED, not PROVEN, below.
  • -mat defaults to nothing — an addLight-spawned light with no -mat has no lightMaterial override, 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 bare addLight -point -color 1 0 0 -radius 150 a 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 -point or -spot explicitly 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):

  • idLight is the sole class for point/spot/probe/area/scattering light types (13,406 corpus instances, only light/point/light/spot/ light/probe as decl bases, lightType the sole discriminator); exactly one LIGHT_PARALLEL instance exists corpus-wide.
  • idLensFlare (7,739 instances, always declFlare set) is a separate class/asset family from idLight, not a light subtype.
  • idGodRays and idLight2 are RTTI classes with 0 placed instances across 39 maps / 28 archives.
  • 117 distinct idLight edit.* fields and 35 distinct idLensFlare fields exist corpus-wide (full tables in re_probes/dynlights/ survey_out.txt; the 35 re-measured mechanically this round with round2_checks.py — pass 1's "25" was a by-eye miscount, see "Pass 1 errors"); light/point/light/spot/light/probe/func/flare decls set nothing but lightType (or nothing at all for func/flare).
  • lightMaterial (a material decl) names both lightfalloffmap and lightprojectmap, both atlas-packed via ...atlasscalebias into one shared _lightimageatlas; 133 distinct lights/... 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/clusteredLighting renderParms are declared view-scope (frame-global); lightMask (Vec 1) and skiplightprobe (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 .entities maps, 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/ writeDebugLightmapImage exist (all exposed: false); addLight's full usage/flag-default text was recovered by decompile this round (Sec.8) — missed entirely in pass 1 despite commands.json being in the task's own tool list.
  • tools/build_custom_map.py already has light_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.
  • castShadows is 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 it true.
  • The kit map's 12 idLight entities are full donor transplants of two real retail c2v1 lights, inheriting lightMaterial/castShadows/ shadowZControl/staticLightMapScale unmodified; only placement, color, intensity and (spots) cone range are authored per-light.
  • re_probes/kitmap/variants/H/chunk_22.resources and the file currently installed at dlc/dlc_0/base/chunk_22.resources are byte-identical (sha256 cd7e9340…, independently re-hashed this round); that file's own c2v1.entities contains exactly 12 idLight blocks, all kitmap_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 the view-scope buffer declarations and the universal-default lightMask, but not in a decompiled shader that shows the two terms being summed.
  • The C++-side default for castShadows when a light omits it (true or false) — still not decompiled at the idLight class 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 -shadows console 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 of idLight'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/skiplightprobe are 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_7ff62df567c0 is 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 an idLight entity in practice, or always comes from envSettings — the one corpus LIGHT_PARALLEL instance looks more like a mislabeled spot-shaped effect than the level's actual sun; not traced to envSettings'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 need idLight::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 via tools/tnc_runtime_code.py --xref lightMaterial found 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 as r4_materials.md's own open question about the virtual-texture append path.
  • Whether a fixed array size backs the clusterLightsId/lights structured 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:

  1. As with the already-proven resourceExec route (project memory note tnc-console-unlock, same technique as God/g_infiniteAmmo): run a cfile replaced by the loader with resourceExec <cfile> -s, now with a line like addLight -point -color 1 0 0 -radius 150 (or testPointLight) in it.
  2. 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.
  3. 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/-spot the default is spot, not point. clearLights/popLight cleans 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:

  1. Start the c2v1 mission (kit building = old Gestapo office, now with kit geometry).
  2. 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)?
  3. 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)?
  4. Save a condump afterwards (like kitmap1.txt in 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.