From 17a6f9f41d06e7c36013bcf6129063c0b73f89f4 Mon Sep 17 00:00:00 2001 From: Rix Date: Tue, 6 Oct 2026 18:31:42 -0700 Subject: [PATCH] Run on hardware: upload over serial and serve the scene The Run on hardware button (and Build > Run on Hardware, Ctrl+F5) did nothing. It now exports the scene, uploads the PCdrv build of psxsplash to a console at the Unirom shell with SEXE (115200 8N2, CHEK/MORE chunk acks from protocol V2), then answers psxsplash's own SIO1 file calls (init, open, read, seek, close) from the export folder, showing the console's text and each call in the status bar popup. The port is a serial device or tcp:HOST:PORT for pcsx-redux's SIO1 server, set in the setup popup. A refusal is the single byte N: psxsplash checks OKAY a byte at a time and a four-byte NOPE left three stray bytes in front of its next answer. editor_tests drives the upload and file calls against a simulated console. In pcsx-redux with a Unirom cart, psxsplash built to take its serial path loads courtyard and renders it pixel-identical to the PCdrv boot. Not yet run on a real console. --- CMakeLists.txt | 8 +- src/editor/hardware.cpp | 99 +++++++ src/editor/hardware.hh | 59 ++++ src/editor/play.cpp | 15 +- src/editor/play.hh | 8 +- src/editor/serial.cpp | 349 ++++++++++++++++++++++++ src/editor/serial.hh | 29 ++ src/editor/tests/editor_tests.cpp | 355 +++++++++++++++++++++++- src/editor/unirom.cpp | 438 ++++++++++++++++++++++++++++++ src/editor/unirom.hh | 58 ++++ src/mockup/main_screen.cpp | 214 +++++++++++++-- src/mockup/main_screen.h | 8 +- 12 files changed, 1614 insertions(+), 26 deletions(-) create mode 100644 src/editor/hardware.cpp create mode 100644 src/editor/hardware.hh create mode 100644 src/editor/serial.cpp create mode 100644 src/editor/serial.hh create mode 100644 src/editor/unirom.cpp create mode 100644 src/editor/unirom.hh diff --git a/CMakeLists.txt b/CMakeLists.txt index 9601c9f..5d9bd04 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -72,10 +72,16 @@ add_library(splasheditdoc STATIC src/editor/gizmo.cpp src/editor/live_export.cpp src/editor/play.cpp - src/editor/project.cpp) + src/editor/project.cpp + src/editor/serial.cpp + src/editor/hardware.cpp + src/editor/unirom.cpp) target_include_directories(splasheditdoc PUBLIC src) find_package(Threads REQUIRED) target_link_libraries(splasheditdoc PUBLIC splashcore Threads::Threads) +if(WIN32) + target_link_libraries(splasheditdoc PUBLIC ws2_32) +endif() if(MSVC) target_compile_options(splasheditdoc PRIVATE /W4 /utf-8) else() diff --git a/src/editor/hardware.cpp b/src/editor/hardware.cpp new file mode 100644 index 0000000..2542aee --- /dev/null +++ b/src/editor/hardware.cpp @@ -0,0 +1,99 @@ +#include "editor/hardware.hh" + +#include +#include + +#include "editor/unirom.hh" + +namespace editor { + +HardwareRun::~HardwareRun() { stop(); } + +bool HardwareRun::active() const { + const Phase p = phase_.load(); + return p == Phase::Connecting || p == Phase::Uploading || p == Phase::Running; +} + +std::string HardwareRun::error() const { + std::lock_guard lock(m_); + return error_; +} + +std::deque HardwareRun::lines() const { + std::lock_guard lock(m_); + return lines_; +} + +uint64_t HardwareRun::lineCount() const { + std::lock_guard lock(m_); + return count_; +} + +void HardwareRun::add(std::string line) { + std::lock_guard lock(m_); + lines_.push_back(std::move(line)); + if (lines_.size() > 2000) lines_.pop_front(); + ++count_; +} + +void HardwareRun::fail(const std::string& why) { + { + std::lock_guard lock(m_); + error_ = why; + } + add(why); + phase_ = Phase::Failed; +} + +HardwareRun::Opener HardwareRun::port(std::string spec) { + return [spec = std::move(spec)](std::string* err) { return openLink(spec, 115200, err); }; +} + +void HardwareRun::stop() { + cancel_ = true; + if (thread_.joinable()) thread_.join(); + if (active()) phase_ = Phase::Stopped; +} + +void HardwareRun::start(Opener open, std::filesystem::path exe, std::filesystem::path dir) { + stop(); + cancel_ = false; + progress_ = 0; + { + std::lock_guard lock(m_); + lines_.clear(); + count_ = 0; + error_.clear(); + } + phase_ = Phase::Connecting; + thread_ = std::thread([this, open = std::move(open), exe = std::move(exe), dir = std::move(dir)] { + std::ifstream in(exe, std::ios::binary); + std::vector bytes((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + if (bytes.empty()) return fail("Cannot read the psxsplash build."); + std::string err; + std::unique_ptr link = open(&err); + if (!link) return fail(err); + if (cancel_) { + phase_ = Phase::Stopped; + return; + } + phase_ = Phase::Uploading; + add("Uploading psxsplash..."); + if (!uniromUpload(*link, bytes, &err, [this](int p) { progress_ = p; }, &cancel_)) { + if (cancel_) { + phase_ = Phase::Stopped; + return; + } + return fail(err); + } + phase_ = Phase::Running; + add("Running. Files are served from " + dir.string()); + PcdrvHost host(dir); + const bool ok = host.serve(*link, cancel_, [this](const std::string& l) { add(l); }, + [this](const std::string& e) { add("[file] " + e); }, &err); + if (!ok && !cancel_) return fail(err); + phase_ = Phase::Stopped; + }); +} + +} // namespace editor diff --git a/src/editor/hardware.hh b/src/editor/hardware.hh new file mode 100644 index 0000000..7c3e29f --- /dev/null +++ b/src/editor/hardware.hh @@ -0,0 +1,59 @@ +// Run on hardware: one background session that uploads psxsplash to a console +// over a Link and then serves its PCdrv files, with what it printed kept for +// the editor to show. No SDL. +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "editor/serial.hh" + +namespace editor { + +class HardwareRun { + public: + enum class Phase { Idle, Connecting, Uploading, Running, Failed, Stopped }; + using Opener = std::function(std::string* err)>; + + HardwareRun() = default; + ~HardwareRun(); + HardwareRun(const HardwareRun&) = delete; + HardwareRun& operator=(const HardwareRun&) = delete; + + // Opens the link with `open`, uploads `exe`, then serves files from `dir` + // until stop(). A session already running is stopped first. + void start(Opener open, std::filesystem::path exe, std::filesystem::path dir); + // The usual opener: a serial port (or tcp:HOST:PORT) at 115200. + static Opener port(std::string spec); + void stop(); + + Phase phase() const { return phase_.load(); } + bool active() const; // connecting, uploading or running + int progress() const { return progress_.load(); } + std::string error() const; + // The console's text and the file calls, oldest first, the newest 2000. + std::deque lines() const; + uint64_t lineCount() const; + + private: + void add(std::string line); + void fail(const std::string& why); + + std::thread thread_; + std::atomic cancel_{false}; + std::atomic phase_{Phase::Idle}; + std::atomic progress_{0}; + mutable std::mutex m_; + std::deque lines_; + uint64_t count_ = 0; + std::string error_; +}; + +} // namespace editor diff --git a/src/editor/play.cpp b/src/editor/play.cpp index d24843a..49da957 100644 --- a/src/editor/play.cpp +++ b/src/editor/play.cpp @@ -33,6 +33,7 @@ PlayTools loadPlayTools(const fs::path& file) { else if (key == "psxsplash") t.psxsplash = p; else if (key == "bios") t.bios = p; else if (key == "disc") t.disc = p; + else if (key == "port") t.port = value; } return t; } @@ -44,7 +45,8 @@ bool savePlayTools(const fs::path& file, const PlayTools& t) { out << "redux=" << utf8(t.redux) << "\n" << "psxsplash=" << utf8(t.psxsplash) << "\n" << "bios=" << utf8(t.bios) << "\n" - << "disc=" << utf8(t.disc) << "\n"; + << "disc=" << utf8(t.disc) << "\n" + << "port=" << t.port << "\n"; return bool(out); } @@ -94,6 +96,8 @@ PlayTools withDefaults(PlayTools t, const fs::path& bundle) { fromEnv(t.psxsplash, "SPLASHEDIT_PSXSPLASH"); fromEnv(t.bios, "SPLASHEDIT_BIOS"); fromEnv(t.disc, "SPLASHEDIT_DISC_ENGINE"); + if (t.port.empty()) + if (const char* v = std::getenv("SPLASHEDIT_PORT"); v && *v) t.port = v; fromBundle(t.redux, bundle, {"redux/pcsx-redux.exe", "redux/PCSX-Redux.app/Contents/MacOS/PCSX-Redux", "redux/pcsx-redux"}); fromBundle(t.psxsplash, bundle, {"engine/psxsplash.ps-exe"}); @@ -123,6 +127,15 @@ std::vector missingDiscTools(const PlayTools& t) { return out; } +std::vector missingHardwareTools(const PlayTools& t) { + std::vector out; + std::error_code ec; + if (t.psxsplash.empty()) out.push_back("The psxsplash build (.ps-exe) is not set."); + else if (!fs::is_regular_file(t.psxsplash, ec)) out.push_back("The psxsplash build not found: " + utf8(t.psxsplash)); + if (t.port.empty()) out.push_back("No serial port is set."); + return out; +} + splash::ExportResult exportForPlay(const splash::Scene& scene, const fs::path& projectRoot, const fs::path& dir) { splash::ExportResult r; std::error_code ec; diff --git a/src/editor/play.hh b/src/editor/play.hh index f2086f7..bfd6d8b 100644 --- a/src/editor/play.hh +++ b/src/editor/play.hh @@ -16,14 +16,15 @@ namespace editor { // `disc` is the CD-ROM build of psxsplash that Export puts on the disc. struct PlayTools { std::filesystem::path redux, psxsplash, bios, disc; + std::string port; // Run on hardware: a serial port, or tcp:HOST:PORT }; -// key=value lines: redux=, psxsplash=, bios=, disc=. A missing file reads as empty. +// key=value lines: redux=, psxsplash=, bios=, disc=, port=. A missing file reads as empty. PlayTools loadPlayTools(const std::filesystem::path& file); bool savePlayTools(const std::filesystem::path& file, const PlayTools& tools); // Fills empty entries from SPLASHEDIT_REDUX, SPLASHEDIT_PSXSPLASH, -// SPLASHEDIT_BIOS and SPLASHEDIT_DISC_ENGINE, then from the copies shipped in +// SPLASHEDIT_BIOS, SPLASHEDIT_DISC_ENGINE and SPLASHEDIT_PORT, then from the copies shipped in // `bundle` (the directory holding the editor: redux/ and engine/), then looks // for pcsx-redux on PATH. PlayTools withDefaults(PlayTools tools, const std::filesystem::path& bundle = {}); @@ -34,6 +35,9 @@ std::vector missingTools(const PlayTools& tools); // The same for Export: only the disc build. std::vector missingDiscTools(const PlayTools& tools); +// The same for Run on hardware: the psxsplash build and a port. +std::vector missingHardwareTools(const PlayTools& tools); + // Writes dir/scene_0.{splashpack,vram,spu}, the names psxsplash's PCdrv // loader opens for the first scene. splash::ExportResult exportForPlay(const splash::Scene& scene, const std::filesystem::path& projectRoot, diff --git a/src/editor/serial.cpp b/src/editor/serial.cpp new file mode 100644 index 0000000..97d49ac --- /dev/null +++ b/src/editor/serial.cpp @@ -0,0 +1,349 @@ +#include "editor/serial.hh" + +#include +#include +#include +#include + +#ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#include +#else +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#endif + +namespace editor { + +namespace { + +#ifdef _WIN32 + +std::string lastError() { + DWORD code = GetLastError(); + char buf[256] = {}; + FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, nullptr, code, 0, buf, sizeof buf, nullptr); + std::string s = buf; + while (!s.empty() && (s.back() == '\n' || s.back() == '\r' || s.back() == ' ')) s.pop_back(); + return s.empty() ? "error " + std::to_string(code) : s; +} + +class SerialLink : public Link { + public: + explicit SerialLink(HANDLE h) : h_(h) {} + ~SerialLink() override { CloseHandle(h_); } + bool write(const void* data, size_t size) override { + const char* p = static_cast(data); + while (size) { + DWORD done = 0; + if (!WriteFile(h_, p, DWORD(std::min(size, 1 << 20)), &done, nullptr)) return false; + p += done; + size -= done; + } + return true; + } + int read(void* data, size_t size, int timeoutMs) override { + COMMTIMEOUTS t = {}; + // Return as soon as anything arrives, or after timeoutMs with nothing. + t.ReadIntervalTimeout = MAXDWORD; + t.ReadTotalTimeoutMultiplier = MAXDWORD; + t.ReadTotalTimeoutConstant = DWORD(std::max(1, timeoutMs)); + t.WriteTotalTimeoutConstant = 5000; + if (timeoutMs != lastTimeout_) { + if (!SetCommTimeouts(h_, &t)) return -1; + lastTimeout_ = timeoutMs; + } + DWORD got = 0; + if (!ReadFile(h_, data, DWORD(size), &got, nullptr)) return -1; + return int(got); + } + + private: + HANDLE h_; + int lastTimeout_ = -2; +}; + +std::unique_ptr openSerial(const std::string& name, int baud, std::string* err) { + std::string path = name.rfind("\\\\.\\", 0) == 0 ? name : "\\\\.\\" + name; + HANDLE h = CreateFileA(path.c_str(), GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, 0, nullptr); + if (h == INVALID_HANDLE_VALUE) { + if (err) *err = "Cannot open " + name + ": " + lastError(); + return nullptr; + } + DCB dcb = {}; + dcb.DCBlength = sizeof dcb; + GetCommState(h, &dcb); + dcb.BaudRate = DWORD(baud); + dcb.ByteSize = 8; + dcb.Parity = NOPARITY; + dcb.StopBits = TWOSTOPBITS; + dcb.fBinary = TRUE; + dcb.fParity = FALSE; + dcb.fOutxCtsFlow = FALSE; + dcb.fOutxDsrFlow = FALSE; + dcb.fDsrSensitivity = FALSE; + dcb.fOutX = FALSE; + dcb.fInX = FALSE; + dcb.fNull = FALSE; + dcb.fAbortOnError = FALSE; + dcb.fDtrControl = DTR_CONTROL_ENABLE; + dcb.fRtsControl = RTS_CONTROL_ENABLE; + if (!SetCommState(h, &dcb)) { + if (err) *err = "Cannot set up " + name + ": " + lastError(); + CloseHandle(h); + return nullptr; + } + PurgeComm(h, PURGE_RXCLEAR | PURGE_TXCLEAR); + return std::make_unique(h); +} + +class SocketLink : public Link { + public: + explicit SocketLink(SOCKET s) : s_(s) {} + ~SocketLink() override { + closesocket(s_); + WSACleanup(); + } + bool write(const void* data, size_t size) override { + const char* p = static_cast(data); + while (size) { + int n = send(s_, p, int(std::min(size, 1 << 20)), 0); + if (n <= 0) return false; + p += n; + size -= size_t(n); + } + return true; + } + int read(void* data, size_t size, int timeoutMs) override { + WSAPOLLFD pfd = {}; + pfd.fd = s_; + pfd.events = POLLRDNORM; + int r = WSAPoll(&pfd, 1, timeoutMs); + if (r < 0) return -1; + if (r == 0) return 0; + int n = recv(s_, static_cast(data), int(size), 0); + return n > 0 ? n : -1; + } + + private: + SOCKET s_; +}; + +#else + +std::string lastError() { return std::strerror(errno); } + +#ifdef MSG_NOSIGNAL +constexpr int kSendFlags = MSG_NOSIGNAL; +#else +constexpr int kSendFlags = 0; // macOS: SO_NOSIGPIPE is set on the socket instead +#endif + +// Waits for fd to be readable; 1 ready, 0 timeout, -1 error. +int waitReadable(int fd, int timeoutMs) { + pollfd pfd = {fd, POLLIN, 0}; + for (;;) { + int r = poll(&pfd, 1, timeoutMs); + if (r < 0 && errno == EINTR) continue; + if (r <= 0) return r; + return 1; + } +} + +class FdLink : public Link { + public: + FdLink(int fd, bool socket) : fd_(fd), socket_(socket) {} + ~FdLink() override { close(fd_); } + bool write(const void* data, size_t size) override { + const char* p = static_cast(data); + while (size) { + ssize_t n = socket_ ? send(fd_, p, size, kSendFlags) : ::write(fd_, p, size); + if (n < 0 && (errno == EINTR || errno == EAGAIN)) { + if (errno == EAGAIN) { + pollfd pfd = {fd_, POLLOUT, 0}; + poll(&pfd, 1, 100); + } + continue; + } + if (n <= 0) return false; + p += n; + size -= size_t(n); + } + if (!socket_) tcdrain(fd_); + return true; + } + int read(void* data, size_t size, int timeoutMs) override { + int r = waitReadable(fd_, timeoutMs); + if (r <= 0) return r; + ssize_t n = ::read(fd_, data, size); + if (n < 0 && (errno == EINTR || errno == EAGAIN)) return 0; + return n > 0 ? int(n) : -1; + } + + private: + int fd_; + bool socket_; +}; + +speed_t speedFor(int baud) { + switch (baud) { + case 9600: return B9600; + case 19200: return B19200; + case 38400: return B38400; + case 57600: return B57600; + case 115200: return B115200; + case 230400: return B230400; + default: return 0; + } +} + +std::unique_ptr openSerial(const std::string& name, int baud, std::string* err) { + speed_t speed = speedFor(baud); + if (!speed) { + if (err) *err = "Unsupported baud rate " + std::to_string(baud); + return nullptr; + } + int fd = open(name.c_str(), O_RDWR | O_NOCTTY | O_NONBLOCK); + if (fd < 0) { + if (err) *err = "Cannot open " + name + ": " + lastError(); + return nullptr; + } + termios tio = {}; + if (tcgetattr(fd, &tio) != 0) { + if (err) *err = "Cannot set up " + name + ": " + lastError(); + close(fd); + return nullptr; + } + cfmakeraw(&tio); + cfsetispeed(&tio, speed); + cfsetospeed(&tio, speed); + tio.c_cflag &= ~(CSIZE | PARENB); + tio.c_cflag |= CS8 | CSTOPB | CLOCAL | CREAD; +#ifdef CRTSCTS + tio.c_cflag &= ~CRTSCTS; +#endif + tio.c_iflag &= ~(IXON | IXOFF | IXANY); + tio.c_cc[VMIN] = 0; + tio.c_cc[VTIME] = 0; + if (tcsetattr(fd, TCSANOW, &tio) != 0) { + if (err) *err = "Cannot set up " + name + ": " + lastError(); + close(fd); + return nullptr; + } + int lines = TIOCM_DTR | TIOCM_RTS; + ioctl(fd, TIOCMBIS, &lines); + tcflush(fd, TCIOFLUSH); + return std::make_unique(fd, false); +} + +#endif + +std::unique_ptr openTcp(const std::string& hostPort, std::string* err) { + size_t colon = hostPort.rfind(':'); + if (colon == std::string::npos || colon == 0 || colon + 1 == hostPort.size()) { + if (err) *err = "Expected tcp:HOST:PORT, got tcp:" + hostPort; + return nullptr; + } + std::string host = hostPort.substr(0, colon), port = hostPort.substr(colon + 1); +#ifdef _WIN32 + WSADATA wsa; + if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) { + if (err) *err = "Winsock did not start"; + return nullptr; + } +#endif + addrinfo hints = {}, *res = nullptr; + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + if (getaddrinfo(host.c_str(), port.c_str(), &hints, &res) != 0 || !res) { + if (err) *err = "Cannot resolve " + host; +#ifdef _WIN32 + WSACleanup(); +#endif + return nullptr; + } + std::string why = "no address"; + for (addrinfo* a = res; a; a = a->ai_next) { +#ifdef _WIN32 + SOCKET s = socket(a->ai_family, a->ai_socktype, a->ai_protocol); + if (s == INVALID_SOCKET) continue; + if (connect(s, a->ai_addr, int(a->ai_addrlen)) == 0) { + BOOL one = TRUE; + setsockopt(s, IPPROTO_TCP, TCP_NODELAY, reinterpret_cast(&one), sizeof one); + freeaddrinfo(res); + return std::make_unique(s); + } + why = lastError(); + closesocket(s); +#else + int s = socket(a->ai_family, a->ai_socktype, a->ai_protocol); + if (s < 0) continue; + if (connect(s, a->ai_addr, a->ai_addrlen) == 0) { + int one = 1; + setsockopt(s, IPPROTO_TCP, TCP_NODELAY, &one, sizeof one); +#ifdef SO_NOSIGPIPE + setsockopt(s, SOL_SOCKET, SO_NOSIGPIPE, &one, sizeof one); +#endif + freeaddrinfo(res); + return std::make_unique(s, true); + } + why = lastError(); + close(s); +#endif + } + freeaddrinfo(res); +#ifdef _WIN32 + WSACleanup(); +#endif + if (err) *err = "Cannot connect to " + hostPort + ": " + why; + return nullptr; +} + +} // namespace + +std::unique_ptr openLink(const std::string& spec, int baud, std::string* err) { + if (spec.empty()) { + if (err) *err = "No serial port set"; + return nullptr; + } + if (spec.rfind("tcp:", 0) == 0) return openTcp(spec.substr(4), err); + return openSerial(spec, baud, err); +} + +std::vector listSerialPorts() { + std::vector out; +#ifdef _WIN32 + char target[512]; + for (int i = 1; i <= 64; ++i) { + std::string name = "COM" + std::to_string(i); + if (QueryDosDeviceA(name.c_str(), target, sizeof target)) out.push_back(name); + } +#else + std::error_code ec; + for (const auto& e : std::filesystem::directory_iterator("/dev", ec)) { + std::string n = e.path().filename().string(); +#ifdef __APPLE__ + if (n.rfind("cu.", 0) == 0 && n != "cu.Bluetooth-Incoming-Port") out.push_back(e.path().string()); +#else + if (n.rfind("ttyUSB", 0) == 0 || n.rfind("ttyACM", 0) == 0) out.push_back(e.path().string()); +#endif + } + std::sort(out.begin(), out.end()); +#endif + return out; +} + +} // namespace editor diff --git a/src/editor/serial.hh b/src/editor/serial.hh new file mode 100644 index 0000000..0acda32 --- /dev/null +++ b/src/editor/serial.hh @@ -0,0 +1,29 @@ +// A byte link to a console: a serial port, or a TCP socket (pcsx-redux's SIO1 +// server). Blocking writes, reads with a timeout. No SDL. +#pragma once + +#include +#include +#include +#include + +namespace editor { + +class Link { + public: + virtual ~Link() = default; + // Sends every byte; false once the link is gone. + virtual bool write(const void* data, size_t size) = 0; + // Reads at most `size` bytes, waiting up to `timeoutMs` for the first one. + // Returns the count, 0 on timeout, -1 once the link is gone. + virtual int read(void* data, size_t size, int timeoutMs) = 0; +}; + +// `spec` is a serial device (COM3, /dev/ttyUSB0) opened at `baud`, 8N2, DTR +// and RTS on, no flow control; or tcp:HOST:PORT. Null with *err on failure. +std::unique_ptr openLink(const std::string& spec, int baud, std::string* err); + +// Serial ports present on this machine, for the setup popup. +std::vector listSerialPorts(); + +} // namespace editor diff --git a/src/editor/tests/editor_tests.cpp b/src/editor/tests/editor_tests.cpp index 2f220cf..91d44ef 100644 --- a/src/editor/tests/editor_tests.cpp +++ b/src/editor/tests/editor_tests.cpp @@ -1,6 +1,13 @@ // Headless tests for the editor's document model: the undo history and the // viewport's ray picker and gizmo maths. No SDL, no GL. Run through CTest (`ctest`). #include +#include +#include +#include +#include +#include +#include +#include #include #include #include @@ -12,11 +19,14 @@ #include "editor/catalog.hh" #include "editor/document.hh" +#include "editor/hardware.hh" #include "editor/gizmo.hh" #include "editor/live_export.hh" #include "editor/pick.hh" #include "editor/play.hh" #include "editor/project.hh" +#include "editor/serial.hh" +#include "editor/unirom.hh" #include "budget.hh" #include "splashpack.hh" @@ -678,10 +688,14 @@ void testPlay() { t.redux = fs::path(u8"/opt/rédux/pcsx-redux"); t.psxsplash = "/x/psxsplash.ps-exe"; t.disc = "/x/psxsplash-cdrom.ps-exe"; + t.port = "tcp:localhost:6699"; CHECK(editor::savePlayTools(cfg, t)); { std::ofstream(cfg, std::ios::app) << "colour=blue\nnot a pair\n"; } editor::PlayTools l = editor::loadPlayTools(cfg); - CHECK(l.redux == t.redux && l.psxsplash == t.psxsplash && l.bios.empty() && l.disc == t.disc); + CHECK(l.redux == t.redux && l.psxsplash == t.psxsplash && l.bios.empty() && l.disc == t.disc && l.port == t.port); + editor::PlayTools noPort = l; + noPort.port.clear(); + CHECK(editor::missingHardwareTools(noPort).size() == 2); // the build is not on disk either editor::PlayTools none = editor::loadPlayTools(dir / "absent.cfg"); CHECK(none.redux.empty() && none.psxsplash.empty()); @@ -953,6 +967,343 @@ void testMove() { CHECK(!d.pinned()); } +// ---- Run on hardware: the Unirom upload and PCdrv host against a simulated console. + +struct Pipe { + std::mutex m; + std::condition_variable cv; + std::deque q; + bool closed = false; +}; + +class PipeEnd : public editor::Link { + public: + PipeEnd(Pipe& in, Pipe& out) : in_(in), out_(out) {} + bool write(const void* d, size_t n) override { + std::lock_guard lock(out_.m); + if (out_.closed) return false; + const uint8_t* p = static_cast(d); + out_.q.insert(out_.q.end(), p, p + n); + out_.cv.notify_all(); + return true; + } + int read(void* d, size_t n, int timeoutMs) override { + std::unique_lock lock(in_.m); + in_.cv.wait_for(lock, std::chrono::milliseconds(timeoutMs), [&] { return !in_.q.empty() || in_.closed; }); + if (in_.q.empty()) return in_.closed ? -1 : 0; + size_t k = std::min(n, in_.q.size()); + std::copy(in_.q.begin(), in_.q.begin() + k, static_cast(d)); + in_.q.erase(in_.q.begin(), in_.q.begin() + k); + return int(k); + } + void close() { + std::lock_guard lock(out_.m); + out_.closed = true; + out_.cv.notify_all(); + } + + private: + Pipe& in_; + Pipe& out_; +}; + +// The console's half of the protocol, as Unirom (sio.c, kdebug.c) speaks it. +struct FakeConsole { + PipeEnd& link; + int offer; // protocol version Unirom offers: 1 (none), 2 or 3 + std::vector problems; + std::vector program; + uint32_t dest = 0, entry = 0; + + void fail(const std::string& s) { problems.push_back(s); } + bool get(void* p, size_t n) { + uint8_t* o = static_cast(p); + while (n) { + int r = link.read(o, n, 3000); + if (r <= 0) { + fail("console: host went quiet"); + return false; + } + o += r; + n -= size_t(r); + } + return true; + } + uint32_t u32() { + uint8_t b[4] = {}; + get(b, 4); + return uint32_t(b[0]) | uint32_t(b[1]) << 8 | uint32_t(b[2]) << 16 | uint32_t(b[3]) << 24; + } + void put(const void* p, size_t n) { link.write(p, n); } + void say(const char* s) { put(s, std::strlen(s)); } + void put32(uint32_t v) { + uint8_t b[4] = {uint8_t(v), uint8_t(v >> 8), uint8_t(v >> 16), uint8_t(v >> 24)}; + put(b, 4); + } + std::string command() { // the shell echoes what it reads + char c[5] = {}; + if (!get(c, 4)) return {}; + put(c, 4); + return c; + } + static uint32_t sum(const uint8_t* p, size_t n) { + uint32_t s = 0; + for (size_t i = 0; i < n; ++i) s += p[i]; + return s; + } + + void shell() { + std::string cmd = command(); + if (cmd != "SEXE") return fail("console: expected SEXE, got " + cmd); + int version = 1; + if (offer >= 2) { + say(offer == 3 ? "OKV3" : "OKV2"); + std::string up = command(); + if (up != (offer == 3 ? "UPV3" : "UPV2")) return fail("console: upgrade answered with " + up); + version = offer; + } + say("OKAY"); + std::vector header(2048); + if (!get(header.data(), header.size())) return; + if (std::memcmp(header.data(), "PS-X EXE", 8)) fail("console: SEXE header is not the EXE header"); + entry = u32(); + dest = u32(); + const uint32_t len = u32(), check = u32(); + if (len % 2048) fail("console: SEXE length not padded"); + program.assign(len, 0); + bool corrupted = false; + for (uint32_t at = 0; at < len;) { + if (!get(program.data() + at, 2048)) return; + if (version >= 2) { + say("CHEK"); + const uint32_t host = u32(); + if (host != sum(program.data() + at, 2048)) fail("console: wrong chunk checksum"); + if (!corrupted) { // pretend the first chunk arrived damaged + corrupted = true; + say("ERR!"); + continue; + } + say("MORE"); + } + at += 2048; + } + uint32_t whole = 0; + if (version == 3) { + whole = 5381; + for (uint8_t b : program) whole = ((whole << 5) + whole) ^ b; + } else { + whole = sum(program.data(), program.size()); + } + if (whole != check) fail("console: wrong SEXE checksum"); + } + + // psxsplash's side of a file call (pcdrv_handler.hh): escape, call number, presence OKAY. + bool okay() { // sio_check_okay: stops reading at the first byte that does not match + for (const char* w = "OKAY"; *w; ++w) { + uint8_t c = 0; + if (!get(&c, 1) || c != uint8_t(*w)) return false; + } + return true; + } + bool call(uint32_t op) { + uint8_t esc[2] = {0, 'p'}; + put(esc, 2); + put32(op); + if (okay()) return true; + fail("console: no presence ack"); + return false; + } + bool init() { + if (!call(0x101)) return false; + uint8_t z = 1; + get(&z, 1); + return z == 0; + } + bool open(const char* name, uint32_t* h) { + if (!call(0x103)) return false; + put(name, std::strlen(name) + 1); + put32(0); + if (!okay()) return false; + *h = u32(); + return true; + } + std::vector read(uint32_t h, uint32_t len) { + if (!call(0x105)) return {}; + put32(h), put32(len), put32(0x80100000); + if (!okay()) { + fail("console: read refused"); + return {}; + } + const uint32_t n = u32(), check = u32(); + std::vector mem(n); + get(mem.data(), n); + if (check != sum(mem.data(), n)) fail("console: read checksum"); + return mem; + } + bool three(uint32_t op, uint32_t a, uint32_t b, uint32_t c, uint32_t* out) { + if (!call(op)) return false; + put32(a), put32(b), put32(c); + if (!okay()) return false; + *out = u32(); + return true; + } +}; + +void runUnirom(int offer) { + namespace fs = std::filesystem; + const fs::path base = g_outDir / ("unirom" + std::to_string(offer)); + std::error_code ec; + fs::remove_all(base, ec); + fs::create_directories(base, ec); + std::vector scene(3000); + for (size_t i = 0; i < scene.size(); ++i) scene[i] = uint8_t(i * 7 + 1); + { + std::ofstream(base / "scene_0.splashpack", std::ios::binary).write(reinterpret_cast(scene.data()), scene.size()); + } + // A program of a bit over two chunks. + std::vector exe(0x800 + 5000); + std::memcpy(exe.data(), "PS-X EXE", 8); + const uint32_t pc = 0x80010000, addr = 0x80010000; + std::memcpy(&exe[0x10], &pc, 4); + std::memcpy(&exe[0x18], &addr, 4); + for (size_t i = 0x800; i < exe.size(); ++i) exe[i] = uint8_t(i * 13); + + Pipe toConsole, toHost; + PipeEnd hostEnd(toHost, toConsole), consoleEnd(toConsole, toHost); + FakeConsole con{consoleEnd, offer, {}, {}, 0, 0}; + std::vector readBack, tail; + uint32_t seekPos = 0, closed = 99; + bool inited = false, escapeRefused = false, missingRefused = false, unsupportedRefused = false; + std::thread console([&] { + con.shell(); + con.say("psxsplash: boot\r\n"); + uint32_t h = 0, n = 0; + inited = con.init(); + if (con.open("scene_0.splashpack", &h)) { + readBack = con.read(h, 4096); // more than the file holds + con.three(0x107, h, 2990, 0, &seekPos); + tail = con.read(h, 64); + con.three(0x104, h, 0, 0, &closed); + } + escapeRefused = !con.open("../outside", &n); + missingRefused = !con.open("missing.bin", &n); + if (con.call(0x106)) { // write: not served, the host answers NOPE + con.put32(h), con.put32(4), con.put32(0x80100000); + unsupportedRefused = !con.okay(); + } + con.say("done\n"); + consoleEnd.close(); + }); + + std::string err; + std::vector progress; + CHECK(editor::uniromUpload(hostEnd, exe, &err, [&](int p) { progress.push_back(p); })); + if (!err.empty()) std::fprintf(stderr, "upload: %s\n", err.c_str()); + std::vector lines, events; + std::atomic cancel{false}; + editor::PcdrvHost host(base); + const bool clean = host.serve(hostEnd, cancel, [&](const std::string& l) { lines.push_back(l); }, + [&](const std::string& e) { events.push_back(e); }, &err); + console.join(); + + CHECK(!clean && err == "The connection closed."); // the console hung up + for (const std::string& p : con.problems) std::fprintf(stderr, "offer %d: %s\n", offer, p.c_str()); + CHECK(con.problems.empty()); + CHECK(con.dest == addr && con.entry == pc); + CHECK(con.program.size() == 6144 && std::equal(exe.begin() + 0x800, exe.end(), con.program.begin())); + CHECK(!progress.empty() && progress.back() == 100); + CHECK(std::find(lines.begin(), lines.end(), "psxsplash: boot") != lines.end()); + CHECK(std::find(lines.begin(), lines.end(), "done") != lines.end()); + CHECK(inited); + CHECK(readBack.size() == 3000 && std::equal(scene.begin(), scene.end(), readBack.begin())); + CHECK(seekPos == 2990 && tail.size() == 10 && std::equal(scene.end() - 10, scene.end(), tail.begin())); + CHECK(closed != 99); + CHECK(escapeRefused && missingRefused && unsupportedRefused); + CHECK(!fs::exists(base.parent_path() / "outside")); +} + +// HardwareRun: the editor's session, over a pipe to the same fake console. +class Borrowed : public editor::Link { + public: + explicit Borrowed(editor::Link& l) : l_(l) {} + bool write(const void* d, size_t n) override { return l_.write(d, n); } + int read(void* d, size_t n, int t) override { return l_.read(d, n, t); } + + private: + editor::Link& l_; +}; + +void testHardwareRun() { + namespace fs = std::filesystem; + const fs::path dir = g_outDir / "hwrun"; + std::error_code ec; + fs::create_directories(dir, ec); + std::vector exe(0x800 + 100); + std::memcpy(exe.data(), "PS-X EXE", 8); + const fs::path exePath = dir / "engine.ps-exe"; + std::ofstream(exePath, std::ios::binary).write(reinterpret_cast(exe.data()), exe.size()); + + Pipe toConsole, toHost; + PipeEnd hostEnd(toHost, toConsole), consoleEnd(toConsole, toHost); + FakeConsole con{consoleEnd, 2, {}, {}, 0, 0}; + std::atomic printed{false}; + std::thread console([&] { + con.shell(); + con.say("hello from the console\n"); + printed = true; + }); + editor::HardwareRun run; + run.start([&](std::string*) { return std::make_unique(hostEnd); }, exePath, dir); + auto waitFor = [&](auto pred) { + for (int i = 0; i < 300 && !pred(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(10)); + return pred(); + }; + console.join(); + CHECK(con.problems.empty()); + CHECK(waitFor([&] { + auto l = run.lines(); + return std::find(l.begin(), l.end(), "hello from the console") != l.end(); + })); + CHECK(run.phase() == editor::HardwareRun::Phase::Running && run.active() && run.progress() == 100); + run.stop(); + CHECK(run.phase() == editor::HardwareRun::Phase::Stopped && !run.active()); + + // A port that does not open: Failed, with the reason. + run.start([](std::string* err) { + *err = "Cannot open COM9"; + return std::unique_ptr(); + }, exePath, dir); + CHECK(waitFor([&] { return run.phase() == editor::HardwareRun::Phase::Failed; })); + CHECK(run.error() == "Cannot open COM9"); + // No build on disk. + run.start([&](std::string*) { return std::make_unique(hostEnd); }, dir / "absent.ps-exe", dir); + CHECK(waitFor([&] { return run.phase() == editor::HardwareRun::Phase::Failed; })); + CHECK(run.error() == "Cannot read the psxsplash build."); +} + +void testUnirom() { + runUnirom(1); + runUnirom(2); + runUnirom(3); + + // Not a PS-X EXE: refused before anything is sent. + Pipe a, b; + PipeEnd end(a, b); + std::string err; + CHECK(!editor::uniromUpload(end, std::vector(4096), &err) && err == "Not a PS-X EXE."); + CHECK(b.q.empty()); + + // A console that never answers. + std::vector exe(0x1000); + std::memcpy(exe.data(), "PS-X EXE", 8); + end.close(); + CHECK(!editor::uniromUpload(end, exe, &err) && !err.empty()); + + std::string linkErr; + CHECK(!editor::openLink("", 115200, &linkErr) && !linkErr.empty()); + CHECK(!editor::openLink("tcp:nonsense", 115200, &linkErr) && linkErr.find("tcp:HOST:PORT") != std::string::npos); +} + int main(int argc, char** argv) { std::error_code ec; std::filesystem::path exe = argc > 0 ? std::filesystem::absolute(argv[0], ec) : std::filesystem::path(); @@ -975,6 +1326,8 @@ int main(int argc, char** argv) { testPlay(); testProject(); testAdoptAsset(); + testUnirom(); + testHardwareRun(); if (g_failures) { std::fprintf(stderr, "editor_tests: %d of %d checks failed\n", g_failures, g_checks); return 1; diff --git a/src/editor/unirom.cpp b/src/editor/unirom.cpp new file mode 100644 index 0000000..8e3e9ab --- /dev/null +++ b/src/editor/unirom.cpp @@ -0,0 +1,438 @@ +#include "editor/unirom.hh" + +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace editor { + +namespace { + +constexpr size_t kChunk = 2048; +constexpr int kWaitMs = 5000; // longest a console is given to answer inside a call + +using Clock = std::chrono::steady_clock; + +bool sendWord(Link& link, uint32_t v) { + uint8_t b[4] = {uint8_t(v), uint8_t(v >> 8), uint8_t(v >> 16), uint8_t(v >> 24)}; + return link.write(b, 4); +} + +bool sendText(Link& link, const char* s) { return link.write(s, std::strlen(s)); } + +uint32_t byteSum(const uint8_t* p, size_t n) { + uint32_t s = 0; + for (size_t i = 0; i < n; ++i) s += p[i]; + return s; +} + +uint32_t readLE(const std::vector& v, size_t at) { + return uint32_t(v[at]) | uint32_t(v[at + 1]) << 8 | uint32_t(v[at + 2]) << 16 | uint32_t(v[at + 3]) << 24; +} + +bool cancelled(const std::atomic* c) { return c && c->load(); } + +// Discards whatever the console sends until it has been quiet for `quietMs`. +void drain(Link& link, int quietMs) { + uint8_t buf[256]; + const auto give_up = Clock::now() + std::chrono::seconds(2); + while (Clock::now() < give_up && link.read(buf, sizeof buf, quietMs) > 0) { + } +} + +// The upload half: Unirom answers in four-letter words, and may offer a newer +// protocol (OKV2, OKV3) before the answer, which is accepted with UPV2/UPV3. +struct Uploader { + Link& link; + int version = 1; + std::string* err; + + // Waits for `expected`; true when it arrives. + bool await(const char* expected, int timeoutMs) { + char tail[4] = {}; + int have = 0; + const auto deadline = Clock::now() + std::chrono::milliseconds(timeoutMs); + while (Clock::now() < deadline) { + uint8_t c; + int n = link.read(&c, 1, 50); + if (n < 0) { + *err = "The connection closed."; + return false; + } + if (n == 0) continue; + std::memmove(tail, tail + 1, 3); + tail[3] = char(c); + if (++have < 4) continue; + if (!std::memcmp(tail, "OKV3", 4) || !std::memcmp(tail, "OKV2", 4)) { + const bool v3 = tail[3] == '3'; + if (!sendText(link, v3 ? "UPV3" : "UPV2")) break; + version = std::max(version, v3 ? 3 : 2); + have = 0; + continue; + } + if (!std::memcmp(tail, "UNSP", 4)) { + *err = "Unirom refused the command: it is already in debug mode. Reset the console and try again."; + return false; + } + if (!std::memcmp(tail, expected, 4)) return true; + } + if (err->empty()) *err = std::string("No answer to the upload (waited for ") + expected + ")."; + return false; + } + + // Sends `cmd` and waits for OKAY, accepting a newer protocol on the way. + bool command(const char* cmd) { + if (!sendText(link, cmd)) { + *err = "The connection closed."; + return false; + } + if (await("OKAY", 10000)) return true; + if (err->rfind("No answer", 0) == 0) + *err = std::string("The console did not answer ") + cmd + ". Is it at the Unirom shell?"; + return false; + } + + uint32_t checksum(const std::vector& data) const { + if (version == 3) { + uint32_t h = 5381; + for (uint8_t b : data) h = ((h << 5) + h) ^ b; + return h; + } + return byteSum(data.data(), data.size()); + } + + // Sends `data` in chunks; from V2 on each chunk is acknowledged, and resent on ERR!. + bool chunks(const std::vector& data, const UploadProgress& progress, const std::atomic* cancel) { + for (size_t at = 0; at < data.size();) { + if (cancelled(cancel)) { + *err = "Cancelled."; + return false; + } + const size_t n = std::min(kChunk, data.size() - at); + if (!link.write(data.data() + at, n)) { + *err = "The connection closed."; + return false; + } + if (version >= 2) { + if (!await("CHEK", kWaitMs)) return false; + if (!sendWord(link, byteSum(data.data() + at, n))) { + *err = "The connection closed."; + return false; + } + // MORE moves on, ERR! asks for the same chunk again. + char tail[4] = {}; + int have = 0; + bool more = false, again = false; + const auto deadline = Clock::now() + std::chrono::milliseconds(kWaitMs); + while (!more && !again && Clock::now() < deadline) { + uint8_t c; + int r = link.read(&c, 1, 50); + if (r < 0) { + *err = "The connection closed."; + return false; + } + if (r == 0) continue; + std::memmove(tail, tail + 1, 3); + tail[3] = char(c); + if (++have < 4) continue; + more = !std::memcmp(tail, "MORE", 4); + again = !std::memcmp(tail, "ERR!", 4); + } + if (!more && !again) { + *err = "The console stopped acknowledging the upload."; + return false; + } + if (again) continue; + } + at += n; + if (progress) progress(int(at * 100 / data.size())); + } + return true; + } +}; + +} // namespace + +bool uniromUpload(Link& link, const std::vector& exe, std::string* err, const UploadProgress& progress, + const std::atomic* cancel) { + std::string local; + if (!err) err = &local; + err->clear(); + if (exe.size() <= 0x800 || std::memcmp(exe.data(), "PS-X EXE", 8) != 0) { + *err = "Not a PS-X EXE."; + return false; + } + const uint32_t entry = readLE(exe, 0x10), dest = readLE(exe, 0x18); + std::vector body(exe.begin() + 0x800, exe.end()); + body.resize((body.size() + kChunk - 1) / kChunk * kChunk, 0); + + Uploader up{link, 1, err}; + drain(link, 100); + if (cancelled(cancel)) { + *err = "Cancelled."; + return false; + } + // SEXE: the 2048-byte header, then where to jump and load, the size and + // checksum of what follows, then the program. It starts on the last chunk. + if (!up.command("SEXE")) return false; + drain(link, 200); + const uint32_t sum = up.checksum(body); + if (!link.write(exe.data(), 0x800) || !sendWord(link, entry) || !sendWord(link, dest) || + !sendWord(link, uint32_t(body.size())) || !sendWord(link, sum)) { + *err = "The connection closed."; + return false; + } + if (!up.chunks(body, progress, cancel)) return false; + return true; +} + +// ---- PCdrv host + +namespace { + +constexpr uint32_t kInit = 0x101, kOpen = 0x103, kClose = 0x104, kRead = 0x105, kSeek = 0x107; +constexpr uint32_t kMaxTransfer = 64u << 20; + +// Exact reads over the link, with a timeout per wait. +struct In { + Link& link; + std::string* err; + bool bytes(uint8_t* p, size_t n) { + while (n) { + int r = link.read(p, n, kWaitMs); + if (r < 0) { + *err = "The connection closed."; + return false; + } + if (r == 0) { + *err = "The console stopped in the middle of a file call."; + return false; + } + p += r; + n -= size_t(r); + } + return true; + } + bool u32(uint32_t* v) { + uint8_t b[4]; + if (!bytes(b, 4)) return false; + *v = uint32_t(b[0]) | uint32_t(b[1]) << 8 | uint32_t(b[2]) << 16 | uint32_t(b[3]) << 24; + return true; + } +}; + +} // namespace + +// A refusal is one byte. psxsplash compares O, K, A, Y one byte at a time and +// stops at the first mismatch, so a longer word would leave the rest of it in +// the console's receive buffer, to be read as the answer to its next call. +static bool refuse(Link& link) { return link.write("N", 1); } + +PcdrvHost::PcdrvHost(fs::path base) : base_(std::move(base)) {} + +PcdrvHost::~PcdrvHost() { + for (auto& [h, f] : files_) std::fclose(f); +} + +fs::path PcdrvHost::resolve(const std::string& name, bool* ok) const { + std::string n = name; + std::replace(n.begin(), n.end(), '\\', '/'); + while (!n.empty() && n.front() == '/') n.erase(n.begin()); + // libsn-style device prefixes ("host:", "pcdrv:") name the PC itself. + if (size_t colon = n.find(':'); colon != std::string::npos && n.find('/') > colon) n = n.substr(colon + 1); + fs::path rel = fs::path(std::u8string(n.begin(), n.end())).lexically_normal(); + *ok = !n.empty() && !rel.empty() && rel.is_relative() && *rel.begin() != ".."; + return base_ / rel; +} + +bool PcdrvHost::serve(Link& link, const std::atomic& cancel, const std::function& line, + const std::function& event, std::string* err) { + std::string local; + if (!err) err = &local; + err->clear(); + std::string text; + bool escape = false; + uint8_t buf[512]; + while (!cancel.load()) { + int n = link.read(buf, sizeof buf, 50); + if (n < 0) { + if (!text.empty() && line) line(text); + *err = "The connection closed."; + return false; + } + if (n == 0) { + if (!text.empty()) { + if (line) line(text); + text.clear(); + } + continue; + } + for (int i = 0; i < n; ++i) { + const uint8_t c = buf[i]; + if (escape) { + escape = false; + if (c != 'p') continue; + if (!text.empty()) { + if (line) line(text); + text.clear(); + } + // The call reads the link itself; give it the bytes already read. + pending_.assign(buf + i + 1, buf + n); + if (!handleCall(link, event, err)) return false; + // Whatever followed the call in this buffer was consumed through pending_. + std::vector rest = std::move(pending_); + pending_.clear(); + // Re-run the loop body on the leftovers. + n = int(std::min(rest.size(), sizeof buf)); + std::copy(rest.begin(), rest.begin() + n, buf); + i = -1; + continue; + } + if (c == 0) { + escape = true; + } else if (c == '\n' || c == '\r') { + if (!text.empty()) { + if (line) line(text); + text.clear(); + } + } else if (c >= 0x20 && c < 0x7f) { + text.push_back(char(c)); + if (text.size() >= 200) { + if (line) line(text); + text.clear(); + } + } + } + } + if (!text.empty() && line) line(text); + return true; +} + +namespace { + +// A Link that first hands out bytes already read off the real one. +class Prefixed : public Link { + public: + Prefixed(Link& inner, std::vector& prefix) : inner_(inner), prefix_(prefix) {} + bool write(const void* d, size_t n) override { return inner_.write(d, n); } + int read(void* d, size_t n, int timeoutMs) override { + if (!prefix_.empty()) { + size_t k = std::min(n, prefix_.size()); + std::memcpy(d, prefix_.data(), k); + prefix_.erase(prefix_.begin(), prefix_.begin() + k); + return int(k); + } + return inner_.read(d, n, timeoutMs); + } + + private: + Link& inner_; + std::vector& prefix_; +}; + +} // namespace + +bool PcdrvHost::handleCall(Link& raw, const std::function& event, std::string* err) { + Prefixed link(raw, pending_); + In in{link, err}; + auto report = [&](const std::string& s) { + if (event) event(s); + }; + auto closed = [&] { + *err = "The connection closed."; + return false; + }; + uint32_t op; + if (!in.u32(&op)) return false; + // Presence: the console gives up on the call unless the host says OKAY. + if (!sendText(link, "OKAY")) return closed(); + + if (op == kInit) { + report("init"); + const uint8_t zero = 0; + return link.write(&zero, 1) || closed(); + } + + if (op == kOpen) { + std::string name; + for (;;) { + uint8_t c; + if (!in.bytes(&c, 1)) return false; + if (!c) break; + if (name.size() < 1024) name.push_back(char(c)); + } + uint32_t flags; + if (!in.u32(&flags)) return false; + bool ok; + const fs::path path = resolve(name, &ok); + std::FILE* f = nullptr; +#ifdef _WIN32 + if (ok) f = _wfopen(path.c_str(), L"rb"); +#else + if (ok) f = std::fopen(path.c_str(), "rb"); +#endif + if (!f) { + report("open " + name + ": " + (ok ? "not found" : "outside the play folder")); + return refuse(link) || closed(); + } + const uint32_t h = nextHandle_++; + files_[h] = f; + report("open " + name + " -> " + std::to_string(h)); + return (sendText(link, "OKAY") && sendWord(link, h)) || closed(); + } + + uint32_t a1, a2, a3; + if (!in.u32(&a1) || !in.u32(&a2) || !in.u32(&a3)) return false; + auto it = files_.find(a1); + std::FILE* f = it == files_.end() ? nullptr : it->second; + const std::string h = std::to_string(a1); + + switch (op) { + case kClose: + if (f) { + std::fclose(f); + files_.erase(it); + } + report("close " + h); + return (sendText(link, "OKAY") && sendWord(link, a1)) || closed(); + + case kSeek: { + const int whence = a3 == 1 ? SEEK_CUR : a3 == 2 ? SEEK_END : SEEK_SET; + if (!f || std::fseek(f, long(int32_t(a2)), whence) != 0) { + report("seek " + h + ": failed"); + return refuse(link) || closed(); + } + const long pos = std::ftell(f); + report("seek " + h + " -> " + std::to_string(pos)); + return (sendText(link, "OKAY") && sendWord(link, uint32_t(pos))) || closed(); + } + + case kRead: { + if (!f || a2 > kMaxTransfer) { + report("read " + h + ": refused"); + return refuse(link) || closed(); + } + // The console reads exactly the count it is told, so the count is what the file had. + std::vector data(a2); + data.resize(std::fread(data.data(), 1, a2, f)); + if (!sendText(link, "OKAY") || !sendWord(link, uint32_t(data.size())) || + !sendWord(link, byteSum(data.data(), data.size())) || !link.write(data.data(), data.size())) + return closed(); + report("read " + h + ": " + std::to_string(data.size()) + " of " + std::to_string(a2) + " bytes"); + return true; + } + + default: + report("unsupported file call 0x" + [&] { + char b[16]; + std::snprintf(b, sizeof b, "%x", unsigned(op)); + return std::string(b); + }()); + return refuse(link) || closed(); + } +} + +} // namespace editor diff --git a/src/editor/unirom.hh b/src/editor/unirom.hh new file mode 100644 index 0000000..de3fc7d --- /dev/null +++ b/src/editor/unirom.hh @@ -0,0 +1,58 @@ +// Run on hardware: upload a PS-X EXE to a console sitting at the Unirom shell, +// then answer the PCdrv file requests it makes over the same link. +// +// Upload is Unirom's SEXE: the EXE header, then the program in 2048-byte +// chunks acked CHEK/MORE from protocol V2 on; the program starts after the +// last one. Afterwards the console sends text, and psxsplash's file calls +// (pcdrv_handler.hh, its SIO1 path on hardware) as 0x00 'p' and a call number: +// 0x101 init, 0x103 open, 0x104 close, 0x105 read, 0x107 seek. Files are +// read-only. All words are little-endian. No SDL. +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "editor/serial.hh" + +namespace editor { + +// Called with a percentage while the program is sent. +using UploadProgress = std::function; + +// Sends `exe` (a whole PS-X EXE file) and starts it. False with *err when the +// console does not answer or the transfer fails. Polls `cancel` between steps. +bool uniromUpload(Link& link, const std::vector& exe, std::string* err, const UploadProgress& progress = {}, + const std::atomic* cancel = nullptr); + +// Serves PCdrv out of `base`: names resolve under it, and a name that would +// leave it is refused. +class PcdrvHost { + public: + explicit PcdrvHost(std::filesystem::path base); + ~PcdrvHost(); + PcdrvHost(const PcdrvHost&) = delete; + PcdrvHost& operator=(const PcdrvHost&) = delete; + + // Reads the link until `cancel` is set or the link goes away, passing each + // line of console text to `line` and one line per file call to `event`. + // Returns false when the link was lost or the protocol broke, with *err. + bool serve(Link& link, const std::atomic& cancel, const std::function& line, + const std::function& event, std::string* err); + + private: + bool handleCall(Link& link, const std::function& event, std::string* err); + std::filesystem::path resolve(const std::string& name, bool* ok) const; + + std::filesystem::path base_; + std::map files_; + uint32_t nextHandle_ = 3; + std::vector pending_; // bytes read ahead of the call being parsed +}; + +} // namespace editor diff --git a/src/mockup/main_screen.cpp b/src/mockup/main_screen.cpp index 6cfc45a..149da74 100644 --- a/src/mockup/main_screen.cpp +++ b/src/mockup/main_screen.cpp @@ -115,7 +115,7 @@ static void togglePlay(State& st, const editor::Document& doc) { p.peakScene = doc.loadId(); if (!editor::missingTools(editor::withDefaults(p.tools, p.bundleDir)).empty()) { p.openSetup = true; - p.setupForExport = false; + p.setupFor = 0; return; } p.build = std::async(std::launch::async, [scene = doc.scene(), root = doc.projectRoot()] { @@ -123,11 +123,47 @@ static void togglePlay(State& st, const editor::Document& doc) { }); } +// Run on hardware exports here, apart from Play's folder, and serves this one. +static std::filesystem::path hardwareDir() { + std::error_code ec; + std::filesystem::path tmp = std::filesystem::temp_directory_path(ec); + return (ec ? std::filesystem::path(".") : tmp) / "splashedit-hardware"; +} + +// Ctrl+F5 and the Run on hardware button: export, upload to the console and +// serve its files; or stop the session that is running. +static void toggleRun(State& st, const editor::Document& doc) { + State::Play& p = st.play; + if (p.hw.active()) { + p.hw.stop(); + return; + } + if (p.hwBuild.valid()) return; + p.message.clear(); + if (!editor::missingHardwareTools(editor::withDefaults(p.tools, p.bundleDir)).empty()) { + p.openSetup = true; + p.setupFor = 2; + return; + } + p.hwBuild = std::async(std::launch::async, [scene = doc.scene(), root = doc.projectRoot()] { + return editor::exportForPlay(scene, root, hardwareDir()); + }); +} + // Ctrl+B and the Export button: ask where the disc image goes, then build it. static void startExport(State& st, const editor::Document& doc); static void updatePlay(State& st) { State::Play& p = st.play; + if (p.hwBuild.valid() && p.hwBuild.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { + splash::ExportResult r = p.hwBuild.get(); + const editor::PlayTools eff = editor::withDefaults(p.tools, p.bundleDir); + if (!r.ok()) p.message = "Run on hardware: " + r.errors.front(); + else { + p.hwPort = eff.port; + p.hw.start(editor::HardwareRun::port(eff.port), eff.psxsplash, hardwareDir()); + } + } p.emu.poll(); // Read lines printed since the last frame (some may already have scrolled out). const std::deque& out = p.emu.output(); @@ -165,6 +201,15 @@ static std::mutex g_pickMutex; static int g_pickWhich = -1; static std::string g_pickPath; +static int resizePort(ImGuiInputTextCallbackData* d) { + if (d->EventFlag == ImGuiInputTextFlags_CallbackResize) { + auto* str = static_cast(d->UserData); + str->resize(size_t(d->BufTextLen)); + d->Buf = str->data(); + } + return 0; +} + static void SDLCALL onToolPicked(void* which, const char* const* files, int) { if (!files || !files[0]) return; std::lock_guard lock(g_pickMutex); @@ -212,6 +257,7 @@ static void playSetup(State& st, const editor::Document& doc, ImVec2 size) { const char* id = "##playsetup"; if (p.openSetup) { p.openSetup = false; + if (p.setupFor == 2) p.ports = editor::listSerialPorts(); ImGui::OpenPopup(id); } const float w = 560; @@ -227,12 +273,14 @@ static void playSetup(State& st, const editor::Document& doc, ImVec2 size) { const ImVec2 o = ImGui::GetCursorScreenPos(); const float inner = w - space::lg * 2; float y = o.y; - const bool forExport = p.setupForExport; - text(dl, ImVec2(o.x, y), f.semibold, type::title, color::text, forExport ? "Set up Export" : "Set up Play"); + const int mode = std::clamp(p.setupFor, 0, 2); + const char* titles[3] = {"Set up Play", "Set up Export", "Set up Run on hardware"}; + const char* blurbs[3] = {"Play exports the scene and boots it in pcsx-redux on your psxsplash build.", + "Export puts the scene on a disc image with the CD-ROM build of psxsplash.", + "Uploads psxsplash to a console at the Unirom shell, then serves it the scene over the cable."}; + text(dl, ImVec2(o.x, y), f.semibold, type::title, color::text, titles[mode]); y += 24; - text(dl, ImVec2(o.x, y), f.regular, type::label, color::textDim, - forExport ? "Export puts the scene on a disc image with the CD-ROM build of psxsplash." - : "Play exports the scene and boots it in pcsx-redux on your psxsplash build."); + text(dl, ImVec2(o.x, y), f.regular, type::label, color::textDim, blurbs[mode]); y += 30; const editor::PlayTools eff = editor::withDefaults(p.tools, p.bundleDir); @@ -248,8 +296,8 @@ static void playSetup(State& st, const editor::Document& doc, ImVec2 size) { static const SDL_DialogFileFilter exeFilter[] = {{"PlayStation executable", "ps-exe;exe"}}; static const SDL_DialogFileFilter biosFilter[] = {{"BIOS image", "bin;rom"}}; // Each popup lists what its own action runs. - const int shown[2][3] = {{0, 1, 2}, {3, -1, -1}}; - for (int i : shown[forExport]) { + const int shown[3][3] = {{0, 1, 2}, {3, -1, -1}, {1, -1, -1}}; + for (int i : shown[mode]) { if (i < 0) continue; const Row& r = rows[i]; ImGui::PushID(i); @@ -281,16 +329,69 @@ static void playSetup(State& st, const editor::Document& doc, ImVec2 size) { ImGui::PopID(); y += rowH + space::sm; } + if (mode == 2) { + // The port: typed, or one of those found on this machine. + const float rowH = 44; + dl->AddRectFilled(ImVec2(o.x, y), ImVec2(o.x + inner, y + rowH), color::field, radius::field); + text(dl, ImVec2(o.x + space::md, y + 6), f.medium, type::body, color::text, "Serial port"); + text(dl, ImVec2(o.x + space::md, y + 24), f.regular, type::caption, p.tools.port.empty() ? color::warn : color::textFaint, + p.tools.port.empty() ? "Not set" : "115200 baud, 8N2"); + const float fx = o.x + 150, fw = inner - 150 - space::sm; + ImGui::SetCursorScreenPos(ImVec2(fx, y + (rowH - size::field) * 0.5f)); + ImGui::SetNextItemWidth(fw); + ImGui::PushFont(f.regular, type::body); + ImGui::PushStyleColor(ImGuiCol_FrameBg, color::raised); + ImGui::PushStyleColor(ImGuiCol_Text, color::text); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(space::sm, (size::field - type::body) * 0.5f - 1)); + ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, radius::field); + if (ImGui::InputTextWithHint("##port", "COM3, /dev/ttyUSB0, or tcp:localhost:6699", p.tools.port.data(), + p.tools.port.capacity() + 1, ImGuiInputTextFlags_CallbackResize, resizePort, &p.tools.port) && + !p.settingsFile.empty()) + editor::savePlayTools(p.settingsFile, p.tools); + interactiveRects().push_back(ImRect(ImVec2(fx, y), ImVec2(fx + fw, y + rowH))); + ImGui::PopStyleVar(2); + ImGui::PopStyleColor(2); + ImGui::PopFont(); + y += rowH + space::sm; + if (!p.ports.empty()) { + text(dl, ImVec2(o.x, y + 6), f.regular, type::caption, color::textFaint, "Found:"); + const float left = o.x + 50; + float px = left; + for (size_t i = 0; i < p.ports.size() && i < 16; ++i) { + const float bw = buttonWidth(nullptr, p.ports[i].c_str()); + if (px > left && px + bw > o.x + inner) { // wrap + px = left; + y += size::field + 6 + space::xs; + } + ImGui::PushID(int(i) + 100); + if (button("port", ImVec2(px, y), nullptr, p.ports[i].c_str(), + p.tools.port == p.ports[i] ? ButtonKind::Secondary : ButtonKind::Ghost)) { + p.tools.port = p.ports[i]; + if (!p.settingsFile.empty()) editor::savePlayTools(p.settingsFile, p.tools); + } + px += bw + space::xs; + ImGui::PopID(); + } + y += size::field + 6 + space::sm; + } + } y += space::sm; - const bool ready = (forExport ? editor::missingDiscTools(eff) : editor::missingTools(eff)).empty(); - const char* goIcon = forExport ? icon::package : icon::play; - const char* goLabel = forExport ? "Export" : "Play"; + const editor::PlayTools effNow = editor::withDefaults(p.tools, p.bundleDir); + const bool ready = (mode == 1 ? editor::missingDiscTools(effNow) + : mode == 2 ? editor::missingHardwareTools(effNow) + : editor::missingTools(effNow)) + .empty(); + const char* goIcons[3] = {icon::play, icon::package, icon::cpu}; + const char* goLabels[3] = {"Play", "Export", "Run"}; + const char* goIcon = goIcons[mode]; + const char* goLabel = goLabels[mode]; const float pw = buttonWidth(goIcon, goLabel), cw = buttonWidth(icon::x, "Cancel"); float bx = o.x + inner - pw; if (ready) { if (button("go", ImVec2(bx, y), goIcon, goLabel, ButtonKind::Primary)) { ImGui::CloseCurrentPopup(); - if (forExport) startExport(st, doc); + if (mode == 1) startExport(st, doc); + else if (mode == 2) toggleRun(st, doc); else togglePlay(st, doc); } } @@ -358,7 +459,10 @@ static ImRect titleBar(State& st, ImDrawList* dl, ImVec2 size, editor::Document& const char* playIcon = playing ? icon::square : icon::play; const char* playLabel = playing ? "Stop" : building ? "Building" : "Play"; wPlay = width(playIcon, playLabel); - wRun = width(icon::cpu, "Run on hardware"); + const bool onHw = st.play.hw.active(), hwBuilding = st.play.hwBuild.valid(); + const char* runIcon = onHw ? icon::square : icon::cpu; + const char* runLabel = onHw ? "Stop hardware" : hwBuilding ? "Building" : "Run on hardware"; + wRun = width(runIcon, runLabel); const bool exporting = st.play.disc.valid(); const char* exportLabel = exporting ? "Exporting" : "Export"; wExport = width(icon::package, exportLabel); @@ -368,8 +472,9 @@ static ImRect titleBar(State& st, ImDrawList* dl, ImVec2 size, editor::Document& if (button("play", ImVec2(ax, ay), playIcon, playLabel, ButtonKind::Primary, nullptr, st.play.emu.running() ? "Stop pcsx-redux (F5)" : "Build and run in pcsx-redux (F5)")) togglePlay(st, doc); - button("run", ImVec2(ax + wPlay + space::sm, ay), icon::cpu, "Run on hardware", ButtonKind::Secondary, nullptr, - "Upload to a console over serial (Ctrl+F5)"); + if (button("run", ImVec2(ax + wPlay + space::sm, ay), runIcon, runLabel, ButtonKind::Secondary, nullptr, + onHw ? "Stop serving the console (Ctrl+F5)" : "Upload to a console over serial (Ctrl+F5)")) + toggleRun(st, doc); if (button("export", ImVec2(ax + wPlay + wRun + space::sm * 2, ay), icon::package, exportLabel, ButtonKind::Ghost, nullptr, "Build a disc image (Ctrl+B)")) startExport(st, doc); @@ -759,7 +864,7 @@ static void startExport(State& st, const editor::Document& doc) { if (p.disc.valid() || doc.projectRoot().empty()) return; if (!editor::missingDiscTools(editor::withDefaults(p.tools, p.bundleDir)).empty()) { p.openSetup = true; - p.setupForExport = true; + p.setupFor = 1; return; } if (!p.exportTo.empty()) { @@ -1040,6 +1145,8 @@ static std::vector menuEntries(int which, State& st, editor::Document break; case 3: // Build m.push_back(item(st.play.emu.running() ? "Stop" : "Play", "F5", !st.play.build.valid(), [&] { togglePlay(st, doc); })); + m.push_back(item(st.play.hw.active() ? "Stop Hardware Run" : "Run on Hardware", "Ctrl+F5", !st.play.hwBuild.valid(), + [&] { toggleRun(st, doc); })); m.push_back(item("Export Disc Image...", "Ctrl+B", !st.play.disc.valid(), [&] { startExport(st, doc); })); break; case 4: // View @@ -3197,7 +3304,7 @@ static std::string mb(size_t bytes) { // Status bar entry for Play: building, running (click for its output), how // it ended, or why it did not start. -static void playStatus(ImDrawList* dl, ImRect bar, float x, State& st) { +static float playStatus(ImDrawList* dl, ImRect bar, float x, State& st) { State::Play& p = st.play; Fonts& f = fonts(); std::string label; @@ -3215,7 +3322,7 @@ static void playStatus(ImDrawList* dl, ImRect bar, float x, State& st) { label = c ? "pcsx-redux exited with code " + std::to_string(c) : "pcsx-redux closed"; tone = c ? color::bad : color::textFaint; } else { - return; + return x; } ImVec2 ls = measure(f.medium, type::caption, label.c_str()); float pad = dot ? 22 : 10; @@ -3235,7 +3342,7 @@ static void playStatus(ImDrawList* dl, ImRect bar, float x, State& st) { const bool open = ImGui::BeginPopup("##playoutput", ImGuiWindowFlags_NoSavedSettings); ImGui::PopStyleVar(4); ImGui::PopStyleColor(2); - if (!open) return; + if (!open) return r.Max.x + space::md; ImGui::PushFont(f.regular, type::caption); ImGui::PushStyleColor(ImGuiCol_ChildBg, color::field); ImGui::PushStyleColor(ImGuiCol_Text, color::textDim); @@ -3251,6 +3358,71 @@ static void playStatus(ImDrawList* dl, ImRect bar, float x, State& st) { ImGui::Dummy(ImVec2(1, size::field + 6)); } ImGui::EndPopup(); + return r.Max.x + space::md; +} + +// Status bar entry for Run on hardware: building, uploading, running (click +// for what the console printed and the files it asked for), or why it stopped. +static void hardwareStatus(ImDrawList* dl, ImRect bar, float x, State& st) { + State::Play& p = st.play; + Fonts& f = fonts(); + using Phase = editor::HardwareRun::Phase; + std::string label; + ImU32 tone = color::textDim, dot = 0; + const Phase ph = p.hw.phase(); + if (p.hwBuild.valid()) { + label = "Building for the console"; + } else if (ph == Phase::Connecting) { + label = "Connecting to " + p.hwPort; + } else if (ph == Phase::Uploading) { + label = "Uploading " + std::to_string(p.hw.progress()) + "%"; + dot = color::warn; + } else if (ph == Phase::Running) { + label = "Running on " + p.hwPort; + dot = color::good; + } else if (ph == Phase::Failed) { + label = p.hw.error(); + tone = color::bad; + } else if (ph == Phase::Stopped) { + label = "Hardware run stopped"; + tone = color::textFaint; + } else { + return; + } + ImVec2 ls = measure(f.medium, type::caption, label.c_str()); + float pad = dot ? 22 : 10; + ImRect r(ImVec2(x, bar.Min.y + 5), ImVec2(x + pad + ls.x + 10, bar.Max.y - 5)); + Hit h = interact("hwstatus", r); + const bool hasOutput = p.hw.lineCount() > 0; + if (hasOutput) { + dl->AddRectFilled(r.Min, r.Max, lerpColor(color::raised, color::hover, h.hover), radius::pill); + if (h.clicked) ImGui::OpenPopup("##hwoutput"); + } + if (dot) dl->AddCircleFilled(ImVec2(r.Min.x + 12, r.GetCenter().y), 3.5f, dot, 12); + text(dl, ImVec2(r.Min.x + pad, bar.Min.y + (size::statusBar - ls.y) * 0.5f), f.medium, type::caption, tone, label.c_str()); + + ImGui::SetNextWindowPos(ImVec2(r.Min.x, r.Min.y - space::xs), ImGuiCond_Always, ImVec2(0, 1)); + ImGui::SetNextWindowSize(ImVec2(640, 0)); + pushPopupStyle(); + const bool open = ImGui::BeginPopup("##hwoutput", ImGuiWindowFlags_NoSavedSettings); + ImGui::PopStyleVar(4); + ImGui::PopStyleColor(2); + if (!open) return; + ImGui::PushFont(f.regular, type::caption); + ImGui::PushStyleColor(ImGuiCol_ChildBg, color::field); + ImGui::PushStyleColor(ImGuiCol_Text, color::textDim); + ImGui::BeginChild("##hwlines", ImVec2(0, 320), ImGuiChildFlags_None, ImGuiWindowFlags_HorizontalScrollbar); + for (const std::string& line : p.hw.lines()) ImGui::TextUnformatted(line.c_str()); + if (ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 4) ImGui::SetScrollHereY(1.0f); + ImGui::EndChild(); + ImGui::PopStyleColor(2); + ImGui::PopFont(); + if (p.hw.active()) { + ImVec2 c = ImGui::GetCursorScreenPos(); + if (button("stophw", c, icon::square, "Stop", ButtonKind::Secondary)) p.hw.stop(); + ImGui::Dummy(ImVec2(1, size::field + 6)); + } + ImGui::EndPopup(); } static void statusBar(ImDrawList* dl, ImVec2 size, const editor::Document& doc, State& st) { @@ -3288,7 +3460,8 @@ static void statusBar(ImDrawList* dl, ImVec2 size, const editor::Document& doc, res ? color::bad : color::textFaint, msg); x += ms.x + space::xl; } - playStatus(dl, bar, x, st); + x = playStatus(dl, bar, x, st); + hardwareStatus(dl, bar, x, st); // Right side: problems and save state. const bool showNotice = st.saveError.empty() && !st.notice.empty() && ImGui::GetTime() < st.noticeUntil; @@ -3383,6 +3556,7 @@ static void shortcuts(State& st, editor::Document& doc) { if (ctrl && !shift && pressed(ImGuiKey_A)) st.openAddObject = true; if (ctrl && !shift && pressed(ImGuiKey_F)) st.focusTreeFilter = true; if (!ctrl && pressed(ImGuiKey_F5)) togglePlay(st, doc); + if (ctrl && !shift && pressed(ImGuiKey_F5)) toggleRun(st, doc); if (ctrl && !shift && pressed(ImGuiKey_S)) saveDoc(st, doc); if (ctrl && !shift && pressed(ImGuiKey_B)) startExport(st, doc); if (ctrl && shift && pressed(ImGuiKey_S) && !doc.projectRoot().empty()) saveDocAs(doc); diff --git a/src/mockup/main_screen.h b/src/mockup/main_screen.h index d116f37..023d3c9 100644 --- a/src/mockup/main_screen.h +++ b/src/mockup/main_screen.h @@ -10,6 +10,7 @@ #include "disc.hh" #include "unitymath.hh" +#include "editor/hardware.hh" #include "editor/live_export.hh" #include "editor/play.hh" #include "editor/project.hh" @@ -73,7 +74,12 @@ struct State { // and whether the setup popup was opened for it rather than for Play. std::future disc; std::filesystem::path discOut; - bool setupForExport = false; + int setupFor = 0; // which action the setup popup is for: 0 Play, 1 Export, 2 Run on hardware + // Run on hardware (Ctrl+F5): the export it uploads, then the serial session. + std::future hwBuild; + editor::HardwareRun hw; + std::string hwPort; // the port the session was started on + std::vector ports; // serial ports found when the setup popup opened std::filesystem::path exportTo; // screenshot mode: write here instead of asking // What the engine reported using in the last Play of scene `peakScene` // (Document::loadId), kept after it stops.