From b797f9ad553227343c8a21f5e487ea2531053078 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9sar=20Morais?= Date: Sun, 10 May 2026 15:24:48 -0300 Subject: [PATCH 1/5] feat(stream): add CSV export of SS_FRAME_FEC_STATUS events --- src/config.cpp | 4 +++ src/config.h | 3 ++ src/stream.cpp | 95 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+) diff --git a/src/config.cpp b/src/config.cpp index 47475a04b4d..906b661a758 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -524,6 +524,8 @@ namespace config { ENCRYPTION_MODE_NEVER, // lan_encryption_mode ENCRYPTION_MODE_OPPORTUNISTIC, // wan_encryption_mode + + {}, // metrics_path (disabled by default) }; nvhttp_t nvhttp { @@ -1228,6 +1230,8 @@ namespace config { int_between_f(vars, "fec_percentage", stream.fec_percentage, {1, 255}); + string_f(vars, "metrics_path", stream.metrics_path); + map_int_int_f(vars, "keybindings"s, input.keybindings); // This config option will only be used by the UI diff --git a/src/config.h b/src/config.h index 6e4f001b707..4230ed8dc30 100644 --- a/src/config.h +++ b/src/config.h @@ -165,6 +165,9 @@ namespace config { // Video encryption settings for LAN and WAN streams int lan_encryption_mode; int wan_encryption_mode; + + // Directory to write per-session metrics CSV files. Empty = disabled. + std::string metrics_path; }; struct nvhttp_t { diff --git a/src/stream.cpp b/src/stream.cpp index 8b303ef24da..999a426d393 100644 --- a/src/stream.cpp +++ b/src/stream.cpp @@ -4,6 +4,7 @@ */ // standard includes +#include #include #include #include @@ -231,6 +232,27 @@ namespace stream { AUDIO_FEC_HEADER fecHeader; }; + // Wire layout of SS_FRAME_FEC_STATUS (Moonlight → Sunshine, big-endian on the wire). + // Mirrors the struct in moonlight-common-c/src/Video.h with explicit endian decoding. + struct ss_frame_fec_status_wire_t { + boost::endian::big_uint32_at frameIndex; + boost::endian::big_uint16_at highestReceivedSequenceNumber; + boost::endian::big_uint16_at nextContiguousSequenceNumber; + boost::endian::big_uint16_at missingPacketsBeforeHighestReceived; + boost::endian::big_uint16_at totalDataPackets; + boost::endian::big_uint16_at totalParityPackets; + boost::endian::big_uint16_at receivedDataPackets; + boost::endian::big_uint16_at receivedParityPackets; + std::uint8_t fecPercentage; + std::uint8_t multiFecBlockIndex; + std::uint8_t multiFecBlockCount; + }; + + static_assert( + sizeof(ss_frame_fec_status_wire_t) == 21, + "SS_FRAME_FEC_STATUS wire format must be 21 bytes" + ); + #pragma pack(pop) constexpr std::size_t round_to_pkcs7_padded(std::size_t size) { @@ -406,6 +428,14 @@ namespace stream { std::uint32_t launch_session_id; + // Per-session metrics CSV state. Populated lazily on the first + // SS_FRAME_FEC_STATUS event when config::stream.metrics_path is set. + // Both fields are accessed only from the control thread, so no locking. + struct { + std::ofstream csv_file; + int idr_count = 0; + } metrics; + safe::mail_raw_t::event_t shutdown_event; safe::signal_t controlEnd; @@ -951,9 +981,74 @@ namespace stream { server->map(packetTypes[IDX_REQUEST_IDR_FRAME], [&](session_t *session, const std::string_view &payload) { BOOST_LOG(debug) << "type [IDX_REQUEST_IDR_FRAME]"sv; + ++session->metrics.idr_count; + session->video.idr_events->raise(true); }); + // SS_FRAME_FEC_STATUS (0x5502) is sent by Moonlight per-frame whenever there + // is FEC recovery activity or a frame drop. Note: the same packet type is + // sent by Sunshine TO clients as "Set RGB LED" — the protocol multiplexes + // 0x5502 by direction. The receive handler is registered only here. + server->map(SS_FRAME_FEC_PTYPE, [&](session_t *session, const std::string_view &payload) { + if (config::stream.metrics_path.empty()) { + return; + } + + if (payload.size() < sizeof(ss_frame_fec_status_wire_t)) { + BOOST_LOG(warning) << "SS_FRAME_FEC_STATUS payload too small: " << payload.size(); + return; + } + + auto *fec = reinterpret_cast(payload.data()); + + auto wall_now = std::chrono::system_clock::now().time_since_epoch(); + auto wall_ms = std::chrono::duration_cast(wall_now).count(); + + if (!session->metrics.csv_file.is_open()) { + std::filesystem::path dir {config::stream.metrics_path}; + std::error_code ec; + std::filesystem::create_directories(dir, ec); + if (ec) { + BOOST_LOG(error) << "metrics_path: failed to create directory "sv << dir << ": "sv << ec.message(); + return; + } + + auto filename = "sunshine_metrics_" + std::to_string(session->launch_session_id) + + "_" + std::to_string(wall_ms) + ".csv"; + auto path = dir / filename; + + session->metrics.csv_file.open(path); + if (!session->metrics.csv_file.is_open()) { + BOOST_LOG(error) << "metrics_path: failed to open "sv << path; + return; + } + + BOOST_LOG(info) << "metrics: writing session metrics to "sv << path; + + session->metrics.csv_file + << "timestamp_ms,session_id,bitrate_kbps,frame_index," + "missing_packets,total_data_packets,received_data_packets," + "total_parity_packets,received_parity_packets,fec_percentage," + "idr_request_count\n"; + } + + session->metrics.csv_file + << wall_ms << ',' + << session->launch_session_id << ',' + << session->config.monitor.bitrate << ',' + << fec->frameIndex << ',' + << fec->missingPacketsBeforeHighestReceived << ',' + << fec->totalDataPackets << ',' + << fec->receivedDataPackets << ',' + << fec->totalParityPackets << ',' + << fec->receivedParityPackets << ',' + << static_cast(fec->fecPercentage) << ',' + << session->metrics.idr_count << '\n'; + + session->metrics.idr_count = 0; + }); + server->map(packetTypes[IDX_INVALIDATE_REF_FRAMES], [&](session_t *session, const std::string_view &payload) { auto frames = (std::int64_t *) payload.data(); auto firstFrame = frames[0]; From 3cf6addd914c93440ac1a42fab3a808bd861efb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9sar=20Morais?= Date: Sun, 10 May 2026 16:11:29 -0300 Subject: [PATCH 2/5] refactor(stream): extract CSV formatters as free functions --- src/stream.cpp | 101 +++++++++++++++++++++++++++++++++++-------------- 1 file changed, 73 insertions(+), 28 deletions(-) diff --git a/src/stream.cpp b/src/stream.cpp index 999a426d393..62aed0dd374 100644 --- a/src/stream.cpp +++ b/src/stream.cpp @@ -8,6 +8,7 @@ #include #include #include +#include // lib includes #include @@ -809,6 +810,64 @@ namespace stream { return replaced; } + /** + * @brief Returns the header line for the per-session metrics CSV. + * Pure function — exposed for testability. + */ + std::string_view metrics_csv_header() { + return "timestamp_ms,session_id,bitrate_kbps,frame_index," + "missing_packets,total_data_packets,received_data_packets," + "total_parity_packets,received_parity_packets,fec_percentage," + "idr_request_count"; + } + + /** + * @brief Decodes a SS_FRAME_FEC_STATUS payload and formats one CSV row. + * Returns an empty string if the payload is too small to decode. + * Pure function — exposed for testability. + * @param payload Raw bytes of the SS_FRAME_FEC_STATUS message (big-endian). + * @param timestamp_ms Wall-clock timestamp in milliseconds since Unix epoch. + * @param session_id The launch session identifier. + * @param bitrate_kbps Bitrate negotiated for the session, in kbps. + * @param idr_count Accumulated IDR requests since the previous row. + */ + std::string format_metrics_csv_row( + const std::string_view &payload, + int64_t timestamp_ms, + uint32_t session_id, + int bitrate_kbps, + int idr_count + ) { + if (payload.size() < sizeof(ss_frame_fec_status_wire_t)) { + return {}; + } + + auto *fec = reinterpret_cast(payload.data()); + + std::ostringstream os; + os << timestamp_ms << ',' + << session_id << ',' + << bitrate_kbps << ',' + << fec->frameIndex << ',' + << fec->missingPacketsBeforeHighestReceived << ',' + << fec->totalDataPackets << ',' + << fec->receivedDataPackets << ',' + << fec->totalParityPackets << ',' + << fec->receivedParityPackets << ',' + << static_cast(fec->fecPercentage) << ',' + << idr_count; + return os.str(); + } + + /** + * @brief Builds the per-session metrics CSV filename (without directory). + * Pure function — exposed for testability. + */ + std::string make_metrics_csv_filename(uint32_t session_id, int64_t timestamp_ms) { + return "sunshine_metrics_" + std::to_string(session_id) + "_" + + std::to_string(timestamp_ms) + ".csv"; + } + /** * @brief Pass gamepad feedback data back to the client. * @param session The session object. @@ -995,16 +1054,21 @@ namespace stream { return; } - if (payload.size() < sizeof(ss_frame_fec_status_wire_t)) { + auto wall_now = std::chrono::system_clock::now().time_since_epoch(); + auto wall_ms = std::chrono::duration_cast(wall_now).count(); + + auto row = format_metrics_csv_row( + payload, + wall_ms, + session->launch_session_id, + session->config.monitor.bitrate, + session->metrics.idr_count + ); + if (row.empty()) { BOOST_LOG(warning) << "SS_FRAME_FEC_STATUS payload too small: " << payload.size(); return; } - auto *fec = reinterpret_cast(payload.data()); - - auto wall_now = std::chrono::system_clock::now().time_since_epoch(); - auto wall_ms = std::chrono::duration_cast(wall_now).count(); - if (!session->metrics.csv_file.is_open()) { std::filesystem::path dir {config::stream.metrics_path}; std::error_code ec; @@ -1014,9 +1078,7 @@ namespace stream { return; } - auto filename = "sunshine_metrics_" + std::to_string(session->launch_session_id) + - "_" + std::to_string(wall_ms) + ".csv"; - auto path = dir / filename; + auto path = dir / make_metrics_csv_filename(session->launch_session_id, wall_ms); session->metrics.csv_file.open(path); if (!session->metrics.csv_file.is_open()) { @@ -1025,27 +1087,10 @@ namespace stream { } BOOST_LOG(info) << "metrics: writing session metrics to "sv << path; - - session->metrics.csv_file - << "timestamp_ms,session_id,bitrate_kbps,frame_index," - "missing_packets,total_data_packets,received_data_packets," - "total_parity_packets,received_parity_packets,fec_percentage," - "idr_request_count\n"; + session->metrics.csv_file << metrics_csv_header() << '\n'; } - session->metrics.csv_file - << wall_ms << ',' - << session->launch_session_id << ',' - << session->config.monitor.bitrate << ',' - << fec->frameIndex << ',' - << fec->missingPacketsBeforeHighestReceived << ',' - << fec->totalDataPackets << ',' - << fec->receivedDataPackets << ',' - << fec->totalParityPackets << ',' - << fec->receivedParityPackets << ',' - << static_cast(fec->fecPercentage) << ',' - << session->metrics.idr_count << '\n'; - + session->metrics.csv_file << row << '\n'; session->metrics.idr_count = 0; }); From a33722d89cea2ece0c7d854dc35140747fdbc5e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9sar=20Morais?= Date: Sun, 10 May 2026 16:11:35 -0300 Subject: [PATCH 3/5] test(stream): add unit tests for CSV metrics formatters --- tests/unit/test_stream.cpp | 129 +++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/tests/unit/test_stream.cpp b/tests/unit/test_stream.cpp index fdc444cf023..3eee87f6fee 100644 --- a/tests/unit/test_stream.cpp +++ b/tests/unit/test_stream.cpp @@ -10,6 +10,16 @@ namespace stream { std::vector concat_and_insert(uint64_t insert_size, uint64_t slice_size, const std::string_view &data1, const std::string_view &data2); + + std::string_view metrics_csv_header(); + std::string format_metrics_csv_row( + const std::string_view &payload, + int64_t timestamp_ms, + uint32_t session_id, + int bitrate_kbps, + int idr_count + ); + std::string make_metrics_csv_filename(uint32_t session_id, int64_t timestamp_ms); } #include "../tests_common.h" @@ -37,3 +47,122 @@ TEST(ConcatAndInsertTests, ConcatSmallStrideTest) { auto expected = std::vector {0, 'a', 0, 'b', 0, 'c', 0, 'd', 0, 'e'}; ASSERT_EQ(res, expected); } + +namespace { + // Builds a 21-byte SS_FRAME_FEC_STATUS payload in the wire (big-endian) format. + std::array build_fec_status_payload( + uint32_t frame_index, + uint16_t highest_received, + uint16_t next_contiguous, + uint16_t missing, + uint16_t total_data, + uint16_t total_parity, + uint16_t received_data, + uint16_t received_parity, + uint8_t fec_percentage, + uint8_t multi_fec_index, + uint8_t multi_fec_count + ) { + auto put_u16_be = [](std::array &out, size_t off, uint16_t v) { + out[off] = static_cast((v >> 8) & 0xFF); + out[off + 1] = static_cast(v & 0xFF); + }; + + std::array payload {}; + payload[0] = static_cast((frame_index >> 24) & 0xFF); + payload[1] = static_cast((frame_index >> 16) & 0xFF); + payload[2] = static_cast((frame_index >> 8) & 0xFF); + payload[3] = static_cast(frame_index & 0xFF); + put_u16_be(payload, 4, highest_received); + put_u16_be(payload, 6, next_contiguous); + put_u16_be(payload, 8, missing); + put_u16_be(payload, 10, total_data); + put_u16_be(payload, 12, total_parity); + put_u16_be(payload, 14, received_data); + put_u16_be(payload, 16, received_parity); + payload[18] = fec_percentage; + payload[19] = multi_fec_index; + payload[20] = multi_fec_count; + return payload; + } +} // namespace + +TEST(MetricsCsvTests, HeaderHasAllColumns) { + auto header = stream::metrics_csv_header(); + EXPECT_NE(header.find("timestamp_ms"), std::string_view::npos); + EXPECT_NE(header.find("session_id"), std::string_view::npos); + EXPECT_NE(header.find("bitrate_kbps"), std::string_view::npos); + EXPECT_NE(header.find("frame_index"), std::string_view::npos); + EXPECT_NE(header.find("missing_packets"), std::string_view::npos); + EXPECT_NE(header.find("total_data_packets"), std::string_view::npos); + EXPECT_NE(header.find("received_data_packets"), std::string_view::npos); + EXPECT_NE(header.find("total_parity_packets"), std::string_view::npos); + EXPECT_NE(header.find("received_parity_packets"), std::string_view::npos); + EXPECT_NE(header.find("fec_percentage"), std::string_view::npos); + EXPECT_NE(header.find("idr_request_count"), std::string_view::npos); + EXPECT_EQ(header.find('\n'), std::string_view::npos); // header has no trailing newline +} + +TEST(MetricsCsvTests, RowDecodesBigEndianFields) { + auto payload = build_fec_status_payload( + /*frame_index=*/0x12345678u, // 305419896 + /*highest_received=*/100, + /*next_contiguous=*/95, + /*missing=*/5, + /*total_data=*/50, + /*total_parity=*/10, + /*received_data=*/45, + /*received_parity=*/9, + /*fec_percentage=*/20, + /*multi_fec_index=*/0, + /*multi_fec_count=*/1 + ); + + auto row = stream::format_metrics_csv_row( + std::string_view {reinterpret_cast(payload.data()), payload.size()}, + /*timestamp_ms=*/1000, + /*session_id=*/42, + /*bitrate_kbps=*/20000, + /*idr_count=*/3 + ); + + EXPECT_EQ(row, "1000,42,20000,305419896,5,50,45,10,9,20,3"); +} + +TEST(MetricsCsvTests, RowReturnsEmptyOnShortPayload) { + std::array short_payload {}; + auto row = stream::format_metrics_csv_row( + std::string_view {reinterpret_cast(short_payload.data()), short_payload.size()}, + 1000, + 42, + 20000, + 0 + ); + EXPECT_TRUE(row.empty()); +} + +TEST(MetricsCsvTests, RowEmbedsSessionMetadata) { + auto payload = build_fec_status_payload(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + + auto row = stream::format_metrics_csv_row( + std::string_view {reinterpret_cast(payload.data()), payload.size()}, + /*timestamp_ms=*/1713045600000LL, + /*session_id=*/7, + /*bitrate_kbps=*/55388, + /*idr_count=*/12 + ); + + // Leading fields come from session metadata, not from the FEC payload. + EXPECT_EQ(row.substr(0, row.find(',')), "1713045600000"); + EXPECT_NE(row.find(",7,"), std::string::npos); + EXPECT_NE(row.find(",55388,"), std::string::npos); + // idr_count is the trailing field + EXPECT_EQ(row.substr(row.rfind(',') + 1), "12"); +} + +TEST(MetricsCsvTests, FilenameFormat) { + EXPECT_EQ( + stream::make_metrics_csv_filename(42, 1713045600000LL), + "sunshine_metrics_42_1713045600000.csv" + ); +} From 50c69129e25694e1e6f6f1728b3a33c8f794ad05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9sar=20Morais?= Date: Sun, 10 May 2026 16:16:21 -0300 Subject: [PATCH 4/5] docs(configuration): document metrics_path option --- docs/configuration.md | 30 ++++++++++++++++++++++++++++++ tests/unit/test_stream.cpp | 2 +- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/docs/configuration.md b/docs/configuration.md index 5ef85dc92a3..ccae8a16d13 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1929,6 +1929,36 @@ editing the `conf` file in a text editor. Use the examples as reference. +### metrics_path + + + + + + + + + + + + + + +
Description + Directory where Sunshine writes a CSV file with per-session network metrics. + One file per session is created lazily on the first FEC status report from + the client (typically only when the network experiences packet loss). Each + row records the timestamp, session id, configured bitrate, frame index, + packet counts, FEC percentage, and accumulated IDR requests since the + previous row. + @note{Leave empty to disable metrics export. The directory is created if + it does not exist.} +
Default@code{} + (empty — disabled) + @endcode
Example@code{} + metrics_path = /var/log/sunshine/metrics + @endcode
+ ### qp diff --git a/tests/unit/test_stream.cpp b/tests/unit/test_stream.cpp index 3eee87f6fee..2a3a7a3859e 100644 --- a/tests/unit/test_stream.cpp +++ b/tests/unit/test_stream.cpp @@ -20,7 +20,7 @@ namespace stream { int idr_count ); std::string make_metrics_csv_filename(uint32_t session_id, int64_t timestamp_ms); -} +} // namespace stream #include "../tests_common.h" From efd8148aee2fdb6a0c2bd41173f26d06d1422634 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9sar=20Morais?= Date: Sun, 10 May 2026 16:59:19 -0300 Subject: [PATCH 5/5] refactor(stream): address SonarCloud code smells --- src/stream.cpp | 79 +++++++++++++++++++-------------- tests/unit/test_stream.cpp | 90 +++++++++++++++++++------------------- 2 files changed, 91 insertions(+), 78 deletions(-) diff --git a/src/stream.cpp b/src/stream.cpp index 62aed0dd374..d3e202d8d8c 100644 --- a/src/stream.cpp +++ b/src/stream.cpp @@ -4,7 +4,9 @@ */ // standard includes +#include #include +#include #include #include #include @@ -842,19 +844,20 @@ namespace stream { return {}; } - auto *fec = reinterpret_cast(payload.data()); + ss_frame_fec_status_wire_t fec; + std::memcpy(&fec, payload.data(), sizeof(fec)); std::ostringstream os; os << timestamp_ms << ',' << session_id << ',' << bitrate_kbps << ',' - << fec->frameIndex << ',' - << fec->missingPacketsBeforeHighestReceived << ',' - << fec->totalDataPackets << ',' - << fec->receivedDataPackets << ',' - << fec->totalParityPackets << ',' - << fec->receivedParityPackets << ',' - << static_cast(fec->fecPercentage) << ',' + << fec.frameIndex << ',' + << fec.missingPacketsBeforeHighestReceived << ',' + << fec.totalDataPackets << ',' + << fec.receivedDataPackets << ',' + << fec.totalParityPackets << ',' + << fec.receivedParityPackets << ',' + << static_cast(fec.fecPercentage) << ',' << idr_count; return os.str(); } @@ -864,8 +867,36 @@ namespace stream { * Pure function — exposed for testability. */ std::string make_metrics_csv_filename(uint32_t session_id, int64_t timestamp_ms) { - return "sunshine_metrics_" + std::to_string(session_id) + "_" + - std::to_string(timestamp_ms) + ".csv"; + return std::format("sunshine_metrics_{}_{}.csv", session_id, timestamp_ms); + } + + /** + * @brief Lazily opens the per-session metrics CSV file and writes the header. + * No-op on subsequent calls. Returns true if the file is open and ready. + */ + bool ensure_metrics_csv_open(session_t *session, int64_t wall_ms) { + if (session->metrics.csv_file.is_open()) { + return true; + } + + std::filesystem::path dir {config::stream.metrics_path}; + std::error_code ec; + std::filesystem::create_directories(dir, ec); + if (ec) { + BOOST_LOG(error) << "metrics_path: failed to create directory "sv << dir << ": "sv << ec.message(); + return false; + } + + auto path = dir / make_metrics_csv_filename(session->launch_session_id, wall_ms); + session->metrics.csv_file.open(path); + if (!session->metrics.csv_file.is_open()) { + BOOST_LOG(error) << "metrics_path: failed to open "sv << path; + return false; + } + + BOOST_LOG(info) << "metrics: writing session metrics to "sv << path; + session->metrics.csv_file << metrics_csv_header() << '\n'; + return true; } /** @@ -1058,36 +1089,16 @@ namespace stream { auto wall_ms = std::chrono::duration_cast(wall_now).count(); auto row = format_metrics_csv_row( - payload, - wall_ms, - session->launch_session_id, - session->config.monitor.bitrate, - session->metrics.idr_count + payload, wall_ms, session->launch_session_id, + session->config.monitor.bitrate, session->metrics.idr_count ); if (row.empty()) { BOOST_LOG(warning) << "SS_FRAME_FEC_STATUS payload too small: " << payload.size(); return; } - if (!session->metrics.csv_file.is_open()) { - std::filesystem::path dir {config::stream.metrics_path}; - std::error_code ec; - std::filesystem::create_directories(dir, ec); - if (ec) { - BOOST_LOG(error) << "metrics_path: failed to create directory "sv << dir << ": "sv << ec.message(); - return; - } - - auto path = dir / make_metrics_csv_filename(session->launch_session_id, wall_ms); - - session->metrics.csv_file.open(path); - if (!session->metrics.csv_file.is_open()) { - BOOST_LOG(error) << "metrics_path: failed to open "sv << path; - return; - } - - BOOST_LOG(info) << "metrics: writing session metrics to "sv << path; - session->metrics.csv_file << metrics_csv_header() << '\n'; + if (!ensure_metrics_csv_open(session, wall_ms)) { + return; } session->metrics.csv_file << row << '\n'; diff --git a/tests/unit/test_stream.cpp b/tests/unit/test_stream.cpp index 2a3a7a3859e..28e7ba1183e 100644 --- a/tests/unit/test_stream.cpp +++ b/tests/unit/test_stream.cpp @@ -49,40 +49,42 @@ TEST(ConcatAndInsertTests, ConcatSmallStrideTest) { } namespace { + struct fec_status_values_t { + uint32_t frame_index = 0; + uint16_t highest_received = 0; + uint16_t next_contiguous = 0; + uint16_t missing = 0; + uint16_t total_data = 0; + uint16_t total_parity = 0; + uint16_t received_data = 0; + uint16_t received_parity = 0; + uint8_t fec_percentage = 0; + uint8_t multi_fec_index = 0; + uint8_t multi_fec_count = 0; + }; + // Builds a 21-byte SS_FRAME_FEC_STATUS payload in the wire (big-endian) format. - std::array build_fec_status_payload( - uint32_t frame_index, - uint16_t highest_received, - uint16_t next_contiguous, - uint16_t missing, - uint16_t total_data, - uint16_t total_parity, - uint16_t received_data, - uint16_t received_parity, - uint8_t fec_percentage, - uint8_t multi_fec_index, - uint8_t multi_fec_count - ) { - auto put_u16_be = [](std::array &out, size_t off, uint16_t v) { - out[off] = static_cast((v >> 8) & 0xFF); - out[off + 1] = static_cast(v & 0xFF); + std::array build_fec_status_payload(const fec_status_values_t &v) { + auto put_u16_be = [](std::array &out, size_t off, uint16_t value) { + out[off] = static_cast((value >> 8) & 0xFF); + out[off + 1] = static_cast(value & 0xFF); }; std::array payload {}; - payload[0] = static_cast((frame_index >> 24) & 0xFF); - payload[1] = static_cast((frame_index >> 16) & 0xFF); - payload[2] = static_cast((frame_index >> 8) & 0xFF); - payload[3] = static_cast(frame_index & 0xFF); - put_u16_be(payload, 4, highest_received); - put_u16_be(payload, 6, next_contiguous); - put_u16_be(payload, 8, missing); - put_u16_be(payload, 10, total_data); - put_u16_be(payload, 12, total_parity); - put_u16_be(payload, 14, received_data); - put_u16_be(payload, 16, received_parity); - payload[18] = fec_percentage; - payload[19] = multi_fec_index; - payload[20] = multi_fec_count; + payload[0] = static_cast((v.frame_index >> 24) & 0xFF); + payload[1] = static_cast((v.frame_index >> 16) & 0xFF); + payload[2] = static_cast((v.frame_index >> 8) & 0xFF); + payload[3] = static_cast(v.frame_index & 0xFF); + put_u16_be(payload, 4, v.highest_received); + put_u16_be(payload, 6, v.next_contiguous); + put_u16_be(payload, 8, v.missing); + put_u16_be(payload, 10, v.total_data); + put_u16_be(payload, 12, v.total_parity); + put_u16_be(payload, 14, v.received_data); + put_u16_be(payload, 16, v.received_parity); + payload[18] = v.fec_percentage; + payload[19] = v.multi_fec_index; + payload[20] = v.multi_fec_count; return payload; } } // namespace @@ -104,19 +106,19 @@ TEST(MetricsCsvTests, HeaderHasAllColumns) { } TEST(MetricsCsvTests, RowDecodesBigEndianFields) { - auto payload = build_fec_status_payload( - /*frame_index=*/0x12345678u, // 305419896 - /*highest_received=*/100, - /*next_contiguous=*/95, - /*missing=*/5, - /*total_data=*/50, - /*total_parity=*/10, - /*received_data=*/45, - /*received_parity=*/9, - /*fec_percentage=*/20, - /*multi_fec_index=*/0, - /*multi_fec_count=*/1 - ); + auto payload = build_fec_status_payload({ + .frame_index = 0x12345678u, // 305419896 + .highest_received = 100, + .next_contiguous = 95, + .missing = 5, + .total_data = 50, + .total_parity = 10, + .received_data = 45, + .received_parity = 9, + .fec_percentage = 20, + .multi_fec_index = 0, + .multi_fec_count = 1, + }); auto row = stream::format_metrics_csv_row( std::string_view {reinterpret_cast(payload.data()), payload.size()}, @@ -142,7 +144,7 @@ TEST(MetricsCsvTests, RowReturnsEmptyOnShortPayload) { } TEST(MetricsCsvTests, RowEmbedsSessionMetadata) { - auto payload = build_fec_status_payload(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + auto payload = build_fec_status_payload({}); auto row = stream::format_metrics_csv_row( std::string_view {reinterpret_cast(payload.data()), payload.size()},