Something went wrong. Try again.
HEXWORK - A Lantern in the Deep: 3D ASCII terminal roguelike RPG in Rust.
Entirely designed and built by Muse Spark 1.3 to evaluate if I like the model.
Something went wrong. Try again.
7.7 kB · 207 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208//! Triple-buffered sim snapshot: the render thread's read-only feed//! (docs/04 §4.5, docs/05 §5.2 step 1).//!//! The render thread never touches live sim state (docs/04 §4.10 rule 3).//! Each tick the sim publishes a [`Snapshot`] — visible light plane +//! actor list, ~200 KB at the 120×36 reference size (120×36 light bytes +//! actor records) — and the renderer reads the latest published frame.//!//! Contention semantics (docs/05 §5.2 step 1): [`TripleBuffer::read`] takes//! `&self` and never blocks. On contention the render side re-uses the last//! published frame — tearing beats stalling. The sim side//! ([`TripleBuffer::write`] + [`publish`](TripleBuffer::publish)) never//! waits on the renderer either. This type is the single-threaded storage +//! rotation policy; when the threaded handoff lands, the parent must guard//! the `latest` index with an atomic (e.g. `AtomicUsize`, `SeqCst`) — the//! slot discipline (write into a back buffer, publish, read latest) stays//! exactly as modeled here.//!//! Parent-unification notes (parallel workstream `ray.rs` / `shade.rs`)://! - `Snapshot.cells_light` is the 24-step light level per cell the shade//! step multiplies in (docs/05 §5.1 "Light as truth", §5.2 step 3).//! - `SnapActor.glyph` / `fg` are the billboard contract: `ray.rs`//! depth-tests them against the ray depth buffer brightest-first, and//! `shade.rs` owns species+posture+phase glyph selection (docs/05 §5.1//! "Actors as glyphs-with-posture", §5.2 step 4). Status overlays//! (burning `*`, blessed halo, cursed drip) ride on `glyph`/`fg` too.
/// Per-frame bytes carried by one actor record (estimate; see [`byte_size`]).pub const SNAP_ACTOR_BYTES: usize = 32;/// Fixed per-snapshot bookkeeping overhead in the [`byte_size`] estimate.pub const SNAPSHOT_OVERHEAD_BYTES: usize = 128;
/// One actor as the renderer sees it: billboard contract for `ray.rs` //// `shade.rs` (docs/05 §5.2 step 4). Continuous sim position; the renderer/// interpolates between ticks (see `camera::interpolate`).#[derive(Clone, Copy, Debug, Default, PartialEq)]pub struct SnapActor { /// Sim entity id (stable across ticks for interpolation). pub id: u64, /// Continuous world position in metres. pub pos: [f32; 3], /// Pre-selected billboard glyph (species + posture + anim phase). pub glyph: char, /// Pre-shaded foreground truecolor. pub fg: [u8; 3],}
/// One published sim frame (docs/04 §4.5, docs/05 §5.2 step 1).#[derive(Clone, Debug, Default, PartialEq)]pub struct Snapshot { /// Frame width in cells. pub w: usize, /// Frame height in cells. pub h: usize, /// Sim tick this frame was published at (docs/04 §4.2: 10 Hz). pub tick: u64, /// Per-cell 24-step light level, row-major, `w*h` bytes. pub cells_light: Vec<u8>, /// Visible actors for billboard compositing. pub actors: Vec<SnapActor>,}
impl Snapshot { /// Blank frame with a zeroed `w*h` light plane. pub fn new(w: usize, h: usize) -> Self { Self { w, h, tick: 0, cells_light: vec![0; w.saturating_mul(h)], actors: Vec::new(), } }}
/// Estimated bytes of one [`Snapshot`] for `w`×`h` cells and `actors`/// records: `w*h` light bytes + [`SNAP_ACTOR_BYTES`] per actor +/// [`SNAPSHOT_OVERHEAD_BYTES`]. At 120×36 + 64 actors this is ≈6 KB/// (inside the ~200 KB step-1 budget, docs/05 §5.2); the triple total at/// 240×70 + 512 actors stays ≤ 1 MB (docs/05 §5.2 §"Performance budget").pub fn byte_size(w: usize, h: usize, actors: usize) -> usize { w.saturating_mul(h) .saturating_add(actors.saturating_mul(SNAP_ACTOR_BYTES)) .saturating_add(SNAPSHOT_OVERHEAD_BYTES)}
/// Triple-buffered snapshot store (docs/04 §4.5).////// Slot discipline: the sim fills the back buffer via [`write`](Self::write),/// publishes it with [`publish`](Self::publish), and the renderer reads the/// latest published frame via [`read`](Self::read). `read` never blocks/// `write` and `write` never blocks `read`.pub struct TripleBuffer { bufs: [Snapshot; 3], latest: usize, back: usize,}
impl TripleBuffer { /// Three blank `w`×`h` frames; [`read`](Self::read) yields tick 0 until /// the first [`publish`](Self::publish). pub fn new(w: usize, h: usize) -> Self { Self { bufs: [ Snapshot::new(w, h), Snapshot::new(w, h), Snapshot::new(w, h), ], latest: 0, back: 1, } }
/// Sim side: exclusive access to the back buffer. Mutate, then call /// [`publish`](Self::publish) to make it visible to [`read`](Self::read). pub fn write(&mut self) -> &mut Snapshot { &mut self.bufs[self.back] }
/// Sim side: publish the back buffer and rotate in a fresh one. pub fn publish(&mut self) { self.latest = self.back; self.back = (self.latest + 1) % 3; }
/// Render side: the latest published snapshot. Non-blocking by /// construction (`&self`); on contention the caller re-uses this frame /// (docs/05 §5.2 step 1: tearing beats stalling). pub fn read(&self) -> &Snapshot { &self.bufs[self.latest] }}
#[cfg(test)]mod tests { use super::*;
fn actor(id: u64, tick_tag: f32) -> SnapActor { SnapActor { id, pos: [tick_tag, 0.0, 0.0], glyph: '@', fg: [255, 214, 64], } }
#[test] fn write_publish_read_visibility() { let mut tb = TripleBuffer::new(8, 4); tb.write().tick = 42; tb.write().actors.push(actor(7, 1.0)); tb.publish(); let r = tb.read(); assert_eq!(r.tick, 42); assert_eq!(r.actors.len(), 1); assert_eq!(r.actors[0].id, 7); }
#[test] fn unpublished_write_stays_invisible() { let mut tb = TripleBuffer::new(8, 4); tb.publish(); // tick 0 baseline tb.write().tick = 99; assert_eq!(tb.read().tick, 0, "read must see last published frame"); tb.publish(); assert_eq!(tb.read().tick, 99); }
#[test] fn read_never_blocks_write() { // Sequential proof of the non-blocking contract: interleaved // read/write/publish over many frames always shows the latest // published tick and never stalls either side. let mut tb = TripleBuffer::new(8, 4); for t in 1..=10u64 { let _ = tb.read(); tb.write().tick = t; tb.publish(); assert_eq!(tb.read().tick, t); } }
#[test] fn byte_size_budgets() { // Reference frame fits the ~200 KB step-1 budget (docs/05 §5.2). assert!(byte_size(120, 36, 64) <= 200_000); // Triple total at max size fits the ≤ 1 MB budget (docs/05 §5.2). assert!(3 * byte_size(240, 70, 512) <= 1_000_000); // Light plane dominates: one byte per cell plus actor records. assert_eq!( byte_size(10, 10, 2), 100 + 2 * SNAP_ACTOR_BYTES + SNAPSHOT_OVERHEAD_BYTES ); }
#[test] fn default_and_new_are_sane() { let d = Snapshot::default(); assert_eq!((d.w, d.h, d.tick), (0, 0, 0)); assert!(d.cells_light.is_empty() && d.actors.is_empty()); let s = Snapshot::new(8, 4); assert_eq!(s.cells_light.len(), 32); let tb = TripleBuffer::new(8, 4); assert_eq!(tb.read().tick, 0); assert_eq!(tb.read().cells_light.len(), 32); }}