// SPDX-FileCopyrightText: 2026 EmuCoreX contributors // SPDX-License-Identifier: GPL-3.0+ #include "LocalLinkAdapter.h" #include "DEV9.h" #include "common/Console.h" #include #include #include #include #include #ifdef _WIN32 #include #else #include #include #include #include #include #include #endif namespace { constexpr u32 LOCAL_LINK_MAGIC = 0x45434c58; // "ECLX" on the wire after htonl(). constexpr u8 LOCAL_LINK_VERSION = 1; constexpr u8 MESSAGE_HELLO = 1; constexpr u8 MESSAGE_HELLO_ACK = 2; constexpr u8 MESSAGE_DATA = 3; #pragma pack(push, 1) struct WireHeader { u32 magic; u8 version; u8 type; u16 header_size; u32 peer_id; u32 sequence; u32 frame_id; u16 fragment_index; u16 fragment_count; u16 payload_size; u16 reserved; u64 session_nonce; u64 auth_tag; }; #pragma pack(pop) static_assert(sizeof(WireHeader) == 44); u64 RotateLeft(u64 value, int bits) { return (value << bits) | (value >> (64 - bits)); } u64 HostToNetwork64(u64 value) { #if defined(__BYTE_ORDER__) && __BYTE_ORDER__ == __ORDER_BIG_ENDIAN__ return value; #else return (static_cast(htonl(static_cast(value))) << 32) | htonl(static_cast(value >> 32)); #endif } u64 NetworkToHost64(u64 value) { return HostToNetwork64(value); } u64 Load64Le(const u8* bytes) { u64 value = 0; for (int i = 7; i >= 0; --i) value = (value << 8) | bytes[i]; return value; } void SipRound(u64& v0, u64& v1, u64& v2, u64& v3) { v0 += v1; v1 = RotateLeft(v1, 13); v1 ^= v0; v0 = RotateLeft(v0, 32); v2 += v3; v3 = RotateLeft(v3, 16); v3 ^= v2; v0 += v3; v3 = RotateLeft(v3, 21); v3 ^= v0; v2 += v1; v1 = RotateLeft(v1, 17); v1 ^= v2; v2 = RotateLeft(v2, 32); } u64 SipHash24(const u8* data, std::size_t size, u64 key0, u64 key1) { u64 v0 = 0x736f6d6570736575ULL ^ key0; u64 v1 = 0x646f72616e646f6dULL ^ key1; u64 v2 = 0x6c7967656e657261ULL ^ key0; u64 v3 = 0x7465646279746573ULL ^ key1; const u8* cursor = data; const u8* end = data + (size & ~static_cast(7)); while (cursor != end) { const u64 word = Load64Le(cursor); v3 ^= word; SipRound(v0, v1, v2, v3); SipRound(v0, v1, v2, v3); v0 ^= word; cursor += 8; } u64 tail = static_cast(size) << 56; for (std::size_t i = 0; i < (size & 7); ++i) tail |= static_cast(cursor[i]) << (8 * i); v3 ^= tail; SipRound(v0, v1, v2, v3); SipRound(v0, v1, v2, v3); v0 ^= tail; v2 ^= 0xff; for (int i = 0; i < 4; ++i) SipRound(v0, v1, v2, v3); return v0 ^ v1 ^ v2 ^ v3; } } // namespace LocalLinkAdapter::LocalLinkAdapter() : NetAdapter() { if (!EmuConfig.DEV9.EthEnable || EmuConfig.DEV9.LocalLinkRoomCode.size() < 4 || EmuConfig.DEV9.LocalLinkRoomCode.size() > 12) { Console.Error("DEV9: Local Link requires Ethernet and a 4-12 character room code"); return; } m_host = EmuConfig.DEV9.LocalLinkHost; m_port = static_cast(std::clamp(EmuConfig.DEV9.LocalLinkPort, 1024, 65535)); m_peer_id = m_host ? 1u : std::clamp(EmuConfig.DEV9.LocalLinkPeerId, 2, 65533); if (m_host) { std::random_device random; const u64 nonce = (static_cast(random()) << 32) | random(); m_session_nonce.store(nonce != 0 ? nonce : 1, std::memory_order_relaxed); } m_auth_key0 = DeriveKey(EmuConfig.DEV9.LocalLinkRoomCode, 0x9e3779b97f4a7c15ULL); m_auth_key1 = DeriveKey(EmuConfig.DEV9.LocalLinkRoomCode, 0xd1b54a32d192ed03ULL); if (!OpenSocket() || !ConfigureEndpoint()) { close(); return; } PacketReader::MAC_Address mac = defaultMAC; mac.bytes[4] = static_cast((m_peer_id >> 8) & 0xff); mac.bytes[5] = static_cast(m_peer_id & 0xff); SetMACAddress(&mac); // Use the full peer id in a /16 so different identities never collapse to // the same DHCP lease. Skip 192.0.2.1, which belongs to the internal server. u32 host_part = m_peer_id; if (host_part >= 513) ++host_part; const PacketReader::IP::IP_Address ps2_ip{{{192, 0, static_cast((host_part >> 8) & 0xff), static_cast(host_part & 0xff)}}}; const PacketReader::IP::IP_Address subnet{{{255, 255, 0, 0}}}; const PacketReader::IP::IP_Address gateway = internalIP; InitInternalServer(nullptr, true, ps2_ip, subnet, gateway); m_initialized.store(true, std::memory_order_release); SendHelloIfNeeded(true); Console.WriteLn("DEV9: Local Link %s ready on port %u as peer %u", m_host ? "host" : "client", m_port, m_peer_id); } LocalLinkAdapter::~LocalLinkAdapter() { close(); } bool LocalLinkAdapter::blocks() { return false; } bool LocalLinkAdapter::isInitialised() { return m_initialized.load(std::memory_order_acquire); } bool LocalLinkAdapter::OpenSocket() { #ifdef _WIN32 WSADATA data{}; if (WSAStartup(MAKEWORD(2, 2), &data) != 0) return false; m_wsa_started = true; m_socket = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); if (m_socket == INVALID_SOCKET) return false; u_long non_blocking = 1; if (ioctlsocket(m_socket, FIONBIO, &non_blocking) != 0) return false; #else m_socket = socket(AF_INET, SOCK_DGRAM, 0); if (m_socket < 0) return false; const int flags = fcntl(m_socket, F_GETFL, 0); if (flags < 0 || fcntl(m_socket, F_SETFL, flags | O_NONBLOCK) < 0) return false; #endif const int reuse = 1; setsockopt(m_socket, SOL_SOCKET, SO_REUSEADDR, reinterpret_cast(&reuse), sizeof(reuse)); return true; } bool LocalLinkAdapter::ConfigureEndpoint() { sockaddr_in local{}; local.sin_family = AF_INET; local.sin_addr.s_addr = htonl(INADDR_ANY); local.sin_port = htons(m_host ? m_port : 0); if (bind(m_socket, reinterpret_cast(&local), sizeof(local)) != 0) { Console.Error("DEV9: Local Link failed to bind UDP port %u", m_host ? m_port : 0); return false; } if (m_host) return true; m_host_endpoint.sin_family = AF_INET; m_host_endpoint.sin_port = htons(m_port); const std::string& host = EmuConfig.DEV9.LocalLinkAddress; if (host.empty()) { Console.Error("DEV9: Local Link join mode needs the host device's address"); return false; } // Numeric IPv4 FIRST, and deliberately so: a LAN address or a VPN address (Tailscale's // 100.x.y.z, ZeroTier, WireGuard) is the overwhelmingly common case, and this path must never // touch DNS. ConfigureEndpoint runs from the adapter's constructor via GetNetAdapter() while // the VM is booting on the CPU thread, so a blocking resolve here stalls game start. if (inet_pton(AF_INET, host.c_str(), &m_host_endpoint.sin_addr) == 1) return true; // Not a literal, so treat it as a hostname — a dynamic-DNS name for a port-forwarded host, or // a VPN's own DNS name. One blocking lookup, once, at boot. There is no portable timeout for // getaddrinfo (DNS_Server.cpp sidesteps that by resolving on its own thread), so a name that // does not resolve costs the platform's DNS timeout before we give up — at which point DEV9 // fails safe: GetNetAdapter deletes the adapter and InitNet clears EthEnable. addrinfo hints{}; hints.ai_family = AF_INET; hints.ai_socktype = SOCK_DGRAM; hints.ai_protocol = IPPROTO_UDP; #ifdef AI_ADDRCONFIG hints.ai_flags = AI_ADDRCONFIG; #endif addrinfo* results = nullptr; if (getaddrinfo(host.c_str(), nullptr, &hints, &results) != 0 || results == nullptr) { if (results != nullptr) freeaddrinfo(results); Console.Error("DEV9: Local Link could not resolve host '%s' (use the numeric IPv4 address " "shown on the host device if this keeps failing)", host.c_str()); return false; } m_host_endpoint.sin_addr = reinterpret_cast(results->ai_addr)->sin_addr; freeaddrinfo(results); char resolved[INET_ADDRSTRLEN] = {}; inet_ntop(AF_INET, &m_host_endpoint.sin_addr, resolved, sizeof(resolved)); Console.WriteLn("DEV9: Local Link resolved host '%s' to %s", host.c_str(), resolved); return true; } bool LocalLinkAdapter::recv(NetPacket* pkt) { if (NetAdapter::recv(pkt)) return true; if (!m_initialized.load(std::memory_order_acquire) || m_closed.load(std::memory_order_acquire)) return false; SendHelloIfNeeded(); PurgeExpiredState(); for (int i = 0; i < 16; ++i) { bool had_datagram = false; if (ReceiveDatagram(pkt, &had_datagram)) return true; if (!had_datagram) break; } return false; } bool LocalLinkAdapter::send(NetPacket* pkt) { if (NetAdapter::send(pkt)) return true; if (!m_initialized.load(std::memory_order_acquire) || m_closed.load(std::memory_order_acquire) || pkt == nullptr || pkt->size <= 0 || pkt->size > 1514) return false; InspectSend(pkt); if (!m_host && m_session_nonce.load(std::memory_order_acquire) == 0) return true; if (!m_host) return SendFrameFragments(*pkt, m_host_endpoint); bool sent = false; std::lock_guard lock(m_peer_mutex); for (const Peer& peer : m_peers) sent = SendFrameFragments(*pkt, peer.endpoint) || sent; return sent || m_peers.empty(); } void LocalLinkAdapter::reloadSettings() { // Local Link endpoint, identity and room authentication changes require a // complete adapter restart. ReconfigureLiveNet() handles that comparison. } void LocalLinkAdapter::close() { if (m_closed.exchange(true, std::memory_order_acq_rel)) return; m_initialized.store(false, std::memory_order_release); std::lock_guard socket_lock(m_socket_mutex); #ifdef _WIN32 if (m_socket != INVALID_SOCKET) { closesocket(m_socket); m_socket = INVALID_SOCKET; } if (m_wsa_started) { WSACleanup(); m_wsa_started = false; } #else if (m_socket >= 0) { ::close(m_socket); m_socket = -1; } #endif } void LocalLinkAdapter::SendHelloIfNeeded(bool force) { if (m_host || !m_initialized.load(std::memory_order_acquire)) return; const auto now = std::chrono::steady_clock::now(); if (!force && now - m_last_hello < std::chrono::seconds(1)) return; m_last_hello = now; SendControl(MESSAGE_HELLO, m_host_endpoint); } void LocalLinkAdapter::SendControl(u8 type, const sockaddr_in& endpoint) { SendDatagram(type, m_peer_id, 0, 0, 1, nullptr, 0, endpoint); } bool LocalLinkAdapter::SendFrameFragments(const NetPacket& pkt, const sockaddr_in& endpoint) { const u32 frame_id = m_frame_id.fetch_add(1, std::memory_order_relaxed); const u16 count = static_cast((pkt.size + MAX_FRAGMENT_PAYLOAD - 1) / MAX_FRAGMENT_PAYLOAD); bool sent = true; for (u16 index = 0; index < count; ++index) { const std::size_t offset = index * MAX_FRAGMENT_PAYLOAD; const u16 size = static_cast(std::min(MAX_FRAGMENT_PAYLOAD, pkt.size - offset)); sent = SendDatagram(MESSAGE_DATA, m_peer_id, frame_id, index, count, pkt.buffer + offset, size, endpoint) && sent; } return sent; } bool LocalLinkAdapter::SendDatagram(u8 type, u32 source_peer, u32 frame_id, u16 fragment_index, u16 fragment_count, const void* payload, u16 payload_size, const sockaddr_in& endpoint) { std::array datagram{}; WireHeader header{}; header.magic = htonl(LOCAL_LINK_MAGIC); header.version = LOCAL_LINK_VERSION; header.type = type; header.header_size = htons(sizeof(WireHeader)); header.peer_id = htonl(source_peer); header.sequence = htonl(m_send_sequence.fetch_add(1, std::memory_order_relaxed)); header.frame_id = htonl(frame_id); header.fragment_index = htons(fragment_index); header.fragment_count = htons(fragment_count); header.payload_size = htons(payload_size); header.session_nonce = HostToNetwork64(m_session_nonce.load(std::memory_order_acquire)); header.auth_tag = 0; std::memcpy(datagram.data(), &header, sizeof(header)); if (payload_size != 0) std::memcpy(datagram.data() + sizeof(header), payload, payload_size); header.auth_tag = HostToNetwork64(Authenticate(datagram.data(), sizeof(header), datagram.data() + sizeof(header), payload_size, m_auth_key0, m_auth_key1)); std::memcpy(datagram.data(), &header, sizeof(header)); std::lock_guard socket_lock(m_socket_mutex); if (m_closed.load(std::memory_order_acquire)) return false; const int result = sendto(m_socket, reinterpret_cast(datagram.data()), static_cast(sizeof(header) + payload_size), 0, reinterpret_cast(&endpoint), sizeof(endpoint)); return result == static_cast(sizeof(header) + payload_size); } bool LocalLinkAdapter::ReceiveDatagram(NetPacket* pkt, bool* had_datagram) { *had_datagram = false; std::array datagram{}; sockaddr_in source{}; #ifdef _WIN32 int source_size = sizeof(source); #else socklen_t source_size = sizeof(source); #endif int size; { std::lock_guard socket_lock(m_socket_mutex); if (m_closed.load(std::memory_order_acquire)) return false; size = recvfrom(m_socket, reinterpret_cast(datagram.data()), static_cast(datagram.size()), 0, reinterpret_cast(&source), &source_size); } if (size >= 0) *had_datagram = true; if (size < static_cast(sizeof(WireHeader))) return false; WireHeader header{}; std::memcpy(&header, datagram.data(), sizeof(header)); const u64 received_tag = NetworkToHost64(header.auth_tag); header.auth_tag = 0; std::memcpy(datagram.data(), &header, sizeof(header)); const u16 header_size = ntohs(header.header_size); const u16 payload_size = ntohs(header.payload_size); if (ntohl(header.magic) != LOCAL_LINK_MAGIC || header.version != LOCAL_LINK_VERSION || header_size != sizeof(WireHeader) || payload_size > MAX_FRAGMENT_PAYLOAD || size != static_cast(sizeof(WireHeader) + payload_size) || received_tag != Authenticate(datagram.data(), sizeof(WireHeader), datagram.data() + sizeof(WireHeader), payload_size, m_auth_key0, m_auth_key1)) { return false; } const u32 source_peer = ntohl(header.peer_id); const u32 sequence = ntohl(header.sequence); const u64 session_nonce = NetworkToHost64(header.session_nonce); if (source_peer == 0 || source_peer == m_peer_id) return false; header.auth_tag = HostToNetwork64(received_tag); std::memcpy(datagram.data(), &header, sizeof(header)); if (header.type == MESSAGE_HELLO) { if (m_host && RegisterPeer(source_peer, sequence, source)) SendControl(MESSAGE_HELLO_ACK, source); return false; } if (header.type == MESSAGE_HELLO_ACK) { if (!m_host && SameEndpoint(source, m_host_endpoint) && session_nonce != 0) { const u64 previous = m_session_nonce.exchange(session_nonce, std::memory_order_acq_rel); if (previous != 0 && previous != session_nonce) { m_remote_peers.clear(); m_reassembly.clear(); } } return false; } if (header.type != MESSAGE_DATA) return false; if (session_nonce == 0 || session_nonce != m_session_nonce.load(std::memory_order_acquire)) return false; if (m_host) { std::lock_guard lock(m_peer_mutex); Peer* peer = FindPeer(source_peer, source); if (peer == nullptr || !AcceptSequence(*peer, sequence)) return false; peer->last_seen = std::chrono::steady_clock::now(); RelayDatagram(datagram.data(), size, source); } else if (!SameEndpoint(source, m_host_endpoint)) { return false; } else { auto remote_it = m_remote_peers.find(source_peer); if (remote_it == m_remote_peers.end()) { if (m_remote_peers.size() >= MAX_PEERS + 1) return false; remote_it = m_remote_peers.emplace(source_peer, Peer{}).first; } Peer& remote = remote_it->second; if (remote.id == 0) { remote.id = source_peer; remote.endpoint = source; } if (!AcceptSequence(remote, sequence)) return false; remote.last_seen = std::chrono::steady_clock::now(); } const u16 fragment_index = ntohs(header.fragment_index); const u16 fragment_count = ntohs(header.fragment_count); if (fragment_count == 0 || fragment_count > 2 || fragment_index >= fragment_count) return false; const ReassemblyKey key{source_peer, ntohl(header.frame_id)}; auto frame_it = m_reassembly.find(key); if (frame_it == m_reassembly.end()) { if (m_reassembly.size() >= 64) return false; frame_it = m_reassembly.emplace(key, Reassembly{}).first; } Reassembly& frame = frame_it->second; if (frame.fragment_count == 0) { frame.fragment_count = fragment_count; frame.created = std::chrono::steady_clock::now(); } if (frame.fragment_count != fragment_count) { m_reassembly.erase(key); return false; } const std::size_t offset = fragment_index * MAX_FRAGMENT_PAYLOAD; if (offset + payload_size > frame.data.size()) { m_reassembly.erase(key); return false; } std::memcpy(frame.data.data() + offset, datagram.data() + sizeof(WireHeader), payload_size); frame.sizes[fragment_index] = payload_size; frame.received[fragment_index] = true; for (u16 i = 0; i < fragment_count; ++i) { if (!frame.received[i]) return false; } const int frame_size = (fragment_count - 1) * MAX_FRAGMENT_PAYLOAD + frame.sizes[fragment_count - 1]; std::memcpy(pkt->buffer, frame.data.data(), frame_size); m_reassembly.erase(key); if (!VerifyLocalLinkPacket(pkt, frame_size)) return false; InspectRecv(pkt); return true; } void LocalLinkAdapter::RelayDatagram(const void* data, std::size_t size, const sockaddr_in& source) { std::lock_guard socket_lock(m_socket_mutex); if (m_closed.load(std::memory_order_acquire)) return; for (const Peer& peer : m_peers) { if (!SameEndpoint(peer.endpoint, source)) sendto(m_socket, reinterpret_cast(data), static_cast(size), 0, reinterpret_cast(&peer.endpoint), sizeof(peer.endpoint)); } } LocalLinkAdapter::Peer* LocalLinkAdapter::FindPeer(u32 id, const sockaddr_in& endpoint) { const auto it = std::find_if(m_peers.begin(), m_peers.end(), [&](const Peer& peer) { return peer.id == id && SameEndpoint(peer.endpoint, endpoint); }); return it == m_peers.end() ? nullptr : &*it; } bool LocalLinkAdapter::RegisterPeer(u32 id, u32 hello_sequence, const sockaddr_in& endpoint) { if (id <= 1) return false; std::lock_guard lock(m_peer_mutex); if (Peer* peer = FindPeer(id, endpoint)) { if (hello_sequence <= peer->highest_sequence) { peer->highest_sequence = 0; peer->replay_window = 0; } peer->last_seen = std::chrono::steady_clock::now(); return true; } const auto same_id = std::find_if(m_peers.begin(), m_peers.end(), [&](const Peer& peer) { return peer.id == id; }); if (same_id != m_peers.end()) { if (same_id->endpoint.sin_addr.s_addr != endpoint.sin_addr.s_addr) { Console.Error("DEV9: Local Link rejected duplicate peer id %u", id); return false; } same_id->endpoint = endpoint; same_id->highest_sequence = 0; same_id->replay_window = 0; same_id->last_seen = std::chrono::steady_clock::now(); return true; } if (m_peers.size() >= MAX_PEERS) return false; m_peers.push_back(Peer{endpoint, id, std::chrono::steady_clock::now(), 0, 0}); Console.WriteLn("DEV9: Local Link peer %u joined", id); return true; } bool LocalLinkAdapter::VerifyLocalLinkPacket(NetPacket* pkt, int read_size) { const PacketReader::MAC_Address& destination = *reinterpret_cast(&pkt->buffer[0]); const PacketReader::MAC_Address& source = *reinterpret_cast(&pkt->buffer[6]); if (destination != ps2MAC && destination != broadcastMAC && (destination.bytes[0] & 0x01) == 0) return false; if (source == ps2MAC) return false; pkt->size = read_size; return true; } bool LocalLinkAdapter::AcceptSequence(Peer& peer, u32 sequence) { if (sequence > peer.highest_sequence) { const u32 shift = sequence - peer.highest_sequence; peer.replay_window = shift >= 64 ? 1 : ((peer.replay_window << shift) | 1); peer.highest_sequence = sequence; return true; } const u32 delta = peer.highest_sequence - sequence; if (delta >= 64 || ((peer.replay_window >> delta) & 1) != 0) return false; peer.replay_window |= 1ULL << delta; return true; } void LocalLinkAdapter::PurgeExpiredState() { const auto now = std::chrono::steady_clock::now(); for (auto it = m_reassembly.begin(); it != m_reassembly.end();) { if (now - it->second.created > std::chrono::seconds(2)) it = m_reassembly.erase(it); else ++it; } if (!m_host) return; std::lock_guard lock(m_peer_mutex); m_peers.erase(std::remove_if(m_peers.begin(), m_peers.end(), [&](const Peer& peer) { return now - peer.last_seen > std::chrono::seconds(10); }), m_peers.end()); } bool LocalLinkAdapter::SameEndpoint(const sockaddr_in& lhs, const sockaddr_in& rhs) { return lhs.sin_family == rhs.sin_family && lhs.sin_port == rhs.sin_port && lhs.sin_addr.s_addr == rhs.sin_addr.s_addr; } u64 LocalLinkAdapter::DeriveKey(const std::string& room_code, u64 salt) { u64 hash = 1469598103934665603ULL ^ salt; for (const unsigned char value : room_code) { hash ^= value; hash *= 1099511628211ULL; } return hash; } u64 LocalLinkAdapter::Authenticate(const void* header, std::size_t header_size, const void* payload, std::size_t payload_size, u64 key0, u64 key1) { std::array bytes{}; std::memcpy(bytes.data(), header, header_size); if (payload_size != 0) std::memcpy(bytes.data() + header_size, payload, payload_size); return SipHash24(bytes.data(), header_size + payload_size, key0, key1); }