// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari #include "export.hpp" #include #include #include #include #include #include #include #include namespace udpscope { namespace { // The writers memcpy `f64`/`f32` straight out and label them `= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_'; out.push_back(ok ? ch : '_'); } if (out.empty()) out = "col"; return out; } /// `base` carrying a `_2`, `_3` … suffix for the `n`-th copy (`n == 1` is the name itself), /// cut to `max_len` bytes when one is given -- MDF's name field is 32 bytes wide and the file /// is re-read by a reader that keeps only the *first* occurrence of a name, so a cut that made /// two channels equal would silently lose one. std::string numbered(const std::string& base, int n, std::size_t max_len) { const std::string suffix = n == 1 ? std::string() : "_" + std::to_string(n); std::size_t room = base.size(); if (max_len != 0) room = max_len > suffix.size() ? max_len - suffix.size() : 0; return base.substr(0, room) + suffix; } /// The final labels: sanitised, and de-duplicated against each other *and* against /// `taken` -- the names the writer already spends -- with a `_2`, `_3` suffix. Two /// elements of one vector signal can easily sanitise to the same string. std::vector column_labels(const ExportTable& table, std::vector taken, std::size_t max_len = 0) { std::vector out; out.reserve(table.columns.size()); for (const ExportTable::Column& col : table.columns) { const std::string base = sanitise(col.name); std::string label = numbered(base, 1, max_len); for (int n = 2; std::find(taken.begin(), taken.end(), label) != taken.end(); ++n) { label = numbered(base, n, max_len); } taken.push_back(label); out.push_back(std::move(label)); } return out; } struct File { explicit File(const std::string& path) : f(std::fopen(path.c_str(), "wb")) {} ~File() { if (f != nullptr) std::fclose(f); } File(const File&) = delete; File& operator=(const File&) = delete; std::FILE* f = nullptr; }; bool opened(const File& out, const std::string& path, std::string& error) { if (out.f != nullptr) return true; error = "cannot write '" + path + "': " + std::strerror(errno); return false; } /// The block lengths of MDF 3.30, from the **reader's** own field lists /// (`asammdf/blocks/v2_v3_constants.py`, 8.8.27): every block is a two-letter id, a 16-bit /// length and then its own fields, and the reader walks the file by those lengths alone, so a /// block written one field too wide is a file whose reader lands in the middle of the next one. /// The widths here are the ones a `bytes(block)` of the reference file measures (the known-good /// 3.30 file `/tmp/ref.mdf` this writer was byte-diffed against). constexpr std::size_t kMdfIdBlock = 64; // "MDF 3.30" and the flags, no id/length constexpr std::size_t kMdfHeader = 208; // 164 common + 44 of the 3.2-era time fields constexpr std::size_t kMdfTxOverhead = 5; // "TX", the u16 length and the terminating NUL constexpr std::size_t kMdfDataGroup = 28; // 4 addresses, the group count, 4 reserved constexpr std::size_t kMdfConversion = 46; // a NONE conversion is the common fields alone constexpr std::size_t kMdfChannel = 228; // the display-name-length CNBLOCK constexpr std::size_t kMdfChannelGroup = 30; // 26 + the sample-reduction address of 3.30 constexpr std::size_t kMdfNameBytes = 31; // a CNBLOCK's 32-byte name field, less its NUL /// A byte sink for the MDF header blocks, which are a few hundred bytes each and have to be /// laid out field by field. The little-endian `raw()` writes are the same assumption the `.npy` /// writer states at the top of this file, and `static_assert`s there. struct Bytes { std::vector v; void raw(const void* p, std::size_t n) { const auto* b = static_cast(p); v.insert(v.end(), b, b + n); } void u16(std::uint16_t x) { const unsigned char b[2] = {static_cast(x & 0xFFu), static_cast((x >> 8) & 0xFFu)}; raw(b, sizeof(b)); } void i16(std::int16_t x) { u16(static_cast(x)); } void u32(std::uint32_t x) { const unsigned char b[4] = {static_cast(x & 0xFFu), static_cast((x >> 8) & 0xFFu), static_cast((x >> 16) & 0xFFu), static_cast((x >> 24) & 0xFFu)}; raw(b, sizeof(b)); } void u64(std::uint64_t x) { u32(static_cast(x & 0xFFFFFFFFu)); u32(static_cast(x >> 32)); } void f64(double x) { raw(&x, sizeof(x)); } void f32(float x) { raw(&x, sizeof(x)); } void zeros(std::size_t n) { v.insert(v.end(), n, 0); } /// A fixed-width text field: the bytes, then NUL padding. MDF's names and units are /// NUL-terminated inside a field of a width the reader is told by the block size, so this is /// how every string in the format is spelled. void text(const std::string& s, std::size_t width) { const std::size_t n = std::min(s.size(), width); raw(s.data(), n); zeros(width - n); } std::size_t size() const noexcept { return v.size(); } }; /// Checked rather than trusted: a field written at the wrong width would not fail here, it /// would produce a file whose reader walks into the middle of the next block. The widths are /// the asammdf 8.8.27 reader's own field lists (`blocks/v2_v3_constants.py`). bool mdf_block_size(const Bytes& block, std::size_t want, const char* what, std::string& error) { if (block.size() == want) return true; error = std::string("internal: the MDF ") + what + " block is " + std::to_string(block.size()) + " bytes, the layout wants " + std::to_string(want); return false; } } // namespace ExportTable collect_export(ScopeModel& model, RowSet& rows) { ExportTable table; const RowSet::Row& r0 = rows.row(0); if (r0.plot == nullptr) return table; const plot::Range view = r0.plot->x_range(r0.axis_x); // Which channels: every bound slot on a visible row, de-duplicated, then sorted. A // signal dropped on two rows is one column, and channel order is the signal list's // order, so the columns read in the same order as the panel. std::vector chans; for (int i = 0; i < rows.row_count(); ++i) { for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { const int c = rows.channel_of(i, s); if (c >= 0 && std::find(chans.begin(), chans.end(), c) == chans.end()) { chans.push_back(c); } } } std::sort(chans.begin(), chans.end()); if (chans.empty()) return table; const std::vector& infos = model.channels(); const auto name_of = [&](int c) { return static_cast(c) < infos.size() ? infos[static_cast(c)].name : std::string("ch") + std::to_string(c); }; // The unit travels with the column so the MDF writer can put it on the channel; the other // writers ignore it, as they always have. const auto unit_of = [&](int c) { return static_cast(c) < infos.size() ? infos[static_cast(c)].unit : std::string(); }; // What the rows bind to is what gets exported, so the split is the same `DisplayMode` // `RowSet::bind()` switches on: the frozen event only while one is held, the live ring // otherwise -- including mid-capture, where the rows follow the live ring too. Reading // the ring while an event is on screen would export data the user cannot see, and // reading the stale snapshot mid-capture would export the *previous* event. // What is on screen: a pause's frozen view, or the trigger's held event. Both are immutable // snapshots, so the export is the picture the user is looking at either way. std::shared_ptr snap = model.display_snapshot(); // The absolute clock of display x = 0, so `abs_base + time[i]` is the number the stream // stamped sample i with -- **one expression, correct in all four display modes**, which is the // whole reason `stream_at_x0()` exists. `epoch()` is the stream time of the first sample ever // seen (the rings store stamps relative to it) and `stream_at_x0()` is the offset of the // display's origin within that, whether it comes from a snapshot or from the live epoch. The // snapshot branch used to add `trigger_time()` here, which named an unrelated edge on a // *paused* export, or none at all before the first latch. const f64 abs_base = model.epoch() + model.stream_at_x0(); if (snap != nullptr) { // The snapshot is immutable and already index-aligned, so the visible window is two // binary searches over its stamps and the same index range for every channel. const std::vector& st = snap->stamps; const auto lo = std::lower_bound(st.begin(), st.end(), view.min); const auto hi = std::upper_bound(st.begin(), st.end(), view.max); if (hi <= lo) return table; const std::size_t first = static_cast(lo - st.begin()); const std::size_t n = static_cast(hi - lo); table.time.resize(n); table.abs_seconds.resize(n); for (std::size_t i = 0; i < n; ++i) { table.time[i] = st[first + i]; table.abs_seconds[i] = snap->abs_stamps.empty() ? abs_base + table.time[i] : snap->abs_stamps[first + i]; } for (const int c : chans) { const std::size_t ch = static_cast(c); if (ch >= snap->values.size()) continue; const std::vector& src = snap->values[ch]; if (src.size() < first + n) continue; table.columns.push_back({name_of(c), unit_of(c), std::vector(src.begin() + static_cast(first), src.begin() + static_cast(first + n))}); } return table; } // Live: one absolute index range over the shared time ring, used for every channel -- // `live_window_locked()` is the same cut a latch makes, so an export and a capture of // the same window hold the same samples. The lock is against a structural change // dropping the rings under the copy, which is what the UI thread has to fear here. std::lock_guard lock(model.mutex()); // The axis reads `stamp - stream_at_x0()`, so the window has to be put back on the stream's // clock before the ring is searched -- and that offset is a whole window while streaming now, // not the 0 it once was: the live axis is `[-window, 0]` about the newest sample. const f64 s0 = model.stream_at_x0(); u64 first = 0, last = 0; model.live_window_locked(view.min + s0, view.max + s0, first, last); const u64 n = last - first; if (n == 0) return table; BufferStore& store = model.live(); std::vector stamps(static_cast(n)); store.time().copy_from(first, n, stamps.data()); table.time.resize(static_cast(n)); table.abs_seconds.resize(static_cast(n)); std::vector abs_stamps; if (const plot::data::RingBuffer* abs_ring = store.abs_time(); abs_ring != nullptr) { abs_stamps.resize(static_cast(n)); abs_ring->copy_from(first, n, abs_stamps.data()); } for (std::size_t i = 0; i < stamps.size(); ++i) { table.time[i] = stamps[i] - s0; table.abs_seconds[i] = abs_stamps.empty() ? abs_base + table.time[i] : model.epoch() + abs_stamps[i]; } for (const int c : chans) { const std::size_t ch = static_cast(c); if (ch >= model.channel_count()) continue; std::vector values(static_cast(n)); store.values(ch).copy_from(first, n, values.data()); table.columns.push_back({name_of(c), unit_of(c), std::move(values)}); } return table; } ExportTable event_table(ScopeModel& model, const EventSnapshot& snapshot) { ExportTable table; table.time = snapshot.stamps; table.abs_seconds = snapshot.abs_stamps.empty() ? snapshot.stamps : snapshot.abs_stamps; std::lock_guard lock(model.mutex()); const std::vector& channels = model.channels(); for (std::size_t c = 0; c < snapshot.values.size() && c < channels.size(); ++c) { ExportTable::Column column; column.name = channels[c].name; column.unit = channels[c].unit; column.values = snapshot.values[c]; table.columns.push_back(std::move(column)); } return table; } bool write_npy(const std::string& path, const ExportTable& table, std::string& error) { if (table.empty()) { error = "nothing to export: no signal is plotted in the visible range"; return false; } const std::vector labels = column_labels(table, {"time", "abs_seconds"}); // `descr` as a list of (name, format) pairs is a *packed* structured dtype -- NumPy // only inserts alignment padding when asked to (`align=True`) -- so the records below // are field widths back to back with no gaps. std::string descr = "[('time', ' 0xFFFFu) { error = "too many columns for a v1.0 .npy header"; return false; } File out(path); if (!opened(out, path, error)) return false; const unsigned char len[2] = {static_cast(header.size() & 0xFFu), static_cast((header.size() >> 8) & 0xFFu)}; std::fwrite("\x93NUMPY\x01\x00", 1, 8, out.f); std::fwrite(len, 1, 2, out.f); std::fwrite(header.data(), 1, header.size(), out.f); // One record at a time, into a reused buffer: a column-major staging array would be a // second copy of the whole export, which at a full ring is tens of megabytes. std::vector record(2 * sizeof(f64) + table.columns.size() * sizeof(f32)); for (std::size_t i = 0; i < table.rows(); ++i) { std::size_t at = 0; std::memcpy(record.data() + at, &table.time[i], sizeof(f64)); at += sizeof(f64); std::memcpy(record.data() + at, &table.abs_seconds[i], sizeof(f64)); at += sizeof(f64); for (const ExportTable::Column& col : table.columns) { const f32 v = i < col.values.size() ? col.values[i] : 0.0f; std::memcpy(record.data() + at, &v, sizeof(f32)); at += sizeof(f32); } if (std::fwrite(record.data(), 1, record.size(), out.f) != record.size()) { error = "short write to '" + path + "': " + std::strerror(errno); return false; } } if (std::fflush(out.f) != 0) { error = "cannot flush '" + path + "': " + std::strerror(errno); return false; } return true; } bool write_csv(const std::string& path, const ExportTable& table, std::string& error) { if (table.empty()) { error = "nothing to export: no signal is plotted in the visible range"; return false; } File out(path); if (!opened(out, path, error)) return false; const std::vector labels = column_labels(table, {"time", "abs_seconds"}); std::string line = "time,abs_seconds"; for (const std::string& label : labels) line += "," + label; line += "\n"; std::fwrite(line.data(), 1, line.size(), out.f); // `%.17g` for the f64 times and `%.9g` for the f32 values: the shortest widths that // round-trip each type exactly. Anything less silently rewrites the samples, which is // the one thing an export may not do. char buf[64]; for (std::size_t i = 0; i < table.rows(); ++i) { line.clear(); std::snprintf(buf, sizeof(buf), "%.17g", table.time[i]); line += buf; std::snprintf(buf, sizeof(buf), "%.17g", table.abs_seconds[i]); line += ","; line += buf; for (const ExportTable::Column& col : table.columns) { std::snprintf(buf, sizeof(buf), "%.9g", double(i < col.values.size() ? col.values[i] : 0.0f)); line += ","; line += buf; } line += "\n"; if (std::fwrite(line.data(), 1, line.size(), out.f) != line.size()) { error = "short write to '" + path + "': " + std::strerror(errno); return false; } } if (std::fflush(out.f) != 0) { error = "cannot flush '" + path + "': " + std::strerror(errno); return false; } return true; } bool write_mdf(const std::string& path, const ExportTable& table, std::string& error) { if (table.empty()) { error = "nothing to export: no signal is plotted in the visible range"; return false; } const std::size_t rows = table.rows(); const std::size_t cols = table.columns.size(); // **The master is the absolute clock, named `time`**, and the axis the plot showed goes out as // an ordinary channel, `time_relative`. The relative column is 0 at a different sample in each // display mode, so a master off it would put a live export and a held event's export on two // timelines -- the one thing the second column exists to avoid -- while `abs_seconds` is the // number the stream itself stamped the samples with. Both carry the unit `s`. const std::vector labels = column_labels(table, {"time", "time_relative"}, kMdfNameBytes); const std::size_t channels = cols + 2; const std::size_t record = 2 * sizeof(f64) + cols * sizeof(f32); if (rows > 0xFFFFFFFFull) { error = "too many samples for an MDF 3 file: the channel group counts cycles in 32 bits"; return false; } if (128 + (cols - 1) * 32 > 0xFFFFu) { error = "too many signals for an MDF 3 record: a channel's start offset is 16 bits"; return false; } // One channel per column, described once: what the CNBLOCK says about it (where in the record // its samples start, how wide they are, whether it is the master) and what the CCBLOCK says // (its unit). The range is measured from the samples rather than left at zero, so // `range_flag = 1` means what it says. struct Channel { std::string name; std::string unit; std::uint16_t bit_offset; std::uint16_t bit_count; std::uint16_t data_type; // MDF 3: 2 = float, 3 = double, both in the ID block's byte order std::uint16_t type; // 0 = value, 1 = master f64 lo, hi; }; const auto range_of = [](const auto& values) { if (values.empty()) return std::pair{0.0, 0.0}; const auto mm = std::minmax_element(values.begin(), values.end()); return std::pair{static_cast(*mm.first), static_cast(*mm.second)}; }; std::vector chans; chans.reserve(channels); const auto abs_range = range_of(table.abs_seconds); const auto rel_range = range_of(table.time); chans.push_back({"time", "s", 0, 64, 3, 1, abs_range.first, abs_range.second}); chans.push_back({"time_relative", "s", 64, 64, 3, 0, rel_range.first, rel_range.second}); for (std::size_t i = 0; i < cols; ++i) { const auto range = range_of(table.columns[i].values); chans.push_back({labels[i], table.columns[i].unit, static_cast(128 + i * 32), 32, 2, 0, range.first, range.second}); } // Every address in the file, from the fixed block lengths above: the writer therefore knows // where it is going before it writes a byte, which is what lets each block carry its links. const std::string comment = "udpscope export: the visible window, " + std::to_string(rows) + " samples x " + std::to_string(cols) + " signals"; const std::size_t hd_end = kMdfIdBlock + kMdfHeader; const std::size_t comment_addr = hd_end; const std::size_t data_addr = comment_addr + kMdfTxOverhead + comment.size(); const std::size_t dg_addr = data_addr + rows * record; const std::size_t ch_addr = dg_addr + kMdfDataGroup; const std::size_t cg_addr = ch_addr + channels * (kMdfConversion + kMdfChannel); const std::uint64_t total = cg_addr + kMdfChannelGroup; if (total > 0xFFFFFFFFull) { error = "the export is larger than an MDF 3 file can address"; return false; } const auto cc_addr = [&](std::size_t i) { return ch_addr + i * (kMdfConversion + kMdfChannel); }; // The wall clock the measurement file was written at. `abs_time` is the same instant in // nanoseconds since the epoch, which is what the 3.2-era header field asks for. const std::time_t now = std::time(nullptr); std::tm tm{}; localtime_r(&now, &tm); char date[11] = {}; char clock[9] = {}; std::strftime(date, sizeof(date), "%d:%m:%Y", &tm); std::strftime(clock, sizeof(clock), "%H:%M:%S", &tm); Bytes head; // Identification block: the version string, who wrote the file, and the byte order every // sample in it is in. head.text("MDF ", 8); head.text("3.30", 8); head.text("udpscope", 8); head.u16(0); // byte order: Intel, ahead of every value and the float format below head.u16(0); // float format: IEEE head.u16(330); // MDF version, digits without the separator head.u16(0); // code page: latin-1 head.zeros(2); // reserved head.zeros(26); // reserved head.u16(0); // unfinalised standard flags head.u16(0); // unfinalised custom flags if (!mdf_block_size(head, kMdfIdBlock, "identification", error)) return false; // Header block, which points at the data group and at the comment that follows it. head.raw("HD", 2); head.u16(static_cast(kMdfHeader)); head.u32(static_cast(dg_addr)); // first data group head.u32(static_cast(comment_addr)); head.u32(0); // program block: nothing wrote this but us head.u16(1); // data groups head.text(date, 10); head.text(clock, 8); head.text("", 32); // author head.text("", 32); // department head.text("", 32); // project head.text("", 32); // subject head.u64(static_cast(now) * 1000000000ull); head.i16(static_cast(tm.tm_gmtoff / 3600)); head.u16(0); // time quality head.text("Local PC Reference Time", 32); if (!mdf_block_size(head, hd_end, "header", error)) return false; // The comment, in the one text block the file carries: plain words rather than the XML a // writer that also *edits* files keeps its structured properties in. head.raw("TX", 2); head.u16(static_cast(comment.size() + kMdfTxOverhead)); head.raw(comment.data(), comment.size()); head.zeros(1); Bytes tail; // Data group: one channel group, whose channels are chained, and the address of the samples. tail.raw("DG", 2); tail.u16(static_cast(kMdfDataGroup)); tail.u32(0); // next data group tail.u32(static_cast(cg_addr)); tail.u32(0); // trigger block tail.u32(static_cast(data_addr)); tail.u16(1); // channel groups tail.u16(0); // record IDs: the group is sorted tail.zeros(4); // reserved if (!mdf_block_size(tail, kMdfDataGroup, "data group", error)) return false; for (std::size_t i = 0; i < channels; ++i) { const Channel& c = chans[i]; // Conversion block: the unit lives here and nowhere else in MDF 3, which is where ScryLab // reads a signal's unit from. NONE because the samples are already the physical values -- // the table was collected as `f32`, so a conversion would only be a second copy of it. Bytes cc; cc.raw("CC", 2); cc.u16(static_cast(kMdfConversion)); cc.u16(1); // range_flag: the range below is measured, so it is valid cc.f64(c.lo); cc.f64(c.hi); cc.text(c.unit, 20); cc.u16(0xFFFFu); cc.u16(0); // referenced parameters if (!mdf_block_size(cc, kMdfConversion, "conversion", error)) return false; // Channel block: the name ScryLab turns into the signal name, where this channel's samples // sit in a record, and the link to the next channel. Bytes cn; cn.raw("CN", 2); cn.u16(static_cast(kMdfChannel)); cn.u32(i + 1 < channels ? static_cast(cc_addr(i + 1) + kMdfConversion) : 0); cn.u32(static_cast(cc_addr(i))); cn.u32(0); // source: the stream does not say what produced a sample cn.u32(0); // dependency block cn.u32(0); // comment cn.u16(c.type); cn.text(c.name, 32); cn.zeros(128); // description cn.u16(c.bit_offset); cn.u16(c.bit_count); cn.u16(c.data_type); cn.u16(1); // range_flag cn.f64(c.lo); cn.f64(c.hi); cn.f64(0.0); // sampling rate: only a virtual time channel carries one cn.u32(0); // long name cn.u32(0); // display name cn.u16(0); // additional byte offset if (!mdf_block_size(cn, kMdfChannel, "channel", error)) return false; tail.v.insert(tail.v.end(), cc.v.begin(), cc.v.end()); tail.v.insert(tail.v.end(), cn.v.begin(), cn.v.end()); } // Channel group, last: it names the first channel, the record size and the cycle count from // which a reader derives the size of the sample block at `data_addr`. tail.raw("CG", 2); tail.u16(static_cast(kMdfChannelGroup)); tail.u32(0); // next channel group tail.u32(static_cast(ch_addr + kMdfConversion)); tail.u32(0); // comment tail.u16(1); // record ID tail.u16(static_cast(channels)); tail.u16(static_cast(record)); tail.u32(static_cast(rows)); tail.zeros(4); // sample reduction address if (!mdf_block_size(tail, kMdfDataGroup + channels * (kMdfConversion + kMdfChannel) + kMdfChannelGroup, "data group and channels", error)) { return false; } File out(path); if (!opened(out, path, error)) return false; const auto put = [&](const Bytes& bytes) { return bytes.v.empty() || std::fwrite(bytes.v.data(), 1, bytes.v.size(), out.f) == bytes.v.size(); }; if (!put(head)) { error = "short write to '" + path + "': " + std::strerror(errno); return false; } // One record at a time, in the channel order the blocks above declare, so a full ring is not // held twice -- the same shape the `.npy` writer streams its records in. std::vector rec(record); for (std::size_t i = 0; i < rows; ++i) { std::size_t at = 0; std::memcpy(rec.data() + at, &table.abs_seconds[i], sizeof(f64)); at += sizeof(f64); std::memcpy(rec.data() + at, &table.time[i], sizeof(f64)); at += sizeof(f64); for (const ExportTable::Column& col : table.columns) { const f32 v = i < col.values.size() ? col.values[i] : 0.0f; std::memcpy(rec.data() + at, &v, sizeof(f32)); at += sizeof(f32); } if (std::fwrite(rec.data(), 1, rec.size(), out.f) != rec.size()) { error = "short write to '" + path + "': " + std::strerror(errno); return false; } } if (!put(tail)) { error = "short write to '" + path + "': " + std::strerror(errno); return false; } if (std::fflush(out.f) != 0) { error = "cannot flush '" + path + "': " + std::strerror(errno); return false; } return true; } bool write_export(const std::string& path, const ExportTable& table, std::string& error) { const auto ends_with = [&path](const char* ext) { const std::size_t n = std::strlen(ext); return path.size() >= n && path.compare(path.size() - n, n, ext) == 0; }; if (ends_with(".csv")) return write_csv(path, table, error); if (ends_with(".mf3") || ends_with(".mdf")) return write_mdf(path, table, error); return write_npy(path, table, error); } } // namespace udpscope