#ifdef _WIN32
    #ifndef NOMINMAX
        #define NOMINMAX
    #endif
#endif

#ifdef _MSC_VER
    #pragma warning(push)
    #pragma warning(disable: 4995)
#endif
#include "x502api.h"
#include "e502api.h"
#ifdef _MSC_VER
    #pragma warning(pop)
#endif

#include "capture_file_writer.h"
#include "tty_protocol_writer.h"

#include <algorithm>
#include <array>
#include <atomic>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <deque>
#include <iomanip>
#include <iostream>
#include <limits>
#include <memory>
#include <numeric>
#include <optional>
#include <sstream>
#include <stdexcept>
#include <string>
#include <vector>

#ifndef _WIN32
    #include <csignal>
    #include <dlfcn.h>
#endif

namespace {

enum class StopMode {
    TargetFrames,
    DiSyn2Rise,
    DiSyn2Fall
};

enum class Di1Mode {
    ZeroOnChange,
    Trace,
    Ignore
};

enum class CaptureProfile {
    Manual,
    Phase,
    Amplitude
};

struct Config {
    std::string serial;
    std::optional<uint32_t> ip_addr;

    CaptureProfile profile = CaptureProfile::Manual;
    uint32_t channel_count = 2;
    uint32_t mode = X502_LCH_MODE_DIFF;
    uint32_t range = X502_ADC_RANGE_02;
    uint32_t ch1 = 2;
    uint32_t ch2 = 3;

    double sample_clock_hz = 125000.0;
    bool sample_clock_specified = false;
    bool max_internal_clock = false;
    double duration_ms = 100.0;
    uint32_t packet_limit = 0;
    uint32_t internal_ref_freq = X502_REF_FREQ_2000KHZ;

    uint32_t sync_mode = X502_SYNC_DI_SYN1_RISE;
    uint32_t sync_start_mode = X502_SYNC_DI_SYN2_RISE;
    StopMode stop_mode = StopMode::DiSyn2Fall;
    Di1Mode di1_mode = Di1Mode::ZeroOnChange;

    uint32_t recv_block_words = 32768;
    uint32_t recv_timeout_ms = 50;
    uint32_t stats_period_ms = 1000;
    uint32_t start_wait_ms = 10000;
    uint32_t input_buffer_words = 8 * 1024 * 1024;
    uint32_t input_step_words = 32768;
    uint32_t live_update_period_ms = 1000;
    uint32_t svg_history_packets = 50;

    bool recv_block_specified = false;
    bool input_buffer_specified = false;
    bool input_step_specified = false;
    bool live_update_specified = false;
    bool mode_specified = false;
    bool channel_count_specified = false;
    bool ch1_specified = false;
    bool ch2_specified = false;

    bool pullup_syn1 = false;
    bool pullup_syn2 = false;
    bool pulldown_conv_in = false;
    bool pulldown_start_in = false;

