Skip to main content

roxlap_scene/
fow.rs

1//! FW.0 — fog-of-war mask core (`PORTING-FOW.md`): SS13-style
2//! *knowledge* visibility, computed host-side, applied by renderers
3//! in later substages.
4//!
5//! A [`FogOfWar`] tracks, per deck and per mip-0 XY column ("cell"),
6//! what the observed character *knows*: never seen ([`CellState::Unseen`]),
7//! seen before ([`CellState::Memory`]), currently in the facing cone /
8//! peripheral radius ([`CellState::Visible`]), or revealed by a sound
9//! source ([`CellState::Heard`]). Each cell carries a 6-bit intensity
10//! that fades between states (the boundary is a smooth per-cell
11//! taper — **no dither**, the OC round-1 lesson) and decays while in
12//! Memory.
13//!
14//! Conventions (chapter 2: **+z is DOWN**):
15//! - Cells are grid-local mip-0 XY columns; a [`DeckBand`] limits each
16//!   deck's z extent (`z_top` is the ceiling — the *smaller* z).
17//! - Line of sight is a 2D recursive shadowcast on the active deck: a
18//!   cell blocks when any solid voxel intersects the band's eye-level
19//!   z range. Cell opacity (and the light-gate sample) is cached per
20//!   XY chunk tile and invalidated by [`Grid::chunk_version`]s, so
21//!   edits are picked up without rescanning quiet chunks.
22//! - The optional [`LightGate`] keeps *dark* cells from becoming
23//!   Visible: geometric LOS is scaled by the baked brightness of the
24//!   first solid surface under the eye (plus an emissive colour-key
25//!   list — an emissive strip lights its cell regardless of bake).
26//!   Dynamic lights do NOT open vision in v1 (entry-doc decision 5).
27//!
28//! This module is **presentation-only** state (entry-doc decision 10):
29//! nothing in the simulation, collision, audio, or pathing may read
30//! the mask, and no snapshot wire change is involved (savegame
31//! persistence of explored area is an explicit follow-up). Renderers
32//! consume it in FW.2/FW.3; the known-twin sync (FW.1) walks
33//! [`FogOfWar::for_each_live_cell`].
34
35use std::collections::HashMap;
36use std::hash::{BuildHasherDefault, Hasher};
37
38use glam::{DVec3, IVec2, IVec3, UVec3, Vec2};
39use roxlap_formats::color::Rgb;
40use roxlap_formats::vxl::Vxl;
41
42use crate::{DirtyExtent, Grid, GridId, GridTransform, LodThresholds, Scene, CHUNK_SIZE_XY};
43
44/// Maximum cell intensity (6 bits of the mask byte).
45pub const INTENSITY_MAX: u8 = 63;
46
47/// XY size of one mask/opacity tile — locked to the chunk footprint so
48/// tile keys are chunk indices and version invalidation is 1:1.
49const TILE: i32 = CHUNK_SIZE_XY as i32;
50const TILE_CELLS: usize = (TILE * TILE) as usize;
51const TILE_WORDS: usize = TILE_CELLS / 64;
52
53/// Intensity settle threshold — transitions below this snap and stop
54/// ticking.
55const SETTLE_EPS: f32 = 0.25;
56
57/// What the observer knows about a cell. Packed into the top 2 bits
58/// of the mask byte (intensity in the low 6).
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60#[repr(u8)]
61pub enum CellState {
62    /// Never seen — renderers draw background (FW.2).
63    Unseen = 0,
64    /// Seen before — the known twin's frozen last-seen look, dimmed.
65    Memory = 1,
66    /// In the current facing cone or peripheral radius — live.
67    Visible = 2,
68    /// Revealed by a heard sound source — live data, Memory styling.
69    Heard = 3,
70}
71
72impl CellState {
73    fn from_bits(bits: u8) -> Self {
74        match bits & 3 {
75            1 => Self::Memory,
76            2 => Self::Visible,
77            3 => Self::Heard,
78            _ => Self::Unseen,
79        }
80    }
81}
82
83/// One deck's z extent, grid-local, inclusive, z-down (`z_top` is the
84/// ceiling — numerically the smallest z). The caller derives these
85/// from the same levels as CA's deck clips. The eye-level opacity band
86/// is NOT here — it rides the observer ([`FowObserver::eye_z`]) so LOS
87/// tracks the character's real eye over uneven ground (stairs, ramps,
88/// boulders, furniture) instead of a fixed floor-relative slab.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub struct DeckBand {
91    /// Ceiling z (inclusive, smallest value).
92    pub z_top: i32,
93    /// Floor z (inclusive, largest value) — the light gate scans down
94    /// to here for the surface brightness sample.
95    pub z_bottom: i32,
96}
97
98impl DeckBand {
99    /// Does grid-local `z` fall inside this deck's `[z_top, z_bottom]`?
100    #[must_use]
101    pub fn contains_z(&self, z: i32) -> bool {
102        (self.z_top..=self.z_bottom).contains(&z)
103    }
104}
105
106/// Half-height of the eye-level opacity band around [`FowObserver::eye_z`]
107/// (cells). A voxel blocks LOS when it lies within `±EYE_HALF` of the
108/// observer's eye — thin, so the character sees OVER low obstacles
109/// (crates, rubble) at eye level rather than being walled in by them.
110const EYE_HALF: i32 = 2;
111
112/// Decision 5 — light gates vision: a cell only reaches full Visible
113/// intensity when its surface is lit. Samples the **baked** brightness
114/// byte (colour high byte) of the first solid voxel scanning down from
115/// `eye_top` to the deck floor; emissive colour keys count as fully
116/// lit anywhere in that scan. Dynamic point lights are not sampled in
117/// v1.
118#[derive(Debug, Clone, PartialEq)]
119pub struct LightGate {
120    /// Baked-brightness byte at/above which the cell is fully lit
121    /// (bakes centre around `0x80` neutral).
122    pub lit_brightness: u8,
123    /// Soft knee below the threshold: brightness in
124    /// `[lit_brightness - softness, lit_brightness]` scales visibility
125    /// linearly. `0` = hard cut.
126    pub softness: u8,
127    /// Colour keys (brightness-stripped, the engine's colour→material
128    /// map convention) treated as self-lit — emissive strips keep
129    /// their cell visible in a pitch-dark corridor.
130    pub emissive_colors: Vec<Rgb>,
131}
132
133impl LightGate {
134    /// Visibility factor `0..=1` for a surface of baked brightness
135    /// `b`.
136    #[must_use]
137    pub fn lit_factor(&self, b: u8) -> f32 {
138        if b >= self.lit_brightness {
139            return 1.0;
140        }
141        if self.softness == 0 {
142            return 0.0;
143        }
144        let knee = self.lit_brightness.saturating_sub(self.softness);
145        if b <= knee {
146            return 0.0;
147        }
148        f32::from(b - knee) / f32::from(self.softness)
149    }
150}
151
152/// All FW tuning in one place. Geometry fields are in cells (mip-0
153/// voxels) and radians; rates are intensity units (`0..=63`) per
154/// second. The styling knobs at the bottom are read by the renderers
155/// from FW.2 on — unread in FW.0.
156#[derive(Debug, Clone, PartialEq)]
157pub struct VisionConfig {
158    /// Half-angle of the facing cone, radians.
159    pub cone_half_angle: f32,
160    /// Angular taper band *inside* the half-angle over which intensity
161    /// falls to zero (smooth cone rim), radians.
162    pub cone_taper: f32,
163    /// Facing-cone reach, cells.
164    pub range: f32,
165    /// 360° close-range vision, cells (an observer is never blind in
166    /// their own back at arm's length).
167    pub peripheral_range: f32,
168    /// Radial taper band inside `range` / `peripheral_range` /
169    /// heard-blob radii, cells.
170    pub edge_taper: f32,
171    /// The decks, index-aligned with [`FowObserver::deck`] and every
172    /// per-deck query. Must stay the same length across
173    /// [`FogOfWar::set_config`] calls (layers are keyed by index).
174    pub decks: Vec<DeckBand>,
175    /// Decision 5 — `None` = geometric visibility only.
176    pub light_gate: Option<LightGate>,
177    /// Intensity rise rate toward a live (Visible/Heard) target.
178    pub fade_in: f32,
179    /// Intensity fall rate when leaving sight, down to
180    /// `memory_intensity`.
181    pub fade_out: f32,
182    /// Where a freshly-demoted Memory cell settles before slow decay.
183    pub memory_intensity: u8,
184    /// Decision 7 — slow decay rate from `memory_intensity` toward
185    /// `memory_floor`. `0` = memories never fade.
186    pub memory_decay: f32,
187    /// Decayed-memory minimum intensity — old memories stay barely
188    /// readable rather than vanishing back to Unseen.
189    pub memory_floor: u8,
190    /// Heard-blob radius in cells per unit of loudness.
191    pub heard_radius: f32,
192    /// Seconds a heard blob stays live before fading out.
193    pub heard_duration: f32,
194    /// FW.2+ styling: Memory/Heard brightness multiplier.
195    pub memory_dim: f32,
196    /// FW.2+ styling: Memory/Heard desaturation amount `0..=1`.
197    pub memory_desaturate: f32,
198}
199
200impl VisionConfig {
201    /// Defaults tuned for ship scale (~1 voxel ≈ 10 cm): a 120° cone
202    /// reaching 96 cells, 12-cell peripheral, light gate off.
203    #[must_use]
204    pub fn for_decks(decks: Vec<DeckBand>) -> Self {
205        Self {
206            cone_half_angle: 1.05,
207            cone_taper: 0.12,
208            range: 96.0,
209            peripheral_range: 12.0,
210            edge_taper: 4.0,
211            decks,
212            light_gate: None,
213            fade_in: 240.0,
214            fade_out: 120.0,
215            memory_intensity: 26,
216            memory_decay: 0.8,
217            memory_floor: 10,
218            heard_radius: 10.0,
219            heard_duration: 1.5,
220            memory_dim: 0.45,
221            memory_desaturate: 0.6,
222        }
223    }
224
225    /// Index of the deck whose `[z_top, z_bottom]` contains grid-local
226    /// `z` (the head-follow deck pick, CA round-3 convention: feed the
227    /// HEAD z, not the feet).
228    #[must_use]
229    pub fn deck_for_z(&self, z: i32) -> Option<usize> {
230        self.decks.iter().position(|d| d.contains_z(z))
231    }
232}
233
234/// FW.3 — the fog mask flattened for GPU upload (see
235/// [`FogOfWar::gpu_mask`]). Backend-neutral: `roxlap-render` packs
236/// `cells` into a storage buffer and writes the scalars as uniforms; the
237/// WGSL kernel reproduces [`FowRender`]'s verdict from the same bytes +
238/// bands + styling, so the two backends agree.
239#[derive(Debug, Clone, PartialEq)]
240pub struct GpuFowMask {
241    /// Grid-local cell coordinate of the buffer's `(0, 0)`.
242    pub origin_cell: [i32; 2],
243    /// Buffer width in cells.
244    pub width: u32,
245    /// Buffer height in cells.
246    pub height: u32,
247    /// Per-deck `(z_top, z_bottom)` inclusive bands (deck order matches
248    /// the `deck` axis of `cells`).
249    pub decks: Vec<[i32; 2]>,
250    /// Visual-pass round 5 (#1): the observer's ACTIVE deck — the layer
251    /// every rendered voxel is classified against, regardless of its z.
252    /// (The CPU [`FowRender`] reads [`FogOfWar::visible_deck`] directly;
253    /// the GPU gets it here.)
254    pub active_deck: usize,
255    /// Deck-major, row-major mask bytes (state in bits 6–7, intensity in
256    /// bits 0–5); `d*width*height + y*width + x`.
257    pub cells: Vec<u8>,
258    /// [`VisionConfig::memory_dim`].
259    pub memory_dim: f32,
260    /// [`VisionConfig::memory_desaturate`].
261    pub memory_desaturate: f32,
262    /// [`FogOfWar::mask_version`] this snapshot was taken at (upload gate).
263    pub version: u64,
264}
265
266/// Who is looking: grid-local cell, facing, and active deck index.
267/// The caller converts from world space ([`crate::world_to_grid_local`])
268/// — keeping the mask grid-local makes ship rotation/movement free.
269#[derive(Debug, Clone, Copy)]
270pub struct FowObserver {
271    /// Observer cell (grid-local mip-0 XY column).
272    pub cell: IVec2,
273    /// Facing direction, grid-local XY. Zero disables the cone
274    /// (peripheral-only vision).
275    pub facing: Vec2,
276    /// Active deck index into [`VisionConfig::decks`].
277    pub deck: usize,
278    /// The character's eye z, grid-local (z-down). LOS is blocked by any
279    /// solid voxel within `±EYE_HALF` of it, so vision tracks the real
280    /// eye over stairs / ramps / boulders — not the deck floor.
281    pub eye_z: i32,
282}
283
284/// One 128×128 mask tile: byte per cell, state in bits 6–7, intensity
285/// in bits 0–5.
286struct MaskTile {
287    bytes: Box<[u8; TILE_CELLS]>,
288}
289
290impl MaskTile {
291    fn new() -> Self {
292        Self {
293            bytes: vec![0u8; TILE_CELLS].into_boxed_slice().try_into().unwrap(),
294        }
295    }
296}
297
298/// Lazily-computed per-cell opacity + light sample for one XY chunk
299/// tile, keyed by the chunk-version mix so edits invalidate it.
300struct OpacityTile {
301    key: u64,
302    computed: Box<[u64; TILE_WORDS]>,
303    blocked: Box<[u64; TILE_WORDS]>,
304    /// Quantised lit factor `0..=255` (255 = fully lit); only
305    /// meaningful where `computed` is set.
306    lit: Box<[u8; TILE_CELLS]>,
307}
308
309impl OpacityTile {
310    fn new(key: u64) -> Self {
311        Self {
312            key,
313            computed: vec![0u64; TILE_WORDS]
314                .into_boxed_slice()
315                .try_into()
316                .unwrap(),
317            blocked: vec![0u64; TILE_WORDS]
318                .into_boxed_slice()
319                .try_into()
320                .unwrap(),
321            lit: vec![0u8; TILE_CELLS].into_boxed_slice().try_into().unwrap(),
322        }
323    }
324}
325
326/// FW.2 review perf #1 — the render styler calls `FogOfWar::state` once
327/// per SOLID hit (hundreds of thousands per frame at Boarding scale, and
328/// once per internal cell of a Hide volume the marcher walks through).
329/// The default `HashMap` SipHash on the `(i32, i32)` tile key dominates
330/// that path; this Fx-style integer hasher (the same
331/// `rotate_left(5) ^ v * K` multiply rustc uses) cuts each lookup to a
332/// few instructions. Deterministic, zero-dependency, and `Sync` (the
333/// styler is shared read-only across the per-tile rayon workers, so a
334/// mutable last-tile cache is not an option — a faster hash is).
335#[derive(Default)]
336struct FxHasher(u64);
337
338impl Hasher for FxHasher {
339    #[inline]
340    fn finish(&self) -> u64 {
341        self.0
342    }
343    #[inline]
344    fn write(&mut self, bytes: &[u8]) {
345        for &b in bytes {
346            self.write_u64(u64::from(b));
347        }
348    }
349    #[inline]
350    fn write_i32(&mut self, i: i32) {
351        self.write_u64(u64::from(i as u32));
352    }
353    #[inline]
354    fn write_u64(&mut self, n: u64) {
355        const K: u64 = 0x517c_c1b7_2722_0a95;
356        self.0 = (self.0.rotate_left(5) ^ n).wrapping_mul(K);
357    }
358}
359
360type FastMap<K, V> = HashMap<K, V, BuildHasherDefault<FxHasher>>;
361
362/// Per-deck layers: sparse mask + opacity tiles keyed by chunk XY
363/// index. Fx-hashed (perf #1).
364#[derive(Default)]
365struct DeckLayer {
366    mask: FastMap<(i32, i32), MaskTile>,
367    opacity: FastMap<(i32, i32), OpacityTile>,
368}
369
370fn split_cell(cell: IVec2) -> ((i32, i32), usize) {
371    let tx = cell.x.div_euclid(TILE);
372    let ty = cell.y.div_euclid(TILE);
373    let ix = cell.x.rem_euclid(TILE);
374    let iy = cell.y.rem_euclid(TILE);
375    ((tx, ty), (iy * TILE + ix) as usize)
376}
377
378impl DeckLayer {
379    fn byte(&self, cell: IVec2) -> u8 {
380        let (tile, idx) = split_cell(cell);
381        self.mask.get(&tile).map_or(0, |t| t.bytes[idx])
382    }
383
384    /// Write a mask byte; returns whether the stored value changed.
385    fn write(&mut self, cell: IVec2, val: u8) -> bool {
386        let (tile, idx) = split_cell(cell);
387        if val == 0 && !self.mask.contains_key(&tile) {
388            return false;
389        }
390        let t = self.mask.entry(tile).or_insert_with(MaskTile::new);
391        let changed = t.bytes[idx] != val;
392        t.bytes[idx] = val;
393        changed
394    }
395}
396
397fn pack(state: CellState, intensity: u8) -> u8 {
398    ((state as u8) << 6) | intensity.min(INTENSITY_MAX)
399}
400
401#[inline]
402fn fnv(h: &mut u64, v: u64) {
403    *h ^= v;
404    *h = h.wrapping_mul(0x0000_0100_0000_01b3);
405}
406
407/// Mix one chunk's change-signature: the edit version AND its
408/// materialisation state. Presence matters because a generated /
409/// streamed-in chunk keeps `chunk_version == 0` (same as absent) —
410/// keying on the version alone would reuse a stale "all air" opacity
411/// tile after the chunk appears (walls behind the fresh geometry stay
412/// visible). Folding presence in flips the key on the absent→present
413/// transition too.
414#[inline]
415fn mix_chunk_sig(h: &mut u64, grid: &Grid, idx: IVec3) {
416    fnv(h, grid.chunk_version(idx));
417    fnv(h, u64::from(grid.chunk(idx).is_some()));
418}
419
420fn band_chz_range(band: DeckBand, eye_lo: i32, eye_hi: i32) -> (i32, i32) {
421    let cs_z = crate::CHUNK_SIZE_Z as i32;
422    (
423        band.z_top.min(eye_lo).div_euclid(cs_z),
424        band.z_bottom.max(eye_hi).div_euclid(cs_z),
425    )
426}
427
428/// Chunk-z range for a deck's RENDER extent `[z_top, z_bottom]` alone
429/// (no eye band) — used by the twin sync to copy the geometry a seen
430/// cell needs, independent of where the observer's eye happens to be.
431fn deck_chz_range(band: DeckBand) -> (i32, i32) {
432    let cs_z = crate::CHUNK_SIZE_Z as i32;
433    (band.z_top.div_euclid(cs_z), band.z_bottom.div_euclid(cs_z))
434}
435
436/// FNV-1a mix of the chunk signatures (plus the config generation and the
437/// observer's eye band, so light-gate retunes AND eye-height changes
438/// re-sample) backing one XY tile of a deck band.
439fn tile_key(
440    grid: &Grid,
441    band: DeckBand,
442    eye_lo: i32,
443    eye_hi: i32,
444    config_gen: u64,
445    tx: i32,
446    ty: i32,
447) -> u64 {
448    let (chz_lo, chz_hi) = band_chz_range(band, eye_lo, eye_hi);
449    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
450    fnv(&mut h, config_gen);
451    fnv(&mut h, eye_lo as u64);
452    fnv(&mut h, eye_hi as u64);
453    for chz in chz_lo..=chz_hi {
454        mix_chunk_sig(&mut h, grid, IVec3::new(tx, ty, chz));
455    }
456    h
457}
458
459/// FNV-1a mix of every chunk signature the LOS scan could touch from
460/// `origin` at `radius` (cells) on `band`. Keys the LOS recompute on
461/// edits *within view* only — far-side debris / streaming elsewhere on
462/// the ship no longer forces a full radius-96 shadowcast every frame
463/// (the old key hung on the grid-wide `mutation_counter`).
464fn local_edit_key(
465    grid: &Grid,
466    band: DeckBand,
467    eye_lo: i32,
468    eye_hi: i32,
469    config_gen: u64,
470    origin: IVec2,
471    radius: i32,
472) -> u64 {
473    let cs_xy = CHUNK_SIZE_XY as i32;
474    let cx_lo = (origin.x - radius).div_euclid(cs_xy);
475    let cx_hi = (origin.x + radius).div_euclid(cs_xy);
476    let cy_lo = (origin.y - radius).div_euclid(cs_xy);
477    let cy_hi = (origin.y + radius).div_euclid(cs_xy);
478    let (chz_lo, chz_hi) = band_chz_range(band, eye_lo, eye_hi);
479    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
480    fnv(&mut h, config_gen);
481    for chz in chz_lo..=chz_hi {
482        for cy in cy_lo..=cy_hi {
483            for cx in cx_lo..=cx_hi {
484                mix_chunk_sig(&mut h, grid, IVec3::new(cx, cy, chz));
485            }
486        }
487    }
488    h
489}
490
491/// One live heard reveal. Its covered cells + intensities are computed
492/// ONCE at [`FogOfWar::hear`] time (they never change over the TTL), so
493/// the per-tick union carries no `sqrt`.
494struct HeardBlob {
495    deck: usize,
496    cells: Vec<(IVec2, f32)>,
497    ttl: f32,
498}
499
500/// The fog-of-war state for ONE FW-enabled grid (entry-doc decision
501/// 3): per-deck mask layers, the LOS cache, heard blobs, and the
502/// fade/decay state machine. Owned by the host beside the scene;
503/// call [`Self::update`] once per tick with the observed character's
504/// grid-local pose.
505pub struct FogOfWar {
506    config: VisionConfig,
507    config_gen: u64,
508    layers: Vec<DeckLayer>,
509    /// Currently-visible cells of `visible_deck` → target intensity.
510    visible: HashMap<(i32, i32), f32>,
511    visible_deck: usize,
512    los_key: Option<(IVec2, i32, usize, i32, u64, u64)>,
513    heard: Vec<HeardBlob>,
514    /// Set when a blob was added, so the next tick rebuilds the union;
515    /// blob expiry sets it inline. Steady state skips the rebuild.
516    heard_dirty: bool,
517    /// Cells currently stamped Heard → target intensity.
518    heard_cells: HashMap<(usize, i32, i32), f32>,
519    /// Cells mid-fade/decay → current fractional intensity (the float
520    /// is the truth while transitioning; the mask byte is its
521    /// rounding).
522    transitions: HashMap<(usize, i32, i32), f32>,
523    mask_version: u64,
524    /// FW.4 — bumps ONLY when the set of Visible cells changes (a cell
525    /// enters or leaves the facing cone / peripheral). Sprite visibility
526    /// (decision 8) depends on nothing else — Memory/Unseen/Heard all
527    /// hide — so the GPU sprite-cull skip cache keys on this, NOT on
528    /// `mask_version` (which bumps on every intensity fade). Review perf
529    /// #1: without it every fade frame re-culls an identical sprite set.
530    sprite_epoch: u64,
531}
532
533impl FogOfWar {
534    /// A fresh all-Unseen fog for `config`.
535    #[must_use]
536    pub fn new(config: VisionConfig) -> Self {
537        let layers = config.decks.iter().map(|_| DeckLayer::default()).collect();
538        Self {
539            config,
540            config_gen: 0,
541            layers,
542            visible: HashMap::new(),
543            visible_deck: 0,
544            los_key: None,
545            heard: Vec::new(),
546            heard_dirty: false,
547            heard_cells: HashMap::new(),
548            transitions: HashMap::new(),
549            mask_version: 0,
550            sprite_epoch: 0,
551        }
552    }
553
554    /// Current tuning.
555    #[must_use]
556    pub fn config(&self) -> &VisionConfig {
557        &self.config
558    }
559
560    /// Replace the tuning (sliders in the demo). The deck list must
561    /// keep its length — layers are keyed by index; explored memory
562    /// survives the swap. Forces a LOS + light-sample recompute.
563    ///
564    /// # Panics
565    /// If `config.decks.len()` differs from the current one.
566    pub fn set_config(&mut self, config: VisionConfig) {
567        assert_eq!(
568            config.decks.len(),
569            self.config.decks.len(),
570            "FW deck count is fixed per FogOfWar (layers are index-keyed)"
571        );
572        self.config = config;
573        self.config_gen += 1;
574    }
575
576    /// Bumped whenever any mask byte changes — FW.3 gates the GPU
577    /// mask re-upload on it.
578    #[must_use]
579    pub fn mask_version(&self) -> u64 {
580        self.mask_version
581    }
582
583    /// FW.4 — bumped ONLY when the Visible-cell set changes (see the
584    /// field docs). Sprite visibility depends on nothing else, so the
585    /// GPU sprite-cull skip cache keys on this instead of `mask_version`
586    /// — an intensity fade re-uploads the mask but does not re-cull the
587    /// sprites (review perf #1).
588    #[must_use]
589    pub fn sprite_epoch(&self) -> u64 {
590        self.sprite_epoch
591    }
592
593    /// The deck the last [`Self::update`] ran LOS on.
594    #[must_use]
595    pub fn visible_deck(&self) -> usize {
596        self.visible_deck
597    }
598
599    /// What the observer knows about `cell` on `deck`, plus the
600    /// current fade intensity `0..=63`.
601    #[must_use]
602    pub fn state(&self, deck: usize, cell: IVec2) -> (CellState, u8) {
603        let Some(layer) = self.layers.get(deck) else {
604            return (CellState::Unseen, 0);
605        };
606        let b = layer.byte(cell);
607        (CellState::from_bits(b >> 6), b & INTENSITY_MAX)
608    }
609
610    /// Decision 6 — a sound source at `cell` on `deck` was heard at
611    /// `loudness` (feed it `source_acoustics` transmission×gain, or
612    /// any game-side loudness): stamps a live Heard blob of radius
613    /// `heard_radius × loudness` for `heard_duration` seconds.
614    /// Hearing goes through walls by design — no LOS test. Returns
615    /// whether a blob was actually stamped (a sub-cell radius — a
616    /// too-faint source — stamps nothing).
617    pub fn hear(&mut self, deck: usize, cell: IVec2, loudness: f32) -> bool {
618        if deck >= self.config.decks.len() || loudness <= 0.0 {
619            return false;
620        }
621        let r = self.config.heard_radius * loudness;
622        let ri = r.ceil() as i32;
623        let taper = self.config.edge_taper.max(1.0);
624        let mut cells = Vec::new();
625        for dy in -ri..=ri {
626            for dx in -ri..=ri {
627                let d = f64::from(dx * dx + dy * dy).sqrt() as f32;
628                let t = ((r - d) / taper).clamp(0.0, 1.0);
629                if t > 0.0 {
630                    cells.push((cell + IVec2::new(dx, dy), t * f32::from(INTENSITY_MAX)));
631                }
632            }
633        }
634        if cells.is_empty() {
635            return false;
636        }
637        self.heard.push(HeardBlob {
638            deck,
639            cells,
640            ttl: self.config.heard_duration,
641        });
642        self.heard_dirty = true;
643        true
644    }
645
646    /// FW.4 — map a WORLD point to `(deck, grid-local cell)`, or `None`
647    /// if it lies OUTSIDE the fog grid's `footprint` (grid-local cell
648    /// bounds `[lo, hi)`, from [`crate::Grid::footprint_cells`]). Off the
649    /// footprint = open space the fog knows nothing about (hazard 3 —
650    /// actors on the water / in space are never touched). A z between
651    /// decks (a stair, a jump, the hull) falls back to the observer's
652    /// active deck, so an actor mid-transit over a visible XY does not
653    /// pop out (review #6).
654    fn resolve_world(
655        &self,
656        transform: &GridTransform,
657        footprint: (IVec2, IVec2),
658        world: DVec3,
659    ) -> Option<(usize, IVec2)> {
660        let glp = crate::addr::world_to_grid_local(world, transform);
661        let v = crate::addr::voxel_global(glp.chunk, glp.voxel);
662        let (lo, hi) = footprint;
663        if v.x < lo.x || v.x >= hi.x || v.y < lo.y || v.y >= hi.y {
664            return None; // outside the grid footprint — open space
665        }
666        let deck = self
667            .config
668            .deck_for_z(v.z)
669            .unwrap_or_else(|| self.visible_deck());
670        Some((deck, IVec2::new(v.x, v.y)))
671    }
672
673    /// FW.4 — [`Self::hear`] for a WORLD-space source: maps `world`
674    /// through the fog grid's `transform` (+ its `footprint`) to a cell +
675    /// deck and stamps a heard blob. Returns whether a blob was stamped —
676    /// `false` when the source is OUTSIDE the footprint (a passing ship's
677    /// noise must not stamp this grid's mask — review #2) or too faint to
678    /// cover a cell. `transform`/`footprint` come from the fog grid.
679    pub fn hear_world(
680        &mut self,
681        transform: &GridTransform,
682        footprint: (IVec2, IVec2),
683        world: DVec3,
684        loudness: f32,
685    ) -> bool {
686        match self.resolve_world(transform, footprint, world) {
687            Some((deck, cell)) => self.hear(deck, cell, loudness),
688            None => false,
689        }
690    }
691
692    /// FW.4 — decision 8: should a sprite at world position `world` be
693    /// HIDDEN by the fog? Shown ONLY where the observer currently SEES
694    /// it — a Visible cell. Memory / Unseen cells hide it (you see the
695    /// frozen geometry of a remembered room, not the actors in it); a
696    /// Heard cell also hides the sprite in v1 (hearing reveals the
697    /// GEOMETRY pocket, not a precise live actor — a dimmed heard-sprite
698    /// is a follow-up, review #5). A point OUTSIDE the grid `footprint`
699    /// is open space and is NEVER hidden (hazard 3 — review #1).
700    /// `transform`/`footprint` come from the fog grid. Binary (no alpha
701    /// fade in v1).
702    #[must_use]
703    pub fn hides_sprite(
704        &self,
705        transform: &GridTransform,
706        footprint: (IVec2, IVec2),
707        world: DVec3,
708    ) -> bool {
709        self.resolve_world(transform, footprint, world)
710            .is_some_and(|(deck, cell)| self.state(deck, cell).0 != CellState::Visible)
711    }
712
713    /// Every live cell — Visible and Heard — with its state. FW.1's
714    /// known-twin sync walks this each frame to decide which chunks
715    /// re-copy. Cheap: only the (small) live + heard sets. A cell that
716    /// has SINCE decayed to Memory keeps its already-copied geometry in
717    /// the twin, so it need not reappear here — but see
718    /// [`Self::for_each_known_cell`] for a fresh twin.
719    pub fn for_each_live_cell(&self, mut f: impl FnMut(usize, IVec2, CellState)) {
720        for &(x, y) in self.visible.keys() {
721            f(self.visible_deck, IVec2::new(x, y), CellState::Visible);
722        }
723        for &(deck, x, y) in self.heard_cells.keys() {
724            // A cell the active deck also sees is reported once, as
725            // Visible (it wins) — skip the Heard duplicate.
726            if deck == self.visible_deck && self.visible.contains_key(&(x, y)) {
727                continue;
728            }
729            f(deck, IVec2::new(x, y), CellState::Heard);
730        }
731    }
732
733    /// Every KNOWN cell — Visible, Memory, or Heard (state != Unseen) —
734    /// across all decks. A FRESH twin (first sync after (re)attach — fog
735    /// toggled off then on, or re-armed after a load) walks this ONCE so
736    /// it copies geometry for REMEMBERED rooms too; otherwise the mask
737    /// would say Memory while the new twin holds no geometry there, and
738    /// the remembered rooms would render empty. Steady-state syncs use
739    /// the cheap [`Self::for_each_live_cell`] (a demoted cell keeps the
740    /// copy it got while Visible). Walks the mask tiles, so O(explored).
741    pub fn for_each_known_cell(&self, mut f: impl FnMut(usize, IVec2, CellState)) {
742        for (deck, layer) in self.layers.iter().enumerate() {
743            for (&(tx, ty), tile) in &layer.mask {
744                for (idx, &b) in tile.bytes.iter().enumerate() {
745                    let state = CellState::from_bits(b >> 6);
746                    if state == CellState::Unseen {
747                        continue;
748                    }
749                    let ix = idx as i32 % TILE;
750                    let iy = idx as i32 / TILE;
751                    f(deck, IVec2::new(tx * TILE + ix, ty * TILE + iy), state);
752                }
753            }
754        }
755    }
756
757    /// Raw mask tiles of one deck (tile key = chunk XY index; 128×128
758    /// row-major bytes) — the FW.3 GPU upload path.
759    pub fn deck_tiles(&self, deck: usize) -> impl Iterator<Item = (IVec2, &[u8])> {
760        self.layers.get(deck).into_iter().flat_map(|l| {
761            l.mask
762                .iter()
763                .map(|(&(tx, ty), t)| (IVec2::new(tx, ty), &t.bytes[..]))
764        })
765    }
766
767    /// FW.3 — flatten the whole mask into a dense deck-major, row-major
768    /// byte buffer over the grid-local cell rectangle
769    /// `[origin_cell, origin_cell + (width, height))`, for GPU upload.
770    /// Cell `(x, y)` on deck `d` lives at
771    /// `d*width*height + (y - origin_y)*width + (x - origin_x)`; cells
772    /// outside a materialised tile stay `0` (Unseen ⇒ the shader hides
773    /// them). The caller sizes the rectangle to the twin's
774    /// [`Grid::gpu_residency_hint`] (`origin_chunk * 128`,
775    /// `chunks_dims * 128`) so it covers the whole ship. Also returns the
776    /// per-deck `(z_top, z_bottom)` bands and styling the shader needs to
777    /// reproduce [`FowRender`]'s verdict. Gate uploads on
778    /// [`Self::mask_version`].
779    #[must_use]
780    pub fn gpu_mask(&self, origin_cell: IVec2, width: u32, height: u32) -> GpuFowMask {
781        let (w, h) = (width as usize, height as usize);
782        let deck_count = self.config.decks.len();
783        let mut cells = vec![0u8; deck_count * w * h];
784        let tile = TILE;
785        for (deck, layer) in self.layers.iter().enumerate() {
786            let base = deck * w * h;
787            for (&(tx, ty), t) in &layer.mask {
788                // Tile's grid-local cell origin, relative to the buffer.
789                let tile_x0 = tx * tile - origin_cell.x;
790                let tile_y0 = ty * tile - origin_cell.y;
791                for iy in 0..tile {
792                    let by = tile_y0 + iy;
793                    if by < 0 || by >= height as i32 {
794                        continue;
795                    }
796                    for ix in 0..tile {
797                        let bx = tile_x0 + ix;
798                        if bx < 0 || bx >= width as i32 {
799                            continue;
800                        }
801                        let src = (iy * tile + ix) as usize;
802                        let dst = base + (by as usize) * w + bx as usize;
803                        cells[dst] = t.bytes[src];
804                    }
805                }
806            }
807        }
808        GpuFowMask {
809            origin_cell: [origin_cell.x, origin_cell.y],
810            width,
811            height,
812            decks: self
813                .config
814                .decks
815                .iter()
816                .map(|d| [d.z_top, d.z_bottom])
817                .collect(),
818            active_deck: self.visible_deck,
819            cells,
820            memory_dim: self.config.memory_dim,
821            memory_desaturate: self.config.memory_desaturate,
822            version: self.mask_version,
823        }
824    }
825
826    /// Advance the fog one tick: recompute LOS if the observer moved /
827    /// turned / switched deck or the grid was edited (the
828    /// [`Grid::mutation_counter`] key), restamp heard blobs, and step
829    /// every fading cell by `dt` seconds.
830    pub fn update(&mut self, grid: &Grid, observer: &FowObserver, dt: f32) {
831        let mut changed = false;
832
833        // Deck switch: demote the whole visible set of the old deck.
834        if observer.deck != self.visible_deck && !self.visible.is_empty() {
835            let old = std::mem::take(&mut self.visible);
836            let deck = self.visible_deck;
837            for (cell, _) in old {
838                changed |= self.demote_to_memory(deck, IVec2::new(cell.0, cell.1));
839            }
840            self.los_key = None;
841            self.sprite_epoch = self.sprite_epoch.wrapping_add(1); // visible set emptied
842        }
843        self.visible_deck = observer.deck;
844
845        // LOS recompute when the key moves.
846        if observer.deck < self.config.decks.len() {
847            let facing_q = quantize_facing(observer.facing);
848            let band = self.config.decks[observer.deck];
849            let (eye_lo, eye_hi) = (observer.eye_z - EYE_HALF, observer.eye_z + EYE_HALF);
850            let radius = self.config.range.max(self.config.peripheral_range).ceil() as i32;
851            let edit_key = local_edit_key(
852                grid,
853                band,
854                eye_lo,
855                eye_hi,
856                self.config_gen,
857                observer.cell,
858                radius,
859            );
860            let key = (
861                observer.cell,
862                facing_q,
863                observer.deck,
864                observer.eye_z,
865                edit_key,
866                self.config_gen,
867            );
868            if self.los_key != Some(key) {
869                self.los_key = Some(key);
870                changed |= self.recompute_los(grid, observer);
871            }
872        } else if !self.visible.is_empty() {
873            // Observer off every configured deck: nothing is visible.
874            let old = std::mem::take(&mut self.visible);
875            for (cell, _) in old {
876                changed |= self.demote_to_memory(
877                    observer.deck.min(self.layers.len()),
878                    IVec2::new(cell.0, cell.1),
879                );
880            }
881            self.sprite_epoch = self.sprite_epoch.wrapping_add(1); // visible set emptied
882        }
883
884        changed |= self.update_heard(dt);
885        changed |= self.tick_transitions(dt);
886
887        if changed {
888            self.mask_version += 1;
889        }
890    }
891
892    /// Rebuild the visible set via shadowcast; diff against the old
893    /// one, stamping states and queueing fades. Returns whether any
894    /// mask byte changed.
895    fn recompute_los(&mut self, grid: &Grid, observer: &FowObserver) -> bool {
896        let deck = observer.deck;
897        let band = self.config.decks[deck];
898        let mut new_visible: HashMap<(i32, i32), f32> = HashMap::new();
899        {
900            let layer = &mut self.layers[deck];
901            let mut scan = LosScan {
902                grid,
903                cfg: &self.config,
904                band,
905                eye_lo: observer.eye_z - EYE_HALF,
906                eye_hi: observer.eye_z + EYE_HALF,
907                config_gen: self.config_gen,
908                layer,
909                origin: observer.cell,
910                facing: normalize_facing(observer.facing),
911                out: &mut new_visible,
912                key_memo: None,
913            };
914            scan.run();
915        }
916
917        let mut changed = false;
918        // FW.4 — the VISIBLE set is exactly what gates sprite visibility;
919        // bump `sprite_epoch` iff its membership changed (a demotion or a
920        // new cell), so the GPU sprite cull re-runs then — and NOT on the
921        // intensity fades that follow (which leave membership untouched).
922        let mut membership_changed = false;
923        // Demotions: previously visible, no longer.
924        let old = std::mem::take(&mut self.visible);
925        for &cell in old.keys() {
926            if !new_visible.contains_key(&cell) {
927                changed |= self.demote_to_memory(deck, IVec2::new(cell.0, cell.1));
928                membership_changed = true;
929            }
930        }
931        // Stamps: state flips to Visible AND intensity snaps to full at
932        // once. Visual-pass round 7 (#3): no fade-in — a cell only
933        // briefly in the cone (the edge sweeping past as you turn) must
934        // still demote at FULL intensity, or it leaves a faint memory
935        // streak where the cone edge passed. The fade-in was invisible
936        // live anyway (Visible is unstyled); dropping it makes the memory
937        // uniform regardless of sweep speed. Any pending decay is
938        // cancelled — the cell is fully lit again.
939        for &(x, y) in new_visible.keys() {
940            if !old.contains_key(&(x, y)) {
941                membership_changed = true;
942            }
943            let cell = IVec2::new(x, y);
944            changed |= self.layers[deck].write(cell, pack(CellState::Visible, INTENSITY_MAX));
945            self.transitions.remove(&(deck, x, y));
946        }
947        self.visible = new_visible;
948        if membership_changed {
949            self.sprite_epoch = self.sprite_epoch.wrapping_add(1);
950        }
951        changed
952    }
953
954    /// Flip a cell to Memory (keeping its intensity) and queue its
955    /// decay. No-op on Unseen cells.
956    fn demote_to_memory(&mut self, deck: usize, cell: IVec2) -> bool {
957        let Some(layer) = self.layers.get_mut(deck) else {
958            return false;
959        };
960        let b = layer.byte(cell);
961        if CellState::from_bits(b >> 6) == CellState::Unseen {
962            return false;
963        }
964        let intensity = b & INTENSITY_MAX;
965        let changed = layer.write(cell, pack(CellState::Memory, intensity));
966        self.transitions
967            .entry((deck, cell.x, cell.y))
968            .or_insert_with(|| f32::from(intensity));
969        changed
970    }
971
972    /// Tick blob TTLs, rebuild the heard union only when the blob set
973    /// changed (perf: the union carries no `sqrt` — blob cells are
974    /// precomputed), then stamp the Heard byte for every union cell the
975    /// active-deck LOS pass didn't already claim as Visible.
976    fn update_heard(&mut self, dt: f32) -> bool {
977        let mut changed = false;
978        let before = self.heard.len();
979        for blob in &mut self.heard {
980            blob.ttl -= dt;
981        }
982        self.heard.retain(|b| b.ttl > 0.0);
983        let expired = self.heard.len() != before;
984
985        // The union depends only on the blob set — rebuild solely when
986        // a blob was added (`heard_dirty`) or expired. Visible-priority
987        // is NOT baked in here (it changes as the observer moves); it is
988        // applied at stamp time below, so union stays blob-only.
989        if self.heard_dirty || expired {
990            self.heard_dirty = false;
991            let mut now: HashMap<(usize, i32, i32), f32> = HashMap::new();
992            for blob in &self.heard {
993                for &(cell, target) in &blob.cells {
994                    let e = now.entry((blob.deck, cell.x, cell.y)).or_insert(0.0);
995                    *e = e.max(target);
996                }
997            }
998            // Demote cells that fell out of every blob — but NOT one the
999            // just-run LOS pass marked Visible (the player turned toward
1000            // the noise): stomping it back to Memory left a live cell
1001            // dead ahead rendering as memory until the observer moved.
1002            let old = std::mem::take(&mut self.heard_cells);
1003            for &key in old.keys() {
1004                if now.contains_key(&key) {
1005                    continue;
1006                }
1007                if key.0 == self.visible_deck && self.visible.contains_key(&(key.1, key.2)) {
1008                    continue;
1009                }
1010                changed |= self.demote_to_memory(key.0, IVec2::new(key.1, key.2));
1011            }
1012            self.heard_cells = now;
1013        }
1014
1015        // Stamp the Heard byte for union cells (skipping any the active
1016        // deck currently sees) and queue their fades. Runs every tick so
1017        // a cell that just left the visible cone reverts Visible→Heard.
1018        for (&(deck, x, y), &target) in &self.heard_cells {
1019            if deck == self.visible_deck && self.visible.contains_key(&(x, y)) {
1020                continue;
1021            }
1022            let cell = IVec2::new(x, y);
1023            let cur = self.layers[deck].byte(cell);
1024            let intensity = cur & INTENSITY_MAX;
1025            changed |= self.layers[deck].write(cell, pack(CellState::Heard, intensity));
1026            if (f32::from(intensity) - target).abs() > SETTLE_EPS {
1027                self.transitions
1028                    .entry((deck, x, y))
1029                    .or_insert_with(|| f32::from(intensity));
1030            }
1031        }
1032        changed
1033    }
1034
1035    /// Step every transitioning cell toward its current target.
1036    fn tick_transitions(&mut self, dt: f32) -> bool {
1037        if self.transitions.is_empty() {
1038            return false;
1039        }
1040        let cfg = &self.config;
1041        let mut changed = false;
1042        let mut settled: Vec<(usize, i32, i32)> = Vec::new();
1043        let visible_deck = self.visible_deck;
1044        for (&key, cur) in &mut self.transitions {
1045            let (deck, x, y) = key;
1046            let cell = IVec2::new(x, y);
1047            // Target + rate from current membership.
1048            let (target, rate) = if deck == visible_deck && self.visible.contains_key(&(x, y)) {
1049                let t = self.visible[&(x, y)];
1050                (t, if *cur < t { cfg.fade_in } else { cfg.fade_out })
1051            } else if let Some(&t) = self.heard_cells.get(&key) {
1052                (t, if *cur < t { cfg.fade_in } else { cfg.fade_out })
1053            } else {
1054                // Memory: fast fall to memory_intensity, then slow
1055                // decay to memory_floor (0 decay = stop at sustain).
1056                let sustain = f32::from(cfg.memory_intensity);
1057                if *cur > sustain {
1058                    (sustain.max(f32::from(cfg.memory_floor)), cfg.fade_out)
1059                } else if cfg.memory_decay > 0.0 {
1060                    (f32::from(cfg.memory_floor).min(*cur), cfg.memory_decay)
1061                } else {
1062                    (*cur, f32::INFINITY)
1063                }
1064            };
1065
1066            let step = rate * dt;
1067            if (*cur - target).abs() <= step.max(SETTLE_EPS) {
1068                *cur = target;
1069            } else if *cur < target {
1070                *cur += step;
1071            } else {
1072                *cur -= step;
1073            }
1074
1075            let layer = &mut self.layers[deck];
1076            let b = layer.byte(cell);
1077            let state = CellState::from_bits(b >> 6);
1078            let rounded = (cur.round().clamp(0.0, f32::from(INTENSITY_MAX))) as u8;
1079            changed |= layer.write(cell, pack(state, rounded));
1080            if (*cur - target).abs() <= SETTLE_EPS {
1081                // Memory cells settle for good only once fully decayed
1082                // (or decay is off); live cells settle at their target.
1083                let memory_pending = state == CellState::Memory
1084                    && cfg.memory_decay > 0.0
1085                    && *cur > f32::from(cfg.memory_floor) + SETTLE_EPS;
1086                if !memory_pending {
1087                    settled.push(key);
1088                }
1089            }
1090        }
1091        for key in settled {
1092            self.transitions.remove(&key);
1093        }
1094        changed
1095    }
1096}
1097
1098/// (edit version, materialised?) of a real chunk — the twin's
1099/// copy-freshness key. Either changing means the twin's copy is stale.
1100fn chunk_sig_pair(grid: &Grid, idx: IVec3) -> (u64, bool) {
1101    (grid.chunk_version(idx), grid.chunk(idx).is_some())
1102}
1103
1104/// One pending twin copy: (chunk idx, cloned real chunk or `None` if
1105/// absent, real `(version, present)` signature, first-seen flag).
1106type TwinCopy = (IVec3, Option<Vxl>, (u64, bool), bool);
1107
1108/// Full chunk bounding box `(origin_chunk, chunks_dims)` of a grid, for
1109/// the twin's GPU residency hint — sized to the WHOLE real grid so the
1110/// twin's pool never aliases as exploration adds chunks. `None` for an
1111/// empty grid.
1112fn grid_chunk_bbox(grid: &Grid) -> Option<([i32; 3], [u32; 3])> {
1113    let mut it = grid.chunks.keys();
1114    let first = it.next()?;
1115    let (mut lo, mut hi) = (*first, *first);
1116    for k in it {
1117        lo = lo.min(*k);
1118        hi = hi.max(*k);
1119    }
1120    Some((
1121        [lo.x, lo.y, lo.z],
1122        [
1123            (hi.x - lo.x + 1) as u32,
1124            (hi.y - lo.y + 1) as u32,
1125            (hi.z - lo.z + 1) as u32,
1126        ],
1127    ))
1128}
1129
1130/// FW.1 render-config the twin mirrors from the real grid each sync, so
1131/// the drawn twin looks exactly like the real grid would have (a moving
1132/// ship, a CA deck clip, LOD / mip overrides). NOT mirrored: the flags
1133/// (`presentation_only` stays on the twin), streaming, water, bake
1134/// lights — those are authoring / sim state the twin must never carry.
1135struct TwinMirror {
1136    transform: GridTransform,
1137    render_sky: bool,
1138    mip_levels_override: Option<u32>,
1139    lod_thresholds: LodThresholds,
1140    z_clip: Option<i32>,
1141}
1142
1143impl TwinMirror {
1144    fn read(g: &Grid) -> Self {
1145        Self {
1146            transform: g.transform,
1147            render_sky: g.render_sky,
1148            mip_levels_override: g.mip_levels_override,
1149            lod_thresholds: g.lod_thresholds,
1150            z_clip: g.z_clip,
1151        }
1152    }
1153
1154    fn apply(&self, g: &mut Grid) {
1155        g.transform = self.transform;
1156        g.render_sky = self.render_sky;
1157        g.mip_levels_override = self.mip_levels_override;
1158        // FW.2 review #3 — the twin keeps the real grid's Near→Mid
1159        // behaviour (Mid mip is coarse but still marched through the
1160        // fog-aware DDA), but NEVER reaches the Far tier: Far blits a
1161        // `BillboardCache` impostor built from the raw twin with no
1162        // per-cell fog styling, so at Far distance the whole known+unseen
1163        // ship would flash fully visible — exactly the range where
1164        // concealment matters most. `r_mid = INFINITY` extends Mid to
1165        // any distance.
1166        let mut lod = self.lod_thresholds;
1167        lod.r_mid = f64::INFINITY;
1168        g.lod_thresholds = lod;
1169        g.z_clip = self.z_clip;
1170    }
1171}
1172
1173/// FW.1 — the known-twin binding for one fog-of-war grid (entry-doc
1174/// decision 1): a second grid registered in the [`Scene`] that holds
1175/// the *last-seen* copy of the real grid and is what actually renders.
1176/// The real grid is flagged [`Grid::render_excluded`] (simulated, never
1177/// drawn); the twin is flagged [`Grid::presentation_only`] (drawn,
1178/// never queried). [`Self::sync`] copies chunks from real → twin ONLY
1179/// under currently Visible / Heard cells, so geometry (and its baked
1180/// light) outside the observer's knowledge stays frozen at what they
1181/// last saw — "memory" is simply the twin not being updated there.
1182///
1183/// v1 sync is **chunk-granular** (entry-doc decision 2): a chunk under
1184/// any live cell re-copies whenever its real edit version changed, so
1185/// an edit in the *unseen* part of a partially-seen chunk becomes
1186/// visible early (bounded by one chunk). The per-column refinement is a
1187/// deliberate follow-up.
1188pub struct FowTwin {
1189    real: GridId,
1190    twin: GridId,
1191    /// Per real-chunk `(version, present)` last copied into the twin.
1192    /// A live chunk re-copies only when this differs.
1193    copied: HashMap<IVec3, (u64, bool)>,
1194    /// Quiet-frame gate: `(mask_version, real mutation_counter)` of the
1195    /// last [`Self::sync`] that ran. Unchanged ⇒ nothing to copy, skip
1196    /// the whole live-cell rescan.
1197    last_synced: Option<(u64, u64)>,
1198}
1199
1200impl FowTwin {
1201    /// Register a known twin for `real`: adds a grid mirroring `real`'s
1202    /// transform, flags `real` [`Grid::render_excluded`] and the twin
1203    /// [`Grid::presentation_only`], and sizes the twin's GPU residency
1204    /// hint to the real grid's full chunk bbox (so its slot pool covers
1205    /// the whole ship from the first upload — no aliasing as rooms are
1206    /// explored). The twin starts empty (all Unseen); the first
1207    /// [`Self::sync`] copies what the observer can see.
1208    ///
1209    /// # Panics
1210    /// If `real` is not a registered grid.
1211    pub fn attach(scene: &mut Scene, real: GridId) -> Self {
1212        let (transform, hint) = {
1213            let g = scene
1214                .grid(real)
1215                .expect("FowTwin::attach: real grid must be registered");
1216            (g.transform, grid_chunk_bbox(g))
1217        };
1218        let twin = scene.add_grid(transform);
1219        scene
1220            .grid_mut(real)
1221            .expect("real grid just checked")
1222            .render_excluded = true;
1223        let t = scene.grid_mut(twin).expect("twin just added");
1224        t.presentation_only = true;
1225        t.gpu_residency_hint = hint;
1226        Self {
1227            real,
1228            twin,
1229            copied: HashMap::new(),
1230            last_synced: None,
1231        }
1232    }
1233
1234    /// The simulated grid (flagged `render_excluded`).
1235    #[must_use]
1236    pub fn real(&self) -> GridId {
1237        self.real
1238    }
1239
1240    /// The rendered twin grid (flagged `presentation_only`).
1241    #[must_use]
1242    pub fn twin(&self) -> GridId {
1243        self.twin
1244    }
1245
1246    /// Undo [`Self::attach`]: clear `real`'s `render_excluded` (it
1247    /// renders normally again) and remove the twin grid. Call before
1248    /// dropping the fog-of-war for this grid.
1249    pub fn detach(self, scene: &mut Scene) {
1250        if let Some(r) = scene.grid_mut(self.real) {
1251            r.render_excluded = false;
1252        }
1253        scene.remove_grid(self.twin);
1254    }
1255
1256    /// Advance the twin for the current fog state: mirror the real
1257    /// grid's render config, then copy-on-first-seen / re-sync every
1258    /// chunk under a Visible or Heard cell whose real version changed.
1259    ///
1260    /// Returns `false` — and does nothing — when either grid is missing
1261    /// (e.g. after a snapshot load or lockstep rollback: the twin was
1262    /// derived state and is gone, the real grid loaded with fog off).
1263    /// A host **must** treat `false` as "re-arm fog-of-war": drop this
1264    /// binding and [`Self::attach`] a fresh one. `#[must_use]` so the
1265    /// signal can't be silently dropped.
1266    ///
1267    /// Cheap on quiet frames: if neither the mask nor the real grid
1268    /// changed since the last run it early-outs before the live-cell
1269    /// rescan.
1270    #[must_use]
1271    pub fn sync(&mut self, scene: &mut Scene, fow: &FogOfWar) -> bool {
1272        // Both grids must still exist — a lost twin means the host has
1273        // not re-armed after a load/rollback.
1274        let Some(real_mut) = scene.grid(self.real).map(Grid::mutation_counter) else {
1275            return false;
1276        };
1277        if scene.grid(self.twin).is_none() {
1278            return false;
1279        }
1280
1281        // Quiet-frame early-out: the mask version moves on any
1282        // visibility/fade change, the mutation counter on any real edit
1283        // or chunk install/evict — nothing else can change what to copy.
1284        let key = (fow.mask_version(), real_mut);
1285        if self.last_synced == Some(key) {
1286            return true;
1287        }
1288        // First sync of a fresh twin (nothing copied yet): walk ALL known
1289        // cells so REMEMBERED geometry is copied too, not just what's
1290        // live this frame (#2 — else a fog off/on re-arm renders memory
1291        // rooms empty). Steady state uses the cheap live-only walk.
1292        let first_scan = self.last_synced.is_none();
1293        self.last_synced = Some(key);
1294
1295        // Phase 1 — read the real grid: mirror, hint, and the copies due
1296        // (with a first-seen flag: first copy bumps the whole chunk, a
1297        // re-sync bumps only the edited bbox).
1298        let (mirror, hint, copies) = {
1299            let real = scene.grid(self.real).expect("checked above");
1300            let mirror = TwinMirror::read(real);
1301            let hint = grid_chunk_bbox(real);
1302            // Every chunk under a live cell, deduped, with its real sig.
1303            let mut want: HashMap<IVec3, (u64, bool)> = HashMap::new();
1304            let decks = &fow.config().decks;
1305            let mut collect = |deck: usize, cell: IVec2| {
1306                let Some(band) = decks.get(deck) else {
1307                    return;
1308                };
1309                let (chz_lo, chz_hi) = deck_chz_range(*band);
1310                let chx = cell.x.div_euclid(TILE);
1311                let chy = cell.y.div_euclid(TILE);
1312                for chz in chz_lo..=chz_hi {
1313                    let idx = IVec3::new(chx, chy, chz);
1314                    want.entry(idx).or_insert_with(|| chunk_sig_pair(real, idx));
1315                }
1316            };
1317            if first_scan {
1318                fow.for_each_known_cell(|deck, cell, _| collect(deck, cell));
1319            } else {
1320                fow.for_each_live_cell(|deck, cell, _| collect(deck, cell));
1321            }
1322            let mut copies: Vec<TwinCopy> = Vec::new();
1323            for (idx, sig) in want {
1324                if self.copied.get(&idx) != Some(&sig) {
1325                    let first_seen = !self.copied.contains_key(&idx);
1326                    copies.push((idx, real.chunk(idx).cloned(), sig, first_seen));
1327                }
1328            }
1329            (mirror, hint, copies)
1330        };
1331
1332        // Phase 1b — drain each copied chunk's accumulated dirty extent
1333        // from the REAL grid. The real grid never renders, so nothing
1334        // else consumes its `chunk_dirty` — draining here both feeds the
1335        // twin's incremental GPU re-upload (edited bbox, not whole
1336        // chunk) and stops that orphaned map from leaking.
1337        let extents: Vec<Option<DirtyExtent>> = {
1338            let real = scene.grid_mut(self.real).expect("checked above");
1339            copies
1340                .iter()
1341                .map(|(idx, _, _, _)| real.take_chunk_dirty(*idx))
1342                .collect()
1343        };
1344
1345        // Phase 2 — write the twin (disjoint borrow).
1346        let twin = scene.grid_mut(self.twin).expect("checked above");
1347        mirror.apply(twin);
1348        twin.gpu_residency_hint = hint;
1349        let mut any_change = false;
1350        for ((idx, chunk, sig, first_seen), extent) in copies.into_iter().zip(extents) {
1351            match chunk {
1352                Some(vxl) => {
1353                    twin.chunks.insert(idx, vxl);
1354                    // First copy = whole chunk is new to the twin (Full);
1355                    // a re-sync only touched the real edit's bbox.
1356                    match (first_seen, extent) {
1357                        (false, Some(DirtyExtent::Bbox(lo, hi))) => {
1358                            twin.bump_chunk_version_bbox(idx, lo, hi);
1359                        }
1360                        _ => twin.bump_chunk_version(idx),
1361                    }
1362                    self.copied.insert(idx, sig);
1363                    any_change = true;
1364                }
1365                None => {
1366                    // Absent in the real grid (evicted, or open space):
1367                    // KEEP the twin's last-seen copy as MEMORY — never
1368                    // remove it (that would drop geometry the observer is
1369                    // looking at) and never bump a phantom version for a
1370                    // chunk that exists nowhere. Record the sig only if
1371                    // we actually hold a memory copy, so a genuinely-
1372                    // empty cell doesn't re-clone every frame.
1373                    if twin.chunks.contains_key(&idx) {
1374                        self.copied.insert(idx, sig);
1375                    }
1376                }
1377            }
1378        }
1379        if any_change {
1380            // Invalidate the Far-tier impostor cache like every other
1381            // chunk-set path (S7.4) — else the twin renders a frozen
1382            // first-look impostor at distance.
1383            twin.billboards = None;
1384        }
1385        true
1386    }
1387}
1388
1389/// FW.2 — the render-time fog-of-war classifier: wraps a [`FogOfWar`]
1390/// and answers the [`roxlap_core::dda::FowStyler`] verdict per hit. The
1391/// scene renderer builds one for the twin grid each frame and hands it
1392/// to the CPU DDA (`DdaEnv::fow`).
1393///
1394/// State → verdict:
1395/// - **Unseen** ⇒ `Hide` (the marcher treats the cell as air).
1396/// - **Visible** ⇒ `Show { dynamic: true }`, dimmed toward the memory
1397///   look at the cone edge (intensity `< max`) so the FOV boundary is a
1398///   smooth taper, full at the cone centre.
1399/// - **Memory / Heard** ⇒ `Show { dynamic: false }` (frozen baked look),
1400///   dimmed by [`VisionConfig::memory_dim`] scaled by the decaying
1401///   intensity and desaturated by [`VisionConfig::memory_desaturate`].
1402pub struct FowRender<'a> {
1403    fow: &'a FogOfWar,
1404}
1405
1406/// Visual-pass round 9 (#1): the verdict for an UNSEEN voxel on a deck
1407/// BELOW the observer — rendered opaque and black (`dim 0`) so the ray
1408/// STOPS on it (no bright sky-hole down a stairwell, no live "two decks
1409/// at once") without revealing any of the unexplored deck's detail.
1410const OCCLUDE_BELOW: roxlap_core::dda::FowVerdict = roxlap_core::dda::FowVerdict::Show {
1411    dynamic: false,
1412    dim: 0.0,
1413    desaturate: 0.0,
1414};
1415
1416impl<'a> FowRender<'a> {
1417    /// Wrap `fow` for a render pass.
1418    #[must_use]
1419    pub fn new(fow: &'a FogOfWar) -> Self {
1420        Self { fow }
1421    }
1422}
1423
1424impl roxlap_core::dda::FowStyler for FowRender<'_> {
1425    fn verdict(&self, x: i32, y: i32, z: i32) -> roxlap_core::dda::FowVerdict {
1426        use roxlap_core::dda::FowVerdict;
1427        let cfg = self.fow.config();
1428        let active = self.fow.visible_deck();
1429        // Visual-pass round 9 (#1): classify each voxel by its OWN deck
1430        // (`deck_for_z`), then gate by that deck's state — round 6's Z
1431        // WINDOW claimed the whole NEXT deck's band for the active deck,
1432        // so you saw the deck below LIVE through the floor. `deck_below`
1433        // = a deck lower than the one you stand on.
1434        let below = |deck: usize| deck > active;
1435        let Some(deck) = cfg.deck_for_z(z) else {
1436            // z in a gap between bands. Below the active floor it's the
1437            // stair/sub-floor shaft — occlude it dark (see the note on
1438            // `Unseen` below); above, stay transparent.
1439            let active_floor = cfg.decks.get(active).map_or(i32::MAX, |b| b.z_bottom);
1440            return if z > active_floor {
1441                OCCLUDE_BELOW
1442            } else {
1443                FowVerdict::Hide
1444            };
1445        };
1446        let (state, intensity) = self.fow.state(deck, IVec2::new(x, y));
1447        let t = f32::from(intensity) / f32::from(INTENSITY_MAX);
1448        match state {
1449            // Visual-pass round 9 (#1): an UNSEEN voxel on a deck BELOW
1450            // the one you stand on is occluded OPAQUE-DARK, not made
1451            // transparent — you must not see down through your own floor
1452            // into an unexplored basement (that read as "two decks at
1453            // once", or as a bright sky-hole down the stairwell). Unseen
1454            // cells on the active deck or ABOVE it stay transparent
1455            // (`Hide`) so a deck you're under — the flooded-bilge swim,
1456            // round 8 — still shows through.
1457            CellState::Unseen if below(deck) => OCCLUDE_BELOW,
1458            CellState::Unseen => FowVerdict::Hide,
1459            // A currently-Visible cell is drawn UNSTYLED — full
1460            // brightness, full colour — so the cone rim / peripheral ring
1461            // don't read as dim/desaturated smudges (round 6 #3). Only
1462            // the active deck ever has Visible cells.
1463            CellState::Visible => FowVerdict::Show {
1464                dynamic: true,
1465                dim: 1.0,
1466                desaturate: 0.0,
1467            },
1468            CellState::Memory | CellState::Heard => FowVerdict::Show {
1469                dynamic: false,
1470                dim: cfg.memory_dim + (1.0 - cfg.memory_dim) * t,
1471                desaturate: cfg.memory_desaturate * (1.0 - t),
1472            },
1473        }
1474    }
1475}
1476
1477fn normalize_facing(f: Vec2) -> Option<Vec2> {
1478    let len = f.length();
1479    if len < 1e-6 {
1480        None
1481    } else {
1482        Some(f / len)
1483    }
1484}
1485
1486/// Facing quantised to one of 2048 buckets `[0, 2048)`, or `i32::MIN`
1487/// for "no facing" (peripheral-only). The sentinel sits outside the
1488/// bucket range so a near-zero-angle facing (e.g. `(1, -0.003)`) can
1489/// never alias it — aliasing froze the cone on/off across the
1490/// facing↔zero transition. The `rem_euclid` also folds the ±π wrap
1491/// (bucket 2048 ≡ 0) so opposite-signed representations of the same
1492/// heading share a bucket.
1493fn quantize_facing(f: Vec2) -> i32 {
1494    match normalize_facing(f) {
1495        None => i32::MIN,
1496        Some(n) => {
1497            let ang = f64::from(n.y).atan2(f64::from(n.x));
1498            let raw = (ang / std::f64::consts::TAU * 2048.0).round() as i32;
1499            raw.rem_euclid(2048)
1500        }
1501    }
1502}
1503
1504/// One LOS recompute: recursive shadowcasting (the classic RogueBasin
1505/// 8-octant algorithm) over the deck's opacity, classifying every
1506/// LOS-clear cell against the cone/peripheral shape and the light
1507/// gate.
1508struct LosScan<'a> {
1509    grid: &'a Grid,
1510    cfg: &'a VisionConfig,
1511    band: DeckBand,
1512    /// Eye-level opacity band `[eye_lo, eye_hi]`, grid-local — derived
1513    /// from the observer's `eye_z ± EYE_HALF`, not the deck.
1514    eye_lo: i32,
1515    eye_hi: i32,
1516    config_gen: u64,
1517    layer: &'a mut DeckLayer,
1518    origin: IVec2,
1519    facing: Option<Vec2>,
1520    out: &'a mut HashMap<(i32, i32), f32>,
1521    /// Last (tile coords, tile_key) — shadowcasting has strong tile
1522    /// locality, so this reuses the FNV mix across the run of cells in
1523    /// the same tile instead of rehashing every chz version per cell.
1524    key_memo: Option<((i32, i32), u64)>,
1525}
1526
1527/// Octant transforms (xx, xy, yx, yy).
1528const OCTANTS: [[i32; 4]; 8] = [
1529    [1, 0, 0, 1],
1530    [0, 1, 1, 0],
1531    [0, -1, 1, 0],
1532    [-1, 0, 0, 1],
1533    [-1, 0, 0, -1],
1534    [0, -1, -1, 0],
1535    [0, 1, -1, 0],
1536    [1, 0, 0, -1],
1537];
1538
1539impl LosScan<'_> {
1540    fn radius(&self) -> i32 {
1541        self.cfg.range.max(self.cfg.peripheral_range).ceil() as i32
1542    }
1543
1544    fn run(&mut self) {
1545        // The observer's own cell is always visible (gate-exempt: you
1546        // know where you stand even in the dark).
1547        self.out
1548            .insert((self.origin.x, self.origin.y), f32::from(INTENSITY_MAX));
1549        for m in OCTANTS {
1550            self.cast(1, 1.0, 0.0, m);
1551        }
1552    }
1553
1554    /// Is LOS blocked at `cell`? Lazily computes + caches the cell's
1555    /// opacity/light sample.
1556    fn blocked_at(&mut self, cell: IVec2) -> bool {
1557        self.sample(cell).0
1558    }
1559
1560    /// (blocked, lit factor 0..=1) for `cell`, from the version-keyed
1561    /// tile cache.
1562    fn sample(&mut self, cell: IVec2) -> (bool, f32) {
1563        let ((tx, ty), idx) = split_cell(cell);
1564        let key = match self.key_memo {
1565            Some((t, k)) if t == (tx, ty) => k,
1566            _ => {
1567                let k = tile_key(
1568                    self.grid,
1569                    self.band,
1570                    self.eye_lo,
1571                    self.eye_hi,
1572                    self.config_gen,
1573                    tx,
1574                    ty,
1575                );
1576                self.key_memo = Some(((tx, ty), k));
1577                k
1578            }
1579        };
1580        let tile = self
1581            .layer
1582            .opacity
1583            .entry((tx, ty))
1584            .or_insert_with(|| OpacityTile::new(key));
1585        if tile.key != key {
1586            *tile = OpacityTile::new(key);
1587        }
1588        let (w, bit) = (idx / 64, 1u64 << (idx % 64));
1589        if tile.computed[w] & bit == 0 {
1590            let (blocked, lit) = sample_cell(
1591                self.grid,
1592                self.band,
1593                self.eye_lo,
1594                self.eye_hi,
1595                self.cfg.light_gate.as_ref(),
1596                cell,
1597            );
1598            tile.computed[w] |= bit;
1599            if blocked {
1600                tile.blocked[w] |= bit;
1601            }
1602            tile.lit[idx] = (lit * 255.0).round() as u8;
1603        }
1604        (tile.blocked[w] & bit != 0, f32::from(tile.lit[idx]) / 255.0)
1605    }
1606
1607    /// Classify an LOS-clear (or wall-face) cell at offset
1608    /// `(dx, dy)` from the origin and record its target intensity.
1609    fn visit(&mut self, cell: IVec2, dx: i32, dy: i32) {
1610        let d = f64::from(dx * dx + dy * dy).sqrt() as f32;
1611        let taper = self.cfg.edge_taper.max(1.0);
1612        let mut vis = 0.0f32;
1613        if d <= self.cfg.peripheral_range {
1614            vis = ((self.cfg.peripheral_range - d) / taper).clamp(0.0, 1.0);
1615        }
1616        if let Some(f) = self.facing {
1617            if d <= self.cfg.range && d > 0.0 {
1618                let dir = Vec2::new(dx as f32, dy as f32) / d;
1619                let ang = dir.dot(f).clamp(-1.0, 1.0).acos();
1620                if ang <= self.cfg.cone_half_angle {
1621                    let ang_t = ((self.cfg.cone_half_angle - ang) / self.cfg.cone_taper.max(1e-3))
1622                        .clamp(0.0, 1.0);
1623                    let rad_t = ((self.cfg.range - d) / taper).clamp(0.0, 1.0);
1624                    vis = vis.max(ang_t.min(rad_t));
1625                }
1626            }
1627        }
1628        if vis <= 0.0 {
1629            return;
1630        }
1631        let lit = self.sample(cell).1;
1632        vis *= lit;
1633        if vis <= 0.0 {
1634            return;
1635        }
1636        // Visual-pass round 7 (#3): `vis` (the cone/peripheral/light
1637        // taper) decides only WHETHER the cell is seen; a seen cell
1638        // records FULL intensity, not a taper-scaled one. Visible cells
1639        // are drawn unstyled, so the scaling was invisible live and only
1640        // shaped the MEMORY a cell became — a cone-edge / fast-swept cell
1641        // demoted at low intensity and left a faint gradient "развод"
1642        // where the cone edge had passed. Full intensity → spatially
1643        // uniform memory; only the temporal decay remains.
1644        let target = f32::from(INTENSITY_MAX);
1645        let e = self.out.entry((cell.x, cell.y)).or_insert(0.0);
1646        *e = e.max(target);
1647    }
1648
1649    /// One octant of recursive shadowcasting.
1650    fn cast(&mut self, row: i32, mut start: f64, end: f64, m: [i32; 4]) {
1651        if start < end {
1652            return;
1653        }
1654        let radius = self.radius();
1655        let r2 = i64::from(radius) * i64::from(radius);
1656        let mut new_start = start;
1657        let mut blocked = false;
1658        let mut j = row;
1659        while j <= radius && !blocked {
1660            let dy = -j;
1661            for dx in -j..=0 {
1662                let l_slope = (f64::from(dx) - 0.5) / (f64::from(dy) + 0.5);
1663                let r_slope = (f64::from(dx) + 0.5) / (f64::from(dy) - 0.5);
1664                if start < r_slope {
1665                    continue;
1666                }
1667                if end > l_slope {
1668                    break;
1669                }
1670                let sx = dx * m[0] + dy * m[1];
1671                let sy = dx * m[2] + dy * m[3];
1672                let cell = self.origin + IVec2::new(sx, sy);
1673                if i64::from(dx) * i64::from(dx) + i64::from(dy) * i64::from(dy) <= r2 {
1674                    self.visit(cell, sx, sy);
1675                }
1676                let blk = self.blocked_at(cell);
1677                if blocked {
1678                    if blk {
1679                        new_start = r_slope;
1680                    } else {
1681                        blocked = false;
1682                        start = new_start;
1683                    }
1684                } else if blk && j < radius {
1685                    blocked = true;
1686                    self.cast(j + 1, start, l_slope, m);
1687                    new_start = r_slope;
1688                }
1689            }
1690            j += 1;
1691        }
1692    }
1693}
1694
1695/// Column scan for one cell, returning `(blocked, lit 0..=1)`:
1696/// - **blocked** — any solid voxel in the eye band `[eye_lo, eye_hi]`
1697///   (LOS occlusion; the band rides the observer's eye, not the deck);
1698///   a ceiling / floor voxel outside the band never blocks.
1699/// - **lit** — with a gate, the gate factor of the **floor** surface:
1700///   the first solid at or below `eye_lo` scanning down to the deck
1701///   floor (a ceiling plate above the eye is NOT the surface — reading
1702///   its dark underside kept fully-lit rooms permanently unseen). An
1703///   emissive colour key ANYWHERE in the scanned span forces `1.0`
1704///   (ceiling lamps light their cell) without marking it blocked. No
1705///   gate ⇒ always `1.0`; no floor found under a gate ⇒ `0.0`.
1706///
1707/// Borrows the chunk once per z-layer (`chz`) rather than a per-voxel
1708/// grid probe — a first-fill of a 128² tile was ~340k `voxel_split` +
1709/// HashMap lookups.
1710fn sample_cell(
1711    grid: &Grid,
1712    band: DeckBand,
1713    eye_lo: i32,
1714    eye_hi: i32,
1715    gate: Option<&LightGate>,
1716    cell: IVec2,
1717) -> (bool, f32) {
1718    let cs_xy = CHUNK_SIZE_XY as i32;
1719    let cs_z = crate::CHUNK_SIZE_Z as i32;
1720    let chx = cell.x.div_euclid(cs_xy);
1721    let chy = cell.y.div_euclid(cs_xy);
1722    let ix = cell.x.rem_euclid(cs_xy) as u32;
1723    let iy = cell.y.rem_euclid(cs_xy) as u32;
1724
1725    let z_lo = band.z_top.min(eye_lo);
1726    let z_hi = band.z_bottom.max(eye_hi);
1727
1728    let mut blocked = false;
1729    let mut surface: Option<u8> = None;
1730    let mut lit_override = false;
1731
1732    let mut cur_chz = i32::MIN;
1733    let mut chunk: Option<&Vxl> = None;
1734    for z in z_lo..=z_hi {
1735        let chz = z.div_euclid(cs_z);
1736        if chz != cur_chz {
1737            cur_chz = chz;
1738            chunk = grid.chunk(IVec3::new(chx, chy, chz));
1739        }
1740        let Some(vxl) = chunk else {
1741            continue;
1742        };
1743        let iz = z.rem_euclid(cs_z) as u32;
1744        if !Grid::chunk_voxel_solid(vxl, UVec3::new(ix, iy, iz)) {
1745            continue;
1746        }
1747        if (eye_lo..=eye_hi).contains(&z) {
1748            blocked = true;
1749        }
1750        if let Some(g) = gate {
1751            let color = vxl.voxel_color(ix, iy, iz);
1752            if let Some(c) = color {
1753                if g.emissive_colors.contains(&c.rgb_part()) {
1754                    lit_override = true;
1755                }
1756            }
1757            // Floor surface = first solid at or below eye level; skip
1758            // the ceiling region above the eye.
1759            if surface.is_none() && z >= eye_lo {
1760                surface = Some(color.map_or(0x80, |c| (c.0 >> 24) as u8));
1761            }
1762        } else if blocked {
1763            // No gate: once the eye band is occluded there is nothing
1764            // more to learn from this column.
1765            return (true, 1.0);
1766        }
1767    }
1768
1769    let lit = match gate {
1770        None => 1.0,
1771        Some(_) if lit_override => 1.0,
1772        Some(g) => surface.map_or(0.0, |b| g.lit_factor(b)),
1773    };
1774    (blocked, lit)
1775}
1776
1777#[cfg(test)]
1778mod tests {
1779    use super::*;
1780    use crate::GridTransform;
1781    use roxlap_formats::color::VoxColor;
1782
1783    const FLOOR_Z: i32 = 100;
1784    /// Test eye height: `[EYE_Z-EYE_HALF, EYE_Z+EYE_HALF]` = [92, 96],
1785    /// the old fixed band's centre — 6 voxels above the floor.
1786    const EYE_Z: i32 = 94;
1787
1788    fn band() -> DeckBand {
1789        DeckBand {
1790            z_top: 80,
1791            z_bottom: FLOOR_Z,
1792        }
1793    }
1794
1795    /// Flat lit floor `[-64, 63]²` at `FLOOR_Z`, one deck.
1796    fn open_room() -> Grid {
1797        let mut g = Grid::new(GridTransform::identity());
1798        g.set_rect(
1799            IVec3::new(-64, -64, FLOOR_Z),
1800            IVec3::new(63, 63, FLOOR_Z),
1801            Some(VoxColor::rgb(120, 120, 120)),
1802        );
1803        g
1804    }
1805
1806    fn cfg() -> VisionConfig {
1807        let mut c = VisionConfig::for_decks(vec![band()]);
1808        c.range = 40.0;
1809        c.peripheral_range = 12.0;
1810        c
1811    }
1812
1813    fn observer(cell: IVec2, facing: Vec2) -> FowObserver {
1814        FowObserver {
1815            cell,
1816            facing,
1817            deck: 0,
1818            eye_z: EYE_Z,
1819        }
1820    }
1821
1822    /// A wall column blocking the eye band at `(x, y)`.
1823    fn wall(g: &mut Grid, x: i32, y: i32) {
1824        g.set_rect(
1825            IVec3::new(x, y, 88),
1826            IVec3::new(x, y, FLOOR_Z),
1827            Some(VoxColor::rgb(200, 60, 60)),
1828        );
1829    }
1830
1831    const SETTLE: f32 = 1000.0;
1832
1833    #[test]
1834    fn cone_and_peripheral_classification() {
1835        let g = open_room();
1836        let mut fow = FogOfWar::new(cfg());
1837        fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
1838
1839        // Ahead, inside the cone.
1840        assert_eq!(fow.state(0, IVec2::new(20, 0)).0, CellState::Visible);
1841        // Behind, inside the peripheral radius.
1842        assert_eq!(fow.state(0, IVec2::new(-6, 0)).0, CellState::Visible);
1843        // Behind, beyond the peripheral radius.
1844        assert_eq!(fow.state(0, IVec2::new(-25, 0)).0, CellState::Unseen);
1845        // Ahead but beyond range.
1846        assert_eq!(fow.state(0, IVec2::new(60, 0)).0, CellState::Unseen);
1847        // Full intensity well inside the cone.
1848        assert_eq!(fow.state(0, IVec2::new(20, 0)).1, INTENSITY_MAX);
1849    }
1850
1851    #[test]
1852    fn wall_blocks_line_of_sight() {
1853        let mut g = open_room();
1854        for y in -12..=12 {
1855            wall(&mut g, 15, y);
1856        }
1857        let mut fow = FogOfWar::new(cfg());
1858        fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
1859
1860        // The wall face itself is visible…
1861        assert_eq!(fow.state(0, IVec2::new(15, 0)).0, CellState::Visible);
1862        // …the room behind it is not.
1863        assert_eq!(fow.state(0, IVec2::new(25, 0)).0, CellState::Unseen);
1864        // Open cells before the wall are visible.
1865        assert_eq!(fow.state(0, IVec2::new(10, 0)).0, CellState::Visible);
1866    }
1867
1868    /// Review #2 — a fresh twin's first sync must repaint remembered
1869    /// rooms: `for_each_known_cell` reports Memory cells (so their
1870    /// geometry re-copies), while the steady-state `for_each_live_cell`
1871    /// does not (a demoted cell keeps the copy it got while Visible).
1872    #[test]
1873    fn known_cells_include_memory_live_cells_dont() {
1874        use std::collections::HashSet;
1875        let g = open_room();
1876        let mut fow = FogOfWar::new(cfg());
1877        // See the +X cells, then turn away so (20, 0) demotes to Memory.
1878        fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
1879        fow.update(&g, &observer(IVec2::new(0, 0), -Vec2::X), 0.01);
1880        assert_eq!(fow.state(0, IVec2::new(20, 0)).0, CellState::Memory);
1881
1882        let mut live = HashSet::new();
1883        fow.for_each_live_cell(|_d, c, _s| {
1884            live.insert((c.x, c.y));
1885        });
1886        assert!(!live.contains(&(20, 0)), "memory cell is not LIVE");
1887
1888        let mut known = HashSet::new();
1889        fow.for_each_known_cell(|_d, c, _s| {
1890            known.insert((c.x, c.y));
1891        });
1892        assert!(known.contains(&(20, 0)), "memory cell IS known");
1893    }
1894
1895    #[test]
1896    fn edit_invalidates_opacity_cache() {
1897        let mut g = open_room();
1898        let mut fow = FogOfWar::new(cfg());
1899        fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
1900        assert_eq!(fow.state(0, IVec2::new(25, 0)).0, CellState::Visible);
1901
1902        // Build the wall AFTER first sight; the edit bumps chunk
1903        // versions + the mutation counter, so the same observer pose
1904        // recomputes against fresh opacity.
1905        for y in -12..=12 {
1906            wall(&mut g, 15, y);
1907        }
1908        fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
1909        assert_eq!(fow.state(0, IVec2::new(25, 0)).0, CellState::Memory);
1910        assert_eq!(fow.state(0, IVec2::new(15, 0)).0, CellState::Visible);
1911    }
1912
1913    #[test]
1914    fn memory_decays_to_floor_and_reseeing_resets() {
1915        let g = open_room();
1916        let mut c = cfg();
1917        c.memory_decay = 8.0;
1918        let floor = c.memory_floor;
1919        let sustain = c.memory_intensity;
1920        let mut fow = FogOfWar::new(c);
1921        let cell = IVec2::new(20, 0);
1922        fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
1923        assert_eq!(fow.state(0, cell), (CellState::Visible, INTENSITY_MAX));
1924
1925        // Turn around: the cell leaves the cone and falls to Memory.
1926        fow.update(&g, &observer(IVec2::new(0, 0), -Vec2::X), 0.05);
1927        assert_eq!(fow.state(0, cell).0, CellState::Memory);
1928        let after_fall = fow.state(0, cell).1;
1929        assert!(after_fall < INTENSITY_MAX);
1930
1931        // Long fade-out settles at the sustain level, then decay
1932        // grinds it down to the floor and stops.
1933        fow.update(&g, &observer(IVec2::new(0, 0), -Vec2::X), 1.0);
1934        let at_sustain = fow.state(0, cell).1;
1935        assert!(at_sustain <= sustain);
1936        fow.update(&g, &observer(IVec2::new(0, 0), -Vec2::X), 30.0);
1937        assert_eq!(fow.state(0, cell).1, floor);
1938
1939        // Re-seeing restores full Visible.
1940        fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
1941        assert_eq!(fow.state(0, cell), (CellState::Visible, INTENSITY_MAX));
1942    }
1943
1944    #[test]
1945    fn visible_snaps_full_then_memory_fades_gradually() {
1946        // Visual-pass round 7 (#3): a seen cell snaps to FULL intensity
1947        // in ONE tick (no fade-in) so the memory it later becomes is
1948        // spatially uniform — a briefly-glimpsed cell can't leave a faint
1949        // gradient. The gradual fade is now the MEMORY fall-out only.
1950        let g = open_room();
1951        let mut fow = FogOfWar::new(cfg());
1952        let cell = IVec2::new(20, 0);
1953        // One tiny tick: already fully Visible (was: a partial fade-in).
1954        fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), 0.001);
1955        assert_eq!(fow.state(0, cell), (CellState::Visible, INTENSITY_MAX));
1956        // Turn away: Memory, and its intensity falls GRADUALLY (fade-out),
1957        // not instantly, so the memory eases out over time.
1958        fow.update(&g, &observer(IVec2::new(0, 0), -Vec2::X), 0.05);
1959        let (state, mid) = fow.state(0, cell);
1960        assert_eq!(state, CellState::Memory);
1961        assert!(mid > 0 && mid < INTENSITY_MAX, "mid fade-out, got {mid}");
1962    }
1963
1964    #[test]
1965    fn light_gate_caps_dark_cells() {
1966        // Lit floor up to x = 9, dark floor beyond (built disjoint —
1967        // inserting over an existing solid voxel keeps its colour, so
1968        // an overwrite would silently stay lit).
1969        let mut g = Grid::new(GridTransform::identity());
1970        g.set_rect(
1971            IVec3::new(-64, -64, FLOOR_Z),
1972            IVec3::new(9, 63, FLOOR_Z),
1973            Some(VoxColor::rgb(120, 120, 120)),
1974        );
1975        g.set_rect(
1976            IVec3::new(10, -64, FLOOR_Z),
1977            IVec3::new(63, 63, FLOOR_Z),
1978            Some(VoxColor::rgb(120, 120, 120).with_brightness(20)),
1979        );
1980        // An emissive strip voxel on the dark side.
1981        let emissive = VoxColor::rgb(80, 255, 160);
1982        g.set_voxel(
1983            IVec3::new(30, 5, FLOOR_Z - 1),
1984            Some(emissive.with_brightness(20)),
1985        );
1986
1987        let mut c = cfg();
1988        c.light_gate = Some(LightGate {
1989            lit_brightness: 100,
1990            softness: 0,
1991            emissive_colors: vec![emissive.rgb_part()],
1992        });
1993        let mut fow = FogOfWar::new(c);
1994        fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
1995
1996        // Lit floor (VoxColor::rgb default brightness 0x80 = 128).
1997        assert_eq!(fow.state(0, IVec2::new(5, 0)).0, CellState::Visible);
1998        // Dark floor in the cone stays unknown.
1999        assert_eq!(fow.state(0, IVec2::new(20, 0)).0, CellState::Unseen);
2000        // The emissive cell is visible in the dark.
2001        assert_eq!(fow.state(0, IVec2::new(30, 5)).0, CellState::Visible);
2002    }
2003
2004    #[test]
2005    fn deck_layers_are_independent() {
2006        let mut g = open_room();
2007        // A second, lower deck with its own floor.
2008        g.set_rect(
2009            IVec3::new(-64, -64, 130),
2010            IVec3::new(63, 63, 130),
2011            Some(VoxColor::rgb(90, 90, 140)),
2012        );
2013        let lower = DeckBand {
2014            z_top: 110,
2015            z_bottom: 130,
2016        };
2017        let mut c = cfg();
2018        c.decks = vec![band(), lower];
2019        let mut fow = FogOfWar::new(c);
2020
2021        fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
2022        assert_eq!(fow.state(0, IVec2::new(20, 0)).0, CellState::Visible);
2023        assert_eq!(fow.state(1, IVec2::new(20, 0)).0, CellState::Unseen);
2024
2025        // Descend to deck 1: deck 0 demotes to Memory, deck 1 lights up.
2026        // The eye rides down with the observer (124 ≈ 6 above the lower
2027        // floor 130), as it would on a real staircase.
2028        let mut obs = observer(IVec2::new(0, 0), Vec2::X);
2029        obs.deck = 1;
2030        obs.eye_z = 124;
2031        fow.update(&g, &obs, SETTLE);
2032        assert_eq!(fow.state(0, IVec2::new(20, 0)).0, CellState::Memory);
2033        assert_eq!(fow.state(1, IVec2::new(20, 0)).0, CellState::Visible);
2034    }
2035
2036    #[test]
2037    fn heard_blob_reveals_behind_wall_then_fades() {
2038        let mut g = open_room();
2039        for y in -12..=12 {
2040            wall(&mut g, 15, y);
2041        }
2042        let mut fow = FogOfWar::new(cfg());
2043        let src = IVec2::new(25, 0);
2044        fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
2045        assert_eq!(fow.state(0, src).0, CellState::Unseen);
2046
2047        // A noise behind the wall: revealed without LOS.
2048        fow.hear(0, src, 1.0);
2049        fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), 0.5);
2050        assert_eq!(fow.state(0, src).0, CellState::Heard);
2051        assert!(fow.state(0, IVec2::new(27, 2)).1 > 0);
2052
2053        // Past the TTL the blob expires and the pocket becomes Memory.
2054        fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), 2.0);
2055        assert_eq!(fow.state(0, src).0, CellState::Memory);
2056    }
2057
2058    #[test]
2059    fn mask_version_tracks_changes() {
2060        let g = open_room();
2061        let mut fow = FogOfWar::new(cfg());
2062        let v0 = fow.mask_version();
2063        fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
2064        let v1 = fow.mask_version();
2065        assert!(v1 > v0);
2066        // Same pose, settled fades: nothing changes.
2067        fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), 1.0);
2068        assert_eq!(fow.mask_version(), v1);
2069    }
2070
2071    #[test]
2072    fn live_cells_cover_visible_and_heard() {
2073        let mut g = open_room();
2074        for y in -12..=12 {
2075            wall(&mut g, 15, y);
2076        }
2077        let mut fow = FogOfWar::new(cfg());
2078        fow.hear(0, IVec2::new(25, 0), 0.5);
2079        fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), 0.5);
2080        let mut visible = 0usize;
2081        let mut heard = 0usize;
2082        fow.for_each_live_cell(|deck, _, state| {
2083            assert_eq!(deck, 0);
2084            match state {
2085                CellState::Visible => visible += 1,
2086                CellState::Heard => heard += 1,
2087                _ => panic!("live cells are Visible or Heard"),
2088            }
2089        });
2090        assert!(visible > 0 && heard > 0);
2091    }
2092
2093    // ---- regression tests for the FW.0 review round ----
2094
2095    /// Bug 1 — the light gate must sample the FLOOR under the eye, not
2096    /// a ceiling plate above it. A fully-lit floor room with a dark
2097    /// ceiling stayed permanently Unseen.
2098    #[test]
2099    fn light_gate_reads_floor_not_ceiling() {
2100        let mut g = open_room(); // lit floor at z=100 (brightness 0x80)
2101                                 // Dark ceiling plate above the eye band (z=85 < eye_top=92)
2102                                 // over the far half of the room.
2103        g.set_rect(
2104            IVec3::new(10, -32, 85),
2105            IVec3::new(40, 32, 85),
2106            Some(VoxColor::rgb(120, 120, 120).with_brightness(15)),
2107        );
2108        let mut c = cfg();
2109        c.light_gate = Some(LightGate {
2110            lit_brightness: 100,
2111            softness: 0,
2112            emissive_colors: Vec::new(),
2113        });
2114        let mut fow = FogOfWar::new(c);
2115        fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
2116        // Floor under the dark ceiling is lit → Visible (the ceiling is
2117        // not the surface the gate reads).
2118        assert_eq!(fow.state(0, IVec2::new(20, 0)).0, CellState::Visible);
2119    }
2120
2121    /// Bug 2 — a full `Grid::bake` rewrites brightness bytes; it must
2122    /// bump chunk versions so the FW opacity/light cache re-reads.
2123    #[test]
2124    fn full_bake_bumps_chunk_version() {
2125        let g = open_room();
2126        let (chunk_idx, _) = crate::voxel_split(IVec3::new(0, 0, FLOOR_Z));
2127        let v0 = g.chunk_version(chunk_idx);
2128        let mut g = g;
2129        g.bake(crate::BakeMode::Directional);
2130        assert!(
2131            g.chunk_version(chunk_idx) > v0,
2132            "full bake must bump the version (was {v0})"
2133        );
2134    }
2135
2136    /// Bug 3 — a chunk materialised at version 0 (generation / stream-in
2137    /// keeps `chunk_version == 0`) must still invalidate the opacity
2138    /// tile: the key folds in chunk presence, so absent→present flips it
2139    /// even with no version change.
2140    #[test]
2141    fn tile_key_changes_on_materialisation_at_version_zero() {
2142        let mut g = Grid::new(GridTransform::identity());
2143        let b = band();
2144        let (eye_lo, eye_hi) = (EYE_Z - EYE_HALF, EYE_Z + EYE_HALF);
2145        let k_absent = tile_key(&g, b, eye_lo, eye_hi, 0, 0, 0);
2146        // Materialise chunk (0,0,0) as empty air — mirrors a stream-in:
2147        // version stays 0, only presence changes.
2148        let _ = g.ensure_chunk(IVec3::new(0, 0, 0));
2149        assert_eq!(g.chunk_version(IVec3::new(0, 0, 0)), 0);
2150        let k_present = tile_key(&g, b, eye_lo, eye_hi, 0, 0, 0);
2151        assert_ne!(
2152            k_absent, k_present,
2153            "presence must change the tile key at version 0"
2154        );
2155    }
2156
2157    /// Bug 4 — an emissive voxel on the CEILING (above the eye band)
2158    /// lights its cell without blocking LOS. It used to be treated as a
2159    /// wall, carving an Unseen wedge behind each ceiling lamp.
2160    #[test]
2161    fn ceiling_emissive_lights_without_blocking() {
2162        let mut g = open_room();
2163        let emissive = VoxColor::rgb(80, 255, 160);
2164        // Lamp on the ceiling (z=85, above eye_top=92).
2165        g.set_voxel(IVec3::new(20, 0, 85), Some(emissive.with_brightness(30)));
2166        let mut c = cfg();
2167        c.light_gate = Some(LightGate {
2168            lit_brightness: 100,
2169            softness: 0,
2170            emissive_colors: vec![emissive.rgb_part()],
2171        });
2172        let mut fow = FogOfWar::new(c);
2173        fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
2174        // The lamp cell is lit and does NOT block: a cell behind it
2175        // stays visible.
2176        assert_eq!(fow.state(0, IVec2::new(20, 0)).0, CellState::Visible);
2177        assert_eq!(fow.state(0, IVec2::new(25, 0)).0, CellState::Visible);
2178    }
2179
2180    /// Bug 5 — turning toward a heard source must leave the cell
2181    /// Visible, not have `update_heard` immediately stomp it to Memory.
2182    #[test]
2183    fn facing_a_heard_source_stays_visible() {
2184        let g = open_room();
2185        let mut fow = FogOfWar::new(cfg());
2186        let src = IVec2::new(25, 0);
2187        // Heard while facing away (source behind, outside the cone).
2188        fow.hear(0, src, 1.0);
2189        fow.update(&g, &observer(IVec2::new(0, 0), -Vec2::X), 0.1);
2190        assert_eq!(fow.state(0, src).0, CellState::Heard);
2191        // Turn toward it (blob still alive): LOS claims it Visible and
2192        // the heard pass must not overwrite that.
2193        fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), 0.1);
2194        assert_eq!(fow.state(0, src).0, CellState::Visible);
2195    }
2196
2197    /// Bug 6 — a near-zero-angle facing must not alias the "no facing"
2198    /// sentinel: switching from it to zero facing has to recompute LOS.
2199    #[test]
2200    fn near_zero_facing_distinct_from_none() {
2201        let g = open_room();
2202        let mut fow = FogOfWar::new(cfg());
2203        let cell = IVec2::new(30, 0); // in cone (range 40), past peripheral (12)
2204                                      // Facing ≈ -0.003 rad: its old bucket was -1, colliding with the
2205                                      // sentinel.
2206        fow.update(
2207            &g,
2208            &observer(IVec2::new(0, 0), Vec2::new(1.0, -0.003)),
2209            SETTLE,
2210        );
2211        assert_eq!(fow.state(0, cell).0, CellState::Visible);
2212        // Drop to no facing: must recompute (key differs) and the cone
2213        // cell falls out of sight.
2214        fow.update(&g, &observer(IVec2::new(0, 0), Vec2::ZERO), SETTLE);
2215        assert_eq!(fow.state(0, cell).0, CellState::Memory);
2216    }
2217
2218    /// Perf 1 — a far-side edit outside the view radius must NOT force a
2219    /// LOS recompute (the key is local now, not the grid-wide mutation
2220    /// counter).
2221    #[test]
2222    fn far_edit_does_not_bump_mask_version() {
2223        let mut g = open_room();
2224        let mut fow = FogOfWar::new(cfg());
2225        fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
2226        let v = fow.mask_version();
2227        // Edit far outside the radius-40 view (a whole chunk away).
2228        g.set_voxel(IVec3::new(600, 600, FLOOR_Z), Some(VoxColor::rgb(1, 2, 3)));
2229        fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), 0.0);
2230        assert_eq!(fow.mask_version(), v, "far edit should not recompute LOS");
2231    }
2232
2233    // ---- FW.1: known-twin grid ----
2234
2235    use crate::Scene;
2236    use glam::DVec3;
2237
2238    /// Register a real room grid in a fresh scene; return (scene, id).
2239    fn scene_with_room() -> (Scene, GridId) {
2240        let mut scene = Scene::new();
2241        let id = scene.add_grid(GridTransform::identity());
2242        let g = scene.grid_mut(id).unwrap();
2243        g.set_rect(
2244            IVec3::new(-64, -64, FLOOR_Z),
2245            IVec3::new(63, 63, FLOOR_Z),
2246            Some(VoxColor::rgb(120, 120, 120)),
2247        );
2248        (scene, id)
2249    }
2250
2251    /// `attach` must flag the real grid render-excluded and the twin
2252    /// presentation-only, so render/query iterators split them.
2253    #[test]
2254    fn attach_splits_render_and_query_grids() {
2255        let (mut scene, real) = scene_with_room();
2256        let twin = FowTwin::attach(&mut scene, real);
2257        assert!(scene.grid(real).unwrap().render_excluded);
2258        assert!(scene.grid(twin.twin()).unwrap().presentation_only);
2259
2260        // Render sees the twin, not the real grid.
2261        let rendered: Vec<GridId> = scene.render_grids().map(|(id, _)| id).collect();
2262        assert!(rendered.contains(&twin.twin()));
2263        assert!(!rendered.contains(&real));
2264
2265        // Queries see the real grid, not the twin.
2266        let queried: Vec<GridId> = scene.query_grids().map(|(id, _)| id).collect();
2267        assert!(queried.contains(&real));
2268        assert!(!queried.contains(&twin.twin()));
2269    }
2270
2271    /// The real grid stays fully solid to raycasts even while excluded
2272    /// from rendering (sim truth is unchanged).
2273    #[test]
2274    fn excluded_real_grid_still_raycasts() {
2275        let (mut scene, real) = scene_with_room();
2276        let _twin = FowTwin::attach(&mut scene, real);
2277        let hit = scene.raycast(
2278            DVec3::new(0.5, 0.5, FLOOR_Z as f64 - 5.0),
2279            DVec3::new(0.0, 0.0, 1.0),
2280            32.0,
2281        );
2282        assert!(hit.is_some(), "real grid must still answer raycasts");
2283    }
2284
2285    fn fow_deck() -> DeckBand {
2286        band()
2287    }
2288
2289    /// Copy-on-first-seen: a chunk under a Visible cell is copied into
2290    /// the twin; a never-seen chunk is not.
2291    #[test]
2292    fn sync_copies_only_seen_chunks() {
2293        let (mut scene, real) = scene_with_room();
2294        let mut twin = FowTwin::attach(&mut scene, real);
2295        let mut fow = FogOfWar::new({
2296            let mut c = VisionConfig::for_decks(vec![fow_deck()]);
2297            c.range = 40.0;
2298            c.peripheral_range = 12.0;
2299            c
2300        });
2301        // Observer at origin (chunk 0,0,0) facing +X.
2302        let real_grid = scene.grid(real).unwrap();
2303        fow.update(real_grid, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
2304        assert!(twin.sync(&mut scene, &fow));
2305
2306        let twin_grid = scene.grid(twin.twin()).unwrap();
2307        // Seen chunk (0,0,0) copied → floor voxel present.
2308        assert!(twin_grid.voxel_solid(IVec3::new(20, 0, FLOOR_Z)));
2309        // A far chunk never in view was never copied.
2310        assert!(twin_grid.chunk(IVec3::new(4, 4, 0)).is_none());
2311    }
2312
2313    /// Edit behind a wall (unseen chunk) must NOT reach the twin until
2314    /// the observer walks over it.
2315    #[test]
2316    fn edit_behind_wall_invisible_until_seen() {
2317        let (mut scene, real) = scene_with_room();
2318        // Extend the floor into chunk (1,0,0) so there's geometry to see.
2319        scene.grid_mut(real).unwrap().set_rect(
2320            IVec3::new(128, -8, FLOOR_Z),
2321            IVec3::new(200, 8, FLOOR_Z),
2322            Some(VoxColor::rgb(120, 120, 120)),
2323        );
2324        let mut twin = FowTwin::attach(&mut scene, real);
2325        let mut fow = FogOfWar::new({
2326            let mut c = VisionConfig::for_decks(vec![fow_deck()]);
2327            c.range = 40.0;
2328            c
2329        });
2330        // See only chunk 0.
2331        fow.update(
2332            scene.grid(real).unwrap(),
2333            &observer(IVec2::new(0, 0), Vec2::X),
2334            SETTLE,
2335        );
2336        assert!(twin.sync(&mut scene, &fow));
2337        assert!(scene
2338            .grid(twin.twin())
2339            .unwrap()
2340            .chunk(IVec3::new(1, 0, 0))
2341            .is_none());
2342
2343        // Edit the far (unseen) chunk. Still not in view → twin unchanged.
2344        scene.grid_mut(real).unwrap().set_voxel(
2345            IVec3::new(150, 0, FLOOR_Z - 1),
2346            Some(VoxColor::rgb(255, 0, 0)),
2347        );
2348        fow.update(
2349            scene.grid(real).unwrap(),
2350            &observer(IVec2::new(0, 0), Vec2::X),
2351            SETTLE,
2352        );
2353        assert!(twin.sync(&mut scene, &fow));
2354        assert!(
2355            scene
2356                .grid(twin.twin())
2357                .unwrap()
2358                .chunk(IVec3::new(1, 0, 0))
2359                .is_none(),
2360            "unseen edit must not reach the twin"
2361        );
2362
2363        // Walk over so the far chunk is in view → now it copies.
2364        fow.update(
2365            scene.grid(real).unwrap(),
2366            &observer(IVec2::new(150, 0), Vec2::X),
2367            SETTLE,
2368        );
2369        assert!(twin.sync(&mut scene, &fow));
2370        let tg = scene.grid(twin.twin()).unwrap();
2371        assert!(
2372            tg.voxel_solid(IVec3::new(150, 0, FLOOR_Z - 1)),
2373            "walked-over edit now seen"
2374        );
2375    }
2376
2377    /// Re-baking / editing a chunk the observer no longer sees must stay
2378    /// frozen in the twin (memory holds the last-seen look).
2379    #[test]
2380    fn memory_chunk_frozen_after_observer_leaves() {
2381        let (mut scene, real) = scene_with_room();
2382        let mut twin = FowTwin::attach(&mut scene, real);
2383        let mut fow = FogOfWar::new({
2384            let mut c = VisionConfig::for_decks(vec![fow_deck()]);
2385            c.range = 40.0;
2386            c.peripheral_range = 4.0;
2387            c
2388        });
2389        let cell = IVec3::new(20, 0, FLOOR_Z);
2390        // See it, copy it.
2391        fow.update(
2392            scene.grid(real).unwrap(),
2393            &observer(IVec2::new(0, 0), Vec2::X),
2394            SETTLE,
2395        );
2396        assert!(twin.sync(&mut scene, &fow));
2397        let seen_color = scene.grid(twin.twin()).unwrap().voxel_color(cell);
2398        assert!(seen_color.is_some());
2399
2400        // Turn away (cell → Memory), then repaint the real voxel.
2401        fow.update(
2402            scene.grid(real).unwrap(),
2403            &observer(IVec2::new(0, 0), -Vec2::X),
2404            0.05,
2405        );
2406        scene
2407            .grid_mut(real)
2408            .unwrap()
2409            .set_voxel(cell, Some(VoxColor::rgb(9, 9, 9)));
2410        fow.update(
2411            scene.grid(real).unwrap(),
2412            &observer(IVec2::new(0, 0), -Vec2::X),
2413            0.05,
2414        );
2415        assert!(twin.sync(&mut scene, &fow));
2416
2417        // Twin still holds the ORIGINAL colour — the edit is unseen.
2418        assert_eq!(
2419            scene.grid(twin.twin()).unwrap().voxel_color(cell),
2420            seen_color,
2421            "memory geometry must stay frozen"
2422        );
2423    }
2424
2425    /// Documents the accepted v1 leak (entry-doc decision 2): an edit in
2426    /// the UNSEEN part of a partially-visible chunk reaches the twin,
2427    /// because sync is chunk-granular.
2428    #[test]
2429    fn chunk_granular_sync_leaks_within_a_seen_chunk() {
2430        let (mut scene, real) = scene_with_room();
2431        let mut twin = FowTwin::attach(&mut scene, real);
2432        let mut fow = FogOfWar::new({
2433            let mut c = VisionConfig::for_decks(vec![fow_deck()]);
2434            c.range = 20.0; // sees only part of chunk (0,0,0)
2435            c.peripheral_range = 4.0;
2436            c
2437        });
2438        fow.update(
2439            scene.grid(real).unwrap(),
2440            &observer(IVec2::new(0, 0), Vec2::X),
2441            SETTLE,
2442        );
2443        assert!(twin.sync(&mut scene, &fow));
2444
2445        // Edit a cell in the SAME chunk but outside the 20-cell view.
2446        let unseen = IVec3::new(60, 60, FLOOR_Z - 1);
2447        assert_eq!(fow.state(0, IVec2::new(60, 60)).0, CellState::Unseen);
2448        scene
2449            .grid_mut(real)
2450            .unwrap()
2451            .set_voxel(unseen, Some(VoxColor::rgb(255, 0, 0)));
2452        // The chunk is still under visible cells → whole-chunk re-copy.
2453        fow.update(
2454            scene.grid(real).unwrap(),
2455            &observer(IVec2::new(0, 0), Vec2::X),
2456            SETTLE,
2457        );
2458        assert!(twin.sync(&mut scene, &fow));
2459        assert!(
2460            scene.grid(twin.twin()).unwrap().voxel_solid(unseen),
2461            "v1 chunk-granular sync copies the whole chunk (accepted leak)"
2462        );
2463    }
2464
2465    /// `detach` restores the real grid and removes the twin.
2466    #[test]
2467    fn detach_restores_real_and_removes_twin() {
2468        let (mut scene, real) = scene_with_room();
2469        let twin = FowTwin::attach(&mut scene, real);
2470        let twin_id = twin.twin();
2471        twin.detach(&mut scene);
2472        assert!(!scene.grid(real).unwrap().render_excluded);
2473        assert!(scene.grid(twin_id).is_none());
2474    }
2475
2476    /// The twin is derived state — excluded from snapshots (no wire
2477    /// change); the real grid persists.
2478    #[test]
2479    fn twin_excluded_from_snapshot() {
2480        let (mut scene, real) = scene_with_room();
2481        let mut twin = FowTwin::attach(&mut scene, real);
2482        let mut fow = FogOfWar::new({
2483            let mut c = VisionConfig::for_decks(vec![fow_deck()]);
2484            c.range = 40.0;
2485            c
2486        });
2487        fow.update(
2488            scene.grid(real).unwrap(),
2489            &observer(IVec2::new(0, 0), Vec2::X),
2490            SETTLE,
2491        );
2492        assert!(twin.sync(&mut scene, &fow));
2493        let snap = scene.to_snapshot();
2494        // Only the real grid is in the snapshot.
2495        assert_eq!(snap.grids.len(), 1);
2496        assert_eq!(snap.grids[0].0, real);
2497    }
2498
2499    // ---- FW.1 review round ----
2500
2501    fn seeing_fow() -> FogOfWar {
2502        let mut c = VisionConfig::for_decks(vec![fow_deck()]);
2503        c.range = 40.0;
2504        c.peripheral_range = 12.0;
2505        FogOfWar::new(c)
2506    }
2507
2508    /// Bug #1/#2 — `attach` sizes the twin's GPU residency hint to the
2509    /// REAL grid's full chunk bbox (so the pool covers the whole ship,
2510    /// and the twin registers even while empty).
2511    #[test]
2512    fn attach_sets_gpu_residency_hint_to_real_bbox() {
2513        let (mut scene, real) = scene_with_room();
2514        // Add a chunk on a higher deck so the bbox spans chz 0..1.
2515        scene
2516            .grid_mut(real)
2517            .unwrap()
2518            .set_voxel(IVec3::new(10, 10, 260), Some(VoxColor::rgb(1, 2, 3)));
2519        let twin = FowTwin::attach(&mut scene, real);
2520        let hint = scene.grid(twin.twin()).unwrap().gpu_residency_hint;
2521        // The room `[-64, 63]²` spans chunks (-1,-1)..(0,0); the extra
2522        // voxel at z=260 adds chunk (0,0,1). Bbox origin (-1,-1,0),
2523        // dims (2,2,2).
2524        assert_eq!(hint, Some(([-1, -1, 0], [2, 2, 2])));
2525    }
2526
2527    /// Bug #3/#7 — evicting a real chunk under a live cell must NOT drop
2528    /// it from the twin (memory holds the last-seen copy).
2529    #[test]
2530    fn real_eviction_keeps_twin_memory() {
2531        let (mut scene, real) = scene_with_room();
2532        let mut twin = FowTwin::attach(&mut scene, real);
2533        let mut fow = seeing_fow();
2534        fow.update(
2535            scene.grid(real).unwrap(),
2536            &observer(IVec2::new(0, 0), Vec2::X),
2537            SETTLE,
2538        );
2539        assert!(twin.sync(&mut scene, &fow));
2540        assert!(scene
2541            .grid(twin.twin())
2542            .unwrap()
2543            .voxel_solid(IVec3::new(20, 0, FLOOR_Z)));
2544
2545        // Evict the real chunk (streaming does exactly this: remove +
2546        // bump the mutation counter) while it's still in view.
2547        {
2548            let g = scene.grid_mut(real).unwrap();
2549            g.chunks.remove(&IVec3::new(0, 0, 0));
2550            g.note_chunk_set_changed();
2551        }
2552        assert!(twin.sync(&mut scene, &fow));
2553        assert!(
2554            scene
2555                .grid(twin.twin())
2556                .unwrap()
2557                .voxel_solid(IVec3::new(20, 0, FLOOR_Z)),
2558            "twin must keep the last-seen copy after real eviction"
2559        );
2560    }
2561
2562    /// Bug #5 — a lost twin (post snapshot-load / rollback) makes sync
2563    /// return `false` so the host knows to re-arm, instead of silently
2564    /// no-op'ing.
2565    #[test]
2566    fn sync_signals_lost_twin() {
2567        let (mut scene, real) = scene_with_room();
2568        let mut twin = FowTwin::attach(&mut scene, real);
2569        let fow = seeing_fow();
2570        assert!(twin.sync(&mut scene, &fow));
2571        // Drop the twin grid (what to_snapshot + load does).
2572        scene.remove_grid(twin.twin());
2573        assert!(!twin.sync(&mut scene, &fow), "lost twin must signal re-arm");
2574    }
2575
2576    /// Perf P2 — a quiet frame (no mask or real-grid change) does no
2577    /// work: the twin is not re-bumped.
2578    #[test]
2579    fn quiet_frame_skips_resync() {
2580        let (mut scene, real) = scene_with_room();
2581        let mut twin = FowTwin::attach(&mut scene, real);
2582        let mut fow = seeing_fow();
2583        fow.update(
2584            scene.grid(real).unwrap(),
2585            &observer(IVec2::new(0, 0), Vec2::X),
2586            SETTLE,
2587        );
2588        assert!(twin.sync(&mut scene, &fow));
2589        let after_first = scene.grid(twin.twin()).unwrap().mutation_counter();
2590        // Same settled pose → mask_version stable, no real edit.
2591        fow.update(
2592            scene.grid(real).unwrap(),
2593            &observer(IVec2::new(0, 0), Vec2::X),
2594            1.0,
2595        );
2596        assert!(twin.sync(&mut scene, &fow));
2597        assert_eq!(
2598            scene.grid(twin.twin()).unwrap().mutation_counter(),
2599            after_first,
2600            "quiet frame must not re-bump the twin"
2601        );
2602    }
2603
2604    /// Perf P1 — re-syncing an edited in-view chunk bumps the twin with
2605    /// the edit's BBOX (incremental GPU upload), not a whole-chunk
2606    /// `Full`.
2607    #[test]
2608    fn resync_bumps_twin_with_bbox_not_full() {
2609        let (mut scene, real) = scene_with_room();
2610        let mut twin = FowTwin::attach(&mut scene, real);
2611        let mut fow = seeing_fow();
2612        fow.update(
2613            scene.grid(real).unwrap(),
2614            &observer(IVec2::new(0, 0), Vec2::X),
2615            SETTLE,
2616        );
2617        assert!(twin.sync(&mut scene, &fow));
2618        // Clear the first-seen (Full) extent.
2619        let _ = scene
2620            .grid_mut(twin.twin())
2621            .unwrap()
2622            .take_chunk_dirty(IVec3::new(0, 0, 0));
2623
2624        // A single-voxel edit in the seen chunk.
2625        scene.grid_mut(real).unwrap().set_voxel(
2626            IVec3::new(15, 3, FLOOR_Z - 1),
2627            Some(VoxColor::rgb(255, 0, 0)),
2628        );
2629        fow.update(
2630            scene.grid(real).unwrap(),
2631            &observer(IVec2::new(0, 0), Vec2::X),
2632            SETTLE,
2633        );
2634        assert!(twin.sync(&mut scene, &fow));
2635        let extent = scene
2636            .grid_mut(twin.twin())
2637            .unwrap()
2638            .take_chunk_dirty(IVec3::new(0, 0, 0));
2639        assert!(
2640            matches!(extent, Some(crate::DirtyExtent::Bbox(_, _))),
2641            "re-sync should bump a bbox extent, got {extent:?}"
2642        );
2643    }
2644
2645    /// P3 — the exclusion predicates are single-sourced on `Grid`.
2646    #[test]
2647    fn exclusion_predicates() {
2648        let (mut scene, real) = scene_with_room();
2649        let twin = FowTwin::attach(&mut scene, real);
2650        assert!(!scene.grid(real).unwrap().renderable());
2651        assert!(scene.grid(real).unwrap().queryable());
2652        assert!(scene.grid(twin.twin()).unwrap().renderable());
2653        assert!(!scene.grid(twin.twin()).unwrap().queryable());
2654    }
2655
2656    // ---- FW.2: render-time styling verdicts ----
2657
2658    #[test]
2659    fn fow_render_verdict_maps_states() {
2660        use roxlap_core::dda::{FowStyler, FowVerdict};
2661        let g = open_room();
2662        let mut fow = seeing_fow();
2663        fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
2664        let styler = FowRender::new(&fow);
2665        let zc = FLOOR_Z - 1; // inside the deck band, above the floor
2666
2667        // Seen ahead → Show, dynamic on, full (t == 1 at cone centre).
2668        match styler.verdict(20, 0, zc) {
2669            FowVerdict::Show {
2670                dynamic,
2671                dim,
2672                desaturate,
2673            } => {
2674                assert!(dynamic);
2675                assert!((dim - 1.0).abs() < 1e-6, "cone-centre dim {dim}");
2676                assert!(desaturate.abs() < 1e-6);
2677            }
2678            FowVerdict::Hide => panic!("expected Show at the cone centre, got Hide"),
2679        }
2680        // Never seen behind → Hide.
2681        assert_eq!(styler.verdict(-30, 0, zc), FowVerdict::Hide);
2682    }
2683
2684    #[test]
2685    fn fow_render_memory_is_baked_and_dim() {
2686        use roxlap_core::dda::{FowStyler, FowVerdict};
2687        let g = open_room();
2688        let mut fow = seeing_fow();
2689        let zc = FLOOR_Z - 1;
2690        // See cell (20,0), then turn away so it becomes Memory.
2691        fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
2692        fow.update(&g, &observer(IVec2::new(0, 0), -Vec2::X), 0.5);
2693        let styler = FowRender::new(&fow);
2694        match styler.verdict(20, 0, zc) {
2695            FowVerdict::Show {
2696                dynamic,
2697                dim,
2698                desaturate,
2699            } => {
2700                assert!(!dynamic, "memory must skip the dynamic rig");
2701                assert!(dim < 1.0, "memory must be dimmed, got {dim}");
2702                assert!(desaturate > 0.0, "memory desaturated");
2703            }
2704            FowVerdict::Hide => panic!("expected Show(memory), got Hide"),
2705        }
2706    }
2707
2708    /// FW.4 — `hides_sprite` shows sprites over Visible cells, hides over
2709    /// Memory/Unseen, never touches off-footprint points (open space,
2710    /// review #1), and uses the observer deck for off-deck z (review #6).
2711    #[test]
2712    fn hides_sprite_by_cell_state() {
2713        use glam::DVec3;
2714        let g = open_room();
2715        let mut fow = seeing_fow();
2716        let t = GridTransform::identity();
2717        let fp = (IVec2::new(-128, -128), IVec2::new(128, 128));
2718        fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
2719        let at = |x: f64, y: f64| DVec3::new(x, y, (FLOOR_Z - 1) as f64 + 0.5);
2720        // Seen cell ahead → shown.
2721        assert!(!fow.hides_sprite(&t, fp, at(20.5, 0.5)));
2722        // Never-seen cell behind (over the footprint) → hidden.
2723        assert!(fow.hides_sprite(&t, fp, at(-30.5, 0.5)));
2724        // OUTSIDE the footprint (open space) → NEVER hidden (review #1).
2725        assert!(!fow.hides_sprite(&t, fp, at(500.5, 0.5)));
2726        // Off-deck z over a Visible XY → observer-deck fallback → shown
2727        // (review #6, no pop-out mid-transit).
2728        assert!(!fow.hides_sprite(&t, fp, DVec3::new(20.5, 0.5, 300.5)));
2729
2730        // Turn away: the seen cell becomes Memory → now hidden.
2731        fow.update(&g, &observer(IVec2::new(0, 0), -Vec2::X), 0.5);
2732        assert_eq!(fow.state(0, IVec2::new(20, 0)).0, CellState::Memory);
2733        assert!(
2734            fow.hides_sprite(&t, fp, at(20.5, 0.5)),
2735            "memory hides sprites"
2736        );
2737    }
2738
2739    /// FW.4 perf #1 — `sprite_epoch` bumps when the Visible set changes
2740    /// (a turn) but NOT on an intensity fade (a static settle), so the
2741    /// GPU sprite cull isn't re-run every fade frame.
2742    #[test]
2743    fn sprite_epoch_tracks_visible_set_only() {
2744        let g = open_room();
2745        let mut fow = seeing_fow();
2746        // First sight populates the visible set → epoch moves.
2747        let e0 = fow.sprite_epoch();
2748        fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), 0.01);
2749        let e1 = fow.sprite_epoch();
2750        assert!(e1 > e0, "seeing new cells bumps sprite_epoch");
2751        // Same pose, another tick (intensities still fading) → the
2752        // Visible set is unchanged, so the epoch holds even though the
2753        // mask version keeps moving.
2754        let mv = fow.mask_version();
2755        fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), 0.01);
2756        assert_eq!(fow.sprite_epoch(), e1, "a fade must not bump sprite_epoch");
2757        assert!(
2758            fow.mask_version() >= mv,
2759            "mask_version still tracks the fade"
2760        );
2761        // Turning brings new cells into view → epoch moves again.
2762        fow.update(&g, &observer(IVec2::new(0, 0), Vec2::Y), SETTLE);
2763        assert!(fow.sprite_epoch() > e1, "a turn bumps sprite_epoch");
2764    }
2765
2766    /// FW.4 — `hear_world` stamps a heard blob for an in-footprint
2767    /// source, and NOTHING for one outside the footprint (review #2).
2768    #[test]
2769    fn hear_world_maps_and_stamps() {
2770        use glam::DVec3;
2771        let g = open_room();
2772        let mut fow = seeing_fow();
2773        let t = GridTransform::identity();
2774        let fp = (IVec2::new(-128, -128), IVec2::new(128, 128));
2775        // A noise at grid-local voxel (20, 0, 99) — in band()'s deck.
2776        assert!(fow.hear_world(&t, fp, DVec3::new(20.5, 0.5, 99.5), 1.0));
2777        // Observer facing away → (20,0) not Visible; the heard blob wins.
2778        fow.update(&g, &observer(IVec2::new(0, 0), -Vec2::X), 0.1);
2779        assert_eq!(fow.state(0, IVec2::new(20, 0)).0, CellState::Heard);
2780        // A source OUTSIDE the footprint (a passing ship) stamps nothing.
2781        assert!(!fow.hear_world(&t, fp, DVec3::new(500.5, 500.5, 99.5), 1.0));
2782    }
2783
2784    /// FW.3 — `gpu_mask` flattens the sparse per-deck tiles into a dense
2785    /// deck-major, row-major buffer over the requested rectangle; a seen
2786    /// cell lands at the right offset, the rest stay Unseen (0).
2787    #[test]
2788    fn gpu_mask_flattens_seen_cells() {
2789        let g = open_room();
2790        let mut fow = seeing_fow();
2791        fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
2792        // A window covering cells [0,32)² on the single deck.
2793        let mask = fow.gpu_mask(IVec2::ZERO, 32, 32);
2794        assert_eq!(mask.decks.len(), 1);
2795        assert_eq!(mask.decks[0], [80, FLOOR_Z]); // band()'s z_top/z_bottom
2796        assert_eq!(mask.cells.len(), 32 * 32);
2797        // Cell (20,0) was seen (Visible) → state bits 6-7 == 2.
2798        // Index = deck 0 base + y*width + x = 0 + 0*32 + 20.
2799        assert_eq!(mask.cells[20] >> 6, 2, "seen cell is Visible");
2800        // Cell (5, 20) far off the +X cone → Unseen (0).
2801        assert_eq!(mask.cells[20 * 32 + 5], 0, "unseen cell is 0");
2802    }
2803
2804    /// Visual-pass round 6 (#1) — the verdict classifies by the active
2805    /// deck's Z WINDOW: its ceiling (`z_top`) down through the stair/hole
2806    /// shaft (to the next deck's floor, or +∞ for the bottom deck). A
2807    /// stair descending BELOW the band still reads on the deck you stand
2808    /// on; but a voxel ABOVE the ceiling falls back to strict
2809    /// `deck_for_z` (Hidden here, a one-deck config) so the column can't
2810    /// leak. Untracked cells are still gated by the per-(x,y) state.
2811    #[test]
2812    fn fow_render_classifies_by_own_deck() {
2813        use roxlap_core::dda::{FowStyler, FowVerdict};
2814        let g = open_room();
2815        let mut fow = seeing_fow();
2816        fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
2817        let styler = FowRender::new(&fow);
2818        // (20, 0) is a seen cell on the single deck (band 80..=100): its
2819        // own-band z shows LIVE.
2820        assert!(matches!(
2821            styler.verdict(20, 0, 90),
2822            FowVerdict::Show { dynamic: true, .. }
2823        ));
2824        // z BELOW the deck floor (100) is a sub-floor shaft → occluded
2825        // opaque-dark (dim 0), NOT shown or made a sky-hole.
2826        assert_eq!(
2827            styler.verdict(20, 0, 200),
2828            FowVerdict::Show {
2829                dynamic: false,
2830                dim: 0.0,
2831                desaturate: 0.0
2832            }
2833        );
2834        // z ABOVE the deck ceiling (< z_top 80) is transparent (Hide).
2835        assert_eq!(styler.verdict(20, 0, 0), FowVerdict::Hide);
2836        // An unseen cell on the active deck is transparent, not occluded.
2837        assert_eq!(styler.verdict(5, 20, 90), FowVerdict::Hide);
2838    }
2839
2840    /// Visual-pass round 9 (#1) — a deck BELOW the observer occludes
2841    /// opaque-dark when unseen (you don't see through your floor); a deck
2842    /// ABOVE stays transparent (so the deck you're under — the swim —
2843    /// shows through).
2844    #[test]
2845    fn fow_render_below_occludes_above_transparent() {
2846        use roxlap_core::dda::{FowStyler, FowVerdict};
2847        let cfg = VisionConfig::for_decks(vec![
2848            DeckBand {
2849                z_top: 0,
2850                z_bottom: 20,
2851            },
2852            DeckBand {
2853                z_top: 30,
2854                z_bottom: 50,
2855            },
2856        ]);
2857        let mut fow = FogOfWar::new(cfg);
2858        // Observer on the TOP deck (0, the default), nothing explored.
2859        // A deck-1 voxel (z=40, BELOW) that's unseen occludes dark…
2860        assert_eq!(
2861            FowRender::new(&fow).verdict(0, 0, 40),
2862            FowVerdict::Show {
2863                dynamic: false,
2864                dim: 0.0,
2865                desaturate: 0.0
2866            }
2867        );
2868        // …while an unseen voxel on the active deck stays transparent.
2869        assert_eq!(FowRender::new(&fow).verdict(0, 0, 10), FowVerdict::Hide);
2870
2871        // Descend to deck 1: a deck-0 voxel is now ABOVE → transparent,
2872        // so a deck overhead never walls the observer in.
2873        let g = Grid::new(GridTransform::identity());
2874        let mut obs = observer(IVec2::new(0, 0), Vec2::X);
2875        obs.deck = 1;
2876        obs.eye_z = 40;
2877        fow.update(&g, &obs, SETTLE);
2878        assert_eq!(FowRender::new(&fow).verdict(9, 9, 10), FowVerdict::Hide);
2879    }
2880
2881    /// Review #5 — the Visible→Memory seam is continuous: a cell at full
2882    /// brightness that just left the cone (state flips to Memory while
2883    /// intensity is still high) must NOT jump straight to `memory_dim` —
2884    /// its dim stays near 1.0 and only decays as intensity fades.
2885    #[test]
2886    fn fow_render_memory_seam_no_pop() {
2887        use roxlap_core::dda::{FowStyler, FowVerdict};
2888        let g = open_room();
2889        let mut fow = seeing_fow();
2890        let zc = FLOOR_Z - 1;
2891        let cell = (20, 0);
2892        // Fully seen (cone centre, intensity == max).
2893        fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
2894        let dim_visible = match FowRender::new(&fow).verdict(cell.0, cell.1, zc) {
2895            FowVerdict::Show { dim, .. } => dim,
2896            FowVerdict::Hide => panic!("seen cell hidden"),
2897        };
2898        assert!((dim_visible - 1.0).abs() < 1e-6);
2899
2900        // Turn away ONE tiny frame: the cell flips to Memory, intensity
2901        // still high → dim must be ~continuous with the Visible value,
2902        // not collapsed to memory_dim.
2903        fow.update(&g, &observer(IVec2::new(0, 0), -Vec2::X), 0.01);
2904        assert_eq!(
2905            fow.state(0, IVec2::new(cell.0, cell.1)).0,
2906            CellState::Memory
2907        );
2908        let dim_memory = match FowRender::new(&fow).verdict(cell.0, cell.1, zc) {
2909            FowVerdict::Show { dim, dynamic, .. } => {
2910                assert!(!dynamic, "memory skips the rig");
2911                dim
2912            }
2913            FowVerdict::Hide => panic!("just-seen memory hidden"),
2914        };
2915        let memory_dim = fow.config().memory_dim;
2916        assert!(
2917            dim_memory > 0.5 * (1.0 + memory_dim),
2918            "seam pop: dim dropped to {dim_memory} (memory_dim {memory_dim})"
2919        );
2920    }
2921
2922    /// FW.2 review #8 — the composed `fow = Some` wiring must actually
2923    /// style the twin: an all-Unseen fog HIDES the rendered twin (sky),
2924    /// a fog that saw the room SHOWS it. (The old test compared two
2925    /// identical `fow = None` renders and could never fail.)
2926    #[test]
2927    fn composed_render_fow_some_hides_unseen_shows_seen() {
2928        use crate::render::{render_scene_composed_frame, ComposedFrameParams, SceneRenderScratch};
2929        use roxlap_core::{Camera, OpticastSettings};
2930
2931        // Real room + twin; sync so the twin actually holds the floor.
2932        let (mut scene, real) = scene_with_room();
2933        let mut twin = FowTwin::attach(&mut scene, real);
2934        let mut seen = seeing_fow();
2935        seen.update(
2936            scene.grid(real).unwrap(),
2937            &observer(IVec2::new(0, 0), Vec2::X),
2938            SETTLE,
2939        );
2940        assert!(twin.sync(&mut scene, &seen));
2941        let twin_id = twin.twin();
2942
2943        // Top-down camera over the seen floor cells (~x in 5..30).
2944        let camera = Camera {
2945            pos: [18.0, 0.0, 60.0],
2946            right: [1.0, 0.0, 0.0],
2947            down: [0.0, 1.0, 0.0],
2948            forward: [0.0, 0.0, 1.0],
2949        };
2950        let (w, h) = (48u32, 48u32);
2951        let settings = OpticastSettings::for_oracle_framebuffer(w, h);
2952        let render = |fow: Option<(GridId, &FogOfWar)>, scene: &mut Scene| {
2953            let mut fb = vec![0u32; (w * h) as usize];
2954            let mut zb = vec![f32::INFINITY; (w * h) as usize];
2955            let mut scratch = SceneRenderScratch::default();
2956            let mut params = ComposedFrameParams::new(&camera, &settings);
2957            params.fow = fow;
2958            let _ = render_scene_composed_frame(
2959                &mut fb,
2960                &mut zb,
2961                w as usize,
2962                w,
2963                h,
2964                scene,
2965                &params,
2966                &mut scratch,
2967            );
2968            fb
2969        };
2970        let non_sky = |fb: &[u32]| fb.iter().filter(|&&p| p != 0).count();
2971
2972        // No fog → the twin's floor draws.
2973        let base = render(None, &mut scene);
2974        assert!(non_sky(&base) > 0, "the twin floor must render without fog");
2975
2976        // Fog that saw this area → still drawn (Visible cells).
2977        let shown = render(Some((twin_id, &seen)), &mut scene);
2978        assert!(non_sky(&shown) > 0, "seen cells must stay visible");
2979
2980        // A fresh, never-updated fog → every cell Unseen → Hide → sky.
2981        let blank = FogOfWar::new(seeing_fow().config().clone());
2982        let hidden = render(Some((twin_id, &blank)), &mut scene);
2983        assert_eq!(non_sky(&hidden), 0, "unseen cells must be hidden (sky)");
2984    }
2985}