--- _orig/src/seedsigner/views/tools_views.py	2026-06-09 21:10:44.000000000 -0500
+++ ss087b/src/seedsigner/views/tools_views.py	2026-08-05 23:44:29.624282058 -0500
@@ -87,11 +87,314 @@
 
             # Final image will be at least 4x the number of pixels the screen can
             # actually display.
-            camera.start_single_frame_mode(resolution=(2*max_dim, 2*max_dim))
+            # ===== BURST INSTRUMENTATION -- NOT FOR RELEASE =====
+            # Captures two bursts of consecutive frames instead of one frame, so sensor
+            # noise between frames can be measured. Stock capture parameters are left
+            # alone: same resolution, same capture_frame(). Only the number of captures,
+            # the auto-exposure window in phase 2, and the dumping are added.
+            #
+            # Two phases per run, on the same scene:
+            #   phase 1  "stock"    0.25s AE window -- exactly what v0.8.7 does
+            #   phase 2  "long-ae"  20s AE window   -- auto-exposure allowed to ramp
+            #
+            # capture_frame() freezes exposure on its FIRST call (exposure_mode='off'), so
+            # every frame in a burst shares one exposure state and the AE window above is
+            # what decides that state. Phase 2 must close and reopen the camera, because a
+            # fresh PiCamera is what re-enables AE after phase 1 locked it.
+            #
+            # NOTE this deliberately defeats the "image should never get saved" property:
+            # any seed produced by this build must be treated as compromised.
+            import os as _os
 