    std::string csv_path = "capture.csv";
    std::string svg_path = "capture.svg";
    std::string live_html_path = "live_plot.html";
    std::string live_json_path = "live_plot.json";
    std::optional<std::string> tty_path;
    bool di1_group_average = false;
    bool do1_toggle_per_frame = false;
    bool do1_noise_subtract = false;
    bool do1_raw_tty_marked = false;
    bool do1_pair_subtract_avg = false;
    std::optional<uint32_t> noise_avg_steps;
    bool do8_freq_ref = false;
    uint32_t do8_cycle_period_ticks = 10;
    std::optional<double> do8_amplitude_threshold;
};

[[noreturn]] void fail(const std::string& message) {
    throw std::runtime_error(message);
}

std::string trim_copy(const std::string& text) {
    const auto first = text.find_first_not_of(" \t\r\n");
    if (first == std::string::npos) {
        return {};
    }
    const auto last = text.find_last_not_of(" \t\r\n");
    return text.substr(first, last - first + 1);
}

bool starts_with(const std::string& value, const std::string& prefix) {
    return value.rfind(prefix, 0) == 0;
}

uint32_t parse_u32(const std::string& text, const std::string& field_name) {
    const std::string clean = trim_copy(text);
    char* end = nullptr;
    const auto value = std::strtoull(clean.c_str(), &end, 0);
    if ((end == clean.c_str()) || (*end != '\0') || (value > std::numeric_limits<uint32_t>::max())) {
        fail("Invalid integer for " + field_name + ": " + text);
    }
    return static_cast<uint32_t>(value);
}

double parse_double(const std::string& text, const std::string& field_name) {
    const std::string clean = trim_copy(text);
    char* end = nullptr;
    const double value = std::strtod(clean.c_str(), &end);
    if ((end == clean.c_str()) || (*end != '\0') || !std::isfinite(value)) {
        fail("Invalid floating point value for " + field_name + ": " + text);
    }
    return value;
}

uint32_t parse_ipv4(const std::string& text) {
    std::array<uint32_t, 4> parts{};
    std::stringstream ss(text);
    std::string token;
    for (std::size_t i = 0; i < parts.size(); ++i) {
        if (!std::getline(ss, token, '.')) {
            fail("Invalid IPv4 address: " + text);
        }
        parts[i] = parse_u32(token, "ip");
        if (parts[i] > 255) {
            fail("IPv4 byte out of range: " + token);
        }
    }
    if (std::getline(ss, token, '.')) {
        fail("Invalid IPv4 address: " + text);
    }
    return (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3];
}

std::string ipv4_to_string(uint32_t ip_addr) {
    std::ostringstream out;
    out << ((ip_addr >> 24) & 0xFF) << '.'
        << ((ip_addr >> 16) & 0xFF) << '.'
        << ((ip_addr >> 8) & 0xFF) << '.'
        << (ip_addr & 0xFF);
    return out.str();
}

uint32_t parse_range(const std::string& text) {
    const double value = parse_double(text, "range");
    if (std::fabs(value - 10.0) < 1e-9) {
        return X502_ADC_RANGE_10;
    }
    if (std::fabs(value - 5.0) < 1e-9) {
        return X502_ADC_RANGE_5;
    }
    if (std::fabs(value - 2.0) < 1e-9) {
        return X502_ADC_RANGE_2;
    }
    if (std::fabs(value - 1.0) < 1e-9) {
        return X502_ADC_RANGE_1;
    }
    if (std::fabs(value - 0.5) < 1e-9) {
        return X502_ADC_RANGE_05;
    }
    if (std::fabs(value - 0.2) < 1e-9) {
        return X502_ADC_RANGE_02;
    }
    fail("Unsupported E-502 range: " + text);
}

double range_to_volts(uint32_t range) {
    switch (range) {
    case X502_ADC_RANGE_10:
        return 10.0;
    case X502_ADC_RANGE_5:
        return 5.0;
    case X502_ADC_RANGE_2:
        return 2.0;
    case X502_ADC_RANGE_1:
        return 1.0;
    case X502_ADC_RANGE_05:
        return 0.5;
    case X502_ADC_RANGE_02:
        return 0.2;
    default:
        fail("Unknown ADC range enum");
    }
}

uint32_t parse_mode(const std::string& text) {
    const std::string value = trim_copy(text);
    if ((value == "comm") || (value == "gnd") || (value == "single_ended")) {
        return X502_LCH_MODE_COMM;
    }
    if ((value == "diff") || (value == "differential")) {
        return X502_LCH_MODE_DIFF;
    }
    fail("Unsupported input mode: " + text);
}

CaptureProfile parse_profile(const std::string& text) {
    const std::string value = trim_copy(text);
    if (value == "phase") {
        return CaptureProfile::Phase;
    }
    if (value == "amplitude") {
        return CaptureProfile::Amplitude;
    }
    fail("Unsupported profile: " + text + ". Use phase or amplitude");
}

std::string profile_to_string(CaptureProfile profile) {
    switch (profile) {
    case CaptureProfile::Manual:
        return "manual";
    case CaptureProfile::Phase:
        return "phase";
    case CaptureProfile::Amplitude:
        return "amplitude";
    default:
        return "unknown";
    }
}

struct ProfileSettings {
    uint32_t channel_count = 2;
    uint32_t mode = X502_LCH_MODE_DIFF;
    uint32_t ch1 = 2;
    uint32_t ch2 = 3;
};

ProfileSettings profile_settings(CaptureProfile profile) {
    switch (profile) {
    case CaptureProfile::Phase:
        return {2U, X502_LCH_MODE_DIFF, 2U, 3U};
    case CaptureProfile::Amplitude:
        return {1U, X502_LCH_MODE_COMM, 0U, 0U};
    case CaptureProfile::Manual:
    default:
        fail("Internal error: no settings for manual profile");
    }
}

void apply_profile(Config& cfg) {
    if (cfg.profile == CaptureProfile::Manual) {
        return;
    }

    const ProfileSettings settings = profile_settings(cfg.profile);
    const std::string profile_name = profile_to_string(cfg.profile);
    const std::string profile_arg = "profile:" + profile_name;

    if (cfg.mode_specified && (cfg.mode != settings.mode)) {
        fail(profile_arg + " conflicts with explicit mode");
    }
    if (cfg.channel_count_specified && (cfg.channel_count != settings.channel_count)) {
        fail(profile_arg + " conflicts with explicit channels");
    }
    if (cfg.ch1_specified && (cfg.ch1 != settings.ch1)) {
        fail(profile_arg + " conflicts with explicit ch1");
    }
    if (settings.channel_count <= 1U) {
        if (cfg.ch2_specified) {
            fail(profile_arg + " does not allow ch2");
        }
    } else if (cfg.ch2_specified && (cfg.ch2 != settings.ch2)) {
        fail(profile_arg + " conflicts with explicit ch2");
    }

    cfg.mode = settings.mode;
    cfg.channel_count = settings.channel_count;
    cfg.ch1 = settings.ch1;
    cfg.ch2 = settings.ch2;
}

uint32_t parse_internal_ref_freq(const std::string& text) {
    const std::string value = trim_copy(text);
    if ((value == "2000000") || (value == "2000khz") || (value == "2mhz")) {
        return X502_REF_FREQ_2000KHZ;
    }
    if ((value == "1500000") || (value == "1500khz") || (value == "1.5mhz")) {
        return X502_REF_FREQ_1500KHZ;
    }
    fail("Unsupported internal_ref_hz value: " + text + ". Use 1500000 or 2000000");
}

uint32_t parse_channel_count(const std::string& text) {
    const uint32_t value = parse_u32(text, "channels");
    if ((value == 1U) || (value == 2U)) {
        return value;
    }
    fail("channels must be 1 or 2");
}

std::string ref_freq_to_string(uint32_t freq) {
    switch (freq) {
    case X502_REF_FREQ_2000KHZ:
        return "2000000";
    case X502_REF_FREQ_1500KHZ:
        return "1500000";
    default:
        return "unknown";
    }
}

bool use_internal_max_clock(const Config& cfg) {
    return (cfg.sync_mode == X502_SYNC_INTERNAL) && (cfg.max_internal_clock || !cfg.sample_clock_specified);
}

uint32_t parse_sync_mode(const std::string& text) {
    const std::string value = trim_copy(text);
    if ((value == "conv_in") || (value == "start_in") || (value == "external_master")) {
        return X502_SYNC_EXTERNAL_MASTER;
    }
    if (value == "di_syn1_rise") {
        return X502_SYNC_DI_SYN1_RISE;
    }
    if (value == "di_syn1_fall") {
        return X502_SYNC_DI_SYN1_FALL;
    }
    if (value == "di_syn2_rise") {
        return X502_SYNC_DI_SYN2_RISE;
    }
    if (value == "di_syn2_fall") {
        return X502_SYNC_DI_SYN2_FALL;
    }
    if ((value == "internal") || (value == "immediate")) {
        return X502_SYNC_INTERNAL;
    }
    fail("Unsupported sync mode: " + text);
}

std::string sync_mode_to_string(uint32_t mode, bool for_start) {
    switch (mode) {
    case X502_SYNC_INTERNAL:
        return for_start ? "immediate" : "internal";
    case X502_SYNC_EXTERNAL_MASTER:
        return for_start ? "start_in" : "conv_in";
    case X502_SYNC_DI_SYN1_RISE:
        return "di_syn1_rise";
    case X502_SYNC_DI_SYN1_FALL:
        return "di_syn1_fall";
    case X502_SYNC_DI_SYN2_RISE:
        return "di_syn2_rise";
    case X502_SYNC_DI_SYN2_FALL:
        return "di_syn2_fall";
    default:
        return "unknown";
    }
}

StopMode parse_stop_mode(const std::string& text) {
    const std::string value = trim_copy(text);
    if ((value == "frames") || (value == "duration") || (value == "none")) {
        return StopMode::TargetFrames;
    }
    if (value == "di_syn2_rise") {
        return StopMode::DiSyn2Rise;
    }
    if (value == "di_syn2_fall") {
        return StopMode::DiSyn2Fall;
    }
    fail("Unsupported stop mode: " + text);
}

std::string stop_mode_to_string(StopMode mode) {
    switch (mode) {
    case StopMode::TargetFrames:
        return "target_frames";
    case StopMode::DiSyn2Rise:
        return "di_syn2_rise";
    case StopMode::DiSyn2Fall:
        return "di_syn2_fall";
    default:
        return "unknown";
    }
}

Di1Mode parse_di1_mode(const std::string& text) {
    const std::string value = trim_copy(text);
    if ((value == "zero") || (value == "zero_on_change") || (value == "mark")) {
        return Di1Mode::ZeroOnChange;
    }
    if ((value == "trace") || (value == "plot") || (value == "stream")) {
        return Di1Mode::Trace;
    }
    if ((value == "ignore") || (value == "off") || (value == "none")) {
        return Di1Mode::Ignore;
    }
    fail("Unsupported di1 mode: " + text);
}

std::string di1_mode_to_string(Di1Mode mode) {
    switch (mode) {
    case Di1Mode::ZeroOnChange:
        return "zero";
    case Di1Mode::Trace:
        return "trace";
    case Di1Mode::Ignore:
        return "ignore";
    default:
        return "unknown";
    }
}

bool sync_uses_di_syn1(uint32_t mode) {
    return (mode == X502_SYNC_DI_SYN1_RISE) || (mode == X502_SYNC_DI_SYN1_FALL);
}

bool sync_uses_di_syn2(uint32_t mode) {
    return (mode == X502_SYNC_DI_SYN2_RISE) || (mode == X502_SYNC_DI_SYN2_FALL);
}

std::string phy_channel_name(uint32_t mode, uint32_t phy_ch) {
    if (mode == X502_LCH_MODE_DIFF) {
        return "X" + std::to_string(phy_ch + 1) + "-Y" + std::to_string(phy_ch + 1);
    }
    if (phy_ch < 16) {
        return "X" + std::to_string(phy_ch + 1);
    }
    if (phy_ch < 32) {
        return "Y" + std::to_string((phy_ch - 16) + 1);
    }
    return "CH" + std::to_string(phy_ch);
}

void print_help(const char* exe_name) {
    std::cout
        << "Usage:\n"
        << "  " << exe_name << " [serial:SN] [ip:192.168.0.10] [profile:phase|amplitude]\n"
        << "                [channels:2] [ch1:2] [ch2:3]\n"
        << "                [mode:diff|comm] [range:0.2] [clock:di_syn1_rise]\n"
        << "                [start:di_syn2_rise] [stop:di_syn2_fall] [sample_clock_hz:125000|max]\n"
        << "                [internal_ref_hz:2000000]\n"
        << "                [di1:zero|trace|ignore]\n"
        << "                [duration_ms:100] [packet_limit:0] [csv:capture.csv] [svg:capture.svg]\n"
        << "                [live_html:live_plot.html] [live_json:live_plot.json] [tty:/tmp/ttyADC_data] [di1_group_avg]\n"
        << "                [do1_toggle_per_frame] [do1_noise_subtract] [do1_raw_tty_marked] [do1_pair_subtract_avg] [noise_avg_steps:N]\n"
        << "                [do8_freq_ref] [do8_cycle_period:10] [do8_threshold:X]\n"
        << "                [recv_block:32768] [stats_period_ms:1000] [live_update_period_ms:1000] [svg_history_packets:50] [start_wait_ms:10000]\n"
        << "                [buffer_words:8388608] [step_words:32768]\n"
        << "                [pullup_syn1] [pullup_syn2] [pulldown_conv_in] [pulldown_start_in]\n"
        << "\n"
        << "Defaults for E-502:\n"
        << "  channels:2        -> capture CH1 and CH2\n"
        << "  ch1:2, ch2:3      -> X3-Y3 and X4-Y4\n"
        << "  mode:diff         -> differential measurement\n"
        << "  range:0.2         -> +/-0.2 V range\n"
        << "  supported ranges  -> 10, 5, 2, 1, 0.5, 0.2 V\n"
        << "  channels:1        -> capture only CH1, CH2 is ignored\n"
        << "  clock:di_syn1_rise-> external sample clock on DI_SYN1 rising edge\n"
        << "  clock:internal    -> module generates its own clock\n"
        << "  start:di_syn2_rise-> packet starts on DI_SYN2 rising edge\n"
        << "  stop:di_syn2_fall -> packet stops on DI_SYN2 falling edge\n"
        << "  sample_clock_hz:125000 -> requested ADC sample rate; for external clock it is the expected rate\n"
        << "  sample_clock_hz:max -> with clock:internal, use the maximum ADC speed\n"
        << "  internal_ref_hz:2000000 -> internal base clock for clock:internal (1500000 or 2000000)\n"
        << "  di1:zero         -> write ADC sample as 0 on each DI1 level change\n"
        << "  di1:trace        -> keep ADC unchanged and store DI1 as a separate synchronous trace\n"
        << "  di1:ignore       -> ignore DI1 for both zeroing and plotting\n"
        << "  stats_period_ms:1000 -> print online transfer/capture statistics every 1000 ms (0 disables)\n"
        << "  recv_block:32768 -> request up to 32768 raw 32-bit words per X502_Recv() call\n"
        << "  live_update_period_ms:1000 -> refresh live HTML/JSON no more than once per second"
        << " (ignored in tty fast stream-only modes)\n"
        << "  svg_history_packets:50 -> keep last 50 packets in RAM for final SVG (0 keeps all, risky)"
        << " (ignored in tty fast stream-only modes)\n"
        << "  duration_ms:100   -> max packet length if stop edge does not arrive\n"
        << "  packet_limit:0    -> 0 means continuous until Ctrl+C, N means stop after N packets\n"
        << "  buffer_words:8388608 -> input stream buffer size in 32-bit words\n"
        << "  step_words:32768 -> input stream transfer step in 32-bit words\n"
        << "  live_html/live_json -> live graph files updated as packets arrive outside tty fast stream-only modes\n"
        << "  tty:/tmp/ttyADC_data -> write a continuous legacy 4-word CH1/CH2 stream; with channels:1, CH2 is 0\n"
        << "  di1_group_avg -> with tty + di1:trace, emit one averaged 4-word step per constant DI1 run\n"
        << "  do1_toggle_per_frame -> hardware cyclic DO1 pattern in module memory:\n"
        << "                         DO1 outputs 00110011... continuously (toggle every 2 ADC ticks)\n"
        << "                         without per-tick DOUT updates from PC\n"
        << "  do1_noise_subtract -> with tty + do1_toggle_per_frame, use DI1 state from synchronous DIN\n"
        << "                        (expected DO1->DI1 loopback) to take previous DI1=HIGH step as baseline and subtract it\n"
        << "                        from useful steps before tty output\n"
        << "                        (only corrected DO1=LOW steps are sent to tty)\n"
        << "  do1_raw_tty_marked -> with tty + do1_toggle_per_frame + phase profile, emit raw per-step averages\n"
        << "                        from both phase channels, tagged by DI1 level from synchronous DIN:\n"
        << "                        word0=0x00A3 for DO1/DI1 LOW, word0=0x00A4 for DO1/DI1 HIGH\n"
        << "                        no baseline subtraction is applied in this mode\n"
        << "  do1_pair_subtract_avg -> with tty + do1_toggle_per_frame + phase profile, pair adjacent CH1/CH2 ticks,\n"
        << "                           drop boundary pairs with mismatched DI1/DI2, compute low-high by DI2 per pair,\n"
        << "                           average within each constant DI1 run, and emit legacy 4-word tty frames (word0=0x000A)\n"
        << "  noise_avg_steps:N -> kept for compatibility in do1_noise_subtract mode (current baseline is previous HIGH step)\n"
        << "                       (still required when do1_noise_subtract is enabled)\n"
        << "  do8_freq_ref      -> enable DO8/DI8 frequency reference overlay (DO8 and DI8 must be physically looped back);\n"
        << "                       DO8 toggles HIGH for 2 ticks within each do8_cycle_period cycle;\n"
        << "                       DI8=HIGH samples are accumulated separately and emitted as 0x00A8 frames\n"
        << "  do8_cycle_period:10 -> DO8 cycle period in ADC ticks (default 10, minimum 4)\n"
        << "  do8_threshold:X   -> only emit DI8 reference frame when channel amplitude exceeds X (raw ADC code units)\n"
        << "  tty fast stream-only modes -> di1_group_avg, do1_noise_subtract, do1_raw_tty_marked, do1_pair_subtract_avg;\n"
        << "                                skip CSV/SVG/live outputs\n"
        << "  If sample_clock_hz is omitted together with clock:internal, the maximum ADC speed is used\n"
        << "\n"
        << "Profiles:\n"
        << "  profile:phase      -> mode:diff channels:2 ch1:2 ch2:3 (X3-Y3 and X4-Y4)\n"
        << "  profile:amplitude  -> mode:comm channels:1 ch1:0 (X1, raw AD8317 voltage)\n"
        << "  profile:* is strict -> conflicting mode/channels/ch1/ch2 arguments are rejected\n"
        << "\n"
        << "Differential physical channel mapping:\n"
        << "  0..15  -> X1-Y1 .. X16-Y16\n"
        << "\n"
        << "Common-ground physical channel mapping:\n"
        << "  0..15  -> X1..X16\n"
        << "  16..31 -> Y1..Y16\n"
        << "\n"
        << "Useful sync lines on E-502:\n"
        << "  clock: conv_in | di_syn1_rise | di_syn1_fall | di_syn2_rise | di_syn2_fall\n"
        << "  start: immediate | start_in | di_syn1_rise | di_syn1_fall | di_syn2_rise | di_syn2_fall\n"
        << "  stop:  frames | di_syn2_rise | di_syn2_fall\n"
        << "\n"
        << "This build enables synchronous DIN together with ADC. DI_SYN2 stop edges are detected\n"
        << "inside the same input stream, packets are split continuously by DI_SYN2 edges, and DI1\n"
        << "can either zero ADC samples on change, be exported as a separate synchronous trace, or be ignored.\n"
        << "Outside tty fast stream-only modes, open live_plot.html in a browser to see the live graph update over time.\n"
        << "The live HTML supports X/Y zoom buttons, mouse-wheel zoom, and reset.\n"
        << "For tty output, use di1_group_avg together with di1:trace to emit one averaged 4-word step\n"
        << "per constant DI1 run while keeping the current ring-buffered binary frame format.\n"
        << "For DO1 subtraction with loopback, use do1_toggle_per_frame + do1_noise_subtract + noise_avg_steps:N;\n"
        << "DI1 from synchronous DIN is used as the actual DO1 state marker, and each LOW step subtracts previous HIGH step.\n"
        << "For raw loopback diagnostics, use do1_toggle_per_frame + do1_raw_tty_marked in phase profile;\n"
        << "steps are tagged in word0 as 0x00A3 (LOW) / 0x00A4 (HIGH) with raw per-step channel averages.\n"
        << "For DI1-stable low/high DI2 subtraction with legacy tty output, use do1_toggle_per_frame + do1_pair_subtract_avg\n"
        << "in phase profile; boundary CH1/CH2 pairs with mismatched DI1/DI2 are dropped before averaging.\n"
        << "Amplitude mode keeps the raw AD8317 voltage as captured on X1; no inversion is applied.\n"
        << "\n"
        << "Phase profile example:\n"
        << "  " << exe_name
        << " profile:phase clock:di_syn1_rise start:di_syn2_rise stop:di_syn2_fall sample_clock_hz:125000"
        << " duration_ms:100 packet_limit:10 csv:chirp.csv svg:chirp.svg\n"
        << "\n"
        << "Amplitude profile example:\n"
        << "  " << exe_name
        << " profile:amplitude clock:internal internal_ref_hz:2000000 start:di_syn2_rise stop:di_syn2_fall"
        << " sample_clock_hz:max duration_ms:100 packet_limit:0 csv:amplitude.csv svg:amplitude.svg\n"
        << "\n"
        << "Amplitude TTY DI1-group average example:\n"
        << "  " << exe_name
        << " profile:amplitude clock:internal internal_ref_hz:2000000 start:di_syn2_rise stop:di_syn2_fall"
        << " sample_clock_hz:max range:0.2 di1:trace di1_group_avg duration_ms:100 packet_limit:0"
        << " tty:/tmp/ttyADC_data\n"
        << "\n"
        << "Amplitude TTY DO1 noise subtract example:\n"
        << "  " << exe_name
        << " profile:amplitude clock:internal internal_ref_hz:2000000 start:di_syn2_rise stop:di_syn2_fall"
        << " sample_clock_hz:max range:0.2 do1_toggle_per_frame do1_noise_subtract noise_avg_steps:16"
        << " duration_ms:100 packet_limit:0 tty:/tmp/ttyADC_data\n"
        << "\n"
        << "Phase TTY raw DO1-level tagged example:\n"
        << "  " << exe_name
        << " profile:phase clock:internal internal_ref_hz:2000000 start:di_syn2_rise stop:di_syn2_fall"
        << " sample_clock_hz:max do1_toggle_per_frame do1_raw_tty_marked"
        << " duration_ms:100 packet_limit:0 tty:/tmp/ttyADC_data\n";
}

Config parse_args(int argc, char** argv) {
    Config cfg;

    for (int i = 1; i < argc; ++i) {
        const std::string arg = argv[i];
        if ((arg == "help") || (arg == "--help") || (arg == "-h")) {
            print_help(argv[0]);
            std::exit(0);
        }
        if (arg == "pullup_syn1") {
            cfg.pullup_syn1 = true;
            continue;
        }
        if (arg == "pullup_syn2") {
            cfg.pullup_syn2 = true;
            continue;
        }
        if (arg == "pulldown_conv_in") {
            cfg.pulldown_conv_in = true;
            continue;
        }
        if (arg == "pulldown_start_in") {
            cfg.pulldown_start_in = true;
            continue;
        }
        if (starts_with(arg, "serial:")) {
            cfg.serial = arg.substr(7);
            continue;
        }
        if (starts_with(arg, "ip:")) {
            cfg.ip_addr = parse_ipv4(arg.substr(3));
            continue;
        }
        if (starts_with(arg, "profile:")) {
            cfg.profile = parse_profile(arg.substr(8));
            continue;
        }
        if (starts_with(arg, "mode:")) {
            cfg.mode = parse_mode(arg.substr(5));
            cfg.mode_specified = true;
            continue;
        }
        if (starts_with(arg, "channels:")) {
            cfg.channel_count = parse_channel_count(arg.substr(9));
            cfg.channel_count_specified = true;
            continue;
        }
        if (starts_with(arg, "range:")) {
            cfg.range = parse_range(arg.substr(6));
            continue;
        }
        if (starts_with(arg, "ch1:")) {
            cfg.ch1 = parse_u32(arg.substr(4), "ch1");
            cfg.ch1_specified = true;
            continue;
        }
        if (starts_with(arg, "ch2:")) {
            cfg.ch2 = parse_u32(arg.substr(4), "ch2");
            cfg.ch2_specified = true;
            continue;
        }
        if (starts_with(arg, "clock:")) {
            cfg.sync_mode = parse_sync_mode(arg.substr(6));
            continue;
        }
        if (starts_with(arg, "start:")) {
            cfg.sync_start_mode = parse_sync_mode(arg.substr(6));
            continue;
        }
        if (starts_with(arg, "stop:")) {
            cfg.stop_mode = parse_stop_mode(arg.substr(5));
            continue;
        }
        if (starts_with(arg, "di1:")) {
            cfg.di1_mode = parse_di1_mode(arg.substr(4));
            continue;
        }
        if ((arg == "di1_group_avg") || (arg == "di1_avg")) {
            cfg.di1_group_average = true;
            continue;
        }
        if (arg == "do1_noise_subtract") {
            cfg.do1_noise_subtract = true;
            continue;
        }
        if (arg == "do1_raw_tty_marked") {
            cfg.do1_raw_tty_marked = true;
            continue;
        }
        if (arg == "do1_pair_subtract_avg") {
            cfg.do1_pair_subtract_avg = true;
            continue;
        }
        if (arg == "do1_toggle_per_frame") {
            cfg.do1_toggle_per_frame = true;
            continue;
        }
        if (starts_with(arg, "noise_avg_steps:")) {
            cfg.noise_avg_steps = parse_u32(arg.substr(16), "noise_avg_steps");
            continue;
        }
        if (arg == "do8_freq_ref") {
            cfg.do8_freq_ref = true;
            continue;
        }
        if (starts_with(arg, "do8_cycle_period:")) {
            cfg.do8_cycle_period_ticks = parse_u32(arg.substr(17), "do8_cycle_period");
            continue;
        }
        if (starts_with(arg, "do8_threshold:")) {
            cfg.do8_amplitude_threshold = parse_double(arg.substr(14), "do8_threshold");
            continue;
        }
        if (starts_with(arg, "sample_clock_hz:")) {
            const std::string value = trim_copy(arg.substr(16));
            cfg.sample_clock_specified = true;
            if ((value == "max") || (value == "maximum")) {
                cfg.max_internal_clock = true;
            } else {
                cfg.sample_clock_hz = parse_double(value, "sample_clock_hz");
                cfg.max_internal_clock = false;
            }
            continue;
        }
        if (starts_with(arg, "internal_ref_hz:")) {
            cfg.internal_ref_freq = parse_internal_ref_freq(arg.substr(16));
            continue;
        }
        if (starts_with(arg, "duration_ms:")) {
            cfg.duration_ms = parse_double(arg.substr(12), "duration_ms");
            continue;
        }
        if (starts_with(arg, "packet_limit:")) {
            cfg.packet_limit = parse_u32(arg.substr(13), "packet_limit");
            continue;
        }
        if (starts_with(arg, "recv_block:")) {
            cfg.recv_block_words = parse_u32(arg.substr(11), "recv_block");
            cfg.recv_block_specified = true;
            continue;
        }
        if (starts_with(arg, "recv_timeout_ms:")) {
            cfg.recv_timeout_ms = parse_u32(arg.substr(16), "recv_timeout_ms");
            continue;
        }
        if (starts_with(arg, "stats_period_ms:")) {
            cfg.stats_period_ms = parse_u32(arg.substr(16), "stats_period_ms");
            continue;
        }
        if (starts_with(arg, "live_update_period_ms:")) {
            cfg.live_update_period_ms = parse_u32(arg.substr(22), "live_update_period_ms");
            cfg.live_update_specified = true;
            continue;
        }
        if (starts_with(arg, "svg_history_packets:")) {
            cfg.svg_history_packets = parse_u32(arg.substr(20), "svg_history_packets");
            continue;
        }
        if (starts_with(arg, "start_wait_ms:")) {
            cfg.start_wait_ms = parse_u32(arg.substr(14), "start_wait_ms");
            continue;
        }
        if (starts_with(arg, "buffer_words:")) {
            cfg.input_buffer_words = parse_u32(arg.substr(13), "buffer_words");
            cfg.input_buffer_specified = true;
            continue;
        }
        if (starts_with(arg, "step_words:")) {
            cfg.input_step_words = parse_u32(arg.substr(11), "step_words");
            cfg.input_step_specified = true;
            continue;
        }
        if (starts_with(arg, "csv:")) {
            cfg.csv_path = arg.substr(4);
            continue;
        }
        if (starts_with(arg, "svg:")) {
            cfg.svg_path = arg.substr(4);
            continue;
        }
        if (starts_with(arg, "live_html:")) {
            cfg.live_html_path = arg.substr(10);
            continue;
        }
        if (starts_with(arg, "live_json:")) {
            cfg.live_json_path = arg.substr(10);
            continue;
        }
        if (starts_with(arg, "tty:")) {
            cfg.tty_path = arg.substr(4);
            continue;
        }
        fail("Unknown argument: " + arg);
    }

    apply_profile(cfg);

    if (cfg.duration_ms <= 0.0) {
        fail("duration_ms must be > 0");
    }
    if (!cfg.max_internal_clock && (cfg.sample_clock_hz <= 0.0)) {
        fail("sample_clock_hz must be > 0");
    }
    if (cfg.max_internal_clock && (cfg.sync_mode != X502_SYNC_INTERNAL)) {
        fail("sample_clock_hz:max is only valid together with clock:internal");
    }
    const bool high_rate_capture =
        use_internal_max_clock(cfg) ||
        ((cfg.sample_clock_hz >= 1000000.0) &&
         ((cfg.sync_mode == X502_SYNC_INTERNAL) || cfg.sample_clock_specified));
    if (high_rate_capture) {
        if (!cfg.recv_block_specified) {
            cfg.recv_block_words = std::max<uint32_t>(cfg.recv_block_words, 32768U);
        }
        if (!cfg.input_step_specified) {
            cfg.input_step_words = std::max<uint32_t>(cfg.input_step_words, 32768U);
        }
        if (!cfg.input_buffer_specified) {
            cfg.input_buffer_words = std::max<uint32_t>(cfg.input_buffer_words, 8U * 1024U * 1024U);
        }
        if (!cfg.live_update_specified) {
            cfg.live_update_period_ms = std::max<uint32_t>(cfg.live_update_period_ms, 1000U);
        }
    }
    if (cfg.tty_path && (cfg.di1_group_average || cfg.do1_noise_subtract || cfg.do1_raw_tty_marked || cfg.do1_pair_subtract_avg)) {
        if (!cfg.recv_block_specified) {
            cfg.recv_block_words = std::max<uint32_t>(cfg.recv_block_words, 65536U);
        }
        if (!cfg.input_step_specified) {
            cfg.input_step_words = std::max<uint32_t>(cfg.input_step_words, 65536U);
        }
        if (!cfg.input_buffer_specified) {
            cfg.input_buffer_words = std::max<uint32_t>(cfg.input_buffer_words, 16U * 1024U * 1024U);
        }
    }
    if (cfg.recv_block_words == 0) {
        fail("recv_block must be > 0");
    }
    if (cfg.input_step_words == 0) {
        cfg.input_step_words = cfg.recv_block_words;
    }
    if (cfg.input_buffer_words < cfg.recv_block_words) {
        cfg.input_buffer_words = cfg.recv_block_words;
    }
    if (cfg.di1_group_average && (cfg.di1_mode != Di1Mode::Trace)) {
        fail("di1_group_avg requires di1:trace");
    }
    if (cfg.di1_group_average && cfg.do1_raw_tty_marked) {
        fail("do1_raw_tty_marked is incompatible with di1_group_avg");
    }
    if (cfg.di1_group_average && cfg.do1_pair_subtract_avg) {
        fail("do1_pair_subtract_avg is incompatible with di1_group_avg");
    }
    if (cfg.do1_noise_subtract) {
        if (!cfg.tty_path.has_value()) {
            fail("do1_noise_subtract requires tty:<path>");
        }
        if (!cfg.do1_toggle_per_frame) {
            fail("do1_noise_subtract requires do1_toggle_per_frame");
        }
        if (cfg.di1_group_average) {
            fail("do1_noise_subtract is incompatible with di1_group_avg");
        }
        if (cfg.do1_raw_tty_marked) {
            fail("do1_noise_subtract is incompatible with do1_raw_tty_marked");
        }
        if (cfg.do1_pair_subtract_avg) {
            fail("do1_noise_subtract is incompatible with do1_pair_subtract_avg");
        }
        if (!cfg.noise_avg_steps.has_value()) {
            fail("do1_noise_subtract requires noise_avg_steps:N");
        }
        if (*cfg.noise_avg_steps == 0U) {
            fail("noise_avg_steps must be > 0");
        }
    } else if (cfg.noise_avg_steps.has_value()) {
        fail("noise_avg_steps:N can only be used together with do1_noise_subtract");
    }
    if (cfg.do1_raw_tty_marked) {
        if (!cfg.tty_path.has_value()) {
            fail("do1_raw_tty_marked requires tty:<path>");
        }
        if (!cfg.do1_toggle_per_frame) {
            fail("do1_raw_tty_marked requires do1_toggle_per_frame");
        }
        if (cfg.do1_pair_subtract_avg) {
            fail("do1_raw_tty_marked is incompatible with do1_pair_subtract_avg");
        }
        if (cfg.mode != X502_LCH_MODE_DIFF) {
            fail("do1_raw_tty_marked requires phase configuration: mode:diff channels:2 ch1:2 ch2:3");
        }
        if ((cfg.channel_count != 2U) || (cfg.ch1 != 2U) || (cfg.ch2 != 3U)) {
            fail("do1_raw_tty_marked requires phase configuration: mode:diff channels:2 ch1:2 ch2:3");
        }
    }
    if (cfg.do1_pair_subtract_avg) {
        if (!cfg.tty_path.has_value()) {
            fail("do1_pair_subtract_avg requires tty:<path>");
        }
        if (!cfg.do1_toggle_per_frame) {
            fail("do1_pair_subtract_avg requires do1_toggle_per_frame");
        }
        if (cfg.do1_noise_subtract) {
            fail("do1_pair_subtract_avg is incompatible with do1_noise_subtract");
        }
        if (cfg.do1_raw_tty_marked) {
            fail("do1_pair_subtract_avg is incompatible with do1_raw_tty_marked");
        }
        if (cfg.profile != CaptureProfile::Phase) {
            fail("do1_pair_subtract_avg requires profile:phase");
        }
        if ((cfg.mode != X502_LCH_MODE_DIFF) || (cfg.channel_count != 2U) || (cfg.ch1 != 2U) || (cfg.ch2 != 3U)) {
            fail("do1_pair_subtract_avg requires phase configuration: mode:diff channels:2 ch1:2 ch2:3");
        }
    }
    if (cfg.do8_freq_ref) {
        if (!cfg.tty_path.has_value()) {
            fail("do8_freq_ref requires tty:<path>");
        }
        if (!cfg.do1_toggle_per_frame) {
            fail("do8_freq_ref requires do1_toggle_per_frame (cyclic output must be active)");
        }
        if (cfg.do8_cycle_period_ticks < 4U) {
            fail("do8_cycle_period must be >= 4 (at least 2 LOW ticks + 2 HIGH ticks)");
        }
        if (cfg.do8_amplitude_threshold.has_value() && *cfg.do8_amplitude_threshold < 0.0) {
            fail("do8_threshold must be >= 0");
        }
        if (std::gcd(4U, cfg.do8_cycle_period_ticks) > 2U) {
            std::cerr << "Warning: gcd(DO1 period=4"
                      << ", DO8 period=" << cfg.do8_cycle_period_ticks
                      << ") > 2; DI8=HIGH may systematically align with the same DO1 phase\n";
        }
    }
    if (sync_uses_di_syn1(cfg.sync_mode) && sync_uses_di_syn1(cfg.sync_start_mode)) {
        fail("clock and start cannot both use DI_SYN1; use start_in or immediate start");
    }
    if (sync_uses_di_syn2(cfg.sync_mode) && sync_uses_di_syn2(cfg.sync_start_mode)) {
        fail("clock and start cannot both use DI_SYN2; use start_in or immediate start");
    }
    if ((cfg.stop_mode != StopMode::TargetFrames) && sync_uses_di_syn2(cfg.sync_mode)) {
        fail("DI_SYN2 cannot be used simultaneously for clock and stop");
    }
    if ((cfg.stop_mode == StopMode::DiSyn2Rise) && (cfg.sync_start_mode == X502_SYNC_DI_SYN2_RISE)) {
        fail("start and stop cannot both use the same DI_SYN2 rising edge");
    }
    if ((cfg.stop_mode == StopMode::DiSyn2Fall) && (cfg.sync_start_mode == X502_SYNC_DI_SYN2_FALL)) {
        fail("start and stop cannot both use the same DI_SYN2 falling edge");
    }

    if (cfg.mode == X502_LCH_MODE_DIFF) {
        if (cfg.ch1 >= X502_ADC_DIFF_CH_CNT) {
            fail("For differential mode E-502 ch1 must be in range 0..15");
        }
        if ((cfg.channel_count >= 2U) && (cfg.ch2 >= X502_ADC_DIFF_CH_CNT)) {
            fail("For differential mode E-502 ch2 must be in range 0..15");
        }
    } else {
        if (cfg.ch1 >= X502_ADC_COMM_CH_CNT) {
            fail("For common-ground mode E-502 ch1 must be in range 0..31");
        }
        if ((cfg.channel_count >= 2U) && (cfg.ch2 >= X502_ADC_COMM_CH_CNT)) {
            fail("For common-ground mode E-502 ch2 must be in range 0..31");
        }
    }

    return cfg;
}

#ifdef _WIN32
using ModuleHandle = HMODULE;
#else
using ModuleHandle = void*;
#endif

std::string dynamic_loader_error() {
#ifdef _WIN32
    return "unknown error";
#else
    const char* error = dlerror();
    return ((error != nullptr) && (*error != '\0')) ? std::string(error) : std::string("unknown error");
#endif
}

ModuleHandle open_library(const char* path) {
#ifdef _WIN32
    return LoadLibraryA(path);
#else
    dlerror();
    return dlopen(path, RTLD_LAZY | RTLD_LOCAL);
#endif
}

void close_library(ModuleHandle module) {
#ifdef _WIN32
    if (module != nullptr) {
        FreeLibrary(module);
    }
#else
    if (module != nullptr) {
        dlclose(module);
    }
#endif
}

ModuleHandle load_library_or_fail(const std::vector<std::string>& candidates,
                                  const std::string& description) {
    std::string last_error = "no candidates provided";
    for (const auto& candidate : candidates) {
        ModuleHandle module = open_library(candidate.c_str());
        if (module != nullptr) {
            return module;
        }
        last_error = dynamic_loader_error();
    }

    std::ostringstream out;
    out << "Cannot load " << description << ". Tried:";
    for (const auto& candidate : candidates) {
        out << " " << candidate;
    }
    out << ". Last error: " << last_error;
    fail(out.str());
}

template <typename Fn>
Fn load_symbol(ModuleHandle module, const char* name) {
#ifdef _WIN32
    const auto addr = GetProcAddress(module, name);
    if (addr == nullptr) {
        fail(std::string("GetProcAddress failed for symbol: ") + name);
    }
    return reinterpret_cast<Fn>(addr);
#else
    dlerror();
    void* addr = dlsym(module, name);
    const char* error = dlerror();
    if ((addr == nullptr) || (error != nullptr)) {
        std::ostringstream out;
        out << "dlsym failed for symbol " << name << ": "
            << ((error != nullptr) ? error : "unknown error");
        fail(out.str());
    }
    return reinterpret_cast<Fn>(addr);
#endif
}

template <typename Fn>
Fn try_load_symbol(ModuleHandle module, const char* name) {
#ifdef _WIN32
    const auto addr = GetProcAddress(module, name);
    return reinterpret_cast<Fn>(addr);
#else
    dlerror();
    void* addr = dlsym(module, name);
    (void) dlerror();
    return reinterpret_cast<Fn>(addr);
#endif
}

struct Api {
    ModuleHandle x502_module = nullptr;
    ModuleHandle e502_module = nullptr;

