diff --git a/src/editor/relay.cpp b/src/editor/relay.cpp index aab9d48..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; } @@ -344,7 +370,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 +410,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 +437,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 +477,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 +490,8 @@ void Hub::leave(Session& s, double now) { const int roomId = c.room; c.room = 0; c.joined = false; + c.authority = kNoSlot; + s.objects.clear(); if (!r) return; r->members.erase(c.slot); if (r->members.empty()) { @@ -457,6 +508,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,12 +547,13 @@ 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. 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; @@ -512,7 +566,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 +585,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,10 +604,13 @@ 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}); - // The sender's own legacy record is its avatar; anything else in it is ignored. + log(now, c.slot, s.id, Snapshot, f.seq, size, {kRelaySlot}); + // 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; @@ -564,26 +621,39 @@ 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: 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; } } @@ -602,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), 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); + const std::vector frame = encodeFrame(Snapshot, buildSnapshot(avatars), kRelaySlot, 0); + // 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); } } } @@ -875,19 +955,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..17c52c1 100644 --- a/src/editor/relay.hh +++ b/src/editor/relay.hh @@ -2,12 +2,17 @@ // 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. 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 @@ -35,7 +40,7 @@ inline constexpr size_t kHeaderSize = 8; // packHello(const HelloPayload& h); struct HelloAckPayload { // parseHelloAck(const std::vector& payload); std::vector packHelloAck(const HelloAckPayload& a); @@ -127,19 +132,32 @@ 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 ---- // 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 +167,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 +177,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 +219,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; @@ -205,15 +228,17 @@ 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); 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 +301,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..41e9240 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()); @@ -1979,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() { @@ -13782,6 +13958,7 @@ int main(int argc, char** argv) { testRelayRooms(); testRelayReliable(); testRelaySnapshot(); + testRelayWorldObjects(); testRelaySocket(); testPlayBootLogo(); testBuildScenes(); diff --git a/src/mockup/main_screen.cpp b/src/mockup/main_screen.cpp index c2e1cc6..db368c2 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; @@ -8072,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", @@ -8081,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; @@ -8090,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(); @@ -10201,10 +10250,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 +10289,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 +10337,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 +10347,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)); @@ -10321,20 +10386,22 @@ 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()); 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/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/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) diff --git a/tests/relayplaycheck.py b/tests/relayplaycheck.py index ca97347..4ecd3e7 100644 --- a/tests/relayplaycheck.py +++ b/tests/relayplaycheck.py @@ -1,12 +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, none host, 3 players each; -# console 2 must log events console 1 and 3 sent (the relay fanned them out); +# 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 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. # @@ -40,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' @@ -51,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: @@ -83,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) @@ -103,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('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('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)) diff --git a/tests/relayviewcheck.py b/tests/relayviewcheck.py index 44a49b7..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 · not host · 4 players' % (c, c) + 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:]) 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=''):