From aa51dfccc65c56e78a6518415fb5130c4dd1cffb Mon Sep 17 00:00:00 2001 From: Gio Date: Mon, 12 Oct 2026 19:47:47 -0500 Subject: [PATCH] Port: Allow split-screen play in PC menus On the "pc" menu style, a LAN game whose players were all on the host's computer could not start a local splitscreen game. This patch fixes that check. START NOW only starts a countdown for a game that takes no remote connections, has two machines, or is "alone" (server_alone). server_alone only passed if there was literally one player, making splitscreen play impossible. Note the "xbox" menu style did not have this bug since its splitscreen didn't accept remote connections at all. server_alone becomes server_players_on_one_machine: TRUE if there is at least one player and every player is on the same machine. One player still qualifies. Machines that have joined without a player are not counted, so a lone host's countdown is not stopped when one connects (the per-tick server_ok_to_countdown check). The countdown is fully controlled by the host machine, so there is no change to the network protocol. There's an existing test failure on test_elevator_prediction which also exists on main, ignore. --- port/linux/README.md | 4 +- .../ui_widget_game_data_input_functions.c | 2 +- source/networking/network_server_manager.c | 38 +++- tools/harness/tests/lobby_countdown.c | 181 ++++++++++++++++++ tools/harness/tests/test_lobby_countdown.py | 84 ++++++++ 5 files changed, 299 insertions(+), 10 deletions(-) create mode 100644 tools/harness/tests/lobby_countdown.c create mode 100644 tools/harness/tests/test_lobby_countdown.py diff --git a/port/linux/README.md b/port/linux/README.md index 3c3f117735..769f96d43e 100644 --- a/port/linux/README.md +++ b/port/linux/README.md @@ -274,7 +274,9 @@ player's profile is chosen on the ADD PLAYER screen that opens (with any controller). With one gamepad, choose the lobby's ADD PLAYER button first: until then that gamepad shares controller 1 with the keyboard. Two players on one profile get different names from the host. A player's B in the lobby leaves the game alone, and the last player of the -computer leaves it for all of them. In the game, each player's pause menu +computer leaves it for all of them. When all the players are on the host's +computer, START NOW starts the game without another computer, and a team +game does not need a player on each team. In the game, each player's pause menu opens on their part of the screen, and its LEAVE GAME is theirs: their part of the screen stays until the game ends. A game under way shows its own screen before JOIN GAME: players join there the same way, START or ADD diff --git a/source/interface/ui_widget_game_data_input_functions.c b/source/interface/ui_widget_game_data_input_functions.c index b39fd464dd..2796b2a9d5 100644 --- a/source/interface/ui_widget_game_data_input_functions.c +++ b/source/interface/ui_widget_game_data_input_functions.c @@ -2922,7 +2922,7 @@ static void multiplayer_game_directions( if (server) { - /* port: one machine and one player may start (server_alone) */ + /* port: one machine and its players may start (server_players_on_one_machine) */ boolean waiting_for_machines = !network_game_is_splitscreen_local() && game && game->machine_count < 1; diff --git a/source/networking/network_server_manager.c b/source/networking/network_server_manager.c index 79681e5865..8f13ae30fb 100644 --- a/source/networking/network_server_manager.c +++ b/source/networking/network_server_manager.c @@ -3308,12 +3308,34 @@ boolean server_has_enough_machines( return has_enough_machines; } -/* port: a game's one player, with no one else in it yet, may start it: -a system link or internet game's host, or split screen's first player */ -static boolean server_alone( +/* port: TRUE if the game has at least one player and every player is on the +same machine; FALSE with no player. Such a game may start as a host alone +does: a system link or internet game's host, alone or with split screen +players. Machines are not counted, only players' machines: a machine that +has joined without a player does not stop the host's countdown +(server_ok_to_countdown) */ +static boolean server_players_on_one_machine( struct network_game_server *server) { - return server->game.player_count == 1; + short first_machine_index = NONE; + long player_index; + + for (player_index = 0; + player_index < MAXIMUM_NETWORK_PLAYER_COUNT; + player_index++) + { + struct network_player *player = &server->game.players[player_index]; + + if (!network_player_is_valid(player)) + continue; + + if (first_machine_index == NONE) + first_machine_index = (short)player->machine_index; + else if ((short)player->machine_index != first_machine_index) + return FALSE; + } + + return first_machine_index != NONE; } /* port: a machine joining or leaving is in the game before its player is. @@ -3324,8 +3346,8 @@ boolean server_ok_to_countdown( struct network_game_server *server) { if (server_has_enough_machines(server) && - (!server_needs_more_teams(server) || server_alone(server)) && - (server->game.player_count >= server->game.minimum_players || server_alone(server))) + (!server_needs_more_teams(server) || server_players_on_one_machine(server)) && + (server->game.player_count >= server->game.minimum_players || server_players_on_one_machine(server))) { return TRUE; } @@ -3486,7 +3508,7 @@ boolean network_game_server_game_can_start( match_assert(NETWORK_SERVER_MANAGER_FILE, 0x782, server); return server->state == 0 && - (server->game.player_count >= server->game.minimum_players || server_alone(server)); + (server->game.player_count >= server->game.minimum_players || server_players_on_one_machine(server)); } void network_game_server_pause_countdown( @@ -4003,7 +4025,7 @@ void network_game_server_update_countdown( { if (network_game_should_accept_remote_connections() == FALSE || network_game_server_get_client_machine_count(server) > 1 || - server_alone(server)) + server_players_on_one_machine(server)) { unsigned long countdown; diff --git a/tools/harness/tests/lobby_countdown.c b/tools/harness/tests/lobby_countdown.c new file mode 100644 index 0000000000..5cd25997a9 --- /dev/null +++ b/tools/harness/tests/lobby_countdown.c @@ -0,0 +1,181 @@ +/* +LOBBY_COUNTDOWN.C (test) + +The real pregame countdown of source/networking/network_server_manager.c: +whether the lobby's START NOW (the "faster" request, +_network_game_server_countdown_event_player_joined) starts the countdown, +for a host alone, a host with split screen players, and machines together. +test_lobby_countdown.py takes the constants, enums and timer structures +(config.inc) and the code (under_test.inc). +*/ + +#include "harness.h" + +#define NETWORK_SERVER_MANAGER_FILE "server" +#define MIN(a, b) ((a) < (b) ? (a) : (b)) + +#include "config.inc" + +struct network_player +{ + boolean valid; + signed char machine_index; + signed char team_index; +}; + +struct network_game_server_client_machine +{ + void *connection; + boolean joined; +}; + +struct network_game_server +{ + short state; + struct + { + short player_count; + short minimum_players; + struct + { + struct + { + boolean teams; + } universal_variant; + } variant; + struct network_player players[MAXIMUM_NETWORK_PLAYER_COUNT]; + } game; + struct network_game_server_client_machine client_machines[MAXIMUM_NETWORK_MACHINE_COUNT]; + struct network_game_server_countdown_state countdown_state; +}; + +static struct network_game_server server; +static int connection; + +static unsigned long system_milliseconds(void) { return 1000; } +static boolean network_game_should_accept_remote_connections(void) { return TRUE; } +static boolean network_game_is_splitscreen_local(void) { return FALSE; } +static boolean network_player_is_valid(struct network_player *player) { return player->valid; } +static boolean network_game_server_client_machine_is_joined_to_game( + struct network_game_server *s, + struct network_game_server_client_machine *machine) +{ + return machine->joined; +} +static void network_game_server_countdown_started(struct network_game_server *s) { } + +#include "under_test.inc" + +static void join_machine( + short machine_index) +{ + server.client_machines[machine_index].connection = &connection; + server.client_machines[machine_index].joined = TRUE; +} + +/* machine 0 is the host's; no players yet */ +static void lobby( + short machines) +{ + short index; + + memset(&server, 0, sizeof(server)); + server.state = _network_game_server_state_pregame; + server.game.minimum_players = 2; + for (index = 0; index < machines; index++) + join_machine(index); +} + +static void add_player( + short machine_index, + short team_index) +{ + struct network_player *player = &server.game.players[server.game.player_count++]; + + player->valid = TRUE; + player->machine_index = machine_index; + player->team_index = team_index; +} + +/* the lobby's START NOW */ +static void start_now(void) +{ + network_game_server_update_countdown(&server, _network_game_server_countdown_event_player_joined); +} + +int main(int argc, char **argv) +{ + char const *case_name = argc > 1 ? argv[1] : ""; + + CASE("split-screen-start-now") + { + lobby(1); + add_player(0, 0); + add_player(0, 1); + start_now(); + CHECK(server.countdown_state.active, "one machine's two players never start the countdown"); + CHECK(server.countdown_state.timer.time_remaining == NETWORK_GAME_COUNTDOWN_TIME, + "%ld ms to go, not a system link game's countdown", server.countdown_state.timer.time_remaining); + return 0; + } + CASE("alone-start-now") + { + lobby(1); + add_player(0, 0); + start_now(); + CHECK(server.countdown_state.active, "the host alone no longer starts the countdown"); + return 0; + } + CASE("empty-lobby-waits") + { + lobby(1); + CHECK(!server_ok_to_countdown(&server), "an empty lobby may count down"); + start_now(); + CHECK(!server.countdown_state.active, "an empty lobby started the countdown"); + return 0; + } + CASE("two-machines-start-now") + { + lobby(2); + add_player(0, 0); + add_player(1, 1); + start_now(); + CHECK(server.countdown_state.active, "two machines no longer start the countdown"); + return 0; + } + CASE("two-machines-team-game-needs-both-teams") + { + lobby(2); + server.game.variant.universal_variant.teams = TRUE; + add_player(0, 0); + add_player(1, 0); + CHECK(!server_ok_to_countdown(&server), "two machines' players of one team may count down"); + start_now(); + CHECK(!server.countdown_state.active, "two machines' players of one team started the countdown"); + return 0; + } + /* server_ok_to_countdown is checked each tick, and a failure stops the countdown */ + CASE("playerless-machine-keeps-countdown") + { + lobby(1); + add_player(0, 0); + start_now(); + CHECK(server.countdown_state.active, "the host alone no longer starts the countdown"); + join_machine(1); + CHECK(server_ok_to_countdown(&server), "a machine joining without a player stops the countdown"); + return 0; + } + /* intended: one machine skips the team rule, as a lone host does */ + CASE("split-screen-team-game-one-team") + { + lobby(1); + server.game.variant.universal_variant.teams = TRUE; + add_player(0, 0); + add_player(0, 0); + start_now(); + CHECK(server.countdown_state.active, "one machine's players of one team never start the countdown"); + return 0; + } + fprintf(stderr, "unknown case: %s\n", case_name); + return 2; +} diff --git a/tools/harness/tests/test_lobby_countdown.py b/tools/harness/tests/test_lobby_countdown.py new file mode 100644 index 0000000000..0bd69bccfc --- /dev/null +++ b/tools/harness/tests/test_lobby_countdown.py @@ -0,0 +1,84 @@ +"""The real pregame countdown of network_server_manager.c: the lobby's START NOW starts it for a host alone, a host with +split screen players and machines together, and a machine joining without a player does not stop it.""" + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from harness import CHECK_FAILED, build, constant, enum_with, function, mutated, read, run, structure # noqa: E402 + +CASES = [ + "split-screen-start-now", "alone-start-now", "empty-lobby-waits", "two-machines-start-now", + "two-machines-team-game-needs-both-teams", "playerless-machine-keeps-countdown", + "split-screen-team-game-one-team", +] +FUNCTIONS = ( + "countdown_timer_update", "countdown_timer_get_time_remaining", "countdown_timer_increment", + "countdown_timer_decrement", "countdown_timer_set_time_remaining", + "network_game_server_get_client_machine_count", "server_needs_more_teams", + "server_has_enough_machines", "server_players_on_one_machine", "server_ok_to_countdown", + "network_game_server_update_countdown", +) +CONSTANTS = ( + "MAXIMUM_NETWORK_MACHINE_COUNT", "MAXIMUM_NETWORK_PLAYER_COUNT", "NUMBER_OF_MULTIPLAYER_TEAMS", + "NETWORK_GAME_COUNTDOWN_TIME", "NETWORK_GAME_SPLITSCREEN_COUNTDOWN_TIME", + "NETWORK_GAME_COUNTDOWN_ADJUSTMENT", "NETWORK_GAME_MINIMUM_COUNTDOWN_TIME", +) + +# a fault in network_server_manager.c, the function it is in, and the case that must catch it +NEGATIVE_CONTROLS = { + "one-player-only": ( + ("else if ((short)player->machine_index != first_machine_index)", "else"), + "server_players_on_one_machine", "split-screen-start-now", + ), + "machines-ignored": ( + ("else if ((short)player->machine_index != first_machine_index)", "else if (0)"), + "server_players_on_one_machine", "two-machines-team-game-needs-both-teams", + ), + "no-player-counts": ( + ("return first_machine_index != NONE;", "return TRUE;"), + "server_players_on_one_machine", "empty-lobby-waits", + ), + "joined-machines-counted": ( + ( + "return first_machine_index != NONE;", + "return first_machine_index != NONE && network_game_server_get_client_machine_count(server) == 1;", + ), + "server_players_on_one_machine", "playerless-machine-keeps-countdown", + ), +} + + +def generated(fault=None, faulty_function=None): + source = read("source/networking/network_server_manager.c") + # (the machine and player counts are halo_port_limits.h's, which constant() does not follow into) + with_limits = source + read("port/linux/include/halo_port_limits.h") + config = "" + for name in CONSTANTS: + config += f"#define {name} {constant(with_limits, name)}\n" + for member in ("_network_game_server_state_pregame", "_network_game_server_countdown_event_player_left"): + config += enum_with(source, member) + "\n" + for name in ("countdown_timer", "network_game_server_countdown_state"): + config += structure(source, name) + "\n" + text = "" + for name in FUNCTIONS: + code = function(source, name) + if fault and name == faulty_function: + code = mutated(code, *fault) + text += code + "\n" + return (("config.inc", config), ("under_test.inc", text)) + + +@pytest.mark.parametrize("case", CASES) +def test_case(case): + status, output = run(build("lobby_countdown", generated()), case) + assert status == 0, output + + +@pytest.mark.parametrize("control", NEGATIVE_CONTROLS) +def test_negative_control(control): + fault, faulty_function, case = NEGATIVE_CONTROLS[control] + status, output = run(build("lobby_countdown", generated(fault, faulty_function)), case) + assert status == CHECK_FAILED, f"network_server_manager.c with a fault ({control}) passed '{case}': the test cannot see it"