    decltype(&X502_Create) Create = nullptr;
    decltype(&X502_Free) Free = nullptr;
    decltype(&X502_Close) Close = nullptr;
    decltype(&X502_GetErrorString) GetErrorString = nullptr;
    decltype(&X502_GetDevInfo) GetDevInfo = nullptr;
    decltype(&X502_GetDevInfo2) GetDevInfo2 = nullptr;
    decltype(&X502_SetMode) SetMode = nullptr;
    decltype(&X502_StreamsStop) StreamsStop = nullptr;
    decltype(&X502_StreamsDisable) StreamsDisable = nullptr;
    decltype(&X502_SetSyncMode) SetSyncMode = nullptr;
    decltype(&X502_SetSyncStartMode) SetSyncStartMode = nullptr;
    decltype(&X502_SetLChannelCount) SetLChannelCount = nullptr;
    decltype(&X502_SetLChannel) SetLChannel = nullptr;
    decltype(&X502_SetAdcFreqDivider) SetAdcFreqDivider = nullptr;
    decltype(&X502_SetAdcInterframeDelay) SetAdcInterframeDelay = nullptr;
    decltype(&X502_SetDinFreqDivider) SetDinFreqDivider = nullptr;
    decltype(&X502_SetAdcFreq) SetAdcFreq = nullptr;
    decltype(&X502_GetAdcFreq) GetAdcFreq = nullptr;
    decltype(&X502_SetDinFreq) SetDinFreq = nullptr;
    decltype(&X502_SetOutFreq) SetOutFreq = nullptr;
    decltype(&X502_AsyncOutDig) AsyncOutDig = nullptr;
    decltype(&X502_AsyncInDig) AsyncInDig = nullptr;
    decltype(&X502_SetRefFreq) SetRefFreq = nullptr;
    decltype(&X502_SetStreamBufSize) SetStreamBufSize = nullptr;
    decltype(&X502_SetStreamStep) SetStreamStep = nullptr;
    decltype(&X502_SetDigInPullup) SetDigInPullup = nullptr;
    decltype(&X502_SetExtRefFreqValue) SetExtRefFreqValue = nullptr;
    decltype(&X502_Configure) Configure = nullptr;
    decltype(&X502_StreamsEnable) StreamsEnable = nullptr;
    decltype(&X502_StreamsStart) StreamsStart = nullptr;
    decltype(&X502_GetRecvReadyCount) GetRecvReadyCount = nullptr;
    decltype(&X502_Recv) Recv = nullptr;
    decltype(&X502_OutCycleLoadStart) OutCycleLoadStart = nullptr;
    decltype(&X502_OutCycleSetup) OutCycleSetup = nullptr;
    decltype(&X502_OutCycleStop) OutCycleStop = nullptr;
    decltype(&X502_Send) Send = nullptr;
    decltype(&X502_PrepareData) PrepareData = nullptr;
    decltype(&X502_ProcessData) ProcessData = nullptr;

    decltype(&E502_OpenUsb) OpenUsb = nullptr;
    decltype(&E502_OpenByIpAddr) OpenByIpAddr = nullptr;

    Api() {
        x502_module = load_library_or_fail(
#ifdef _WIN32
            {"x502api.dll"},
#else
            {"libx502api.so", "x502api.so", "./libx502api.so", "./x502api.so"},
#endif
            "x502 API library");
        e502_module = load_library_or_fail(
#ifdef _WIN32
            {"e502api.dll"},
#else
            {"libe502api.so", "e502api.so", "./libe502api.so", "./e502api.so"},
#endif
            "e502 API library");

        Create = load_symbol<decltype(Create)>(x502_module, "X502_Create");
        Free = load_symbol<decltype(Free)>(x502_module, "X502_Free");
        Close = load_symbol<decltype(Close)>(x502_module, "X502_Close");
        GetErrorString = load_symbol<decltype(GetErrorString)>(x502_module, "X502_GetErrorString");
        GetDevInfo = try_load_symbol<decltype(GetDevInfo)>(x502_module, "X502_GetDevInfo");
        GetDevInfo2 = try_load_symbol<decltype(GetDevInfo2)>(x502_module, "X502_GetDevInfo2");
        if ((GetDevInfo == nullptr) && (GetDevInfo2 == nullptr)) {
            fail("Neither X502_GetDevInfo nor X502_GetDevInfo2 is available in x502 API library");
        }
        SetMode = load_symbol<decltype(SetMode)>(x502_module, "X502_SetMode");
        StreamsStop = load_symbol<decltype(StreamsStop)>(x502_module, "X502_StreamsStop");
        StreamsDisable = load_symbol<decltype(StreamsDisable)>(x502_module, "X502_StreamsDisable");
        SetSyncMode = load_symbol<decltype(SetSyncMode)>(x502_module, "X502_SetSyncMode");
        SetSyncStartMode = load_symbol<decltype(SetSyncStartMode)>(x502_module, "X502_SetSyncStartMode");
        SetLChannelCount = load_symbol<decltype(SetLChannelCount)>(x502_module, "X502_SetLChannelCount");
        SetLChannel = load_symbol<decltype(SetLChannel)>(x502_module, "X502_SetLChannel");
        SetAdcFreqDivider = load_symbol<decltype(SetAdcFreqDivider)>(x502_module, "X502_SetAdcFreqDivider");
        SetAdcInterframeDelay = load_symbol<decltype(SetAdcInterframeDelay)>(x502_module, "X502_SetAdcInterframeDelay");
        SetDinFreqDivider = load_symbol<decltype(SetDinFreqDivider)>(x502_module, "X502_SetDinFreqDivider");
        SetAdcFreq = load_symbol<decltype(SetAdcFreq)>(x502_module, "X502_SetAdcFreq");
        GetAdcFreq = load_symbol<decltype(GetAdcFreq)>(x502_module, "X502_GetAdcFreq");
        SetDinFreq = load_symbol<decltype(SetDinFreq)>(x502_module, "X502_SetDinFreq");
        SetOutFreq = load_symbol<decltype(SetOutFreq)>(x502_module, "X502_SetOutFreq");
        AsyncOutDig = load_symbol<decltype(AsyncOutDig)>(x502_module, "X502_AsyncOutDig");
        AsyncInDig = load_symbol<decltype(AsyncInDig)>(x502_module, "X502_AsyncInDig");
        SetRefFreq = load_symbol<decltype(SetRefFreq)>(x502_module, "X502_SetRefFreq");
        SetStreamBufSize = load_symbol<decltype(SetStreamBufSize)>(x502_module, "X502_SetStreamBufSize");
        SetStreamStep = load_symbol<decltype(SetStreamStep)>(x502_module, "X502_SetStreamStep");
        SetDigInPullup = load_symbol<decltype(SetDigInPullup)>(x502_module, "X502_SetDigInPullup");
        SetExtRefFreqValue = load_symbol<decltype(SetExtRefFreqValue)>(x502_module, "X502_SetExtRefFreqValue");
        Configure = load_symbol<decltype(Configure)>(x502_module, "X502_Configure");
        StreamsEnable = load_symbol<decltype(StreamsEnable)>(x502_module, "X502_StreamsEnable");
        StreamsStart = load_symbol<decltype(StreamsStart)>(x502_module, "X502_StreamsStart");
        GetRecvReadyCount = load_symbol<decltype(GetRecvReadyCount)>(x502_module, "X502_GetRecvReadyCount");
        Recv = load_symbol<decltype(Recv)>(x502_module, "X502_Recv");
        OutCycleLoadStart = try_load_symbol<decltype(OutCycleLoadStart)>(x502_module, "X502_OutCycleLoadStart");
        OutCycleSetup = try_load_symbol<decltype(OutCycleSetup)>(x502_module, "X502_OutCycleSetup");
        OutCycleStop = try_load_symbol<decltype(OutCycleStop)>(x502_module, "X502_OutCycleStop");
        Send = load_symbol<decltype(Send)>(x502_module, "X502_Send");
        PrepareData = load_symbol<decltype(PrepareData)>(x502_module, "X502_PrepareData");
        ProcessData = load_symbol<decltype(ProcessData)>(x502_module, "X502_ProcessData");

        OpenUsb = load_symbol<decltype(OpenUsb)>(e502_module, "E502_OpenUsb");
        OpenByIpAddr = load_symbol<decltype(OpenByIpAddr)>(e502_module, "E502_OpenByIpAddr");
    }

