diff --git a/src/native/skate3_native_ghost.cpp b/src/native/skate3_native_ghost.cpp index 3f98c0d..6ebd8f3 100644 --- a/src/native/skate3_native_ghost.cpp +++ b/src/native/skate3_native_ghost.cpp @@ -183,9 +183,16 @@ double g_local_pose_time = 0.0; // Remote ghosts, keyed by the sender's session id. struct Ghost { uint32_t session_id = 0; - float world[16] = {}; - uint16_t bone_count = 0; // live skeleton rows (0 = no pose) - float bones[kGhostMaxBones * 12] = {}; + float world[16] = {}; // latest received entity world (cube fallback) + // Target skeleton: the latest complete pose received off the wire. + uint16_t target_count = 0; + float target_bones[kGhostMaxBones * 12] = {}; + // Display skeleton: target chased per render query (exponential smoothing), + // so 15 Hz bone updates render as smooth motion instead of stepping. + uint16_t display_count = 0; + float display_bones[kGhostMaxBones * 12] = {}; + double last_display = 0.0; + bool display_snapped = false; double last_seen = 0.0; }; std::vector g_ghosts; @@ -226,6 +233,10 @@ bool g_lan_probing = false; double g_lan_probe_since = 0.0; double g_last_ping = 0.0; +// Chunk reassembly state per remote session (network thread only; bounded, +// ResetSession clears it). +std::unordered_map g_pending_poses; + // Pose pacing. double g_pose_acc = 0.0; uint32_t g_seq = 0; @@ -288,16 +299,36 @@ void SendPose(GhostSock sock, const GhostAddr& to, uint32_t& seq) { } pp.bone_count = pose_rows; pp.flags = pose_rows != 0 ? kPoseFlagsBones : 0; - PacketBuf p; - BuildPacket(p, kTypePose, &pp, sizeof(pp)); - GhostSockSendTo(sock, to, &p, PacketSize(p)); + // MTU-safe chunks (the full pose is ~3 KB and must not IP-fragment). + PacketBuf chunks[kGhostMaxChunks]; + const uint8_t n = GhostPoseBuildChunks(0, pp.seq, pp, chunks); + for (uint8_t i = 0; i < n; ++i) { + GhostSockSendTo(sock, to, &chunks[i], PacketSize(chunks[i])); + } } // ---- packet receive -------------------------------------------------------- void HandlePacket(const GhostAddr& from, const PacketBuf& p, double now) { - if (p.h.type == kTypePose) { - PosePayload pp = {}; - std::memcpy(&pp, p.payload, sizeof(pp)); + if (p.h.type == kTypePoseChunk) { + PoseChunkPayload cp = {}; + std::memcpy(&cp, p.payload, sizeof(cp)); + if (cp.session_id == 0 || cp.chunks == 0 || cp.chunks > kGhostMaxChunks) { + return; + } + // Reassemble this session's pose stream (network thread only). The + // sender identity is the CHUNK HEADER's session id: the relay re-stamps + // that (offset 0 of the payload), while the blob's own session field is + // a sender-side zero the relay never touches. + const uint32_t sender = cp.session_id; + if (g_pending_poses.size() > 64) { + g_pending_poses.clear(); // churn guard: partials re-send next frame + } + PosePayload pose = {}; + GhostPoseReassembler& r = g_pending_poses[sender]; + if (!r.Push(cp.seq, cp.blob_size, cp.chunk, cp.chunks, cp.data, + cp.data_size, &pose)) { + return; + } { std::lock_guard lock(g_mu); // Determine which relay this came from and skip our own pose echoes @@ -310,42 +341,59 @@ void HandlePacket(const GhostAddr& from, const PacketBuf& p, double now) { const uint32_t own = from_public ? g_session_public : from_lan ? g_session_lan : 0; - if (pp.session_id == 0 || (own != 0 && pp.session_id == own)) { + if (sender == 0 || (own != 0 && sender == own)) { return; } const bool finite = - std::isfinite(pp.pos[0]) && std::isfinite(pp.pos[1]) && - std::isfinite(pp.pos[2]); + std::isfinite(pose.pos[0]) && std::isfinite(pose.pos[1]) && + std::isfinite(pose.pos[2]); if (!finite) { return; } const uint16_t rows = - (pp.flags & kPoseFlagsBones) != 0 - ? std::min(pp.bone_count, kGhostMaxBones) + (pose.flags & kPoseFlagsBones) != 0 + ? std::min(pose.bone_count, kGhostMaxBones) : 0; - auto it = g_ghost_index.find(pp.session_id); + auto it = g_ghost_index.find(sender); if (it != g_ghost_index.end() && it->second < g_ghosts.size()) { Ghost& gh = g_ghosts[it->second]; - std::memcpy(gh.world, pp.rot, 9 * sizeof(float)); - std::memcpy(gh.world + 12, pp.pos, 3 * sizeof(float)); + std::memcpy(gh.world, pose.rot, 9 * sizeof(float)); + std::memcpy(gh.world + 12, pose.pos, 3 * sizeof(float)); gh.world[15] = 1.0f; - gh.bone_count = rows; + gh.target_count = rows; if (rows != 0) { - std::memcpy(gh.bones, pp.bones, size_t(rows) * 12 * sizeof(float)); + std::memcpy(gh.target_bones, pose.bones, + size_t(rows) * 12 * sizeof(float)); + } + // New skeleton (count changed or first pose): snap the display so we + // never blend between two different bone layouts. + if (!gh.display_snapped || gh.display_count != rows) { + gh.display_count = rows; + if (rows != 0) { + std::memcpy(gh.display_bones, gh.target_bones, + size_t(rows) * 12 * sizeof(float)); + } + gh.display_snapped = true; + gh.last_display = 0.0; } gh.last_seen = now; } else if (g_ghosts.size() < 16) { Ghost gh; - gh.session_id = pp.session_id; - std::memcpy(gh.world, pp.rot, 9 * sizeof(float)); - std::memcpy(gh.world + 12, pp.pos, 3 * sizeof(float)); + gh.session_id = sender; + std::memcpy(gh.world, pose.rot, 9 * sizeof(float)); + std::memcpy(gh.world + 12, pose.pos, 3 * sizeof(float)); gh.world[15] = 1.0f; - gh.bone_count = rows; + gh.target_count = rows; + gh.display_count = rows; if (rows != 0) { - std::memcpy(gh.bones, pp.bones, size_t(rows) * 12 * sizeof(float)); + std::memcpy(gh.target_bones, pose.bones, + size_t(rows) * 12 * sizeof(float)); + std::memcpy(gh.display_bones, gh.target_bones, + size_t(rows) * 12 * sizeof(float)); } + gh.display_snapped = true; gh.last_seen = now; - g_ghost_index[pp.session_id] = g_ghosts.size(); + g_ghost_index[sender] = g_ghosts.size(); g_ghosts.push_back(gh); } } @@ -425,6 +473,7 @@ void PruneGhosts(double now) { for (size_t i = 0; i < g_ghosts.size();) { if (now - g_ghosts[i].last_seen > kGhostStaleSec) { g_ghost_index.erase(g_ghosts[i].session_id); + g_pending_poses.erase(g_ghosts[i].session_id); g_ghosts[i] = g_ghosts.back(); g_ghosts.pop_back(); // Fix the moved entry's index (only if it wasn't the one we removed). @@ -444,6 +493,7 @@ void ResetSession() { g_roster = 0; g_ghosts.clear(); g_ghost_index.clear(); + g_pending_poses.clear(); g_session_public = 0; g_session_lan = 0; g_peer_public = Peer{}; @@ -781,13 +831,31 @@ bool GetGhostPose(uint32_t index, float world[16], float color[3], if (index >= g_ghosts.size()) { return false; } - const Ghost& gh = g_ghosts[index]; + Ghost& gh = g_ghosts[index]; std::memcpy(world, gh.world, sizeof(float) * 16); GhostColor(gh.session_id, color); - const uint16_t rows = std::min(gh.bone_count, max_bones); + // Chase the display skeleton toward the latest received target: per-frame + // exponential catch-up (time-constant ~33 ms) turns the stepped 15 Hz + // bone stream into smooth motion. Only once the render thread has moved + // the display do we advance it, so two queries in one frame can't double. + if (gh.display_count != 0 && gh.display_count == gh.target_count) { + const double now = NowSec(); + double dt = gh.last_display > 0.0 ? now - gh.last_display : 0.0; + dt = std::min(dt, 0.1); + if (dt > 0.0) { + const float alpha = float(1.0 - std::exp(-dt * 30.0)); + const size_t n = size_t(gh.display_count) * 12; + for (size_t i = 0; i < n; ++i) { + gh.display_bones[i] += + (gh.target_bones[i] - gh.display_bones[i]) * alpha; + } + } + gh.last_display = now; + } + const uint16_t rows = std::min(gh.display_count, max_bones); *bone_count = rows; if (rows != 0) { - std::memcpy(bones, gh.bones, size_t(rows) * 12 * sizeof(float)); + std::memcpy(bones, gh.display_bones, size_t(rows) * 12 * sizeof(float)); } return true; } diff --git a/src/native/skate3_native_ghost_proto.h b/src/native/skate3_native_ghost_proto.h index 6b46a6b..508321f 100644 --- a/src/native/skate3_native_ghost_proto.h +++ b/src/native/skate3_native_ghost_proto.h @@ -15,13 +15,15 @@ // A packet is PacketHeader followed by a fixed per-type payload. PacketBuf // is sized for the largest payload. +#include +#include #include #include namespace skate3::native_ghost { constexpr uint32_t kGhostMagic = 0x54484753u; // "GHST" (LE) -constexpr uint8_t kGhostVersion = 1; +constexpr uint8_t kGhostVersion = 2; constexpr uint16_t kGhostDefaultPort = 40001; constexpr uint32_t kGhostMaxRoomLen = 8; constexpr uint32_t kGhostMaxRoster = 32; @@ -39,10 +41,11 @@ constexpr const char* kGhostLobbyRoom = "LOBBY"; enum PacketType : uint8_t { kTypeJoin = 1, // client -> relay: { JoinPayload } (empty room = server assigns) kTypeHello = 2, // relay -> client: { HelloPayload } (session id, room, roster) - kTypePose = 3, // client -> relay -> other members: { PosePayload } + kTypePose = 3, // legacy single-datagram pose (forwarded, unused by clients) kTypePing = 4, // client -> relay / LAN broadcast probe: { JoinPayload } kTypePong = 5, // relay -> client: empty kTypeLeave = 6, // client -> relay: empty + kTypePoseChunk = 7, // client -> relay -> other members: { PoseChunkPayload } }; struct alignas(1) PacketHeader { @@ -66,11 +69,12 @@ struct alignas(1) HelloPayload { uint32_t roster[kGhostMaxRoster]; }; -// Full-pose packet (the Phase 2 character-model path): the sender's skater -// skeleton, 3 float4 rows per bone (column-vector affine [R | t], model -> -// world, row k = bone k exactly as the scene renderer's bone ring expects). -// Flags bit 0 = bones[] valid. pos/rot are kept from the entity world for -// the cube fallback / diagnostics. +// Full-pose blob (the character-model path): the sender's skater skeleton, +// 3 float4 rows per bone (column-vector affine [R | t], model -> world, row +// k = bone k exactly as the scene renderer's bone ring expects). Flags bit 0 +// = bones[] valid. pos/rot are kept from the entity world for the cube +// fallback / diagnostics. This struct is the REASSEMBLED content of a +// chunked PoseChunk stream; it is no longer sent as a single datagram. struct alignas(1) PosePayload { uint32_t session_id; // set by the relay on forward (the sender's id) uint32_t seq; // sender monotonic sequence @@ -85,7 +89,81 @@ struct alignas(1) PosePayload { constexpr uint8_t kPoseFlagsBones = 1; -// Largest payload: PosePayload (32 + 3072 = 3104 bytes). +// ---- chunked pose transport ------------------------------------------------- +// A full PosePayload is ~3.1 KB, far above the 1500-byte path MTU, so it is +// split into kGhostMaxChunks MTU-safe datagrams (<= ~1.2 KB each). The relay +// forwards each chunk verbatim (it never parses the body), and the receiver +// reassembles on the sequence. A lost chunk drops that pose (the next frame +// resends); out-of-order chunks are tolerated by the per-chunk offset copy. +constexpr uint16_t kGhostPoseHeaderSize = + uint16_t(offsetof(PosePayload, bones)); // 68 +constexpr uint16_t kGhostChunkData = 1200; +constexpr uint8_t kGhostMaxChunks = 3; +constexpr uint16_t kGhostPoseBlobSize = uint16_t(sizeof(PosePayload)); // 3140 + +struct alignas(1) PoseChunkPayload { + uint32_t session_id; // offset 0: relay re-stamps the sender id + uint32_t seq; // pose sequence this chunk belongs to + uint16_t blob_size; // total blob bytes this seq comprises + uint16_t data_size; // bytes actually valid in data[] + uint8_t chunk; // 0-based chunk index + uint8_t chunks; // total chunks for this seq + uint8_t data[kGhostChunkData]; +}; + +// Receiver-side reassembly for ONE session (one instance per remote peer, +// network thread only). Push() returns true exactly when a complete pose +// becomes available and fills *out; a fresh seq discards any partial state. +struct GhostPoseReassembler { + void Reset() { + seq_ = 0; + blob_size_ = 0; + chunks_ = 0; + received_ = 0; + } + + bool Push(uint32_t seq, uint16_t blob_size, uint8_t chunk, uint8_t chunks, + const uint8_t* data, uint16_t data_size, PosePayload* out) { + if (chunk >= chunks || chunks == 0 || chunks > kGhostMaxChunks || + blob_size == 0 || blob_size > kGhostPoseBlobSize) { + return false; + } + if (seq != seq_) { + Reset(); + seq_ = seq; + blob_size_ = blob_size; + chunks_ = chunks; + } else if (blob_size != blob_size_ || chunks != chunks_) { + return false; // inconsistent mid-sequence; drop + } + const uint32_t bit = 1u << chunk; + if ((received_ & bit) != 0) { + return false; // duplicate chunk + } + received_ |= bit; + const uint16_t off = uint16_t(chunk) * kGhostChunkData; + const uint16_t avail = blob_size_ > off ? blob_size_ - off : 0; + const uint16_t n = std::min(data_size, avail); + std::memcpy(blob_ + off, data, n); + const uint32_t want = (1u << chunks_) - 1u; + if ((received_ & want) != want) { + return false; + } + std::memset(out, 0, sizeof(*out)); + std::memcpy(out, blob_, std::min(blob_size_, uint16_t(sizeof(*out)))); + Reset(); + return true; + } + + private: + uint32_t seq_ = 0; + uint16_t blob_size_ = 0; + uint8_t chunks_ = 0; + uint32_t received_ = 0; + uint8_t blob_[kGhostPoseBlobSize] = {}; +}; + +// Largest payload: PoseChunkPayload (1214 bytes). constexpr uint16_t kGhostMaxPayload = 3200; struct alignas(1) PacketBuf { @@ -107,6 +185,35 @@ inline void BuildPacket(PacketBuf& p, uint8_t type, const void* payload, } } +// Builds the chunk packets for a pose. Always writes exactly the returned +// chunk count (1 for a header-only pose, up to kGhostMaxChunks for a full +// skeleton). The blob is the PosePayload serialized from its first byte. +inline uint8_t GhostPoseBuildChunks(uint32_t session_id, uint32_t seq, + const PosePayload& pose, + PacketBuf out[kGhostMaxChunks]) { + const uint16_t blob_size = + (pose.flags & kPoseFlagsBones) != 0 + ? uint16_t(sizeof(PosePayload)) + : kGhostPoseHeaderSize; + const uint8_t chunks = + uint8_t((blob_size + kGhostChunkData - 1) / kGhostChunkData); + const uint8_t* blob = reinterpret_cast(&pose); + for (uint8_t c = 0; c < chunks; ++c) { + PoseChunkPayload cp = {}; + cp.session_id = session_id; + cp.seq = seq; + cp.blob_size = blob_size; + cp.chunk = c; + cp.chunks = chunks; + const uint16_t off = uint16_t(c) * kGhostChunkData; + const uint16_t n = std::min(kGhostChunkData, blob_size - off); + cp.data_size = n; + std::memcpy(cp.data, blob + off, n); + BuildPacket(out[c], kTypePoseChunk, &cp, sizeof(cp)); + } + return chunks; +} + // ---- room code helpers (uppercase alnum, max kGhostMaxRoomLen) ------------- inline bool RoomValid(const char* room, uint8_t len) { diff --git a/src/native/skate3_native_ghost_relay.cpp b/src/native/skate3_native_ghost_relay.cpp index dd797b2..a7b986c 100644 --- a/src/native/skate3_native_ghost_relay.cpp +++ b/src/native/skate3_native_ghost_relay.cpp @@ -177,7 +177,8 @@ void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p, break; } - case kTypePose: { + case kTypePose: + case kTypePoseChunk: { // Clients join before posing; a pose with no room is auto-joined to // the lobby so the relay stays resilient to a missed HELLO. if (me->room_len == 0) { @@ -186,11 +187,12 @@ void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p, me->last_seen = now; // Forward the payload VERBATIM to the other room members, re-stamping // only the sender session id at payload offset 0 (little-endian). The - // relay never parses the pose body, so the protocol can grow (e.g. the - // full skeleton palette) without a relay update. + // relay never parses the pose body (a full skeleton pose is a + // multi-chunk kTypePoseChunk stream; each chunk is forwarded the same + // way), so the protocol can grow without a relay update. PacketBuf out = p; out.h.magic = kGhostMagic; - out.h.type = kTypePose; + out.h.type = p.h.type; std::memcpy(out.payload, &me->session_id, sizeof(me->session_id)); const uint16_t size = PacketSize(out); for (const Client& c : clients_) {