From a1400d6622797cdd19809158de02b0c1ebe82fb3 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Mon, 17 Aug 2026 01:24:44 -0400 Subject: [PATCH 1/9] fix(probe): a trial must not clear OR pollute the caller's rewind ring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects at the one site every probe trial shares, found while building on `run_uncounted` for the RAM Atlas. The first is a bug I already claimed to have fixed and had not. PR #385 review reported that `latency::measure_in_place` destroyed the user's rewind history; the fix there changed that function's FINAL restore to `restore_quiet` and stopped. Every TRIAL still went through `run_uncounted`'s loud `nes.restore(..)`, and `measure_in_place` runs up to 21 trials against the live emulator — so the ring was still being cleared, twenty-one times over, by a fix that reported the bug closed. The loud variant clears the ring on the sound reasoning that a state loaded from elsewhere is unrelated to what was buffered; that reasoning has never applied to a probe, whose anchor came from this same timeline moments earlier and which ends by putting it back. The second was invisible behind the first. With the wipe fixed, the ring does not shrink — it GROWS, because a trial's frames are captured into it like any others. Those frames are re-simulated and never happened on the user's timeline, so rewinding into them would be rewinding into a measurement. Run-ahead solved exactly this problem for its hidden frames with `set_rewind_capture(false)`; a trial now does the same. `Nes::rewind_capture_enabled` is added so the suppression can save and restore the CALLER's setting rather than assume the default. Run-ahead predates this and restores an unconditional `true`, which is correct only because nothing else disables capture today — a getter makes that assumption inspectable instead of load-bearing. It is a `const fn` reader over an existing field: no new state, no schema change. `a_trial_preserves_the_callers_rewind_ring` pins both halves and asserts the ring comes back EXACTLY as it was, not merely non-empty. Weakening it to "not cleared" would have passed while the pollution defect remained, which is how the first fix passed review. Mutation-checked in both directions: restoring the loud `restore` fails it at `rewind_len()` 0 against 8, and removing the capture guard fails it at 10 against 8. Core touched, so the contract is verified rather than asserted: AccuracyCoin 141/141 on the authoritative RAM decoder, nestest 0-diff, 124 workspace test binaries green, `no_std` cross-build clean. --- crates/rustynes-core/src/nes.rs | 15 +++++++ crates/rustynes-probe/src/lib.rs | 67 +++++++++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/crates/rustynes-core/src/nes.rs b/crates/rustynes-core/src/nes.rs index e57c36ab..2483e4a8 100644 --- a/crates/rustynes-core/src/nes.rs +++ b/crates/rustynes-core/src/nes.rs @@ -645,6 +645,21 @@ impl Nes { self.rewind_capture_enabled = enabled; } + /// Whether the per-frame rewind capture is currently armed. + /// + /// Added in v2.3.6 so a caller that needs to suppress capture temporarily can + /// save and restore the *caller's* setting rather than assume the default. + /// `rustynes-probe` does exactly that around a trial: its replayed frames + /// never happened on the user's timeline, so they must not enter the ring — + /// but nor may re-enabling capture afterwards turn it on for someone who had + /// deliberately turned it off. Run-ahead predates this and still restores an + /// unconditional `true`, which is correct only because nothing else disables + /// capture today. + #[must_use] + pub const fn rewind_capture_enabled(&self) -> bool { + self.rewind_capture_enabled + } + /// Step exactly one CPU instruction. For debuggers / step-through tools. pub fn step_instruction(&mut self) -> u8 { #[cfg(feature = "cpu-boot-trace")] diff --git a/crates/rustynes-probe/src/lib.rs b/crates/rustynes-probe/src/lib.rs index 805de056..e04ccae1 100644 --- a/crates/rustynes-probe/src/lib.rs +++ b/crates/rustynes-probe/src/lib.rs @@ -204,9 +204,31 @@ impl Probe { "probe anchor belongs to a different ROM than the emulator it was \ replayed into; restoring across ROMs yields confident nonsense" ); - nes.restore(&self.snapshot) + // `restore_quiet`, NOT `restore`. The loud variant additionally clears + // the rewind ring, on the correct reasoning that a state loaded from + // elsewhere is unrelated to what was buffered. That reasoning does not + // hold for a probe: the anchor was snapshotted from this same timeline, + // and a trial ends by putting it back. + // + // This was a real, user-visible bug. `latency::measure_in_place` runs up + // to 21 trials against the LIVE emulator, so asking "how much input lag + // does this game have?" silently destroyed the user's rewind history + // twenty-one times over. PR #385 review caught the symptom and the fix + // there changed only `measure_in_place`'s FINAL restore — which left + // every trial's restore, the actual source, untouched. Fixed here at the + // one site all trials share. + nes.restore_quiet(&self.snapshot) .expect("probe anchor round-trips: it came from Nes::snapshot"); + // A trial's frames are re-simulated: they never happened on the user's + // timeline. Letting them into the rewind ring would let the user rewind + // *into a measurement* — the same reason run-ahead suppresses capture + // around its hidden frames. Suppressed for the trial and restored to + // whatever the caller had, not to an assumed `true`, so a caller who had + // deliberately disabled capture does not get it switched back on. + let capture_was = nes.rewind_capture_enabled(); + nes.set_rewind_capture(false); + let n = frames.min(self.budget.max_frames_per_trial); let mut samples = Vec::with_capacity(n as usize); // Generously sized so one frame always fits: an NTSC frame at 192 kHz is @@ -228,6 +250,7 @@ impl Probe { samples.push(sample(nes, observable, &audio)); } + nes.set_rewind_capture(capture_was); samples } @@ -557,6 +580,48 @@ mod tests { assert!(!Probe::agree(&a, &b)); } + /// A trial must not destroy the caller's rewind history. + /// + /// `latency::measure_in_place` runs up to 21 trials against the **live** + /// emulator, so a loud `restore` here means asking "how much input lag does + /// this game have?" wipes the user's rewind buffer twenty-one times over. + /// PR #385 review caught the symptom; the fix there changed only + /// `measure_in_place`'s final restore and left every trial's restore — the + /// actual source — untouched, so the bug survived a fix that claimed it. + /// + /// The ring must come back **exactly** as it was: neither cleared nor grown. + /// Growth is its own defect — a trial's frames are re-simulated and never + /// happened on the user's timeline, so rewinding into them would be rewinding + /// into a measurement. + /// + /// Mutation-checked in both directions: swapping `restore_quiet` back to + /// `restore` fails this at `rewind_len()` 0, and removing the + /// `set_rewind_capture(false)` guard fails it at 10 against 8. + #[test] + fn a_trial_preserves_the_callers_rewind_ring() { + let mut n = nes(); + n.enable_rewind(); + for _ in 0..4 { + n.run_frame(); + n.rewind_capture(); + } + let before = n.rewind_len(); + assert!(before > 0, "fixture failed to buffer any rewind frames"); + + let mut probe = Probe::anchor(&n, Budget::default()); + let _ = probe + .run(&mut n, 2, Observable::Wram, idle) + .expect("within budget"); + + assert_eq!( + n.rewind_len(), + before, + "running a probe trial cleared the caller's rewind ring; a trial \ + restores its own anchor onto the same timeline and has no business \ + invalidating history the user recorded" + ); + } + /// Replaying an anchor into an emulator running a different ROM must fail /// loudly. A snapshot restored across ROMs would produce a confident, wrong /// answer, which is worse than no answer for a tool people act on. From 904556342aef690e2fe1092fa55c5e491216c8c6 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Mon, 17 Aug 2026 09:40:04 -0400 Subject: [PATCH 2/9] =?UTF-8?q?feat(probe):=20the=20RAM=20Atlas=20classifi?= =?UTF-8?q?er=20=E2=80=94=20what=20each=20byte=20of=20work=20RAM=20is=20fo?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v2.3.6 workstream C, headless half. `rustynes-probe::atlas` answers a question every emulator's RAM search leaves to the user: not "which addresses hold 42" but "what is this address FOR". The reason no RAM search answers it is that observation alone cannot. An address counting up while the score counts up might be the score, or a frame counter that happens to be running. Separating them requires changing the byte and seeing whether anything downstream moves — which requires re-simulating one interval twice under a controlled difference. That is sound here only because determinism is a hard contract rather than an aspiration, which is what makes this buildable in RustyNES and not elsewhere. Two stages, with deliberately different epistemic status, expressed in the types rather than in prose: `observe` + `classify` are CORRELATION. Work RAM is captured once per frame and each of the 2048 addresses described as Untouched, FrameTick, RisingCounter, FallingCounter, Sparse or Volatile. Every one is a `Behaviour`, and a `Behaviour` is a hypothesis. `classify` returns all 2048 labels with `Liveness::Untested` throughout — observation is structurally incapable of claiming liveness, so it cannot accidentally do so. `verify_liveness` is a FACT, and a narrow one. It pokes the byte, re-simulates from the same anchor, and compares against an unpoked baseline. Divergence means the byte demonstrably drives the observable. The verdict is relative to the observable, and that is load-bearing rather than an implementation detail: the same byte is Live under `Wram` (the poke reached memory) and Inert under `Framebuffer` (nothing drew from it). Two tests assert exactly that pair — one perturbation, two lenses, opposite answers — which is what proves `Inert` is a real verdict and not a stuck default. `Untested` is a third state, distinct from `Inert` on purpose. "We did not look" and "we looked and saw nothing" are different claims, and collapsing them is how a budget-limited sweep starts reporting addresses it never examined as dead. Affordability is checked UP FRONT, so an unaffordable verification spends zero trials rather than a wasted baseline; the test asserts `trials_used == 0`, and removing the check fails it at 1. What a label does not mean is documented at more length than what it does, because the failure mode here is a confident wrong label that someone then builds a cheat or an achievement on. `Inert` is not "unused" — an address the game rewrites from a master copy every frame reads Inert because the poke is overwritten. `Live` does not say the byte IS a coordinate or a score, only that it participates in what you see. And a `Behaviour` is never upgraded by verification: RisingCounter + Live is two observations, not a conclusion. Thresholds are public constants, not private ones. The module's claim is that its cutoffs are arguable, which requires that a UI can show "changed on 91% of frames, threshold 90%" beside the label. A documented but unreachable threshold is checkable in principle and not in practice. `Probe::run_perturbed` generalizes the trial loop with a setup closure applied after the restore and before frame 0, so a perturbation is provably the only difference between two trials — which is what licenses attributing a divergence to it. It counts against the same budget as `run`: a perturbation sweep is the easiest way to spend unbounded trials and must not have a cheaper path to the emulator. Twenty tests. Fourteen pin the classifier against constructed series, including the wrap case that decides whether a counter rolling 0xFF -> 0x00 stays monotonic or is demoted to churn, and the ordering that makes a frame counter a FrameTick rather than a RisingCounter. Six drive the whole path against a real `Nes`, because this project has twice shipped features whose core logic was tested and whose wiring was not. Mutation-checked: making the perturbation a no-op fails the Live test, and removing the affordability check fails the Untested test. Headless and CI-gated by design; the panel is a separate change, so the classifier can be argued with before it has a UI to hide behind. --- crates/rustynes-probe/src/atlas.rs | 779 +++++++++++++++++++++++++++++ crates/rustynes-probe/src/lib.rs | 42 +- 2 files changed, 819 insertions(+), 2 deletions(-) create mode 100644 crates/rustynes-probe/src/atlas.rs diff --git a/crates/rustynes-probe/src/atlas.rs b/crates/rustynes-probe/src/atlas.rs new file mode 100644 index 00000000..d2b796ae --- /dev/null +++ b/crates/rustynes-probe/src/atlas.rs @@ -0,0 +1,779 @@ +//! RAM Atlas: **what is each byte of work RAM for?** +//! +//! # The gap this fills +//! +//! Every emulator ships a RAM search — `TASVideos`' `RamSearch` is the canonical +//! one, and this project already has RAM Search plus RAM Watch in the memory +//! compare panel, and per-address access counts in the access counter. All of +//! them *narrow a set the user already has a hypothesis about*. None of them says +//! what an address is. +//! +//! The reason none of them does is that you cannot tell from observation alone. +//! An address that counts up while the score counts up might be the score, or +//! might be a frame counter that happens to be running. Distinguishing them +//! requires changing the byte and seeing whether anything downstream actually +//! moves — which requires re-simulating the same interval twice under a +//! controlled difference. `RustyNES` can do that soundly because its determinism +//! contract is a hard guarantee (see the crate docs), so this module can. +//! +//! # Two stages, with deliberately different epistemic status +//! +//! **Stage 1, [`observe`] then [`classify`]: correlation only.** Capture work RAM +//! once per frame over a window and describe how each address behaved — untouched, +//! ticking every frame, counting monotonically up or down, changing rarely, +//! churning. Every result is a [`Behaviour`], and a `Behaviour` is a +//! **hypothesis**. Nothing here can distinguish cause from coincidence, and the +//! type does not pretend otherwise. +//! +//! **Stage 2, [`verify_liveness`]: a fact, and a narrow one.** Poke the byte, +//! re-simulate from the same anchor, and compare against an unpoked baseline. If +//! the screen diverges, that byte demonstrably drives something visible: +//! [`Liveness::Live`]. If not, [`Liveness::Inert`]. +//! +//! # What a label does NOT mean +//! +//! This matters more than what it does mean, because the failure mode of a tool +//! like this is a confident wrong label that a user then builds a cheat or an +//! achievement on: +//! +//! - [`Liveness::Inert`] means "perturbing it changed nothing observable **inside +//! the budget**". It does *not* mean unused. An address the game rewrites from +//! a master copy every frame reads `Inert` because the poke is overwritten +//! before it can matter — and that is a genuinely interesting fact about the +//! address, but it is not "dead". +//! - [`Liveness::Live`] means "changing it changed the framebuffer". It does +//! *not* say the address *is* a coordinate, a score, or health. It says the +//! byte participates in what you see. +//! - A [`Behaviour`] is never upgraded by verification. `RisingCounter` + +//! `Live` means "counts up, and drives something visible" — two separate +//! observations, not a conclusion that it is the score. +//! +//! Every threshold below is a named **public** constant with the reasoning +//! attached. Public deliberately: a classifier whose cutoffs are unexplained +//! magic numbers cannot be argued with, and one whose cutoffs are documented but +//! unreachable cannot be displayed next to the labels they produced. A UI showing +//! "changed on 91% of frames, threshold 90%" is checkable; one showing +//! `FrameTick` is not. + +use rustynes_core::{Buttons, Nes}; + +use crate::{Observable, Probe}; + +/// Bytes of work RAM. The NES has 2 KiB at `$0000-$07FF`, mirrored to `$1FFF`. +pub const WRAM_LEN: usize = 2048; + +/// A change on at least this fraction of frames reads as "every frame". +/// +/// Not 100%: a frame counter incremented in the NMI handler can miss a frame +/// during a lag frame or a scene transition, and a strict test would then +/// misfile it as [`Behaviour::Volatile`]. 90% tolerates that without admitting +/// anything that merely changes often. +pub const FRAME_TICK_RATIO: f64 = 0.9; + +/// At most this many changes over the window reads as event-driven rather than +/// continuous. +/// +/// Three, because the interesting cases — a life lost, a door opened, a state +/// transition — happen a handful of times in a several-second window, while +/// anything continuous changes tens of times. +pub const SPARSE_MAX_CHANGES: u32 = 3; + +/// A monotonic run needs at least this many steps in one direction before it is +/// called a counter. +/// +/// Two, not one. A single step is indistinguishable from any other one-off +/// change, and calling it a "counter" would put a confident label on the +/// weakest possible evidence. +pub const COUNTER_MIN_STEPS: u32 = 2; + +/// A transition from at least this high to at most [`WRAP_LOW`] is treated as a +/// wrap rather than a decrease. +pub const WRAP_HIGH: u8 = 0xF0; + +/// The low side of the wrap window. See [`WRAP_HIGH`]. +pub const WRAP_LOW: u8 = 0x0F; + +/// Work RAM sampled once per frame over a window. +/// +/// Stored **address-major** (all frames for `$0000`, then all frames for `$0001`) +/// because every consumer walks one address across time, and the transpose makes +/// that a contiguous slice instead of a strided gather over 2 KiB. +#[derive(Clone, Debug)] +pub struct Observation { + frames: u32, + /// `WRAM_LEN * frames` bytes, indexed `addr * frames + frame`. + data: Vec, +} + +impl Observation { + /// Build an observation from address-major data. + /// + /// Public so a caller that captured work RAM by some other route — a movie + /// replay, a netplay trace — can classify it without going through + /// [`observe`], and so the classifier can be tested against constructed + /// series rather than only against whatever a fixture ROM happens to do. + /// + /// # Panics + /// + /// Panics unless `data.len() == WRAM_LEN * frames`. A silently-misshaped + /// observation would produce plausible labels for the wrong addresses, which + /// is the worst outcome available here. + #[must_use] + pub fn from_addr_major(frames: u32, data: Vec) -> Self { + assert_eq!( + data.len(), + WRAM_LEN * frames as usize, + "observation data must be exactly WRAM_LEN * frames bytes, \ + address-major; a misshaped buffer labels the wrong addresses" + ); + Self { frames, data } + } + + /// Frames captured. + #[must_use] + pub const fn frames(&self) -> u32 { + self.frames + } + + /// This address's values over the window, one per frame, in order. + /// + /// # Panics + /// + /// Panics if `addr` is outside work RAM. + #[must_use] + pub fn series(&self, addr: u16) -> &[u8] { + let a = addr as usize; + assert!(a < WRAM_LEN, "address ${addr:04X} is outside work RAM"); + let f = self.frames as usize; + &self.data[a * f..(a + 1) * f] + } +} + +/// Capture work RAM once per frame, advancing `nes`. +/// +/// **Advances the emulator it is given**, exactly like [`crate::latency::measure`] +/// — pass a scratch instance, or restore afterwards. It deliberately does not +/// snapshot-and-restore internally: a caller that wants a repeatable window +/// anchors a [`Probe`] first and owns the restore, and hiding that here would +/// make the cost invisible. +/// +/// `input` is called once per frame with the zero-based frame index. Holding a +/// button changes what the game does, so it changes what the atlas describes; +/// that is a feature — an atlas taken while walking right tells you about +/// movement state. +pub fn observe(nes: &mut Nes, frames: u32, mut input: F) -> Observation +where + F: FnMut(u32) -> (Buttons, Buttons), +{ + // Frame-major while capturing (work RAM is contiguous, so a frame is one + // copy), transposed once at the end. The alternative — writing directly into + // address-major layout — would stride 2,048 times per frame across a buffer + // far larger than cache. + let mut frame_major: Vec = Vec::with_capacity(WRAM_LEN * frames as usize); + for f in 0..frames { + let (p1, p2) = input(f); + nes.set_buttons(0, p1); + nes.set_buttons(1, p2); + nes.run_frame(); + frame_major.extend_from_slice(&nes.wram()[..WRAM_LEN]); + } + + let n = frames as usize; + let mut data = vec![0u8; WRAM_LEN * n]; + for (f, chunk) in frame_major.chunks_exact(WRAM_LEN).enumerate() { + for (a, &v) in chunk.iter().enumerate() { + data[a * n + f] = v; + } + } + Observation::from_addr_major(frames, data) +} + +/// How an address behaved over the window. **A hypothesis, never a conclusion.** +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Behaviour { + /// Held one value for the whole window. + /// + /// The largest class by far in practice, and the useful one to be able to + /// exclude: it is most of work RAM on any given screen. + Untouched, + /// Changed on nearly every frame — an animation phase, a frame counter, a + /// scroll position. + FrameTick, + /// Only ever went up (wraps allowed), at least [`COUNTER_MIN_STEPS`] times. + RisingCounter, + /// Only ever went down (wraps allowed), at least [`COUNTER_MIN_STEPS`] times. + FallingCounter, + /// Changed at most [`SPARSE_MAX_CHANGES`] times — event-driven state. + Sparse, + /// Changed often, in both directions, but not every frame. + Volatile, +} + +/// Raw per-address statistics, kept alongside the label as its evidence. +/// +/// Carried so a UI can show *why* an address was classified as it was. A label +/// without its evidence cannot be checked, and this tool's whole claim is that +/// its output can be. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub struct AddressStats { + /// Frames on which the value differed from the previous frame. + pub changes: u32, + /// Distinct values observed. + pub distinct: u32, + /// Transitions strictly upward, excluding wraps. + pub increases: u32, + /// Transitions strictly downward, excluding wraps. + pub decreases: u32, + /// Transitions that looked like an 8-bit wrap in either direction. + pub wraps: u32, + /// Lowest value observed. + pub min: u8, + /// Highest value observed. + pub max: u8, + /// Index of the first frame that differed from frame 0, if any. + pub first_change: Option, +} + +/// Whether perturbing an address demonstrably changes what the screen shows. +/// +/// Read the caveats in the module docs before acting on this — particularly that +/// [`Self::Inert`] is not "unused". +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Liveness { + /// Not tested: verification was not requested, or the trial budget ran out + /// before reaching this address. + /// + /// Distinct from [`Self::Inert`] on purpose. "We did not look" and "we looked + /// and saw nothing" are different claims, and collapsing them is how a + /// budget-limited sweep starts reporting untested addresses as dead. + Untested, + /// Poking the byte changed the framebuffer within the window. + Live, + /// Poking the byte changed nothing observable within the window. + Inert, +} + +/// One address's entry in the atlas: what it did, the evidence, and whether +/// perturbing it mattered. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Label { + /// Work-RAM address, `$0000-$07FF`. + pub addr: u16, + /// The behavioural hypothesis from observation. + pub behaviour: Behaviour, + /// The evidence behind [`Self::behaviour`]. + pub stats: AddressStats, + /// The verification verdict, if verification ran. + pub liveness: Liveness, + /// Frame at which the perturbed run first diverged from the baseline, when + /// [`Self::liveness`] is [`Liveness::Live`]. + /// + /// Useful on its own: a byte that diverges on frame 0 is read the very next + /// frame, while one that diverges on frame 30 feeds something slower. + pub divergence_frame: Option, +} + +/// Compute one address's statistics from its series. +#[must_use] +pub fn stats_for(series: &[u8]) -> AddressStats { + let mut s = AddressStats::default(); + let Some(&first) = series.first() else { + return s; + }; + s.min = first; + s.max = first; + + // Distinct values via a 256-bit set rather than sorting or hashing: the + // domain is one byte, so this is exact and allocation-free. + let mut seen = [false; 256]; + seen[first as usize] = true; + + for (i, w) in series.windows(2).enumerate() { + let (prev, next) = (w[0], w[1]); + s.min = s.min.min(next); + s.max = s.max.max(next); + seen[next as usize] = true; + if prev == next { + continue; + } + s.changes += 1; + if s.first_change.is_none() { + // `i` indexes the window, so the CHANGED frame is `i + 1`. + s.first_change = u32::try_from(i + 1).ok(); + } + // A wrap is counted as neither an increase nor a decrease, so a counter + // that rolls over stays monotonic. Judged by the two values straddling + // the byte boundary rather than by the arithmetic difference, which + // cannot tell 0xFF -> 0x00 from 0xFF -> 0x00 by subtraction alone. + let wrapped_up = prev >= WRAP_HIGH && next <= WRAP_LOW; + let wrapped_down = prev <= WRAP_LOW && next >= WRAP_HIGH; + if wrapped_up || wrapped_down { + s.wraps += 1; + } else if next > prev { + s.increases += 1; + } else { + s.decreases += 1; + } + } + s.distinct = u32::try_from(seen.iter().filter(|&&b| b).count()).unwrap_or(u32::MAX); + s +} + +/// Classify one address from its statistics. +/// +/// Order matters and is deliberate: `Untouched` and `FrameTick` are decided +/// before the counter tests, so a frame counter is reported as a frame tick +/// rather than as a `RisingCounter` — it is monotonic, but "ticks every frame" is +/// the more specific and more useful statement. +#[must_use] +pub fn classify_stats(stats: &AddressStats, frames: u32) -> Behaviour { + if stats.changes == 0 { + return Behaviour::Untouched; + } + // Transitions available, not frames: an N-frame window has N-1 of them. + let transitions = f64::from(frames.saturating_sub(1)); + if transitions > 0.0 && f64::from(stats.changes) / transitions >= FRAME_TICK_RATIO { + return Behaviour::FrameTick; + } + let rising = stats.decreases == 0 && stats.increases >= COUNTER_MIN_STEPS; + let falling = stats.increases == 0 && stats.decreases >= COUNTER_MIN_STEPS; + if rising { + return Behaviour::RisingCounter; + } + if falling { + return Behaviour::FallingCounter; + } + if stats.changes <= SPARSE_MAX_CHANGES { + return Behaviour::Sparse; + } + Behaviour::Volatile +} + +/// Classify every work-RAM address in an observation. +/// +/// Returns all [`WRAM_LEN`] labels, including [`Behaviour::Untouched`] ones, with +/// [`Liveness::Untested`] throughout — verification is a separate, budgeted step. +/// Returning the untouched addresses rather than filtering them is deliberate: +/// "this address did nothing during the window" is an answer, and a caller that +/// wants only the interesting ones can filter, whereas one that wants the full +/// map cannot recover what was dropped. +#[must_use] +pub fn classify(obs: &Observation) -> Vec