Files
Martino FerrariandClaude Opus 4.6 1c61e814c0 fix(udpscope): keep the packet counter in lockstep with the tick reference
The counter is the denominator of the very period lastAccHrt is the
numerator of, so any packet that cannot move the tick reference must not
move the counter either. Two paths were violating that: a reordered
datagram rolled the counter back while the reference correctly held
(next burst drawn 0.048x too narrow at distance 20, 83.3% worst spacing
error under 2% sustained reordering), and a stray hrt == 0 packet
advanced the counter from the warm-up branch without a tick to match
(+22.5 ms of future-dating per stray packet).

Rules 1 and 2 now record a counter too. The duplicate-datagram guard is
keyed on one, so an array rule that recorded none was exempt and plotted
every doubly-delivered update twice.

Also: rule 2 divides by the count the anchor actually spans and falls
back to the last period it derived; the counter-gap test is wrap-safe so
2^32 rollover reads as no information rather than 2e9 lost packets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-28 06:41:47 +02:00

422 KiB
Raw Permalink Blame History

UDPScope Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build Client/udpscope, an ImGui oscilloscope that attaches directly to one UDPStreamer through the standalone C client, with a splittable multi-pane view and a local trigger.

Architecture: Two threads. A receiver thread runs udps_client_poll() forever, reconstructs per-sample timestamps, appends to per-signal ring buffers and runs the trigger edge detector. The GUI thread runs SDL2 + ImGui + ImPlot, reads windows out of the rings each frame, decimates them with a min/max envelope and draws. SignalStore is the single shared object, guarded by one mutex.

Tech Stack: C++17, SDL2, OpenGL 3.3, Dear ImGui v1.91.8, ImPlot v0.17, GoogleTest, CMake ≥ 3.16. The transport is Common/Client/c/udps_client.c (C99, compiled into the build).

Spec: docs/superpowers/specs/2026-08-27-udpscope-direct-udps-imgui-scope-design.md

Global Constraints

  • Language: C++17 for the app, C99 for the vendored udps_client.c.
  • Namespace: everything in namespace udpscope.
  • Warnings: app compiled -Wall -Wextra -Wno-unused-parameter; vendored ImGui/ImPlot compiled -w.
  • Nothing under Client/streamhub/ may be modified. SignalBuffer.h and resources/ are consumed read-only.
  • PaneTree, TimeBase, FrameDecoder, Trigger, Measure, Decimate, Settings, Export must not include any ImGui, SDL or UDPS header. They are unit-tested without a window or a socket.
  • SignalBuffer.h's comment claims it is thread-safe. It is not — it has no locks. All locking lives in SignalStore.
  • Ring margin constant is kRingMargin = 4.0 and must carry the comment explaining why (a capture harvested after its last sample needs the ring to reach further back than the window itself).
  • Decimation is min/max envelope, never LTTB.
  • Signals are identified by name everywhere in the UI and in settings, never by index.
  • This is not a MARTe2 component: STL and C++17 are fine. The "no STL" rule applies only to Source/Components/.
  • EUPL v1.1 headers are not required here (matching Client/streamhub, which has none).

File Structure

File Responsibility
Client/udpscope/CMakeLists.txt Build: SDL2, OpenGL, FetchContent ImGui/ImPlot/GoogleTest, udpsclient C target
Types.h Series, Color, Rect, SignalMeta, FrameView — shared plain data, no logic
Decimate.{h,cpp} Min/max envelope decimation
PaneTree.{h,cpp} BSP split tree: split, close, layout, hit-test
TimeBase.{h,cpp} Producer-clock → wall-clock offset; hrt tick-rate fit
FrameDecoder.{h,cpp} Per-element timestamp reconstruction (spec §5)
Trigger.{h,cpp} Edge detector and capture FSM
SignalStore.{h,cpp} Rings, ring sizing, signal table lifecycle, the one mutex
Receiver.{h,cpp} The thread, the udps_client_t, the three C callbacks
Measure.{h,cpp} Window statistics and cursor readouts
Axes.{h,cpp} Shared X-axis controller; per-trace division scaling
CaptureLatch.{h,cpp} Which capture the panes draw; trigger status badge text
PlotData.{h,cpp} Fetching a trace out of a ring or a capture, ready to draw
Cli.{h,cpp} Command-line parsing, usage text, default config path
Settings.{h,cpp} Session file writer and recursive-descent parser
Export.{h,cpp} Long-format CSV writer
App.{h,cpp} Owns everything; per-frame update()
main.cpp SDL/ImGui/ImPlot bootstrap, style, fonts, event loop
SignalList.cpp Left panel, drag source, profile toggle
PaneView.cpp ImPlot rendering of one pane, split handles
TriggerBar.cpp Trigger controls and status badge
tests/*.cpp GoogleTest suites for the framework-free modules

Task 1: Build scaffold and min/max decimation

Establishes the CMake build (including the test binary) and lands the first framework-free module through it, so the scaffold is proven by something real rather than by an empty target.

Files:

  • Create: Client/udpscope/CMakeLists.txt
  • Create: Client/udpscope/Types.h
  • Create: Client/udpscope/Decimate.h
  • Create: Client/udpscope/Decimate.cpp
  • Create: Client/udpscope/tests/DecimateTest.cpp

Interfaces:

  • Consumes: nothing.

  • Produces:

    • udpscope::Series { std::vector<double> t, v; }
    • udpscope::Color { float r, g, b, a; }
    • udpscope::Rect { double x, y, w, h; }
    • void udpscope::MinMaxDecimate(const double* t, const double* v, size_t n, size_t maxPoints, Series& out)
    • CMake targets udpsclient, imgui_lib, UDPScope, udpscope_tests.
  • Step 1: Write the failing test

Create Client/udpscope/tests/DecimateTest.cpp:

#include "Decimate.h"

#include <gtest/gtest.h>

#include <vector>

using namespace udpscope;

TEST(MinMaxDecimate, PassesShortInputThroughUnchanged) {
    const std::vector<double> t{0.0, 1.0, 2.0};
    const std::vector<double> v{5.0, 6.0, 7.0};
    Series out;

    MinMaxDecimate(t.data(), v.data(), t.size(), 100, out);

    EXPECT_EQ(out.t, t);
    EXPECT_EQ(out.v, v);
}

// The whole reason for preferring min/max over LTTB: a single-sample spike is
// usually the thing the user is looking for, and it must survive decimation.
TEST(MinMaxDecimate, PreservesAnIsolatedSpike) {
    std::vector<double> t(1000), v(1000, 0.0);
    for (size_t i = 0; i < t.size(); i++) { t[i] = static_cast<double>(i); }
    v[437] = 42.0;
    Series out;

    MinMaxDecimate(t.data(), v.data(), t.size(), 50, out);

    ASSERT_FALSE(out.v.empty());
    EXPECT_EQ(*std::max_element(out.v.begin(), out.v.end()), 42.0);
}

TEST(MinMaxDecimate, PreservesTheExtremesOfEveryBucket) {
    std::vector<double> t(100), v(100);
    for (size_t i = 0; i < t.size(); i++) {
        t[i] = static_cast<double>(i);
        v[i] = (i % 10 == 3) ? -9.0 : ((i % 10 == 7) ? 9.0 : 0.0);
    }
    Series out;

    MinMaxDecimate(t.data(), v.data(), t.size(), 20, out);

    EXPECT_EQ(*std::min_element(out.v.begin(), out.v.end()), -9.0);
    EXPECT_EQ(*std::max_element(out.v.begin(), out.v.end()), 9.0);
}

// A ring whose timestamps are not monotonic breaks any later binary search by
// time, so the pair emitted per bucket must be ordered by time, not by value.
TEST(MinMaxDecimate, EmitsPointsInTimeOrder) {
    // Two buckets of four. In the first the minimum comes before the maximum,
    // in the second the order is reversed. An implementation that emitted
    // (min, max) by value rather than by time passes on bucket 0 and fails on
    // bucket 1, so this data exercises the swap that a monotonically growing
    // ramp never triggers.
    const double st[8] = {0, 1, 2, 3, 4, 5, 6, 7};
    const double sv[8] = {-5, 0, 0, 9, 9, 0, 0, -5};
    Series pair;
    MinMaxDecimate(st, sv, 8, 4, pair);
    ASSERT_EQ(pair.size(), 4u);
    const double wantT[4] = {0, 3, 4, 7};
    const double wantV[4] = {-5, 9, 9, -5};
    for (size_t i = 0; i < 4; i++) {
        EXPECT_EQ(pair.t[i], wantT[i]) << "time at " << i;
        EXPECT_EQ(pair.v[i], wantV[i]) << "value at " << i;
    }

    std::vector<double> t(400), v(400);
    for (size_t i = 0; i < t.size(); i++) {
        t[i] = static_cast<double>(i);
        v[i] = (i % 2 == 0) ? -static_cast<double>(i) : static_cast<double>(i);
    }
    Series out;

    MinMaxDecimate(t.data(), v.data(), t.size(), 40, out);

    ASSERT_GT(out.t.size(), 1u);
    for (size_t i = 1; i < out.t.size(); i++) {
        EXPECT_LE(out.t[i - 1], out.t[i]) << "at index " << i;
    }
}

TEST(MinMaxDecimate, HandlesEmptyInput) {
    Series out;
    out.t.push_back(1.0);   // must be cleared
    MinMaxDecimate(nullptr, nullptr, 0, 10, out);
    EXPECT_TRUE(out.t.empty());
    EXPECT_TRUE(out.v.empty());
}
  • Step 2: Create the shared plain-data header

Create Client/udpscope/Types.h:

/**
 * @file Types.h
 * @brief Plain data shared across UDPScope modules. No logic, no dependencies.
 */
#pragma once

#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>

namespace udpscope {

/** A time series as two parallel arrays, which is what ImPlot wants. */
struct Series {
    std::vector<double> t;
    std::vector<double> v;

    void clear() { t.clear(); v.clear(); }
    size_t size() const { return t.size(); }
    bool empty() const { return t.empty(); }
};

/** RGBA in 0..1. Framework-free so PaneTree needs no ImGui. */
struct Color {
    float r = 1.f, g = 1.f, b = 1.f, a = 1.f;
};

/** Screen rectangle in pixels. */
struct Rect {
    double x = 0.0, y = 0.0, w = 0.0, h = 0.0;

    bool contains(double px, double py) const {
        return px >= x && px < (x + w) && py >= y && py < (y + h);
    }
};

} /* namespace udpscope */
  • Step 3: Write the decimation header

Create Client/udpscope/Decimate.h:

/**
 * @file Decimate.h
 * @brief Min/max envelope decimation for screen rendering.
 */
#pragma once

#include "Types.h"

namespace udpscope {

/**
 * @brief Reduce n points to at most maxPoints by emitting each bucket's
 *        minimum and maximum, in time order.
 *
 * LTTB is deliberately not used. It selects representative points and will
 * silently drop a one-sample glitch; on a scope that glitch is usually the
 * thing being looked for. The emitted pair stays in time order rather than
 * value order because callers binary-search the result by time.
 *
 * Input shorter than maxPoints is copied through unchanged.
 */
void MinMaxDecimate(const double* t, const double* v, size_t n,
                    size_t maxPoints, Series& out);

} /* namespace udpscope */
  • Step 4: Write the minimal implementation

Create Client/udpscope/Decimate.cpp:

#include "Decimate.h"

#include <algorithm>

namespace udpscope {

void MinMaxDecimate(const double* t, const double* v, size_t n,
                    size_t maxPoints, Series& out) {
    out.clear();
    if (n == 0 || t == nullptr || v == nullptr) {
        return;
    }
    if (n <= maxPoints || maxPoints < 4) {
        out.t.assign(t, t + n);
        out.v.assign(v, v + n);
        return;
    }

    /* Two points per bucket, so the bucket count is half the budget. */
    const size_t buckets = maxPoints / 2;
    out.t.reserve(buckets * 2);
    out.v.reserve(buckets * 2);

    for (size_t b = 0; b < buckets; b++) {
        const size_t begin = (n * b) / buckets;
        size_t       end   = (n * (b + 1)) / buckets;
        if (end <= begin) { end = begin + 1; }
        if (end > n) { end = n; }

        size_t lo = begin, hi = begin;
        for (size_t i = begin + 1; i < end; i++) {
            if (v[i] < v[lo]) { lo = i; }
            if (v[i] > v[hi]) { hi = i; }
        }

        const size_t first  = std::min(lo, hi);
        const size_t second = std::max(lo, hi);
        out.t.push_back(t[first]);
        out.v.push_back(v[first]);
        if (second != first) {
            out.t.push_back(t[second]);
            out.v.push_back(v[second]);
        }
    }
}

} /* namespace udpscope */
  • Step 5: Write the CMake build

Create Client/udpscope/CMakeLists.txt:

cmake_minimum_required(VERSION 3.16)
project(UDPScope CXX C)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_C_STANDARD 99)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

option(UDPSCOPE_BUILD_TESTS "Build the unit tests" ON)

set(STREAMHUB_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../streamhub)
set(CCLIENT_DIR   ${CMAKE_CURRENT_SOURCE_DIR}/../../Common/Client/c)

# ── The standalone C UDPS client, compiled in directly ────────────────────────
# Building it here rather than shelling out to its own Makefile keeps this a
# single cmake --build away from a working binary.
add_library(udpsclient STATIC ${CCLIENT_DIR}/udps_client.c)
target_include_directories(udpsclient PUBLIC ${CCLIENT_DIR})
target_compile_options(udpsclient PRIVATE -Wall -Wextra -Wpedantic)

# ── System packages ───────────────────────────────────────────────────────────
find_package(OpenGL REQUIRED)

find_package(SDL2 QUIET CONFIG)
if(NOT SDL2_FOUND)
    find_package(PkgConfig REQUIRED)
    pkg_check_modules(SDL2 REQUIRED sdl2)
    add_library(SDL2::SDL2 INTERFACE IMPORTED)
    target_include_directories(SDL2::SDL2 INTERFACE ${SDL2_INCLUDE_DIRS})
    target_link_libraries(SDL2::SDL2 INTERFACE ${SDL2_LIBRARIES})
    target_compile_options(SDL2::SDL2 INTERFACE ${SDL2_CFLAGS_OTHER})
endif()

# ── Dear ImGui + ImPlot ───────────────────────────────────────────────────────
include(FetchContent)

FetchContent_Declare(imgui
    GIT_REPOSITORY https://github.com/ocornut/imgui.git
    GIT_TAG        v1.91.8
    GIT_SHALLOW    TRUE)
FetchContent_MakeAvailable(imgui)

FetchContent_Declare(implot
    GIT_REPOSITORY https://github.com/epezent/implot.git
    GIT_TAG        v0.17
    GIT_SHALLOW    TRUE)
FetchContent_MakeAvailable(implot)

add_library(imgui_lib STATIC
    ${imgui_SOURCE_DIR}/imgui.cpp
    ${imgui_SOURCE_DIR}/imgui_draw.cpp
    ${imgui_SOURCE_DIR}/imgui_tables.cpp
    ${imgui_SOURCE_DIR}/imgui_widgets.cpp
    ${imgui_SOURCE_DIR}/backends/imgui_impl_sdl2.cpp
    ${imgui_SOURCE_DIR}/backends/imgui_impl_opengl3.cpp
    ${implot_SOURCE_DIR}/implot.cpp
    ${implot_SOURCE_DIR}/implot_items.cpp)
target_include_directories(imgui_lib PUBLIC
    ${imgui_SOURCE_DIR} ${imgui_SOURCE_DIR}/backends ${implot_SOURCE_DIR})
target_link_libraries(imgui_lib PUBLIC SDL2::SDL2 OpenGL::GL)
target_compile_options(imgui_lib PRIVATE -w)

# ── Bundled resources, borrowed read-only from the StreamHub client ───────────
set(RESOURCE_DIR ${STREAMHUB_DIR}/resources)
set(FONT_DIR     ${RESOURCE_DIR}/fonts)

if(EXISTS ${FONT_DIR}/fa-solid-900.ttf AND EXISTS ${FONT_DIR}/IconsFontAwesome6.h)
    set(HAVE_FONT_AWESOME TRUE)
    message(STATUS "Font Awesome icons enabled (${FONT_DIR})")
else()
    set(HAVE_FONT_AWESOME FALSE)
    message(WARNING "Bundled Font Awesome missing — using ASCII icon fallbacks")
endif()

# Guarded: file(COPY) is a hard configure error on a missing source, which
# would defeat the fallback the block above just chose.
if(EXISTS ${FONT_DIR})
    file(COPY ${FONT_DIR} DESTINATION ${CMAKE_BINARY_DIR}/resources)
endif()

# ── Core library: everything except main.cpp, so tests can link it ────────────
set(CORE_SOURCES
    Decimate.cpp
)

add_library(udpscope_core STATIC ${CORE_SOURCES})
target_include_directories(udpscope_core PUBLIC
    ${CMAKE_CURRENT_SOURCE_DIR}
    ${STREAMHUB_DIR})          # SignalBuffer.h, reused verbatim
target_link_libraries(udpscope_core PUBLIC udpsclient pthread)
target_compile_options(udpscope_core PRIVATE -Wall -Wextra -Wno-unused-parameter)

# ── Application ───────────────────────────────────────────────────────────────
set(APP_SOURCES
    main.cpp
)

if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/main.cpp)
    add_executable(UDPScope ${APP_SOURCES})
    target_link_libraries(UDPScope PRIVATE udpscope_core imgui_lib SDL2::SDL2 OpenGL::GL)
    target_compile_definitions(UDPScope PRIVATE APP_RESOURCE_DIR="${RESOURCE_DIR}")
    if(HAVE_FONT_AWESOME)
        target_include_directories(UDPScope PRIVATE ${FONT_DIR})
        target_compile_definitions(UDPScope PRIVATE HAVE_FONT_AWESOME)
    endif()
    target_compile_options(UDPScope PRIVATE -Wall -Wextra -Wno-unused-parameter)

    install(TARGETS UDPScope DESTINATION bin)
    install(DIRECTORY ${FONT_DIR} DESTINATION share/udpscope)
endif()

# ── Tests ─────────────────────────────────────────────────────────────────────
if(UDPSCOPE_BUILD_TESTS)
    FetchContent_Declare(googletest
        GIT_REPOSITORY https://github.com/google/googletest.git
        GIT_TAG        v1.15.2
        GIT_SHALLOW    TRUE)
    set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
    FetchContent_MakeAvailable(googletest)

    file(GLOB TEST_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/tests/*.cpp)
    add_executable(udpscope_tests ${TEST_SOURCES})
    target_link_libraries(udpscope_tests PRIVATE udpscope_core GTest::gtest_main)
    target_compile_options(udpscope_tests PRIVATE -Wall -Wextra -Wno-unused-parameter)

    enable_testing()
    include(GoogleTest)
    gtest_discover_tests(udpscope_tests)
endif()

Note the if(EXISTS main.cpp) guard: it lets Tasks 18 build and test with no GUI code present at all, and disappears in Task 9 when main.cpp lands.

  • Step 6: Configure, then run the tests and watch them fail
cd Client/udpscope && cmake -B build -DCMAKE_BUILD_TYPE=Debug && cmake --build build -j

The first configure downloads ImGui, ImPlot and GoogleTest, so it needs network access and takes a minute.

Then:

./build/udpscope_tests

Expected on a first run before Decimate.cpp is written: a link error for MinMaxDecimate. Since Steps 34 already wrote it, the expected result here is PASS, 5 tests. If you want to see the red first, comment out Decimate.cpp in CORE_SOURCES, rebuild, observe the undefined-reference failure, then restore it.

  • Step 7: Add the build directory to git ignore

Create Client/udpscope/.gitignore:

build/
compile_commands.json
  • Step 8: Commit
git add Client/udpscope/CMakeLists.txt Client/udpscope/.gitignore \
        Client/udpscope/Types.h Client/udpscope/Decimate.h \
        Client/udpscope/Decimate.cpp Client/udpscope/tests/DecimateTest.cpp
git commit -m "feat(udpscope): build scaffold and min/max envelope decimation"

Task 2: Pane tree

The BSP layout that the whole UI hangs off. Framework-free, so all the fiddly geometry is settled before a single ImGui call exists.

Files:

  • Create: Client/udpscope/PaneTree.h
  • Create: Client/udpscope/PaneTree.cpp
  • Create: Client/udpscope/tests/PaneTreeTest.cpp
  • Modify: Client/udpscope/CMakeLists.txt (add PaneTree.cpp to CORE_SOURCES)

Interfaces:

  • Consumes: udpscope::Rect, udpscope::Color from Types.h (Task 1).

  • Produces:

    • enum class Orient { Columns, Rows }
    • enum class VMode { Auto, Range, Manual }
    • struct VScale { VMode mode; double div; double offset; }
    • struct Assignment { std::string signalName; Color color; float lineWidth; VScale vs; }
    • struct PaneNode { bool leaf; std::vector<Assignment> signals; Orient orient; double ratio; std::unique_ptr<PaneNode> a, b; }
    • class PaneTree with root(), layout(), splitLeaf(), closeLeaf(), leafCount(), hitTestSplitter(), hitTestHandle()
    • struct PaneTree::Placed { PaneNode* leaf; Rect rect; }
    • struct PaneTree::Splitter { PaneNode* node; Rect rect; Orient orient; }
    • enum class Handle { None, Left, Right, Top, Bottom, Close }
    • constexpr double kMinPaneSize = 80.0;
  • Step 1: Write the failing test

Create Client/udpscope/tests/PaneTreeTest.cpp:

#include "PaneTree.h"

#include <gtest/gtest.h>

using namespace udpscope;

namespace {

const Rect kScreen{0.0, 0.0, 1000.0, 600.0};

std::vector<PaneTree::Placed> leavesOf(const PaneTree& tree, const Rect& area) {
    std::vector<PaneTree::Placed>   leaves;
    std::vector<PaneTree::Splitter> splitters;
    tree.layout(area, leaves, splitters);
    return leaves;
}

} /* namespace */

TEST(PaneTree, StartsAsOneEmptyLeafFillingTheArea) {
    PaneTree tree;
    EXPECT_EQ(tree.leafCount(), 1u);

    const auto leaves = leavesOf(tree, kScreen);
    ASSERT_EQ(leaves.size(), 1u);
    EXPECT_DOUBLE_EQ(leaves[0].rect.w, 1000.0);
    EXPECT_DOUBLE_EQ(leaves[0].rect.h, 600.0);
    EXPECT_TRUE(leaves[0].leaf->signals.empty());
}

TEST(PaneTree, SplittingIntoColumnsHalvesTheWidth) {
    PaneTree tree;
    tree.splitLeaf(tree.root(), Orient::Columns);

    const auto leaves = leavesOf(tree, kScreen);
    ASSERT_EQ(leaves.size(), 2u);
    EXPECT_DOUBLE_EQ(leaves[0].rect.w, 500.0);
    EXPECT_DOUBLE_EQ(leaves[1].rect.w, 500.0);
    EXPECT_DOUBLE_EQ(leaves[0].rect.h, 600.0);
    EXPECT_DOUBLE_EQ(leaves[1].rect.x, 500.0);
}

TEST(PaneTree, SplittingIntoRowsHalvesTheHeight) {
    PaneTree tree;
    tree.splitLeaf(tree.root(), Orient::Rows);

    const auto leaves = leavesOf(tree, kScreen);
    ASSERT_EQ(leaves.size(), 2u);
    EXPECT_DOUBLE_EQ(leaves[0].rect.h, 300.0);
    EXPECT_DOUBLE_EQ(leaves[1].rect.y, 300.0);
    EXPECT_DOUBLE_EQ(leaves[0].rect.w, 1000.0);
}

// The pane being split keeps its content; the new pane is the empty one.
TEST(PaneTree, SplitKeepsTheOriginalContentInTheFirstChild) {
    PaneTree tree;
    tree.root()->signals.push_back(Assignment{"Voltage", Color{}, 1.5f, VScale{}});
    tree.splitLeaf(tree.root(), Orient::Columns);

    const auto leaves = leavesOf(tree, kScreen);
    ASSERT_EQ(leaves.size(), 2u);
    ASSERT_EQ(leaves[0].leaf->signals.size(), 1u);
    EXPECT_EQ(leaves[0].leaf->signals[0].signalName, "Voltage");
    EXPECT_TRUE(leaves[1].leaf->signals.empty());
}

TEST(PaneTree, ClosingALeafGivesItsSpaceToTheSibling) {
    PaneTree tree;
    tree.splitLeaf(tree.root(), Orient::Columns);
    auto leaves = leavesOf(tree, kScreen);
    ASSERT_EQ(leaves.size(), 2u);
    leaves[1].leaf->signals.push_back(Assignment{"Keep", Color{}, 1.5f, VScale{}});

    tree.closeLeaf(leaves[0].leaf);

    EXPECT_EQ(tree.leafCount(), 1u);
    leaves = leavesOf(tree, kScreen);
    ASSERT_EQ(leaves.size(), 1u);
    EXPECT_DOUBLE_EQ(leaves[0].rect.w, 1000.0);
    ASSERT_EQ(leaves[0].leaf->signals.size(), 1u);
    EXPECT_EQ(leaves[0].leaf->signals[0].signalName, "Keep");
}

TEST(PaneTree, RefusesToCloseTheLastLeaf) {
    PaneTree tree;
    tree.closeLeaf(tree.root());
    EXPECT_EQ(tree.leafCount(), 1u);
}

// A pane in the middle of a 3x3 touches no window edge. It must still be
// splittable, which is why handles are inset inside the pane rather than
// keyed on the window border.
TEST(PaneTree, AnInteriorPaneIsStillSplittable) {
    PaneTree tree;
    tree.splitLeaf(tree.root(), Orient::Rows);          // top / bottom
    auto leaves = leavesOf(tree, kScreen);
    tree.splitLeaf(leaves[1].leaf, Orient::Rows);       // 3 rows
    leaves = leavesOf(tree, kScreen);
    ASSERT_EQ(leaves.size(), 3u);

    PaneNode* middle = leaves[1].leaf;
    tree.splitLeaf(middle, Orient::Columns);
    leaves = leavesOf(tree, kScreen);
    tree.splitLeaf(leaves[2].leaf, Orient::Columns);

    EXPECT_EQ(tree.leafCount(), 5u);
}

TEST(PaneTree, LayoutReportsOneSplitterPerSplitNode) {
    PaneTree tree;
    tree.splitLeaf(tree.root(), Orient::Columns);
    auto leaves = leavesOf(tree, kScreen);
    tree.splitLeaf(leaves[0].leaf, Orient::Rows);

    std::vector<PaneTree::Placed>   out;
    std::vector<PaneTree::Splitter> splitters;
    tree.layout(kScreen, out, splitters);

    EXPECT_EQ(out.size(), 3u);
    EXPECT_EQ(splitters.size(), 2u);
}

TEST(PaneTree, RatioSurvivesALayoutRoundTrip) {
    PaneTree tree;
    tree.splitLeaf(tree.root(), Orient::Columns);
    tree.setRatio(tree.root(), 0.25);

    const auto leaves = leavesOf(tree, kScreen);
    ASSERT_EQ(leaves.size(), 2u);
    EXPECT_DOUBLE_EQ(leaves[0].rect.w, 250.0);
    EXPECT_DOUBLE_EQ(leaves[1].rect.w, 750.0);
}

TEST(PaneTree, RatioIsClampedSoNeitherPaneGoesBelowTheMinimum) {
    PaneTree tree;
    tree.splitLeaf(tree.root(), Orient::Columns);
    tree.setRatio(tree.root(), 0.001);

    const auto leaves = leavesOf(tree, kScreen);
    EXPECT_GE(leaves[0].rect.w, kMinPaneSize);
    EXPECT_GE(leaves[1].rect.w, kMinPaneSize);
}

TEST(PaneTree, HitTestFindsTheSplitterBetweenTwoPanes) {
    PaneTree tree;
    tree.splitLeaf(tree.root(), Orient::Columns);

    std::vector<PaneTree::Placed>   leaves;
    std::vector<PaneTree::Splitter> splitters;
    tree.layout(kScreen, leaves, splitters);
    ASSERT_EQ(splitters.size(), 1u);

    const PaneTree::Splitter* hit = tree.hitTestSplitter(splitters, 500.0, 300.0);
    ASSERT_NE(hit, nullptr);
    EXPECT_EQ(hit->orient, Orient::Columns);

    EXPECT_EQ(tree.hitTestSplitter(splitters, 100.0, 300.0), nullptr);
}

TEST(PaneTree, HitTestFindsInsetSplitHandlesAndTheCloseButton) {
    const Rect pane{0.0, 0.0, 400.0, 300.0};

    EXPECT_EQ(PaneTree::hitTestHandle(pane, 8.0, 150.0),  Handle::Left);
    EXPECT_EQ(PaneTree::hitTestHandle(pane, 392.0, 150.0), Handle::Right);
    EXPECT_EQ(PaneTree::hitTestHandle(pane, 200.0, 8.0),   Handle::Top);
    EXPECT_EQ(PaneTree::hitTestHandle(pane, 200.0, 292.0), Handle::Bottom);
    EXPECT_EQ(PaneTree::hitTestHandle(pane, 392.0, 8.0),   Handle::Close);
    EXPECT_EQ(PaneTree::hitTestHandle(pane, 200.0, 150.0), Handle::None);
}
  • Step 2: Run the test to verify it fails
cd Client/udpscope && cmake --build build -j

Expected: FAIL — PaneTree.h: No such file or directory.

  • Step 3: Write the header

Create Client/udpscope/PaneTree.h:

/**
 * @file PaneTree.h
 * @brief Binary-space-partition layout of the plot area.
 *
 * Framework-free: no ImGui, no UDPS. The geometry and the hit-testing are the
 * fiddly part of the pane UI and are unit-tested without a window.
 */
#pragma once

#include "Types.h"

#include <memory>
#include <string>
#include <vector>

namespace udpscope {

/** Direction a node splits its rectangle in. */
enum class Orient { Columns, Rows };

/** Vertical scaling strategy for one trace. */
enum class VMode { Auto, Range, Manual };

struct VScale {
    VMode  mode   = VMode::Auto;
    double div    = 1.0;   /**< Units per division, Manual only. */
    double offset = 0.0;   /**< Centre value, Manual only. */
};

/** One signal drawn in one pane. Signals are named, never indexed. */
struct Assignment {
    std::string signalName;
    Color       color;
    float       lineWidth = 1.5f;
    VScale      vs;
};

/** Smallest a pane may be squeezed to, in pixels. */
constexpr double kMinPaneSize = 80.0;

/** Thickness of the splitter drag zone and of the inset handles, in pixels. */
constexpr double kSplitterGrab = 6.0;
constexpr double kHandleSize   = 18.0;

/** What the pointer is over inside a pane. */
enum class Handle { None, Left, Right, Top, Bottom, Close };

struct PaneNode {
    bool leaf = true;

    /* leaf only */
    std::vector<Assignment> signals;
    bool profilePane = false;   /**< Holds vector signals, not time series. */

    /* split only */
    Orient orient = Orient::Columns;
    double ratio  = 0.5;        /**< First child's share of the parent. */
    std::unique_ptr<PaneNode> a, b;
};

class PaneTree {
public:
    struct Placed   { PaneNode* leaf; Rect rect; };
    struct Splitter { PaneNode* node; Rect rect; Orient orient; };

    PaneTree();

    PaneNode* root() { return root_.get(); }
    const PaneNode* root() const { return root_.get(); }

    /** Replaces the whole tree, e.g. when loading a session. */
    void setRoot(std::unique_ptr<PaneNode> node);

    /**
     * @brief Walk the tree, producing every leaf's rectangle and every split's
     *        drag zone.
     */
    void layout(const Rect& area,
                std::vector<Placed>& leaves,
                std::vector<Splitter>& splitters) const;

    /** Turn a leaf into a split; the original content stays in the first child. */
    void splitLeaf(PaneNode* leaf, Orient orient);

    /** Replace the leaf's parent with its sibling. No-op on the last leaf. */
    void closeLeaf(PaneNode* leaf);

    void setRatio(PaneNode* split, double ratio);

    size_t leafCount() const;

    /** @return the splitter under the point, or nullptr. */
    const Splitter* hitTestSplitter(const std::vector<Splitter>& splitters,
                                    double px, double py) const;

    /**
     * @brief Which inset handle of @p pane the point is over.
     *
     * Handles sit inside the pane so they never overlap the splitter drag zone,
     * and every pane has all four regardless of whether it touches a window
     * edge — a pane in the middle of a 3x3 touches none.
     */
    static Handle hitTestHandle(const Rect& pane, double px, double py);

private:
    static void layoutNode(PaneNode* node, const Rect& r,
                           std::vector<Placed>& leaves,
                           std::vector<Splitter>& splitters);
    static size_t countLeaves(const PaneNode* node);
    static PaneNode* findParent(PaneNode* node, const PaneNode* child);
    static double clampRatio(double ratio, double extent);

    std::unique_ptr<PaneNode> root_;
};

} /* namespace udpscope */
  • Step 4: Write the implementation

Create Client/udpscope/PaneTree.cpp:

#include "PaneTree.h"

#include <algorithm>

namespace udpscope {

PaneTree::PaneTree() : root_(new PaneNode()) {}

void PaneTree::setRoot(std::unique_ptr<PaneNode> node) {
    if (node) { root_ = std::move(node); }
}

double PaneTree::clampRatio(double ratio, double extent) {
    if (extent <= 2.0 * kMinPaneSize) {
        return 0.5;   /* Too small to honour the minimum on both sides. */
    }
    const double lo = kMinPaneSize / extent;
    return std::min(std::max(ratio, lo), 1.0 - lo);
}

void PaneTree::layoutNode(PaneNode* node, const Rect& r,
                          std::vector<Placed>& leaves,
                          std::vector<Splitter>& splitters) {
    if (node == nullptr) { return; }
    if (node->leaf) {
        leaves.push_back(Placed{node, r});
        return;
    }

    if (node->orient == Orient::Columns) {
        const double ratio = clampRatio(node->ratio, r.w);
        const double wA    = r.w * ratio;
        layoutNode(node->a.get(), Rect{r.x, r.y, wA, r.h}, leaves, splitters);
        layoutNode(node->b.get(), Rect{r.x + wA, r.y, r.w - wA, r.h}, leaves, splitters);
        splitters.push_back(Splitter{
            node,
            Rect{r.x + wA - kSplitterGrab * 0.5, r.y, kSplitterGrab, r.h},
            Orient::Columns});
    } else {
        const double ratio = clampRatio(node->ratio, r.h);
        const double hA    = r.h * ratio;
        layoutNode(node->a.get(), Rect{r.x, r.y, r.w, hA}, leaves, splitters);
        layoutNode(node->b.get(), Rect{r.x, r.y + hA, r.w, r.h - hA}, leaves, splitters);
        splitters.push_back(Splitter{
            node,
            Rect{r.x, r.y + hA - kSplitterGrab * 0.5, r.w, kSplitterGrab},
            Orient::Rows});
    }
}

void PaneTree::layout(const Rect& area,
                      std::vector<Placed>& leaves,
                      std::vector<Splitter>& splitters) const {
    leaves.clear();
    splitters.clear();
    layoutNode(root_.get(), area, leaves, splitters);
}

void PaneTree::splitLeaf(PaneNode* leaf, Orient orient) {
    if (leaf == nullptr || !leaf->leaf) { return; }

    /* Move the existing content into a new first child; the second is empty. */
    std::unique_ptr<PaneNode> first(new PaneNode());
    first->signals     = std::move(leaf->signals);
    first->profilePane = leaf->profilePane;

    std::unique_ptr<PaneNode> second(new PaneNode());

    leaf->leaf   = false;
    leaf->orient = orient;
    leaf->ratio  = 0.5;
    leaf->signals.clear();
    leaf->a = std::move(first);
    leaf->b = std::move(second);
}

PaneNode* PaneTree::findParent(PaneNode* node, const PaneNode* child) {
    if (node == nullptr || node->leaf) { return nullptr; }
    if (node->a.get() == child || node->b.get() == child) { return node; }
    if (PaneNode* p = findParent(node->a.get(), child)) { return p; }
    return findParent(node->b.get(), child);
}

void PaneTree::closeLeaf(PaneNode* leaf) {
    if (leaf == nullptr || !leaf->leaf) { return; }

    PaneNode* parent = findParent(root_.get(), leaf);
    if (parent == nullptr) {
        return;   /* The root is the only leaf; a scope with no pane is useless. */
    }

    std::unique_ptr<PaneNode> survivor =
        (parent->a.get() == leaf) ? std::move(parent->b) : std::move(parent->a);

    /* Collapse the parent into the survivor in place, so the parent pointer
     * held by any caller stays valid. */
    parent->leaf        = survivor->leaf;
    parent->signals     = std::move(survivor->signals);
    parent->profilePane = survivor->profilePane;
    parent->orient      = survivor->orient;
    parent->ratio       = survivor->ratio;
    parent->a           = std::move(survivor->a);
    parent->b           = std::move(survivor->b);
}

void PaneTree::setRatio(PaneNode* split, double ratio) {
    if (split != nullptr && !split->leaf) {
        split->ratio = std::min(std::max(ratio, 0.0), 1.0);
    }
}

size_t PaneTree::countLeaves(const PaneNode* node) {
    if (node == nullptr) { return 0; }
    if (node->leaf) { return 1; }
    return countLeaves(node->a.get()) + countLeaves(node->b.get());
}

size_t PaneTree::leafCount() const { return countLeaves(root_.get()); }

const PaneTree::Splitter* PaneTree::hitTestSplitter(
        const std::vector<Splitter>& splitters, double px, double py) const {
    for (const Splitter& s : splitters) {
        if (s.rect.contains(px, py)) { return &s; }
    }
    return nullptr;
}

Handle PaneTree::hitTestHandle(const Rect& pane, double px, double py) {
    if (!pane.contains(px, py)) { return Handle::None; }

    const double relX = px - pane.x;
    const double relY = py - pane.y;
    const double midY = pane.h * 0.5;
    const double midX = pane.w * 0.5;
    const double half = kHandleSize * 0.5;

    /* Close sits in the top-right corner and wins over the edge handles. */
    if (relX >= pane.w - kHandleSize && relY <= kHandleSize) {
        return Handle::Close;
    }
    if (relX <= kHandleSize && std::abs(relY - midY) <= half * 3.0) {
        return Handle::Left;
    }
    if (relX >= pane.w - kHandleSize && std::abs(relY - midY) <= half * 3.0) {
        return Handle::Right;
    }
    if (relY <= kHandleSize && std::abs(relX - midX) <= half * 3.0) {
        return Handle::Top;
    }
    if (relY >= pane.h - kHandleSize && std::abs(relX - midX) <= half * 3.0) {
        return Handle::Bottom;
    }
    return Handle::None;
}

} /* namespace udpscope */

Add #include <cmath> at the top of PaneTree.cpp for std::abs on doubles.

  • Step 5: Register the source with CMake

In Client/udpscope/CMakeLists.txt, change CORE_SOURCES to:

set(CORE_SOURCES
    Decimate.cpp
    PaneTree.cpp
)
  • Step 6: Run the tests and verify they pass
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='PaneTree*'

Expected: PASS, 12 tests.

  • Step 7: Commit
git add Client/udpscope/PaneTree.h Client/udpscope/PaneTree.cpp \
        Client/udpscope/tests/PaneTreeTest.cpp Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): BSP pane tree with split, close and hit-testing"

Task 3: Time base

Maps a producer clock onto wall clock. Two pieces: a one-off offset with drift-triggered recalibration, and a least-squares fit that recovers the hrt tick rate without assuming the client runs on the producer's host.

Files:

  • Create: Client/udpscope/TimeBase.h
  • Create: Client/udpscope/TimeBase.cpp
  • Create: Client/udpscope/tests/TimeBaseTest.cpp
  • Modify: Client/udpscope/CMakeLists.txt (add TimeBase.cpp)

Interfaces:

  • Consumes: nothing.

  • Produces:

    • class ClockOffsetdouble map(double producerSec, double wallSec), bool valid() const, void reset(), static constexpr double kRecalibThresholdS = 0.5
    • class HrtRateFitvoid add(uint64_t hrt, double wallSec), bool ready() const, double ticksPerSecond() const, double toSeconds(uint64_t hrt) const, void reset(), static constexpr size_t kMinSamples = 32
    • double TimeSignalScale(uint8_t typeCode)
  • Step 1: Write the failing test

Create Client/udpscope/tests/TimeBaseTest.cpp:

#include "TimeBase.h"

#include <gtest/gtest.h>

using namespace udpscope;

TEST(ClockOffset, MapsTheFirstReadingOntoWallClockExactly) {
    ClockOffset off;
    EXPECT_FALSE(off.valid());

    const double wall = 1756291200.5;
    EXPECT_DOUBLE_EQ(off.map(10.0, wall), wall);
    EXPECT_TRUE(off.valid());
}

// Network delay jitters the arrival time. If the offset chased every packet
// the whole trace would wobble, so it is latched and only corrected on real
// drift.
TEST(ClockOffset, HoldsTheOffsetThroughSmallArrivalJitter) {
    ClockOffset off;
    off.map(10.0, 1000.0);                      // offset = 990

    // Arrival wanders either side of the prediction. wallSec is a local receive
    // timestamp, so it only ever advances — jitter shows up as the gap growing
    // and shrinking, never as the clock going backwards.
    EXPECT_DOUBLE_EQ(off.map(11.0, 1001.02), 1001.0);   // +0.02 late
    EXPECT_DOUBLE_EQ(off.map(12.0, 1001.97), 1002.0);   // -0.03 early
}

// The threshold has to be symmetric. A producer whose clock steps FORWARD (an
// NTP correction on the producer's host, say) puts the prediction permanently
// ahead of the wall clock — a one-sided "recalibrate only when wall is ahead"
// test never fires for it, and the trace sits in the future for the rest of the
// run.
TEST(ClockOffset, RecalibratesWhenTheProducerClockJumpsForward) {
    ClockOffset off;
    off.map(10.0, 1000.0);                      // offset = 990

    // Producer leaps 100 s ahead while only 1 s of wall time passes.
    EXPECT_DOUBLE_EQ(off.map(111.0, 1001.0), 1001.0);
}

TEST(ClockOffset, RecalibratesWhenDriftExceedsTheThreshold) {
    ClockOffset off;
    off.map(10.0, 1000.0);                      // offset = 990

    /* Producer clock jumped (restart, re-phase): 5 s of error is not jitter. */
    const double mapped = off.map(11.0, 1006.0);
    EXPECT_DOUBLE_EQ(mapped, 1006.0);
}

TEST(ClockOffset, ResetForgetsTheCalibration) {
    ClockOffset off;
    off.map(10.0, 1000.0);
    off.reset();
    EXPECT_FALSE(off.valid());
    EXPECT_DOUBLE_EQ(off.map(50.0, 2000.0), 2000.0);
}

// The tick rate of the producer's high-resolution timer is not carried by the
// protocol, and StreamHub's trick of using the local MARTe timer frequency only
// works on the producer's own host. Recover it from the data instead.
TEST(HrtRateFit, RecoversAKnownTickRate) {
    HrtRateFit fit;
    const double ticksPerSec = 2.5e9;

    EXPECT_FALSE(fit.ready());
    for (int i = 0; i < 64; i++) {
        const double wall = 1000.0 + i * 0.01;
        fit.add(static_cast<uint64_t>(wall * ticksPerSec), wall);
    }

    ASSERT_TRUE(fit.ready());
    EXPECT_NEAR(fit.ticksPerSecond(), ticksPerSec, ticksPerSec * 1e-6);
}

TEST(HrtRateFit, IsNotReadyBeforeTheMinimumSampleCount) {
    HrtRateFit fit;
    for (size_t i = 0; i < HrtRateFit::kMinSamples - 1; i++) {
        fit.add(static_cast<uint64_t>(i) * 1000000u, 1000.0 + i * 0.001);
    }
    EXPECT_FALSE(fit.ready());

    fit.add(static_cast<uint64_t>(HrtRateFit::kMinSamples) * 1000000u,
            1000.0 + HrtRateFit::kMinSamples * 0.001);
    EXPECT_TRUE(fit.ready());
}

TEST(HrtRateFit, ToSecondsUsesTheFittedRate) {
    HrtRateFit fit;
    const double ticksPerSec = 1.0e9;
    for (int i = 0; i < 64; i++) {
        const double wall = 500.0 + i * 0.005;
        fit.add(static_cast<uint64_t>(wall * ticksPerSec), wall);
    }
    ASSERT_TRUE(fit.ready());
    EXPECT_NEAR(fit.toSeconds(2000000000ull), 2.0, 1e-4);
}

TEST(HrtRateFit, SurvivesAStalledClock) {
    HrtRateFit fit;
    for (int i = 0; i < 64; i++) {
        fit.add(12345u, 1000.0 + i * 0.01);   /* hrt never advances */
    }
    /* A degenerate fit must not produce a rate that would divide by zero. */
    if (fit.ready()) {
        EXPECT_GT(fit.ticksPerSecond(), 0.0);
    }
}

TEST(TimeSignalScale, UsesNanosecondsForUint64AndMicrosecondsOtherwise) {
    EXPECT_DOUBLE_EQ(TimeSignalScale(6 /* UDPS_T_UINT64 */), 1.0e-9);
    EXPECT_DOUBLE_EQ(TimeSignalScale(9 /* UDPS_T_FLOAT64 */), 1.0e-6);
    EXPECT_DOUBLE_EQ(TimeSignalScale(4 /* UDPS_T_UINT32 */), 1.0e-6);
}
  • Step 2: Run the test to verify it fails
cd Client/udpscope && cmake --build build -j

Expected: FAIL — TimeBase.h: No such file or directory.

  • Step 3: Write the header

Create Client/udpscope/TimeBase.h:

/**
 * @file TimeBase.h
 * @brief Producer-clock to wall-clock reconstruction.
 *
 * Framework-free. A UDPS stream's accurate timestamps come from a producer
 * clock — either a declared time signal or the packet's embedded hrt — and both
 * need mapping onto the client's wall clock before they can be plotted.
 */
#pragma once

#include <cstddef>
#include <cstdint>
#include <deque>

namespace udpscope {

/**
 * @brief Seconds per count of a time signal, from its type code.
 *
 * The protocol carries uint64 time signals in nanoseconds and everything else
 * in microseconds; this mirrors UDPSourceSession so the two agree on a stream.
 */
double TimeSignalScale(uint8_t typeCode);

/**
 * @brief A latched producer-to-wall offset.
 *
 * Established from the first sample and then held, so network jitter does not
 * wobble the trace. Only a drift beyond kRecalibThresholdS — a producer restart
 * or re-phase, not delivery noise — forces a new calibration.
 */
class ClockOffset {
public:
    static constexpr double kRecalibThresholdS = 0.5;

    /** @return producerSec mapped onto wall clock. */
    double map(double producerSec, double wallSec);

    bool valid() const { return valid_; }
    void reset() { valid_ = false; offset_ = 0.0; }
    double offset() const { return offset_; }

private:
    double offset_ = 0.0;
    bool   valid_  = false;
};

/**
 * @brief Recovers the producer's hrt tick rate by least squares against arrival
 *        time.
 *
 * The protocol does not carry the tick rate, and StreamHub's approach of using
 * the local MARTe HighResolutionTimer frequency is only valid when the client
 * runs on the producer's host. A remote bench scope cannot assume that, so the
 * rate is measured: hrt against recv_time is a straight line whose slope is
 * ticks per second.
 */
class HrtRateFit {
public:
    static constexpr size_t kMinSamples = 32;
    static constexpr size_t kWindow     = 256;

    void add(uint64_t hrt, double wallSec);
    bool ready() const { return n_ >= kMinSamples && rate_ > 0.0; }
    double ticksPerSecond() const { return rate_; }
    double toSeconds(uint64_t hrt) const;
    void reset();

private:
    void refit();

    struct Sample { double hrt; double wall; };
    std::deque<Sample> samples_;
    size_t n_    = 0;
    double rate_ = 0.0;
};

} /* namespace udpscope */
  • Step 4: Write the implementation

Create Client/udpscope/TimeBase.cpp:

#include "TimeBase.h"

#include <cmath>

namespace udpscope {

/* UDPS_T_UINT64 == 6 in Common/UDP/UDPSProtocol.h. Spelled numerically so this
 * translation unit stays free of the C client header. */
static constexpr uint8_t kTypeUint64 = 6u;

double TimeSignalScale(uint8_t typeCode) {
    return (typeCode == kTypeUint64) ? 1.0e-9 : 1.0e-6;
}

double ClockOffset::map(double producerSec, double wallSec) {
    if (!valid_ || std::fabs((offset_ + producerSec) - wallSec) > kRecalibThresholdS) {
        offset_ = wallSec - producerSec;
        valid_  = true;
    }
    return offset_ + producerSec;
}

void HrtRateFit::reset() {
    samples_.clear();
    n_    = 0;
    rate_ = 0.0;
}

void HrtRateFit::add(uint64_t hrt, double wallSec) {
    samples_.push_back(Sample{static_cast<double>(hrt), wallSec});
    if (samples_.size() > kWindow) { samples_.pop_front(); }
    n_++;
    if (n_ >= kMinSamples) { refit(); }
}

void HrtRateFit::refit() {
    const size_t n = samples_.size();
    if (n < 2) { return; }

    /* Least squares slope of hrt against wall time. Both are subtracted from
     * their first value first: raw hrt counts and epoch seconds are large
     * enough that the naive sums lose precision. */
    const double h0 = samples_.front().hrt;
    const double w0 = samples_.front().wall;

    double sw = 0.0, sh = 0.0, sww = 0.0, swh = 0.0;
    for (const Sample& s : samples_) {
        const double w = s.wall - w0;
        const double h = s.hrt - h0;
        sw  += w;
        sh  += h;
        sww += w * w;
        swh += w * h;
    }
    const double dn    = static_cast<double>(n);
    const double denom = dn * sww - sw * sw;
    if (std::fabs(denom) < 1e-12) { return; }

    const double slope = (dn * swh - sw * sh) / denom;
    if (slope > 0.0 && std::isfinite(slope)) { rate_ = slope; }
}

double HrtRateFit::toSeconds(uint64_t hrt) const {
    if (rate_ <= 0.0) { return 0.0; }
    return static_cast<double>(hrt) / rate_;
}

} /* namespace udpscope */
  • Step 5: Register the source with CMake
set(CORE_SOURCES
    Decimate.cpp
    PaneTree.cpp
    TimeBase.cpp
)
  • Step 6: Run the tests and verify they pass
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='ClockOffset*:HrtRateFit*:TimeSignalScale*'

Expected: PASS, 9 tests.

  • Step 7: Commit
git add Client/udpscope/TimeBase.h Client/udpscope/TimeBase.cpp \
        Client/udpscope/tests/TimeBaseTest.cpp Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): producer-clock calibration and hrt tick-rate fit"

Task 4: Frame decoder — per-element timestamps

Spec §5, the part the C library deliberately does not do for you. udps_frame_element_time() is an arrival-anchored estimate; relying on it renders bursty delivery as a sawtooth. This reproduces UDPSourceSession.cpp's rules on top of the C API.

Files:

  • Create: Client/udpscope/FrameDecoder.h
  • Create: Client/udpscope/FrameDecoder.cpp
  • Create: Client/udpscope/tests/FrameDecoderTest.cpp
  • Modify: Client/udpscope/Types.h (add SignalMeta, FrameView, protocol constants)
  • Modify: Client/udpscope/CMakeLists.txt (add FrameDecoder.cpp)

Interfaces:

  • Consumes: ClockOffset, HrtRateFit, TimeSignalScale (Task 3).

  • Produces:

    • udpscope::SignalMeta with numElements(), hasTimeSignal(uint32_t), isVectorProfile()
    • udpscope::FrameView — a non-owning mirror of udps_frame_t
    • constexpr uint8_t kTimePacket/kTimeFullArray/kTimeFirstSample/kTimeLastSample
    • constexpr uint32_t kNoTimeSignal
    • class FrameDecoder with setSignals(), beginFrame(), timestamps(), reset()
  • Step 1: Extend the shared types

Append to Client/udpscope/Types.h, inside namespace udpscope:

/* Protocol constants, spelled out rather than included, so the framework-free
 * modules stay independent of udps_client.h. They mirror Common/UDP/UDPSProtocol.h. */
constexpr uint8_t  kTimePacket      = 0;
constexpr uint8_t  kTimeFullArray   = 1;
constexpr uint8_t  kTimeFirstSample = 2;
constexpr uint8_t  kTimeLastSample  = 3;
constexpr uint32_t kNoTimeSignal    = 0xFFFFFFFFu;

/** Framework-free mirror of udps_signal_t, plus UI state. */
struct SignalMeta {
    std::string name;
    uint8_t     typeCode      = 255;
    uint8_t     quantType     = 0;
    uint32_t    numRows       = 1;
    uint32_t    numCols       = 1;
    double      rangeMin      = 0.0;
    double      rangeMax      = 0.0;
    uint8_t     timeMode      = kTimePacket;
    double      samplingRate  = 0.0;
    uint32_t    timeSignalIdx = kNoTimeSignal;
    std::string unit;

    /** User override: treat an ambiguous PACKET array as a profile, not a burst. */
    bool profileOverride = false;

    uint32_t numElements() const {
        const uint64_t n = static_cast<uint64_t>(numRows ? numRows : 1u) *
                           static_cast<uint64_t>(numCols ? numCols : 1u);
        return n == 0u ? 1u : static_cast<uint32_t>(n);
    }

    bool hasTimeSignal(uint32_t numSignals) const {
        return timeSignalIdx != kNoTimeSignal && timeSignalIdx < numSignals;
    }

    /**
     * @brief True when this array should be plotted against element index
     *        rather than unrolled onto the time axis.
     *
     * Only PACKET arrays are ambiguous: the producer stamped the whole datagram
     * with one time, which is what a genuine vector looks like and also what a
     * burst carrying no time metadata looks like. Default is burst, matching
     * UDPSourceSession, with this flag as the user's override.
     */
    bool isVectorProfile() const {
        return profileOverride && numElements() > 1u && timeMode == kTimePacket;
    }
};

/**
 * @brief Non-owning mirror of udps_frame_t.
 *
 * Kept separate from the C struct so FrameDecoder can be tested with plain
 * arrays and no socket. Points at memory owned by the caller.
 */
struct FrameView {
    uint32_t             counter    = 0;
    uint64_t             hrt        = 0;
    double               recvTime   = 0.0;
    uint32_t             numSamples = 1;
    uint32_t             numSignals = 0;
    const double* const* values     = nullptr;  /**< values[i][0..counts[i]) */
    const uint32_t*      counts     = nullptr;
};
  • Step 2: Write the failing test

Create Client/udpscope/tests/FrameDecoderTest.cpp:

#include "FrameDecoder.h"

#include <gtest/gtest.h>

#include <cmath>
#include <limits>
#include <vector>

using namespace udpscope;

namespace {

/** Builds a FrameView over vectors the test owns. */
struct FrameBuilder {
    std::vector<std::vector<double>> storage;
    std::vector<const double*>       ptrs;
    std::vector<uint32_t>            counts;
    FrameView                        view;

    void addSignal(std::vector<double> vals) {
        storage.push_back(std::move(vals));
    }

    /* Real frames carry a per-update counter; leaving it at zero would hide
     * whichever rules depend on it, so it must be passed explicitly. */
    const FrameView& build(uint64_t hrt, double recvTime, uint32_t numSamples = 1,
                           uint32_t counter = 0) {
        ptrs.clear();
        counts.clear();
        for (const auto& s : storage) {
            ptrs.push_back(s.data());
            counts.push_back(static_cast<uint32_t>(s.size()));
        }
        view.counter    = counter;
        view.hrt        = hrt;
        view.recvTime   = recvTime;
        view.numSamples = numSamples;
        view.numSignals = static_cast<uint32_t>(storage.size());
        view.values     = ptrs.data();
        view.counts     = counts.data();
        return view;
    }
};

SignalMeta burst(const char* name, uint8_t timeMode, double rate,
                 uint32_t elems, uint32_t timeIdx) {
    SignalMeta m;
    m.name          = name;
    m.typeCode      = 8;              /* float32 */
    m.numRows       = elems;
    m.numCols       = 1;
    m.timeMode      = timeMode;
    m.samplingRate  = rate;
    m.timeSignalIdx = timeIdx;
    return m;
}

SignalMeta timeSignal(const char* name, uint32_t elems) {
    SignalMeta m;
    m.name     = name;
    m.typeCode = 6;                   /* uint64 -> nanoseconds */
    m.numRows  = elems;
    m.numCols  = 1;
    return m;
}

} /* namespace */

TEST(FrameDecoder, FullArrayTakesOneStampPerElementFromTheTimeSignal) {
    FrameDecoder dec;
    dec.setSignals({burst("Sine", kTimeFullArray, 1000.0, 4, 1),
                    timeSignal("Time", 4)});

    FrameBuilder fb;
    fb.addSignal({1.0, 2.0, 3.0, 4.0});
    /* Nanoseconds: 5.000, 5.001, 5.002, 5.003 s of producer time. */
    fb.addSignal({5.0e9, 5.001e9, 5.002e9, 5.003e9});
    const FrameView& f = fb.build(0, 1000.0);

    dec.beginFrame(f);
    std::vector<double> ts;
    ASSERT_TRUE(dec.timestamps(f, 0, ts));
    ASSERT_EQ(ts.size(), 4u);

    /* Element 0 lands on the arrival time; the rest keep the producer spacing. */
    EXPECT_NEAR(ts[0], 1000.000, 1e-9);
    EXPECT_NEAR(ts[1], 1000.001, 1e-9);
    EXPECT_NEAR(ts[2], 1000.002, 1e-9);
    EXPECT_NEAR(ts[3], 1000.003, 1e-9);
}

// A host joined on two interfaces receives every unfragmented update twice, and
// the second copy is a different signal's problem only if the guard can see it.
// It is keyed on a counter each rule leaves behind, so an array rule that never
// records one is silently exempt — and would plot every array twice, at two
// arrival times, doubling back on the X axis. Rules 1 and 2 join rule 3's
// counter-keeping for this reason alone; neither reads the value back.
TEST(FrameDecoder, ArrayRulesDropADuplicatedDatagram) {
    for (uint8_t mode : {kTimeFullArray, kTimeFirstSample}) {
        FrameDecoder dec;
        dec.setSignals({burst("Sine", mode, 1000.0, 4, 1),
                        timeSignal("Time", mode == kTimeFullArray ? 4u : 1u)});

        FrameBuilder fb;
        fb.addSignal({1.0, 2.0, 3.0, 4.0});
        if (mode == kTimeFullArray) {
            fb.addSignal({5.0e9, 5.001e9, 5.002e9, 5.003e9});
        } else {
            fb.addSignal({5.0e9});
        }

        const FrameView& first = fb.build(0, 1000.0, 4, 77u);
        dec.beginFrame(first);
        std::vector<double> ts;
        ASSERT_TRUE(dec.timestamps(first, 0, ts)) << "mode " << int(mode);

        /* Same counter, same payload, a fraction of a millisecond later off the
         * second interface. */
        const FrameView& dup = fb.build(0, 1000.0004, 4, 77u);
        dec.beginFrame(dup);
        EXPECT_FALSE(dec.timestamps(dup, 0, ts))
            << "mode " << int(mode) << " emitted the duplicate array twice";
    }
}

TEST(FrameDecoder, FirstSampleAnchorsElementZeroAndCountsForward) {
    FrameDecoder dec;
    dec.setSignals({burst("Sine", kTimeFirstSample, 1000.0, 4, 1),
                    timeSignal("Time", 1)});

    FrameBuilder fb;
    fb.addSignal({1.0, 2.0, 3.0, 4.0});
    fb.addSignal({7.0e9});
    const FrameView& f = fb.build(0, 2000.0);

    dec.beginFrame(f);
    std::vector<double> ts;
    ASSERT_TRUE(dec.timestamps(f, 0, ts));
    ASSERT_EQ(ts.size(), 4u);
    EXPECT_NEAR(ts[0], 2000.000, 1e-9);
    EXPECT_NEAR(ts[3], 2000.003, 1e-9);
}

// With no declared rate there is nothing to spread the array by, and
// UDPSourceSession.cpp:522 leaves the step at zero — every element of the array
// on one instant. A host-local consumer only stores them; this scope's ring,
// decimator and trigger all require increasing stamps, and N points at one X is
// not a trace. Consecutive time-signal anchors carry the burst duration on the
// PRODUCER'S clock, so the spread is recoverable without a rate.
TEST(FrameDecoder, FirstSampleWithNoRateSpreadsFromConsecutiveAnchors) {
    FrameDecoder dec;
    dec.setSignals({burst("Sine", kTimeFirstSample, 0.0, 4, 1),
                    timeSignal("Time", 1)});

    /* 4 samples per packet, anchors 4 ms apart: a 1 ms period. Arrivals are
     * jittered so a spread accidentally taken from arrival would be visible. */
    const double jitter[4] = {0.0, 0.0021, -0.0017, 0.0};
    std::vector<double> ts;
    for (int p = 0; p < 5; p++) {
        FrameBuilder fb;
        fb.addSignal({1.0, 2.0, 3.0, 4.0});
        fb.addSignal({7.0e9 + p * 4.0e6});          /* ns, +4 ms per packet */
        const FrameView& f = fb.build(0, 2000.0 + p * 0.004 + jitter[p % 4], 4,
                                      static_cast<uint32_t>(p + 1));
        dec.beginFrame(f);
        ASSERT_TRUE(dec.timestamps(f, 0, ts));
        ASSERT_EQ(ts.size(), 4u);
        for (size_t i = 1; i < ts.size(); i++) {
            /* The first packet has no predecessor to measure against and legally
             * stacks; from the second on the array must be spread. */
            if (p > 0) { ASSERT_GT(ts[i], ts[i - 1]) << "packet " << p; }
        }
        if (p > 0) { EXPECT_NEAR(ts[1] - ts[0], 0.001, 1e-9) << "packet " << p; }
    }

    /* A lost datagram doubles the anchor difference; without reading the counter
     * the recovery packet would be spread twice as wide. */
    FrameBuilder fb;
    fb.addSignal({1.0, 2.0, 3.0, 4.0});
    fb.addSignal({7.0e9 + 5 * 4.0e6 + 4.0e6});      /* packet 6 arrives, 5 lost */
    const FrameView& f = fb.build(0, 2000.024, 4, 7u);
    dec.beginFrame(f);
    ASSERT_TRUE(dec.timestamps(f, 0, ts));
    EXPECT_NEAR(ts[1] - ts[0], 0.001, 1e-9) << "loss stretched the array";
}

TEST(FrameDecoder, LastSampleAnchorsTheFinalElementAndCountsBackward) {
    FrameDecoder dec;
    dec.setSignals({burst("Sine", kTimeLastSample, 1000.0, 4, 1),
                    timeSignal("Time", 1)});

    FrameBuilder fb;
    fb.addSignal({1.0, 2.0, 3.0, 4.0});
    fb.addSignal({7.0e9});
    const FrameView& f = fb.build(0, 3000.0);

    dec.beginFrame(f);
    std::vector<double> ts;
    ASSERT_TRUE(dec.timestamps(f, 0, ts));
    ASSERT_EQ(ts.size(), 4u);
    EXPECT_NEAR(ts[3], 3000.000, 1e-9);
    EXPECT_NEAR(ts[0], 3000.000 - 0.003, 1e-9);
}

TEST(FrameDecoder, PlainScalarUsesArrivalTime) {
    FrameDecoder dec;
    SignalMeta m;
    m.name = "Level";
    m.typeCode = 9;
    dec.setSignals({m});

    FrameBuilder fb;
    fb.addSignal({42.0});
    const FrameView& f = fb.build(0, 1234.5);

    dec.beginFrame(f);
    std::vector<double> ts;
    ASSERT_TRUE(dec.timestamps(f, 0, ts));
    ASSERT_EQ(ts.size(), 1u);
    EXPECT_DOUBLE_EQ(ts[0], 1234.5);
}

// This is the failure UDPSourceSession.cpp:560 documents. The kernel delivers
// two queued datagrams microseconds apart even though each carries 10 ms of
// signal. Dating from arrival crams the second packet's samples into that gap
// and the trace becomes a sawtooth; dating from the producer hrt does not.
TEST(FrameDecoder, AccumulatedScalarSurvivesBurstyDelivery) {
    FrameDecoder dec;
    SignalMeta m;
    m.name         = "Acc";
    m.typeCode     = 9;
    m.numRows      = 1;
    m.samplingRate = 1000.0;          /* 1 kHz, 10 samples = 10 ms per packet */
    dec.setSignals({m});

    const double ticks = 1.0e9;
    std::vector<double> all;

    for (int p = 0; p < 40; p++) {
        FrameBuilder fb;
        fb.addSignal(std::vector<double>(10, static_cast<double>(p)));
        const double producerSec = 100.0 + p * 0.010;
        /* Packets 20+ arrive in a burst, all within 50 us of each other. */
        const double arrival = (p < 20) ? (500.0 + p * 0.010)
                                        : (500.2 + (p - 20) * 0.00005);
        const FrameView& f = fb.build(static_cast<uint64_t>(producerSec * ticks),
                                      arrival, 10, static_cast<uint32_t>(p + 1));
        dec.beginFrame(f);
        std::vector<double> ts;
        if (dec.timestamps(f, 0, ts)) {
            all.insert(all.end(), ts.begin(), ts.end());
        }
    }

    ASSERT_GT(all.size(), 300u);
    for (size_t i = 1; i < all.size(); i++) {
        EXPECT_GT(all[i], all[i - 1]) << "non-monotonic at " << i;
        EXPECT_NEAR(all[i] - all[i - 1], 0.001, 2e-4)
            << "spacing collapsed at " << i << " (sawtooth)";
    }
}

namespace {

/** Ten contiguous 10-sample bursts at 1 kHz, counters 1..10, ending at 500.090. */
SignalMeta accSignal() {
    SignalMeta m;
    m.name         = "Acc";
    m.typeCode     = 9;
    m.numRows      = 1;
    m.samplingRate = 1000.0;          /* 10 samples = 10 ms per packet */
    return m;
}

void primeTenBursts(FrameDecoder& dec, std::vector<double>& ts, bool withCounter) {
    for (int p = 0; p < 10; p++) {
        FrameBuilder fb;
        fb.addSignal(std::vector<double>(10, 1.0));
        const FrameView& f = fb.build(0, 500.0 + p * 0.010, 10,
                                      withCounter ? static_cast<uint32_t>(p + 1) : 0u);
        dec.beginFrame(f);
        ASSERT_TRUE(dec.timestamps(f, 0, ts));
    }
    ASSERT_NEAR(ts[9], 500.090, 1e-9);
}

} /* namespace */

// The counterweight to the test above. Chaining bursts to suppress arrival
// jitter is only safe if loss is accounted for: a bare chain closes the hole a
// dropped datagram left and dates every later sample early for the rest of the
// run. The wire says exactly how much is missing, so no estimate is needed —
// and this test deliberately makes arrival time a LIAR (200 ms off) to prove
// the reconstruction comes from the counter and not from when the packet landed.
TEST(FrameDecoder, AccumulatedScalarReinstatesLostPacketsFromTheCounterGap) {
    FrameDecoder dec;
    dec.setSignals({accSignal()});
    std::vector<double> ts;
    primeTenBursts(dec, ts, /*withCounter=*/true);

    /* Counter 111 after 10: 100 packets lost, 1000 samples, exactly 1 s. The
     * packet lands 200 ms later than that truth would predict. */
    FrameBuilder fb;
    fb.addSignal(std::vector<double>(10, 1.0));
    const FrameView& f = fb.build(0, 501.300, 10, 111u);
    dec.beginFrame(f);
    ASSERT_TRUE(dec.timestamps(f, 0, ts));

    /* Chaining blindly gives 500.091; anchoring on arrival gives 501.291. */
    EXPECT_NEAR(ts[0], 501.091, 1e-9);
    EXPECT_NEAR(ts[9], 501.100, 1e-9);
}

// A producer that never advances the counter, or restarts it, leaves nothing to
// reconstruct from. Arrival time is then the better of two bad answers, and the
// chain has to be abandoned rather than left to drift forever.
TEST(FrameDecoder, AccumulatedScalarResyncsOnArrivalWhenTheCounterSaysNothing) {
    FrameDecoder dec;
    dec.setSignals({accSignal()});
    std::vector<double> ts;
    primeTenBursts(dec, ts, /*withCounter=*/false);

    FrameBuilder fb;
    fb.addSignal(std::vector<double>(10, 1.0));
    const FrameView& f = fb.build(0, 501.100, 10, 0u);
    dec.beginFrame(f);
    ASSERT_TRUE(dec.timestamps(f, 0, ts));

    EXPECT_NEAR(ts[0], 501.091, 1e-9);
    EXPECT_NEAR(ts[9], 501.100, 1e-9);
}

// Re-anchoring must never move a signal's timestamps backwards: the ring, the
// trigger and the exporter all assume they increase, and a backward step is
// indistinguishable from corruption downstream. Here the counter claims a
// 20 s hole while the packet arrives 10 ms after the last one, so the
// prediction and arrival disagree wildly and arrival points into the past.
TEST(FrameDecoder, AccumulatedScalarNeverStepsBackwardsWhenResyncing) {
    FrameDecoder dec;
    dec.setSignals({accSignal()});
    std::vector<double> ts;
    primeTenBursts(dec, ts, /*withCounter=*/true);
    const double prevEnd = ts[9];

    FrameBuilder fb;
    fb.addSignal(std::vector<double>(10, 1.0));
    const FrameView& f = fb.build(0, 500.000, 10, 2010u);
    dec.beginFrame(f);
    ASSERT_TRUE(dec.timestamps(f, 0, ts));

    /* Arrival (500.000) is behind our timeline, so there is nothing to spread
     * into; the burst is squeezed instead, which bleeds the lead off while still
     * moving strictly forwards. The excess (90 ms) is nine nominal burst widths,
     * so the squeeze hits its floor of 0.05 and the step is 50 us. */
    EXPECT_NEAR(ts[0], 500.09005, 1e-9);
    EXPECT_GT(ts[0], prevEnd) << "resync stepped backwards over the previous burst";
    for (size_t i = 1; i < ts.size(); i++) {
        EXPECT_GT(ts[i], ts[i - 1]);
    }
}

// When the chain has to be abandoned but arrival lies just ahead of where the
// last burst ended, the correction is made by COMPRESSING this one burst rather
// than by stepping back. Rejecting the correction instead would be one-directional
// — `predicted` is never below lastEmittedEnd + dt — and a timeline running fast
// could then never be pulled back.
TEST(FrameDecoder, AccumulatedScalarCompressesOneBurstRatherThanStepBack) {
    FrameDecoder dec;
    dec.setSignals({accSignal()});
    std::vector<double> ts;
    primeTenBursts(dec, ts, /*withCounter=*/true);

    /* The compress branch needs the prediction to be rejected while arrival
     * still sits between the previous burst's end and one burst beyond it —
     * which a plain rate mismatch cannot produce, since the prediction is then
     * only a burst away from arrival. It takes a fabricated loss: this gap
     * claims 900000 lost packets, putting the prediction 2.5 hours out, while
     * the packet itself lands 5 ms after the last burst ended so its arrival
     * anchor (500.086) falls just behind that end. */
    FrameBuilder fb;
    fb.addSignal(std::vector<double>(10, 1.0));
    const FrameView& f = fb.build(0, 500.095, 10, 900011u);
    dec.beginFrame(f);
    ASSERT_TRUE(dec.timestamps(f, 0, ts));

    EXPECT_GT(ts[0], 500.090) << "compressed burst must still start after the last one";
    EXPECT_NEAR(ts[9], 500.095, 1e-9) << "and end exactly on arrival";
    EXPECT_NEAR(ts[1] - ts[0], 0.0005, 1e-9) << "spread over the available room";
}

// The whole point of compressing: a declared SamplingRate is a hand-written
// config value, and even a correct one is measured against the producer host's
// crystal, not ours. Tens of ppm of difference is certain over a long session,
// so the reconstructed timeline WILL run away from the wall clock. It has to be
// pulled back, and it has to stay monotonic while that happens.
TEST(FrameDecoder, AccumulatedScalarDoesNotDriftAwayFromTheWallClockForever) {
    FrameDecoder dec;
    dec.setSignals({accSignal()});   /* declares 1 kHz */

    /* The producer really runs 1 % fast: 10 samples take 9.9 ms of wall time,
     * so a chain stepping the declared 10 ms per packet gains 0.1 ms every
     * packet. This is the direction re-anchoring alone cannot fix: arrival is
     * always BEHIND the chain, so anchoring on it would step backwards and is
     * refused. Only compression pulls the timeline back. */
    double worstLead = 0.0;
    double lastEnd   = 0.0;
    for (int p = 0; p < 20000; p++) {
        FrameBuilder fb;
        fb.addSignal(std::vector<double>(10, 1.0));
        const double arrival = 500.0 + p * 0.0099;
        const FrameView& f =
            fb.build(0, arrival, 10, static_cast<uint32_t>(p + 1));
        dec.beginFrame(f);
        std::vector<double> ts;
        ASSERT_TRUE(dec.timestamps(f, 0, ts));

        for (size_t i = 0; i < ts.size(); i++) {
            ASSERT_GT(ts[i], lastEnd) << "timeline went backwards at packet " << p;
            lastEnd = ts[i];
        }
        worstLead = std::max(worstLead, ts[9] - arrival);
    }

    /* Unchecked, 20000 packets at 0.1 ms each would put the trace 2 s ahead. */
    EXPECT_LT(worstLead, 0.6) << "timeline drifted " << worstLead << " s ahead";
}

// The test above only exercises a rate that is wrong by ppm, where the squeeze's
// proportional term does all the work. A rate wrong by a FACTOR is the case the
// kMinBleedFactor floor cannot handle on its own: at the floor the timeline
// still advances kMinBleedFactor * nominal per packet, so whenever the nominal
// burst is wider than 1/kMinBleedFactor packet intervals the lead grows without
// bound rather than bleeding off (measured: 27 s of lead after 40 s of stream,
// 667 s after 1000 s). Only capping the advance against the wall time really
// elapsed since this signal's previous burst converges for every declared rate.
TEST(FrameDecoder, AccumulatedScalarConvergesWhenTheDeclaredRateIsFarTooLow) {
    FrameDecoder dec;
    SignalMeta m  = accSignal();
    m.samplingRate = 30.0;      /* config says 30 Hz... */
    dec.setSignals({m});

    /* ...while the producer really flushes 10 samples at 1 kHz, so a packet is
     * 10 ms of wall time and 333 ms of nominal, declared time. */
    double worstLead = 0.0;
    double last      = 0.0;
    for (int p = 0; p < 5000; p++) {          /* 50 s of stream */
        FrameBuilder fb;
        fb.addSignal(std::vector<double>(10, 1.0));
        const double     arrival = 500.0 + p * 0.010;
        const FrameView& f =
            fb.build(0, arrival, 10, static_cast<uint32_t>(p + 1));
        dec.beginFrame(f);
        std::vector<double> ts;
        ASSERT_TRUE(dec.timestamps(f, 0, ts));
        for (size_t i = 0; i < ts.size(); i++) {
            ASSERT_GT(ts[i], last) << "timeline went backwards at packet " << p;
            last = ts[i];
        }
        worstLead = std::max(worstLead, ts[9] - arrival);
    }

    /* Bounded, not zero: normal chaining resumes the moment the squeeze stops,
     * so the lead sawtooths up to about kBurstResyncThresholdS and back. */
    EXPECT_LT(worstLead, 1.0) << "timeline ran " << worstLead << " s ahead";
}

// samplingRate is unvalidated wire data. A malformed +inf makes the declared
// period zero, so a burst's nominal width is zero and the proportional squeeze
// evaluates 0.0/0.0 — and a NaN factor slips past the floor, because every
// comparison against NaN is false. The burst, and then every burst after it,
// comes out NaN. Treating a non-finite rate as no rate at all removes the class.
TEST(FrameDecoder, AccumulatedScalarWithANonFiniteRateFallsBackToTheHrtPath) {
    FrameDecoder dec;
    SignalMeta m   = accSignal();
    m.samplingRate = std::numeric_limits<double>::infinity();
    dec.setSignals({m});

    const double ticks = 1.0e9;
    std::vector<double> last;
    for (int p = 0; p < 60; p++) {
        FrameBuilder fb;
        fb.addSignal(std::vector<double>(10, 1.0));
        const double producerSec = 100.0 + p * 0.025;
        /* Packet 40 lands at exactly the same instant as packet 39. With the
         * degenerate zero period the previous burst ends precisely on its own
         * arrival, so this makes the squeeze's excess exactly zero — the 0.0/0.0
         * that produces the NaN. */
        const int q = (p == 40) ? 39 : p;
        /* Zero-mean jitter so the answer also identifies WHICH branch replied:
         * a degenerate declared branch spans the jittered arrival gap, the hrt
         * branch returns the producer's exact 2.5 ms whatever delivery did. */
        const double jitter[4] = {0.0, 0.003, 0.0, -0.003};
        const double arrival   = 700.0 + q * 0.025 + jitter[q % 4];
        const FrameView& f =
            fb.build(static_cast<uint64_t>(producerSec * ticks), arrival, 10,
                     static_cast<uint32_t>(p + 1));
        dec.beginFrame(f);
        std::vector<double> ts;
        if (dec.timestamps(f, 0, ts)) {
            for (size_t i = 0; i < ts.size(); i++) {
                ASSERT_TRUE(std::isfinite(ts[i]))
                    << "packet " << p << " element " << i;
            }
            last = ts;
        }
    }

    ASSERT_EQ(last.size(), 10u);
    /* The tolerance is bounded from both sides and neither bound is arbitrary.
     * Below: hrtDt divides a tick delta by HrtRateFit's fitted rate, and the fit
     * regresses hrt against arrivals carrying the +/-3 ms jitter above, so ~2 us
     * of residual is inherent — 1e-8 fails. Above: the degenerate declared branch
     * would span those same jittered gaps and answer 2.2 or 2.8 ms, 300 us out.
     * 1e-5 sits two orders below the thing it must reject and five times above
     * the noise it must tolerate. */
    EXPECT_NEAR(last[1] - last[0], 0.0025, 1e-5)
        << "an unusable declared rate must fall through to the hrt path";
}

// The C client de-duplicates fragments but not whole unfragmented updates, so a
// host subscribed on two interfaces sees each datagram twice. Emitting the
// repeat would double the values and advance time by a burst that never was.
TEST(FrameDecoder, AccumulatedScalarDropsADuplicatedDatagram) {
    FrameDecoder dec;
    dec.setSignals({accSignal()});
    std::vector<double> ts;
    primeTenBursts(dec, ts, /*withCounter=*/true);
    const double endBefore = ts[9];

    FrameBuilder fb;
    fb.addSignal(std::vector<double>(10, 1.0));
    const FrameView& dup = fb.build(0, 500.1001, 10, 10u);   /* counter 10 again */
    dec.beginFrame(dup);
    EXPECT_FALSE(dec.timestamps(dup, 0, ts));

    /* And the drop must not have disturbed the chain: the genuine next packet
     * still lands one period after burst 10 ended. */
    const FrameView& next = fb.build(0, 500.109, 10, 11u);
    dec.beginFrame(next);
    ASSERT_TRUE(dec.timestamps(next, 0, ts));
    EXPECT_NEAR(ts[0], endBefore + 0.001, 1e-6);
}

// A producer restart returns the counter to zero mid-stream. The unsigned gap
// then wraps to near 2^32; the loss it implies puts the chained prediction
// centuries out, the arrival backstop rejects it, and arrival becomes the only
// usable reference.
TEST(FrameDecoder, AccumulatedScalarSurvivesAProducerRestart) {
    FrameDecoder dec;
    dec.setSignals({accSignal()});
    std::vector<double> ts;
    primeTenBursts(dec, ts, /*withCounter=*/true);
    const double prevEnd = ts[9];

    /* Restarted producer: counter 1 again, and the outage lasted 3 s. */
    FrameBuilder fb;
    fb.addSignal(std::vector<double>(10, 1.0));
    const FrameView& f = fb.build(0, 503.100, 10, 1u);
    dec.beginFrame(f);
    ASSERT_TRUE(dec.timestamps(f, 0, ts));

    /* Reading the wrapped gap as a loss count would claim ~4.3e9 lost packets,
     * some 5e8 seconds of fabricated signal. */
    EXPECT_NEAR(ts[9], 503.100, 1e-9) << "restart must re-anchor on arrival";
    EXPECT_GT(ts[0], prevEnd);
}

// Accumulate mode flushes on a timer, so a short cycle legitimately delivers a
// single sample between two full bursts. That packet must stay on the chain: if
// it fell through to the plain-scalar rule it would be dated from arrival while
// its neighbours are chained, and would leave lastCounter behind so the next
// real burst read the skip as a lost datagram.
TEST(FrameDecoder, AccumulatedScalarKeepsShortFlushesOnTheChain) {
    FrameDecoder dec;
    dec.setSignals({accSignal()});
    std::vector<double> ts;
    primeTenBursts(dec, ts, /*withCounter=*/true);

    double   last    = ts[9];
    uint32_t counter = 10u;
    double   arrival = 500.090;
    for (int p = 0; p < 500; p++) {
        /* Alternating 10-sample and 1-sample flushes, 10 ms and 1 ms of signal. */
        const uint32_t n = (p % 2 == 0) ? 1u : 10u;
        arrival += 0.001 * static_cast<double>(n);
        FrameBuilder fb;
        fb.addSignal(std::vector<double>(n, 1.0));
        const FrameView& f = fb.build(0, arrival, n, ++counter);
        dec.beginFrame(f);
        ASSERT_TRUE(dec.timestamps(f, 0, ts)) << "short flush dropped at " << p;
        ASSERT_EQ(ts.size(), n);
        for (size_t i = 0; i < ts.size(); i++) {
            ASSERT_GT(ts[i], last) << "timeline went backwards at packet " << p;
            /* Contiguous: no phantom loss was ever reinstated. */
            ASSERT_NEAR(ts[i] - last, 0.001, 1e-6) << "gap opened at packet " << p;
            last = ts[i];
        }
    }
}

TEST(FrameDecoder, AccumulatedScalarDerivesDtFromTheHrtGapWhenNoRateIsDeclared) {
    FrameDecoder dec;
    SignalMeta m;
    m.name         = "Acc";
    m.typeCode     = 9;
    m.samplingRate = 0.0;             /* undeclared */
    dec.setSignals({m});

    const double ticks = 1.0e9;
    std::vector<double> last;
    for (int p = 0; p < 40; p++) {
        FrameBuilder fb;
        fb.addSignal(std::vector<double>(10, 1.0));
        /* 25 ms per packet, deliberately NOT 10: at 10 the expected 1 ms period
         * equals kDefaultDt, so a decoder that never derived anything and just
         * returned the default would pass a test named for the derivation. */
        const double producerSec = 100.0 + p * 0.025;
        /* Zero-mean arrival jitter, so the rate fit still converges but no
         * single arrival GAP is right. Without it, uniform arrivals make
         * packetBurst and the hrt path return the same number by construction
         * and the test cannot tell which branch answered. */
        const double jitter[4] = {0.0, 0.003, 0.0, -0.003};
        const FrameView& f = fb.build(static_cast<uint64_t>(producerSec * ticks),
                                      700.0 + p * 0.025 + jitter[p % 4], 10,
                                      static_cast<uint32_t>(p + 1));
        dec.beginFrame(f);
        std::vector<double> ts;
        if (dec.timestamps(f, 0, ts)) { last = ts; }
    }

    ASSERT_EQ(last.size(), 10u);
    /* 25 ms of producer time across 10 samples is a 2.5 ms period, whatever the
     * datagrams did on the way over. Arrival-spanning the last gap (22 ms)
     * would give 2.2 ms; defaulting would give 1 ms. */
    EXPECT_NEAR(last[1] - last[0], 0.0025, 2e-5);
}

// The trap the hrt path fell into once: positioning each burst at
// hrt / ticksPerSecond(). hrt counts from the PRODUCER'S BOOT, so it is already
// ~1e11 ticks for a machine that has been up a day, while the rate is refitted
// on every packet and wobbles by parts in 1e4 as arrival jitter enters and
// leaves the window. The wobble arrives multiplied by that whole epoch — tens of
// milliseconds, in both directions — so bursts land out of order. The producer
// clock here is EXACT; every timestamp inversion this test can see comes from
// the client's own arithmetic.
TEST(FrameDecoder, AccumulatedScalarStaysMonotonicOnALongUndeclaredRunAfterBoot) {
    FrameDecoder dec;
    SignalMeta m;
    m.name         = "Acc";
    m.typeCode     = 9;
    m.samplingRate = 0.0;             /* undeclared: the hrt path */
    dec.setSignals({m});

    const double   ticks    = 1.0e9;
    const uint64_t bootHrt  = static_cast<uint64_t>(86400.0 * ticks);  /* up 1 day */
    double         last     = 0.0;
    uint32_t       seed     = 12345u;
    for (int p = 0; p < 20000; p++) {   /* 500 s of stream */
        FrameBuilder fb;
        fb.addSignal(std::vector<double>(10, 1.0));
        /* Exact producer clock: 25 ms per packet, 2.5 ms per sample. */
        const uint64_t hrt = bootHrt + static_cast<uint64_t>(p * 0.025 * ticks);
        /* Ordinary scheduling jitter, +/- 1 ms, zero mean. */
        seed = seed * 1103515245u + 12345u;
        const double jitter = (static_cast<double>((seed >> 16) & 0xFFFFu) /
                               65535.0 - 0.5) * 0.002;
        const FrameView& f = fb.build(hrt, 700.0 + p * 0.025 + jitter, 10,
                                      static_cast<uint32_t>(p + 1));
        dec.beginFrame(f);
        std::vector<double> ts;
        if (!dec.timestamps(f, 0, ts)) { continue; }
        for (size_t i = 0; i < ts.size(); i++) {
            ASSERT_GT(ts[i], last) << "timeline went backwards at packet " << p;
            /* Ordering alone is too weak to pin this down: clamping a wrong
             * absolute position to "just after the last one" restores the
             * ordering while leaving the positions wrong, and every forward
             * lurch is still accepted. The producer clock is exact, so the
             * spacing must be exact too. */
            if (p > 100) {   /* past the fit warm-up and its packetBurst fallback */
                ASSERT_NEAR(ts[i] - last, 0.0025, 1e-5)
                    << "sample spacing wrong at packet " << p;
            }
            last = ts[i];
        }
    }
}

namespace {

/** One delivered datagram of an undeclared-rate accumulated scalar. */
struct HrtPacket {
    uint64_t hrt;
    double   arrival;
    uint32_t counter;
};

SignalMeta undeclaredAcc() {
    SignalMeta m;
    m.name         = "Acc";
    m.typeCode     = 9;
    m.numRows      = 1;
    m.samplingRate = 0.0;             /* undeclared: the hrt branch */
    return m;
}

/** Runs a delivery schedule of 10-sample bursts; returns the last stamp emitted. */
double runUndeclared(const std::vector<HrtPacket>& pkts) {
    FrameDecoder dec;
    dec.setSignals({undeclaredAcc()});
    double lastTs = 0.0;
    for (const HrtPacket& p : pkts) {
        FrameBuilder fb;
        fb.addSignal(std::vector<double>(10, 1.0));
        const FrameView& f = fb.build(p.hrt, p.arrival, 10, p.counter);
        dec.beginFrame(f);
        std::vector<double> ts;
        if (dec.timestamps(f, 0, ts)) { lastTs = ts.back(); }
    }
    return lastTs;
}

/** 300 clean packets, 25 ms apart, from a producer that has been up a day. */
std::vector<HrtPacket> cleanUndeclaredStream() {
    const double   ticks   = 1.0e9;
    const uint64_t bootHrt = static_cast<uint64_t>(86400.0 * ticks);
    std::vector<HrtPacket> pkts;
    for (int p = 0; p < 300; p++) {
        pkts.push_back(HrtPacket{
            bootHrt + static_cast<uint64_t>(p * 0.025 * ticks),
            700.0 + p * 0.025,
            static_cast<uint32_t>(p + 1)});
    }
    return pkts;
}

/** Runs a schedule of 10-sample bursts; returns one entry per DELIVERED
 *  datagram, empty where the decoder emitted nothing. Per-packet rather than
 *  concatenated because the interesting quantity is the spacing INSIDE a
 *  particular burst, and which burst that is depends on the schedule. */
std::vector<std::vector<double> >
runUndeclaredPerPacket(const std::vector<HrtPacket>& pkts) {
    FrameDecoder dec;
    dec.setSignals({undeclaredAcc()});
    std::vector<std::vector<double> > out;
    for (const HrtPacket& p : pkts) {
        FrameBuilder fb;
        fb.addSignal(std::vector<double>(10, 1.0));
        const FrameView& f = fb.build(p.hrt, p.arrival, 10, p.counter);
        dec.beginFrame(f);
        std::vector<double> ts;
        if (!dec.timestamps(f, 0, ts)) { ts.clear(); }
        out.push_back(ts);
    }
    return out;
}

/** Delays the datagram at slot @p k by @p dist delivery slots: the payloads
 *  behind it each move up one and it lands after them. Only the payload moves —
 *  the arrival time belongs to the slot, because delivery order is what the
 *  socket actually saw. */
std::vector<HrtPacket> delayOne(const std::vector<HrtPacket>& clean,
                                size_t k, size_t dist) {
    std::vector<HrtPacket> out = clean;
    for (size_t i = 0; i < dist; i++) {
        std::swap(out[k + i].hrt,     out[k + i + 1].hrt);
        std::swap(out[k + i].counter, out[k + i + 1].counter);
    }
    return out;
}

} /* namespace */

// A datagram that overtakes its neighbour arrives with an hrt BEHIND the one
// already recorded. It must contribute no producer time — the packet that
// overtook it already counted the interval — and it must also leave the hrt
// reference alone. Writing the reference back is what the code used to do, and
// it makes the NEXT packet's delta span two intervals, fabricating a whole extra
// packet of producer time per reorder. That error never heals: ClockOffset would
// correct it but the monotonic clamp discards every backward correction.
TEST(FrameDecoder, UndeclaredAccumulatedScalarIgnoresReorderedDatagrams) {
    const std::vector<HrtPacket> clean = cleanUndeclaredStream();

    /* Ten swaps: each pair is delivered in the opposite order, so the arrival
     * times stay increasing (delivery order is what the socket saw) while the
     * hrt and counter they carry are exchanged. */
    std::vector<HrtPacket> reordered = clean;
    for (int k = 100; k < 200; k += 10) {
        std::swap(reordered[k].hrt, reordered[k + 1].hrt);
        std::swap(reordered[k].counter, reordered[k + 1].counter);
    }

    const double cleanEnd     = runUndeclared(clean);
    const double reorderedEnd = runUndeclared(reordered);

    /* Each swap used to add about one packet of producer time (25 ms); ten of
     * them left the trace a quarter of a second ahead, for good. */
    EXPECT_NEAR(reorderedEnd, cleanEnd, 1.0e-3)
        << "reordering left " << (reorderedEnd - cleanEnd) << " s of offset";
}

// Leaving the hrt reference alone on a reordered datagram is only half the rule:
// the packet counter is the DENOMINATOR of the very period that reference is the
// numerator of, so it has to stay behind too. Rolling lastCounter back while
// lastAccHrt holds gives the next in-order packet a gap of dist+1 against an
// elapsed spanning a single interval, and it derives a period dist+1 times too
// short. The test above cannot see this: it compares end times, and a burst drawn
// too NARROW ends early rather than late, so the damage hides inside the burst.
//
// The bound asserted here is not "the true spacing". A reorder legitimately
// squeezes bursts, because the late datagram's samples belong in the past and
// downstream demands increasing stamps, so the monotonic clamp walks them
// forward instead — and the timeline it leaves ahead of the producer takes a few
// packets to bleed off, squeezing those too. But that clamp has an exact floor:
// its cap is kWallBleedFraction * wallElapsed / nElems, and wallElapsed / nElems
// IS the producer's true period at steady cadence, so no burst it touches can
// ever be narrower than kWallBleedFraction of true. Anything below that floor
// did not come from the clamp; it came from a mis-derived period. That is what
// separates the defect from the design, and it is why the check is a floor
// rather than a target.
TEST(FrameDecoder, UndeclaredAccumulatedScalarKeepsItsSpacingAfterAReorder) {
    const double trueDt = 0.0025;         /* 10 samples per 25 ms packet */
    const double floorDt = 0.5 * trueDt;  /* kWallBleedFraction * trueDt */

    /* Distance 1 sits exactly ON the floor either way and is here to pin it;
     * 5 and 20 are where the defect drops through it, to 0.167x and 0.048x. */
    for (size_t dist : {size_t(1), size_t(5), size_t(20)}) {
        const std::vector<std::vector<double> > out =
            runUndeclaredPerPacket(delayOne(cleanUndeclaredStream(), 150, dist));

        /* The late payload lands at slot 150 + dist; the slot after it is the
         * first in-order packet to divide by the poisoned counter. */
        const size_t after = 150u + dist + 1u;
        ASSERT_GE(out[after].size(), 2u) << "distance " << dist;
        const double dt = out[after][1] - out[after][0];
        EXPECT_GE(dt, floorDt - 1.0e-9)
            << "distance " << dist << " drew its burst at " << dt << " s/sample, "
            << (dt / trueDt) << "x the true spacing";
        EXPECT_LE(dt, trueDt + 1.0e-9) << "distance " << dist;
    }
}

// The same defect under a network that reorders continuously rather than once.
// Same floor, applied to every burst in the run including the late datagrams'
// own — under sustained reordering there is no quiet packet to exempt, and the
// floor holds for all of them anyway.
TEST(FrameDecoder, UndeclaredAccumulatedScalarKeepsItsSpacingUnderSustainedReordering) {
    std::vector<HrtPacket> pkts = cleanUndeclaredStream();

    /* Six of 300 datagrams — 2% — delayed by one to five slots. */
    for (int n = 0; n < 6; n++) {
        pkts = delayOne(pkts, 40u + static_cast<size_t>(n) * 40u,
                        1u + static_cast<size_t>(n) % 5u);
    }

    const std::vector<std::vector<double> > out = runUndeclaredPerPacket(pkts);

    const double trueDt  = 0.0025;
    double       narrow  = 1.0;           /* smallest ratio to true seen */
    size_t       narrowAt = 0u;
    for (size_t p = 0; p < out.size(); p++) {
        for (size_t e = 1; e < out[p].size(); e++) {
            const double ratio = (out[p][e] - out[p][e - 1u]) / trueDt;
            if (ratio < narrow) { narrow = ratio; narrowAt = p; }
        }
    }
    /* Bottomed out at 0.167x — an 83.3% spacing error — before the counter moved
     * in lockstep with the reference. The clamp's own floor is 0.5x. */
    EXPECT_GE(narrow, 0.5 - 1.0e-9)
        << "narrowest burst " << narrow << "x true spacing at packet " << narrowAt;
}

// The counterweight. A producer restart drops hrt from the machine's whole
// uptime back to near zero, and that is the one case where the hrt reference
// MUST be allowed to regress: refusing every backward step would leave each
// later packet below the reference forever, the elapsed producer time
// permanently zero, and the signal frozen at the fallback period.
TEST(FrameDecoder, UndeclaredAccumulatedScalarSurvivesAProducerRestart) {
    FrameDecoder dec;
    dec.setSignals({undeclaredAcc()});

    const double   ticks   = 1.0e9;
    const uint64_t bootHrt = static_cast<uint64_t>(86400.0 * ticks);
    double         last    = 0.0;
    for (int p = 0; p < 120; p++) {
        const bool     restarted = (p >= 60);
        /* After the restart hrt counts from one second of uptime, and the
         * outage cost two seconds of wall time. */
        const uint64_t hrt = restarted
            ? static_cast<uint64_t>((1.0 + (p - 60) * 0.025) * ticks)
            : bootHrt + static_cast<uint64_t>(p * 0.025 * ticks);
        const double arrival = restarted
            ? (700.0 + 59 * 0.025 + 2.0 + (p - 60) * 0.025)
            : (700.0 + p * 0.025);

        FrameBuilder fb;
        fb.addSignal(std::vector<double>(10, 1.0));
        const FrameView& f =
            fb.build(hrt, arrival, 10, static_cast<uint32_t>(p + 1));
        dec.beginFrame(f);
        std::vector<double> ts;
        if (!dec.timestamps(f, 0, ts)) {
            /* Only the very first packet, which has no previous arrival for the
             * pre-fit fallback to span from. */
            ASSERT_EQ(p, 0) << "packet " << p << " produced nothing";
            continue;
        }
        for (size_t i = 0; i < ts.size(); i++) {
            ASSERT_GT(ts[i], last) << "timeline went backwards at packet " << p;
            last = ts[i];
        }
        /* The restart packet itself has no measurable interval and falls back to
         * the default period; from the next one on the producer's own 2.5 ms
         * must be back. A decoder that could not regress the reference would sit
         * at the 1 ms fallback for the rest of the run. */
        if (p >= 62) {
            EXPECT_NEAR(ts[1] - ts[0], 0.0025, 1e-5)
                << "spacing not recovered at packet " << p;
        }
    }
}

// The hrt branch's clamp used to be one-directional, which is the same defect
// the declared branch's squeeze exists to prevent. A wall clock that steps
// BACKWARDS — an NTP correction, a suspend/resume — leaves the emitted timeline
// permanently ahead, because the recalibrated position is behind lastEmittedEnd
// on every later packet too and the clamp keeps discarding it.
TEST(FrameDecoder, UndeclaredAccumulatedScalarRecoversFromABackwardWallStep) {
    FrameDecoder dec;
    dec.setSignals({undeclaredAcc()});

    const double   ticks   = 1.0e9;
    const uint64_t bootHrt = static_cast<uint64_t>(86400.0 * ticks);
    double         last       = 0.0;
    double         lead       = 0.0;
    double         worstAfter = 0.0;
    /* 100 ms packets of 10 samples. The step is 0.6 s — just past
     * ClockOffset::kRecalibThresholdS, which is what makes the recalibrated
     * position land behind lastEmittedEnd and the clamp fire at all — and it
     * comes after the rate fit's 256-sample window is full, so the fit
     * redistributes it slowly enough not to be mistaken for this recovery. */
    for (int p = 0; p < 340; p++) {
        const uint64_t hrt     = bootHrt + static_cast<uint64_t>(p * 0.1 * ticks);
        const double   arrival = 700.0 + p * 0.1 - ((p >= 300) ? 0.6 : 0.0);

        FrameBuilder fb;
        fb.addSignal(std::vector<double>(10, 1.0));
        const FrameView& f =
            fb.build(hrt, arrival, 10, static_cast<uint32_t>(p + 1));
        dec.beginFrame(f);
        std::vector<double> ts;
        if (!dec.timestamps(f, 0, ts)) {
            ASSERT_EQ(p, 0) << "packet " << p << " produced nothing";
            continue;
        }
        for (size_t i = 0; i < ts.size(); i++) {
            ASSERT_GT(ts[i], last) << "timeline went backwards at packet " << p;
            last = ts[i];
        }
        lead = ts.back() - arrival;
        /* Twenty packets is a generous allowance: the cap bleeds half a packet
         * interval per packet, so the 0.6 s step is gone in twelve. */
        if (p >= 320) { worstAfter = std::max(worstAfter, std::fabs(lead)); }
    }

    EXPECT_LT(worstAfter, 0.1)
        << "still " << worstAfter << " s from the wall clock long after the step";
}

// The two branches must place a burst the same way round or two accumulated
// scalars in one scope, one with a declared rate and one without, sit a whole
// burst apart on the shared X axis. The declared branch anchors the LAST element
// on arrival, which is right: the samples were acquired before the packet
// carrying them landed. The hrt branch used to latch its offset against raw
// arrival, putting the FIRST element there instead.
TEST(FrameDecoder, UndeclaredAccumulatedScalarEndsItsBurstOnArrival) {
    FrameDecoder dec;
    dec.setSignals({undeclaredAcc()});

    const double   ticks   = 1.0e9;
    const uint64_t bootHrt = static_cast<uint64_t>(86400.0 * ticks);
    /* 10 ms per packet of 10 samples. The cadence used to matter — the first
     * hrt-branch packet had no measurable interval, latched ClockOffset using
     * kDefaultDt, and only a 1 ms derived period made that harmless — but the
     * warm-up now hands over a real tick reference, so this assertion holds at
     * every cadence. See UndeclaredAccumulatedScalarCrossesTheHrtHandoverCleanly,
     * which is the test that pins that down; this one only fixes the convention
     * that a burst ends, rather than starts, on arrival. */
    double       lastArrival = 0.0;
    std::vector<double> last;
    for (int p = 0; p < 60; p++) {
        FrameBuilder fb;
        fb.addSignal(std::vector<double>(10, 1.0));
        const uint64_t hrt     = bootHrt + static_cast<uint64_t>(p * 0.010 * ticks);
        const double   arrival = 700.0 + p * 0.010;
        const FrameView& f =
            fb.build(hrt, arrival, 10, static_cast<uint32_t>(p + 1));
        dec.beginFrame(f);
        std::vector<double> ts;
        if (dec.timestamps(f, 0, ts)) { last = ts; lastArrival = arrival; }
    }

    ASSERT_EQ(last.size(), 10u);
    EXPECT_NEAR(last[9], lastArrival, 1e-9) << "burst must END on arrival";
    EXPECT_NEAR(last[0], lastArrival - 0.009, 1e-9);
}

// An undeclared-rate signal is served by TWO different mechanisms in sequence:
// packetBurst spans arrival gaps until HrtRateFit has collected enough packets,
// then the hrt branch takes over. They place a burst differently — packetBurst
// ends it at wallNow, the hrt branch at wallNow - (nElems-1)*hrtDt — so the
// handover is where a discontinuity hides, and it took two separate blind spots
// for the other tests to miss it. UndeclaredAccumulatedScalarEndsItsBurstOnArrival
// runs at 10 samples per 10 ms, the one cadence where the derived period equals
// the kDefaultDt fallback, so nothing was wrong to see. The two long-run tests
// run at 10 samples per 25 ms, where the fallback burst is 9 ms against a 25 ms
// packet interval — too narrow to invert, so their monotonicity assertions held
// while the trace sat 13.5 ms off the wall clock, which neither of them measures.
// So sweep cadences either side of the coincidence AND assert absolute position.
TEST(FrameDecoder, UndeclaredAccumulatedScalarCrossesTheHrtHandoverCleanly) {
    struct Case { uint32_t nElems; double packetSec; };
    const Case cases[] = {
        {10u,   0.0025},   /* 4 kHz: burst wider than the packet interval */
        {100u,  0.010 },   /* 10 kHz */
        {1000u, 0.010 },   /* 100 kHz: a burst is 100x the kDefaultDt guess */
        {10u,   0.050 },   /* 200 Hz: burst narrower than the packet interval */
    };

    for (const Case& c : cases) {
        FrameDecoder dec;
        dec.setSignals({undeclaredAcc()});

        const double   ticks    = 1.0e9;
        const uint64_t bootHrt  = static_cast<uint64_t>(86400.0 * ticks);
        const double   sampleDt = c.packetSec / static_cast<double>(c.nElems);
        double         last     = 0.0;
        bool           seen     = false;
        double         lastArrival = 0.0;
        std::vector<double> lastTs;

        for (int p = 0; p < 200; p++) {
            FrameBuilder fb;
            fb.addSignal(std::vector<double>(c.nElems, 1.0));
            const uint64_t hrt =
                bootHrt + static_cast<uint64_t>(p * c.packetSec * ticks);
            const double arrival = 700.0 + p * c.packetSec;
            const FrameView& f = fb.build(hrt, arrival, c.nElems,
                                          static_cast<uint32_t>(p + 1));
            dec.beginFrame(f);
            std::vector<double> ts;
            if (!dec.timestamps(f, 0, ts)) { continue; }
            for (double t : ts) {
                if (seen) {
                    ASSERT_GT(t, last)
                        << "handover stepped back " << (last - t) << " s with "
                        << c.nElems << " samples per " << c.packetSec << " s packet";
                }
                last = t;
                seen = true;
            }
            lastTs      = ts;
            lastArrival = arrival;
        }

        /* Monotonic is necessary but not sufficient: a clamp restores ordering
         * while leaving the whole trace parked in the past. The producer clock
         * here is exact, so once settled the burst must still end on arrival and
         * step at the true sample period. */
        ASSERT_EQ(lastTs.size(), c.nElems);
        EXPECT_NEAR(lastTs.back(), lastArrival, 1e-6)
            << "trace drifted off the wall clock with " << c.nElems
            << " samples per " << c.packetSec << " s packet";
        EXPECT_NEAR(lastTs[1] - lastTs[0], sampleDt, sampleDt * 1e-3);
    }
}

// Lost datagrams widen the hrt tick gap without widening the sample count that
// gap is divided by, so a recovery burst is drawn as many times too wide as the
// counter gap — and because a burst is anchored on its LAST element, too wide
// means it ends in the FUTURE. The declared branch reads the counter to
// reinstate the hole exactly; this pins the hrt branch to the same standard.
// Assert POSITION, not just spacing: a burst can be correctly spaced and still
// be drawn across the wrong stretch of the axis.
TEST(FrameDecoder, UndeclaredAccumulatedScalarKeepsItsSpacingThroughPacketLoss) {
    FrameDecoder dec;
    dec.setSignals({undeclaredAcc()});

    const double   ticks    = 1.0e9;
    const uint64_t bootHrt  = static_cast<uint64_t>(86400.0 * ticks);
    const double   packetSec = 0.025;
    const double   sampleDt  = 0.0025;
    /* Runs of 1, 4 and 10 consecutive losses, well clear of each other and of
     * the fit warm-up. Ten losses is the interesting one: it used to stretch the
     * recovery burst 11x and date its last sample 225 ms into the future. */
    const int dropFrom[3] = {120, 200, 300};
    const int dropLen[3]  = {1, 4, 10};

    double worstFuture = 0.0;
    double last        = 0.0;
    bool   seen        = false;
    for (int p = 0; p < 500; p++) {
        bool dropped = false;
        for (int k = 0; k < 3; k++) {
            if (p >= dropFrom[k] && p < dropFrom[k] + dropLen[k]) { dropped = true; }
        }
        if (dropped) { continue; }

        FrameBuilder fb;
        fb.addSignal(std::vector<double>(10, 1.0));
        const uint64_t hrt = bootHrt + static_cast<uint64_t>(p * packetSec * ticks);
        const double   arrival = 700.0 + p * packetSec;
        const FrameView& f =
            fb.build(hrt, arrival, 10, static_cast<uint32_t>(p + 1));
        dec.beginFrame(f);
        std::vector<double> ts;
        if (!dec.timestamps(f, 0, ts)) { continue; }

        for (double t : ts) {
            if (seen) { ASSERT_GT(t, last) << "backwards at packet " << p; }
            last = t;
            seen = true;
        }
        if (p > 100) {
            /* The samples were acquired BEFORE the packet carrying them landed,
             * so none of them may be stamped after its arrival. */
            const double future = ts.back() - arrival;
            if (future > worstFuture) { worstFuture = future; }
            EXPECT_NEAR(ts[1] - ts[0], sampleDt, sampleDt * 1e-3)
                << "spacing stretched at packet " << p;
        }
    }
    EXPECT_LT(worstFuture, 1e-6)
        << "a recovery burst ended " << worstFuture << " s in the future";
}

// A restart is the other way kDefaultDt gets latched: hrt goes backwards, so the
// restart packet measures no interval of its own, and whatever burst width it
// falls back on is baked into ClockOffset. The displacement that leaves — 13.5 ms
// at this cadence — is below ClockOffset::kRecalibThresholdS, so it never heals.
// AccumulatedScalarSurvivesAProducerRestart asserts only order and spacing and
// passes right through it; this asserts absolute position.
TEST(FrameDecoder, UndeclaredAccumulatedScalarReturnsToTheWallClockAfterARestart) {
    FrameDecoder dec;
    dec.setSignals({undeclaredAcc()});

    const double   ticks     = 1.0e9;
    const uint64_t bootHrt   = static_cast<uint64_t>(86400.0 * ticks);
    const double   packetSec = 0.025;
    std::vector<double> lastTs;
    double              lastArrival = 0.0;

    for (int p = 0; p < 400; p++) {
        FrameBuilder fb;
        fb.addSignal(std::vector<double>(10, 1.0));
        /* Packet 200 restarts the producer: hrt returns to a fresh boot and the
         * counter to 1. The wall clock does not restart. */
        const bool     after = (p >= 200);
        const uint64_t hrt   = after
            ? static_cast<uint64_t>((p - 200) * packetSec * ticks)
            : bootHrt + static_cast<uint64_t>(p * packetSec * ticks);
        const uint32_t counter = after ? static_cast<uint32_t>(p - 199)
                                       : static_cast<uint32_t>(p + 1);
        const double arrival = 700.0 + p * packetSec;
        const FrameView& f = fb.build(hrt, arrival, 10, counter);
        dec.beginFrame(f);
        std::vector<double> ts;
        if (dec.timestamps(f, 0, ts)) { lastTs = ts; lastArrival = arrival; }
    }

    ASSERT_EQ(lastTs.size(), 10u);
    EXPECT_NEAR(lastTs.back(), lastArrival, 1e-6)
        << "still displaced from the wall clock 200 packets after the restart";
    EXPECT_NEAR(lastTs[1] - lastTs[0], 0.0025, 2.5e-6);
}

// hrt == 0 sends the packet back to the warm-up branch, which spans from
// packetBurst's own lastPacketWall. The hrt branch does not otherwise touch that
// field, so it would be left at whenever this signal last took the warm-up
// branch — the start of the session — and one stray packet would emit a burst
// starting seconds in the past, worse the longer the scope has been running.
//
// The counter must sit out that detour with it, for the same lockstep reason as
// the reorder case: a zero-hrt packet advances the counter but cannot advance
// the tick reference, so the next real packet divides an elapsed spanning one
// interval by a gap reporting two, drawing that burst twice too wide and — since
// the burst is anchored on its LAST element — ending it in the future. Measured
// +22.5 ms for one such packet, +45 ms for two, +112.5 ms for five.
TEST(FrameDecoder, UndeclaredAccumulatedScalarSurvivesAStrayZeroHrtPacket) {
    const double   ticks     = 1.0e9;
    const uint64_t bootHrt   = static_cast<uint64_t>(86400.0 * ticks);
    const double   packetSec = 0.025;

    for (int run : {1, 2, 5}) {
        FrameDecoder dec;
        dec.setSignals({undeclaredAcc()});
        double last = 0.0;
        bool   seen = false;

        for (int p = 0; p < 200; p++) {
            FrameBuilder fb;
            fb.addSignal(std::vector<double>(10, 1.0));
            const bool     zero = (p >= 153) && (p < 153 + run);
            const uint64_t hrt  = zero
                ? 0u
                : bootHrt + static_cast<uint64_t>(p * packetSec * ticks);
            const double   arrival = 700.0 + p * packetSec;
            const FrameView& f =
                fb.build(hrt, arrival, 10, static_cast<uint32_t>(p + 1));
            dec.beginFrame(f);
            std::vector<double> ts;
            if (!dec.timestamps(f, 0, ts)) { continue; }
            for (double t : ts) {
                if (seen) {
                    ASSERT_GT(t, last) << "run of " << run
                                       << " zero-hrt packets stepped back "
                                       << (last - t) << " s at packet " << p;
                }
                last = t;
                seen = true;
            }
            /* And it must not land far from where the stream already is:
             * spanning from a session-old reference put the burst 2.74 s in the
             * past, and a counter that ran on without the tick reference put the
             * recovery burst 22.5 ms per stray packet into the future. The
             * tolerance is a fifth of a packet period: four and a half times
             * tighter than the smallest error it has to reject, and still far
             * enough above HrtRateFit's residual not to chase regression noise.
             * The 50 ms it replaced admitted every one of those errors. */
            if (p > 100) {
                EXPECT_NEAR(ts.back(), arrival, packetSec / 5.0)
                    << "run of " << run << " at packet " << p;
            }
        }
    }
}

// The same double delivery that the declared branch guards against — a host
// joined on two interfaces receives every unfragmented update twice — reaches an
// undeclared-rate signal identically. The guard can only fire if this branch
// leaves a counter behind for it to compare against.
TEST(FrameDecoder, UndeclaredAccumulatedScalarDropsADuplicatedDatagram) {
    FrameDecoder dec;
    dec.setSignals({undeclaredAcc()});

    const double   ticks   = 1.0e9;
    const uint64_t bootHrt = static_cast<uint64_t>(86400.0 * ticks);
    std::vector<double> ts;
    for (int p = 0; p < 50; p++) {
        FrameBuilder fb;
        fb.addSignal(std::vector<double>(10, 1.0));
        const FrameView& f =
            fb.build(bootHrt + static_cast<uint64_t>(p * 0.010 * ticks),
                     700.0 + p * 0.010, 10, static_cast<uint32_t>(p + 1));
        dec.beginFrame(f);
        const bool ok = dec.timestamps(f, 0, ts);
        ASSERT_EQ(ok, p != 0) << "at packet " << p;
    }
    const double endBefore = ts[9];

    /* Counter 50 again, the same update off the second interface. */
    FrameBuilder fb;
    fb.addSignal(std::vector<double>(10, 1.0));
    const FrameView& dup =
        fb.build(bootHrt + static_cast<uint64_t>(49 * 0.010 * ticks),
                 700.0 + 49 * 0.010 + 0.0001, 10, 50u);
    dec.beginFrame(dup);
    EXPECT_FALSE(dec.timestamps(dup, 0, ts)) << "duplicate was emitted twice";

    /* And the drop left the chain alone: the genuine next update still lands one
     * period after the last burst ended. */
    const FrameView& next =
        fb.build(bootHrt + static_cast<uint64_t>(50 * 0.010 * ticks),
                 700.0 + 50 * 0.010, 10, 51u);
    dec.beginFrame(next);
    ASSERT_TRUE(dec.timestamps(next, 0, ts));
    EXPECT_NEAR(ts[0], endBefore + 0.001, 1e-6);
}

// A PACKET burst has no per-element time at all. Elements span
// (lastPacket, thisPacket] — backwards from arrival, because the samples were
// acquired before the packet landed. Forward extrapolation would let a jittered
// packet overlap the next one and break ring monotonicity.
TEST(FrameDecoder, PacketBurstDropsTheFirstFrameThenSpansBackwards) {
    FrameDecoder dec;
    dec.setSignals({burst("Raw", kTimePacket, 0.0, 5, kNoTimeSignal)});

    FrameBuilder fb1;
    fb1.addSignal({1.0, 2.0, 3.0, 4.0, 5.0});
    const FrameView& f1 = fb1.build(0, 10.0);
    dec.beginFrame(f1);
    std::vector<double> ts;
    EXPECT_FALSE(dec.timestamps(f1, 0, ts))
        << "the first packet has no previous arrival to span from";

    FrameBuilder fb2;
    fb2.addSignal({6.0, 7.0, 8.0, 9.0, 10.0});
    const FrameView& f2 = fb2.build(0, 10.05);
    dec.beginFrame(f2);
    ASSERT_TRUE(dec.timestamps(f2, 0, ts));
    ASSERT_EQ(ts.size(), 5u);
    EXPECT_GT(ts[0], 10.0);
    EXPECT_NEAR(ts[4], 10.05, 1e-12);
    EXPECT_NEAR(ts[1] - ts[0], 0.01, 1e-12);
}

TEST(FrameDecoder, PacketBurstStaysMonotonicUnderJitteredArrivals) {
    FrameDecoder dec;
    dec.setSignals({burst("Raw", kTimePacket, 0.0, 8, kNoTimeSignal)});

    const double jitter[] = {0.0, 0.004, -0.003, 0.006, -0.002, 0.0, 0.005, -0.004};
    std::vector<double> all;
    for (int p = 0; p < 8; p++) {
        FrameBuilder fb;
        fb.addSignal(std::vector<double>(8, 1.0));
        const FrameView& f = fb.build(0, 20.0 + p * 0.05 + jitter[p]);
        dec.beginFrame(f);
        std::vector<double> ts;
        if (dec.timestamps(f, 0, ts)) {
            all.insert(all.end(), ts.begin(), ts.end());
        }
    }

    ASSERT_GT(all.size(), 8u);
    for (size_t i = 1; i < all.size(); i++) {
        EXPECT_GT(all[i], all[i - 1]) << "packets overlapped at " << i;
    }
}

TEST(FrameDecoder, ResetForgetsPerSignalHistory) {
    FrameDecoder dec;
    dec.setSignals({burst("Raw", kTimePacket, 0.0, 4, kNoTimeSignal)});

    FrameBuilder fb;
    fb.addSignal({1.0, 2.0, 3.0, 4.0});
    const FrameView& f = fb.build(0, 5.0);
    dec.beginFrame(f);
    std::vector<double> ts;
    EXPECT_FALSE(dec.timestamps(f, 0, ts));

    const FrameView& f2 = fb.build(0, 5.1);
    dec.beginFrame(f2);
    EXPECT_TRUE(dec.timestamps(f2, 0, ts));

    dec.reset();
    const FrameView& f3 = fb.build(0, 5.2);
    dec.beginFrame(f3);
    EXPECT_FALSE(dec.timestamps(f3, 0, ts))
        << "after reset the next packet is again the first one";
}
  • Step 3: Run the test to verify it fails
cd Client/udpscope && cmake --build build -j

Expected: FAIL — FrameDecoder.h: No such file or directory.

  • Step 4: Write the header

Create Client/udpscope/FrameDecoder.h:

/**
 * @file FrameDecoder.h
 * @brief Per-element timestamp reconstruction for UDPS frames.
 *
 * The C client's udps_frame_element_time() is explicitly an arrival-anchored
 * estimate. It is not sufficient: the kernel frequently delivers several queued
 * datagrams in one burst, so two packets are processed microseconds apart even
 * though each represents ~10 ms of signal, and arrival-time interpolation then
 * crams a packet's samples into that tiny gap — the trace renders as a sawtooth.
 * Source/Applications/StreamHub/UDPSourceSession.cpp documents this failure and
 * solves it; these are the same rules, computed from udps_frame_t's own fields.
 *
 * They are NOT the same code, and the differences are not a short list. Every
 * one of them comes from the same root: StreamHub runs on the producer's host,
 * so its arrival time IS the producer's clock and its local
 * HighResolutionTimer::Frequency() IS the frequency behind the packet's hrt.
 * Neither holds over a network, so anything StreamHub can read directly this
 * decoder has to estimate (HrtRateFit, ClockOffset), and anything it estimates
 * it must also defend — hence the monotonic clamps, the kWallBleedFraction
 * bleed, the reorder and restart guards and the duplicate-datagram drop, none of
 * which exist in UDPSourceSession.cpp. Do not read the three sections below as
 * exhaustive; they are the three that change where a sample LANDS, and so the
 * three worth checking first when a trace looks wrong.
 *
 * First, the anchor. StreamHub anchors every accumulated-scalar burst on the
 * packet's own hrt, converted with the LOCAL MARTe HighResolutionTimer
 * frequency — correct only because StreamHub runs on the producer's host. A
 * bench scope attaches over the network and has no access to that frequency; it
 * can only regress hrt against arrival time, which is exactly what the bursty
 * delivery above corrupts. So when a SamplingRate is declared this decoder
 * chains bursts instead, using the packet counter to account for loss and
 * arrival time only as a backstop. The consequence is that a declared rate
 * measured against the producer's crystal rather than ours makes the
 * reconstructed timeline drift, and drift that only arrival time can observe
 * must be corrected against arrival time — see rule 3.
 *
 * Second, which end of the burst is anchored. StreamHub converts the packet's
 * hrt into the position of sample 0 and steps forward, so the burst STARTS at
 * the anchor. Here the anchor is arrival time, and the samples were acquired
 * before the packet carrying them landed — so the burst must END there instead.
 * Both branches of rule 3 do this, or two accumulated scalars in one scope, one
 * with a declared rate and one without, would sit a whole burst apart on the
 * shared X axis.
 *
 * Third, the entry condition. UDPSourceSession.cpp:554 routes any update
 * carrying nElems <= 1 to plain arrival time. That is safe for a host-local
 * consumer whose arrival time is the producer's own clock, but wrong here:
 * Accumulate mode flushes on a TIMER, so a short RT cycle legitimately delivers
 * a single sample between two full bursts. Dating that one sample from arrival
 * while its neighbours are chained puts it off the chain, and — worse — leaves
 * lastCounter behind, so the next full burst reads the skipped counter as a lost
 * datagram and reinstates a hole that never existed. So a signal that has
 * already burst keeps every later update on rule 3 regardless of its length; a
 * signal that has never burst is a genuine scalar and is left to rule 5.
 *
 * Divergences OUTSIDE rule 3 exist as well; two are named here only because they
 * are the ones easily mistaken for bugs. Rules 1 and 2 key ClockOffset on the
 * CONSUMING signal, where UDPSourceSession.cpp:538 and :516 key it on the
 * time-signal index, so signals sharing a time signal share an offset there and
 * not here — immaterial, since the mapping they compute is the same. And a
 * FIRST_SAMPLE/LAST_SAMPLE signal whose time signal is absent falls through to
 * rule 4 rather than using its declared rate; that is a malformed CONFIG, and
 * spanning arrivals is the more honest answer than trusting a rate whose anchor
 * never arrived. Neither this list nor the three above is closed: any other
 * difference you find is unexamined, not sanctioned.
 */
#pragma once

#include "TimeBase.h"
#include "Types.h"

#include <vector>

namespace udpscope {

class FrameDecoder {
public:
    /** Installs the signal table. Clears all per-signal timing history. */
    void setSignals(const std::vector<SignalMeta>& signals);

    const std::vector<SignalMeta>& signals() const { return signals_; }

    /** Call once per frame, before any timestamps() call for that frame. */
    void beginFrame(const FrameView& f);

    /**
     * @brief Timestamps for every value of signal @p idx in this frame.
     * @return false when the signal produced nothing usable — an empty slot, or
     *         the first PACKET burst after connect, which has no previous
     *         arrival to span from and would otherwise poison the ring with
     *         wrongly spaced timestamps.
     */
    bool timestamps(const FrameView& f, uint32_t idx, std::vector<double>& tsOut);

    /** Forgets all timing history; call on reconnect. */
    void reset();

private:
    bool packetBurst(uint32_t idx, uint32_t nElems, double wallNow,
                     std::vector<double>& tsOut);

    struct SigState {
        ClockOffset offset;
        double      lastPacketWall  = 0.0;
        bool        lastPacketValid = false;
        /** Raw ticks, not seconds — see the comment in the samplingRate == 0
         *  branch for why a tick difference is the only safe way to measure a
         *  producer-side interval while the rate is still being re-estimated. */
        uint64_t    lastAccHrt      = 0u;
        /** Producer seconds since this signal's first usable packet, built by
         *  SUMMING short tick deltas. Never recomputed from an absolute tick
         *  count; see the samplingRate == 0 branch. */
        double      accProdSec      = 0.0;
        bool        lastAccValid    = false;
        uint32_t    prevAccCount    = 0;
        /** Last inter-element period the hrt branch actually MEASURED, used
         *  whenever this packet cannot measure one of its own (no previous tick,
         *  or hrt went backwards). The constant kDefaultDt is a poor substitute:
         *  it is only right at 1 kHz, and a wrong period here is not merely a
         *  wrong spacing for one burst — it is the burst width ClockOffset
         *  latches against, and the resulting displacement is usually too small
         *  for kRecalibThresholdS to ever heal. Zero until first measured. */
        double      lastHrtDt       = 0.0;
        /** Rule 2 only: the previous packet's time-signal anchor in PRODUCER
         *  seconds, the element count that anchor spanned, and the last period
         *  actually derived from a pair of them. Consecutive anchors are what
         *  lets an array with no declared sampling rate be spread at all. */
        double      prevAnchorProdSec = 0.0;
        uint32_t    prevAnchorCount   = 0u;
        double      prevAnchorDt      = 0.0;
        bool        prevAnchorValid   = false;
        /** For accumulated scalars (rule 3, either branch): end timestamp of the
         *  most recently emitted burst, and the packet counter it came from. The
         *  next burst is chained onto that end, with the counter gap reinstating
         *  the exact duration of any lost datagrams. */
        double      lastEmittedEnd   = 0.0;
        uint32_t    lastCounter      = 0u;
        /** Whether lastCounter holds a counter this signal has actually seen.
         *  Distinct from lastEmittedValid, which is about the emitted TIMELINE:
         *  rules 1 and 2 keep a counter (so the duplicate-datagram guard can
         *  fire for them too) without ever joining rule 3's chain. */
        bool        counterValid     = false;
        /** ARRIVAL time of the packet that produced lastEmittedEnd. Valid
         *  exactly when lastEmittedValid is, so it needs no flag of its own.
         *  Deliberately not lastPacketWall, which belongs to packetBurst() and
         *  is updated on frames rule 3 never emits. This is the only reference
         *  against which a leading timeline can be pulled back: the correction
         *  has to be expressed as a fraction of the wall time that has really
         *  elapsed since this signal's previous burst, because within a single
         *  timestamps() call the wall clock is frozen and every forward step,
         *  however small, increases the lead measured at that instant. */
        double      lastEmittedWall  = 0.0;
        bool        lastEmittedValid = false;
    };

    std::vector<SignalMeta> signals_;
    std::vector<SigState>   state_;
    HrtRateFit              hrtFit_;
};

} /* namespace udpscope */
  • Step 5: Write the implementation

Create Client/udpscope/FrameDecoder.cpp:

#include "FrameDecoder.h"

#include <cmath>

namespace udpscope {

/** Fallback cycle period before the first inter-packet gap is known. */
static constexpr double kDefaultDt = 1.0e-3;

/**
 * How far a chained burst prediction may sit from where arrival time says it
 * should be before the chain is abandoned and time is re-anchored on arrival.
 *
 * This is a backstop, not the primary mechanism: the packet counter normally
 * accounts for lost datagrams exactly, so the prediction and arrival agree.
 * It catches what the counter cannot describe — a producer restart (the
 * counter returns to zero), a counter that never advances, and a declared
 * sampling rate that does not match the producer's real one. A kernel draining
 * a backlog of queued datagrams can legitimately put the prediction a couple of
 * hundred milliseconds from arrival, so the threshold sits well clear of that.
 * Same value and same reasoning as ClockOffset::kRecalibThresholdS.
 */
static constexpr double kBurstResyncThresholdS = 0.5;

/**
 * Narrowest a burst may be drawn, as a fraction of its nominal width, while a
 * leading timeline is being pulled back. Only a floor: the squeeze is normally
 * proportional to the excess and removes it in a single burst. See the sole use
 * site.
 */
static constexpr double kMinBleedFactor = 0.05;

/**
 * Largest share of the wall time elapsed since a signal's previous burst that
 * that signal's next burst may advance its own timeline by, while a lead is
 * being pulled back.
 *
 * This, and not kMinBleedFactor, is what makes a lead converge. A fraction of
 * the NOMINAL burst width cannot: the floor still advances the timeline by
 * kMinBleedFactor * nominal per packet while the wall advances one packet
 * interval, so it diverges outright whenever nominal exceeds
 * (1 / kMinBleedFactor) packet intervals — a declared SamplingRate of 30 against
 * a producer really flushing 10 samples at 1 kHz put the trace 667 s ahead after
 * 1000 s of stream. Measuring the allowance against elapsed WALL time instead
 * bounds the advance below the wall's own advance for any declared rate, so the
 * lead strictly falls whatever the config says. Any fraction under 1 converges;
 * a half both converges quickly and leaves the burst visibly compressed rather
 * than frozen.
 */
static constexpr double kWallBleedFraction = 0.5;

/**
 * A backward jump in producer hrt larger than this is a producer RESTART; a
 * smaller one is a reordered datagram.
 *
 * The two need opposite handling — a reorder must leave the hrt reference
 * untouched (its interval was already counted by the packet that overtook it),
 * a restart must rebase onto the new epoch or the signal never advances again —
 * and nothing but the size of the jump distinguishes them. A second of producer
 * time is orders of magnitude more than any reordering window a UDP path can
 * produce (a few packet intervals) and orders of magnitude less than a restart,
 * which drops hrt from the producer's whole uptime back to near zero.
 *
 * Deliberately NOT kBurstResyncThresholdS: that one asks how far a WALL-clock
 * prediction may sit from arrival, a different quantity in a different clock
 * that happens to be tuned for delivery jitter. Sharing the number would couple
 * two unrelated tunings.
 */
static constexpr double kProducerRestartS = 1.0;

/**
 * The declared sampling rate, or 0 when there is none to trust.
 *
 * samplingRate arrives unvalidated from a signal descriptor on the wire. A
 * malformed +inf reaches the reciprocal as dt == 0, which makes a burst's
 * nominal width zero and the proportional squeeze compute 0.0/0.0 — and a NaN
 * factor is not caught by the floor, since every comparison against NaN is
 * false, so the whole burst is emitted as NaN. Rejecting it at the boundary
 * costs one test and removes the entire class.
 */
static double DeclaredRate(double samplingRate) {
    return (std::isfinite(samplingRate) && samplingRate > 0.0) ? samplingRate
                                                               : 0.0;
}

void FrameDecoder::setSignals(const std::vector<SignalMeta>& signals) {
    signals_ = signals;
    state_.assign(signals_.size(), SigState{});
    hrtFit_.reset();
}

void FrameDecoder::reset() {
    state_.assign(signals_.size(), SigState{});
    hrtFit_.reset();
}

void FrameDecoder::beginFrame(const FrameView& f) {
    if (f.hrt != 0u) { hrtFit_.add(f.hrt, f.recvTime); }
}

bool FrameDecoder::packetBurst(uint32_t idx, uint32_t nElems, double wallNow,
                               std::vector<double>& tsOut) {
    SigState& st = state_[idx];
    if (!st.lastPacketValid || wallNow <= st.lastPacketWall) {
        /* No previous arrival to span from, or time went backwards. Remember
         * this one and drop the samples rather than store them at made-up
         * spacing. */
        st.lastPacketWall  = wallNow;
        st.lastPacketValid = true;
        return false;
    }

    const double dt = (wallNow - st.lastPacketWall) / static_cast<double>(nElems);
    tsOut.resize(nElems);
    for (uint32_t e = 0; e < nElems; e++) {
        tsOut[e] = st.lastPacketWall + static_cast<double>(e + 1u) * dt;
    }
    st.lastPacketWall = wallNow;
    return true;
}

bool FrameDecoder::timestamps(const FrameView& f, uint32_t idx,
                              std::vector<double>& tsOut) {
    tsOut.clear();
    if (idx >= signals_.size() || idx >= f.numSignals ||
        f.counts == nullptr || f.values == nullptr) {
        return false;
    }

    const SignalMeta& d      = signals_[idx];
    const uint32_t    nElems = f.counts[idx];
    if (nElems == 0u) { return false; }

    const double wallNow = f.recvTime;
    SigState&    st      = state_[idx];

    /* A repeated counter is a duplicated datagram — the same update arriving
     * twice because the host joined the multicast group on two interfaces, say.
     * The C client only de-duplicates fragments, so an unfragmented update
     * reaches us intact both times; emitting it again would double the values
     * and advance the timeline by a burst that never existed. Counter zero is
     * excluded because a producer that never sets one leaves it there.
     *
     * Keyed on counterValid, not lastEmittedValid: rules 1 and 2 are exposed to
     * the same double delivery and would otherwise plot every array twice, since
     * neither of them ever joins rule 3's emitted chain. */
    if (st.counterValid && f.counter != 0u && f.counter == st.lastCounter) {
        return false;
    }

    /* hasTimeSignal() bounds the index against the FRAME's signal count, but
     * the time signal's type code is read from our own table, whose size is
     * independent — a frame carrying more signals than the installed table
     * (briefly possible after a CONFIG change) would otherwise read past it. */
    const bool     hasTimeSig = d.hasTimeSignal(f.numSignals) &&
                                d.timeSignalIdx < signals_.size();
    const uint32_t tIdx       = hasTimeSig ? d.timeSignalIdx : 0u;
    const double   tScale     = hasTimeSig
                                ? TimeSignalScale(signals_[tIdx].typeCode)
                                : 1.0e-6;

    /* Rule 1: one stamp per element, straight from the time signal. */
    if (d.timeMode == kTimeFullArray && hasTimeSig &&
        f.counts[tIdx] >= nElems && f.values[tIdx] != nullptr) {
        const double* tv = f.values[tIdx];
        const double  t0 = tv[0] * tScale;
        (void) st.offset.map(t0, wallNow);
        const double base = st.offset.offset();
        tsOut.resize(nElems);
        for (uint32_t e = 0; e < nElems; e++) {
            tsOut[e] = base + tv[e] * tScale;
        }
        /* Only so the duplicate-datagram guard above has something to compare
         * against; nothing in this rule reads it back. */
        st.lastCounter  = f.counter;
        st.counterValid = true;
        return true;
    }

    /* Rule 2: anchor from the time signal, spread by the sampling rate. */
    if ((d.timeMode == kTimeFirstSample || d.timeMode == kTimeLastSample) &&
        hasTimeSig && f.counts[tIdx] >= 1u && f.values[tIdx] != nullptr) {
        const double prodSec = f.values[tIdx][0] * tScale;
        const double anchor  = st.offset.map(prodSec, wallNow);
        const double rate    = DeclaredRate(d.samplingRate);
        double       dt      = (rate > 0.0) ? (1.0 / rate) : 0.0;

        /* No rate declared. UDPSourceSession.cpp:522 leaves dt at zero here,
         * which stacks every element of the array on one instant — harmless for
         * a host-local consumer that only stores them, but this scope's ring,
         * decimator and trigger all require a signal's stamps to increase, and a
         * plot of N points at one X is not a trace.
         *
         * The spread is recoverable without a rate: consecutive anchors come
         * from the time signal, so their difference is the burst's true duration
         * in producer seconds, measured on the producer's own clock rather than
         * on arrival — immune to the bursty delivery that corrupts everything
         * arrival-derived. Divide by the counter gap for the same reason rule 3
         * does: a lost datagram widens the anchor difference without widening
         * the array.
         *
         * Which array, though, is a question of which end is anchored. For
         * LAST_SAMPLE the anchors bracket THIS packet's elements, so the divisor
         * is nElems; for FIRST_SAMPLE they bracket the PREVIOUS packet's, so it
         * is that packet's count. Accumulate mode flushes on a timer, so the
         * count really does vary between packets — using the wrong one against a
         * 10,10,2,10,20 pattern gave 5x, 0.2x and 0.5x the true period and one
         * backward step of 3 ms.
         *
         * Two packets cannot always be measured. The first has no predecessor,
         * and a reordered one has an anchor behind its predecessor's; rather
         * than stack the whole array on one instant — the very defect this
         * paragraph exists to remove — reuse the last period actually measured,
         * exactly as the hrt branch reuses lastHrtDt. Only the genuine first
         * packet of a run stacks, and only until the second arrives. */
        if (!(dt > 0.0) && nElems > 1u) {
            const uint32_t divisor = (d.timeMode == kTimeLastSample)
                                     ? nElems : st.prevAnchorCount;
            const uint32_t rawGap  = f.counter - st.lastCounter;
            const bool     fwdGap  = (f.counter != 0u) && st.counterValid &&
                                     (rawGap != 0u) && (rawGap < 0x80000000u);
            if (st.prevAnchorValid && prodSec > st.prevAnchorProdSec &&
                divisor > 0u) {
                dt = (prodSec - st.prevAnchorProdSec) /
                     (static_cast<double>(divisor) *
                      static_cast<double>(fwdGap ? rawGap : 1u));
                st.prevAnchorDt = dt;
            } else if (st.prevAnchorDt > 0.0) {
                dt = st.prevAnchorDt;
            }
        }
        /* All four move together or not at all: a reordered packet must not
         * leave a newer anchor and an older counter behind for the next one to
         * divide one by the other. */
        if (!st.prevAnchorValid || prodSec > st.prevAnchorProdSec) {
            st.prevAnchorProdSec = prodSec;
            st.prevAnchorCount   = nElems;
            st.prevAnchorValid   = true;
            st.lastCounter       = f.counter;
            st.counterValid      = true;
        }

        tsOut.resize(nElems);
        for (uint32_t e = 0; e < nElems; e++) {
            tsOut[e] = (d.timeMode == kTimeFirstSample)
                     ? (anchor + static_cast<double>(e) * dt)
                     : (anchor - static_cast<double>(nElems - 1u - e) * dt);
        }
        return true;
    }

    /* Rule 3: accumulated scalar, based on declared sampling rate or hrt.
     *
     * When samplingRate is declared the inter-element step is exact and we
     * anchor from the end of the previous burst rather than from arrival time
     * or hrt.  This makes the output immune to arrival jitter: even when the
     * kernel delivers two packets microseconds apart each burst starts exactly
     * one sample period after the previous burst ended.
     *
     * When samplingRate is absent we must derive dt from the hrt gap, which
     * requires the HrtRateFit to be ready. Until then we fall back to
     * packetBurst (arrival-time spanning), which is accurate during the normal
     * pre-burst delivery phase that precedes the fit becoming ready.
     *
     * A signal that has already produced a burst stays on this rule even when a
     * later packet carries a single sample — Accumulate mode flushes on a timer,
     * so a short cycle legitimately yields one. Dropping such a packet to rule 5
     * would date it from arrival while its neighbours are chained, and would
     * leave lastCounter behind so the next real burst read the skip as a lost
     * datagram and reinstated a hole that never existed. A signal that has never
     * burst is a genuine scalar and is left to rule 5. */
    if (d.numElements() == 1u && (nElems > 1u || st.lastEmittedValid)) {
        /* A rate that is not a finite positive number is no rate at all; see
         * DeclaredRate(). Such a signal takes the hrt branch below. */
        const double declared = DeclaredRate(d.samplingRate);
        const double dt       = (declared > 0.0) ? (1.0 / declared) : 0.0;

        if (declared > 0.0) {
            /* Where arrival time says this burst begins: its last element was
             * acquired just before the packet landed. */
            const double arrivalAnchor =
                wallNow - static_cast<double>(nElems - 1u) * dt;

            /* Chaining onto the end of the previous burst is immune to arrival
             * jitter — a kernel draining several queued datagrams microseconds
             * apart still yields contiguous timestamps. What a bare chain gets
             * wrong is loss: it closes the hole a dropped datagram left, and
             * every later sample is then dated early for the rest of the run.
             *
             * The wire says exactly how much is missing. counter increments
             * once per update, so a gap of g means g-1 lost packets, each
             * carrying (as far as we can tell) as many samples as the last one
             * we saw. Reinstating that duration keeps the chain honest without
             * consulting arrival time at all. */
            double base = arrivalAnchor;
            double step = dt;
            if (st.lastEmittedValid) {
                /* Unsigned subtraction wraps, so this stays right across the
                 * counter's own 2^32 rollover.
                 *
                 * A producer restart or a reordered datagram makes the wrapped
                 * gap enormous, and this deliberately does NOT special-case
                 * that: an absurd gap yields an absurd prediction, which the
                 * arrival backstop below then rejects on its own.
                 *
                 * Clamping the gap first is not a harmless earlier version of
                 * the same decision — it reaches the OPPOSITE answer. A clamp
                 * that treats gap > kMaxCounterGap as unknowable has to fall
                 * back to lost == 0, so the prediction becomes
                 * lastEmittedEnd + dt: one sample period after the last burst,
                 * which is exactly the shape of a healthy chain and therefore
                 * lands INSIDE the arrival backstop, is accepted, and silently
                 * closes an outage of arbitrary length. Letting the absurd gap
                 * through produces an absurd prediction that the backstop
                 * catches, and the burst re-anchors on arrival — which is the
                 * right answer, and what
                 * AccumulatedScalarSurvivesAProducerRestart pins down. The
                 * arithmetic cannot overflow: gap and prevAccCount are both
                 * bounded by 2^32-1, so lost is at most ~1.8e19, finite, and
                 * always rejected. */
                const uint32_t gap  = f.counter - st.lastCounter;
                const double   lost = (gap > 1u)
                    ? static_cast<double>(gap - 1u) *
                      static_cast<double>(st.prevAccCount)
                    : 0.0;
                const double predicted = st.lastEmittedEnd + dt * (1.0 + lost);

                /* Backstop for what the counter cannot express: a producer
                 * restart, a counter stuck at zero, or a declared rate that is
                 * simply wrong. Beyond this the chain is not recoverable and
                 * arrival time is the better of two bad answers. */
                if (std::fabs(predicted - arrivalAnchor) <= kBurstResyncThresholdS) {
                    base = predicted;
                }

                if (base <= st.lastEmittedEnd) {
                    /* Re-anchoring here would step backwards, and the ring, the
                     * trigger and the exporter all require a signal's stamps to
                     * increase. Rejecting the correction outright is not an
                     * option either: `predicted` is never less than
                     * lastEmittedEnd + dt, so rejection would make the backstop
                     * one-directional and let a timeline that runs FAST — two
                     * hosts' crystals differ by tens of ppm, so this is certain
                     * on a long session, not hypothetical — drift ahead of the
                     * wall clock without bound.
                     *
                     * So compress instead of stepping back: start immediately
                     * after the previous burst and spread this one out to
                     * arrival. A single packet is drawn narrower than its true
                     * width, and in exchange the timeline is back in step. */
                    if (wallNow > st.lastEmittedEnd) {
                        step = (wallNow - st.lastEmittedEnd) /
                               static_cast<double>(nElems);
                        base = st.lastEmittedEnd + step;
                    } else {
                        /* The timeline has run PAST arrival: our last burst is
                         * dated later than the moment this packet landed, so
                         * there is no room to spread into and no burst can end
                         * on arrival without starting before it. Squeeze this
                         * one instead, by the excess and by the wall time that
                         * has really elapsed since this signal's last burst.
                         *
                         * Both terms are needed, and only the second one
                         * converges. Within this call the wall clock is frozen
                         * at wallNow, so ANY positive step increases the lead
                         * measured at this instant; the lead falls only because
                         * the wall advances BETWEEN packets. A step expressed
                         * purely as a fraction of the nominal burst width
                         * therefore diverges as soon as the nominal width
                         * outruns the packet interval — with the floor alone, a
                         * declared 30 Hz against a producer really flushing 10
                         * samples at 1 kHz gained ~0.67 s of lead per second of
                         * stream, without bound. Capping the burst's total
                         * advance at kWallBleedFraction of the elapsed wall time
                         * makes it advance strictly slower than the wall for any
                         * declared rate, so the lead strictly falls.
                         *
                         * The proportional term still does the fine work: when
                         * the excess is smaller than a burst it removes it in
                         * one packet. kMinBleedFactor only keeps that term
                         * positive when the excess exceeds a whole burst.
                         *
                         * Steady state is a sawtooth, not a fixed offset: the
                         * squeeze pulls the lead down, ordinary chaining resumes
                         * on the very next packet and pushes it back up until
                         * the prediction misses arrival by more than
                         * kBurstResyncThresholdS. So the lead cycles between a
                         * fraction of a millisecond and roughly that threshold —
                         * bounded, which is what matters, but not zero. */
                        const double nominal = static_cast<double>(nElems) * dt;
                        const double excess  = st.lastEmittedEnd - wallNow;
                        double       factor  = 1.0 - excess / nominal;
                        if (factor < kMinBleedFactor) { factor = kMinBleedFactor; }
                        double advance = nominal * factor;

                        /* A non-positive elapsed means the wall has not moved
                         * since this signal's previous burst. Skipping the cap
                         * then is deliberate and, more to the point, makes no
                         * difference: forcing the cap to zero instead sends step
                         * through the floor below to dt * kMinBleedFactor, which
                         * is the same number the proportional factor already
                         * yields once the excess exceeds one burst. Both leave
                         * the same-tick case diverging; only real elapsed wall
                         * time can bleed lead off, and a recv_time from
                         * CLOCK_REALTIME (udps_client.c:120) does not repeat. */
                        const double wallElapsed = wallNow - st.lastEmittedWall;
                        if (wallElapsed > 0.0) {
                            const double cap = kWallBleedFraction * wallElapsed;
                            if (cap < advance) { advance = cap; }
                        }
                        step = advance / static_cast<double>(nElems);
                        /* Unreachable with a finite positive dt — kept because
                         * downstream monotonicity must not depend on that
                         * argument holding for every value off the wire. */
                        if (!(step > 0.0)) { step = dt * kMinBleedFactor; }
                        base = st.lastEmittedEnd + step;
                    }
                }
            }
            tsOut.resize(nElems);
            for (uint32_t e = 0; e < nElems; e++) {
                tsOut[e] = base + static_cast<double>(e) * step;
            }
            st.lastEmittedEnd   = tsOut[nElems - 1u];
            st.lastEmittedWall  = wallNow;
            st.lastCounter      = f.counter;
            st.counterValid     = true;
            st.prevAccCount     = nElems;
            st.lastEmittedValid = true;
            return true;
        }

        /* No declared rate: need hrt-derived dt. */
        if (!hrtFit_.ready() || f.hrt == 0u) {
            const bool ok = packetBurst(idx, nElems, wallNow, tsOut);
            /* Carry the warm-up's state into the hrt branch, or the handover
             * from one to the other is a discontinuity in both directions.
             *
             * The producer-clock reference (lastAccHrt, prevAccCount) matters
             * most. Without it the first hrt packet has no previous tick to
             * subtract, falls back to kDefaultDt for its inter-element step and
             * latches ClockOffset against wallNow - (nElems-1)*kDefaultDt.
             * kDefaultDt is only right when the burst happens to run at 1 kHz;
             * at 100 samples per 10 ms packet it is ten times too wide and the
             * latch lands 89 ms in the past — permanently, since it is below
             * ClockOffset's recalibration threshold. Seeding here means the
             * first hrt packet measures a real tick delta and latches correctly.
             *
             * The emitted-timeline reference (lastEmitted*) then only has to
             * cover residual disagreement, but it is what keeps the handover
             * MONOTONIC: packetBurst ends its burst at wallNow while the hrt
             * branch ends at wallNow - (nElems-1)*hrtDt, and without a previous
             * end to clamp against the first hrt packet steps the signal
             * backwards by up to a whole burst width. */
            if (f.hrt != 0u) {
                st.lastAccHrt   = f.hrt;
                st.lastAccValid = true;
            }
            /* Same lockstep rule as the hrt branch below. A packet with hrt == 0
             * lands here mid-stream and cannot move the tick reference, so it
             * must not move the counter either: advancing the counter alone
             * makes the next packet's elapsed span two intervals while its gap
             * reports one, drawing that burst twice too wide and ending it
             * 22.5 ms in the future at 10 samples per 25 ms packet (6x and
             * +112 ms after five such packets). Before any tick reference exists
             * nothing is keyed to the counter, so it is free to advance and arm
             * the duplicate guard for a producer that never sets hrt at all. */
            if (f.hrt != 0u || !st.lastAccValid) {
                st.lastCounter  = f.counter;
                st.counterValid = true;
            }
            st.prevAccCount = nElems;
            if (!ok) { return false; }
            st.lastEmittedEnd   = tsOut[nElems - 1u];
            st.lastEmittedWall  = wallNow;
            st.lastEmittedValid = true;
            return true;
        }
        const double rate = hrtFit_.ticksPerSecond();

        /* Integrate short tick DELTAS. Never convert an absolute tick count, and
         * never subtract two such conversions.
         *
         * hrt counts from the producer's boot, so it is already ~1e11 ticks when
         * the scope attaches, while the fit is re-estimated on every packet and
         * wobbles by a few parts in 1e4. Any absolute hrt/rate therefore carries
         * that relative wobble multiplied by the whole elapsed epoch — tens of
         * milliseconds, moving in either direction from one packet to the next.
         * As a burst's position that is not merely imprecise, it is
         * NON-MONOTONIC: on a 2 h stream with ordinary scheduling jitter a few
         * percent of samples land before their own predecessor.
         *
         * A delta spans one packet, so its share of the wobble is microseconds,
         * and summing deltas keeps it there. ClockOffset then latches the
         * arbitrary epoch that leaves behind, exactly as it would have latched
         * the producer's boot epoch. */
        double elapsed = 0.0;
        /* Whether lastAccHrt should take this packet's value. Only a packet
         * that legitimately defines the new front of producer time may move it;
         * see the backward case below. */
        bool   takeHrt = true;
        if (st.lastAccValid) {
            if (f.hrt > st.lastAccHrt) {
                elapsed = static_cast<double>(f.hrt - st.lastAccHrt) / rate;
            } else {
                /* hrt went backwards. Two entirely different events look like
                 * this and only the SIZE of the jump separates them.
                 *
                 * A small one is a reordered datagram: the packet that overtook
                 * it already counted the interval it covers, so it must
                 * contribute nothing — and must also leave lastAccHrt alone.
                 * Letting it write lastAccHrt anyway (which is what this code
                 * used to do unconditionally) rolls the reference back one
                 * interval, so the NEXT packet's delta spans two and fabricates
                 * a whole extra packet of producer time. It never heals:
                 * ClockOffset would correct it, but the monotonic clamp below
                 * discards every backward correction. A hundred swaps on a
                 * 25 ms stream left the trace 3.5 s ahead, permanently. The C
                 * client does not reorder for us — udps_client.c only COUNTS
                 * counter gaps — so this is reachable on any real network.
                 *
                 * A large one is a producer restart: hrt drops from the
                 * producer's whole uptime to near zero. Here the unconditional
                 * write was the right behaviour and must be kept, because
                 * refusing to regress would leave every subsequent packet below
                 * lastAccHrt forever, elapsed permanently zero and the signal
                 * frozen. Rebase, and reset the offset so it re-latches against
                 * the new epoch instead of being dragged there by recalibration. */
                const double backward =
                    static_cast<double>(st.lastAccHrt - f.hrt) / rate;
                if (backward > kProducerRestartS) {
                    st.offset.reset();
                } else {
                    takeHrt = false;
                }
            }
        }
        st.accProdSec += elapsed;

        /* The flushes carry contiguous RT cycles, so the tick gap divided by the
         * number of cycles it spans is exactly one cycle period. That count is
         * NOT prevAccCount: elapsed spans every packet since the last one we
         * saw, so a lost datagram makes the tick gap wider without making
         * prevAccCount larger. Dividing by prevAccCount alone therefore returns
         * a period scaled by the whole counter gap — 2x for one lost datagram,
         * 11x for ten — which draws the recovery burst that many times too wide
         * and, because the burst is anchored on its LAST element, ends it in the
         * FUTURE (measured: +22.5 ms for one loss, +225 ms for ten, at 10
         * samples per 25 ms packet). At 1% loss that mis-spaced 4.0% of all
         * samples. The declared branch already reads the counter for exactly
         * this purpose (`lost`, above); the hrt branch must too.
         *
         * The gap and `elapsed` must be measured from the SAME packet, or the
         * division mixes references. That is why lastCounter is written under
         * takeHrt below, in lockstep with lastAccHrt: a reordered datagram that
         * rolled lastCounter back while leaving lastAccHrt alone would give the
         * next in-order packet a gap of d+1 against an elapsed spanning one
         * interval, dividing its period by d+1 — measured 0.5x the true spacing
         * for a swap of neighbours, 0.048x for a distance of twenty.
         *
         * Unsigned subtraction wraps, which is what makes this right across the
         * counter's own 2^32 rollover. A gap in the top half of the range is not
         * a forward gap at all but a backward one seen through the wrap, so it
         * is treated as no information rather than as 2 billion lost packets. */
        const uint32_t rawGap = f.counter - st.lastCounter;
        const bool     fwdGap = (f.counter != 0u) && st.counterValid &&
                                (rawGap != 0u) && (rawGap < 0x80000000u);
        const double   cycles = static_cast<double>(st.prevAccCount) *
                                static_cast<double>(fwdGap ? rawGap : 1u);

        /* Falling back to kDefaultDt is a last resort, not a default: see
         * SigState::lastHrtDt. What makes the fallback matter is the producer
         * restart, because that is the one path where elapsed is zero AND
         * offset.reset() has just forced a re-latch, so the burst width used
         * here is the one calibrated against — measured 13.5 ms of standing
         * displacement at 10 samples per 25 ms packet, 89 ms at 100 per 10 ms,
         * both below kRecalibThresholdS and so never corrected. A reorder also
         * reaches the fallback but does not re-latch, and its measured standing
         * error is 0.013 ms, i.e. nothing.
         *
         * This is better than kDefaultDt at every cadence except one: a producer
         * that restarts having CHANGED its cycle time is remembered wrongly, and
         * a tenfold change measured -20 ms against kDefaultDt's -6.8 ms. Both
         * are bounded and neither is correct; the remembered period wins the
         * case that actually happens. */
        double hrtDt;
        if (elapsed > 0.0 && cycles > 0.0) {
            hrtDt         = elapsed / cycles;
            st.lastHrtDt  = hrtDt;
        } else if (st.lastHrtDt > 0.0) {
            hrtDt = st.lastHrtDt;
        } else {
            hrtDt = kDefaultDt;
        }

        /* Anchor the burst's LAST element on arrival, not its first. The
         * packet's hrt is the tick count of sample 0 (UDPSourceSession.cpp:574),
         * so stepping forward from it is right — but ClockOffset latches
         * offset = wall - producerSec on its first call, and passing the raw
         * arrival would put sample 0 at the instant the packet carrying the
         * whole burst LANDED, dating every sample in it late by a burst. The
         * declared-rate branch above already anchors on the burst end
         * (arrivalAnchor), and two accumulated scalars in one scope, one with a
         * declared rate and one without, would otherwise sit a burst apart on a
         * shared X axis — 9 ms for 10 samples at 1 kHz, plain to see at a 200 ms
         * window. Since map() latches once, this is a constant shift applied at
         * latch and recalibration only; it changes no spacing. */
        double base = st.offset.map(
            st.accProdSec,
            wallNow - static_cast<double>(nElems - 1u) * hrtDt);
        double step = hrtDt;

        /* ClockOffset recalibrates once true drift passes its threshold, and a
         * recalibration can land behind where this signal already is.
         * Downstream requires increasing stamps, so step forward minimally —
         * but a bare forward step is one-directional, exactly the defect the
         * declared branch's squeeze exists to avoid. A backward wall step (an
         * NTP correction, a suspend/resume) would otherwise leave this signal
         * permanently ahead of the wall clock, since the recalibrated base is
         * behind lastEmittedEnd on every later packet too and the clamp keeps
         * discarding it. So cap the burst's total advance against the wall time
         * elapsed since this signal's previous burst, for the reason spelled out
         * at kWallBleedFraction: only that makes the lead bleed off. */
        if (st.lastEmittedValid && base <= st.lastEmittedEnd) {
            const double wallElapsed = wallNow - st.lastEmittedWall;
            if (wallElapsed > 0.0) {
                const double cap = kWallBleedFraction * wallElapsed /
                                   static_cast<double>(nElems);
                if (cap < step) { step = cap; }
            }
            base = st.lastEmittedEnd + step;
        }

        tsOut.resize(nElems);
        for (uint32_t e = 0; e < nElems; e++) {
            tsOut[e] = base + static_cast<double>(e) * step;
        }
        if (takeHrt) { st.lastAccHrt = f.hrt; }
        st.lastAccValid     = true;
        st.prevAccCount     = nElems;
        st.lastEmittedEnd   = tsOut[nElems - 1u];
        st.lastEmittedWall  = wallNow;
        /* Keep packetBurst's reference current even though this branch does not
         * use it. A single packet with hrt == 0 re-enters the warm-up branch
         * above, and packetBurst would otherwise span from whenever this signal
         * last took that branch — the whole session. Measured: 153 packets into
         * a 25 ms stream, one zero-hrt packet emitted a burst starting 2.74 s
         * behind the trace, and that figure grows with session length. */
        st.lastPacketWall   = wallNow;
        st.lastPacketValid  = true;
        /* In lockstep with lastAccHrt, and for the same reason: these two are
         * the numerator and the denominator of the next packet's period. Only a
         * packet that defines the new front of producer time may move either.
         * Advancing the counter alone on a reordered datagram halves the next
         * packet's spacing; see the gap comment above. Leaving a counter behind
         * at all is what lets the duplicate-datagram guard at the top of
         * timestamps() fire — a host joined on two interfaces receives every
         * unfragmented update twice, and the original always sets takeHrt. */
        if (takeHrt) {
            st.lastCounter  = f.counter;
            st.counterValid = true;
        }
        st.lastEmittedValid = true;
        return true;
    }

    /* Rule 4: PACKET burst with no time reference at all. */
    if (nElems > 1u) {
        return packetBurst(idx, nElems, wallNow, tsOut);
    }

    /* Rule 5: plain scalar. */
    tsOut.assign(1, wallNow);
    return true;
}

} /* namespace udpscope */
  • Step 6: Register the source with CMake
set(CORE_SOURCES
    Decimate.cpp
    PaneTree.cpp
    TimeBase.cpp
    FrameDecoder.cpp
)
  • Step 7: Run the tests and verify they pass
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='FrameDecoder*'

Expected: PASS, 33 FrameDecoder tests — 62 across the whole udpscope_tests binary.

If AccumulatedScalarSurvivesBurstyDelivery fails, do NOT reach for the hrt fit: with a declared samplingRate rule 3 never consults it, precisely because the fit is not ready for the first 32 packets and — since HrtRateFit regresses hrt against ARRIVAL time — is itself corrupted by the very bursts it would be asked to survive. Check instead that lastEmittedEnd, lastCounter, prevAccCount and lastEmittedValid are updated on every emitted burst.

Note for AccumulatedScalarDerivesDtFromTheHrtGapWhenNoRateIsDeclared: its arrivals carry zero-mean jitter on purpose. Under UNIFORM arrivals the hrt path and packetBurst return the same number by construction (the fit expresses hrt in arrival-clock seconds), so the test could not tell which branch answered. Its producer period is 25 ms, not 10 ms, for the same reason: at 10 ms the expected 1 ms answer equals kDefaultDt, so a decoder that derived nothing would pass.

If that test returns exactly kDefaultDt, or a value that wanders between runs of different length, the cause is almost certainly a reintroduced hrtFit_.toSeconds(a) - hrtFit_.toSeconds(b). toSeconds() divides an ABSOLUTE tick count by a rate refitted on every packet; a producer that has been up for a day is at ~1e11 ticks, so the fit's few-parts-in-1e4 wobble becomes tens of milliseconds of jitter on the result — larger than the interval being measured. Difference the raw ticks and divide once by ticksPerSecond().

If AccumulatedScalarDoesNotDriftAwayFromTheWallClockForever fails with a lead that grows without bound, the proportional squeeze is not firing. Note that the "spread out to arrival" compression is unreachable in this case by construction: a LEADING timeline has lastEmittedEnd > wallNow, so there is no room to spread into. That branch handles only a bad prediction while arrival is still ahead; the leading case needs the kMinBleedFactor branch below it. Get the SIGN of the test's arrival spacing right — the producer must be FAST (arrivals closer together than the declared period, e.g. 0.0099 s for a 10 ms nominal burst). A slow producer makes the chain LAG, which the one-directional backstop already handles, so the test would pass with the squeeze deleted.

If AccumulatedScalarStaysMonotonicOnALongUndeclaredRunAfterBoot fails, the hrt path has been rewritten to position bursts from an ABSOLUTE tick conversion. Note that this test asserts spacing as well as order: the base <= lastEmittedEnd guard alone restores order while leaving positions wrong, so an order-only assertion would pass against a broken decoder.

If AccumulatedScalarConvergesWhenTheDeclaredRateIsFarTooLow fails with a lead in the tens of seconds, the kWallBleedFraction cap has been dropped or expressed against the wrong reference. kMinBleedFactor alone CANNOT converge, and the difference is not a matter of speed: inside one timestamps() call the wall clock is frozen, so every positive step increases the lead measured at that instant; the lead falls only because the wall advances between packets. A step that is a fraction of the NOMINAL burst therefore outruns the wall whenever the nominal burst is wider than 1 / kMinBleedFactor packet intervals — declared 30 Hz against a producer really flushing 10 samples at 1 kHz gained 0.67 s per second of stream, unbounded, and declared 50 Hz was exactly marginal. The cap must be a fraction of wallNow - st.lastEmittedWall, i.e. of wall time really elapsed since THIS signal's previous burst. Do not reuse lastPacketWall for that reference: it belongs to packetBurst() and is updated on frames rule 3 never emits.

If UndeclaredAccumulatedScalarIgnoresReorderedDatagrams fails, st.lastAccHrt is being written for a packet whose hrt is behind it. The elapsed-time guard alone is not enough — it correctly contributes zero for the late packet, but rolling the reference back makes the NEXT packet's delta span two intervals and fabricate a whole extra packet of producer time, permanently (ClockOffset would correct it; the monotonic clamp discards every backward correction). Note this test is also sensitive to the kWallBleedFraction cap on the hrt branch, which is what reabsorbs the extra clamped burst a reorder emits; UndeclaredAccumulatedScalarSurvivesAProducerRestart is the test that isolates the reference-update rule on its own.

If UndeclaredAccumulatedScalarSurvivesAProducerRestart fails with the spacing stuck at kDefaultDt, the backward-jump handling has been collapsed into "never regress". A restart drops hrt from the producer's whole uptime to near zero, so every later packet is below the reference forever, elapsed is permanently zero and the signal freezes. Only the SIZE of the jump separates a restart from a reorder — hence kProducerRestartS. Do not reuse kBurstResyncThresholdS for it: that one answers how far a wall-clock prediction may sit from arrival, a different quantity in a different clock, tuned for delivery jitter.

If UndeclaredAccumulatedScalarRecoversFromABackwardWallStep fails, the hrt branch's base <= lastEmittedEnd clamp has gone back to a bare lastEmittedEnd + hrtDt, which is one-directional and leaves an NTP correction or a suspend/resume as a permanent lead. Note the test's geometry is deliberate: the step is 0.6 s (it must exceed ClockOffset::kRecalibThresholdS or the recalibration that puts base behind lastEmittedEnd never happens at all), and it lands after the 256-sample rate-fit window is full. HrtRateFit regresses hrt against ARRIVAL, so it eventually absorbs the step too, at roughly step / kWindow per packet — a much slower second correction that would swamp the measurement if the step were placed early or the run continued for hundreds of packets past it.

If UndeclaredAccumulatedScalarEndsItsBurstOnArrival fails by exactly (nElems - 1) * hrtDt, ClockOffset::map() is being latched against raw wallNow again, which puts the burst's FIRST element on arrival while the declared branch puts its LAST one there — two accumulated scalars in one scope, one with a declared rate and one without, then sit a whole burst apart on the shared X axis.

That test alone is NOT sufficient cover for the hrt branch's anchoring, which is why UndeclaredAccumulatedScalarCrossesTheHrtHandoverCleanly exists beside it. Its 10 samples per 10 ms packet make the derived period exactly kDefaultDt, the one cadence at which the warm-up-to-hrt handover cannot misbehave, so it passes against a decoder that steps backwards by up to a whole burst at that handover. The handover is the sharp edge here: an undeclared-rate signal is served by packetBurst until HrtRateFit is ready and by the hrt branch afterwards, and the two place a burst differently — packetBurst ends it at wallNow, the hrt branch at wallNow - (nElems - 1) * hrtDt.

If UndeclaredAccumulatedScalarCrossesTheHrtHandoverCleanly fails on the monotonicity assertion, the warm-up branch is returning packetBurst()'s result directly without recording state. Two different fields matter and they fix two different symptoms. lastEmittedEnd/lastEmittedWall/lastEmittedValid are what the monotonic clamp needs; without them the first hrt packet skips the clamp entirely and steps back by up to a burst width (-6.5 ms at 10 samples per 2.5 ms, -0.99 s at 1000 samples per 10 ms). lastAccHrt/prevAccCount are what stops the failure being merely hidden: without a previous tick to subtract, the first hrt packet has no measurable interval, falls back to kDefaultDt and latches ClockOffset against wallNow - (nElems - 1) * kDefaultDt. At 100 samples per 10 ms packet that is ten times too wide and parks the trace 89 ms in the past permanently, since 89 ms is below ClockOffset::kRecalibThresholdS. That is why the test asserts the settled burst still ends on arrival and steps at the true sample period, not just that it never goes backwards — the clamp on its own would satisfy monotonicity while leaving the trace displaced.

Three more tests exist because hrtDt is not just a spacing — it is the burst width ClockOffset latches against, so a wrong one displaces the whole trace permanently whenever the error stays below ClockOffset::kRecalibThresholdS. Every route to a wrong hrtDt therefore needs its own cover, and each of these asserts ABSOLUTE POSITION rather than only order and spacing, because the monotonic clamp restores order while leaving the trace parked in the wrong place.

If UndeclaredAccumulatedScalarKeepsItsSpacingThroughPacketLoss fails, hrtDt is dividing the tick delta by prevAccCount alone. elapsed spans every packet since the last one seen, so a lost datagram widens it without widening the sample count — the period comes out scaled by the whole counter gap, and since a burst is anchored on its LAST element, too wide means it ends in the FUTURE. Measured at 10 samples per 25 ms packet: +22.5 ms for one loss, +90 ms for four, +225 ms for ten, mis-spacing 2.7% of all samples at 1% loss. Divide by prevAccCount * counterGap, exactly as the declared branch's lost already does.

If UndeclaredAccumulatedScalarReturnsToTheWallClockAfterARestart fails, the kDefaultDt fallback is being used where lastHrtDt should be. On the restart packet hrt goes backwards, so elapsed is zero and no period can be measured — but st.offset.reset() on that same packet means it is also the packet that re-latches. kDefaultDt is only right at 1 kHz: measured standing displacement is +13.5 ms at 10 samples per 25 ms and -89 ms at 100 per 10 ms, both too small for recalibration to ever heal. Note AccumulatedScalarSurvivesAProducerRestart runs at exactly the +13.5 ms cadence and passes right through this, because it asserts only order and spacing.

If UndeclaredAccumulatedScalarSurvivesAStrayZeroHrtPacket fails on the monotonicity assertion, the hrt branch has stopped keeping lastPacketWall current. A packet with hrt == 0 re-enters the warm-up branch, and packetBurst spans from that field — which the hrt branch does not otherwise write, so it would still hold whenever this signal last took the warm-up branch. Measured: 153 packets into a 25 ms stream, one zero-hrt packet emitted a burst starting 2.74 s behind the trace, and that figure grows with session length.

If the same test fails instead on the EXPECT_NEAR(ts.back(), arrival, ...) assertion, the warm-up branch is advancing lastCounter for a packet that could not advance lastAccHrt. Those two are the denominator and the numerator of the next packet's period and must move together; a counter that ran on alone makes the recovery burst twice too wide and — burst anchored on its LAST element — ends it in the future, +22.5 ms per stray packet at 10 samples per 25 ms (+45 ms for two, +112.5 ms for five). Guard the write with f.hrt != 0u || !st.lastAccValid: before any tick reference exists nothing is keyed to the counter, so it is free to advance and arm the duplicate guard for a producer that never sets hrt at all. The test's tolerance is deliberately a fifth of a packet period; the 50 ms it started at admitted every one of those errors and passed for the wrong reason.

The reorder counterpart of that same lockstep rule is covered by UndeclaredAccumulatedScalarKeepsItsSpacingAfterAReorder and ...UnderSustainedReordering. If either fails, st.lastCounter is being written outside the takeHrt guard: a late datagram rolls the counter back while the tick reference correctly holds, so the next in-order packet divides an elapsed spanning one interval by a gap reporting dist + 1 and derives a period that many times too short — measured 0.500x true spacing at distance 1, 0.167x at 5, 0.048x at 20, and a worst spacing error of 83.3% under 2% sustained reordering.

Read the bound those two assert carefully before "fixing" it. They do NOT assert the true spacing, because a reorder legitimately squeezes bursts: the late datagram's samples belong in the past, downstream demands increasing stamps, so the monotonic clamp walks them forward, and the timeline it leaves ahead of the producer takes several packets to bleed off. What the clamp cannot do is go below its own floor — its cap is kWallBleedFraction * wallElapsed / nElems, and wallElapsed / nElems IS the true period at steady cadence, so kWallBleedFraction (0.5x) is an exact lower bound on anything the clamp touches. A burst narrower than that did not come from the clamp; it came from a mis-derived period. That is also why distance 1 is in the list but proves nothing on its own — it sits exactly ON the floor either way, and only distances 5 and 20 drop through it.

If ArrayRulesDropADuplicatedDatagram fails, rules 1 and 2 have stopped recording lastCounter/counterValid. The duplicate-datagram guard at the top of timestamps() is keyed on a counter each rule leaves behind, so a rule that records none is silently exempt from it — and a host joined on two interfaces would then plot every array twice, at two arrival times, doubling back on the X axis. Neither rule reads the value back; they keep it only so the guard can fire. This is also why the guard is keyed on counterValid rather than lastEmittedValid: the latter is about rule 3's emitted chain, which rules 1 and 2 never join.

If FirstSampleWithNoRateSpreadsFromConsecutiveAnchors fails, rule 2 has gone back to UDPSourceSession.cpp:522's behaviour of leaving the step at zero when no rate is declared. That stacks every element of the array on one instant, which a host-local consumer can store but this scope cannot plot, and which contradicts the strictly-increasing invariant rule 3 defends everywhere. The spread is recoverable from consecutive time-signal anchors — the producer's own clock, so immune to the bursty delivery that corrupts anything arrival-derived — and must be divided by the counter gap for the same reason rule 3 divides by it.

  • Step 8: Commit
git add Client/udpscope/FrameDecoder.h Client/udpscope/FrameDecoder.cpp \
        Client/udpscope/Types.h Client/udpscope/tests/FrameDecoderTest.cpp \
        Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): per-element timestamp reconstruction from UDPS frames"

Task 5: Trigger FSM

The trigger runs in the receiver thread, fed the same reconstructed (t, v) arrays that go into the ring. It never stores samples itself — it only decides when a capture window opened and closed. The GUI later reads [tTrig - preSec, tTrig + postSec] out of the ring.

Files:

  • Create: Client/udpscope/Trigger.h
  • Create: Client/udpscope/Trigger.cpp
  • Modify: Client/udpscope/CMakeLists.txt (add Trigger.cpp to CORE_SOURCES)
  • Test: Client/udpscope/tests/TriggerTest.cpp

Interfaces:

  • Consumes: nothing from earlier tasks (pure logic over double arrays).
  • Produces:
enum class Edge     { Rising, Falling, Both };
enum class TrigMode { Normal, Single };
enum class TrigState{ Idle, Armed, Collecting, Held };

struct TrigConfig {
    std::string signalName;
    Edge     edge       = Edge::Rising;
    double   threshold  = 0.0;
    double   hysteresis = 0.0;
    double   windowSec  = 0.1;
    double   prePercent = 20.0;
    TrigMode mode       = TrigMode::Normal;
    double preSec()  const;
    double postSec() const;
};

class Trigger {
public:
    void setConfig(const TrigConfig& c);
    const TrigConfig& config() const;
    void arm();
    void disarm();
    void rearm();
    void feed(const double* t, const double* v, size_t n, double ringOldestTime);
    TrigState state() const;
    double    fillFraction() const;
    double    trigTime() const;
    bool      captureReady() const;
    void      captureTaken();
    static constexpr double kHarvestMarginSec = 0.05;
};

Why the harvest margin exists: the GUI reads the capture out of the ring one repaint tick after the receiver declares it complete. Without a margin the last samples of the window can be overwritten before they are read. This is exactly the bug that was found and fixed in the Go hub (Common/Client/go/wshub/ringbuf.go, captureLagSec) — do not remove it.

  • Step 1: Write the failing tests

Create Client/udpscope/tests/TriggerTest.cpp:

#include "Trigger.h"
#include <gtest/gtest.h>
#include <cmath>
#include <vector>

using namespace udpscope;

namespace {

// Builds a Trigger armed on a rising edge through 0.5 with the given window.
Trigger makeTrigger(Edge e, double thr, double hyst,
                    double windowSec, double prePercent,
                    TrigMode mode = TrigMode::Normal) {
    TrigConfig c;
    c.signalName = "sig";
    c.edge       = e;
    c.threshold  = thr;
    c.hysteresis = hyst;
    c.windowSec  = windowSec;
    c.prePercent = prePercent;
    c.mode       = mode;
    Trigger tr;
    tr.setConfig(c);
    return tr;
}

// Feeds one sample at a time so the FSM sees realistic packet granularity.
void feedOne(Trigger& tr, double t, double v, double oldest) {
    tr.feed(&t, &v, 1, oldest);
}

} // namespace

TEST(TriggerConfig, SplitsTheWindowByThePrePercentage) {
    TrigConfig c;
    c.windowSec  = 0.2;
    c.prePercent = 25.0;
    EXPECT_DOUBLE_EQ(c.preSec(),  0.05);
    EXPECT_DOUBLE_EQ(c.postSec(), 0.15);
}

TEST(Trigger, StartsIdleAndOnlyArmsWhenAsked) {
    Trigger tr = makeTrigger(Edge::Rising, 0.5, 0.0, 0.1, 20.0);
    EXPECT_EQ(tr.state(), TrigState::Idle);
    // An unarmed trigger ignores a crossing entirely.
    feedOne(tr, 1.0, 0.0, 0.0);
    feedOne(tr, 1.001, 1.0, 0.0);
    EXPECT_EQ(tr.state(), TrigState::Idle);
    EXPECT_FALSE(tr.captureReady());
}

// The pre-window has to already be in the ring when the edge lands, or the
// capture has nothing to back-fill from. Arming reports Armed but the FSM
// refuses to accept an edge until the ring reaches back far enough.
TEST(Trigger, RefusesToFireBeforeThePreWindowIsBuffered) {
    Trigger tr = makeTrigger(Edge::Rising, 0.5, 0.0, 0.1, 50.0); // preSec = 0.05
    tr.arm();
    ASSERT_EQ(tr.state(), TrigState::Armed);

    // Ring only reaches back to t = 0.98, i.e. 0.02 s of history at t = 1.0.
    feedOne(tr, 1.000, 0.0, 0.98);
    feedOne(tr, 1.001, 1.0, 0.98);
    EXPECT_EQ(tr.state(), TrigState::Armed) << "fired without a full pre-window";
    EXPECT_LT(tr.fillFraction(), 1.0);

    // Now the ring reaches back 0.06 s and the same edge is accepted.
    feedOne(tr, 1.100, 0.0, 1.04);
    feedOne(tr, 1.101, 1.0, 1.04);
    EXPECT_EQ(tr.state(), TrigState::Collecting);
}

TEST(Trigger, FillFractionReportsPreWindowProgress) {
    Trigger tr = makeTrigger(Edge::Rising, 0.5, 0.0, 0.1, 50.0); // preSec = 0.05
    tr.arm();
    feedOne(tr, 1.0, 0.0, 0.975);  // 0.025 s of 0.05 s
    EXPECT_NEAR(tr.fillFraction(), 0.5, 1e-9);
    feedOne(tr, 1.0, 0.0, 0.90);   // more than enough
    EXPECT_DOUBLE_EQ(tr.fillFraction(), 1.0);
}

// The crossing almost never lands exactly on a sample. Interpolating gives a
// stable trigger point instead of one that jitters by a sample period.
TEST(Trigger, InterpolatesTheCrossingBetweenSamples) {
    Trigger tr = makeTrigger(Edge::Rising, 0.5, 0.0, 0.1, 20.0);
    tr.arm();
    feedOne(tr, 1.000, 0.0, 0.0);
    feedOne(tr, 1.010, 1.0, 0.0);
    // tTrig = 1.000 + (0.5 - 0.0)/(1.0 - 0.0) * 0.010 = 1.005
    ASSERT_EQ(tr.state(), TrigState::Collecting);
    EXPECT_NEAR(tr.trigTime(), 1.005, 1e-12);
}

TEST(Trigger, FallingEdgeFiresOnTheDownwardCrossing) {
    Trigger tr = makeTrigger(Edge::Falling, 0.5, 0.0, 0.1, 20.0);
    tr.arm();
    feedOne(tr, 1.000, 1.0, 0.0);
    EXPECT_EQ(tr.state(), TrigState::Armed);
    feedOne(tr, 1.010, 0.0, 0.0);
    ASSERT_EQ(tr.state(), TrigState::Collecting);
    EXPECT_NEAR(tr.trigTime(), 1.005, 1e-12);
}

TEST(Trigger, RisingEdgeIgnoresADownwardCrossing) {
    Trigger tr = makeTrigger(Edge::Rising, 0.5, 0.0, 0.1, 20.0);
    tr.arm();
    feedOne(tr, 1.000, 1.0, 0.0);
    feedOne(tr, 1.010, 0.0, 0.0);
    EXPECT_EQ(tr.state(), TrigState::Armed);
}

TEST(Trigger, BothEdgesFireOnWhicheverComesFirst) {
    Trigger tr = makeTrigger(Edge::Both, 0.5, 0.0, 0.1, 20.0);
    tr.arm();
    feedOne(tr, 1.000, 1.0, 0.0);
    feedOne(tr, 1.010, 0.0, 0.0);
    EXPECT_EQ(tr.state(), TrigState::Collecting);
}

// A noisy signal riding on the threshold produces a burst of crossings. With
// hysteresis the signal must first retreat past threshold - hysteresis before
// another rising edge counts, so one physical event yields one trigger.
TEST(Trigger, HysteresisSuppressesARecrossFromNoise) {
    Trigger tr = makeTrigger(Edge::Rising, 0.5, 0.2, 0.1, 20.0);
    tr.arm();
    feedOne(tr, 1.000, 0.0, 0.0);
    feedOne(tr, 1.010, 1.0, 0.0);
    ASSERT_EQ(tr.state(), TrigState::Collecting);
    const double first = tr.trigTime();

    // Close the capture and re-arm, then wiggle just below threshold: 0.45 is
    // under 0.5 but has not retreated past the 0.3 arm level.
    feedOne(tr, 1.200, 0.0, 0.0);          // past 1.005 + 0.08 + 0.05
    ASSERT_TRUE(tr.captureReady());
    tr.captureTaken();
    ASSERT_EQ(tr.state(), TrigState::Armed);

    feedOne(tr, 1.300, 0.45, 0.0);
    feedOne(tr, 1.310, 0.60, 0.0);
    EXPECT_EQ(tr.state(), TrigState::Armed) << "re-armed inside the hysteresis band";

    // A genuine retreat below 0.3 re-arms the detector.
    feedOne(tr, 1.400, 0.10, 0.0);
    feedOne(tr, 1.410, 0.60, 0.0);
    EXPECT_EQ(tr.state(), TrigState::Collecting);
    EXPECT_GT(tr.trigTime(), first);
}

// Collecting must run past the end of the post-window by the harvest margin,
// because the GUI reads the ring a tick after the receiver says "done".
TEST(Trigger, CollectingEndsAPostWindowPlusMarginAfterTheEdge) {
    Trigger tr = makeTrigger(Edge::Rising, 0.5, 0.0, 0.1, 20.0); // post = 0.08
    tr.arm();
    feedOne(tr, 1.000, 0.0, 0.0);
    feedOne(tr, 1.010, 1.0, 0.0);            // tTrig = 1.005
    ASSERT_EQ(tr.state(), TrigState::Collecting);

    const double end = 1.005 + 0.08 + Trigger::kHarvestMarginSec;
    feedOne(tr, end - 1e-3, 1.0, 0.0);
    EXPECT_FALSE(tr.captureReady()) << "harvested before the margin elapsed";

    feedOne(tr, end + 1e-3, 1.0, 0.0);
    EXPECT_TRUE(tr.captureReady());
}

TEST(Trigger, NormalModeRearmsAfterTheCaptureIsTaken) {
    Trigger tr = makeTrigger(Edge::Rising, 0.5, 0.0, 0.1, 20.0, TrigMode::Normal);
    tr.arm();
    feedOne(tr, 1.000, 0.0, 0.0);
    feedOne(tr, 1.010, 1.0, 0.0);
    feedOne(tr, 1.500, 1.0, 0.0);
    ASSERT_TRUE(tr.captureReady());
    tr.captureTaken();
    EXPECT_EQ(tr.state(), TrigState::Armed);
    EXPECT_FALSE(tr.captureReady());
}

TEST(Trigger, SingleModeHoldsAfterTheCaptureIsTaken) {
    Trigger tr = makeTrigger(Edge::Rising, 0.5, 0.0, 0.1, 20.0, TrigMode::Single);
    tr.arm();
    feedOne(tr, 1.000, 0.0, 0.0);
    feedOne(tr, 1.010, 1.0, 0.0);
    feedOne(tr, 1.500, 1.0, 0.0);
    ASSERT_TRUE(tr.captureReady());
    tr.captureTaken();
    EXPECT_EQ(tr.state(), TrigState::Held);

    // A Held trigger ignores further edges until explicitly re-armed.
    feedOne(tr, 2.000, 0.0, 0.0);
    feedOne(tr, 2.010, 1.0, 0.0);
    EXPECT_EQ(tr.state(), TrigState::Held);

    tr.rearm();
    EXPECT_EQ(tr.state(), TrigState::Armed);
}

TEST(Trigger, ChangingTheConfigAbandonsAnInFlightCapture) {
    Trigger tr = makeTrigger(Edge::Rising, 0.5, 0.0, 0.1, 20.0);
    tr.arm();
    feedOne(tr, 1.000, 0.0, 0.0);
    feedOne(tr, 1.010, 1.0, 0.0);
    ASSERT_EQ(tr.state(), TrigState::Collecting);

    TrigConfig c = tr.config();
    c.threshold = 0.9;
    tr.setConfig(c);
    EXPECT_EQ(tr.state(), TrigState::Idle) << "kept a capture cut to the old config";
}

TEST(Trigger, DisarmDropsBackToIdle) {
    Trigger tr = makeTrigger(Edge::Rising, 0.5, 0.0, 0.1, 20.0);
    tr.arm();
    tr.disarm();
    EXPECT_EQ(tr.state(), TrigState::Idle);
    feedOne(tr, 1.000, 0.0, 0.0);
    feedOne(tr, 1.010, 1.0, 0.0);
    EXPECT_EQ(tr.state(), TrigState::Idle);
}

// A single packet carries thousands of samples; the edge is somewhere inside
// it, and the same packet can also carry the whole post-window.
TEST(Trigger, FindsAnEdgeInTheMiddleOfALargeBlockAndCanCompleteInIt) {
    Trigger tr = makeTrigger(Edge::Rising, 0.5, 0.0, 0.02, 25.0); // post = 0.015
    tr.arm();
    std::vector<double> t(1000), v(1000);
    for (size_t i = 0; i < t.size(); ++i) {
        t[i] = 1.0 + static_cast<double>(i) * 1e-4;   // 10 kHz, 0.1 s span
        v[i] = (i < 500) ? 0.0 : 1.0;
    }
    tr.feed(t.data(), v.data(), t.size(), 0.5);
    // Crossing between i=499 (t=1.0499, v=0) and i=500 (t=1.05, v=1):
    // tTrig = 1.0499 + 0.5 * 1e-4 = 1.04995
    EXPECT_NEAR(tr.trigTime(), 1.04995, 1e-9);
    EXPECT_TRUE(tr.captureReady()) << "block extends past the post-window and margin";
}
  • Step 2: Run the tests to verify they fail
cd Client/udpscope && cmake --build build -j 2>&1 | tail -5

Expected: FAIL — Trigger.h: No such file or directory.

  • Step 3: Write the header

Create Client/udpscope/Trigger.h:

/**
 * @file Trigger.h
 * @brief Client-side edge trigger FSM for UDPScope.
 *
 * The trigger observes one signal's reconstructed samples in the receiver
 * thread and decides when a capture window opened and closed. It stores no
 * samples: the GUI reads [trigTime() - preSec, trigTime() + postSec] out of
 * the ring once captureReady() goes true.
 */
#ifndef UDPSCOPE_TRIGGER_H
#define UDPSCOPE_TRIGGER_H

#include <cstddef>
#include <string>

namespace udpscope {

enum class Edge     { Rising, Falling, Both };
enum class TrigMode { Normal, Single };
enum class TrigState{ Idle, Armed, Collecting, Held };

struct TrigConfig {
    std::string signalName;
    Edge     edge       = Edge::Rising;
    double   threshold  = 0.0;
    double   hysteresis = 0.0;
    double   windowSec  = 0.1;
    double   prePercent = 20.0;
    TrigMode mode       = TrigMode::Normal;

    double preSec()  const { return windowSec * prePercent / 100.0; }
    double postSec() const { return windowSec - preSec(); }
};

class Trigger {
public:
    /**
     * Extra time the FSM keeps Collecting past the end of the post-window.
     *
     * The GUI harvests the capture out of the ring on its next repaint, which
     * is up to one frame after the receiver declares the window complete. The
     * ring must therefore still hold the window's last samples then. The Go
     * hub had this same bug without a margin (captureLagSec in
     * Common/Client/go/wshub/ringbuf.go) and truncated every capture. Do not
     * remove this.
     */
    static constexpr double kHarvestMarginSec = 0.05;

    void setConfig(const TrigConfig& c);
    const TrigConfig& config() const { return cfg_; }

    void arm();      /**< Idle -> Armed. No-op if already armed. */
    void disarm();   /**< Any state -> Idle, abandoning an in-flight capture. */
    void rearm();    /**< Held -> Armed, discarding the held capture. */

    /**
     * Feed one packet's worth of reconstructed samples.
     *
     * @param t              per-sample times, strictly increasing
     * @param v              per-sample values of the trigger signal
     * @param n              number of samples
     * @param ringOldestTime time of the oldest sample still in the ring; used
     *                       to gate arming until the pre-window is buffered
     */
    void feed(const double* t, const double* v, size_t n, double ringOldestTime);

    TrigState state() const { return state_; }
    /** Fraction of the pre-window currently held by the ring, clamped to 1. */
    double fillFraction() const { return fill_; }
    double trigTime() const { return trigTime_; }
    bool   captureReady() const { return ready_; }
    /** Called by the GUI once it has copied the capture out of the ring. */
    void   captureTaken();

private:
    void reset();
    bool crosses(double v0, double v1) const;

    TrigConfig cfg_;
    TrigState  state_    = TrigState::Idle;
    double     fill_     = 0.0;
    double     trigTime_ = 0.0;
    double     endTime_  = 0.0;
    bool       ready_    = false;
    bool       havePrev_ = false;
    double     prevT_    = 0.0;
    double     prevV_    = 0.0;
    /** Hysteresis gate: false until the signal has retreated past the arm level. */
    bool       gateOpen_ = true;
};

} /* namespace udpscope */

#endif /* UDPSCOPE_TRIGGER_H */
  • Step 4: Write the implementation

Create Client/udpscope/Trigger.cpp:

#include "Trigger.h"

#include <algorithm>

namespace udpscope {

void Trigger::reset() {
    state_    = TrigState::Idle;
    fill_     = 0.0;
    trigTime_ = 0.0;
    endTime_  = 0.0;
    ready_    = false;
    havePrev_ = false;
    gateOpen_ = true;
}

void Trigger::setConfig(const TrigConfig& c) {
    cfg_ = c;
    /* A capture cut to the old threshold/window would be misleading once the
       config changes, so drop it rather than finish it. */
    reset();
}

void Trigger::arm() {
    if (state_ == TrigState::Idle) {
        state_    = TrigState::Armed;
        ready_    = false;
        havePrev_ = false;
        gateOpen_ = true;
    }
}

void Trigger::disarm() { reset(); }

void Trigger::rearm() {
    reset();
    arm();
}

void Trigger::captureTaken() {
    if (!ready_) {
        return;
    }
    ready_ = false;
    if (cfg_.mode == TrigMode::Single) {
        state_ = TrigState::Held;
    } else {
        state_    = TrigState::Armed;
        havePrev_ = false;
        /* Force a retreat past the arm level before the next edge counts, so
           ringing on the tail of this capture cannot immediately re-trigger. */
        gateOpen_ = (cfg_.hysteresis <= 0.0);
    }
}

bool Trigger::crosses(double v0, double v1) const {
    const double thr = cfg_.threshold;
    const bool up   = (v0 < thr) && (v1 >= thr);
    const bool down = (v0 > thr) && (v1 <= thr);
    switch (cfg_.edge) {
    case Edge::Rising:  return up;
    case Edge::Falling: return down;
    case Edge::Both:    return up || down;
    }
    return false;
}

void Trigger::feed(const double* t, const double* v, size_t n, double ringOldestTime) {
    if (n == 0u || state_ == TrigState::Idle || state_ == TrigState::Held) {
        return;
    }

    for (size_t i = 0u; i < n; ++i) {
        const double ti = t[i];
        const double vi = v[i];

        if (state_ == TrigState::Collecting) {
            if (ti >= endTime_) {
                ready_ = true;
                return;   /* the rest of the block belongs to the next capture */
            }
            continue;
        }

        /* Armed. The pre-window must already be in the ring or the capture
           has nothing to back-fill from. */
        const double pre  = cfg_.preSec();
        const double have = ti - ringOldestTime;
        fill_ = (pre > 0.0) ? std::min(1.0, std::max(0.0, have / pre)) : 1.0;

        if (!havePrev_) {
            havePrev_ = true;
            prevT_    = ti;
            prevV_    = vi;
            continue;
        }

        /* Hysteresis: after a fire the signal must retreat past the arm level
           before another edge of the same polarity is accepted. */
        if (!gateOpen_ && cfg_.hysteresis > 0.0) {
            const double armLevel = (cfg_.edge == Edge::Falling)
                                        ? cfg_.threshold + cfg_.hysteresis
                                        : cfg_.threshold - cfg_.hysteresis;
            const bool retreated = (cfg_.edge == Edge::Falling) ? (vi >= armLevel)
                                                                : (vi <= armLevel);
            if (retreated) {
                gateOpen_ = true;
            }
            prevT_ = ti;
            prevV_ = vi;
            continue;
        }

        if (fill_ >= 1.0 && crosses(prevV_, vi)) {
            const double dv = vi - prevV_;
            const double frac = (dv != 0.0) ? (cfg_.threshold - prevV_) / dv : 0.0;
            trigTime_ = prevT_ + frac * (ti - prevT_);
            endTime_  = trigTime_ + cfg_.postSec() + kHarvestMarginSec;
            state_    = TrigState::Collecting;
            gateOpen_ = (cfg_.hysteresis <= 0.0);
            continue;   /* re-enters the Collecting branch on the next sample */
        }

        prevT_ = ti;
        prevV_ = vi;
    }
}

} /* namespace udpscope */

Note on Edge::Both with hysteresis: the arm level is derived from the configured edge, and Both uses the rising form. That is a deliberate simplification — a symmetric band would need two gates and no bench scope exposes that. It is documented in Docs/UDPScope.md (Task 16).

  • Step 5: Register the source with CMake

In Client/udpscope/CMakeLists.txt, extend CORE_SOURCES:

set(CORE_SOURCES
    Decimate.cpp
    PaneTree.cpp
    TimeBase.cpp
    FrameDecoder.cpp
    Trigger.cpp
)
  • Step 6: Run the tests and verify they pass
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='Trigger*'

Expected: PASS, 15 tests.

  • Step 7: Run the whole suite to check nothing regressed
cd Client/udpscope && ./build/udpscope_tests

Expected: PASS, all tests from Tasks 15.

  • Step 8: Commit
git add Client/udpscope/Trigger.h Client/udpscope/Trigger.cpp \
        Client/udpscope/tests/TriggerTest.cpp Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): edge trigger FSM with fill gate and harvest margin"

Task 6: SignalStore — the one lock between the two threads

SignalStore owns every ring, the latest vector profiles, and the most recent completed capture. The receiver thread writes; the GUI thread reads; one std::mutex guards all of it. Nothing else in the program is shared between the threads.

Files:

  • Create: Client/udpscope/SignalStore.h
  • Create: Client/udpscope/SignalStore.cpp
  • Modify: Client/udpscope/CMakeLists.txt (add SignalStore.cpp to CORE_SOURCES)
  • Test: Client/udpscope/tests/SignalStoreTest.cpp

Interfaces:

  • Consumes: SignalMeta, Series from Types.h (Task 1/4); StreamHubClient::SignalBuffer from ../streamhub/SignalBuffer.h (already on the include path from Task 1).
  • Produces:
struct Profile { double time = 0.0; std::vector<double> x, v; };
struct Capture {
    uint64_t seq = 0;
    double   trigTime = 0.0, t0 = 0.0, t1 = 0.0;
    std::vector<std::string> names;
    std::vector<Series>      series;
};

class SignalStore {
public:
    static constexpr double kRingMargin       = 4.0;
    static constexpr size_t kMinRingPoints    = 4096u;
    static constexpr size_t kMaxRingPoints    = 4000000u;
    static constexpr size_t kTotalPointBudget = 16000000u;

    void setSignals(const std::vector<SignalMeta>& metas);
    std::vector<SignalMeta> signals() const;
    uint64_t generation() const;

    void push(const std::string& name, const double* t, const double* v, size_t n);
    void pushProfile(const std::string& name, double time, const double* v, size_t n);

    size_t readLast(const std::string& name, size_t n, Series& out) const;
    size_t readRange(const std::string& name, double t0, double t1, Series& out) const;
    bool   readProfile(const std::string& name, Profile& out) const;

    bool   span(const std::string& name, double& oldest, double& newest) const;
    double rate(const std::string& name) const;
    size_t capacity(const std::string& name) const;

    void   setWindowSec(double windowSec);
    double windowSec() const;
    void   maintain();

    void     publishCapture(Capture&& c);
    uint64_t captureSeq() const;
    bool     readCapture(Capture& out) const;
};

Why kRingMargin is 4.0 and must stay explicit: the rings must hold more than the trigger window itself. The pre-window has to be resident before the edge arrives, the post-window accumulates after it, the capture is harvested a repaint later (Trigger::kHarvestMarginSec), and the rate estimate that sizes the ring lags a step behind a rate change. Four window-lengths covers all four at a cost of a few tens of MB. Sizing the rings to the window alone is precisely the bug that truncated every capture in the Go hub. Do not tune this down without re-running the capture tests.

  • Step 1: Write the failing tests

Create Client/udpscope/tests/SignalStoreTest.cpp:

#include "SignalStore.h"
#include <gtest/gtest.h>
#include <atomic>
#include <cmath>
#include <thread>
#include <vector>

using namespace udpscope;

namespace {

SignalMeta scalarMeta(const std::string& name) {
    SignalMeta m;
    m.name     = name;
    m.typeCode = 8;   /* float32 */
    m.numRows  = 1;
    m.numCols  = 1;
    return m;
}

// Pushes `n` samples at `rate` Hz starting at t0; returns the time just past
// the last sample so callers can chain blocks.
double pushBlock(SignalStore& s, const std::string& name,
                 double t0, double rate, size_t n) {
    std::vector<double> t(n), v(n);
    const double dt = 1.0 / rate;
    for (size_t i = 0; i < n; ++i) {
        t[i] = t0 + static_cast<double>(i) * dt;
        v[i] = static_cast<double>(i);
    }
    s.push(name, t.data(), v.data(), n);
    return t0 + static_cast<double>(n) * dt;
}

} // namespace

TEST(SignalStore, CreatesARingPerSignalAtTheMinimumCapacity) {
    SignalStore s;
    s.setSignals({scalarMeta("a"), scalarMeta("b")});
    EXPECT_EQ(s.signals().size(), 2u);
    EXPECT_EQ(s.capacity("a"), SignalStore::kMinRingPoints);
    EXPECT_EQ(s.capacity("b"), SignalStore::kMinRingPoints);
}

TEST(SignalStore, PushAndReadLastRoundTrip) {
    SignalStore s;
    s.setSignals({scalarMeta("a")});
    const double t[] = {1.0, 2.0, 3.0};
    const double v[] = {10.0, 20.0, 30.0};
    s.push("a", t, v, 3);

    Series out;
    ASSERT_EQ(s.readLast("a", 10, out), 3u);
    EXPECT_DOUBLE_EQ(out.t[0], 1.0);
    EXPECT_DOUBLE_EQ(out.v[2], 30.0);
}

TEST(SignalStore, ReadRangeClipsToTheRequestedInterval) {
    SignalStore s;
    s.setSignals({scalarMeta("a")});
    pushBlock(s, "a", 0.0, 1000.0, 1000);   // 0 .. 0.999 s

    Series out;
    ASSERT_EQ(s.readRange("a", 0.100, 0.200, out), 101u);
    EXPECT_NEAR(out.t.front(), 0.100, 1e-9);
    EXPECT_NEAR(out.t.back(),  0.200, 1e-9);
}

TEST(SignalStore, SpanReportsTheOldestAndNewestResidentSample) {
    SignalStore s;
    s.setSignals({scalarMeta("a")});
    double oldest = 0.0, newest = 0.0;
    EXPECT_FALSE(s.span("a", oldest, newest)) << "empty ring must report no span";

    // Overfill the ring so the oldest samples are gone.
    const size_t n = SignalStore::kMinRingPoints + 500u;
    pushBlock(s, "a", 0.0, 1000.0, n);

    ASSERT_TRUE(s.span("a", oldest, newest));
    EXPECT_NEAR(oldest, 500.0 / 1000.0, 1e-9);
    EXPECT_NEAR(newest, static_cast<double>(n - 1) / 1000.0, 1e-9);
}

TEST(SignalStore, RateEstimateTracksThePushCadence) {
    SignalStore s;
    s.setSignals({scalarMeta("a")});
    double t = 0.0;
    for (int i = 0; i < 50; ++i) {
        t = pushBlock(s, "a", t, 10000.0, 100);   // 10 kHz in 10 ms blocks
    }
    EXPECT_NEAR(s.rate("a"), 10000.0, 200.0);
}

TEST(SignalStore, UnknownSignalsAreIgnoredRatherThanCreated) {
    SignalStore s;
    s.setSignals({scalarMeta("a")});
    const double t = 1.0, v = 2.0;
    s.push("ghost", &t, &v, 1);

    Series out;
    EXPECT_EQ(s.readLast("ghost", 10, out), 0u);
    EXPECT_EQ(s.capacity("ghost"), 0u);
    EXPECT_EQ(s.signals().size(), 1u) << "push must not invent a signal";
}

TEST(SignalStore, SetSignalsClearsPreviousDataAndBumpsTheGeneration) {
    SignalStore s;
    s.setSignals({scalarMeta("a")});
    pushBlock(s, "a", 0.0, 1000.0, 100);
    const uint64_t g0 = s.generation();

    s.setSignals({scalarMeta("a"), scalarMeta("b")});
    EXPECT_GT(s.generation(), g0);

    Series out;
    EXPECT_EQ(s.readLast("a", 10, out), 0u) << "stale samples survived a reconfigure";
}

// The whole point of the store: a 1 MSps signal with a 0.2 s trigger window
// needs 200 k points for the window itself and kRingMargin times that in the
// ring, or the capture is overwritten before the GUI can read it.
TEST(SignalStore, MaintainGrowsTheRingToTheWindowTimesTheMargin) {
    SignalStore s;
    s.setSignals({scalarMeta("a")});
    s.setWindowSec(0.2);
    double t = 0.0;
    for (int i = 0; i < 50; ++i) {
        t = pushBlock(s, "a", t, 1.0e6, 1000);
    }
    s.maintain();

    const size_t want = static_cast<size_t>(1.0e6 * 0.2 * SignalStore::kRingMargin);
    EXPECT_NEAR(static_cast<double>(s.capacity("a")), static_cast<double>(want),
                0.1 * static_cast<double>(want));
}

TEST(SignalStore, MaintainPreservesTheSamplesAlreadyBuffered) {
    SignalStore s;
    s.setSignals({scalarMeta("a")});
    s.setWindowSec(0.2);
    double t = 0.0;
    for (int i = 0; i < 50; ++i) {
        t = pushBlock(s, "a", t, 1.0e6, 1000);
    }
    Series before;
    const size_t n = s.readLast("a", 1000, before);
    ASSERT_EQ(n, 1000u);

    s.maintain();

    Series after;
    ASSERT_EQ(s.readLast("a", 1000, after), 1000u) << "resize threw the ring away";
    for (size_t i = 0; i < n; ++i) {
        ASSERT_DOUBLE_EQ(after.t[i], before.t[i]) << "sample " << i << " moved";
        ASSERT_DOUBLE_EQ(after.v[i], before.v[i]);
    }
}

TEST(SignalStore, MaintainShrinksTheRingWhenTheWindowShrinks) {
    SignalStore s;
    s.setSignals({scalarMeta("a")});
    s.setWindowSec(1.0);
    double t = 0.0;
    for (int i = 0; i < 50; ++i) {
        t = pushBlock(s, "a", t, 1.0e6, 1000);
    }
    s.maintain();
    const size_t big = s.capacity("a");

    s.setWindowSec(0.01);
    s.maintain();
    EXPECT_LT(s.capacity("a"), big / 2u);
    EXPECT_GE(s.capacity("a"), SignalStore::kMinRingPoints);
}

TEST(SignalStore, MaintainClampsToTheMinimumAndTheMaximum) {
    SignalStore s;
    s.setSignals({scalarMeta("slow")});
    s.setWindowSec(1e-6);
    double t = 0.0;
    for (int i = 0; i < 50; ++i) {
        t = pushBlock(s, "slow", t, 10.0, 2);
    }
    s.maintain();
    EXPECT_EQ(s.capacity("slow"), SignalStore::kMinRingPoints);

    SignalStore fast;
    fast.setSignals({scalarMeta("fast")});
    fast.setWindowSec(3600.0);
    t = 0.0;
    for (int i = 0; i < 50; ++i) {
        t = pushBlock(fast, "fast", t, 1.0e6, 1000);
    }
    fast.maintain();
    EXPECT_EQ(fast.capacity("fast"), SignalStore::kMaxRingPoints);
}

// Rings are not allowed to sum past the global budget, however many signals
// the streamer publishes.
TEST(SignalStore, MaintainSharesTheGlobalBudgetBetweenSignals) {
    SignalStore s;
    std::vector<SignalMeta> metas;
    for (int i = 0; i < 8; ++i) {
        metas.push_back(scalarMeta("s" + std::to_string(i)));
    }
    s.setSignals(metas);
    s.setWindowSec(10.0);
    for (int i = 0; i < 8; ++i) {
        double t = 0.0;
        for (int b = 0; b < 50; ++b) {
            t = pushBlock(s, "s" + std::to_string(i), t, 1.0e6, 1000);
        }
    }
    s.maintain();

    size_t total = 0u;
    for (int i = 0; i < 8; ++i) {
        total += s.capacity("s" + std::to_string(i));
    }
    EXPECT_LE(total, SignalStore::kTotalPointBudget);
    EXPECT_GT(total, SignalStore::kTotalPointBudget / 2u) << "budget left unused";
}

// A resize on every maintain() would clear-and-refill 64 MB per call at 60 Hz.
TEST(SignalStore, MaintainIsAStableNoOpWhenNothingChanged) {
    SignalStore s;
    s.setSignals({scalarMeta("a")});
    s.setWindowSec(0.2);
    double t = 0.0;
    for (int i = 0; i < 50; ++i) {
        t = pushBlock(s, "a", t, 1.0e6, 1000);
    }
    s.maintain();
    const size_t cap = s.capacity("a");
    for (int i = 0; i < 10; ++i) {
        t = pushBlock(s, "a", t, 1.0e6, 1000);
        s.maintain();
    }
    EXPECT_EQ(s.capacity("a"), cap);
}

TEST(SignalStore, ProfileKeepsOnlyTheLatestSnapshot) {
    SignalStore s;
    SignalMeta m = scalarMeta("vec");
    m.numCols          = 4;
    m.profileOverride  = true;
    s.setSignals({m});

    const double a[] = {1.0, 2.0, 3.0, 4.0};
    const double b[] = {5.0, 6.0, 7.0, 8.0};
    s.pushProfile("vec", 1.0, a, 4);
    s.pushProfile("vec", 2.0, b, 4);

    Profile p;
    ASSERT_TRUE(s.readProfile("vec", p));
    EXPECT_DOUBLE_EQ(p.time, 2.0);
    ASSERT_EQ(p.v.size(), 4u);
    EXPECT_DOUBLE_EQ(p.v[0], 5.0);
    EXPECT_DOUBLE_EQ(p.x[3], 3.0) << "x must be the element index";
}

TEST(SignalStore, CaptureIsPublishedAndReadBackWithARisingSequence) {
    SignalStore s;
    EXPECT_EQ(s.captureSeq(), 0u);
    Capture none;
    EXPECT_FALSE(s.readCapture(none));

    Capture c;
    c.trigTime = 5.0;
    c.t0 = 4.9;
    c.t1 = 5.1;
    c.names.push_back("a");
    c.series.emplace_back();
    c.series[0].t = {4.9, 5.0, 5.1};
    c.series[0].v = {0.0, 1.0, 0.0};
    s.publishCapture(std::move(c));

    EXPECT_EQ(s.captureSeq(), 1u);
    Capture got;
    ASSERT_TRUE(s.readCapture(got));
    EXPECT_EQ(got.seq, 1u);
    EXPECT_DOUBLE_EQ(got.trigTime, 5.0);
    ASSERT_EQ(got.series.size(), 1u);
    EXPECT_EQ(got.series[0].t.size(), 3u);

    Capture c2;
    c2.trigTime = 6.0;
    s.publishCapture(std::move(c2));
    EXPECT_EQ(s.captureSeq(), 2u);
}

// Not a proof of correctness, but it catches an unlocked member or a
// use-after-resize. Run the suite under -fsanitize=thread occasionally.
TEST(SignalStore, ConcurrentPushAndReadDoNotCrash) {
    SignalStore s;
    s.setSignals({scalarMeta("a")});
    s.setWindowSec(0.05);
    std::atomic<bool> stop(false);

    std::thread writer([&] {
        double t = 0.0;
        while (!stop.load()) {
            t = pushBlock(s, "a", t, 1.0e6, 1000);
            s.maintain();
        }
    });

    Series out;
    for (int i = 0; i < 2000; ++i) {
        s.readLast("a", 4096, out);
        double o = 0.0, nw = 0.0;
        (void)s.span("a", o, nw);
        (void)s.rate("a");
    }
    stop.store(true);
    writer.join();

    /* Not just "it did not crash": the store must still be coherent after the
       race. The window is 0.05 s at 1 MHz, so the ring spans at most
       0.05 * kRingMargin seconds, and readLast was capped at 4096 points. */
    double oldest = 0.0, newest = 0.0;
    ASSERT_TRUE(s.span("a", oldest, newest));
    EXPECT_GE(newest, oldest);
    EXPECT_LE(newest - oldest, 0.05 * SignalStore::kRingMargin * 1.5);
    EXPECT_LE(out.size(), 4096u);
    EXPECT_EQ(out.t.size(), out.v.size());
    for (size_t i = 1; i < out.size(); ++i) {
        EXPECT_GE(out.t[i], out.t[i - 1]) << "timestamps went backwards at " << i;
    }
}
  • Step 2: Run the tests to verify they fail
cd Client/udpscope && cmake --build build -j 2>&1 | tail -5

Expected: FAIL — SignalStore.h: No such file or directory.

  • Step 3: Write the header

Create Client/udpscope/SignalStore.h:

/**
 * @file SignalStore.h
 * @brief The single mutex-guarded handoff between the receiver and GUI threads.
 *
 * Owns one ring per scalar signal, the latest snapshot per vector-profile
 * signal, and the most recent completed capture. Every accessor takes the
 * same lock; the reused StreamHubClient::SignalBuffer has none of its own
 * despite what its comment claims.
 */
#ifndef UDPSCOPE_SIGNALSTORE_H
#define UDPSCOPE_SIGNALSTORE_H

#include "SignalBuffer.h"
#include "Types.h"

#include <cstdint>
#include <map>
#include <mutex>
#include <string>
#include <vector>

namespace udpscope {

/** Latest snapshot of a true vector signal, plotted against element index. */
struct Profile {
    double time = 0.0;
    std::vector<double> x, v;
};

/** One completed trigger capture, copied out of the rings by the receiver. */
struct Capture {
    uint64_t seq = 0u;
    double   trigTime = 0.0;
    double   t0 = 0.0;
    double   t1 = 0.0;
    std::vector<std::string> names;
    std::vector<Series>      series;
};

class SignalStore {
public:
    /**
     * Ring length as a multiple of the trigger window.
     *
     * The window alone is not enough: the pre-window must be resident before
     * the edge arrives, the post-window fills after it, the GUI harvests a
     * repaint later (Trigger::kHarvestMarginSec), and the rate estimate that
     * sizes the ring lags one step behind a rate change. Undersized rings are
     * what truncated every capture in the Go hub. Do not reduce this without
     * re-running the capture tests.
     */
    static constexpr double kRingMargin = 4.0;
    /** Floor, so a slow signal still shows a usable live trace. */
    static constexpr size_t kMinRingPoints = 4096u;
    /** Per-signal ceiling: 4 M points is 64 MB of (t,v) pairs. */
    static constexpr size_t kMaxRingPoints = 4000000u;
    /** Ceiling on the sum of all rings: 16 M points is 256 MB. */
    static constexpr size_t kTotalPointBudget = 16000000u;

    /** Replace the signal set. Clears all rings and bumps generation(). */
    void setSignals(const std::vector<SignalMeta>& metas);
    std::vector<SignalMeta> signals() const;
    /** Increments on every setSignals(); the GUI uses it to drop stale state. */
    uint64_t generation() const;

    /** Append samples. Unknown names are dropped, never auto-created. */
    void push(const std::string& name, const double* t, const double* v, size_t n);
    void pushProfile(const std::string& name, double time, const double* v, size_t n);

    size_t readLast(const std::string& name, size_t n, Series& out) const;
    size_t readRange(const std::string& name, double t0, double t1, Series& out) const;
    bool   readProfile(const std::string& name, Profile& out) const;

    /** @return false when the ring is empty. */
    bool   span(const std::string& name, double& oldest, double& newest) const;
    /** Smoothed samples-per-second, independent of the ring's current length. */
    double rate(const std::string& name) const;
    size_t capacity(const std::string& name) const;

    void   setWindowSec(double windowSec);
    double windowSec() const;
    /** Re-size rings whose target has drifted. Call once per receiver poll. */
    void   maintain();

    void     publishCapture(Capture&& c);
    uint64_t captureSeq() const;
    bool     readCapture(Capture& out) const;

private:
    struct Entry {
        StreamHubClient::SignalBuffer buf{kMinRingPoints};
        double  rate     = 0.0;
        double  lastT    = 0.0;
        bool    haveLast = false;
        bool    profile  = false;
        Profile snapshot;
    };

    /** Caller holds mu_. */
    size_t targetCapacity(const Entry& e, size_t ringCount) const;

    mutable std::mutex mu_;
    std::vector<SignalMeta>     metas_;
    std::map<std::string, Entry> entries_;
    double   windowSec_  = 0.1;
    uint64_t generation_ = 0u;
    Capture  capture_;
    uint64_t captureSeq_ = 0u;
};

} /* namespace udpscope */

#endif /* UDPSCOPE_SIGNALSTORE_H */
  • Step 4: Write the implementation

Create Client/udpscope/SignalStore.cpp:

#include "SignalStore.h"

#include <algorithm>
#include <cmath>

namespace udpscope {

namespace {

/** Smoothing factor for the per-signal rate estimate (one block per update). */
const double kRateAlpha = 0.1;
/** Relative drift that justifies paying for a resize. */
const double kResizeHysteresis = 0.25;

double oldestOf(const StreamHubClient::SignalBuffer& b) {
    const size_t idx = (b.head + b.capacity - b.count) % b.capacity;
    return b.t[idx];
}

double newestOf(const StreamHubClient::SignalBuffer& b) {
    const size_t idx = (b.head + b.capacity - 1u) % b.capacity;
    return b.t[idx];
}

} /* namespace */

void SignalStore::setSignals(const std::vector<SignalMeta>& metas) {
    std::lock_guard<std::mutex> lk(mu_);
    metas_ = metas;
    entries_.clear();
    for (size_t i = 0u; i < metas_.size(); ++i) {
        Entry e;
        e.profile = metas_[i].isVectorProfile();
        entries_.emplace(metas_[i].name, std::move(e));
    }
    ++generation_;
}

std::vector<SignalMeta> SignalStore::signals() const {
    std::lock_guard<std::mutex> lk(mu_);
    return metas_;
}

uint64_t SignalStore::generation() const {
    std::lock_guard<std::mutex> lk(mu_);
    return generation_;
}

void SignalStore::push(const std::string& name, const double* t, const double* v, size_t n) {
    if (n == 0u) {
        return;
    }
    std::lock_guard<std::mutex> lk(mu_);
    std::map<std::string, Entry>::iterator it = entries_.find(name);
    if (it == entries_.end()) {
        return;
    }
    Entry& e = it->second;
    for (size_t i = 0u; i < n; ++i) {
        e.buf.push(t[i], v[i]);
    }

    /* Rate from this block's span, so it survives a ring resize. */
    const double last = t[n - 1u];
    if (e.haveLast) {
        const double dt = last - e.lastT;
        if (dt > 0.0) {
            const double inst = static_cast<double>(n) / dt;
            e.rate = (e.rate > 0.0) ? (1.0 - kRateAlpha) * e.rate + kRateAlpha * inst
                                    : inst;
        }
    }
    e.lastT    = last;
    e.haveLast = true;
}

void SignalStore::pushProfile(const std::string& name, double time,
                              const double* v, size_t n) {
    std::lock_guard<std::mutex> lk(mu_);
    std::map<std::string, Entry>::iterator it = entries_.find(name);
    if (it == entries_.end()) {
        return;
    }
    Profile& p = it->second.snapshot;
    p.time = time;
    p.x.resize(n);
    p.v.resize(n);
    for (size_t i = 0u; i < n; ++i) {
        p.x[i] = static_cast<double>(i);
        p.v[i] = v[i];
    }
}

size_t SignalStore::readLast(const std::string& name, size_t n, Series& out) const {
    std::lock_guard<std::mutex> lk(mu_);
    std::map<std::string, Entry>::const_iterator it = entries_.find(name);
    if (it == entries_.end()) {
        out.clear();
        return 0u;
    }
    return it->second.buf.readLast(n, out.t, out.v);
}

size_t SignalStore::readRange(const std::string& name, double t0, double t1,
                              Series& out) const {
    std::lock_guard<std::mutex> lk(mu_);
    std::map<std::string, Entry>::const_iterator it = entries_.find(name);
    if (it == entries_.end()) {
        out.clear();
        return 0u;
    }
    return it->second.buf.readRange(t0, t1, out.t, out.v);
}

bool SignalStore::readProfile(const std::string& name, Profile& out) const {
    std::lock_guard<std::mutex> lk(mu_);
    std::map<std::string, Entry>::const_iterator it = entries_.find(name);
    if (it == entries_.end() || it->second.snapshot.v.empty()) {
        return false;
    }
    out = it->second.snapshot;
    return true;
}

bool SignalStore::span(const std::string& name, double& oldest, double& newest) const {
    std::lock_guard<std::mutex> lk(mu_);
    std::map<std::string, Entry>::const_iterator it = entries_.find(name);
    if (it == entries_.end() || it->second.buf.count == 0u) {
        return false;
    }
    oldest = oldestOf(it->second.buf);
    newest = newestOf(it->second.buf);
    return true;
}

double SignalStore::rate(const std::string& name) const {
    std::lock_guard<std::mutex> lk(mu_);
    std::map<std::string, Entry>::const_iterator it = entries_.find(name);
    return (it == entries_.end()) ? 0.0 : it->second.rate;
}

size_t SignalStore::capacity(const std::string& name) const {
    std::lock_guard<std::mutex> lk(mu_);
    std::map<std::string, Entry>::const_iterator it = entries_.find(name);
    return (it == entries_.end()) ? 0u : it->second.buf.capacity;
}

void SignalStore::setWindowSec(double windowSec) {
    std::lock_guard<std::mutex> lk(mu_);
    if (windowSec > 0.0) {
        windowSec_ = windowSec;
    }
}

double SignalStore::windowSec() const {
    std::lock_guard<std::mutex> lk(mu_);
    return windowSec_;
}

size_t SignalStore::targetCapacity(const Entry& e, size_t ringCount) const {
    if (e.rate <= 0.0) {
        return kMinRingPoints;
    }
    const double want = e.rate * windowSec_ * kRingMargin;
    size_t cap = (want >= static_cast<double>(kMaxRingPoints))
                     ? kMaxRingPoints
                     : static_cast<size_t>(want);
    const size_t share = kTotalPointBudget / std::max<size_t>(1u, ringCount);
    cap = std::min(cap, share);
    cap = std::min(cap, kMaxRingPoints);
    return std::max(cap, kMinRingPoints);
}

void SignalStore::maintain() {
    std::lock_guard<std::mutex> lk(mu_);
    const size_t ringCount = entries_.size();
    std::vector<double> t, v;
    for (std::map<std::string, Entry>::iterator it = entries_.begin();
         it != entries_.end(); ++it) {
        Entry& e = it->second;
        if (e.profile) {
            continue;
        }
        const size_t want = targetCapacity(e, ringCount);
        const double cur  = static_cast<double>(e.buf.capacity);
        if (std::fabs(static_cast<double>(want) - cur) <= kResizeHysteresis * cur) {
            continue;
        }
        /* setCapacity() clears, so read the survivors out and push them back. */
        const size_t keep = std::min(want, e.buf.count);
        e.buf.readLast(keep, t, v);
        e.buf.setCapacity(want);
        for (size_t i = 0u; i < t.size(); ++i) {
            e.buf.push(t[i], v[i]);
        }
    }
}

void SignalStore::publishCapture(Capture&& c) {
    std::lock_guard<std::mutex> lk(mu_);
    ++captureSeq_;
    capture_     = std::move(c);
    capture_.seq = captureSeq_;
}

uint64_t SignalStore::captureSeq() const {
    std::lock_guard<std::mutex> lk(mu_);
    return captureSeq_;
}

bool SignalStore::readCapture(Capture& out) const {
    std::lock_guard<std::mutex> lk(mu_);
    if (captureSeq_ == 0u) {
        return false;
    }
    out = capture_;
    return true;
}

} /* namespace udpscope */
  • Step 5: Register the source with CMake

In Client/udpscope/CMakeLists.txt, extend CORE_SOURCES:

set(CORE_SOURCES
    Decimate.cpp
    PaneTree.cpp
    TimeBase.cpp
    FrameDecoder.cpp
    Trigger.cpp
    SignalStore.cpp
)

The tests now use threads, so link them:

find_package(Threads REQUIRED)
target_link_libraries(udpscope_core PUBLIC Threads::Threads)
  • Step 6: Run the tests and verify they pass
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='SignalStore*'

Expected: PASS, 15 tests.

If MaintainSharesTheGlobalBudgetBetweenSignals reports an unused budget, check that targetCapacity() divides by the number of rings, not the number of assigned signals.

  • Step 7: Commit
git add Client/udpscope/SignalStore.h Client/udpscope/SignalStore.cpp \
        Client/udpscope/tests/SignalStoreTest.cpp Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): mutex-guarded signal store with window-driven ring sizing"

Task 7: Receiver — frame path (no thread, no socket)

The receiver's decision-making is factored out of the C callback into plain methods so it can be driven from tests with synthetic frames. This task writes those methods and their tests; Task 8 bolts the thread and the C client onto the same class.

Files:

  • Create: Client/udpscope/Receiver.h
  • Create: Client/udpscope/Receiver.cpp
  • Modify: Client/udpscope/CMakeLists.txt (add Receiver.cpp to CORE_SOURCES)
  • Test: Client/udpscope/tests/ReceiverTest.cpp

Interfaces:

  • Consumes: SignalStore, Capture (Task 6); Trigger, TrigConfig, TrigState, TrigMode (Task 5); FrameDecoder (Task 4); SignalMeta, FrameView, Series (Tasks 1/4).
  • Produces:
struct TrigStatus {
    TrigState state    = TrigState::Idle;
    double    fill     = 0.0;
    double    trigTime = 0.0;
    uint64_t  captures = 0u;
};

class Receiver {
public:
    explicit Receiver(SignalStore& store);
    void handleConfig(const std::vector<SignalMeta>& metas);
    void handleFrame(const FrameView& f);
    void       setTrigConfig(const TrigConfig& c);
    TrigConfig trigConfig() const;
    void arm();
    void disarm();
    void rearm();
    TrigStatus trigStatus() const;
};

Threading contract, stated once and relied on everywhere below: handleConfig/handleFrame run only on the receiver thread. The GUI calls only setTrigConfig/arm/disarm/rearm/trigConfig/trigStatus, which take ctlMu_ and never touch the Trigger directly — commands are queued and applied at the top of the next frame. ctlMu_ is never held across a SignalStore call, so the two locks cannot deadlock.

  • Step 1: Write the failing tests

Create Client/udpscope/tests/ReceiverTest.cpp:

#include "Receiver.h"
#include <gtest/gtest.h>
#include <cmath>
#include <string>
#include <vector>

using namespace udpscope;

namespace {

// Owns the value arrays for one synthetic frame and hands out a FrameView
// pointing into them. Keep the Synth alive for as long as the view is used.
struct Synth {
    std::vector<std::vector<double> > vals;
    std::vector<const double*>        ptrs;
    std::vector<uint32_t>             counts;

    void add(const std::vector<double>& v) { vals.push_back(v); }

    FrameView view(uint32_t counter, uint64_t hrt, double recvTime,
                   uint32_t numSamples) {
        ptrs.clear();
        counts.clear();
        for (size_t i = 0; i < vals.size(); ++i) {
            ptrs.push_back(vals[i].data());
            counts.push_back(static_cast<uint32_t>(vals[i].size()));
        }
        FrameView f;
        f.counter    = counter;
        f.hrt        = hrt;
        f.recvTime   = recvTime;
        f.numSamples = numSamples;
        f.numSignals = static_cast<uint32_t>(vals.size());
        f.values     = ptrs.data();
        f.counts     = counts.data();
        return f;
    }
};

SignalMeta scalar(const std::string& name) {
    SignalMeta m;
    m.name     = name;
    m.typeCode = 8;      /* float32 */
    m.numRows  = 1;
    m.numCols  = 1;
    m.timeMode = kTimePacket;
    return m;
}

// A uint64 nanosecond time signal, as TimeArrayGAM emits.
SignalMeta timeSignal(const std::string& name, uint32_t nElems) {
    SignalMeta m;
    m.name     = name;
    m.typeCode = 6;      /* uint64 -> 1e-9 scale */
    m.numRows  = 1;
    m.numCols  = nElems;
    m.timeMode = kTimeFullArray;
    return m;
}

// A burst signal anchored on its first sample, paired with time signal 0.
SignalMeta burst(const std::string& name, uint32_t nElems, double rate) {
    SignalMeta m;
    m.name          = name;
    m.typeCode      = 8;
    m.numRows       = 1;
    m.numCols       = nElems;
    m.timeMode      = kTimeFirstSample;
    m.samplingRate  = rate;
    m.timeSignalIdx = 0u;
    return m;
}

} // namespace

TEST(Receiver, ConfigCreatesTheStoreSignals) {
    SignalStore store;
    Receiver rx(store);
    rx.handleConfig({scalar("a"), scalar("b")});
    EXPECT_EQ(store.signals().size(), 2u);
    EXPECT_EQ(store.capacity("a"), SignalStore::kMinRingPoints);
}

TEST(Receiver, ScalarFramesLandInTheRingAtArrivalTime) {
    SignalStore store;
    Receiver rx(store);
    rx.handleConfig({scalar("a")});

    for (int i = 0; i < 3; ++i) {
        Synth s;
        s.add({static_cast<double>(i)});
        FrameView f = s.view(static_cast<uint32_t>(i), 0u, 1.0 + 0.01 * i, 1u);
        rx.handleFrame(f);
    }

    Series out;
    ASSERT_EQ(store.readLast("a", 10, out), 3u);
    EXPECT_NEAR(out.t[0], 1.00, 1e-9);
    EXPECT_NEAR(out.t[2], 1.02, 1e-9);
    EXPECT_DOUBLE_EQ(out.v[2], 2.0);
}

// A burst arrives as one packet but must be unrolled onto the time axis, or
// the scope draws a staircase at the packet rate instead of the waveform.
TEST(Receiver, BurstsAreUnrolledOntoTheTimeAxis) {
    SignalStore store;
    Receiver rx(store);
    const uint32_t N = 8u;
    rx.handleConfig({timeSignal("t", N), burst("a", N, 10000.0)});

    Synth s;
    std::vector<double> ts(N), vs(N);
    for (uint32_t e = 0; e < N; ++e) {
        ts[e] = 1.0e9 + static_cast<double>(e) * 1.0e5;  /* 1 s + e * 0.1 ms, in ns */
        vs[e] = static_cast<double>(e);
    }
    s.add(ts);
    s.add(vs);
    FrameView f = s.view(0u, 0u, 50.0, N);
    rx.handleFrame(f);

    Series out;
    ASSERT_EQ(store.readLast("a", 100, out), N);
    // Rule 2 anchors on the time signal and spreads by 1/10 kHz = 0.1 ms.
    for (uint32_t e = 1; e < N; ++e) {
        EXPECT_NEAR(out.t[e] - out.t[e - 1u], 1.0e-4, 1e-9) << "element " << e;
    }
    EXPECT_DOUBLE_EQ(out.v[N - 1u], static_cast<double>(N - 1u));
}

TEST(Receiver, VectorProfilesGoToTheSnapshotSlotNotTheRing) {
    SignalStore store;
    Receiver rx(store);
    SignalMeta m       = scalar("vec");
    m.numCols          = 4u;
    m.timeMode         = kTimePacket;
    m.profileOverride  = true;
    ASSERT_TRUE(m.isVectorProfile());
    rx.handleConfig({m});

    Synth s;
    s.add({1.0, 2.0, 3.0, 4.0});
    FrameView f = s.view(0u, 0u, 7.0, 1u);
    rx.handleFrame(f);

    Series ring;
    EXPECT_EQ(store.readLast("vec", 10, ring), 0u) << "profile leaked into the ring";
    Profile p;
    ASSERT_TRUE(store.readProfile("vec", p));
    EXPECT_DOUBLE_EQ(p.time, 7.0);
    ASSERT_EQ(p.v.size(), 4u);
    EXPECT_DOUBLE_EQ(p.v[3], 4.0);
}

TEST(Receiver, TriggerCommandsTakeEffectOnTheNextFrame) {
    SignalStore store;
    Receiver rx(store);
    rx.handleConfig({scalar("a")});

    TrigConfig c;
    c.signalName = "a";
    c.threshold  = 0.5;
    c.windowSec  = 0.02;
    c.prePercent = 25.0;
    rx.setTrigConfig(c);
    EXPECT_EQ(rx.trigStatus().state, TrigState::Idle);

    rx.arm();
    EXPECT_EQ(rx.trigStatus().state, TrigState::Idle) << "applied without a frame";

    Synth s;
    s.add({0.0});
    FrameView f = s.view(0u, 0u, 1.0, 1u);
    rx.handleFrame(f);
    EXPECT_EQ(rx.trigStatus().state, TrigState::Armed);

    EXPECT_DOUBLE_EQ(store.windowSec(), 0.02) << "window not forwarded to the store";
}

// The whole trigger path, end to end: a step on the trigger signal produces a
// capture holding every scalar signal over the configured window.
TEST(Receiver, AReadyCaptureIsPublishedWithEverySignal) {
    SignalStore store;
    Receiver rx(store);
    rx.handleConfig({scalar("a"), scalar("b")});

    TrigConfig c;
    c.signalName = "a";
    c.edge       = Edge::Rising;
    c.threshold  = 0.5;
    c.windowSec  = 0.020;      /* pre 0.005, post 0.015 */
    c.prePercent = 25.0;
    c.mode       = TrigMode::Normal;
    rx.setTrigConfig(c);
    rx.arm();

    // 1 kHz of scalar frames; "a" steps high at t = 1.100.
    double t = 1.000;
    for (int i = 0; i < 300; ++i, t += 0.001) {
        Synth s;
        s.add({(t >= 1.100) ? 1.0 : 0.0});
        s.add({static_cast<double>(i)});
        FrameView f = s.view(static_cast<uint32_t>(i), 0u, t, 1u);
        rx.handleFrame(f);
    }

    EXPECT_EQ(rx.trigStatus().captures, 1u);
    Capture cap;
    ASSERT_TRUE(store.readCapture(cap));
    EXPECT_NEAR(cap.trigTime, 1.0995, 1e-6);
    ASSERT_EQ(cap.names.size(), 2u);
    ASSERT_EQ(cap.series.size(), 2u);
    EXPECT_GT(cap.series[0].t.size(), 10u);
    EXPECT_LE(cap.series[0].t.front(), cap.trigTime);
    EXPECT_GE(cap.series[0].t.back(),  cap.trigTime);
    EXPECT_NEAR(cap.t0, cap.trigTime - 0.005, 1e-9);
    EXPECT_NEAR(cap.t1, cap.trigTime + 0.015, 1e-9);
}

TEST(Receiver, NormalModeKeepsCapturingAndSingleModeStops) {
    SignalStore store;
    Receiver rx(store);
    rx.handleConfig({scalar("a")});

    TrigConfig c;
    c.signalName = "a";
    c.threshold  = 0.5;
    c.windowSec  = 0.020;
    c.prePercent = 25.0;
    c.mode       = TrigMode::Normal;
    rx.setTrigConfig(c);
    rx.arm();

    // Square wave: 50 ms high, 50 ms low, sampled at 1 kHz for 1 s.
    double t = 1.000;
    for (int i = 0; i < 1000; ++i, t += 0.001) {
        Synth s;
        s.add({((i / 50) % 2 == 0) ? 0.0 : 1.0});
        FrameView f = s.view(static_cast<uint32_t>(i), 0u, t, 1u);
        rx.handleFrame(f);
    }
    const uint64_t normalCaptures = rx.trigStatus().captures;
    EXPECT_GE(normalCaptures, 5u) << "normal mode stopped re-arming";
    EXPECT_EQ(rx.trigStatus().state, TrigState::Armed);

    c.mode = TrigMode::Single;
    rx.setTrigConfig(c);      /* resets the FSM to Idle */
    rx.arm();
    for (int i = 0; i < 1000; ++i, t += 0.001) {
        Synth s;
        s.add({((i / 50) % 2 == 0) ? 0.0 : 1.0});
        FrameView f = s.view(static_cast<uint32_t>(i), 0u, t, 1u);
        rx.handleFrame(f);
    }
    EXPECT_EQ(rx.trigStatus().state, TrigState::Held);
    EXPECT_EQ(rx.trigStatus().captures, normalCaptures + 1u);
}

// A streamer restart re-sends CONFIG. Old samples are on the old time base and
// old signal set, so they must not survive.
TEST(Receiver, AMidStreamReconfigureDropsTheOldSamples) {
    SignalStore store;
    Receiver rx(store);
    rx.handleConfig({scalar("a")});
    Synth s;
    s.add({1.0});
    FrameView f = s.view(0u, 0u, 1.0, 1u);
    rx.handleFrame(f);
    Series out;
    ASSERT_EQ(store.readLast("a", 10, out), 1u);

    rx.handleConfig({scalar("a"), scalar("b")});
    EXPECT_EQ(store.readLast("a", 10, out), 0u);
    EXPECT_EQ(store.signals().size(), 2u);
    EXPECT_EQ(rx.trigStatus().state, TrigState::Idle) << "trigger kept across CONFIG";
}

// packetBurst() cannot stamp the first packet of a stream — there is no
// previous arrival to span from. Those samples must be dropped, not stored at
// invented times.
TEST(Receiver, UnstampableSamplesAreDroppedRatherThanInvented) {
    SignalStore store;
    Receiver rx(store);
    SignalMeta m = scalar("a");
    m.numCols    = 4u;         /* PACKET burst, no time signal, no rate */
    rx.handleConfig({m});

    Synth s1;
    s1.add({1.0, 2.0, 3.0, 4.0});
    FrameView f1 = s1.view(0u, 0u, 1.0, 4u);
    rx.handleFrame(f1);
    Series out;
    EXPECT_EQ(store.readLast("a", 10, out), 0u) << "first packet stamped anyway";

    Synth s2;
    s2.add({5.0, 6.0, 7.0, 8.0});
    FrameView f2 = s2.view(1u, 0u, 1.01, 4u);
    rx.handleFrame(f2);
    ASSERT_EQ(store.readLast("a", 10, out), 4u);
    EXPECT_GT(out.t.front(), 1.0);
    EXPECT_NEAR(out.t.back(), 1.01, 1e-9);
}
  • Step 2: Run the tests to verify they fail
cd Client/udpscope && cmake --build build -j 2>&1 | tail -5

Expected: FAIL — Receiver.h: No such file or directory.

  • Step 3: Write the header

Create Client/udpscope/Receiver.h:

/**
 * @file Receiver.h
 * @brief Frame-path logic for the UDPS receiver.
 *
 * handleConfig()/handleFrame() run on the receiver thread only. The GUI thread
 * uses the trigger control methods, which queue their effect under ctlMu_ and
 * are applied at the top of the next frame. ctlMu_ is never held across a
 * SignalStore call.
 */
#ifndef UDPSCOPE_RECEIVER_H
#define UDPSCOPE_RECEIVER_H

#include "FrameDecoder.h"
#include "SignalStore.h"
#include "Trigger.h"
#include "Types.h"

#include <cstdint>
#include <mutex>
#include <string>
#include <vector>

namespace udpscope {

/** Snapshot of the trigger FSM, published for the GUI once per frame. */
struct TrigStatus {
    TrigState state    = TrigState::Idle;
    double    fill     = 0.0;
    double    trigTime = 0.0;
    uint64_t  captures = 0u;
};

class Receiver {
public:
    explicit Receiver(SignalStore& store) : store_(store) {}

    /* --- receiver thread ------------------------------------------------ */

    /** Apply a new signal set: resets the decoder, store and trigger. */
    void handleConfig(const std::vector<SignalMeta>& metas);
    /** Decode one frame into the store, feed the trigger, harvest a capture. */
    void handleFrame(const FrameView& f);

    /* --- GUI thread ----------------------------------------------------- */

    /** Also forwards windowSec to the store so the rings are resized. */
    void       setTrigConfig(const TrigConfig& c);
    TrigConfig trigConfig() const;
    void arm();
    void disarm();
    void rearm();
    TrigStatus trigStatus() const;

private:
    enum class Cmd { None, Arm, Disarm, Rearm };

    void applyPending();
    void harvestCapture();
    void publishStatus();

    SignalStore& store_;
    FrameDecoder decoder_;
    Trigger      trig_;
    std::vector<double> ts_;         /**< scratch, receiver thread only */

    mutable std::mutex ctlMu_;
    TrigConfig cfg_;
    bool       cfgDirty_ = false;
    Cmd        cmd_      = Cmd::None;
    TrigStatus status_;
};

} /* namespace udpscope */

#endif /* UDPSCOPE_RECEIVER_H */
  • Step 4: Write the implementation

Create Client/udpscope/Receiver.cpp:

#include "Receiver.h"

namespace udpscope {

void Receiver::handleConfig(const std::vector<SignalMeta>& metas) {
    decoder_.reset();
    decoder_.setSignals(metas);
    store_.setSignals(metas);
    /* Samples from before the reconfigure are on a different time base and a
       different signal set; a capture spanning both would be nonsense. */
    trig_.disarm();
    publishStatus();
}

void Receiver::applyPending() {
    TrigConfig cfg;
    bool       dirty = false;
    Cmd        cmd   = Cmd::None;
    {
        std::lock_guard<std::mutex> lk(ctlMu_);
        dirty     = cfgDirty_;
        cfg       = cfg_;
        cfgDirty_ = false;
        cmd       = cmd_;
        cmd_      = Cmd::None;
    }
    if (dirty) {
        trig_.setConfig(cfg);
    }
    switch (cmd) {
    case Cmd::Arm:    trig_.arm();    break;
    case Cmd::Disarm: trig_.disarm(); break;
    case Cmd::Rearm:  trig_.rearm();  break;
    case Cmd::None:   break;
    }
}

void Receiver::handleFrame(const FrameView& f) {
    applyPending();
    decoder_.beginFrame(f);

    const std::vector<SignalMeta>& metas = decoder_.signals();
    const std::string trigName = trig_.config().signalName;

    for (uint32_t i = 0u; i < metas.size() && i < f.numSignals; i++) {
        const SignalMeta& m     = metas[i];
        const uint32_t    count = (f.counts != nullptr) ? f.counts[i] : 0u;
        const double*     vals  = (f.values != nullptr) ? f.values[i] : nullptr;
        if (count == 0u || vals == nullptr) {
            continue;
        }

        if (m.isVectorProfile()) {
            /* Plot against element index; only the newest snapshot matters. */
            const uint32_t n    = m.numElements();
            const uint32_t take = (count >= n) ? n : count;
            store_.pushProfile(m.name, f.recvTime, vals + (count - take), take);
            continue;
        }

        if (!decoder_.timestamps(f, i, ts_) || ts_.size() != count) {
            continue;   /* unstampable: drop rather than invent times */
        }
        store_.push(m.name, ts_.data(), vals, count);

        if (!trigName.empty() && m.name == trigName) {
            double oldest = ts_[0];
            double newest = 0.0;
            (void) store_.span(m.name, oldest, newest);
            trig_.feed(ts_.data(), vals, count, oldest);
        }
    }

    if (trig_.captureReady()) {
        harvestCapture();
    }
    publishStatus();
}

void Receiver::harvestCapture() {
    const TrigConfig& c = trig_.config();
    Capture cap;
    cap.trigTime = trig_.trigTime();
    cap.t0       = cap.trigTime - c.preSec();
    cap.t1       = cap.trigTime + c.postSec();

    const std::vector<SignalMeta>& metas = decoder_.signals();
    for (size_t i = 0u; i < metas.size(); i++) {
        if (metas[i].isVectorProfile()) {
            continue;
        }
        Series s;
        store_.readRange(metas[i].name, cap.t0, cap.t1, s);
        cap.names.push_back(metas[i].name);
        cap.series.push_back(s);
    }
    store_.publishCapture(std::move(cap));
    trig_.captureTaken();
}

void Receiver::publishStatus() {
    std::lock_guard<std::mutex> lk(ctlMu_);
    status_.state    = trig_.state();
    status_.fill     = trig_.fillFraction();
    status_.trigTime = trig_.trigTime();
    status_.captures = store_.captureSeq();
}

void Receiver::setTrigConfig(const TrigConfig& c) {
    {
        std::lock_guard<std::mutex> lk(ctlMu_);
        cfg_      = c;
        cfgDirty_ = true;
        /* The FSM will reset on apply; reflect that immediately so the GUI does
           not show a stale Armed badge for a frame. */
        status_.state = TrigState::Idle;
        status_.fill  = 0.0;
    }
    /* Outside the lock: the store has its own mutex. */
    store_.setWindowSec(c.windowSec);
}

TrigConfig Receiver::trigConfig() const {
    std::lock_guard<std::mutex> lk(ctlMu_);
    return cfg_;
}

void Receiver::arm() {
    std::lock_guard<std::mutex> lk(ctlMu_);
    cmd_ = Cmd::Arm;
}

void Receiver::disarm() {
    std::lock_guard<std::mutex> lk(ctlMu_);
    cmd_ = Cmd::Disarm;
}

void Receiver::rearm() {
    std::lock_guard<std::mutex> lk(ctlMu_);
    cmd_ = Cmd::Rearm;
}

TrigStatus Receiver::trigStatus() const {
    std::lock_guard<std::mutex> lk(ctlMu_);
    return status_;
}

} /* namespace udpscope */

publishStatus() reads store_.captureSeq() while holding ctlMu_. That is the one place where the store lock is taken under ctlMu_; it is safe only because no SignalStore method ever calls back into Receiver. Keep it that way.

  • Step 5: Register the source with CMake
set(CORE_SOURCES
    Decimate.cpp
    PaneTree.cpp
    TimeBase.cpp
    FrameDecoder.cpp
    Trigger.cpp
    SignalStore.cpp
    Receiver.cpp
)
  • Step 6: Run the tests and verify they pass
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='Receiver*'

Expected: PASS, 9 tests.

If AReadyCaptureIsPublishedWithEverySignal reports zero captures, check that the fill gate is being fed the ring's oldest time and not the frame's first timestamp — the trigger cannot fire until preSec of history exists.

  • Step 7: Run the whole suite
cd Client/udpscope && ./build/udpscope_tests

Expected: PASS, all tests from Tasks 17.

  • Step 8: Commit
git add Client/udpscope/Receiver.h Client/udpscope/Receiver.cpp \
        Client/udpscope/tests/ReceiverTest.cpp Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): receiver frame path with trigger control and capture harvest"

Task 8: Receiver — thread, C client wiring and profile overrides

Bolt the socket and the thread onto Receiver, translate the C structs into the framework-free ones, and add the per-signal "this is a vector, not a burst" override the spec requires for ambiguous PACKET signals. The task ships udpscope_rxprobe, a console tool that runs the receiver against a real streamer and prints the link counters — that is how the socket path is verified, and it stays in the tree as a diagnostic.

Files:

  • Modify: Client/udpscope/Receiver.h (thread, options, link status, overrides)
  • Modify: Client/udpscope/Receiver.cpp (same)
  • Create: Client/udpscope/tools/rxprobe.cpp
  • Modify: Client/udpscope/CMakeLists.txt (add the udpscope_rxprobe target)
  • Test: Client/udpscope/tests/ReceiverLinkTest.cpp

Interfaces:

  • Consumes: everything from Task 7, plus the C API from Common/Client/c/udps_client.h (udps_client_config_init, udps_client_create, udps_client_set_callbacks, udps_client_poll, udps_client_destroy, udps_client_is_connected, udps_client_stats, udps_client_last_error).
  • Produces:
struct ReceiverOptions {
    std::string host = "127.0.0.1";
    uint16_t    port = 44500u;
    std::string multicastGroup;      // empty = unicast
    std::string interfaceAddr;
    uint16_t    dataPort = 0u;
    double      silenceTimeoutSec = 2.0;
};

struct LinkStatus {
    bool     running = false, connected = false, haveConfig = false;
    uint64_t packets = 0u, frames = 0u, configUpdates = 0u;
    uint64_t counterGaps = 0u, fragmentsDropped = 0u, reconnects = 0u;
    double   lastFrameWall = 0.0;
    std::string lastEvent;
};

// added to Receiver:
bool start(const ReceiverOptions& opt, std::string& err);
void stop();
bool running() const;
LinkStatus link() const;
void setProfileOverride(const std::string& signalName, bool isProfile);
bool profileOverride(const std::string& signalName) const;
  • Step 1: Write the failing tests

Create Client/udpscope/tests/ReceiverLinkTest.cpp:

#include "Receiver.h"
#include <gtest/gtest.h>
#include <string>
#include <vector>

using namespace udpscope;

namespace {

// A 4-element PACKET signal with no time signal and no declared rate: the one
// case the protocol leaves ambiguous between a time burst and a true vector.
SignalMeta ambiguous(const std::string& name) {
    SignalMeta m;
    m.name     = name;
    m.typeCode = 8;
    m.numRows  = 1;
    m.numCols  = 4;
    m.timeMode = kTimePacket;
    return m;
}

struct Synth {
    std::vector<std::vector<double> > vals;
    std::vector<const double*>        ptrs;
    std::vector<uint32_t>             counts;
    FrameView view(double recvTime) {
        ptrs.clear();
        counts.clear();
        for (size_t i = 0; i < vals.size(); ++i) {
            ptrs.push_back(vals[i].data());
            counts.push_back(static_cast<uint32_t>(vals[i].size()));
        }
        FrameView f;
        f.recvTime   = recvTime;
        f.numSamples = 4u;
        f.numSignals = static_cast<uint32_t>(vals.size());
        f.values     = ptrs.data();
        f.counts     = counts.data();
        return f;
    }
};

} // namespace

TEST(ReceiverLink, DefaultsToTreatingAnAmbiguousSignalAsABurst) {
    SignalStore store;
    Receiver rx(store);
    rx.handleConfig({ambiguous("a")});
    EXPECT_FALSE(store.signals()[0].isVectorProfile());
    EXPECT_FALSE(rx.profileOverride("a"));
}

TEST(ReceiverLink, ProfileOverrideIsAppliedToTheNextFrame) {
    SignalStore store;
    Receiver rx(store);
    rx.handleConfig({ambiguous("a")});
    rx.setProfileOverride("a", true);
    EXPECT_TRUE(rx.profileOverride("a"));

    Synth s;
    s.vals.push_back({1.0, 2.0, 3.0, 4.0});
    FrameView f1 = s.view(1.0);
    rx.handleFrame(f1);          /* applies the pending override, reconfigures */

    Synth s2;
    s2.vals.push_back({5.0, 6.0, 7.0, 8.0});
    FrameView f2 = s2.view(1.01);
    rx.handleFrame(f2);

    ASSERT_EQ(store.signals().size(), 1u);
    EXPECT_TRUE(store.signals()[0].isVectorProfile());
    Profile p;
    ASSERT_TRUE(store.readProfile("a", p));
    EXPECT_DOUBLE_EQ(p.v[0], 5.0);
    Series ring;
    EXPECT_EQ(store.readLast("a", 10, ring), 0u) << "still filling the ring";
}

// The streamer re-sends CONFIG on reconnect. The user's override is a UI
// choice and must outlive that.
TEST(ReceiverLink, ProfileOverrideSurvivesAReconfigure) {
    SignalStore store;
    Receiver rx(store);
    rx.handleConfig({ambiguous("a")});
    rx.setProfileOverride("a", true);
    Synth s;
    s.vals.push_back({1.0, 2.0, 3.0, 4.0});
    FrameView f = s.view(1.0);
    rx.handleFrame(f);
    ASSERT_TRUE(store.signals()[0].isVectorProfile());

    rx.handleConfig({ambiguous("a"), ambiguous("b")});
    ASSERT_EQ(store.signals().size(), 2u);
    EXPECT_TRUE(store.signals()[0].isVectorProfile()) << "override lost on CONFIG";
    EXPECT_FALSE(store.signals()[1].isVectorProfile());
}

TEST(ReceiverLink, StartsAndStopsCleanlyWithNoServerPresent) {
    SignalStore store;
    Receiver rx(store);
    ReceiverOptions opt;
    opt.host = "127.0.0.1";
    opt.port = 45999;            /* nothing is listening here */
    std::string err;
    ASSERT_TRUE(rx.start(opt, err)) << err;
    EXPECT_TRUE(rx.running());
    EXPECT_TRUE(rx.link().running);
    rx.stop();
    EXPECT_FALSE(rx.running());
    EXPECT_EQ(store.captureSeq(), 0u);
}

TEST(ReceiverLink, StartIsRejectedWhileAlreadyRunning) {
    SignalStore store;
    Receiver rx(store);
    ReceiverOptions opt;
    opt.port = 45999;
    std::string err;
    ASSERT_TRUE(rx.start(opt, err)) << err;
    EXPECT_FALSE(rx.start(opt, err));
    EXPECT_FALSE(err.empty());
    rx.stop();
}

TEST(ReceiverLink, StopIsSafeWhenNeverStarted) {
    SignalStore store;
    Receiver rx(store);
    rx.stop();
    EXPECT_FALSE(rx.running());
    EXPECT_FALSE(rx.link().running);
}
  • Step 2: Run the tests to verify they fail
cd Client/udpscope && cmake --build build -j 2>&1 | tail -5

Expected: FAIL — no member named 'start' in 'udpscope::Receiver'.

  • Step 3: Extend the header

In Client/udpscope/Receiver.h, add above the class:

#include <atomic>
#include <map>
#include <thread>

struct udps_client;   /* opaque, see Common/Client/c/udps_client.h */
/** Where to attach. Mirrors the fields of udps_client_config_t we expose. */
struct ReceiverOptions {
    std::string host = "127.0.0.1";
    uint16_t    port = 44500u;
    std::string multicastGroup;      /**< empty = unicast */
    std::string interfaceAddr;       /**< local interface IP, not a name */
    uint16_t    dataPort = 0u;       /**< 0 = server-chosen */
    double      silenceTimeoutSec = 2.0;
};

/** Link health, published once per poll for the status bar. */
struct LinkStatus {
    bool     running   = false;
    bool     connected = false;
    bool     haveConfig = false;
    uint64_t packets = 0u;
    uint64_t frames = 0u;
    uint64_t configUpdates = 0u;
    uint64_t counterGaps = 0u;
    uint64_t fragmentsDropped = 0u;
    uint64_t reconnects = 0u;
    double   lastFrameWall = 0.0;
    std::string lastEvent;
};

Add to the public section of Receiver:

    ~Receiver();

    /** Spawns the receiver thread. @return false and fills err on failure. */
    bool start(const ReceiverOptions& opt, std::string& err);
    /** Joins the thread. Safe to call when not running. */
    void stop();
    bool running() const { return running_.load(); }
    LinkStatus link() const;

    /**
     * Force a PACKET signal with more than one element to be read as a vector
     * profile (index axis) rather than a time burst. Applied on the next frame
     * and remembered across reconfigures.
     */
    void setProfileOverride(const std::string& signalName, bool isProfile);
    bool profileOverride(const std::string& signalName) const;

Add to the private section:

    static void onConfigC(const udps_signal_t* sigs, uint32_t n,
                          uint8_t publishMode, void* user);
    static void onDataC(const udps_frame_t* frame, void* user);
    static void onEventC(udps_event_t ev, const char* detail, void* user);

    void threadMain();
    void updateLink();
    std::vector<SignalMeta> withOverrides(const std::vector<SignalMeta>& in) const;

    udps_client_t*    client_ = nullptr;
    std::thread       thread_;
    std::atomic<bool> running_{false};
    ReceiverOptions   opt_;
    std::string       hostStr_, groupStr_, ifaceStr_;  /**< own the C strings */

    std::vector<SignalMeta> wireMetas_;   /**< last CONFIG, before overrides */
    std::vector<const double*> valPtrs_;  /**< per-frame scratch */
    std::vector<uint32_t>      valCounts_;

    mutable std::mutex linkMu_;
    LinkStatus         link_;

ctlMu_ also gains:

    std::map<std::string, bool> overrides_;
    bool overridesDirty_ = false;

The #include <mutex> and <vector> already present cover the rest. Receiver.cpp must additionally #include "udps_client.h", which resolves through the udpsclient target's PUBLIC include directory (${CCLIENT_DIR}, set up in Task 1).

  • Step 4: Rewrite handleConfig and applyPending for overrides

Replace those two functions in Client/udpscope/Receiver.cpp:

std::vector<SignalMeta> Receiver::withOverrides(
    const std::vector<SignalMeta>& in) const {
    std::lock_guard<std::mutex> lk(ctlMu_);
    std::vector<SignalMeta> out = in;
    for (size_t i = 0u; i < out.size(); i++) {
        std::map<std::string, bool>::const_iterator it = overrides_.find(out[i].name);
        out[i].profileOverride = (it != overrides_.end()) && it->second;
    }
    return out;
}

void Receiver::handleConfig(const std::vector<SignalMeta>& metas) {
    wireMetas_ = metas;
    const std::vector<SignalMeta> effective = withOverrides(metas);
    decoder_.reset();
    decoder_.setSignals(effective);
    store_.setSignals(effective);
    /* Samples from before the reconfigure are on a different time base and a
       different signal set; a capture spanning both would be nonsense. */
    trig_.disarm();
    publishStatus();
}

void Receiver::applyPending() {
    TrigConfig cfg;
    bool dirty = false, ovDirty = false;
    Cmd  cmd   = Cmd::None;
    {
        std::lock_guard<std::mutex> lk(ctlMu_);
        dirty           = cfgDirty_;
        cfg             = cfg_;
        cfgDirty_       = false;
        cmd             = cmd_;
        cmd_            = Cmd::None;
        ovDirty         = overridesDirty_;
        overridesDirty_ = false;
    }
    /* Overrides first: re-reading CONFIG resets the FSM, so an arm command in
       the same batch must be applied after it, not before. */
    if (ovDirty && !wireMetas_.empty()) {
        handleConfig(wireMetas_);
    }
    if (dirty) {
        trig_.setConfig(cfg);
    }
    switch (cmd) {
    case Cmd::Arm:    trig_.arm();    break;
    case Cmd::Disarm: trig_.disarm(); break;
    case Cmd::Rearm:  trig_.rearm();  break;
    case Cmd::None:   break;
    }
}

void Receiver::setProfileOverride(const std::string& signalName, bool isProfile) {
    std::lock_guard<std::mutex> lk(ctlMu_);
    overrides_[signalName] = isProfile;
    overridesDirty_        = true;
}

bool Receiver::profileOverride(const std::string& signalName) const {
    std::lock_guard<std::mutex> lk(ctlMu_);
    std::map<std::string, bool>::const_iterator it = overrides_.find(signalName);
    return (it != overrides_.end()) && it->second;
}

withOverrides() takes ctlMu_ and is called from handleConfig(), which is called from applyPending() after it has released ctlMu_. Do not move the call inside the lock scope.

  • Step 5: Add the thread and the C callbacks

Append to Client/udpscope/Receiver.cpp (and add #include "udps_client.h", #include <cstring> at the top):

Receiver::~Receiver() {
    stop();
}

void Receiver::onConfigC(const udps_signal_t* sigs, uint32_t n,
                         uint8_t publishMode, void* user) {
    (void) publishMode;
    Receiver* self = static_cast<Receiver*>(user);
    std::vector<SignalMeta> metas;
    metas.reserve(n);
    for (uint32_t i = 0u; i < n; i++) {
        SignalMeta m;
        m.name          = sigs[i].name;
        m.typeCode      = sigs[i].type_code;
        m.quantType     = sigs[i].quant_type;
        m.numRows       = (sigs[i].num_rows > 0u) ? sigs[i].num_rows : 1u;
        m.numCols       = (sigs[i].num_cols > 0u) ? sigs[i].num_cols : 1u;
        m.rangeMin      = sigs[i].range_min;
        m.rangeMax      = sigs[i].range_max;
        m.timeMode      = sigs[i].time_mode;
        m.samplingRate  = sigs[i].sampling_rate;
        m.timeSignalIdx = sigs[i].time_signal_idx;
        m.unit          = sigs[i].unit;
        metas.push_back(m);
    }
    self->handleConfig(metas);
    {
        std::lock_guard<std::mutex> lk(self->linkMu_);
        self->link_.haveConfig = true;
    }
}

void Receiver::onDataC(const udps_frame_t* frame, void* user) {
    Receiver* self = static_cast<Receiver*>(user);
    /* udps_frame_t stores an array of {ptr,count} structs; FrameView wants two
       parallel arrays. Reuse the scratch vectors instead of allocating per
       frame — this runs at the packet rate. */
    self->valPtrs_.resize(frame->num_signals);
    self->valCounts_.resize(frame->num_signals);
    for (uint32_t i = 0u; i < frame->num_signals; i++) {
        self->valPtrs_[i]   = frame->values[i].values;
        self->valCounts_[i] = frame->values[i].count;
    }
    FrameView f;
    f.counter    = frame->counter;
    f.hrt        = frame->hrt;
    f.recvTime   = frame->recv_time;
    f.numSamples = frame->num_samples;
    f.numSignals = frame->num_signals;
    f.values     = self->valPtrs_.data();
    f.counts     = self->valCounts_.data();
    self->handleFrame(f);

    std::lock_guard<std::mutex> lk(self->linkMu_);
    self->link_.lastFrameWall = frame->recv_time;
}

void Receiver::onEventC(udps_event_t ev, const char* detail, void* user) {
    Receiver* self = static_cast<Receiver*>(user);
    std::lock_guard<std::mutex> lk(self->linkMu_);
    switch (ev) {
    case UDPS_EVENT_CONNECTED:    self->link_.lastEvent = "connected";    break;
    case UDPS_EVENT_DISCONNECTED: self->link_.lastEvent = "disconnected"; break;
    case UDPS_EVENT_ERROR:        self->link_.lastEvent = "error";        break;
    }
    if (detail != nullptr && detail[0] != '\0') {
        self->link_.lastEvent += ": ";
        self->link_.lastEvent += detail;
    }
}

bool Receiver::start(const ReceiverOptions& opt, std::string& err) {
    if (running_.load()) {
        err = "receiver already running";
        return false;
    }
    opt_ = opt;
    /* udps_client_config_t holds borrowed const char*, so keep the storage
       alive for as long as the client. */
    hostStr_  = opt.host;
    groupStr_ = opt.multicastGroup;
    ifaceStr_ = opt.interfaceAddr;

    udps_client_config_t cfg;
    udps_client_config_init(&cfg);
    cfg.server_addr      = hostStr_.c_str();
    cfg.server_port      = opt.port;
    cfg.multicast_group  = groupStr_.empty() ? nullptr : groupStr_.c_str();
    cfg.interface_addr   = ifaceStr_.empty() ? nullptr : ifaceStr_.c_str();
    cfg.data_port        = opt.dataPort;
    cfg.silence_timeout_s = opt.silenceTimeoutSec;

    client_ = udps_client_create(&cfg);
    if (client_ == nullptr) {
        err = "udps_client_create failed (bad address or out of memory)";
        return false;
    }
    udps_client_set_callbacks(client_, &Receiver::onConfigC, &Receiver::onDataC,
                              &Receiver::onEventC, this);
    running_.store(true);
    {
        std::lock_guard<std::mutex> lk(linkMu_);
        link_ = LinkStatus();
        link_.running = true;
    }
    thread_ = std::thread(&Receiver::threadMain, this);
    return true;
}

void Receiver::stop() {
    if (!running_.exchange(false)) {
        return;
    }
    if (thread_.joinable()) {
        thread_.join();
    }
    udps_client_destroy(client_);
    client_ = nullptr;
    std::lock_guard<std::mutex> lk(linkMu_);
    link_.running   = false;
    link_.connected = false;
}

void Receiver::threadMain() {
    while (running_.load()) {
        /* 100 ms keeps stop() responsive; the poll returns as soon as a packet
           lands, so this is not a latency floor. */
        (void) udps_client_poll(client_, 100);
        store_.maintain();
        updateLink();
    }
}

void Receiver::updateLink() {
    udps_stats_t st;
    udps_client_stats(client_, &st);
    const int connected = udps_client_is_connected(client_);
    std::lock_guard<std::mutex> lk(linkMu_);
    link_.connected        = (connected != 0);
    link_.packets          = st.packets_received;
    link_.frames           = st.frames_delivered;
    link_.configUpdates    = st.config_updates;
    link_.counterGaps      = st.counter_gaps;
    link_.fragmentsDropped = st.fragments_dropped;
    link_.reconnects       = st.reconnects;
}

LinkStatus Receiver::link() const {
    std::lock_guard<std::mutex> lk(linkMu_);
    return link_;
}
  • Step 6: Write the probe tool

Create Client/udpscope/tools/rxprobe.cpp:

/**
 * @file rxprobe.cpp
 * @brief Headless smoke test for the UDPScope receiver.
 *
 * Attaches to a UDPStreamer, runs the real receiver thread and prints the link
 * counters and the per-signal ring state once a second. Use it to tell a
 * receiver problem from a rendering problem.
 */
#include "Receiver.h"
#include "SignalStore.h"

#include <chrono>
#include <csignal>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <thread>

namespace {
volatile sig_atomic_t g_stop = 0;
void onSignal(int) { g_stop = 1; }
}

int main(int argc, char** argv) {
    udpscope::ReceiverOptions opt;
    int seconds = 0;   /* 0 = until Ctrl-C */

    for (int i = 1; i < argc; i++) {
        const bool hasNext = (i + 1 < argc);
        if (std::strcmp(argv[i], "--host") == 0 && hasNext) {
            opt.host = argv[++i];
        } else if (std::strcmp(argv[i], "--port") == 0 && hasNext) {
            opt.port = static_cast<uint16_t>(std::atoi(argv[++i]));
        } else if (std::strcmp(argv[i], "--multicast") == 0 && hasNext) {
            opt.multicastGroup = argv[++i];
        } else if (std::strcmp(argv[i], "--iface") == 0 && hasNext) {
            opt.interfaceAddr = argv[++i];
        } else if (std::strcmp(argv[i], "--data-port") == 0 && hasNext) {
            opt.dataPort = static_cast<uint16_t>(std::atoi(argv[++i]));
        } else if (std::strcmp(argv[i], "--seconds") == 0 && hasNext) {
            seconds = std::atoi(argv[++i]);
        } else {
            std::printf("usage: %s [--host H] [--port P] [--multicast G] "
                        "[--iface IP] [--data-port P] [--seconds N]\n", argv[0]);
            return (std::strcmp(argv[i], "--help") == 0) ? 0 : 2;
        }
    }

    std::signal(SIGINT, onSignal);
    std::signal(SIGTERM, onSignal);

    udpscope::SignalStore store;
    udpscope::Receiver    rx(store);
    std::string err;
    if (!rx.start(opt, err)) {
        std::fprintf(stderr, "start failed: %s\n", err.c_str());
        return 1;
    }
    std::printf("attached to %s:%u\n", opt.host.c_str(), opt.port);

    for (int elapsed = 0; g_stop == 0 && (seconds == 0 || elapsed < seconds);
         elapsed++) {
        std::this_thread::sleep_for(std::chrono::seconds(1));
        const udpscope::LinkStatus l = rx.link();
        std::printf("[%3ds] %s pkts=%llu frames=%llu cfg=%llu gaps=%llu "
                    "frag=%llu recon=%llu %s\n",
                    elapsed + 1, l.connected ? "UP  " : "DOWN",
                    (unsigned long long) l.packets,
                    (unsigned long long) l.frames,
                    (unsigned long long) l.configUpdates,
                    (unsigned long long) l.counterGaps,
                    (unsigned long long) l.fragmentsDropped,
                    (unsigned long long) l.reconnects,
                    l.lastEvent.c_str());
        const std::vector<udpscope::SignalMeta> sigs = store.signals();
        for (size_t i = 0; i < sigs.size(); i++) {
            double t0 = 0.0, t1 = 0.0;
            const bool have = store.span(sigs[i].name, t0, t1);
            std::printf("        %-24s rate=%9.1f Hz cap=%8zu span=%.3f s\n",
                        sigs[i].name.c_str(), store.rate(sigs[i].name),
                        store.capacity(sigs[i].name), have ? (t1 - t0) : 0.0);
        }
    }

    rx.stop();
    return 0;
}
  • Step 7: Add the probe target to CMake
add_executable(udpscope_rxprobe tools/rxprobe.cpp)
target_link_libraries(udpscope_rxprobe PRIVATE udpscope_core)
  • Step 8: Run the tests and verify they pass
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='ReceiverLink*:Receiver*'

Expected: PASS, 15 tests (9 from Task 7, 6 new).

  • Step 9: Verify the socket path against a real streamer

In one terminal:

cd /home/martino/Projects/marte2_projects/MARTe_Integrated_components
source env.sh
"${MARTe2_DIR}/Build/x86-linux/App/MARTeApp.ex" \
    -l RealTimeLoader -f Test/Configurations/streamhub_demo.cfg \
    -s Running -m StateMachine:START

In another:

cd /home/martino/Projects/marte2_projects/MARTe_Integrated_components/Client/udpscope
./build/udpscope_rxprobe --host 127.0.0.1 --port 44501 --seconds 5

Expected: UP, pkts and frames climbing, one line per signal with a plausible rate and a span that stops growing once the ring is full. gaps and frag should stay at 0 on loopback.

Cross-check the same source with the reference dumper, which must report the same signal names and rates:

cd ../../Common/Client/c && make && \
    ./build/udps_dump --host 127.0.0.1 --port 44501 --frames 20
  • Step 10: Commit
git add Client/udpscope/Receiver.h Client/udpscope/Receiver.cpp \
        Client/udpscope/tools/rxprobe.cpp \
        Client/udpscope/tests/ReceiverLinkTest.cpp Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): receiver thread, UDPS client wiring and rxprobe tool"

Task 9: Command line, window bootstrap and the app shell

First runnable binary: parses the command line, opens an SDL2/OpenGL window, starts the receiver, and draws the menu bar, the signal list and the status bar. The plot area is an empty placeholder that Task 10 fills in.

Files:

  • Create: Client/udpscope/Cli.h
  • Create: Client/udpscope/Cli.cpp
  • Create: Client/udpscope/App.h
  • Create: Client/udpscope/App.cpp
  • Create: Client/udpscope/SignalList.cpp
  • Create: Client/udpscope/main.cpp
  • Modify: Client/udpscope/CMakeLists.txt
  • Test: Client/udpscope/tests/CliTest.cpp

Interfaces:

  • Consumes: ReceiverOptions, Receiver, LinkStatus (Task 8); SignalStore (Task 6); PaneTree (Task 2).
  • Produces:
struct CliOptions {
    ReceiverOptions source;
    std::string     configPath;         // empty = DefaultConfigPath()
    size_t          maxPlotPoints = 4000u;
    bool setHost = false, setPort = false, setMulticast = false;
    bool setIface = false, setDataPort = false, setSilence = false;
    bool setConfigPath = false, setMaxPlotPoints = false;
};
enum class CliResult { Ok, Help, Error };
CliResult   ParseCli(int argc, char** argv, CliOptions& out, std::string& err);
std::string CliUsage();
std::string DefaultConfigPath();

class App {
public:
    explicit App(const CliOptions& opt);
    ~App();
    void draw();                 // one ImGui frame
    bool wantsQuit() const;
    void requestQuit();
};

Why the set* flags exist: spec §11 requires that an explicit command-line option beats the settings file, while an omitted one falls back to it. Without per-field "was it given" flags there is no way to tell --port 44500 from the default, and connecting from the command line would silently override a saved session (or vice versa). Task 15 consumes these flags.

  • Step 1: Write the failing tests

Create Client/udpscope/tests/CliTest.cpp:

#include "Cli.h"
#include <gtest/gtest.h>
#include <cstdlib>
#include <string>
#include <vector>

using namespace udpscope;

namespace {

// ParseCli takes char**, as main() does.
CliResult parse(const std::vector<std::string>& args, CliOptions& out,
                std::string& err) {
    std::vector<char*> argv;
    argv.push_back(const_cast<char*>("udpscope"));
    for (size_t i = 0; i < args.size(); ++i) {
        argv.push_back(const_cast<char*>(args[i].c_str()));
    }
    return ParseCli(static_cast<int>(argv.size()), argv.data(), out, err);
}

} // namespace

TEST(Cli, DefaultsMatchUdpsDump) {
    CliOptions o;
    std::string err;
    ASSERT_EQ(parse({}, o, err), CliResult::Ok) << err;
    EXPECT_EQ(o.source.host, "127.0.0.1");
    EXPECT_EQ(o.source.port, 44500u);
    EXPECT_TRUE(o.source.multicastGroup.empty());
    EXPECT_EQ(o.source.dataPort, 0u);
    EXPECT_FALSE(o.setHost);
    EXPECT_FALSE(o.setPort);
}

TEST(Cli, ParsesEveryOptionAndMarksItAsGiven) {
    CliOptions o;
    std::string err;
    ASSERT_EQ(parse({"--host", "10.0.0.5", "--port", "44501",
                     "--multicast", "239.0.0.1", "--iface", "192.168.1.2",
                     "--data-port", "44503", "--silence", "3.5",
                     "--config", "/tmp/s.conf", "--max-mpts", "8000"},
                    o, err), CliResult::Ok) << err;
    EXPECT_EQ(o.source.host, "10.0.0.5");
    EXPECT_EQ(o.source.port, 44501u);
    EXPECT_EQ(o.source.multicastGroup, "239.0.0.1");
    EXPECT_EQ(o.source.interfaceAddr, "192.168.1.2");
    EXPECT_EQ(o.source.dataPort, 44503u);
    EXPECT_DOUBLE_EQ(o.source.silenceTimeoutSec, 3.5);
    EXPECT_EQ(o.configPath, "/tmp/s.conf");
    EXPECT_EQ(o.maxPlotPoints, 8000u);
    EXPECT_TRUE(o.setHost && o.setPort && o.setMulticast && o.setIface &&
                o.setDataPort && o.setSilence && o.setConfigPath &&
                o.setMaxPlotPoints);
}

TEST(Cli, HelpIsNotAnError) {
    CliOptions o;
    std::string err;
    EXPECT_EQ(parse({"--help"}, o, err), CliResult::Help);
    EXPECT_FALSE(CliUsage().empty());
}

TEST(Cli, RejectsAnUnknownOption) {
    CliOptions o;
    std::string err;
    EXPECT_EQ(parse({"--colour", "red"}, o, err), CliResult::Error);
    EXPECT_NE(err.find("--colour"), std::string::npos);
}

TEST(Cli, RejectsAMissingValue) {
    CliOptions o;
    std::string err;
    EXPECT_EQ(parse({"--port"}, o, err), CliResult::Error);
    EXPECT_NE(err.find("--port"), std::string::npos);
}

// Single-dash clustering is the trap the Qt client documents; reject it loudly
// rather than misparse it.
TEST(Cli, RejectsSingleDashOptions) {
    CliOptions o;
    std::string err;
    EXPECT_EQ(parse({"-host", "10.0.0.5"}, o, err), CliResult::Error);
    EXPECT_NE(err.find("-host"), std::string::npos);
}

TEST(Cli, RejectsAnOutOfRangePort) {
    CliOptions o;
    std::string err;
    EXPECT_EQ(parse({"--port", "70000"}, o, err), CliResult::Error);
    EXPECT_EQ(parse({"--port", "abc"}, o, err), CliResult::Error);
}

TEST(Cli, DefaultConfigPathFollowsXdg) {
    const char* old = std::getenv("XDG_CONFIG_HOME");
    const std::string saved = (old != nullptr) ? old : "";
    setenv("XDG_CONFIG_HOME", "/tmp/xdg-test", 1);
    EXPECT_EQ(DefaultConfigPath(), "/tmp/xdg-test/udpscope/session.conf");

    unsetenv("XDG_CONFIG_HOME");
    const std::string home = DefaultConfigPath();
    EXPECT_NE(home.find("/.config/udpscope/session.conf"), std::string::npos);

    if (!saved.empty()) {
        setenv("XDG_CONFIG_HOME", saved.c_str(), 1);
    }
}
  • Step 2: Run the tests to verify they fail
cd Client/udpscope && cmake --build build -j 2>&1 | tail -5

Expected: FAIL — Cli.h: No such file or directory.

  • Step 3: Write Cli.h and Cli.cpp

Create Client/udpscope/Cli.h:

/**
 * @file Cli.h
 * @brief Command-line parsing, mirroring Common/Client/c/example/udps_dump.c.
 *
 * Every field records whether it was given explicitly, because an explicit
 * option must beat the settings file while an omitted one must not.
 */
#ifndef UDPSCOPE_CLI_H
#define UDPSCOPE_CLI_H

#include "Receiver.h"

#include <cstddef>
#include <string>

namespace udpscope {

struct CliOptions {
    ReceiverOptions source;
    std::string     configPath;              /**< empty = DefaultConfigPath() */
    size_t          maxPlotPoints = 4000u;   /**< decimation budget per trace */

    bool setHost = false;
    bool setPort = false;
    bool setMulticast = false;
    bool setIface = false;
    bool setDataPort = false;
    bool setSilence = false;
    bool setConfigPath = false;
    bool setMaxPlotPoints = false;
};

enum class CliResult { Ok, Help, Error };

CliResult   ParseCli(int argc, char** argv, CliOptions& out, std::string& err);
std::string CliUsage();
/** $XDG_CONFIG_HOME/udpscope/session.conf, else ~/.config/... */
std::string DefaultConfigPath();

} /* namespace udpscope */

#endif /* UDPSCOPE_CLI_H */

Create Client/udpscope/Cli.cpp:

#include "Cli.h"

#include <cstdlib>
#include <cstring>
#include <string>

namespace udpscope {

namespace {

bool parseUInt(const char* s, unsigned long& out) {
    if (s == nullptr || s[0] == '\0') {
        return false;
    }
    char* end = nullptr;
    const unsigned long v = std::strtoul(s, &end, 10);
    if (end == s || *end != '\0') {
        return false;
    }
    out = v;
    return true;
}

bool parseDouble(const char* s, double& out) {
    if (s == nullptr || s[0] == '\0') {
        return false;
    }
    char* end = nullptr;
    const double v = std::strtod(s, &end);
    if (end == s || *end != '\0') {
        return false;
    }
    out = v;
    return true;
}

} /* namespace */

std::string CliUsage() {
    return "usage: udpscope [--host ADDR] [--port N] [--multicast GROUP]\n"
           "                [--iface ADDR] [--data-port N] [--silence SEC]\n"
           "                [--config PATH] [--max-mpts N] [--help]\n"
           "\n"
           "  --host ADDR       streamer address (default 127.0.0.1)\n"
           "  --port N          streamer control port (default 44500)\n"
           "  --multicast GROUP join this multicast group for data\n"
           "  --iface ADDR      local interface IP (an address, not a name)\n"
           "  --data-port N     local data port (default: server-chosen)\n"
           "  --silence SEC     reconnect after this much silence (default 2)\n"
           "  --config PATH     settings file (default XDG session.conf)\n"
           "  --max-mpts N      max plotted points per trace (default 4000)\n";
}

std::string DefaultConfigPath() {
    const char* xdg = std::getenv("XDG_CONFIG_HOME");
    if (xdg != nullptr && xdg[0] != '\0') {
        return std::string(xdg) + "/udpscope/session.conf";
    }
    const char* home = std::getenv("HOME");
    const std::string base = (home != nullptr && home[0] != '\0') ? home : ".";
    return base + "/.config/udpscope/session.conf";
}

CliResult ParseCli(int argc, char** argv, CliOptions& out, std::string& err) {
    for (int i = 1; i < argc; i++) {
        const char* a       = argv[i];
        const bool  hasNext = (i + 1 < argc);
        const char* next    = hasNext ? argv[i + 1] : nullptr;

        if (std::strcmp(a, "--help") == 0 || std::strcmp(a, "-h") == 0) {
            return CliResult::Help;
        }
        /* Long options only. A single dash is silently reinterpreted as
           clustered short flags by some parsers; refuse it instead. */
        if (a[0] != '-' || a[1] != '-') {
            err = std::string("unexpected argument '") + a +
                  "' (long -- options only)";
            return CliResult::Error;
        }

        unsigned long u = 0u;
        double        d = 0.0;

        if (std::strcmp(a, "--host") == 0) {
            if (!hasNext) { err = "--host needs an address"; return CliResult::Error; }
            out.source.host = next;
            out.setHost = true;
            i++;
        } else if (std::strcmp(a, "--port") == 0) {
            if (!hasNext || !parseUInt(next, u) || u == 0u || u > 65535u) {
                err = "--port needs a number in 1..65535";
                return CliResult::Error;
            }
            out.source.port = static_cast<uint16_t>(u);
            out.setPort = true;
            i++;
        } else if (std::strcmp(a, "--multicast") == 0) {
            if (!hasNext) { err = "--multicast needs a group"; return CliResult::Error; }
            out.source.multicastGroup = next;
            out.setMulticast = true;
            i++;
        } else if (std::strcmp(a, "--iface") == 0) {
            if (!hasNext) { err = "--iface needs an address"; return CliResult::Error; }
            out.source.interfaceAddr = next;
            out.setIface = true;
            i++;
        } else if (std::strcmp(a, "--data-port") == 0) {
            if (!hasNext || !parseUInt(next, u) || u > 65535u) {
                err = "--data-port needs a number in 0..65535";
                return CliResult::Error;
            }
            out.source.dataPort = static_cast<uint16_t>(u);
            out.setDataPort = true;
            i++;
        } else if (std::strcmp(a, "--silence") == 0) {
            if (!hasNext || !parseDouble(next, d) || d <= 0.0) {
                err = "--silence needs a positive number of seconds";
                return CliResult::Error;
            }
            out.source.silenceTimeoutSec = d;
            out.setSilence = true;
            i++;
        } else if (std::strcmp(a, "--config") == 0) {
            if (!hasNext) { err = "--config needs a path"; return CliResult::Error; }
            out.configPath = next;
            out.setConfigPath = true;
            i++;
        } else if (std::strcmp(a, "--max-mpts") == 0) {
            if (!hasNext || !parseUInt(next, u) || u < 100u) {
                err = "--max-mpts needs a number of at least 100";
                return CliResult::Error;
            }
            out.maxPlotPoints = static_cast<size_t>(u);
            out.setMaxPlotPoints = true;
            i++;
        } else {
            err = std::string("unknown option '") + a + "'";
            return CliResult::Error;
        }
    }
    return CliResult::Ok;
}

} /* namespace udpscope */
  • Step 4: Run the CLI tests and verify they pass
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='Cli*'

Expected: PASS, 8 tests. Add Cli.cpp to CORE_SOURCES first:

set(CORE_SOURCES
    Decimate.cpp
    PaneTree.cpp
    TimeBase.cpp
    FrameDecoder.cpp
    Trigger.cpp
    SignalStore.cpp
    Receiver.cpp
    Cli.cpp
)
  • Step 5: Write the app shell header

Create Client/udpscope/App.h:

/**
 * @file App.h
 * @brief GUI-thread application state and per-frame drawing.
 *
 * Owns the store, the receiver and the pane tree. Everything here runs on the
 * GUI thread; the only cross-thread access is through SignalStore and the
 * Receiver's control methods.
 */
#ifndef UDPSCOPE_APP_H
#define UDPSCOPE_APP_H

#include "Cli.h"
#include "PaneTree.h"
#include "Receiver.h"
#include "SignalStore.h"

#include <string>
#include <vector>

namespace udpscope {

class App {
public:
    explicit App(const CliOptions& opt);
    ~App();

    /** Draw one frame. Call between ImGui::NewFrame() and ImGui::Render(). */
    void draw();
    bool wantsQuit() const { return quit_; }
    void requestQuit() { quit_ = true; }

private:
    void drawMenuBar();
    void drawSignalList();
    void drawPlotArea();
    void drawStatusBar();
    /** Refresh the cached signal list when the store's generation changes. */
    void syncSignals();

    CliOptions  opt_;
    SignalStore store_;
    Receiver    rx_;
    PaneTree    tree_;

    std::vector<SignalMeta> sigs_;
    uint64_t    sigGeneration_ = 0u;
    std::string status_;
    bool        quit_ = false;
};

} /* namespace udpscope */

#endif /* UDPSCOPE_APP_H */
  • Step 6: Write the app shell implementation

Create Client/udpscope/App.cpp:

#include "App.h"

#include "imgui.h"
#include "implot.h"

#include <cinttypes>
#include <cstdio>

namespace udpscope {

App::App(const CliOptions& opt) : opt_(opt), rx_(store_) {
    std::string err;
    if (!rx_.start(opt_.source, err)) {
        status_ = "receiver failed to start: " + err;
    } else {
        char buf[128];
        std::snprintf(buf, sizeof(buf), "attaching to %s:%u",
                      opt_.source.host.c_str(),
                      static_cast<unsigned>(opt_.source.port));
        status_ = buf;
    }
}

App::~App() {
    rx_.stop();
}

void App::syncSignals() {
    const uint64_t gen = store_.generation();
    if (gen != sigGeneration_) {
        sigGeneration_ = gen;
        sigs_          = store_.signals();
        /* Assignments naming a signal that is gone are pruned by the pane
           drawing code in Task 10; nothing to do here yet. */
    }
}

void App::drawMenuBar() {
    if (!ImGui::BeginMainMenuBar()) {
        return;
    }
    if (ImGui::BeginMenu("File")) {
        if (ImGui::MenuItem("Quit", "Ctrl+Q")) {
            requestQuit();
        }
        ImGui::EndMenu();
    }
    if (ImGui::BeginMenu("Help")) {
        ImGui::MenuItem("UDPScope — direct UDPS oscilloscope", nullptr, false, false);
        ImGui::EndMenu();
    }

    /* Connection badge, right-aligned. */
    const LinkStatus l = rx_.link();
    const char* text = l.connected ? "CONNECTED" : (l.running ? "connecting..." : "stopped");
    const float w = ImGui::CalcTextSize(text).x;
    ImGui::SameLine(ImGui::GetWindowWidth() - w - 16.0f);
    ImGui::TextColored(l.connected ? ImVec4(0.65f, 0.89f, 0.63f, 1.0f)
                                   : ImVec4(0.98f, 0.70f, 0.53f, 1.0f),
                       "%s", text);
    ImGui::EndMainMenuBar();
}

void App::drawPlotArea() {
    /* Task 10 replaces this with the pane tree. */
    ImGui::TextDisabled("drag a signal here (panes land in Task 10)");
}

void App::drawStatusBar() {
    const LinkStatus l = rx_.link();
    ImGui::Text("pkts %" PRIu64 "  frames %" PRIu64 "  cfg %" PRIu64,
                l.packets, l.frames, l.configUpdates);
    ImGui::SameLine();
    /* Gaps and dropped fragments are the honest signal that the scope is not
       seeing everything, so they are highlighted rather than buried. */
    const ImVec4 bad(0.95f, 0.55f, 0.66f, 1.0f);
    const ImVec4 ok(0.65f, 0.68f, 0.75f, 1.0f);
    ImGui::TextColored(l.counterGaps > 0u ? bad : ok, "gaps %" PRIu64, l.counterGaps);
    ImGui::SameLine();
    ImGui::TextColored(l.fragmentsDropped > 0u ? bad : ok,
                       "frag %" PRIu64, l.fragmentsDropped);
    ImGui::SameLine();
    ImGui::TextColored(l.reconnects > 0u ? bad : ok,
                       "reconn %" PRIu64, l.reconnects);
    if (!status_.empty()) {
        ImGui::SameLine();
        ImGui::TextDisabled("| %s", status_.c_str());
    }
}

void App::draw() {
    syncSignals();
    drawMenuBar();

    const ImGuiViewport* vp = ImGui::GetMainViewport();
    ImGui::SetNextWindowPos(vp->WorkPos);
    ImGui::SetNextWindowSize(vp->WorkSize);
    const ImGuiWindowFlags flags =
        ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove |
        ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoNavFocus |
        ImGuiWindowFlags_NoSavedSettings;
    ImGui::Begin("##root", nullptr, flags);

    const float statusH = ImGui::GetTextLineHeightWithSpacing() + 8.0f;
    const float bodyH   = ImGui::GetContentRegionAvail().y - statusH;

    ImGui::BeginChild("##list", ImVec2(220.0f, bodyH), true);
    drawSignalList();
    ImGui::EndChild();

    ImGui::SameLine();
    ImGui::BeginChild("##panes", ImVec2(0.0f, bodyH), true);
    drawPlotArea();
    ImGui::EndChild();

    ImGui::Separator();
    drawStatusBar();
    ImGui::End();

    if (ImGui::IsKeyDown(ImGuiKey_ModCtrl) && ImGui::IsKeyPressed(ImGuiKey_Q)) {
        requestQuit();
    }
}

} /* namespace udpscope */
  • Step 6b: Write SignalList.cpp

The side panel gets its own translation unit, as spec §3.4 lays out. It is still an App method, so it needs no accessors for the state it reads.

Create Client/udpscope/SignalList.cpp:

#include "App.h"

#include "imgui.h"

namespace udpscope {

void App::drawSignalList() {
    ImGui::TextUnformatted("Signals");
    ImGui::Separator();
    if (sigs_.empty()) {
        ImGui::TextDisabled("waiting for CONFIG");
        return;
    }
    for (size_t i = 0u; i < sigs_.size(); i++) {
        const SignalMeta& m = sigs_[i];
        ImGui::PushID(static_cast<int>(i));
        ImGui::Selectable(m.name.c_str());
        if (ImGui::IsItemHovered()) {
            double t0 = 0.0, t1 = 0.0;
            const bool have = store_.span(m.name, t0, t1);
            ImGui::SetTooltip("%s\n%u element(s), %s\n%.1f Hz, %.2f s buffered",
                              m.name.c_str(), m.numElements(),
                              m.unit.empty() ? "no unit" : m.unit.c_str(),
                              store_.rate(m.name), have ? (t1 - t0) : 0.0);
        }
        ImGui::PopID();
    }
}

} /* namespace udpscope */
  • Step 7: Write main.cpp

Create Client/udpscope/main.cpp:

/**
 * @file main.cpp
 * @brief SDL2 + OpenGL 3.3 + Dear ImGui bootstrap for UDPScope.
 */
#include "App.h"
#include "Cli.h"

#include "imgui.h"
#include "imgui_impl_opengl3.h"
#include "imgui_impl_sdl2.h"
#include "implot.h"

#include <SDL.h>
#include <SDL_opengl.h>

#include <cstdio>
#include <string>
#include <sys/stat.h>
#include <unistd.h>

namespace {

/** Directory holding the running binary, or "." if it cannot be determined. */
std::string exeDir() {
    char buf[4096];
    const ssize_t n = readlink("/proc/self/exe", buf, sizeof(buf) - 1);
    if (n <= 0) {
        return ".";
    }
    buf[n] = '\0';
    std::string p(buf);
    const size_t slash = p.rfind('/');
    return (slash == std::string::npos) ? std::string(".") : p.substr(0, slash);
}

bool fileExists(const std::string& p) {
    struct stat st;
    return stat(p.c_str(), &st) == 0;
}

/** First existing of: next to the binary, the build tree, the source tree. */
std::string findFont(const char* name) {
    const std::string dir = exeDir();
    const std::string candidates[] = {
        dir + "/resources/fonts/" + name,
        dir + "/../share/udpscope/fonts/" + name,
        std::string(APP_RESOURCE_DIR) + "/fonts/" + name,
    };
    for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); i++) {
        if (fileExists(candidates[i])) {
            return candidates[i];
        }
    }
    return std::string();
}

void applyStyle() {
    ImGui::StyleColorsDark();
    ImGuiStyle& s = ImGui::GetStyle();
    s.WindowRounding    = 4.0f;
    s.FrameRounding     = 4.0f;
    s.GrabRounding      = 4.0f;
    s.ScrollbarRounding = 4.0f;
    s.WindowBorderSize  = 1.0f;
    /* Catppuccin Mocha, matching the StreamHub ImGui client. */
    ImVec4* c = s.Colors;
    c[ImGuiCol_WindowBg]       = ImVec4(0.12f, 0.12f, 0.18f, 1.00f);
    c[ImGuiCol_ChildBg]        = ImVec4(0.14f, 0.14f, 0.20f, 1.00f);
    c[ImGuiCol_PopupBg]        = ImVec4(0.10f, 0.10f, 0.15f, 0.98f);
    c[ImGuiCol_Border]         = ImVec4(0.27f, 0.28f, 0.35f, 1.00f);
    c[ImGuiCol_FrameBg]        = ImVec4(0.19f, 0.20f, 0.27f, 1.00f);
    c[ImGuiCol_FrameBgHovered] = ImVec4(0.24f, 0.25f, 0.33f, 1.00f);
    c[ImGuiCol_TitleBgActive]  = ImVec4(0.17f, 0.18f, 0.25f, 1.00f);
    c[ImGuiCol_MenuBarBg]      = ImVec4(0.15f, 0.15f, 0.22f, 1.00f);
    c[ImGuiCol_Header]         = ImVec4(0.24f, 0.25f, 0.33f, 1.00f);
    c[ImGuiCol_Button]         = ImVec4(0.22f, 0.23f, 0.31f, 1.00f);
    c[ImGuiCol_ButtonHovered]  = ImVec4(0.29f, 0.31f, 0.41f, 1.00f);
    c[ImGuiCol_Text]           = ImVec4(0.80f, 0.84f, 0.96f, 1.00f);
    c[ImGuiCol_TextDisabled]   = ImVec4(0.43f, 0.45f, 0.55f, 1.00f);
}

} /* namespace */

int main(int argc, char** argv) {
    udpscope::CliOptions opt;
    std::string err;
    const udpscope::CliResult r = udpscope::ParseCli(argc, argv, opt, err);
    if (r == udpscope::CliResult::Help) {
        std::printf("%s", udpscope::CliUsage().c_str());
        return 0;
    }
    if (r == udpscope::CliResult::Error) {
        std::fprintf(stderr, "%s\n\n%s", err.c_str(), udpscope::CliUsage().c_str());
        return 2;
    }

    if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER) != 0) {
        std::fprintf(stderr, "SDL_Init: %s\n", SDL_GetError());
        return 1;
    }
    SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, 0);
    SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE);
    SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
    SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3);
    SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
    SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24);

    SDL_Window* win = SDL_CreateWindow(
        "UDPScope", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, 1600, 1000,
        SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI);
    if (win == nullptr) {
        std::fprintf(stderr, "SDL_CreateWindow: %s\n", SDL_GetError());
        SDL_Quit();
        return 1;
    }
    SDL_GLContext gl = SDL_GL_CreateContext(win);
    SDL_GL_MakeCurrent(win, gl);
    SDL_GL_SetSwapInterval(1);   /* vsync: the scope repaints at the refresh rate */

    IMGUI_CHECKVERSION();
    ImGui::CreateContext();
    ImPlot::CreateContext();
    ImGuiIO& io = ImGui::GetIO();
    /* The layout lives in our own settings file (Task 14), not imgui.ini. */
    io.IniFilename = nullptr;
    applyStyle();

    const std::string font = findFont("FiraSans-Regular.ttf");
    if (!font.empty()) {
        io.Fonts->AddFontFromFileTTF(font.c_str(), 16.0f);
    }

    ImGui_ImplSDL2_InitForOpenGL(win, gl);
    ImGui_ImplOpenGL3_Init("#version 330");

    {
        udpscope::App app(opt);
        bool done = false;
        while (!done && !app.wantsQuit()) {
            SDL_Event e;
            while (SDL_PollEvent(&e) != 0) {
                ImGui_ImplSDL2_ProcessEvent(&e);
                if (e.type == SDL_QUIT) {
                    done = true;
                }
                if (e.type == SDL_WINDOWEVENT &&
                    e.window.event == SDL_WINDOWEVENT_CLOSE &&
                    e.window.windowID == SDL_GetWindowID(win)) {
                    done = true;
                }
            }

            ImGui_ImplOpenGL3_NewFrame();
            ImGui_ImplSDL2_NewFrame();
            ImGui::NewFrame();
            app.draw();
            ImGui::Render();

            int w = 0, h = 0;
            SDL_GetWindowSize(win, &w, &h);
            glViewport(0, 0, w, h);
            glClearColor(0.09f, 0.09f, 0.13f, 1.0f);
            glClear(GL_COLOR_BUFFER_BIT);
            ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
            SDL_GL_SwapWindow(win);
        }
    }   /* App destroyed here, stopping the receiver before SDL shuts down. */

    ImGui_ImplOpenGL3_Shutdown();
    ImGui_ImplSDL2_Shutdown();
    ImPlot::DestroyContext();
    ImGui::DestroyContext();
    SDL_GL_DeleteContext(gl);
    SDL_DestroyWindow(win);
    SDL_Quit();
    return 0;
}
  • Step 8: Update CMake for the app sources

Replace the application block in Client/udpscope/CMakeLists.txt — the if(EXISTS main.cpp) guard from Task 1 has done its job and goes away:

# ── Application ───────────────────────────────────────────────────────────────
set(APP_SOURCES
    main.cpp
    App.cpp
    SignalList.cpp
)

add_executable(UDPScope ${APP_SOURCES})
target_link_libraries(UDPScope PRIVATE udpscope_core imgui_lib SDL2::SDL2 OpenGL::GL)
target_compile_definitions(UDPScope PRIVATE APP_RESOURCE_DIR="${RESOURCE_DIR}")
if(HAVE_FONT_AWESOME)
    target_include_directories(UDPScope PRIVATE ${FONT_DIR})
    target_compile_definitions(UDPScope PRIVATE HAVE_FONT_AWESOME)
endif()
target_compile_options(UDPScope PRIVATE -Wall -Wextra -Wno-unused-parameter)

install(TARGETS UDPScope DESTINATION bin)
install(DIRECTORY ${FONT_DIR} DESTINATION share/udpscope)
  • Step 9: Build and run against a real streamer
cd Client/udpscope && cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build -j
./build/udpscope_tests

Expected: PASS, all tests from Tasks 19.

# terminal 1
source env.sh && "${MARTe2_DIR}/Build/x86-linux/App/MARTeApp.ex" \
    -l RealTimeLoader -f Test/Configurations/streamhub_demo.cfg \
    -s Running -m StateMachine:START
# terminal 2
./Client/udpscope/build/UDPScope --host 127.0.0.1 --port 44501

Expected: a window opens, the badge reads CONNECTED, the signal list fills with the demo signals, hovering one shows a plausible rate and buffered span, and the status bar counters climb with gaps 0. Ctrl+Q closes it.

./Client/udpscope/build/UDPScope --help          # prints usage, exits 0
./Client/udpscope/build/UDPScope -host 1.2.3.4   # rejects, exits 2
  • Step 10: Commit
git add Client/udpscope/Cli.h Client/udpscope/Cli.cpp Client/udpscope/App.h \
        Client/udpscope/App.cpp Client/udpscope/SignalList.cpp \
        Client/udpscope/main.cpp \
        Client/udpscope/tests/CliTest.cpp Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): CLI, SDL2/ImGui bootstrap and application shell"

Task 10: Trace fetching and the first real plot

Split the plotting work in two: PlotData decides what points to draw and is fully unit-tested against the store; PaneView is thin ImPlot glue that draws them. This task draws the tree's single root leaf across the whole plot area and lets the user drag signals onto it. Splitting arrives in Task 11.

Files:

  • Create: Client/udpscope/PlotData.h
  • Create: Client/udpscope/PlotData.cpp
  • Create: Client/udpscope/PaneView.h
  • Create: Client/udpscope/PaneView.cpp
  • Modify: Client/udpscope/App.h, Client/udpscope/App.cpp
  • Modify: Client/udpscope/CMakeLists.txt
  • Test: Client/udpscope/tests/PlotDataTest.cpp

Interfaces:

  • Consumes: SignalStore, Capture, Profile (Task 6); MinMaxDecimate, Series, Color (Task 1); PaneNode, Assignment (Task 2).
  • Produces:
struct TraceData {
    Series raw;    // every sample in range — statistics use this
    Series draw;   // decimated to the plot budget
    bool   found = false;
};

bool FetchLiveTrace(const SignalStore& store, const std::string& name,
                    double t0, double t1, size_t maxPoints, TraceData& out);
bool FetchCaptureTrace(const Capture& cap, const std::string& name,
                       double t0, double t1, size_t maxPoints, TraceData& out);
Color PaletteColor(size_t index);

struct PaneContext {
    SignalStore*   store = nullptr;
    const Capture* capture = nullptr;   // nullptr => live
    double x0 = 0.0, x1 = 1.0;
    size_t maxPoints = 4000u;
};
class PaneView {
public:
    void drawLeaf(PaneNode& leaf, const char* id, const ImVec2& size,
                  PaneContext& ctx);
};

Why raw is kept alongside draw: spec §8.4 requires min/max/pp/mean/RMS to be computed from undecimated data. Computing them from the decimated series would report the extremes of the drawn envelope but a mean and RMS weighted by bucket rather than by sample. Task 14 reads TraceData::raw.

  • Step 1: Write the failing tests

Create Client/udpscope/tests/PlotDataTest.cpp:

#include "PlotData.h"
#include <gtest/gtest.h>
#include <algorithm>
#include <cmath>
#include <string>
#include <vector>

using namespace udpscope;

namespace {

SignalMeta scalar(const std::string& name) {
    SignalMeta m;
    m.name     = name;
    m.typeCode = 8;
    m.numRows  = 1;
    m.numCols  = 1;
    return m;
}

// Fills "a" with a 10 kHz ramp over [0, 1) s, with a one-sample spike at 0.5 s.
void fill(SignalStore& s) {
    s.setSignals({scalar("a")});
    std::vector<double> t(10000), v(10000);
    for (size_t i = 0; i < t.size(); ++i) {
        t[i] = static_cast<double>(i) * 1e-4;
        v[i] = static_cast<double>(i);
    }
    v[5000] = 1.0e6;
    s.setWindowSec(1.0);
    s.maintain();
    s.push("a", t.data(), v.data(), t.size());
}

} // namespace

TEST(PlotData, LiveFetchClipsToTheRequestedRange) {
    SignalStore s;
    fill(s);
    TraceData d;
    ASSERT_TRUE(FetchLiveTrace(s, "a", 0.2, 0.3, 4000, d));
    EXPECT_TRUE(d.found);
    EXPECT_NEAR(d.raw.t.front(), 0.2, 1e-9);
    EXPECT_NEAR(d.raw.t.back(),  0.3, 1e-9);
    EXPECT_EQ(d.raw.size(), 1001u);
}

TEST(PlotData, DrawSeriesRespectsTheBudgetAndRawDoesNot) {
    SignalStore s;
    fill(s);
    TraceData d;
    ASSERT_TRUE(FetchLiveTrace(s, "a", 0.0, 1.0, 500, d));
    EXPECT_EQ(d.raw.size(), 10000u) << "raw must stay undecimated for statistics";
    EXPECT_LE(d.draw.size(), 500u);
    EXPECT_GT(d.draw.size(), 2u);
}

// The reason for min/max decimation: the spike must still be on screen.
TEST(PlotData, DecimationKeepsTheSpike) {
    SignalStore s;
    fill(s);
    TraceData d;
    ASSERT_TRUE(FetchLiveTrace(s, "a", 0.0, 1.0, 500, d));
    EXPECT_DOUBLE_EQ(*std::max_element(d.draw.v.begin(), d.draw.v.end()), 1.0e6);
}

TEST(PlotData, AMissingSignalIsReportedNotFabricated) {
    SignalStore s;
    fill(s);
    TraceData d;
    EXPECT_FALSE(FetchLiveTrace(s, "ghost", 0.0, 1.0, 500, d));
    EXPECT_FALSE(d.found);
    EXPECT_TRUE(d.raw.empty());
    EXPECT_TRUE(d.draw.empty());
}

TEST(PlotData, AnEmptyRangeYieldsAnEmptyTraceWithoutFailing) {
    SignalStore s;
    fill(s);
    TraceData d;
    EXPECT_TRUE(FetchLiveTrace(s, "a", 50.0, 51.0, 500, d));
    EXPECT_TRUE(d.found);
    EXPECT_TRUE(d.raw.empty());
    EXPECT_TRUE(d.draw.empty());
}

TEST(PlotData, CaptureFetchReadsTheFrozenSnapshotNotTheRing) {
    Capture cap;
    cap.trigTime = 5.0;
    cap.t0 = 4.9;
    cap.t1 = 5.1;
    cap.names.push_back("a");
    cap.series.emplace_back();
    for (int i = 0; i < 201; ++i) {
        cap.series[0].t.push_back(4.9 + i * 1e-3);
        cap.series[0].v.push_back(static_cast<double>(i));
    }

    TraceData d;
    ASSERT_TRUE(FetchCaptureTrace(cap, "a", 4.95, 5.05, 4000, d));
    EXPECT_EQ(d.raw.size(), 101u);
    EXPECT_NEAR(d.raw.t.front(), 4.95, 1e-9);

    TraceData miss;
    EXPECT_FALSE(FetchCaptureTrace(cap, "b", 4.95, 5.05, 4000, miss));
}

TEST(PlotData, PaletteColoursAreDistinctAndWrap) {
    const Color c0 = PaletteColor(0);
    const Color c1 = PaletteColor(1);
    EXPECT_FALSE(c0.r == c1.r && c0.g == c1.g && c0.b == c1.b);
    const Color wrapped = PaletteColor(0 + 8);
    EXPECT_FLOAT_EQ(wrapped.r, c0.r);
    EXPECT_FLOAT_EQ(wrapped.a, 1.0f);
}
  • Step 2: Run the tests to verify they fail
cd Client/udpscope && cmake --build build -j 2>&1 | tail -5

Expected: FAIL — PlotData.h: No such file or directory.

  • Step 3: Write PlotData.h

Create Client/udpscope/PlotData.h:

/**
 * @file PlotData.h
 * @brief Turns store or capture contents into a drawable trace.
 *
 * Framework-free so it can be tested without a GUI. Every fetch returns both
 * the undecimated samples (for statistics) and the decimated ones (for the
 * screen).
 */
#ifndef UDPSCOPE_PLOTDATA_H
#define UDPSCOPE_PLOTDATA_H

#include "SignalStore.h"
#include "Types.h"

#include <cstddef>
#include <string>

namespace udpscope {

struct TraceData {
    Series raw;            /**< every sample in [t0,t1]; statistics use this */
    Series draw;           /**< min/max-decimated to the plot budget */
    bool   found = false;  /**< the signal exists, even if the range is empty */
};

/** @return false when the signal is not in the store. */
bool FetchLiveTrace(const SignalStore& store, const std::string& name,
                    double t0, double t1, size_t maxPoints, TraceData& out);
/** @return false when the signal is not in the capture. */
bool FetchCaptureTrace(const Capture& cap, const std::string& name,
                       double t0, double t1, size_t maxPoints, TraceData& out);

/** Deterministic 8-colour palette, wrapping on overflow. */
Color PaletteColor(size_t index);

} /* namespace udpscope */

#endif /* UDPSCOPE_PLOTDATA_H */
  • Step 4: Write PlotData.cpp

Create Client/udpscope/PlotData.cpp:

#include "PlotData.h"

#include "Decimate.h"

#include <algorithm>

namespace udpscope {

namespace {

/* Catppuccin Mocha accents, in the order the StreamHub clients use them. */
const Color kPalette[8] = {
    {0.537f, 0.706f, 0.980f, 1.0f},   /* blue   */
    {0.980f, 0.702f, 0.529f, 1.0f},   /* peach  */
    {0.651f, 0.890f, 0.631f, 1.0f},   /* green  */
    {0.949f, 0.545f, 0.659f, 1.0f},   /* pink   */
    {0.976f, 0.886f, 0.686f, 1.0f},   /* yellow */
    {0.796f, 0.651f, 0.969f, 1.0f},   /* mauve  */
    {0.584f, 0.890f, 0.839f, 1.0f},   /* teal   */
    {0.937f, 0.604f, 0.604f, 1.0f},   /* red    */
};

void decimateInto(TraceData& d, size_t maxPoints) {
    if (d.raw.empty()) {
        d.draw.clear();
        return;
    }
    MinMaxDecimate(d.raw.t.data(), d.raw.v.data(), d.raw.size(), maxPoints, d.draw);
}

} /* namespace */

Color PaletteColor(size_t index) {
    return kPalette[index % 8u];
}

bool FetchLiveTrace(const SignalStore& store, const std::string& name,
                    double t0, double t1, size_t maxPoints, TraceData& out) {
    out.raw.clear();
    out.draw.clear();
    out.found = false;

    /* capacity() is 0 only for a name the store does not know. */
    if (store.capacity(name) == 0u) {
        return false;
    }
    out.found = true;
    store.readRange(name, t0, t1, out.raw);
    decimateInto(out, maxPoints);
    return true;
}

bool FetchCaptureTrace(const Capture& cap, const std::string& name,
                       double t0, double t1, size_t maxPoints, TraceData& out) {
    out.raw.clear();
    out.draw.clear();
    out.found = false;

    for (size_t i = 0u; i < cap.names.size() && i < cap.series.size(); i++) {
        if (cap.names[i] != name) {
            continue;
        }
        out.found = true;
        const Series& s = cap.series[i];
        /* The capture is already sorted by time; a linear scan is fine because
           it is bounded by the window, not by the ring. */
        for (size_t k = 0u; k < s.t.size(); k++) {
            if (s.t[k] >= t0 && s.t[k] <= t1) {
                out.raw.t.push_back(s.t[k]);
                out.raw.v.push_back(s.v[k]);
            }
        }
        decimateInto(out, maxPoints);
        return true;
    }
    return false;
}

} /* namespace udpscope */

Add PlotData.cpp to CORE_SOURCES.

  • Step 5: Run the PlotData tests and verify they pass
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='PlotData*'

Expected: PASS, 7 tests.

  • Step 6: Write the pane view

Create Client/udpscope/PaneView.h:

/**
 * @file PaneView.h
 * @brief ImPlot rendering of one pane-tree leaf. GUI thread only.
 */
#ifndef UDPSCOPE_PANEVIEW_H
#define UDPSCOPE_PANEVIEW_H

#include "PaneTree.h"
#include "PlotData.h"
#include "SignalStore.h"

#include "imgui.h"

namespace udpscope {

/** Everything a leaf needs to draw itself, rebuilt each frame by App. */
struct PaneContext {
    SignalStore*   store   = nullptr;
    const Capture* capture = nullptr;   /**< nullptr => draw live data */
    double         x0 = 0.0;
    double         x1 = 1.0;
    size_t         maxPoints = 4000u;
};

/** The ImGui drag-and-drop payload carrying a signal name from the list. */
extern const char* const kSignalPayload;

class PaneView {
public:
    /** Draws one leaf, including its drop target and legend context menu. */
    void drawLeaf(PaneNode& leaf, const char* id, const ImVec2& size,
                  PaneContext& ctx);
};

} /* namespace udpscope */

#endif /* UDPSCOPE_PANEVIEW_H */

Create Client/udpscope/PaneView.cpp:

#include "PaneView.h"

#include "implot.h"

#include <cstdio>
#include <cstring>

namespace udpscope {

const char* const kSignalPayload = "UDPSCOPE_SIG";

namespace {

ImVec4 toImVec4(const Color& c) { return ImVec4(c.r, c.g, c.b, c.a); }

/** Appends a signal to a pane, colouring it by its position in the pane. */
void assign(PaneNode& leaf, const char* name) {
    for (size_t i = 0u; i < leaf.signals.size(); i++) {
        if (leaf.signals[i].signalName == name) {
            return;   /* already shown here */
        }
    }
    Assignment a;
    a.signalName = name;
    a.color      = PaletteColor(leaf.signals.size());
    leaf.signals.push_back(a);
}

} /* namespace */

void PaneView::drawLeaf(PaneNode& leaf, const char* id, const ImVec2& size,
                        PaneContext& ctx) {
    if (!ImPlot::BeginPlot(id, size, ImPlotFlags_NoTitle)) {
        return;
    }
    ImPlot::SetupAxes("t [s]", nullptr);
    /* The X range is owned by App so every pane shares it; Task 12 makes the
       user's pan and zoom write back into it. */
    ImPlot::SetupAxisLimits(ImAxis_X1, ctx.x0, ctx.x1, ImPlotCond_Always);

    TraceData d;
    for (size_t i = 0u; i < leaf.signals.size(); i++) {
        Assignment& a = leaf.signals[i];
        const bool ok = (ctx.capture != nullptr)
                            ? FetchCaptureTrace(*ctx.capture, a.signalName,
                                                ctx.x0, ctx.x1, ctx.maxPoints, d)
                            : FetchLiveTrace(*ctx.store, a.signalName,
                                             ctx.x0, ctx.x1, ctx.maxPoints, d);
        if (!ok || d.draw.empty()) {
            continue;
        }
        ImPlot::SetNextLineStyle(toImVec4(a.color), a.lineWidth);
        ImPlot::PlotLine(a.signalName.c_str(), d.draw.t.data(), d.draw.v.data(),
                         static_cast<int>(d.draw.size()));

        if (ImPlot::BeginLegendPopup(a.signalName.c_str())) {
            ImGui::ColorEdit3("colour", &a.color.r);
            ImGui::SliderFloat("width", &a.lineWidth, 0.5f, 4.0f, "%.1f px");
            if (ImGui::Button("remove")) {
                leaf.signals.erase(leaf.signals.begin() +
                                   static_cast<long>(i));
                ImGui::CloseCurrentPopup();
                ImPlot::EndLegendPopup();
                break;
            }
            ImPlot::EndLegendPopup();
        }
    }

    /* Dropping anywhere on the plot assigns the signal to this pane. */
    if (ImPlot::BeginDragDropTargetPlot()) {
        const ImGuiPayload* p = ImGui::AcceptDragDropPayload(kSignalPayload);
        if (p != nullptr && p->Data != nullptr) {
            assign(leaf, static_cast<const char*>(p->Data));
        }
        ImPlot::EndDragDropTarget();
    }

    ImPlot::EndPlot();
}

} /* namespace udpscope */
  • Step 7: Wire the view into App

In Client/udpscope/App.h, add #include "PaneView.h" and the members:

    PaneView paneView_;
    double   xSpanSec_ = 1.0;   /**< live window width, Task 12 makes it settable */

In Client/udpscope/SignalList.cpp — that is where Task 9 put App::drawSignalList(), not App.cpp — make the signal list a drag source by replacing the ImGui::Selectable(m.name.c_str()); line with:

        ImGui::Selectable(m.name.c_str());
        if (ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID)) {
            /* Payload is the name including its terminator, so the drop side
               can use it as a C string directly. */
            ImGui::SetDragDropPayload(kSignalPayload, m.name.c_str(),
                                      m.name.size() + 1u);
            ImGui::TextUnformatted(m.name.c_str());
            ImGui::EndDragDropSource();
        }

and replace drawPlotArea() with:

void App::drawPlotArea() {
    PaneContext ctx;
    ctx.store     = &store_;
    ctx.capture   = nullptr;
    ctx.maxPoints = opt_.maxPlotPoints;

    /* Live: the window ends at the newest sample of any assigned signal. The
       shared, user-controllable X range arrives in Task 12. */
    double newest = 0.0;
    bool   any    = false;
    for (size_t i = 0u; i < sigs_.size(); i++) {
        double o = 0.0, n = 0.0;
        if (store_.span(sigs_[i].name, o, n) && (!any || n > newest)) {
            newest = n;
            any    = true;
        }
    }
    ctx.x1 = any ? newest : 1.0;
    ctx.x0 = ctx.x1 - xSpanSec_;

    PaneNode* root = tree_.root();
    paneView_.drawLeaf(*root, "##pane0", ImGui::GetContentRegionAvail(), ctx);
}
  • Step 8: Update CMake
set(CORE_SOURCES
    Decimate.cpp
    PaneTree.cpp
    TimeBase.cpp
    FrameDecoder.cpp
    Trigger.cpp
    SignalStore.cpp
    Receiver.cpp
    Cli.cpp
    PlotData.cpp
)

set(APP_SOURCES
    main.cpp
    App.cpp
    SignalList.cpp
    PaneView.cpp
)

SignalList.cpp stays in the list — it holds App::drawSignalList() and dropping it is a link error, not a warning.

PaneView.cpp needs ImGui headers, so it belongs to the executable, not the core library — that is what keeps udpscope_tests free of a GUI dependency.

  • Step 9: Build and check it plots
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests

Expected: PASS, all tests from Tasks 110.

Run it against the demo streamer as in Task 9, then drag Sine1 from the list onto the plot. Expected: a 1 Hz sine scrolling right to left, smooth (not a staircase), with the legend entry right-clickable for colour, width and remove. Drag a second signal on and both draw with different colours.

  • Step 10: Commit
git add Client/udpscope/PlotData.h Client/udpscope/PlotData.cpp \
        Client/udpscope/PaneView.h Client/udpscope/PaneView.cpp \
        Client/udpscope/App.h Client/udpscope/App.cpp \
        Client/udpscope/tests/PlotDataTest.cpp Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): trace fetching, min/max decimation and the first live plot"

Task 11: Splittable pane grid

Draw the whole tree instead of just the root leaf: every leaf gets its rect from PaneTree::layout(), hovering one reveals four inset split handles and a close ✕, and the shared borders drag to re-proportion the split.

Files:

  • Modify: Client/udpscope/PaneTree.h, Client/udpscope/PaneTree.cpp (deferred command application)
  • Modify: Client/udpscope/PaneView.h, Client/udpscope/PaneView.cpp (tree drawing and interaction)
  • Modify: Client/udpscope/App.cpp
  • Test: Client/udpscope/tests/PaneCommandTest.cpp

Interfaces:

  • Consumes: PaneTree, PaneNode, Rect, Orient, Handle, kMinPaneSize, kSplitterGrab, kHandleSize (Task 2); PaneView, PaneContext (Task 10).
  • Produces:
// PaneTree.h
struct PaneCommand {
    enum class Kind { None, Split, Close, SetRatio };
    Kind      kind   = Kind::None;
    PaneNode* target = nullptr;
    Orient    orient = Orient::Columns;   // Split only
    double    ratio  = 0.5;               // SetRatio only
};
void ApplyPaneCommand(PaneTree& tree, const PaneCommand& cmd);

// PaneView.h
void PaneView::drawTree(PaneTree& tree, const Rect& area, PaneContext& ctx);

Why commands are deferred: layout() hands out raw PaneNode*. Splitting or closing during the walk re-parents nodes and invalidates those pointers mid-frame. Collect at most one command per frame and apply it after the walk.

  • Step 1: Write the failing tests

Create Client/udpscope/tests/PaneCommandTest.cpp:

#include "PaneTree.h"
#include <gtest/gtest.h>

using namespace udpscope;

TEST(PaneCommand, NoneLeavesTheTreeAlone) {
    PaneTree tree;
    PaneCommand cmd;
    ApplyPaneCommand(tree, cmd);
    EXPECT_EQ(tree.leafCount(), 1u);
}

TEST(PaneCommand, SplitAddsALeaf) {
    PaneTree tree;
    PaneCommand cmd;
    cmd.kind   = PaneCommand::Kind::Split;
    cmd.target = tree.root();
    cmd.orient = Orient::Rows;
    ApplyPaneCommand(tree, cmd);
    EXPECT_EQ(tree.leafCount(), 2u);
    EXPECT_FALSE(tree.root()->leaf);
    EXPECT_EQ(tree.root()->orient, Orient::Rows);
}

TEST(PaneCommand, CloseRemovesALeaf) {
    PaneTree tree;
    PaneCommand split;
    split.kind   = PaneCommand::Kind::Split;
    split.target = tree.root();
    ApplyPaneCommand(tree, split);
    ASSERT_EQ(tree.leafCount(), 2u);

    PaneCommand close;
    close.kind   = PaneCommand::Kind::Close;
    close.target = tree.root()->a.get();
    ApplyPaneCommand(tree, close);
    EXPECT_EQ(tree.leafCount(), 1u);
}

// Closing the only pane would leave nothing to draw and no way to get a pane
// back, so it is refused.
TEST(PaneCommand, ClosingTheLastLeafIsRefused) {
    PaneTree tree;
    PaneNode* only = tree.root();
    PaneCommand close;
    close.kind   = PaneCommand::Kind::Close;
    close.target = only;
    ApplyPaneCommand(tree, close);
    EXPECT_EQ(tree.leafCount(), 1u);
    EXPECT_TRUE(tree.root()->leaf);
}

TEST(PaneCommand, SetRatioClampsIntoTheLegalRange) {
    PaneTree tree;
    PaneCommand split;
    split.kind   = PaneCommand::Kind::Split;
    split.target = tree.root();
    ApplyPaneCommand(tree, split);

    PaneCommand r;
    r.kind   = PaneCommand::Kind::SetRatio;
    r.target = tree.root();
    r.ratio  = 5.0;
    ApplyPaneCommand(tree, r);
    EXPECT_LE(tree.root()->ratio, 1.0);
    EXPECT_GT(tree.root()->ratio, 0.0);
}

TEST(PaneCommand, ANullTargetIsIgnored) {
    PaneTree tree;
    PaneCommand cmd;
    cmd.kind   = PaneCommand::Kind::Split;
    cmd.target = nullptr;
    ApplyPaneCommand(tree, cmd);
    EXPECT_EQ(tree.leafCount(), 1u);
}
  • Step 2: Run the tests to verify they fail
cd Client/udpscope && cmake --build build -j 2>&1 | tail -5

Expected: FAIL — no type named 'PaneCommand' in namespace 'udpscope'.

  • Step 3: Add PaneCommand to PaneTree.h

Append inside namespace udpscope, after the PaneTree class:

/**
 * A pending mutation of the tree.
 *
 * layout() hands out raw PaneNode*, which splitting or closing invalidates.
 * The view therefore records at most one command per frame and applies it
 * after the layout walk has finished.
 */
struct PaneCommand {
    enum class Kind { None, Split, Close, SetRatio };
    Kind      kind   = Kind::None;
    PaneNode* target = nullptr;
    Orient    orient = Orient::Columns;
    double    ratio  = 0.5;
};

void ApplyPaneCommand(PaneTree& tree, const PaneCommand& cmd);
  • Step 4: Implement it in PaneTree.cpp
void ApplyPaneCommand(PaneTree& tree, const PaneCommand& cmd) {
    if (cmd.kind == PaneCommand::Kind::None || cmd.target == nullptr) {
        return;
    }
    switch (cmd.kind) {
    case PaneCommand::Kind::Split:
        tree.splitLeaf(cmd.target, cmd.orient);
        break;
    case PaneCommand::Kind::Close:
        /* Refuse the last one: an empty tree has nothing to draw and no way
           back. */
        if (tree.leafCount() > 1u) {
            tree.closeLeaf(cmd.target);
        }
        break;
    case PaneCommand::Kind::SetRatio:
        tree.setRatio(cmd.target, cmd.ratio);   /* clamps internally */
        break;
    case PaneCommand::Kind::None:
        break;
    }
}
  • Step 5: Run the command tests and verify they pass
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='PaneCommand*:PaneTree*'

Expected: PASS, 18 tests (12 from Task 2, 6 new).

  • Step 6: Draw the tree

In Client/udpscope/PaneView.h, add to the class:

    void drawTree(PaneTree& tree, const Rect& area, PaneContext& ctx);

private:
    void drawHandles(PaneNode& leaf, const Rect& r, PaneCommand& cmd);

    PaneCommand pending_;
    PaneNode*   dragSplitter_ = nullptr;

In Client/udpscope/PaneView.cpp, add:

namespace {

/* Panes are drawn inset so the shared border stays free for the splitter. */
const float kInset = 3.0f;

ImVec2 topLeft(const Rect& r)  { return ImVec2((float) r.x, (float) r.y); }
ImVec2 sizeOf(const Rect& r)   { return ImVec2((float) r.w, (float) r.h); }

} /* namespace */

void PaneView::drawHandles(PaneNode& leaf, const Rect& r, PaneCommand& cmd) {
    ImDrawList* dl = ImGui::GetWindowDrawList();
    const float  h  = (float) kHandleSize;
    const ImU32  bg = IM_COL32(60, 62, 84, 220);
    const ImU32  fg = IM_COL32(205, 214, 244, 255);

    struct Spot { Handle which; ImVec2 pos; const char* glyph; };
    const Spot spots[5] = {
        {Handle::Left,   ImVec2((float) (r.x + kHandleSize),
                                (float) (r.y + r.h * 0.5)), "|"},
        {Handle::Right,  ImVec2((float) (r.x + r.w - kHandleSize),
                                (float) (r.y + r.h * 0.5)), "|"},
        {Handle::Top,    ImVec2((float) (r.x + r.w * 0.5),
                                (float) (r.y + kHandleSize)), "-"},
        {Handle::Bottom, ImVec2((float) (r.x + r.w * 0.5),
                                (float) (r.y + r.h - kHandleSize)), "-"},
        {Handle::Close,  ImVec2((float) (r.x + r.w - kHandleSize),
                                (float) (r.y + kHandleSize)), "x"},
    };

    for (int i = 0; i < 5; i++) {
        const ImVec2 c = spots[i].pos;
        ImGui::SetCursorScreenPos(ImVec2(c.x - h * 0.5f, c.y - h * 0.5f));
        ImGui::PushID(i);
        /* Submitted after the plot, so it wins the hit test over it. */
        const bool clicked = ImGui::InvisibleButton("##handle", ImVec2(h, h));
        const bool hovered = ImGui::IsItemHovered();
        ImGui::PopID();

        dl->AddRectFilled(ImVec2(c.x - h * 0.5f, c.y - h * 0.5f),
                          ImVec2(c.x + h * 0.5f, c.y + h * 0.5f),
                          hovered ? IM_COL32(88, 91, 112, 255) : bg, 3.0f);
        dl->AddText(ImVec2(c.x - 3.0f, c.y - 7.0f), fg, spots[i].glyph);

        if (!clicked) {
            continue;
        }
        switch (spots[i].which) {
        case Handle::Left:
        case Handle::Right:
            cmd.kind   = PaneCommand::Kind::Split;
            cmd.target = &leaf;
            cmd.orient = Orient::Columns;
            break;
        case Handle::Top:
        case Handle::Bottom:
            cmd.kind   = PaneCommand::Kind::Split;
            cmd.target = &leaf;
            cmd.orient = Orient::Rows;
            break;
        case Handle::Close:
            cmd.kind   = PaneCommand::Kind::Close;
            cmd.target = &leaf;
            break;
        case Handle::None:
            break;
        }
    }
}

void PaneView::drawTree(PaneTree& tree, const Rect& area, PaneContext& ctx) {
    std::vector<PaneTree::Placed>   placed;
    std::vector<PaneTree::Splitter> splitters;
    tree.layout(area, placed, splitters);

    PaneCommand cmd;

    for (size_t i = 0u; i < placed.size(); i++) {
        Rect r = placed[i].rect;
        r.x += kInset;
        r.y += kInset;
        r.w -= 2.0 * kInset;
        r.h -= 2.0 * kInset;
        if (r.w <= 1.0 || r.h <= 1.0) {
            continue;
        }

        char id[32];
        std::snprintf(id, sizeof(id), "##pane%zu", i);
        ImGui::SetCursorScreenPos(topLeft(r));
        drawLeaf(*placed[i].leaf, id, sizeOf(r), ctx);

        const ImVec2 m = ImGui::GetIO().MousePos;
        if (r.contains(m.x, m.y)) {
            drawHandles(*placed[i].leaf, r, cmd);
        }
    }

    /* Splitter drags. The grab zone is the gap the inset left behind. */
    for (size_t i = 0u; i < splitters.size(); i++) {
        const Rect& s = splitters[i].rect;
        ImGui::SetCursorScreenPos(topLeft(s));
        ImGui::PushID(static_cast<int>(1000 + i));
        ImGui::InvisibleButton("##split", sizeOf(s));
        const bool hovered = ImGui::IsItemHovered();
        const bool active  = ImGui::IsItemActive();
        ImGui::PopID();

        if (hovered || active) {
            ImGui::SetMouseCursor(splitters[i].orient == Orient::Columns
                                      ? ImGuiMouseCursor_ResizeEW
                                      : ImGuiMouseCursor_ResizeNS);
        }
        if (!active) {
            continue;
        }
        /* Convert the drag into a ratio on the parent's own extent, so the
           pointer stays glued to the border regardless of nesting depth. The
           Splitter struct does not carry the parent's size, so recover it by
           summing the child rects that this splitter separates. */
        const PaneNode* node  = splitters[i].node;
        const ImVec2    d     = ImGui::GetIO().MouseDelta;
        const double    delta = (splitters[i].orient == Orient::Columns) ? d.x : d.y;
        double parentExtent = 0.0;
        for (size_t k = 0u; k < placed.size(); k++) {
            const Rect& pr = placed[k].rect;
            if (splitters[i].orient == Orient::Columns) {
                if (pr.y <= s.y + 1.0 && pr.y + pr.h >= s.y + s.h - 1.0) {
                    parentExtent += pr.w;
                }
            } else {
                if (pr.x <= s.x + 1.0 && pr.x + pr.w >= s.x + s.w - 1.0) {
                    parentExtent += pr.h;
                }
            }
        }
        if (parentExtent < kMinPaneSize) {
            continue;
        }
        cmd.kind   = PaneCommand::Kind::SetRatio;
        cmd.target = const_cast<PaneNode*>(node);
        cmd.ratio  = node->ratio + delta / parentExtent;
    }

    ApplyPaneCommand(tree, cmd);
}

drawTree() needs #include <cstdio> for snprintf and <vector>; both are already pulled in by PaneView.cpp's existing includes.

  • Step 7: Call it from App

Replace the last two lines of App::drawPlotArea():

    const ImVec2 origin = ImGui::GetCursorScreenPos();
    const ImVec2 avail  = ImGui::GetContentRegionAvail();
    Rect area;
    area.x = origin.x;
    area.y = origin.y;
    area.w = avail.x;
    area.h = avail.y;
    paneView_.drawTree(tree_, area, ctx);
  • Step 8: Build and check the interaction by hand
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests

Expected: PASS, all tests from Tasks 111.

Run against the demo streamer and walk this checklist:

  1. Hover a pane: five handles appear (four edge midpoints, ✕ top-right).
  2. Click the right handle: two side-by-side panes, the new one empty.
  3. Drag a signal onto the new pane: it plots there and not in the first.
  4. Click the bottom handle of the right pane: it splits into two rows.
  5. Drag the border between the columns: both resize, the pointer stays on the border, and neither pane goes below roughly 80 px.
  6. Click ✕ on the bottom-right pane: it closes and its sibling takes the space.
  7. Close panes until one is left, then click its ✕: nothing happens.
  • Step 9: Commit
git add Client/udpscope/PaneTree.h Client/udpscope/PaneTree.cpp \
        Client/udpscope/PaneView.h Client/udpscope/PaneView.cpp \
        Client/udpscope/App.cpp Client/udpscope/tests/PaneCommandTest.cpp
git commit -m "feat(udpscope): splittable pane grid with inset handles and draggable splitters"

Task 12: Shared X axis and per-trace vertical scale

Two pieces of scope behaviour that are pure arithmetic and therefore fully testable: the X axis that every pane shares and that follows the newest sample until the user pans, and the per-trace vertical scale in the three modes the spec calls for.

Every pane's Y axis is a fixed ±4 divisions, exactly like a bench scope, and each trace is mapped into division space by its own volts-per-division and offset. Auto and Range compute those two numbers; Manual takes them from the user. This is what makes traces with different units share a pane without either being squashed.

Files:

  • Create: Client/udpscope/Axes.h
  • Create: Client/udpscope/Axes.cpp
  • Modify: Client/udpscope/App.h, Client/udpscope/App.cpp
  • Modify: Client/udpscope/PaneView.cpp
  • Modify: Client/udpscope/CMakeLists.txt
  • Test: Client/udpscope/tests/AxesTest.cpp

Interfaces:

  • Consumes: VScale, VMode (Task 2); SignalMeta, Series (Task 1).
  • Produces:
constexpr double kDivisionsY  = 8.0;   // full pane height, ±4
constexpr double kUsableDivsY = 6.0;   // data fills 6 of the 8

void   ComputeVScale(const SignalMeta& meta, const Series& raw, VScale& vs);
double ToDivisions(double value, const VScale& vs);

class XAxisController {
public:
    void   setSpan(double sec);
    double span() const;
    void   setLive(bool live);
    bool   live() const;
    void   followNewest(double newest);
    void   userRange(double x0, double x1);
    double x0() const;
    double x1() const;
};
  • Step 1: Write the failing tests

Create Client/udpscope/tests/AxesTest.cpp:

#include "Axes.h"
#include <gtest/gtest.h>
#include <cmath>

using namespace udpscope;

namespace {

SignalMeta meta(double lo, double hi) {
    SignalMeta m;
    m.name     = "a";
    m.typeCode = 8;
    m.rangeMin = lo;
    m.rangeMax = hi;
    return m;
}

Series ramp(double lo, double hi, size_t n) {
    Series s;
    for (size_t i = 0; i < n; ++i) {
        const double f = static_cast<double>(i) / static_cast<double>(n - 1);
        s.t.push_back(static_cast<double>(i));
        s.v.push_back(lo + f * (hi - lo));
    }
    return s;
}

} // namespace

TEST(VScale, AutoCentresTheDataAndFillsTheUsableDivisions) {
    VScale vs;
    vs.mode = VMode::Auto;
    const Series s = ramp(1.0, 3.0, 100);
    ComputeVScale(meta(-10.0, 10.0), s, vs);

    EXPECT_NEAR(vs.offset, 2.0, 1e-9) << "midpoint should be screen centre";
    EXPECT_NEAR(vs.div, 2.0 / kUsableDivsY, 1e-9);
    EXPECT_NEAR(ToDivisions(3.0, vs),  kUsableDivsY / 2.0, 1e-9);
    EXPECT_NEAR(ToDivisions(1.0, vs), -kUsableDivsY / 2.0, 1e-9);
    EXPECT_NEAR(ToDivisions(2.0, vs), 0.0, 1e-9);
}

// range_min/range_max are already in the CONFIG packet and are the physically
// meaningful full scale, so Range must not peek at the data.
TEST(VScale, RangeUsesTheConfigFullScaleNotTheData) {
    VScale vs;
    vs.mode = VMode::Range;
    const Series s = ramp(1.0, 1.001, 100);
    ComputeVScale(meta(-10.0, 10.0), s, vs);

    EXPECT_NEAR(vs.offset, 0.0, 1e-9);
    EXPECT_NEAR(vs.div, 20.0 / kUsableDivsY, 1e-9);
    EXPECT_NEAR(ToDivisions(10.0, vs), kUsableDivsY / 2.0, 1e-9);
}

TEST(VScale, ManualIsLeftExactlyAsTheUserSetIt) {
    VScale vs;
    vs.mode   = VMode::Manual;
    vs.div    = 0.25;
    vs.offset = 1.5;
    ComputeVScale(meta(-10.0, 10.0), ramp(0.0, 100.0, 10), vs);

    EXPECT_DOUBLE_EQ(vs.div, 0.25);
    EXPECT_DOUBLE_EQ(vs.offset, 1.5);
    EXPECT_DOUBLE_EQ(ToDivisions(1.75, vs), 1.0);
}

// A flat signal has zero peak-to-peak; dividing by it would put the trace at
// infinity instead of on the centre line.
TEST(VScale, AConstantSignalGetsAUsableScale) {
    VScale vs;
    vs.mode = VMode::Auto;
    Series s;
    for (int i = 0; i < 10; ++i) { s.t.push_back(i); s.v.push_back(7.0); }
    ComputeVScale(meta(0.0, 0.0), s, vs);

    EXPECT_GT(vs.div, 0.0);
    EXPECT_TRUE(std::isfinite(ToDivisions(7.0, vs)));
    EXPECT_NEAR(ToDivisions(7.0, vs), 0.0, 1e-9);
}

TEST(VScale, AutoOnAnEmptySeriesLeavesTheScaleUsable) {
    VScale vs;
    vs.mode = VMode::Auto;
    const Series empty;
    ComputeVScale(meta(0.0, 0.0), empty, vs);
    EXPECT_GT(vs.div, 0.0);
}

TEST(XAxis, LiveFollowsTheNewestSample) {
    XAxisController x;
    x.setSpan(2.0);
    x.followNewest(100.0);
    EXPECT_DOUBLE_EQ(x.x1(), 100.0);
    EXPECT_DOUBLE_EQ(x.x0(), 98.0);
    x.followNewest(101.0);
    EXPECT_DOUBLE_EQ(x.x1(), 101.0);
}

TEST(XAxis, AUserRangeDetachesFromLive) {
    XAxisController x;
    x.setSpan(2.0);
    x.followNewest(100.0);
    x.userRange(10.0, 12.5);
    EXPECT_FALSE(x.live());
    EXPECT_DOUBLE_EQ(x.x0(), 10.0);
    EXPECT_DOUBLE_EQ(x.span(), 2.5) << "a zoom must redefine the span";

    x.followNewest(200.0);
    EXPECT_DOUBLE_EQ(x.x0(), 10.0) << "detached axis must not be dragged along";
}

TEST(XAxis, ReattachingSnapsBackToTheNewestSample) {
    XAxisController x;
    x.setSpan(2.0);
    x.userRange(10.0, 12.0);
    ASSERT_FALSE(x.live());
    x.setLive(true);
    x.followNewest(300.0);
    EXPECT_TRUE(x.live());
    EXPECT_DOUBLE_EQ(x.x1(), 300.0);
    EXPECT_DOUBLE_EQ(x.x0(), 298.0);
}

TEST(XAxis, ChangingTheSpanKeepsTheRightEdgePinned) {
    XAxisController x;
    x.setSpan(2.0);
    x.followNewest(100.0);
    x.setSpan(0.5);
    EXPECT_DOUBLE_EQ(x.x1(), 100.0);
    EXPECT_DOUBLE_EQ(x.x0(), 99.5);
}

TEST(XAxis, ADegenerateRangeIsRejected) {
    XAxisController x;
    x.setSpan(2.0);
    x.userRange(5.0, 5.0);
    EXPECT_GT(x.span(), 0.0);
    x.setSpan(0.0);
    EXPECT_GT(x.span(), 0.0);
}
  • Step 2: Run the tests to verify they fail
cd Client/udpscope && cmake --build build -j 2>&1 | tail -5

Expected: FAIL — Axes.h: No such file or directory.

  • Step 3: Write Axes.h

Create Client/udpscope/Axes.h:

/**
 * @file Axes.h
 * @brief Shared X axis behaviour and per-trace vertical scaling.
 *
 * Panes always show ±4 divisions vertically, as a bench scope does. Each trace
 * carries its own volts-per-division and offset, so traces with different units
 * can share a pane without one flattening the other.
 */
#ifndef UDPSCOPE_AXES_H
#define UDPSCOPE_AXES_H

#include "PaneTree.h"
#include "Types.h"

namespace udpscope {

/** Full pane height in divisions. */
constexpr double kDivisionsY = 8.0;
/** Divisions the data is scaled to fill, leaving one at the top and bottom. */
constexpr double kUsableDivsY = 6.0;

/**
 * Fill vs.div and vs.offset for Auto and Range. Manual is left untouched.
 * Never leaves div at zero, whatever the data does.
 */
void ComputeVScale(const SignalMeta& meta, const Series& raw, VScale& vs);

/** Map a value into division space: 0 is the centre line. */
double ToDivisions(double value, const VScale& vs);

/**
 * The one X range every pane shares.
 *
 * Live mode pins the right edge to the newest sample. Any pan or zoom detaches
 * it — silently re-attaching would fight the user every frame — and the Live
 * button re-attaches.
 */
class XAxisController {
public:
    void   setSpan(double sec);
    double span() const { return span_; }
    void   setLive(bool live) { live_ = live; }
    bool   live() const { return live_; }
    /** Called once a frame while live. Ignored when detached. */
    void   followNewest(double newest);
    /** Records a user pan or zoom and detaches. */
    void   userRange(double x0, double x1);
    double x0() const { return x1_ - span_; }
    double x1() const { return x1_; }

private:
    double span_ = 1.0;
    double x1_   = 1.0;
    bool   live_ = true;
};

} /* namespace udpscope */

#endif /* UDPSCOPE_AXES_H */
  • Step 4: Write Axes.cpp

Create Client/udpscope/Axes.cpp:

#include "Axes.h"

#include <algorithm>
#include <cmath>

namespace udpscope {

namespace {

/** Turn a full-scale span into a per-division value, never zero. */
double divFromSpan(double span) {
    if (!(span > 0.0) || !std::isfinite(span)) {
        return 1.0;
    }
    return span / kUsableDivsY;
}

} /* namespace */

void ComputeVScale(const SignalMeta& meta, const Series& raw, VScale& vs) {
    if (vs.mode == VMode::Manual) {
        if (!(vs.div > 0.0) || !std::isfinite(vs.div)) {
            vs.div = 1.0;
        }
        return;
    }

    if (vs.mode == VMode::Range) {
        /* The CONFIG full scale, whatever the data happens to be doing. */
        vs.offset = 0.5 * (meta.rangeMin + meta.rangeMax);
        vs.div    = divFromSpan(meta.rangeMax - meta.rangeMin);
        return;
    }

    /* Auto: fit the samples actually on screen. */
    if (raw.empty()) {
        vs.offset = 0.0;
        vs.div    = 1.0;
        return;
    }
    double lo = raw.v[0];
    double hi = raw.v[0];
    for (size_t i = 1u; i < raw.v.size(); i++) {
        lo = std::min(lo, raw.v[i]);
        hi = std::max(hi, raw.v[i]);
    }
    vs.offset = 0.5 * (lo + hi);
    vs.div    = divFromSpan(hi - lo);   /* a flat trace lands on the centre */
}

double ToDivisions(double value, const VScale& vs) {
    const double d = (vs.div > 0.0 && std::isfinite(vs.div)) ? vs.div : 1.0;
    return (value - vs.offset) / d;
}

void XAxisController::setSpan(double sec) {
    if (sec > 0.0 && std::isfinite(sec)) {
        span_ = sec;   /* x1_ is unchanged, so the right edge stays pinned */
    }
}

void XAxisController::followNewest(double newest) {
    if (live_ && std::isfinite(newest)) {
        x1_ = newest;
    }
}

void XAxisController::userRange(double x0, double x1) {
    if (!(x1 > x0) || !std::isfinite(x0) || !std::isfinite(x1)) {
        return;
    }
    span_ = x1 - x0;
    x1_   = x1;
    live_ = false;
}

} /* namespace udpscope */

Add Axes.cpp to CORE_SOURCES.

  • Step 5: Run the axis tests and verify they pass
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='VScale*:XAxis*'

Expected: PASS, 10 tests.

  • Step 6: Draw traces in division space

In Client/udpscope/PaneView.cpp, add #include "Axes.h" and replace the plotting body of drawLeaf()'s loop (between the FetchLiveTrace call and BeginLegendPopup) with:

        if (!ok || d.draw.empty()) {
            continue;
        }
        ComputeVScale(ctx.metaFor(a.signalName), d.raw, a.vs);

        /* Reuse one scratch buffer per pane rather than allocating per trace
           at 60 Hz. */
        divScratch_.resize(d.draw.size());
        for (size_t k = 0u; k < d.draw.size(); k++) {
            divScratch_[k] = ToDivisions(d.draw.v[k], a.vs);
        }

        char label[128];
        std::snprintf(label, sizeof(label), "%s  %.4g %s/div", a.signalName.c_str(),
                      a.vs.div, ctx.metaFor(a.signalName).unit.empty()
                                    ? "u" : ctx.metaFor(a.signalName).unit.c_str());
        ImPlot::SetNextLineStyle(toImVec4(a.color), a.lineWidth);
        ImPlot::PlotLine(label, d.draw.t.data(), divScratch_.data(),
                         static_cast<int>(d.draw.size()));

The legend popup key must change with the label, so replace ImPlot::BeginLegendPopup(a.signalName.c_str()) with ImPlot::BeginLegendPopup(label), and add the vertical-scale controls to it:

            const char* modes[] = {"auto", "range", "manual"};
            int mode = static_cast<int>(a.vs.mode);
            if (ImGui::Combo("v-scale", &mode, modes, 3)) {
                a.vs.mode = static_cast<VMode>(mode);
            }
            if (a.vs.mode == VMode::Manual) {
                ImGui::InputDouble("per div", &a.vs.div, 0.0, 0.0, "%.6g");
                ImGui::InputDouble("offset",  &a.vs.offset, 0.0, 0.0, "%.6g");
            }

Add to PaneView's private section:

    std::vector<double> divScratch_;

and to PaneContext:

    const std::vector<SignalMeta>* metas = nullptr;
    /** Metadata for a signal, or a default-constructed one if it is gone. */
    const SignalMeta& metaFor(const std::string& name) const;

implemented in PaneView.cpp:

const SignalMeta& PaneContext::metaFor(const std::string& name) const {
    static const SignalMeta kUnknown;
    if (metas != nullptr) {
        for (size_t i = 0u; i < metas->size(); i++) {
            if ((*metas)[i].name == name) {
                return (*metas)[i];
            }
        }
    }
    return kUnknown;
}

Finally pin the Y axis and let ImPlot report the user's X range. Replace the SetupAxes/SetupAxisLimits pair with:

    ImPlot::SetupAxes("t [s]", "div");
    ImPlot::SetupAxisLimits(ImAxis_X1, ctx.x0, ctx.x1,
                            ctx.xLive ? ImPlotCond_Always : ImPlotCond_Once);
    ImPlot::SetupAxisLimits(ImAxis_Y1, -kDivisionsY * 0.5, kDivisionsY * 0.5,
                            ImPlotCond_Always);

and just before ImPlot::EndPlot():

    /* A pan or zoom in any pane redefines the shared range for all of them. */
    if (ImPlot::IsPlotHovered() && ImGui::IsMouseDragging(ImGuiMouseButton_Left)) {
        const ImPlotRect lim = ImPlot::GetPlotLimits();
        ctx.userX0 = lim.X.Min;
        ctx.userX1 = lim.X.Max;
        ctx.userChanged = true;
    }
    if (ImPlot::IsPlotHovered() && ImGui::GetIO().MouseWheel != 0.0f) {
        const ImPlotRect lim = ImPlot::GetPlotLimits();
        ctx.userX0 = lim.X.Min;
        ctx.userX1 = lim.X.Max;
        ctx.userChanged = true;
    }

with the three new PaneContext fields:

    bool   xLive = true;
    double userX0 = 0.0, userX1 = 0.0;
    bool   userChanged = false;
  • Step 7: Own the axis in App

In App.h, add #include "Axes.h" and replace double xSpanSec_ with:

    XAxisController xaxis_;

In App.cpp, replace the range computation in drawPlotArea() with:

    double newest = 0.0;
    bool   any    = false;
    for (size_t i = 0u; i < sigs_.size(); i++) {
        double o = 0.0, n = 0.0;
        if (store_.span(sigs_[i].name, o, n) && (!any || n > newest)) {
            newest = n;
            any    = true;
        }
    }
    if (any) {
        xaxis_.followNewest(newest);
    }
    ctx.x0     = xaxis_.x0();
    ctx.x1     = xaxis_.x1();
    ctx.xLive  = xaxis_.live();
    ctx.metas  = &sigs_;

and after paneView_.drawTree(...):

    if (ctx.userChanged) {
        xaxis_.userRange(ctx.userX0, ctx.userX1);
    }

There is no View menu yet — Task 9 built only File and Help. Add one to drawMenuBar(), between the File and Help blocks:

    if (ImGui::BeginMenu("View")) {
        bool live = xaxis_.live();
        if (ImGui::MenuItem("Live", "L", &live)) {
            xaxis_.setLive(live);
        }
        double span = xaxis_.span();
        ImGui::SetNextItemWidth(120.0f);
        if (ImGui::InputDouble("window [s]", &span, 0.0, 0.0, "%.4g")) {
            xaxis_.setSpan(span);
        }
        ImGui::EndMenu();
    }
  • Step 8: Build and verify by hand
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests

Expected: PASS, all tests from Tasks 112.

Against the demo streamer:

  1. Two panes, a signal in each: both scroll together.
  2. Drag left in one pane: both stop following and pan together; View → Live is now unchecked.
  3. Tick View → Live: both snap back to the newest sample.
  4. Set View → window to 0.05: both show 50 ms, right edge pinned.
  5. Right-click a legend entry, set v-scale to range: the trace rescales to the CONFIG full scale and the label shows the new value per division.
  6. Set it to manual with a small per-div: the trace clips off the top and bottom of its ±4 divisions, as a bench scope does.
  • Step 9: Commit
git add Client/udpscope/Axes.h Client/udpscope/Axes.cpp \
        Client/udpscope/App.h Client/udpscope/App.cpp Client/udpscope/PaneView.h \
        Client/udpscope/PaneView.cpp Client/udpscope/tests/AxesTest.cpp \
        Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): shared X axis with live follow and per-trace division scaling"

Task 13: Trigger bar and capture display

Files:

  • Create: Client/udpscope/CaptureLatch.h
  • Create: Client/udpscope/CaptureLatch.cpp
  • Create: Client/udpscope/tests/CaptureLatchTest.cpp
  • Create: Client/udpscope/TriggerBar.cpp
  • Modify: Client/udpscope/App.h
  • Modify: Client/udpscope/App.cpp
  • Modify: Client/udpscope/CMakeLists.txt

Interfaces:

  • Consumes: SignalStore::captureSeq(), SignalStore::readCapture(), Capture (Task 6); Receiver::setTrigConfig/trigConfig/arm/disarm/rearm/trigStatus and TrigStatus (Task 7); TrigConfig, Edge, TrigMode, TrigState (Task 5); PaneContext::capture (Task 10); XAxisController (Task 12).
  • Produces: class CaptureLatch with poll(const SignalStore&) -> bool, showing(), setFollow(bool), follow(), returnToLive(), capture() -> const Capture&, x0(), x1(), seen() -> uint64_t; and std::string TrigBadge(const TrigStatus&). Task 16 exports CaptureLatch::capture() to CSV.

Why a latch rather than reading the store directly in the draw call: the pane tree draws many leaves per frame and each one needs the same capture, so the copy out of the store must happen once per frame, not once per pane. The latch also owns the freeze decision — a user studying one capture must not have it swapped out from under them by the next trigger — and that decision is pure state machine, so it is unit-testable without a window.

  • Step 1: Write the failing tests

Create Client/udpscope/tests/CaptureLatchTest.cpp:

#include "CaptureLatch.h"

#include <gtest/gtest.h>
#include <string>

using namespace udpscope;

namespace {

/* Publish a capture whose bounds identify it, so the tests can tell which
   one the latch is holding. */
void publish(SignalStore& s, double t0, double t1) {
    Capture c;
    c.trigTime = 0.5 * (t0 + t1);
    c.t0       = t0;
    c.t1       = t1;
    c.names.push_back("sig");
    Series ser;
    ser.t.push_back(t0);
    ser.v.push_back(1.0);
    c.series.push_back(ser);
    s.publishCapture(std::move(c));
}

TrigStatus status(TrigState st, double fill) {
    TrigStatus s;
    s.state = st;
    s.fill  = fill;
    return s;
}

} // namespace

TEST(CaptureLatch, FreshLatchShowsLive) {
    CaptureLatch latch;
    SignalStore store;
    EXPECT_FALSE(latch.showing());
    EXPECT_FALSE(latch.poll(store));
    EXPECT_FALSE(latch.showing());
    EXPECT_TRUE(latch.follow());
}

TEST(CaptureLatch, PollAdoptsTheFirstCapture) {
    CaptureLatch latch;
    SignalStore store;
    publish(store, 1.0, 2.0);

    EXPECT_TRUE(latch.poll(store));
    ASSERT_TRUE(latch.showing());
    EXPECT_DOUBLE_EQ(latch.x0(), 1.0);
    EXPECT_DOUBLE_EQ(latch.x1(), 2.0);
    EXPECT_EQ(latch.capture().names.size(), 1u);
}

// The draw loop polls every frame; only a genuinely new capture is news.
TEST(CaptureLatch, PollIsIdempotentWithoutANewCapture) {
    CaptureLatch latch;
    SignalStore store;
    publish(store, 1.0, 2.0);

    ASSERT_TRUE(latch.poll(store));
    EXPECT_FALSE(latch.poll(store));
    EXPECT_FALSE(latch.poll(store));
    EXPECT_TRUE(latch.showing());
}

TEST(CaptureLatch, FollowingLatchAdoptsTheNewerCapture) {
    CaptureLatch latch;
    SignalStore store;
    publish(store, 1.0, 2.0);
    ASSERT_TRUE(latch.poll(store));

    publish(store, 5.0, 6.0);
    EXPECT_TRUE(latch.poll(store));
    EXPECT_DOUBLE_EQ(latch.x0(), 5.0);
}

// Studying a waveform must not be interrupted by the next trigger.
TEST(CaptureLatch, FrozenLatchKeepsTheDisplayedCapture) {
    CaptureLatch latch;
    SignalStore store;
    publish(store, 1.0, 2.0);
    ASSERT_TRUE(latch.poll(store));

    latch.setFollow(false);
    publish(store, 5.0, 6.0);
    EXPECT_FALSE(latch.poll(store));
    EXPECT_DOUBLE_EQ(latch.x0(), 1.0);
}

// Un-freezing must not have to wait for yet another trigger: the capture that
// arrived while frozen is still the newest one, and it is adopted at once.
TEST(CaptureLatch, UnfreezingAdoptsTheCaptureThatArrivedWhileFrozen) {
    CaptureLatch latch;
    SignalStore store;
    publish(store, 1.0, 2.0);
    ASSERT_TRUE(latch.poll(store));
    latch.setFollow(false);
    publish(store, 5.0, 6.0);
    ASSERT_FALSE(latch.poll(store));

    latch.setFollow(true);
    EXPECT_TRUE(latch.poll(store));
    EXPECT_DOUBLE_EQ(latch.x0(), 5.0);
}

TEST(CaptureLatch, ReturnToLiveDropsTheDisplayedCapture) {
    CaptureLatch latch;
    SignalStore store;
    publish(store, 1.0, 2.0);
    ASSERT_TRUE(latch.poll(store));

    latch.returnToLive();
    EXPECT_FALSE(latch.showing());
    EXPECT_FALSE(latch.poll(store));   // the same capture is not re-adopted
}

TEST(CaptureLatch, ReturnToLiveIsUndoneByTheNextTrigger) {
    CaptureLatch latch;
    SignalStore store;
    publish(store, 1.0, 2.0);
    ASSERT_TRUE(latch.poll(store));
    latch.returnToLive();

    publish(store, 5.0, 6.0);
    EXPECT_TRUE(latch.poll(store));
    EXPECT_TRUE(latch.showing());
    EXPECT_DOUBLE_EQ(latch.x0(), 5.0);
}

TEST(TrigBadge, IdleReadsIdle) {
    EXPECT_EQ(TrigBadge(status(TrigState::Idle, 0.0)), "IDLE");
}

TEST(TrigBadge, ArmedShowsTheFillPercentage) {
    EXPECT_EQ(TrigBadge(status(TrigState::Armed, 0.625)), "ARMED 62%");
    EXPECT_EQ(TrigBadge(status(TrigState::Armed, 1.0)), "ARMED 100%");
}

// fill is a ratio computed from live timestamps and can overshoot slightly.
TEST(TrigBadge, ArmedPercentageIsClamped) {
    EXPECT_EQ(TrigBadge(status(TrigState::Armed, 1.4)), "ARMED 100%");
    EXPECT_EQ(TrigBadge(status(TrigState::Armed, -0.2)), "ARMED 0%");
}

TEST(TrigBadge, CollectingAndHeldHaveTheirOwnLabels) {
    EXPECT_EQ(TrigBadge(status(TrigState::Collecting, 1.0)), "TRIG'D");
    EXPECT_EQ(TrigBadge(status(TrigState::Held, 1.0)), "HELD");
}
  • Step 2: Run the tests to verify they fail
cd Client/udpscope && cmake --build build -j 2>&1 | tail -5

Expected: FAIL — CaptureLatch.h: No such file or directory.

  • Step 3: Write CaptureLatch.h

Create Client/udpscope/CaptureLatch.h:

/**
 * @file CaptureLatch.h
 * @brief Decides, once per frame, whether the panes draw live data or a
 *        harvested trigger capture.
 */
#ifndef UDPSCOPE_CAPTURELATCH_H
#define UDPSCOPE_CAPTURELATCH_H

#include "Receiver.h"
#include "SignalStore.h"

#include <cstdint>
#include <string>

namespace udpscope {

/** Human-readable trigger state for the trigger bar badge. */
std::string TrigBadge(const TrigStatus& st);

/**
 * Holds the capture the panes are drawing. The receiver thread publishes
 * captures into the SignalStore; this pulls at most one copy per frame so
 * that every pane in the tree draws the same waveform.
 */
class CaptureLatch {
public:
    /**
     * Adopts a newly published capture if there is one and the latch is
     * following. Returns true only on the frame the capture changes, which
     * is when the caller should re-range the X axis.
     */
    bool poll(const SignalStore& store);

    /** True when capture() is valid and the panes should draw it. */
    bool showing() const { return showing_; }

    /** The capture being displayed; only meaningful while showing(). */
    const Capture& capture() const { return cap_; }

    double x0() const { return cap_.t0; }
    double x1() const { return cap_.t1; }

    /** Sequence number of the capture last adopted; 0 before the first. */
    uint64_t seen() const { return seen_; }

    /** When false, new captures are ignored until following resumes. */
    void setFollow(bool on) { follow_ = on; }
    bool follow() const { return follow_; }

    /** Drops the displayed capture and goes back to live data. */
    void returnToLive() { showing_ = false; }

private:
    Capture  cap_;
    uint64_t seen_    = 0u;
    bool     showing_ = false;
    bool     follow_  = true;
};

} /* namespace udpscope */

#endif /* UDPSCOPE_CAPTURELATCH_H */
  • Step 4: Write CaptureLatch.cpp

Create Client/udpscope/CaptureLatch.cpp:

#include "CaptureLatch.h"

#include <algorithm>
#include <cmath>
#include <cstdio>

namespace udpscope {

std::string TrigBadge(const TrigStatus& st) {
    switch (st.state) {
    case TrigState::Armed: {
        double f = st.fill;
        if (!std::isfinite(f)) {
            f = 0.0;
        }
        f = std::min(1.0, std::max(0.0, f));
        char buf[32];
        std::snprintf(buf, sizeof(buf), "ARMED %d%%",
                      static_cast<int>(f * 100.0));
        return std::string(buf);
    }
    case TrigState::Collecting:
        return "TRIG'D";
    case TrigState::Held:
        return "HELD";
    case TrigState::Idle:
    default:
        return "IDLE";
    }
}

bool CaptureLatch::poll(const SignalStore& store) {
    if (!follow_) {
        return false;
    }
    /* seen_ is only advanced when a capture is actually adopted, so a
       capture published while frozen is picked up as soon as following
       resumes. */
    if (store.captureSeq() == seen_) {
        return false;
    }
    Capture fresh;
    if (!store.readCapture(fresh)) {
        return false;
    }
    cap_     = std::move(fresh);
    seen_    = cap_.seq;
    showing_ = true;
    return true;
}

} /* namespace udpscope */

Add CaptureLatch.cpp to CORE_SOURCES in Client/udpscope/CMakeLists.txt.

  • Step 5: Run the tests to verify they pass
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='CaptureLatch*:TrigBadge*'

Expected: PASS, 12 tests.

  • Step 6: Commit the latch
git add Client/udpscope/CaptureLatch.h Client/udpscope/CaptureLatch.cpp \
        Client/udpscope/tests/CaptureLatchTest.cpp Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): capture latch and trigger state badge"
  • Step 7: Add the trigger bar to App.h

In Client/udpscope/App.h, add the include and members:

#include "CaptureLatch.h"

Add to the private method list, next to drawMenuBar():

    void drawTriggerBar();
    /** Pushes the edited config into the receiver and re-ranges the panes. */
    void applyTrigConfig();

and to the private data, after PaneTree tree_;:

    CaptureLatch latch_;
    TrigConfig   trig_;        /**< the bar's editable copy */
    int          trigSignal_ = -1;   /**< index into sigs_, -1 = none */
  • Step 8: Draw the trigger bar

The bar gets its own translation unit, as spec §3.4 lays out; the functions are still App methods so they reach the receiver and the latch directly.

Create Client/udpscope/TriggerBar.cpp:

#include "App.h"

#include "imgui.h"

namespace udpscope {

void App::applyTrigConfig() {
    rx_.setTrigConfig(trig_);
}

void App::drawTriggerBar() {
    ImGui::BeginChild("trigbar", ImVec2(0.0f, ImGui::GetFrameHeightWithSpacing() + 6.0f),
                      false, ImGuiWindowFlags_NoScrollbar);

    /* Signal. The list can change under us on a CONFIG re-send, so the
       selection is re-resolved by name every frame. */
    trigSignal_ = -1;
    for (size_t i = 0u; i < sigs_.size(); i++) {
        if (sigs_[i].name == trig_.signalName) {
            trigSignal_ = static_cast<int>(i);
            break;
        }
    }
    const char* preview = (trigSignal_ >= 0) ? trig_.signalName.c_str() : "(none)";
    ImGui::SetNextItemWidth(160.0f);
    if (ImGui::BeginCombo("##trigsig", preview)) {
        for (size_t i = 0u; i < sigs_.size(); i++) {
            const bool sel = (static_cast<int>(i) == trigSignal_);
            if (ImGui::Selectable(sigs_[i].name.c_str(), sel)) {
                trig_.signalName = sigs_[i].name;
                applyTrigConfig();
            }
        }
        ImGui::EndCombo();
    }
    ImGui::SameLine();

    const char* edges[] = {"rising", "falling", "both"};
    int edge = static_cast<int>(trig_.edge);
    ImGui::SetNextItemWidth(90.0f);
    if (ImGui::Combo("##trigedge", &edge, edges, 3)) {
        trig_.edge = static_cast<Edge>(edge);
        applyTrigConfig();
    }
    ImGui::SameLine();

    ImGui::SetNextItemWidth(100.0f);
    if (ImGui::InputDouble("thr", &trig_.threshold, 0.0, 0.0, "%.6g",
                           ImGuiInputTextFlags_EnterReturnsTrue)) {
        applyTrigConfig();
    }
    ImGui::SameLine();

    ImGui::SetNextItemWidth(100.0f);
    if (ImGui::InputDouble("hyst", &trig_.hysteresis, 0.0, 0.0, "%.6g",
                           ImGuiInputTextFlags_EnterReturnsTrue)) {
        if (trig_.hysteresis < 0.0) {
            trig_.hysteresis = 0.0;
        }
        applyTrigConfig();
    }
    ImGui::SameLine();

    ImGui::SetNextItemWidth(100.0f);
    if (ImGui::InputDouble("win [s]", &trig_.windowSec, 0.0, 0.0, "%.6g",
                           ImGuiInputTextFlags_EnterReturnsTrue)) {
        if (!(trig_.windowSec > 0.0)) {
            trig_.windowSec = 0.1;
        }
        applyTrigConfig();
    }
    ImGui::SameLine();

    float pre = static_cast<float>(trig_.prePercent);
    ImGui::SetNextItemWidth(120.0f);
    if (ImGui::SliderFloat("pre %", &pre, 0.0f, 90.0f, "%.0f")) {
        trig_.prePercent = static_cast<double>(pre);
        applyTrigConfig();
    }
    ImGui::SameLine();

    int mode = (trig_.mode == TrigMode::Single) ? 1 : 0;
    if (ImGui::RadioButton("Norm", mode == 0)) {
        trig_.mode = TrigMode::Normal;
        applyTrigConfig();
    }
    ImGui::SameLine();
    if (ImGui::RadioButton("1x", mode == 1)) {
        trig_.mode = TrigMode::Single;
        applyTrigConfig();
    }
    ImGui::SameLine();

    /* Badge. Amber while waiting for the pre-window to fill, green once the
       capture is on screen. */
    const TrigStatus ts = rx_.trigStatus();
    ImVec4 badge(0.68f, 0.71f, 0.75f, 1.0f);              /* overlay1  */
    if (ts.state == TrigState::Armed) {
        badge = ImVec4(0.98f, 0.70f, 0.53f, 1.0f);        /* peach     */
    } else if (ts.state == TrigState::Collecting || ts.state == TrigState::Held) {
        badge = ImVec4(0.65f, 0.89f, 0.63f, 1.0f);        /* green     */
    }
    ImGui::TextColored(badge, "%s", TrigBadge(ts).c_str());
    ImGui::SameLine();

    if (ImGui::Button("Arm")) {
        applyTrigConfig();
        rx_.arm();
    }
    ImGui::SameLine();
    if (ImGui::Button("Disarm")) {
        rx_.disarm();
    }
    ImGui::SameLine();
    if (ImGui::Button("Re-arm")) {
        rx_.rearm();
    }
    ImGui::SameLine();

    bool follow = latch_.follow();
    if (ImGui::Checkbox("follow", &follow)) {
        latch_.setFollow(follow);
    }
    ImGui::SameLine();

    ImGui::BeginDisabled(!latch_.showing());
    if (ImGui::Button("Live")) {
        latch_.returnToLive();
        xaxis_.setLive(true);
    }
    ImGui::EndDisabled();
    ImGui::SameLine();
    ImGui::TextDisabled("captures: %llu",
                        static_cast<unsigned long long>(ts.captures));

    ImGui::EndChild();
    ImGui::Separator();
}

} /* namespace udpscope */

Add TriggerBar.cpp to APP_SOURCES.

Why EnterReturnsTrue on the numeric fields: without it every keystroke mid-edit is pushed to the receiver, so typing 0.05 momentarily configures a window of 0, then 0.0, resetting the FSM three times and throwing away a half-filled pre-window. The combos and slider have no such intermediate states and apply immediately.

  • Step 9: Wire the latch into the frame

In App::draw(), immediately after syncSignals() and before the panes are drawn, add:

    if (latch_.poll(store_)) {
        /* A capture just arrived: pin the shared axis to its window. This
           detaches the axis from live follow, which is what we want — the
           user can then zoom inside the capture with the normal controls. */
        xaxis_.userRange(latch_.x0(), latch_.x1());
    }

and add drawTriggerBar(); to the frame between drawMenuBar() and the signal list, matching spec §8.1's ordering.

In App::drawPlotArea(), replace ctx.capture = nullptr; with:

    ctx.capture = latch_.showing() ? &latch_.capture() : nullptr;
  • Step 10: Build and verify by hand
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests

Expected: PASS, all tests from Tasks 113.

Against the demo streamer:

source env.sh
"${MARTe2_DIR}/Build/x86-linux/App/MARTeApp.ex" -l RealTimeLoader \
    -f Test/Configurations/streamhub_demo.cfg -s Running -m StateMachine:START &
./Client/udpscope/build/UDPScope --port 44501
  1. Drop a sine into a pane; the badge reads IDLE.
  2. Pick that signal in the trigger bar, threshold 0, window 0.02, pre 20, Norm, press Arm: the badge goes ARMED 0%ARMED 100% within a fraction of a second, then flashes TRIG'D on each trigger.
  3. The pane freezes onto a stable waveform whose rising edge sits 20 % in from the left, and the capture counter climbs.
  4. Untick follow: the waveform stops updating while the counter keeps climbing. Tick it again: it jumps straight to the newest capture.
  5. Press Live: the pane scrolls again and the Live button greys out.
  6. Switch to 1x and press Arm: exactly one capture appears, the badge settles on HELD, and Re-arm produces the next one.
  7. Set the threshold above the sine's amplitude: the badge stays ARMED 100% and no new captures arrive.
  • Step 11: Commit
git add Client/udpscope/App.h Client/udpscope/App.cpp \
        Client/udpscope/TriggerBar.cpp Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): trigger bar with arm/disarm and capture display"

Task 14: Cursors and measurements

Files:

  • Create: Client/udpscope/Measure.h
  • Create: Client/udpscope/Measure.cpp
  • Create: Client/udpscope/tests/MeasureTest.cpp
  • Modify: Client/udpscope/PaneView.h
  • Modify: Client/udpscope/PaneView.cpp
  • Modify: Client/udpscope/App.h
  • Modify: Client/udpscope/App.cpp
  • Modify: Client/udpscope/CMakeLists.txt

Interfaces:

  • Consumes: Series (Task 1), TraceData::raw (Task 10), PaneContext (Tasks 10/12), VScale/ToDivisions (Task 12).
  • Produces: struct Stats, ComputeStats(const Series&, double t0, double t1), bool SampleAt(const Series&, double t, double& v), struct Cursors (enabled, tA, tB, dt(), freq()). Task 15 persists Cursors.

Why statistics come from TraceData::raw: the drawn series is a min/max envelope, two points per bucket. Its extremes are correct, but a mean or RMS over it weights each bucket equally instead of each sample, so a burst of 1000 samples in one bucket would count the same as a bucket holding two. Spec §8.4 requires true statistics, so ComputeStats reads the undecimated series.

  • Step 1: Write the failing tests

Create Client/udpscope/tests/MeasureTest.cpp:

#include "Measure.h"

#include <gtest/gtest.h>
#include <cmath>

using namespace udpscope;

namespace {

Series ramp() {
    /* t = 0,1,2,3,4  v = 0,1,2,3,4 */
    Series s;
    for (int i = 0; i < 5; ++i) {
        s.t.push_back(static_cast<double>(i));
        s.v.push_back(static_cast<double>(i));
    }
    return s;
}

} // namespace

TEST(ComputeStats, EmptySeriesIsInvalid) {
    Series s;
    const Stats st = ComputeStats(s, 0.0, 1.0);
    EXPECT_FALSE(st.valid);
    EXPECT_EQ(st.count, 0u);
}

TEST(ComputeStats, ReportsMinMaxPeakToPeakMeanAndRms) {
    Series s;
    s.t = {0.0, 1.0, 2.0, 3.0};
    s.v = {-2.0, 0.0, 0.0, 2.0};

    const Stats st = ComputeStats(s, -1.0, 10.0);
    ASSERT_TRUE(st.valid);
    EXPECT_EQ(st.count, 4u);
    EXPECT_DOUBLE_EQ(st.min, -2.0);
    EXPECT_DOUBLE_EQ(st.max, 2.0);
    EXPECT_DOUBLE_EQ(st.pp, 4.0);
    EXPECT_DOUBLE_EQ(st.mean, 0.0);
    EXPECT_NEAR(st.rms, std::sqrt(8.0 / 4.0), 1e-12);
}

// With cursors on, the statistics describe the span between them, not the
// whole visible trace.
TEST(ComputeStats, RestrictsItselfToTheGivenWindow) {
    const Series s = ramp();
    const Stats st = ComputeStats(s, 1.0, 3.0);
    ASSERT_TRUE(st.valid);
    EXPECT_EQ(st.count, 3u);
    EXPECT_DOUBLE_EQ(st.min, 1.0);
    EXPECT_DOUBLE_EQ(st.max, 3.0);
    EXPECT_DOUBLE_EQ(st.mean, 2.0);
}

TEST(ComputeStats, ReversedWindowIsAcceptedAsIs) {
    const Series s = ramp();
    const Stats st = ComputeStats(s, 3.0, 1.0);
    ASSERT_TRUE(st.valid);
    EXPECT_EQ(st.count, 3u);
}

TEST(ComputeStats, WindowWithNoSamplesIsInvalid) {
    const Series s = ramp();
    const Stats st = ComputeStats(s, 10.0, 11.0);
    EXPECT_FALSE(st.valid);
}

TEST(ComputeStats, IgnoresNonFiniteSamples) {
    Series s;
    s.t = {0.0, 1.0, 2.0};
    s.v = {1.0, std::nan(""), 3.0};

    const Stats st = ComputeStats(s, 0.0, 2.0);
    ASSERT_TRUE(st.valid);
    EXPECT_EQ(st.count, 2u);
    EXPECT_DOUBLE_EQ(st.mean, 2.0);
}

TEST(SampleAt, InterpolatesBetweenSamples) {
    const Series s = ramp();
    double v = 0.0;
    ASSERT_TRUE(SampleAt(s, 2.25, v));
    EXPECT_DOUBLE_EQ(v, 2.25);
}

TEST(SampleAt, ReturnsTheEndpointsExactly) {
    const Series s = ramp();
    double v = 0.0;
    ASSERT_TRUE(SampleAt(s, 0.0, v));
    EXPECT_DOUBLE_EQ(v, 0.0);
    ASSERT_TRUE(SampleAt(s, 4.0, v));
    EXPECT_DOUBLE_EQ(v, 4.0);
}

// A cursor dragged off the end of the data has no value to report; it must
// not clamp, or the readout would silently lie.
TEST(SampleAt, FailsOutsideTheSeries) {
    const Series s = ramp();
    double v = 0.0;
    EXPECT_FALSE(SampleAt(s, -0.5, v));
    EXPECT_FALSE(SampleAt(s, 4.5, v));
    Series empty;
    EXPECT_FALSE(SampleAt(empty, 0.0, v));
}

TEST(SampleAt, HandlesRepeatedTimestamps) {
    /* A min/max envelope stores two points at the same time. */
    Series s;
    s.t = {0.0, 1.0, 1.0, 2.0};
    s.v = {0.0, -5.0, 5.0, 0.0};
    double v = 0.0;
    ASSERT_TRUE(SampleAt(s, 1.0, v));
    EXPECT_TRUE(v == -5.0 || v == 5.0);
}

TEST(Cursors, DeltaAndFrequency) {
    Cursors c;
    c.tA = 1.0;
    c.tB = 1.004;
    EXPECT_NEAR(c.dt(), 0.004, 1e-15);
    EXPECT_NEAR(c.freq(), 250.0, 1e-9);
}

// Both cursors on the same sample would divide by zero.
TEST(Cursors, ZeroSpanHasNoFrequency) {
    Cursors c;
    c.tA = 2.0;
    c.tB = 2.0;
    EXPECT_DOUBLE_EQ(c.dt(), 0.0);
    EXPECT_DOUBLE_EQ(c.freq(), 0.0);
}

TEST(Cursors, DeltaIsSignedFromAToB) {
    Cursors c;
    c.tA = 3.0;
    c.tB = 1.0;
    EXPECT_DOUBLE_EQ(c.dt(), -2.0);
    EXPECT_NEAR(c.freq(), 0.5, 1e-12);
}
  • Step 2: Run the tests to verify they fail
cd Client/udpscope && cmake --build build -j 2>&1 | tail -5

Expected: FAIL — Measure.h: No such file or directory.

  • Step 3: Write Measure.h

Create Client/udpscope/Measure.h:

/**
 * @file Measure.h
 * @brief Cursor readouts and per-trace statistics (spec §8.4).
 */
#ifndef UDPSCOPE_MEASURE_H
#define UDPSCOPE_MEASURE_H

#include "Types.h"

#include <cstddef>

namespace udpscope {

/** Statistics over the undecimated samples inside a time window. */
struct Stats {
    bool   valid = false;
    size_t count = 0u;
    double min   = 0.0;
    double max   = 0.0;
    double pp    = 0.0;   /**< max - min */
    double mean  = 0.0;
    double rms   = 0.0;
};

/**
 * @param raw undecimated samples, time-ordered.
 * @param t0,t1 window bounds, in either order; both ends inclusive.
 */
Stats ComputeStats(const Series& raw, double t0, double t1);

/**
 * Linearly interpolates the trace at @a t.
 * @return false when @a t is outside the series or the series is empty.
 */
bool SampleAt(const Series& raw, double t, double& v);

/** The two global time cursors. */
struct Cursors {
    bool   enabled = false;
    double tA      = 0.0;
    double tB      = 0.0;

    double dt() const { return tB - tA; }
    /** 1/|dt|, or 0 when the cursors coincide. */
    double freq() const;
};

} /* namespace udpscope */

#endif /* UDPSCOPE_MEASURE_H */
  • Step 4: Write Measure.cpp

Create Client/udpscope/Measure.cpp:

#include "Measure.h"

#include <algorithm>
#include <cmath>

namespace udpscope {

Stats ComputeStats(const Series& raw, double t0, double t1) {
    Stats st;
    if (t1 < t0) {
        std::swap(t0, t1);
    }
    double sum = 0.0;
    double sq  = 0.0;
    for (size_t i = 0u; i < raw.size(); i++) {
        const double t = raw.t[i];
        if (t < t0 || t > t1) {
            continue;
        }
        const double v = raw.v[i];
        if (!std::isfinite(v)) {
            continue;   /* a quantised NaN must not poison mean and RMS */
        }
        if (st.count == 0u) {
            st.min = v;
            st.max = v;
        } else {
            st.min = std::min(st.min, v);
            st.max = std::max(st.max, v);
        }
        sum += v;
        sq  += v * v;
        st.count++;
    }
    if (st.count == 0u) {
        return st;
    }
    const double n = static_cast<double>(st.count);
    st.pp    = st.max - st.min;
    st.mean  = sum / n;
    st.rms   = std::sqrt(sq / n);
    st.valid = true;
    return st;
}

bool SampleAt(const Series& raw, double t, double& v) {
    const size_t n = raw.size();
    if (n == 0u || !std::isfinite(t)) {
        return false;
    }
    if (t < raw.t[0] || t > raw.t[n - 1u]) {
        return false;
    }
    /* First sample at or after t. Timestamps repeat in a min/max envelope,
       so lower_bound may land on either member of a pair; both are equally
       valid readings at that instant. */
    const size_t hi = static_cast<size_t>(
        std::lower_bound(raw.t.begin(), raw.t.end(), t) - raw.t.begin());
    if (hi == 0u || raw.t[hi] == t) {
        v = raw.v[hi];
        return true;
    }
    const double t0 = raw.t[hi - 1u];
    const double t1 = raw.t[hi];
    const double dt = t1 - t0;
    if (!(dt > 0.0)) {
        v = raw.v[hi];
        return true;
    }
    v = raw.v[hi - 1u] + (raw.v[hi] - raw.v[hi - 1u]) * (t - t0) / dt;
    return true;
}

double Cursors::freq() const {
    const double d = std::fabs(dt());
    if (!(d > 0.0) || !std::isfinite(d)) {
        return 0.0;
    }
    return 1.0 / d;
}

} /* namespace udpscope */

Add Measure.cpp to CORE_SOURCES in Client/udpscope/CMakeLists.txt.

  • Step 5: Run the tests to verify they pass
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='ComputeStats*:SampleAt*:Cursors*'

Expected: PASS, 13 tests.

  • Step 6: Commit the measurement core
git add Client/udpscope/Measure.h Client/udpscope/Measure.cpp \
        Client/udpscope/tests/MeasureTest.cpp Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): cursor sampling and undecimated trace statistics"
  • Step 7: Draw the cursors and the statistics table

In Client/udpscope/PaneView.h, add #include "Measure.h" and two fields to PaneContext:

    Cursors* cursors   = nullptr;   /**< shared by every pane; may be null */
    bool     showStats = false;

In Client/udpscope/PaneView.cpp, inside drawLeaf()'s plot, after the trace loop and before the legend popup handling, add:

    if (ctx.cursors != nullptr && ctx.cursors->enabled) {
        /* Dragging in any pane moves the cursors in all of them, because
           they are one shared pair of times (spec §8.4). */
        double a = ctx.cursors->tA;
        double b = ctx.cursors->tB;
        const ImVec4 amber(0.98f, 0.70f, 0.53f, 1.0f);
        const ImVec4 blue(0.54f, 0.71f, 0.98f, 1.0f);
        if (ImPlot::DragLineX(kCursorAId, &a, amber, 1.0f)) {
            ctx.cursors->tA = a;
        }
        if (ImPlot::DragLineX(kCursorBId, &b, blue, 1.0f)) {
            ctx.cursors->tB = b;
        }
    }

with the ids declared at file scope in PaneView.cpp:

namespace {
/* ImPlot drag-line ids must be unique within a plot but may repeat across
   plots; A and B are the same logical cursors in every pane. */
const int kCursorAId = 1001;
const int kCursorBId = 1002;
} /* namespace */

Then, still inside the plot and after the cursor block, draw the readout as a plot annotation-free overlay:

    const bool cursorsUp = (ctx.cursors != nullptr) && ctx.cursors->enabled;
    if ((ctx.showStats || cursorsUp) && !traces_.empty()) {
        /* Statistics are taken between the cursors when they are up, over the
           visible range otherwise. */
        const double s0 = cursorsUp ? ctx.cursors->tA : ctx.x0;
        const double s1 = cursorsUp ? ctx.cursors->tB : ctx.x1;

        ImPlot::PushPlotClipRect();
        const ImVec2 org = ImPlot::GetPlotPos();
        ImDrawList*  dl  = ImPlot::GetPlotDrawList();
        float        y   = org.y + 4.0f;
        for (size_t i = 0u; i < traces_.size(); i++) {
            char line[288];
            int  used = std::snprintf(line, sizeof(line), "%s",
                                      traces_[i].name.c_str());

            if (cursorsUp) {
                /* Spec §8.4: the value at each cursor and their difference,
                   per displayed signal. */
                double va = 0.0, vb = 0.0;
                const bool ha = SampleAt(traces_[i].raw, ctx.cursors->tA, va);
                const bool hb = SampleAt(traces_[i].raw, ctx.cursors->tB, vb);
                if (ha && hb) {
                    used += std::snprintf(line + used,
                                          sizeof(line) - static_cast<size_t>(used),
                                          "  A %.4g  B %.4g  dV %.4g",
                                          va, vb, vb - va);
                } else {
                    used += std::snprintf(line + used,
                                          sizeof(line) - static_cast<size_t>(used),
                                          "  A -  B -  dV -");
                }
            }

            if (ctx.showStats) {
                const Stats st = ComputeStats(traces_[i].raw, s0, s1);
                if (st.valid) {
                    std::snprintf(line + used,
                                  sizeof(line) - static_cast<size_t>(used),
                                  "  min %.4g  max %.4g  pp %.4g  avg %.4g  rms %.4g",
                                  st.min, st.max, st.pp, st.mean, st.rms);
                } else {
                    std::snprintf(line + used,
                                  sizeof(line) - static_cast<size_t>(used),
                                  "  (no samples)");
                }
            }

            dl->AddText(ImVec2(org.x + 6.0f, y),
                        ImGui::ColorConvertFloat4ToU32(toImVec4(traces_[i].color)),
                        line);
            y += ImGui::GetTextLineHeight();
        }
        ImPlot::PopPlotClipRect();
    }

The statistics need the raw series after the trace loop has finished, so drawLeaf() must keep them. Add to PaneView's private section:

    struct TraceKeep {
        std::string name;
        Color       color;
        Series      raw;
    };
    std::vector<TraceKeep> traces_;

clear it at the top of the trace loop (traces_.clear();) and append inside the loop, right after ComputeVScale(...):

        if (ctx.showStats || (ctx.cursors != nullptr && ctx.cursors->enabled)) {
            TraceKeep keep;
            keep.name  = a.signalName;
            keep.color = a.color;
            keep.raw   = d.raw;      /* copied: d is reused by the next trace */
            traces_.push_back(keep);
        }
  • Step 8: Own the cursors in App

In App.h, add #include "Measure.h" and, next to latch_:

    Cursors cursors_;
    bool    showStats_ = false;

In App.cpp, extend the View menu built in Task 12:

        ImGui::Separator();
        ImGui::MenuItem("Cursors", "C", &cursors_.enabled);
        ImGui::MenuItem("Measurements", "M", &showStats_);

and pass them to the panes in drawPlotArea(), beside ctx.metas = &sigs_;:

    ctx.cursors   = &cursors_;
    ctx.showStats = showStats_;

Place the cursors sensibly the first time they are switched on, otherwise they sit at t=0, far off the left of a wall-clock axis. Immediately after the View menu block in drawMenuBar():

    if (cursors_.enabled && !cursorsPlaced_) {
        const double span = xaxis_.span();
        cursors_.tA = xaxis_.x0() + span * 0.25;
        cursors_.tB = xaxis_.x0() + span * 0.75;
        cursorsPlaced_ = true;
    }
    if (!cursors_.enabled) {
        cursorsPlaced_ = false;
    }

with bool cursorsPlaced_ = false; added next to showStats_.

Finally show the readout in the status bar. In drawStatusBar(), before the existing counters:

    if (cursors_.enabled) {
        ImGui::Text("A %.6g s   B %.6g s   dt %.6g s   1/dt %.6g Hz",
                    cursors_.tA, cursors_.tB, cursors_.dt(), cursors_.freq());
        ImGui::SameLine();
        ImGui::TextDisabled("|");
        ImGui::SameLine();
    }
  • Step 9: Build and verify by hand
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests

Expected: PASS, all tests from Tasks 114.

Against the demo streamer:

  1. View → Cursors: two vertical lines appear a quarter and three quarters of the way across every pane, and the status bar shows A, B, dt, 1/dt.
  2. Drag cursor A in one pane: it moves in every pane and the readout follows.
  3. Put the cursors one sine period apart: 1/dt reads the configured frequency of the demo sine.
  4. With cursors on, each pane already lists A, B and dV per trace in the trace colour; drag a cursor past the end of the data and that trace's three values become -.
  5. View → Measurements: min/max/pp/avg/rms are appended to the same lines.
  6. With cursors on, narrow them to the top half of a sine: avg rises and pp shrinks, confirming the statistics track the cursor span rather than the visible range.
  • Step 10: Commit
git add Client/udpscope/PaneView.h Client/udpscope/PaneView.cpp \
        Client/udpscope/App.h Client/udpscope/App.cpp
git commit -m "feat(udpscope): shared time cursors and per-pane measurements"

Task 15: Session persistence

Files:

  • Create: Client/udpscope/Settings.h
  • Create: Client/udpscope/Settings.cpp
  • Create: Client/udpscope/tests/SettingsTest.cpp
  • Modify: Client/udpscope/App.h
  • Modify: Client/udpscope/App.cpp
  • Modify: Client/udpscope/CMakeLists.txt

Interfaces:

  • Consumes: PaneNode, Assignment, VScale, VMode, Orient, PaneTree::setRoot (Task 2); TrigConfig, Edge, TrigMode (Task 5); ReceiverOptions (Task 7); CliOptions and its set* flags, DefaultConfigPath() (Task 9); Cursors (Task 14).
  • Produces: struct Session { ReceiverOptions source; TrigConfig trigger; Cursors cursors; std::unique_ptr<PaneNode> tree; }, std::string WriteSession(const Session&), bool ParseSession(const std::string&, Session&, std::string&), bool LoadSessionFile(const std::string&, Session&, std::string&), bool SaveSessionFile(const std::string&, const Session&, std::string&), void MergeCli(const CliOptions&, ReceiverOptions&).

Why indentation is written but not parsed: the pane tree is written in prefix order — a split line is always followed by exactly two subtrees, a leaf line by its sig lines — so the structure is unambiguous from the keywords alone. Indenting makes the file readable; requiring exact indentation on the way back in would only add a way for a hand-edited file to be rejected for no reason.

Deviation from the spec's example: the source line also carries silence=. --silence is a source field the command line can set, and a settings file that cannot round-trip everything the CLI sets would silently drop it on the next save.

  • Step 1: Write the failing tests

Create Client/udpscope/tests/SettingsTest.cpp:

#include "Settings.h"

#include <gtest/gtest.h>
#include <string>

using namespace udpscope;

namespace {

Session fullSession() {
    Session s;
    s.source.host           = "10.0.0.5";
    s.source.port           = 44501u;
    s.source.multicastGroup = "239.0.0.1";
    s.source.interfaceAddr  = "192.168.1.2";
    s.source.dataPort       = 44503u;
    s.source.silenceTimeoutSec = 3.5;

    s.trigger.signalName = "Voltage";
    s.trigger.edge       = Edge::Falling;
    s.trigger.threshold  = 0.5;
    s.trigger.hysteresis = 0.01;
    s.trigger.windowSec  = 0.02;
    s.trigger.prePercent = 30.0;
    s.trigger.mode       = TrigMode::Single;

    s.cursors.enabled = true;
    s.cursors.tA      = 0.0123;
    s.cursors.tB      = 0.0456;

    auto root = std::unique_ptr<PaneNode>(new PaneNode());
    root->leaf   = false;
    root->orient = Orient::Columns;
    root->ratio  = 0.4;

    auto left = std::unique_ptr<PaneNode>(new PaneNode());
    Assignment v;
    v.signalName = "Voltage";
    v.color      = Color{0.54f, 0.71f, 0.98f, 1.0f};
    v.lineWidth  = 1.5f;
    v.vs.mode    = VMode::Auto;
    left->signals.push_back(v);
    Assignment c;
    c.signalName = "Current";
    c.color      = Color{0.98f, 0.70f, 0.53f, 1.0f};
    c.lineWidth  = 2.0f;
    c.vs.mode    = VMode::Manual;
    c.vs.div     = 0.2;
    c.vs.offset  = -1.0;
    left->signals.push_back(c);

    auto right = std::unique_ptr<PaneNode>(new PaneNode());
    Assignment t;
    t.signalName = "Temp";
    t.color      = Color{0.65f, 0.89f, 0.63f, 1.0f};
    t.vs.mode    = VMode::Range;
    right->signals.push_back(t);

    root->a = std::move(left);
    root->b = std::move(right);
    s.tree  = std::move(root);
    return s;
}

} // namespace

TEST(Settings, RoundTripsAFullSession) {
    const Session in = fullSession();
    const std::string text = WriteSession(in);

    Session out;
    std::string err;
    ASSERT_TRUE(ParseSession(text, out, err)) << err;

    EXPECT_EQ(out.source.host, "10.0.0.5");
    EXPECT_EQ(out.source.port, 44501u);
    EXPECT_EQ(out.source.multicastGroup, "239.0.0.1");
    EXPECT_EQ(out.source.interfaceAddr, "192.168.1.2");
    EXPECT_EQ(out.source.dataPort, 44503u);
    EXPECT_DOUBLE_EQ(out.source.silenceTimeoutSec, 3.5);

    EXPECT_EQ(out.trigger.signalName, "Voltage");
    EXPECT_EQ(out.trigger.edge, Edge::Falling);
    EXPECT_DOUBLE_EQ(out.trigger.threshold, 0.5);
    EXPECT_DOUBLE_EQ(out.trigger.hysteresis, 0.01);
    EXPECT_DOUBLE_EQ(out.trigger.windowSec, 0.02);
    EXPECT_DOUBLE_EQ(out.trigger.prePercent, 30.0);
    EXPECT_EQ(out.trigger.mode, TrigMode::Single);

    EXPECT_TRUE(out.cursors.enabled);
    EXPECT_NEAR(out.cursors.tA, 0.0123, 1e-9);
    EXPECT_NEAR(out.cursors.tB, 0.0456, 1e-9);

    ASSERT_TRUE(out.tree);
    ASSERT_FALSE(out.tree->leaf);
    EXPECT_EQ(out.tree->orient, Orient::Columns);
    EXPECT_NEAR(out.tree->ratio, 0.4, 1e-9);
    ASSERT_TRUE(out.tree->a && out.tree->b);
    ASSERT_EQ(out.tree->a->signals.size(), 2u);
    EXPECT_EQ(out.tree->a->signals[0].signalName, "Voltage");
    EXPECT_EQ(out.tree->a->signals[1].vs.mode, VMode::Manual);
    EXPECT_NEAR(out.tree->a->signals[1].vs.div, 0.2, 1e-9);
    EXPECT_NEAR(out.tree->a->signals[1].vs.offset, -1.0, 1e-9);
    EXPECT_NEAR(out.tree->a->signals[1].lineWidth, 2.0f, 1e-6f);
    ASSERT_EQ(out.tree->b->signals.size(), 1u);
    EXPECT_EQ(out.tree->b->signals[0].vs.mode, VMode::Range);
}

TEST(Settings, ColoursSurviveAsHex) {
    const Session in = fullSession();
    Session out;
    std::string err;
    ASSERT_TRUE(ParseSession(WriteSession(in), out, err)) << err;

    const Color got = out.tree->a->signals[0].color;
    EXPECT_NEAR(got.r, 0.54f, 1.0f / 255.0f);
    EXPECT_NEAR(got.g, 0.71f, 1.0f / 255.0f);
    EXPECT_NEAR(got.b, 0.98f, 1.0f / 255.0f);
}

TEST(Settings, TheWrittenFormMatchesTheDocumentedShape) {
    const Session in = fullSession();
    const std::string text = WriteSession(in);
    EXPECT_EQ(text.compare(0, 11, "udpscope 1\n"), 0);
    EXPECT_NE(text.find("\nsource host=10.0.0.5 port=44501"), std::string::npos);
    EXPECT_NE(text.find("\ncursors on "), std::string::npos);
    EXPECT_NE(text.find("\ntree\n"), std::string::npos);
    EXPECT_NE(text.find("split cols 0.4"), std::string::npos);
    EXPECT_NE(text.find("sig Voltage color=#"), std::string::npos);
}

TEST(Settings, ASessionWithoutATreeIsValid) {
    Session in;
    in.trigger.signalName = "Voltage";
    Session out;
    std::string err;
    ASSERT_TRUE(ParseSession(WriteSession(in), out, err)) << err;
    EXPECT_FALSE(out.tree);
    EXPECT_EQ(out.trigger.signalName, "Voltage");
}

TEST(Settings, CursorsOffRoundTrips) {
    Session in;
    in.cursors.enabled = false;
    Session out;
    out.cursors.enabled = true;
    std::string err;
    ASSERT_TRUE(ParseSession(WriteSession(in), out, err)) << err;
    EXPECT_FALSE(out.cursors.enabled);
}

TEST(Settings, EveryEdgeAndModeNameRoundTrips) {
    const Edge edges[] = {Edge::Rising, Edge::Falling, Edge::Both};
    const TrigMode modes[] = {TrigMode::Normal, TrigMode::Single};
    for (int e = 0; e < 3; ++e) {
        for (int m = 0; m < 2; ++m) {
            Session in;
            in.trigger.edge = edges[e];
            in.trigger.mode = modes[m];
            Session out;
            std::string err;
            ASSERT_TRUE(ParseSession(WriteSession(in), out, err)) << err;
            EXPECT_EQ(out.trigger.edge, edges[e]);
            EXPECT_EQ(out.trigger.mode, modes[m]);
        }
    }
}

// The file is meant to be hand-editable, so re-indenting it must not break it.
TEST(Settings, IndentationIsIgnored) {
    const std::string text =
        "udpscope 1\n"
        "tree\n"
        "split rows 0.25\n"
        "leaf\n"
        "sig A color=#ffffff width=1 vs=auto\n"
        "                leaf\n"
        "\t\tsig B color=#000000 width=1 vs=auto\n";
    Session out;
    std::string err;
    ASSERT_TRUE(ParseSession(text, out, err)) << err;
    ASSERT_TRUE(out.tree && !out.tree->leaf);
    EXPECT_EQ(out.tree->orient, Orient::Rows);
    EXPECT_EQ(out.tree->a->signals[0].signalName, "A");
    EXPECT_EQ(out.tree->b->signals[0].signalName, "B");
}

TEST(Settings, BlankLinesAndCommentsAreSkipped) {
    const std::string text =
        "udpscope 1\n"
        "\n"
        "# written by hand\n"
        "tree\n"
        "  leaf\n";
    Session out;
    std::string err;
    ASSERT_TRUE(ParseSession(text, out, err)) << err;
    ASSERT_TRUE(out.tree);
    EXPECT_TRUE(out.tree->leaf);
}

TEST(Settings, MissingHeaderIsRejected) {
    Session out;
    std::string err;
    EXPECT_FALSE(ParseSession("tree\n  leaf\n", out, err));
    EXPECT_FALSE(err.empty());
}

TEST(Settings, VersionMismatchIsRejected) {
    Session out;
    std::string err;
    EXPECT_FALSE(ParseSession("udpscope 2\ntree\n  leaf\n", out, err));
    EXPECT_NE(err.find("version"), std::string::npos);
}

TEST(Settings, UnknownKeywordIsRejected) {
    Session out;
    std::string err;
    EXPECT_FALSE(ParseSession("udpscope 1\nbananas 3\n", out, err));
}

TEST(Settings, UnknownKeyIsRejected) {
    Session out;
    std::string err;
    EXPECT_FALSE(ParseSession("udpscope 1\nsource host=a bogus=1\n", out, err));
}

TEST(Settings, TruncatedTreeIsRejected) {
    Session out;
    std::string err;
    /* A split promises two children and only delivers one. */
    EXPECT_FALSE(ParseSession("udpscope 1\ntree\n  split cols 0.5\n    leaf\n",
                              out, err));
}

// Spec §9: a bad file is reported and ignored, never partially applied.
TEST(Settings, AFailedParseLeavesTheOutputUntouched) {
    Session out;
    out.source.host      = "keepme";
    out.trigger.signalName = "keepme-too";
    std::string err;

    ASSERT_FALSE(ParseSession("udpscope 1\n"
                              "source host=clobbered port=1\n"
                              "trigger signal=clobbered\n"
                              "bananas\n",
                              out, err));
    EXPECT_EQ(out.source.host, "keepme");
    EXPECT_EQ(out.trigger.signalName, "keepme-too");
}

// Signal names are bare tokens in the file format.
TEST(Settings, WhitespaceInASignalNameIsRefusedOnWrite) {
    Session in;
    in.tree = std::unique_ptr<PaneNode>(new PaneNode());
    Assignment a;
    a.signalName = "bad name";
    in.tree->signals.push_back(a);

    std::string err;
    EXPECT_FALSE(SaveSessionFile("/tmp/udpscope_should_not_exist.conf", in, err));
    EXPECT_FALSE(err.empty());
}

TEST(Settings, SaveThenLoadAFile) {
    const std::string path = "/tmp/udpscope_settings_test.conf";
    const Session in = fullSession();
    std::string err;
    ASSERT_TRUE(SaveSessionFile(path, in, err)) << err;

    Session out;
    ASSERT_TRUE(LoadSessionFile(path, out, err)) << err;
    EXPECT_EQ(out.source.port, 44501u);
    ASSERT_TRUE(out.tree);
    std::remove(path.c_str());
}

TEST(Settings, LoadingAMissingFileFails) {
    Session out;
    std::string err;
    EXPECT_FALSE(LoadSessionFile("/tmp/udpscope_definitely_absent.conf", out, err));
}

// Spec §11: an explicit option wins, an omitted one falls back to the file.
TEST(MergeCli, ExplicitOptionsWin) {
    ReceiverOptions fromFile;
    fromFile.host = "10.0.0.5";
    fromFile.port = 44501u;

    CliOptions cli;
    cli.source.port = 44502u;
    cli.setPort     = true;

    MergeCli(cli, fromFile);
    EXPECT_EQ(fromFile.host, "10.0.0.5");   // not given on the command line
    EXPECT_EQ(fromFile.port, 44502u);       // given, so it wins
}

TEST(MergeCli, OmittedOptionsLeaveTheFileAlone) {
    ReceiverOptions fromFile;
    fromFile.host           = "10.0.0.5";
    fromFile.multicastGroup = "239.0.0.1";
    fromFile.dataPort       = 44503u;

    CliOptions cli;   // nothing given
    MergeCli(cli, fromFile);
    EXPECT_EQ(fromFile.host, "10.0.0.5");
    EXPECT_EQ(fromFile.multicastGroup, "239.0.0.1");
    EXPECT_EQ(fromFile.dataPort, 44503u);
}
  • Step 2: Run the tests to verify they fail
cd Client/udpscope && cmake --build build -j 2>&1 | tail -5

Expected: FAIL — Settings.h: No such file or directory.

  • Step 3: Write Settings.h

Create Client/udpscope/Settings.h:

/**
 * @file Settings.h
 * @brief The line-based session file described in spec §9.
 */
#ifndef UDPSCOPE_SETTINGS_H
#define UDPSCOPE_SETTINGS_H

#include "Cli.h"
#include "Measure.h"
#include "PaneTree.h"
#include "Receiver.h"
#include "Trigger.h"

#include <memory>
#include <string>

namespace udpscope {

/** Everything that survives a restart. */
struct Session {
    ReceiverOptions           source;
    TrigConfig                trigger;
    Cursors                   cursors;
    std::unique_ptr<PaneNode> tree;   /**< null when nothing was saved */
};

/** Current file format version; anything else is refused. */
constexpr int kSessionVersion = 1;

/** @return the session as text, always ending in a newline. */
std::string WriteSession(const Session& s);

/**
 * Parses @a text. On failure @a out is left exactly as it was, so a broken
 * file can never half-apply.
 */
bool ParseSession(const std::string& text, Session& out, std::string& err);

bool LoadSessionFile(const std::string& path, Session& out, std::string& err);

/** Creates the parent directory if needed. */
bool SaveSessionFile(const std::string& path, const Session& s, std::string& err);

/** Applies the options that were actually given on the command line. */
void MergeCli(const CliOptions& cli, ReceiverOptions& io);

} /* namespace udpscope */

#endif /* UDPSCOPE_SETTINGS_H */
  • Step 4: Write Settings.cpp

Create Client/udpscope/Settings.cpp:

#include "Settings.h"

#include <cerrno>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <sstream>
#include <sys/stat.h>
#include <sys/types.h>
#include <vector>

namespace udpscope {

namespace {

std::vector<std::string> tokens(const std::string& line) {
    std::vector<std::string> out;
    std::istringstream is(line);
    std::string t;
    while (is >> t) {
        out.push_back(t);
    }
    return out;
}

bool splitKv(const std::string& tok, std::string& k, std::string& v) {
    const size_t eq = tok.find('=');
    if (eq == std::string::npos) {
        return false;
    }
    k = tok.substr(0u, eq);
    v = tok.substr(eq + 1u);
    return true;
}

double toD(const std::string& s) { return std::strtod(s.c_str(), NULL); }

unsigned toU(const std::string& s) {
    return static_cast<unsigned>(std::strtoul(s.c_str(), NULL, 10));
}

std::string hexOf(const Color& c) {
    const int r = static_cast<int>(c.r * 255.0f + 0.5f);
    const int g = static_cast<int>(c.g * 255.0f + 0.5f);
    const int b = static_cast<int>(c.b * 255.0f + 0.5f);
    char buf[16];
    std::snprintf(buf, sizeof(buf), "#%02x%02x%02x",
                  r < 0 ? 0 : (r > 255 ? 255 : r),
                  g < 0 ? 0 : (g > 255 ? 255 : g),
                  b < 0 ? 0 : (b > 255 ? 255 : b));
    return std::string(buf);
}

bool colorOf(const std::string& s, Color& c) {
    if (s.size() != 7u || s[0] != '#') {
        return false;
    }
    for (size_t i = 1u; i < 7u; i++) {
        if (std::isxdigit(static_cast<unsigned char>(s[i])) == 0) {
            return false;
        }
    }
    const unsigned long v = std::strtoul(s.c_str() + 1, NULL, 16);
    c.r = static_cast<float>((v >> 16) & 0xffu) / 255.0f;
    c.g = static_cast<float>((v >> 8) & 0xffu) / 255.0f;
    c.b = static_cast<float>(v & 0xffu) / 255.0f;
    c.a = 1.0f;
    return true;
}

const char* edgeName(Edge e) {
    switch (e) {
    case Edge::Falling: return "falling";
    case Edge::Both:    return "both";
    case Edge::Rising:
    default:            return "rising";
    }
}

bool edgeOf(const std::string& s, Edge& e) {
    if (s == "rising")  { e = Edge::Rising;  return true; }
    if (s == "falling") { e = Edge::Falling; return true; }
    if (s == "both")    { e = Edge::Both;    return true; }
    return false;
}

const char* vmodeName(VMode m) {
    switch (m) {
    case VMode::Range:  return "range";
    case VMode::Manual: return "manual";
    case VMode::Auto:
    default:            return "auto";
    }
}

bool vmodeOf(const std::string& s, VMode& m) {
    if (s == "auto")   { m = VMode::Auto;   return true; }
    if (s == "range")  { m = VMode::Range;  return true; }
    if (s == "manual") { m = VMode::Manual; return true; }
    return false;
}

bool hasSpace(const std::string& s) {
    for (size_t i = 0u; i < s.size(); i++) {
        if (std::isspace(static_cast<unsigned char>(s[i])) != 0) {
            return true;
        }
    }
    return s.empty();
}

void writeNode(const PaneNode& n, int depth, std::string& out) {
    const std::string pad(static_cast<size_t>(depth) * 2u, ' ');
    char buf[256];
    if (!n.leaf) {
        std::snprintf(buf, sizeof(buf), "%ssplit %s %.6g\n", pad.c_str(),
                      (n.orient == Orient::Rows) ? "rows" : "cols", n.ratio);
        out += buf;
        if (n.a) { writeNode(*n.a, depth + 1, out); }
        if (n.b) { writeNode(*n.b, depth + 1, out); }
        return;
    }
    out += pad + "leaf\n";
    for (size_t i = 0u; i < n.signals.size(); i++) {
        const Assignment& a = n.signals[i];
        std::snprintf(buf, sizeof(buf), "%s  sig %s color=%s width=%.4g vs=%s",
                      pad.c_str(), a.signalName.c_str(), hexOf(a.color).c_str(),
                      static_cast<double>(a.lineWidth), vmodeName(a.vs.mode));
        out += buf;
        if (a.vs.mode == VMode::Manual) {
            std::snprintf(buf, sizeof(buf), " div=%.10g off=%.10g",
                          a.vs.div, a.vs.offset);
            out += buf;
        }
        out += "\n";
    }
}

/** True if every signal name in the tree is a bare token. */
bool namesAreWritable(const PaneNode& n, std::string& bad) {
    if (n.leaf) {
        for (size_t i = 0u; i < n.signals.size(); i++) {
            if (hasSpace(n.signals[i].signalName)) {
                bad = n.signals[i].signalName;
                return false;
            }
        }
        return true;
    }
    if (n.a && !namesAreWritable(*n.a, bad)) { return false; }
    if (n.b && !namesAreWritable(*n.b, bad)) { return false; }
    return true;
}

/** Recursive descent over the pre-tokenised lines; @a i is the cursor. */
std::unique_ptr<PaneNode> readNode(const std::vector<std::vector<std::string> >& L,
                                   size_t& i, std::string& err) {
    if (i >= L.size()) {
        err = "tree ends early";
        return std::unique_ptr<PaneNode>();
    }
    const std::vector<std::string>& t = L[i];
    std::unique_ptr<PaneNode> n(new PaneNode());

    if (t[0] == "split") {
        if (t.size() != 3u) {
            err = "split needs an orientation and a ratio";
            return std::unique_ptr<PaneNode>();
        }
        n->leaf = false;
        if (t[1] == "cols") {
            n->orient = Orient::Columns;
        } else if (t[1] == "rows") {
            n->orient = Orient::Rows;
        } else {
            err = "unknown split orientation '" + t[1] + "'";
            return std::unique_ptr<PaneNode>();
        }
        n->ratio = toD(t[2]);
        if (!(n->ratio > 0.0) || !(n->ratio < 1.0)) {
            err = "split ratio out of range";
            return std::unique_ptr<PaneNode>();
        }
        i++;
        n->a = readNode(L, i, err);
        if (!n->a) { return std::unique_ptr<PaneNode>(); }
        n->b = readNode(L, i, err);
        if (!n->b) { return std::unique_ptr<PaneNode>(); }
        return n;
    }

    if (t[0] != "leaf") {
        err = "expected 'split' or 'leaf', got '" + t[0] + "'";
        return std::unique_ptr<PaneNode>();
    }
    i++;
    while (i < L.size() && L[i][0] == "sig") {
        const std::vector<std::string>& s = L[i];
        if (s.size() < 2u) {
            err = "sig needs a name";
            return std::unique_ptr<PaneNode>();
        }
        Assignment a;
        a.signalName = s[1];
        for (size_t k = 2u; k < s.size(); k++) {
            std::string key, val;
            if (!splitKv(s[k], key, val)) {
                err = "sig field '" + s[k] + "' is not key=value";
                return std::unique_ptr<PaneNode>();
            }
            if (key == "color") {
                if (!colorOf(val, a.color)) {
                    err = "bad colour '" + val + "'";
                    return std::unique_ptr<PaneNode>();
                }
            } else if (key == "width") {
                a.lineWidth = static_cast<float>(toD(val));
            } else if (key == "vs") {
                if (!vmodeOf(val, a.vs.mode)) {
                    err = "bad vertical mode '" + val + "'";
                    return std::unique_ptr<PaneNode>();
                }
            } else if (key == "div") {
                a.vs.div = toD(val);
            } else if (key == "off") {
                a.vs.offset = toD(val);
            } else {
                err = "unknown sig key '" + key + "'";
                return std::unique_ptr<PaneNode>();
            }
        }
        n->signals.push_back(a);
        i++;
    }
    return n;
}

} /* namespace */

std::string WriteSession(const Session& s) {
    std::string out;
    char buf[512];
    std::snprintf(buf, sizeof(buf), "udpscope %d\n", kSessionVersion);
    out += buf;

    std::snprintf(buf, sizeof(buf),
                  "source host=%s port=%u multicast=%s iface=%s dataport=%u "
                  "silence=%.6g\n",
                  s.source.host.c_str(), static_cast<unsigned>(s.source.port),
                  s.source.multicastGroup.c_str(), s.source.interfaceAddr.c_str(),
                  static_cast<unsigned>(s.source.dataPort),
                  s.source.silenceTimeoutSec);
    out += buf;

    std::snprintf(buf, sizeof(buf),
                  "trigger signal=%s edge=%s thr=%.10g hyst=%.10g win=%.10g "
                  "pre=%.6g mode=%s\n",
                  s.trigger.signalName.c_str(), edgeName(s.trigger.edge),
                  s.trigger.threshold, s.trigger.hysteresis, s.trigger.windowSec,
                  s.trigger.prePercent,
                  (s.trigger.mode == TrigMode::Single) ? "single" : "normal");
    out += buf;

    std::snprintf(buf, sizeof(buf), "cursors %s %.10g %.10g\n",
                  s.cursors.enabled ? "on" : "off", s.cursors.tA, s.cursors.tB);
    out += buf;

    if (s.tree) {
        out += "tree\n";
        writeNode(*s.tree, 1, out);
    }
    return out;
}

bool ParseSession(const std::string& text, Session& out, std::string& err) {
    /* Everything lands in a scratch session and is moved out only on success
       (spec §9: never partially applied). */
    Session s;
    std::vector<std::vector<std::string> > lines;
    {
        std::istringstream is(text);
        std::string line;
        while (std::getline(is, line)) {
            std::vector<std::string> t = tokens(line);
            if (t.empty() || t[0][0] == '#') {
                continue;
            }
            lines.push_back(t);
        }
    }
    if (lines.empty() || lines[0].size() != 2u || lines[0][0] != "udpscope") {
        err = "not a udpscope session file";
        return false;
    }
    if (std::atoi(lines[0][1].c_str()) != kSessionVersion) {
        err = "unsupported session version '" + lines[0][1] + "'";
        return false;
    }

    for (size_t i = 1u; i < lines.size(); ) {
        const std::vector<std::string>& t = lines[i];

        if (t[0] == "source" || t[0] == "trigger") {
            for (size_t k = 1u; k < t.size(); k++) {
                std::string key, val;
                if (!splitKv(t[k], key, val)) {
                    err = "field '" + t[k] + "' is not key=value";
                    return false;
                }
                if (t[0] == "source") {
                    if (key == "host")            { s.source.host = val; }
                    else if (key == "port")       { s.source.port = static_cast<uint16_t>(toU(val)); }
                    else if (key == "multicast")  { s.source.multicastGroup = val; }
                    else if (key == "iface")      { s.source.interfaceAddr = val; }
                    else if (key == "dataport")   { s.source.dataPort = static_cast<uint16_t>(toU(val)); }
                    else if (key == "silence")    { s.source.silenceTimeoutSec = toD(val); }
                    else { err = "unknown source key '" + key + "'"; return false; }
                } else {
                    if (key == "signal")     { s.trigger.signalName = val; }
                    else if (key == "edge")  {
                        if (!edgeOf(val, s.trigger.edge)) {
                            err = "unknown edge '" + val + "'";
                            return false;
                        }
                    }
                    else if (key == "thr")   { s.trigger.threshold = toD(val); }
                    else if (key == "hyst")  { s.trigger.hysteresis = toD(val); }
                    else if (key == "win")   { s.trigger.windowSec = toD(val); }
                    else if (key == "pre")   { s.trigger.prePercent = toD(val); }
                    else if (key == "mode")  {
                        if (val == "single")      { s.trigger.mode = TrigMode::Single; }
                        else if (val == "normal") { s.trigger.mode = TrigMode::Normal; }
                        else { err = "unknown trigger mode '" + val + "'"; return false; }
                    }
                    else { err = "unknown trigger key '" + key + "'"; return false; }
                }
            }
            i++;
            continue;
        }

        if (t[0] == "cursors") {
            if (t.size() != 4u) {
                err = "cursors needs on|off and two times";
                return false;
            }
            s.cursors.enabled = (t[1] == "on");
            s.cursors.tA      = toD(t[2]);
            s.cursors.tB      = toD(t[3]);
            i++;
            continue;
        }

        if (t[0] == "tree") {
            i++;
            s.tree = readNode(lines, i, err);
            if (!s.tree) {
                return false;
            }
            if (i != lines.size()) {
                err = "trailing content after the tree: '" + lines[i][0] + "'";
                return false;
            }
            continue;
        }

        err = "unknown keyword '" + t[0] + "'";
        return false;
    }

    out.source  = s.source;
    out.trigger = s.trigger;
    out.cursors = s.cursors;
    out.tree    = std::move(s.tree);
    return true;
}

bool LoadSessionFile(const std::string& path, Session& out, std::string& err) {
    std::ifstream f(path.c_str());
    if (!f) {
        err = "cannot open " + path;
        return false;
    }
    std::ostringstream ss;
    ss << f.rdbuf();
    return ParseSession(ss.str(), out, err);
}

bool SaveSessionFile(const std::string& path, const Session& s, std::string& err) {
    std::string bad;
    if (s.tree && !namesAreWritable(*s.tree, bad)) {
        err = "signal name '" + bad + "' cannot be written to the session file";
        return false;
    }
    const size_t slash = path.find_last_of('/');
    if (slash != std::string::npos && slash > 0u) {
        const std::string dir = path.substr(0u, slash);
        if (mkdir(dir.c_str(), 0755) != 0 && errno != EEXIST) {
            err = "cannot create " + dir + ": " + std::strerror(errno);
            return false;
        }
    }
    std::ofstream f(path.c_str(), std::ios::trunc);
    if (!f) {
        err = "cannot write " + path;
        return false;
    }
    f << WriteSession(s);
    if (!f) {
        err = "write failed for " + path;
        return false;
    }
    return true;
}

void MergeCli(const CliOptions& cli, ReceiverOptions& io) {
    if (cli.setHost)      { io.host              = cli.source.host; }
    if (cli.setPort)      { io.port              = cli.source.port; }
    if (cli.setMulticast) { io.multicastGroup    = cli.source.multicastGroup; }
    if (cli.setIface)     { io.interfaceAddr     = cli.source.interfaceAddr; }
    if (cli.setDataPort)  { io.dataPort          = cli.source.dataPort; }
    if (cli.setSilence)   { io.silenceTimeoutSec = cli.source.silenceTimeoutSec; }
}

} /* namespace udpscope */

Add Settings.cpp to CORE_SOURCES. mkdir only creates the last component, which is enough for ~/.config/udpscope; a missing ~/.config is reported rather than created.

  • Step 5: Run the tests to verify they pass
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='Settings*:MergeCli*'

Expected: PASS, 18 tests.

  • Step 6: Commit the session core
git add Client/udpscope/Settings.h Client/udpscope/Settings.cpp \
        Client/udpscope/tests/SettingsTest.cpp Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): session file writer, parser and CLI precedence"
  • Step 7: Load the session before the receiver starts

In App.h add #include "Settings.h" and these members next to opt_:

    std::string configPath_;
    bool        dirty_ = false;   /**< something worth saving has changed */

and declare:

    void loadSession();
    void saveSession();
    Session currentSession() const;

Replace the body of App::App() in App.cpp with:

App::App(const CliOptions& opt) : opt_(opt), rx_(store_) {
    configPath_ = opt_.setConfigPath ? opt_.configPath : DefaultConfigPath();
    loadSession();

    std::string err;
    if (!rx_.start(opt_.source, err)) {
        status_ = "receiver failed to start: " + err;
    } else {
        char buf[160];
        std::snprintf(buf, sizeof(buf), "attaching to %s:%u",
                      opt_.source.host.c_str(),
                      static_cast<unsigned>(opt_.source.port));
        status_ = buf;
    }
    rx_.setTrigConfig(trig_);
}

void App::loadSession() {
    Session s;
    std::string err;
    if (!LoadSessionFile(configPath_, s, err)) {
        /* No session yet is the normal first run, not a failure worth
           shouting about; a malformed one is (spec §9). */
        status_ = err;
        return;   /* opt_.source already holds defaults plus the command line */
    }
    /* File first, command line on top. */
    ReceiverOptions merged = s.source;
    MergeCli(opt_, merged);
    opt_.source = merged;

    trig_     = s.trigger;
    cursors_  = s.cursors;
    if (s.tree) {
        tree_.setRoot(std::move(s.tree));
    }
}

Session App::currentSession() const {
    Session s;
    s.source  = opt_.source;
    s.trigger = trig_;
    s.cursors = cursors_;
    s.tree    = ClonePane(tree_.root());
    return s;
}

void App::saveSession() {
    const Session s = currentSession();
    std::string err;
    if (SaveSessionFile(configPath_, s, err)) {
        status_ = "saved " + configPath_;
        dirty_  = false;
    } else {
        status_ = err;
    }
}

currentSession() needs a deep copy of the live tree, because Session owns its nodes and the tree keeps drawing. Add to PaneTree.h:

/** Deep-copies a subtree; returns null for a null input. */
std::unique_ptr<PaneNode> ClonePane(const PaneNode* n);

and to PaneTree.cpp:

std::unique_ptr<PaneNode> ClonePane(const PaneNode* n) {
    if (n == NULL) {
        return std::unique_ptr<PaneNode>();
    }
    std::unique_ptr<PaneNode> c(new PaneNode());
    c->leaf        = n->leaf;
    c->signals     = n->signals;
    c->profilePane = n->profilePane;
    c->orient      = n->orient;
    c->ratio       = n->ratio;
    c->a           = ClonePane(n->a.get());
    c->b           = ClonePane(n->b.get());
    return c;
}

Add a round-trip test to Client/udpscope/tests/PaneTreeTest.cpp:

TEST(PaneTree, CloneIsADeepCopy) {
    PaneTree tree;
    tree.splitLeaf(tree.root(), Orient::Rows);
    Assignment a;
    a.signalName = "Voltage";
    tree.root()->a->signals.push_back(a);

    std::unique_ptr<PaneNode> copy = ClonePane(tree.root());
    ASSERT_TRUE(copy && !copy->leaf);
    ASSERT_EQ(copy->a->signals.size(), 1u);
    EXPECT_EQ(copy->a->signals[0].signalName, "Voltage");

    copy->a->signals[0].signalName = "Other";
    EXPECT_EQ(tree.root()->a->signals[0].signalName, "Voltage");
}
  • Step 8: Add the File menu and save on exit

In App::drawMenuBar(), replace the File menu block Task 9 wrote (the one whose only item is Quit) — do not add a second one:

    if (ImGui::BeginMenu("File")) {
        if (ImGui::MenuItem("Save Layout", "Ctrl+S")) {
            saveSession();
        }
        if (ImGui::MenuItem("Reload Layout")) {
            loadSession();
        }
        ImGui::Separator();
        if (ImGui::MenuItem("Quit", "Ctrl+Q")) {
            requestQuit();
        }
        ImGui::EndMenu();
    }

and in App::~App():

App::~App() {
    saveSession();   /* spec §9: saved on clean exit */
    rx_.stop();
}
  • Step 9: Build and verify by hand
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests

Expected: PASS, all tests from Tasks 115.

Against the demo streamer:

./Client/udpscope/build/UDPScope --port 44501
  1. Split into three panes, drop different signals in each, set one to manual v-scale, turn cursors on, configure the trigger. Quit.
  2. cat ~/.config/udpscope/session.conf shows the documented shape.
  3. Relaunch with no arguments: the same three panes, signals, colours, v-scales, cursors and trigger settings come back, and it reconnects to port 44501 because the file recorded it.
  4. Relaunch with --port 44502: the layout is restored but the source is the command-line one; the status bar shows the new port.
  5. Append bananas to the file and relaunch: the status bar reports unknown keyword 'bananas' and the app opens with a single empty pane — nothing half-applied.
  • Step 10: Commit
git add Client/udpscope/App.h Client/udpscope/App.cpp \
        Client/udpscope/PaneTree.h Client/udpscope/PaneTree.cpp \
        Client/udpscope/tests/PaneTreeTest.cpp
git commit -m "feat(udpscope): restore and save the session layout"

Task 16: CSV export

Files:

  • Create: Client/udpscope/Export.h
  • Create: Client/udpscope/Export.cpp
  • Create: Client/udpscope/tests/ExportTest.cpp
  • Modify: Client/udpscope/App.h
  • Modify: Client/udpscope/App.cpp
  • Modify: Client/udpscope/CMakeLists.txt

Interfaces:

  • Consumes: Series (Task 1), PaneNode (Task 2), SignalStore and Capture (Task 6), FetchLiveTrace/FetchCaptureTrace (Task 10), CaptureLatch (Task 13).
  • Produces: struct CsvTrace { std::string name; Series data; }, std::string BuildCsv(const std::vector<CsvTrace>&, double t0), bool ExportCsvFile(const std::string& path, const std::vector<CsvTrace>&, double t0, std::string& err), std::vector<std::string> SignalsInPane(const PaneNode*).

Column meanings: time_s is relative to the start of the exported window, so a capture reads from 0 regardless of when it was taken; wallclock_s is the absolute Unix time the sample carries. Precision is 9 decimals on time_s (the spec's example shows 6, which is exactly one microsecond and would collide at the 1 MSps rates this scope is aimed at), 6 decimals on wallclock_s, and %.10g on the value, which round-trips a float32 and every 16-bit quantised value exactly.

  • Step 1: Write the failing tests

Create Client/udpscope/tests/ExportTest.cpp:

#include "Export.h"

#include <gtest/gtest.h>
#include <cstdio>
#include <fstream>
#include <sstream>
#include <string>

using namespace udpscope;

namespace {

CsvTrace trace(const std::string& name, const std::vector<double>& t,
               const std::vector<double>& v) {
    CsvTrace c;
    c.name   = name;
    c.data.t = t;
    c.data.v = v;
    return c;
}

std::vector<std::string> linesOf(const std::string& s) {
    std::vector<std::string> out;
    std::istringstream is(s);
    std::string line;
    while (std::getline(is, line)) {
        out.push_back(line);
    }
    return out;
}

} // namespace

TEST(BuildCsv, StartsWithTheDocumentedHeader) {
    std::vector<CsvTrace> tr;
    const std::vector<std::string> l = linesOf(BuildCsv(tr, 0.0));
    ASSERT_EQ(l.size(), 1u);
    EXPECT_EQ(l[0], "signal,time_s,wallclock_s,value");
}

TEST(BuildCsv, WritesOneRowPerSampleInLongFormat) {
    std::vector<CsvTrace> tr;
    tr.push_back(trace("Voltage", {1000.0, 1000.5}, {0.25, -0.5}));
    tr.push_back(trace("Current", {1000.25}, {2.0}));

    const std::vector<std::string> l = linesOf(BuildCsv(tr, 1000.0));
    ASSERT_EQ(l.size(), 4u);
    EXPECT_EQ(l[1], "Voltage,0.000000000,1000.000000,0.25");
    EXPECT_EQ(l[2], "Voltage,0.500000000,1000.500000,-0.5");
    EXPECT_EQ(l[3], "Current,0.250000000,1000.250000,2");
}

// A capture starts at t0, so the relative column reads from zero whenever the
// export was taken.
TEST(BuildCsv, TimeIsRelativeToTheWindowStart) {
    std::vector<CsvTrace> tr;
    tr.push_back(trace("S", {1756291200.123456}, {1.0}));
    const std::vector<std::string> l = linesOf(BuildCsv(tr, 1756291200.0));
    ASSERT_EQ(l.size(), 2u);
    EXPECT_EQ(l[1].compare(0, 14, "S,0.123456000"), 0) << l[1];
    EXPECT_NE(l[1].find(",1756291200.123456,"), std::string::npos) << l[1];
}

TEST(BuildCsv, AnEmptyTraceContributesNoRows) {
    std::vector<CsvTrace> tr;
    tr.push_back(trace("Empty", {}, {}));
    tr.push_back(trace("S", {1.0}, {1.0}));
    EXPECT_EQ(linesOf(BuildCsv(tr, 0.0)).size(), 2u);
}

TEST(BuildCsv, MismatchedTimeAndValueCountsAreTruncated) {
    CsvTrace c;
    c.name   = "S";
    c.data.t = {1.0, 2.0, 3.0};
    c.data.v = {1.0};
    std::vector<CsvTrace> tr;
    tr.push_back(c);
    EXPECT_EQ(linesOf(BuildCsv(tr, 0.0)).size(), 2u);
}

TEST(ExportCsvFile, WritesWhatBuildCsvProduces) {
    const std::string path = "/tmp/udpscope_export_test.csv";
    std::vector<CsvTrace> tr;
    tr.push_back(trace("S", {1.0, 2.0}, {3.0, 4.0}));

    std::string err;
    ASSERT_TRUE(ExportCsvFile(path, tr, 1.0, err)) << err;

    std::ifstream f(path.c_str());
    std::ostringstream ss;
    ss << f.rdbuf();
    EXPECT_EQ(ss.str(), BuildCsv(tr, 1.0));
    std::remove(path.c_str());
}

TEST(ExportCsvFile, ReportsAnUnwritablePath) {
    std::vector<CsvTrace> tr;
    std::string err;
    EXPECT_FALSE(ExportCsvFile("/proc/definitely/not/here.csv", tr, 0.0, err));
    EXPECT_FALSE(err.empty());
}

TEST(SignalsInPane, ListsALeafInOrder) {
    PaneNode leaf;
    Assignment a;
    a.signalName = "B";
    leaf.signals.push_back(a);
    a.signalName = "A";
    leaf.signals.push_back(a);

    const std::vector<std::string> got = SignalsInPane(&leaf);
    ASSERT_EQ(got.size(), 2u);
    EXPECT_EQ(got[0], "B");
    EXPECT_EQ(got[1], "A");
}

// Exporting "all panes" must not write the same signal twice when it is
// dropped into two panes for comparison.
TEST(SignalsInPane, WalksTheTreeDepthFirstWithoutDuplicates) {
    PaneNode root;
    root.leaf = false;
    root.a    = std::unique_ptr<PaneNode>(new PaneNode());
    root.b    = std::unique_ptr<PaneNode>(new PaneNode());
    Assignment a;
    a.signalName = "X";
    root.a->signals.push_back(a);
    a.signalName = "Y";
    root.a->signals.push_back(a);
    a.signalName = "X";
    root.b->signals.push_back(a);

    const std::vector<std::string> got = SignalsInPane(&root);
    ASSERT_EQ(got.size(), 2u);
    EXPECT_EQ(got[0], "X");
    EXPECT_EQ(got[1], "Y");
}

TEST(SignalsInPane, HandlesNullAndEmpty) {
    EXPECT_TRUE(SignalsInPane(NULL).empty());
    PaneNode empty;
    EXPECT_TRUE(SignalsInPane(&empty).empty());
}
  • Step 2: Run the tests to verify they fail
cd Client/udpscope && cmake --build build -j 2>&1 | tail -5

Expected: FAIL — Export.h: No such file or directory.

  • Step 3: Write Export.h

Create Client/udpscope/Export.h:

/**
 * @file Export.h
 * @brief Long-format CSV export of a capture or of the visible live window
 *        (spec §10).
 */
#ifndef UDPSCOPE_EXPORT_H
#define UDPSCOPE_EXPORT_H

#include "PaneTree.h"
#include "Types.h"

#include <string>
#include <vector>

namespace udpscope {

/** One signal's samples, already restricted to the exported window. */
struct CsvTrace {
    std::string name;
    Series      data;
};

/**
 * @param t0 the window start; `time_s` is measured from it.
 * @return the whole file, header included, ending in a newline.
 */
std::string BuildCsv(const std::vector<CsvTrace>& traces, double t0);

bool ExportCsvFile(const std::string& path, const std::vector<CsvTrace>& traces,
                   double t0, std::string& err);

/** Every distinct signal assigned anywhere under @a n, first use first. */
std::vector<std::string> SignalsInPane(const PaneNode* n);

} /* namespace udpscope */

#endif /* UDPSCOPE_EXPORT_H */
  • Step 4: Write Export.cpp

Create Client/udpscope/Export.cpp:

#include "Export.h"

#include <algorithm>
#include <cstdio>
#include <fstream>

namespace udpscope {

namespace {

void collect(const PaneNode* n, std::vector<std::string>& out) {
    if (n == NULL) {
        return;
    }
    if (n->leaf) {
        for (size_t i = 0u; i < n->signals.size(); i++) {
            const std::string& name = n->signals[i].signalName;
            if (std::find(out.begin(), out.end(), name) == out.end()) {
                out.push_back(name);
            }
        }
        return;
    }
    collect(n->a.get(), out);
    collect(n->b.get(), out);
}

} /* namespace */

std::vector<std::string> SignalsInPane(const PaneNode* n) {
    std::vector<std::string> out;
    collect(n, out);
    return out;
}

std::string BuildCsv(const std::vector<CsvTrace>& traces, double t0) {
    std::string out = "signal,time_s,wallclock_s,value\n";
    char buf[320];
    for (size_t i = 0u; i < traces.size(); i++) {
        const CsvTrace& c = traces[i];
        const size_t n = std::min(c.data.t.size(), c.data.v.size());
        for (size_t k = 0u; k < n; k++) {
            std::snprintf(buf, sizeof(buf), "%s,%.9f,%.6f,%.10g\n",
                          c.name.c_str(), c.data.t[k] - t0, c.data.t[k],
                          c.data.v[k]);
            out += buf;
        }
    }
    return out;
}

bool ExportCsvFile(const std::string& path, const std::vector<CsvTrace>& traces,
                   double t0, std::string& err) {
    std::ofstream f(path.c_str(), std::ios::trunc);
    if (!f) {
        err = "cannot write " + path;
        return false;
    }
    f << BuildCsv(traces, t0);
    if (!f) {
        err = "write failed for " + path;
        return false;
    }
    return true;
}

} /* namespace udpscope */

Add Export.cpp to CORE_SOURCES.

  • Step 5: Run the tests to verify they pass
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='BuildCsv*:ExportCsvFile*:SignalsInPane*'

Expected: PASS, 10 tests.

  • Step 6: Commit the export core
git add Client/udpscope/Export.h Client/udpscope/Export.cpp \
        Client/udpscope/tests/ExportTest.cpp Client/udpscope/CMakeLists.txt
git commit -m "feat(udpscope): long-format CSV export"
  • Step 7: Gather the traces and hook up the File menu

In App.h, add #include "Export.h" and:

    /**
     * @param node the subtree to export, or the whole tree when null.
     * @return false with @a err set when there is nothing to export.
     */
    bool gatherExport(const PaneNode* node, std::vector<CsvTrace>& out,
                      double& t0, std::string& err) const;
    void exportCsv(const PaneNode* node);

    std::string exportDir_;         /**< where the last export went */
    PaneNode*   exportPane_ = NULL; /**< set by the pane context menu */

In App.cpp:

bool App::gatherExport(const PaneNode* node, std::vector<CsvTrace>& out,
                       double& t0, std::string& err) const {
    const std::vector<std::string> names =
        SignalsInPane(node != NULL ? node : tree_.root());
    if (names.empty()) {
        err = "nothing to export: no signals are assigned";
        return false;
    }

    const bool haveCapture = latch_.showing();
    /* A capture is exported whole; live data is exported over exactly the
       range on screen, which is what the user is looking at. */
    const double x0 = haveCapture ? latch_.x0() : xaxis_.x0();
    const double x1 = haveCapture ? latch_.x1() : xaxis_.x1();
    t0 = x0;

    for (size_t i = 0u; i < names.size(); i++) {
        TraceData d;
        bool ok = false;
        if (haveCapture) {
            /* maxPoints is the raw count here: the export must not be
               decimated, and FetchCaptureTrace fills raw regardless. */
            ok = FetchCaptureTrace(latch_.capture(), names[i], x0, x1,
                                   opt_.maxPlotPoints, d);
        } else {
            ok = FetchLiveTrace(store_, names[i], x0, x1, opt_.maxPlotPoints, d);
        }
        if (!ok || d.raw.empty()) {
            continue;
        }
        CsvTrace c;
        c.name = names[i];
        c.data = d.raw;
        out.push_back(c);
    }
    if (out.empty()) {
        err = "nothing to export: no samples in the visible range";
        return false;
    }
    return true;
}

void App::exportCsv(const PaneNode* node) {
    std::vector<CsvTrace> traces;
    double      t0 = 0.0;
    std::string err;
    if (!gatherExport(node, traces, t0, err)) {
        status_ = err;
        return;
    }
    /* No file dialog: one fewer dependency, and a timestamped name in the
       working directory is what a bench capture wants anyway. */
    char name[128];
    std::snprintf(name, sizeof(name), "udpscope-%lld.csv",
                  static_cast<long long>(std::time(NULL)));
    const std::string path = exportDir_.empty() ? std::string(name)
                                                : exportDir_ + "/" + name;
    if (ExportCsvFile(path, traces, t0, err)) {
        status_ = "exported " + path;
    } else {
        status_ = err;
    }
}

App.cpp needs #include <ctime> for the file name stamp.

Add to the File menu, above Save Layout:

        if (ImGui::MenuItem("Export CSV (all panes)")) {
            exportCsv(NULL);
        }
        if (ImGui::MenuItem("Export CSV (this pane)", NULL, false,
                            exportPane_ != NULL)) {
            exportCsv(exportPane_);
        }
        ImGui::Separator();

exportPane_ is the pane the pointer was last over. In drawPlotArea(), after paneView_.drawTree(...), add:

    exportPane_ = ctx.hoveredLeaf;

with PaneNode* hoveredLeaf = nullptr; added to PaneContext, set in PaneView::drawLeaf() right after ImPlot::BeginPlot() succeeds:

    if (ImPlot::IsPlotHovered()) {
        ctx.hoveredLeaf = &leaf;
    }

and cleared by PaneView::drawTree() before it walks the layout:

    ctx.hoveredLeaf = nullptr;
  • Step 8: Build and verify by hand
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests

Expected: PASS, all tests from Tasks 116.

Against the demo streamer:

  1. Two panes with one signal each, live. File → Export CSV (all panes) writes udpscope-<unix>.csv in the working directory and the status bar names it.
  2. head -3 shows the header and two rows whose time_s starts near 0 and whose wallclock_s is the current Unix time.
  3. cut -d, -f1 file.csv | sort -u lists exactly the two signals.
  4. Arm the trigger, wait for a capture, export again: the row count matches window × rate for each signal, and time_s spans the window.
  5. Hover one pane, File → Export CSV (this pane): only that pane's signals are in the file.
  • Step 9: Commit
git add Client/udpscope/App.h Client/udpscope/App.cpp \
        Client/udpscope/PaneView.h Client/udpscope/PaneView.cpp
git commit -m "feat(udpscope): export the capture or the live window to CSV"

Task 17: Profile panes and the array-interpretation toggle

Spec §4.2 says a signal that is genuinely a vector is plotted against element index, not unrolled onto the time axis. Tasks 68 already store and override those signals; nothing draws them yet and nothing lets the user flip the interpretation. This closes both.

Files:

  • Modify: Client/udpscope/PlotData.h
  • Modify: Client/udpscope/PlotData.cpp
  • Modify: Client/udpscope/PaneView.cpp
  • Modify: Client/udpscope/SignalList.cpp
  • Modify: Client/udpscope/tests/PlotDataTest.cpp

Interfaces:

  • Consumes: SignalStore::readProfile, Profile (Task 6); Receiver::setProfileOverride/profileOverride (Task 8); SignalMeta::isVectorProfile() (Task 4); PaneNode::profilePane (Task 2); TraceData (Task 10).
  • Produces: bool FetchProfileTrace(const SignalStore&, const std::string&, TraceData&, double& stamp).

Why a profile gets a whole pane rather than sharing one: its X axis is element index, not seconds. Mixing it with a time trace in the same pane would put two incompatible units on one axis. PaneNode::profilePane already exists for exactly this, and PaneTree already propagates it through splits and closes; this task is the first code that reads it.

  • Step 1: Write the failing tests

Append to Client/udpscope/tests/PlotDataTest.cpp:

TEST(FetchProfileTrace, PlotsTheVectorAgainstElementIndex) {
    SignalStore store;
    std::vector<SignalMeta> metas;
    SignalMeta m = scalar("vec");
    m.numCols = 4;
    metas.push_back(m);
    store.setSignals(metas);

    const double vals[4] = {10.0, 20.0, 30.0, 40.0};
    store.pushProfile("vec", 1234.5, vals, 4u);

    TraceData d;
    double stamp = 0.0;
    ASSERT_TRUE(FetchProfileTrace(store, "vec", d, stamp));
    EXPECT_TRUE(d.found);
    EXPECT_DOUBLE_EQ(stamp, 1234.5);
    ASSERT_EQ(d.raw.size(), 4u);
    EXPECT_DOUBLE_EQ(d.raw.t[0], 0.0);
    EXPECT_DOUBLE_EQ(d.raw.t[3], 3.0);
    EXPECT_DOUBLE_EQ(d.raw.v[2], 30.0);
    /* A profile is one screenful of points; it is never decimated. */
    EXPECT_EQ(d.draw.size(), 4u);
}

TEST(FetchProfileTrace, FailsForASignalThatIsNotAProfile) {
    SignalStore store;
    std::vector<SignalMeta> metas;
    metas.push_back(scalar("plain"));
    store.setSignals(metas);
    const double t[1] = {1.0};
    const double v[1] = {2.0};
    store.push("plain", t, v, 1u);

    TraceData d;
    double stamp = 0.0;
    EXPECT_FALSE(FetchProfileTrace(store, "plain", d, stamp));
    EXPECT_FALSE(FetchProfileTrace(store, "absent", d, stamp));
}

TEST(FetchProfileTrace, AnEmptyProfileIsNotDrawable) {
    SignalStore store;
    std::vector<SignalMeta> metas;
    SignalMeta m = scalar("vec");
    m.numCols = 4;
    metas.push_back(m);
    store.setSignals(metas);

    TraceData d;
    double stamp = 0.0;
    EXPECT_FALSE(FetchProfileTrace(store, "vec", d, stamp));
}
  • Step 2: Run the tests to verify they fail
cd Client/udpscope && cmake --build build -j 2>&1 | tail -5

Expected: FAIL — 'FetchProfileTrace' was not declared in this scope.

  • Step 3: Implement FetchProfileTrace

Add to Client/udpscope/PlotData.h, beside the other fetchers:

/**
 * @brief Reads the latest vector snapshot, plotted against element index.
 * @param stamp receives the wall-clock time the snapshot was taken.
 * @return false when the signal has no profile snapshot.
 */
bool FetchProfileTrace(const SignalStore& store, const std::string& name,
                       TraceData& out, double& stamp);

and to Client/udpscope/PlotData.cpp:

bool FetchProfileTrace(const SignalStore& store, const std::string& name,
                       TraceData& out, double& stamp) {
    out.raw.clear();
    out.draw.clear();
    out.found = false;

    Profile p;
    if (!store.readProfile(name, p) || p.v.empty()) {
        return false;
    }
    out.found = true;
    stamp     = p.time;
    out.raw.t = p.x;
    out.raw.v = p.v;
    /* Element count is bounded by the array size, not by a sample rate, so
       there is nothing to decimate. */
    out.draw = out.raw;
    return true;
}
  • Step 4: Run the tests to verify they pass
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='FetchProfileTrace*'

Expected: PASS, 3 tests.

  • Step 5: Draw profile panes

In Client/udpscope/PaneView.cpp, at the top of drawLeaf()'s plot body, branch on the pane kind. Replace the ImPlot::SetupAxes("t [s]", "div") call and the trace loop's entry condition with:

    if (leaf.profilePane) {
        ImPlot::SetupAxes("element", "value");
        ImPlot::SetupAxisLimits(ImAxis_X1, 0.0, 1.0, ImPlotCond_Once);
        ImPlot::SetupAxisLimits(ImAxis_Y1, 0.0, 1.0, ImPlotCond_Once);
        for (size_t i = 0u; i < leaf.signals.size(); i++) {
            Assignment& a = leaf.signals[i];
            TraceData   d;
            double      stamp = 0.0;
            if (ctx.store == nullptr ||
                !FetchProfileTrace(*ctx.store, a.signalName, d, stamp) ||
                d.draw.empty()) {
                continue;
            }
            char plabel[128];
            std::snprintf(plabel, sizeof(plabel), "%s  @%.3f s",
                          a.signalName.c_str(), stamp);
            ImPlot::SetNextLineStyle(toImVec4(a.color), a.lineWidth);
            ImPlot::PlotLine(plabel, d.draw.t.data(), d.draw.v.data(),
                             static_cast<int>(d.draw.size()));
        }
        ImPlot::EndPlot();
        return;   /* no divisions, no cursors, no shared X axis here */
    }

A profile pane keeps ImPlot's own auto-fit (ImPlotCond_Once plus the user's zoom), because it has no time axis to share and no division model to obey.

Assigning a signal to a pane decides what kind of pane it is. In the drag-and-drop accept block, replace the plain leaf.signals.push_back(a); with:

            const SignalMeta& dm  = ctx.metaFor(dropped);
            const bool wantsProfile = dm.isVectorProfile();
            /* Not a `return`: the drop target sits between BeginPlot() and
               EndPlot(), so bailing out here would unbalance ImPlot. */
            if (leaf.signals.empty() || leaf.profilePane == wantsProfile) {
                leaf.profilePane = wantsProfile;
                leaf.signals.push_back(a);
            }
  • Step 6: Add the interpretation toggle to the signal list

In Client/udpscope/SignalList.cpp, inside the per-signal loop after the tooltip, add:

        if (m.numElements() > 1u && m.timeMode == kTimePacket) {
            /* Only PACKET arrays are ambiguous: everything else says outright
               whether it is a burst. */
            if (ImGui::BeginPopupContextItem("##sigmenu")) {
                bool prof = rx_.profileOverride(m.name);
                if (ImGui::MenuItem("plot against element index", NULL, &prof)) {
                    rx_.setProfileOverride(m.name, prof);
                    status_ = m.name + (prof ? ": vector profile"
                                             : ": packed burst");
                }
                ImGui::EndPopup();
            }
        }

The override reaches the receiver thread through Receiver's command queue and survives a CONFIG re-send (Task 8), so a stream that re-announces itself does not silently revert to burst.

SignalList.cpp needs #include "Types.h" for kTimePacket; App.h already pulls it in through SignalStore.h.

  • Step 7: Build and verify by hand
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests

Expected: PASS, all tests from Tasks 117.

Against the demo streamer, whose SineArrayGAM produces a packed array:

  1. Drop the array signal into a pane: it is unrolled onto the time axis and looks like a continuous sine, which is the correct default.
  2. Right-click it in the signal list, tick plot against element index: the status bar confirms vector profile.
  3. Drop it into an empty pane: the X axis now reads element, the trace has exactly NumberOfElements points, and the legend shows the snapshot time ticking forward.
  4. Try to drop a scalar into that same pane: the drop is refused, because the axes are incompatible.
  5. Untick the override: dropping it into a fresh pane gives a time trace again.
  • Step 8: Commit
git add Client/udpscope/PlotData.h Client/udpscope/PlotData.cpp \
        Client/udpscope/PaneView.cpp Client/udpscope/SignalList.cpp \
        Client/udpscope/tests/PlotDataTest.cpp
git commit -m "feat(udpscope): index-plot vector profiles and toggle the array interpretation"

Task 18: Documentation and repository integration

Files:

  • Create: Docs/UDPScope.md
  • Create: Client/udpscope/resources/udpscope.desktop
  • Create: Client/udpscope/resources/icons/udpscope.svg
  • Modify: Client/udpscope/CMakeLists.txt
  • Modify: README.md
  • Modify: CLAUDE.md
  • Modify: ARCHITECTURE.md
  • Modify: .gitignore

Interfaces:

  • Consumes: everything built in Tasks 117. No new code.

  • Step 1: Write Docs/UDPScope.md

Create Docs/UDPScope.md:

# UDPScope

A bench oscilloscope that attaches directly to one `UDPStreamer`. No StreamHub,
no WebSocket, no browser: the UDPS datagrams go straight into the scope through
the standalone C client in `Common/Client/c`.

Use it when you want to look at a signal now — on a control-room machine, over
a lab network, on a host that has nothing installed. Use StreamHub instead when
you need several sources aggregated, history on disk, or more than one client
watching at once.

## Build

Needs SDL2 and OpenGL; ImGui, ImPlot and GoogleTest are fetched by CMake.

```bash
cd Client/udpscope
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
./build/udpscope_tests          # unit tests
```

## Run

```bash
./build/UDPScope --host 192.168.1.10 --port 44500
```

| Option | Default | Meaning |
|---|---|---|
| `--host ADDR` | `127.0.0.1` | Streamer control address |
| `--port N` | `44500` | Streamer control port |
| `--multicast GROUP` | — | Join this group instead of unicast |
| `--iface ADDR` | — | Local interface IP for the multicast join |
| `--data-port N` | `0` | Data port when the streamer separates it |
| `--silence SEC` | `2.0` | Reconnect after this long without a packet |
| `--config PATH` | `$XDG_CONFIG_HOME/udpscope/session.conf` | Session file |
| `--max-mpts N` | `4000` | Drawn points per trace before decimation |

Long `--` options only, matching `Common/Client/c/example/udps_dump.c`. An
option given on the command line beats the session file; anything omitted comes
from the file.

## Panes

The plot area is a splittable grid. Hover a pane to reveal its handles:

- the four edge handles split it left/right/top/bottom;
- the ✕ closes it and gives its space back to its sibling;
- the border between two panes is a splitter you can drag.

Drag a signal from the list on the left into a pane to plot it. Right-click a
legend entry for colour, line width and vertical scale; the same menu removes
the trace.

## Vertical scale

Panes are eight divisions tall, ±4 about the centre line, so traces in
different units share a pane without lying about each other's amplitude. Each
trace has its own volts-per-division:

- **auto** — fits the samples currently on screen;
- **range** — uses the `range_min`/`range_max` the CONFIG packet carries;
- **manual** — you set per-division and offset, as on a bench scope.

The legend shows the value per division for each trace.

## Time axis

Every pane shares one X axis. In live mode it follows the newest sample; any
pan or zoom detaches it, and View → Live re-attaches. View → window sets the
span in seconds.

## Trigger

The trigger bar configures a client-side trigger: signal, edge, threshold,
hysteresis, window length, and where in that window the trigger point sits
(`pre %`).

| Mode | Behaviour |
|---|---|
| **Norm** | Re-arms after every capture; the display holds the last one until the next trigger |
| **1x** | Captures once and stays held until you press Re-arm |

The badge reads `IDLE`, `ARMED nn%`, `TRIG'D` or `HELD`. The percentage is the
pre-trigger window filling up: the scope refuses to arm until it holds enough
history to back-fill the part of the capture that precedes the trigger, so a
capture never starts mid-waveform.

Untick **follow** to study one capture while later ones go by; tick it again
and the newest capture appears at once. **Live** drops the capture and goes
back to the rolling display.

Known simplification: with `edge = both` and a non-zero hysteresis, re-arming
uses the rising-edge arm level for both directions. Set hysteresis to 0 if you
need symmetric behaviour on a noisy bipolar signal.

## Cursors and measurements

View → Cursors puts two draggable time cursors in every pane at once; the
status bar reads `A`, `B`, `dt` and `1/dt`. View → Measurements overlays
min/max/peak-to-peak/average/RMS per trace, taken between the cursors when they
are up and over the visible range otherwise.

All statistics come from the undecimated samples, never from the drawn
envelope, so a single-sample spike is counted even when it is not individually
visible.

## Array signals

A signal with `NumberOfElements > 1` is a packed burst by default: its elements
are unrolled onto the time axis using the accompanying time signal or the
declared sampling rate. A signal that is genuinely a vector — a spatial
profile, not a burst — is plotted against element index instead. Toggle the
interpretation from the signal list's context menu; the choice survives a
CONFIG re-send.

## Export

File → Export CSV writes long format:

```
signal,time_s,wallclock_s,value
Voltage,0.000000000,1756291200.123456,0.4981
```

`time_s` is measured from the start of the exported window; `wallclock_s` is
absolute. One row per sample per signal, because signals carry independent
timestamps and a wide format would need resampling. Export covers every
assigned signal, or just the pane under the pointer.

## Session file

Layout, colours, vertical scales, trigger settings, cursors and the source are
saved on exit and from File → Save Layout, to
`$XDG_CONFIG_HOME/udpscope/session.conf`. The format is line-based and
hand-editable; indentation is decorative. A malformed file is reported in the
status bar and ignored outright, never partially applied.

## Diagnostics

The status bar counts packets, frames, counter gaps, dropped fragments and
reconnects; the last three turn red as soon as they are non-zero.

`udpscope_rxprobe` is a headless build of the same receive path. When the GUI
shows nothing, run it to find out whether the problem is the network or the
scope:

```bash
./build/udpscope_rxprobe --host 192.168.1.10 --port 44500
```

Cross-check against the reference C client if they disagree:

```bash
cd Common/Client/c && make && ./udps_dump 192.168.1.10 44500
```

## Relationship to StreamHub

| | UDPScope | StreamHub clients |
|---|---|---|
| Sources | One streamer | Many, aggregated |
| Transport | UDPS direct | UDPS → hub → WebSocket |
| History | In-memory rings only | Disk-backed `.shist` files |
| Trigger | In the client | In the hub, shared by clients |
| Processes | One | Hub plus client |

The two share the wire format (`Common/UDP/UDPSProtocol.h`) and nothing else.
UDPScope reads `Client/streamhub/SignalBuffer.h` and the shared fonts
read-only; it never modifies anything under `Client/streamhub/`.
  • Step 1b: Add the desktop entry and icon

Spec §12 asks for install rules covering the .desktop entry and icon, which Tasks 117 left out because there was nothing to install them from. UDPScope needs its own rather than reusing Client/streamhub/resources/, which is read-only to this project.

Create Client/udpscope/resources/udpscope.desktop:

[Desktop Entry]
Type=Application
Name=UDPScope
GenericName=Signal Oscilloscope
Comment=Bench oscilloscope attached directly to a MARTe2 UDPStreamer
Exec=UDPScope
Icon=udpscope
Terminal=false
Categories=Science;Engineering;DataVisualization;
Keywords=MARTe2;oscilloscope;UDPS;signals;
StartupWMClass=UDPScope

Create Client/udpscope/resources/icons/udpscope.svg — a trace on a graticule, in the same Catppuccin palette the app uses:

<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
  <rect x="2" y="2" width="60" height="60" rx="8" fill="#1e1e2e"/>
  <g stroke="#45475a" stroke-width="1">
    <path d="M2 17h60M2 32h60M2 47h60M17 2v60M32 2v60M47 2v60"/>
  </g>
  <path d="M4 32 C 12 4, 20 60, 32 32 S 52 4, 60 32"
        fill="none" stroke="#a6e3a1" stroke-width="3" stroke-linecap="round"/>
  <circle cx="32" cy="32" r="3" fill="#fab387"/>
</svg>

Extend the install block in Client/udpscope/CMakeLists.txt:

install(FILES ${RESOURCE_DIR}/udpscope.desktop
        DESTINATION share/applications)
install(FILES ${RESOURCE_DIR}/icons/udpscope.svg
        DESTINATION share/icons/hicolor/scalable/apps)

RESOURCE_DIR points at Client/streamhub/resources (Task 1, for the fonts), so add a second variable beside it and use that here:

set(UDPSCOPE_RESOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/resources)

then replace ${RESOURCE_DIR} with ${UDPSCOPE_RESOURCE_DIR} in the two install lines above.

Verify:

cd Client/udpscope && cmake -B build -DCMAKE_INSTALL_PREFIX=/tmp/udpscope-prefix \
  && cmake --build build -j && cmake --install build
find /tmp/udpscope-prefix -type f | sort

Expected: bin/UDPScope, the fonts under share/udpscope, share/applications/udpscope.desktop and share/icons/hicolor/scalable/apps/udpscope.svg.

  • Step 2: Add the repository entries

In README.md, add to the capability table after the Integrated client row:

| **Direct scope**      | `Client/udpscope`        | ImGui bench oscilloscope attached straight to one UDPStreamer                                                  |

add to the repository structure block under Client/:

├── Client/udpscope/                Direct-UDPS ImGui oscilloscope (SDL2 + ImPlot)

add a component section after StreamHub Application:

### UDPScope

Single-source bench oscilloscope (`Client/udpscope/`) that decodes UDPS
datagrams directly through `Common/Client/c` — no hub, no browser, one process.
Splittable pane grid, client-side Normal/Single trigger with a pre-trigger
window, per-trace division scaling, cursors and measurements, CSV export and a
saved session layout.

```bash
cd Client/udpscope && cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build
./build/UDPScope --host 127.0.0.1 --port 44500

See Docs/UDPScope.md.


and add to the documentation table:

```markdown
| `Docs/UDPScope.md`            | Direct-UDPS bench oscilloscope user guide                      |

In CLAUDE.md, add to the build block after the Qt client lines:

# Direct-UDPS ImGui bench scope (not a MARTe2 component; needs SDL2)
cd Client/udpscope && cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build
./build/udpscope_tests

and a paragraph after the Qt client paragraph:

**UDPScope** (`Client/udpscope/`): bench oscilloscope that talks UDPS directly
to a single `UDPStreamer` through the standalone C client, bypassing StreamHub
entirely. A receiver thread owns the C client and the trigger FSM; a
mutex-guarded `SignalStore` hands data to the GUI thread. Everything except
`main.cpp`, `App.cpp` and `PaneView.cpp` is framework-free and unit-tested in
`udpscope_tests`. It consumes `Client/streamhub/SignalBuffer.h` and the shared
fonts read-only and must never modify anything under `Client/streamhub/`.

In ARCHITECTURE.md, add UDPScope as a second consumer of the streaming path: a client that attaches to UDPStreamer directly rather than through StreamHub, with the trigger in the client instead of the hub.

In .gitignore, add:

Client/udpscope/build/
  • Step 3: Check the documentation against the build
cd Client/udpscope && rm -rf build \
  && cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build -j \
  && ./build/udpscope_tests

Expected: a clean tree builds from the commands as written, and every test passes. Then walk Docs/UDPScope.md from top to bottom against the running binary: every option in the table is accepted, every menu item named exists, and the trigger badge shows the four documented strings.

  • Step 4: Commit
git add Docs/UDPScope.md Client/udpscope/resources Client/udpscope/CMakeLists.txt \
        README.md CLAUDE.md ARCHITECTURE.md .gitignore
git commit -m "docs: UDPScope bench oscilloscope"