    ~Api() {
        close_library(e502_module);
        close_library(x502_module);
    }
};

std::string x502_error(const Api& api, int32_t err) {
    const char* text = api.GetErrorString ? api.GetErrorString(err) : nullptr;
    std::ostringstream out;
    out << "err=" << err;
    if ((text != nullptr) && (*text != '\0')) {
        out << " (" << text << ")";
    }
    return out.str();
}

void expect_ok(const Api& api, int32_t err, const std::string& what) {
    if (err != X502_ERR_OK) {
        fail(what + ": " + x502_error(api, err));
    }
}

constexpr uint32_t kE502DiSyn2Mask =
    (static_cast<uint32_t>(1U) << 13U) | (static_cast<uint32_t>(1U) << 17U);
constexpr uint32_t kE502Digital1Mask = (static_cast<uint32_t>(1U) << 0U);
constexpr uint32_t kE502Digital2Mask = (static_cast<uint32_t>(1U) << 1U);
constexpr uint32_t kE502Do1Mask = (static_cast<uint32_t>(1U) << 0U);
constexpr uint32_t kE502Do2Mask = (static_cast<uint32_t>(1U) << 1U);
constexpr uint32_t kE502Digital8Mask = (static_cast<uint32_t>(1U) << 6U);  // DI8 reads as bit 6 (hardware offset confirmed by loopback test)
constexpr uint32_t kE502Do8Mask = (static_cast<uint32_t>(1U) << 7U);
constexpr uint32_t kDo1TogglePeriodTicks = 2U;
constexpr uint32_t kDo1CyclePatternWords = kDo1TogglePeriodTicks * 2U;
constexpr uint32_t kDo8HighTicks = 2U;
constexpr uint16_t kTtyMarkerDi8High = 0x00A8U;
constexpr uint32_t kStreamInputAdcFlag = 0x80000000U;
constexpr uint32_t kStreamInputCalibratedAdcFlag = 0x40000000U;

using TickMs = uint64_t;

TickMs tick_count_ms() {
#ifdef _WIN32
    return static_cast<TickMs>(GetTickCount64());
#else
    using namespace std::chrono;
    return static_cast<TickMs>(
        duration_cast<milliseconds>(steady_clock::now().time_since_epoch()).count());
#endif
}

#ifdef _WIN32
volatile LONG g_console_stop_requested = 0;

BOOL WINAPI console_ctrl_handler(DWORD ctrl_type) {
    if ((ctrl_type == CTRL_C_EVENT) || (ctrl_type == CTRL_BREAK_EVENT) || (ctrl_type == CTRL_CLOSE_EVENT)) {
        InterlockedExchange(&g_console_stop_requested, 1);
        return TRUE;
    }
    return FALSE;
}

bool console_stop_requested() {
    return InterlockedCompareExchange(&g_console_stop_requested, 0, 0) != 0;
}
#else
volatile std::sig_atomic_t g_console_stop_requested = 0;

void console_ctrl_handler(int) {
    g_console_stop_requested = 1;
}

bool console_stop_requested() {
    return g_console_stop_requested != 0;
}
#endif

enum class PacketCloseReason {
    ExternalStopEdge,
    DurationLimit,
    UserStop
};

const char* packet_close_reason_to_string(PacketCloseReason reason) {
    switch (reason) {
    case PacketCloseReason::ExternalStopEdge:
        return "DI_SYN2 edge";
    case PacketCloseReason::DurationLimit:
        return "duration limit";
    case PacketCloseReason::UserStop:
        return "user stop";
    default:
        return "unknown";
    }
}

bool matches_sync_edge(uint32_t mode, bool prev_level, bool current_level) {
    if (mode == X502_SYNC_DI_SYN2_RISE) {
        return !prev_level && current_level;
    }
    if (mode == X502_SYNC_DI_SYN2_FALL) {
        return prev_level && !current_level;
    }
    return false;
}

bool matches_stop_edge(StopMode mode, bool prev_level, bool current_level) {
    if (mode == StopMode::DiSyn2Rise) {
        return !prev_level && current_level;
    }
    if (mode == StopMode::DiSyn2Fall) {
        return prev_level && !current_level;
    }
    return false;
}

struct PacketAccumulator {
    std::array<std::vector<double>, 2> channels;
    std::vector<uint8_t> di1;
    std::size_t zeroed_samples = 0;
    std::size_t stored_samples = 0;
    bool pending_frame_di1_valid = false;
    uint8_t pending_frame_di1 = 0;

    void reset(std::size_t reserve_frames, std::size_t channel_count) {
        for (std::size_t i = 0; i < channels.size(); ++i) {
            auto& channel = channels[i];
            channel.clear();
            if (i < channel_count) {
                channel.reserve(reserve_frames);
            }
        }
        di1.clear();
        di1.reserve(reserve_frames);
        zeroed_samples = 0;
        stored_samples = 0;
        pending_frame_di1_valid = false;
        pending_frame_di1 = 0;
    }

    std::size_t frame_count(std::size_t channel_count) const {
        std::size_t frames = (channel_count <= 1U) ? channels[0].size() : std::min(channels[0].size(), channels[1].size());
        if (!di1.empty()) {
            frames = std::min(frames, di1.size());
        }
        return frames;
    }
};

struct TtyContinuousState {
    bool pending_ch1_valid = false;
    int16_t pending_ch1 = 0;
    uint16_t next_index = 1;
};

struct TtyGroupAverageState {
    double sum_ch1 = 0.0;
    double sum_ch2 = 0.0;
    uint32_t count_ch1 = 0;
    uint32_t count_ch2 = 0;
    uint32_t next_index = 1;

    void clear_step() {
        sum_ch1 = 0.0;
        sum_ch2 = 0.0;
        count_ch1 = 0;
        count_ch2 = 0;
    }

    void reset_packet() {
        clear_step();
        next_index = 1;
    }

    void add_sample(uint32_t lch, double raw_code) {
        if (lch == 0U) {
            sum_ch1 += raw_code;
            ++count_ch1;
        } else if (lch == 1U) {
            sum_ch2 += raw_code;
            ++count_ch2;
        }
    }

    bool has_complete_step(uint32_t channel_count) const {
        return (count_ch1 != 0U) && ((channel_count <= 1U) || (count_ch2 != 0U));
    }
};

struct Do1RawTaggedState {
    std::array<double, 2> step_sum {};
    std::array<uint32_t, 2> step_count {};
    bool step_level_initialized = false;
    bool step_di1_high = false;
    uint16_t next_index = 1;

    void clear_step() {
        step_sum = {};
        step_count = {};
    }

    void reset_packet() {
        clear_step();
        step_level_initialized = false;
        step_di1_high = false;
        next_index = 1;
    }

    void start_new_step(bool di1_high) {
        clear_step();
        step_di1_high = di1_high;
        step_level_initialized = true;
    }

    void add_sample(uint32_t lch, double raw_code) {
        if (lch >= step_sum.size()) {
            return;
        }
        step_sum[lch] += raw_code;
        ++step_count[lch];
    }

    bool has_complete_step(uint32_t channel_count) const {
        return (step_count[0] != 0U) && ((channel_count <= 1U) || (step_count[1] != 0U));
    }

    double step_average(uint32_t lch) const {
        if ((lch >= step_sum.size()) || (step_count[lch] == 0U)) {
            return 0.0;
        }
        return step_sum[lch] / static_cast<double>(step_count[lch]);
    }
};

struct Do1NoiseSubtractState {
    std::array<double, 2> step_sum {};
    std::array<uint32_t, 2> step_count {};
    std::array<double, 2> last_high_avg {};
    std::array<bool, 2> last_high_valid {};
    bool high_baseline_pending = false;
    bool step_level_initialized = false;
    bool step_di1_high = false;
    uint16_t next_index = 1;

    void configure(uint32_t history_steps) {
        (void) history_steps;
        clear_baseline();
        reset_packet();
    }

    void clear_step() {
        step_sum = {};
        step_count = {};
    }

    void clear_baseline() {
        last_high_avg = {};
        last_high_valid = {};
        high_baseline_pending = false;
    }

    void reset_packet() {
        clear_step();
        step_level_initialized = false;
        step_di1_high = false;
        next_index = 1;
    }

    void start_new_step(bool di1_high) {
        clear_step();
        step_di1_high = di1_high;
        step_level_initialized = true;
    }

    void add_sample(uint32_t lch, double raw_code) {
        if (lch >= step_sum.size()) {
            return;
        }
        step_sum[lch] += raw_code;
        ++step_count[lch];
    }

    bool has_complete_step(uint32_t channel_count) const {
        return (step_count[0] != 0U) && ((channel_count <= 1U) || (step_count[1] != 0U));
    }

    double step_average(uint32_t lch) const {
        if ((lch >= step_sum.size()) || (step_count[lch] == 0U)) {
            return 0.0;
        }
        return step_sum[lch] / static_cast<double>(step_count[lch]);
    }

    void update_last_high(uint32_t lch, double avg_value) {
        if (lch >= last_high_avg.size()) {
            return;
        }
        last_high_avg[lch] = avg_value;
        last_high_valid[lch] = true;
    }

    bool has_pending_high(uint32_t channel_count) const {
        if (!high_baseline_pending) {
            return false;
        }
        if (!last_high_valid[0]) {
            return false;
        }
        if ((channel_count > 1U) && !last_high_valid[1]) {
            return false;
        }
        return true;
    }

    void mark_high_pending() { high_baseline_pending = true; }

    void consume_pending_high() { high_baseline_pending = false; }

    double last_high(uint32_t lch) const {
        if (lch >= last_high_avg.size()) {
            return 0.0;
        }
        return last_high_avg[lch];
    }

    void finish_step() { clear_step(); }
};

struct Do1PairSubtractAverageState {
    struct PairSample {
        double ch1 = 0.0;
        double ch2 = 0.0;
    };

    std::deque<PairSample> low_pairs;
    std::deque<PairSample> high_pairs;
    bool run_initialized = false;
    bool run_di1_high = false;
    double diff_sum_ch1 = 0.0;
    double diff_sum_ch2 = 0.0;
    uint32_t diff_count = 0;

    bool pending_ch1_valid = false;
    double pending_ch1_raw = 0.0;
    bool pending_ch1_di1_high = false;
    bool pending_ch1_di2_high = false;
    bool pending_ch1_di8_high = false;  // raw DI8 level (not guard-filtered)

    uint16_t next_index = 1;

    void clear_run_pairs() {
        low_pairs.clear();
        high_pairs.clear();
        diff_sum_ch1 = 0.0;
        diff_sum_ch2 = 0.0;
        diff_count = 0;
    }

    void clear_pending_ch1() {
        pending_ch1_valid = false;
        pending_ch1_raw = 0.0;
        pending_ch1_di1_high = false;
        pending_ch1_di2_high = false;
        pending_ch1_di8_high = false;
    }

    void reset_packet() {
        clear_run_pairs();
        run_initialized = false;
        run_di1_high = false;
        clear_pending_ch1();
        next_index = 1;
    }

    void start_run(bool di1_high) {
        clear_run_pairs();
        run_initialized = true;
        run_di1_high = di1_high;
    }

    void clear_run() {
        clear_run_pairs();
        run_initialized = false;
    }

    void add_pending_ch1(double raw_code, bool di1_high, bool di2_high,
                         bool di8_high_level = false) {
        pending_ch1_valid = true;
        pending_ch1_raw = raw_code;
        pending_ch1_di1_high = di1_high;
        pending_ch1_di2_high = di2_high;
        pending_ch1_di8_high = di8_high_level;
    }

    void add_pair_sample(bool di2_high, double ch1_value, double ch2_value) {
        const PairSample sample{ch1_value, ch2_value};
        if (di2_high) {
            high_pairs.push_back(sample);
        } else {
            low_pairs.push_back(sample);
        }

        while (!low_pairs.empty() && !high_pairs.empty()) {
            const PairSample low = low_pairs.front();
            const PairSample high = high_pairs.front();
            low_pairs.pop_front();
            high_pairs.pop_front();

            diff_sum_ch1 += (low.ch1 - high.ch1);
            diff_sum_ch2 += (low.ch2 - high.ch2);
            ++diff_count;
        }
    }

    bool has_output() const { return diff_count != 0U; }

    double avg_diff_ch1() const {
        if (diff_count == 0U) {
            return 0.0;
        }
        return diff_sum_ch1 / static_cast<double>(diff_count);
    }

    double avg_diff_ch2() const {
        if (diff_count == 0U) {
            return 0.0;
        }
        return diff_sum_ch2 / static_cast<double>(diff_count);
    }
};

struct Di8FreqRefState {
    std::array<double, 2> high_sum {};
    std::array<uint32_t, 2> high_count {};

    static constexpr uint32_t kGuardSamples = 2U;
    bool di8_prev_level = false;
    bool di8_initialized = false;
    uint32_t samples_since_transition = 0;
    bool in_guard = false;

    uint16_t last_standard_index = 0;
    bool last_standard_valid = false;

    void clear_step() {
        high_sum = {};
        high_count = {};
    }

    void reset_packet() {
        clear_step();
        di8_prev_level = false;
        di8_initialized = false;
        samples_since_transition = 0;
        in_guard = false;
        last_standard_index = 0;
        last_standard_valid = false;
    }

    // Returns true if the sample is in the settled DI8=HIGH region.
    // Returns false for guard samples and DI8=LOW.
    // Sets in_guard for samples within the guard window after any transition.
    bool update_di8(bool di8_level) {
        if (!di8_initialized) {
            di8_prev_level = di8_level;
            di8_initialized = true;
            in_guard = true;
            samples_since_transition = 0;
            return false;
        }

        if (di8_level != di8_prev_level) {
            di8_prev_level = di8_level;
            samples_since_transition = 0;
            in_guard = true;
            return false;
        }

        if (in_guard) {
            ++samples_since_transition;
            if (samples_since_transition >= kGuardSamples) {
                in_guard = false;
            } else {
                return false;
            }
        }

        return di8_level;
    }

    void add_sample(uint32_t lch, double raw_code) {
        if (lch >= high_sum.size()) {
            return;
        }
        high_sum[lch] += raw_code;
        ++high_count[lch];
    }

    bool has_complete_step(uint32_t channel_count) const {
        return (high_count[0] != 0U) && ((channel_count <= 1U) || (high_count[1] != 0U));
    }

    double step_average(uint32_t lch) const {
        if ((lch >= high_sum.size()) || (high_count[lch] == 0U)) {
            return 0.0;
        }
        return high_sum[lch] / static_cast<double>(high_count[lch]);
    }
};

int16_t pack_raw_code_to_int16(double avg_raw_code) {
    const double scaled =
        avg_raw_code * 32767.0 / static_cast<double>(X502_ADC_SCALE_CODE_MAX);
    const long long rounded = std::llround(scaled);
    const long long clamped = std::clamp<long long>(rounded, -32768LL, 32767LL);
    return static_cast<int16_t>(clamped);
}

bool try_extract_raw_adc_code(uint32_t word, double& raw_code) {
    if ((word & kStreamInputAdcFlag) == 0U) {
        return false;
    }

    int32_t value = 0;
    if ((word & kStreamInputCalibratedAdcFlag) != 0U) {
        const uint32_t payload = word & 0x00FFFFFFU;
        value = static_cast<int32_t>(payload);
        if ((payload & 0x00800000U) != 0U) {
            value |= static_cast<int32_t>(0xFF000000U);
        }
    } else {
        const uint32_t payload = word & 0x0000FFFFU;
        value = static_cast<int32_t>(payload);
        if ((payload & 0x00008000U) != 0U) {
            value |= static_cast<int32_t>(0xFFFF0000U);
        }
    }

    raw_code = static_cast<double>(value);
    return true;
}

struct ConsoleCtrlGuard {
    bool installed = false;

#ifndef _WIN32
    bool sigint_installed = false;
    bool sigterm_installed = false;
    bool sigabrt_installed = false;
    struct sigaction old_sigint {};
    struct sigaction old_sigterm {};
    struct sigaction old_sigabrt {};
#endif

    ConsoleCtrlGuard() {
#ifdef _WIN32
        InterlockedExchange(&g_console_stop_requested, 0);
        installed = SetConsoleCtrlHandler(console_ctrl_handler, TRUE) != 0;
#else
        g_console_stop_requested = 0;

        struct sigaction action {};
        action.sa_handler = console_ctrl_handler;
        sigemptyset(&action.sa_mask);
        action.sa_flags = 0;

        sigint_installed = sigaction(SIGINT, &action, &old_sigint) == 0;
        sigterm_installed = sigaction(SIGTERM, &action, &old_sigterm) == 0;
        sigabrt_installed = sigaction(SIGABRT, &action, &old_sigabrt) == 0;
        installed = sigint_installed && sigterm_installed && sigabrt_installed;
#endif
    }

    ~ConsoleCtrlGuard() {
#ifdef _WIN32
        if (installed) {
            SetConsoleCtrlHandler(console_ctrl_handler, FALSE);
        }
        InterlockedExchange(&g_console_stop_requested, 0);
#else
        if (sigint_installed) {
            sigaction(SIGINT, &old_sigint, nullptr);
        }
        if (sigterm_installed) {
            sigaction(SIGTERM, &old_sigterm, nullptr);
        }
        if (sigabrt_installed) {
            sigaction(SIGABRT, &old_sigabrt, nullptr);
        }
        g_console_stop_requested = 0;
#endif
    }
};

struct DeviceHandle {
    const Api& api;
    t_x502_hnd hnd = nullptr;
    bool opened = false;
    bool streams_started = false;

    explicit DeviceHandle(const Api& api_ref) : api(api_ref), hnd(api.Create()) {
        if (hnd == nullptr) {
            fail("X502_Create failed");
        }
    }

