# Architecture: threads, the data path and the layers The two threads and what they share, the four display modes, the display clock, and the layers to keep straight. Part of [CLAUDE.md](../../CLAUDE.md). `SPECS.md` is the requirement document. `README.md` documents what was actually built, including deliberate deviations from the spec and known limitations — read it before concluding something is a bug. ### Threads Exactly two: a producer thread in `Acquisition` owns a `DataSource` and calls `source->poll(50)`; the GTK main thread owns every widget and GL context. They share `ScopeModel` under `ScopeModel::mutex()`, taken once per DATA frame by the producer and once per frame by the UI (`tick()` in `src/ui.cpp`) around `RowSet::bind()`/`acquire()`. Samples themselves cross via `plot::data::RingBuffer` (single producer, single consumer) and never through the lock. Cross-thread scalars are atomics on `ScopeModel` (`trigger_enabled_`, `trigger_armed_`, `trigger_latches_`, `trigger_time_`, `event_`, `generation_`). Two of them are a pair: the producer writes `capture_edge_` before `trigger_pending_` and those two carry release/acquire, so a consumer that sees a capture open sees which edge it belongs to. ### Sources What feeds `Acquisition` is a `DataSource`, one of the registered *kinds*. - The kinds live in `src/source/`. `register_builtin_sources()` fills the registry explicitly -- there is no static-initialiser registration, which a linker may drop -- and a future `dlopen` loader would only call `register_kind()`. - A `DataSource` delivers into `Acquisition` (a `SourceSink`) from inside `poll()`. `Frame` is source-neutral, and its pointers are valid **only during `on_data`**: copy what is kept. - The GTK thread reaches a source **only** through `DataSource::post(Command)`; the queue is drained at the top of `poll()`. A panel never calls a source directly. - `UdpsSource` wraps `deps/udpsclient` unchanged; `udps_*` names appear only in `src/source/udps_*.cpp` (`grep -rn "udps_" src` outside those files is a leak of the old coupling). - The frame is **single-timebase**. A multi-timebase source (ABScope) will change the interface and the data path together; do not bend one frame to carry two clocks. - A kind's capability panels are pages of `side_stack`, named `source-`. A disconnected source leaves them **insensitive, not hidden** (`apply_source_panels` in `src/ui_source.cpp`). - Session file naming for a non-UDPS kind: the pseudo-host is `-` (characters outside `[A-Za-z0-9._-]` become `_`) on port **1** (0 is rejected by the session-file parser), the kind is stored as `source_kind` and its settings as `source_opt_`. A UDPS file keeps `_` and has neither key, so a legacy session is read as UDPS and nothing existing moves. - **The E-1608 kind has three layers, and only the top one knows the framework.** `e1608_link` (discovery, handshake, framed TCP commands, the scan socket) and `e1608_device` (typed commands, factory calibration, `decode_scans`) are plain POSIX C++ with no GTK or `DataSource` types; `e1608_source` is the `DataSource`; `e1608_kind` and `e1608_panels` are the UI. A change to the wire never reaches the UI files. - The E-1608 source runs **everything inside `poll()`** on the producer thread: posted commands, the connect state machine (Down, Scanning, Halted), the scan read, the 500 ms status check (its not-running restart is skipped when a hardware trigger is configured), the digital input/counter poll and the 20 ms waveform tick. There is no second thread. A connect attempt (discovery, handshake, calibration read, scan start) can block `poll()` for as long as the command timeout allows each step; that delays only the command queue. Every exception is caught inside `poll()`: an escape would end the producer thread, and `drain_commands()` runs inside the same `try` for that reason. - Its frames are **fixed-size blocks** of about 10 ms (`round(scan_rate / 100)` scans, 1 to 4096), not whatever one `recv` returned: `build_stamps` warns once when a frame's sample count differs from the first one's. Digital lines and the counter are held across a block (a step trace). - The E-1608 panels read the board's state from `E1608Readouts`, a set of atomics the producer writes and a tick callback throttled to 100 ms reads; a panel never touches the source's internals, and writes only through `post()`. `Acquisition::on_event` ignores the detail string and does nothing for `Error`, so the source also writes each new error to stderr as `[e1608] ...` (once per distinct message, not per retry); that line is where a user sees why a connection failed. ### Data path `ScopeModel` holds **one `BufferStore`** (`live()`): one shared time-base ring plus one value ring per channel, sized by `plan_partition()` to cover `--max-window` seconds within the byte budget. The producer always writes it, whatever the trigger is doing — that is what guarantees a full pre-trigger history on the *first* latch after arming. The spec's second buffer (item 1) is the latched `EventSnapshot`, which is already an immutable copy of the captured window; see README for why two live rings were dropped. What a trace binds to is `ScopeModel::display_mode()`, one of four states, and `RowSet::bind()` takes it as an argument rather than recomputing it: - `Live` — no trigger: the live ring, on the clock of the **newest sample** (`display_epoch() == -newest`), so the axis is a fixed `[-window, 0]` and x reads as an age. - `Capturing` — an edge has been accepted (so the pre window was full) and the post window is still filling: the live ring, on the coming event's own clock (`display_epoch() == -edge`), inside an axis already pinned to `[-pre, +post]`. The post side fills in view and the marker sits at x = 0 throughout. - `Held` — the frozen `EventSnapshot` of the last event, whose stamps are already edge-relative, so the display epoch is 0. - `Waiting` — armed, nothing captured yet: blank, with the live epoch (there is nothing bound, but the axis is still a live one). **Disarm during a capture publishes what the capture got to**, then disarms: the producer cuts the window at the newest sample and that partial snapshot becomes the held event, so the picture on screen at the moment of the Disarm stays on screen. `engine.disarm()` clears `pending_`, which drops the display out of `Capturing`, and with nothing published the view fell back to the *previous* event — or to blank on a first capture. No event fired, so `trigger_latches()` is deliberately not bumped; `ScopeModel::event_seq()` counts published snapshots instead, and the frame tick keys its event framing on *that*. Keep the two distinct: the latch count is what the probes assert "how many events fired" with. `Capturing` is a **deliberate departure from SPECS:37** ("data visualisation should not be refreshed till next event"), asked for explicitly; that reading is what the old "never the live ring mid-capture" rule enforced. What survives of the rule is that `Held` binds the snapshot *only* — a held event is never quietly replaced by live data — and that the *axis* does not move during a capture: it is the event's window from the edge onwards, never a sliding live view. `display_mode()` must be read **once per frame** and passed around (the tick does this, and hands it to `bind()`). The producer can open or close a capture between any two reads, and two reads in one frame put the bind, the epoch, the pin and the status line on different clocks. **The display clock is not the stream clock, and `ScopeModel::stream_at_x0()` is the only conversion.** `display_x = stream_t - stream_at_x0()` in **every** mode, and the offset lives in one of two places: a snapshot carries its own (`EventSnapshot::x_offset`, frozen with the picture) and a live view carries it as minus `display_epoch()`. Anything crossing between the two clocks goes through that one accessor rather than picking the branch itself — the export's `abs_seconds` column, the pause handler putting a display window back on the ring to cut it, the measurement marks — because there are four modes and picking the branch by hand gets one of them wrong. It reads atomics only, so it is safe to call inside an existing `mutex()` guard. Three things about the live epoch specifically: - **It is written after `bind()` and `acquire_all()`, not before.** It must be minus the newest sample the *traces* can report, and asking before the acquire is one frame of production stale — at 1 MS/s over a 50 ms window that is a third of the screen to the right of the axis. - **There is no feedback loop**, even though the newest stamp is read through the epoch that is about to change: `RowSet::live_edge()` reports in the epoch currently in force, so `display_epoch() - live_edge()` is minus the *raw* stamp and the fixed point is reached in one frame rather than converged on. - **`Waiting` takes the live epoch too.** Nothing is bound, but the axis is still a live one, and an epoch of 0 there would make the axis jump on the frame a capture closes. **A measurement mark is glued to its sample; a cursor stays on screen.** The two instruments move in opposite directions under a rolling axis and that is deliberate — a bench scope's cursors sit where the user put them while the waveform rolls under, and a measurement's mark names the sample it measured. The cursors need no code: their x *is* a display coordinate. The marks are stored in **stream** seconds and reconverted every frame by `place_measure_marks()`, since a mark left in display coordinates would crawl forward at one window per window. `App::measure_window` is stream-clocked for the same reason, and that one is load-bearing: the recompute trigger is "the window moved by 2 % of its span", and in display coordinates a live window is a fixed `[-window, 0]` — it never moves, so the measurements would never be recomputed again. Layers to keep straight: - `SignalInfo` — one signal as the CONFIG described it. The unit of enable/disable. - `ChannelInfo` / `ChannelBuffer` — the unit of acquisition and plotting. A `ChannelBuffer` is a `plot::data::Series` over a (value, time) ring pair. - `SlotSeries` (`src/channel.hpp`) — a trace's stable series object, rebound each frame to either a `ChannelBuffer` or an `EventSeries`. - `RowSet` (`src/plot_host.hpp`) — **one `plot::Figure`** of up to `kMaxRows` rows (added and removed one at a time by the layout editor), each a borrowed `plot::Plot*` with `kSlotsPerRow` fixed trace slots. The figure owns everything the rows have to agree on: the x window (one x link group every row joins), the live follow (the *group's* — a member's own axis must be `XFollow::Off`, since `Figure::link_x()` refuses a `Live` member), the left gutter and the crosshair. - `ExportTable` (`src/export.hpp`) — the visible window as columns, collected from the snapshot or the live rings and handed to the `.npy`/`.csv` writers. Always the stored samples, never the LOD pyramid's buckets. `src/ui.cpp` is the GTK4 window (toolbar, trigger panel, signal panel, plot box) plus the `tick()` frame function that binds, updates the trigger state and rewrites the status line. One `plot::gtk4::PlotArea` in figure mode draws the whole figure. It is by far the largest file.