added basic network play

This commit is contained in:
2025-12-23 19:03:33 +01:00
parent 5ec4bf926b
commit a7a3ae9055
14 changed files with 1387 additions and 62 deletions

View File

@ -0,0 +1,21 @@
#pragma once
#include <cstdint>
namespace coopnet {
// 8-bit input mask carried in NetSession::InputFrame.
// Keep in sync across capture/apply on both peers.
enum Buttons : uint8_t {
MoveLeft = 1u << 0,
MoveRight = 1u << 1,
SoftDrop = 1u << 2,
RotCW = 1u << 3,
RotCCW = 1u << 4,
HardDrop = 1u << 5,
Hold = 1u << 6,
};
inline bool has(uint8_t mask, Buttons b) {
return (mask & static_cast<uint8_t>(b)) != 0;
}
}

324
src/network/NetSession.cpp Normal file
View File

@ -0,0 +1,324 @@
#include "NetSession.h"
#include <enet/enet.h>
#include <SDL3/SDL.h>
#include <cstring>
namespace {
constexpr uint8_t kChannelReliable = 0;
static bool netLogVerboseEnabled() {
// Set environment variable / hint: SPACETRIS_NET_LOG=1
const char* v = SDL_GetHint("SPACETRIS_NET_LOG");
return v && v[0] == '1';
}
template <typename T>
static void append(std::vector<uint8_t>& out, const T& value) {
const uint8_t* p = reinterpret_cast<const uint8_t*>(&value);
out.insert(out.end(), p, p + sizeof(T));
}
template <typename T>
static bool read(const uint8_t* data, size_t size, size_t& off, T& out) {
if (off + sizeof(T) > size) return false;
std::memcpy(&out, data + off, sizeof(T));
off += sizeof(T);
return true;
}
}
NetSession::NetSession() = default;
NetSession::~NetSession() {
shutdown();
}
bool NetSession::ensureEnetInitialized() {
static bool s_inited = false;
if (s_inited) return true;
if (enet_initialize() != 0) {
setError("enet_initialize failed");
m_state = ConnState::Error;
return false;
}
s_inited = true;
return true;
}
void NetSession::setError(const std::string& msg) {
m_lastError = msg;
}
bool NetSession::host(const std::string& bindHost, uint16_t port) {
shutdown();
if (!ensureEnetInitialized()) return false;
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] host(bind='%s', port=%u)", bindHost.c_str(), (unsigned)port);
ENetAddress address{};
address.host = ENET_HOST_ANY;
address.port = port;
if (!bindHost.empty() && bindHost != "0.0.0.0") {
if (enet_address_set_host(&address, bindHost.c_str()) != 0) {
setError("enet_address_set_host (bind) failed");
m_state = ConnState::Error;
return false;
}
}
// 1 peer, 2 channels (reserve extra)
m_host = enet_host_create(&address, 1, 2, 0, 0);
if (!m_host) {
setError("enet_host_create (host) failed");
m_state = ConnState::Error;
return false;
}
m_mode = Mode::Host;
m_state = ConnState::Connecting;
return true;
}
bool NetSession::join(const std::string& hostNameOrIp, uint16_t port) {
shutdown();
if (!ensureEnetInitialized()) return false;
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] join(remote='%s', port=%u)", hostNameOrIp.c_str(), (unsigned)port);
m_host = enet_host_create(nullptr, 1, 2, 0, 0);
if (!m_host) {
setError("enet_host_create (client) failed");
m_state = ConnState::Error;
return false;
}
ENetAddress address{};
if (enet_address_set_host(&address, hostNameOrIp.c_str()) != 0) {
setError("enet_address_set_host failed");
m_state = ConnState::Error;
return false;
}
address.port = port;
m_peer = enet_host_connect(m_host, &address, 2, 0);
if (!m_peer) {
setError("enet_host_connect failed");
m_state = ConnState::Error;
return false;
}
m_mode = Mode::Client;
m_state = ConnState::Connecting;
return true;
}
void NetSession::shutdown() {
if (m_host || m_peer) {
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] shutdown(mode=%d state=%d)", (int)m_mode, (int)m_state);
}
m_remoteInputs.clear();
m_remoteHashes.clear();
m_receivedHandshake.reset();
m_inputsSent = 0;
m_inputsReceived = 0;
m_hashesSent = 0;
m_hashesReceived = 0;
m_handshakesSent = 0;
m_handshakesReceived = 0;
m_lastRecvInputTick = 0xFFFFFFFFu;
m_lastRecvHashTick = 0xFFFFFFFFu;
m_lastStatsLogMs = 0;
if (m_peer) {
enet_peer_disconnect(m_peer, 0);
m_peer = nullptr;
}
if (m_host) {
enet_host_destroy(m_host);
m_host = nullptr;
}
m_mode = Mode::None;
m_state = ConnState::Disconnected;
m_lastError.clear();
}
void NetSession::poll(uint32_t timeoutMs) {
if (!m_host) return;
ENetEvent event{};
while (enet_host_service(m_host, &event, static_cast<enet_uint32>(timeoutMs)) > 0) {
switch (event.type) {
case ENET_EVENT_TYPE_CONNECT:
m_peer = event.peer;
m_state = ConnState::Connected;
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] connected (mode=%d)", (int)m_mode);
break;
case ENET_EVENT_TYPE_RECEIVE:
if (event.packet) {
handlePacket(event.packet->data, event.packet->dataLength);
enet_packet_destroy(event.packet);
}
break;
case ENET_EVENT_TYPE_DISCONNECT:
m_peer = nullptr;
m_state = ConnState::Disconnected;
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "[NET] disconnected");
break;
case ENET_EVENT_TYPE_NONE:
default:
break;
}
// After first event, do non-blocking passes.
timeoutMs = 0;
}
// Rate-limited stats log (opt-in)
if (netLogVerboseEnabled()) {
const uint32_t nowMs = SDL_GetTicks();
if (m_lastStatsLogMs == 0) m_lastStatsLogMs = nowMs;
if (nowMs - m_lastStatsLogMs >= 1000u) {
m_lastStatsLogMs = nowMs;
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
"[NET] stats: sent(in=%u hash=%u hs=%u) recv(in=%u hash=%u hs=%u) lastRecv(inTick=%u hashTick=%u) state=%d",
m_inputsSent,
m_hashesSent,
m_handshakesSent,
m_inputsReceived,
m_hashesReceived,
m_handshakesReceived,
m_lastRecvInputTick,
m_lastRecvHashTick,
(int)m_state);
}
}
}
bool NetSession::sendBytesReliable(const void* data, size_t size) {
if (!m_peer) return false;
ENetPacket* packet = enet_packet_create(data, size, ENET_PACKET_FLAG_RELIABLE);
if (!packet) return false;
if (enet_peer_send(m_peer, kChannelReliable, packet) != 0) {
enet_packet_destroy(packet);
return false;
}
// Let the caller decide flush cadence; but for tiny control packets, flushing is cheap.
enet_host_flush(m_host);
return true;
}
bool NetSession::sendHandshake(const Handshake& hs) {
if (m_mode != Mode::Host) return false;
m_handshakesSent++;
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] sendHandshake(seed=%u startTick=%u startLevel=%u)", hs.rngSeed, hs.startTick, (unsigned)hs.startLevel);
std::vector<uint8_t> buf;
buf.reserve(1 + sizeof(uint32_t) * 2 + sizeof(uint8_t));
buf.push_back(static_cast<uint8_t>(MsgType::Handshake));
append(buf, hs.rngSeed);
append(buf, hs.startTick);
append(buf, hs.startLevel);
return sendBytesReliable(buf.data(), buf.size());
}
std::optional<NetSession::Handshake> NetSession::takeReceivedHandshake() {
auto out = m_receivedHandshake;
m_receivedHandshake.reset();
return out;
}
bool NetSession::sendLocalInput(uint32_t tick, uint8_t buttons) {
m_inputsSent++;
if (netLogVerboseEnabled() && (tick % 60u) == 0u) {
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] sendInput(tick=%u buttons=0x%02X)", tick, (unsigned)buttons);
}
std::vector<uint8_t> buf;
buf.reserve(1 + sizeof(uint32_t) + sizeof(uint8_t));
buf.push_back(static_cast<uint8_t>(MsgType::Input));
append(buf, tick);
append(buf, buttons);
return sendBytesReliable(buf.data(), buf.size());
}
std::optional<uint8_t> NetSession::getRemoteButtons(uint32_t tick) const {
auto it = m_remoteInputs.find(tick);
if (it == m_remoteInputs.end()) return std::nullopt;
return it->second;
}
bool NetSession::sendStateHash(uint32_t tick, uint64_t hash) {
m_hashesSent++;
if (netLogVerboseEnabled()) {
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] sendHash(tick=%u hash=%llu)", tick, (unsigned long long)hash);
}
std::vector<uint8_t> buf;
buf.reserve(1 + sizeof(uint32_t) + sizeof(uint64_t));
buf.push_back(static_cast<uint8_t>(MsgType::Hash));
append(buf, tick);
append(buf, hash);
return sendBytesReliable(buf.data(), buf.size());
}
std::optional<uint64_t> NetSession::takeRemoteHash(uint32_t tick) {
auto it = m_remoteHashes.find(tick);
if (it == m_remoteHashes.end()) return std::nullopt;
uint64_t v = it->second;
m_remoteHashes.erase(it);
return v;
}
void NetSession::handlePacket(const uint8_t* data, size_t size) {
if (!data || size < 1) return;
size_t off = 0;
uint8_t typeByte = 0;
if (!read(data, size, off, typeByte)) return;
MsgType t = static_cast<MsgType>(typeByte);
switch (t) {
case MsgType::Handshake: {
Handshake hs{};
if (!read(data, size, off, hs.rngSeed)) return;
if (!read(data, size, off, hs.startTick)) return;
if (!read(data, size, off, hs.startLevel)) return;
m_receivedHandshake = hs;
m_handshakesReceived++;
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] recvHandshake(seed=%u startTick=%u startLevel=%u)", hs.rngSeed, hs.startTick, (unsigned)hs.startLevel);
break;
}
case MsgType::Input: {
uint32_t tick = 0;
uint8_t buttons = 0;
if (!read(data, size, off, tick)) return;
if (!read(data, size, off, buttons)) return;
m_remoteInputs[tick] = buttons;
m_inputsReceived++;
m_lastRecvInputTick = tick;
if (netLogVerboseEnabled() && (tick % 60u) == 0u) {
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] recvInput(tick=%u buttons=0x%02X)", tick, (unsigned)buttons);
}
break;
}
case MsgType::Hash: {
uint32_t tick = 0;
uint64_t hash = 0;
if (!read(data, size, off, tick)) return;
if (!read(data, size, off, hash)) return;
m_remoteHashes[tick] = hash;
m_hashesReceived++;
m_lastRecvHashTick = tick;
if (netLogVerboseEnabled()) {
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] recvHash(tick=%u hash=%llu)", tick, (unsigned long long)hash);
}
break;
}
default:
break;
}
}