    ~DeviceHandle() {
        if (hnd != nullptr) {
            if (streams_started) {
                api.StreamsStop(hnd);
            }
            if (opened) {
                api.Close(hnd);
            }
            api.Free(hnd);
        }
    }
};

void print_device_info(const t_x502_info& info) {
    std::cout << "Device: " << info.name << "\n"
              << "Serial: " << info.serial << "\n"
              << "FPGA version: " << static_cast<unsigned>(info.fpga_ver >> 8) << "."
              << static_cast<unsigned>(info.fpga_ver & 0xFF) << "\n"
              << "PLDA version: " << static_cast<unsigned>(info.plda_ver) << "\n"
              << "Board revision: " << static_cast<unsigned>(info.board_rev) << "\n"
              << "MCU firmware: " << info.mcu_firmware_ver << "\n";
}

int run(const Config& cfg) {
    Api api;
    DeviceHandle device(api);

    int32_t err = X502_ERR_OK;
    if (cfg.ip_addr.has_value()) {
        err = api.OpenByIpAddr(device.hnd, *cfg.ip_addr, 0, 5000);
    } else {
        err = api.OpenUsb(device.hnd, cfg.serial.empty() ? nullptr : cfg.serial.c_str());
    }
    expect_ok(api, err, "Open device");
    device.opened = true;

    t_x502_info info{};
    if (api.GetDevInfo2 != nullptr) {
        err = api.GetDevInfo2(device.hnd, &info, sizeof(info));
    } else {
        err = api.GetDevInfo(device.hnd, &info);
    }
    expect_ok(api, err, "Get device info");
    print_device_info(info);

    expect_ok(api, api.SetMode(device.hnd, X502_MODE_FPGA), "Set FPGA mode");
    api.StreamsStop(device.hnd);
    api.StreamsDisable(device.hnd, X502_STREAM_ALL_IN | X502_STREAM_ALL_OUT);

    expect_ok(api, api.SetSyncMode(device.hnd, cfg.sync_mode), "Set sync mode");
    expect_ok(api, api.SetSyncStartMode(device.hnd, cfg.sync_start_mode), "Set sync start mode");

    if (cfg.sync_mode == X502_SYNC_INTERNAL) {
        expect_ok(api, api.SetRefFreq(device.hnd, cfg.internal_ref_freq), "Set internal reference frequency");
    } else {
        const int32_t ext_ref_err = api.SetExtRefFreqValue(device.hnd, cfg.sample_clock_hz);
        if (ext_ref_err != X502_ERR_OK) {
            if (cfg.sample_clock_hz <= 1500000.0) {
                expect_ok(api, ext_ref_err, "Set external reference frequency");
            } else {
                std::cerr << "Warning: X502_SetExtRefFreqValue(" << cfg.sample_clock_hz
                          << ") failed, continuing with manual divider configuration: "
                          << x502_error(api, ext_ref_err) << "\n";
            }
        }
    }

    expect_ok(api, api.SetLChannelCount(device.hnd, cfg.channel_count), "Set logical channel count");
    expect_ok(api, api.SetLChannel(device.hnd, 0, cfg.ch1, cfg.mode, cfg.range, 1), "Set logical channel 0");
    if (cfg.channel_count >= 2U) {
        expect_ok(api, api.SetLChannel(device.hnd, 1, cfg.ch2, cfg.mode, cfg.range, 1), "Set logical channel 1");
    }

    const bool internal_max_clock = use_internal_max_clock(cfg);
    double actual_sample_clock_hz = cfg.sample_clock_hz;
    double actual_frame_freq_hz = cfg.sample_clock_hz / 2.0;
    if (internal_max_clock) {
        actual_sample_clock_hz = static_cast<double>(cfg.internal_ref_freq);
        actual_frame_freq_hz = 0.0;
        expect_ok(api, api.SetAdcFreq(device.hnd, &actual_sample_clock_hz, nullptr), "Set ADC frequency to maximum");
        expect_ok(api, api.GetAdcFreq(device.hnd, &actual_sample_clock_hz, &actual_frame_freq_hz),
                  "Get effective ADC frequency");
    } else {
        expect_ok(api, api.SetAdcFreq(device.hnd, &actual_sample_clock_hz, &actual_frame_freq_hz), "Set ADC frequency");
    }

    double actual_din_freq_hz = actual_sample_clock_hz;
    expect_ok(api, api.SetDinFreq(device.hnd, &actual_din_freq_hz), "Set DIN frequency");
    if (std::fabs(actual_din_freq_hz - actual_sample_clock_hz) > std::max(0.5, actual_sample_clock_hz * 1e-6)) {
        std::ostringstream message;
        message << "ADC and DIN frequencies no longer match closely enough for 1:1 masking logic. "
                << "ADC=" << actual_sample_clock_hz << " Hz, DIN=" << actual_din_freq_hz << " Hz.";
        fail(message.str());
    }
    const double combined_input_rate_hz = actual_sample_clock_hz + actual_din_freq_hz;
    if (cfg.ip_addr.has_value() && (combined_input_rate_hz > 2500000.0)) {
        std::ostringstream message;
        message << "Current Ethernet input load is too high for E-502: ADC "
                << actual_sample_clock_hz << " Hz + DIN " << actual_din_freq_hz
                << " Hz = " << combined_input_rate_hz
                << " words/s, while the documented Ethernet input-only limit is about 2500000 words/s.";
        fail(message.str());
    }
    double actual_dout_freq_hz = actual_frame_freq_hz;
    if (cfg.do1_toggle_per_frame) {
        if (actual_sample_clock_hz <= 0.0) {
            fail("do1_toggle_per_frame requires a positive ADC sample rate");
        }
        actual_dout_freq_hz = actual_sample_clock_hz;
        expect_ok(api, api.SetOutFreq(device.hnd, &actual_dout_freq_hz), "Set DOUT frequency");
    }

    expect_ok(api, api.SetStreamBufSize(device.hnd, X502_STREAM_CH_IN, cfg.input_buffer_words), "Set input buffer size");
    expect_ok(api, api.SetStreamStep(device.hnd, X502_STREAM_CH_IN, cfg.input_step_words), "Set input stream step");
    if (cfg.do1_toggle_per_frame) {
        expect_ok(api, api.SetStreamBufSize(device.hnd, X502_STREAM_CH_OUT, cfg.input_buffer_words), "Set output buffer size");
        expect_ok(api, api.SetStreamStep(device.hnd, X502_STREAM_CH_OUT, cfg.input_step_words), "Set output stream step");
    }

    uint32_t pullups = 0;
    if (cfg.pullup_syn1) {
        pullups |= X502_PULLUPS_DI_SYN1;
    }
    if (cfg.pullup_syn2) {
        pullups |= X502_PULLUPS_DI_SYN2;
    }
    if (cfg.pulldown_conv_in) {
        pullups |= X502_PULLDOWN_CONV_IN;
    }
    if (cfg.pulldown_start_in) {
        pullups |= X502_PULLDOWN_START_IN;
    }
    expect_ok(api, api.SetDigInPullup(device.hnd, pullups), "Set digital input pullups/pulldowns");

    expect_ok(api, api.Configure(device.hnd, 0), "Configure device");
    expect_ok(api, api.AsyncOutDig(device.hnd, kE502Do2Mask, 0U), "Force DO2 HIGH");

    // DO8/DI8 loopback diagnostic (before streams start, AsyncOutDig/AsyncInDig work)
    if (cfg.do8_freq_ref) {
        uint32_t din_val = 0;
        // Set DO8 HIGH
        expect_ok(api, api.AsyncOutDig(device.hnd, kE502Do2Mask | kE502Do8Mask, 0U),
                  "DO8 loopback test: set DO8 HIGH");
        usleep(2000);
        expect_ok(api, api.AsyncInDig(device.hnd, &din_val), "DO8 loopback test: read DIN");
        const bool di8_high = (din_val & kE502Digital8Mask) != 0U;
        std::cerr << "DO8/DI8 loopback: DO8=HIGH -> DI8=" << (di8_high ? "HIGH" : "LOW")
                  << " (DIN=0x" << std::hex << din_val << std::dec << ")\n";
        // Set DO8 LOW
        expect_ok(api, api.AsyncOutDig(device.hnd, kE502Do2Mask, 0U),
                  "DO8 loopback test: set DO8 LOW");
        usleep(2000);
        expect_ok(api, api.AsyncInDig(device.hnd, &din_val), "DO8 loopback test: read DIN");
        const bool di8_low = (din_val & kE502Digital8Mask) != 0U;
        std::cerr << "DO8/DI8 loopback: DO8=LOW  -> DI8=" << (di8_low ? "HIGH" : "LOW")
                  << " (DIN=0x" << std::hex << din_val << std::dec << ")\n";
        if (!di8_high || di8_low) {
            std::cerr << "WARNING: DO8/DI8 loopback FAILED. Check physical connection.\n";
        } else {
            std::cerr << "DO8/DI8 loopback PASSED.\n";
        }
        // Restore
        expect_ok(api, api.AsyncOutDig(device.hnd, kE502Do2Mask, 0U), "Restore DO2 HIGH only");
    }
    uint32_t enabled_streams = X502_STREAM_ADC | X502_STREAM_DIN;
    if (cfg.do1_toggle_per_frame) {
        enabled_streams |= X502_STREAM_DOUT;
    }
    expect_ok(api, api.StreamsEnable(device.hnd, enabled_streams), "Enable data streams");

    const std::size_t target_frames = std::max<std::size_t>(
        1, static_cast<std::size_t>(std::llround((cfg.duration_ms / 1000.0) * actual_frame_freq_hz)));

    std::cout << "Capture settings:\n"
              << "  clock source: " << sync_mode_to_string(cfg.sync_mode, false) << "\n"
              << "  start source: " << sync_mode_to_string(cfg.sync_start_mode, true) << "\n"
              << "  stop source: " << stop_mode_to_string(cfg.stop_mode) << "\n"
              << "  sample clock: " << actual_sample_clock_hz << " Hz";
    if (cfg.sync_mode == X502_SYNC_INTERNAL) {
        std::cout << " (internal ref " << ref_freq_to_string(cfg.internal_ref_freq) << " Hz)";
        if (internal_max_clock) {
            std::cout << ", maximum ADC speed";
        }
    }
    std::cout << "\n"
              << "  DIN clock: " << actual_din_freq_hz << " Hz\n"
              << "  ADC+DIN total input rate: " << combined_input_rate_hz << " words/s\n"
              << "  ADC logical channels: " << cfg.channel_count << "\n"
              << "  per-channel frame rate: " << actual_frame_freq_hz << " Hz\n"
              << "  duration: " << cfg.duration_ms << " ms\n"
              << "  packet limit: "
              << ((cfg.packet_limit == 0U) ? std::string("continuous until Ctrl+C")
                                           : std::to_string(cfg.packet_limit) + " packet(s)") << "\n"
              << "  live update period: "
              << ((cfg.live_update_period_ms == 0U) ? std::string("every packet")
                                                    : std::to_string(cfg.live_update_period_ms) + " ms") << "\n"
              << "  channel 1: " << phy_channel_name(cfg.mode, cfg.ch1) << "\n"
              << "  channel 2: "
              << ((cfg.channel_count >= 2U) ? phy_channel_name(cfg.mode, cfg.ch2) : std::string("disabled")) << "\n"
              << "  DI1 handling: " << di1_mode_to_string(cfg.di1_mode) << "\n"
              << "  DO1 toggle per frame: "
              << (cfg.do1_toggle_per_frame ? std::string("enabled (hardware cyclic 00110011..., toggle every 2 ADC ticks)")
                                           : std::string("disabled")) << "\n"
              << "  DO2 state: forced HIGH\n"
              << "  DOUT rate: "
              << (cfg.do1_toggle_per_frame ? std::to_string(actual_dout_freq_hz) + " Hz" : std::string("disabled")) << "\n"
              << "  DO8 freq ref: "
              << (cfg.do8_freq_ref
                  ? ("enabled (period=" + std::to_string(cfg.do8_cycle_period_ticks)
                     + " ticks, high=" + std::to_string(kDo8HighTicks) + " ticks"
                     + (cfg.do8_amplitude_threshold.has_value()
                        ? ", threshold=" + std::to_string(*cfg.do8_amplitude_threshold) : "")
                     + ")")
                  : std::string("disabled"))
              << "\n"
              << "  ADC range: +/-" << range_to_volts(cfg.range) << " V\n"
              << "  max frames per packet per channel: " << target_frames << "\n";

    const uint32_t read_capacity_words = std::max(cfg.recv_block_words, cfg.input_step_words);
    std::size_t tty_ring_capacity_bytes = 0;
    std::unique_ptr<TtyProtocolWriter> tty_writer;
    const bool tty_di1_group_average = cfg.tty_path.has_value() && cfg.di1_group_average;
    const bool tty_do1_noise_subtract = cfg.tty_path.has_value() && cfg.do1_noise_subtract;
    const bool tty_do1_raw_tty_marked = cfg.tty_path.has_value() && cfg.do1_raw_tty_marked;
    const bool tty_do1_pair_subtract_avg = cfg.tty_path.has_value() && cfg.do1_pair_subtract_avg;
    const bool fast_tty_avg_stream_mode =
        tty_di1_group_average || tty_do1_noise_subtract || tty_do1_raw_tty_marked || tty_do1_pair_subtract_avg;
    const uint16_t tty_packet_start_marker =
        (cfg.profile == CaptureProfile::Amplitude) ? 0x001AU : 0x000AU;
    const std::string fast_tty_mode_name = tty_do1_noise_subtract
        ? std::string("do1_noise_subtract")
        : (tty_do1_raw_tty_marked ? std::string("do1_raw_tty_marked")
                                  : (tty_do1_pair_subtract_avg ? std::string("do1_pair_subtract_avg")
                                                                : (tty_di1_group_average ? std::string("di1_group_avg")
                                                                                         : std::string("none"))));
    if (cfg.tty_path) {
        const uint64_t typical_tty_batch_frames = (static_cast<uint64_t>(read_capacity_words) + 1U) / 2U;
        const uint64_t typical_tty_batch_bytes = typical_tty_batch_frames * sizeof(uint16_t) * 4U;
        const uint64_t required_tty_ring_bytes = typical_tty_batch_bytes * 16U;
        const uint64_t tty_ring_bytes_u64 = std::max<uint64_t>(1024U * 1024U, required_tty_ring_bytes);
        if (tty_ring_bytes_u64 > static_cast<uint64_t>(std::numeric_limits<std::size_t>::max())) {
            fail("TTY ring buffer size overflowed size_t");
        }
        tty_ring_capacity_bytes = static_cast<std::size_t>(tty_ring_bytes_u64);
        tty_writer = std::make_unique<TtyProtocolWriter>(*cfg.tty_path, tty_ring_capacity_bytes);
        if (!tty_di1_group_average && !tty_do1_noise_subtract && !tty_do1_raw_tty_marked &&
            !tty_do1_pair_subtract_avg) {
            tty_writer->emit_packet_start(tty_packet_start_marker);
        }
    }
    std::unique_ptr<CaptureFileWriter> writer;
    if (!fast_tty_avg_stream_mode) {
        writer = std::make_unique<CaptureFileWriter>(cfg.csv_path, cfg.svg_path, cfg.live_html_path, cfg.live_json_path);
        writer->initialize_live_plot();
        writer->initialize_csv(cfg.channel_count, cfg.di1_mode == Di1Mode::Trace);
    }
    if (fast_tty_avg_stream_mode) {
        std::cout << "  tty fast stream-only mode: enabled (" << fast_tty_mode_name << ")\n"
                  << "  tty stream output: " << *cfg.tty_path << "\n"
                  << "  tty ring buffer bytes: " << tty_ring_capacity_bytes << "\n"
                  << "  recv block words: " << cfg.recv_block_words << "\n"
                  << "  input step words: " << cfg.input_step_words << "\n"
                  << "  input buffer words: " << cfg.input_buffer_words << "\n"
                  << "  tty di1_group_avg: " << (tty_di1_group_average ? "enabled" : "disabled") << "\n"
                  << "  tty do1_noise_subtract: " << (tty_do1_noise_subtract ? "enabled" : "disabled") << "\n"
                  << "  tty do1_raw_tty_marked: " << (tty_do1_raw_tty_marked ? "enabled" : "disabled") << "\n"
                  << "  tty do1_pair_subtract_avg: " << (tty_do1_pair_subtract_avg ? "enabled" : "disabled") << "\n"
                  << "  do1_noise_subtract marker: "
                  << (tty_do1_noise_subtract ? "DI1 from DIN (DO1->DI1 loopback expected)" : "n/a") << "\n"
                  << "  do1_raw_tty_marked markers: "
                  << (tty_do1_raw_tty_marked ? "0x00A3=LOW, 0x00A4=HIGH (DI1 from DIN)" : "n/a") << "\n"
                  << "  do1_pair_subtract_avg marker: "
                  << (tty_do1_pair_subtract_avg ? "word0=0x000A, low-high by DI2, averaged per DI1 run" : "n/a") << "\n"
                  << "  noise_avg_steps: "
                  << (tty_do1_noise_subtract ? std::to_string(*cfg.noise_avg_steps) : std::string("n/a")) << "\n"
                  << "  stream-only note: ignoring csv/svg/live_html/live_json/live_update_period_ms/svg_history_packets\n";
    } else {
        std::cout << "  live plot html: " << writer->live_html_path() << "\n"
                  << "  live plot data: " << writer->live_json_path() << "\n"
                  << "  tty stream output: " << (cfg.tty_path ? *cfg.tty_path : std::string("disabled")) << "\n"
                  << "  tty ring buffer bytes: "
                  << (cfg.tty_path ? std::to_string(tty_ring_capacity_bytes) : std::string("disabled")) << "\n"
                  << "  recv block words: " << cfg.recv_block_words << "\n"
                  << "  input step words: " << cfg.input_step_words << "\n"
                  << "  input buffer words: " << cfg.input_buffer_words << "\n"
                  << "  tty di1_group_avg: "
                  << (tty_di1_group_average ? std::string("enabled")
                                            : (cfg.di1_group_average ? std::string("requested, tty disabled")
                                                                     : std::string("disabled")))
                  << "\n"
                  << "  tty do1_noise_subtract: "
                  << (tty_do1_noise_subtract ? std::string("enabled")
                                             : (cfg.do1_noise_subtract ? std::string("requested, tty disabled")
                                                                       : std::string("disabled")))
                  << "\n"
                  << "  tty do1_raw_tty_marked: "
                  << (tty_do1_raw_tty_marked ? std::string("enabled")
                                             : (cfg.do1_raw_tty_marked ? std::string("requested, tty disabled")
                                                                       : std::string("disabled")))
                  << "\n"
                  << "  tty do1_pair_subtract_avg: "
                  << (tty_do1_pair_subtract_avg ? std::string("enabled")
                                                : (cfg.do1_pair_subtract_avg ? std::string("requested, tty disabled")
                                                                             : std::string("disabled")))
                  << "\n"
                  << "  SVG history packets kept in RAM: "
                  << ((cfg.svg_history_packets == 0U) ? std::string("all (unbounded)") : std::to_string(cfg.svg_history_packets))
                  << "\n";
    }

    ConsoleCtrlGuard console_guard;
    if (!console_guard.installed) {
        std::cerr << "Warning: Ctrl+C handler could not be installed; continuous mode may stop abruptly.\n";
    }

    const uint32_t cycle_pattern_len = cfg.do8_freq_ref
        ? static_cast<uint32_t>(std::lcm(static_cast<uint32_t>(kDo1CyclePatternWords),
                                          cfg.do8_cycle_period_ticks))
        : kDo1CyclePatternWords;
    std::vector<uint32_t> do1_cycle_pattern(cycle_pattern_len, 0U);
    std::vector<uint32_t> do1_cycle_encoded(cycle_pattern_len, 0U);
    if (cfg.do1_toggle_per_frame) {
        if ((api.OutCycleLoadStart == nullptr) || (api.OutCycleSetup == nullptr) || (api.OutCycleStop == nullptr)) {
            fail("do1_toggle_per_frame requires X502_OutCycle* API support (cyclic output mode)");
        }

        for (std::size_t i = 0; i < do1_cycle_pattern.size(); ++i) {
            uint32_t word = kE502Do2Mask;

            const uint32_t do1_phase = static_cast<uint32_t>(i) % kDo1CyclePatternWords;
            if (do1_phase >= kDo1TogglePeriodTicks) {
                word |= kE502Do1Mask;
            }

            if (cfg.do8_freq_ref) {
                const uint32_t do8_phase = static_cast<uint32_t>(i) % cfg.do8_cycle_period_ticks;
                if (do8_phase >= (cfg.do8_cycle_period_ticks - kDo8HighTicks)) {
                    word |= kE502Do8Mask;
                }
            }

            do1_cycle_pattern[i] = word;
        }

        // Debug: print cyclic DOUT pattern
        {
            std::cerr << "Cyclic DOUT pattern (" << do1_cycle_pattern.size() << " words):";
            for (std::size_t i = 0; i < do1_cycle_pattern.size(); ++i) {
                const uint32_t w = do1_cycle_pattern[i];
                const char do1_ch = (w & kE502Do1Mask) ? '1' : '0';
                const char do2_ch = (w & kE502Do2Mask) ? '1' : '0';
                const char do8_ch = (w & kE502Do8Mask) ? '1' : '0';
                std::cerr << "\n  [" << i << "] 0x" << std::hex << w << std::dec
                          << "  DO1=" << do1_ch << " DO2=" << do2_ch << " DO8=" << do8_ch;
            }
            std::cerr << "\n";
        }

        if (cycle_pattern_len > 262144U) {
            fail("Combined DO1+DO8 cyclic pattern too large: " + std::to_string(cycle_pattern_len));
        }

        expect_ok(api,
                  api.OutCycleLoadStart(device.hnd, static_cast<uint32_t>(do1_cycle_pattern.size())),
                  "Allocate cyclic DOUT buffer");
        expect_ok(api,
                  api.PrepareData(device.hnd,
                                  nullptr,
                                  nullptr,
                                  do1_cycle_pattern.data(),
                                  static_cast<uint32_t>(do1_cycle_pattern.size()),
                                  0,
                                  do1_cycle_encoded.data()),
                  "Prepare cyclic DOUT pattern");

        std::size_t sent_cycle_words = 0;
        while (sent_cycle_words < do1_cycle_encoded.size()) {
            const int32_t sent = api.Send(device.hnd,
                                          do1_cycle_encoded.data() + sent_cycle_words,
                                          static_cast<uint32_t>(do1_cycle_encoded.size() - sent_cycle_words),
                                          cfg.recv_timeout_ms);
            if (sent < 0) {
                fail("Send cyclic DOUT pattern: " + x502_error(api, sent));
            }
            if (sent == 0) {
                fail("Send cyclic DOUT pattern timed out before all words were queued");
            }
            sent_cycle_words += static_cast<std::size_t>(sent);
        }

        // Debug: print encoded pattern
        {
            std::cerr << "Encoded DOUT pattern (" << do1_cycle_encoded.size() << " words):";
            for (std::size_t i = 0; i < do1_cycle_encoded.size(); ++i) {
                std::cerr << " 0x" << std::hex << do1_cycle_encoded[i] << std::dec;
            }
            std::cerr << "\nSent " << sent_cycle_words << " encoded words to device.\n";
        }

        expect_ok(api, api.OutCycleSetup(device.hnd, X502_OUT_CYCLE_FLAGS_WAIT_DONE), "Activate cyclic DOUT pattern");
    }

    expect_ok(api, api.StreamsStart(device.hnd), "Start streams");
    device.streams_started = true;

    std::vector<uint32_t> raw(read_capacity_words);
    std::vector<double> adc_buffer(fast_tty_avg_stream_mode ? 0U : read_capacity_words);
    std::vector<double> adc_raw_buffer(read_capacity_words);
    std::vector<uint32_t> din_buffer(read_capacity_words);
    std::deque<double> pending_adc;
    std::deque<double> pending_adc_raw;
    std::deque<uint32_t> pending_din;
    std::deque<CapturePacket> packets;
    PacketAccumulator current_packet;
    TtyContinuousState tty_state;
    TtyGroupAverageState tty_group_state;
    Do1RawTaggedState tty_do1_raw_state;
    Do1NoiseSubtractState tty_do1_noise_state;
    Do1PairSubtractAverageState tty_do1_pair_state;
    Di8FreqRefState tty_di8_state;
    std::vector<uint16_t> tty_frame_words;
    tty_frame_words.reserve(static_cast<std::size_t>(read_capacity_words) * 2U + 16U);
    if (tty_do1_noise_subtract) {
        tty_do1_noise_state.configure(*cfg.noise_avg_steps);
    }
    if (!fast_tty_avg_stream_mode) {
        current_packet.reset(target_frames, cfg.channel_count);
    }
    std::size_t csv_global_frame_index = 0;
    std::size_t packet_avg_steps = 0;
    std::size_t fast_packet_frames = 0;
    uint64_t packet_clock_count = 0;
    uint64_t packet_di2_high_clocks = 0;
    uint64_t packet_di2_low_clocks = 0;
    uint64_t packet_di8_high_clocks = 0;
    uint64_t packet_di8_low_clocks = 0;
    uint64_t packet_di8_samples_accumulated = 0;
    uint64_t packet_di8_ref_frames_emitted = 0;
    uint64_t packet_di8_no_complete = 0;
    uint64_t packet_di8_no_standard = 0;
    uint64_t packet_di8_both_high = 0;
    uint64_t packet_di8_both_normal = 0;
    uint64_t packet_di8_mixed = 0;

    bool capture_started = false;
    bool stop_loop_requested = false;
    bool packet_active = false;
    bool trigger_level_initialized = false;
    bool trigger_prev_level = false;
    std::size_t total_zeroed_samples = 0;
    uint32_t next_lch = 0;
    bool di1_initialized = false;
    bool di1_prev_level = false;
    const TickMs start_wait_deadline = tick_count_ms() + cfg.start_wait_ms;
    const TickMs capture_loop_start = tick_count_ms();
    TickMs stats_window_start = capture_loop_start;
    TickMs last_stats_print = capture_loop_start;
    TickMs last_live_update = 0;

    uint64_t total_raw_words = 0;
    uint64_t total_adc_samples = 0;
    uint64_t total_din_samples = 0;
    uint64_t total_stored_adc_samples = 0;
    uint64_t total_completed_frames = 0;
    uint64_t total_completed_packets = 0;

    uint64_t stats_raw_words = 0;
    uint64_t stats_adc_samples = 0;
    uint64_t stats_din_samples = 0;
    uint64_t stats_stored_adc_samples = 0;
    uint64_t stats_zeroed_samples = 0;
    uint64_t stats_completed_frames = 0;
    uint64_t stats_completed_packets = 0;
    TtyProtocolWriter::StatsSnapshot tty_stats_window_start {};
    bool tty_overflow_warning_printed = false;

    auto print_stats = [&](bool final_report) {
        const TickMs now = tick_count_ms();
        const double elapsed_s = std::max(1e-9, static_cast<double>(now - stats_window_start) / 1000.0);
        const double mb_per_s = (static_cast<double>(stats_raw_words) * sizeof(uint32_t)) / elapsed_s / 1000.0 / 1000.0;
        const double adc_samples_per_s = static_cast<double>(stats_adc_samples) / elapsed_s;
        const double din_samples_per_s = static_cast<double>(stats_din_samples) / elapsed_s;
        const double frames_per_ch_per_s = static_cast<double>(stats_completed_frames) / elapsed_s;
        const double packets_per_s = static_cast<double>(stats_completed_packets) / elapsed_s;
        const double zeroed_fraction = (stats_stored_adc_samples == 0U)
            ? 0.0
            : (100.0 * static_cast<double>(stats_zeroed_samples) / static_cast<double>(stats_stored_adc_samples));
        const TtyProtocolWriter::StatsSnapshot tty_stats_now =
            tty_writer ? tty_writer->stats() : TtyProtocolWriter::StatsSnapshot {};
        const uint64_t tty_frames_written = tty_stats_now.frames_written - tty_stats_window_start.frames_written;
        const uint64_t tty_frames_dropped = tty_stats_now.frames_dropped - tty_stats_window_start.frames_dropped;
        const uint64_t tty_ring_overflows = tty_stats_now.ring_overflows - tty_stats_window_start.ring_overflows;

        std::cout << std::fixed << std::setprecision(3)
                  << (final_report ? "Final stats: " : "Online stats: ")
                  << "MB/s=" << mb_per_s
                  << ", ADC samples/s=" << adc_samples_per_s
                  << ", DIN samples/s=" << din_samples_per_s
                  << ", frames/s per channel=" << frames_per_ch_per_s
                  << ", packets/s=" << packets_per_s;
        if (!fast_tty_avg_stream_mode) {
            if (cfg.di1_mode == Di1Mode::ZeroOnChange) {
                std::cout << ", zeroed on DI1 change=" << zeroed_fraction << "% ("
                          << stats_zeroed_samples << "/" << stats_stored_adc_samples << ")";
            } else if (cfg.di1_mode == Di1Mode::Trace) {
                std::cout << ", DI1 trace=enabled";
            }
        }
        if (tty_writer) {
            std::cout << ", tty_frames_written=" << tty_frames_written
                      << ", tty_frames_dropped=" << tty_frames_dropped
                      << ", tty_ring_overflows=" << tty_ring_overflows;
        }
        std::cout << "\n";

        if (!final_report) {
            stats_window_start = now;
            last_stats_print = now;
            stats_raw_words = 0;
            stats_adc_samples = 0;
            stats_din_samples = 0;
            stats_stored_adc_samples = 0;
            stats_zeroed_samples = 0;
            stats_completed_frames = 0;
            stats_completed_packets = 0;
            tty_stats_window_start = tty_stats_now;
        }
    };

    auto append_tty_frame = [&](uint16_t word0, uint16_t word1, uint16_t word2, uint16_t word3) {
        tty_frame_words.push_back(word0);
        tty_frame_words.push_back(word1);
        tty_frame_words.push_back(word2);
        tty_frame_words.push_back(word3);
    };

    auto append_tty_packet_start = [&]() {
        append_tty_frame(tty_packet_start_marker, 0xFFFF, 0xFFFF, 0xFFFF);
    };

    auto append_tty_group_step = [&]() {
        if (!tty_di1_group_average || !tty_group_state.has_complete_step(cfg.channel_count)) {
            return;
        }
        if (tty_group_state.next_index >= 0xFFFFU) {
            fail("TTY protocol step index overflow within packet");
        }

        const double ch1_avg = tty_group_state.sum_ch1 / static_cast<double>(tty_group_state.count_ch1);
        const double ch2_avg =
            (cfg.channel_count <= 1U)
                ? 0.0
                : (tty_group_state.sum_ch2 / static_cast<double>(tty_group_state.count_ch2));
        const uint16_t step_idx = static_cast<uint16_t>(tty_group_state.next_index);
        append_tty_frame( (cfg.profile == CaptureProfile::Amplitude) ? 0x001AU : 0x000AU,
                         step_idx,
                         static_cast<uint16_t>(pack_raw_code_to_int16(ch1_avg)),
                         static_cast<uint16_t>(pack_raw_code_to_int16(ch2_avg)));
        ++tty_group_state.next_index;
        ++packet_avg_steps;
        if (cfg.do8_freq_ref) {
            tty_di8_state.last_standard_index = step_idx;
            tty_di8_state.last_standard_valid = true;
        }
    };

    auto append_tty_do1_raw_marked_step = [&]() {
        if (!tty_do1_raw_tty_marked) {
            return;
        }
        if (!tty_do1_raw_state.step_level_initialized) {
            return;
        }
        if (!tty_do1_raw_state.has_complete_step(cfg.channel_count)) {
            tty_do1_raw_state.clear_step();
            return;
        }
        if (tty_do1_raw_state.next_index >= 0xFFFFU) {
            fail("TTY protocol step index overflow within packet");
        }

        const double ch1_avg = tty_do1_raw_state.step_average(0U);
        const double ch2_avg = (cfg.channel_count <= 1U) ? 0.0 : tty_do1_raw_state.step_average(1U);
        const uint16_t marker = tty_do1_raw_state.step_di1_high ? 0x00A4U : 0x00A3U;
        const uint16_t step_idx = tty_do1_raw_state.next_index;
        append_tty_frame(marker,
                         step_idx,
                         static_cast<uint16_t>(pack_raw_code_to_int16(ch1_avg)),
                         static_cast<uint16_t>(pack_raw_code_to_int16(ch2_avg)));
        ++tty_do1_raw_state.next_index;
        ++packet_avg_steps;
        tty_do1_raw_state.clear_step();
        if (cfg.do8_freq_ref) {
            tty_di8_state.last_standard_index = step_idx;
            tty_di8_state.last_standard_valid = true;
        }
    };

    auto append_tty_do1_subtracted_step = [&]() {
        if (!tty_do1_noise_subtract) {
            return;
        }

        if (!tty_do1_noise_state.step_level_initialized) {
            return;
        }

        if (!tty_do1_noise_state.has_complete_step(cfg.channel_count)) {
            if (!tty_do1_noise_state.step_di1_high &&
                tty_do1_noise_state.has_pending_high(cfg.channel_count)) {
                // Invalidate the HIGH->LOW pair if LOW step is incomplete.
                tty_do1_noise_state.consume_pending_high();
            }
            tty_do1_noise_state.finish_step();
            return;
        }

        const double ch1_avg = tty_do1_noise_state.step_average(0U);
        const double ch2_avg = (cfg.channel_count <= 1U) ? 0.0 : tty_do1_noise_state.step_average(1U);

        if (tty_do1_noise_state.step_di1_high) {
            tty_do1_noise_state.update_last_high(0U, ch1_avg);
            if (cfg.channel_count > 1U) {
                tty_do1_noise_state.update_last_high(1U, ch2_avg);
            }
            tty_do1_noise_state.mark_high_pending();
            tty_do1_noise_state.finish_step();
            return;
        }

        if (!tty_do1_noise_state.has_pending_high(cfg.channel_count)) {
            // Skip LOW output unless a preceding HIGH step baseline is pending.
            tty_do1_noise_state.finish_step();
            return;
        }

        if (tty_do1_noise_state.next_index >= 0xFFFFU) {
            fail("TTY protocol step index overflow within packet");
        }

        const double ch1_corrected = ch1_avg - tty_do1_noise_state.last_high(0U);
        const double ch2_corrected =
            (cfg.channel_count <= 1U) ? 0.0 : (ch2_avg - tty_do1_noise_state.last_high(1U));

        const uint16_t step_idx = tty_do1_noise_state.next_index;
        append_tty_frame((cfg.profile == CaptureProfile::Amplitude) ? 0x001AU : 0x000AU,
                         step_idx,
                         static_cast<uint16_t>(pack_raw_code_to_int16(ch1_corrected)),
                         static_cast<uint16_t>(pack_raw_code_to_int16(ch2_corrected)));
        ++tty_do1_noise_state.next_index;
        ++packet_avg_steps;
        tty_do1_noise_state.consume_pending_high();
        tty_do1_noise_state.finish_step();
        if (cfg.do8_freq_ref) {
            tty_di8_state.last_standard_index = step_idx;
            tty_di8_state.last_standard_valid = true;
        }
    };

    auto append_tty_do1_pair_subtracted_avg_run = [&]() {
        if (!tty_do1_pair_subtract_avg) {
            return;
        }
        if (!tty_do1_pair_state.run_initialized) {
            return;
        }

        if (!tty_do1_pair_state.has_output()) {
            tty_do1_pair_state.clear_run();
            return;
        }

        if (tty_do1_pair_state.next_index >= 0xFFFFU) {
            fail("TTY protocol step index overflow within packet");
        }

        const uint16_t step_idx = tty_do1_pair_state.next_index;
        append_tty_frame(0x000AU,
                         step_idx,
                         static_cast<uint16_t>(pack_raw_code_to_int16(tty_do1_pair_state.avg_diff_ch1())),
                         static_cast<uint16_t>(pack_raw_code_to_int16(tty_do1_pair_state.avg_diff_ch2())));
        ++tty_do1_pair_state.next_index;
        ++packet_avg_steps;
        tty_do1_pair_state.clear_run();
        if (cfg.do8_freq_ref) {
            tty_di8_state.last_standard_index = step_idx;
            tty_di8_state.last_standard_valid = true;
        }
    };

    auto append_tty_di8_ref_step = [&]() {
        if (!cfg.do8_freq_ref) {
            return;
        }
        if (!tty_di8_state.has_complete_step(cfg.channel_count)) {
            ++packet_di8_no_complete;
            tty_di8_state.clear_step();
            return;
        }

        const double ch1_avg = tty_di8_state.step_average(0U);
        const double ch2_avg = (cfg.channel_count <= 1U) ? 0.0 : tty_di8_state.step_average(1U);

        bool exceeds = true;
        if (cfg.do8_amplitude_threshold.has_value()) {
            const double t = *cfg.do8_amplitude_threshold;
            exceeds = (std::abs(ch1_avg) > t) ||
                      ((cfg.channel_count > 1U) && (std::abs(ch2_avg) > t));
        }

        if (!exceeds) {
            tty_di8_state.clear_step();
            return;
        }

        if (!tty_di8_state.last_standard_valid) {
            ++packet_di8_no_standard;
            tty_di8_state.clear_step();
            return;
        }

        append_tty_frame(kTtyMarkerDi8High,
                         tty_di8_state.last_standard_index,
                         static_cast<uint16_t>(pack_raw_code_to_int16(ch1_avg)),
                         static_cast<uint16_t>(pack_raw_code_to_int16(ch2_avg)));
        ++packet_di8_ref_frames_emitted;
        tty_di8_state.clear_step();
    };

    auto flush_tty_frames = [&]() {
        if (!tty_writer || tty_frame_words.empty()) {
            return;
        }

        tty_writer->enqueue_encoded_frames(tty_frame_words.data(), tty_frame_words.size() / 4U);
        tty_frame_words.clear();

        const auto tty_stats = tty_writer->stats();
        if (!tty_overflow_warning_printed && (tty_stats.ring_overflows != 0U)) {
            std::cerr << "Warning: TTY ring buffer overflowed; dropping oldest frames to keep the stream continuous\n";
            tty_overflow_warning_printed = true;
        }
    };

    auto start_packet = [&]() {
        if (packet_active) {
            return;
        }
        packet_avg_steps = 0;
        fast_packet_frames = 0;
        packet_clock_count = 0;
        packet_di2_high_clocks = 0;
        packet_di2_low_clocks = 0;
        packet_di8_high_clocks = 0;
        packet_di8_low_clocks = 0;
        packet_di8_samples_accumulated = 0;
        packet_di8_ref_frames_emitted = 0;
        packet_di8_no_complete = 0;
        packet_di8_no_standard = 0;
        packet_di8_both_high = 0;
        packet_di8_both_normal = 0;
        packet_di8_mixed = 0;
        if (!fast_tty_avg_stream_mode) {
            current_packet.reset(target_frames, cfg.channel_count);
        }
        if (tty_di1_group_average) {
            tty_group_state.reset_packet();
            append_tty_packet_start();
        } else if (tty_do1_raw_tty_marked) {
            tty_do1_raw_state.reset_packet();
            append_tty_packet_start();
        } else if (tty_do1_noise_subtract) {
            tty_do1_noise_state.reset_packet();
            append_tty_packet_start();
        } else if (tty_do1_pair_subtract_avg) {
            tty_do1_pair_state.reset_packet();
            append_tty_packet_start();
        }
        if (cfg.do8_freq_ref) {
            tty_di8_state.reset_packet();
        }
        packet_active = true;
    };

    auto finish_packet = [&](PacketCloseReason reason) {
        if (packet_clock_count != (packet_di2_high_clocks + packet_di2_low_clocks)) {
            std::ostringstream message;
            message << "DI2 clock split invariant failed: clocks=" << packet_clock_count
                    << ", high=" << packet_di2_high_clocks
                    << ", low=" << packet_di2_low_clocks;
            fail(message.str());
        }

        if (tty_di1_group_average) {
            append_tty_group_step();
            if (cfg.do8_freq_ref) { append_tty_di8_ref_step(); }
            tty_group_state.clear_step();
        } else if (tty_do1_raw_tty_marked) {
            append_tty_do1_raw_marked_step();
            if (cfg.do8_freq_ref) { append_tty_di8_ref_step(); }
            tty_do1_raw_state.clear_step();
        } else if (tty_do1_noise_subtract) {
            // Finalize the last DI1 run in packet, drop only incomplete channel tuples.
            append_tty_do1_subtracted_step();
            if (cfg.do8_freq_ref) { append_tty_di8_ref_step(); }
            tty_do1_noise_state.clear_step();
        } else if (tty_do1_pair_subtract_avg) {
            append_tty_do1_pair_subtracted_avg_run();
            if (cfg.do8_freq_ref) { append_tty_di8_ref_step(); }
            tty_do1_pair_state.reset_packet();
        }
        if (cfg.do8_freq_ref) {
            tty_di8_state.clear_step();
        }

        const std::size_t frames = fast_tty_avg_stream_mode
            ? fast_packet_frames
            : current_packet.frame_count(cfg.channel_count);
        if (frames != 0U) {
            const double packet_duration_ms = (1000.0 * static_cast<double>(frames)) / actual_frame_freq_hz;
            if (fast_tty_avg_stream_mode) {
                const std::size_t packet_index = static_cast<std::size_t>(total_completed_packets + 1U);
                std::cout << std::fixed << std::setprecision(3)
                          << "Packet " << packet_index
                          << ": frames/ch=" << frames
                          << ", duration_ms=" << packet_duration_ms
                          << ", close_reason=" << packet_close_reason_to_string(reason)
                          << ", clocks=" << packet_clock_count
                          << ", di2_high_clocks=" << packet_di2_high_clocks
                          << ", di2_low_clocks=" << packet_di2_low_clocks;
                if (cfg.do8_freq_ref) {
                    std::cout << ", di8_high_clocks=" << packet_di8_high_clocks
                              << ", di8_low_clocks=" << packet_di8_low_clocks
                              << ", di8_pairs=" << packet_di8_samples_accumulated
                              << ", di8_ref_frames=" << packet_di8_ref_frames_emitted
                              << ", di8_no_complete=" << packet_di8_no_complete
                              << ", di8_no_std=" << packet_di8_no_standard
                              << ", di8_hi=" << packet_di8_both_high
                              << ", di8_lo=" << packet_di8_both_normal
                              << ", di8_mix=" << packet_di8_mixed;
                }
                std::cout << ", avg_steps=" << packet_avg_steps
                          << "\n";
            } else {
                current_packet.channels[0].resize(frames);
                if (cfg.channel_count >= 2U) {
                    current_packet.channels[1].resize(frames);
                } else {
                    current_packet.channels[1].clear();
                }
                if (cfg.di1_mode == Di1Mode::Trace) {
                    current_packet.di1.resize(frames);
                } else {
                    current_packet.di1.clear();
                }

                CapturePacket packet;
                packet.packet_index = static_cast<std::size_t>(total_completed_packets + 1U);
                packet.channel_count = cfg.channel_count;
                packet.has_di1_trace = (cfg.di1_mode == Di1Mode::Trace);
                packet.ch1 = std::move(current_packet.channels[0]);
                packet.ch2 = std::move(current_packet.channels[1]);
                packet.di1 = std::move(current_packet.di1);

                const double zeroed_fraction = (current_packet.stored_samples == 0U)
                    ? 0.0
                    : (100.0 * static_cast<double>(current_packet.zeroed_samples) /
                       static_cast<double>(current_packet.stored_samples));

                std::cout << std::fixed << std::setprecision(3)
                          << "Packet " << packet.packet_index
                          << ": frames/ch=" << frames
                          << ", duration_ms=" << packet_duration_ms
                          << ", close_reason=" << packet_close_reason_to_string(reason)
                          << ", clocks=" << packet_clock_count
                          << ", di2_high_clocks=" << packet_di2_high_clocks
                          << ", di2_low_clocks=" << packet_di2_low_clocks;
                if (cfg.do8_freq_ref) {
                    std::cout << ", di8_high_clocks=" << packet_di8_high_clocks
                              << ", di8_low_clocks=" << packet_di8_low_clocks;
                }
                if (cfg.di1_mode == Di1Mode::ZeroOnChange) {
                    std::cout << ", zeroed_on_DI1_change=" << zeroed_fraction << "% ("
                              << current_packet.zeroed_samples << "/" << current_packet.stored_samples << ")";
                } else if (cfg.di1_mode == Di1Mode::Trace) {
                    std::cout << ", di1_trace_frames=" << packet.di1.size();
                }
                std::cout << "\n";

                writer->append_csv_packet(packet, actual_frame_freq_hz, csv_global_frame_index);
                packets.push_back(std::move(packet));
                if ((cfg.svg_history_packets != 0U) && (packets.size() > cfg.svg_history_packets)) {
                    packets.pop_front();
                }

                const double elapsed_capture_s =
                    std::max(1e-9, static_cast<double>(tick_count_ms() - capture_loop_start) / 1000.0);
                const double packets_per_second =
                    (elapsed_capture_s >= 0.1)
                        ? (static_cast<double>(total_completed_packets + 1U) / elapsed_capture_s)
                        : 0.0;
                const TickMs now = tick_count_ms();
                const bool should_update_live =
                    (cfg.live_update_period_ms == 0U) ||
                    (last_live_update == 0U) ||
                    ((now - last_live_update) >= cfg.live_update_period_ms);
                if (should_update_live) {
                    writer->update_live_plot(packets.back(),
                                             static_cast<std::size_t>(total_completed_packets + 1U),
                                             packets_per_second,
                                             actual_frame_freq_hz,
                                             packet_close_reason_to_string(reason),
                                             current_packet.zeroed_samples,
                                             current_packet.stored_samples);
                    last_live_update = now;
                }
            }

            ++total_completed_packets;
            ++stats_completed_packets;

            const double elapsed_capture_s =
                std::max(1e-9, static_cast<double>(tick_count_ms() - capture_loop_start) / 1000.0);
            const double packets_per_second =
                (elapsed_capture_s >= 0.1)
                    ? (static_cast<double>(total_completed_packets) / elapsed_capture_s)
                    : 0.0;
            std::cout << std::fixed << std::setprecision(3)
                      << "  packets/s(avg)=" << packets_per_second;
            if (elapsed_capture_s < 0.1) {
                std::cout << " (warming up)";
            }
            std::cout << "\n";
        }

        packet_active = false;
        packet_avg_steps = 0;
        fast_packet_frames = 0;
        packet_clock_count = 0;
        packet_di2_high_clocks = 0;
        packet_di2_low_clocks = 0;
        packet_di8_high_clocks = 0;
        packet_di8_low_clocks = 0;
        packet_di8_samples_accumulated = 0;
        packet_di8_ref_frames_emitted = 0;
        packet_di8_no_complete = 0;
        packet_di8_no_standard = 0;
        packet_di8_both_high = 0;
        packet_di8_both_normal = 0;
        packet_di8_mixed = 0;
        if (!fast_tty_avg_stream_mode) {
            current_packet.reset(target_frames, cfg.channel_count);
        }
        if (tty_di1_group_average) {
            tty_group_state.clear_step();
        } else if (tty_do1_raw_tty_marked) {
            tty_do1_raw_state.clear_step();
        } else if (tty_do1_noise_subtract) {
            tty_do1_noise_state.clear_step();
        } else if (tty_do1_pair_subtract_avg) {
            tty_do1_pair_state.reset_packet();
        }
    };

    while (!stop_loop_requested) {
        if (tty_writer) {
            tty_writer->throw_if_failed();
        }
        if ((cfg.packet_limit != 0U) && (total_completed_packets >= cfg.packet_limit)) {
            stop_loop_requested = true;
            break;
        }
        if (console_stop_requested()) {
            if (packet_active) {
                finish_packet(PacketCloseReason::UserStop);
                flush_tty_frames();
            }
            stop_loop_requested = true;
            break;
        }

        uint32_t recv_request_words = cfg.recv_block_words;
        uint32_t recv_timeout_ms = cfg.recv_timeout_ms;
        uint32_t ready_words = 0;
        const int32_t ready_err = api.GetRecvReadyCount(device.hnd, &ready_words);
        if ((ready_err == X502_ERR_OK) && (ready_words != 0U)) {
            recv_request_words = std::min<uint32_t>(ready_words, read_capacity_words);
            recv_timeout_ms = 0;
        }

        const int32_t recvd = api.Recv(device.hnd, raw.data(), recv_request_words, recv_timeout_ms);
        if (recvd < 0) {
            fail("X502_Recv failed: " + x502_error(api, recvd));
        }
        if (recvd > 0) {
            total_raw_words += static_cast<uint64_t>(recvd);
            stats_raw_words += static_cast<uint64_t>(recvd);
        }
        if (recvd == 0) {
            if (!capture_started && (tick_count_ms() >= start_wait_deadline)) {
                std::ostringstream message;
                message << "Timeout before first ADC data. start="
                        << sync_mode_to_string(cfg.sync_start_mode, true)
                        << ", clock=" << sync_mode_to_string(cfg.sync_mode, false) << ". ";

                if (cfg.sync_start_mode == X502_SYNC_EXTERNAL_MASTER) {
                    message << "With start:start_in the module waits for START_IN after StreamsStart(); "
                               "until that condition occurs the external clock is ignored. ";
                } else if (cfg.sync_start_mode == X502_SYNC_INTERNAL) {
                    message << "Start is immediate, so this usually means there is no valid external clock on the selected clock line. ";
                } else {
                    message << "This usually means the selected start condition did not occur after StreamsStart(), "
                               "or no valid external clock arrived afterwards. ";
                }

                message << "For a quick clock-only check, try start:immediate. "
                           "If you use a separate start pulse, it must arrive after the program starts waiting. "
                           "You can also increase start_wait_ms.";
                fail(message.str());
            }
            continue;
        }

        uint32_t adc_count = fast_tty_avg_stream_mode ? static_cast<uint32_t>(adc_raw_buffer.size())
                                                      : static_cast<uint32_t>(adc_buffer.size());
        uint32_t din_count = static_cast<uint32_t>(din_buffer.size());
        const uint32_t process_flags =
            fast_tty_avg_stream_mode ? 0U : static_cast<uint32_t>(X502_PROC_FLAGS_VOLT);
        const int32_t process_err = api.ProcessData(device.hnd,
                                                    raw.data(),
                                                    static_cast<uint32_t>(recvd),
                                                    process_flags,
                                                    fast_tty_avg_stream_mode ? adc_raw_buffer.data() : adc_buffer.data(),
                                                    &adc_count,
                                                    din_buffer.data(),
                                                    &din_count);
        if (process_err == X502_ERR_STREAM_OVERFLOW) {
            std::ostringstream message;
            message << "Process ADC+DIN data: " << x502_error(api, process_err)
                    << ". Current ADC rate=" << actual_sample_clock_hz
                    << " Hz, DIN rate=" << actual_din_freq_hz
                    << " Hz, combined input=" << combined_input_rate_hz
                    << " words/s. Try larger recv_block/buffer_words/step_words, a longer "
                    << "live_update_period_ms, or a lower sample_clock_hz / DIN load.";
            fail(message.str());
        }
        expect_ok(api, process_err, "Process ADC+DIN data");

        uint32_t raw_adc_count = fast_tty_avg_stream_mode ? adc_count : 0U;
        if (tty_writer && !fast_tty_avg_stream_mode) {
            for (std::size_t i = 0; i < static_cast<std::size_t>(recvd); ++i) {
                double raw_code = 0.0;
                if (!try_extract_raw_adc_code(raw[i], raw_code)) {
                    continue;
                }
                if (raw_adc_count >= adc_raw_buffer.size()) {
                    fail("Raw ADC parsing overflowed the temporary buffer");
                }
                adc_raw_buffer[raw_adc_count++] = raw_code;
            }
            if (raw_adc_count != adc_count) {
                fail("Raw ADC parsing returned a different sample count than voltage processing");
            }

            if (!tty_di1_group_average) {
                tty_frame_words.clear();
                const std::size_t tty_frame_count =
                    (cfg.channel_count <= 1U)
                        ? static_cast<std::size_t>(raw_adc_count)
                        : ((static_cast<std::size_t>(raw_adc_count) + 1U) / 2U);
                tty_frame_words.reserve(tty_frame_count * 4U);
                for (uint32_t i = 0; i < raw_adc_count; ++i) {
                    const int16_t sample = pack_raw_code_to_int16(adc_raw_buffer[i]);
                    if (cfg.channel_count <= 1U) {
                        append_tty_frame( (cfg.profile == CaptureProfile::Amplitude) ? 0x001AU : 0x000AU,
                                         tty_state.next_index,
                                         static_cast<uint16_t>(sample),
                                         0U);
                        if (tty_state.next_index >= 0xFFFEU) {
                            tty_state.next_index = 1U;
                        } else {
                            ++tty_state.next_index;
                        }
                        continue;
                    }
                    if (!tty_state.pending_ch1_valid) {
                        tty_state.pending_ch1 = sample;
                        tty_state.pending_ch1_valid = true;
                        continue;
                    }

                    append_tty_frame( (cfg.profile == CaptureProfile::Amplitude) ? 0x001AU : 0x000AU,
                                     tty_state.next_index,
                                     static_cast<uint16_t>(tty_state.pending_ch1),
                                     static_cast<uint16_t>(sample));
                    tty_state.pending_ch1_valid = false;

                    if (tty_state.next_index >= 0xFFFEU) {
                        tty_state.next_index = 1U;
                    } else {
                        ++tty_state.next_index;
                    }
                }
                flush_tty_frames();
            }
        }

        if ((adc_count == 0U) && (din_count == 0U) && (!tty_writer || (raw_adc_count == 0U))) {
            continue;
        }

        capture_started = capture_started || (adc_count != 0U);
        total_adc_samples += adc_count;
        total_din_samples += din_count;
        stats_adc_samples += adc_count;
        stats_din_samples += din_count;
        if (fast_tty_avg_stream_mode) {
            for (uint32_t i = 0; i < raw_adc_count; ++i) {
                pending_adc_raw.push_back(adc_raw_buffer[i]);
            }
        } else {
            for (uint32_t i = 0; i < adc_count; ++i) {
                pending_adc.push_back(adc_buffer[i]);
            }
        }
        for (uint32_t i = 0; i < din_count; ++i) {
            pending_din.push_back(din_buffer[i]);
        }

        if (((!fast_tty_avg_stream_mode) && (pending_adc.size() > 1000000U)) ||
            (pending_din.size() > 1000000U) ||
            (fast_tty_avg_stream_mode && (pending_adc_raw.size() > 1000000U))) {
            fail("Internal backlog grew too large while aligning ADC and DIN samples");
        }

        while ((fast_tty_avg_stream_mode ? !pending_adc_raw.empty() : !pending_adc.empty()) &&
               !pending_din.empty() &&
               !stop_loop_requested) {
            const double adc_value = fast_tty_avg_stream_mode ? 0.0 : pending_adc.front();
            if (!fast_tty_avg_stream_mode) {
                pending_adc.pop_front();
            }
            const double adc_raw_value = fast_tty_avg_stream_mode ? pending_adc_raw.front() : 0.0;
            if (fast_tty_avg_stream_mode) {
                pending_adc_raw.pop_front();
            }

            const uint32_t din_value = pending_din.front();
            pending_din.pop_front();
            // Keep ADC logical-channel phase aligned even when samples are skipped outside packet windows.
            const uint32_t lch = next_lch;
            next_lch = (next_lch + 1U) % cfg.channel_count;

            const bool di1_level = (din_value & kE502Digital1Mask) != 0U;
            bool di1_changed = false;
            if (!di1_initialized) {
                di1_prev_level = di1_level;
                di1_initialized = true;
            } else if (di1_level != di1_prev_level) {
                di1_changed = true;
                di1_prev_level = di1_level;
            }

            const bool di_syn2_level = (din_value & kE502DiSyn2Mask) != 0U;
            bool start_edge = false;
            bool stop_edge = false;
            if (!trigger_level_initialized) {
                trigger_prev_level = di_syn2_level;
                trigger_level_initialized = true;
                if (!packet_active) {
                    if (cfg.sync_start_mode == X502_SYNC_INTERNAL) {
                        start_packet();
                    } else if (sync_uses_di_syn2(cfg.sync_start_mode) && di_syn2_level) {
                        start_packet();
                    } else if (!sync_uses_di_syn2(cfg.sync_start_mode)) {
                        start_packet();
                    }
                }
            } else {
                start_edge = matches_sync_edge(cfg.sync_start_mode, trigger_prev_level, di_syn2_level);
                if (cfg.stop_mode != StopMode::TargetFrames) {
                    stop_edge = matches_stop_edge(cfg.stop_mode, trigger_prev_level, di_syn2_level);
                }
                trigger_prev_level = di_syn2_level;
            }
            if (!packet_active && (cfg.sync_start_mode == X502_SYNC_INTERNAL)) {
                start_packet();
            }

            if (packet_active && start_edge) {
                finish_packet(PacketCloseReason::ExternalStopEdge);
                if ((cfg.packet_limit != 0U) && (total_completed_packets >= cfg.packet_limit)) {
                    stop_loop_requested = true;
                    continue;
                }
                start_packet();
            } else if (!packet_active && start_edge) {
                start_packet();
            }

            if (packet_active && stop_edge) {
                finish_packet(PacketCloseReason::ExternalStopEdge);
                if ((cfg.packet_limit != 0U) && (total_completed_packets >= cfg.packet_limit)) {
                    stop_loop_requested = true;
                }
                continue;
            }

            if (!packet_active) {
                continue;
            }

            const bool di2_level = (din_value & kE502Digital2Mask) != 0U;
            if (di2_level) {
                ++packet_di2_high_clocks;
            } else {
                ++packet_di2_low_clocks;
            }
            ++packet_clock_count;

            if (cfg.do8_freq_ref) {
                const bool di8_raw = (din_value & kE502Digital8Mask) != 0U;
                if (di8_raw) {
                    ++packet_di8_high_clocks;
                } else {
                    ++packet_di8_low_clocks;
                }
            }

            const bool di8_level = cfg.do8_freq_ref
                ? ((din_value & kE502Digital8Mask) != 0U)
                : false;
            const bool di8_settled_high = cfg.do8_freq_ref
                ? tty_di8_state.update_di8(di8_level)
                : false;
            const bool di8_in_guard = cfg.do8_freq_ref && tty_di8_state.in_guard;

            if (tty_di1_group_average && di1_changed) {
                append_tty_group_step();
                if (cfg.do8_freq_ref) { append_tty_di8_ref_step(); tty_di8_state.clear_step(); }
                tty_group_state.clear_step();
            } else if (tty_do1_raw_tty_marked) {
                if (!tty_do1_raw_state.step_level_initialized) {
                    tty_do1_raw_state.start_new_step(di1_level);
                } else if (di1_changed) {
                    append_tty_do1_raw_marked_step();
                    if (cfg.do8_freq_ref) { append_tty_di8_ref_step(); tty_di8_state.clear_step(); }
                    tty_do1_raw_state.start_new_step(di1_level);
                }
            } else if (tty_do1_noise_subtract) {
                if (!tty_do1_noise_state.step_level_initialized) {
                    tty_do1_noise_state.start_new_step(di1_level);
                } else if (di1_changed) {
                    append_tty_do1_subtracted_step();
                    if (cfg.do8_freq_ref) { append_tty_di8_ref_step(); tty_di8_state.clear_step(); }
                    tty_do1_noise_state.start_new_step(di1_level);
                }
            }

            if (!fast_tty_avg_stream_mode &&
                (cfg.di1_mode == Di1Mode::Trace) &&
                ((cfg.channel_count <= 1U) || (lch == 0U))) {
                current_packet.pending_frame_di1 = static_cast<uint8_t>(di1_level ? 1U : 0U);
                current_packet.pending_frame_di1_valid = true;
            }

            if (fast_tty_avg_stream_mode) {
                if (fast_packet_frames < target_frames) {
                    if (tty_do1_pair_subtract_avg) {
                        if (lch == 0U) {
                            tty_do1_pair_state.add_pending_ch1(adc_raw_value, di1_level, di2_level,
                                                               di8_level);
                        } else if (lch == 1U) {
                            if (tty_do1_pair_state.pending_ch1_valid) {
                                const bool pair_di1_matches =
                                    (tty_do1_pair_state.pending_ch1_di1_high == di1_level);
                                const bool pair_di2_matches =
                                    (tty_do1_pair_state.pending_ch1_di2_high == di2_level);
                                // Use raw DI8 level for pair matching (not guard-filtered).
                                // Guard-based filtering consumed ALL settled HIGH samples
                                // due to per-DIN-sample guard counting vs per-DOUT-tick transitions.
                                // Mixed-level pairs (transition boundaries) are still discarded.
                                const bool both_di8_high =
                                    tty_do1_pair_state.pending_ch1_di8_high && di8_level;
                                const bool both_di8_normal =
                                    !tty_do1_pair_state.pending_ch1_di8_high && !di8_level;

                                if (pair_di1_matches && pair_di2_matches) {
                                    if (both_di8_high) {
                                        tty_di8_state.add_sample(0U, tty_do1_pair_state.pending_ch1_raw);
                                        tty_di8_state.add_sample(1U, adc_raw_value);
                                        ++packet_di8_samples_accumulated;
                                        ++packet_di8_both_high;
                                    } else if (both_di8_normal) {
                                        ++packet_di8_both_normal;
                                        if (!tty_do1_pair_state.run_initialized) {
                                            tty_do1_pair_state.start_run(di1_level);
                                        } else if (tty_do1_pair_state.run_di1_high != di1_level) {
                                            append_tty_do1_pair_subtracted_avg_run();
                                            if (cfg.do8_freq_ref) { append_tty_di8_ref_step(); tty_di8_state.clear_step(); }
                                            tty_do1_pair_state.start_run(di1_level);
                                        }

                                        tty_do1_pair_state.add_pair_sample(di2_level,
                                                                           tty_do1_pair_state.pending_ch1_raw,
                                                                           adc_raw_value);
                                    } else {
                                        ++packet_di8_mixed;  // mixed DI8 pair -- discard
                                    }
                                }
                            }
                            tty_do1_pair_state.clear_pending_ch1();
                        } else {
                            tty_do1_pair_state.clear_pending_ch1();
                        }
                    } else if (di8_settled_high) {
                        tty_di8_state.add_sample(lch, adc_raw_value);
                    } else if (!di8_in_guard) {
                        if (tty_do1_noise_subtract) {
                            tty_do1_noise_state.add_sample(lch, adc_raw_value);
                        } else if (tty_do1_raw_tty_marked) {
                            tty_do1_raw_state.add_sample(lch, adc_raw_value);
                        } else {
                            tty_group_state.add_sample(lch, adc_raw_value);
                        }
                    }
                    if (lch == (cfg.channel_count - 1U)) {
                        ++fast_packet_frames;
                        ++total_completed_frames;
                        ++stats_completed_frames;
                    }
                }
            } else {
                double stored_value = adc_value;
                if ((cfg.di1_mode == Di1Mode::ZeroOnChange) && di1_changed) {
                    stored_value = 0.0;
                    ++total_zeroed_samples;
                    ++stats_zeroed_samples;
                    ++current_packet.zeroed_samples;
                }

                if (current_packet.channels[lch].size() < target_frames) {
                    current_packet.channels[lch].push_back(stored_value);
                    ++current_packet.stored_samples;
                    ++total_stored_adc_samples;
                    ++stats_stored_adc_samples;
                    if (lch == (cfg.channel_count - 1U)) {
                        if ((cfg.di1_mode == Di1Mode::Trace) &&
                            current_packet.pending_frame_di1_valid &&
                            (current_packet.di1.size() < target_frames)) {
                            current_packet.di1.push_back(current_packet.pending_frame_di1);
                            current_packet.pending_frame_di1_valid = false;
                        }
                        ++total_completed_frames;
                        ++stats_completed_frames;
                    }
                }
            }

            const std::size_t completed_frames = fast_tty_avg_stream_mode
                ? fast_packet_frames
                : current_packet.frame_count(cfg.channel_count);
            if (completed_frames >= target_frames) {
                finish_packet(PacketCloseReason::DurationLimit);
                if ((cfg.packet_limit != 0U) && (total_completed_packets >= cfg.packet_limit)) {
                    stop_loop_requested = true;
                }
            }
        }

        flush_tty_frames();

        if (console_stop_requested()) {
            if (packet_active) {
                finish_packet(PacketCloseReason::UserStop);
            }
            flush_tty_frames();
            stop_loop_requested = true;
        }

        if ((cfg.stats_period_ms != 0U) && ((tick_count_ms() - last_stats_print) >= cfg.stats_period_ms)) {
            print_stats(false);
        }
    }

    if (cfg.do1_toggle_per_frame) {
        expect_ok(api, api.OutCycleStop(device.hnd, X502_OUT_CYCLE_FLAGS_WAIT_DONE), "Stop cyclic DOUT pattern");
    }

    expect_ok(api, api.StreamsStop(device.hnd), "Stop streams");
    device.streams_started = false;
    if (cfg.do1_toggle_per_frame) {
        const uint32_t clear_mask = kE502Do1Mask | kE502Do2Mask | (cfg.do8_freq_ref ? kE502Do8Mask : 0U);
        expect_ok(api,
                  api.AsyncOutDig(device.hnd, kE502Do2Mask, ~clear_mask),
                  "Force DO1/DO8 LOW and keep DO2 HIGH after cyclic DOUT stop");
    } else {
        expect_ok(api, api.AsyncOutDig(device.hnd, kE502Do2Mask, ~kE502Do2Mask), "Keep DO2 HIGH after streams stop");
    }

    if ((cfg.stats_period_ms != 0U) && ((stats_raw_words != 0U) || (stats_adc_samples != 0U) ||
        (stats_din_samples != 0U) || (stats_stored_adc_samples != 0U) || (stats_zeroed_samples != 0U) ||
        (stats_completed_frames != 0U))) {
        print_stats(false);
    }

    TtyProtocolWriter::StatsSnapshot tty_final_stats {};
    if (tty_writer) {
        tty_writer->shutdown();
        tty_writer->throw_if_failed();
        tty_final_stats = tty_writer->stats();
    }

    if (!fast_tty_avg_stream_mode) {
        std::cout << "Captured " << total_completed_packets << " packet(s) in capture mode\n";
        if (cfg.di1_mode == Di1Mode::ZeroOnChange) {
            std::cout << "ADC samples forced to 0 on DI1 change: " << total_zeroed_samples << "\n";
        } else if (cfg.di1_mode == Di1Mode::Trace) {
            std::cout << "DI1 synchronous trace exported to live plot\n";
        }
    } else {
        std::cout << "Captured " << total_completed_packets
                  << " packet(s) in tty fast stream-only mode (" << fast_tty_mode_name << ")\n";
    }
    std::cout << "Average stats: "
              << "MB/s=" << std::fixed << std::setprecision(3)
              << ((static_cast<double>(total_raw_words) * sizeof(uint32_t)) /
                  std::max(1e-9, static_cast<double>(tick_count_ms() - capture_loop_start) / 1000.0) /
                  1000.0 / 1000.0)
              << ", ADC samples/s="
              << (static_cast<double>(total_adc_samples) /
                  std::max(1e-9, static_cast<double>(tick_count_ms() - capture_loop_start) / 1000.0))
              << ", DIN samples/s="
              << (static_cast<double>(total_din_samples) /
                  std::max(1e-9, static_cast<double>(tick_count_ms() - capture_loop_start) / 1000.0))
              << ", frames/s per channel="
              << (static_cast<double>(total_completed_frames) /
                  std::max(1e-9, static_cast<double>(tick_count_ms() - capture_loop_start) / 1000.0))
              << ", packets/s="
              << (static_cast<double>(total_completed_packets) /
                  std::max(1e-9, static_cast<double>(tick_count_ms() - capture_loop_start) / 1000.0))
              << ", packets captured=" << total_completed_packets;
    if (!fast_tty_avg_stream_mode) {
        if (cfg.di1_mode == Di1Mode::ZeroOnChange) {
            std::cout << ", zeroed on DI1 change="
                      << ((total_stored_adc_samples == 0U)
                          ? 0.0
                          : (100.0 * static_cast<double>(total_zeroed_samples) / static_cast<double>(total_stored_adc_samples)))
                      << "% (" << total_zeroed_samples << "/" << total_stored_adc_samples << ")";
        } else if (cfg.di1_mode == Di1Mode::Trace) {
            std::cout << ", DI1 trace=enabled";
        }
    }
    if (tty_writer) {
        std::cout << ", tty_frames_written=" << tty_final_stats.frames_written
                  << ", tty_frames_dropped=" << tty_final_stats.frames_dropped
                  << ", tty_ring_overflows=" << tty_final_stats.ring_overflows;
    }
    if (fast_tty_avg_stream_mode) {
        std::cout << ", tty fast stream-only mode=" << fast_tty_mode_name;
    }
    std::cout << "\n";

    return 0;
}

} // namespace

int main(int argc, char** argv) {
    try {
        const Config cfg = parse_args(argc, argv);
        return run(cfg);
    } catch (const std::bad_alloc&) {
        std::cerr << "Error: bad allocation. The process ran out of RAM. "
                     "Try a smaller duration_ms, fewer logical channels, or a lower svg_history_packets "
                     "(for example svg_history_packets:10 or svg_history_packets:0 only if you really need all packets in SVG).\n";
        return 1;
    } catch (const std::exception& ex) {
        std::cerr << "Error: " << ex.what() << "\n";
        return 1;
    }
}
