From f0653ea5bb05f4015a685d13e38f8a9241ca09b2 Mon Sep 17 00:00:00 2001 From: vnnkl <8235476+vnnkl@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:46:09 +0200 Subject: [PATCH 1/2] feat: keep a lingering reply on the page while the writer answers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Any pen contact during Lingering dissolved the reply before the writer could ink a stroke — you could never write your answer while reading what the diary said. Now the contact that used to dismiss the reply starts the writer's stroke instead; the reply stays put and is drunk together with their ink at commit time (it still fades on its own if they never write). The oracle snapshot must not photograph the lingering reply, so to_png now replays the stroke model into a clean offscreen buffer instead of reading the screen. Erases already edit the stroke model (forget_near), so the replay is faithful to the visible ink; geometry mirrors Surface::stamp/brush_line. Adds a test that screen-only pixels cannot leak into the oracle image. --- riddle/src/ink.rs | 100 +++++++++++++++++++++++++++++++++++++++++++-- riddle/src/main.rs | 48 +++++++++++++++++----- 2 files changed, 135 insertions(+), 13 deletions(-) diff --git a/riddle/src/ink.rs b/riddle/src/ink.rs index 1b9a549..9876e29 100644 --- a/riddle/src/ink.rs +++ b/riddle/src/ink.rs @@ -108,6 +108,13 @@ impl Ink { /// Crops to the ink bounding box and box-downscales so the long side stays /// ≤ 800px (at least 2x): the model reads handwriting fine at that scale, /// and image pixels are the dominant vision-token / latency cost. + /// + /// The image is built by replaying the stroke model into a clean offscreen + /// buffer — NOT by reading the screen — so anything else on the page (a + /// lingering reply the writer answers underneath) never leaks into what + /// the oracle sees. Erases already edit the stroke model (`forget_near`), + /// so the replay is faithful to the visible ink. `surf` is only consulted + /// for the page dimensions. pub fn to_png(&self, surf: &Surface, path: &str) -> std::io::Result<()> { if self.bbox.is_empty() { return Err(std::io::Error::other("no ink")); @@ -117,8 +124,16 @@ impl Ink { let y0 = (by - 20).max(0) as usize; let x1 = ((bx + bw + 20) as usize).min(surf.w); let y1 = ((by + bh + 20) as usize).min(surf.h); - let f = ((x1 - x0).max(y1 - y0)).div_ceil(800).max(2); - let (w, h) = ((x1 - x0) / f, (y1 - y0) / f); + let (cw, ch) = (x1 - x0, y1 - y0); + + // Full-resolution replay of the writer's ink on a white page crop. + let mut page = vec![255u8; cw * ch]; + for stroke in self.strokes.iter().chain(std::iter::once(&self.current)) { + replay_stroke(&mut page, cw, ch, x0 as i32, y0 as i32, stroke); + } + + let f = cw.max(ch).div_ceil(800).max(2); + let (w, h) = (cw / f, ch / f); let mut gray = vec![0u8; w * h]; for oy in 0..h { @@ -126,7 +141,7 @@ impl Ink { let mut acc = 0u32; for sy in 0..f { for sx in 0..f { - acc += surf.luma((x0 + ox * f + sx) as i32, (y0 + oy * f + sy) as i32) as u32; + acc += page[(oy * f + sy) * cw + ox * f + sx] as u32; } } gray[oy * w + ox] = (acc / (f * f) as u32) as u8; @@ -147,6 +162,47 @@ impl Ink { } } +/// Replay one stroke into a grayscale crop buffer, mirroring the on-screen +/// geometry of `pen_point`: first point stamps a disc; later points brush +/// from the previous one with the same radius-growth clamp. +fn replay_stroke(page: &mut [u8], w: usize, h: usize, ox: i32, oy: i32, pts: &[(i32, i32, i32)]) { + let mut last: Option<(i32, i32, i32)> = None; + for &(x, y, r) in pts { + let (cx, cy) = (x - ox, y - oy); + match last { + Some((px, py, pr)) => brush_g(page, w, h, px, py, cx, cy, r.min(pr + 1)), + None => stamp_g(page, w, h, cx, cy, r), + } + last = Some((cx, cy, r)); + } +} + +/// `Surface::stamp` (black ink) for a grayscale buffer. +fn stamp_g(page: &mut [u8], w: usize, h: usize, cx: i32, cy: i32, r: i32) { + for dy in -r..=r { + for dx in -r..=r { + if dx * dx + dy * dy <= r * r { + let (x, y) = (cx + dx, cy + dy); + if x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h { + page[y as usize * w + x as usize] = 0; + } + } + } + } +} + +/// `Surface::brush_line` (black ink) for a grayscale buffer. +fn brush_g(page: &mut [u8], w: usize, h: usize, x0: i32, y0: i32, x1: i32, y1: i32, r: i32) { + let dx = (x1 - x0).abs(); + let dy = (y1 - y0).abs(); + let steps = dx.max(dy).max(1); + for i in 0..=steps { + let x = x0 + (x1 - x0) * i / steps; + let y = y0 + (y1 - y0) * i / steps; + stamp_g(page, w, h, x, y, r); + } +} + /// Deterministic per-pixel hash for the dissolve pattern. #[inline] fn px_hash(x: i32, y: i32) -> u32 { @@ -208,6 +264,44 @@ mod tests { } } + #[test] + fn to_png_sees_only_the_stroke_model_not_the_screen() { + let (_buf, mut s) = surf(); + let mut ink = Ink::new(); + // The writer's ink: a short stroke. + for x in (60..=160).step_by(10) { + ink.pen_point(&mut s, x, 100, 3); + } + ink.pen_up(); + // A "lingering reply" painted on the SCREEN near the ink (inside the + // crop, clear of the stroke), but not in the stroke model — it must + // not appear in the oracle snapshot. + s.stamp(110, 115, 8, BLACK); + + let path = std::env::temp_dir().join("riddle-ink-test.png"); + ink.to_png(&s, path.to_str().unwrap()).unwrap(); + + let dec = png::Decoder::new(std::fs::File::open(&path).unwrap()); + let mut reader = dec.read_info().unwrap(); + let mut img = vec![0u8; reader.output_buffer_size()]; + let info = reader.next_frame(&mut img).unwrap(); + let (w, h) = (info.width as usize, info.height as usize); + let f = 2; // crop is small, so the min downscale factor applies + // Screen-blot center in image coords: crop starts at bbox-20. + let (bx, by, _, _) = ink.bbox.rect(); + let (ix, iy) = ((110 - (bx - 20)) as usize / f, (115 - (by - 20)) as usize / f); + assert!(iy < h && ix < w, "blot should fall inside the crop"); + assert!( + img[iy * w + ix] > 200, + "screen-only pixels leaked into the oracle snapshot (luma {})", + img[iy * w + ix] + ); + // And the real stroke IS there. + let (sx, sy) = ((110 - (bx - 20)) as usize / f, (100 - (by - 20)) as usize / f); + assert!(img[sy * w + sx] < 60, "the writer's ink is missing from the snapshot"); + let _ = std::fs::remove_file(&path); + } + #[test] fn erasing_everything_empties_the_ink() { let (_buf, mut s) = surf(); diff --git a/riddle/src/main.rs b/riddle/src/main.rs index 24601b3..c710b8b 100644 --- a/riddle/src/main.rs +++ b/riddle/src/main.rs @@ -60,7 +60,9 @@ type OracleRx = mpsc::Receiver>; enum State { Listening { last_pen: Option }, - Drinking { stage: u32, next: Instant, region: BBox, rx: OracleRx }, + /// `reply`: a lingering reply the writer wrote underneath — drunk + /// together with the new ink (empty when there was none). + Drinking { stage: u32, next: Instant, region: BBox, reply: BBox, rx: OracleRx }, Thinking { rx: OracleRx, pulse: Instant, blot_on: bool, since: Instant }, Replying { plan: WritePlan, next: Instant, rx: Option }, Lingering { until: Instant, region: BBox }, @@ -254,6 +256,9 @@ fn run() -> std::io::Result<()> { let mut stylus_on = false; let mut stylus_tapped = false; let mut ink_dirty = BBox::empty(); + // A reply the writer started answering while it still lingered on the + // page: it stays visible while they write and is drunk with their ink. + let mut pending_reply = BBox::empty(); 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) }; @@ -337,6 +342,16 @@ fn run() -> std::io::Result<()> { } continue; } + // Pen contact while a reply lingers: keep Tom's words on the + // page and start inking with this same contact — the reply is + // remembered and drunk together with the new ink at commit. + if let State::Lingering { region, .. } = state { + if !region.is_empty() { + pending_reply.add(region.x0, region.y0, 0); + pending_reply.add(region.x1, region.y1, 0); + } + state = State::Listening { last_pen: None }; + } match state { State::Listening { ref mut last_pen } => { pen_down = true; @@ -353,9 +368,6 @@ fn run() -> std::io::Result<()> { } *last_pen = Some(Instant::now()); } - State::Lingering { region, .. } => { - state = State::FadingReply { stage: 0, next: Instant::now(), region }; - } _ => {} } } @@ -374,6 +386,15 @@ fn run() -> std::io::Result<()> { qtfb::INPUT_PEN_PRESS | qtfb::INPUT_PEN_UPDATE => { stylus_on = true; stylus_tapped = true; + // Same as the raw-pen path: writing over a lingering + // reply keeps it on the page. + if let State::Lingering { region, .. } = state { + if !region.is_empty() { + pending_reply.add(region.x0, region.y0, 0); + pending_reply.add(region.x1, region.y1, 0); + } + state = State::Listening { last_pen: None }; + } if let State::Listening { ref mut last_pen } = state { pen_down = true; let r = 2 + ev.d.clamp(0, 100) / 45; @@ -383,8 +404,6 @@ fn run() -> std::io::Result<()> { ink_dirty.add(d.x1, d.y1, 0); } *last_pen = Some(Instant::now()); - } else if let State::Lingering { region, .. } = state { - state = State::FadingReply { stage: 0, next: Instant::now(), region }; } } qtfb::INPUT_PEN_RELEASE => { @@ -461,26 +480,35 @@ fn run() -> std::io::Result<()> { let _ = std::fs::remove_file(PNG_PATH); } let region = user_ink.bbox; - State::Drinking { stage: 0, next: Instant::now(), region, rx } + let reply = pending_reply; + pending_reply = BBox::empty(); + State::Drinking { stage: 0, next: Instant::now(), region, reply, rx } } } _ => State::Listening { last_pen }, }, - State::Drinking { stage, next, region, rx } => { + State::Drinking { stage, next, region, reply, rx } => { const STAGES: u32 = 14; if Instant::now() >= next { ink::dissolve_pass(&mut surf, region, stage, STAGES); let (x, y, w, h) = region.rect(); disp.update(x, y, w, h, true); + // A reply the writer answered underneath dissolves along + // with their ink. + if !reply.is_empty() { + ink::dissolve_pass(&mut surf, reply, stage, STAGES); + let (x, y, w, h) = reply.rect(); + disp.update(x, y, w, h, true); + } if stage + 1 >= STAGES { user_ink.clear(); State::Thinking { rx, pulse: Instant::now(), blot_on: false, since: Instant::now() } } else { - State::Drinking { stage: stage + 1, next: Instant::now() + Duration::from_millis(70), region, rx } + State::Drinking { stage: stage + 1, next: Instant::now() + Duration::from_millis(70), region, reply, rx } } } else { - State::Drinking { stage, next, region, rx } + State::Drinking { stage, next, region, reply, rx } } } From 7bb6e3c299a45ca316e19ee9914161991f7902b6 Mon Sep 17 00:00:00 2001 From: vnnkl <8235476+vnnkl@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:06:57 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20stran?= =?UTF-8?q?ded=20lingering=20replies=20and=20erased=20segments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A touch that keeps a lingering reply but leads to no committed ink (an eraser tap, or a stroke fully erased) left the reply with no fade timer, stale on the page until some future turn drank it. Both paths (never-inked and all-erased) now hand the reply to FadingReply after the idle timeout. - forget_near removed POINTS inside the eraser but kept adjacency between two surviving points whose connecting SEGMENT the eraser whitened (fast strokes store sparse points), so the offscreen replay resurrected visually erased ink for the oracle. Adjacency now also breaks when the segment passes within the eraser radius; regression test included. --- riddle/src/ink.rs | 39 +++++++++++++++++++++++++++++++++++++++ riddle/src/main.rs | 23 ++++++++++++++++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/riddle/src/ink.rs b/riddle/src/ink.rs index 9876e29..3935dcb 100644 --- a/riddle/src/ink.rs +++ b/riddle/src/ink.rs @@ -69,6 +69,11 @@ impl Ink { /// Remove committed stroke points within `r` of (x, y); split strokes that /// are erased through the middle, and recompute the ink bbox. + /// + /// Adjacency is also broken when the SEGMENT between two surviving points + /// passes through the eraser: fast strokes store sparse points, so an + /// eraser can whiten the brushed line between two points without touching + /// either — replaying that line would resurrect visually erased ink. fn forget_near(&mut self, x: i32, y: i32, r: i32) { let r2 = (r + 2) * (r + 2); let mut kept: Vec> = Vec::new(); @@ -81,6 +86,11 @@ impl Ink { kept.push(std::mem::take(&mut seg)); } } else { + if let Some(&prev) = seg.last() { + if segment_near(prev.0, prev.1, p.0, p.1, x, y, r + 2) { + kept.push(std::mem::take(&mut seg)); + } + } seg.push(p); } } @@ -203,6 +213,16 @@ fn brush_g(page: &mut [u8], w: usize, h: usize, x0: i32, y0: i32, x1: i32, y1: i } } +/// Does the segment A->B pass within `r` of point (x, y)? +fn segment_near(ax: i32, ay: i32, bx: i32, by: i32, x: i32, y: i32, r: i32) -> bool { + let (abx, aby) = ((bx - ax) as f32, (by - ay) as f32); + let (apx, apy) = ((x - ax) as f32, (y - ay) as f32); + let len2 = abx * abx + aby * aby; + let t = if len2 <= f32::EPSILON { 0.0 } else { ((apx * abx + apy * aby) / len2).clamp(0.0, 1.0) }; + let (cx, cy) = (apx - t * abx, apy - t * aby); + cx * cx + cy * cy <= (r * r) as f32 +} + /// Deterministic per-pixel hash for the dissolve pattern. #[inline] fn px_hash(x: i32, y: i32) -> u32 { @@ -302,6 +322,25 @@ mod tests { let _ = std::fs::remove_file(&path); } + #[test] + fn erasing_between_sparse_points_splits_the_stroke() { + let (_buf, mut s) = surf(); + let mut ink = Ink::new(); + // A sparse stroke: two points 80px apart (a fast pen). + ink.pen_point(&mut s, 60, 100, 3); + ink.pen_point(&mut s, 140, 100, 3); + ink.pen_up(); + assert_eq!(ink.stroke_list().len(), 1); + // Erase midway: neither point center is inside the eraser, but the + // brushed line between them is. + ink.erase_point(&mut s, 100, 100, 15); + assert_eq!( + ink.stroke_list().len(), + 2, + "segment through the eraser must break adjacency" + ); + } + #[test] fn erasing_everything_empties_the_ink() { let (_buf, mut s) = surf(); diff --git a/riddle/src/main.rs b/riddle/src/main.rs index c710b8b..f2708eb 100644 --- a/riddle/src/main.rs +++ b/riddle/src/main.rs @@ -431,12 +431,33 @@ fn run() -> std::io::Result<()> { // ---- state machine ---- state = match state { State::Listening { last_pen } => match last_pen { + // The touch that kept a lingering reply led to no ink at all + // (an eraser tap, or contact without a stroke): give the + // reply its fade back, or it would sit on the page forever. + Some(t) + if !pen_down + && t.elapsed() >= IDLE_COMMIT + && user_ink.is_empty() + && !pending_reply.is_empty() => + { + let region = pending_reply; + pending_reply = BBox::empty(); + State::FadingReply { stage: 0, next: Instant::now(), region } + } Some(t) if !pen_down && t.elapsed() >= IDLE_COMMIT && !user_ink.is_empty() => { if region_all_white(&surf, user_ink.bbox) { // Everything was erased before the pause: nothing to // commit (and no phantom "?" from erased strokes). user_ink.clear(); - State::Listening { last_pen: None } + if pending_reply.is_empty() { + State::Listening { last_pen: None } + } else { + // …but the lingering reply the writer touched + // still needs its fade. + let region = pending_reply; + pending_reply = BBox::empty(); + State::FadingReply { stage: 0, next: Instant::now(), region } + } } else if help::looks_like_question_mark(user_ink.stroke_list()) { // Absorb the "?" and open the guide instead of asking. let (qx, qy, qw, qh) = user_ink.bbox.rect();