-            time.sleep(0.25)
-            self.controller.image_entropy_final_image = camera.capture_frame()
-            camera.stop_single_frame_mode()
+            BURST_N = 10
+            QUIET_S = 10.0        # after the click, camera idle -- lets the press settle
+            AE_STOCK_S = 0.25     # stock v0.8.7 value, unchanged
+            AE_LONG_S = 20.0      # unused unless re-added to PHASES on the next line
+            PHASES = (("stock", AE_STOCK_S),)   # + ("long-ae", AE_LONG_S) to probe a long window
+            DUMP_ROOT = "/mnt/microsd"
+
+            stamp = time.strftime("%Y%m%d-%H%M%S")
+            # No RTC on these boards: every boot starts at 1970-01-01, so two units
+            # running at a similar time after power-on produce identical run names,
+            # and an import overwrote and destroyed a capture set exactly that way.
+            # Suffix the run name with the unit hash so names are unique per device.
+            # Computed here (rather than at the log line below) so the name can use it.
+            try:
+                _ser = _os.popen("cat /proc/cpuinfo | grep Serial").read().split(":")[-1].strip()
+                _unit = hashlib.sha256(_ser.encode()).hexdigest()[:12]
+            except Exception:
+                _unit = ""
+            out_dir = "%s/burst-%s-%s" % (DUMP_ROOT, stamp, _unit[:4] or "xxxx")
+            log_lines = []
+
+            def _log(msg):
+                # Held in memory, flushed to the card at the end. The microSD is the only
+                # durable sink here; a console log is not retained.
+                log_lines.append(msg)
+
+            for _d in [out_dir, "%s/preview" % out_dir] + ["%s/%s" % (out_dir, _p) for _p, _ in PHASES]:
+                try:
+                    _os.mkdir(_d)
+                except Exception:
+                    pass
+
+            try:
+                model = open("/proc/device-tree/model").read().strip("\x00").strip()
+            except Exception:
+                model = "unknown"
+
+            _log("timestamp: %s" % stamp)
+            _log("board: %s" % model)
+            _log("panel: %dx%d" % (self.canvas_width, self.canvas_height))
+            _log("requested_resolution: %dx%d" % (2*max_dim, 2*max_dim))
+            # Identify the physical unit so datasets from different boards are never
+            # conflated. The CPU serial is hashed, not logged: it distinguishes units
+            # without publishing a hardware identifier.
+            if _unit:
+                _log("unit_id: %s   (sha256 of CPU serial, truncated)" % _unit)
+            else:
+                _log("unit_id: unavailable")
+            _log("quiet_period_s: %.1f" % QUIET_S)
+            _log("burst_n: %d" % BURST_N)
+            _log("preview_frames_chained: %d"
+                 % len(self.controller.image_entropy_preview_frames or []))
+
+            # ----- PREVIEW-FRAME LAYER (see capture-rig/PREVIEW-FRAME-PLAN.md) -----
+            # The rolling preview window, exactly as it stands at the click, is what the
+            # entropy hash chains ahead of the final image. Sampling rule: N <= 20 dump
+            # every frame, otherwise the first 10 and the last 10 -- never every Nth,
+            # because uniform subsampling inflates the frame-to-frame differences, which
+            # is the anti-conservative direction.
+            previews = self.controller.image_entropy_preview_frames or []
+            n_prev = len(previews)
+            if n_prev <= 20:
+                dump_idxs = list(range(n_prev))
+            else:
+                dump_idxs = list(range(10)) + list(range(n_prev - 10, n_prev))
+            # Positional sampling alone loses the content of exactly the frames
+            # worth having. In the first preview runs the only live frame of a
+            # 50-slot window sat at index 37, inside the gap, so its content was
+            # unrecoverable while its distinctness was known from the digest. The
+            # digests are computed here anyway, so also dump a representative of
+            # any digest the positional sample missed. Capped: in a lit scene
+            # every frame is distinct and an uncapped rule would dump the whole
+            # window.
+            PREVIEW_EXTRA_MAX = 10
+
+            # Pre-flight space check. A full run writes BURST_N frames per phase plus
+            # the sampled preview frames; running out mid-burst leaves a partial series
+            # and, worse, loses the log. Check first and say so plainly rather than
+            # failing on frame 7. Preview frames are RGBA at the panel dimension, not
+            # the still size.
+            frame_bytes = (2 * max_dim) * (2 * max_dim) * 3
+            preview_bytes = max_dim * max_dim * 4
+            need = (frame_bytes * BURST_N * len(PHASES)
+                    + preview_bytes * (len(dump_idxs) + PREVIEW_EXTRA_MAX))
+            try:
+                st = _os.statvfs(DUMP_ROOT)
+                free = st.f_bavail * st.f_frsize
+                _log("microsd_free_bytes: %d   need: %d (%d burst x %d + %d preview x %d)"
+                     % (free, need, BURST_N * len(PHASES), frame_bytes,
+                        len(dump_idxs), preview_bytes))
+                if free < need:
+                    _log("ABORTED: insufficient free space on %s" % DUMP_ROOT)
+                    try:
+                        with open("%s/capture.log" % out_dir, "w") as fh:
+                            fh.write("\n".join(log_lines) + "\n")
+                    except Exception:
+                        pass
+                    raise Exception(
+                        "microSD has %d bytes free; this run needs %d" % (free, need))
+            except OSError as e:
+                _log("microsd_free_bytes: unavailable (%r)" % e)
+
+            # Quiet period with the camera IDLE, so the mechanical disturbance from the
+            # button press damps out before any capture.
+            time.sleep(QUIET_S)
+
+            # Record the auto-exposure state as it stands at the end of the AE window, i.e.
+            # what capture_frame() is about to freeze. This makes the AE question measured
+            # rather than inferred: whether a longer window actually changed anything, and
+            # whether a reframed scene made AE work differently, are both answered by these
+            # numbers rather than deduced from the noise statistics.
+            def _ae_state(cam, when):
+                try:
+                    p = cam._picamera
+                    _log("ae_%s: exposure_speed=%s shutter_speed=%s analog_gain=%s "
+                         "digital_gain=%s iso=%s awb_gains=%s exposure_mode=%s"
+                         % (when, p.exposure_speed, p.shutter_speed,
+                            float(p.analog_gain), float(p.digital_gain), p.iso,
+                            tuple(float(g) for g in p.awb_gains), p.exposure_mode))
+                except Exception as e:
+                    _log("ae_%s: unavailable (%r)" % (when, e))
+
+            last_img = None
+            try:
+                # Hash EVERY frame in the preview window, dump only the sample. The
+                # distinct-digest count answers a question the dumped sample cannot:
+                # whether the N slots hold N distinct sensor reads, over the entire
+                # window, at zero storage cost. This runs ONCE, after the preview loop
+                # has returned and before the burst -- never inside a capture loop,
+                # which stretched the inter-frame gap from ~0.6s to 3.2s (README trap
+                # #1). It sits inside this try so the finally below flushes the log if
+                # a write fails partway. Frames are dumped as the exact bytes the
+                # entropy hash consumes -- PIL .tobytes(), no conversion -- with the
+                # original window index in the filename so the early and late blocks
+                # stay identifiable.
+                # Pass 1: hash the whole window and log every digest.
+                _digests = []
+                for _i, _img in enumerate(previews):
+                    _raw = _img.tobytes()
+                    _dig = hashlib.sha256(_raw).hexdigest()
+                    _digests.append(_dig)
+                    _log("preview %02d: digest=%s bytes=%d mode=%s size=%dx%d"
+                         % (_i, _dig, len(_raw), _img.mode, _img.width, _img.height))
+                _log("preview_distinct_frames: %d of %d"
+                     % (len(set(_digests)), n_prev))
+
+                # Preview-loop timing, collected in the screen (see BURST_PREVIEW_TIMING
+                # in tools_screens.py). Two rates, and they are not the same thing:
+                #   loop rate     -- how fast slots are filled (every append)
+                #   delivery rate -- how fast NEW content arrives (appends whose digest
+                #                    differs from the previous one)
+                # If the loop outpaces delivery, a full 50-slot window cannot hold 50
+                # distinct sensor reads under any lighting, which is a fact about the
+                # code path rather than about the scene.
+                #
+                # Read the delivery figure as a LOWER BOUND: it counts content changes
+                # between consecutive appends, so when the loop runs slower than the
+                # camera every poll returns something new and the figure saturates at the
+                # loop rate rather than reporting true sensor delivery. It is exact only
+                # in the case that matters here -- loop faster than camera. Equal rates
+                # alias and read low. In a dark scene it also undercounts badly, because
+                # separate reads that both quantize to black are one digest; the number
+                # is only meaningful on a lit capture.
+                try:
+                    from seedsigner.gui.screens import tools_screens as _ts
+                    _t = _ts.BURST_PREVIEW_TIMING
+                    _ap = _t.get("appends") or []
+                    _log("")
+                    _log("preview_none_reads: %d   (camera warm-up polls, no frame)"
+                         % _t.get("none_reads", -1))
+                    if _ap:
+                        _log("preview_warmup_s: %.3f   (stream start -> first append)"
+                             % (_ap[0] - _t["stream_start"]))
+                        _log("preview_appends_total: %d   (whole session; window keeps "
+                             "the last %d)" % (len(_ap), n_prev))
+                        _span = _ap[-1] - _ap[0]
+                        if _span > 0 and len(_ap) > 1:
+                            _log("preview_loop_rate_fps: %.2f   over %.3f s"
+                                 % ((len(_ap) - 1) / _span, _span))
+                        # Delivery rate over the retained window: count digest changes
+                        # and divide by the time those frames spanned.
+                        _w = _ap[-n_prev:] if n_prev <= len(_ap) else _ap
+                        _new = sum(1 for _k in range(1, len(_digests))
+                                   if _digests[_k] != _digests[_k - 1])
+                        _wspan = (_w[-1] - _w[0]) if len(_w) > 1 else 0.0
+                        if _wspan > 0:
+                            _log("preview_window_span_s: %.3f   content_changes: %d   "
+                                 "delivery_rate_fps: %.2f" % (_wspan, _new, _new / _wspan))
+                        _gaps = [_ap[_k] - _ap[_k - 1] for _k in range(1, len(_ap))]
+                        if _gaps:
+                            _log("preview_gap_s: min=%.4f median=%.4f max=%.4f"
+                                 % (min(_gaps), sorted(_gaps)[len(_gaps) // 2], max(_gaps)))
+                        if "click" in _t:
+                            _log("preview_click_to_last_append_s: %.4f"
+                                 % (_t["click"] - _ap[-1]))
+                            _log("preview_total_s: %.3f   (stream start -> click)"
+                                 % (_t["click"] - _t["stream_start"]))
+                        # Per-retained-frame offsets, seconds before the click, so a
+                        # dumped frame's index can be placed in time.
+                        _ref = _t.get("click", _ap[-1])
+                        _log("preview_frame_offsets_s (index:seconds_before_click): %s"
+                             % " ".join("%d:%.3f" % (_k, _ref - _w[_k])
+                                        for _k in range(len(_w))))
+                    else:
+                        _log("preview_timing: unavailable (no appends recorded)")
+                except Exception as _e:
+                    _log("preview_timing: unavailable (%r)" % _e)
+
+                # Pass 2: positional sample, plus a first-occurrence
+                # representative of any digest the sample missed, capped.
+                _dump_set = set(dump_idxs)
+                _covered = set(_digests[_i] for _i in _dump_set)
+                _extra = []
+                for _i, _dig in enumerate(_digests):
+                    if len(_extra) >= PREVIEW_EXTRA_MAX:
+                        break
+                    if _dig not in _covered:
+                        _covered.add(_dig)
+                        _extra.append(_i)
+                _missed = len(set(_digests)) - len(_covered)
+                _dump_set |= set(_extra)
+                for _i in sorted(_dump_set):
+                    with open("%s/preview/frame%02d.raw" % (out_dir, _i), "wb") as fh:
+                        fh.write(previews[_i].tobytes())
+                _log("preview_frames_dumped: %d   indices: %s"
+                     % (len(_dump_set), ",".join(str(_i) for _i in sorted(_dump_set))))
+                _log("preview_extra_dumped: %d   indices: %s   (unsampled digests "
+                     "still uncovered: %d)"
+                     % (len(_extra), ",".join(str(_i) for _i in _extra), _missed))
+
+                for phase, ae_wait in PHASES:  # add ("long-ae", AE_LONG_S) to probe a long window
+                    camera.start_single_frame_mode(resolution=(2*max_dim, 2*max_dim))
+                    try:
+                        rev = camera._picamera.revision
+                    except Exception:
+                        rev = "unavailable"
+                    _log("")
+                    _log("--- phase: %s   ae_window_s=%.2f   camera_revision=%s ---"
+                         % (phase, ae_wait, rev))
+
+                    time.sleep(ae_wait)
+                    _ae_state(camera, "before_lock")
+
+                    # The capture loop does nothing but capture and note the time. Hashing,
+                    # means and file writes all happen afterwards: they are validity checks
+                    # on the data, not things that have to happen live, and doing them here
+                    # stretched the inter-frame gap to 3.2s when the capture itself is only
+                    # ~0.6s. Ten frames held in RAM is ~6.9MB.
+                    imgs = []
+                    times = []
+                    for i in range(BURST_N):
+                        t0 = time.time()
+                        imgs.append(camera.capture_frame())
+                        times.append((t0, time.time()))
+
+                    # Write the frames, and log only what the frames cannot carry: the
+                    # timing. Hashes, means, duplicate detection and every statistic are
+                    # derivable from the .raw files and are computed off-device, so none of
+                    # them belong here.
+                    for i, img in enumerate(imgs):
+                        raw = img.tobytes()
+                        t0, t1 = times[i]
+                        gap = (t0 - times[i - 1][0]) if i else 0.0
+                        with open("%s/%s/frame%02d.raw" % (out_dir, phase, i), "wb") as fh:
+                            fh.write(raw)
+                        _log("frame %02d: bytes=%d mode=%s size=%dx%d gap=%.3f capture_s=%.3f"
+                             % (i, len(raw), img.mode, img.width, img.height,
+                                gap, t1 - t0))
+                    last_img = imgs[-1]
+                    imgs = None
+
+                    # After the burst: capture_frame() locked exposure on its first call,
+                    # so these should match the pre-lock values with exposure_mode='off'.
+                    # A mismatch would mean the lock is not holding across the burst, which
+                    # would invalidate treating the series as one exposure state.
+                    _ae_state(camera, "after_burst")
+
+                    camera.stop_single_frame_mode()
+            finally:
+                # Always flush the log, even if a capture or a write failed partway. The
+                # log is the thing most likely to be lost and the hardest to reconstruct.
+                try:
+                    with open("%s/capture.log" % out_dir, "w") as fh:
+                        fh.write("\n".join(log_lines) + "\n")
+                except Exception as e:
+                    logger.error("burst log write FAILED: %r", e, exc_info=True)
+
+            self.controller.image_entropy_final_image = last_img
+            # ===== END BURST INSTRUMENTATION =====
 
         # Prep a copy of the image for display:
         #   * Boost the contrast for better presentation (but preserve the original pixels)
--- _orig/src/seedsigner/gui/screens/tools_screens.py	2026-06-09 21:10:44.000000000 -0500
+++ ss087b/src/seedsigner/gui/screens/tools_screens.py	2026-08-05 23:43:39.095377779 -0500
@@ -14,6 +14,25 @@
 
 
 
+# ===== BURST INSTRUMENTATION -- NOT FOR RELEASE =====
+# Timing for the live-preview loop, read by tools_views.py after the screen returns.
+# A module global rather than a changed return signature, so the screen's contract with
+# the View is untouched and the instrumentation stays removable in one hunk.
+#
+# What this answers: the preview list fills at the DISPLAY LOOP's rate, while new content
+# arrives at the camera's configured 24 fps, and read_video_stream() returns whatever the
+# capture thread last stored with no synchronisation. So if the loop outpaces delivery the
+# excess polls necessarily re-return one buffer. Timestamps here, combined with the
+# per-frame digests logged in tools_views.py, separate the two rates directly: every
+# append gives the loop rate, appends whose content differs from the previous give the
+# delivery rate.
+#
+# Cost: time.time() plus a list append, microseconds against a ~30 ms iteration dominated
+# by a 115,200-byte SPI write. This is NOT the in-loop hashing that stretched the burst
+# inter-frame gap from 0.59 s to 3.2 s -- that was megabytes of SHA-256 per iteration.
+BURST_PREVIEW_TIMING = {}
+
+
 @dataclass
 class ToolsImageEntropyLivePreviewScreen(BaseScreen):
     def __post_init__(self):
@@ -26,12 +45,19 @@
         # TODO: Figure out why (camera expecting frame dims of multiples other than 16?)
         max_dimension = max(self.canvas_width, self.canvas_height)
         self.camera.start_video_stream_mode(resolution=(max_dimension, max_dimension), framerate=24, format="rgb")
+        # Stamped here, not in _run(), so camera warm-up is measured from the moment the
+        # stream was asked to start rather than from the first loop iteration.
+        BURST_PREVIEW_TIMING.clear()
+        BURST_PREVIEW_TIMING["stream_start"] = time.time()
 
 
     def _run(self):
         # save preview image frames to use as additional entropy below
         preview_images = []
         max_entropy_frames = 50
+        BURST_PREVIEW_TIMING["run_start"] = time.time()
+        BURST_PREVIEW_TIMING["appends"] = []
+        BURST_PREVIEW_TIMING["none_reads"] = 0
         instructions_font = Fonts.get_font(GUIConstants.get_body_font_name(), GUIConstants.get_button_font_size())
 
         while True:
@@ -46,6 +72,7 @@
 
             if frame is None:
                 # Camera probably isn't ready yet
+                BURST_PREVIEW_TIMING["none_reads"] += 1
                 time.sleep(0.01)
                 continue
 
@@ -78,6 +105,7 @@
             if self.hw_inputs.check_for_low(keys=HardwareButtonsConstants.KEYS__ANYCLICK):
                 # Have to manually update last input time since we're not in a wait_for loop
                 self.hw_inputs.update_last_input_time()
+                BURST_PREVIEW_TIMING["click"] = time.time()
                 self.camera.stop_video_stream_mode()
 
                 with self.renderer.lock:
@@ -118,6 +146,10 @@
                 # before we add the currest frame.
                 preview_images.pop(0)
             preview_images.append(frame)
+            # Every append, not just the retained window: the full series measures the
+            # loop rate over the whole session, and the last len(preview_images) entries
+            # align positionally with the window that is actually returned.
+            BURST_PREVIEW_TIMING["appends"].append(time.time())
 
 
 
--- _orig/src/seedsigner/controller.py	2026-06-09 21:10:44.000000000 -0500
+++ ss087b/src/seedsigner/controller.py	2026-08-05 22:29:41.217080990 -0500
@@ -100,7 +100,7 @@
         rather than at the top in order avoid circular imports.
     """
 
-    VERSION = "0.8.7"
+    VERSION = "0.8.7-BURST-DEBUG"
 
     # Declare class member vars with type hints to enable richer IDE support throughout
     # the code.
