diff --git a/.github/docker/ci/Dockerfile b/.github/docker/ci/Dockerfile index 5d94a9b3c..3ae7a9ffd 100644 --- a/.github/docker/ci/Dockerfile +++ b/.github/docker/ci/Dockerfile @@ -10,6 +10,7 @@ RUN apt-get update \ ca-certificates \ git \ libgles2-mesa-dev \ + libgl1-mesa-dri \ libjpeg-dev \ libsdl2-dev \ pkg-config \ diff --git a/.github/workflows/c-cpp.yml b/.github/workflows/c-cpp.yml index 19f3a9c2c..0450f9ef8 100644 --- a/.github/workflows/c-cpp.yml +++ b/.github/workflows/c-cpp.yml @@ -8,6 +8,62 @@ on: branches: ["main"] jobs: + eos: + name: EOS regressions and live multiplayer checks + # Fork PRs cannot read the licensed SDK/player credentials. Run their normal + # SDK-free checks; never execute fork code through pull_request_target. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 25 + permissions: + contents: read + packages: read + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Fetch the pinned private EOS SDK archive + uses: actions/checkout@v4 + with: + repository: ${{ vars.EOS_SDK_REPOSITORY }} + ref: ${{ vars.EOS_SDK_REF }} + ssh-key: ${{ secrets.EOS_SDK_DEPLOY_KEY }} + persist-credentials: false + path: data/eos-archive + + - name: Verify SDK and prepare restricted player config + env: + OPENREALM_EOS_PRODUCT_ID: ${{ secrets.OPENREALM_EOS_PRODUCT_ID }} + OPENREALM_EOS_SANDBOX_ID: ${{ secrets.OPENREALM_EOS_SANDBOX_ID }} + OPENREALM_EOS_DEPLOYMENT_ID: ${{ secrets.OPENREALM_EOS_DEPLOYMENT_ID }} + OPENREALM_EOS_CLIENT_ID: ${{ secrets.OPENREALM_EOS_CLIENT_ID }} + OPENREALM_EOS_CLIENT_SECRET: ${{ secrets.OPENREALM_EOS_CLIENT_SECRET }} + run: python3 dist-scripts/eos/prepare_release.py --platform Linux --archive data/eos-archive/EOS-SDK-1.19.2.1-CL58105819-runtime.zip + + - name: Pull Linux test environment + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_ACTOR: ${{ github.actor }} + run: | + printf '%s' "$GH_TOKEN" | docker login ghcr.io --username "$GH_ACTOR" --password-stdin + docker pull ghcr.io/corepunch/open-realm-ci:ubuntu-24.04 + docker logout ghcr.io + # Include PR dependency changes and software GL for the actual client. + # Dependency installation stays in the shared image Dockerfile. + docker build --tag openrealm-eos-ci .github/docker/ci + + - name: Run full EOS-enabled suite + run: >- + docker run --rm --mount "type=bind,source=$PWD,target=/workspace" + --workdir /workspace openrealm-eos-ci + make EOS=1 TEST_JOBS=4 test + + - name: Check live login, gameplay, relay, graceful leave and host crash + env: + EOS_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }} + run: python3 dist-scripts/eos/run_acceptance.py --image openrealm-eos-ci --run-id "$EOS_RUN_ID" + notifications: name: Discord notification fixtures runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d7177200f..a31484cee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -110,6 +110,21 @@ jobs: shell: msys2 {0} run: make BUILD=release EOS=1 EOS_CONFIG_FILE=data/eos/eos.cfg build + - name: Verify EOS session regressions (Unix) + if: runner.os != 'Windows' + run: >- + make BUILD=release ARCH=x86_64 EOS=1 + BIN_DIR=build/eos-tests/bin LIB_DIR=build/eos-tests/lib + SHARE_INSTALL=build/eos-tests/share test-eos-service + + - name: Verify EOS session regressions (Windows) + if: runner.os == 'Windows' + shell: msys2 {0} + run: >- + make BUILD=release EOS=1 + BIN_DIR=build/eos-tests/bin LIB_DIR=build/eos-tests/lib + SHARE_INSTALL=build/eos-tests/share test-eos-service + - name: Create release archive (Unix) if: runner.os != 'Windows' run: | diff --git a/client/cl_parse.c b/client/cl_parse.c index bcdb5e670..7982894a5 100644 --- a/client/cl_parse.c +++ b/client/cl_parse.c @@ -867,6 +867,9 @@ static void CL_ParseConsolePrint(sizeBuf_t *msg) { if (text[0]) CON_printf("%s", text); } +#ifdef BZ_TESTS +void (*cl_test_lobby_chat)(cstring_t text, bool own); +#endif static void CL_ParseLobbyChat(sizeBuf_t *msg) { char text[512] = { 0 }; char command[sizeof(text) + 32]; @@ -880,6 +883,9 @@ static void CL_ParseLobbyChat(sizeBuf_t *msg) { snprintf(command, sizeof(command), "menu_game_setup_chat %u %s", own ? 1u : 0u, text); Cbuf_AddText(command); Cbuf_AddText("\n"); +#ifdef BZ_TESTS + if (cl_test_lobby_chat) cl_test_lobby_chat(text, own); +#endif } /* Apply an authoritative server selection to the client cache and refresh the active unit UI. diff --git a/client/client.h b/client/client.h index a46308052..fe340785f 100644 --- a/client/client.h +++ b/client/client.h @@ -350,6 +350,9 @@ extern struct client_state cl; extern struct client_static cls; extern refExport_t re; extern menuExport_t menu; +#ifdef BZ_TESTS +extern void (*cl_test_lobby_chat)(cstring_t text, bool own); +#endif extern mouseEvent_t mouse; extern bool scr_initialized; diff --git a/common/common.c b/common/common.c index 085839776..bb5743bc1 100644 --- a/common/common.c +++ b/common/common.c @@ -1,4 +1,5 @@ #include "common.h" +#include "online.h" #include "mpq.h" #include "test.h" @@ -1826,6 +1827,9 @@ void Com_Init(int argc, cstring_t *argv) { Cmd_AddCommand("path", Com_Path_f); Cmd_AddCommand("dir", Com_Dir_f); Cmd_AddCommand("test", Com_Test_f); +#if defined(BZ_EOS) && defined(BZ_TESTS) + Cmd_AddCommand("online_acceptance", Online_Acceptance_f); +#endif Cvar_ApplyConfigCommandLine(argc, argv); Cbuf_AddEarlyCommands(false); Cbuf_Execute(); diff --git a/common/cvar.c b/common/cvar.c index eee63f275..7a7e88030 100644 --- a/common/cvar.c +++ b/common/cvar.c @@ -537,6 +537,7 @@ void Cvar_Init(void) { Cvar_GetD("map", "", 0, "map file to load at startup (e.g. Maps/HumanCampaign1.w3m)"); #endif Cvar_GetD("connect", "", 0, "server address to connect to at startup"); + Cvar_GetD("online_force_relay", "0", 0, "force EOS relay transport; set before entering Internet games for the first time"); Cvar_GetD("cl_debug_entities","0", 0, "log client-side entity sync events"); Cvar_GetD("sv_debug_entities","0", 0, "log server-side entity sync events"); Cvar_GetD("r_debug_entities", "0", 0, "log renderer entity lifecycle events"); diff --git a/common/main.c b/common/main.c index f69ee34c7..7f31c85b8 100644 --- a/common/main.c +++ b/common/main.c @@ -471,15 +471,19 @@ int main(int argc, string_t argv[]) { * without a map. The game module is a link dependency, so its TEST() * constructors have already registered by the time we get here. */ bool run_tests = false; + bool run_online_check = false; bool has_load = false; for (int i = 1; i < COM_Argc(); i++) { +#if defined(BZ_EOS) && defined(BZ_TESTS) + if (!strcmp(COM_Argv(i), "+online_acceptance")) run_online_check = true; +#endif if (!strcmp(COM_Argv(i), "+test")) { run_tests = true; break; } if (!strcmp(COM_Argv(i), "+load")) has_load = true; } if (dedicated) { // Dedicated server mode: no client stack, no SDL window. - if (!has_map && !run_tests && !has_load) { + if (!has_map && !run_tests && !run_online_check && !has_load) { fprintf(stderr, "Dedicated server requires +map \n"); return 1; } diff --git a/common/online.h b/common/online.h index 7c29c7ac2..18ae45975 100644 --- a/common/online.h +++ b/common/online.h @@ -8,6 +8,9 @@ typedef struct { } onlineGame_t; #ifdef BZ_EOS +#ifdef BZ_TESTS +void Online_Acceptance_f(void); +#endif bool Online_Begin(void); void Online_Frame(uint32_t msec); void Online_Shutdown(void); @@ -16,6 +19,7 @@ bool Online_Ready(void); bool Online_IsHost(void); bool Online_HostReady(void); bool Online_InLobby(void); +bool Online_ConnectionLost(NETSOURCE source, netadr_t const *address); void Online_Refresh(void); uint32_t Online_NumGames(void); bool Online_Game(uint32_t index, onlineGame_t *out); @@ -35,6 +39,7 @@ static inline bool Online_Ready(void) { return false; } static inline bool Online_IsHost(void) { return false; } static inline bool Online_HostReady(void) { return false; } static inline bool Online_InLobby(void) { return false; } +static inline bool Online_ConnectionLost(NETSOURCE source, netadr_t const *address) { (void)source; (void)address; return false; } static inline void Online_Refresh(void) {} static inline uint32_t Online_NumGames(void) { return 0; } static inline bool Online_Game(uint32_t index, onlineGame_t *out) { (void)index; (void)out; return false; } diff --git a/common/online_eos.c b/common/online_eos.c index 20e8cc804..a3b0a7014 100644 --- a/common/online_eos.c +++ b/common/online_eos.c @@ -23,6 +23,8 @@ typedef struct { typedef struct { EOS_ProductUserId user; + EOS_ENetworkConnectionType network_type; + bool closed; onlineAssembly_t assembly[2]; } onlinePeer_t; @@ -33,7 +35,8 @@ static struct { EOS_HP2P p2p; EOS_HLobbySearch search; EOS_ProductUserId user, owner; - EOS_NotificationId request_notify, member_notify, update_notify, expiration_notify, login_notify; + EOS_NotificationId request_notify, established_notify, closed_notify; + EOS_NotificationId member_notify, update_notify, expiration_notify, login_notify; EOS_P2P_SocketId socket; onlineResult_t games[ONLINE_MAX_GAMES]; uint32_t num_games, epoch, message_id; @@ -43,7 +46,7 @@ static struct { onlineGame_t hosted; uint32_t map_crc; bool initialized, authenticating, ready, enabled, hosting, search_pending, host_dirty; - bool operation_pending, updating, admission_closed, connecting, published; + bool operation_pending, updating, admission_closed, connecting, published, departure_failed; uint32_t leave_pending, receive_budget[2]; uint32_t update_retry; } online; @@ -133,6 +136,37 @@ static onlinePeer_t *Online_Peer(EOS_ProductUserId user, NETSOURCE source) { return NULL; } +/* A permanent host loss must clear the guest room even before engine sign-on. */ +static void EOS_CALL Online_ConnectionClosed(EOS_P2P_OnRemoteConnectionClosedInfo const *info) { + if (info->LocalUserId != online.user || !info->SocketId || + strcmp(info->SocketId->SocketName, online.socket.SocketName) || + info->Reason == EOS_CCR_ClosedByLocalUser) return; + onlinePeer_t *peer = Online_Peer(info->RemoteUserId, online.hosting ? NS_SERVER : NS_CLIENT); + if (!peer) return; + fprintf(stderr, "EOS peer connection closed (reason %d)\n", info->Reason); + for (int i = 0; i < 2; i++) Online_ClearAssembly(&peer->assembly[i]); + peer->network_type = EOS_NCT_NoConnection; + peer->closed = true; + if (!online.hosting) { + Online_Leave(); + snprintf(online.status, sizeof(online.status), "Connection to the Internet host has closed."); + } +} + +/* Keep the actual path observable so forcing relays can be verified, not assumed. */ +static void EOS_CALL Online_ConnectionEstablished(EOS_P2P_OnPeerConnectionEstablishedInfo const *info) { + if (info->LocalUserId != online.user || !info->SocketId || + strcmp(info->SocketId->SocketName, online.socket.SocketName)) return; + onlinePeer_t *peer = Online_Peer(info->RemoteUserId, online.hosting ? NS_SERVER : NS_CLIENT); + if (!peer) return; + peer->network_type = info->NetworkType; + peer->closed = false; + fprintf(stderr, "EOS peer connection %s: %s\n", + info->ConnectionType == EOS_CET_Reconnection ? "reestablished" : "established", + info->NetworkType == EOS_NCT_RelayedConnection ? "relay" : + info->NetworkType == EOS_NCT_DirectConnection ? "direct" : "unknown network type"); +} + static void EOS_CALL Online_Request(EOS_P2P_OnIncomingConnectionRequestInfo const *info) { if (strcmp(info->SocketId->SocketName, online.socket.SocketName)) return; Online_UpdateMembers(); @@ -146,17 +180,25 @@ static void EOS_CALL Online_Request(EOS_P2P_OnIncomingConnectionRequestInfo cons Online_Result("accept peer", EOS_P2P_AcceptConnection(online.p2p, &options)); } -static void EOS_CALL Online_Left(EOS_Lobby_LeaveLobbyCallbackInfo const *info) { +/* Successful late departures must not erase an earlier cleanup failure. */ +static void Online_Departed(cstring_t operation, EOS_EResult result) { if (online.leave_pending) online.leave_pending--; - Online_Result("leave lobby", info->ResultCode); + if (result == EOS_NotFound) { + /* Closure notifications can race our leave request. Absence completes cleanup. */ + fprintf(stderr, "EOS %s: lobby already absent\n", operation); + } else if (!Online_Result(operation, result)) online.departure_failed = true; +} + +static void EOS_CALL Online_Left(EOS_Lobby_LeaveLobbyCallbackInfo const *info) { + Online_Departed("leave lobby", info->ResultCode); } static void EOS_CALL Online_Destroyed(EOS_Lobby_DestroyLobbyCallbackInfo const *info) { - if (online.leave_pending) online.leave_pending--; - Online_Result("destroy lobby", info->ResultCode); + Online_Departed("destroy lobby", info->ResultCode); } static void Online_Depart(cstring_t lobby, bool host, void *context) { + if (!online.leave_pending) online.departure_failed = false; online.leave_pending++; if (host) { EOS_Lobby_DestroyLobbyOptions options = { @@ -215,6 +257,20 @@ static void EOS_CALL Online_LoginStatus(EOS_Connect_LoginStatusChangedCallbackIn static void Online_LoggedIn(EOS_ProductUserId user) { online.authenticating = false; online.ready = true; online.user = user; + if (online.established_notify == EOS_INVALID_NOTIFICATIONID) { + EOS_P2P_AddNotifyPeerConnectionEstablishedOptions established = { + .ApiVersion = EOS_P2P_ADDNOTIFYPEERCONNECTIONESTABLISHED_API_LATEST, + .LocalUserId = user, .SocketId = &online.socket + }; + online.established_notify = EOS_P2P_AddNotifyPeerConnectionEstablished(online.p2p, &established, NULL, Online_ConnectionEstablished); + } + if (online.closed_notify == EOS_INVALID_NOTIFICATIONID) { + EOS_P2P_AddNotifyPeerConnectionClosedOptions closed = { + .ApiVersion = EOS_P2P_ADDNOTIFYPEERCONNECTIONCLOSED_API_LATEST, + .LocalUserId = user, .SocketId = &online.socket + }; + online.closed_notify = EOS_P2P_AddNotifyPeerConnectionClosed(online.p2p, &closed, NULL, Online_ConnectionClosed); + } if (online.request_notify == EOS_INVALID_NOTIFICATIONID) { EOS_P2P_AddNotifyPeerConnectionRequestOptions request = { .ApiVersion = EOS_P2P_ADDNOTIFYPEERCONNECTIONREQUEST_API_LATEST, @@ -222,7 +278,7 @@ static void Online_LoggedIn(EOS_ProductUserId user) { }; online.request_notify = EOS_P2P_AddNotifyPeerConnectionRequest(online.p2p, &request, NULL, Online_Request); } - if (online.request_notify == EOS_INVALID_NOTIFICATIONID) { + if (!online.request_notify || !online.established_notify || !online.closed_notify) { online.ready = false; snprintf(online.status, sizeof(online.status), "EOS connection notification registration failed."); fprintf(stderr, "%s\n", online.status); return; @@ -335,6 +391,15 @@ bool Online_Begin(void) { online.connect = EOS_Platform_GetConnectInterface(online.platform); online.lobbies = EOS_Platform_GetLobbyInterface(online.platform); online.p2p = EOS_Platform_GetP2PInterface(online.platform); + EOS_P2P_SetRelayControlOptions relay = { + .ApiVersion = EOS_P2P_SETRELAYCONTROL_API_LATEST, + .RelayControl = Cvar_Integer("online_force_relay", 0) ? EOS_RC_ForceRelays : EOS_RC_AllowRelays + }; + if (!Online_Result("configure relay policy", EOS_P2P_SetRelayControl(online.p2p, &relay))) { + Online_Shutdown(); + snprintf(online.status, sizeof(online.status), "Internet relay policy could not be configured."); + return false; + } online.socket.ApiVersion = EOS_P2P_SOCKETID_API_LATEST; snprintf(online.socket.SocketName, sizeof(online.socket.SocketName), "OpenRealm"); EOS_P2P_SetPacketQueueSizeOptions queues = { @@ -582,7 +647,8 @@ void Online_CloseAdmission(void) { void Online_Send(NETSOURCE source, int length, void const *data, netadr_t const *to) { EOS_ProductUserId user = EOS_ProductUserId_FromString(to->peer); - if (!online.ready || !Online_Peer(user, source) || length <= 0 || length > MAX_MSGLEN) { + onlinePeer_t *peer = Online_Peer(user, source); + if (!online.ready || !peer || peer->closed || length <= 0 || length > MAX_MSGLEN) { fprintf(stderr, "EOS rejected packet for inactive peer or invalid size\n"); return; } EOS_P2P_AcceptConnectionOptions accept = { @@ -616,8 +682,8 @@ int Online_Receive(NETSOURCE source, netadr_t *from, sizeBuf_t *message) { if (result == EOS_NotFound) return 0; if (!Online_Result("receive packet", result)) return 0; onlinePeer_t *peer = Online_Peer(user, source); - if (!peer || strcmp(socket.SocketName, online.socket.SocketName) || channel != requested) { - fprintf(stderr, "EOS rejected packet from foreign peer/socket/channel\n"); continue; + if (!peer || peer->closed || strcmp(socket.SocketName, online.socket.SocketName) || channel != requested) { + fprintf(stderr, "EOS rejected packet from inactive peer/socket/channel\n"); continue; } int size = Online_ReadFragment(&peer->assembly[source], bytes, length, message); if (size < 0) { fprintf(stderr, "EOS rejected malformed packet fragment\n"); continue; } @@ -667,6 +733,11 @@ bool Online_HostReady(void) { !online.updating && !online.admission_closed; } bool Online_InLobby(void) { return online.lobby[0] || online.operation_pending; } +bool Online_ConnectionLost(NETSOURCE source, netadr_t const *address) { + if (!address || address->type != NA_EOS) return false; + onlinePeer_t *peer = Online_Peer(EOS_ProductUserId_FromString(address->peer), source); + return !peer || peer->closed; +} uint32_t Online_NumGames(void) { return online.num_games; } bool Online_Game(uint32_t index, onlineGame_t *out) { if (!out || index >= online.num_games) return false; @@ -677,6 +748,8 @@ void Online_Shutdown(void) { if (!online.platform) return; Online_Leave(); if (online.request_notify) EOS_P2P_RemoveNotifyPeerConnectionRequest(online.p2p, online.request_notify); + if (online.established_notify) EOS_P2P_RemoveNotifyPeerConnectionEstablished(online.p2p, online.established_notify); + if (online.closed_notify) EOS_P2P_RemoveNotifyPeerConnectionClosed(online.p2p, online.closed_notify); if (online.member_notify) EOS_Lobby_RemoveNotifyLobbyMemberStatusReceived(online.lobbies, online.member_notify); if (online.update_notify) EOS_Lobby_RemoveNotifyLobbyUpdateReceived(online.lobbies, online.update_notify); if (online.expiration_notify) EOS_Connect_RemoveNotifyAuthExpiration(online.connect, online.expiration_notify); @@ -689,6 +762,157 @@ void Online_Shutdown(void) { #ifdef BZ_TESTS #include "shared/test.h" +#include "server/server.h" +static bool online_test_sdk; +static void Online_TestSDKShutdown(void) { + if (online_test_sdk && !online.initialized) EOS_Shutdown(); +} +static bool Online_TestSDK(void) { + if (online.initialized || online_test_sdk) return true; + EOS_InitializeOptions init = { .ApiVersion = EOS_INITIALIZE_API_LATEST, + .ProductName = "OpenRealm Tests", .ProductVersion = "1" }; + EOS_EResult result = EOS_Initialize(&init); + T_EQ(result, EOS_Success); + if (result != EOS_Success) return false; + /* The native SDK owns one lifetime per process, shared by offline tests. */ + online_test_sdk = true; atexit(Online_TestSDKShutdown); + return true; +} +TEST(online_service, lost_guest_releases_server_slot_without_dropping_host) { + void *saved = malloc(sizeof(online)); + client_t *clients = malloc(2 * sizeof(*clients)); + T_NOT_NULL(saved); T_NOT_NULL(clients); + if (!saved || !clients) { free(saved); free(clients); return; } + if (!Online_TestSDK()) { free(saved); free(clients); return; } + memcpy(saved, &online, sizeof(online)); memset(&online, 0, sizeof(online)); + memcpy(clients, svs.clients, 2 * sizeof(*clients)); + lobbyState_t lobby = svs.lobby; + uint32_t count = svs.num_clients, realtime = svs.realtime; + serverState_t state = sv.state; + sv.state = ss_dead; svs.num_clients = 2; svs.realtime = 100; + memset(svs.clients, 0, 2 * sizeof(*clients)); memset(&svs.lobby, 0, sizeof(svs.lobby)); + svs.lobby.active = true; svs.lobby.slot_count = 2; + svs.lobby.slots[1] = (lobbySlot_t){ .visible = true, .occupied = true, .client = 1, .type = LOBBY_SLOT_HUMAN }; + svs.clients[0].state = svs.clients[1].state = cs_connected; + svs.clients[0].netchan.remote_address.type = NA_LOOPBACK; + svs.clients[1].netchan.remote_address.type = NA_EOS; + strlcpy(svs.clients[1].netchan.remote_address.peer, "0123456789abcdef0123456789abcde2", sizeof(svs.clients[1].netchan.remote_address.peer)); + SZ_Init(&svs.clients[1].netchan.message, svs.clients[1].netchan.message_buf, MAX_MSGLEN); + online.user = EOS_ProductUserId_FromString("0123456789abcdef0123456789abcde1"); + online.hosting = true; online.num_peers = 1; + online.peers[0].user = EOS_ProductUserId_FromString(svs.clients[1].netchan.remote_address.peer); + strlcpy(online.lobby, "test-room", sizeof(online.lobby)); strlcpy(online.socket.SocketName, "OpenRealm", sizeof(online.socket.SocketName)); + /* A member waiting for its first connection is not a lost connection. */ + T_ASSERT(!Online_ConnectionLost(NS_SERVER, &svs.clients[1].netchan.remote_address)); + SV_ReapZombieClients(); T_EQ(svs.clients[1].state, cs_connected); + EOS_P2P_OnRemoteConnectionClosedInfo closed = { .LocalUserId = online.user, + .RemoteUserId = online.peers[0].user, .SocketId = &online.socket, .Reason = EOS_CCR_ConnectionClosed }; + Online_ConnectionClosed(&closed); + T_ASSERT(Online_ConnectionLost(NS_SERVER, &svs.clients[1].netchan.remote_address)); + SV_ReapZombieClients(); + T_EQ(svs.clients[0].state, cs_connected); T_EQ(svs.clients[1].state, cs_zombie); + T_ASSERT(!svs.lobby.slots[1].occupied); T_EQ(svs.lobby.slots[1].type, LOBBY_SLOT_OPEN); + svs.realtime += BZ_CLIENT_ZOMBIE_MSEC; + SV_ReapZombieClients(); T_EQ(svs.clients[1].state, cs_free); T_EQ(svs.num_clients, 1); + svs.num_clients = 2; svs.clients[1].state = cs_connected; + T_ASSERT(SV_LobbyAssignClient(1, false)); T_ASSERT(svs.lobby.slots[1].occupied); + memcpy(&online, saved, sizeof(online)); free(saved); + memcpy(svs.clients, clients, 2 * sizeof(*clients)); free(clients); + svs.lobby = lobby; svs.num_clients = count; svs.realtime = realtime; sv.state = state; +} +TEST(online_service, lost_host_connection_clears_guest_room_and_partial_packets) { + void *saved = malloc(sizeof(online)); + T_NOT_NULL(saved); if (!saved) return; + memcpy(saved, &online, sizeof(online)); memset(&online, 0, sizeof(online)); + /* Opaque identities are compared only; this callback test needs no SDK login. */ + EOS_ProductUserId local = (EOS_ProductUserId)(uintptr_t)1; + EOS_ProductUserId owner = (EOS_ProductUserId)(uintptr_t)2; + EOS_ProductUserId other = (EOS_ProductUserId)(uintptr_t)3; + EOS_P2P_SocketId socket = { .ApiVersion = EOS_P2P_SOCKETID_API_LATEST }; + snprintf(socket.SocketName, sizeof(socket.SocketName), "OpenRealm"); + online.socket = socket; online.user = local; online.owner = owner; + online.enabled = online.ready = online.connecting = true; + snprintf(online.lobby, sizeof(online.lobby), "test-room"); + online.peers[0].user = owner; online.num_peers = 1; + online.peers[0].assembly[NS_CLIENT].data = malloc(32); + T_NOT_NULL(online.peers[0].assembly[NS_CLIENT].data); + EOS_P2P_OnRemoteConnectionClosedInfo closed = { .LocalUserId = local, + .RemoteUserId = other, .SocketId = &socket, .Reason = EOS_CCR_ConnectionClosed }; + Online_ConnectionClosed(&closed); T_ASSERT(Online_InLobby()); + closed.RemoteUserId = owner; closed.LocalUserId = other; + Online_ConnectionClosed(&closed); T_ASSERT(Online_InLobby()); + closed.LocalUserId = local; snprintf(socket.SocketName, sizeof(socket.SocketName), "Foreign"); + Online_ConnectionClosed(&closed); T_ASSERT(Online_InLobby()); + socket = online.socket; closed.Reason = EOS_CCR_ClosedByLocalUser; + Online_ConnectionClosed(&closed); T_ASSERT(Online_InLobby()); + closed.Reason = EOS_CCR_ConnectionClosed; + Online_ConnectionClosed(&closed); + T_ASSERT(!Online_InLobby()); T_ASSERT(!Online_Ready()); + T_ASSERT(!online.connecting); T_NULL(online.owner); T_EQ(online.num_peers, 0); + T_NULL(online.peers[0].assembly[NS_CLIENT].data); + T_STREQ(Online_Status(), "Connection to the Internet host has closed."); + Online_ClearPeers(); + memcpy(&online, saved, sizeof(online)); free(saved); +} + +TEST(online_service, departing_guest_does_not_close_host_or_other_peer_assembly) { + void *saved = malloc(sizeof(online)); + T_NOT_NULL(saved); if (!saved) return; + memcpy(saved, &online, sizeof(online)); memset(&online, 0, sizeof(online)); + online.user = online.owner = (EOS_ProductUserId)(uintptr_t)1; + online.hosting = online.enabled = online.ready = online.published = true; + snprintf(online.lobby, sizeof(online.lobby), "test-room"); + snprintf(online.socket.SocketName, sizeof(online.socket.SocketName), "OpenRealm"); + online.peers[0].user = (EOS_ProductUserId)(uintptr_t)2; + online.peers[1].user = (EOS_ProductUserId)(uintptr_t)3; + online.num_peers = 2; + for (int i = 0; i < 2; i++) for (int j = 0; j < 2; j++) { + online.peers[i].assembly[j].data = malloc(32); + T_NOT_NULL(online.peers[i].assembly[j].data); + } + EOS_P2P_OnPeerConnectionEstablishedInfo established = { .LocalUserId = online.user, + .RemoteUserId = online.peers[0].user, .SocketId = &online.socket, + .NetworkType = EOS_NCT_RelayedConnection }; + Online_ConnectionEstablished(&established); + T_EQ(online.peers[0].network_type, EOS_NCT_RelayedConnection); + EOS_P2P_OnRemoteConnectionClosedInfo closed = { .LocalUserId = online.user, + .RemoteUserId = online.peers[0].user, .SocketId = &online.socket, .Reason = EOS_CCR_ConnectionClosed }; + Online_ConnectionClosed(&closed); + T_ASSERT(Online_InLobby()); T_ASSERT(Online_HostReady()); + T_EQ(online.peers[0].network_type, EOS_NCT_NoConnection); + T_ASSERT(online.peers[0].closed); T_ASSERT(!online.peers[1].closed); + for (int i = 0; i < 2; i++) { + T_NULL(online.peers[0].assembly[i].data); + T_NOT_NULL(online.peers[1].assembly[i].data); + } + established.ConnectionType = EOS_CET_Reconnection; + established.NetworkType = EOS_NCT_DirectConnection; + Online_ConnectionEstablished(&established); + T_EQ(online.peers[0].network_type, EOS_NCT_DirectConnection); + T_ASSERT(!online.peers[0].closed); + Online_ClearPeers(); + memcpy(&online, saved, sizeof(online)); free(saved); +} + +TEST(online_service, departure_failures_remain_visible_until_the_batch_finishes) { + void *saved = malloc(sizeof(online)); + T_NOT_NULL(saved); if (!saved) return; + memcpy(saved, &online, sizeof(online)); memset(&online, 0, sizeof(online)); + online.leave_pending = 2; + EOS_Lobby_DestroyLobbyCallbackInfo destroyed = { .ResultCode = EOS_UnexpectedError }; + Online_Destroyed(&destroyed); + T_EQ(online.leave_pending, 1); T_ASSERT(online.departure_failed); + EOS_Lobby_LeaveLobbyCallbackInfo left = { .ResultCode = EOS_Success }; + Online_Left(&left); + T_EQ(online.leave_pending, 0); T_ASSERT(online.departure_failed); + T_STREQ(Online_Status(), "destroy lobby: EOS_UnexpectedError"); + online.departure_failed = false; online.leave_pending = 1; + left.ResultCode = EOS_NotFound; + Online_Left(&left); + T_EQ(online.leave_pending, 0); T_ASSERT(!online.departure_failed); + memcpy(&online, saved, sizeof(online)); free(saved); +} + TEST(online_service, cancel_waits_for_late_create_and_update_callbacks) { void *saved = malloc(sizeof(online)); T_NOT_NULL(saved); if (!saved) return; @@ -730,14 +954,7 @@ TEST(online_service, canceled_search_cannot_replace_results_or_status) { TEST(online_service, server_and_client_admit_only_their_lobby_peers) { void *saved = malloc(sizeof(online)); T_NOT_NULL(saved); if (!saved) return; - bool const own_sdk = !online.initialized; - if (own_sdk) { - EOS_InitializeOptions init = { .ApiVersion = EOS_INITIALIZE_API_LATEST, - .ProductName = "OpenRealm Tests", .ProductVersion = "1" }; - EOS_EResult result = EOS_Initialize(&init); - T_EQ(result, EOS_Success); - if (result != EOS_Success) { free(saved); return; } - } + if (!Online_TestSDK()) { free(saved); return; } memcpy(saved, &online, sizeof(online)); memset(&online, 0, sizeof(online)); EOS_ProductUserId local = EOS_ProductUserId_FromString("0123456789abcdef0123456789abcde1"); EOS_ProductUserId owner = EOS_ProductUserId_FromString("0123456789abcdef0123456789abcde2"); @@ -755,7 +972,7 @@ TEST(online_service, server_and_client_admit_only_their_lobby_peers) { T_NULL(Online_Peer(other, NS_SERVER)); /* departed member */ online.lobby[0] = 0; T_NULL(Online_Peer(owner, NS_SERVER)); memcpy(&online, saved, sizeof(online)); free(saved); - if (own_sdk) T_EQ(EOS_Shutdown(), EOS_Success); } +#include "../tests/online_acceptance.h" #endif #endif diff --git a/dist-scripts/eos/run_acceptance.py b/dist-scripts/eos/run_acceptance.py new file mode 100644 index 000000000..326ef30f1 --- /dev/null +++ b/dist-scripts/eos/run_acceptance.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Run live EOS adapter checks in isolated Linux guest installations.""" + +import argparse +from pathlib import Path +import re +import subprocess +import sys +import time +import uuid + + +SCENARIOS = { + "solo": (("solo",), 1), + "default": (("host", "guest"), 0), + "relay": (("host", "guest"), 1), + "crash": (("crash-host", "guest"), 1), + "game-default": (("game-host", "game-guest"), 0), + "game-relay": (("game-host", "game-guest"), 1), + "game-crash": (("game-crash-host", "game-guest"), 1), + "game-guest-crash": (("game-survivor", "game-crash-guest"), 1), +} +DEADLINE = 220 # seconds; includes container startup around the 180-second SDK watchdog + + +def run_scenario(root, image, run_id, scenario): + roles, relay = SCENARIOS[scenario] + room = f"ci-{run_id}-{scenario}" + containers = [] + processes = [] + deadline = time.monotonic() + DEADLINE + print(f"EOS live check: {scenario} ({room})", flush=True) + try: + for role in roles: + name = f"eos-{run_id}-{scenario}-{role}" + containers.append(name) + # Mount only the build/config read-only. Each container owns its + # writable home and native guest store; the guest rejects equal IDs. + command = [ + "docker", "run", "--rm", "--name", name, + "--mount", f"type=bind,source={root},target=/workspace,readonly", + "--workdir", "/workspace", + "--env", "OPENREALM_EOS_CONFIG=/workspace/data/eos/eos.cfg", + "--env", "SDL_VIDEODRIVER=offscreen", + "--env", "SDL_AUDIODRIVER=dummy", + "--env", "LIBGL_ALWAYS_SOFTWARE=1", + image, "build/bin/openwarcraft3-tests", "-data", "build/tests", + "+dedicated", "1", "+online_force_relay", str(relay), + "+online_acceptance", role, room, + ] + processes.append((role, subprocess.Popen(command))) + for role, process in processes: + result = process.wait(timeout=max(0.01, deadline - time.monotonic())) + if result: + raise RuntimeError(f"{scenario}: {role} exited with status {result}") + except subprocess.TimeoutExpired: + raise RuntimeError(f"{scenario}: container deadline exceeded") from None + finally: + # Also stop the surviving peer on failure/cancellation. Never keep guest + # storage or publish SDK/config/test artifacts from a live run. + for name in containers: + cleanup = subprocess.run(["docker", "rm", "--force", name], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if cleanup.returncode and b"No such container" not in cleanup.stderr: + print(f"EOS container cleanup failed: {name}", file=sys.stderr) + for _, process in processes: + process.wait(timeout=10) + print(f"EOS live check: {scenario} PASS", flush=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--image", default="ghcr.io/corepunch/open-realm-ci:ubuntu-24.04") + parser.add_argument("--run-id", default=uuid.uuid4().hex[:16]) + parser.add_argument("--scenario", choices=SCENARIOS) + args = parser.parse_args() + if not re.fullmatch(r"[a-z0-9-]{1,40}", args.run_id): + parser.error("run-id must contain 1-40 lowercase letters, digits or hyphens") + root = Path.cwd().resolve() + for required in ("build/bin/openwarcraft3-tests", "build/tests/tests.mpq", "data/eos/eos.cfg"): + if not (root / required).is_file(): + parser.error("Missing live check input: " + required) + try: + for scenario in (args.scenario,) if args.scenario else SCENARIOS: + run_scenario(root, args.image, args.run_id, scenario) + except (RuntimeError, OSError, subprocess.TimeoutExpired) as error: + print(f"EOS live checks FAIL: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/architecture/epic-online-services.md b/docs/architecture/epic-online-services.md index 2c78b5140..ecfe783bd 100644 --- a/docs/architecture/epic-online-services.md +++ b/docs/architecture/epic-online-services.md @@ -20,8 +20,13 @@ The SDK lives under ignored `data/eos/SDK`; its headers, binaries and archive are not committed to the game repository. The restricted release player client and Live deployment are configured, with credentials stored in Actions secrets and ignored private local configuration. A compact SDK archive is stored in a -separate private mirror. Two-installation gameplay, forced relay and host-crash -cleanup remain separate acceptance checks. +separate private mirror. Trusted GitHub CI runs paired gameplay, forced relay +and host/guest process-crash acceptance in separate guest installations. + +Internet play supports guest login, public discovery and server-authored gameplay. `online_force_relay=1` now selects EOS's forced-relay policy before the +platform is first created; connection notifications report the actual direct +or relay path. A permanent host P2P closure now leaves the guest room promptly, +without waiting for the engine's packet timeout. ## Build and configuration @@ -31,11 +36,16 @@ its `SDK` directory outside tracked source, then build: ```sh make EOS=1 EOS_SDK_ROOT=data/eos/SDK openwarcraft3 make EOS=1 EOS_SDK_ROOT=data/eos/SDK openwarcraft3-tests -build/bin/openwarcraft3-tests -data build/tests +dedicated 1 +test 'online_service.*' +make EOS=1 EOS_SDK_ROOT=data/eos/SDK test-eos-service ``` `EOS=0` is the default; `BZ_EOS` guards all SDK includes and implementation. -Normal `make build` and PR CI require no SDK headers, libraries or credentials. +Normal `make build` and the SDK-free PR CI jobs require no SDK headers, libraries +or credentials. Trusted PRs also run the EOS job described below. +`test-eos-service` requires `EOS=1` explicitly; an SDK-free invocation fails +instead of reporting success with no service tests. Release builds run these +offline SDK tests on Linux, macOS and Windows, using separate output directories +so test executables/modules are excluded from release archives. Only the engine executable links EOS; game and menu modules import the generic service API. The matching runtime library is copied to `build/lib` on macOS/Linux and `build/bin` on Windows x64. Linux selects its @@ -238,6 +248,21 @@ only the owner as a server. A client also checks the packet source against its connected host. The initial engine handshake retries once per second and times out after 10 seconds, including when the host disappears before replying. LAN replies never populate the Internet list. +The adapter registers established/closed notifications for the authenticated +user and `OpenRealm` socket. Established notifications log the actual network +type, including reconnections. Closed notifications validate local identity, +socket and active peer membership before changing state. Local close callbacks +are ignored so delayed teardown cannot invalidate a new room. A guest's +permanent host closure clears pending connection state and all reassembly +buffers; `CL_Frame` then takes its existing room-closed disconnect path. A host +losing one guest marks that connection permanently closed and clears its partial +messages. `SV_ReapZombieClients` observes `Online_ConnectionLost`, calls the +normal `SV_DropClient` lifecycle, releases the lobby slot to Open and reclaims +the client slot after the two-second zombie grace period. The host and other +clients keep running. A member waiting for its first P2P connection is not +considered lost; established/reestablished notifications clear the closed bit. +EOS handles temporary interruptions before a permanent-close notification. + Engine messages can reach 256 KiB, while EOS P2P accepts 1170-byte packets. `common/online_packet.c` adds a 12-byte little-endian message-ID/total/offset header and fragments into reliable ordered P2P packets. Reassembly retains one @@ -251,9 +276,14 @@ Operation epochs discard old search/join/create/update callbacks; pending operations and departures stay busy until their callbacks complete, preventing a canceled operation from reviving a room. EOS lobby closure disconnects guests; loss of the host's service session shuts down its local server. Engine timeouts -still handle network stalls and process crashes. Final process shutdown submits -best-effort departure before releasing SDK handles; service crash-cleanup latency -has not been measured. +still handle network stalls. Final process shutdown submits best-effort departure +before releasing SDK handles. The live CI driver separately measures host-crash +public-directory cleanup and guest-crash server-slot cleanup. + +Departure callbacks retain any failure across the pending cleanup batch. An +`EOS_NotFound` departure is logged as an already-absent lobby, which can happen +when closure and local leave race; other failures remain errors. The live +acceptance command waits for departure callbacks and rejects failed cleanup. ## Credentials and open source @@ -282,7 +312,7 @@ Forks with separate deployments do not share the official lobby directory. Epic's agreement restricts intentional credential sharing to Licensed EOS Developers; do not publish the credential in GitHub. -## Validation and remaining acceptance +## Validation and automated acceptance Regression coverage exercises maximum/sign-on packet fragmentation, interleaved peers/channels, malformed and out-of-order fragments, incomplete-message cleanup, @@ -291,11 +321,216 @@ foreign peer admission and separate retail Internet/LAN button commands. The ordinary required suite remains `make test`; SDK-specific state tests use the EOS-enabled executable command above and do not contact the service. -Before calling Internet play production-ready, use two separate guest -installations to verify login, public search/create/join, full and incompatible -rooms, slot/chat changes, map launch, graceful leave, reconnect, process crashes -and relay-only connectivity. Record cleanup latency and queue failures. Unit -tests cannot validate the configured Epic deployment or NAT/relay behavior. +The host-connection regression first failed with seven assertions: the room, +owner, connection request and partial packet remained live after host closure. +The fix covers that cleanup, foreign/local close filtering, host survival when +a guest disappears, reconnection network type, and failed departure callbacks. +On 2026-10-03, local macOS arm64 validation passed the SDK-free full suite, the +EOS-enabled full suite and the isolated optimized release test build. The original six +offline EOS service tests passed all 66 assertions. These results establish +code/fixture behavior, not the live acceptance rows below. + +### GitHub EOS checks + +The `CI` workflow adds an EOS job on main/tag pushes and same-repository PRs. +It fetches the same pinned private SDK and restricted player secrets as release +builds, then runs `make EOS=1 TEST_JOBS=4 test` in the published Linux CI image. +Fork PRs retain the SDK-free jobs; they cannot access these secrets. This job +uses ordinary `pull_request`, never privileged execution of fork code. + +`dist-scripts/eos/run_acceptance.py` then runs eight explicit live scenarios: +single-guest publication/reconnect, paired default-policy exchange/departure, +paired forced-relay exchange/departure, and forced-relay host-crash cleanup. +Four additional scenarios run real game clients: default-policy gameplay, +forced-relay gameplay, host crash during gameplay and guest crash during gameplay. +Host and guest run concurrently in separate disposable Docker containers with +private writable homes/native guest stores and a read-only source/build/config +mount. The adapter rejects identical Product User IDs. Rooms include the +Actions run ID, attempt and scenario to prevent concurrent-run collisions. +Both peers must exit successfully; an intentional crash-host exit alone cannot +pass without the guest verifying host loss and public-directory removal. +Paired hosts wait for publication readiness, then receive packets; the guest +proves public indexing by finding and joining the room. Hosts must not wait for +their own public search result: a guest may fill the two-member room before that +search completes, and full rooms are intentionally excluded by the browser. +The first GitHub live run on 2026-10-03 passed solo login/publication/reconnect +in 11.9 seconds but reproduced that acceptance-driver race after the guest +joined and both peers established direct connectivity. + +After fixing that race, [CI run 37130087222](https://github.com/corepunch/open-realm/actions/runs/37130087222) +passed all jobs on 2026-10-03 at commit `521e49d1d`. The EOS-enabled full suite +passed 38,202 assertions in 2,286 engine tests per edition. Linux x64 containers +used distinct native Device ID guests, the same fixture map and Live deployment: + +| Live adapter scenario | Verified result | +| --- | --- | +| Solo | Guest login, public publication/search, private admission, destroy and stable-identity reconnect passed | +| Default policy | Both peers established direct connections and verified 262,144 bytes in each direction; guest detected graceful host loss after 369 ms and public absence after 502 ms | +| Forced relay | Both peers established relay connections and verified 262,144 bytes in each direction; graceful host loss after 399 ms and public absence after 511 ms | +| Forced-relay host crash | Host exited without SDK teardown after the packet acknowledgement; guest detected loss after 13,288 ms and public absence after 18,587 ms, then passed cleanup | + +Latency starts when the guest sends its packet acknowledgement, immediately +before host departure/crash, and includes reconnect/search for public absence. +These timings are observations from one hosted run, not guaranteed service SLAs. + +Each native command has its 180-second watchdog; the runner adds a 220-second +scenario deadline and removes surviving containers on failure. Service failures +fail CI. Only console diagnostics are retained: no SDK, guest stores, private +config or credential-bearing build artifacts are uploaded by the EOS job. +For a Linux Docker host with prepared SDK/config and EOS test build: + +```sh +python3 dist-scripts/eos/run_acceptance.py +# Limit a diagnostic rerun to one scenario: +python3 dist-scripts/eos/run_acceptance.py --scenario crash +``` + +Runner failure/timeout/peer-isolation checks are included in `make test` through +`test-eos-release`; they use no Docker or service credentials. Passing the live +adapter scenarios prove service/packet/lifecycle behavior. The additional gameplay +scenarios run `SV_StartLobby`, `SV_Map`, `SV_Frame` and `CL_Frame` with the real +WC3 module, client parser, menu callbacks and software-GL renderer. + +### Bounded live adapter checks + +`+online_acceptance` is an opt-in command in the EOS-enabled **test executable**, +not a registered unit test. It drives the real Connect/Lobbies/P2P adapter, +filesystem map CRC and `NET_SendPacket` / `NET_GetPacket`. It never runs through +`make test` or release CI; the separate GitHub EOS job invokes it explicitly. +Every process has a 180-second watchdog, including +synchronous native SDK calls; timeout is a failure. Use a unique room name per +run, matching game protocol/edition and the same credentials/deployment on both +installations. The default map is the generated fixture in `build/tests`. + +Build fixtures and run the single-installation publication/reconnect check: + +```sh +make EOS=1 test-eos-service +OPENREALM_EOS_CONFIG=/private/path/eos.cfg \ + build/bin/openwarcraft3-tests -data build/tests +dedicated 1 \ + +online_force_relay 1 +online_acceptance solo acceptance-unique-name +``` + +`solo` verifies guest login, configured relay policy, public indexing, private +admission after Start, asynchronous destruction, stable guest identity on +re-entry, a second publication and eventual public-list cleanup. Selecting the +forced-relay policy alone does **not** prove that a relay connection works. + +On installation A, then promptly on installation B, run: + +```sh +# A +OPENREALM_EOS_CONFIG=/private/path/eos.cfg \ + build/bin/openwarcraft3-tests -data build/tests +dedicated 1 \ + +online_force_relay 1 +online_acceptance host acceptance-unique-name +# B +OPENREALM_EOS_CONFIG=/private/path/eos.cfg \ + build/bin/openwarcraft3-tests -data build/tests +dedicated 1 \ + +online_force_relay 1 +online_acceptance guest acceptance-unique-name +``` + +The guest rejects the host's own Product User ID, joins the public room and +sends a deterministic `MAX_MSGLEN` (256 KiB) packet. The host checks every byte +and echoes it; the guest verifies the echo and acknowledges receipt. With +`online_force_relay=1`, both processes require the SDK's established connection +type to be relayed. The host closes admission and departs; the guest reports +host-loss detection and public-list disappearance latency, then reconnects to +search. Repeat with `online_force_relay=0` for the default direct/relay policy. + +For crash cleanup, replace A's role with `crash-host` and use a fresh room name. +After receiving the acknowledgement, A deliberately uses `_Exit(0)` without +any EOS teardown. B must detect host loss and observe that the room disappears +from a successful public search before its watchdog expires. A prints the crash +action and exits without a final PASS line; B is the cleanup verifier. Capture +both outputs, latencies and any packet-queue errors. + +These are adapter checks. They do not run lobby UI, game sign-on, simulation, +snapshots or input. Those still require the gameplay acceptance matrix below. +To use an installed map for the CRC check, pass +`+set online_acceptance_map 'Maps/(2)OgreMound.w3m'` before the command and use +`-data` pointing to retail data on both installations. + +On macOS, EOS Device ID creation can synchronously wait for Keychain permission +when accessing an existing guest item from a newly rebuilt executable. Handle +the native dialog yourself; do not change the stored identity, Keychain access +controls or TLS settings to bypass it. A local run on 2026-10-03 was stopped +while `EOS_Connect_CreateDeviceId` was waiting inside `SecItemCopyMatching`; +it produced no live service acceptance result. The command now has a separate +timer watchdog for this native blocking case. The headless command pumps the +CoreFoundation run loop for EOS HTTP; the normal graphical client uses SDL. + +### Paired gameplay acceptance + +`games/warcraft-3/tests/online_gameplay.h` adds explicit `game-host`, `game-guest`, +`game-crash-host`, `game-survivor` and `game-crash-guest` roles to the same bounded +command. It uses the generated `Maps/Transport.w3m` nested MPQ, a 32x32-tile +ROC-format map with two human starts and two fixture Footmen created by JASS. +The driver completes `CL_Init`'s queued `menu_main` command with `online_mode=0` +before enabling the Internet lobby. Main-menu entry intentionally leaves EOS; +running that deferred command during a live search cancels its operation epoch. +`tools/wc3fixturegen.py` writes real W3I/W3E/pathing/placement/script files; +`make test-assets` packs them. The earlier Human02 fixture is only placeholder +bytes for CRC tests and cannot load a game. No retail archives are required. + +| Scenario | Required observation | +| --- | --- | +| Default gameplay | Distinct guest identities discover/join; both clients receive authoritative slots, names, races, teams, colors and chat ownership; real map sign-on reaches active gameplay | +| Admission | An incompatible discovery bucket excludes the room; a changed cached host CRC is rejected by the production join check before restoring real metadata; a full room disappears from successful public search | +| Shared simulation | Each player selects its own Footman and issues SmartPoint/Move over the engine channel; both clients observe both moved units, their own camera-command result, matching map checksum and 100 fresh monotonic snapshots | +| Forced relay | Both actual remote engine connections report relay; the same sign-on and shared-simulation assertions pass | +| Graceful host leave | Guest takes the normal client disconnect path, pumps queued menu restoration, signs in again and observes public-room removal | +| Host process crash | Host exits without engine/SDK teardown after guest gameplay acknowledgement; guest returns to menu, reconnects and measures directory cleanup | +| Guest process crash | Guest exits after shared-simulation verification; host stays active with advancing snapshots, releases the lobby slot to Open and reclaims the server client slot | + +The gameplay container sets `SDL_VIDEODRIVER=offscreen`, +`SDL_AUDIODRIVER=dummy` and `LIBGL_ALWAYS_SOFTWARE=1`. Mesa software rendering +is a dependency of the shared CI Dockerfile. The EOS job builds that image so +PR dependency changes are tested before the refreshed image is published. +Rendering still uses the real renderer; fixture art is minimal and missing +retail presentation resources are logged. This validates the runtime lifecycle +and menu callback flow, not retail artwork, visual fidelity, long matches or +cross-platform hardware/NAT combinations. + +A local loopback diagnostic runs the same map, real game/client/render path, +Move/camera commands and snapshot assertions without authenticating to EOS: + +```sh +make EOS=1 openwarcraft3-tests test-assets +build/bin/openwarcraft3-tests -data build/tests +dedicated 1 \ + +online_acceptance local local-check +# Linux Docker with prepared SDK/player config: +python3 dist-scripts/eos/run_acceptance.py --scenario game-relay +python3 dist-scripts/eos/run_acceptance.py --scenario game-crash +python3 dist-scripts/eos/run_acceptance.py --scenario game-guest-crash +``` + +Local macOS arm64 loopback validation passed real map loading, Move/camera +command round-trips and fresh snapshots. The new lost-guest regression first +reproduced four failures: the server client remained connected, the lobby slot +remained occupied, and the slot/high-water count were never reclaimed. After +fixing transport-close observation in the normal server lifecycle, the seven +offline EOS service tests passed 81 assertions, including reassignment of the +released slot and isolation of the surviving host. Offline tests share one +native SDK initialization lifetime; reinitializing it between tests fails on +macOS even after shutdown. + +Growing the fixture archive exposed `mpqtool pack` silently truncating its +argument list at 128 files. Its argument list now scales with the actual CLI +input; the fixture archive verification checks files beyond the former cutoff. + +The paired lobby check also exposed `SV_LobbySayClient_f` generating generic +"Player 1/2" chat labels instead of the sanitized name already held by the +server client and shown in its slot. It now broadcasts that authoritative name; +the server command regression checks sender text and ownership on both clients. + +The gameplay pump follows the normal engine loop's `SV_IsActive()` guard. +Continuing to call `SV_Frame` while waiting for asynchronous EOS departure after +`SV_Shutdown` runs the game callback against freed state. A local diagnostic +reproduced that crash and now checks menu recovery and idle frames after shutdown +without advancing the stopped server. + +Keep deployment failures as failing CI results. Acceptance is bounded and +explicit; ordinary unit tests never authenticate or contact the live service. ## References diff --git a/docs/diagnostic-tools.md b/docs/diagnostic-tools.md index b2176283b..faab25a9b 100644 --- a/docs/diagnostic-tools.md +++ b/docs/diagnostic-tools.md @@ -1,5 +1,14 @@ # Diagnostic Tools +## EOS Internet acceptance + +`make EOS=1 test-eos-service` runs offline SDK session regressions. The +EOS-enabled test executable also provides the explicit `+online_acceptance` +command for bounded live publication, paired packet transport, forced-relay +and host-crash cleanup checks. It is never invoked by ordinary test runs. +See [EOS acceptance and gameplay release gate](architecture/epic-online-services.md#bounded-live-adapter-checks) +for commands, required separate guest identities and remaining gameplay checks. + ## Warcraft III Campaign Map Audit Use `make audit-wc3-maps` to run every shipped RoC/TFT campaign map for a @@ -280,3 +289,5 @@ This differs from `ability_map.c`, whose rawcode relationships are manually tran ## Galaxy Native Coverage Audit `python3 tools/galaxy_audit.py ` inventories reachable missing bindings and obvious placeholder candidates without executing scripts. See [Galaxy native coverage](games/starcraft-2/galaxy-native-coverage.md) for exact MPQ extraction commands, audit limits, and the complete Markdown snapshot. Use [bounded runtime traces](games/starcraft-2/galaxy-scripting.md#reproducing-detailed-traces) to distinguish static coverage from executed callbacks. + +EOS paired gameplay diagnostics (`game-relay`, `game-crash`, `game-guest-crash`) and the no-login loopback check use the generated Transport map; see [paired gameplay acceptance](architecture/epic-online-services.md#paired-gameplay-acceptance). diff --git a/games/warcraft-3/game.mk b/games/warcraft-3/game.mk index 0f9d86d13..0de47e52f 100644 --- a/games/warcraft-3/game.mk +++ b/games/warcraft-3/game.mk @@ -40,6 +40,7 @@ else ifeq ($(UNAME_S),Darwin) EOS_RUNTIME_NAME := libEOSSDK-Mac-Shipping.dylib EOS_RUNTIME_DIR := $(LIB_DIR) WC3_EOS_LIBS := -L$(EOS_SDK_ROOT)/Bin -lEOSSDK-Mac-Shipping +WC3_EOS_LIBS += -framework CoreFoundation # Sign the build copy; preserve the SDK archive and its original library. EOS_RUNTIME_SIGN := codesign --force --sign '$(EOS_CODESIGN_IDENTITY)' else ifeq ($(UNAME_S),Linux) @@ -195,6 +196,7 @@ test-render-harness: fdfbindgen mpqtool .PHONY: test-eos-release test-eos-release: + python3 tests/test_eos_acceptance_runner.py python3 tests/test_eos_release.py # Golden-image render regression test (deterministic MDX renders vs committed @@ -239,12 +241,23 @@ $(eval $(call app_schema,$(BINARY),$(SHARED_LIB) $(JASS_LIB) $(SHEET_LIB) $(GAME # --------------------------------------------------------------------------- GAME_WC3_TEST_LIB := $(LIB_DIR)/libgame-wc3-test$(LIB_EXT) WC3_TEST_BINARY := $(BIN_DIR)/openwarcraft3-tests$(EXE_EXT) +$(WC3_TEST_BINARY): tests/online_acceptance.h $(WC3_TEST_DIR)/online_gameplay.h $(eval $(call unity_lib_schema,$(GAME_WC3_TEST_LIB),$(GAME_BASE_DEPS) $(JASS_LIB) $(SHEET_LIB) $(WORLD_CORE_SRCS) $(WC3_COMMON_SRCS) $(call CSRC,$(WC3_GAME_DIR)),game-wc3-test,$(WC3_GAME_DIR) $(WC3_DIR)/common,! -name 'world_w3.c',$(WC3_FDF_CFLAGS) -DBZ_TESTS,common/mpq.c,-lsheet -lshared -ljass $(LIBS) -lm -lz)) $(eval $(call app_schema,$(WC3_TEST_BINARY),$(SHARED_LIB) $(JASS_LIB) $(SHEET_LIB) $(GAME_WC3_TEST_LIB) $(RENDERER_LIB) $(MENU_LIB) $(WC3_EOS_RUNTIME) $(EOS_BUILD_CONFIG) $(APP_SRCS) $(WC3_COMMON_SRCS) $(CLIENT_HEADERS) $(COMMON_HEADERS) $(WC3_TEST_DIR)/test_coordinates.c,openwarcraft3-tests,$(WC3_FDF_CFLAGS) $(WC3_EOS_CFLAGS) -DBZ_CLIENT_WORLD -DBZ_TESTS,-lsheet -lshared -ljass -lgame-wc3-test -lrenderer -lmenu $(LIBS) $(WC3_FFMPEG_LIBS) $(WC3_EOS_LIBS) -lz,$(WC3_DIR)/common/world_w3.c $(WC3_TEST_DIR)/test_coordinates.c)) openwarcraft3-tests: $(WC3_TEST_BINARY) +.PHONY: test-eos-service +ifeq ($(EOS),1) +test-eos-service: $(WC3_TEST_BINARY) test-assets + $(WC3_TEST_BINARY) -data $(TESTS_DIR) +dedicated 1 +test 'online_service.*' +else +test-eos-service: + @echo 'test-eos-service requires EOS=1 and EOS_SDK_ROOT pointing to the C SDK' >&2 + @exit 1 +endif + WC3_PATTERN ?= * test-wc3-engine: $(WC3_TEST_BINARY) test-assets | $(TEST_JUNIT_DIR) TEST_JUNIT="$(TEST_JUNIT_DIR)/test-wc3-engine-classic.xml" TEST_JUNIT_SUITE="test-wc3-engine-classic" $(WC3_TEST_BINARY) -data $(TESTS_DIR) +dedicated 1 +test '$(WC3_PATTERN)' @@ -361,9 +374,13 @@ test-assets: blpgen mdxgen mpqtool mdxtool | $(TESTS_DIR) $(TESTS_SRC_DIR)/MapOverlay/war3map.w3a war3map.w3a \ $(TESTS_RES_DIR)/MapOverlay/Textures/minimap_hero.blp "Textures\\minimap_hero.blp" @echo "[test-assets] packing tests.mpq" + @python3 tools/wc3fixturegen.py $(TESTS_RES_DIR)/TransportMap + @set --; for f in $(TESTS_RES_DIR)/TransportMap/*; do \ + set -- "$$@" "$$f" "$${f##*/}"; done; \ + $(BIN_DIR)/mpqtool$(EXE_EXT) -mpq $(TESTS_RES_DIR)/Maps/Transport.w3m pack "$$@" @set --; \ for f in $$(find $(TESTS_RES_DIR) -type f | sort); do \ - rel=$${f#$(TESTS_RES_DIR)/}; set -- "$$@" "$$f" "$$rel"; \ + rel=$${f#$(TESTS_RES_DIR)/}; case "$$rel" in TransportMap/*) continue;; esac; set -- "$$@" "$$f" "$$rel"; \ done; \ for f in $$(find $(TESTS_SRC_DIR) -type f | sort); do \ rel=$${f#$(TESTS_SRC_DIR)/}; arc=$$rel; \ diff --git a/games/warcraft-3/tests/online_gameplay.h b/games/warcraft-3/tests/online_gameplay.h new file mode 100644 index 000000000..b5e7fb5e5 --- /dev/null +++ b/games/warcraft-3/tests/online_gameplay.h @@ -0,0 +1,208 @@ +/* Opt-in engine acceptance: real WC3 game, menu, client and renderer. No retail assets. */ +#include "server/server.h" +#include "client/client.h" + +static lobbyState_t online_live_lobby; +static void (*online_live_lobby_update)(lobbyState_t const *); +static bool online_live_chat_host, online_live_chat_guest, online_live_chat_own; + +static void Online_LiveChat(cstring_t text, bool own) { + fprintf(stderr, "EOS gameplay chat: own=%u %s\n", own ? 1u : 0u, text); + if (!strcmp(text, "Acceptance host: host-ready") && own == online_live_chat_own) online_live_chat_host = true; + if (!strcmp(text, "Acceptance guest: guest-ready") && own != online_live_chat_own) online_live_chat_guest = true; +} + +static void Online_LiveLobbyUpdate(lobbyState_t const *state) { + online_live_lobby = *state; + if (online_live_lobby_update) online_live_lobby_update(state); +} + +static void Online_LiveGameFrame(uint32_t msec) { + if (online_live_server && SV_IsActive()) SV_Frame(msec); + CL_Frame(msec); +} + +static void Online_LiveCommand(cstring_t command) { + MSG_WriteByte(&cls.netchan.message, clc_stringcmd); + MSG_WriteString(&cls.netchan.message, command); +} + +static bool Online_LiveGameRun(cstring_t role, cstring_t room) { + bool local = !strcmp(role, "local"), host = strstr(role, "guest") == NULL; + bool crash = !strcmp(role, "game-crash-host"); + Cvar_Set("dedicated", "0"); + /* Main-menu initialization intentionally leaves Internet play. Complete + * that phase before enabling the online lobby, as the normal UI does. */ + Cvar_Set("online_mode", "0"); + Cvar_Set("vid_hidden", "1"); Cvar_Set("s_sound", "0"); + Cvar_Set("ui_skip_transitions", "1"); + Cvar_Set("cl_camera_edge_scroll", "0"); + Cvar_Set("name", host ? "Acceptance host" : "Acceptance guest"); + CL_Init(); + Cbuf_Execute(); + Cvar_Set("online_mode", local ? "0" : "1"); + online_live_lobby_update = menu.UpdateLobbySetup; + menu.UpdateLobbySetup = Online_LiveLobbyUpdate; + online_live_chat_own = host; cl_test_lobby_chat = Online_LiveChat; + online_live_game = true; online_live_server = host; + if (local) { + SV_Map("Maps/Transport.w3m"); + CL_Connect("localhost", 0); + } else if (host) { + SV_StartLobby("Maps/Transport.w3m"); + SV_LobbySetConfig(2, 2, room); + for (uint32_t i = 0; i < 2; i++) { + lobbySlot_t slot = { .visible = true, .type = i ? LOBBY_SLOT_OPEN : LOBBY_SLOT_HUMAN, + .map_player = i, .race = 1, .team = 0, .color = i }; + SV_LobbySetSlot(i, &slot); + } + CL_Connect("localhost", 0); + while (!Online_HostReady()) if (!Online_LivePump()) return false; + } else { + char bucket[sizeof(online.bucket)]; + strlcpy(bucket, online.bucket, sizeof(bucket)); + strlcpy(online.bucket, "OpenRealm-incompatible-protocol", sizeof(online.bucket)); + if (!Online_LiveFind(room, false)) return false; + strlcpy(online.bucket, bucket, sizeof(online.bucket)); + if (!Online_LiveFind(room, true)) return false; + uint32_t index; + for (index = 0; index < online.num_games; index++) + if (!strcmp(online.games[index].game.hostname, room)) break; + if (index == online.num_games) return false; + netadr_t owner; + if (!NET_StringToAdr(online.games[index].game.address, 0, &owner) || + EOS_ProductUserId_FromString(owner.peer) == online.user) return false; + /* Exercise the production admission check against a mismatching CRC + * before restoring the actual public map metadata and joining. */ + online.games[index].crc ^= 1; + Online_Join(index); + bool rejected = !online.operation_pending && !online.lobby[0] && + !strcmp(Online_Status(), "The installed map differs from the host's map."); + online.games[index].crc ^= 1; + if (!rejected) return false; + fprintf(stderr, "EOS gameplay: incompatible discovery bucket and mismatching map CRC rejected\n"); + Online_Join(index); + } + if (!local) { + while (online_live_lobby.slot_count != 2 || !online_live_lobby.slots[1].occupied) { + if (!Online_LivePump()) return false; + } + if (online_live_lobby.local_slot != (host ? 0u : 1u) || + online_live_lobby.game_speed != 2 || !online_live_lobby.slots[0].occupied || + strcmp(online_live_lobby.slots[0].name, "Acceptance host") || + strcmp(online_live_lobby.slots[1].name, "Acceptance guest") || + online_live_lobby.slots[0].color != 0 || online_live_lobby.slots[1].color != 1) return false; + for (uint32_t i = 0; i < 2; i++) + if (online_live_lobby.slots[i].race != 1 || online_live_lobby.slots[i].team != 0 || + online_live_lobby.slots[i].type != LOBBY_SLOT_HUMAN) return false; + fprintf(stderr, "EOS gameplay: received authoritative two-player lobby and distinct slots/names/colors\n"); + Online_LiveCommand(host ? "lobby_say host-ready" : "lobby_say guest-ready"); + uint32_t chat_started = SDL_GetTicks(); + while (!online_live_chat_host || !online_live_chat_guest) { + if (!Online_LivePump() || SDL_GetTicks() - chat_started > 10000) return false; + } + fprintf(stderr, "EOS gameplay: both lobby chat messages decoded with correct sender ownership\n"); + if (host) { + if (!Online_LivePublished() || !Online_LiveFind(room, false)) return false; + fprintf(stderr, "EOS gameplay: full room excluded from public browser\n"); + /* Allow the remote setup packet to be consumed before the map transition. */ + uint32_t wait = SDL_GetTicks(); + while (SDL_GetTicks() - wait < 1000) if (!Online_LivePump()) return false; + SV_Map("Maps/Transport.w3m"); + } + } + while (cls.state != ca_active) { + if (!Online_LivePump() || (host && sv.state == ss_dead)) return false; + } + if (cl.playerstate.client_ui_state != CLIENT_UI_GAME || !cl.refresh_prepped || + strcmp(cl.configstrings[CS_WORLD], "Maps/Transport.w3m") || !CM_GetMapChecksum()) return false; + if (!local) { + if (host) while (svs.clients[1].state != cs_spawned) if (!Online_LivePump()) return false; + onlinePeer_t *peer = Online_Peer(host ? EOS_ProductUserId_FromString(svs.clients[1].netchan.remote_address.peer) : online.owner, + host ? NS_SERVER : NS_CLIENT); + if (!peer || (peer->network_type != EOS_NCT_DirectConnection && peer->network_type != EOS_NCT_RelayedConnection) || + (Cvar_Integer("online_force_relay", 0) && peer->network_type != EOS_NCT_RelayedConnection)) return false; + fprintf(stderr, "EOS gameplay: engine traffic uses %s\n", peer->network_type == EOS_NCT_RelayedConnection ? "relay" : "direct"); + } + uint32_t first = cl.frame.serverframe, previous = first, frames = 0; + uint32_t started = SDL_GetTicks(), last = started; + uint32_t units[2] = {0}; + for (uint32_t i = 0; i < cl.num_active; i++) { + entityState_t const *ent = &cl.ents[cl.active_entities[i]].current; + if (ent->class_id == MAKEFOURCC('h', 'f', 'o', 'o') && ent->player < 2) units[ent->player] = ent->number; + } + if (!units[0] || !units[1]) { + fprintf(stderr, "EOS gameplay: both authored units must reach the initial snapshot\n"); return false; + } + char select[64]; + snprintf(select, sizeof(select), "select %u", units[host ? 0 : 1]); + Online_LiveCommand(select); + Online_LiveCommand(host ? "smartpoint -768 512" : "smartpoint 768 512"); + Online_LiveCommand(host ? "camera move -384 128" : "camera move 384 128"); + while (frames < 100) { + if (!Online_LivePump() || cls.state != ca_active) return false; + if (cl.frame.serverframe < (int)previous) return false; + if (cl.frame.serverframe > (int)previous) { + frames++; previous = cl.frame.serverframe; last = SDL_GetTicks(); + } + if (SDL_GetTicks() - last > 5000) return false; + } + if (fabsf(cl.playerstate.vieworigin.x - (host ? -384 : 384)) > 1 || + fabsf(cl.playerstate.vieworigin.y - 128) > 1) { + fprintf(stderr, "EOS gameplay: camera round-trip failed: received %.1f %.1f, expected %d 128\n", + cl.playerstate.vieworigin.x, cl.playerstate.vieworigin.y, host ? -384 : 384); + return false; + } + for (uint32_t i = 0; i < (local ? 1u : 2u); i++) { + entityState_t const *ent = &cl.ents[units[i]].current; + if (fabsf(ent->origin.x - (i ? 768 : -768)) > 32 || fabsf(ent->origin.y - 512) > 32) { + fprintf(stderr, "EOS gameplay: player %u Move command not reflected in shared snapshot: %.1f %.1f\n", + i, ent->origin.x, ent->origin.y); return false; + } + } + fprintf(stderr, "EOS gameplay: active sign-on, map checksum, camera command round-trip, %u fresh snapshots (%u -> %u) in %u ms\n", + frames, first, previous, SDL_GetTicks() - started); + fprintf(stderr, "EOS gameplay: player Move commands changed the shared unit snapshots\n"); + if (local) { + SV_Shutdown(); + while (cls.state != ca_disconnected) if (!Online_LivePump()) return false; + if (!Online_LivePump() || !CL_MenuActive()) return false; + uint32_t ended = sv.framenum, idle = SDL_GetTicks(); + while (SDL_GetTicks() - idle < 250) if (!Online_LivePump()) return false; + if (sv.framenum != ended) return false; + fprintf(stderr, "EOS gameplay: local host shutdown returned to menu\n"); + return true; + } + if (host) { + if (!strcmp(role, "game-survivor")) { + started = SDL_GetTicks(); + while (svs.num_clients > 1 || svs.lobby.slots[1].occupied) { + if (!Online_LivePump() || cls.state != ca_active || !Online_InLobby()) return false; + } + if (cl.frame.serverframe <= (int)previous || svs.lobby.slots[1].type != LOBBY_SLOT_OPEN) return false; + fprintf(stderr, "EOS gameplay: guest crash freed server/lobby slot after %u ms; host snapshots continue\n", SDL_GetTicks() - started); + SV_Shutdown(); + return Online_LiveDeparture(); + } + /* The guest signals verification through the production game command; + * observe its resulting authoritative player state before departure. */ + while (!svs.clients[1].edict || fabsf(svs.clients[1].edict->client->ps.vieworigin.x - 512) > 1) { + if (!Online_LivePump()) return false; + } + if (crash) { + fprintf(stderr, "EOS gameplay: exiting host without engine/SDK teardown\n"); fflush(stderr); _Exit(0); + } + SV_Shutdown(); + return Online_LiveDeparture(); + } + if (!strcmp(role, "game-crash-guest")) { + fprintf(stderr, "EOS gameplay: exiting guest without engine/SDK teardown\n"); fflush(stderr); _Exit(0); + } + Online_LiveCommand("camera move 512 128"); + started = SDL_GetTicks(); + while (cls.state != ca_disconnected) if (!Online_LivePump()) return false; + /* Flush the queued menu restoration and prove the actual client UI recovered. */ + if (!Online_LivePump() || !CL_MenuActive()) return false; + fprintf(stderr, "EOS gameplay: client returned to menu after host loss in %u ms\n", SDL_GetTicks() - started); + return Online_LiveDeparture() && Online_LiveLogin() && Online_LiveFind(room, false); +} diff --git a/games/warcraft-3/tests/test_commands.c b/games/warcraft-3/tests/test_commands.c index 319fc3538..796be2973 100644 --- a/games/warcraft-3/tests/test_commands.c +++ b/games/warcraft-3/tests/test_commands.c @@ -503,6 +503,7 @@ typedef struct { bool twin_w3m; bool twin_w3x; bool overlay; + bool transport; } mapListState_t; static void count_fixture_map(cstring_t path, void *userData) { @@ -519,22 +520,25 @@ static void count_fixture_map(cstring_t path, void *userData) { state->twin_w3x = true; } else if (!strcmp(path, "Maps\\MapOverlay.w3x")) { state->overlay = true; + } else if (!strcmp(path, "Maps\\Transport.w3m")) { + state->transport = true; } } -/* tests.mpq packs MapOverlay.w3x under Maps/ as a nested sheet/w3a archive, so FS_ListMaps reports five maps. */ +/* Nested overlay and loadable transport archives are included in the fixture map list. */ TEST(commands, fixture_maps_are_listed_from_mpq) { mapListState_t state = { 0 }; setup_command_tests(); - T_EQ(FS_ListMaps(count_fixture_map, &state), 5); - T_EQ(state.count, 5); + T_EQ(FS_ListMaps(count_fixture_map, &state), 6); + T_EQ(state.count, 6); T_ASSERT(state.human02); T_ASSERT(state.orc01); T_ASSERT(state.twin_w3m); T_ASSERT(state.twin_w3x); T_ASSERT(state.overlay); + T_ASSERT(state.transport); } TEST(commands, short_map_name_resolves_from_fixture_mpq) { diff --git a/games/warcraft-3/tests/test_coordinates.c b/games/warcraft-3/tests/test_coordinates.c index b2c0146f4..333ac2393 100644 --- a/games/warcraft-3/tests/test_coordinates.c +++ b/games/warcraft-3/tests/test_coordinates.c @@ -1,5 +1,19 @@ #include "renderer/r_game.h" #include "shared/test.h" +#include "common/cmodel.h" + +TEST(wc3_coordinates, transport_fixture_has_real_two_player_map_metadata) { + mapInfo_t info; + T_ASSERT(CM_ReadMapInfo("Maps/Transport.w3m", &info)); + T_STREQ(info.mapName, "Two-player transport fixture"); + T_EQ(info.fileFormat, 18); + T_ASSERT(info.players[0].used); T_ASSERT(info.players[1].used); + T_EQ(info.players[0].playerType, kPlayerTypeHuman); T_EQ(info.players[1].playerType, kPlayerTypeHuman); + T_FEQ(info.players[0].startingPosition.x, -256, 0.01f); + T_FEQ(info.players[1].startingPosition.x, 256, 0.01f); + T_EQ(info.num_teams, 1); T_EQ(info.teams[0].playerMasks, 3); + CM_FreeMapInfo(&info); +} TEST(wc3_coordinates, mandatory_identity_preserves_mdx_placement) { FOR_LOOP(i, 12) { diff --git a/games/warcraft-3/tests/test_server_net.c b/games/warcraft-3/tests/test_server_net.c index d8ac004a6..9220c8ef8 100644 --- a/games/warcraft-3/tests/test_server_net.c +++ b/games/warcraft-3/tests/test_server_net.c @@ -1297,6 +1297,19 @@ TEST(server_net, lobby_chat_broadcasts_to_connected_clients) { MSG_ReadString(&msg, text); T_STREQ(text, "Host: hello team"); } + /* The real client command must use the same name as its lobby slot. */ + uint8_t command_buf[128]; + sizeBuf_t command = { .data = command_buf, .maxsize = sizeof(command_buf) }; + strlcpy(svs.clients[1].name, "Guest Player", sizeof(svs.clients[1].name)); + MSG_WriteString(&command, "lobby_say guest ready"); + SV_ExecuteUserCommand(&command, &svs.clients[1]); + FOR_LOOP(i, svs.num_clients) { + T_ASSERT(NET_GetPacket(NS_CLIENT, &from, &msg)); + T_EQ(MSG_ReadByte(&msg), svc_lobby_chat); + T_EQ(MSG_ReadByte(&msg), i == 1 ? 1 : 0); + MSG_ReadString(&msg, text); + T_STREQ(text, "Guest Player: guest ready"); + } } /* Early and late clients receive the same loading resources, before any world-only configstrings. */ diff --git a/server/sv_init.c b/server/sv_init.c index 3b42da0be..7f54cafe4 100644 --- a/server/sv_init.c +++ b/server/sv_init.c @@ -98,6 +98,10 @@ static void SV_TrimClientSlots(void) { void SV_ReapZombieClients(void) { FOR_LOOP(i, svs.num_clients) { client_t *cl = &svs.clients[i]; + if (cl->state >= cs_connected && Online_ConnectionLost(NS_SERVER, &cl->netchan.remote_address)) { + fprintf(stderr, "SV_ReapZombieClients: Internet peer %u departed\n", i); + SV_DropClient(cl); + } if (cl->state != cs_zombie || svs.realtime - cl->drop_time < BZ_CLIENT_ZOMBIE_MSEC) continue; memset(cl, 0, sizeof(*cl)); diff --git a/server/sv_lobby.c b/server/sv_lobby.c index 2c2df95d8..e479dac66 100644 --- a/server/sv_lobby.c +++ b/server/sv_lobby.c @@ -215,8 +215,10 @@ void SV_LobbyRemoveClient(uint32_t clientnum) { if (!slot->occupied || slot->client != clientnum) continue; slot->occupied = false; slot->client = MAX_CLIENTS; - if (slot->type == LOBBY_SLOT_HUMAN) - snprintf(slot->name, sizeof(slot->name), "Player"); + if (slot->type == LOBBY_SLOT_HUMAN) { + slot->type = LOBBY_SLOT_OPEN; + snprintf(slot->name, sizeof(slot->name), "Open"); + } changed = true; } } diff --git a/server/sv_user.c b/server/sv_user.c index 7df451aca..e4f451aed 100644 --- a/server/sv_user.c +++ b/server/sv_user.c @@ -123,7 +123,6 @@ static uint32_t SV_ClientIndex(client_t *client) { static void SV_LobbySayClient_f(client_t *cl, int argc, cstring_t *argv) { char text[256]; size_t used = 0; - char sender[32]; if (argc < 2 || !argv) { return; @@ -144,8 +143,7 @@ static void SV_LobbySayClient_f(client_t *cl, int argc, cstring_t *argv) { used += len; text[used] = '\0'; } - snprintf(sender, sizeof(sender), "Player %u", (unsigned)SV_ClientIndex(cl) + 1); - SV_LobbyBroadcastChatFrom(SV_ClientIndex(cl), sender, text); + SV_LobbyBroadcastChatFrom(SV_ClientIndex(cl), cl->name, text); } /* Quake 2 SV_Disconnect_f: the client announces it is leaving. This used to fall through to the game as an diff --git a/tests/online_acceptance.h b/tests/online_acceptance.h new file mode 100644 index 000000000..1656a4e32 --- /dev/null +++ b/tests/online_acceptance.h @@ -0,0 +1,234 @@ +/* Opt-in live check of the production adapter, included by online_eos.c. + * Not a registered TEST: make test must never contact the live deployment. */ +#include +#ifdef __APPLE__ +#include +#endif + +#define ONLINE_LIVE_TIMEOUT 180000 // milliseconds; bounds service/indexing/crash checks including native SDK calls + +static uint32_t online_live_start, online_live_tick; +static char online_live_status[256]; +static bool online_live_game, online_live_server; + +static void Online_LiveGameFrame(uint32_t msec); + +/* SDK entry points can block in native credential storage before any tick. */ +static Uint32 SDLCALL Online_LiveWatchdog(Uint32 interval, void *context) { + (void)interval; (void)context; + fprintf(stderr, "EOS acceptance FAIL: 180-second process deadline exceeded (including native SDK calls).\n"); + fflush(stderr); _Exit(1); +} + +/* Give native HTTP and the production adapter the same advancing wall clock. */ +static bool Online_LivePump(void) { +#ifdef __APPLE__ + /* Dedicated SDL has no video event pump, but EOS native HTTP needs one. */ + CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.005, false); +#else + SDL_Delay(5); +#endif + uint32_t now = SDL_GetTicks(); + uint32_t msec = now - online_live_tick; + Online_Frame(msec); online_live_tick = now; + if (online_live_game) Online_LiveGameFrame(msec); + if (strcmp(online_live_status, Online_Status())) { + snprintf(online_live_status, sizeof(online_live_status), "%s", Online_Status()); + fprintf(stderr, "EOS acceptance %u ms: %s\n", now - online_live_start, online_live_status); + } + return now - online_live_start < ONLINE_LIVE_TIMEOUT; +} + +static bool Online_LiveIdle(void) { + return !online.operation_pending && !online.updating && !online.leave_pending; +} + +/* Submission alone does not prove that leave/destroy reached the service. */ +static bool Online_LiveDeparture(void) { + Online_Leave(); + while (!Online_LiveIdle()) if (!Online_LivePump()) return false; + return !online.lobby[0] && !online.num_peers && !online.departure_failed; +} + +/* The first public update, not CreateLobby, grants host admission readiness. */ +static bool Online_LivePublished(void) { + while (!Online_HostReady()) { + if (!Online_LivePump() || !Online_Ready() || !Online_IsHost()) return false; + } + return true; +} + +/* Verify the same privacy transition used when the engine starts a match. */ +static bool Online_LiveAdmission(void) { + Online_CloseAdmission(); + while (online.host_dirty || online.updating) if (!Online_LivePump()) return false; + EOS_HLobbyDetails details = Online_Details(); + if (!details) return false; + EOS_LobbyDetails_Info *info = NULL; + EOS_LobbyDetails_CopyInfoOptions copy = { .ApiVersion = EOS_LOBBYDETAILS_COPYINFO_API_LATEST }; + bool valid = Online_Result("verify admission", EOS_LobbyDetails_CopyInfo(details, ©, &info)); + if (valid) { + valid = info->PermissionLevel == EOS_LPL_INVITEONLY && !Online_HostReady(); + EOS_LobbyDetails_Info_Release(info); + } + EOS_LobbyDetails_Release(details); + return valid; +} + +/* Public indexing is asynchronous; failed searches cannot establish absence. */ +static bool Online_LiveFind(cstring_t room, bool present) { + for (;;) { + while (!Online_LiveIdle()) if (!Online_LivePump()) return false; + Online_Search(); + while (online.operation_pending) if (!Online_LivePump()) return false; + /* A failed request cannot prove directory cleanup. */ + char expected[64]; + snprintf(expected, sizeof(expected), "%u Internet games found.", online.num_games); + if (strcmp(Online_Status(), expected)) return false; + bool found = false; + for (uint32_t i = 0; i < online.num_games; i++) + if (!strcmp(online.games[i].game.hostname, room)) found = true; + if (found == present) return true; + uint32_t wait = SDL_GetTicks(); + while (SDL_GetTicks() - wait < 5000) if (!Online_LivePump()) return false; + } +} + +/* Exercise real peer admission, channel routing, queues and full-size reassembly. */ +static bool Online_LiveExchange(bool host, bool relay, netadr_t *remote, uint8_t *payload, uint8_t *received) { + sizeBuf_t message = { .data = received, .maxsize = MAX_MSGLEN }; + NETSOURCE source = host ? NS_SERVER : NS_CLIENT; + if (!host) NET_SendPacket(source, MAX_MSGLEN, payload, *remote); + int size = 0; + while (!size) { + if (!Online_LivePump() || !online.lobby[0]) return false; + size = NET_GetPacket(source, remote, &message); + } + if (size != MAX_MSGLEN || memcmp(payload, received, MAX_MSGLEN)) return false; + EOS_ProductUserId user = EOS_ProductUserId_FromString(remote->peer); + onlinePeer_t *peer = Online_Peer(user, source); + if (!peer || (peer->network_type != EOS_NCT_DirectConnection && peer->network_type != EOS_NCT_RelayedConnection) || + (relay && peer->network_type != EOS_NCT_RelayedConnection)) return false; + if (host) NET_SendPacket(source, MAX_MSGLEN, payload, *remote); + fprintf(stderr, "EOS acceptance: verified %d-byte %s message over %s\n", MAX_MSGLEN, + host ? "client-to-server" : "server-to-client", + peer->network_type == EOS_NCT_RelayedConnection ? "relay" : "direct"); + return true; +} + +static bool Online_LiveLogin(void) { + if (!Online_Begin()) return false; + while (!Online_Ready()) { + if (!Online_LivePump() || (!online.ready && !online.authenticating)) return false; + } + return true; +} + +#include "games/warcraft-3/tests/online_gameplay.h" + +/* Pair installations without replacing the adapter or registering a networked TEST. */ +static bool Online_LiveRun(cstring_t role, cstring_t room, cstring_t map) { + bool solo = !strcmp(role, "solo"), host = solo || !strcmp(role, "host") || !strcmp(role, "crash-host"); + bool relay = Cvar_Integer("online_force_relay", 0) != 0; + if (!Online_LiveLogin()) return false; + EOS_ProductUserId identity = online.user; + EOS_ERelayControl policy; + EOS_P2P_GetRelayControlOptions control = { .ApiVersion = EOS_P2P_GETRELAYCONTROL_API_LATEST }; + if (!Online_Result("read relay policy", EOS_P2P_GetRelayControl(online.p2p, &control, &policy)) || + policy != (relay ? EOS_RC_ForceRelays : EOS_RC_AllowRelays)) return false; + if (!strncmp(role, "game-", 5)) return Online_LiveGameRun(role, room); + + if (host) { + Online_Host(map, room, 1, 2, 1); + if (!Online_LivePublished()) return false; + if (solo) { + if (!Online_LiveFind(room, true) || !Online_LiveAdmission() || + !Online_LiveDeparture() || !Online_LiveLogin()) return false; + if (online.user != identity) return false; + Online_Host(map, room, 1, 2, 2); + return Online_LivePublished() && Online_LiveDeparture() && Online_LiveLogin() && Online_LiveFind(room, false); + } + /* The guest proves public discovery. Once it joins this two-member + * room is full, so the browser correctly excludes it from searches. */ + } else { + if (!Online_LiveFind(room, true)) return false; + uint32_t index; + for (index = 0; index < online.num_games; index++) + if (!strcmp(online.games[index].game.hostname, room)) break; + if (index == online.num_games) return false; + netadr_t owner; + if (!NET_StringToAdr(online.games[index].game.address, 0, &owner)) return false; + if (EOS_ProductUserId_FromString(owner.peer) == online.user) { + fprintf(stderr, "EOS acceptance requires a second guest identity on another installation/OS user.\n"); + return false; + } + Online_Join(index); + while (online.operation_pending) if (!Online_LivePump()) return false; + if (!online.lobby[0]) return false; + } + + uint8_t *payload = malloc(MAX_MSGLEN), *received = malloc(MAX_MSGLEN); + if (!payload || !received) { free(payload); free(received); return false; } + for (uint32_t i = 0; i < MAX_MSGLEN; i++) payload[i] = (uint8_t)(i * 73); + netadr_t remote = {0}; + bool valid = host || Online_TakeConnection(&remote); + if (valid) valid = Online_LiveExchange(host, relay, &remote, payload, received); + free(payload); free(received); + if (!valid) return false; + if (host) { + uint8_t bytes[32]; sizeBuf_t ack = { .data = bytes, .maxsize = sizeof(bytes) }; + int size = 0; + while (!size) if (!Online_LivePump()) return false; else size = NET_GetPacket(NS_SERVER, &remote, &ack); + if (size != 4 || memcmp(bytes, "done", 4)) return false; + if (!strcmp(role, "crash-host")) { + fprintf(stderr, "EOS acceptance: exiting host without SDK teardown (simulate process crash).\n"); + fflush(stderr); _Exit(0); + } + return Online_LiveAdmission() && Online_LiveDeparture(); + } + NET_SendPacket(NS_CLIENT, 4, "done", remote); + uint32_t ended = SDL_GetTicks(); + while (online.lobby[0]) if (!Online_LivePump()) return false; + fprintf(stderr, "EOS acceptance: host loss detected after %u ms\n", SDL_GetTicks() - ended); + if (!Online_LiveDeparture() || !Online_LiveLogin() || !Online_LiveFind(room, false)) return false; + fprintf(stderr, "EOS acceptance: public room gone after %u ms\n", SDL_GetTicks() - ended); + return true; +} + +/* This explicit diagnostic owns its process and always returns a bounded result. */ +void Online_Acceptance_f(void) { + char role[32], room[sizeof(online.hosted.hostname)]; + char map[sizeof(online.hosted.mapname)]; + cstring_t map_path = Cvar_String("online_acceptance_map", "Maps/Campaign/Human02.w3m"); + if (Cmd_Argc() != 3 || !Cmd_Argv(2)[0] || strlen(Cmd_Argv(2)) >= sizeof(room) || strlen(Cmd_Argv(1)) >= sizeof(role)) { + fprintf(stderr, "EOS acceptance: invalid role/room arguments\n"); exit(1); + } + snprintf(role, sizeof(role), "%s", Cmd_Argv(1)); + snprintf(room, sizeof(room), "%s", Cmd_Argv(2)); + if ( + strlen(map_path) >= sizeof(map) || + (strcmp(role, "solo") && strcmp(role, "host") && strcmp(role, "guest") && strcmp(role, "crash-host") && + strcmp(role, "game-host") && strcmp(role, "game-guest") && strcmp(role, "game-crash-host") && + strcmp(role, "game-survivor") && strcmp(role, "game-crash-guest") && strcmp(role, "local"))) { + fprintf(stderr, "Usage: +online_acceptance \n"); + exit(1); + } + snprintf(map, sizeof(map), "%s", map_path); + if (SDL_InitSubSystem(SDL_INIT_TIMER) != 0 || !SDL_AddTimer(ONLINE_LIVE_TIMEOUT, Online_LiveWatchdog, NULL)) { + fprintf(stderr, "EOS acceptance cannot start deadline timer: %s\n", SDL_GetError()); + exit(1); + } + online_live_start = online_live_tick = SDL_GetTicks(); + online_live_status[0] = 0; + bool valid = !strcmp(role, "local") ? Online_LiveGameRun(role, room) : Online_LiveRun(role, room, map); + if (!valid && online_live_game) + fprintf(stderr, "EOS gameplay failed: role=%s client=%d server=%d frame=%d clients=%u status=%s\n", + role, cls.state, sv.state, cl.frame.serverframe, svs.num_clients, Online_Status()); + online_live_game = online_live_server = false; + if (SV_IsActive()) SV_Shutdown(); + /* Complete asynchronous departure before releasing SDK handles. */ + if (!Online_LiveDeparture()) valid = false; + Online_Shutdown(); + fprintf(stderr, "EOS acceptance %s: %s (%u ms)\n", role, valid ? "PASS" : "FAIL", SDL_GetTicks() - online_live_start); + exit(valid ? 0 : 1); +} diff --git a/tests/test_eos_acceptance_runner.py b/tests/test_eos_acceptance_runner.py new file mode 100644 index 000000000..9a34c1712 --- /dev/null +++ b/tests/test_eos_acceptance_runner.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Verify orchestration failures without contacting EOS or requiring Docker.""" + +import importlib.util +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import MagicMock, patch + + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location("eos_acceptance", ROOT / "dist-scripts/eos/run_acceptance.py") +runner = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(runner) + + +class EOSAcceptanceRunnerTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + self.cleanup = patch.object(runner.subprocess, "run", return_value= + subprocess.CompletedProcess([], 0, b"", b"")) + self.clean = self.cleanup.start() + self.addCleanup(self.cleanup.stop) + + def test_pair_uses_separate_containers_and_read_only_build(self): + processes = [MagicMock(), MagicMock()] + for process in processes: + process.wait.return_value = 0 + with patch.object(runner.subprocess, "Popen", side_effect=processes) as launch: + runner.run_scenario(self.root, "image", "fixture", "relay") + commands = [call.args[0] for call in launch.call_args_list] + self.assertEqual(len(commands), 2) + self.assertNotEqual(commands[0][commands[0].index("--name") + 1], + commands[1][commands[1].index("--name") + 1]) + for role, command in zip(("host", "guest"), commands): + self.assertIn(f"type=bind,source={self.root},target=/workspace,readonly", command) + self.assertEqual(command[-3:], ["+online_acceptance", role, "ci-fixture-relay"]) + self.assertEqual(command[command.index("+online_force_relay") + 1], "1") + self.assertEqual(self.clean.call_count, 2) + + def test_failed_host_fails_job_and_cleans_up_both_containers(self): + processes = [MagicMock(), MagicMock()] + processes[0].wait.return_value = 1 + with patch.object(runner.subprocess, "Popen", side_effect=processes): + with self.assertRaisesRegex(RuntimeError, "host exited with status 1"): + runner.run_scenario(self.root, "image", "fixture", "default") + self.assertEqual(self.clean.call_count, 2) + + def test_watchdog_timeout_fails_job_and_cleans_up(self): + process = MagicMock() + process.wait.side_effect = [subprocess.TimeoutExpired("docker", 220), 0] + with patch.object(runner.subprocess, "Popen", return_value=process): + with self.assertRaisesRegex(RuntimeError, "deadline"): + runner.run_scenario(self.root, "image", "fixture", "solo") + self.clean.assert_called_once() + + def test_crash_host_requires_successful_guest_cleanup(self): + processes = [MagicMock(), MagicMock()] + processes[0].wait.return_value = 0 + processes[1].wait.return_value = 1 + with patch.object(runner.subprocess, "Popen", side_effect=processes) as launch: + with self.assertRaisesRegex(RuntimeError, "guest exited with status 1"): + runner.run_scenario(self.root, "image", "fixture", "crash") + self.assertEqual(launch.call_args_list[0].args[0][-2], "crash-host") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/mpqtool.c b/tools/mpqtool.c index 27f3bf138..b2ec1d904 100644 --- a/tools/mpqtool.c +++ b/tools/mpqtool.c @@ -1179,13 +1179,13 @@ int main(int argc, char **argv) { return 1; } { - char *pairs[256]; - int pair_count = 0; - pairs[pair_count++] = (char *)arg; - for (int i = 0; i < extra_count && pair_count < (int)(sizeof(pairs) / sizeof(pairs[0])); i++) { - pairs[pair_count++] = extra[i]; - } - return cmd_pack(mpq, pair_count, pairs); + char **pairs = malloc((extra_count + 1) * sizeof(*pairs)); + if (!pairs) { fprintf(stderr, "pack: cannot allocate argument list\n"); return 1; } + pairs[0] = (char *)arg; + for (int i = 0; i < extra_count; i++) pairs[i + 1] = extra[i]; + int result = cmd_pack(mpq, extra_count + 1, pairs); + free(pairs); + return result; } } diff --git a/tools/wc3fixturegen.py b/tools/wc3fixturegen.py new file mode 100644 index 000000000..93dfc343c --- /dev/null +++ b/tools/wc3fixturegen.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Generate a redistributable two-player Warcraft III integration map.""" + +from pathlib import Path +import struct +import sys + + +def generate(directory): + directory.mkdir(parents=True, exist_ok=True) + pack = lambda fmt, *values: struct.pack("<" + fmt, *values) + string = lambda value: value.encode() + b"\0" + # ROC W3I v18, 32x32 tiles, two human players and one shared team. + info = pack("3I", 18, 1, 6060) + for value in ("Two-player transport fixture", "OpenRealm", "Generated test map", "2"): + info += string(value) + info += pack("8f4i2IIcI", -1536, -1536, -1536, 1536, 1536, 1536, 1536, -1536, + 4, 4, 4, 4, 24, 24, 0x40, b"L", 0xFFFFFFFF) + info += b"\0" * 3 + pack("I", 0) + b"\0" * 3 + pack("I", 2) + for number in range(2): + info += pack("4I", number, 1, 1, 1) + string(f"Player {number + 1}") + info += pack("2f2I", -256 + number * 512, 0, 0, 0) + info += pack("3I", 1, 3, 3) + string("Team 1") + pack("4I", 0, 0, 0, 0) + (directory / "war3map.w3i").write_bytes(info) + terrain = b"W3E!" + pack("IcI", 11, b"L", 0) + terrain += pack("I", 1) + b"Ldrt" + pack("I", 0) + terrain += pack("2I2f", 33, 33, -2048, -2048) + terrain += pack("2H3B", 0x2000, 0x2000, 0, 0, 2) * (33 * 33) + (directory / "war3map.w3e").write_bytes(terrain) + for name in ("war3map.doo", "war3mapUnits.doo"): + data = b"W3do" + pack("3I", 8, 11, 0) + if name == "war3map.doo": + data += pack("2I", 0, 0) + (directory / name).write_bytes(data) + (directory / "war3map.wpm").write_bytes(b"MP3W" + pack("3I", 0, 128, 128) + bytes(128 * 128)) + (directory / "war3map.j").write_text( + "function config takes nothing returns nothing\nendfunction\n" + "function main takes nothing returns nothing\n" + "call FogMaskEnable(false)\ncall FogEnable(false)\n" + "call CreateUnit(Player(0), 'hfoo', -768.0, 0.0, 0.0)\n" + "call CreateUnit(Player(1), 'hfoo', 768.0, 0.0, 0.0)\nendfunction\n") + + +if __name__ == "__main__": + generate(Path(sys.argv[1]))