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.