* tool: lighttest — interactive lighting + render-perf testbed
A standalone dev tool (like tools/mapgen.lg): generate a dungeon with
xsofy.terrain, drop a movable light, and render the lit result live — no game
loop, monsters, or turns. Reuses only the pure terrain/lighting/fov functions
plus terminal I/O, so it isolates how the lighting reads without the rest of the
game in the way.
Run it: `lg tools/lighttest.lg` (or with config: `lg tools/lighttest.lg '{:seed 42}'`).
Drive the light with the keyboard (arrows/hjkl/yubn) or let a screensaver demo
fly it on a Lissajous or edge-bounce path; toggle wall occlusion, terrain
lights, radius, and color. It doubles as the probe behind the render-perf lane:
a live delta-vs-naive render A/B with a drawn/sgr readout, a color-depth
posterize knob, and a synchronized-output (DEC 2026) toggle, all shown in the
HUD. Frame model is a poll loop (key-pending? + sleep) that blocks when idle and
animates when the demo is on, with resize handled by a full repaint.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* tool(lighttest): collapse demo-mode enum, cycle render A/B, fix HUD jitter
Review feedback on #148:
- Merge :demo?/:demo-mode into one :demo-mode :none|:liss|:bounce; p cycles
all three, drop P.
- Replace :naive? bool with :render-mode :delta|:sgr-off|:naive; e cycles.
:sgr-off keeps the damage buffer + cursor elision on and only re-emits SGR
per drawn cell, isolating the #145 SGR-elision win; :naive is the full
repaint-everything baseline.
- Fixed-width HUD slots (lpad numbers, pad enums) so coords/counters no longer
push the rest of the line sideways frame-to-frame.
- Repaint one frame when demo toggles off, so the HUD shows demo:off
immediately instead of leaving a stale animated frame up until the next key.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* tool(lighttest): add :mapshift demo mode for the tearing A/B
A single moving light barely dirties the frame, so synchronized-output
tearing is hard to judge. :mapshift scrolls the whole terrain under a fixed
light (toroidal wrap, one cell/frame diagonal), changing every non-void cell
every frame — coherent full-screen motion, the canonical tearing stressor.
Cycles in via p (off/liss/bounce/mapshift); leaving the mode resets the
scroll offset. Terrain is sampled at the shifted coord; lighting/FOV stay at
screen coords so the light pool holds still while the map flows through it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
What
Adds a small render-only "pen" to the map render that skips re-emitting a
set-fg/set-bgwhose color already matches the last one emitted.move-cursorandwriteare untouched, and iteration order is unchanged, so the output is the same — same glyphs, same colors, fewer escape sequences.Why
term'sset-fg/set-bgwrite an SGR escape on every call, and the map loop calls them for every cell unconditionally. SGR attributes persist across cursor moves, so any cell whose color equals the previous one's is re-sending bytes for no visual change. On native that's wasted output; on the WASM build each call is a crossing into the JS host and xterm.js, so the waste is larger there.How it works
set-fgis correct as long as nothing reset the terminal's color in between.Where it helps
Elision fires where same-color cells are already adjacent in emit order, so it does most of its work on the full-screen repaints — floor change, camera pan, resize — which walk the map row-major and are the biggest single render spikes. The per-move FOV-delta path iterates in set order and elides little; that's left as-is on purpose. Sorting it into runs would raise the elision rate slightly, but
fire/water/lavatiles draw their color from the globalrandstream, so reordering iteration would change which cell consumes which draw. Harmless in itself (that stream is unseeded, so the shimmer already varies run to run) but a needless behavior change for ~1% fewer escapes, so base iteration order stands.Scope
Modest on its own — most frames are per-move FOV-delta renders, where there's little to elide. Its value is cutting the full-repaint spikes, and serving as the foundation for a frame-to-frame appearance diff (a follow-up PR stacked on this one), which is where the bulk of the render savings come from.
Safety
Rendering is a pure side effect — it doesn't touch the seed, action log, or world state — so this cannot affect determinism or replay. Static terrain and glyphs render identically before and after (verified by comparing the displayed cells, colors included, over a fixed input sequence); animated tiles already vary run to run via the unseeded
randstream, unaffected either way. Existing test suite passes unchanged.