118
src/network/NetSession.h Normal file
View File

@ -0,0 +1,118 @@
#pragma once
#include <cstdint>
#include <optional>
#include <string>
#include <unordered_map>
#include <vector>
struct _ENetHost;
struct _ENetPeer;
// Lockstep networking session for COOPERATE (network) mode.
//
// Design goals:
// - Non-blocking polling (caller drives poll from the main loop)
// - Reliable, ordered delivery for inputs and control messages
// - Host provides seed + start tick (handshake)
// - Only inputs/state hashes are exchanged (no board sync)
class NetSession {
public:
enum class Mode {
None,
Host,
Client,
};
enum class ConnState {
Disconnected,
Connecting,
Connected,
Error,
};
struct Handshake {
uint32_t rngSeed = 0;
uint32_t startTick = 0;
uint8_t startLevel = 0;
};
struct InputFrame {
uint32_t tick = 0;
uint8_t buttons = 0;
};
NetSession();
~NetSession();
NetSession(const NetSession&) = delete;
NetSession& operator=(const NetSession&) = delete;
// If bindHost is empty or "0.0.0.0", binds to ENET_HOST_ANY.
bool host(const std::string& bindHost, uint16_t port);
bool join(const std::string& hostNameOrIp, uint16_t port);
void shutdown();
void poll(uint32_t timeoutMs = 0);
Mode mode() const { return m_mode; }
ConnState state() const { return m_state; }
bool isConnected() const { return m_state == ConnState::Connected; }
// Host-only: send handshake once the peer connects.
bool sendHandshake(const Handshake& hs);
// Client-only: becomes available once received from host.
std::optional<Handshake> takeReceivedHandshake();
// Input exchange --------------------------------------------------------
// Send local input for a given simulation tick.
bool sendLocalInput(uint32_t tick, uint8_t buttons);
// Returns the last received remote input for a tick (if any).
std::optional<uint8_t> getRemoteButtons(uint32_t tick) const;
// Hash exchange (for desync detection) ---------------------------------
bool sendStateHash(uint32_t tick, uint64_t hash);
std::optional<uint64_t> takeRemoteHash(uint32_t tick);
// Diagnostics
std::string lastError() const { return m_lastError; }
private:
enum class MsgType : uint8_t {
Handshake = 1,
Input = 2,
Hash = 3,
};
bool ensureEnetInitialized();
void setError(const std::string& msg);
bool sendBytesReliable(const void* data, size_t size);
void handlePacket(const uint8_t* data, size_t size);
Mode m_mode = Mode::None;
ConnState m_state = ConnState::Disconnected;
_ENetHost* m_host = nullptr;
_ENetPeer* m_peer = nullptr;
std::string m_lastError;
std::optional<Handshake> m_receivedHandshake;
std::unordered_map<uint32_t, uint8_t> m_remoteInputs;
std::unordered_map<uint32_t, uint64_t> m_remoteHashes;
// Debug logging (rate-limited)
uint32_t m_inputsSent = 0;
uint32_t m_inputsReceived = 0;
uint32_t m_hashesSent = 0;
uint32_t m_hashesReceived = 0;
uint32_t m_handshakesSent = 0;
uint32_t m_handshakesReceived = 0;
uint32_t m_lastRecvInputTick = 0xFFFFFFFFu;
uint32_t m_lastRecvHashTick = 0xFFFFFFFFu;
uint32_t m_lastStatsLogMs = 0;
};