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.

Recovered configurations

Snowballs — original (2001-era, zone_lighter_2001)

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.

Snowballs — modern derivation (zone_lighter)

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%.

Ring continents (r2_*, zone_lighter)

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 era combine formula

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.

Kit

Two probes and one baseline.

  • NL3D_ZONE_LIGHTER_DUMP_LUMELS (env in CZoneLighter::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 via CZone::retrieve. This is the output side: what the original build wrote.
  • Compression baseline. The .zonel codec 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.

Fitting the sun direction

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:

  1. 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.
  2. 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).

Fitting the sky

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·k sky 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.

Fitting shadows

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.

The 8-neighbour dependency ring

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.

Analytic lattice — matching era tessellation

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=1 on zone_lighter_2001 replaces leaf vertex positions with pure eval(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 tile-displacement noise trap

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 case-sensitivity on Linux

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.

Lumel codec skew

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.

Modern derivation

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 / fallof beyond fallof — 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) ≈ modern sun_radius / sun_distance. The modern default sun_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.

Residual taxonomy — what does NOT reduce to a parameter

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_2001 matches gcc/SSE within one block on snowballs 3_AK — confirmation that the residual is MSVC6 x87 codegen and micro-model detail (era tessellation averaged EndPos across 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|>10 drops 2.11%→0.77% with load_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).

Kit checklist

For a fresh reversal on any lost lighting cfg (mainland-scale, Ring-scale, snowballs-scale):

  1. Ensure the getTileNoiseMap fix is present in your NeL tree (ryzomcore 508885216 or later).
  2. Prepare lowercase mirrors of zones + igs; keep the shapes dir at original case.
  3. Confirm the correct bank (era-shipped, not pipeline-intermediates).
  4. Dump reference lumels (zone_lumel_dump) and probe-relight lumels (NL3D_ZONE_LIGHTER_DUMP_LUMELS).
  5. Sun direction: identity-line trimmed LSQ, arbitrate basin by inlier volume and affine-signature. Verify |D| = 1.
  6. Sky: exact-lumel-percentage grid over sky_intensity on the sun-away subset. Peak at the true value.
  7. Shadow: extract per-lumel factor field, fit against penumbra parameters. Verify on zones with real cast shadows (not flat ones).
  8. Verify dependency set is 8-neighbour, not 5×5 or solo.
  9. If era code and modern landscape don't match: NL2001_ANALYTIC_LATTICE=1 (or the equivalent for the tool at hand).
  10. 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.

⚠️ **GitHub.com Fallback** ⚠️