zone_lighter_config_reversal - ryzom/ryzomcore GitHub Wiki
title: Zone lighter configuration reversal description: Method for recovering an offline zone-lighter configuration (sun direction, sky, shadow knobs) from stored .zonel lumels when the original build config was lost. published: true date: 2026-07-24T00:00:00.000Z tags: editor: markdown dateCreated: 2026-07-24T00:00:00.000Z
Reference for recovering an offline zone_lighter configuration from the stored per-zone .zonel lumel output, without access to the original build cfg. Developed while reversing the 2001-era snowballs lighter config and reused verbatim for the 2006 r2_* Ring continent configs (both summarized below).
The method is general to any NeL landscape lighting that fits the era combine formula and works from stored lumels alone; the specific era-tool tessellation subtleties in the "Analytic lattice" section apply when reversing a config for a lighter old enough to predate the modern refine path.
The canonical recovery, matching the shipped 2003 client lumels. Cfg: nel/tools/3d/zone_lighter_2001/cfg/snowballs_recovered.cfg, per-parameter evidence in comments.
| Parameter | Value | Notes |
|---|---|---|
sun_direction |
{ -0.095, +1.0, -0.50 } |
Pre-2001-10-26 direction; preserved as a commented-out line in the era cfg/properties.cfg. Trimmed identity fit locks it to 0.024° over 152,097 lumels. |
sky_contribution |
1 |
ON. Facing-away floor at ~190.5 pins this. |
sky_intensity |
0.8 |
Uniform 1.066 deficit ratio at SI=0.75 pins SI=0.8; 0.75 and 1.5 tested and rejected. |
shadow |
1 |
ON. |
softshadow |
1 |
Era polygonal softshadow. |
softshadow_blur_size |
1.0 (m) |
|
softshadow_fallof |
10.0 (m) |
Penumbra saturates beyond this range. |
softshadow_shape_vertex_count |
10 |
|
shadow_bias |
1.0 |
|
oversampling |
0 |
|
load_ig |
1 |
|
| Dependencies | 8-neighbour ring exactly | 5×5 wrecks 3_AK 86→65% blocks; solo drops to 4%. |
Reproduction: lowercase mirror of zones+igs, original-case shapes dir, client bank (data/tiles/bank.bank), envs NL_ZONE_LIGHTER_NO_NOISE=1 NL2001_ANALYTIC_LATTICE=1.
Gate: 96/96 reference zones, 88.08% blocks byte-identical / 98.10% values exact; best zones 96%+ blocks / 99.7% values.
The parameters translated forward for the modern lighter (combine formula and sky model unchanged; shadows moved from era polygonal to zbuffer sun-camera). Cfg: nel/tools/3d/zone_lighter/cfg/snowballs_derived.cfg with @TOKEN@ paths.
| Parameter | Value | Notes |
|---|---|---|
sun_direction |
{ -0.095, +1.0, -0.50 } |
Identical to era. |
sky_contribution |
1 |
Identical. |
sky_intensity |
0.8 |
Identical. |
shadow |
1 |
Identical (softshadow toggle folded into sun_radius). |
sun_center |
{ 1599, -835, 0 } |
Map bbox center. |
sun_radius |
500 |
0.01 rad = saturated ~1 m penumbra at ~100 m occluders, matching the era saturation slope. Default 5000 (0.1 rad) over-blurs shadows past 10 m. |
sun_distance |
50000 |
Modern default. |
Gate: 96/96 reference zones via the LIGHT tier in snowballs_corpus.py: 91.81% exact / 98.88% ±3, 0 failed, no zone below 90%.
Recovered from the 2006 originals in ryzom_setup_644.exe. The mainland configs were CVS-recovered (per Kaetemi) and are NOT improvised; the r2_* configs in the leveldesign workspace were improvised during OSS asset recovery, and the reversal confirmed those improvisers guessed all five sun directions right by borrowing ecosystem-matched mainland values. sky_intensity for r2_desert and r2_roots was corrected (leveldesign 9027698f).
| Continent | sun_direction |
sky_intensity |
Corpus exact / ±3 (zones) |
|---|---|---|---|
r2_desert |
{ -0.5, 0, -0.85 } |
0.25 (was 0.20 improvised) | 79.4% / 90.6% (351) |
r2_forest |
{ -0.776685, +0.216619, -0.59147 } |
0.20 | 77.8% / 89.0% (623) |
r2_jungle |
{ -0.776685, +0.216619, -0.59147 } |
0.20 | 68.8% / 84.4% (660) |
r2_lakes |
{ -0.5, 0, -0.85 } |
0.50 | 74.2% / 91.5% (550) |
r2_roots |
{ -0.5, 0, -0.85 } |
0.25 (was 0.50 improvised) | 76.0% / 83.4% (391) |
Sun camera: kept the CVS-recovered mainland production values sun_radius / sun_distance = 5000 / 50000 (0.1 rad, π/6 fov). Penumbra A/B on a low-sun jungle zone rules out >0.1 rad, can't resolve below 0.05 given the corpus resolution. sun_center improvised map-bbox values show no edge-of-map shadow displacement, confirmed consistent.
Sun fits converge <0.05° onto a mainland literal with decisive inlier volumes (roots: 44.9% correct basin vs 0.3% wrong basin). Sky peaks are sharp (roots: 74.4/77.4/74.4 exact at 0.24/0.25/0.26). Forest ecosystem shares the jungle bank; confirmed by fit quality.
Gate: 2575 ring zonels relit against 2006 originals, 0 failures. Residual taxonomy same as snowballs (cross-era codec skew, shadow-edge sampling, float tier).
The lighter (2001-onward, unchanged in its combine to the modern zbuffer variant) writes one uint8 lumel per patch texel as:
final = 255 · (shadow · clamp(−N·D − sky, 0, 1) + sky)
-
N= per-lumel normal (bezier tessellation, possibly noise-composed from tile displacement maps) -
D= sun direction (unit vector) -
sky = sky_intensity · sky_contribution_factor(N)— for the era formula,sky = SI · N_z · sin²(fPhi)where fPhi comes from the horizon table -
shadow= 1 in open sky, 0 fully shadowed, fractional in penumbra
The key structural feature is the max: unshadowed sun-facing lumels evaluate to −N·D; unshadowed sun-away lumels floor at sky; shadow only enters when the sun term exceeds the sky floor.
Two probes and one baseline.
-
NL3D_ZONE_LIGHTER_DUMP_LUMELS(env inCZoneLighter::light) — dumps the exact per-lumel positions and normals the lighting pass uses. This is the input side of the reversal: the lighter's own view of the geometry, so you're fitting against the same normals the reference build saw (assuming the tessellation matches; see below). -
zone_lumel_dump(scratch tool) — extracts decompressed reference lumels viaCZone::retrieve. This is the output side: what the original build wrote. -
Compression baseline. The
.zonelcodec is lossy 4×4 block quantization. Load → retrieve → build → save on our own output survives only ~57% byte-exact (±22 tails in high-gradient blocks). A CORRECT model should therefore match refs at ~57%+ where right; every wrong model sits at ~1%, with the only fully-matching 4×4 blocks being trivially black. This is the noise floor to compare against.
Always compare AFTER a run, not directly against raw model predictions: the codec noise dominates and both sides must share it. Whole-run compression roundtrip is idempotent enough (99.9%/±2 on refs) that block-percentage-exact is a stable signal at the true parameter values.
The identity line is the cheap discriminator: when shadow = 1 and dot − sky ∈ (0, 1), ref = −N·D exactly (sky cancels, no clamp active). Trimmed LSQ over the lumel set that sits within some tolerance of that identity gives a direction candidate with per-lumel evidence.
For every lumel (N, ref):
predict = -dot(N, D_candidate) * 255
residual = ref - predict
Trim outliers, refit.
Two arbitration signals to pick the correct basin among candidates:
- Inlier volume. A correct basin announces itself with roughly 75× more inliers than a wrong one. On snowballs, the correct fit locked 152,097 lumels (23% of mid-range) to 0.024° of the true direction; the wrong basin at ~(−0.33, 0.43, −0.84) sat around 2k coincidental inliers of a smooth model that demonstrably did not generate the data. Always sanity-check the volume, not just the residual.
-
Affine-signature comparison. Fit
a + w·N(an affine model of the observation against the lumel normal) to both the reference lumels and to your own clamped-Lambert output run through the same tool. Matching (slope, ambient) signatures across the two paired with mismatched directions means wrong D, right model — the ambient is picking up the sky floor, the slope is the sun term's magnitude. This was the compass that pointed at the correct basin on snowballs after the wrong-basin fit had wasted several passes.
|D| should converge to 1.0000 within noise. |D| < 1 by more than the lumel discretization means the model is fitting through a distorted geometry (typically wrong tessellation; see analytic lattice below).
Once the sun direction is locked, the sky_contribution / sky_intensity are pinned by the sun-away lumels (unshadowed, −N·D ≤ 0), which floor at 255 · SI · N_z · sin²(fPhi).
- Grid over sky_intensity (e.g. 0.0 through 1.0 at 0.05 steps), score each candidate by exact-lumel percentage in the sun-away subset. The sharp peak at the true value is the reliable signal. ±3 or ±5 tolerance windows are too flat to distinguish neighboring candidates — always score by exact count for the peak.
- Sky-off vs sky-on can be genuinely ambiguous at first, because a facing-away histogram at exactly 0 is also consistent with steep cliff lumels whose
N·ksky term is near 0 anyway. Rule sky-on in only when the sky-on grid shows a sharp peak that beats the sky-off model on the flat-open subset. - Snowballs example: flat floored patches at ~190.5 and a uniform 1.066 deficit ratio at SI=0.75 pinned SI = 0.8; SI ∈ {0.75, 1.5} tested and rejected on the same footing.
Shadow enters as the multiplier on the sun term when −N·D > sky. The per-lumel shadow-factor field can be extracted from unshadowed vs partially-shadowed patches:
s = (ref − sky_component) / (dot − sky_component)
For fully-lit lumels s ≈ 1, for penumbra 0 < s < 1, for full shadow s = 0.
For the era polygonal softshadow the free parameters are blur_size, fallof, sample vertex count, and bias. Snowballs settled on blur = 1.0, fallof = 10.0, 10 vertices, bias = 1.0. Sensitivity is measurable: on snowballs 3_AK, sun_distance 50k → 500k changes 25% of lumels, sun_center map-center → origin changes 4%. The map bbox center is a defensible default for sun_center when no other signal is available.
For the modern zbuffer sun-camera the free parameters are sun_radius and sun_distance and their ratio maps to a penumbra angle. See "Modern derivation" below.
Zone lighting depends on the surrounding zones' geometry through the GI heightfield horizon term. Getting the dependency set wrong looks like a whole-model failure, not a per-parameter one.
- The 3×3 (self + 8-neighbour) ring is the era-standard set and produces the reference results on snowballs (88% blocks exact).
- A 5×5 (24-neighbour) window over-loads context: snowballs 3_AK drops 86 → 65% blocks with the same cfg.
- Solo (self only, no neighbours) drops it to 4% — the horizon integral loses all far context.
The zone_dependencies tool that computed the reference dependency lists explicitly produced 8-neighbour sets; on any recovered lighter, ensure the same shape is emitted, and A/B against the alternatives if a whole-corpus fit stays low despite good per-parameter fits.
Applies to lighters resurrected against a modern NeL landscape library. The 2001 refineAll moved every tessellation vertex to EndPos == computeVertex == eval at threshold 0. The modern refine path leaves enforced-split vertices geomorphed at StartPos (linear parent midpoints), which diverges from CBezierPatch::eval by up to ~9 m per leaf in flat cases.
A resurrected tool reading Pos inherits this divergence in all three lumel-lighting consumers: pass-1 positions, pass-2 normals, and shadow-caster triangles. The reference lighter, running on era-CLandscape, never saw the divergence. Match refs by forcing the tessellation to analytic values:
-
NL2001_ANALYTIC_LATTICE=1onzone_lighter_2001replaces leaf vertex positions with pureeval(s,t)in all three consumers. Snowballs gate jumps 0.41% → 34% blocks on this flag alone.
The modern nel/src/3d/zone_lighter.cpp is not affected — it reads face->V*->EndPos directly, which the modern refine path fills correctly. Only tools that read the geomorphed Pos hit this.
The bank references the per-tileset displacement (noise) maps under an authored path like R:\graphics\...\displace\bump*.tga. Offline lighting consumers compose the authored path verbatim; on Linux (or on any machine that isn't the bank author's), the load silently fails and CTileBank::getTileNoiseMap substitutes a static placeholder pattern. Every lumel normal then tessellates with wrong nonzero noise.
Fixed at NeL level (ryzomcore 508885216): when the authored path doesn't exist, fall back to CPath::lookup of the bare backslash-normalized filename. Empty filenames (e.g. snowballs' displacement-empty client bank) keep the old behavior. The client makes the same paths relative on load, which is why it never saw the failure.
Any offline lighting consumer needs this fix. Without it, all fits are against wrong normals; the snowballs recovered sun direction landed cleanly, but only after the noise maps loaded correctly.
For eras that lit with no displacement at all (snowballs shipped its client bank with all displacement tiles = 0), the additional env NL_ZONE_LIGHTER_NO_NOISE=1 short-circuits the noise composition entirely.
CPath alternative-dir lookup concatenates the LOWERCASED filename, so mixed-case era assets (PI_PO_*.shape, *.zonel deps) silently fail on Linux. Prepare a lowercase symlink mirror (sb_lc/ and friends) for era data before running any offline tool that will resolve mixed-case names.
The 2001 tool loads dependency zones by literal dir+name+ext (no CPath) — dependency names in the dep cfg must match the filesystem case in that path.
The 2001 unpackLumelBlock dequantizes with exact integer /7 and /5 divisions; the modern lumel decoder uses >>8 LUT approximations that differ by ±1..2 per value. The block codec's basin flips amplify single-lumel deltas to whole-4×4 mismatches when you compare raw byte-percentages across the two decoders.
- For byte-exact gates against era references, decode both sides with era tables. A Python port of the era codec (
snowballs_zonel_gate.py) implements both quantizations for the corpus tester. - Cross-codec comparisons (era refs vs modern-decoded outputs) will always show a residual skew even at the correct parameter values. This is measurable and taxonomizable but not fixable without touching the decoder.
Once the era-lighter cfg is recovered and validated at the era codec level, translate forward for the modern zone_lighter. The combine formula and sky model are unchanged; only shadows moved from era polygonal softshadow to the modern zbuffer sun-camera. Penumbra mapping:
- Era polygonal softshadow SATURATES its penumbra at
blur_size / fallofbeyondfallof— the 1 m / 10 m era default is a saturated ~1 m penumbra at ~100 m occluders and beyond. - Modern sun-disc penumbra grows linearly with
sun_radius / sun_distance. - Direct equivalence at close range: era
blur_size / fallof(rad) ≈ modernsun_radius / sun_distance. The modern defaultsun_radius = 5000, sun_distance = 50000(0.1 rad) matches the era pre-saturation slope but over-blurs everything beyond 10 m. - Snowballs settled on
sun_radius = 500(0.01 rad = the saturated ~1 m penumbra at ~100 m occluders). Improves every zone measured; picks up 5-10 percentage points on ±3 in some zones.
Wire a LIGHT tier into the corpus tester: relight all reference zones with the derived modern cfg, tolerance-compare against the reference .zonel, and gate on ±3 ≥ threshold (snowballs gates 96%). Score both the era codec baseline and the modern derivation; the deltas taxonomize as cross-codec skew + float codegen tier + asset drift + shadow-edge sampling.
After a full parameter recovery, several residual sources remain, none of which are chase-worthy for parameter validation:
-
Float codegen tier. The 2001 reference binary was MSVC6 x87 (80-bit intermediates); modern builds are gcc/SSE (or VS2008 x87, which is not MSVC6 x87). Block-codec basin flips amplify single-lumel ±1..2 deltas into whole-4×4 mismatches. A VS2008/Wine x87 build of
zone_lighter_2001matches gcc/SSE within one block on snowballs 3_AK — confirmation that the residual is MSVC6 x87 codegen and micro-model detail (era tessellation averagedEndPosacross multipatch binds; the analytic-lattice path uses own-patch eval). Chasing this means emulating MSVC6 codegen and the bind-averaging. -
Post-lighting asset drift. Some zones have shadow casters added to the ig after the lighting build. Snowballs 6_AN has a 40 m shadow cone WE cast that the ref lacks (object added later);
|d|>10drops 2.11%→0.77% withload_ig=0, mirroring T3-side zone-source drift on the same file. - Zone-edge welds. The corpus-gated welding pass has its own residual band at zone borders. Expected, taxonomize separately.
- Shadow-edge sampling on the modern derivation (zbuffer vs polygonal).
None of these are parametric. If a corpus fit stays significantly below the residual-taxonomy expectation, keep looking for the parameter miss (check the dependency ring first, then the noise-maps trap, then the tessellation).
For a fresh reversal on any lost lighting cfg (mainland-scale, Ring-scale, snowballs-scale):
- Ensure the
getTileNoiseMapfix is present in your NeL tree (ryzomcore 508885216 or later). - Prepare lowercase mirrors of zones + igs; keep the shapes dir at original case.
- Confirm the correct bank (era-shipped, not pipeline-intermediates).
- Dump reference lumels (
zone_lumel_dump) and probe-relight lumels (NL3D_ZONE_LIGHTER_DUMP_LUMELS). - Sun direction: identity-line trimmed LSQ, arbitrate basin by inlier volume and affine-signature. Verify |D| = 1.
- Sky: exact-lumel-percentage grid over sky_intensity on the sun-away subset. Peak at the true value.
- Shadow: extract per-lumel factor field, fit against penumbra parameters. Verify on zones with real cast shadows (not flat ones).
- Verify dependency set is 8-neighbour, not 5×5 or solo.
- If era code and modern landscape don't match:
NL2001_ANALYTIC_LATTICE=1(or the equivalent for the tool at hand). - Corpus-gate the era-lighter cfg first; then translate to the modern lighter, corpus-gate that against a tolerance band; wire the LIGHT tier into the tester.
Traps to watch for at each step: silent noise-map placeholder, CPath lowercase folding, lumel codec skew, wrong-basin sun fits with plausible-looking residuals.