From 21b0fee9b4ccf9fd5128e4ba7d46e70da3f65209 Mon Sep 17 00:00:00 2001 From: vnnkl <8235476+vnnkl@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:50:57 +0200 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20basilisk-fang=20erase=20=E2=80=94?= =?UTF-8?q?=20stab=20the=20diary=20to=20make=20it=20forget?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The diary now has an in-fiction way to forget, and it's the movie's: stab it. Hold the pen's eraser still on one spot; after a second an irregular ink splat pools at the tip (the warning — lift to abort and the page reabsorbs it), and at three seconds the splat bursts, ink floods the page from the wound, drains away, and every remembered page dies (MemoryStore::forget_all: entries, index.tsv, *.strokes). The splat is an organic stain, not a circle: its edge wobbles with two seeded sine lobes so it keeps its shape while growing, ringed by satellite droplets that land at fixed spots as the splat grows past each one's threshold, every third flung outward as a radial streak. While the warning ink pools the tip stops erasing — it's a fang, not an eraser — so no stroke points are dropped under the splat. --- riddle/oracle.env.example | 4 + riddle/src/main.rs | 201 +++++++++++++++++++++++++++++++++++++- riddle/src/memory.rs | 9 ++ 3 files changed, 213 insertions(+), 1 deletion(-) diff --git a/riddle/oracle.env.example b/riddle/oracle.env.example index d9ed692..00651ed 100644 --- a/riddle/oracle.env.example +++ b/riddle/oracle.env.example @@ -43,6 +43,10 @@ RIDDLE_OPENAI_KEY=sk-your-key-here # Tom's reply) in /home/root/riddle-data/memories, entirely on the tablet. # Recent pages ride along with each request so Tom can follow a conversation, # and writing "show me…" conjures a past page back in your own hand. +# To make the diary forget EVERYTHING, do what Harry did: stab it. Hold the +# pen's ERASER still on one spot — after a second ink pools at the tip like a +# wound (lift to abort), and at three seconds the ink floods the page, drains +# away, and every memory is gone. # RIDDLE_MEMORY=off # forget nothing more, remember nothing new # RIDDLE_MEMORY_DIR=/home/root/riddle-data/memories # RIDDLE_MEMORY_TURNS=6 # how many recent pages Tom carries into a reply diff --git a/riddle/src/main.rs b/riddle/src/main.rs index 24601b3..0bbb958 100644 --- a/riddle/src/main.rs +++ b/riddle/src/main.rs @@ -254,6 +254,14 @@ fn run() -> std::io::Result<()> { let mut stylus_on = false; let mut stylus_tapped = false; let mut ink_dirty = BBox::empty(); + // Basilisk-fang erase: hold the eraser still on one spot for 3s and the + // diary's memory dies. (origin x, origin y, press start), the nominal + // splat radius already inked, and the bbox of everything the splat drew. + let mut stab: Option<(i32, i32, Instant)> = None; + let mut stab_pool: i32 = 0; + let mut stab_bbox = BBox::empty(); + // Latest eraser contact this loop; None once the pen lifts or flips. + let mut eraser_at: Option<(i32, i32)> = None; let mut last_flush = Instant::now(); // Takeover swaps are cheap and synchronous; qtfb needs coalescing. let flush_every = if takeover { Duration::from_millis(8) } else { Duration::from_millis(35) }; @@ -331,6 +339,7 @@ fn run() -> std::io::Result<()> { if pen_down { pen_down = false; user_ink.pen_up(); + eraser_at = None; if let State::Listening { ref mut last_pen } = state { *last_pen = Some(Instant::now()); } @@ -342,10 +351,21 @@ fn run() -> std::io::Result<()> { pen_down = true; let d = match s.tool { pen::Tool::Pen => { + eraser_at = None; let r = 2 + s.pressure * 3 / pen::MAX_PRESSURE; user_ink.pen_point(&mut surf, s.x, s.y, r) } - pen::Tool::Eraser => user_ink.erase_point(&mut surf, s.x, s.y, 22), + pen::Tool::Eraser => { + eraser_at = Some((s.x, s.y)); + // While the stab's warning ink pools, the tip + // is a fang, not an eraser: don't erase (and + // don't drop stroke points under the splat). + if stab_pool > 0 { + BBox::empty() + } else { + user_ink.erase_point(&mut surf, s.x, s.y, 22) + } + } }; if !d.is_empty() { ink_dirty.add(d.x0, d.y0, 0); @@ -401,6 +421,87 @@ fn run() -> std::io::Result<()> { } } + // ---- basilisk-fang stab (hold the eraser still for 3s) ---- + let stab_live = pen_down && matches!(state, State::Listening { .. }); + match (stab, eraser_at, stab_live) { + (None, Some((x, y)), true) => stab = Some((x, y, Instant::now())), + (Some((sx, sy, t0)), Some((x, y)), true) => { + let (dx, dy) = (x - sx, y - sy); + if dx * dx + dy * dy > 20 * 20 { + // Drifted: this is erasing, not stabbing. Reabsorb any + // warning ink and re-arm at the new spot. + if !stab_bbox.is_empty() { + absorb_region(&mut surf, &disp, stab_bbox); + } + stab = Some((x, y, Instant::now())); + stab_pool = 0; + stab_bbox = BBox::empty(); + } else { + let held = t0.elapsed(); + if held >= Duration::from_secs(3) { + // The fang lands: the splat bursts, ink floods the + // page, drains away — and the memory dies with it. + eprintln!("riddle: basilisk fang — the diary's memory is erased"); + let seed = splat_seed(sx, sy); + let b = draw_splat(&mut surf, sx, sy, 260, seed); + let (bx, by, bw, bh) = b.rect(); + disp.update(bx, by, bw, bh, true); + std::thread::sleep(Duration::from_millis(320)); + let (w, h) = (surf.w as i32, surf.h as i32); + let maxr = { + let fx = sx.max(w - sx); + let fy = sy.max(h - sy); + (((fx * fx + fy * fy) as f64).sqrt() as i32) + 2 + }; + for step in 1..=5 { + flood_disc(&mut surf, sx, sy, 260 + (maxr - 260) * step / 5); + disp.update(0, 0, w, h, true); + std::thread::sleep(Duration::from_millis(150)); + } + std::thread::sleep(Duration::from_millis(500)); + if let Some(ref mut s) = store { + s.forget_all(); + } + surf.fill_rect(0, 0, surf.w, surf.h, WHITE); + disp.full_refresh(surf.w, surf.h); + user_ink.clear(); + ink_dirty = BBox::empty(); + stab = None; + stab_pool = 0; + stab_bbox = BBox::empty(); + eraser_at = None; + state = State::Listening { last_pen: None }; + } else if held >= Duration::from_secs(1) { + // Warning ink pools at the stab point — the writer's + // signal that the stab is registering; lift to abort. + let ms = (held.as_millis() as i32 - 1000).clamp(0, 2000); + let r = 10 + ms * 100 / 2000; + if r >= stab_pool + 3 { + let seed = splat_seed(sx, sy); + let b = draw_splat(&mut surf, sx, sy, r, seed); + stab_bbox.add(b.x0, b.y0, 0); + stab_bbox.add(b.x1, b.y1, 0); + let (bx, by, bw, bh) = b.rect(); + disp.update(bx, by, bw, bh, true); + stab_pool = r; + } + } + } + } + (Some(_), _, _) => { + // Lifted (or the state moved on): abort, reabsorb the warning + // ink. Anything under the splat goes with it — the writer did + // press an eraser there. + if !stab_bbox.is_empty() { + absorb_region(&mut surf, &disp, stab_bbox); + } + stab = None; + stab_pool = 0; + stab_bbox = BBox::empty(); + } + _ => {} + } + // ---- coalesced ink flush ---- if !ink_dirty.is_empty() && last_flush.elapsed() >= flush_every { let (x, y, w, h) = ink_dirty.rect(); @@ -770,6 +871,104 @@ fn region_all_white(surf: &Surface, region: BBox) -> bool { /// What Tom writes when the spirit cannot answer: short, in a diary's voice, /// but specific enough to act on. The raw error still goes to stderr. +/// Deterministic per-stab randomness: the splat keeps its shape as it grows. +fn splat_seed(x: i32, y: i32) -> u32 { + let mut h = (x as u32).wrapping_mul(0x9E37_79B1) ^ (y as u32).wrapping_mul(0x85EB_CA6B); + h ^= h >> 13; + h = h.wrapping_mul(0xC2B2_AE35); + h ^ (h >> 16) +} + +/// Per-droplet hash stream (stable across redraws). +fn splat_hash(seed: u32, i: u32) -> u32 { + let mut h = seed.wrapping_add(i.wrapping_mul(0x9E37_79B1)); + h ^= h >> 15; + h = h.wrapping_mul(0x85EB_CA6B); + h ^ (h >> 13) +} + +/// Draw an ink splat of nominal radius `r`: an irregular blob (its edge +/// wobbles with two sine lobes, phases from `seed`) ringed by satellite +/// droplets and radial streaks. Droplets have fixed positions and appear as +/// `r` passes each one's threshold, so a growing splat spreads organically — +/// pixels only ever gain ink, never lose it, between redraws at the same +/// seed. Returns the bbox of everything drawn. +fn draw_splat(surf: &mut Surface, cx: i32, cy: i32, r: i32, seed: u32) -> BBox { + let mut bbox = BBox::empty(); + + // The main blob: edge(θ) = r · (1 + 0.26·sin(3θ+p1) + 0.16·sin(7θ+p2)). + let p1 = (seed % 6283) as f32 / 1000.0; + let p2 = ((seed >> 10) % 6283) as f32 / 1000.0; + let rmax = (r as f32 * 1.45) as i32 + 2; + for dy in -rmax..=rmax { + for dx in -rmax..=rmax { + let d = ((dx * dx + dy * dy) as f32).sqrt(); + if d > rmax as f32 { + continue; + } + let th = (dy as f32).atan2(dx as f32); + let edge = r as f32 * (1.0 + 0.26 * (3.0 * th + p1).sin() + 0.16 * (7.0 * th + p2).sin()); + if d <= edge { + surf.put_px(cx + dx, cy + dy, BLACK); + bbox.add(cx + dx, cy + dy, 1); + } + } + } + + // Satellite droplets: fixed absolute positions derived from their own + // appearance threshold, so already-drawn droplets never move. + for i in 0..26u32 { + let h = splat_hash(seed, i); + let threshold = 12 + (h % 99) as i32; // r at which this droplet lands + if r < threshold { + continue; + } + let ang = ((h >> 7) % 6283) as f32 / 1000.0; + let dist = threshold as f32 * (1.3 + ((h >> 17) % 100) as f32 / 80.0); + let size = 2 + ((h >> 24) % 5) as i32; + let (px, py) = (cx + (dist * ang.cos()) as i32, cy + (dist * ang.sin()) as i32); + surf.stamp(px, py, size, BLACK); + bbox.add(px, py, size + 1); + // Every third droplet is flung: a radial streak of shrinking dots. + if h % 3 == 0 { + for step in 1..=3 { + let d2 = dist + (step * (size + 2)) as f32; + let (qx, qy) = (cx + (d2 * ang.cos()) as i32, cy + (d2 * ang.sin()) as i32); + let sr = (size - step / 2).max(1); + surf.stamp(qx, qy, sr, BLACK); + bbox.add(qx, qy, sr + 1); + } + } + } + bbox +} + +/// Reabsorb warning ink after an aborted stab: dissolve the region in place. +fn absorb_region(surf: &mut Surface, disp: &display::Display, region: BBox) { + for stage in 0..8 { + ink::dissolve_pass(surf, region, stage, 8); + let (x, y, w, h) = region.rect(); + disp.update(x, y, w, h, true); + std::thread::sleep(Duration::from_millis(45)); + } +} + +/// One expanding pass of the stab flood: a solid black disc clipped to the +/// page, drawn as row spans so a page-sized flood stays cheap. +fn flood_disc(surf: &mut Surface, cx: i32, cy: i32, r: i32) { + let y0 = (cy - r).max(0); + let y1 = (cy + r).min(surf.h as i32 - 1); + for y in y0..=y1 { + let dy = y - cy; + let half = (((r * r - dy * dy) as f64).sqrt()) as i32; + let x0 = (cx - half).max(0); + let x1 = (cx + half).min(surf.w as i32 - 1); + for x in x0..=x1 { + surf.put_px(x, y, BLACK); + } + } +} + fn oracle_excuse(e: &str) -> String { if e.contains("no oracle") { "The diary lies dormant: it found no oracle. \ diff --git a/riddle/src/memory.rs b/riddle/src/memory.rs index 5a33b4c..8d1f446 100644 --- a/riddle/src/memory.rs +++ b/riddle/src/memory.rs @@ -109,6 +109,15 @@ impl MemoryStore { self.prune(); } + /// The basilisk fang lands: forget every remembered page, on disk too. + pub fn forget_all(&mut self) { + for e in &self.entries { + let _ = std::fs::remove_file(self.strokes_path(e.id)); + } + let _ = std::fs::remove_file(self.index_path()); + self.entries.clear(); + } + /// Forget the oldest pages beyond MAX_MEMORIES. fn prune(&mut self) { if self.entries.len() <= MAX_MEMORIES { From c06e7d52b0a87fb0c57d7ea80443c03915ea9acc Mon Sep 17 00:00:00 2001 From: vnnkl <8235476+vnnkl@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:01:21 +0200 Subject: [PATCH 2/9] fix: the stab flood spreads as a stain, not a circle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pooling splat and impact burst were organic, but the page-covering flood still expanded as plain discs — circular wavefronts painting over the stain. The flood wavefront now wobbles with the same seeded lobes (row-span fill, two trig evaluations per row, so a page-sized flood stays cheap), and the final radius overshoots the corner distance to cover the wobble's inward troughs. --- riddle/src/main.rs | 41 ++++++++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/riddle/src/main.rs b/riddle/src/main.rs index 0bbb958..8c73232 100644 --- a/riddle/src/main.rs +++ b/riddle/src/main.rs @@ -448,13 +448,16 @@ fn run() -> std::io::Result<()> { disp.update(bx, by, bw, bh, true); std::thread::sleep(Duration::from_millis(320)); let (w, h) = (surf.w as i32, surf.h as i32); + // The wobble can pull the wavefront in to ~0.58×r, so + // overshoot the corner distance to be sure the final + // step swallows the whole page. let maxr = { let fx = sx.max(w - sx); let fy = sy.max(h - sy); - (((fx * fx + fy * fy) as f64).sqrt() as i32) + 2 + ((((fx * fx + fy * fy) as f64).sqrt() * 1.75) as i32) + 2 }; for step in 1..=5 { - flood_disc(&mut surf, sx, sy, 260 + (maxr - 260) * step / 5); + flood_splat(&mut surf, sx, sy, 260 + (maxr - 260) * step / 5, seed); disp.update(0, 0, w, h, true); std::thread::sleep(Duration::from_millis(150)); } @@ -953,16 +956,32 @@ fn absorb_region(surf: &mut Surface, disp: &display::Display, region: BBox) { } } -/// One expanding pass of the stab flood: a solid black disc clipped to the -/// page, drawn as row spans so a page-sized flood stays cheap. -fn flood_disc(surf: &mut Surface, cx: i32, cy: i32, r: i32) { - let y0 = (cy - r).max(0); - let y1 = (cy + r).min(surf.h as i32 - 1); +/// One expanding pass of the stab flood. The wavefront wobbles with the same +/// seeded lobes as the splat, so the ink spreads as a growing stain rather +/// than a circle. Row-span fill (two trig evaluations per row) keeps a +/// page-sized flood cheap. +fn flood_splat(surf: &mut Surface, cx: i32, cy: i32, r: i32, seed: u32) { + let p1 = (seed % 6283) as f32 / 1000.0; + let p2 = ((seed >> 10) % 6283) as f32 / 1000.0; + let wobble = |th: f32| 1.0 + 0.26 * (3.0 * th + p1).sin() + 0.16 * (7.0 * th + p2).sin(); + let rf = r as f32; + let reach = (rf * 1.45) as i32 + 1; + let y0 = (cy - reach).max(0); + let y1 = (cy + reach).min(surf.h as i32 - 1); for y in y0..=y1 { - let dy = y - cy; - let half = (((r * r - dy * dy) as f64).sqrt()) as i32; - let x0 = (cx - half).max(0); - let x1 = (cx + half).min(surf.w as i32 - 1); + let dy = (y - cy) as f32; + // Approximate where the wobbled edge crosses this row on each side: + // take the angle the plain circle would cross at, evaluate the + // wobbled radius there, and intersect that radius with the row. + let s = (dy / rf).clamp(-1.0, 1.0); + let th_right = s.asin(); + let th_left = std::f32::consts::PI - th_right; + let er = rf * wobble(th_right); + let el = rf * wobble(th_left); + let half_r = (er * er - dy * dy).max(0.0).sqrt() as i32; + let half_l = (el * el - dy * dy).max(0.0).sqrt() as i32; + let x0 = (cx - half_l).max(0); + let x1 = (cx + half_r).min(surf.w as i32 - 1); for x in x0..=x1 { surf.put_px(x, y, BLACK); } From 8b30f43587b662b4e35f213b77bd540e3ef93bf8 Mon Sep 17 00:00:00 2001 From: vnnkl <8235476+vnnkl@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:07:00 +0200 Subject: [PATCH 3/9] fix: the stab's death is one continuous motion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trigger cut from the 110px pooled splat straight to a 260px one, droplet thresholds stopped at ~110 so the growth went bald, and the flood's slow lobes smoothed into near-circles at page scale — the seam the eye caught. Now the pooled splat surges frame-by-frame (same seed, 110→300) with droplet thresholds extended so fresh spatter lands throughout, and the flood wavefront adds two higher-frequency lobes so ink reaches out in fingers all the way to the edges. --- riddle/src/main.rs | 51 +++++++++++++++++++++++++++++++--------------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/riddle/src/main.rs b/riddle/src/main.rs index 8c73232..973b9dc 100644 --- a/riddle/src/main.rs +++ b/riddle/src/main.rs @@ -439,27 +439,35 @@ fn run() -> std::io::Result<()> { } else { let held = t0.elapsed(); if held >= Duration::from_secs(3) { - // The fang lands: the splat bursts, ink floods the - // page, drains away — and the memory dies with it. + // The fang lands. One continuous motion, no cuts: + // the pooled splat SURGES — same seed, same shape, + // growing frame by frame with fresh spatter landing — + // then accelerates into the flood, whose wavefront + // keeps the wobble (plus finger lobes) so the page is + // swallowed by a stain, never a circle. eprintln!("riddle: basilisk fang — the diary's memory is erased"); let seed = splat_seed(sx, sy); - let b = draw_splat(&mut surf, sx, sy, 260, seed); - let (bx, by, bw, bh) = b.rect(); - disp.update(bx, by, bw, bh, true); - std::thread::sleep(Duration::from_millis(320)); + let mut r = stab_pool.max(10); + while r < 300 { + r = (r + 22).min(300); + let b = draw_splat(&mut surf, sx, sy, r, seed); + let (bx, by, bw, bh) = b.rect(); + disp.update(bx, by, bw, bh, true); + std::thread::sleep(Duration::from_millis(45)); + } let (w, h) = (surf.w as i32, surf.h as i32); - // The wobble can pull the wavefront in to ~0.58×r, so + // The wobble can pull the wavefront in to ~0.56×r, so // overshoot the corner distance to be sure the final // step swallows the whole page. let maxr = { let fx = sx.max(w - sx); let fy = sy.max(h - sy); - ((((fx * fx + fy * fy) as f64).sqrt() * 1.75) as i32) + 2 + ((((fx * fx + fy * fy) as f64).sqrt() * 2.3) as i32) + 2 }; - for step in 1..=5 { - flood_splat(&mut surf, sx, sy, 260 + (maxr - 260) * step / 5, seed); + for step in 1..=8 { + flood_splat(&mut surf, sx, sy, 300 + (maxr - 300) * step / 8, seed); disp.update(0, 0, w, h, true); - std::thread::sleep(Duration::from_millis(150)); + std::thread::sleep(Duration::from_millis(110)); } std::thread::sleep(Duration::from_millis(500)); if let Some(ref mut s) = store { @@ -919,10 +927,12 @@ fn draw_splat(surf: &mut Surface, cx: i32, cy: i32, r: i32, seed: u32) -> BBox { } // Satellite droplets: fixed absolute positions derived from their own - // appearance threshold, so already-drawn droplets never move. - for i in 0..26u32 { + // appearance threshold, so already-drawn droplets never move. Thresholds + // run past the pooling range (≤110) so fresh spatter keeps landing + // through the death surge instead of the growth going bald. + for i in 0..48u32 { let h = splat_hash(seed, i); - let threshold = 12 + (h % 99) as i32; // r at which this droplet lands + let threshold = 12 + (h % 280) as i32; // r at which this droplet lands if r < threshold { continue; } @@ -963,9 +973,18 @@ fn absorb_region(surf: &mut Surface, disp: &display::Display, region: BBox) { fn flood_splat(surf: &mut Surface, cx: i32, cy: i32, r: i32, seed: u32) { let p1 = (seed % 6283) as f32 / 1000.0; let p2 = ((seed >> 10) % 6283) as f32 / 1000.0; - let wobble = |th: f32| 1.0 + 0.26 * (3.0 * th + p1).sin() + 0.16 * (7.0 * th + p2).sin(); + let p3 = ((seed >> 20) % 6283) as f32 / 1000.0; + // The splat's own lobes plus two higher-frequency ones: at page-scale + // radii the slow lobes read as smooth arcs, so the fast ones keep the + // front ragged — ink reaching out in fingers, never a circle. + let wobble = |th: f32| { + 1.0 + 0.26 * (3.0 * th + p1).sin() + + 0.16 * (7.0 * th + p2).sin() + + 0.09 * (17.0 * th + p3).sin() + + 0.05 * (31.0 * th + p1 + p2).sin() + }; let rf = r as f32; - let reach = (rf * 1.45) as i32 + 1; + let reach = (rf * 1.60) as i32 + 1; let y0 = (cy - reach).max(0); let y1 = (cy + reach).min(surf.h as i32 - 1); for y in y0..=y1 { From 9ab2f2c7b2849a3c82af390c69ff25ea35558448 Mon Sep 17 00:00:00 2001 From: vnnkl <8235476+vnnkl@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:17:06 +0200 Subject: [PATCH 4/9] feat: the stab flood is a paper-absorption soak, not a scaled shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaling the pooled splat read as a zoom — the same outline resized, with no new ink flowing — and the wavefront still went geometric by the page edges. Now the pooled splat is left exactly as inked and its pixels seed a cellular automaton in the Suibokuga ink-model tradition: the page is a 6px-cell grid with absorbency from smooth value noise, and ink spreads to neighboring cells with probability set by absorbency and by how much ink already borders them. Fingers race down high-absorbency fibres, bays lag, the front is grainy (jittered discs, never a lattice), and generations per frame accelerate until the page drowns. Droplet thresholds return to the original pooling range. --- riddle/src/main.rs | 212 ++++++++++++++++++++++++++++++--------------- 1 file changed, 142 insertions(+), 70 deletions(-) diff --git a/riddle/src/main.rs b/riddle/src/main.rs index 973b9dc..86a579b 100644 --- a/riddle/src/main.rs +++ b/riddle/src/main.rs @@ -439,36 +439,14 @@ fn run() -> std::io::Result<()> { } else { let held = t0.elapsed(); if held >= Duration::from_secs(3) { - // The fang lands. One continuous motion, no cuts: - // the pooled splat SURGES — same seed, same shape, - // growing frame by frame with fresh spatter landing — - // then accelerates into the flood, whose wavefront - // keeps the wobble (plus finger lobes) so the page is - // swallowed by a stain, never a circle. + // The fang lands. The pooled splat is not replaced or + // scaled — its own ink becomes the seed of a paper- + // absorption cellular automaton and SOAKS outward: + // the front advances cell by cell, fast through + // high-absorbency fibres (fingers), slow elsewhere + // (bays), accelerating until the page drowns. eprintln!("riddle: basilisk fang — the diary's memory is erased"); - let seed = splat_seed(sx, sy); - let mut r = stab_pool.max(10); - while r < 300 { - r = (r + 22).min(300); - let b = draw_splat(&mut surf, sx, sy, r, seed); - let (bx, by, bw, bh) = b.rect(); - disp.update(bx, by, bw, bh, true); - std::thread::sleep(Duration::from_millis(45)); - } - let (w, h) = (surf.w as i32, surf.h as i32); - // The wobble can pull the wavefront in to ~0.56×r, so - // overshoot the corner distance to be sure the final - // step swallows the whole page. - let maxr = { - let fx = sx.max(w - sx); - let fy = sy.max(h - sy); - ((((fx * fx + fy * fy) as f64).sqrt() * 2.3) as i32) + 2 - }; - for step in 1..=8 { - flood_splat(&mut surf, sx, sy, 300 + (maxr - 300) * step / 8, seed); - disp.update(0, 0, w, h, true); - std::thread::sleep(Duration::from_millis(110)); - } + flood_soak(&mut surf, &disp, stab_bbox); std::thread::sleep(Duration::from_millis(500)); if let Some(ref mut s) = store { s.forget_all(); @@ -927,12 +905,10 @@ fn draw_splat(surf: &mut Surface, cx: i32, cy: i32, r: i32, seed: u32) -> BBox { } // Satellite droplets: fixed absolute positions derived from their own - // appearance threshold, so already-drawn droplets never move. Thresholds - // run past the pooling range (≤110) so fresh spatter keeps landing - // through the death surge instead of the growth going bald. - for i in 0..48u32 { + // appearance threshold, so already-drawn droplets never move. + for i in 0..26u32 { let h = splat_hash(seed, i); - let threshold = 12 + (h % 280) as i32; // r at which this droplet lands + let threshold = 12 + (h % 99) as i32; // r at which this droplet lands if r < threshold { continue; } @@ -966,45 +942,141 @@ fn absorb_region(surf: &mut Surface, disp: &display::Display, region: BBox) { } } -/// One expanding pass of the stab flood. The wavefront wobbles with the same -/// seeded lobes as the splat, so the ink spreads as a growing stain rather -/// than a circle. Row-span fill (two trig evaluations per row) keeps a -/// page-sized flood cheap. -fn flood_splat(surf: &mut Surface, cx: i32, cy: i32, r: i32, seed: u32) { - let p1 = (seed % 6283) as f32 / 1000.0; - let p2 = ((seed >> 10) % 6283) as f32 / 1000.0; - let p3 = ((seed >> 20) % 6283) as f32 / 1000.0; - // The splat's own lobes plus two higher-frequency ones: at page-scale - // radii the slow lobes read as smooth arcs, so the fast ones keep the - // front ragged — ink reaching out in fingers, never a circle. - let wobble = |th: f32| { - 1.0 + 0.26 * (3.0 * th + p1).sin() - + 0.16 * (7.0 * th + p2).sin() - + 0.09 * (17.0 * th + p3).sin() - + 0.05 * (31.0 * th + p1 + p2).sin() +/// The stab flood: a paper-absorption cellular automaton (after the classic +/// Suibokuga/Chinese-ink models). The page is a grid of 6px cells, each with +/// an absorbency drawn from smooth value noise; ink spreads from the splat's +/// own pixels to neighboring cells with probability set by absorbency and by +/// how much ink already borders the cell. High-absorbency channels race +/// ahead as fingers, low ones lag as bays — the stain GROWS, it is never +/// scaled. Generations per frame accelerate until the page drowns. +fn flood_soak(surf: &mut Surface, disp: &display::Display, seed_region: BBox) { + const CELL: i32 = 6; + if seed_region.is_empty() { + return; + } + let gw = (surf.w as i32 + CELL - 1) / CELL; + let gh = (surf.h as i32 + CELL - 1) / CELL; + let idx = |x: i32, y: i32| (y * gw + x) as usize; + // 0 = dry paper, 1 = inked, 2 = queued on the frontier. + let mut state = vec![0u8; (gw * gh) as usize]; + + // Seed: every cell whose center pixel is already dark inside the splat's + // bbox — the blob, its droplets, any writing the splat touched. + let mut inked = 0usize; + let cx0 = (seed_region.x0 / CELL).max(0); + let cy0 = (seed_region.y0 / CELL).max(0); + let cx1 = (seed_region.x1 / CELL).min(gw - 1); + let cy1 = (seed_region.y1 / CELL).min(gh - 1); + for cy in cy0..=cy1 { + for cx in cx0..=cx1 { + if surf.luma(cx * CELL + CELL / 2, cy * CELL + CELL / 2) < 128 { + state[idx(cx, cy)] = 1; + inked += 1; + } + } + } + const NBR: [(i32, i32); 4] = [(1, 0), (-1, 0), (0, 1), (0, -1)]; + let mut frontier: Vec = Vec::new(); + for cy in (cy0 - 1).max(0)..=(cy1 + 1).min(gh - 1) { + for cx in (cx0 - 1).max(0)..=(cx1 + 1).min(gw - 1) { + if state[idx(cx, cy)] != 0 { + continue; + } + let touches = NBR.iter().any(|&(dx, dy)| { + let (nx, ny) = (cx + dx, cy + dy); + nx >= 0 && ny >= 0 && nx < gw && ny < gh && state[idx(nx, ny)] == 1 + }); + if touches { + state[idx(cx, cy)] = 2; + frontier.push(cy * gw + cx); + } + } + } + if frontier.is_empty() { + return; + } + + // Absorbency 0..~100: bilinear value noise (~72px features — the paper's + // fibre structure) salted with per-cell grain. + let noise_seed = splat_seed(seed_region.x0, seed_region.y1); + let coarse = 12.0f32; + let lattice = + |lx: i32, ly: i32| (splat_hash(noise_seed, (ly * 517 + lx) as u32) % 1000) as f32 / 1000.0; + let absorb = |cx: i32, cy: i32| -> i32 { + let (fx, fy) = (cx as f32 / coarse, cy as f32 / coarse); + let (lx, ly) = (fx.floor() as i32, fy.floor() as i32); + let (tx, ty) = (fx - fx.floor(), fy - fy.floor()); + let v = lattice(lx, ly) * (1.0 - tx) * (1.0 - ty) + + lattice(lx + 1, ly) * tx * (1.0 - ty) + + lattice(lx, ly + 1) * (1.0 - tx) * ty + + lattice(lx + 1, ly + 1) * tx * ty; + let grain = (splat_hash(noise_seed ^ 0xA5A5, (cy * gw + cx) as u32) % 30) as f32 / 100.0; + ((v * 0.85 + grain) * 100.0) as i32 }; - let rf = r as f32; - let reach = (rf * 1.60) as i32 + 1; - let y0 = (cy - reach).max(0); - let y1 = (cy + reach).min(surf.h as i32 - 1); - for y in y0..=y1 { - let dy = (y - cy) as f32; - // Approximate where the wobbled edge crosses this row on each side: - // take the angle the plain circle would cross at, evaluate the - // wobbled radius there, and intersect that radius with the row. - let s = (dy / rf).clamp(-1.0, 1.0); - let th_right = s.asin(); - let th_left = std::f32::consts::PI - th_right; - let er = rf * wobble(th_right); - let el = rf * wobble(th_left); - let half_r = (er * er - dy * dy).max(0.0).sqrt() as i32; - let half_l = (el * el - dy * dy).max(0.0).sqrt() as i32; - let x0 = (cx - half_l).max(0); - let x1 = (cx + half_r).min(surf.w as i32 - 1); - for x in x0..=x1 { - surf.put_px(x, y, BLACK); + + let total = (gw * gh) as usize; + let mut gens_run = 0u32; + for frame in 0i32.. { + // Few generations at first (the soak visibly starts at the splat), + // many later (the page drowns in an accelerating rush). + let gens = (4.0 * 1.45f32.powi(frame)).min(260.0) as u32; + let mut newly: Vec = Vec::new(); + for _ in 0..gens { + if frontier.is_empty() { + break; + } + gens_run += 1; + let mut next: Vec = Vec::with_capacity(frontier.len()); + for &c in &frontier { + if state[c as usize] == 1 { + continue; + } + let (cx, cy) = (c % gw, c / gw); + let mut n = 0; + for &(dx, dy) in &NBR { + let (nx, ny) = (cx + dx, cy + dy); + if nx >= 0 && ny >= 0 && nx < gw && ny < gh && state[idx(nx, ny)] == 1 { + n += 1; + } + } + let p = (18 + absorb(cx, cy) * 2 / 3 + n * 12).min(96) as u32; + if splat_hash(noise_seed ^ gens_run, c as u32) % 100 < p { + state[c as usize] = 1; + inked += 1; + newly.push(c); + for &(dx, dy) in &NBR { + let (nx, ny) = (cx + dx, cy + dy); + if nx >= 0 && ny >= 0 && nx < gw && ny < gh && state[idx(nx, ny)] == 0 { + state[idx(nx, ny)] = 2; + next.push(ny * gw + nx); + } + } + } else { + next.push(c); + } + } + frontier = next; + } + // Ink the newly soaked cells as jittered discs: a grainy, fibrous + // front, never a lattice. + for &c in &newly { + let (cx, cy) = (c % gw, c / gw); + let h = splat_hash(noise_seed ^ 0x5EED, c as u32); + let jx = (h % 5) as i32 - 2; + let jy = ((h >> 4) % 5) as i32 - 2; + let r = 4 + ((h >> 8) % 3) as i32; + surf.stamp(cx * CELL + CELL / 2 + jx, cy * CELL + CELL / 2 + jy, r, BLACK); + } + disp.update(0, 0, surf.w as i32, surf.h as i32, true); + if frontier.is_empty() || inked >= total || frame > 28 { + break; } + std::thread::sleep(Duration::from_millis(120)); } + // Whatever stray dry specks remain, the ink claims: solid black before + // the drain, invisible against the already-drowned page. + surf.fill_rect(0, 0, surf.w, surf.h, BLACK); + disp.update(0, 0, surf.w as i32, surf.h as i32, true); } fn oracle_excuse(e: &str) -> String { From a43ef93c5c8aa50a7bbe08c336ea446bcc38a9a2 Mon Sep 17 00:00:00 2001 From: vnnkl <8235476+vnnkl@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:24:26 +0200 Subject: [PATCH 5/9] =?UTF-8?q?feat:=20the=20stab=20death=20is=20compound?= =?UTF-8?q?=20blob=20budding=20=E2=80=94=20one=20visual=20language?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CA soak read as a texture switch at the 3-second mark: grainy cell-growth against the smooth pooled splat. The death now speaks exactly the warning's language: wobbly-edged lobes (row-span blobs, monotonic in r for a fixed seed) erupt from the stain's rim, grow in place, and bud outward-biased children — each announced by the same flung droplet spatter the pooling phase taught the eye — cascading generation by generation until every corner drowns. Growth is always new ink arriving, never a resize or a re-texture of what's already inked. --- riddle/src/main.rs | 260 +++++++++++++++++++++++---------------------- 1 file changed, 131 insertions(+), 129 deletions(-) diff --git a/riddle/src/main.rs b/riddle/src/main.rs index 86a579b..e9438da 100644 --- a/riddle/src/main.rs +++ b/riddle/src/main.rs @@ -439,14 +439,16 @@ fn run() -> std::io::Result<()> { } else { let held = t0.elapsed(); if held >= Duration::from_secs(3) { - // The fang lands. The pooled splat is not replaced or - // scaled — its own ink becomes the seed of a paper- - // absorption cellular automaton and SOAKS outward: - // the front advances cell by cell, fast through - // high-absorbency fibres (fingers), slow elsewhere - // (bays), accelerating until the page drowns. + // The fang lands. The death speaks the same language + // as the warning: new lobes of ink — wobbly blobs + // just like the pooled one, each flinging the same + // droplet spatter ahead of itself — keep erupting + // from the stain's edge, budding outward generation + // by generation until the page drowns. Nothing is + // ever scaled or re-textured. eprintln!("riddle: basilisk fang — the diary's memory is erased"); - flood_soak(&mut surf, &disp, stab_bbox); + let seed = splat_seed(sx, sy); + flood_bloom(&mut surf, &disp, sx, sy, stab_pool.max(40), seed); std::thread::sleep(Duration::from_millis(500)); if let Some(ref mut s) = store { s.forget_all(); @@ -942,141 +944,141 @@ fn absorb_region(surf: &mut Surface, disp: &display::Display, region: BBox) { } } -/// The stab flood: a paper-absorption cellular automaton (after the classic -/// Suibokuga/Chinese-ink models). The page is a grid of 6px cells, each with -/// an absorbency drawn from smooth value noise; ink spreads from the splat's -/// own pixels to neighboring cells with probability set by absorbency and by -/// how much ink already borders the cell. High-absorbency channels race -/// ahead as fingers, low ones lag as bays — the stain GROWS, it is never -/// scaled. Generations per frame accelerate until the page drowns. -fn flood_soak(surf: &mut Surface, disp: &display::Display, seed_region: BBox) { - const CELL: i32 = 6; - if seed_region.is_empty() { - return; - } - let gw = (surf.w as i32 + CELL - 1) / CELL; - let gh = (surf.h as i32 + CELL - 1) / CELL; - let idx = |x: i32, y: i32| (y * gw + x) as usize; - // 0 = dry paper, 1 = inked, 2 = queued on the frontier. - let mut state = vec![0u8; (gw * gh) as usize]; - - // Seed: every cell whose center pixel is already dark inside the splat's - // bbox — the blob, its droplets, any writing the splat touched. - let mut inked = 0usize; - let cx0 = (seed_region.x0 / CELL).max(0); - let cy0 = (seed_region.y0 / CELL).max(0); - let cx1 = (seed_region.x1 / CELL).min(gw - 1); - let cy1 = (seed_region.y1 / CELL).min(gh - 1); - for cy in cy0..=cy1 { - for cx in cx0..=cx1 { - if surf.luma(cx * CELL + CELL / 2, cy * CELL + CELL / 2) < 128 { - state[idx(cx, cy)] = 1; - inked += 1; +/// The stab flood: compound blob budding. The death uses the exact visual +/// vocabulary of the pooled warning splat — wobbly-edged blobs and flung +/// droplet spatter — and simply produces MORE of it: lobes erupt from the +/// stain's edge, each growing in place and spawning outward-biased children +/// (with their own spatter) until the page drowns. Growth is new ink +/// arriving, never a resize of what's already there. +struct Blob { + x: i32, + y: i32, + r: f32, + target: f32, + seed: u32, + spawned: bool, +} + +fn flood_bloom(surf: &mut Surface, disp: &display::Display, sx: i32, sy: i32, r0: i32, seed: u32) { + let (w, h) = (surf.w as i32, surf.h as i32); + let mut blobs = vec![Blob { + x: sx, + y: sy, + r: r0 as f32, + target: r0 as f32 * 1.7, + seed, + spawned: false, + }]; + let mut next_id = 1u32; + for _frame in 0..22 { + // Grow every unfinished lobe a step and re-ink it (same seed: the + // wobbled outline only ever gains ink). + for b in blobs.iter_mut() { + if b.r < b.target { + b.r = (b.r * 1.45 + 12.0).min(b.target); + blob_span(surf, b.x, b.y, b.r as i32, b.seed); } } - } - const NBR: [(i32, i32); 4] = [(1, 0), (-1, 0), (0, 1), (0, -1)]; - let mut frontier: Vec = Vec::new(); - for cy in (cy0 - 1).max(0)..=(cy1 + 1).min(gh - 1) { - for cx in (cx0 - 1).max(0)..=(cx1 + 1).min(gw - 1) { - if state[idx(cx, cy)] != 0 { + // Lobes past ~55% growth bud children on their rim, biased outward + // from the stab point, each announced by flung droplets — the same + // spatter the pooling phase taught the eye. + let mut count = blobs.len(); + let mut born: Vec = Vec::new(); + for b in blobs.iter_mut() { + if b.spawned || b.r < b.target * 0.55 || count + born.len() >= 60 { continue; } - let touches = NBR.iter().any(|&(dx, dy)| { - let (nx, ny) = (cx + dx, cy + dy); - nx >= 0 && ny >= 0 && nx < gw && ny < gh && state[idx(nx, ny)] == 1 - }); - if touches { - state[idx(cx, cy)] = 2; - frontier.push(cy * gw + cx); - } - } - } - if frontier.is_empty() { - return; - } - - // Absorbency 0..~100: bilinear value noise (~72px features — the paper's - // fibre structure) salted with per-cell grain. - let noise_seed = splat_seed(seed_region.x0, seed_region.y1); - let coarse = 12.0f32; - let lattice = - |lx: i32, ly: i32| (splat_hash(noise_seed, (ly * 517 + lx) as u32) % 1000) as f32 / 1000.0; - let absorb = |cx: i32, cy: i32| -> i32 { - let (fx, fy) = (cx as f32 / coarse, cy as f32 / coarse); - let (lx, ly) = (fx.floor() as i32, fy.floor() as i32); - let (tx, ty) = (fx - fx.floor(), fy - fy.floor()); - let v = lattice(lx, ly) * (1.0 - tx) * (1.0 - ty) - + lattice(lx + 1, ly) * tx * (1.0 - ty) - + lattice(lx, ly + 1) * (1.0 - tx) * ty - + lattice(lx + 1, ly + 1) * tx * ty; - let grain = (splat_hash(noise_seed ^ 0xA5A5, (cy * gw + cx) as u32) % 30) as f32 / 100.0; - ((v * 0.85 + grain) * 100.0) as i32 - }; - - let total = (gw * gh) as usize; - let mut gens_run = 0u32; - for frame in 0i32.. { - // Few generations at first (the soak visibly starts at the splat), - // many later (the page drowns in an accelerating rush). - let gens = (4.0 * 1.45f32.powi(frame)).min(260.0) as u32; - let mut newly: Vec = Vec::new(); - for _ in 0..gens { - if frontier.is_empty() { - break; + b.spawned = true; + // Lobes far off the page have nothing left to flood. + if b.x < -400 || b.y < -400 || b.x > w + 400 || b.y > h + 400 { + continue; } - gens_run += 1; - let mut next: Vec = Vec::with_capacity(frontier.len()); - for &c in &frontier { - if state[c as usize] == 1 { - continue; - } - let (cx, cy) = (c % gw, c / gw); - let mut n = 0; - for &(dx, dy) in &NBR { - let (nx, ny) = (cx + dx, cy + dy); - if nx >= 0 && ny >= 0 && nx < gw && ny < gh && state[idx(nx, ny)] == 1 { - n += 1; - } - } - let p = (18 + absorb(cx, cy) * 2 / 3 + n * 12).min(96) as u32; - if splat_hash(noise_seed ^ gens_run, c as u32) % 100 < p { - state[c as usize] = 1; - inked += 1; - newly.push(c); - for &(dx, dy) in &NBR { - let (nx, ny) = (cx + dx, cy + dy); - if nx >= 0 && ny >= 0 && nx < gw && ny < gh && state[idx(nx, ny)] == 0 { - state[idx(nx, ny)] = 2; - next.push(ny * gw + nx); - } - } + for k in 0..2u32 { + let hh = splat_hash(seed, next_id * 7 + k); + next_id += 1; + let base = if b.x == sx && b.y == sy { + ((hh % 6283) as f32 / 1000.0) + k as f32 * 2.6 } else { - next.push(c); + ((b.y - sy) as f32).atan2((b.x - sx) as f32) + }; + let ang = base + ((hh >> 8) % 2600) as f32 / 1000.0 - 1.3; + let dist = b.r * (0.85 + ((hh >> 20) % 40) as f32 / 100.0); + let cx = b.x + (dist * ang.cos()) as i32; + let cy = b.y + (dist * ang.sin()) as i32; + let target = (b.target * 1.45).min(760.0); + born.push(Blob { + x: cx, + y: cy, + r: target * 0.22, + target, + seed: hh | 1, + spawned: false, + }); + for d in 0..4u32 { + let dh = splat_hash(hh, d); + let da = ang + (dh % 1400) as f32 / 1000.0 - 0.7; + let dd = dist + target * (0.5 + ((dh >> 10) % 60) as f32 / 100.0); + let dr = 3 + ((dh >> 20) % 6) as i32; + let (px, py) = (b.x + (dd * da.cos()) as i32, b.y + (dd * da.sin()) as i32); + if px > -40 && py > -40 && px < w + 40 && py < h + 40 { + surf.stamp(px, py, dr, BLACK); + } } } - frontier = next; - } - // Ink the newly soaked cells as jittered discs: a grainy, fibrous - // front, never a lattice. - for &c in &newly { - let (cx, cy) = (c % gw, c / gw); - let h = splat_hash(noise_seed ^ 0x5EED, c as u32); - let jx = (h % 5) as i32 - 2; - let jy = ((h >> 4) % 5) as i32 - 2; - let r = 4 + ((h >> 8) % 3) as i32; - surf.stamp(cx * CELL + CELL / 2 + jx, cy * CELL + CELL / 2 + jy, r, BLACK); } - disp.update(0, 0, surf.w as i32, surf.h as i32, true); - if frontier.is_empty() || inked >= total || frame > 28 { + count += born.len(); + let _ = count; + blobs.extend(born); + disp.update(0, 0, w, h, true); + // Done once every corner has drowned under some lobe (0.5 x r is + // inside even at the wobble's deepest trough). + let corners = [(0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1)]; + let covered = corners.iter().all(|&(qx, qy)| { + blobs.iter().any(|b| { + let (dx, dy) = ((qx - b.x) as f32, (qy - b.y) as f32); + (dx * dx + dy * dy).sqrt() < b.r * 0.5 + }) + }); + if covered { break; } - std::thread::sleep(Duration::from_millis(120)); + std::thread::sleep(Duration::from_millis(110)); } - // Whatever stray dry specks remain, the ink claims: solid black before - // the drain, invisible against the already-drowned page. + // Claim any last dry slivers: solid black before the drain, an invisible + // seam against the drowned page. surf.fill_rect(0, 0, surf.w, surf.h, BLACK); - disp.update(0, 0, surf.w as i32, surf.h as i32, true); + disp.update(0, 0, w, h, true); +} + +/// One wobbly-edged blob, row-span filled: per row, take the angle a plain +/// circle would cross at, evaluate the two-lobe wobble there, intersect with +/// the row. Cheap at any radius, and monotonic in `r` for a fixed seed. +fn blob_span(surf: &mut Surface, cx: i32, cy: i32, r: i32, seed: u32) { + let p1 = (seed % 6283) as f32 / 1000.0; + let p2 = ((seed >> 10) % 6283) as f32 / 1000.0; + let wobble = |th: f32| { + 1.0 + 0.26 * (3.0 * th + p1).sin() + 0.16 * (7.0 * th + p2).sin() + + 0.08 * (15.0 * th + p1 + p2).sin() + }; + let rf = r as f32; + let reach = (rf * 1.55) as i32 + 1; + let y0 = (cy - reach).max(0); + let y1 = (cy + reach).min(surf.h as i32 - 1); + for y in y0..=y1 { + let dy = (y - cy) as f32; + let s = (dy / rf).clamp(-1.0, 1.0); + let th_right = s.asin(); + let th_left = std::f32::consts::PI - th_right; + let er = rf * wobble(th_right); + let el = rf * wobble(th_left); + let half_r = (er * er - dy * dy).max(0.0).sqrt() as i32; + let half_l = (el * el - dy * dy).max(0.0).sqrt() as i32; + let x0 = (cx - half_l).max(0); + let x1 = (cx + half_r).min(surf.w as i32 - 1); + for x in x0..=x1 { + surf.put_px(x, y, BLACK); + } + } } fn oracle_excuse(e: &str) -> String { From 67ea46e77f6aef5a29a387397bf72032bea45e02 Mon Sep 17 00:00:00 2001 From: vnnkl <8235476+vnnkl@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:41:47 +0200 Subject: [PATCH 6/9] =?UTF-8?q?feat:=20the=20stab=20is=20one=20continuous?= =?UTF-8?q?=20bleed=20=E2=80=94=20no=20stages=20at=20all?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every previous design had two visual acts (pool, then death) and the seam between them always showed. Now there is a single process from first contact to black page: a noise-displaced distance field — a pixel is inked once dist(px, tip) ≤ R·f(px), with f a multi-octave value-noise field ANCHORED TO THE PAPER. R creeps while the fang is held (the warning pool), and the same R accelerates past 3s (the death). Because the field belongs to the page, growth constantly reveals new structure — fingers run where the paper drinks fast, bays lag — never a resize, never a texture switch. Spatter droplets fling ahead as R passes their thresholds and are swallowed later. This is the community-standard ink-bleed construction (threshold of a noise-displaced radial field — the same recipe artists use manually: blur, displace, threshold), and the whole thing is integer math per pixel over the growth annulus, cheap enough for the A53. --- riddle/src/main.rs | 365 ++++++++++++++++++--------------------------- 1 file changed, 143 insertions(+), 222 deletions(-) diff --git a/riddle/src/main.rs b/riddle/src/main.rs index e9438da..fa06a05 100644 --- a/riddle/src/main.rs +++ b/riddle/src/main.rs @@ -258,8 +258,7 @@ fn run() -> std::io::Result<()> { // diary's memory dies. (origin x, origin y, press start), the nominal // splat radius already inked, and the bbox of everything the splat drew. let mut stab: Option<(i32, i32, Instant)> = None; - let mut stab_pool: i32 = 0; - let mut stab_bbox = BBox::empty(); + let mut bleed: Option = None; // Latest eraser contact this loop; None once the pen lifts or flips. let mut eraser_at: Option<(i32, i32)> = None; let mut last_flush = Instant::now(); @@ -360,7 +359,7 @@ fn run() -> std::io::Result<()> { // While the stab's warning ink pools, the tip // is a fang, not an eraser: don't erase (and // don't drop stroke points under the splat). - if stab_pool > 0 { + if bleed.is_some() { BBox::empty() } else { user_ink.erase_point(&mut surf, s.x, s.y, 22) @@ -421,34 +420,54 @@ fn run() -> std::io::Result<()> { } } - // ---- basilisk-fang stab (hold the eraser still for 3s) ---- + // ---- basilisk-fang stab (hold the eraser still on one spot) ---- + // One continuous process from first pooling to page-drown: the bleed + // (a noise-displaced distance field anchored to the paper) creeps + // while the fang is held, and the SAME bleed accelerates into the + // death past 3s. Lift before then and the page reabsorbs it. let stab_live = pen_down && matches!(state, State::Listening { .. }); match (stab, eraser_at, stab_live) { (None, Some((x, y)), true) => stab = Some((x, y, Instant::now())), (Some((sx, sy, t0)), Some((x, y)), true) => { let (dx, dy) = (x - sx, y - sy); if dx * dx + dy * dy > 20 * 20 { - // Drifted: this is erasing, not stabbing. Reabsorb any - // warning ink and re-arm at the new spot. - if !stab_bbox.is_empty() { - absorb_region(&mut surf, &disp, stab_bbox); + // Drifted: this is erasing, not stabbing. + if let Some(bl) = bleed.take() { + if !bl.bbox.is_empty() { + absorb_region(&mut surf, &disp, bl.bbox); + } } stab = Some((x, y, Instant::now())); - stab_pool = 0; - stab_bbox = BBox::empty(); } else { let held = t0.elapsed(); if held >= Duration::from_secs(3) { - // The fang lands. The death speaks the same language - // as the warning: new lobes of ink — wobbly blobs - // just like the pooled one, each flinging the same - // droplet spatter ahead of itself — keep erupting - // from the stain's edge, budding outward generation - // by generation until the page drowns. Nothing is - // ever scaled or re-textured. + // The fang stays in the wound: the diary bleeds out. + // Same bleed, same field — R just stops being gentle. eprintln!("riddle: basilisk fang — the diary's memory is erased"); - let seed = splat_seed(sx, sy); - flood_bloom(&mut surf, &disp, sx, sy, stab_pool.max(40), seed); + let mut bl = bleed + .take() + .unwrap_or_else(|| bleed_new(&surf, sx, sy, splat_seed(sx, sy))); + let (w, h) = (surf.w as i32, surf.h as i32); + let corners = [(0i32, 0i32), (w - 1, 0), (0, h - 1), (w - 1, h - 1)]; + loop { + let nr = (bl.r * 1.32 + 14.0).min(6000.0); + bleed_grow(&mut surf, &mut bl, nr); + disp.update(0, 0, w, h, true); + let drowned = corners.iter().all(|&(qx, qy)| { + let (cdx, cdy) = ((qx - bl.ox) as i64, (qy - bl.oy) as i64); + let d2 = (cdx * cdx + cdy * cdy) as u64; + let g = bl.field + [((qy / BLEED_CELL) * bl.fw + qx / BLEED_CELL) as usize] + as u64; + d2 * 4096 <= (bl.r * bl.r) as u64 * g * g + }); + if drowned { + break; + } + std::thread::sleep(Duration::from_millis(90)); + } + surf.fill_rect(0, 0, surf.w, surf.h, BLACK); + disp.update(0, 0, w, h, true); std::thread::sleep(Duration::from_millis(500)); if let Some(ref mut s) = store { s.forget_all(); @@ -458,37 +477,34 @@ fn run() -> std::io::Result<()> { user_ink.clear(); ink_dirty = BBox::empty(); stab = None; - stab_pool = 0; - stab_bbox = BBox::empty(); eraser_at = None; state = State::Listening { last_pen: None }; - } else if held >= Duration::from_secs(1) { - // Warning ink pools at the stab point — the writer's - // signal that the stab is registering; lift to abort. - let ms = (held.as_millis() as i32 - 1000).clamp(0, 2000); - let r = 10 + ms * 100 / 2000; - if r >= stab_pool + 3 { - let seed = splat_seed(sx, sy); - let b = draw_splat(&mut surf, sx, sy, r, seed); - stab_bbox.add(b.x0, b.y0, 0); - stab_bbox.add(b.x1, b.y1, 0); - let (bx, by, bw, bh) = b.rect(); + } else if held >= Duration::from_millis(800) { + // The bleed pools under the held fang — the warning, + // and the writer's window to abort by lifting. + if bleed.is_none() { + bleed = Some(bleed_new(&surf, sx, sy, splat_seed(sx, sy))); + } + let bl = bleed.as_mut().unwrap(); + let target = 8.0 + (held.as_millis() as f32 - 800.0) / 2200.0 * 110.0; + if target >= bl.r + 2.0 { + bleed_grow(&mut surf, bl, target); + let (bx, by, bw, bh) = bl.bbox.rect(); disp.update(bx, by, bw, bh, true); - stab_pool = r; } } } } (Some(_), _, _) => { - // Lifted (or the state moved on): abort, reabsorb the warning - // ink. Anything under the splat goes with it — the writer did - // press an eraser there. - if !stab_bbox.is_empty() { - absorb_region(&mut surf, &disp, stab_bbox); + // Lifted (or the state moved on): the page reabsorbs the + // bleed. Anything under it goes too — an eraser was pressed + // there, after all. + if let Some(bl) = bleed.take() { + if !bl.bbox.is_empty() { + absorb_region(&mut surf, &disp, bl.bbox); + } } stab = None; - stab_pool = 0; - stab_bbox = BBox::empty(); } _ => {} } @@ -878,60 +894,102 @@ fn splat_hash(seed: u32, i: u32) -> u32 { h ^ (h >> 13) } -/// Draw an ink splat of nominal radius `r`: an irregular blob (its edge -/// wobbles with two sine lobes, phases from `seed`) ringed by satellite -/// droplets and radial streaks. Droplets have fixed positions and appear as -/// `r` passes each one's threshold, so a growing splat spreads organically — -/// pixels only ever gain ink, never lose it, between redraws at the same -/// seed. Returns the bbox of everything drawn. -fn draw_splat(surf: &mut Surface, cx: i32, cy: i32, r: i32, seed: u32) -> BBox { - let mut bbox = BBox::empty(); +/// A single continuous ink bleed, from first pooling to page-drown: a pixel +/// is inked once dist(px, tip) ≤ R · f(px), where f is a multi-octave value- +/// noise field ANCHORED TO THE PAPER. One process, one look: as R grows the +/// front keeps meeting new paper — fingers run where the page drinks fast, +/// bays lag where it doesn't — so growth reads as spreading ink, never as a +/// shape being resized (the field never moves with the stain). +struct Bleed { + ox: i32, + oy: i32, + /// Paper absorbency ≈ f×64 (f ~ 0.55..1.55), sampled every 4px. + field: Vec, + fw: i32, + r: f32, + bbox: BBox, + seed: u32, +} - // The main blob: edge(θ) = r · (1 + 0.26·sin(3θ+p1) + 0.16·sin(7θ+p2)). - let p1 = (seed % 6283) as f32 / 1000.0; - let p2 = ((seed >> 10) % 6283) as f32 / 1000.0; - let rmax = (r as f32 * 1.45) as i32 + 2; - for dy in -rmax..=rmax { - for dx in -rmax..=rmax { - let d = ((dx * dx + dy * dy) as f32).sqrt(); - if d > rmax as f32 { - continue; - } - let th = (dy as f32).atan2(dx as f32); - let edge = r as f32 * (1.0 + 0.26 * (3.0 * th + p1).sin() + 0.16 * (7.0 * th + p2).sin()); - if d <= edge { - surf.put_px(cx + dx, cy + dy, BLACK); - bbox.add(cx + dx, cy + dy, 1); +const BLEED_CELL: i32 = 4; + +fn bleed_new(surf: &Surface, ox: i32, oy: i32, seed: u32) -> Bleed { + let fw = surf.w as i32 / BLEED_CELL + 2; + let fh = surf.h as i32 / BLEED_CELL + 2; + // Three octaves of bilinear value noise (~44/100/220px wavelengths): + // fine feathering on the small pool, long fibres for the page-scale run. + let mut field = vec![0u8; (fw * fh) as usize]; + let octaves: [(i32, f32); 3] = [(11, 0.35), (25, 0.30), (55, 0.35)]; + let lat = |s: u32, lx: i32, ly: i32| { + (splat_hash(seed ^ s, (ly * 977 + lx) as u32) % 1000) as f32 / 1000.0 + }; + for y in 0..fh { + for x in 0..fw { + let mut n = 0.0f32; + for (oi, &(wl, amp)) in octaves.iter().enumerate() { + let (fx, fy) = (x as f32 / wl as f32, y as f32 / wl as f32); + let (lx, ly) = (fx.floor() as i32, fy.floor() as i32); + let (tx, ty) = (fx - fx.floor(), fy - fy.floor()); + let s = 0x1000 * (oi as u32 + 1); + let v = lat(s, lx, ly) * (1.0 - tx) * (1.0 - ty) + + lat(s, lx + 1, ly) * tx * (1.0 - ty) + + lat(s, lx, ly + 1) * (1.0 - tx) * ty + + lat(s, lx + 1, ly + 1) * tx * ty; + n += v * amp; } + field[(y * fw + x) as usize] = ((0.55 + n) * 64.0) as u8; } } + Bleed { ox, oy, field, fw, r: 0.0, bbox: BBox::empty(), seed } +} - // Satellite droplets: fixed absolute positions derived from their own - // appearance threshold, so already-drawn droplets never move. - for i in 0..26u32 { - let h = splat_hash(seed, i); - let threshold = 12 + (h % 99) as i32; // r at which this droplet lands - if r < threshold { +/// Grow the bleed to nominal radius `new_r`: ink every newly claimed pixel +/// (d² · 4096 ≤ R² · g², integer-only per pixel), plus the spatter droplets +/// whose appearance thresholds R just crossed — flung ink landing ahead of +/// the front, swallowed by it later. +fn bleed_grow(surf: &mut Surface, b: &mut Bleed, new_r: f32) { + let old_r = b.r; + if new_r <= old_r { + return; + } + b.r = new_r; + let (w, h) = (surf.w as i32, surf.h as i32); + let reach = (new_r * 1.6) as i32 + 2; + let y0 = (b.oy - reach).max(0); + let y1 = (b.oy + reach).min(h - 1); + let x0 = (b.ox - reach).max(0); + let x1 = (b.ox + reach).min(w - 1); + let r2 = (new_r * new_r) as u64; + let old2 = (old_r * old_r) as u64; + for y in y0..=y1 { + let frow = (y / BLEED_CELL) * b.fw; + for x in x0..=x1 { + let (dx, dy) = ((x - b.ox) as i64, (y - b.oy) as i64); + let d2 = (dx * dx + dy * dy) as u64; + let g = b.field[(frow + x / BLEED_CELL) as usize] as u64; + let g2 = g * g; + let lhs = d2 * 4096; + if lhs <= r2 * g2 && lhs > old2 * g2 { + surf.put_px(x, y, BLACK); + b.bbox.add(x, y, 1); + } + } + } + for i in 0..40u32 { + let hh = splat_hash(b.seed ^ 0xD09, i); + let thr = 12.0 + (hh % 300) as f32; + if !(old_r < thr && thr <= new_r) { continue; } - let ang = ((h >> 7) % 6283) as f32 / 1000.0; - let dist = threshold as f32 * (1.3 + ((h >> 17) % 100) as f32 / 80.0); - let size = 2 + ((h >> 24) % 5) as i32; - let (px, py) = (cx + (dist * ang.cos()) as i32, cy + (dist * ang.sin()) as i32); - surf.stamp(px, py, size, BLACK); - bbox.add(px, py, size + 1); - // Every third droplet is flung: a radial streak of shrinking dots. - if h % 3 == 0 { - for step in 1..=3 { - let d2 = dist + (step * (size + 2)) as f32; - let (qx, qy) = (cx + (d2 * ang.cos()) as i32, cy + (d2 * ang.sin()) as i32); - let sr = (size - step / 2).max(1); - surf.stamp(qx, qy, sr, BLACK); - bbox.add(qx, qy, sr + 1); - } + let ang = ((hh >> 9) % 6283) as f32 / 1000.0; + let dist = thr * (1.25 + ((hh >> 20) % 80) as f32 / 100.0); + let rr = 2 + ((hh >> 27) % 6) as i32; + let (px, py) = (b.ox + (dist * ang.cos()) as i32, b.oy + (dist * ang.sin()) as i32); + if px > -20 && py > -20 && px < w + 20 && py < h + 20 { + surf.stamp(px, py, rr, BLACK); + b.bbox.add(px, py, rr + 1); } } - bbox } /// Reabsorb warning ink after an aborted stab: dissolve the region in place. @@ -944,143 +1002,6 @@ fn absorb_region(surf: &mut Surface, disp: &display::Display, region: BBox) { } } -/// The stab flood: compound blob budding. The death uses the exact visual -/// vocabulary of the pooled warning splat — wobbly-edged blobs and flung -/// droplet spatter — and simply produces MORE of it: lobes erupt from the -/// stain's edge, each growing in place and spawning outward-biased children -/// (with their own spatter) until the page drowns. Growth is new ink -/// arriving, never a resize of what's already there. -struct Blob { - x: i32, - y: i32, - r: f32, - target: f32, - seed: u32, - spawned: bool, -} - -fn flood_bloom(surf: &mut Surface, disp: &display::Display, sx: i32, sy: i32, r0: i32, seed: u32) { - let (w, h) = (surf.w as i32, surf.h as i32); - let mut blobs = vec![Blob { - x: sx, - y: sy, - r: r0 as f32, - target: r0 as f32 * 1.7, - seed, - spawned: false, - }]; - let mut next_id = 1u32; - for _frame in 0..22 { - // Grow every unfinished lobe a step and re-ink it (same seed: the - // wobbled outline only ever gains ink). - for b in blobs.iter_mut() { - if b.r < b.target { - b.r = (b.r * 1.45 + 12.0).min(b.target); - blob_span(surf, b.x, b.y, b.r as i32, b.seed); - } - } - // Lobes past ~55% growth bud children on their rim, biased outward - // from the stab point, each announced by flung droplets — the same - // spatter the pooling phase taught the eye. - let mut count = blobs.len(); - let mut born: Vec = Vec::new(); - for b in blobs.iter_mut() { - if b.spawned || b.r < b.target * 0.55 || count + born.len() >= 60 { - continue; - } - b.spawned = true; - // Lobes far off the page have nothing left to flood. - if b.x < -400 || b.y < -400 || b.x > w + 400 || b.y > h + 400 { - continue; - } - for k in 0..2u32 { - let hh = splat_hash(seed, next_id * 7 + k); - next_id += 1; - let base = if b.x == sx && b.y == sy { - ((hh % 6283) as f32 / 1000.0) + k as f32 * 2.6 - } else { - ((b.y - sy) as f32).atan2((b.x - sx) as f32) - }; - let ang = base + ((hh >> 8) % 2600) as f32 / 1000.0 - 1.3; - let dist = b.r * (0.85 + ((hh >> 20) % 40) as f32 / 100.0); - let cx = b.x + (dist * ang.cos()) as i32; - let cy = b.y + (dist * ang.sin()) as i32; - let target = (b.target * 1.45).min(760.0); - born.push(Blob { - x: cx, - y: cy, - r: target * 0.22, - target, - seed: hh | 1, - spawned: false, - }); - for d in 0..4u32 { - let dh = splat_hash(hh, d); - let da = ang + (dh % 1400) as f32 / 1000.0 - 0.7; - let dd = dist + target * (0.5 + ((dh >> 10) % 60) as f32 / 100.0); - let dr = 3 + ((dh >> 20) % 6) as i32; - let (px, py) = (b.x + (dd * da.cos()) as i32, b.y + (dd * da.sin()) as i32); - if px > -40 && py > -40 && px < w + 40 && py < h + 40 { - surf.stamp(px, py, dr, BLACK); - } - } - } - } - count += born.len(); - let _ = count; - blobs.extend(born); - disp.update(0, 0, w, h, true); - // Done once every corner has drowned under some lobe (0.5 x r is - // inside even at the wobble's deepest trough). - let corners = [(0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1)]; - let covered = corners.iter().all(|&(qx, qy)| { - blobs.iter().any(|b| { - let (dx, dy) = ((qx - b.x) as f32, (qy - b.y) as f32); - (dx * dx + dy * dy).sqrt() < b.r * 0.5 - }) - }); - if covered { - break; - } - std::thread::sleep(Duration::from_millis(110)); - } - // Claim any last dry slivers: solid black before the drain, an invisible - // seam against the drowned page. - surf.fill_rect(0, 0, surf.w, surf.h, BLACK); - disp.update(0, 0, w, h, true); -} - -/// One wobbly-edged blob, row-span filled: per row, take the angle a plain -/// circle would cross at, evaluate the two-lobe wobble there, intersect with -/// the row. Cheap at any radius, and monotonic in `r` for a fixed seed. -fn blob_span(surf: &mut Surface, cx: i32, cy: i32, r: i32, seed: u32) { - let p1 = (seed % 6283) as f32 / 1000.0; - let p2 = ((seed >> 10) % 6283) as f32 / 1000.0; - let wobble = |th: f32| { - 1.0 + 0.26 * (3.0 * th + p1).sin() + 0.16 * (7.0 * th + p2).sin() - + 0.08 * (15.0 * th + p1 + p2).sin() - }; - let rf = r as f32; - let reach = (rf * 1.55) as i32 + 1; - let y0 = (cy - reach).max(0); - let y1 = (cy + reach).min(surf.h as i32 - 1); - for y in y0..=y1 { - let dy = (y - cy) as f32; - let s = (dy / rf).clamp(-1.0, 1.0); - let th_right = s.asin(); - let th_left = std::f32::consts::PI - th_right; - let er = rf * wobble(th_right); - let el = rf * wobble(th_left); - let half_r = (er * er - dy * dy).max(0.0).sqrt() as i32; - let half_l = (el * el - dy * dy).max(0.0).sqrt() as i32; - let x0 = (cx - half_l).max(0); - let x1 = (cx + half_r).min(surf.w as i32 - 1); - for x in x0..=x1 { - surf.put_px(x, y, BLACK); - } - } -} - fn oracle_excuse(e: &str) -> String { if e.contains("no oracle") { "The diary lies dormant: it found no oracle. \ From 470c4a7fc26846f430377c3a035dc2462d42f400 Mon Sep 17 00:00:00 2001 From: vnnkl <8235476+vnnkl@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:45:12 +0200 Subject: [PATCH 7/9] fix: ease the death's acceleration in The bleed jumped from ~50px/s creep to ~550px/s the instant 3s hit. The growth multiplier and increment now ramp over the first ~8 frames (~0.7s), so the creep visibly leans into the rush instead of snapping. --- riddle/src/main.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/riddle/src/main.rs b/riddle/src/main.rs index fa06a05..066f7ea 100644 --- a/riddle/src/main.rs +++ b/riddle/src/main.rs @@ -449,8 +449,14 @@ fn run() -> std::io::Result<()> { .unwrap_or_else(|| bleed_new(&surf, sx, sy, splat_seed(sx, sy))); let (w, h) = (surf.w as i32, surf.h as i32); let corners = [(0i32, 0i32), (w - 1, 0), (0, h - 1), (w - 1, h - 1)]; + // Ease into the rush: the creep leans forward over + // the first ~0.7s instead of snapping to full speed. + let mut frame = 0i32; loop { - let nr = (bl.r * 1.32 + 14.0).min(6000.0); + let mult = 1.0 + (0.04 * frame as f32).min(0.30); + let add = (3 + frame * 2).min(20) as f32; + let nr = (bl.r * mult + add).min(6000.0); + frame += 1; bleed_grow(&mut surf, &mut bl, nr); disp.update(0, 0, w, h, true); let drowned = corners.iter().all(|&(qx, qy)| { From d736100e926f242850711c0a43f053c73f5fc700 Mon Sep 17 00:00:00 2001 From: vnnkl <8235476+vnnkl@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:47:51 +0200 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20slow=20the=20death's=20full=20speed?= =?UTF-8?q?=20=E2=80=94=20a=20drowning,=20not=20an=20explosion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gentler ramp (0.025/frame, cap 1.22x) and smaller increments; the page now drowns in roughly three seconds instead of under two. --- riddle/src/main.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/riddle/src/main.rs b/riddle/src/main.rs index 066f7ea..9b11027 100644 --- a/riddle/src/main.rs +++ b/riddle/src/main.rs @@ -450,11 +450,13 @@ fn run() -> std::io::Result<()> { let (w, h) = (surf.w as i32, surf.h as i32); let corners = [(0i32, 0i32), (w - 1, 0), (0, h - 1), (w - 1, h - 1)]; // Ease into the rush: the creep leans forward over - // the first ~0.7s instead of snapping to full speed. + // the first second or so instead of snapping to full + // speed, and the full speed itself stays unhurried — + // a drowning, not an explosion. let mut frame = 0i32; loop { - let mult = 1.0 + (0.04 * frame as f32).min(0.30); - let add = (3 + frame * 2).min(20) as f32; + let mult = 1.0 + (0.025 * frame as f32).min(0.22); + let add = (2 + frame).min(14) as f32; let nr = (bl.r * mult + add).min(6000.0); frame += 1; bleed_grow(&mut surf, &mut bl, nr); From 0aa7b5c68afe66c61c91678b8604da51e3830160 Mon Sep 17 00:00:00 2001 From: vnnkl <8235476+vnnkl@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:08:28 +0200 Subject: [PATCH 9/9] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20abort?= =?UTF-8?q?=20preserves=20handwriting,=20erase=20leaves=20no=20orphans?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Aborting a stab dissolved every dark pixel in the bleed's bbox, destroying handwriting under the warning pool even though the action was aborted — and leaving the stroke model referencing ink that was no longer visible (phantom '?' detection, invisible ink in memory replay and oracle snapshots). The bleed now records exactly the pixels IT blackened (white→black transitions, pooling only; the death drops the log) and an abort restores exactly those, so ink that predated the pool survives untouched and screen and stroke model stay in agreement. - forget_all only deleted stroke files listed in the loaded index, leaving orphaned .strokes behind after a lost/corrupt index.tsv or a strokes-written-index-failed append. It now sweeps every *.strokes file in the memory dir — erase means erase. --- riddle/src/main.rs | 61 ++++++++++++++++++++++++++++++++------------ riddle/src/memory.rs | 12 +++++++-- 2 files changed, 55 insertions(+), 18 deletions(-) diff --git a/riddle/src/main.rs b/riddle/src/main.rs index 9b11027..5106cbf 100644 --- a/riddle/src/main.rs +++ b/riddle/src/main.rs @@ -433,9 +433,7 @@ fn run() -> std::io::Result<()> { if dx * dx + dy * dy > 20 * 20 { // Drifted: this is erasing, not stabbing. if let Some(bl) = bleed.take() { - if !bl.bbox.is_empty() { - absorb_region(&mut surf, &disp, bl.bbox); - } + bleed_absorb(&mut surf, &disp, &bl); } stab = Some((x, y, Instant::now())); } else { @@ -447,6 +445,8 @@ fn run() -> std::io::Result<()> { let mut bl = bleed .take() .unwrap_or_else(|| bleed_new(&surf, sx, sy, splat_seed(sx, sy))); + // No abort past this point — the undo log can go. + bl.painted = Vec::new(); let (w, h) = (surf.w as i32, surf.h as i32); let corners = [(0i32, 0i32), (w - 1, 0), (0, h - 1), (w - 1, h - 1)]; // Ease into the rush: the creep leans forward over @@ -459,7 +459,7 @@ fn run() -> std::io::Result<()> { let add = (2 + frame).min(14) as f32; let nr = (bl.r * mult + add).min(6000.0); frame += 1; - bleed_grow(&mut surf, &mut bl, nr); + bleed_grow(&mut surf, &mut bl, nr, false); disp.update(0, 0, w, h, true); let drowned = corners.iter().all(|&(qx, qy)| { let (cdx, cdy) = ((qx - bl.ox) as i64, (qy - bl.oy) as i64); @@ -496,7 +496,7 @@ fn run() -> std::io::Result<()> { let bl = bleed.as_mut().unwrap(); let target = 8.0 + (held.as_millis() as f32 - 800.0) / 2200.0 * 110.0; if target >= bl.r + 2.0 { - bleed_grow(&mut surf, bl, target); + bleed_grow(&mut surf, bl, target, true); let (bx, by, bw, bh) = bl.bbox.rect(); disp.update(bx, by, bw, bh, true); } @@ -508,9 +508,7 @@ fn run() -> std::io::Result<()> { // bleed. Anything under it goes too — an eraser was pressed // there, after all. if let Some(bl) = bleed.take() { - if !bl.bbox.is_empty() { - absorb_region(&mut surf, &disp, bl.bbox); - } + bleed_absorb(&mut surf, &disp, &bl); } stab = None; } @@ -917,6 +915,10 @@ struct Bleed { r: f32, bbox: BBox, seed: u32, + /// Pixels the bleed itself blackened (they were white before), pooling + /// only. An abort restores exactly these — handwriting beneath the pool + /// was already dark, never enters this list, and so survives untouched. + painted: Vec<(u16, u16)>, } const BLEED_CELL: i32 = 4; @@ -948,14 +950,14 @@ fn bleed_new(surf: &Surface, ox: i32, oy: i32, seed: u32) -> Bleed { field[(y * fw + x) as usize] = ((0.55 + n) * 64.0) as u8; } } - Bleed { ox, oy, field, fw, r: 0.0, bbox: BBox::empty(), seed } + Bleed { ox, oy, field, fw, r: 0.0, bbox: BBox::empty(), seed, painted: Vec::new() } } /// Grow the bleed to nominal radius `new_r`: ink every newly claimed pixel /// (d² · 4096 ≤ R² · g², integer-only per pixel), plus the spatter droplets /// whose appearance thresholds R just crossed — flung ink landing ahead of /// the front, swallowed by it later. -fn bleed_grow(surf: &mut Surface, b: &mut Bleed, new_r: f32) { +fn bleed_grow(surf: &mut Surface, b: &mut Bleed, new_r: f32, record: bool) { let old_r = b.r; if new_r <= old_r { return; @@ -978,6 +980,9 @@ fn bleed_grow(surf: &mut Surface, b: &mut Bleed, new_r: f32) { let g2 = g * g; let lhs = d2 * 4096; if lhs <= r2 * g2 && lhs > old2 * g2 { + if record && surf.luma(x, y) >= 128 { + b.painted.push((x as u16, y as u16)); + } surf.put_px(x, y, BLACK); b.bbox.add(x, y, 1); } @@ -994,17 +999,41 @@ fn bleed_grow(surf: &mut Surface, b: &mut Bleed, new_r: f32) { let rr = 2 + ((hh >> 27) % 6) as i32; let (px, py) = (b.ox + (dist * ang.cos()) as i32, b.oy + (dist * ang.sin()) as i32); if px > -20 && py > -20 && px < w + 20 && py < h + 20 { - surf.stamp(px, py, rr, BLACK); + for oy in -rr..=rr { + for ox in -rr..=rr { + if ox * ox + oy * oy > rr * rr { + continue; + } + let (qx, qy) = (px + ox, py + oy); + if qx < 0 || qy < 0 || qx >= w || qy >= h { + continue; + } + if record && surf.luma(qx, qy) >= 128 { + b.painted.push((qx as u16, qy as u16)); + } + surf.put_px(qx, qy, BLACK); + } + } b.bbox.add(px, py, rr + 1); } } } -/// Reabsorb warning ink after an aborted stab: dissolve the region in place. -fn absorb_region(surf: &mut Surface, disp: &display::Display, region: BBox) { - for stage in 0..8 { - ink::dissolve_pass(surf, region, stage, 8); - let (x, y, w, h) = region.rect(); +/// Reabsorb an aborted bleed: restore exactly the pixels the bleed itself +/// blackened, in the same staggered pattern as the drink dissolve. Ink that +/// was already on the page (handwriting under the pool) was never recorded +/// and is left untouched, keeping the screen true to the stroke model. +fn bleed_absorb(surf: &mut Surface, disp: &display::Display, bl: &Bleed) { + if bl.painted.is_empty() { + return; + } + for stage in 0..8u32 { + for &(x, y) in &bl.painted { + if splat_seed(x as i32, y as i32) % 8 <= stage { + surf.put_px(x as i32, y as i32, WHITE); + } + } + let (x, y, w, h) = bl.bbox.rect(); disp.update(x, y, w, h, true); std::thread::sleep(Duration::from_millis(45)); } diff --git a/riddle/src/memory.rs b/riddle/src/memory.rs index 8d1f446..9636cb0 100644 --- a/riddle/src/memory.rs +++ b/riddle/src/memory.rs @@ -110,9 +110,17 @@ impl MemoryStore { } /// The basilisk fang lands: forget every remembered page, on disk too. + /// Every `*.strokes` file in the memory dir goes — including orphans not + /// in the loaded index (a lost/corrupt index.tsv, or an append that wrote + /// strokes and then failed) — because erase must mean erase. pub fn forget_all(&mut self) { - for e in &self.entries { - let _ = std::fs::remove_file(self.strokes_path(e.id)); + if let Ok(dir) = std::fs::read_dir(&self.dir) { + for entry in dir.flatten() { + let p = entry.path(); + if p.extension().is_some_and(|x| x == "strokes") { + let _ = std::fs::remove_file(&p); + } + } } let _ = std::fs::remove_file(self.index_path()); self.entries.clear();