feat: basilisk-fang erase — stab the diary and it bleeds out#16
Conversation
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.
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.
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.
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.
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.
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.
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.
Gentler ramp (0.025/frame, cap 1.22x) and smaller increments; the page now drowns in roughly three seconds instead of under two.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d736100e92
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if let Some(bl) = bleed.take() { | ||
| if !bl.bbox.is_empty() { | ||
| absorb_region(&mut surf, &disp, bl.bbox); |
There was a problem hiding this comment.
Preserve existing ink when aborting a bleed
When an aborted stab overlaps handwriting, this path dissolves the entire bleed bbox, which removes any pre-existing dark pixels under the warning pool even though erase_point was stopped while bleed.is_some(). That leaves the visible page changed by an action the user aborted, and the stroke model can still retain points for ink that was just whitened, so later help detection or remembered-page replay can use strokes that are no longer visible. This happens after holding the eraser long enough for pooling and then lifting or leaving Listening; restore the underlying pixels or only remove pixels that belong to the bleed.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 0aa7b5c, via the 'only remove pixels that belong to the bleed' route: bleed_grow records exactly the pixels it turns white→black (pooling only; the death path drops the log), and the abort restores exactly those. Handwriting under the pool was already dark, never enters the list, and survives untouched — screen and stroke model stay in agreement, so no phantom '?' detection or invisible ink in snapshots/memory replay.
| for e in &self.entries { | ||
| let _ = std::fs::remove_file(self.strokes_path(e.id)); | ||
| } | ||
| let _ = std::fs::remove_file(self.index_path()); |
There was a problem hiding this comment.
Remove orphan stroke files when forgetting
This only removes stroke files for entries loaded from the current index, so any <id>.strokes file not represented in entries is left behind after the new “forget everything” gesture. That can happen if index.tsv is missing/corrupt or if append wrote the strokes file and then failed before appending the index, leaving local handwriting data in RIDDLE_MEMORY_DIR despite the erase operation. To satisfy the erase semantics, delete all *.strokes files in the memory directory along with the index.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 0aa7b5c. forget_all now sweeps every *.strokes file in the memory dir via read_dir instead of iterating loaded entries, so orphans from a lost/corrupt index or a strokes-written-index-failed append are erased too.
…phans - 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 <id>.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.
The diary now has an in-fiction way to forget, and it's the movie's: stab it. v0.3.0's only erase is
RIDDLE_MEMORY=offor deleting files over SSH.The gesture
Hold the pen's eraser still on one spot (drift < 20px). After ~0.8s ink starts bleeding out from under the tip — the warning, and the window to abort (lift, and the page reabsorbs it). At 3 seconds the same bleed accelerates until the page drowns in ink, drains away, and every remembered page dies (
MemoryStore::forget_all: entries, index.tsv, *.strokes). While bleeding, the tip stops erasing — it's a fang, not an eraser — so no stroke points are dropped under the stain.The bleed (one continuous process, no stages)
A pixel is inked once
dist(px, tip) ≤ R·f(px), wherefis a 3-octave value-noise field anchored to the paper (built once per stab, integer math per pixel over the growth annulus). OnlyRchanges: it creeps during the hold and eases into a rush after 3s. Because the field belongs to the page, growth constantly reveals new structure — fingers run where the paper drinks fast, bays lag — so it reads as spreading ink, never as a shape being resized. Spatter droplets fling ahead as R passes their thresholds and are swallowed later.(This is the standard ink-bleed construction — threshold of a noise-displaced radial field, the same recipe artists use manually as blur → displace → threshold — after several iterations of user-testing rejected polar-wobble scaling, cellular automata, and compound blob budding as visibly artificial.)
Tested
Extensively on a Paper Pro (Ferrari, OS 3.27.3.0, takeover): pooling, abort-reabsorb, drift-while-erasing, full erase (memory dir emptied, next conversation is a first meeting), and repeated stabs. Documented in oracle.env.example.
🤖 Generated with Claude Code