From 28cfc18883a10ddf1b0a7630b90d9da582409da3 Mon Sep 17 00:00:00 2001 From: Rix Date: Tue, 20 Oct 2026 21:24:47 -0700 Subject: [PATCH 1/6] Relay: give each room an authority, named in every HelloAck The relay's HelloAck left hostSlot at 0, the relay itself, so no console was ever psxsplash's host and nothing registered with Net.RegisterActor replicated. A room's first member is now its authority; when it leaves, the lowest slot still in takes over and the announce acks say so. A newcomer in a freed lower slot does not take it. Slot 0 is kRelaySlot now, and the Network view's log gets a row per authority change. --- src/editor/relay.cpp | 69 +++++++++++++++++++++++-------- src/editor/relay.hh | 37 +++++++++++------ src/editor/tests/editor_tests.cpp | 46 ++++++++++++++++----- src/mockup/main_screen.cpp | 2 +- tests/relayplaycheck.py | 5 ++- tests/relayviewcheck.py | 2 +- 6 files changed, 116 insertions(+), 45 deletions(-) diff --git a/src/editor/relay.cpp b/src/editor/relay.cpp index aab9d48..f40f397 100644 --- a/src/editor/relay.cpp +++ b/src/editor/relay.cpp @@ -344,7 +344,7 @@ int Hub::visiblePlayers(int conn) const { 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); + const std::pair key(from == kRelaySlot ? -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) { @@ -384,18 +384,19 @@ void Hub::send(Session& s, uint8_t type, const std::vector& payload, st q = s.txSeq; s.txSeq = uint16_t(s.txSeq + 1); } - sendRaw(s, encodeFrame(type, payload, kHostSlot, q)); + sendRaw(s, encodeFrame(type, payload, kRelaySlot, q)); } void Hub::sendAck(Session& s, double now) { Connection& c = info(s); HelloAckPayload a; a.yourSlot = c.slot; + a.hostSlot = c.authority; // the room's authority is psxsplash's host 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}); + log(now, kRelaySlot, s.id, HelloAck, seq, 1 + kHeaderSize + 16 + 2, {c.slot}); } void Hub::announce(int roomId, int except, double now) { @@ -410,7 +411,25 @@ void Hub::announce(int roomId, int except, double now) { } } -bool Hub::join(Session& s, uint32_t sceneHash) { +void Hub::setAuthority(Room& r, uint8_t slot, double now) { + Packet p; + p.id = m_nextPacket++; + p.time = now; + p.from = kRelaySlot; + p.conn = r.id; + p.type = 0; + p.previous = r.authority; + p.authority = slot; + r.authority = slot; + for (const auto& [member, conn] : r.members) { + m_conns.at(conn).authority = slot; + p.to.push_back(member); + } + m_log.push_back(std::move(p)); + while (m_log.size() > kPacketLog) m_log.pop_front(); +} + +bool Hub::join(Session& s, uint32_t sceneHash, double now) { Room* target = nullptr; for (Room& r : m_rooms) { if (r.members.size() >= size_t(kMaxSlots - kFirstPlayerSlot)) continue; @@ -432,6 +451,10 @@ bool Hub::join(Session& s, uint32_t sceneHash) { c.slot = slot; c.room = target->id; c.joined = true; + c.authority = target->authority; + // The first member is the authority, and stays it while it is in: a later + // console with a lower slot (one freed by a leaver) does not take it. + if (target->authority == kNoSlot) setAuthority(*target, slot, now); return true; } @@ -441,6 +464,7 @@ void Hub::leave(Session& s, double now) { const int roomId = c.room; c.room = 0; c.joined = false; + c.authority = kNoSlot; if (!r) return; r->members.erase(c.slot); if (r->members.empty()) { @@ -457,6 +481,8 @@ void Hub::leave(Session& s, double now) { only = h; } if (one && only) r->sceneHash = only; + // The authority left: the lowest slot still in takes over, and the acks below say so. + if (r->authority == c.slot) setAuthority(*r, r->members.begin()->first, now); announce(roomId, s.id, now); } @@ -494,7 +520,7 @@ 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}); + log(now, c.slot, s.id, Hello, f.seq, size, {kRelaySlot}); 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. @@ -512,7 +538,7 @@ void Hub::onHello(Session& s, const Frame& f, double now) { return; } c.sceneHash = h->sceneHash; - if (!c.room && !join(s, h->sceneHash)) return; + if (!c.room && !join(s, h->sceneHash, now)) return; s.connected = true; sendAck(s, now); announce(info(s).room, s.id, now); @@ -531,10 +557,10 @@ void Hub::onReliable(Session& s, const Frame& f, double now) { } 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}); + log(now, c.slot, s.id, f.type, f.seq, size, relays && !slots.empty() ? slots : std::vector{kRelaySlot}); // 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}); + log(now, kRelaySlot, s.id, EventAck, f.seq, 1 + kHeaderSize + 2, {c.slot}); if (dup) return; s.rxSeq = f.seq; if (!relays) return; @@ -550,7 +576,7 @@ void Hub::dispatch(Session& s, const Frame& f, double now) { switch (f.type) { case Hello: onHello(s, f, now); break; case Snapshot: { - log(now, c.slot, s.id, Snapshot, f.seq, size, {kHostSlot}); + log(now, c.slot, s.id, Snapshot, f.seq, size, {kRelaySlot}); // 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]; @@ -570,20 +596,20 @@ void Hub::dispatch(Session& s, const Frame& f, double now) { case ObjectState: case AppData: onReliable(s, f, now); break; case Ping: { - log(now, c.slot, s.id, Ping, f.seq, size, {kHostSlot}); + log(now, c.slot, s.id, Ping, f.seq, size, {kRelaySlot}); 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}); + log(now, kRelaySlot, s.id, Pong, seq, 1 + kHeaderSize + 12 + 2, {c.slot}); break; } case Bye: - log(now, c.slot, s.id, Bye, f.seq, size, {kHostSlot}); + log(now, c.slot, s.id, Bye, f.seq, size, {kRelaySlot}); close(s, now, false); break; - default: log(now, c.slot, s.id, f.type, f.seq, size, {kHostSlot}); break; + default: log(now, c.slot, s.id, f.type, f.seq, size, {kRelaySlot}); break; } } @@ -606,9 +632,9 @@ void Hub::tick(double now) { avatars.push_back({slot, m_sessions.at(conn).avatar}); to.push_back(slot); } - const std::vector frame = encodeFrame(Snapshot, buildSnapshot(avatars), kHostSlot, 0); + const std::vector frame = encodeFrame(Snapshot, buildSnapshot(avatars), kRelaySlot, 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); + log(now, kRelaySlot, r.id, Snapshot, 0, frame.size() * members.size(), to); } } } @@ -875,19 +901,26 @@ bool PacketFilter::shows(const Packet& p) const { return std::find(p.to.begin(), p.to.end(), uint8_t(slot)) != p.to.end(); } +std::string describeType(const Packet& p) { + if (!p.handOff()) return packetTypeName(p.type); + std::string s = "Authority slot " + std::to_string(p.authority); + if (p.previous != kNoSlot) s += " (was " + std::to_string(p.previous) + ")"; + return s; +} + std::string describeEnd(uint8_t slot, int conn) { - if (slot == kHostSlot) return "relay"; + if (slot == kRelaySlot) 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"; + if (to.size() == 1 && to[0] == kRelaySlot) 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]); + s += to[i] == kRelaySlot ? std::string("relay") : to[i] == kNoSlot ? std::string("?") : std::to_string(to[i]); } return s; } diff --git a/src/editor/relay.hh b/src/editor/relay.hh index e2bb1ae..c4ce218 100644 --- a/src/editor/relay.hh +++ b/src/editor/relay.hh @@ -2,12 +2,15 @@ // 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. +// slots 1..10 (slot 0 is the relay itself, which is in no game), 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, and it gives each room an +// authority: its first member, then, when the authority leaves, the lowest +// slot still in it. The HelloAck's hostSlot names it, so that console is +// psxsplash's host (yourSlot == hostSlot) and the rest are clients; a later +// ack hands authority over. psxsplash needs no change for any of it. // // 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 @@ -35,7 +38,7 @@ inline constexpr size_t kHeaderSize = 8; // packHello(const HelloPayload& h); struct HelloAckPayload { // parseHelloAck(const std::vector& payload); std::vector packHelloAck(const HelloAckPayload& a); @@ -136,10 +139,12 @@ std::vector buildSnapshot(std::vector> membe // ---- 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 +// kRelaySlot for the relay, kNoSlot for a console that has none yet (then +// `conn` says which). `to` lists the slots it went to (kRelaySlot: the relay // took it). Snapshots and Ping/Pong a sender sends within a second fold into -// one entry, `count` of them. +// one entry, `count` of them. A row with `authority` set is no packet: the +// room (`conn`) got that authority, after `previous` (kNoSlot: the room's +// first); `type` is 0 and `to` the members it was told to. struct Packet { uint64_t id = 0; double time = 0; // seconds since the relay started @@ -149,6 +154,8 @@ struct Packet { uint32_t size = 0; // frame bytes, all `count` of them uint32_t count = 1; std::vector to; + uint8_t authority = kNoSlot, previous = kNoSlot; + bool handOff() const { return authority != kNoSlot; } }; // One accepted connection: `index` counts from 1 in accept order (console k @@ -157,6 +164,8 @@ struct Connection { int index = 0; uint8_t slot = kNoSlot; int room = 0; // 0: none + uint8_t authority = kNoSlot; // its room's authority slot + bool isAuthority() const { return joined && slot != kNoSlot && authority == slot; } uint32_t sceneHash = 0; bool open = true, joined = false; double connectedAt = 0, lastSeen = 0; @@ -197,6 +206,7 @@ class Hub { int id = 0; std::optional sceneHash; std::map members; // slot -> connection + uint8_t authority = kNoSlot; // psxsplash's host: the slot whose world objects count }; struct Session { int id = 0; @@ -210,10 +220,11 @@ class Hub { 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); + bool join(Session& s, uint32_t sceneHash, double now); void leave(Session& s, double now); void sendAck(Session& s, double now); void announce(int roomId, int except, double now); + void setAuthority(Room& r, uint8_t slot, 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); @@ -276,6 +287,8 @@ struct PacketFilter { int slot = -1; bool shows(const Packet& p) const; }; +// The row's type in words: packetTypeName, or "Authority slot 2 (was 1)". +std::string describeType(const Packet& p); // "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". diff --git a/src/editor/tests/editor_tests.cpp b/src/editor/tests/editor_tests.cpp index 14cc9b1..fff2dc5 100644 --- a/src/editor/tests/editor_tests.cpp +++ b/src/editor/tests/editor_tests.cpp @@ -1683,7 +1683,7 @@ void testRelayFrames() { 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); + const std::vector frame = rl::encodeFrame(rl::Snapshot, rl::buildSnapshot({{1, a}}), rl::kRelaySlot, 0); CHECK(frame == fromHex("a50300000000001800010000000101000001000000020000000300000004000000943e")); const std::vector f1 = rl::encodeFrame(rl::Event, {1, 2, 3, 4, 5, 6, 7, 8}, 2, 77); @@ -1760,7 +1760,9 @@ void testRelayProtobuf() { } // Rooms: join by scene, lowest free slot, re-Hello keeps the slot, leaving -// and silence free it, and every member hears of each change. +// and silence free it, and every member hears of each change. The first +// member is the room's authority (every ack's hostSlot) until it leaves; then +// the lowest slot left takes over, and a newcomer in a freed lower slot does not. void testRelayRooms() { rl::Hub h; const uint32_t A = 0xAAAA0001, B = 0xBBBB0002; @@ -1769,20 +1771,22 @@ void testRelayRooms() { 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 && + CHECK(a1.size() == 1 && a1[0].yourSlot == 1 && a1[0].hostSlot == 1 && 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); + CHECK(sent[c1].size() == 1 && sent[c1][0].srcSlot == rl::kRelaySlot); // 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); + CHECK(a2.size() == 1 && a2[0].yourSlot == 2 && a2[0].playerCount == 2 && a2[0].hostSlot == 1); a1 = acksIn(sent[c1]); - CHECK(a1.size() == 1 && a1[0].yourSlot == 1 && a1[0].playerCount == 2); + CHECK(a1.size() == 1 && a1[0].yourSlot == 1 && a1[0].playerCount == 2 && a1[0].hostSlot == 1); + CHECK(h.connections().at(c1).isAuthority() && !h.connections().at(c2).isAuthority() && h.connections().at(c2).authority == 1); // 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(acksIn(sent[c3]).size() == 1 && acksIn(sent[c3])[0].yourSlot == 1 && acksIn(sent[c3])[0].playerCount == 1 && + acksIn(sent[c3])[0].hostSlot == 1 && h.connections().at(c3).isAuthority()); 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); @@ -1800,21 +1804,41 @@ void testRelayRooms() { 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); + CHECK(acksIn(sent[c4])[0].hostSlot == 1 && acksIn(sent[c1])[0].hostSlot == 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); + // Slot 1 left: authority passes to slot 2, and both who stay are told so. + CHECK(acksIn(sent[c2])[0].hostSlot == 2 && acksIn(sent[c4])[0].hostSlot == 2); + CHECK(h.connections().at(c2).isAuthority() && h.connections().at(c4).authority == 2 && !h.connections().at(c1).isAuthority()); + { + // The Network view's log has the hand-off: room 1 now has slot 2, after 1, told to 2 and 3. + std::vector rows; + for (const rl::Packet& k : h.packets()) + if (k.handOff()) rows.push_back(&k); + CHECK(rows.size() == 3); // room A's first, room B's first, the hand-off + if (rows.size() == 3) { + CHECK(rows[0]->authority == 1 && rows[0]->previous == rl::kNoSlot && rl::describeType(*rows[0]) == "Authority slot 1"); + CHECK(rows[2]->authority == 2 && rows[2]->previous == 1 && rows[2]->from == rl::kRelaySlot && rows[2]->type == 0 && + rows[2]->to == std::vector({2, 3}) && rl::describeType(*rows[2]) == "Authority slot 2 (was 1)" && + rl::PacketFilter{}.shows(*rows[2])); + } + } // 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); + // It takes the freed slot 1, not the authority: that stays with slot 2. + CHECK(acksIn(sent[c5])[0].hostSlot == 2 && acksIn(sent[c2])[0].hostSlot == 2 && !h.connections().at(c5).isAuthority()); // 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()); + CHECK(acksIn(sent[c2]).size() == 1 && acksIn(sent[c2])[0].playerCount == 2 && acksIn(sent[c2])[0].hostSlot == 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); @@ -1854,7 +1878,7 @@ void testRelayReliable() { 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 && + CHECK(sent[c[i]].size() == 1 && sent[c[i]][0].type == rl::Event && sent[c[i]][0].srcSlot == rl::kRelaySlot && 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. @@ -1898,7 +1922,7 @@ void testRelayReliable() { 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(events[1]->to == std::vector({rl::kRelaySlot})); 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); @@ -1971,7 +1995,7 @@ void testRelaySnapshot() { 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 && + CHECK(sent[c].size() == 1 && sent[c][0].type == rl::Snapshot && sent[c][0].seq == 0 && sent[c][0].srcSlot == rl::kRelaySlot && sent[c][0].payload == want); h.tick(1.01); CHECK(hubSent(h).empty()); diff --git a/src/mockup/main_screen.cpp b/src/mockup/main_screen.cpp index c2e1cc6..cea84f7 100644 --- a/src/mockup/main_screen.cpp +++ b/src/mockup/main_screen.cpp @@ -10321,7 +10321,7 @@ static void networkView(State& st) { 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", + ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(k.from == rl::kRelaySlot ? 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()); diff --git a/tests/relayplaycheck.py b/tests/relayplaycheck.py index ca97347..b7533a8 100644 --- a/tests/relayplaycheck.py +++ b/tests/relayplaycheck.py @@ -5,7 +5,8 @@ # 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; +# Linked" with slots 1, 2 and 3 in console order, console 1 (the room's +# authority) host and the others not, 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. @@ -110,7 +111,7 @@ def check(name, ok, detail=''): 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('console 1 is host, the others are not', [linked[c][2] for c in (1, 2, 3)] == [True, False, False], 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 ')] diff --git a/tests/relayviewcheck.py b/tests/relayviewcheck.py index 44a49b7..a585ff1 100644 --- a/tests/relayviewcheck.py +++ b/tests/relayviewcheck.py @@ -176,7 +176,7 @@ def shot(name): 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) + want = 'Relay slot %d · Linked · slot %d · %s · 4 players' % (c, c, 'host' if c == 1 else 'not host') 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')]) From b84ae33c69eceff50816fc5d7082d63376fe8e33 Mon Sep 17 00:00:00 2001 From: Rix Date: Tue, 20 Oct 2026 21:27:06 -0700 Subject: [PATCH 2/6] Relay: carry the authority's world objects in its snapshots The relay kept only the sender's own avatar record of a snapshot, so the host's Net.RegisterActor objects never reached the other consoles. It now keeps the world objects the room's authority sends (and only its), and adds them to the snapshot it builds for the rest of that scene group, in the records psxsplash reads: compact (11 bytes, in the origin and spread) or legacy (20 bytes), cut to one frame. The authority's own copy has none. With no objects the bytes are psnl's, as before. --- src/editor/relay.cpp | 78 ++++++++++++--- src/editor/relay.hh | 26 +++-- src/editor/tests/editor_tests.cpp | 153 ++++++++++++++++++++++++++++++ 3 files changed, 239 insertions(+), 18 deletions(-) diff --git a/src/editor/relay.cpp b/src/editor/relay.cpp index f40f397..3161c42 100644 --- a/src/editor/relay.cpp +++ b/src/editor/relay.cpp @@ -257,15 +257,19 @@ std::vector packHelloAck(const HelloAckPayload& a) { static constexpr int64_t kFp12TwoPi = 25736; // one turn in 4.12 fixed point -std::vector buildSnapshot(std::vector> members) { +static int64_t normalYaw(int32_t yaw) { return ((int64_t(yaw) % kFp12TwoPi) + kFp12TwoPi) % kFp12TwoPi; } + +std::vector buildSnapshot(std::vector> members, std::vector objects) { 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 + const size_t records = members.size() + objects.size(); + if (records >= 2) { // compact pays for itself from 2 records 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 (size_t i = 0; i < records; ++i) { + const int64_t v[3] = {i < members.size() ? members[i].second.px : objects[i - members.size()].px, + i < members.size() ? members[i].second.py : objects[i - members.size()].py, + i < members.size() ? members[i].second.pz : objects[i - members.size()].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]; @@ -279,8 +283,11 @@ std::vector buildSnapshot(std::vector> membe break; } if (shift >= 0) { + // As many objects as fit one frame: 16 bytes of header and origin, 10 an avatar, 11 an object. + const size_t room = (kMaxPayload - 16 - 10 * members.size()) / 11; + if (objects.size() > room) objects.resize(room); b.push_back(uint8_t(members.size())); - b.push_back(0); + b.push_back(uint8_t(objects.size())); 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]))); @@ -290,13 +297,23 @@ std::vector buildSnapshot(std::vector> membe 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)); + put16(b, uint16_t(normalYaw(a.yaw))); + } + for (const WorldObject& o : objects) { + put16(b, o.actorId); + b.push_back(o.flags); + put16(b, uint16_t((int64_t(o.px) - lo[0]) >> shift)); + put16(b, uint16_t((int64_t(o.py) - lo[1]) >> shift)); + put16(b, uint16_t((int64_t(o.pz) - lo[2]) >> shift)); + put16(b, uint16_t(normalYaw(o.yaw))); } return b; } } + const size_t room = (kMaxPayload - 4) / 20 - std::min(members.size(), (kMaxPayload - 4) / 20); + if (objects.size() > room) objects.resize(room); b.push_back(uint8_t(members.size())); - b.push_back(0); + b.push_back(uint8_t(objects.size())); b.push_back(0); // SNAP_FORMAT_LEGACY b.push_back(0); for (const auto& [slot, a] : members) { @@ -308,6 +325,15 @@ std::vector buildSnapshot(std::vector> membe put32(b, uint32_t(a.pz)); put32(b, uint32_t(a.yaw)); } + for (const WorldObject& o : objects) { + put16(b, o.actorId); + b.push_back(o.flags); + b.push_back(0); + put32(b, uint32_t(o.px)); + put32(b, uint32_t(o.py)); + put32(b, uint32_t(o.pz)); + put32(b, uint32_t(o.yaw)); + } return b; } @@ -465,6 +491,7 @@ void Hub::leave(Session& s, double now) { c.room = 0; c.joined = false; c.authority = kNoSlot; + s.objects.clear(); if (!r) return; r->members.erase(c.slot); if (r->members.empty()) { @@ -526,6 +553,7 @@ void Hub::onHello(Session& s, const Frame& f, double now) { // Again: a scene change (or a lost ack). Keep the slot, re-ack. const bool moved = c.sceneHash != h->sceneHash; c.sceneHash = h->sceneHash; + if (moved) s.objects.clear(); s.rxSeq.reset(); if (Room* r = room(c.room)) { bool all = true; @@ -577,9 +605,12 @@ void Hub::dispatch(Session& s, const Frame& f, double now) { case Hello: onHello(s, f, now); break; case Snapshot: { log(now, c.slot, s.id, Snapshot, f.seq, size, {kRelaySlot}); - // The sender's own legacy record is its avatar; anything else in it is ignored. + // psxsplash sends legacy records: its own avatar, then, from the + // host, its world objects. The sender's own avatar record is kept, + // and the world objects only from the room's authority; anything + // else in it is ignored. if (!c.room || f.payload.size() < 4) break; - const size_t count = f.payload[0]; + const size_t count = f.payload[0], objectCount = f.payload[1]; 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; @@ -590,6 +621,19 @@ void Hub::dispatch(Session& s, const Frame& f, double now) { s.avatar.pz = int32_t(get32(r + 12)); s.avatar.yaw = int32_t(get32(r + 16)); } + if (!c.isAuthority() || f.payload[2] != 0) break; // legacy only: the format psxsplash sends + s.objects.clear(); + for (size_t i = 0; i < objectCount && off + 20 <= f.payload.size(); ++i, off += 20) { + const uint8_t* r = f.payload.data() + off; + WorldObject o; + o.actorId = get16(r); + o.flags = r[2]; + o.px = int32_t(get32(r + 4)); + o.py = int32_t(get32(r + 8)); + o.pz = int32_t(get32(r + 12)); + o.yaw = int32_t(get32(r + 16)); + s.objects.push_back(o); + } break; } case Event: @@ -628,13 +672,23 @@ void Hub::tick(double now) { for (const auto& [hash, members] : groups) { std::vector> avatars; std::vector to; + const std::vector* objects = nullptr; // the authority's, when it is in this scene group for (const auto& [slot, conn] : members) { avatars.push_back({slot, m_sessions.at(conn).avatar}); to.push_back(slot); + if (slot == r.authority && !m_sessions.at(conn).objects.empty()) objects = &m_sessions.at(conn).objects; } const std::vector frame = encodeFrame(Snapshot, buildSnapshot(avatars), kRelaySlot, 0); - for (const auto& [slot, conn] : members) sendRaw(m_sessions.at(conn), frame); - log(now, kRelaySlot, r.id, Snapshot, 0, frame.size() * members.size(), to); + // The others get the authority's world objects too; the authority has them. + const std::vector withObjects = + objects ? encodeFrame(Snapshot, buildSnapshot(avatars, *objects), kRelaySlot, 0) : frame; + size_t bytes = 0; + for (const auto& [slot, conn] : members) { + const std::vector& f = slot == r.authority ? frame : withObjects; + sendRaw(m_sessions.at(conn), f); + bytes += f.size(); + } + log(now, kRelaySlot, r.id, Snapshot, 0, bytes, to); } } } diff --git a/src/editor/relay.hh b/src/editor/relay.hh index c4ce218..17c52c1 100644 --- a/src/editor/relay.hh +++ b/src/editor/relay.hh @@ -10,7 +10,9 @@ // authority: its first member, then, when the authority leaves, the lowest // slot still in it. The HelloAck's hostSlot names it, so that console is // psxsplash's host (yourSlot == hostSlot) and the rest are clients; a later -// ack hands authority over. psxsplash needs no change for any of it. +// ack hands authority over. Only the authority's world objects (the +// Net.RegisterActor actors in its snapshots) go on to the others, in the +// relay's own snapshots. psxsplash needs no change for any of it. // // 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 @@ -130,11 +132,22 @@ 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); +// A world object of the authority's snapshot: an actor its scene registered +// with Net.RegisterActor. +struct WorldObject { + uint16_t actorId = 0; + int32_t px = 0, py = 0, pz = 0, yaw = 0; + uint8_t flags = 0x01; +}; +// One snapshot for `members` (slot, avatar) and `objects`. With no objects it +// is 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. Objects follow +// the avatars as psxsplash reads them (networkmanager.cpp +// applyPendingSnapshot): legacy [u16 actorId][u8 flags][u8 0][i32 x y z yaw], +// compact [u16 actorId][u8 flags][u16 dx dy dz yaw], counted in the origin and +// spread, compact from 2 records in all. Objects past kMaxPayload are left out. +std::vector buildSnapshot(std::vector> members, std::vector objects = {}); // ---- the relay's rules, without sockets ---- @@ -215,6 +228,7 @@ class Hub { std::optional rxSeq; uint16_t txSeq = 1; Avatar avatar; + std::vector objects; // its last snapshot's, while it is its room's authority std::vector out; }; void dispatch(Session& s, const Frame& f, double now); diff --git a/src/editor/tests/editor_tests.cpp b/src/editor/tests/editor_tests.cpp index fff2dc5..41e9240 100644 --- a/src/editor/tests/editor_tests.cpp +++ b/src/editor/tests/editor_tests.cpp @@ -2003,6 +2003,158 @@ void testRelaySnapshot() { CHECK(hubSent(h).size() == 2); } +// What psxsplash reads of a snapshot's world objects: networkmanager.cpp +// applyPendingSnapshot, line for line (avatars skipped, then each object's +// actorId and body, legacy or compact). {actorId, x, y, z, yaw} each. +std::vector> engineObjects(const std::vector& snap) { + std::vector> out; + const uint8_t* p = snap.data(); + const size_t len = snap.size(); + if (len < 4) return out; + const uint8_t avatarCount = p[0], objectCount = p[1], format = p[2], posShift = p[3]; + size_t off = 4; + const bool compact = format == 1; + int32_t ox = 0, oy = 0, oz = 0; + if (compact) { + if (len < off + 12 || posShift > 15) return out; + std::memcpy(&ox, p + off, 4); + std::memcpy(&oy, p + off + 4, 4); + std::memcpy(&oz, p + off + 8, 4); + off += 12; + } else if (format != 0) + return out; + const size_t avatarSize = compact ? 10 : 20, objectSize = compact ? 11 : 20; + auto body = [&](size_t at, std::array& r) { + if (compact) { + uint16_t d[4]; + std::memcpy(d, p + at, 8); + r[1] = ox + (int32_t(d[0]) << posShift); + r[2] = oy + (int32_t(d[1]) << posShift); + r[3] = oz + (int32_t(d[2]) << posShift); + r[4] = int32_t(d[3]); + } else + std::memcpy(&r[1], p + at, 16); + }; + for (uint8_t i = 0; i < avatarCount && off + avatarSize <= len; ++i) off += avatarSize; + for (uint8_t i = 0; i < objectCount; ++i) { + if (off + objectSize > len) break; + std::array r{}; + r[0] = int32_t(p[off] | (p[off + 1] << 8)); + body(off + (compact ? 3 : 4), r); + off += objectSize; + out.push_back(r); + } + return out; +} + +// A snapshot as psxsplash builds one (buildAndSendSnapshot): legacy, its +// avatar at `slot`, then `objects` {actorId, x, y, z, yaw}. +std::vector engineSnapshot(uint8_t slot, const std::vector>& objects) { + std::vector b = {1, uint8_t(objects.size()), 0, 0, slot, 1, 0, 0}; + auto put = [&](int32_t v) { + for (int k = 0; k < 4; ++k) b.push_back(uint8_t(uint32_t(v) >> (8 * k))); + }; + for (int32_t v : {slot * 100, 0, -slot * 100, 0}) put(v); + for (const auto& o : objects) { + b.insert(b.end(), {uint8_t(o[0]), uint8_t(o[0] >> 8), 1, 0}); + for (int k = 1; k < 5; ++k) put(o[k]); + } + return b; +} + +// World objects: the authority's (and only the authority's) go on in the +// relay's snapshots to the rest of its scene group, as psxsplash reads them; +// a hand-off moves that to the new authority. +void testRelayWorldObjects() { + struct Case { + const char* name; + std::vector>> members; + std::vector> objects; + const char* hex; + }; + // Generated by /home/pixel/sources/ng-deps/authority/snapobjgen.py (psnl avatars, psxsplash object records). + const Case cases[] = { + {"object_only", {}, {{5, 4096, -8192, 12288, 100, 1}}, "00010000050001000010000000e0ffff0030000064000000"}, + {"one_and_one", {{1, {100, -200, 300, 50, 1}}}, {{7, 9000, 0, -4000, -100, 1}}, + "010101006400000038ffffff60f0ffff010100000000cc103200070001c422c80000002464"}, + {"two_and_two", {{2, {1000, 0, -1000, 4096, 1}}, {1, {1100, 50, -900, -10, 1}}}, + {{3, 200000, 40, 7, 30000, 1}, {12, -5000, -60, 1000, 25736, 1}}, + "0202010278ecffffc4ffffff18fcffff0101f5051b0019007e640201dc050f000000001003000132c81900fb00a8100c000100000000f4010000"}, + {"far_object", {{1, {-2000000000, 0, 0, 1, 1}}}, {{300, 2000000000, 5, -5, 2, 1}}, + "0101000001010000006cca880000000000000000010000002c0101000094357705000000fbffffff02000000"}, + }; + 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])}}); + std::vector o; + for (const auto& v : c.objects) o.push_back(rl::WorldObject{uint16_t(v[0]), v[1], v[2], v[3], v[4], uint8_t(v[5])}); + const std::vector snap = rl::buildSnapshot(m, o); + const bool same = snap == fromHex(c.hex); + if (!same) std::fprintf(stderr, "relay snapshot case %s differs from snapobjgen\n", c.name); + CHECK(same); + // And psxsplash reads the objects back: ids exact, positions within the shift's step. + const std::vector> back = engineObjects(snap); + CHECK(back.size() == o.size()); + for (size_t i = 0; i < back.size() && i < o.size(); ++i) { + const int32_t step = snap[2] == 1 ? (1 << snap[3]) : 1; + CHECK(back[i][0] == o[i].actorId && back[i][1] <= o[i].px && o[i].px - back[i][1] < step && back[i][2] <= o[i].py && + o[i].py - back[i][2] < step && back[i][3] <= o[i].pz && o[i].pz - back[i][3] < step); + } + } + // Never past one frame: 10 avatars and 40 objects (compact), and far apart (legacy). + { + std::vector> m; + for (uint8_t s = 1; s <= 10; ++s) m.push_back({s, rl::Avatar{s * 10, 0, 0, 0, 1}}); + std::vector o(40); + for (size_t i = 0; i < o.size(); ++i) o[i].actorId = uint16_t(i + 1); + std::vector snap = rl::buildSnapshot(m, o); + CHECK(snap.size() <= rl::kMaxPayload && snap[2] == 1 && snap.size() == 16 + 10 * 10 + 11 * size_t(snap[1]) && snap[1] == 36); + o[0].px = 2000000000; + m[0].second.px = -2000000000; + snap = rl::buildSnapshot(m, o); + CHECK(snap.size() <= rl::kMaxPayload && snap[2] == 0 && snap.size() == 4 + 20 * (10 + size_t(snap[1])) && snap[1] == 15); + } + + // The hub: slot 1 (the authority) and slot 2 both send objects; 2 and 3 + // get slot 1's, slot 1 gets none; slot 2's are dropped. + rl::Hub h; + const int a = h.connect(0), b = h.connect(0), c = h.connect(0); + for (int k : {a, b, c}) hubSend(h, k, helloFrame(9), 0); + hubSent(h); + const std::vector> mine = {{4, 40960, -4096, 8192, 1024}, {9, 45056, 0, 0, 0}}; + hubSend(h, a, rl::encodeFrame(rl::Snapshot, engineSnapshot(1, mine), 1, 0), 0.5); + hubSend(h, b, rl::encodeFrame(rl::Snapshot, engineSnapshot(2, {{6, 1, 2, 3, 4}}), 2, 0), 0.5); + h.tick(1.0); + auto sent = hubSent(h); + std::vector> avatars = {{1, rl::Avatar{100, 0, -100, 0, 1}}, {2, rl::Avatar{200, 0, -200, 0, 1}}, + {3, rl::Avatar{}}}; + std::vector objs; + for (const auto& v : mine) objs.push_back(rl::WorldObject{uint16_t(v[0]), v[1], v[2], v[3], v[4], 1}); + const std::vector plain = rl::buildSnapshot(avatars), carried = rl::buildSnapshot(avatars, objs); + CHECK(sent[a].size() == 1 && sent[a][0].type == rl::Snapshot && sent[a][0].payload == plain && sent[a][0].payload[1] == 0); + for (int k : {b, c}) { + CHECK(sent[k].size() == 1 && sent[k][0].type == rl::Snapshot && sent[k][0].payload == carried); + if (sent[k].size() == 1) { + const std::vector> got = engineObjects(sent[k][0].payload); + CHECK(got.size() == 2 && got[0][0] == 4 && got[1][0] == 9 && got[0][1] == 40960 && got[0][2] == -4096 && got[0][3] == 8192 && + got[0][4] == 1024 && got[1][1] == 45056); + } + } + // Slot 1 leaves: slot 2 is the authority, slot 1's objects are gone, and slot 2's now count. + hubSend(h, a, rl::encodeFrame(rl::Bye, {}, 1, 3), 1.1); + h.tick(1.2); + sent = hubSent(h); + for (int k : {b, c}) + for (const rl::Frame& f : sent[k]) + if (f.type == rl::Snapshot) CHECK(f.payload[1] == 0); + hubSend(h, b, rl::encodeFrame(rl::Snapshot, engineSnapshot(2, {{6, 1, 2, 3, 4}}), 2, 1), 1.3); + h.tick(1.4); + sent = hubSent(h); + const std::vector> got = sent[c].size() == 1 ? engineObjects(sent[c][0].payload) : std::vector>{}; + CHECK(got.size() == 1 && got[0] == (std::array{6, 1, 2, 3, 4})); + CHECK(sent[b].size() == 1 && sent[b][0].payload[1] == 0); +} + // The server on a socket: three clients speaking redux's wire get slots 1, 2 // and 3 in the order they connected. void testRelaySocket() { @@ -13806,6 +13958,7 @@ int main(int argc, char** argv) { testRelayRooms(); testRelayReliable(); testRelaySnapshot(); + testRelayWorldObjects(); testRelaySocket(); testPlayBootLogo(); testBuildScenes(); From 1a3f862df9417d8769f4f57ad603d324980467b5 Mon Sep 17 00:00:00 2001 From: Rix Date: Tue, 20 Oct 2026 21:44:01 -0700 Subject: [PATCH 3/6] Network view and console status name the relay's authority The Network view's head line and the authority's console chip say which slot is the room's authority, and each authority change is a row in its log ("Authority slot 2 (was 1)"). Under each console, the relayed status line says "authority" or which slot is. Scripted runs print net: authority and net: hand-off. relayviewcheck now makes console 1 leave after Pause and checks the hand-off to slot 2 end to end. --- src/mockup/main_screen.cpp | 44 ++++++++++++++++++------ tests/relayviewcheck.py | 69 +++++++++++++++++++++++++++++--------- 2 files changed, 86 insertions(+), 27 deletions(-) diff --git a/src/mockup/main_screen.cpp b/src/mockup/main_screen.cpp index cea84f7..f3ca46c 100644 --- a/src/mockup/main_screen.cpp +++ b/src/mockup/main_screen.cpp @@ -4601,7 +4601,8 @@ static std::optional relayConnection(const State::Pla // 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. +// relay slot and whose the room's authority is 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; @@ -4659,7 +4660,10 @@ static void consolePanel(State& st, ImDrawList* dl, ImRect r, int i) { 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) + : rc->joined ? "Relay slot " + std::to_string(rc->slot) + + (rc->isAuthority() ? std::string(" \xc2\xb7 authority") + : rc->authority != editor::relay::kNoSlot ? " \xc2\xb7 authority slot " + std::to_string(rc->authority) + : std::string()) : "Relay: connected, no Hello yet"; line = relay + " \xc2\xb7 " + line; if (!rc || !rc->open) dot = color::bad; @@ -10201,10 +10205,16 @@ static void networkView(State& st) { // 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; + std::string authority; // the slot each room's authority (psxsplash's host) has + for (const rl::Connection& c : conns) { + joined += c.open && c.joined; + if (c.open && c.isAuthority()) authority += (authority.empty() ? "" : ", ") + std::to_string(c.slot); + } 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" + std::to_string(joined) + " of " + std::to_string(p.players) + " consoles in a room" + + (authority.empty() ? std::string() : " \xc2\xb7 authority: slot " + authority) : "Relay stopped \xc2\xb7 what it routed last Play"; + scriptLine("net.authority", "net: authority " + (authority.empty() ? std::string("none") : "slot " + authority)); 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); @@ -10234,10 +10244,15 @@ static void networkView(State& st) { 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"); + const std::string label = "Console " + std::to_string(c.index) + + (in ? " slot " + std::to_string(c.slot) + (c.isAuthority() ? " \xc2\xb7 authority" : "") + : 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) : "") + ": " + + const std::string tip = "Relay connection " + std::to_string(c.index) + (in ? ", slot " + std::to_string(c.slot) : "") + + (in && c.isAuthority() ? ", the room's authority (psxsplash's host: its world objects go to the others)" : "") + + ": " + 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; @@ -10277,7 +10292,7 @@ static void networkView(State& st) { 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."); + "Newest last. Snapshots, Pings and Pongs a console sends within a second share a row; Authority rows are hand-offs."); y += cs.y + space::xs; } std::string filterWords = p.netFilter.slot < 0 ? "any" : std::to_string(p.netFilter.slot); @@ -10287,9 +10302,14 @@ static void networkView(State& st) { // 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) + " > " + + last += " | " + rl::describeType(*shown[i]) + " " + rl::describeEnd(shown[i]->from, shown[i]->conn) + " > " + rl::describeTo(shown[i]->to); scriptLine("net.last", "net: last" + last); + // The newest authority change among them. + std::string handOff; + for (const rl::Packet* k : shown) + if (k->handOff()) handOff = " " + rl::describeType(*k) + " " + rl::describeEnd(k->from, k->conn) + " > " + rl::describeTo(k->to); + scriptLine("net.handoff", "net: hand-off" + (handOff.empty() ? std::string(" none") : handOff)); } ImGui::SetCursorScreenPos(ImVec2(o.x, y)); @@ -10326,15 +10346,17 @@ static void networkView(State& st) { 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()); + ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(k.handOff() ? color::warn : packetColor(k.type)), "%s", + rl::describeType(k).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)); + if (k.count == 1 && k.type != rl::Snapshot && !k.handOff()) + ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::textFaint), "%u", unsigned(k.seq)); ImGui::TableNextColumn(); - ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::textFaint), "%u", unsigned(k.size)); + if (!k.handOff()) ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::textFaint), "%u", unsigned(k.size)); } if (!p.netPaused && ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 4) ImGui::SetScrollHereY(1.0f); ImGui::EndTable(); diff --git a/tests/relayviewcheck.py b/tests/relayviewcheck.py index a585ff1..c1d4859 100644 --- a/tests/relayviewcheck.py +++ b/tests/relayviewcheck.py @@ -8,16 +8,19 @@ # 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. +# gives console k slot k. Once the check sees Pause resumed, it creates a +# file that makes console 1's fake say Bye: the authority leaves. # # 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. +# Linked · ... 4 players" (every member hears of every join), console 1 the +# authority and host, the others naming it; a click gives console 3 the +# keyboard; the Network view maps consoles to slots, names the authority, +# counts the handshake and the Events, filters by slot and type, and Pause +# holds it; when console 1 leaves, its log has the hand-off to slot 2 and +# console 2 is the authority and host; 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 +import os, re, shutil, stat, subprocess, sys, tempfile, threading editor = os.path.abspath(sys.argv[1]) display = os.path.join(os.path.dirname(editor), 'emulator_tests') @@ -74,7 +77,7 @@ def talk(sock): sock.settimeout(0.1) buf, psnl, slot, seq, start, parent = b'', b'', 0xFF, 2, time.time(), os.getppid() nextEvent = time.time() + 1 - while time.time() - start < 100 and os.getppid() == parent: + while time.time() - start < 250 and os.getppid() == parent: try: got = sock.recv(4096) if not got: @@ -101,6 +104,9 @@ def talk(sock): print('splashedit-link: state 2 host %d slot %d players %d' % (int(slot == host), slot, players), flush=True) elif t == 4: sock.sendall(wire(frame(5, b'', sq, slot))) + if k == 1 and slot != 0xFF and os.path.exists(os.environ.get('RELAYVIEW_LEAVE', '')): + sock.sendall(wire(frame(7, b'', seq, slot))) # Bye: the authority leaves + return if slot != 0xFF and time.time() >= 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: @@ -176,7 +198,8 @@ def shot(name): 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 · %s · 4 players' % (c, c, 'host' if c == 1 else 'not host') + want = ('Relay slot 1 · authority · Linked · slot 1 · host · 4 players' if c == 1 else + 'Relay slot %d · authority slot 1 · 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')]) @@ -194,10 +217,24 @@ def shot(name): 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:]) +auth = [(i, l) for i, l in enumerate(lines) if l.startswith('net: authority')] +check('the Network view names slot 1 the authority', auth and auth[0][1] == 'net: authority slot 1', auth[:3]) 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) +# The hand-off: console 1 said Bye after Resume. +after = lines[resumed[0]:] if resumed else [] +check('when slot 1 leaves, the Network view names slot 2', 'net: authority slot 2' in after, + [l for l in after if l.startswith('net: authority')]) +handoff = [l for l in after if l.startswith('net: hand-off')] +check('and its log shows the hand-off row', 'net: hand-off Authority slot 2 (was 1) relay > slots 2, 3, 4' in handoff, handoff[-3:]) +check('console 2 is the authority and host now', + any('| Relay slot 2 · authority · Linked · slot 2 · host · 3 players' in l for l in after if l.startswith('game: console 2 ')), + status.get(2, [])[-2:]) +check('console 3 names slot 2', + any('| Relay slot 3 · authority slot 2 · Linked · slot 3 · not host · 3 players' in l for l in after if l.startswith('game: console 3 ')), + status.get(3, [])[-2:]) 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:]) From 2d0a73a68958bbd5669b22d3d259041f51ad15ff Mon Sep 17 00:00:00 2001 From: Rix Date: Tue, 20 Oct 2026 21:49:10 -0700 Subject: [PATCH 4/6] Scene > Network: lock the id to the scene's name, and Play's consoles The Network id's help named Net.Begin; the engine's call is Net.Connect. While the id is empty, a button sets it to the scene's name in one undoable edit. Below it, how many consoles Play boots and the relay switch for 2, the same saved settings as the Players list, with one line on direct link against relay. netidcheck covers the button, its undo, both controls, the saved settings and the toolbar's list moving the section. --- src/mockup/main_screen.cpp | 59 +++++++++++++++++++++++++++++++++----- tests/netidcheck.py | 51 ++++++++++++++++++++++++++++++-- 2 files changed, 101 insertions(+), 9 deletions(-) diff --git a/src/mockup/main_screen.cpp b/src/mockup/main_screen.cpp index f3ca46c..db368c2 100644 --- a/src/mockup/main_screen.cpp +++ b/src/mockup/main_screen.cpp @@ -8076,8 +8076,10 @@ static void sceneInspector(State& st, ImDrawList* dl, ImRect r, float y, editor: } } - // Network: the id two consoles compare before they link. Empty, the - // engine derives one from the scene index and object count. + // Network: the id two consoles compare before they link (empty, the + // engine derives one from the scene index and object count; one click + // locks it to the scene's name), then how many consoles Play boots and + // whether 2 go through the relay: the Players list's own settings. { const float stop = y; const bool open = section("s_network", ImRect(ImVec2(x0, y), ImVec2(x1, y + 34)), icon::globe, color::textDim, "Network", @@ -8085,7 +8087,7 @@ static void sceneInspector(State& st, ImDrawList* dl, ImRect r, float y, editor: if (!open) ImGui::PushClipRect(ImVec2(0, 0), ImVec2(0, 0), false); y += 34 + space::xs; const ImRect idRow = row("lnetid", "Network id", - "Consoles linked with Net.Begin only connect when their scenes have the same id. Name the scene " + "Consoles linked with Net.Connect only connect when their scenes have the same id. Name the scene " "here to keep the id stable between builds."); nameRect("scene.netid", idRow); std::string typed; @@ -8094,14 +8096,57 @@ static void sceneInspector(State& st, ImDrawList* dl, ImRect r, float y, editor: const uint32_t hash = splash::hashSceneId(set.networkId); char hex[16]; std::snprintf(hex, sizeof hex, "0x%08X", unsigned(hash)); - if (set.networkId.empty()) + scriptLine("network", set.networkId.empty() ? std::string("network: derived") + : "network: locked " + set.networkId + " hash " + hex); + if (set.networkId.empty()) { caption("Derived at run time: it changes when objects are added, so consoles running different builds may not " "connect.", color::textFaint); - else + // One click locks it to the scene's name: stable, and the same on every build of it. + const std::string stem = doc.sceneStem(); + const std::string label = "Use \"" + stem + "\""; + const std::string tip = "Sets the Network id to the scene's name, " + stem + ". Ctrl+Z puts it back."; + if (!stem.empty() && button("scene.netid.fill", ImVec2(x0 + space::sm + lw, y), icon::lock, label.c_str(), ButtonKind::Secondary, + nullptr, tip.c_str())) + doc.editSettings([stem](splash::SceneSettings& s) { s.networkId = stem; }); + if (!stem.empty()) y += size::field + 6 + space::xs; + } else caption(std::string("Locked: hash ") + hex + ".", color::good); - scriptLine("network", set.networkId.empty() ? std::string("network: derived") - : "network: locked " + set.networkId + " hash " + hex); + + // Play's consoles: the Players list (toolbar), saved for this user, not in the scene. + y += space::sm; + State::Play& play = st.play; + const ImRect playersRow = row("lnetplayers", "Play boots", + "How many consoles Play starts for this scene's network code: the toolbar's Players list. " + "Saved for you in the editor's settings, not in the scene."); + nameRect("scene.players", playersRow); + std::vector counts; + for (int n = 1; n <= editor::kMaxPlayers; ++n) + counts.push_back(n == 1 ? std::string("1 Console") : n == 2 ? std::string("2 Linked Consoles") : std::to_string(n) + " Consoles"); + std::vector countItems; + for (const std::string& c : counts) countItems.push_back(c.c_str()); + const int cur = std::clamp(play.tools.players, 1, editor::kMaxPlayers) - 1; + const int pickPlayers = dropdownMenu("scene.players", playersRow, icon::users, countItems[size_t(cur)], countItems, cur); + if (pickPlayers >= 0 && pickPlayers != cur) setPlayers(st, pickPlayers + 1); + const ImRect relayRow = row("lnetrelay", "Relay", + "Route 2 consoles through the editor's relay instead of linking them directly: the Players " + "list's Use the Relay for 2 Consoles. 3 or more always use it."); + { + // 3 or more have no choice: no switch then, only what happens. + float tx = relayRow.Min.x; + if (play.tools.players < 3) { + const ImRect relaySw(ImVec2(relayRow.Min.x, relayRow.GetCenter().y - 8), ImVec2(relayRow.Min.x + 30, relayRow.GetCenter().y + 8)); + nameRect("scene.relay", relaySw); + if (toggle("scene.relay", relaySw, play.tools.relay)) setRelay(st, !play.tools.relay); + tx = relaySw.Max.x + space::sm; + } + const char* what = play.tools.players >= 3 ? "Always, for 3 or more consoles" : "Use it for 2 consoles"; + const float ty = relayRow.GetCenter().y - measure(f.regular, type::caption, what).y * 0.5f; + text(dl, ImVec2(tx, ty), f.regular, type::caption, editor::usesRelay(play.tools) ? color::textDim : color::textFaint, what); + } + caption("2 consoles link directly, 3 or more through the relay.", color::textFaint); + scriptLine("scene.play", "scene: play " + std::to_string(play.tools.players) + (play.tools.relay ? " relay on" : " relay off") + + (editor::usesRelay(play.tools) ? " via relay" : "")); y += space::xs; if (!open) { ImGui::PopClipRect(); diff --git a/tests/netidcheck.py b/tests/netidcheck.py index 265e4c7..f894764 100644 --- a/tests/netidcheck.py +++ b/tests/netidcheck.py @@ -5,7 +5,10 @@ # splashpack header (sceneHash, read with tests/splashdiff.py). The hash is # Unity's: FNV-1a over the low byte of each UTF-16 unit. The editor prints # "network: derived" or "network: locked hash 0x<8 hex digits>" when it -# changes. +# changes. While the id is empty, a button locks it to the scene's name in +# one undoable click. The section also holds Play's consoles and relay, the +# Players list's own settings: picking them there saves them to the play +# settings file (each run gets its own) and the toolbar's list agrees. # usage: python3 netidcheck.py [screenshot.png] (needs a display: xvfb-run -a) import json, os, shutil, subprocess, sys, tempfile @@ -44,12 +47,22 @@ def copy(): return project +def settings(project): + """The run's own play settings file, beside the project: 2 consoles, no relay at first.""" + cfg = os.path.join(os.path.dirname(project), 'play.cfg') + if not os.path.exists(cfg): + with open(cfg, 'w') as fh: + fh.write('players=2\nrelay=0\n') + return cfg + + def run(project, args): """Runs the editor on `project` (the scene inspector shows), scrolls the inspector to its end, then `args`. Returns the network: lines, all output lines, the named controls and the screenshot path.""" shot = os.path.join(project, 'shot.png') - cmd = [editor, '--size', '1600x960', '--project', project, '--wait', '5', '--wheel', '1400,600,-60', '--wait', '3'] + args + \ + cmd = [editor, '--size', '1600x960', '--project', project, '--play-settings', settings(project), + '--wait', '5', '--wheel', '1400,600,-60', '--wait', '3'] + args + \ ['--wait', '5', '--screenshot', shot] r = subprocess.run(cmd, capture_output=True, text=True, env=ENV, timeout=180) lines = (r.stdout + r.stderr).strip().splitlines() @@ -109,5 +122,39 @@ def typed(text): net, lines, _, _ = run(p, typed('lobby') + ['--key', 'ctrl+z', '--wait', '3']) check('Ctrl+Z after typing goes back to derived', net == ['network: derived', lobby, 'network: derived'], net) +# One click locks the id to the scene's name; Ctrl+Z puts the derived id back. +p = copy() +net, lines, controls, _ = run(p, []) +check('an empty id offers the scene\'s name', 'scene.netid.fill' in controls, sorted(c for c in controls if c.startswith('scene.'))) +courtyard = 'network: locked courtyard hash 0x%08X' % unity_hash('courtyard') +net, lines, controls, _ = run(p, ['--click', '@scene.netid.fill', '--wait', '5']) +check('the button locks the id to "courtyard"', net == ['network: derived', courtyard], net) +check('and goes once the id is set', 'scene.netid.fill' not in controls) +net, lines, _, _ = run(p, ['--click', '@scene.netid.fill', '--wait', '5', '--key', 'ctrl+z', '--wait', '3']) +check('Ctrl+Z after the button goes back to derived', net == ['network: derived', courtyard, 'network: derived'], net) + +# Play's consoles and the relay, from the section: saved to the play settings, the toolbar agrees. +p = copy() +net, lines, controls, _ = run(p, []) +scene = [l for l in lines if l.startswith('scene: play')] +check('the section shows 2 consoles, relay off', scene[-1:] == ['scene: play 2 relay off'], scene) +check('with a relay switch for 2', 'scene.relay' in controls and 'scene.players' in controls) +net, lines, controls, _ = run(p, ['--click', '@scene.relay', '--wait', '5']) +scene = [l for l in lines if l.startswith('scene: play')] +check('the switch puts 2 consoles through the relay', scene[-1:] == ['scene: play 2 relay on via relay'] and + 'play: players 2 relay' in lines, scene + [l for l in lines if l.startswith('play: players')]) +cfg = open(settings(p)).read() +check('and saves relay=1 to the play settings', 'relay=1\n' in cfg and 'players=2\n' in cfg, cfg) +net, lines, controls, _ = run(p, ['--click', '@scene.players', '--wait', '5', '--click', '@scene.players/5 Consoles', '--wait', '5']) +scene = [l for l in lines if l.startswith('scene: play')] +check('picking 5 consoles there', scene[-1:] == ['scene: play 5 relay on via relay'] and 'play: players 5 relay' in lines, scene) +check('has no relay switch (3 or more always use it)', 'scene.relay' not in controls) +cfg = open(settings(p)).read() +check('and saves players=5', 'players=5\n' in cfg, cfg) +net, lines, controls, _ = run(p, ['--click', '@players', '--wait', '5', '--click', '@menu.players/3 Consoles', '--wait', '5']) +scene = [l for l in lines if l.startswith('scene: play')] +check('the next run starts at 5, and the toolbar\'s list moves the section to 3', + scene[:1] == ['scene: play 5 relay on via relay'] and scene[-1:] == ['scene: play 3 relay on via relay'], scene) + print('FAILS=%d' % fails) sys.exit(1 if fails else 0) From 9498696b7d5ef213edeea296d66720a1689e26e3 Mon Sep 17 00:00:00 2001 From: Rix Date: Tue, 20 Oct 2026 21:58:41 -0700 Subject: [PATCH 5/6] relayplaycheck: console 1 is the authority, and its crate crosses The relay now names console 1 the authority, so the check expects it host and the others not, and the status lines to say so. The scene registers Crate (3) with Net.RegisterActor; the host moves it each second and a console that is not host logs once it sees it move, which only the relay's forwarded world objects can do. Console 2's output, filtered to that line, must show it. Passes on a real pcsx-redux with 3 consoles. --- tests/relayplaycheck.py | 53 ++++++++++++++++++++++++++++++++++------- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/tests/relayplaycheck.py b/tests/relayplaycheck.py index b7533a8..4ecd3e7 100644 --- a/tests/relayplaycheck.py +++ b/tests/relayplaycheck.py @@ -1,13 +1,18 @@ # 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, +# copy of the courtyard whose object script calls Net.Connect() and +# Net.RegisterActor()s the object Crate (3), and once connected Net.Send()s +# an event every second and, on the host, moves the crate 2 units along x; +# a console that is not host logs "cratecheck: moved" once its crate is +# somewhere else than where it first saw it (only the host's world objects, +# through the relay's snapshots, move it there). The scene script logs each +# event 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, console 1 (the room's # authority) host and the others not, 3 players each; -# console 2 must log events console 1 and 3 sent (the relay fanned them out); +# console 2 must log events console 1 and 3 sent (the relay fanned them out) +# and see the crate the host moved; # 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. # @@ -41,9 +46,11 @@ def check(name, ok, detail=''): 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' + fh.write('local frames, seconds, crate, startX = 0, 0, nil, nil\n' 'function onCreate(self)\n' ' Net.Connect()\n' + ' crate = Actor.Find("Crate (3)")\n' + ' if crate then Net.RegisterActor(crate) end\n' 'end\n' 'function onUpdate(self, dt)\n' ' frames = frames + 1\n' @@ -52,6 +59,16 @@ def check(name, ok, detail=''): ' seconds = seconds + 1\n' ' if Net.State() == 2 then\n' ' Net.Send(7, Net.LocalSlot() * 1000 + seconds)\n' + ' if crate then\n' + ' local x = Actor.GetPositionXZ(crate)\n' + ' if Net.IsHost() then\n' + ' Actor.SetPosition(crate, Vec3.add(Actor.GetPosition(crate), Vec3.new(2, 0, 0)))\n' + ' elseif startX == nil then\n' + ' startX = x\n' + ' elseif x ~= startX then\n' + ' Debug.Log("cratecheck: moved " .. startX .. " to " .. x)\n' + ' end\n' + ' end\n' ' end\n' 'end\n') with open(os.path.join(project, 'scripts', 'net_scene.lua'), 'w', newline='\n') as fh: @@ -84,10 +101,19 @@ def check(name, ok, detail=''): '--click', '@net.type.Snapshot', '--wait', '20'] if shots: args += ['--shot', shots + '-network-nosnap.png'] +# Back to console 2's output, filtered to the crate the host moved. +args += ['--click', '@play.tab.output', '--wait', '10', '--click', '@play.filter', '--wait', '3', '--key', 'escape', '--wait', '3', + '--text', 'cratecheck: moved', '--wait', '60'] +if shots: + args += ['--shot', shots + '-crate.png'] +args += ['--click', '@play.tab.network', '--wait', '10'] # out of the filter field, so F5 below reaches Play 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 +if shots: + with open(shots + '-out.txt', 'w') as fh: + fh.write(out + '\n' + r.stderr) 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) @@ -104,22 +130,33 @@ def check(name, ok, detail=''): 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) + m = re.search(r'Relay slot (\d+) · (authority|authority 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))) + linked[c] = (int(m.group(1)), int(m.group(3)), m.group(4) == 'host', int(m.group(5)), m.group(2)) 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('console 1 is host, the others are not', [linked[c][2] for c in (1, 2, 3)] == [True, False, False], linked) + check('the relay names console 1 the authority', [linked[c][4] for c in (1, 2, 3)] == + ['authority', 'authority slot 1', 'authority slot 1'], 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 ')] +# The output's filter counts: 'relaycheck: got' until the Network tab, 'cratecheck: moved' once back on the output. +olines = out.splitlines() +tabs = [i for i, l in enumerate(olines) if l == 'play: output tab network'] +back = [i for i, l in enumerate(olines) if l == 'play: output tab output' and tabs and i > tabs[0]] +shown = [l for l in (olines[:tabs[0]] if tabs else olines) 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:]) +moved = [l for l in (olines[back[0]:] if back else []) if l.startswith('console: play shown ')] +n = [int(m.group(1)) for l in moved for m in [re.match(r'console: play shown (\d+) of', l)] if m] +check('console 2 sees the crate the host moved (world objects through the relay)', n and n[-1] > 0, moved[-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:]) +check('and names slot 1 the authority', 'net: authority slot 1' in out.splitlines(), + [l for l in out.splitlines() if l.startswith('net: authority')][: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)) From a2155d7ef4a85f62144e48f5d1ce022d14b90d9b Mon Sep 17 00:00:00 2001 From: Rix Date: Tue, 20 Oct 2026 23:03:03 -0700 Subject: [PATCH 6/6] memcardeditcheck, statusbudgetcheck: scroll to the switch by name Both wheeled the scene inspector a fixed amount to its bottom, taking Memory card for the last section. With Scene > Network grown below it, the bottom left the Enable saves switch above the panel. They now use --scroll-to on the controls they click, which wheels either way until the control is inside the panel. Both pass on main and on this branch. --- tests/memcardeditcheck.py | 12 ++++++------ tests/statusbudgetcheck.py | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/memcardeditcheck.py b/tests/memcardeditcheck.py index 88d6484..1a5f20e 100644 --- a/tests/memcardeditcheck.py +++ b/tests/memcardeditcheck.py @@ -10,15 +10,15 @@ example = os.path.join(here, '..', 'examples', 'courtyard') fails = 0 ENV = dict(os.environ, SPLASHEDIT_REDUX='/nonexistent/pcsx-redux', SPLASHEDIT_PSXSPLASH='/nonexistent/psxsplash.ps-exe') -# At 1600x960 with nothing selected the inspector shows the scene, and Memory -# card is its last section. Once the background export has landed (its sizes -# change the height of the sections above), the wheel over the inspector -# scrolls it to the bottom, which brings Memory card into view however many -# sections sit above it. Each run checks that both controls ended up on screen. +# At 1600x960 with nothing selected the inspector shows the scene. Once the +# background export has landed (its sizes change the height of the sections +# above), --scroll-to wheels the inspector until the Save title field, then +# the Enable saves switch above it, is inside it, wherever Memory card sits +# among the sections. Each run checks that both controls ended up on screen. # Controls are clicked by name (--click @scene.mcenable): the editor aims at # where the frame before drew them, so no pixel positions live here. H = 960 -SCROLL = ['--wait', '20', '--wait-export', '--wheel', '1420,500,-200', '--wait', '3'] +SCROLL = ['--wait', '20', '--wait-export', '--scroll-to', '@scene.mctitle', '--scroll-to', '@scene.mcenable', '--wait', '3'] ENABLE = '@scene.mcenable' # the Enable saves switch TITLE = '@scene.mctitle' # the Save title field TYPED = 'MY GAME 1' diff --git a/tests/statusbudgetcheck.py b/tests/statusbudgetcheck.py index df9bd41..ddb2b96 100644 --- a/tests/statusbudgetcheck.py +++ b/tests/statusbudgetcheck.py @@ -15,8 +15,8 @@ fails = 0 ENV = dict(os.environ, SPLASHEDIT_REDUX='/nonexistent/pcsx-redux', SPLASHEDIT_PSXSPLASH='/nonexistent/psxsplash.ps-exe') # The scene inspector shows with nothing selected; once the export has landed -# the wheel scrolls it to Memory card, its last section (memcardeditcheck.py). -ENABLE_SAVES = ['--wheel', '1420,500,-200', '--wait', '3', '--click', '@scene.mcenable', '--wait', '3'] +# --scroll-to wheels it until Memory card's Enable saves switch is in view. +ENABLE_SAVES = ['--scroll-to', '@scene.mcenable', '--wait', '3', '--click', '@scene.mcenable', '--wait', '3'] def check(name, ok, detail=''):