diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index aeb6569..86a2c0f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -292,6 +292,9 @@ jobs: - name: Triangles the engine lost, in the Game view and Render buffers if: runner.os == 'Linux' run: LIBGL_ALWAYS_SOFTWARE=1 xvfb-run -a -s "-screen 0 1600x960x24" python3 tests/renderlostcheck.py build/splashedit + - name: Play with 4 consoles through the relay, grid and Network view + if: runner.os == 'Linux' + run: LIBGL_ALWAYS_SOFTWARE=1 xvfb-run -a -s "-screen 0 1600x960x24" python3 tests/relayviewcheck.py build/splashedit - name: Far scenery pops in, warned with its fog fix if: runner.os == 'Linux' run: LIBGL_ALWAYS_SOFTWARE=1 xvfb-run -a -s "-screen 0 1600x960x24" python3 tests/farpopcheck.py build/splashedit diff --git a/CMakeLists.txt b/CMakeLists.txt index a7898db..e40ff53 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -93,6 +93,7 @@ add_library(splasheditdoc STATIC src/editor/lua_editor.cpp src/editor/play.cpp src/editor/linkplay.cpp + src/editor/relay.cpp src/editor/model_import.cpp src/editor/project.cpp src/editor/serial.cpp diff --git a/src/editor/play.cpp b/src/editor/play.cpp index 2d88ecf..8079b30 100644 --- a/src/editor/play.cpp +++ b/src/editor/play.cpp @@ -44,7 +44,11 @@ PlayTools loadPlayTools(const fs::path& file) { else if (key == "overlay_perf") t.overlayPerf = value == "1"; else if (key == "overlay_rooms") t.overlayRooms = value == "1"; else if (key == "boot_logo") t.bootLogo = value != "0"; - else if (key == "players") t.players = value == "2" ? 2 : 1; + else if (key == "players") { + char* end = nullptr; + const long n = std::strtol(value.c_str(), &end, 10); + t.players = value.empty() || *end ? 1 : int(std::clamp(n, 1, kMaxPlayers)); + } else if (key == "relay") t.relay = value == "1"; } return t; } @@ -61,7 +65,8 @@ bool savePlayTools(const fs::path& file, const PlayTools& t) { << "overlay_perf=" << (t.overlayPerf ? 1 : 0) << "\n" << "overlay_rooms=" << (t.overlayRooms ? 1 : 0) << "\n" << "boot_logo=" << (t.bootLogo ? 1 : 0) << "\n" - << "players=" << (t.players == 2 ? 2 : 1) << "\n"; + << "players=" << std::clamp(t.players, 1, kMaxPlayers) << "\n" + << "relay=" << (t.relay ? 1 : 0) << "\n"; return bool(out); } diff --git a/src/editor/play.hh b/src/editor/play.hh index f2ec50b..26361e1 100644 --- a/src/editor/play.hh +++ b/src/editor/play.hh @@ -26,13 +26,20 @@ struct PlayTools { std::string port; // Run on hardware: a serial port, or tcp:HOST:PORT bool overlayPerf = false, overlayRooms = false; bool bootLogo = true; - // Play boots 1 console, or 2 linked over SIO1 (editor/linkplay.hh). + // Play boots 1 console, or 2 to kMaxPlayers linked ones. 2 are linked + // to each other over SIO1 (editor/linkplay.hh) unless `relay` is on; + // 3 or more always go through the editor's relay (editor/relay.hh). int players = 1; + bool relay = false; }; +inline constexpr int kMaxPlayers = 10; +// Whether a Play with these tools runs its consoles through the relay. +inline bool usesRelay(const PlayTools& t) { return t.players >= 3 || (t.players == 2 && t.relay); } // key=value lines: redux=, psxsplash=, bios=, disc=, port=, overlay_perf=, -// overlay_rooms=, boot_logo= (1 or 0), players= (1 or 2). A missing file or -// key reads as empty or off, but boot_logo as on and players as 1. +// overlay_rooms=, boot_logo= (1 or 0), players= (1 to kMaxPlayers, clamped), +// relay= (1 or 0). A missing file or key reads as empty or off, but boot_logo +// as on and players as 1 (also for a value that is not a number). PlayTools loadPlayTools(const std::filesystem::path& file); bool savePlayTools(const std::filesystem::path& file, const PlayTools& tools); diff --git a/src/editor/relay.cpp b/src/editor/relay.cpp new file mode 100644 index 0000000..aab9d48 --- /dev/null +++ b/src/editor/relay.cpp @@ -0,0 +1,895 @@ +#include "editor/relay.hh" + +#include +#include +#include + +#ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#include +#else +#include +#include +#include +#include +#include +#include +#include +#include +#endif + +namespace editor::relay { + +// ---- little-endian helpers ---- + +static void put16(std::vector& b, uint16_t v) { + b.push_back(uint8_t(v)); + b.push_back(uint8_t(v >> 8)); +} +static void put32(std::vector& b, uint32_t v) { + for (int i = 0; i < 4; ++i) b.push_back(uint8_t(v >> (8 * i))); +} +static uint16_t get16(const uint8_t* p) { return uint16_t(p[0] | (p[1] << 8)); } +static uint32_t get32(const uint8_t* p) { return uint32_t(p[0]) | uint32_t(p[1]) << 8 | uint32_t(p[2]) << 16 | uint32_t(p[3]) << 24; } + +std::string packetTypeName(uint8_t type) { + static const char* names[] = {"Hello", "HelloAck", "Snapshot", "Event", "EventAck", + "Ping", "Bye", "ObjectState", "AppData", "Pong"}; + if (type >= 1 && type <= kPacketTypes) return names[type - 1]; + return "Type " + std::to_string(type); +} + +uint16_t crc16(const uint8_t* data, size_t n, uint16_t crc) { + for (size_t i = 0; i < n; ++i) { + crc = uint16_t(crc ^ (data[i] << 8)); + for (int b = 0; b < 8; ++b) crc = (crc & 0x8000) ? uint16_t((crc << 1) ^ 0x1021) : uint16_t(crc << 1); + } + return crc; +} + +std::vector encodeFrame(uint8_t type, const std::vector& payload, uint8_t srcSlot, uint16_t seq, + uint8_t flags) { + const size_t n = std::min(payload.size(), kMaxPayload); + std::vector f; + f.reserve(1 + kHeaderSize + n + 2); + f.push_back(kSync); + f.push_back(type); + f.push_back(srcSlot); + f.push_back(flags); + f.push_back(0); + put16(f, seq); + put16(f, uint16_t(n)); + f.insert(f.end(), payload.begin(), payload.begin() + std::ptrdiff_t(n)); + put16(f, crc16(f.data() + 1, kHeaderSize + n)); + return f; +} + +void FrameParser::feed(const uint8_t* data, size_t n, std::vector& out) { + m_buf.insert(m_buf.end(), data, data + n); + size_t at = 0; // consumed so far + for (;;) { + while (at < m_buf.size() && m_buf[at] != kSync) ++at; // hunt for the sync byte + if (m_buf.size() - at < 1 + kHeaderSize) break; + const uint8_t* h = m_buf.data() + at + 1; + const uint16_t len = get16(h + 6); + if (len > kMaxPayload) { // a mis-sync: hunt again from the next byte + ++resyncs; + ++at; + continue; + } + const size_t total = 1 + kHeaderSize + len + 2; + if (m_buf.size() - at < total) break; + if (crc16(h, kHeaderSize + len) != get16(h + kHeaderSize + len)) { + ++crcErrors; + ++at; + continue; + } + Frame f; + f.type = h[0]; + f.srcSlot = h[1]; + f.flags = h[2]; + f.reserved = h[3]; + f.seq = get16(h + 4); + f.payload.assign(h + kHeaderSize, h + kHeaderSize + len); + out.push_back(std::move(f)); + ++frames; + at += total; + } + m_buf.erase(m_buf.begin(), m_buf.begin() + std::ptrdiff_t(at)); +} + +// ---- protobuf ---- + +static void putVarint(std::vector& b, uint64_t v) { + while (v >= 0x80) { + b.push_back(uint8_t(v | 0x80)); + v >>= 7; + } + b.push_back(uint8_t(v)); +} + +void encodeSio(const uint8_t* data, size_t n, std::vector& out) { + for (size_t at = 0; at < n; at += kMaxSioData) { + const size_t k = std::min(kMaxSioData, n - at); + std::vector inner; // DataTransfer{1: data} + inner.push_back(0x0A); + putVarint(inner, k); + inner.insert(inner.end(), data + at, data + at + k); + std::vector msg; // SIOPayload{1: DataTransfer} + msg.push_back(0x0A); + putVarint(msg, inner.size()); + msg.insert(msg.end(), inner.begin(), inner.end()); + out.push_back(uint8_t(msg.size())); // at most 251 with k <= 245 + out.insert(out.end(), msg.begin(), msg.end()); + } +} + +namespace { +// Reads protobuf fields from [p, end); false when the bytes are not well formed. +struct Reader { + const uint8_t *p, *end; + bool varint(uint64_t* v) { + uint64_t r = 0; + for (int shift = 0; shift < 64; shift += 7) { + if (p >= end) return false; + const uint8_t b = *p++; + r |= uint64_t(b & 0x7F) << shift; + if (!(b & 0x80)) { + *v = r; + return true; + } + } + return false; + } + // Skips one field of `wire` type; false if it cannot. + bool skip(unsigned wire) { + uint64_t v; + switch (wire) { + case 0: return varint(&v); + case 1: if (end - p < 8) return false; p += 8; return true; + case 2: if (!varint(&v) || uint64_t(end - p) < v) return false; p += v; return true; + case 5: if (end - p < 4) return false; p += 4; return true; + default: return false; + } + } +}; +} // namespace + +void SioDecoder::feed(const uint8_t* data, size_t n, std::vector& out) { + m_buf.insert(m_buf.end(), data, data + n); + size_t at = 0; + while (at < m_buf.size()) { + const size_t size = m_buf[at]; + if (m_buf.size() - at < 1 + size) break; + const uint8_t* msg = m_buf.data() + at + 1; + at += 1 + size; + ++messages; + Reader r{msg, msg + size}; + std::vector got; + bool ok = true, flow = false; + while (ok && r.p < r.end) { + uint64_t key; + if (!r.varint(&key)) { ok = false; break; } + const unsigned field = unsigned(key >> 3), wire = unsigned(key & 7); + if (field == 1 && wire == 2) { // DataTransfer + uint64_t len; + if (!r.varint(&len) || uint64_t(r.end - r.p) < len) { ok = false; break; } + Reader in{r.p, r.p + len}; + r.p += len; + while (ok && in.p < in.end) { + uint64_t ik; + if (!in.varint(&ik)) { ok = false; break; } + if ((ik >> 3) == 1 && (ik & 7) == 2) { + uint64_t dl; + if (!in.varint(&dl) || uint64_t(in.end - in.p) < dl) { ok = false; break; } + got.insert(got.end(), in.p, in.p + dl); + in.p += dl; + } else if (!in.skip(unsigned(ik & 7))) ok = false; + } + } else { + if (field == 2) flow = true; + if (!r.skip(wire)) ok = false; + } + } + if (!ok) { + ++decodeErrors; + continue; + } + if (got.empty() && flow) ++flowControls; + out.insert(out.end(), got.begin(), got.end()); + } + m_buf.erase(m_buf.begin(), m_buf.begin() + std::ptrdiff_t(at)); +} + +// ---- payloads ---- + +std::optional parseHello(const std::vector& b) { + if (b.size() != 16) return std::nullopt; + HelloPayload h; + h.magic = get32(&b[0]); + h.protoVersion = get16(&b[4]); + h.sceneHash = get32(&b[8]); + h.tiebreak = get32(&b[12]); + return h; +} + +std::vector packHello(const HelloPayload& h) { + std::vector b; + put32(b, h.magic); + put16(b, h.protoVersion); + put16(b, 0); + put32(b, h.sceneHash); + put32(b, h.tiebreak); + return b; +} + +std::optional parseHelloAck(const std::vector& b) { + if (b.size() != 16) return std::nullopt; + HelloAckPayload a; + a.magic = get32(&b[0]); + a.protoVersion = get16(&b[4]); + a.yourSlot = b[6]; + a.hostSlot = b[7]; + a.sceneHash = get32(&b[8]); + a.playerCount = b[12]; + return a; +} + +std::vector packHelloAck(const HelloAckPayload& a) { + std::vector b; + put32(b, a.magic); + put16(b, a.protoVersion); + b.push_back(a.yourSlot); + b.push_back(a.hostSlot); + put32(b, a.sceneHash); + b.push_back(a.playerCount); + b.insert(b.end(), 3, 0); + return b; +} + +// ---- snapshots ---- + +static constexpr int64_t kFp12TwoPi = 25736; // one turn in 4.12 fixed point + +std::vector buildSnapshot(std::vector> members) { + std::stable_sort(members.begin(), members.end(), [](const auto& a, const auto& b) { return a.first < b.first; }); + std::vector b; + if (members.size() >= 2) { // compact pays for itself from 2 avatars + int64_t lo[3], hi[3]; + for (int k = 0; k < 3; ++k) lo[k] = hi[k] = 0; + for (size_t i = 0; i < members.size(); ++i) { + const Avatar& a = members[i].second; + const int64_t v[3] = {a.px, a.py, a.pz}; + for (int k = 0; k < 3; ++k) { + if (i == 0 || v[k] < lo[k]) lo[k] = v[k]; + if (i == 0 || v[k] > hi[k]) hi[k] = v[k]; + } + } + const int64_t spread = std::max({hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]}); + int shift = -1; + for (int s = 0; s <= 15; ++s) + if ((spread >> s) <= 0xFFFF) { + shift = s; + break; + } + if (shift >= 0) { + b.push_back(uint8_t(members.size())); + b.push_back(0); + b.push_back(1); // SNAP_FORMAT_COMPACT + b.push_back(uint8_t(shift)); + for (int k = 0; k < 3; ++k) put32(b, uint32_t(int32_t(lo[k]))); + for (const auto& [slot, a] : members) { + b.push_back(slot); + b.push_back(a.flags); + put16(b, uint16_t((int64_t(a.px) - lo[0]) >> shift)); + put16(b, uint16_t((int64_t(a.py) - lo[1]) >> shift)); + put16(b, uint16_t((int64_t(a.pz) - lo[2]) >> shift)); + put16(b, uint16_t(((int64_t(a.yaw) % kFp12TwoPi) + kFp12TwoPi) % kFp12TwoPi)); + } + return b; + } + } + b.push_back(uint8_t(members.size())); + b.push_back(0); + b.push_back(0); // SNAP_FORMAT_LEGACY + b.push_back(0); + for (const auto& [slot, a] : members) { + b.push_back(slot); + b.push_back(a.flags); + put16(b, 0); + put32(b, uint32_t(a.px)); + put32(b, uint32_t(a.py)); + put32(b, uint32_t(a.pz)); + put32(b, uint32_t(a.yaw)); + } + return b; +} + +// ---- Hub ---- + +int Hub::connect(double now) { + const int id = m_nextConn++; + Session& s = m_sessions[id]; + s.id = id; + Connection& c = m_conns[id]; + c.index = id; + c.connectedAt = c.lastSeen = now; + return id; +} + +Hub::Room* Hub::room(int id) { + for (Room& r : m_rooms) + if (r.id == id) return &r; + return nullptr; +} + +int Hub::visiblePlayers(int conn) const { + auto it = m_conns.find(conn); + if (it == m_conns.end() || !it->second.room) return 1; + for (const Room& r : m_rooms) { + if (r.id != it->second.room) continue; + int n = 0; + for (const auto& [slot, member] : r.members) + if (m_conns.at(member).sceneHash == it->second.sceneHash) ++n; + return n; + } + return 1; +} + +void Hub::log(double now, uint8_t from, int conn, uint8_t type, uint16_t seq, size_t size, std::vector to) { + const bool folds = type == Snapshot || type == Ping || type == Pong; + const std::pair key(from == kHostSlot ? -conn : conn, type); + if (folds) { + auto it = m_folding.find(key); + if (it != m_folding.end() && !m_log.empty() && it->second >= m_log.front().id) { + Packet& p = m_log[size_t(it->second - m_log.front().id)]; + if (now - p.time < 1.0 && p.to == to && p.from == from) { + ++p.count; + p.size += uint32_t(size); + return; + } + } + } + Packet p; + p.id = m_nextPacket++; + p.time = now; + p.from = from; + p.conn = conn; + p.type = type; + p.seq = seq; + p.size = uint32_t(size); + p.to = std::move(to); + if (folds) m_folding[key] = p.id; + m_log.push_back(std::move(p)); + while (m_log.size() > kPacketLog) m_log.pop_front(); +} + +void Hub::sendRaw(Session& s, const std::vector& frame) { + s.out.insert(s.out.end(), frame.begin(), frame.end()); + Connection& c = info(s); + ++c.framesOut; + c.bytesOut += frame.size(); +} + +void Hub::send(Session& s, uint8_t type, const std::vector& payload, std::optional seq) { + uint16_t q; + if (seq) q = *seq; + else { + q = s.txSeq; + s.txSeq = uint16_t(s.txSeq + 1); + } + sendRaw(s, encodeFrame(type, payload, kHostSlot, q)); +} + +void Hub::sendAck(Session& s, double now) { + Connection& c = info(s); + HelloAckPayload a; + a.yourSlot = c.slot; + a.sceneHash = c.sceneHash; + a.playerCount = uint8_t(visiblePlayers(s.id)); + const uint16_t seq = s.txSeq; + send(s, HelloAck, packHelloAck(a), std::nullopt); + log(now, kHostSlot, s.id, HelloAck, seq, 1 + kHeaderSize + 16 + 2, {c.slot}); +} + +void Hub::announce(int roomId, int except, double now) { + if (quietRoomChanges) return; + Room* r = room(roomId); + if (!r) return; + const std::map members = r->members; + for (const auto& [slot, conn] : members) { + if (conn == except) continue; + auto it = m_sessions.find(conn); + if (it != m_sessions.end() && it->second.connected) sendAck(it->second, now); + } +} + +bool Hub::join(Session& s, uint32_t sceneHash) { + Room* target = nullptr; + for (Room& r : m_rooms) { + if (r.members.size() >= size_t(kMaxSlots - kFirstPlayerSlot)) continue; + if (r.sceneHash && *r.sceneHash != sceneHash) continue; + target = &r; + break; + } + if (!target) { + m_rooms.push_back(Room{}); + target = &m_rooms.back(); + target->id = m_nextRoom++; + } + if (!target->sceneHash) target->sceneHash = sceneHash; + uint8_t slot = kFirstPlayerSlot; + while (slot < kMaxSlots && target->members.count(slot)) ++slot; + if (slot >= kMaxSlots) return false; + target->members[slot] = s.id; + Connection& c = info(s); + c.slot = slot; + c.room = target->id; + c.joined = true; + return true; +} + +void Hub::leave(Session& s, double now) { + Connection& c = info(s); + Room* r = room(c.room); + const int roomId = c.room; + c.room = 0; + c.joined = false; + if (!r) return; + r->members.erase(c.slot); + if (r->members.empty()) { + m_rooms.erase(std::remove_if(m_rooms.begin(), m_rooms.end(), [&](const Room& x) { return x.id == roomId; }), + m_rooms.end()); + return; + } + // The last straggler left a scene move: the room follows the rest (rooms.py Room.remove). + std::optional only; + bool one = true; + for (const auto& [slot, conn] : r->members) { + const uint32_t h = m_conns.at(conn).sceneHash; + if (only && *only != h) one = false; + only = h; + } + if (one && only) r->sceneHash = only; + announce(roomId, s.id, now); +} + +void Hub::close(Session& s, double now, bool told) { + Connection& c = info(s); + if (c.room) leave(s, now); + s.connected = false; + c.open = false; + if (!told) m_closed.push_back(s.id); + m_sessions.erase(s.id); +} + +void Hub::disconnect(int conn, double now) { + auto it = m_sessions.find(conn); + if (it != m_sessions.end()) close(it->second, now, true); +} + +void Hub::receive(int conn, const uint8_t* data, size_t n, double now) { + auto it = m_sessions.find(conn); + if (it == m_sessions.end()) return; + Connection& c = info(it->second); + c.lastSeen = now; + c.bytesIn += n; + std::vector frames; + it->second.parser.feed(data, n, frames); + for (const Frame& f : frames) { + auto again = m_sessions.find(conn); // a Bye ends the session + if (again == m_sessions.end()) break; + ++info(again->second).framesIn; + dispatch(again->second, f, now); + } +} + +void Hub::onHello(Session& s, const Frame& f, double now) { + Connection& c = info(s); + const size_t size = 1 + kHeaderSize + f.payload.size() + 2; + const std::optional h = parseHello(f.payload); + log(now, c.slot, s.id, Hello, f.seq, size, {kHostSlot}); + if (!h || h->magic != kMagic || h->protoVersion != kProtoVersion) return; // the console latches the mismatch itself + if (s.connected) { + // Again: a scene change (or a lost ack). Keep the slot, re-ack. + const bool moved = c.sceneHash != h->sceneHash; + c.sceneHash = h->sceneHash; + s.rxSeq.reset(); + if (Room* r = room(c.room)) { + bool all = true; + for (const auto& [slot, conn] : r->members) + if (m_conns.at(conn).sceneHash != h->sceneHash) all = false; + if (all) r->sceneHash = h->sceneHash; + } + sendAck(s, now); + if (moved && c.room) announce(c.room, s.id, now); + return; + } + c.sceneHash = h->sceneHash; + if (!c.room && !join(s, h->sceneHash)) return; + s.connected = true; + sendAck(s, now); + announce(info(s).room, s.id, now); +} + +void Hub::onReliable(Session& s, const Frame& f, double now) { + Connection& c = info(s); + const size_t size = 1 + kHeaderSize + f.payload.size() + 2; + std::vector others; + std::vector slots; + if (Room* r = room(c.room)) + for (const auto& [slot, conn] : r->members) + if (conn != s.id && m_sessions.count(conn)) { + others.push_back(conn); + slots.push_back(slot); + } + const bool dup = s.rxSeq && *s.rxSeq == f.seq; + const bool relays = !dup && (f.type == ObjectState || f.type == AppData || (f.type == Event && f.payload.size() >= 8)); + log(now, c.slot, s.id, f.type, f.seq, size, relays && !slots.empty() ? slots : std::vector{kHostSlot}); + // Ack duplicates too: that is what stops the sender retransmitting. + send(s, EventAck, {}, f.seq); + log(now, kHostSlot, s.id, EventAck, f.seq, 1 + kHeaderSize + 2, {c.slot}); + if (dup) return; + s.rxSeq = f.seq; + if (!relays) return; + // Events go on as their 8 bytes (id, arg), as psnl does; the rest as they came. + const std::vector payload = + f.type == Event ? std::vector(f.payload.begin(), f.payload.begin() + 8) : f.payload; + for (int conn : others) send(m_sessions.at(conn), f.type, payload, std::nullopt); +} + +void Hub::dispatch(Session& s, const Frame& f, double now) { + Connection& c = info(s); + const size_t size = 1 + kHeaderSize + f.payload.size() + 2; + switch (f.type) { + case Hello: onHello(s, f, now); break; + case Snapshot: { + log(now, c.slot, s.id, Snapshot, f.seq, size, {kHostSlot}); + // The sender's own legacy record is its avatar; anything else in it is ignored. + if (!c.room || f.payload.size() < 4) break; + const size_t count = f.payload[0]; + size_t off = 4; + for (size_t i = 0; i < count && off + 20 <= f.payload.size(); ++i, off += 20) { + const uint8_t* r = f.payload.data() + off; + if (r[0] != c.slot) continue; + s.avatar.flags = r[1]; + s.avatar.px = int32_t(get32(r + 4)); + s.avatar.py = int32_t(get32(r + 8)); + s.avatar.pz = int32_t(get32(r + 12)); + s.avatar.yaw = int32_t(get32(r + 16)); + } + break; + } + case Event: + case ObjectState: + case AppData: onReliable(s, f, now); break; + case Ping: { + log(now, c.slot, s.id, Ping, f.seq, size, {kHostSlot}); + if (f.payload.size() != 12) break; + std::vector pong(f.payload.begin(), f.payload.begin() + 4); // the echo, as sent + pong.insert(pong.end(), 8, 0); + const uint16_t seq = s.txSeq; + send(s, Pong, pong, std::nullopt); + log(now, kHostSlot, s.id, Pong, seq, 1 + kHeaderSize + 12 + 2, {c.slot}); + break; + } + case Bye: + log(now, c.slot, s.id, Bye, f.seq, size, {kHostSlot}); + close(s, now, false); + break; + default: log(now, c.slot, s.id, f.type, f.seq, size, {kHostSlot}); break; + } +} + +void Hub::tick(double now) { + std::vector stale; + for (auto& [id, s] : m_sessions) + if (now - m_conns.at(id).lastSeen > kPeerTimeoutSeconds) stale.push_back(id); + for (int id : stale) + if (auto it = m_sessions.find(id); it != m_sessions.end()) close(it->second, now, false); + if (now < m_nextSnapshot) return; + m_nextSnapshot += kSnapshotSeconds; + if (m_nextSnapshot < now) m_nextSnapshot = now + kSnapshotSeconds; // overran: resync, no catch-up + for (const Room& r : m_rooms) { + std::map>> groups; // scene hash -> (slot, conn) + for (const auto& [slot, conn] : r.members) groups[m_conns.at(conn).sceneHash].push_back({slot, conn}); + for (const auto& [hash, members] : groups) { + std::vector> avatars; + std::vector to; + for (const auto& [slot, conn] : members) { + avatars.push_back({slot, m_sessions.at(conn).avatar}); + to.push_back(slot); + } + const std::vector frame = encodeFrame(Snapshot, buildSnapshot(avatars), kHostSlot, 0); + for (const auto& [slot, conn] : members) sendRaw(m_sessions.at(conn), frame); + log(now, kHostSlot, r.id, Snapshot, 0, frame.size() * members.size(), to); + } + } +} + +std::vector>> Hub::takeOutput() { + std::vector>> out; + for (auto& [id, s] : m_sessions) + if (!s.out.empty()) { + out.push_back({id, std::move(s.out)}); + s.out.clear(); + } + return out; +} + +std::vector Hub::takeClosed() { + std::vector c; + c.swap(m_closed); + return c; +} + +// ---- the server ---- + +#ifdef _WIN32 +using Sock = SOCKET; +static const Sock kNoSock = INVALID_SOCKET; +static void closeSock(Sock s) { closesocket(s); } +static bool wouldBlock() { return WSAGetLastError() == WSAEWOULDBLOCK; } +static void nonBlocking(Sock s) { + u_long on = 1; + ioctlsocket(s, FIONBIO, &on); +} +using PollFd = WSAPOLLFD; +static int pollFds(PollFd* fds, size_t n, int ms) { return WSAPoll(fds, ULONG(n), ms); } +static int sendFlags() { return 0; } +#else +using Sock = int; +static const Sock kNoSock = -1; +static void closeSock(Sock s) { ::close(s); } +static bool wouldBlock() { return errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR; } +static void nonBlocking(Sock s) { + fcntl(s, F_SETFL, fcntl(s, F_GETFL, 0) | O_NONBLOCK); + fcntl(s, F_SETFD, FD_CLOEXEC); // not into the consoles Play starts +} +using PollFd = pollfd; +static int pollFds(PollFd* fds, size_t n, int ms) { return ::poll(fds, nfds_t(n), ms); } +#ifdef MSG_NOSIGNAL +static int sendFlags() { return MSG_NOSIGNAL; } +#else +static int sendFlags() { return 0; } +#endif +#endif + +struct Relay::Impl { + mutable std::mutex mu; // hub + Hub hub; + Sock listener = kNoSock; + struct Conn { + Sock sock = kNoSock; + int id = 0; + SioDecoder decoder; + std::vector out; + bool dead = false; + }; + std::vector conns; + std::chrono::steady_clock::time_point started = std::chrono::steady_clock::now(); +#ifdef _WIN32 + bool wsa = false; +#endif + double now() const { return std::chrono::duration(std::chrono::steady_clock::now() - started).count(); } +}; + +Relay::Relay() : m(std::make_unique()) {} + +Relay::~Relay() { stop(); } + +bool Relay::start(std::string* error) { + stop(); + m = std::make_unique(); + auto fail = [&](const std::string& what) { + if (error) *error = what; + if (m->listener != kNoSock) closeSock(m->listener); + m->listener = kNoSock; + return false; + }; +#ifdef _WIN32 + WSADATA wsa; + if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) return fail("Winsock did not start"); + m->wsa = true; +#endif +#ifdef SOCK_CLOEXEC + m->listener = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, IPPROTO_TCP); +#else + m->listener = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); +#endif + if (m->listener == kNoSock) return fail("cannot open a socket for the relay"); + sockaddr_in a; + std::memset(&a, 0, sizeof a); + a.sin_family = AF_INET; + a.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + a.sin_port = 0; + if (bind(m->listener, reinterpret_cast(&a), sizeof a) != 0 || listen(m->listener, 16) != 0) + return fail("the relay cannot listen on 127.0.0.1"); + sockaddr_in b; + socklen_t n = sizeof b; + if (getsockname(m->listener, reinterpret_cast(&b), &n) != 0) return fail("the relay has no port"); + m_port = ntohs(b.sin_port); + nonBlocking(m->listener); + m_accepted = 0; + m_stop = false; + m_running = true; + m_thread = std::thread([this] { run(); }); + return true; +} + +void Relay::stop() { + if (m_thread.joinable()) { + m_stop = true; + m_thread.join(); + } + m_running = false; + if (!m) return; + for (Impl::Conn& c : m->conns) + if (c.sock != kNoSock) closeSock(c.sock); + m->conns.clear(); + if (m->listener != kNoSock) closeSock(m->listener); + m->listener = kNoSock; +#ifdef _WIN32 + if (m->wsa) WSACleanup(); + m->wsa = false; +#endif +} + +void Relay::run() { + std::vector fds; + std::vector buf(4096), data; + while (!m_stop) { + fds.clear(); + PollFd l{}; + l.fd = m->listener; + l.events = POLLIN; + fds.push_back(l); + for (const Impl::Conn& c : m->conns) { + PollFd p{}; + p.fd = c.sock; + p.events = short(POLLIN | (c.out.empty() ? 0 : POLLOUT)); + fds.push_back(p); + } + pollFds(fds.data(), fds.size(), 5); + // New consoles, in the order they connect. + for (;;) { + // Close-on-exec from the start: Play may be starting a console on another thread. +#if defined(SOCK_CLOEXEC) && defined(__linux__) + Sock s = accept4(m->listener, nullptr, nullptr, SOCK_CLOEXEC); +#else + Sock s = accept(m->listener, nullptr, nullptr); +#endif + if (s == kNoSock) break; + nonBlocking(s); + int one = 1; + setsockopt(s, IPPROTO_TCP, TCP_NODELAY, reinterpret_cast(&one), sizeof one); +#ifdef SO_NOSIGPIPE + setsockopt(s, SOL_SOCKET, SO_NOSIGPIPE, &one, sizeof one); +#endif + Impl::Conn c; + c.sock = s; + { + std::lock_guard lock(m->mu); + c.id = m->hub.connect(m->now()); + } + m->conns.push_back(std::move(c)); + ++m_accepted; + } + const double now = m->now(); + for (size_t i = 0; i < m->conns.size(); ++i) { + Impl::Conn& c = m->conns[i]; + const short re = i + 1 < fds.size() ? fds[i + 1].revents : short(0); + if (!(re & (POLLIN | POLLHUP | POLLERR))) continue; + for (;;) { + const auto got = recv(c.sock, reinterpret_cast(buf.data()), int(buf.size()), 0); + if (got > 0) { + data.clear(); + c.decoder.feed(buf.data(), size_t(got), data); + if (!data.empty()) { + std::lock_guard lock(m->mu); + m->hub.receive(c.id, data.data(), data.size(), now); + } + continue; + } + if (got < 0 && wouldBlock()) break; + c.dead = true; // closed, or failed + break; + } + } + std::vector>> out; + std::vector closed; + { + std::lock_guard lock(m->mu); + for (const Impl::Conn& c : m->conns) + if (c.dead) m->hub.disconnect(c.id, now); + m->hub.tick(now); + out = m->hub.takeOutput(); + closed = m->hub.takeClosed(); + } + for (auto& [id, bytes] : out) + for (Impl::Conn& c : m->conns) + if (c.id == id && !c.dead) encodeSio(bytes.data(), bytes.size(), c.out); + for (Impl::Conn& c : m->conns) { + if (std::find(closed.begin(), closed.end(), c.id) != closed.end()) c.dead = true; + while (!c.dead && !c.out.empty()) { + const auto put = ::send(c.sock, reinterpret_cast(c.out.data()), int(c.out.size()), sendFlags()); + if (put > 0) { + c.out.erase(c.out.begin(), c.out.begin() + put); + continue; + } + if (put < 0 && wouldBlock()) break; + c.dead = true; + } + // A console that stops reading must not grow this without bound. + if (c.out.size() > (4u << 20)) c.dead = true; + } + for (size_t i = 0; i < m->conns.size();) { + if (!m->conns[i].dead) { + ++i; + continue; + } + { + std::lock_guard lock(m->mu); + m->hub.disconnect(m->conns[i].id, now); + } + closeSock(m->conns[i].sock); + m->conns.erase(m->conns.begin() + std::ptrdiff_t(i)); + } + } +} + +std::vector Relay::connections() const { + std::lock_guard lock(m->mu); + std::vector v; + for (const auto& [id, c] : m->hub.connections()) v.push_back(c); + return v; +} + +std::vector Relay::packets(uint64_t after) const { + std::lock_guard lock(m->mu); + std::vector v; + for (const Packet& p : m->hub.packets()) + if (p.id > after) v.push_back(p); + return v; +} + +double Relay::seconds() const { return m->now(); } + +uint64_t Relay::packetCount() const { + std::lock_guard lock(m->mu); + return m->hub.packetCount(); +} + +// ---- the Network view's words ---- + +bool PacketFilter::shows(const Packet& p) const { + if (p.type >= 1 && p.type <= 31 && !(types & (1u << p.type))) return false; + if (slot < 0) return true; + if (p.from == slot) return true; + return std::find(p.to.begin(), p.to.end(), uint8_t(slot)) != p.to.end(); +} + +std::string describeEnd(uint8_t slot, int conn) { + if (slot == kHostSlot) return "relay"; + if (slot == kNoSlot) return "conn " + std::to_string(conn); + return "slot " + std::to_string(slot); +} + +std::string describeTo(const std::vector& to) { + if (to.empty()) return "nobody"; + if (to.size() == 1 && to[0] == kHostSlot) return "relay"; + std::string s = to.size() == 1 ? "slot " : "slots "; + for (size_t i = 0; i < to.size(); ++i) { + if (i) s += ", "; + s += to[i] == kHostSlot ? std::string("relay") : to[i] == kNoSlot ? std::string("?") : std::to_string(to[i]); + } + return s; +} + +} // namespace editor::relay diff --git a/src/editor/relay.hh b/src/editor/relay.hh new file mode 100644 index 0000000..e2bb1ae --- /dev/null +++ b/src/editor/relay.hh @@ -0,0 +1,284 @@ +// Play's relay: a PSNL server inside the editor, so Play can run 3 to 10 +// linked consoles (each a pcsx-redux) with nothing else installed. Every +// console is redux's SIO1 client to it. It is a port of the default relay +// mode of psnl_server (the Python reference server): rooms by scene hash, +// slots 1..10 (slot 0 is the relay itself, the host), reliable Events acked +// and fanned out, ObjectState and AppData relayed, Ping answered, and a 30 Hz +// snapshot of every member's own avatar. Unlike psnl it also tells every +// member when the room changes (an unsolicited HelloAck), so each console's +// player count stays current. psxsplash needs no change for any of it: a +// HelloAck from slot 0 makes a console a client. +// +// The pure parts (CRC, frames, redux's protobuf wire, rooms and the snapshot) +// have no sockets and are tested on their own; Relay runs them on a thread +// with a listening socket. No SDL, no ImGui. +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace editor::relay { + +// ---- PSNL frames (psxsplash src/netprotocol.hh) ---- + +inline constexpr uint8_t kSync = 0xA5; +inline constexpr size_t kHeaderSize = 8; // payload; +}; + +// [sync][header][payload][crc16 LE over header + payload]. A payload over +// kMaxPayload is cut to it (the relay never builds one). +std::vector encodeFrame(uint8_t type, const std::vector& payload, uint8_t srcSlot, uint16_t seq, + uint8_t flags = 0); + +// The console's receive state machine: hunts for the sync byte, drops one +// byte and hunts again on a bad CRC or an impossible length. Feed it any +// chunks; whole valid frames come out. +class FrameParser { + public: + void feed(const uint8_t* data, size_t n, std::vector& out); + uint64_t frames = 0, crcErrors = 0, resyncs = 0; + + private: + std::vector m_buf; +}; + +// ---- pcsx-redux's SIO1 client wire (src/core/sio1.h) ---- +// +// [u8 len][SIOPayload] repeated; SIOPayload{1: DataTransfer{1: bytes data}, +// 2: FlowControl{1: bool dxr, 2: bool xts}}. Redux ends the session on a +// message it cannot decode, so the relay only ever sends DataTransfers of at +// most kMaxSioData bytes, and never a FlowControl (one with xts off would +// stop redux sending). +inline constexpr size_t kMaxSioData = 245; // the largest whose message still fits the u8 length +// `n` bytes as DataTransfer messages of at most kMaxSioData each, appended to `out`. +void encodeSio(const uint8_t* data, size_t n, std::vector& out); + +class SioDecoder { + public: + // Appends the data bytes of every whole message to `out`. A message it + // cannot read is skipped and counted, not fatal. + void feed(const uint8_t* data, size_t n, std::vector& out); + uint64_t messages = 0, flowControls = 0, decodeErrors = 0; + + private: + std::vector m_buf; +}; + +// ---- payloads ---- + +struct HelloPayload { // parseHello(const std::vector& payload); +std::vector packHello(const HelloPayload& h); + +struct HelloAckPayload { // parseHelloAck(const std::vector& payload); +std::vector packHelloAck(const HelloAckPayload& a); + +// ---- snapshots (psnl_server/rooms.py, Room.build_snapshot) ---- + +struct Avatar { + int32_t px = 0, py = 0, pz = 0, yaw = 0; + uint8_t flags = 0x01; // SNAP_FLAG_ACTIVE +}; +// One snapshot for `members` (slot, avatar), byte for byte what psnl builds: +// sorted by slot; compact format 1 (an origin and 16-bit offsets) from 2 +// avatars when the spread fits in 16 bits at some shift up to 15, else the +// 20-byte legacy records. +std::vector buildSnapshot(std::vector> members); + +// ---- the relay's rules, without sockets ---- + +// One routed packet, for the Network view. `from` is the sender's slot: +// kHostSlot for the relay, kNoSlot for a console that has none yet (then +// `conn` says which). `to` lists the slots it went to (kHostSlot: the relay +// took it). Snapshots and Ping/Pong a sender sends within a second fold into +// one entry, `count` of them. +struct Packet { + uint64_t id = 0; + double time = 0; // seconds since the relay started + uint8_t from = kNoSlot, type = 0; + int conn = 0; + uint16_t seq = 0; + uint32_t size = 0; // frame bytes, all `count` of them + uint32_t count = 1; + std::vector to; +}; + +// One accepted connection: `index` counts from 1 in accept order (console k +// of a Play is the relay's connection k). +struct Connection { + int index = 0; + uint8_t slot = kNoSlot; + int room = 0; // 0: none + uint32_t sceneHash = 0; + bool open = true, joined = false; + double connectedAt = 0, lastSeen = 0; + uint64_t framesIn = 0, framesOut = 0, bytesIn = 0, bytesOut = 0; +}; + +inline constexpr double kSnapshotSeconds = 1.0 / 30; +inline constexpr double kPeerTimeoutSeconds = 20; +inline constexpr size_t kPacketLog = 4096; + +class Hub { + public: + // A new connection; its index (1, 2, ...). + int connect(double now); + // PSNL bytes connection `conn` sent. + void receive(int conn, const uint8_t* data, size_t n, double now); + // The connection is gone: it leaves its room. + void disconnect(int conn, double now); + // Call often: sends the snapshots due and drops members silent for kPeerTimeoutSeconds. + void tick(double now); + + // PSNL bytes to send, per connection, since the last call. + std::vector>> takeOutput(); + // Connections the hub ended (Bye, timeout) for the caller to close, since the last call. + std::vector takeClosed(); + + const std::map& connections() const { return m_conns; } + const std::deque& packets() const { return m_log; } + uint64_t packetCount() const { return m_nextPacket - 1; } + // Players in `conn`'s scene group of its room (the HelloAck's playerCount). + int visiblePlayers(int conn) const; + // When true, a room change is not announced to the members it did not + // happen to (psnl's own behaviour; for tests of what the addition fixes). + bool quietRoomChanges = false; + + private: + struct Room { + int id = 0; + std::optional sceneHash; + std::map members; // slot -> connection + }; + struct Session { + int id = 0; + FrameParser parser; + bool connected = false; // said Hello and was answered + std::optional rxSeq; + uint16_t txSeq = 1; + Avatar avatar; + std::vector out; + }; + void dispatch(Session& s, const Frame& f, double now); + void onHello(Session& s, const Frame& f, double now); + void onReliable(Session& s, const Frame& f, double now); + bool join(Session& s, uint32_t sceneHash); + void leave(Session& s, double now); + void sendAck(Session& s, double now); + void announce(int roomId, int except, double now); + void send(Session& s, uint8_t type, const std::vector& payload, std::optional seq); + void sendRaw(Session& s, const std::vector& frame); + void close(Session& s, double now, bool told); + Connection& info(const Session& s) { return m_conns.at(s.id); } + void log(double now, uint8_t from, int conn, uint8_t type, uint16_t seq, size_t size, std::vector to); + Room* room(int id); + + std::map m_sessions; + std::map m_conns; // every connection's info, kept after it closes + std::vector m_rooms; + int m_nextConn = 1, m_nextRoom = 1; + double m_nextSnapshot = 0; + std::vector m_closed; + std::deque m_log; + uint64_t m_nextPacket = 1; + std::map, uint64_t> m_folding; // (conn, type) -> the entry still taking more +}; + +// ---- the server ---- + +// Listens on 127.0.0.1 at a port the system picks and runs a Hub on its own +// thread. Thread safe. +class Relay { + public: + Relay(); + ~Relay(); + Relay(const Relay&) = delete; + Relay& operator=(const Relay&) = delete; + + // False with `error` set if it cannot listen. + bool start(std::string* error); + // Closes every connection and the listener. What it routed stays readable. + void stop(); + bool running() const { return m_running; } + uint16_t port() const { return m_port; } + // Connections accepted since start(). + int accepted() const { return m_accepted; } + // Seconds since start(), the clock of Connection and Packet times. + double seconds() const; + + std::vector connections() const; + // The routed packets kept (the newest kPacketLog), oldest first, with id over `after`. + std::vector packets(uint64_t after = 0) const; + uint64_t packetCount() const; + + private: + void run(); + struct Impl; + std::unique_ptr m; + std::thread m_thread; + std::atomic m_running{false}, m_stop{false}; + std::atomic m_accepted{0}; + uint16_t m_port = 0; +}; + +// The Network view's filter: packet types shown (bit t for type t) and the +// slot a packet must come from or go to (-1: any). +struct PacketFilter { + uint32_t types = ~0u; + int slot = -1; + bool shows(const Packet& p) const; +}; +// "relay", "slot 3", "conn 2" (a console with no slot yet). +std::string describeEnd(uint8_t slot, int conn); +// The recipients in words: "slot 2, 3", "relay". +std::string describeTo(const std::vector& to); + +} // namespace editor::relay diff --git a/src/editor/tests/editor_tests.cpp b/src/editor/tests/editor_tests.cpp index b4ba111..67a6222 100644 --- a/src/editor/tests/editor_tests.cpp +++ b/src/editor/tests/editor_tests.cpp @@ -1,6 +1,7 @@ // 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 @@ -25,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -46,6 +48,7 @@ #include "editor/pick.hh" #include "editor/play.hh" #include "editor/linkplay.hh" +#include "editor/relay.hh" #include "editor/project.hh" #include "editor/psxmon.hh" #include "editor/serial.hh" @@ -1579,16 +1582,37 @@ void testLinkPlay() { } #endif - // Players: round-trips, reads 1 when absent or anything but 2. + // Players: 1 to 10 round-trip with the relay toggle; out of range clamps; + // absent or not a number reads 1, relay off. const fs::path cfg = dir / "play.cfg"; - t.players = 2; - CHECK(editor::savePlayTools(cfg, t) && editor::loadPlayTools(cfg).players == 2); - t.players = 1; - CHECK(editor::savePlayTools(cfg, t) && editor::loadPlayTools(cfg).players == 1); + for (int n = 1; n <= editor::kMaxPlayers; ++n) { + t.players = n; + t.relay = n % 2; + const editor::PlayTools back = editor::savePlayTools(cfg, t) ? editor::loadPlayTools(cfg) : editor::PlayTools{}; + CHECK(back.players == n && back.relay == bool(n % 2)); + } + t.relay = false; { std::ofstream(cfg, std::ios::trunc) << "redux=/r\nplayers=3\n"; } + CHECK(editor::loadPlayTools(cfg).players == 3 && !editor::loadPlayTools(cfg).relay); + { std::ofstream(cfg, std::ios::trunc) << "redux=/r\nplayers=40\n"; } + CHECK(editor::loadPlayTools(cfg).players == editor::kMaxPlayers); + { std::ofstream(cfg, std::ios::trunc) << "redux=/r\nplayers=0\n"; } + CHECK(editor::loadPlayTools(cfg).players == 1); + { std::ofstream(cfg, std::ios::trunc) << "redux=/r\nplayers=two\n"; } CHECK(editor::loadPlayTools(cfg).players == 1); { std::ofstream(cfg, std::ios::trunc) << "redux=/r\n"; } CHECK(editor::loadPlayTools(cfg).players == 1); + // 2 link directly unless the relay is asked for; 3 or more always use it. + editor::PlayTools u; + u.players = 1; + CHECK(!editor::usesRelay(u)); + u.players = 2; + CHECK(!editor::usesRelay(u)); + u.relay = true; + CHECK(editor::usesRelay(u)); + u.relay = false; + u.players = 3; + CHECK(editor::usesRelay(u)); // The reporter: last object, script only; its lines parse and read as words. splash::Scene sc; @@ -1613,6 +1637,401 @@ void testLinkPlay() { CHECK(editor::describeLinkStatus({4, false, 0, 0}, true).find("another scene") != std::string::npos); } +// ---- Play's relay (editor/relay.hh) ---- + +namespace rl = editor::relay; + +std::vector fromHex(const std::string& h) { + std::vector b; + for (size_t i = 0; i + 1 < h.size(); i += 2) b.push_back(uint8_t(std::stoi(h.substr(i, 2), nullptr, 16))); + return b; +} + +// The frames the hub sent since the last call, per connection. +std::map> hubSent(rl::Hub& h) { + std::map> m; + for (auto& [conn, bytes] : h.takeOutput()) { + rl::FrameParser p; + p.feed(bytes.data(), bytes.size(), m[conn]); + } + return m; +} + +std::vector helloFrame(uint32_t scene, uint16_t seq = 1, uint32_t magic = rl::kMagic) { + rl::HelloPayload hp; + hp.sceneHash = scene; + hp.tiebreak = 0x1234; + hp.magic = magic; + return rl::encodeFrame(rl::Hello, rl::packHello(hp), rl::kNoSlot, seq); +} + +void hubSend(rl::Hub& h, int conn, const std::vector& frame, double now) { h.receive(conn, frame.data(), frame.size(), now); } + +// The HelloAcks among `frames`. +std::vector acksIn(const std::vector& frames) { + std::vector v; + for (const rl::Frame& f : frames) + if (f.type == rl::HelloAck) + if (std::optional a = rl::parseHelloAck(f.payload)) v.push_back(*a); + return v; +} + +// CRC, frames and the parser's resync, against psnl's own bytes. +void testRelayFrames() { + const char* nine = "123456789"; + CHECK(rl::crc16(reinterpret_cast(nine), 9) == 0x29B1); + // The same frame psnl's encode_frame builds (ng-deps/relay/snapgen.py). + rl::Avatar a{1, 2, 3, 4, 1}; + const std::vector frame = rl::encodeFrame(rl::Snapshot, rl::buildSnapshot({{1, a}}), rl::kHostSlot, 0); + CHECK(frame == fromHex("a50300000000001800010000000101000001000000020000000300000004000000943e")); + + const std::vector f1 = rl::encodeFrame(rl::Event, {1, 2, 3, 4, 5, 6, 7, 8}, 2, 77); + const std::vector f2 = rl::encodeFrame(rl::AppData, {9, 9}, 3, 78, 1); + std::vector got; + { + rl::FrameParser p; + p.feed(f1.data(), f1.size(), got); + CHECK(got.size() == 1 && got[0].type == rl::Event && got[0].srcSlot == 2 && got[0].seq == 77 && got[0].payload.size() == 8 && + got[0].payload[7] == 8 && p.frames == 1); + } + // Garbage (with a false sync in it), a frame, a frame with a flipped byte, + // a header claiming 600 bytes, then a frame: the good ones come through. + std::vector stream = {0x00, 0x13, 0xA5, 0x01, 0x02, 0xFF}; + stream.insert(stream.end(), f1.begin(), f1.end()); + std::vector bad = f2; + bad[5] ^= 0x40; + stream.insert(stream.end(), bad.begin(), bad.end()); + const std::vector huge = {0xA5, 4, 1, 0, 0, 0, 0, 0x58, 0x02}; + stream.insert(stream.end(), huge.begin(), huge.end()); + stream.insert(stream.end(), f2.begin(), f2.end()); + for (int chunk : {1, 3, 1000}) { + rl::FrameParser p; + got.clear(); + for (size_t at = 0; at < stream.size(); at += size_t(chunk)) + p.feed(stream.data() + at, std::min(size_t(chunk), stream.size() - at), got); + CHECK(got.size() == 2 && got[0].seq == 77 && got[1].type == rl::AppData && got[1].seq == 78 && got[1].flags == 1 && + got[1].payload == std::vector({9, 9})); + CHECK(p.crcErrors >= 1 && p.resyncs >= 1); + } + CHECK(rl::packetTypeName(rl::HelloAck) == "HelloAck" && rl::packetTypeName(rl::Pong) == "Pong" && + rl::packetTypeName(42) == "Type 42"); +} + +// Redux's SIO1 client wire: messages of at most 245 data bytes, and the +// stream Redux itself sends (a FlowControl, then one byte per message). +void testRelayProtobuf() { + std::vector out; + const std::vector ten = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; + rl::encodeSio(ten.data(), ten.size(), out); + std::vector want = {14, 0x0A, 12, 0x0A, 10}; + want.insert(want.end(), ten.begin(), ten.end()); + CHECK(out == want); + // 245 bytes fit one message (its length byte 251); 246 need two; 600 three. + for (size_t n : {size_t(245), size_t(246), size_t(600)}) { + std::vector data(n); + for (size_t i = 0; i < n; ++i) data[i] = uint8_t(i * 7); + out.clear(); + rl::encodeSio(data.data(), n, out); + size_t msgs = 0, maxLen = 0; + for (size_t at = 0; at < out.size(); at += 1 + out[at]) { + ++msgs; + maxLen = std::max(maxLen, out[at]); + } + CHECK(msgs == (n + rl::kMaxSioData - 1) / rl::kMaxSioData && maxLen <= 251); + if (n == 245) CHECK(out[0] == 251 && out.size() == 252); + rl::SioDecoder d; + std::vector back; + for (size_t at = 0; at < out.size(); at += 5) d.feed(out.data() + at, std::min(5, out.size() - at), back); + CHECK(back == data && d.messages == msgs && d.decodeErrors == 0); + } + // As Redux sends: FlowControl{dxr, xts}, then DataTransfer{data: 1 byte} + // with an empty FlowControl beside it; a broken message between is skipped. + std::vector redux = {6, 0x12, 4, 0x08, 1, 0x10, 1}; + for (uint8_t b : {uint8_t(0xA5), uint8_t(0x00), uint8_t(0x42)}) { + const std::vector m = {7, 0x0A, 3, 0x0A, 1, b, 0x12, 0}; + redux.insert(redux.end(), m.begin(), m.end()); + if (b == 0x00) redux.insert(redux.end(), {3, 0x0A, 0xFF, 0xFF}); // a length running past its message + } + rl::SioDecoder d; + std::vector back; + d.feed(redux.data(), redux.size(), back); + CHECK(back == std::vector({0xA5, 0x00, 0x42}) && d.flowControls == 1 && d.decodeErrors == 1 && d.messages == 5); +} + +// Rooms: join by scene, lowest free slot, re-Hello keeps the slot, leaving +// and silence free it, and every member hears of each change. +void testRelayRooms() { + rl::Hub h; + const uint32_t A = 0xAAAA0001, B = 0xBBBB0002; + const int c1 = h.connect(0), c2 = h.connect(0), c3 = h.connect(0); + CHECK(c1 == 1 && c2 == 2 && c3 == 3); + hubSend(h, c1, helloFrame(A), 0.1); + auto sent = hubSent(h); + std::vector a1 = acksIn(sent[c1]); + CHECK(a1.size() == 1 && a1[0].yourSlot == 1 && a1[0].hostSlot == rl::kHostSlot && a1[0].playerCount == 1 && + a1[0].sceneHash == A && a1[0].magic == rl::kMagic && a1[0].protoVersion == rl::kProtoVersion); + CHECK(sent[c1].size() == 1 && sent[c1][0].srcSlot == rl::kHostSlot); + // A second console in the same scene: slot 2, and console 1 is told there are 2 now. + hubSend(h, c2, helloFrame(A), 0.2); + sent = hubSent(h); + std::vector a2 = acksIn(sent[c2]); + CHECK(a2.size() == 1 && a2[0].yourSlot == 2 && a2[0].playerCount == 2); + a1 = acksIn(sent[c1]); + CHECK(a1.size() == 1 && a1[0].yourSlot == 1 && a1[0].playerCount == 2); + // Another scene: a room of its own, nobody else hears of it. + hubSend(h, c3, helloFrame(B), 0.3); + sent = hubSent(h); + CHECK(acksIn(sent[c3]).size() == 1 && acksIn(sent[c3])[0].yourSlot == 1 && acksIn(sent[c3])[0].playerCount == 1); + CHECK(sent.count(c1) == 0 && sent.count(c2) == 0); + // Hello again (a scene reload, or a lost ack): same slot, acked again; no change for the others. + hubSend(h, c2, helloFrame(A, 2), 0.4); + sent = hubSent(h); + CHECK(acksIn(sent[c2]).size() == 1 && acksIn(sent[c2])[0].yourSlot == 2 && acksIn(sent[c2])[0].playerCount == 2); + CHECK(sent.count(c1) == 0); + // A foreign Hello is ignored. + const int cx = h.connect(0.5); + hubSend(h, cx, helloFrame(A, 1, 0x12345678), 0.5); + sent = hubSent(h); + CHECK(sent.count(cx) == 0 && !h.connections().at(cx).joined); + // Console 4 takes slot 3; console 1 leaves with Bye; 2 and 4 hear there are 2. + const int c4 = h.connect(0.6); + hubSend(h, c4, helloFrame(A), 0.6); + sent = hubSent(h); + CHECK(acksIn(sent[c4]).size() == 1 && acksIn(sent[c4])[0].yourSlot == 3 && acksIn(sent[c4])[0].playerCount == 3); + CHECK(acksIn(sent[c1]).size() == 1 && acksIn(sent[c1])[0].playerCount == 3 && acksIn(sent[c2]).size() == 1); + hubSend(h, c1, rl::encodeFrame(rl::Bye, {}, 1, 3), 0.7); + sent = hubSent(h); + CHECK(h.takeClosed() == std::vector({c1}) && !h.connections().at(c1).open); + CHECK(acksIn(sent[c2]).size() == 1 && acksIn(sent[c2])[0].playerCount == 2 && acksIn(sent[c4]).size() == 1 && + acksIn(sent[c4])[0].playerCount == 2 && acksIn(sent[c4])[0].yourSlot == 3); + // The lowest free slot again: 1. + const int c5 = h.connect(0.8); + hubSend(h, c5, helloFrame(A), 0.8); + sent = hubSent(h); + CHECK(acksIn(sent[c5]).size() == 1 && acksIn(sent[c5])[0].yourSlot == 1 && acksIn(sent[c5])[0].playerCount == 3); + CHECK(h.connections().at(c5).slot == 1 && h.connections().at(c5).index == c5); + // A socket that closes leaves too. + h.disconnect(c5, 0.9); + sent = hubSent(h); + CHECK(acksIn(sent[c2]).size() == 1 && acksIn(sent[c2])[0].playerCount == 2 && h.takeClosed().empty()); + // 20 s of silence ends a member; one that spoke stays. + hubSend(h, c2, rl::encodeFrame(rl::Ping, std::vector(12, 0), 2, 4), 15.0); + h.tick(21.0); + std::vector closed = h.takeClosed(); + std::sort(closed.begin(), closed.end()); + CHECK(closed == std::vector({c3, cx, c4})); + CHECK(h.connections().at(c2).open && h.connections().at(c2).joined && !h.connections().at(c4).joined); + sent = hubSent(h); + CHECK(acksIn(sent[c2]).size() == 1 && acksIn(sent[c2])[0].playerCount == 1); + + // Ten fill a room; the eleventh starts a new one. + rl::Hub full; + std::vector ids; + for (int i = 0; i < 11; ++i) { + ids.push_back(full.connect(0)); + hubSend(full, ids.back(), helloFrame(A), 0); + } + CHECK(full.connections().at(ids[9]).slot == 10 && full.connections().at(ids[10]).slot == 1 && + full.connections().at(ids[10]).room != full.connections().at(ids[0]).room && full.visiblePlayers(ids[0]) == 10 && + full.visiblePlayers(ids[10]) == 1); +} + +// Events: acked (duplicates too), deduped on the last seq, fanned out to the +// rest of the room as their 8 bytes; ObjectState and AppData relayed; Ping +// answered with its echo; and the log lists who got what. +void testRelayReliable() { + rl::Hub h; + const uint32_t A = 7; + int c[3]; + for (int i = 0; i < 3; ++i) { + c[i] = h.connect(0); + hubSend(h, c[i], helloFrame(A), 0); + } + hubSent(h); + std::vector ev = {7, 0, 0, 0, 0xE9, 3, 0, 0, 0xEE, 0xEE}; // id 7, arg 1001, and 2 more bytes + hubSend(h, c[0], rl::encodeFrame(rl::Event, ev, 1, 5), 1.0); + auto sent = hubSent(h); + CHECK(sent[c[0]].size() == 1 && sent[c[0]][0].type == rl::EventAck && sent[c[0]][0].seq == 5 && sent[c[0]][0].payload.empty()); + for (int i : {1, 2}) + CHECK(sent[c[i]].size() == 1 && sent[c[i]][0].type == rl::Event && sent[c[i]][0].srcSlot == rl::kHostSlot && + sent[c[i]][0].payload == std::vector(ev.begin(), ev.begin() + 8)); + const uint16_t seqTo2 = sent[c[1]][0].seq; + // The retransmission: acked again, not delivered again. + hubSend(h, c[0], rl::encodeFrame(rl::Event, ev, 1, 5), 1.1); + sent = hubSent(h); + CHECK(sent[c[0]].size() == 1 && sent[c[0]][0].type == rl::EventAck && sent[c[0]][0].seq == 5); + CHECK(sent.count(c[1]) == 0 && sent.count(c[2]) == 0); + // The next one goes through, with the next seq on each recipient's own count. + hubSend(h, c[0], rl::encodeFrame(rl::Event, ev, 1, 6), 1.2); + sent = hubSent(h); + CHECK(sent[c[1]].size() == 1 && sent[c[1]][0].seq == uint16_t(seqTo2 + 1)); + // From console 3: to 1 and 2. + hubSend(h, c[2], rl::encodeFrame(rl::AppData, {'h', 'i'}, 3, 40), 1.3); + sent = hubSent(h); + CHECK(sent[c[2]].size() == 1 && sent[c[2]][0].type == rl::EventAck && sent[c[2]][0].seq == 40); + CHECK(sent[c[0]].size() == 1 && sent[c[0]][0].type == rl::AppData && sent[c[0]][0].payload == std::vector({'h', 'i'})); + CHECK(sent[c[1]].size() == 1 && sent[c[1]][0].type == rl::AppData); + hubSend(h, c[1], rl::encodeFrame(rl::ObjectState, {1, 2, 3}, 2, 9), 1.4); + sent = hubSent(h); + CHECK(sent[c[0]].size() == 1 && sent[c[0]][0].type == rl::ObjectState && sent[c[2]].size() == 1 && sent[c[1]][0].type == rl::EventAck); + // A short Event is acked and dropped, as psnl does. + hubSend(h, c[0], rl::encodeFrame(rl::Event, {1, 2}, 1, 7), 1.5); + sent = hubSent(h); + CHECK(sent[c[0]].size() == 1 && sent.count(c[1]) == 0); + // Ping: a Pong with the echo, the rest zero. + std::vector ping = {0xEF, 0xBE, 0xAD, 0xDE, 1, 2, 3, 4, 5, 6, 0, 0}; + hubSend(h, c[1], rl::encodeFrame(rl::Ping, ping, 2, 10), 1.6); + hubSend(h, c[1], rl::encodeFrame(rl::Ping, ping, 2, 11), 1.7); + sent = hubSent(h); + CHECK(sent[c[1]].size() == 2 && sent[c[1]][0].type == rl::Pong && + sent[c[1]][0].payload == std::vector({0xEF, 0xBE, 0xAD, 0xDE, 0, 0, 0, 0, 0, 0, 0, 0})); + + // The log: the first Event from slot 1 to slots 2 and 3, its ack to slot 1; + // the duplicate stopped at the relay; the two Pings one entry. + const std::deque& log = h.packets(); + std::vector events, pings; + for (const rl::Packet& p : log) { + if (p.type == rl::Event) events.push_back(&p); + if (p.type == rl::Ping) pings.push_back(&p); + } + CHECK(events.size() == 4); + if (events.size() == 4) { + CHECK(events[0]->from == 1 && events[0]->to == std::vector({2, 3}) && events[0]->seq == 5 && events[0]->size == 21); + CHECK(events[1]->to == std::vector({rl::kHostSlot})); + CHECK(rl::describeTo(events[0]->to) == "slots 2, 3" && rl::describeTo(events[1]->to) == "relay"); + } + CHECK(pings.size() == 1 && pings[0]->count == 2 && pings[0]->from == 2); + bool hello = false; + for (const rl::Packet& p : log) + if (p.type == rl::Hello && p.from == rl::kNoSlot && p.conn == c[0]) hello = true; + CHECK(hello && rl::describeEnd(rl::kNoSlot, 3) == "conn 3" && rl::describeEnd(0, 0) == "relay" && rl::describeEnd(4, 1) == "slot 4"); + // The Network view's filter: by type, and by slot either end. + rl::PacketFilter only; + only.types = 1u << rl::Event; + CHECK(only.shows(*events[0]) && !only.shows(*pings[0])); + only.types = ~0u; + only.slot = 3; + CHECK(only.shows(*events[0]) && !only.shows(*pings[0])); + only.slot = 2; + CHECK(only.shows(*pings[0])); +} + +// Snapshots: byte for byte what psnl's Room.build_snapshot makes +// (ng-deps/relay/snapgen.py), and what the hub sends each room at 30 Hz. +void testRelaySnapshot() { + struct Case { + const char* name; + std::vector>> members; + const char* hex; + }; + // Generated by /home/pixel/sources/ng-deps/relay/snapgen.py from psnl_server/rooms.py Room.build_snapshot. + const Case cases[] = { + {"one", {{1, {100, -200, 300, 50, 1}}}, "01000000010100006400000038ffffff2c01000032000000"}, + {"two_close", {{2, {1000, 0, -1000, 4096, 1}}, {1, {1100, 50, -900, -10, 1}}}, + "02000100e80300000000000018fcffff01016400320064007e6402010000000000000010"}, + {"three_shift", {{3, {0, 0, 0, 0, 1}}, {1, {200000, -5, 7, 30000, 1}}, {2, {-100000, 9, 70000, 25736, 0}}}, + "030001036079fefffbffffff0000000001017c9200000000a8100200000001002e2200000301d430000000000000"}, + {"far", {{1, {-2000000000, 0, 0, 1, 1}}, {2, {2000000000, 0, 0, 2, 1}}}, + "0200000001010000006cca880000000000000000010000000201000000943577000000000000000002000000"}, + {"zero_avatars", {{1, {0, 0, 0, 0, 1}}, {2, {0, 0, 0, 0, 1}}, {3, {0, 0, 0, 0, 1}}}, + "03000100000000000000000000000000010100000000000000000201000000000000000003010000000000000000"}, + {"ten", + {{10, {40960, -7770, 113466, 21000, 1}}, {9, {36864, -6993, 114465, 18000, 1}}, {8, {32768, -6216, 115464, 15000, 1}}, + {7, {28672, -5439, 116463, 12000, 1}}, {6, {24576, -4662, 117462, 9000, 1}}, {5, {20480, -3885, 118461, 6000, 1}}, + {4, {16384, -3108, 119460, 3000, 1}}, {3, {12288, -2331, 120459, 0, 1}}, {2, {8192, -1554, 121458, -3000, 1}}, + {1, {4096, -777, 122457, -6000, 1}}}, + "0a00010000100000a6e1ffff3abb010001010000511b1f23184d020100104818381fd058030100203f15511b00000401003036126a17b80b05010040" + "2d0f8313701706010050240c9c0f2823070100601b09b50be02e080100701206ce07983a090100800903e70350460a010090000000000852"}, + }; + for (const Case& c : cases) { + std::vector> m; + for (const auto& [slot, v] : c.members) m.push_back({slot, rl::Avatar{v[0], v[1], v[2], v[3], uint8_t(v[4])}}); + const bool same = rl::buildSnapshot(m) == fromHex(c.hex); + if (!same) std::fprintf(stderr, "relay snapshot case %s differs from psnl\n", c.name); + CHECK(same); + } + + // The hub: each member's own legacy record (a record for another slot is + // ignored), sent to the room as one snapshot every 1/30 s. + rl::Hub h; + const int a = h.connect(0), b = h.connect(0); + hubSend(h, a, helloFrame(9), 0); + hubSend(h, b, helloFrame(9), 0); + hubSent(h); + std::vector snap = {2, 0, 0, 0}; + for (uint8_t slot : {uint8_t(1), uint8_t(2)}) { + const int32_t v[4] = {slot == 1 ? 5000 : 99, -20, 30, 1000}; + snap.insert(snap.end(), {slot, 1, 0, 0}); + for (int32_t x : v) + for (int k = 0; k < 4; ++k) snap.push_back(uint8_t(uint32_t(x) >> (8 * k))); + } + hubSend(h, a, rl::encodeFrame(rl::Snapshot, snap, 1, 0), 0.5); + h.tick(1.0); + auto sent = hubSent(h); + const std::vector want = rl::buildSnapshot({{1, rl::Avatar{5000, -20, 30, 1000, 1}}, {2, rl::Avatar{}}}); + for (int c : {a, b}) + CHECK(sent[c].size() == 1 && sent[c][0].type == rl::Snapshot && sent[c][0].seq == 0 && sent[c][0].srcSlot == rl::kHostSlot && + sent[c][0].payload == want); + h.tick(1.01); + CHECK(hubSent(h).empty()); + h.tick(1.0 + rl::kSnapshotSeconds + 0.001); + CHECK(hubSent(h).size() == 2); +} + +// The server on a socket: three clients speaking redux's wire get slots 1, 2 +// and 3 in the order they connected. +void testRelaySocket() { +#ifndef _WIN32 + rl::Relay relay; + std::string err; + CHECK(relay.start(&err) && relay.port() != 0 && relay.running()); + int socks[3] = {-1, -1, -1}; + for (int i = 0; i < 3; ++i) { + socks[i] = socket(AF_INET, SOCK_STREAM, 0); + sockaddr_in a{}; + a.sin_family = AF_INET; + a.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + a.sin_port = htons(relay.port()); + CHECK(connect(socks[i], reinterpret_cast(&a), sizeof a) == 0); + // Accepted in order before the next connects, as Play waits for console k. + const auto until = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (relay.accepted() < i + 1 && std::chrono::steady_clock::now() < until) + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + CHECK(relay.accepted() == i + 1); + // As redux: a FlowControl, then one DataTransfer per byte. + std::vector wire = {6, 0x12, 4, 0x08, 1, 0x10, 1}; + for (uint8_t b : helloFrame(0xC0FFEE)) wire.insert(wire.end(), {5, 0x0A, 3, 0x0A, 1, b}); + CHECK(send(socks[i], wire.data(), wire.size(), 0) == ssize_t(wire.size())); + } + for (int i = 0; i < 3; ++i) { + rl::SioDecoder d; + rl::FrameParser p; + std::vector frames; + std::optional ack; + const auto until = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (!ack && std::chrono::steady_clock::now() < until) { + pollfd pf{socks[i], POLLIN, 0}; + if (poll(&pf, 1, 50) <= 0) continue; + uint8_t buf[2048]; + const ssize_t n = recv(socks[i], buf, sizeof buf, 0); + if (n <= 0) break; + std::vector data; + d.feed(buf, size_t(n), data); + p.feed(data.data(), data.size(), frames); + if (std::vector acks = acksIn(frames); !acks.empty()) ack = acks.front(); + } + CHECK(ack && ack->yourSlot == i + 1 && d.decodeErrors == 0); + } + const std::vector conns = relay.connections(); + CHECK(conns.size() == 3); + for (size_t i = 0; i < conns.size(); ++i) CHECK(conns[i].index == int(i + 1) && conns[i].slot == i + 1 && conns[i].joined); + CHECK(relay.packetCount() >= 6); + for (int s : socks) close(s); + relay.stop(); + CHECK(!relay.running() && relay.packets().size() >= 6); // kept for the Network view +#endif +} + // Build > Performance / Room Debug Overlay: Play and Run on hardware boot the // psxsplash build beside the chosen one that has them; the disc never does. void testPlayOverlays() { @@ -13328,6 +13747,12 @@ int main(int argc, char** argv) { testPlay(); testPlayOverlays(); testLinkPlay(); + testRelayFrames(); + testRelayProtobuf(); + testRelayRooms(); + testRelayReliable(); + testRelaySnapshot(); + testRelaySocket(); testPlayBootLogo(); testBuildScenes(); testBuildReport(); diff --git a/src/main.cpp b/src/main.cpp index b4819aa..8e6364e 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -971,7 +971,7 @@ int main(int argc, char** argv) { // While the Game view has the keyboard, arrows and Enter are the controller: // they must not move ImGui's keyboard focus or press the button it lands on. // The script editor's arrows, Tab and Enter edit text. - if ((state.play.gameFocus && state.play.game.attached()) || state.scripts.hasKeyboard()) + if ((state.play.gameFocus && state.play.cons[size_t(state.play.focus)].game.attached()) || state.scripts.hasKeyboard()) io.ConfigFlags &= ~ImGuiConfigFlags_NavEnableKeyboard; else io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; @@ -1030,7 +1030,7 @@ int main(int argc, char** argv) { if (!writeShot(window, args.screenshot)) return 1; // For scripted checks: menus and pickers left open by the input. std::printf("popups open: %s\n", ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopupId) ? "yes" : "no"); - std::printf("game attached: %s\n", state.play.game.attached() ? "yes" : "no"); + std::printf("game attached: %s\n", state.play.cons[0].game.attached() ? "yes" : "no"); std::printf("status: %s\n", state.notice.c_str()); std::printf("unsaved changes: %s\n", doc.dirty() ? "yes" : "no"); std::printf("inspector import warnings: %zu\n", state.importPanel.shown.size()); @@ -1044,7 +1044,8 @@ int main(int argc, char** argv) { SDL_GL_SwapWindow(window); } - state.play.emu.stop(); + for (auto& c : state.play.cons) c.emu.stop(); + if (state.play.relay) state.play.relay->stop(); splash::shutdown(); ImGui_ImplOpenGL3_Shutdown(); ImGui_ImplSDL3_Shutdown(); diff --git a/src/mockup/main_screen.cpp b/src/mockup/main_screen.cpp index 676c697..f7f5b28 100644 --- a/src/mockup/main_screen.cpp +++ b/src/mockup/main_screen.cpp @@ -139,36 +139,36 @@ static void notify(State& st, std::string msg, bool bad); static void scriptLine(const char* tag, const std::string& line); static std::string utf8(const std::filesystem::path& p); -// Stop: both consoles when 2 are linked. +// Stop: every console of the session, and the relay they went through. static void stopPlay(State::Play& p) { - p.emu.stop(); - p.emu2.stop(); - p.linkWaiting = false; + for (State::Play::Console& c : p.cons) c.emu.stop(); + p.starting = 0; + if (p.relay) p.relay->stop(); } -// Console `i` (0 or 1) of the session: console 2 exists only with 2 linked. -static Emulator& consoleEmu(State::Play& p, int i) { return i == 1 ? p.emu2 : p.emu; } -static const GameView& consoleGame(const State::Play& p, int i) { return i == 1 ? p.game2 : p.game; } +// Console `i` (from 0) of the session. +static Emulator& consoleEmu(State::Play& p, int i) { return p.cons[size_t(i)].emu; } +static const GameView& consoleGame(const State::Play& p, int i) { return p.cons[size_t(i)].game; } // Where console `i`'s capture files are: the Play directory with 1 console, -// the console's own directory with 2 (they share the Play directory). +// the console's own directory with more (they share the Play directory). static std::filesystem::path captureDir(const State::Play& p, int i) { - return p.players == 2 ? editor::consoleDir(playDir(), i) : playDir(); + return p.players >= 2 ? editor::consoleDir(playDir(), i) : playDir(); } // Capture frame (the Game view, Build menu): asks framecapture.lua in the // running redux for one GPU frame; pollFrameCapture opens it in the Frame tab. -// With 2 linked consoles, the one that has the keyboard. +// With linked consoles, the one that has the keyboard. static bool canCaptureFrame(const State& st) { const State::Play& p = st.play; - const int i = p.players == 2 ? p.focus : 0; - return p.emu.running() && consoleGame(p, i).attached() && !p.capturing && !p.captureScript.empty(); + const int i = p.players >= 2 ? p.focus : 0; + return p.cons[0].emu.running() && consoleGame(p, i).attached() && !p.capturing && !p.captureScript.empty(); } static void captureFrame(State& st) { if (!canCaptureFrame(st)) return; std::string err; - st.play.captureConsole = st.play.players == 2 ? st.play.focus : 0; + st.play.captureConsole = st.play.players >= 2 ? st.play.focus : 0; if (!editor::requestCapture(captureDir(st.play, st.play.captureConsole), &err)) { notify(st, "Capture frame: " + err, true); return; @@ -210,7 +210,7 @@ static void pollFrameCapture(State& st) { notify(st, "Capture frame: " + err, true); return; } - notify(st, p.players == 2 ? "Captured a frame from Console " + std::to_string(p.captureConsole + 1) : "Captured a frame", + notify(st, p.players >= 2 ? "Captured a frame from Console " + std::to_string(p.captureConsole + 1) : "Captured a frame", false); scriptLine(nullptr, "frame: captured from console " + std::to_string(p.captureConsole + 1) + " " + utf8(dir)); } @@ -312,7 +312,7 @@ static void reportBuild(State& st, const char* what, const State::Play::Launch& // F5 and the Play button: start, or stop what is running. static void togglePlay(State& st, const editor::Document& doc) { State::Play& p = st.play; - if (p.emu.running()) { + if (p.cons[0].emu.running()) { stopPlay(p); return; } @@ -335,13 +335,13 @@ static void togglePlay(State& st, const editor::Document& doc) { p.reloadIndex = indexIn(files, openSceneFile(doc)); p.built = {}; p.playLaunch = launchOf(files, openSceneFile(doc)); - p.players = p.tools.players == 2 ? 2 : 1; + p.players = std::clamp(p.tools.players, 1, editor::kMaxPlayers); + p.relayed = editor::usesRelay(p.tools); p.focus = 0; - p.link[0].reset(); - p.link[1].reset(); + for (State::Play::Console& c : p.cons) c.link.reset(); p.linkError.clear(); p.networkId = !doc.scene().settings.networkId.empty(); - const std::filesystem::path reporter = p.players == 2 ? linkReporterFile() : std::filesystem::path(); + const std::filesystem::path reporter = p.players >= 2 ? linkReporterFile() : std::filesystem::path(); p.build = std::async(std::launch::async, [files = std::move(files), open = openSceneFile(doc), scene = doc.scene(), root = doc.projectRoot(), reporter] { splash::ExportResult r = exportBuild(files, open, scene, root, playDir(), reporter); @@ -354,7 +354,7 @@ static void togglePlay(State& st, const editor::Document& doc) { // Ctrl+Shift+F5 and the Restart button: stop the running game, then build // and run again, edits included, in one step. static void restartPlay(State& st, const editor::Document& doc) { - if (!st.play.emu.running()) return; + if (!st.play.cons[0].emu.running()) return; stopPlay(st.play); togglePlay(st, doc); } @@ -403,7 +403,7 @@ static void sendEdits(State& st, const editor::Document& doc) { splash::ExportResult r = p.reload.get(); p.message = r.ok() ? std::string() : "Live edit not sent: " + r.errors.front(); } - if (!p.emu.running() || p.build.valid() || doc.loadId() != p.peakScene || p.reloadIndex < 0) return; + if (!p.cons[0].emu.running() || p.build.valid() || doc.loadId() != p.peakScene || p.reloadIndex < 0) return; if (doc.revision() != p.seenRev) { p.seenRev = doc.revision(); p.seenAt = now; @@ -411,7 +411,7 @@ static void sendEdits(State& st, const editor::Document& doc) { if (doc.revision() == p.sentRev || now - p.seenAt < 0.3) return; p.sentRev = doc.revision(); const unsigned gen = ++p.reloadGeneration; - splash::Scene scene = p.players == 2 ? editor::withLinkReporter(doc.scene(), linkReporterFile()) : doc.scene(); + splash::Scene scene = p.players >= 2 ? editor::withLinkReporter(doc.scene(), linkReporterFile()) : doc.scene(); p.reload = std::async(std::launch::async, [scene = std::move(scene), root = doc.projectRoot(), gen, index = size_t(p.reloadIndex)] { return editor::reloadForPlay(scene, root, playDir(), gen, index); }); @@ -435,79 +435,89 @@ static void updatePlay(State& st, const editor::Document& doc) { editor::restartLog(p.hwLog); } } - p.emu.poll(); - p.emu2.poll(); - // Console 2's lines: only its link reports are read. - { - const std::deque& out2 = p.emu2.output(); - if (p.emu2.lineCount() < p.linesSeen2) p.linesSeen2 = 0; - const uint64_t first2 = p.emu2.lineCount() - out2.size(); - for (uint64_t i = std::max(p.linesSeen2, first2); i < p.emu2.lineCount(); ++i) { + for (State::Play::Console& c : p.cons) c.emu.poll(); + // Every console's link reports; console 1's lines also its render reports. + for (int k = 0; k < editor::kMaxPlayers; ++k) { + State::Play::Console& c = p.cons[size_t(k)]; + const std::deque& out = c.emu.output(); + if (c.emu.lineCount() < c.linesSeen) c.linesSeen = 0; // restarted + const uint64_t first = c.emu.lineCount() - out.size(); + for (uint64_t i = std::max(c.linesSeen, first); i < c.emu.lineCount(); ++i) { + const std::string& line = out[size_t(i - first)]; + editor::RenderPeak rp; + editor::RenderLost rl; editor::LinkStatus ls; - if (editor::parseLinkStatus(out2[size_t(i - first2)], &ls)) p.link[1] = ls; - } - p.linesSeen2 = p.emu2.lineCount(); - } - // Read lines printed since the last frame (some may already have scrolled out). - const std::deque& out = p.emu.output(); - if (p.emu.lineCount() < p.linesSeen) p.linesSeen = 0; // restarted - const uint64_t first = p.emu.lineCount() - out.size(); - for (uint64_t i = std::max(p.linesSeen, first); i < p.emu.lineCount(); ++i) { - editor::RenderPeak rp; - editor::RenderLost rl; - editor::LinkStatus ls; - if (editor::parseLinkStatus(out[size_t(i - first)], &ls)) p.link[0] = ls; - else if (editor::parseRenderPeak(out[size_t(i - first)], &rp)) p.peak = rp; - else if (editor::parseRenderLost(out[size_t(i - first)], &rl)) { - const double now = ImGui::GetTime(); - p.lostGap = p.lost ? now - p.lostAt : 0; - p.lostAt = now; - p.lost = rl; - p.lostMax = editor::maxRenderLost(p.lostMax, rl); - } - } - p.linesSeen = p.emu.lineCount(); - // Console 2 starts once console 1's link server is listening: redux's + if (editor::parseLinkStatus(line, &ls)) c.link = ls; + else if (k > 0) continue; + else if (editor::parseRenderPeak(line, &rp)) p.peak = rp; + else if (editor::parseRenderLost(line, &rl)) { + const double now = ImGui::GetTime(); + p.lostGap = p.lost ? now - p.lostAt : 0; + p.lostAt = now; + p.lost = rl; + p.lostMax = editor::maxRenderLost(p.lostMax, rl); + } + } + c.linesSeen = c.emu.lineCount(); + } + // Console k starts once console k-1 has connected: to console 1's link + // server with 2 linked directly, to the relay (its k-th connection, which + // is how the relay's connections map to consoles) otherwise. Redux's // client tries to connect once, at startup. - if (p.linkWaiting) { - if (!p.emu.running()) p.linkWaiting = false; - else if (editor::linkPortTaken(p.linkPort)) { - p.linkWaiting = false; + if (p.starting > 0) { + const int k = p.starting; + const uint64_t waited = SDL_GetTicks() - p.startSince; + const bool ready = p.relayed ? p.relay && p.relay->accepted() >= k : editor::linkPortTaken(p.linkPort); + if (!p.cons[0].emu.running()) p.starting = 0; + else if (ready) { std::string err; const editor::PlayTools eff = editor::withDefaults(p.tools, p.bundleDir); - if (!p.emu2.start(editor::reduxCommand(eff, playDir(), p.captureScript, editor::consoleDir(playDir(), 1)), &err)) - p.linkError = "Could not start pcsx-redux: " + err; - else { - editor::restartLog(p.playLog2); - scriptLine(nullptr, "play: console 2 started, link port " + std::to_string(p.linkPort)); - } - } else if (SDL_GetTicks() - p.linkSince > 15000) { - p.linkWaiting = false; - p.linkError = "Not started: console 1 never opened its link port (" + std::to_string(p.linkPort) + - "); this pcsx-redux may lack SIO1 server support"; - scriptLine(nullptr, "play: console 2 not started: " + p.linkError); - } - } - if (p.emu.running()) { - if (!p.game.attached() && p.game.attach(p.emu.pid())) p.gameFocus = true; - p.game.update(); - if (!p.game.attached() && p.message.empty() && SDL_GetTicks() - p.startedAt > 10000) + State::Play::Console& c = p.cons[size_t(k)]; + if (!c.emu.start(editor::reduxCommand(eff, playDir(), p.captureScript, editor::consoleDir(playDir(), k)), &err)) { + p.linkError = "Console " + std::to_string(k + 1) + " could not start pcsx-redux: " + err; + p.starting = 0; + } else { + editor::restartLog(c.log); + scriptLine(nullptr, "play: console " + std::to_string(k + 1) + " started, " + + (p.relayed ? "relay port " + std::to_string(p.relay->port()) + : "link port " + std::to_string(p.linkPort))); + p.starting = k + 1 < p.players ? k + 1 : 0; + p.startSince = SDL_GetTicks(); + } + } else if (waited > (p.relayed ? 30000u : 15000u)) { + p.starting = 0; + p.linkError = p.relayed ? "Console " + std::to_string(k + 1) + " not started: console " + std::to_string(k) + + " never connected to the relay (port " + std::to_string(p.relay ? p.relay->port() : 0) + + "); this pcsx-redux may lack SIO1 client support" + : "Not started: console 1 never opened its link port (" + std::to_string(p.linkPort) + + "); this pcsx-redux may lack SIO1 server support"; + scriptLine(nullptr, "play: console " + std::to_string(k + 1) + " not started: " + p.linkError); + } + } + if (p.cons[0].emu.running()) { + if (!p.cons[0].game.attached() && p.cons[0].game.attach(p.cons[0].emu.pid())) p.gameFocus = true; + p.cons[0].game.update(); + if (!p.cons[0].game.attached() && p.message.empty() && SDL_GetTicks() - p.startedAt > 10000) p.message = "pcsx-redux started but shows nothing here; it needs -shmdisplay support"; } else { - // Console 1 gone ends the session: console 2 goes with it. - if (p.emu2.running()) p.emu2.stop(); - p.linkWaiting = false; - if (p.game.attached()) { - p.game.detach(); + // Console 1 gone ends the session: the others and the relay go with it. + if (p.starting || std::any_of(p.cons.begin() + 1, p.cons.end(), [](const State::Play::Console& c) { return c.emu.running(); }) || + (p.relay && p.relay->running())) + stopPlay(p); + if (p.cons[0].game.attached()) { + p.cons[0].game.detach(); p.gameFocus = false; } } - if (p.emu2.running()) { - if (!p.game2.attached()) p.game2.attach(p.emu2.pid()); - p.game2.update(); - } else if (p.game2.attached()) { - p.game2.detach(); - if (p.focus == 1) p.focus = 0; + for (int k = 1; k < editor::kMaxPlayers; ++k) { + State::Play::Console& c = p.cons[size_t(k)]; + if (c.emu.running()) { + if (!c.game.attached()) c.game.attach(c.emu.pid()); + c.game.update(); + } else if (c.game.attached()) { + c.game.detach(); + if (p.focus == k) p.focus = 0; + } } pollFrameCapture(st); if (!p.build.valid() || p.build.wait_for(std::chrono::seconds(0)) != std::future_status::ready) return; @@ -522,29 +532,44 @@ static void updatePlay(State& st, const editor::Document& doc) { return; } std::string err; - std::filesystem::path console; // console 1's directory with 2 linked - if (p.players == 2) { - p.linkPort = editor::pickLinkPort(); - if (!p.linkPort) err = "no free TCP port for the link between the consoles"; - for (int i = 0; i < 2 && err.empty(); ++i) - editor::prepareConsole(editor::consoleDir(playDir(), i), i == 0, p.linkPort, &err); + std::filesystem::path console; // console 1's directory with linked consoles + if (p.players >= 2) { + // Relayed, every console is a client of the relay, which listens + // first; linked directly, console 1 is the server of console 2. + if (p.relayed) { + if (!p.relay) p.relay = std::make_unique(); + p.netFrozen.clear(); + p.netPaused = false; + if (p.relay->start(&err)) p.linkPort = p.relay->port(); + } else { + p.linkPort = editor::pickLinkPort(); + if (!p.linkPort) err = "no free TCP port for the link between the consoles"; + } + for (int i = 0; i < p.players && err.empty(); ++i) + editor::prepareConsole(editor::consoleDir(playDir(), i), !p.relayed && i == 0, p.linkPort, &err); if (!err.empty()) { - p.message = "Could not start 2 linked consoles: " + err; + if (p.relay) p.relay->stop(); + p.message = "Could not start " + std::to_string(p.players) + " linked consoles: " + err; return; } console = editor::consoleDir(playDir(), 0); + if (p.relayed) scriptLine(nullptr, "play: relay listening, port " + std::to_string(p.linkPort)); } - if (!p.emu.start(editor::reduxCommand(eff, playDir(), p.captureScript, console), &err)) + if (!p.cons[0].emu.start(editor::reduxCommand(eff, playDir(), p.captureScript, console), &err)) + { p.message = "Could not start pcsx-redux: " + err; + if (p.relay) p.relay->stop(); + } else { p.startedAt = SDL_GetTicks(); - editor::restartLog(p.playLog); + editor::restartLog(p.cons[0].log); scriptLine(nullptr, "play: boots " + utf8(engine.filename())); scriptLine(nullptr, "play: started"); - if (p.players == 2) { - p.linkWaiting = true; - p.linkSince = SDL_GetTicks(); - scriptLine(nullptr, "play: console 1 started, link port " + std::to_string(p.linkPort)); + if (p.players >= 2) { + p.starting = 1; + p.startSince = SDL_GetTicks(); + scriptLine(nullptr, std::string("play: console 1 started, ") + (p.relayed ? "relay port " : "link port ") + + std::to_string(p.linkPort)); } } } @@ -774,7 +799,7 @@ static void playSetup(State& st, const editor::Document& doc, ImVec2 size) { } // Title-bar menu labels this frame, for placing the open menu under its label. -static ImRect g_menuRects[6]; +static ImRect g_menuRects[7]; // the six menus, then the Players button // A press outside an open menu closes it; when that press lands on the same // label, its release must not open the menu again. static int g_menuIgnoreClick = -1; @@ -827,7 +852,12 @@ static void dockTabs(State& st, ImDrawList* dl, ImRect r) { st.bottomTab = State::BottomTab::Frame; } -static void setPlayers(State& st, int players); +// The Players button's words for `t`: "1 player", "2 linked", "2 via relay", "4 players". +static std::string playersLabel(const editor::PlayTools& t) { + if (t.players <= 1) return "1 player"; + if (t.players == 2) return t.relay ? "2 via relay" : "2 linked"; + return std::to_string(std::min(t.players, editor::kMaxPlayers)) + " players"; +} static ImRect titleBar(State& st, ImDrawList* dl, ImVec2 size, editor::Document& doc) { Fonts& f = fonts(); ImRect bar(ImVec2(0, 0), ImVec2(size.x, size::titleBar)); @@ -875,7 +905,7 @@ static ImRect titleBar(State& st, ImDrawList* dl, ImVec2 size, editor::Document& auto width = [&](const char* ic, const char* l) { return space::md * 2 + measure(f.medium, type::icon, ic).x + space::sm - 2 + measure(f.medium, type::body, l).x; }; - const bool playing = st.play.emu.running(), building = st.play.build.valid(); + const bool playing = st.play.cons[0].emu.running(), building = st.play.build.valid(); const char* playIcon = playing ? icon::square : icon::play; const char* playLabel = playing ? "Stop" : building ? "Building" : "Play"; wPlay = width(playIcon, playLabel); @@ -889,16 +919,18 @@ static ImRect titleBar(State& st, ImDrawList* dl, ImVec2 size, editor::Document& const bool exporting = st.play.disc.valid(); const char* exportLabel = exporting ? "Exporting" : "Export"; wExport = width(icon::package, exportLabel); - // Players, right of Play: 1 console, or 2 linked over SIO1 (the next Play). - const bool two = st.play.tools.players == 2; - const char* playersIcon = two ? icon::users : icon::user; - const char* playersLabel = two ? "2 linked" : "1 player"; - const float wPlayers = width(playersIcon, playersLabel) + space::sm; + // Players, right of Play: how many consoles the next Play boots; a click + // opens the list (menu 6). + const int players = st.play.tools.players; + const char* playersIcon = players >= 2 ? icon::users : icon::user; + const std::string playersText = playersLabel(st.play.tools); + const char* playersLabelText = playersText.c_str(); + const float wPlayers = width(playersIcon, playersLabelText) + space::sm; float total = wPlay + wRestart + wPlayers + wRun + wExport + space::sm * 2; float ax = std::floor(size.x * 0.5f + 90 - total * 0.5f); float ay = bar.Min.y + (bar.GetHeight() - (size::field + 6)) * 0.5f; const editor::PlayTools& tools = st.play.tools; - const std::string playTip = st.play.emu.running() ? std::string("Stop pcsx-redux (F5)") + const std::string playTip = st.play.cons[0].emu.running() ? std::string("Stop pcsx-redux (F5)") : tools.overlayPerf && tools.overlayRooms ? std::string("Build and run in pcsx-redux with the performance and room debug overlays (F5)") : tools.overlayPerf ? std::string("Build and run in pcsx-redux with the performance overlay (F5)") @@ -928,13 +960,18 @@ static ImRect titleBar(State& st, ImDrawList* dl, ImVec2 size, editor::Document& } } { - const std::string tip = std::string(two ? "Play runs 2 consoles linked by cable (SIO1), side by side, for a two-player scene" - : "Play runs 1 console") + - ". Click for " + (two ? "1 console" : "2 linked consoles") + - (st.play.emu.running() ? "; applies from the next Play" : ""); - if (button("players", ImVec2(ax + wPlay + wRestart + space::sm, ay), playersIcon, playersLabel, ButtonKind::Ghost, nullptr, - tip.c_str())) - setPlayers(st, two ? 1 : 2); + const std::string tip = + (players == 1 ? std::string("Play runs 1 console") + : !editor::usesRelay(st.play.tools) + ? std::string("Play runs 2 consoles linked by cable (SIO1), side by side, for a two-player scene") + : "Play runs " + std::to_string(players) + + " consoles, each linked to the editor's relay, for a multiplayer scene; the Network view shows what they send") + + ". Click to choose" + (st.play.cons[0].emu.running() ? "; applies from the next Play" : ""); + const ImVec2 at(ax + wPlay + wRestart + space::sm, ay); + float bw = 0; + if (button("players", at, playersIcon, playersLabelText, ButtonKind::Ghost, &bw, tip.c_str())) + st.menuOpen = st.menuOpen == 6 || g_menuIgnoreClick == 6 ? -1 : 6; + g_menuRects[6] = ImRect(at, at + ImVec2(bw, size::field + 6)); } if (button("run", ImVec2(ax + wPlay + wRestart + wPlayers + space::sm, ay), runIcon, runLabel, ButtonKind::Secondary, nullptr, onHw ? "Stop serving the console (Ctrl+F5)" : "Upload to a console over serial (Ctrl+F5)")) @@ -2571,13 +2608,20 @@ static MenuEntry heading(std::string label) { return e; } -// The Players button and Build > One Console / Two Linked Consoles: how -// many consoles the next Play boots. Saved with the tools. +// The Players list (the toolbar button, Build > Players): how many consoles +// the next Play boots, and whether 2 go through the relay. Saved with the tools. static void setPlayers(State& st, int players) { State::Play& p = st.play; - p.tools.players = players == 2 ? 2 : 1; + p.tools.players = std::clamp(players, 1, editor::kMaxPlayers); if (!p.settingsFile.empty()) editor::savePlayTools(p.settingsFile, p.tools); - scriptLine(nullptr, "play: players " + std::to_string(p.tools.players)); + scriptLine(nullptr, "play: players " + std::to_string(p.tools.players) + (editor::usesRelay(p.tools) ? " relay" : "")); +} + +static void setRelay(State& st, bool on) { + State::Play& p = st.play; + p.tools.relay = on; + if (!p.settingsFile.empty()) editor::savePlayTools(p.settingsFile, p.tools); + scriptLine(nullptr, "play: players " + std::to_string(p.tools.players) + (editor::usesRelay(p.tools) ? " relay" : "")); } // Build > Performance / Room Debug Overlay: which psxsplash build Play and @@ -2660,8 +2704,8 @@ static std::vector menuEntries(int which, State& st, editor::Document m.push_back(item("Align with View", "Ctrl+Shift+F", hasSel, [&] { st.alignRequest = true; })); 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("Restart", "Ctrl+Shift+F5", st.play.emu.running(), [&] { restartPlay(st, doc); })); + m.push_back(item(st.play.cons[0].emu.running() ? "Stop" : "Play", "F5", !st.play.build.valid(), [&] { togglePlay(st, doc); })); + m.push_back(item("Restart", "Ctrl+Shift+F5", st.play.cons[0].emu.running(), [&] { restartPlay(st, doc); })); m.push_back(item("Capture Frame", "", canCaptureFrame(st), [&] { captureFrame(st); })); m.push_back(item(st.play.hw.active() ? "Stop Hardware Run" : "Run on Hardware", "Ctrl+F5", !st.play.hwBuild.valid(), [&] { toggleRun(st, doc); })); @@ -2681,8 +2725,15 @@ static std::vector menuEntries(int which, State& st, editor::Document m.push_back(item("Boot Logo in Test Runs", "", true, [&] { setBootLogo(st, !st.play.tools.bootLogo); }, st.play.tools.bootLogo)); m.push_back(separator()); - m.push_back(item("Play One Console", "", true, [&] { setPlayers(st, 1); }, st.play.tools.players != 2)); - m.push_back(item("Play Two Linked Consoles", "", true, [&] { setPlayers(st, 2); }, st.play.tools.players == 2)); + m.push_back(item("Players: " + playersLabel(st.play.tools) + "...", "", true, [&] { st.menuOpen = 6; })); + break; + case 6: // the Players button's list + m.push_back(heading("Consoles Play boots")); + for (int n = 1; n <= editor::kMaxPlayers; ++n) + m.push_back(item(n == 1 ? std::string("1 Console") : n == 2 ? std::string("2 Linked Consoles") : std::to_string(n) + " Consoles", + "", true, [&st, n] { setPlayers(st, n); }, st.play.tools.players == n)); + m.push_back(separator()); + m.push_back(item("Use the Relay for 2 Consoles", "", true, [&] { setRelay(st, !st.play.tools.relay); }, st.play.tools.relay)); break; case 4: // View m.push_back(item("Hand", "Q", true, [&] { st.tool = 0; }, st.tool == 0)); @@ -2806,7 +2857,7 @@ static std::function menuRows(const std::vector& entries, flo } static void menus(State& st, editor::Document& doc, viewport::Ps1View& view) { - static const char* ids[6] = {"##m_file", "##m_edit", "##m_object", "##m_build", "##m_view", "##m_help"}; + static const char* ids[7] = {"##m_file", "##m_edit", "##m_object", "##m_build", "##m_view", "##m_help", "##m_players"}; static int shown = -1; // the menu whose popup was open last frame if (shown >= 0 && !ImGui::IsPopupOpen(ids[shown])) { // Closed by a press elsewhere: that press must not reopen it from its own label. @@ -2838,7 +2889,7 @@ static void menus(State& st, editor::Document& doc, viewport::Ps1View& view) { ImGuiWindowFlags_NoSavedSettings); ImGui::PopStyleVar(2); if (!open) return; - static const char* names[6] = {"menu.file", "menu.edit", "menu.object", "menu.build", "menu.view", "menu.help"}; + static const char* names[7] = {"menu.file", "menu.edit", "menu.object", "menu.build", "menu.view", "menu.help", "menu.players"}; std::function run = menuRows(entries, w, h, names[shown]); if (ImGui::IsKeyPressed(ImGuiKey_Escape, false)) st.menuOpen = -1; if (run) st.menuOpen = -1; @@ -4364,7 +4415,7 @@ static void viewportInput(State& st, ImRect r, editor::Document& doc, viewport:: st.followAt[0] = selAt.x, st.followAt[1] = selAt.y, st.followAt[2] = selAt.z; } - const bool keys = !io.WantTextInput && !(st.play.gameFocus && st.play.game.attached()); + const bool keys = !io.WantTextInput && !(st.play.gameFocus && st.play.cons[0].game.attached()); if ((keys && io.KeyCtrl && io.KeyShift && ImGui::IsKeyPressed(ImGuiKey_F, false)) || st.alignRequest) alignWithView(doc, view, flats); st.alignRequest = false; if ((keys && !io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_F, false)) || st.frameRequest) { @@ -4435,26 +4486,49 @@ static uint16_t hostPad(bool keyboard) { static std::string cutTo(ImFont* font, float size, const std::string& s, float room); -// With 2 linked consoles the Game view holds both: side by side when that -// gives the 4:3 pictures more room, stacked otherwise, each with its link -// status line under its picture. +// With linked consoles the Game view holds them all, each with its link +// status line under its picture: 2 side by side when that gives the 4:3 +// pictures more room, stacked otherwise; more in a grid of ceil(sqrt(N)) +// columns. static constexpr float kLinkStatusH = 24; -static void consoleRects(ImRect r, ImRect out[2]) { +static std::vector consoleRects(ImRect r, int n) { const float g = space::sm, w = r.GetWidth(), h = r.GetHeight(), lh = kLinkStatusH; - const bool across = std::min((w - g) / 2, (h - lh) * 4 / 3) >= std::min(w, ((h - g) / 2 - lh) * 4 / 3); - if (across) { - const float half = (w - g) / 2; - out[0] = ImRect(r.Min, ImVec2(r.Min.x + half, r.Max.y)); - out[1] = ImRect(ImVec2(r.Max.x - half, r.Min.y), r.Max); - } else { - const float half = (h - g) / 2; - out[0] = ImRect(r.Min, ImVec2(r.Max.x, r.Min.y + half)); - out[1] = ImRect(ImVec2(r.Min.x, r.Max.y - half), r.Max); + std::vector out; + if (n == 2) { + const bool across = std::min((w - g) / 2, (h - lh) * 4 / 3) >= std::min(w, ((h - g) / 2 - lh) * 4 / 3); + if (across) { + const float half = (w - g) / 2; + out.push_back(ImRect(r.Min, ImVec2(r.Min.x + half, r.Max.y))); + out.push_back(ImRect(ImVec2(r.Max.x - half, r.Min.y), r.Max)); + } else { + const float half = (h - g) / 2; + out.push_back(ImRect(r.Min, ImVec2(r.Max.x, r.Min.y + half))); + out.push_back(ImRect(ImVec2(r.Min.x, r.Max.y - half), r.Max)); + } + return out; } + const int cols = std::max(1, int(std::ceil(std::sqrt(double(n) - 1e-9)))); + const int rows = (n + cols - 1) / cols; + const float cw = (w - g * float(cols - 1)) / float(cols), ch = (h - g * float(rows - 1)) / float(rows); + for (int i = 0; i < n; ++i) { + const int cx = i % cols, cy = i / cols; + const ImVec2 a(r.Min.x + float(cx) * (cw + g), r.Min.y + float(cy) * (ch + g)); + out.push_back(ImRect(a, a + ImVec2(cw, ch))); + } + return out; +} + +// The relay connection console `i` is (its accept order, see updatePlay), if it connected. +static std::optional relayConnection(const State::Play& p, int i) { + if (!p.relayed || !p.relay) return std::nullopt; + for (const editor::relay::Connection& c : p.relay->connections()) + if (c.index == i + 1) return c; + return std::nullopt; } -// One of 2 linked consoles: its picture, a "Console N" tag, the keys while it -// has them (the focused one is outlined), and its link status line below. +// One of the linked consoles: its picture, a "Console N" tag, the keys while +// it has them (the focused one is outlined), and its status line below: its +// relay slot when relayed, then what its engine last reported of its link. static void consolePanel(State& st, ImDrawList* dl, ImRect r, int i) { Fonts& f = fonts(); State::Play& p = st.play; @@ -4467,12 +4541,17 @@ static void consolePanel(State& st, ImDrawList* dl, ImRect r, int i) { const ImVec2 c = pic.GetCenter(); if (unsigned tex = game.texture()) ui::addTextureImage(dl, (ImTextureID)(intptr_t)tex, c - ImVec2(gw / 2, gh / 2), c + ImVec2(gw / 2, gh / 2)); - else if (i == 1 && !p.linkError.empty()) + else if (i > 0 && !p.linkError.empty() && !consoleEmu(p, i).running()) textCentered(dl, pic, f.regular, type::body, color::bad, cutTo(f.regular, type::body, p.linkError, pic.GetWidth() - space::md * 2).c_str()); - else - textCentered(dl, pic, f.regular, type::body, color::textDim, - i == 1 && p.linkWaiting ? "Waiting for console 1's link..." : "Waiting for pcsx-redux..."); + else { + const std::string wait = p.starting == i && i > 0 + ? (p.relayed ? "Waiting for console " + std::to_string(i) + " to reach the relay..." + : std::string("Waiting for console 1's link...")) + : p.starting > 0 && i > p.starting ? std::string("Starts after console ") + std::to_string(i) + : std::string("Waiting for pcsx-redux..."); + textCentered(dl, pic, f.regular, type::body, color::textDim, cutTo(f.regular, type::body, wait, pic.GetWidth() - space::md * 2).c_str()); + } const std::string name = "Console " + std::to_string(i + 1); const ImVec2 ls = measure(f.medium, type::label, name.c_str()); const ImRect tag(pic.Min + ImVec2(space::md, space::md), pic.Min + ImVec2(space::md + ls.x + 20, space::md + 24)); @@ -4494,7 +4573,7 @@ static void consolePanel(State& st, ImDrawList* dl, ImRect r, int i) { } if (active) dl->AddRect(pic.Min, pic.Max, color::accent, radius::window, 0, 2.0f); // The link status line: what this console's engine last reported. - const std::optional& ls2 = p.link[i]; + const std::optional& ls2 = p.cons[size_t(i)].link; std::string line; ImU32 dot = color::textFaint; if (!consoleEmu(p, i).running()) line = "Not running"; @@ -4503,6 +4582,15 @@ static void consolePanel(State& st, ImDrawList* dl, ImRect r, int i) { line = editor::describeLinkStatus(*ls2, p.networkId); dot = ls2->linked() ? color::good : ls2->state == 1 ? color::warn : ls2->state >= 3 ? color::bad : color::textFaint; } + if (p.relayed && consoleEmu(p, i).running()) { + const std::optional rc = relayConnection(p, i); + const std::string relay = !rc ? "Relay: not connected" + : !rc->open ? "Relay: disconnected" + : rc->joined ? "Relay slot " + std::to_string(rc->slot) + : "Relay: connected, no Hello yet"; + line = relay + " \xc2\xb7 " + line; + if (!rc || !rc->open) dot = color::bad; + } const ImRect sl(ImVec2(r.Min.x, pic.Max.y), r.Max); const float y = sl.Min.y + (sl.GetHeight() - measure(f.regular, type::caption, "Ag").y) * 0.5f; dl->AddCircleFilled(ImVec2(sl.Min.x + 8, sl.GetCenter().y), 3.5f, dot, 12); @@ -4521,17 +4609,16 @@ static void consolePanel(State& st, ImDrawList* dl, ImRect r, int i) { static void gamePanel(State& st, ImDrawList* dl, ImRect r) { Fonts& f = fonts(); State::Play& p = st.play; - if (p.players == 2) { - ImRect cr[2]; - consoleRects(r, cr); - for (int i = 0; i < 2; ++i) consolePanel(st, dl, cr[i], i); + if (p.players >= 2) { + const std::vector cr = consoleRects(r, p.players); + for (int i = 0; i < p.players; ++i) consolePanel(st, dl, cr[size_t(i)], i); // The keyboard and pad go to the focused console only. - p.game.setPads(p.focus == 0 ? hostPad(p.gameFocus) : 0xffff, 0xffff); - p.game2.setPads(p.focus == 1 ? hostPad(p.gameFocus) : 0xffff, 0xffff); - if (!p.captureScript.empty()) { + const uint16_t pad = hostPad(p.gameFocus); + for (int i = 0; i < p.players; ++i) p.cons[size_t(i)].game.setPads(p.focus == i ? pad : 0xffff, 0xffff); + if (!p.captureScript.empty() && p.focus < p.players) { const char* capLabel = p.capturing ? "Capturing..." : "Capture frame"; const float bw = buttonWidth(icon::camera, capLabel); - const ImRect fr = cr[p.focus]; + const ImRect fr = cr[size_t(p.focus)]; const ImVec2 at(fr.Max.x - space::md - bw, fr.Min.y + space::md - 3); if (button("game.capture", at, icon::camera, capLabel, ButtonKind::Secondary, nullptr, "Record the next frame this console draws and open it in the Frame tab") && @@ -4545,12 +4632,12 @@ static void gamePanel(State& st, ImDrawList* dl, ImRect r) { float gw = std::min(w, h * 4 / 3), gh = gw * 3 / 4; ImVec2 c = r.GetCenter(); ImRect g(c - ImVec2(gw / 2, gh / 2), c + ImVec2(gw / 2, gh / 2)); - if (unsigned tex = p.game.texture()) + if (unsigned tex = p.cons[0].game.texture()) ui::addTextureImage(dl, (ImTextureID)(intptr_t)tex, g.Min, g.Max); else textCentered(dl, r, f.regular, type::body, color::textDim, "Waiting for pcsx-redux..."); // The keyboard is the controller only while the Game view has focus; a gamepad always is. - p.game.setPads(hostPad(p.gameFocus), 0xffff); + p.cons[0].game.setPads(hostPad(p.gameFocus), 0xffff); const char* label = "Game"; ImVec2 ls = measure(f.medium, type::label, label); ImRect tag(r.Min + ImVec2(space::md, space::md), r.Min + ImVec2(space::md + ls.x + 20, space::md + 24)); @@ -4565,7 +4652,7 @@ static void gamePanel(State& st, ImDrawList* dl, ImRect r) { // Triangles the engine lost or bent lately, above that. It reports only // when there are some, every 60 frames, so a report much older than the // gap between the last two means there are none now. - const bool lostNow = p.emu.running() && p.lost && p.lost->any() && + const bool lostNow = p.cons[0].emu.running() && p.lost && p.lost->any() && ImGui::GetTime() - p.lostAt <= std::max(3.0, 1.5 * p.lostGap); if (lostNow) { const std::string lost = "Last 60 frames: " + editor::describeRenderLost(*p.lost); @@ -4626,11 +4713,10 @@ static ImRect splitViewport(State& st, ImDrawList* dl, ImRect r) { ImGui::IsMouseClicked(ImGuiMouseButton_Middle))) { if (game.Contains(ImGui::GetMousePos())) p.gameFocus = true; if (scene.Contains(ImGui::GetMousePos())) p.gameFocus = false; - if (p.players == 2) { - ImRect cr[2]; - consoleRects(game, cr); - for (int i = 0; i < 2; ++i) - if (cr[i].Contains(ImGui::GetMousePos())) p.focus = i; + if (p.players >= 2) { + const std::vector cr = consoleRects(game, p.players); + for (int i = 0; i < p.players; ++i) + if (cr[size_t(i)].Contains(ImGui::GetMousePos())) p.focus = i; } } gamePanel(st, dl, game); @@ -4942,7 +5028,7 @@ static void viewportPanel(State& st, ImDrawList* dl, ImRect r, editor::Document& st.prefabViews.pop_back(); } Fonts& f = fonts(); - if (st.play.game.attached()) r = splitViewport(st, dl, r); + if (st.play.cons[0].game.attached()) r = splitViewport(st, dl, r); view.clean = st.viewMode == 1; // In pixels, so the clean view stays sharp at any UI scale or pixel density. const ImVec2 fb = ImGui::GetIO().DisplayFramebufferScale; @@ -9973,6 +10059,217 @@ static std::string cutTo(ImFont* font, float size, const std::string& s, float r return shown + "..."; } +// ---- The Network view: what Play's relay routed (editor/relay.hh). ---- + +// A colour per kind of packet: handshakes blue, reliable traffic green, +// application data amber, snapshots cyan, keepalives faint. +static ImU32 packetColor(uint8_t type) { + namespace rl = editor::relay; + switch (type) { + case rl::Hello: + case rl::HelloAck: + case rl::Bye: return color::accent; + case rl::Event: + case rl::EventAck: return color::good; + case rl::ObjectState: + case rl::AppData: return color::warn; + case rl::Snapshot: return code::api; + case rl::Ping: + case rl::Pong: return color::textFaint; + default: return color::textDim; + } +} + +// A small toggle chip at `at`, flowing onto a new line past `maxX` (back to +// `x0`); `at` moves past it. True when clicked. +static bool netChip(const std::string& id, ImVec2& at, float x0, float maxX, const std::string& label, ImU32 tone, bool on, + const std::string& tip = {}) { + Fonts& f = fonts(); + ImDrawList* dl = ImGui::GetWindowDrawList(); + const ImVec2 ts = measure(f.medium, type::caption, label.c_str()); + const float w = ts.x + 30, h = 24; + if (at.x + w > maxX && at.x > x0) { + at.x = x0; + at.y += h + space::xs; + } + const ImRect r(at, at + ImVec2(w, h)); + const Hit hit = interact(id.c_str(), r); + nameRect(id, r); + dl->AddRectFilled(r.Min, r.Max, on ? lerpColor(color::hover, color::active, hit.hover) : lerpColor(color::field, color::hover, hit.hover), + radius::pill); + if (on) dl->AddRect(r.Min, r.Max, tone, radius::pill, 0, 1.0f); + dl->AddCircleFilled(ImVec2(r.Min.x + 12, r.GetCenter().y), 3.5f, on ? tone : color::textFaint, 12); + text(dl, ImVec2(r.Min.x + 21, r.Min.y + (h - ts.y) * 0.5f), f.medium, type::caption, on ? color::text : color::textFaint, label.c_str()); + if (!tip.empty() && hit.hovered) tooltip(tip.c_str()); + at.x += w + space::xs; + return hit.clicked; +} + +// The relay's connections, a filter by console slot and by packet type, a +// Pause, and every packet that passes, newest last. +static void networkView(State& st) { + namespace rl = editor::relay; + State::Play& p = st.play; + Fonts& f = fonts(); + ImDrawList* dl = ImGui::GetWindowDrawList(); + const ImVec2 o = ImGui::GetCursorScreenPos(); + const float width = ImGui::GetContentRegionAvail().x, maxX = o.x + width; + if (!p.relay) { + text(dl, o, f.regular, type::body, color::textDim, + "The relay runs while Play boots 3 or more consoles, or 2 with Use the Relay on (the Players button)."); + ImGui::Dummy(ImVec2(width, 28)); + scriptLine("net.view", "net: no relay"); + return; + } + const std::vector conns = p.relay->connections(); + std::vector live; + if (!p.netPaused) live = p.relay->packets(); + const std::vector& all = p.netPaused ? p.netFrozen : live; + + // What the relay is doing, in one line, and Pause at the right. + int joined = 0; + for (const rl::Connection& c : conns) joined += c.open && c.joined; + const std::string head = p.relay->running() ? "Relay on 127.0.0.1:" + std::to_string(p.relay->port()) + " \xc2\xb7 " + + std::to_string(joined) + " of " + std::to_string(p.players) + " consoles in a room" + : "Relay stopped \xc2\xb7 what it routed last Play"; + const float rowH = size::field + 6; + const ImVec2 hs = measure(f.medium, type::body, head.c_str()); + dl->AddCircleFilled(ImVec2(o.x + 6, o.y + rowH * 0.5f), 4, p.relay->running() ? color::good : color::textFaint, 12); + text(dl, ImVec2(o.x + 18, o.y + (rowH - hs.y) * 0.5f), f.medium, type::body, color::text, head.c_str()); + { + const char* label = p.netPaused ? "Resume" : "Pause"; + const char* ic = p.netPaused ? icon::play : nullptr; + const float bw = buttonWidth(ic, label); + const ImVec2 at(maxX - bw, o.y); + if (button("net.pause", at, ic, label, p.netPaused ? ButtonKind::Secondary : ButtonKind::Ghost, + nullptr, p.netPaused ? "Show packets as they come again" : "Hold the list still to read it; the relay keeps routing")) { + p.netPaused = !p.netPaused; + if (p.netPaused) p.netFrozen = p.relay->packets(); + else p.netFrozen.clear(); + scriptLine(nullptr, std::string("net: ") + (p.netPaused ? "paused" : "resumed")); + } + nameRect("net.pause", ImRect(at, at + ImVec2(bw, rowH))); + } + + // Slots: All, then a chip per console (its slot, and whether it is in). + float y = o.y + rowH + space::sm; + const float labelW = 52; + text(dl, ImVec2(o.x, y + 4), f.regular, type::caption, color::textFaint, "Slot"); + ImVec2 at(o.x + labelW, y); + if (netChip("net.slot.all", at, o.x + labelW, maxX, "All", color::accent, p.netFilter.slot < 0, "Packets from and to every console")) + p.netFilter.slot = -1; + std::string connLine; + for (const rl::Connection& c : conns) { + const bool in = c.open && c.joined; + const std::string label = "Console " + std::to_string(c.index) + (in ? " slot " + std::to_string(c.slot) : c.open ? " no slot" : " gone"); + char ago[48]; + std::snprintf(ago, sizeof ago, "last heard from %.1f s ago", std::max(0.0, p.relay->seconds() - c.lastSeen)); + const std::string tip = "Relay connection " + std::to_string(c.index) + (in ? ", slot " + std::to_string(c.slot) : "") + ": " + + std::to_string(c.framesIn) + " frames in, " + std::to_string(c.framesOut) + " out, " + + (c.open ? std::string(ago) : std::string("disconnected")) + ". Click to show only packets from or to it."; + const bool on = in && p.netFilter.slot == c.slot; + if (netChip("net.slot." + std::to_string(c.index), at, o.x + labelW, maxX, label, in ? color::good : c.open ? color::warn : color::bad, on, tip) && + in) + p.netFilter.slot = on ? -1 : c.slot; + connLine += " " + std::to_string(c.index) + ":" + (in ? std::to_string(c.slot) : std::string("-")); + } + if (conns.empty()) text(dl, ImVec2(at.x + space::sm, y + 4), f.regular, type::caption, color::textFaint, "No console has connected yet"); + scriptLine("net.conns", "net: connections" + (connLine.empty() ? std::string(" none") : connLine)); + + // Types, each with how many came. + y = at.y + 24 + space::sm; + text(dl, ImVec2(o.x, y + 4), f.regular, type::caption, color::textFaint, "Type"); + at = ImVec2(o.x + labelW, y); + uint64_t perType[rl::kPacketTypes + 1] = {}; + for (const rl::Packet& k : all) + if (k.type >= 1 && k.type <= rl::kPacketTypes) perType[k.type] += k.count; + std::string typeLine; + for (int t = 1; t <= rl::kPacketTypes; ++t) { + const std::string name = rl::packetTypeName(uint8_t(t)); + const bool on = p.netFilter.types & (1u << t); + if (netChip("net.type." + name, at, o.x + labelW, maxX, name + " " + std::to_string(perType[t]), packetColor(uint8_t(t)), on, + on ? "Click to hide " + name + " packets" : "Click to show " + name + " packets")) + p.netFilter.types ^= 1u << t; + if (perType[t]) typeLine += " " + name + " " + std::to_string(perType[t]); + } + scriptLine("net.types", "net: types" + typeLine); + + std::vector shown; + for (const rl::Packet& k : all) + if (p.netFilter.shows(k)) shown.push_back(&k); + y = at.y + 24 + space::sm; + { + const std::string count = std::to_string(shown.size()) + " of " + std::to_string(all.size()) + " shown" + + (p.netPaused ? " \xc2\xb7 paused" : ""); + const ImVec2 cs = measure(f.regular, type::caption, count.c_str()); + text(dl, ImVec2(maxX - cs.x, y), f.regular, type::caption, p.netPaused ? color::warn : color::textFaint, count.c_str()); + text(dl, ImVec2(o.x, y), f.regular, type::caption, color::textFaint, + "Newest last. Snapshots, Pings and Pongs a console sends within a second share a row."); + y += cs.y + space::xs; + } + std::string filterWords = p.netFilter.slot < 0 ? "any" : std::to_string(p.netFilter.slot); + scriptLine("net.view", "net: shown " + std::to_string(shown.size()) + " of " + std::to_string(all.size()) + " slot " + filterWords + + (p.netPaused ? " paused" : "")); + { + // The newest few of the rows shown, for scripted checks. + std::string last; + for (size_t i = shown.size() > 3 ? shown.size() - 3 : 0; i < shown.size(); ++i) + last += " | " + rl::packetTypeName(shown[i]->type) + " " + rl::describeEnd(shown[i]->from, shown[i]->conn) + " > " + + rl::describeTo(shown[i]->to); + scriptLine("net.last", "net: last" + last); + } + + ImGui::SetCursorScreenPos(ImVec2(o.x, y)); + ImGui::PushFont(f.regular, type::caption); + ImGui::PushStyleColor(ImGuiCol_TableRowBg, color::field); + ImGui::PushStyleColor(ImGuiCol_TableRowBgAlt, lerpColor(color::field, color::raised, 0.5f)); + ImGui::PushStyleColor(ImGuiCol_TableHeaderBg, color::raised); + const ImGuiTableFlags tf = ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY | ImGuiTableFlags_SizingFixedFit | + ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_PadOuterX; + if (ImGui::BeginTable("##netpackets", 6, tf, ImVec2(width, 340))) { + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableSetupColumn("Time", ImGuiTableColumnFlags_WidthFixed, 70); + ImGui::TableSetupColumn("From", ImGuiTableColumnFlags_WidthFixed, 70); + ImGui::TableSetupColumn("To", ImGuiTableColumnFlags_WidthFixed, 130); + ImGui::TableSetupColumn("Type", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Seq", ImGuiTableColumnFlags_WidthFixed, 56); + ImGui::TableSetupColumn("Bytes", ImGuiTableColumnFlags_WidthFixed, 64); + ImGui::PushStyleColor(ImGuiCol_Text, color::textDim); + ImGui::TableHeadersRow(); + ImGui::PopStyleColor(); + ImGuiListClipper clip; + clip.Begin(int(shown.size())); + while (clip.Step()) + for (int i = clip.DisplayStart; i < clip.DisplayEnd; ++i) { + const rl::Packet& k = *shown[size_t(i)]; + char tbuf[32]; + std::snprintf(tbuf, sizeof tbuf, "%.3f", k.time); + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::textFaint), "%s", tbuf); + ImGui::TableNextColumn(); + ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(k.from == rl::kHostSlot ? color::textDim : color::text), "%s", + rl::describeEnd(k.from, k.conn).c_str()); + ImGui::TableNextColumn(); + ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::textDim), "\xe2\x86\x92 %s", rl::describeTo(k.to).c_str()); + ImGui::TableNextColumn(); + ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(packetColor(k.type)), "%s", rl::packetTypeName(k.type).c_str()); + if (k.count > 1) { + ImGui::SameLine(); + ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::textFaint), "\xc3\x97%u", unsigned(k.count)); + } + ImGui::TableNextColumn(); + if (k.count == 1 && k.type != rl::Snapshot) ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::textFaint), "%u", unsigned(k.seq)); + ImGui::TableNextColumn(); + ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::textFaint), "%u", unsigned(k.size)); + } + if (!p.netPaused && ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 4) ImGui::SetScrollHereY(1.0f); + ImGui::EndTable(); + } + ImGui::PopStyleColor(3); + ImGui::PopFont(); +} + // Status bar entry for Play: building, running (click for its output), how // it ended, or why it did not start. static float playStatus(ImDrawList* dl, ImRect bar, float x, float maxX, State& st, const editor::Document& doc) { @@ -9982,14 +10279,16 @@ static float playStatus(ImDrawList* dl, ImRect bar, float x, float maxX, State& ImU32 tone = color::textDim, dot = 0; if (p.build.valid()) { label = "Building for Play"; - } else if (p.emu.running()) { - label = p.players == 2 ? "pcsx-redux running, 2 linked consoles" : "pcsx-redux running"; + } else if (p.cons[0].emu.running()) { + label = p.players >= 2 ? "pcsx-redux running, " + std::to_string(p.players) + " linked consoles" + + (p.relayed ? " via the relay" : "") + : "pcsx-redux running"; dot = color::good; } else if (!p.message.empty()) { label = p.message; tone = color::bad; - } else if (p.emu.exitCode()) { - int c = *p.emu.exitCode(); + } else if (p.cons[0].emu.exitCode()) { + int c = *p.cons[0].emu.exitCode(); label = c ? "pcsx-redux exited with code " + std::to_string(c) : "pcsx-redux closed"; tone = c ? color::bad : color::textFaint; } else { @@ -10000,7 +10299,7 @@ static float playStatus(ImDrawList* dl, ImRect bar, float x, float maxX, State& label = cutTo(f.medium, type::caption, label, std::max(60.0f, maxX - x - pad - 10)); ImVec2 ls = measure(f.medium, type::caption, label.c_str()); ImRect r(ImVec2(x, bar.Min.y + 5), ImVec2(x + pad + ls.x + 10, bar.Max.y - 5)); - const bool hasOutput = !p.emu.output().empty(); + const bool hasOutput = !p.cons[0].emu.output().empty() || p.relay; Hit h = interact("playstatus", r); nameRect("playstatus", r); if (label != whole) tooltip(whole.c_str()); @@ -10012,17 +10311,37 @@ static float playStatus(ImDrawList* dl, ImRect bar, float x, float maxX, State& 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(720, 0)); + const bool network = p.relay && p.outputTab == 1; + const float popupW = network ? 900.0f : 720.0f; + // Kept inside the window: the status entry sits well right of the middle. + const float popupX = std::max(space::sm, std::min(r.Min.x, ImGui::GetIO().DisplaySize.x - popupW - space::sm)); + ImGui::SetNextWindowPos(ImVec2(popupX, r.Min.y - space::xs), ImGuiCond_Always, ImVec2(0, 1)); + ImGui::SetNextWindowSize(ImVec2(popupW, 0)); pushPopupStyle(); const bool open = ImGui::BeginPopup("##playoutput", ImGuiWindowFlags_NoSavedSettings); ImGui::PopStyleVar(4); ImGui::PopStyleColor(2); if (!open) return r.Max.x + space::md; - // With 2 linked consoles, the output of the one that has the keyboard. - if (p.players == 2 && p.focus == 1) outputLog(st, "play", p.emu2.output(), p.emu2.lineCount(), p.playLog2); - else outputLog(st, "play", p.emu.output(), p.emu.lineCount(), p.playLog); - if (p.emu.running()) { + // Relayed: Output (the focused console's lines) or Network (the relay's packets). + const int shownConsole = p.players >= 2 && p.focus < p.players ? p.focus : 0; + if (p.relay) { + const ImVec2 c = ImGui::GetCursorScreenPos(); + ImVec2 at = c; + const float maxX = c.x + ImGui::GetContentRegionAvail().x; + if (netChip("play.tab.output", at, c.x, maxX, "Output of console " + std::to_string(shownConsole + 1), color::accent, !network, + "What the console with the keyboard printed; click a console in the Game view to read another")) + p.outputTab = 0; + if (netChip("play.tab.network", at, c.x, maxX, "Network", color::accent, network, "Every packet the relay routed between the consoles")) + p.outputTab = 1; + ImGui::SetCursorScreenPos(ImVec2(c.x, at.y + 24 + space::sm)); + scriptLine("play.tab", std::string("play: output tab ") + (network ? "network" : "output")); + } + if (network) networkView(st); + else { + State::Play::Console& c = p.cons[size_t(shownConsole)]; + outputLog(st, "play", c.emu.output(), c.emu.lineCount(), c.log); + } + if (p.cons[0].emu.running()) { ImVec2 c = ImGui::GetCursorScreenPos(); float ws = 0; if (button("stopplay", c, icon::square, "Stop", ButtonKind::Secondary, &ws)) stopPlay(p); @@ -10352,7 +10671,7 @@ static void shortcuts(State& st, editor::Document& doc) { } if (io.WantTextInput || st.unsavedShown || st.newProject.shown || st.unityImport.shown || st.menuOpen >= 0) return; // While the game has the viewport its keys are the controller; only Play/Stop stays. - if (st.play.gameFocus && st.play.game.attached()) { + if (st.play.gameFocus && st.play.cons[0].game.attached()) { if (!ctrl && pressed(ImGuiKey_F5)) togglePlay(st, doc); if (ctrl && shift && pressed(ImGuiKey_F5)) restartPlay(st, doc); return; diff --git a/src/mockup/main_screen.h b/src/mockup/main_screen.h index 6060ad5..7f9cf8c 100644 --- a/src/mockup/main_screen.h +++ b/src/mockup/main_screen.h @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -27,6 +28,7 @@ #include "editor/ui_canvas_view.hh" #include "editor/linkplay.hh" #include "editor/play.hh" +#include "editor/relay.hh" #include "editor/project.hh" #include "editor/frame_view.hh" #include "editor/timeline_view.hh" @@ -113,26 +115,44 @@ struct State { bool capturing = false; // a capture.flag is out, waiting for capture.gpd uint64_t captureAt = 0; // SDL ticks when it was written std::future build; - Emulator emu; - GameView game; // what redux shows, once it has published a frame + // The session's consoles, cons[0] being console 1 (the only one with + // 1 player): its pcsx-redux, what it shows once it has published a + // frame, its last link report, the output lines read so far and its + // output popup's filter. A fixed array, so references to one stay put. + struct Console { + Emulator emu; + GameView game; + std::optional link; + uint64_t linesSeen = 0; + editor::LogView log; + }; + std::array cons; bool gameFocus = false; // the Game view has the keyboard (a click in either view moves it) - // Play with 2 linked consoles (tools.players): the session's console - // count, fixed when Play starts; console 2 (redux's SIO1 client), - // started once console 1's link port is up; which console has the - // keyboard and pad; each one's last link report; why console 2 did - // not start; and which console a capture in flight asked. + // Play with linked consoles (tools.players): the session's console + // count and whether they go through the relay, fixed when Play + // starts; the console waiting to start (0: none) and since when (each + // starts only once the one before it has connected: redux's client + // tries once, at startup); which console has the keyboard and pad; + // why a console did not start; and which console a capture in flight + // asked. Two consoles without the relay link directly on linkPort. int players = 1; - Emulator emu2; - GameView game2; + bool relayed = false; int focus = 0; uint16_t linkPort = 0; - bool linkWaiting = false; - uint64_t linkSince = 0; - std::optional link[2]; + int starting = 0; + uint64_t startSince = 0; bool networkId = false; // the played scene has a Network id std::string linkError; int captureConsole = 0; - uint64_t linesSeen2 = 0; + // The relay (editor/relay.hh) of the last relayed Play, kept after it + // stops so the Network view still shows what it routed. The Network + // view: which tab the output popup shows (0 Output, 1 Network), its + // filter, and while paused, the packets it froze on. + std::unique_ptr relay; + int outputTab = 0; + editor::relay::PacketFilter netFilter; + bool netPaused = false; + std::vector netFrozen; float split = 0.5f; // share of the viewport the Scene view keeps while the game runs uint64_t startedAt = 0; // SDL ticks when redux started, to notice it never publishing std::string message; // why the last Play did not start, for the status bar @@ -169,9 +189,8 @@ struct State { std::optional lost; double lostAt = 0, lostGap = 0; editor::RenderLost lostMax; - uint64_t linesSeen = 0; - // The output popups: filter, clear point and wrapping, for Play and Run on hardware. - editor::LogView playLog, hwLog, playLog2; + // The output popup's filter, clear point and wrapping for Run on hardware. + editor::LogView hwLog; // Live edits: the document revision the running game was last sent, // the revision seen last frame and since when, the export in flight, // the count written to reload.flag, and the open scene's place in the diff --git a/tests/relayplaycheck.py b/tests/relayplaycheck.py new file mode 100644 index 0000000..ca97347 --- /dev/null +++ b/tests/relayplaycheck.py @@ -0,0 +1,151 @@ +# Play with 3 consoles through the editor's relay, on a real pcsx-redux: a +# copy of the courtyard whose object script calls Net.Connect() and, once +# connected, Net.Send()s an event every second, and whose scene script logs +# each one it gets (onNetEvent). Play (F5) with players=3 starts the relay, +# then console 1, then each next console once the relay has accepted the one +# before it (every console is redux's SIO1 client to the relay). All three +# views must show frames, their status lines must reach "Relay slot S · +# Linked" with slots 1, 2 and 3 in console order, none host, 3 players each; +# console 2 must log events console 1 and 3 sent (the relay fanned them out); +# and the Network view must list Hellos, HelloAcks and Events from slot 1 to +# slots 2 and 3. Stop (F5) ends every console and the relay. +# +# Needs SPLASHEDIT_REDUX (a pcsx-redux dev build with -shmdisplay and SIO1) +# and SPLASHEDIT_PSXSPLASH (a development psxsplash with Net.*), and +# SPLASHEDIT_BIOS if that redux has none; without them it says so and passes. +# Not in CI: CI's checks never get a real pcsx-redux. Runs 3 pcsx-redux at once. +# usage: python3 relayplaycheck.py [screenshot prefix] (needs a display: xvfb-run -a) +import json, os, re, shutil, subprocess, sys, tempfile + +editor = os.path.abspath(sys.argv[1]) +shots = os.path.abspath(sys.argv[2]) if len(sys.argv) > 2 else None +here = os.path.dirname(os.path.abspath(__file__)) +example = os.path.join(here, '..', 'examples', 'courtyard') +fails = 0 +N = 3 + +redux, engine = os.environ.get('SPLASHEDIT_REDUX', ''), os.environ.get('SPLASHEDIT_PSXSPLASH', '') +if not (os.path.isfile(redux) and os.path.isfile(engine)): + print('skip relayplaycheck: set SPLASHEDIT_REDUX and SPLASHEDIT_PSXSPLASH to a real pcsx-redux and psxsplash') + sys.exit(0) + + +def check(name, ok, detail=''): + global fails + print(('ok ' if ok else 'FAIL ') + name + ('' if ok else ': ' + str(detail))) + fails += not ok + + +work = tempfile.mkdtemp() +project = os.path.join(work, 'courtyard') +shutil.copytree(example, project) +with open(os.path.join(project, 'scripts', 'game_logic.lua'), 'w', newline='\n') as fh: + fh.write('local frames, seconds = 0, 0\n' + 'function onCreate(self)\n' + ' Net.Connect()\n' + 'end\n' + 'function onUpdate(self, dt)\n' + ' frames = frames + 1\n' + ' if frames < 60 then return end\n' + ' frames = 0\n' + ' seconds = seconds + 1\n' + ' if Net.State() == 2 then\n' + ' Net.Send(7, Net.LocalSlot() * 1000 + seconds)\n' + ' end\n' + 'end\n') +with open(os.path.join(project, 'scripts', 'net_scene.lua'), 'w', newline='\n') as fh: + fh.write('function onNetEvent(id, arg)\n' + ' Debug.Log("relaycheck: got " .. id .. " arg " .. arg)\n' + 'end\n') +scene = os.path.join(project, 'courtyard.scene') +with open(scene) as fh: + doc = json.load(fh) +doc['settings']['script'] = 'scripts/net_scene.lua' +doc['settings']['networkId'] = '' +with open(scene, 'w') as fh: + json.dump(doc, fh, indent=2) +cfg = os.path.join(work, 'play.cfg') +with open(cfg, 'w') as fh: + fh.write('players=%d\n' % N) +tmp = os.path.join(work, 'tmp') +os.makedirs(tmp) +ENV = dict(os.environ, TMPDIR=tmp, TMP=tmp, TEMP=tmp) + +net = (shots + '-network.png') if shots else os.path.join(work, 'network.png') +args = [editor, '--size', '1600x960', '--project', project, '--scene', scene, '--play-settings', cfg, + '--wait', '30', '--key', 'f5', '--wait-play', '--wait', '1500'] +if shots: + args += ['--shot', shots + '-grid.png'] +# Console 2's output, filtered to the events it got; then the Network view. +args += ['--click', '@game.console2', '--wait', '20', '--click', '@playstatus', '--wait', '20', + '--click', '@play.filter', '--text', 'relaycheck: got', '--wait', '30', + '--click', '@play.tab.network', '--wait', '60', '--shot', net, + '--click', '@net.type.Snapshot', '--wait', '20'] +if shots: + args += ['--shot', shots + '-network-nosnap.png'] +args += ['--key', 'escape', '--wait', '10', '--key', 'f5', '--wait', '60', + '--screenshot', os.path.join(work, 'stopped.png')] +r = subprocess.run(args, capture_output=True, text=True, timeout=900, env=ENV) +out = r.stdout +check('editor ran', r.returncode == 0, r.stderr[-800:]) +play = [l for l in out.splitlines() if l.startswith('play:')] +check('the relay listens first', any(l.startswith('play: relay listening, port ') for l in play), play) +started = [int(m.group(1)) for l in play for m in [re.match(r'play: console (\d+) started, relay port \d+', l)] if m] +check('Play boots 3 consoles in order', started == [1, 2, 3], play) + +lines = {c: [] for c in range(1, N + 1)} +for line in out.splitlines(): + m = re.match(r'game: console (\d+) (.*)$', line) + if m and int(m.group(1)) in lines: + lines[int(m.group(1))].append(m.group(2)) +for c in lines: + check('console %d shows frames' % c, any(l.startswith('frames') for l in lines[c]), lines[c][-3:]) +linked = {} +for c in lines: + for l in lines[c]: + m = re.search(r'Relay slot (\d+) · Linked · slot (\d+) · (host|not host) · (\d+) players', l) + if m: + linked[c] = (int(m.group(1)), int(m.group(2)), m.group(3) == 'host', int(m.group(4))) + check('console %d reaches Connected through the relay' % c, c in linked, lines[c][-3:]) +if len(linked) == N: + check('slots 1, 2, 3 in console order', [linked[c][0] for c in (1, 2, 3)] == [1, 2, 3], linked) + check('the engine agrees with the relay', all(linked[c][0] == linked[c][1] for c in linked), linked) + check('no console is host', not any(linked[c][2] for c in linked), linked) + check('3 players each', all(linked[c][3] == N for c in linked), linked) + +shown = [l for l in out.splitlines() if l.startswith('console: play shown ')] +got = [int(m.group(1)) for l in shown for m in [re.match(r'console: play shown (\d+) of', l)] if m] +check('console 2 got the others\' events (Net.Send crossed)', got and got[-1] > 0, shown[-3:]) + +conns = [l for l in out.splitlines() if l.startswith('net: connections')] +check('the Network view maps consoles to slots', 'net: connections 1:1 2:2 3:3' in conns, conns[-3:]) +types = [l for l in out.splitlines() if l.startswith('net: types')] +last = types[-1] if types else '' +counts = dict((k, int(v)) for k, v in re.findall(r' (\w+) (\d+)', last)) +check('the Network view lists the handshake and the events', + counts.get('Hello', 0) >= N and counts.get('HelloAck', 0) >= N and counts.get('Event', 0) > 0 and + counts.get('EventAck', 0) > 0 and counts.get('Snapshot', 0) > 0, last) +# Each join told the others: at least 1 + 2 + 3 HelloAcks for 3 joins. +check('every member hears of each join', counts.get('HelloAck', 0) >= 6, last) +lastrows = [l for l in out.splitlines() if l.startswith('net: last')] +check('an Event from a slot goes to the other two', + any(re.search(r'Event slot (\d) > slots (\d), (\d)', l) for l in lastrows), lastrows[-3:]) +check('hiding Snapshots hides them', any(l.startswith('net: shown') for l in out.splitlines()) and + not any('Snapshot' in l for l in lastrows[-1:]), lastrows[-1:]) +try: + with open(os.path.join(tmp, 'splashedit-play', 'console3', 'pcsx.json')) as fh: + d = json.load(fh)['emulator']['Debug'] + check('console 3 is a client of the relay', d['SIO1Client'] and not d['SIO1Server'] and d['SIO1Clienthost'] == '127.0.0.1', d) +except (OSError, KeyError, ValueError) as e: + check('console 3 is a client of the relay', False, e) +status = [l for l in out.splitlines() if l.startswith('playstatus: ')] +check('the status bar says 3 via the relay', any('3 linked consoles via the relay' in l for l in status), status[-3:]) +check('stopping Play stops them all', status and not status[-1].startswith('playstatus: pcsx-redux running'), status[-2:]) +if shots and os.path.exists(net): + print('network screenshot: ' + net) + +if fails: + print('\n'.join(out.splitlines()[-60:])) +print('relayplaycheck: %d failure(s)' % fails) +shutil.rmtree(work, ignore_errors=True) +sys.exit(1 if fails else 0) diff --git a/tests/relayviewcheck.py b/tests/relayviewcheck.py new file mode 100644 index 0000000..44a49b7 --- /dev/null +++ b/tests/relayviewcheck.py @@ -0,0 +1,209 @@ +# Play with 4 consoles through the editor's relay, with fake pcsx-redux +# processes: the Players list, the Game view's grid, each console's relay +# slot, and the Network view. Each fake (SPLASHEDIT_REDUX, a Python script) +# reads its console's pcsx.json, connects to the relay as redux's SIO1 client +# does (protobuf, one byte per message after a FlowControl), says Hello, +# prints the link reporter's line from every HelloAck it gets, sends an Event +# a second, then execs emulator_tests with SPLASHEDIT_FAKE_REDUX set (its +# display block opens the Game view) while a child keeps talking. Console k +# waits (4 - k) * 0.6 s before connecting, so consoles started all at once +# would reach the relay in the wrong order: only Play's one-at-a-time start +# gives console k slot k. +# +# Checks: Players > 4 Consoles is saved; Play starts the relay, then the four +# consoles in order; the grid shows four consoles, each "Relay slot k · +# Linked · ... 4 players" (every member hears of every join); a click gives +# console 3 the keyboard; the Network view maps consoles to slots, counts the +# handshake and the Events, filters by slot and type, and Pause holds it; +# Stop (F5) ends the session. +# usage: python3 relayviewcheck.py [screenshot prefix] (needs a display: xvfb-run -a) +import os, re, shutil, stat, subprocess, sys, tempfile + +editor = os.path.abspath(sys.argv[1]) +display = os.path.join(os.path.dirname(editor), 'emulator_tests') +shots = os.path.abspath(sys.argv[2]) if len(sys.argv) > 2 else None +here = os.path.dirname(os.path.abspath(__file__)) +example = os.path.join(here, '..', 'examples', 'courtyard') +fails = 0 +N = 4 + +if os.name == 'nt': + print('skip relayviewcheck: the fake pcsx-redux forks') + sys.exit(0) + + +def check(name, ok, detail=''): + global fails + print(('ok ' if ok else 'FAIL ') + name + ('' if ok else ': ' + str(detail))) + fails += not ok + + +work = tempfile.mkdtemp() +engine = os.path.join(work, 'psxsplash.ps-exe') +with open(engine, 'wb') as fh: + fh.write(b'PS-X EXE fake') +FAKE = r'''#!/usr/bin/env python3 +import json, os, re, socket, struct, sys, time +args = sys.argv[1:] +portable = args[args.index('-portable') + 1] if '-portable' in args else '' +k = int(re.search(r'console(\d+)$', portable.rstrip('/')).group(1)) if portable else 1 + + +def crc16(data): + crc = 0xFFFF + for b in data: + crc ^= b << 8 + for _ in range(8): + crc = ((crc << 1) ^ 0x1021) & 0xFFFF if crc & 0x8000 else (crc << 1) & 0xFFFF + return crc + + +def frame(t, payload, seq, slot=0xFF): + body = struct.pack('= 1 + buf[0]: + msg, buf = buf[1:1 + buf[0]], buf[1 + buf[0]:] + if msg[:1] == b'\x0a': # SIOPayload{1: DataTransfer{1: data}} as the relay sends it + n = msg[3] + psnl += msg[4:4 + n] if msg[2:3] == b'\x0a' else b'' + while True: + at = psnl.find(b'\xa5') + if at < 0 or len(psnl) - at < 9: + break + t, src, fl, rs, sq, ln = struct.unpack_from('= nextEvent: + nextEvent += 1 + sock.sendall(wire(frame(4, struct.pack(' 4 Consoles picks 4, through the relay', 'play: players 4 relay' in lines, [l for l in lines if l.startswith('play: players')]) +with open(cfg) as fh: + saved = fh.read() +check('and is saved', 'players=4\n' in saved and 'relay=0\n' in saved, saved) +play = [l for l in lines if l.startswith('play:')] +listen = [i for i, l in enumerate(lines) if l.startswith('play: relay listening, port ')] +first = [i for i, l in enumerate(lines) if l.startswith('play: console 1 started')] +check('the relay listens before console 1 starts', listen and first and listen[0] < first[0], play) +started = [int(m.group(1)) for l in play for m in [re.match(r'play: console (\d+) started, relay port \d+', l)] if m] +check('Play starts the 4 consoles one at a time, in order', started == [1, 2, 3, 4], play) + +status = {} +focused = {} +for line in lines: + m = re.match(r'game: console (\d+) \w+( focused)? \| (.*)$', line) + if m: + status.setdefault(int(m.group(1)), []).append(m.group(3)) + focused[int(m.group(1))] = bool(m.group(2)) +check('the grid shows 4 consoles', sorted(status) == [1, 2, 3, 4], sorted(status)) +for c in range(1, N + 1): + want = 'Relay slot %d · Linked · slot %d · not host · 4 players' % (c, c) + check('console %d: relay slot %d, 4 players' % (c, c), any(l.startswith(want) for l in status.get(c, [])), status.get(c, [])[-2:]) +check('a click gives console 3 the keyboard', focused.get(3) and not focused.get(1), focused) +check('the output popup has Output and Network', 'play: output tab network' in lines, [l for l in lines if l.startswith('play: output tab')]) + +conns = [l for l in lines if l.startswith('net: connections')] +check('the Network view maps consoles to slots', 'net: connections 1:1 2:2 3:3 4:4' in conns, conns[-3:]) +types = [l for l in lines if l.startswith('net: types')] +counts = dict((k, int(v)) for k, v in re.findall(r' (\w+) (\d+)', types[-1])) if types else {} +check('it counts the handshake', counts.get('Hello', 0) == N and counts.get('HelloAck', 0) >= 1 + 2 + 3 + 4, counts) +check('and the Events and their acks', counts.get('Event', 0) > 0 and counts.get('EventAck', 0) > 0, counts) +views = [l for l in lines if l.startswith('net: shown ')] +check('a console chip filters by its slot', any(' slot 2' in l for l in views), views[-3:]) +last = [l for l in lines if l.startswith('net: last')] +slot2 = [l for l in last if l != 'net: last'] +check('filtered rows all touch slot 2, no Snapshots', + slot2 and all(all('slot 2' in row or 'slots 2' in row or ', 2' in row for row in l.split(' | ')[1:]) and 'Snapshot' not in l + for l in slot2[-1:]), slot2[-2:]) +paused = [i for i, l in enumerate(lines) if l == 'net: paused'] +resumed = [i for i, l in enumerate(lines) if l == 'net: resumed'] +held = [l for l in lines[paused[0]:resumed[0]] if l.startswith('net: shown ')] if paused and resumed else [] +check('Pause holds the list still', paused and resumed and len(held) == 1 and held[0].endswith(' paused'), held) +end = [l for l in lines if l.startswith('playstatus: ')] +check('the status bar says 4 via the relay', any('4 linked consoles via the relay' in l for l in end), end[-3:]) +check('Stop stops every console', end and not end[-1].startswith('playstatus: pcsx-redux running'), end[-2:]) + +if fails: + print('\n'.join(lines[-50:])) +print('relayviewcheck: %d failure(s)' % fails) +shutil.rmtree(work, ignore_errors=True) +sys.exit(1 if fails else 0)