From 66f45286706795fccf58dd60a1419df8cb50e508 Mon Sep 17 00:00:00 2001 From: Alex B <142788550+A13xB0@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:31:55 +0000 Subject: [PATCH 1/2] radioserver: build for Windows, and over TCP everywhere The radio model sits between an emulated MCU and the engine, and every emulated node needs one - so a bundle without it ships two emulators that cannot be driven. This file could not be built for Windows: it included unconditionally and used read/write/poll on socket handles, none of which mingw has. Confined to the same five-line compat block bridge/main.cpp already uses, plus WSAPoll for waiting on two sockets. Windows takes the TCP half only - its AF_UNIX is unreachable without , and the simulator already asks for ":0" there for both emulators - and says so rather than listening nowhere. Includes the TCP transport and the console-message fix from #3, which this supersedes. --- bridge/radioserver.cpp | 513 +++++++++++++++++++++++++++++++++-------- 1 file changed, 415 insertions(+), 98 deletions(-) diff --git a/bridge/radioserver.cpp b/bridge/radioserver.cpp index 964fc26..367ddbe 100644 --- a/bridge/radioserver.cpp +++ b/bridge/radioserver.cpp @@ -1,51 +1,117 @@ -// The radio model, offered on a socket for an emulated MCU to clock. +// The radio model, between an emulated MCU and the RF engine. // -// A native node calls VirtualSX1262 in process, through SimHal. An emulated one -// cannot: the firmware is inside QEMU, and its SPI controller reaches out over -// a socket to whatever is modelling the chip. This is that end. +// A native node reaches VirtualSX1262 in process through SimHal. An emulated one +// cannot: its firmware is inside QEMU, so the chip has to sit outside and be +// reachable from both sides at once. // -// It is deliberately the same chip object either way. Writing a second model -// for the emulated path would give two things to keep in agreement, and the -// first time they drifted every comparison between a native node and an -// emulated one would be measuring our own code rather than MeshCore's. +// QEMU --- SPI transactions ---> this --- frames and ticks ---> engine // -// The protocol is the one QEMU's sx1262 device speaks, and it is small because -// it sits on the hot path of every SPI byte: +// Deliberately the same chip object as the native path. A second model would be +// a second thing to keep in agreement, and the first time the two drifted every +// comparison between a native node and an emulated one would be measuring our +// own code rather than MeshCore's. // -// 0x01 chip select asserted -> beginTransaction() -// 0x02 chip select released -> endTransaction() -// 0x03 one byte out, one back -> transferByte() -// 0x04 read the BUSY line -> one byte, 0 or 1 +// One thing is different from a native node and it matters. There, the bridge +// owns the firmware's execution: a tick runs loop() a millisecond at a time and +// nothing else happens in between. Here the firmware runs inside an emulator on +// its own schedule, so SPI transactions arrive whenever QEMU feels like it, +// while ticks arrive from the engine. Both mutate the chip, so both take a lock, +// and the ordering between them is not reproducible the way a native node's is. +// See the note at the bottom. // // Usage: -// radioserver /run/user/1000/meshbench-radio-7.sock +// radioserver [--bridge host:port] #include "VirtualSX1262.h" +// Sockets, on the three families of desktop this ships for. The same five-line +// confinement bridge/main.cpp uses, for the same reason: Winsock needs +// initialising, its handles are not file descriptors, and closesocket is not +// close. +// +// Two things here are not in the bridge. Waiting on two sockets at once is +// WSAPoll on Windows and poll everywhere else - same structure, different +// name - and Unix domain sockets exist only on the POSIX side, which is why +// the transport is chosen by the address rather than compiled in. +#ifdef _WIN32 + #include + #include + using sock_t = SOCKET; + #define BAD_SOCK INVALID_SOCKET + #define CLOSE_SOCK closesocket + using pollfd_t = WSAPOLLFD; + static int pollSockets(pollfd_t* f, int n, int t) { return WSAPoll(f, n, t); } + static int sockRead(sock_t s, void* p, size_t n) { return recv(s, (char*)p, (int)n, 0); } + static int sockWrite(sock_t s, const void* p, size_t n) { return send(s, (const char*)p, (int)n, 0); } + using sockopt_t = char; + using socklen_compat_t = int; +#else + #include + #include + #include + #include + #include + #include + #include + #include + using sock_t = int; + #define BAD_SOCK (-1) + #define CLOSE_SOCK close + using pollfd_t = struct pollfd; + static int pollSockets(pollfd_t* f, int n, int t) { return poll(f, (nfds_t)n, t); } + static int sockRead(sock_t s, void* p, size_t n) { return (int)read(s, p, n); } + static int sockWrite(sock_t s, const void* p, size_t n) { return (int)write(s, p, n); } + using sockopt_t = int; + using socklen_compat_t = socklen_t; +#endif + #include #include #include #include +#include #include - -#include -#include -#include +#include namespace { +// The QEMU side. Small because it is on the hot loop of every SPI byte. enum : uint8_t { kCsAssert = 0x01, kCsRelease = 0x02, kXfer = 0x03, kReadBusy = 0x04, + // Whether DIO1 is asserted. QEMU never needed this - the ESP32 firmware + // polls the chip's IRQ register over SPI - but an nRF52 waits on the pin, + // and a pin nothing drives is a node that configures its radio and then + // sits there for ever. + kReadIrq = 0x05, }; -// Read exactly n bytes, or say the peer has gone. -bool readAll(int fd, void* buf, size_t n) { +// The engine side, shared with the simulator's Go half and with bridge/main.cpp. +constexpr uint8_t kFrame = 0x01; +constexpr uint8_t kTick = 0x02; +constexpr uint8_t kAck = 0x03; +constexpr uint8_t kTxDone = 0x04; +// Console traffic reaches an emulated node over the emulator's own serial +// port, so these two are named here only to be ignored deliberately rather +// than to fall through to the unknown-message path. +constexpr uint8_t kConsoleIn = 0x06; +constexpr uint8_t kChannelBusy = 0x08; +constexpr uint8_t kRadioStats = 0x09; + +VirtualSX1262 gChip; +// MESHCORE_RADIO_TRACE=1 logs every SPI transaction. Off by default: this is +// on the hot path of every byte. +const bool gTracing = getenv("MESHCORE_RADIO_TRACE") != nullptr; +std::vector gTrace; +std::mutex gChipMu; // QEMU and the engine both reach the chip +uint32_t gSimMillis = 0; + +bool readAll(sock_t fd, void* buf, size_t n) { auto* p = static_cast(buf); while (n > 0) { - ssize_t got = ::read(fd, p, n); + int got = sockRead(fd, p, n); if (got <= 0) return false; p += got; n -= (size_t)got; @@ -53,10 +119,10 @@ bool readAll(int fd, void* buf, size_t n) { return true; } -bool writeAll(int fd, const void* buf, size_t n) { - auto* p = static_cast(buf); +bool writeAll(sock_t fd, const void* buf, size_t n) { + const auto* p = static_cast(buf); while (n > 0) { - ssize_t put = ::write(fd, p, n); + int put = sockWrite(fd, p, n); if (put <= 0) return false; p += put; n -= (size_t)put; @@ -64,112 +130,363 @@ bool writeAll(int fd, const void* buf, size_t n) { return true; } +bool writeMsg(sock_t fd, uint8_t kind, const uint8_t* p, size_t n) { + uint8_t hdr[3] = {kind, (uint8_t)(n >> 8), (uint8_t)n}; + if (!writeAll(fd, hdr, 3)) return false; + return n == 0 || writeAll(fd, p, n); +} + +// Anything the firmware handed its radio goes out to the engine now. +// +// Transmission reaches the channel immediately and is *not* immediately +// complete: the chip stays in transmit until the engine sends kTxDone, exactly +// as a native node does, because that is what stops a node talking over itself. +void drainTx(sock_t bridgeFd) { + if (bridgeFd == BAD_SOCK || !gChip.hasPendingTx) return; + gChip.hasPendingTx = false; + writeMsg(bridgeFd, kFrame, gChip.pendingTx.data(), gChip.pendingTx.size()); +} + +sock_t dialBridge(const std::string& addr) { + auto colon = addr.rfind(':'); + if (colon == std::string::npos) { + fprintf(stderr, "radioserver: --bridge wants host:port, got %s\n", addr.c_str()); + return BAD_SOCK; + } + std::string host = addr.substr(0, colon), port = addr.substr(colon + 1); + + addrinfo hints{}; + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + addrinfo* res = nullptr; + if (getaddrinfo(host.c_str(), port.c_str(), &hints, &res) != 0) { + fprintf(stderr, "radioserver: cannot resolve %s\n", addr.c_str()); + return BAD_SOCK; + } + sock_t fd = BAD_SOCK; + for (addrinfo* a = res; a; a = a->ai_next) { + fd = ::socket(a->ai_family, a->ai_socktype, a->ai_protocol); + if (fd == BAD_SOCK) continue; + if (::connect(fd, a->ai_addr, (socklen_compat_t)a->ai_addrlen) == 0) break; + CLOSE_SOCK(fd); + fd = BAD_SOCK; + } + freeaddrinfo(res); + if (fd == BAD_SOCK) { + fprintf(stderr, "radioserver: cannot reach the engine at %s\n", addr.c_str()); + return BAD_SOCK; + } + // Frames are small and latency is the whole game here: a tick that waits on + // Nagle is a node that answers late for no reason. + sockopt_t one = 1; + ::setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, (const char*)&one, sizeof(one)); + return fd; +} + +// One message from the engine. +bool serviceBridge(sock_t fd) { + uint8_t hdr[3]; + if (!readAll(fd, hdr, 3)) return false; + const uint8_t kind = hdr[0]; + const size_t n = ((size_t)hdr[1] << 8) | hdr[2]; + std::vector payload(n); + if (n && !readAll(fd, payload.data(), n)) return false; + + std::lock_guard lock(gChipMu); + switch (kind) { + case kFrame: + // A packet the channel delivered. Only CRC-passing frames arrive here, + // exactly as on hardware: everything else was recorded and withheld. + gChip.inbox.push_back(std::move(payload)); + break; + + case kTxDone: + gChip.transmitFinished(); + break; + + case kChannelBusy: + if (n >= 1) gChip.setChannelBusy(payload[0] != 0); + break; + + case kTick: { + if (n != 4) break; + uint32_t at = ((uint32_t)payload[0] << 24) | ((uint32_t)payload[1] << 16) | + ((uint32_t)payload[2] << 8) | payload[3]; + // A millisecond at a time, as a native node does. Stepping rather than + // jumping is what keeps the chip's own timeouts behaving: a preamble flag + // that should clear after 66 ms does not, if time arrives in 500 ms + // lumps. + while (gSimMillis < at) { + gSimMillis++; + gChip.tick(gSimMillis); + drainTx(fd); + } + gChip.tick(gSimMillis); + drainTx(fd); + + uint32_t st[4] = {gChip.irqReads(), gChip.busyReads(), gChip.busyMs(), + gChip.spuriousRaises()}; + uint8_t sb[16]; + for (int k = 0; k < 4; k++) { + sb[k * 4 + 0] = (uint8_t)(st[k] >> 24); + sb[k * 4 + 1] = (uint8_t)(st[k] >> 16); + sb[k * 4 + 2] = (uint8_t)(st[k] >> 8); + sb[k * 4 + 3] = (uint8_t)st[k]; + } + writeMsg(fd, kRadioStats, sb, sizeof(sb)); + if (!writeMsg(fd, kAck, payload.data(), 4)) return false; + break; + } + + case kConsoleIn: + // Console input belongs to the firmware's serial port, and an emulated + // node's serial port is the emulator's, not this socket. Ignoring it is + // the whole handling: what matters is that it is not fatal. Treating it + // as unknown killed the radio model the moment anything typed at the + // fleet, and the node then reported "radio init failed: -2" - chip not + // found - which points at wiring rather than at a console. + break; + + default: + // Skipped rather than fatal. The framing is length-prefixed and the + // payload has already been read, so an unrecognised kind costs nothing + // and cannot desynchronise the stream - whereas exiting takes the node + // down for a message it did not need. + fprintf(stderr, "radioserver: ignoring engine message 0x%02x (%zu bytes)\n", + kind, n); + break; + } + return true; +} + +// One message from the emulator. +bool serviceQemu(sock_t fd, uint64_t* transactions, uint64_t* bytes) { + uint8_t tag = 0; + if (!readAll(fd, &tag, 1)) return false; + + std::lock_guard lock(gChipMu); + switch (tag) { + case kCsAssert: + gChip.beginTransaction(); + gTrace.clear(); + return true; + + case kCsRelease: + gChip.endTransaction(); + (*transactions)++; + // One line per SPI transaction, opcode first. The point is comparison: + // the same chip serves a native node, an emulated ESP32 and an emulated + // nRF52, so when one of them fails to bring its radio up, a diff of the + // three traces says which command got an answer it did not like. + if (gTracing && !gTrace.empty()) { + fprintf(stderr, "spi:"); + for (size_t i = 0; i < gTrace.size() && i < 24; i++) { + fprintf(stderr, " %02x", gTrace[i]); + } + if (gTrace.size() > 24) fprintf(stderr, " ...(%zu)", gTrace.size()); + fprintf(stderr, "\n"); + fflush(stderr); + } + return true; + + case kXfer: { + uint8_t out = 0; + if (!readAll(fd, &out, 1)) return false; + uint8_t in = gChip.transferByte(out); + (*bytes)++; + if (gTracing) gTrace.push_back(out); + return writeAll(fd, &in, 1); + } + + case kReadIrq: { + uint8_t irq = gChip.irqAsserted() ? 1 : 0; + return writeAll(fd, &irq, 1); + } + + case kReadBusy: { + // Always clear, which is what the native path does too: SimHal holds BUSY + // low and VirtualSX1262 does not model the time a real chip spends + // digesting a command. Answering differently here would make an emulated + // node a different radio from a native one, which is the one thing this + // whole arrangement exists to avoid. + uint8_t busy = 0; + return writeAll(fd, &busy, 1); + } + + default: + fprintf(stderr, "radioserver: unknown emulator tag 0x%02x\n", tag); + return false; + } +} + } // namespace int main(int argc, char** argv) { if (argc < 2) { - fprintf(stderr, "usage: %s \n", argv[0]); + fprintf(stderr, "usage: %s [--bridge host:port]\n", argv[0]); return 2; } const char* path = argv[1]; - - // A broken pipe is an emulator that has exited, which is ordinary. Let the - // read fail and tidy up rather than dying on a signal. - ::signal(SIGPIPE, SIG_IGN); - - ::unlink(path); - - int srv = ::socket(AF_UNIX, SOCK_STREAM, 0); - if (srv < 0) { - perror("socket"); - return 1; + std::string bridgeAddr; + for (int i = 2; i < argc - 1; i++) { + if (strcmp(argv[i], "--bridge") == 0) bridgeAddr = argv[i + 1]; } - sockaddr_un addr{}; - addr.sun_family = AF_UNIX; - if (strlen(path) >= sizeof(addr.sun_path)) { - fprintf(stderr, "radioserver: socket path too long: %s\n", path); - return 1; - } - strncpy(addr.sun_path, path, sizeof(addr.sun_path) - 1); - if (::bind(srv, (sockaddr*)&addr, sizeof(addr)) < 0) { - perror("bind"); +#ifdef _WIN32 + WSADATA wsa; + if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) { + fprintf(stderr, "radioserver: Winsock will not start\n"); return 1; } - if (::listen(srv, 1) < 0) { - perror("listen"); - return 1; +#else + // A broken pipe is an emulator that has exited, which is ordinary. Let the + // read fail and tidy up rather than dying on a signal. Windows has no + // SIGPIPE: a send to a closed socket returns an error there, which is the + // behaviour this is asking for. + ::signal(SIGPIPE, SIG_IGN); +#endif + + // Two ways in, because there are two emulators. QEMU is native and takes a + // Unix socket; Renode runs on Mono, whose Unix domain socket support has been + // unreliable for long enough that betting an emulated node on it is a poor + // trade for one path separator. A leading colon asks for TCP on loopback. + // + // Windows has only the TCP half. Its own AF_UNIX exists but mingw has no + // to reach it with, and the simulator already asks for ":0" on + // Windows for both emulators - so the missing half is unreachable rather + // than merely untested. Saying so beats a build that silently listens + // nowhere. + const bool useTcp = path[0] == ':'; + sock_t srv = BAD_SOCK; + if (useTcp) { + const int port = atoi(path + 1); + srv = ::socket(AF_INET, SOCK_STREAM, 0); + if (srv == BAD_SOCK) { + fprintf(stderr, "radioserver: cannot make a socket\n"); + return 1; + } + sockopt_t on = 1; + ::setsockopt(srv, SOL_SOCKET, SO_REUSEADDR, (const char*)&on, sizeof(on)); + sockaddr_in in{}; + in.sin_family = AF_INET; + in.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + in.sin_port = htons((uint16_t)port); + if (::bind(srv, (sockaddr*)&in, sizeof(in)) != 0) { + fprintf(stderr, "radioserver: cannot bind %s\n", path); + return 1; + } + if (::listen(srv, 1) != 0) { + fprintf(stderr, "radioserver: cannot listen on %s\n", path); + return 1; + } + // The chosen port is printed because port 0 means "any", which is what a + // harness starting several nodes at once wants: it reads the number back + // rather than picking one and hoping. + socklen_compat_t len = sizeof(in); + if (::getsockname(srv, (sockaddr*)&in, &len) == 0) { + printf("radioserver: listening on 127.0.0.1:%d\n", ntohs(in.sin_port)); + } + } else { +#ifdef _WIN32 + fprintf(stderr, "radioserver: this build takes ':port' and not a socket " + "path (%s): Windows reaches both emulators over TCP\n", path); + return 2; +#else + ::unlink(path); + srv = ::socket(AF_UNIX, SOCK_STREAM, 0); + if (srv == BAD_SOCK) { + perror("socket"); + return 1; + } + sockaddr_un addr{}; + addr.sun_family = AF_UNIX; + if (strlen(path) >= sizeof(addr.sun_path)) { + fprintf(stderr, "radioserver: socket path too long: %s\n", path); + return 1; + } + strncpy(addr.sun_path, path, sizeof(addr.sun_path) - 1); + if (::bind(srv, (sockaddr*)&addr, sizeof(addr)) < 0) { + perror("bind"); + return 1; + } + if (::listen(srv, 1) < 0) { + perror("listen"); + return 1; + } + printf("radioserver: listening on %s\n", path); +#endif } - printf("radioserver: listening on %s\n", path); fflush(stdout); - VirtualSX1262 chip; + sock_t bridgeFd = BAD_SOCK; + if (!bridgeAddr.empty()) { + bridgeFd = dialBridge(bridgeAddr); + if (bridgeFd == BAD_SOCK) return 1; + printf("radioserver: joined the engine at %s\n", bridgeAddr.c_str()); + fflush(stdout); + } else { + // Worth saying. Without the engine this chip transmits into nowhere and + // never receives, so the firmware comes up and then waits for ever on a + // transmission that cannot complete - which looks like a hang rather than + // like a missing argument. + printf("radioserver: no --bridge, so this node is deaf and mute\n"); + fflush(stdout); + } - int fd = ::accept(srv, nullptr, nullptr); - if (fd < 0) { - perror("accept"); + sock_t qemuFd = ::accept(srv, nullptr, nullptr); + if (qemuFd == BAD_SOCK) { + fprintf(stderr, "radioserver: the emulator never connected\n"); return 1; } printf("radioserver: emulator connected\n"); fflush(stdout); uint64_t transactions = 0, bytes = 0; - for (;;) { - uint8_t tag = 0; - if (!readAll(fd, &tag, 1)) break; + pollfd_t fds[2]; + int n = 0; + fds[n++] = {qemuFd, POLLIN, 0}; + if (bridgeFd != BAD_SOCK) fds[n++] = {bridgeFd, POLLIN, 0}; - switch (tag) { - case kCsAssert: - chip.beginTransaction(); - break; + if (pollSockets(fds, n, -1) < 0) break; - case kCsRelease: - chip.endTransaction(); - transactions++; - break; - - case kXfer: { - uint8_t out = 0; - if (!readAll(fd, &out, 1)) goto done; - uint8_t in = chip.transferByte(out); - bytes++; - if (!writeAll(fd, &in, 1)) goto done; - break; - } - - case kReadBusy: { - // Never busy for now. BUSY is asserted by the chip while it digests a - // command, and modelling that needs the simulated clock this process - // does not yet have - see the note below about time. - uint8_t busy = 0; - if (!writeAll(fd, &busy, 1)) goto done; + if (fds[0].revents & (POLLIN | POLLHUP)) { + if (!serviceQemu(qemuFd, &transactions, &bytes)) break; + } + if (bridgeFd != BAD_SOCK && (fds[1].revents & (POLLIN | POLLHUP))) { + if (!serviceBridge(bridgeFd)) { + fprintf(stderr, "radioserver: the engine went away\n"); break; } - - default: - fprintf(stderr, "radioserver: unknown tag 0x%02x; the stream has " - "desynchronised, closing\n", tag); - goto done; } } -done: printf("radioserver: %llu transactions, %llu bytes\n", (unsigned long long)transactions, (unsigned long long)bytes); - ::close(fd); - ::close(srv); - ::unlink(path); + if (bridgeFd != BAD_SOCK) CLOSE_SOCK(bridgeFd); + CLOSE_SOCK(qemuFd); + CLOSE_SOCK(srv); +#ifndef _WIN32 + if (!useTcp) ::unlink(path); +#endif +#ifdef _WIN32 + WSACleanup(); +#endif return 0; } -// Not here yet, and both are the same missing thing: simulated time. +// What this is not, and it is worth being exact. // -// * BUSY always reads clear. A real chip raises it while it works, and the -// driver waits on it. Answering truthfully means knowing what time it is. -// * Nothing connects this chip to the RF engine, so it transmits into -// nowhere and never receives. VirtualSX1262 already has pendingTx and an -// inbox for exactly that; they need the bridge on the other side. +// A native node runs in lockstep: the engine supplies the clock, the bridge runs +// loop() one millisecond at a time, and the same seed gives the same answer +// every time. Here the engine still supplies the clock to the *chip*, so IRQ +// timing and channel state are engine-relative - but the *firmware* runs inside +// an emulator on wall time, so the instant at which it reads a register is not +// reproducible. // -// Both arrive with the lockstep link, which is what gives an emulated node the -// same clock every native node already runs on. +// The consequence is narrow and real: an emulated node can transmit and receive +// and take part in a mesh, and two runs of one seed will not produce identical +// ledgers. Mixing emulated and native nodes in a measurement therefore costs the +// determinism the native path has. Fixing it means QEMU's -icount with the +// engine driving virtual time, which is the same contract the native bridge +// already implements. From f464d7e8efebbdf4d70f3ee4dcc4535b25180504 Mon Sep 17 00:00:00 2001 From: Alex B <142788550+A13xB0@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:32:08 +0000 Subject: [PATCH 2/2] build.sh: a radioserver target Nothing built the radio model. build.sh compiled bridge/main.cpp into every role and left bridge/radioserver.cpp alone, so the one binary an emulated node cannot start without was built by hand or not at all - and every shipped bundle so far has been the latter. It needs neither MeshCore nor Crypto, only the chip model beside it, so those two checkouts are no longer demanded for this target. Built from this tree rather than from the simulator so a native node and an emulated one keep the same VirtualSX1262. --- build.sh | 45 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/build.sh b/build.sh index dcd77dc..960c41d 100755 --- a/build.sh +++ b/build.sh @@ -26,13 +26,21 @@ if [ -z "$role" ]; then echo "usage: MESHCORE=... CRYPTO=... $0 [outdir]" >&2 exit 2 fi -: "${MESHCORE:?set MESHCORE to a MeshCore checkout}" -: "${CRYPTO:?set CRYPTO to arduinolibs/libraries/Crypto}" +# The radio model is ours rather than a MeshCore application: it carries its own +# main() and opens neither MeshCore nor Crypto. Demanding two checkouts it never +# reads would make the one build an emulator-only packaging job needs the most +# awkward one to ask for. +if [ "$role" != radioserver ]; then + : "${MESHCORE:?set MESHCORE to a MeshCore checkout}" + : "${CRYPTO:?set CRYPTO to arduinolibs/libraries/Crypto}" +fi root=$(cd "$(dirname "$0")" && pwd) variant="$root/variants/host" -src="$MESHCORE/examples/$role" -[ -d "$src" ] || { echo "no such role: $role (looked in $MESHCORE/examples)" >&2; exit 2; } +src="${MESHCORE:-}/examples/$role" +if [ "$role" != radioserver ]; then + [ -d "$src" ] || { echo "no such role: $role (looked in $MESHCORE/examples)" >&2; exit 2; } +fi # The target, which is not necessarily this machine. Windows and 32-bit builds # are produced by cross-compilers on a Linux runner, so os/arch are inputs with @@ -73,6 +81,35 @@ if [ ${#extra_flags[@]} -gt 0 ]; then extra_link+=(${extra_flags[@]+"${extra_flags[@]}"}) fi +# The radio model, which every emulated node needs and no native one does. +# +# Built from this tree rather than from the simulator's packaging because the +# chip model lives here: an emulated node and a native one have to be the same +# VirtualSX1262, and compiling both from one checkout is the cheapest way to +# keep them that. It reaches nothing else - no MeshCore, no Crypto, no RadioLib +# - so it is two objects and a link rather than the sweep below. +if [ "$role" = radioserver ]; then + obj="$out/obj/radioserver" + mkdir -p "$obj" + bin="$out/radioserver-$os-$arch$exe" + rs_flags=("${STD:--std=c++17}" -O2 -w ${extra_flags[@]+"${extra_flags[@]}"}) + rs_objs=() + for f in "$variant/VirtualSX1262.cpp" "$root/bridge/radioserver.cpp"; do + o="$obj/$(basename "${f%.cpp}").o" + if ! "$CXX" "${rs_flags[@]}" -I "$variant" -c "$f" -o "$o"; then + echo "build.sh: radioserver: $(basename "$f") did not compile for $os/$arch" >&2 + exit 1 + fi + rs_objs+=("$o") + done + if ! "$CXX" -o "$bin" "${rs_objs[@]}" ${extra_link[@]+"${extra_link[@]}"}; then + echo "build.sh: radioserver does not link for $os/$arch" >&2 + exit 3 + fi + echo "$bin" + exit 0 +fi + obj="$out/obj/$role" mkdir -p "$obj" bin="$out/meshcore-$role-$os-$arch$exe"