From 2ecf0e82c8793f892efc0dae111e4c2b36d712e3 Mon Sep 17 00:00:00 2001 From: Michael Pfister Date: Sat, 3 Oct 2026 12:33:06 -0500 Subject: [PATCH 1/2] Discover Xbox v5 community multiplayer maps --- .github/workflows/build.yml | 4 +- docs/community-maps.md | 41 ++ port/linux/game/custom_maps.c | 154 ++++++ port/linux/game/menu_functions.c | 84 ++- port/linux/include/halo_custom_maps.h | 17 + port/linux/include/halo_port_capacity.h | 4 + port/windows/include/halo_custom_maps.h | 2 + source/cache/cache_files.c | 5 + source/cache/cache_files_windows.c | 12 +- source/interface/ui_widget.c | 9 +- .../ui_widget_event_handler_functions.c | 69 ++- .../ui_widget_game_data_input_functions.c | 56 ++ tools/test_custom_maps.py | 507 ++++++++++++++++++ 13 files changed, 932 insertions(+), 32 deletions(-) create mode 100644 docs/community-maps.md create mode 100644 port/linux/game/custom_maps.c create mode 100644 port/linux/include/halo_custom_maps.h create mode 100644 port/windows/include/halo_custom_maps.h create mode 100644 tools/test_custom_maps.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a4a5101001..9c6e27619c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -67,7 +67,9 @@ jobs: run: python tools/ci_build.py linux release - name: Test - run: python -m pytest -q tools/test_linux_port.py + run: | + python -m pytest -q tools/test_linux_port.py + python -m unittest tools.test_custom_maps - name: Compile cache run: ccache --show-stats diff --git a/docs/community-maps.md b/docs/community-maps.md new file mode 100644 index 0000000000..a9a63f5c83 --- /dev/null +++ b/docs/community-maps.md @@ -0,0 +1,41 @@ +# Xbox v5 community maps + +Native Windows, Linux, and Android builds discover compatible Xbox v5 +multiplayer caches in the active game-data map directory. Add the `.map` files +alongside the retail maps before starting the game, then open the multiplayer +map selector. If the installation uses a localized `maps_de`, `maps_fr`, +`maps_es`, or `maps_it` directory, use that active directory. Restart after +adding or removing maps: discovery runs once per process. + +The original 13 multiplayer maps retain their order, localized names, and +previews. Custom entries follow in case-insensitive name order, with a generic +preview and a community-map caption in both the PC and Xbox menu styles. The +list holds 128 maps total. A custom map uses its cache basename for selection, +saved preferences, and network identity. Keep the same compatible map files on +every player’s machine; map names alone do not establish matching contents. + +Discovery checks the Xbox v5 header and footer, multiplayer scenario type, +recognized regional build, bounded tag-data range, and a cache name matching +the filename without `.map`, ignoring case. Names must contain 1–31 ASCII +letters, digits, spaces, underscores, or hyphens. Retail-name duplicates are +not added. Use distinct names for community variants, such as `h1pb_prisoner`, +and preserve the original retail files. Matching header names require a cache +built with that identity; renaming a file alone is insufficient. + +The native multiplayer disk-cache limit is 128 MiB of declared uncompressed +data, including the exact boundary. The Xbox tag arena remains 22 MiB. Direct +precaching also enforces the multiplayer disk limit. These are header and +capacity checks, not complete validation of a map’s payload, tags, scripts, +game modes, or behavior. + +These files must already be in Xbox cache version 5. Halo PC v7 and Custom +Edition v609 files are different formats; renaming them or changing the version +field does not convert them. Source-tag conversion and game data are not +included in this change. + +Focused checks compile the production scanner, cache capacity checks, and both +menu adapters with synthetic maps and AddressSanitizer/UndefinedBehaviorSanitizer: + +```sh +python3 -m unittest tools.test_custom_maps -v +``` diff --git a/port/linux/game/custom_maps.c b/port/linux/game/custom_maps.c new file mode 100644 index 0000000000..005ca8604d --- /dev/null +++ b/port/linux/game/custom_maps.c @@ -0,0 +1,154 @@ +/* Discover compatible Xbox v5 multiplayer maps once per process. The + platform file API matches names and .map extensions case-insensitively. + Custom identities are cache basenames, fitting the engine's 64-byte stage + name without inventing a stock levels\\test\\\\ path. */ +#include "cseries.h" +#include "cache/cache_files.h" +#include "halo_custom_maps.h" +#include + +static char *map_list[HALO_CUSTOM_MAP_LIMIT]; +static char custom_names[HALO_CUSTOM_MAP_LIMIT][HALO_CUSTOM_MAP_NAME_SIZE]; +static short map_count; +static int initialized; + +static char const *const stock_names[] = { + "beavercreek", "sidewinder", "damnation", "ratrace", "prisoner", + "hangemhigh", "chillout", "carousel", "boardingaction", "bloodgulch", + "wizard", "putput", "longest" +}; + +char const *native_map_basename(char const *map) +{ + char const *p, *name = map; + if (!map) return ""; + for (p = map; *p; p++) + if (*p == '\\' || *p == '/') name = p + 1; + return name; +} + +int native_map_is_custom(char const *map) +{ + unsigned int i; + char const *name = native_map_basename(map); + for (i = 0; i < NUMBEROF(stock_names); i++) + if (!_stricmp(name, stock_names[i])) return FALSE; + return name[0] != 0; +} + +void native_map_display_name(char const *map, char *text, unsigned int size) +{ + unsigned int i; + int capitalize = TRUE; + char const *name = native_map_basename(map); + if (!size) return; + for (i = 0; i + 1 < size && name[i]; i++) { + unsigned char c = (unsigned char)name[i]; + if (c == '_') c = ' '; + if (capitalize && c >= 'a' && c <= 'z') c -= 'a' - 'A'; + text[i] = c; + capitalize = c == ' '; + } + text[i] = 0; +} + +static unsigned long little_u32(unsigned char const *p) +{ + return (unsigned long)p[0] | (unsigned long)p[1] << 8 | + (unsigned long)p[2] << 16 | (unsigned long)p[3] << 24; +} + +int native_map_header_valid(unsigned char const *header, char const *filename) +{ + char name[32], build[32]; + unsigned long length, tag_offset, tag_size; + unsigned int i, name_length; + if (memcmp(header, "daeh", 4) || memcmp(header + 0x7FC, "toof", 4) || + little_u32(header + 4) != 5 || header[0x60] != 1 || header[0x61] != 0 || + !memchr(header + 0x20, 0, 32) || !memchr(header + 0x40, 0, 32)) + return FALSE; + memcpy(name, header + 0x20, 32); + memcpy(build, header + 0x40, 32); + length = little_u32(header + 8); + tag_offset = little_u32(header + 0x10); + tag_size = little_u32(header + 0x14); + if (length < 2048 || length > HALO_PORT_MULTIPLAYER_CACHE_SIZE || + tag_offset < 2048 || tag_offset > length || + /* Nine 32-bit fields in the Xbox cache_file_tag_header. */ + tag_size < 0x24 || tag_size > 0x01600000 || tag_size > length - tag_offset || + !cache_files_build_region(build)) return FALSE; + name_length = (unsigned int)strlen(name); + if (!name_length || strlen(filename) != name_length + 4 || + _strnicmp(filename, name, name_length) || + _stricmp(filename + name_length, ".map")) return FALSE; + for (i = 0; i < name_length; i++) { + unsigned char c = (unsigned char)name[i]; + if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '_' || c == '-' || c == ' ')) + return FALSE; + } + return TRUE; +} + +static int map_compare(void const *a, void const *b) +{ + return _stricmp(*(char *const *)a, *(char *const *)b); +} + +char **native_multiplayer_map_list(char **stock, short stock_count, short *count) +{ + WIN32_FIND_DATAA entry; + HANDLE search; + char pattern[256]; + if (!initialized) { + short i; + /* Only the established stock list belongs ahead of discovered maps. */ + if (!stock || stock_count != HALO_STOCK_MULTIPLAYER_MAP_COUNT) { + *count = 0; + return map_list; + } + initialized = TRUE; + map_count = stock_count; + for (i = 0; i < stock_count; i++) map_list[i] = stock[i]; + if (strlen(cache_files_map_directory()) + 5 >= sizeof(pattern)) { + *count = map_count; + return map_list; + } + snprintf(pattern, sizeof(pattern), "%s*.map", cache_files_map_directory()); + search = FindFirstFileA(pattern, &entry); + if (search != INVALID_HANDLE_VALUE) { + do { + unsigned char header[2048]; + unsigned long read; + HANDLE file; + char path[256]; + int duplicate = FALSE; + if ((entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) || + strlen(entry.cFileName) + strlen(cache_files_map_directory()) >= sizeof(path)) + continue; + snprintf(path, sizeof(path), "%s%s", cache_files_map_directory(), entry.cFileName); + file = CreateFileA(path, GENERIC_READ, 0, NULL, OPEN_EXISTING, 0, NULL); + if (file == INVALID_HANDLE_VALUE) continue; + read = 0; + if (!ReadFile(file, header, sizeof(header), &read, NULL)) read = 0; + CloseHandle(file); + if (read != sizeof(header) || !native_map_header_valid(header, entry.cFileName)) continue; + for (i = 0; i < map_count; i++) + if (!_stricmp(native_map_basename(map_list[i]), (char *)header + 0x20)) duplicate = TRUE; + if (duplicate) continue; + if (map_count == HALO_CUSTOM_MAP_LIMIT) { + fprintf(stderr, "[maps] multiplayer map list is full (%d)\n", HALO_CUSTOM_MAP_LIMIT); + break; + } + memcpy(custom_names[map_count], header + 0x20, HALO_CUSTOM_MAP_NAME_SIZE); + map_list[map_count] = custom_names[map_count]; + map_count++; + } while (FindNextFileA(search, &entry)); + CloseHandle(search); + } + qsort(map_list + stock_count, map_count - stock_count, sizeof(map_list[0]), map_compare); + fprintf(stderr, "[maps] %d stock and %d custom multiplayer maps\n", stock_count, map_count - stock_count); + } + *count = map_count; + return map_list; +} diff --git a/port/linux/game/menu_functions.c b/port/linux/game/menu_functions.c index 8ae83bea79..6fdf574dd1 100644 --- a/port/linux/game/menu_functions.c +++ b/port/linux/game/menu_functions.c @@ -69,6 +69,7 @@ their handlers open opens. #include "text/unicode.h" #include "halo_menus.h" +#include "halo_custom_maps.h" #include #include @@ -1645,7 +1646,6 @@ Xbox's networking, run by the engine's port entry points (ui_widget_event_handler_functions.c) on our lists */ #define MAXIMUM_ADVERTISED_GAMES 9 -#define MULTIPLAYER_MAP_COUNT 13 #define MAP_ROWS 11 #define GAMETYPE_ROWS 10 #define MAXIMUM_GAMETYPES 100 @@ -1768,6 +1768,9 @@ static struct byte preview_xnaddr[12]; } multiplayer = { 0, 0, 0, { 0 }, 0, { 0 }, 0, 0, 0, 0, { 0 }, NUMBEROF(maximum_players) - 1 }; +static char const *const *multiplayer_map_names; +static short multiplayer_map_count; + /* ---- a text field (Direct Link's link, the game's name): the keyboard types into it (Ctrl+V pastes), its row's A (enter) is done, B (escape) cancels */ @@ -1916,18 +1919,48 @@ static boolean multiplayer_host(struct widget_instance *widget, struct event_rec /* ---- the map list */ +static void map_name_text(char const *name, wchar_t *text) +{ + char caption[HALO_CUSTOM_MAP_NAME_SIZE]; + short index; + + native_map_display_name(name, caption, sizeof(caption)); + for (index = 0; caption[index]; index++) text[index] = (unsigned char)caption[index]; + text[index] = 0; +} + +/* These preview boxes also show authored stock strings. Resize even when + one of those shorter strings already allocated their text buffer. */ +static void map_caption_set(struct widget_instance *widget, wchar_t const *caption) +{ + wchar_t *text; + if (!widget) return; + text = ui_widget_realloc(widget->parameters.text_box.text, + ROW_TEXT_LENGTH * sizeof(wchar_t), __FILE__, __LINE__); + if (!text) return; + widget->parameters.text_box.text = text; + ustrncpy(text, caption, ROW_TEXT_LENGTH - 1); + text[ROW_TEXT_LENGTH - 1] = 0; + widget->parameters.text_box.string_list_index = HALO_CUSTOM_MAP_TEXT; +} + static void map_row_text(short row, wchar_t *text) { - string_get("pc\\main_menu\\mp_map_list", (short)(multiplayer.map_first + row), text); + short map = (short)(multiplayer.map_first + row); + text[0] = 0; + if (map < 0 || map >= multiplayer_map_count) return; + if (map < HALO_STOCK_MULTIPLAYER_MAP_COUNT) + string_get("pc\\main_menu\\mp_map_list", map, text); + else + map_name_text(multiplayer_map_names[map], text); } /* "mp level list initialize" */ static boolean map_list_initialize(struct widget_instance *list) { - char const *const *names; - - ui_widget_port_multiplayer_maps(&names, &multiplayer.map_chosen); - multiplayer.map_first = (short)PIN(multiplayer.map_chosen - MAP_ROWS / 2, 0, MULTIPLAYER_MAP_COUNT - MAP_ROWS); + multiplayer_map_count = ui_widget_port_multiplayer_maps(&multiplayer_map_names, &multiplayer.map_chosen); + multiplayer.map_first = (short)PIN(multiplayer.map_chosen - MAP_ROWS / 2, + 0, MAX(0, multiplayer_map_count - MAP_ROWS)); focus_row(list, (short)(multiplayer.map_chosen - multiplayer.map_first)); return TRUE; } @@ -1937,11 +1970,23 @@ static void map_list_update(struct widget_instance *list) { struct widget_instance *description = list->parameters.list.extended_description; struct widget_instance *widget; - short map = list_scroll(list, &multiplayer.map_first, MULTIPLAYER_MAP_COUNT, MAP_ROWS); + short map = list_scroll(list, &multiplayer.map_first, multiplayer_map_count, MAP_ROWS); + wchar_t caption[ROW_TEXT_LENGTH]; - if (map != NONE) + if (map >= 0 && map < multiplayer_map_count) multiplayer.map_chosen = map; - rows_update(list, MAP_ROWS, map_row_text); + rows_update(list, (short)MIN(MAP_ROWS, MAX(0, multiplayer_map_count - multiplayer.map_first)), map_row_text); + if (multiplayer.map_chosen < 0 || multiplayer.map_chosen >= multiplayer_map_count) return; + if (multiplayer.map_chosen >= HALO_STOCK_MULTIPLAYER_MAP_COUNT) + { + map_name_text(multiplayer_map_names[multiplayer.map_chosen], caption); + map_caption_set(named(description, "mp_map_right_name", 0), caption); + map_caption_set(named(description, "mp_map_right_data", 0), L"Community map"); + if ((widget = named(description, "mp_map_right_pic", 0)) != NULL) + widget->animation.current_frame_index = 19; + profile_name_show(description); + return; + } if ((widget = named(description, "mp_map_right_name", 0)) != NULL) widget->parameters.text_box.string_list_index = multiplayer.map_chosen; if ((widget = named(description, "mp_map_right_pic", 0)) != NULL) @@ -2359,9 +2404,12 @@ static void game_map_name(struct advertised_game const *game, wchar_t *text) for (index = 0; index < count; index++) { - if (!_stricmp(names[index], game->map_name)) + if (!_stricmp(native_map_basename(names[index]), native_map_basename(game->map_name))) { - string_get("pc\\main_menu\\mp_map_list", index, text); + if (index < HALO_STOCK_MULTIPLAYER_MAP_COUNT) + string_get("pc\\main_menu\\mp_map_list", index, text); + else + map_name_text(names[index], text); return; } } @@ -2540,13 +2588,23 @@ static void lobby_map_show(struct widget_instance *description, char const *map_ for (index = 0; index < count; index++) { - if (!_stricmp(names[index], map_name)) + if (index < HALO_STOCK_MULTIPLAYER_MAP_COUNT && + !_stricmp(native_map_basename(names[index]), native_map_basename(map_name))) map = index; } if ((widget = named(description, "lobby_map_pic", 0)) != NULL) widget->animation.current_frame_index = map; if ((widget = named(description, "lobby_map_name", 0)) != NULL) - widget->parameters.text_box.string_list_index = map; + { + if (map == 19 && native_map_is_custom(map_name)) + { + wchar_t caption[ROW_TEXT_LENGTH]; + map_name_text(map_name, caption); + map_caption_set(widget, caption); + } + else + widget->parameters.text_box.string_list_index = map; + } } /* "port lobby update" */ diff --git a/port/linux/include/halo_custom_maps.h b/port/linux/include/halo_custom_maps.h new file mode 100644 index 0000000000..2a95f13c4b --- /dev/null +++ b/port/linux/include/halo_custom_maps.h @@ -0,0 +1,17 @@ +/* Compatible Xbox v5 multiplayer caches present when the game starts. */ +#ifndef __HALO_CUSTOM_MAPS_H +#define __HALO_CUSTOM_MAPS_H + +#define HALO_CUSTOM_MAP_LIMIT 128 +#define HALO_CUSTOM_MAP_NAME_SIZE 32 +#define HALO_STOCK_MULTIPLAYER_MAP_COUNT 13 +/* A text box owns its caption instead of indexing an authored string list. */ +#define HALO_CUSTOM_MAP_TEXT (-2) + +char **native_multiplayer_map_list(char **stock, short stock_count, short *count); +char const *native_map_basename(char const *map); +int native_map_is_custom(char const *map); +int native_map_header_valid(unsigned char const *header, char const *filename); +void native_map_display_name(char const *map, char *text, unsigned int size); + +#endif diff --git a/port/linux/include/halo_port_capacity.h b/port/linux/include/halo_port_capacity.h index 281793d5e2..e88d1d2b6e 100644 --- a/port/linux/include/halo_port_capacity.h +++ b/port/linux/include/halo_port_capacity.h @@ -15,6 +15,10 @@ client's own objects take the upper half of the object array). #ifndef __HALO_PORT_CAPACITY_H #define __HALO_PORT_CAPACITY_H +/* Multiplayer caches may exceed the Xbox's 47 MiB disk slots. This does + not enlarge the original 22 MiB tag arena. */ +#define HALO_PORT_MULTIPLAYER_CACHE_SIZE 0x08000000 + /* ---------- game state The Xbox game state is 0x345000 bytes at 0x80061000 and ends where the tag diff --git a/port/windows/include/halo_custom_maps.h b/port/windows/include/halo_custom_maps.h new file mode 100644 index 0000000000..0836e7878a --- /dev/null +++ b/port/windows/include/halo_custom_maps.h @@ -0,0 +1,2 @@ +/* Shared native map discovery. */ +#include "../../linux/include/halo_custom_maps.h" diff --git a/source/cache/cache_files.c b/source/cache/cache_files.c index 80d53d2757..b6332719d1 100644 --- a/source/cache/cache_files.c +++ b/source/cache/cache_files.c @@ -583,6 +583,11 @@ boolean cache_file_header_verify( header->footer_signature != CACHE_FILE_FOOTER_SIGNATURE || header->file_length < 0 || header->file_length > 0x11600000 || +#ifdef HALO_PORT_MULTIPLAYER_CACHE_SIZE + /* Direct/network precaching can reach a map without the menu scanner. */ + (header->reserved60[0] == 1 && header->reserved60[1] == 0 && + header->file_length > HALO_PORT_MULTIPLAYER_CACHE_SIZE) || +#endif csstrlen(header->name) > 31) { if (fatal) diff --git a/source/cache/cache_files_windows.c b/source/cache/cache_files_windows.c index 6760fb28f3..9646201d63 100644 --- a/source/cache/cache_files_windows.c +++ b/source/cache/cache_files_windows.c @@ -205,7 +205,11 @@ enum SOLO_CACHE_FILE_MAXIMUM_SIZE = 0x11600000, MAIN_MENU_CACHE_FILE_MAXIMUM_SIZE = 0x02300000, +#ifdef HALO_PORT_MULTIPLAYER_CACHE_SIZE + MULTIPLAYER_CACHE_FILE_MAXIMUM_SIZE = HALO_PORT_MULTIPLAYER_CACHE_SIZE, +#else MULTIPLAYER_CACHE_FILE_MAXIMUM_SIZE = 0x02F00000, +#endif }; /* ---------- macros */ @@ -1235,7 +1239,13 @@ static short cached_map_files_find_free_map( { struct cached_map_file *map_file = cached_map_file_get(map_file_index); - if (cached_map_file_get_size(map_file_index) > file_length) + if (cached_map_file_get_size(map_file_index) > file_length +#ifdef HALO_PORT_MULTIPLAYER_CACHE_SIZE + /* A native multiplayer cache may exactly fill its disk slot. */ + || (scenario_type == _scenario_type_multiplayer && + cached_map_file_get_size(map_file_index) == file_length) +#endif + ) { if (best_map_file_index == NONE || cached_map_file_get_size(map_file_index) < cached_map_file_get_size(best_map_file_index) || diff --git a/source/interface/ui_widget.c b/source/interface/ui_widget.c index f5502acb74..e074c8cde4 100644 --- a/source/interface/ui_widget.c +++ b/source/interface/ui_widget.c @@ -629,6 +629,9 @@ struct widget_instance; #include "cseries.h" #include "errors.h" +#ifdef HALO_PORT_MAXIMUM_NETWORK_PLAYERS +#include "halo_custom_maps.h" +#endif #include "bitmaps/bitmap_group.h" #include "bitmaps/bitmaps.h" #include "bink/bink_playback.h" @@ -5085,7 +5088,11 @@ static void widget_instance_render_text_box( rectangle2d bounds; rectangle2d clip; - if (definition->text_label_string_list.index != NONE) + if (definition->text_label_string_list.index != NONE +#ifdef HALO_PORT_MAXIMUM_NETWORK_PLAYERS + && widget->parameters.text_box.string_list_index != HALO_CUSTOM_MAP_TEXT +#endif + ) { short string_list_index; wchar_t *string; diff --git a/source/interface/ui_widget_event_handler_functions.c b/source/interface/ui_widget_event_handler_functions.c index b5ae04faf8..6b725a70ea 100644 --- a/source/interface/ui_widget_event_handler_functions.c +++ b/source/interface/ui_widget_event_handler_functions.c @@ -929,6 +929,9 @@ symbols in this file: #include "saved games/saved_game_files.h" #include "text/unicode.h" #include "halo_menus.h" /* port: PC_MENU_FUNCTION_BASE */ +#ifdef HALO_PORT_MAXIMUM_NETWORK_PLAYERS +#include "halo_custom_maps.h" +#endif /* ---------- constants */ @@ -2957,6 +2960,11 @@ static boolean multiplayer_level_list_initialize( char map_name[256]; struct ui_widget_definition *definition = ui_widget_definition_get(widget->definition_tag_index); short level_count = 13; + char **levels = event_handler_functions.multiplayer_levels; + +#ifdef HALO_PORT_MAXIMUM_NETWORK_PLAYERS + levels = native_multiplayer_map_list(levels, level_count, &level_count); +#endif match_vassert("c:\\halo\\SOURCE\\interface\\ui_widget_event_handler_functions.c", 1228, definition->type == 2, @@ -2964,14 +2972,19 @@ static boolean multiplayer_level_list_initialize( match_vassert("c:\\halo\\SOURCE\\interface\\ui_widget_event_handler_functions.c", 1229, definition->child_count == 3, "expected 3 list items for 'multiplayer level list' widget"); - widget->generated_list = event_handler_functions.multiplayer_levels; + widget->generated_list = levels; widget->generated_count = level_count; if (saved_game_file_retrieve_last_used_multiplayer_map(map_name)) { widget->data3C.selected_index = 0; while (widget->data3C.selected_index < level_count && +#ifdef HALO_PORT_MAXIMUM_NETWORK_PLAYERS + _stricmp(native_map_basename(map_name), + native_map_basename(levels[widget->data3C.selected_index]))) +#else _stricmp(map_name, - event_handler_functions.multiplayer_levels[widget->data3C.selected_index])) + levels[widget->data3C.selected_index])) +#endif { widget->data3C.selected_index++; } @@ -5579,6 +5592,14 @@ static boolean playlist_profile_change_player_options( return result; } +/* The saved-game API writes a fixed 256-byte field from this pointer. */ +static void multiplayer_map_remember(char const *map_name) +{ + char stored_name[256] = {0}; + csstrncpy(stored_name, map_name, sizeof(stored_name) - 1); + saved_game_file_remember_last_used_multiplayer_map(stored_name); +} + static boolean multiplayer_level_select( struct widget_instance *widget, struct event_record *event, @@ -5611,9 +5632,13 @@ static boolean multiplayer_level_select( "expected 3 list items for 'multiplayer level list' widget"); level_list = widget->child->child; match_vassert("c:\\halo\\SOURCE\\interface\\ui_widget_event_handler_functions.c", 1298, - level_list->data3C.selected_index >= 0 && level_list->data3C.selected_index < 13, + level_list->data3C.selected_index >= 0 && + level_list->data3C.selected_index < level_list->generated_count, "invalid multiplayer level specified from 'multiplayer level list' list widget"); - map_name = event_handler_functions.multiplayer_levels[level_list->data3C.selected_index]; + if (!level_list->generated_list || level_list->data3C.selected_index < 0 || + level_list->data3C.selected_index >= level_list->generated_count) + return FALSE; + map_name = ((char **)level_list->generated_list)[level_list->data3C.selected_index]; file = fopen("d:\\map_automation.txt", "r"); if (file) { @@ -5642,11 +5667,12 @@ static boolean multiplayer_level_select( if (server) network_game_server_change_map_name(server, map_name); } - for (level_index = 0; level_index < 13; level_index++) + for (level_index = 0; level_index < level_list->generated_count; level_index++) { - if (!_stricmp(map_name, event_handler_functions.multiplayer_levels[level_index])) + char *listed_map = ((char **)level_list->generated_list)[level_index]; + if (!_stricmp(map_name, listed_map)) { - saved_game_file_remember_last_used_multiplayer_map(event_handler_functions.multiplayer_levels[level_index]); + multiplayer_map_remember(listed_map); break; } } @@ -5842,25 +5868,34 @@ static boolean solo_level_initialize_list_single_player( /* port: the PC version's multiplayer menus (port/linux/game/menu_functions.c), on our lists rather than the Xbox's spinners: */ -/* the multiplayer maps (the Xbox's 13), and the one used last (else 0) */ +/* The stock maps followed by compatible v5 caches, and the last used map. */ short ui_widget_port_multiplayer_maps( char const *const **names, short *last_used) { char map_name[256]; short level_index; + short level_count = 13; + char **levels = event_handler_functions.multiplayer_levels; - *names = (char const *const *)event_handler_functions.multiplayer_levels; +#ifdef HALO_PORT_MAXIMUM_NETWORK_PLAYERS + levels = native_multiplayer_map_list(levels, level_count, &level_count); +#endif + *names = (char const *const *)levels; *last_used = 0; if (saved_game_file_retrieve_last_used_multiplayer_map(map_name)) { - for (level_index = 0; level_index < 13; level_index++) + for (level_index = 0; level_index < level_count; level_index++) { - if (!_stricmp(map_name, event_handler_functions.multiplayer_levels[level_index])) +#ifdef HALO_PORT_MAXIMUM_NETWORK_PLAYERS + if (!_stricmp(native_map_basename(map_name), native_map_basename(levels[level_index]))) +#else + if (!_stricmp(map_name, levels[level_index])) +#endif *last_used = level_index; } } - return 13; + return level_count; } /* the map chosen (as multiplayer_level_select), the server's if there is @@ -5869,11 +5904,14 @@ boolean ui_widget_port_multiplayer_map_choose( short level_index) { char const *map_name; + char const *const *names; + short last_used; + short level_count = ui_widget_port_multiplayer_maps(&names, &last_used); void *server = global_network_game_server_get(); - if (level_index < 0 || level_index >= 13) + if (level_index < 0 || level_index >= level_count) return FALSE; - map_name = event_handler_functions.multiplayer_levels[level_index]; + map_name = names[level_index]; { char build[0x20]; @@ -5888,7 +5926,7 @@ boolean ui_widget_port_multiplayer_map_choose( game_engine_override_map_name(map_name); if (server) network_game_server_change_map_name(server, map_name); - saved_game_file_remember_last_used_multiplayer_map(event_handler_functions.multiplayer_levels[level_index]); + multiplayer_map_remember(map_name); return TRUE; } @@ -6122,4 +6160,3 @@ boolean ui_widget_port_gametype_save( } return player_ui_save_profile(); } - diff --git a/source/interface/ui_widget_game_data_input_functions.c b/source/interface/ui_widget_game_data_input_functions.c index 620a184c48..4efa004291 100644 --- a/source/interface/ui_widget_game_data_input_functions.c +++ b/source/interface/ui_widget_game_data_input_functions.c @@ -355,6 +355,9 @@ symbols in this file: #include "text/text_group.h" #include "text/unicode.h" #include "halo_menus.h" /* port: PC_MENU_FUNCTION_BASE */ +#ifdef HALO_PORT_MAXIMUM_NETWORK_PLAYERS +#include "halo_custom_maps.h" +#endif /* ---------- constants */ @@ -688,6 +691,24 @@ static byte const local_player_controller_bitmap_frames[2][MAXIMUM_LOCAL_PLAYERS /* ---------- public code */ +#ifdef HALO_PORT_MAXIMUM_NETWORK_PLAYERS +static void custom_map_text(struct widget_instance *widget, char const *caption) +{ + char name[HALO_CUSTOM_MAP_NAME_SIZE]; + unsigned int i; + wchar_t *text; + + native_map_display_name(caption, name, sizeof(name)); + widget->parameters.text_box.string_list_index = HALO_CUSTOM_MAP_TEXT; + text = ui_widget_realloc(widget->parameters.text_box.text, + sizeof(name) * sizeof(wchar_t), __FILE__, __LINE__); + if (!text) return; + widget->parameters.text_box.text = text; + for (i = 0; name[i]; i++) text[i] = (unsigned char)name[i]; + text[i] = 0; +} +#endif + void ui_widget_game_data_function_invoke( struct widget_instance *widget, word function) @@ -1127,6 +1148,11 @@ static void server_list_menu_update( } map_name = server->map_name; +#ifdef HALO_PORT_MAXIMUM_NETWORK_PLAYERS + if (native_map_is_custom(map_name)) + map_bitmap->animation.current_frame_index = 13; + else +#endif if (strstr(map_name, "beavercreek")) map_bitmap->animation.current_frame_index = 0; else if (strstr(map_name, "sidewinder")) @@ -1160,6 +1186,10 @@ static void server_list_menu_update( (server->open == TRUE) ? 20 : 21; map_name_text->parameters.text_box.string_list_index = map_bitmap->animation.current_frame_index; +#ifdef HALO_PORT_MAXIMUM_NETWORK_PLAYERS + if (native_map_is_custom(map_name)) + custom_map_text(map_name_text, map_name); +#endif switch (server->engine_type) { @@ -2418,6 +2448,13 @@ static void multiplayer_game_set_text_box_for_map_name( if (game) { map_name = game->map.name; +#ifdef HALO_PORT_MAXIMUM_NETWORK_PLAYERS + if (native_map_is_custom(map_name)) + { + custom_map_text(widget, map_name); + return; + } +#endif if (strstr(map_name, "beavercreek")) { widget->parameters.text_box.string_list_index = 0; @@ -2686,6 +2723,13 @@ static void multiplayer_game_set_bitmap_for_map( if (game) { map_name = game->map.name; +#ifdef HALO_PORT_MAXIMUM_NETWORK_PLAYERS + if (native_map_is_custom(map_name)) + { + widget->animation.current_frame_index = 13; + return; + } +#endif if (strstr(map_name, "beavercreek")) { widget->animation.current_frame_index = 0; @@ -4235,6 +4279,18 @@ static void mp_level_select_list_update_displayed_items( (short)displayed_item_indices[item_index]; map_description->parameters.text_box.string_list_index = (short)displayed_item_indices[item_index]; +#ifdef HALO_PORT_MAXIMUM_NETWORK_PLAYERS + if (displayed_item_indices[item_index] >= HALO_STOCK_MULTIPLAYER_MAP_COUNT) + { + char const *map = "Unknown map"; + if (list_widget->parameters.list.list_items && + displayed_item_indices[item_index] < list_widget->parameters.list.number_of_items) + map = ((char **)list_widget->parameters.list.list_items)[displayed_item_indices[item_index]]; + custom_map_text(map_name, map); + custom_map_text(map_description, "Community map"); + map_bitmap->animation.current_frame_index = 13; + } +#endif } return; } diff --git a/tools/test_custom_maps.py b/tools/test_custom_maps.py new file mode 100644 index 0000000000..94c0964771 --- /dev/null +++ b/tools/test_custom_maps.py @@ -0,0 +1,507 @@ +"""Compile production Xbox-v5 discovery and menu adapters with synthetic maps. + +Only platform file I/O, unrelated engine services and authored tag lookup are +stubbed. AddressSanitizer covers the fixed-size save and dynamic caption paths. +No game data, application launch, or optional CE conversion is required. +""" +from pathlib import Path +import re +import shutil +import struct +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +STOCK = ("beavercreek", "sidewinder", "damnation", "ratrace", "prisoner", "hangemhigh", + "chillout", "carousel", "boardingaction", "bloodgulch", "wizard", "putput", "longest") + + +def c_block(source, start): + opening = source.index("{", start) + depth = 0 + tokens = re.finditer(r'//[^\n]*|/\*.*?\*/|"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\'|[{}]', + source[opening:], re.S) + for token in tokens: + if token.group() == "{": + depth += 1 + elif token.group() == "}": + depth -= 1 + if not depth: + return source[start:opening + token.end()] + raise ValueError("Unclosed C block") + + +def function(source, name): + match = re.search(r"(?m)^(?:static\s+)?[\w *]+\b" + re.escape(name) + r"\s*\([^;{]*\)\s*\{", source) + if not match: + raise ValueError(f"Production definition not found: {name}") + return c_block(source, match.start()) + + +def header(name="downrush", version=5, kind=1, length=8192, build="01.10.12.2276", + tag_offset=2048, tag_size=0x24): + result = bytearray(2048) + result[:4] = b"daeh" + struct.pack_into(" +#include +#include +#include +#include +#include +#include +typedef unsigned char byte; +typedef unsigned char boolean; +typedef unsigned short word; +typedef float real; +#define TRUE 1 +#define FALSE 0 +#define NONE (-1) +#define NUMBEROF(a) (sizeof(a)/sizeof((a)[0])) +#define MIN(a,b) ((a)<(b)?(a):(b)) +#define MAX(a,b) ((a)>(b)?(a):(b)) +#define PIN(a,b,c) MIN(MAX((a),(b)),(c)) +#define _stricmp strcasecmp +#define _strnicmp strncasecmp +#define csstrlen strlen +#define csstrncmp strncmp +#define csstrncpy strncpy +#define csmemcpy memcpy +#define match_assert(file,line,test) assert(test) +#define match_vassert(file,line,test,message) assert(test) +''' + +XTL = r''' +#pragma once +typedef void *HANDLE; +typedef struct { unsigned long dwFileAttributes; char cFileName[260]; } WIN32_FIND_DATAA; +#define INVALID_HANDLE_VALUE ((HANDLE)-1) +#define GENERIC_READ 1 +#define OPEN_EXISTING 1 +#define FILE_ATTRIBUTE_DIRECTORY 16 +HANDLE CreateFileA(const char *, int, int, void *, int, int, void *); +int ReadFile(HANDLE, void *, unsigned long, unsigned long *, void *); +int CloseHandle(HANDLE); +HANDLE FindFirstFileA(const char *, WIN32_FIND_DATAA *); +int FindNextFileA(HANDLE, WIN32_FIND_DATAA *); +''' + +FILES = r''' +#include +#include +static char directory[512]; +static int handles; +const char *cache_files_map_directory(void) {return directory;} +struct handle {int kind; FILE *file; DIR *dir;}; +HANDLE CreateFileA(const char *name,int access,int share,void *security,int creation,int flags,void *template) { + struct handle *h=calloc(1,sizeof(*h)); h->kind=1; h->file=fopen(name,"rb"); + if (!h->file) {free(h); return INVALID_HANDLE_VALUE;} handles++; return h; +} +int ReadFile(HANDLE handle,void *data,unsigned long size,unsigned long *read,void *overlapped) { + struct handle *h=handle; *read=fread(data,1,size,h->file); return !ferror(h->file); +} +int CloseHandle(HANDLE handle) { + struct handle *h=handle; if(h->kind==1) fclose(h->file); else closedir(h->dir); + free(h); handles--; return 1; +} +int FindNextFileA(HANDLE handle,WIN32_FIND_DATAA *out) { + struct handle *h=handle; struct dirent *entry; + while((entry=readdir(h->dir))) { + size_t length=strlen(entry->d_name); + if(length<4 || strcasecmp(entry->d_name+length-4,".map")) continue; + char path[1024]; struct stat info; snprintf(path,sizeof(path),"%s%s",directory,entry->d_name); + if(stat(path,&info)) continue; + out->dwFileAttributes=S_ISDIR(info.st_mode)?FILE_ATTRIBUTE_DIRECTORY:0; + snprintf(out->cFileName,sizeof(out->cFileName),"%s",entry->d_name); return 1; + } return 0; +} +HANDLE FindFirstFileA(const char *pattern,WIN32_FIND_DATAA *out) { + struct handle *h=calloc(1,sizeof(*h)); h->dir=opendir(directory); + if(!h->dir || !FindNextFileA(h,out)) {if(h->dir) closedir(h->dir); free(h); return INVALID_HANDLE_VALUE;} + handles++; return h; +} +static void set_directory(const char *path) {snprintf(directory,sizeof(directory),"%s/",path);} +static char *stock[]={ + "levels\\test\\beavercreek\\beavercreek", "levels\\test\\sidewinder\\sidewinder", + "levels\\test\\damnation\\damnation", "levels\\test\\ratrace\\ratrace", + "levels\\test\\prisoner\\prisoner", "levels\\test\\hangemhigh\\hangemhigh", + "levels\\test\\chillout\\chillout", "levels\\test\\carousel\\carousel", + "levels\\test\\boardingaction\\boardingaction", "levels\\test\\bloodgulch\\bloodgulch", + "levels\\test\\wizard\\wizard", "levels\\test\\putput\\putput", "levels\\test\\longest\\longest" +}; +''' + +SCANNER_MAIN = r''' +int main(int argc,char **argv) { + assert(argc>=3); + if(!strcmp(argv[1],"header")) { + assert(argc==4); unsigned char data[2048]; FILE *file=fopen(argv[2],"rb"); assert(file); + assert(fread(data,1,sizeof(data),file)==sizeof(data)); fclose(file); + printf("%d\n",native_map_header_valid(data,argv[3])); return 0; + } + set_directory(argv[2]); short count=123; + if(!strcmp(argv[1],"invalid_stock")) { + native_multiplayer_map_list(NULL,13,&count); assert(count==0); + native_multiplayer_map_list(stock,-1,&count); assert(count==0); + native_multiplayer_map_list(stock,129,&count); assert(count==0); + } + char **names=native_multiplayer_map_list(stock,13,&count); + assert(count>=13 && count<=HALO_CUSTOM_MAP_LIMIT && handles==0); + for(short i=0;i<13;i++) assert(names[i]==stock[i]); + for(short i=13;i13) assert(strcasecmp(names[i-1],names[i])<0); + } + if(!strcmp(argv[1],"immutable")) { + assert(argc==4); set_directory(argv[3]); short repeated=0; + assert(native_multiplayer_map_list(stock,13,&repeated)==names && repeated==count && handles==0); + } + char label[40]; memset(label,'x',sizeof(label)); + native_map_display_name("levels\\test\\h1pb_prisoner",label,5); + assert(!strcmp(label,"H1pb") && label[5]=='x'); + native_map_display_name(NULL,label,sizeof(label)); assert(!label[0]); + assert(!native_map_is_custom(NULL) && !native_map_is_custom("LEVELS\\TEST\\PRISONER\\PRISONER")); + assert(native_map_is_custom("levels\\test\\h1pb_prisoner\\h1pb_prisoner")); + for(short i=0;i=0 && index<8); return &definitions[index];} +static int fail_allocation; +static void *ui_widget_realloc(void *p,size_t size,const char *file,long line) {return fail_allocation?NULL:realloc(p,size);} +static wchar_t *ustrncpy(wchar_t *dest,const wchar_t *src,size_t size) { + size_t i=0; for(;i=0 && index<13); stock_lookups++; return (wchar_t *)stock_text[index]; +} +static void profile_name_show(struct widget_instance *widget) {} +static char last_used[256],main_map[256],stage_map[256],server_map[256],saved_copy[256]; +static int server_present,local_game,region_supported=1,unavailable,save_count; +static boolean saved_game_file_retrieve_last_used_multiplayer_map(char *out) {memcpy(out,last_used,256);return last_used[0]!=0;} +static void saved_game_file_remember_last_used_multiplayer_map(const char *name) { + /* The real API reads the entire field, even for short registry strings. */ + memcpy(saved_copy,name,sizeof(saved_copy)); save_count++; + for(size_t i=strlen(saved_copy)+1;ichild; while(n-- && child)child=child->next;return child; +} +static long displayed[3]; +static void spinner_list_3wide_determine_displayed_item_indices(struct widget_instance *widget,long *out) {memcpy(out,displayed,sizeof(displayed));} +static struct widget_instance *ui_widget_port_error_text_box; +static const wchar_t *ui_widget_port_error_text; +#define SPINNER_EXTRA_DESCRIPTION_BASE 20000 +static const wchar_t *kills_to_win_extra_descriptions[]={L"extra"}; +static void *widget_memory_pool; +static void *pool_resize_pointer(void *pool,void *pointer,size_t size,const char *file,long line) {return realloc(pointer,size);} +''' + +MENU_MAIN = r''' +static void free_text(struct widget_instance *widget) {free(widget->parameters.text_box.text);widget->parameters.text_box.text=NULL;} +int main(int argc,char **argv) { + assert(argc==2); set_directory(argv[1]); memcpy(event_handler_functions.multiplayer_levels,stock,sizeof(stock)); + char const *const *names;short last; short count=ui_widget_port_multiplayer_maps(&names,&last); + assert(count==16 && last==0 && !strcmp(names[13],"atlas") && !strcmp(names[14],"downrush") && !strcmp(names[15],"h1pb_prisoner")); + strcpy(last_used,"levels\\test\\downrush\\downrush"); + assert(ui_widget_port_multiplayer_maps(&names,&last)==16 && last==14); + server_present=1; + assert(!ui_widget_port_multiplayer_map_choose(-1) && !ui_widget_port_multiplayer_map_choose(16) && save_count==0); + assert(ui_widget_port_multiplayer_map_choose(15)); + assert(!strcmp(main_map,"h1pb_prisoner") && !strcmp(stage_map,main_map) && !strcmp(server_map,main_map) && !strcmp(saved_copy,main_map)); + region_supported=0; assert(!ui_widget_port_multiplayer_map_choose(14) && unavailable==1 && save_count==1); + local_game=1; assert(ui_widget_port_multiplayer_map_choose(14));local_game=0;region_supported=1; + assert(ui_widget_port_multiplayer_map_choose(4) && !strcmp(saved_copy,stock[4])); + + /* The Xbox selector uses its generated list, including the same custom IDs. */ + struct event_widget wrapper={0},screen={0},selector={0}; boolean deleted=0; + wrapper.definition_tag_index=0;wrapper.child=&screen;screen.definition_tag_index=1;screen.child=&selector; + selector.definition_tag_index=2;definitions[0].child_count=1;definitions[1].child_count=3; + definitions[2].type=2;definitions[2].child_count=3; + assert(multiplayer_level_list_initialize(&selector,NULL,&deleted)); + assert(selector.generated_count==16 && selector.data3C.selected_index==14); + selector.data3C.selected_index=15;assert(multiplayer_level_select(&wrapper,NULL,&deleted)); + assert(!strcmp(saved_copy,"h1pb_prisoner") && !strcmp(main_map,saved_copy)); + + /* Actual PC scrolling and row update, including the end of the list. */ + struct widget_instance list={0},rows[12]={0},row_text[11]={0},description={0},preview[3]={0}; + char row_names[12][24]; + for(int i=0;i<12;i++) { + snprintf(row_names[i],sizeof(row_names[i]),i<11?"list_item_%d":"buttons",i); + rows[i].name=row_names[i];rows[i].next=i<11?&rows[i+1]:NULL; + if(i<11){rows[i].child=&row_text[i];row_text[i].name="list_item_text";} + } + preview[0].name="mp_map_right_name";preview[1].name="mp_map_right_pic";preview[2].name="mp_map_right_data"; + preview[0].next=&preview[1];preview[1].next=&preview[2];description.child=preview; + list.child=rows;list.parameters.list.extended_description=&description; + assert(map_list_initialize(&list) && multiplayer.map_chosen==14 && multiplayer.map_first==5); + map_list_update(&list); assert(wide_equals(preview[0].parameters.text_box.text,L"Downrush")); + assert(preview[1].animation.current_frame_index==19 && preview[0].parameters.text_box.string_list_index==HALO_CUSTOM_MAP_TEXT); + assert(wide_equals(preview[2].parameters.text_box.text,L"Community map")); + list.focused_child=&rows[10];map_list_update(&list);assert(multiplayer.map_chosen==15); + assert(wide_equals(row_text[10].parameters.text_box.text,L"H1pb Prisoner")); + list.focused_child=&rows[11];map_list_update(&list);assert(multiplayer.map_chosen==15); + multiplayer.map_first=0;focus_row(&list,4);map_list_update(&list); + assert(multiplayer.map_chosen==4 && preview[0].parameters.text_box.string_list_index==4 && preview[1].animation.current_frame_index==4); + render_caption(&preview[0],&definitions[3]);assert(wide_equals(preview[0].parameters.text_box.text,L"Prisoner")); + /* Stock rendering allocated only18 bytes: custom caption must resize it. */ + multiplayer.map_first=5;focus_row(&list,10);map_list_update(&list); + int lookups=stock_lookups;render_caption(&preview[0],&definitions[3]); + assert(stock_lookups==lookups && wide_equals(preview[0].parameters.text_box.text,L"H1pb Prisoner")); + wchar_t caption[64];map_row_text(11,caption);assert(caption[0]==0); + + /* Browser/lobby stock basenames and aliases never share stock indices. */ + struct advertised_game advertised;strcpy(advertised.map_name,"levels\\test\\h1pb_prisoner\\h1pb_prisoner"); + game_map_name(&advertised,caption);assert(wide_equals(caption,L"H1pb Prisoner")); + strcpy(advertised.map_name,"PRISONER");game_map_name(&advertised,caption);assert(wide_equals(caption,L"Prisoner")); + preview[0].name="lobby_map_name";preview[1].name="lobby_map_pic"; + lobby_map_show(&description,"levels\\test\\h1pb_prisoner\\h1pb_prisoner"); + assert(preview[1].animation.current_frame_index==19 && preview[0].parameters.text_box.string_list_index==HALO_CUSTOM_MAP_TEXT); + lobby_map_show(&description,"prisoner");assert(preview[1].animation.current_frame_index==4 && preview[0].parameters.text_box.string_list_index==4); + + /* Xbox three-card captions and lobby; preserve the retail fallback13. */ + struct widget_instance xbox_list={0},cards[3]={0},card_labels[3][3]={0}; + xbox_list.definition_tag_index=2;xbox_list.child=cards;xbox_list.parameters.list.list_items=(void *)names;xbox_list.parameters.list.number_of_items=count; + definitions[4].child_count=3;definitions[5].type=1;definitions[6].type=0; + for(int i=0;i<3;i++) { + cards[i].definition_tag_index=4;cards[i].child=card_labels[i];cards[i].next=i<2?&cards[i+1]:NULL; + for(int j=0;j<3;j++){card_labels[i][j].definition_tag_index=j==1?6:5;card_labels[i][j].next=j<2?&card_labels[i][j+1]:NULL;} + } + displayed[0]=4;displayed[1]=15;displayed[2]=16; + mp_level_select_list_update_displayed_items(&xbox_list); + assert(card_labels[0][0].parameters.text_box.string_list_index==4 && card_labels[0][1].animation.current_frame_index==4); + assert(wide_equals(card_labels[1][0].parameters.text_box.text,L"H1pb Prisoner") && card_labels[1][1].animation.current_frame_index==13); + assert(wide_equals(card_labels[2][0].parameters.text_box.text,L"Unknown Map")); + fail_allocation=1;displayed[0]=15;mp_level_select_list_update_displayed_items(&xbox_list);fail_allocation=0; + assert(card_labels[0][0].parameters.text_box.string_list_index==HALO_CUSTOM_MAP_TEXT); + struct widget_instance xbox_name={0},xbox_picture={0};xbox_name.type=1; + strcpy(network_game.map.name,"levels\\test\\h1pb_prisoner\\h1pb_prisoner"); + multiplayer_game_set_text_box_for_map_name(&xbox_name);multiplayer_game_set_bitmap_for_map(&xbox_picture); + assert(wide_equals(xbox_name.parameters.text_box.text,L"H1pb Prisoner") && xbox_picture.animation.current_frame_index==13); + strcpy(network_game.map.name,stock[4]);multiplayer_game_set_text_box_for_map_name(&xbox_name);multiplayer_game_set_bitmap_for_map(&xbox_picture); + assert(xbox_name.parameters.text_box.string_list_index==4 && xbox_picture.animation.current_frame_index==4); + for(int i=0;i<11;i++) free_text(&row_text[i]);free_text(&preview[0]);free_text(&preview[2]);free_text(&xbox_name); + for(int i=0;i<3;i++){free_text(&card_labels[i][0]);free_text(&card_labels[i][2]);} + assert(handles==0);return 0; +} +''' + +CACHE_STUBS = r''' +#define CACHE_FILE_HEADER_SIGNATURE 0x68656164UL +#define CACHE_FILE_FOOTER_SIGNATURE 0x666F6F74UL +enum {_scenario_type_solo, _scenario_type_multiplayer, _scenario_type_main_menu}; +struct cached_map_file {long last_modification_date;}; +static struct cached_map_file cached_maps[6]; +static struct {short open_map_file_index;} cache_file_globals={NONE}; +static struct cached_map_file *cached_map_file_get(short index) {assert(index>=0 && index<6);return &cached_maps[index];} +static long cached_map_file_get_size(short index) { + assert(index>=0 && index<6);return index<=1?0x11600000:index==2?0x02300000:HALO_PORT_MULTIPLAYER_CACHE_SIZE; +} +static int CompareFileTime(const long *a,const long *b) {return *a>*b?1:*a<*b?-1:0;} +''' + +CACHE_MAIN = r''' +int main(void) { + assert(sizeof(struct cache_file_header)==2048); + struct cache_file_header header={0}; + header.header_signature=CACHE_FILE_HEADER_SIGNATURE;header.footer_signature=CACHE_FILE_FOOTER_SIGNATURE; + header.version=5;strcpy(header.name,"weld");header.reserved60[0]=1; + header.file_length=60972544;assert(cache_file_header_verify(&header,"weld",FALSE)); + assert(cached_map_files_find_free_map(header.file_length,_scenario_type_multiplayer)>=3); + header.file_length=HALO_PORT_MULTIPLAYER_CACHE_SIZE; + assert(cache_file_header_verify(&header,"boundary",FALSE)); + assert(cached_map_files_find_free_map(header.file_length,_scenario_type_multiplayer)>=3); + header.file_length++;assert(!cache_file_header_verify(&header,"too-large",FALSE)); + header.reserved60[0]=0;assert(cache_file_header_verify(&header,"solo",FALSE)); + assert(cached_map_files_find_free_map(header.file_length,_scenario_type_solo)<2); + header.reserved60[0]=1;header.file_length=0x11600000; + assert(!cache_file_header_verify(&header,"direct-precache",FALSE)); + header.file_length=8192;header.version=7;assert(!cache_file_header_verify(&header,"pc",FALSE)); + return 0; +} +''' + + +def fixture_sources(): + cache = (ROOT / "source/cache/cache_files.c").read_text() + table = cache[cache.index("static struct\n{\n\tchar const *build;"):cache.index("/* the region of a build")] + region = ("static struct {struct {char build[32];} header;} cache_file_globals;\n" + + table + function(cache, "cache_files_build_region")) + common = '#include "cseries.h"\n#include "xtl.h"\n#include "halo_port_capacity.h"\n' + region + FILES + '\n#include "custom_maps.c"\n' + menu = (ROOT / "port/linux/game/menu_functions.c").read_text() + events = (ROOT / "source/interface/ui_widget_event_handler_functions.c").read_text() + data = (ROOT / "source/interface/ui_widget_game_data_input_functions.c").read_text() + renderer = (ROOT / "source/interface/ui_widget.c").read_text() + widget = c_block(menu, menu.index("struct widget_instance\n")) + ";\n" + event_widget = (c_block(events, events.index("struct widget_instance\n")) + ";\n").replace("widget_instance", "event_widget") + functions = [] + for name in ("multiplayer_map_remember", "ui_widget_port_multiplayer_maps", "ui_widget_port_multiplayer_map_choose", + "multiplayer_level_list_initialize", "multiplayer_level_select"): + functions.append(function(events, name).replace("widget_instance", "event_widget")) + for name in ("descendant", "named", "text_set_length", "text_set", "string_get", "focused_row", "focus_row", + "rows_update", "list_scroll", "map_name_text", "map_caption_set", "map_row_text", + "map_list_initialize", "map_list_update", "game_map_name", "lobby_map_show"): + functions.append(function(menu, name)) + for name in ("custom_map_text", "mp_level_select_list_update_displayed_items", + "multiplayer_game_set_text_box_for_map_name", "multiplayer_game_set_bitmap_for_map"): + functions.append(function(data, name)) + # Compile the exact renderer's authored-string loading path, before drawing. + render = function(renderer, "widget_instance_render_text_box") + begin = render.index("\tif (definition->text_label_string_list.index") + end = render.index("\ttext = &widget->parameters.text_box.text;", begin) + functions.append("static void render_caption(struct widget_instance *widget, struct ui_widget_definition *definition)\n{\n" + render[begin:end] + "}\n") + cache_header = c_block(cache, cache.index("struct cache_file_header\n")) + ";\n" + cache_header = re.sub(r"\blong\b", "int32_t", cache_header.replace("unsigned long", "uint32_t")) + slots = (ROOT / "source/cache/cache_files_windows.c").read_text() + cache_fixture = ('#include "cseries.h"\n#include "halo_port_capacity.h"\n' + cache_header + CACHE_STUBS + + function(cache, "cache_file_header_verify") + function(slots, "cached_map_files_find_free_map") + CACHE_MAIN) + return {"scanner": common + SCANNER_MAIN, + "menus": common + widget + event_widget + MENU_STUBS + "\n\n".join(functions) + MENU_MAIN, + "cache": cache_fixture} + + +class NativeCustomMaps(unittest.TestCase): + @classmethod + def setUpClass(cls): + compiler = shutil.which("clang") + if not compiler: + raise unittest.SkipTest("clang is required for production C fixtures") + cls.temp = tempfile.TemporaryDirectory(prefix="halo-v5-community-") + cls.folder = Path(cls.temp.name) + (cls.folder / "cache").mkdir() + (cls.folder / "cseries.h").write_text("#pragma once\n" + CSERIES) + (cls.folder / "xtl.h").write_text(XTL) + (cls.folder / "cache/cache_files.h").write_text("char const *cache_files_map_directory(void);\nchar const *cache_files_build_region(char const *);\n") + cls.binaries = {} + for name, source in fixture_sources().items(): + path = cls.folder / f"{name}.c" + path.write_text(source) + binary = cls.folder / f"test-{name}" + result = subprocess.run([compiler, "-std=gnu11", "-fshort-wchar", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-parameter", "-Wno-unused-function", "-Wno-multichar", "-Wno-sign-compare", + "-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-DHALO_PORT_MAXIMUM_NETWORK_PLAYERS=128", + "-I", str(cls.folder), "-iquote", str(ROOT / "port/linux/include"), + "-I", str(ROOT / "port/linux/game"), str(path), "-o", str(binary)], capture_output=True, text=True) + if result.returncode: + raise AssertionError(result.stderr) + cls.binaries[name] = binary + + @classmethod + def tearDownClass(cls): + cls.temp.cleanup() + + def run_fixture(self, fixture, *args): + result = subprocess.run([str(self.binaries[fixture]), *map(str, args)], cwd=self.folder, + capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + return result.stdout.splitlines() + + def valid(self, data, filename="downrush.map"): + path = self.folder / "header.bin" + path.write_bytes(data) + return self.run_fixture("scanner", "header", path, filename) == ["1"] + + def test_header_versions_regions_bounds_and_names(self): + for build in ("01.10.12.2276", "01.01.14.2342", "01.08.15.1749"): + self.assertTrue(self.valid(header(build=build))) + self.assertTrue(self.valid(header(), "DOWNRUSH.MAP")) + self.assertTrue(self.valid(header(length=128 * 1024 * 1024))) + self.assertTrue(self.valid(header(name="a" * 31), "a" * 31 + ".map")) + for kwargs in ({"version": 7}, {"version": 609}, {"kind": 0}, {"kind": 2}, + {"build": "unsupported"}, {"length": 2047}, {"length": 128 * 1024 * 1024 + 1}, + {"tag_offset": 2047}, {"tag_offset": 8193}, {"tag_offset": 0xFFFFFFF0}, + {"tag_size": 0x23}, {"tag_size": 8192}, {"tag_size": 0xFFFFFFFF}, + {"name": ""}, {"name": "../downrush"}, {"name": "bad:name"}): + with self.subTest(kwargs=kwargs): + self.assertFalse(self.valid(header(**kwargs))) + self.assertFalse(self.valid(header(), "alias.map")) + for offset in (0, 0x7FC, 32, 64): + broken = header() + broken[offset:offset + (32 if offset in (32, 64) else 4)] = b"x" * (32 if offset in (32, 64) else 4) + self.assertFalse(self.valid(broken)) + + def populate(self, directory, names): + directory.mkdir() + for name in names: + (directory / f"{name}.map").write_bytes(header(name) + b"\0" * 6144) + + def test_discovery_order_aliases_invalid_files_and_immutable_lifetime(self): + with tempfile.TemporaryDirectory(dir=self.folder) as temp: + root = Path(temp); maps = root / "maps" + self.populate(maps, ("h1pb_prisoner", "downrush", "atlas", "prisoner")) + (maps / "ATLAS.MAP").write_bytes(header("atlas")) + for name, data in {"v7.map": header("v7", version=7), "ui.map": header("ui", kind=2), + "bad.map": b"short", "other.map": header("mismatch"), + "wrongbuild.map": header("wrongbuild", build="unsupported"), + "partial.map.part": header("partial")}.items(): + (maps / name).write_bytes(data) + (maps / "directory.map").mkdir() + (maps / "broken.map").symlink_to(maps / "missing.map") + second = root / "second"; self.populate(second, ("late",)) + before = {p.name: p.read_bytes() for p in maps.iterdir() if p.is_file()} + names = self.run_fixture("scanner", "immutable", maps, second) + self.assertEqual(names[:13], [f"levels\\test\\{name}\\{name}" for name in STOCK]) + self.assertEqual(names[13:], ["atlas", "downrush", "h1pb_prisoner"]) + self.assertEqual(before, {p.name: p.read_bytes() for p in maps.iterdir() if p.is_file()}) + + def test_capacity_and_missing_directory_fallback(self): + with tempfile.TemporaryDirectory(dir=self.folder) as temp: + maps = Path(temp) / "maps" + self.populate(maps, [f"custom_{i:03}" for i in range(150)]) + self.assertEqual(len(self.run_fixture("scanner", "invalid_stock", maps)), 128) + self.assertEqual(len(self.run_fixture("scanner", "scan", maps / "missing")), 13) + + def test_production_pc_and_xbox_selectors_captions_and_saved_identity(self): + with tempfile.TemporaryDirectory(dir=self.folder) as temp: + maps = Path(temp) / "maps"; self.populate(maps, ("atlas", "downrush", "h1pb_prisoner")) + self.run_fixture("menus", maps) + + def test_direct_cache_header_and_multiplayer_slot_capacity_agree(self): + self.run_fixture("cache") + + +if __name__ == "__main__": + unittest.main() From f89cde011525923f4c9fd5dbb534a6a9e07f1585 Mon Sep 17 00:00:00 2001 From: Michael Pfister Date: Sat, 3 Oct 2026 12:54:08 -0500 Subject: [PATCH 2/2] Fix Enter input after closing the profile keyboard --- .github/workflows/build.yml | 2 +- port/linux/src/xinput_sdl.c | 13 +- source/interface/virtual_keyboard.c | 27 ++-- tools/test_virtual_keyboard_input.py | 207 +++++++++++++++++++++++++++ 4 files changed, 234 insertions(+), 15 deletions(-) create mode 100644 tools/test_virtual_keyboard_input.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9c6e27619c..7160c3264f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -69,7 +69,7 @@ jobs: - name: Test run: | python -m pytest -q tools/test_linux_port.py - python -m unittest tools.test_custom_maps + python -m unittest tools.test_custom_maps tools.test_virtual_keyboard_input - name: Compile cache run: ccache --show-stats diff --git a/port/linux/src/xinput_sdl.c b/port/linux/src/xinput_sdl.c index 06672577af..e406b34160 100644 --- a/port/linux/src/xinput_sdl.c +++ b/port/linux/src/xinput_sdl.c @@ -210,6 +210,7 @@ arrows, which move about it, enter (Done, once let go of since it came up) and escape (cancel) */ static BOOL text_typing; static BOOL text_typing_enter_armed; +static BOOL text_typing_enter_blocked; /* (the on-screen keyboard's, and a menu's text field's: menu_functions.c) */ static BOOL text_typing_keyboard, text_typing_field; @@ -219,6 +220,8 @@ static void text_typing_update(void) if (typing && !text_typing) text_typing_enter_armed = FALSE; + if (!typing && text_typing) + text_typing_enter_blocked = TRUE; text_typing = typing; } @@ -256,6 +259,12 @@ static void keyboard_gamepad(const struct platform_input_state *input, XINPUT_GA BOOL mouse = !input->mouse_released; const unsigned char *m = input->mouse_buttons; int x = 0, y = 0; + BOOL enter = k[SDL_SCANCODE_RETURN] || k[SDL_SCANCODE_KP_ENTER]; + + /* The Enter that finished typing must be released before it can + activate the menu underneath the keyboard. */ + if (!enter) + text_typing_enter_blocked = FALSE; if (text_typing) { @@ -284,8 +293,8 @@ static void keyboard_gamepad(const struct platform_input_state *input, XINPUT_GA /* (escape backs out, as backspace does: the pause menu's B resumes the game, the main menu's asks to quit; Start would choose, as A does) */ - pad->bAnalogButtons[XINPUT_GAMEPAD_A] |= analog(k[SDL_SCANCODE_SPACE] || k[SDL_SCANCODE_RETURN] || - k[SDL_SCANCODE_KP_ENTER]); + pad->bAnalogButtons[XINPUT_GAMEPAD_A] |= analog(k[SDL_SCANCODE_SPACE] || + (enter && !text_typing_enter_blocked)); pad->bAnalogButtons[XINPUT_GAMEPAD_B] |= analog(k[SDL_SCANCODE_ESCAPE] || k[SDL_SCANCODE_BACKSPACE] || (mouse && m[SDL_BUTTON_X1])); #ifdef HALO_ANDROID diff --git a/source/interface/virtual_keyboard.c b/source/interface/virtual_keyboard.c index d12181f7d9..818fa41c26 100644 --- a/source/interface/virtual_keyboard.c +++ b/source/interface/virtual_keyboard.c @@ -347,6 +347,16 @@ static rectangle2d keyboard_rect[NUMBER_OF_VIRTUAL_KEYS] = static struct virtual_keyboard_globals virtual_keyboard_globals= {0}; +/* Keep the computer keyboard's input mode in sync with every open/close + path. The UI stops processing the virtual keyboard once it is inactive. */ +static void virtual_keyboard_set_active(boolean active) +{ + extern void platform_text_typing(int typing); + + virtual_keyboard_globals.active = active; + platform_text_typing(active); +} + /* ---------- public code */ boolean virtual_keyboard_initialize( @@ -354,7 +364,7 @@ boolean virtual_keyboard_initialize( { long keyboard_index; - virtual_keyboard_globals.active = FALSE; + virtual_keyboard_set_active(FALSE); virtual_keyboard_globals.shift_active = FALSE; virtual_keyboard_globals.caps_active = FALSE; virtual_keyboard_globals.symbols_active = FALSE; @@ -399,7 +409,7 @@ boolean virtual_keyboard_initialize( void virtual_keyboard_dispose( void) { - virtual_keyboard_globals.active = FALSE; + virtual_keyboard_set_active(FALSE); virtual_keyboard_globals.shift_active = FALSE; virtual_keyboard_globals.caps_active = FALSE; virtual_keyboard_globals.symbols_active = FALSE; @@ -440,7 +450,7 @@ boolean virtual_keyboard_launch( event_manager_flush(); virtual_keyboard_globals.row = 0; virtual_keyboard_globals.column = 0; - virtual_keyboard_globals.active = TRUE; + virtual_keyboard_set_active(TRUE); virtual_keyboard_globals.text_buffer = text_buffer; virtual_keyboard_globals.cursor = text_buffer + ustrlen(text_buffer); virtual_keyboard_globals.buffer_size = buffer_size; @@ -553,7 +563,7 @@ static boolean virtual_keyboard_tab_down( static boolean virtual_keyboard_cancel( void) { - virtual_keyboard_globals.active = FALSE; + virtual_keyboard_set_active(FALSE); if (virtual_keyboard_globals.text_buffer) { ustrncpy( @@ -957,13 +967,6 @@ void virtual_keyboard_render( void virtual_keyboard_process( void) { - /* port: while it is up the computer's keyboard types into it - (port/linux/src/xinput_sdl.c) */ - { - extern void platform_text_typing(int typing); - - platform_text_typing(virtual_keyboard_globals.active); - } if (virtual_keyboard_globals.active) virtual_keyboard_process_internal(); @@ -1013,7 +1016,7 @@ static boolean virtual_keyboard_select( virtual_keyboard_globals.last_exit_saved_text = TRUE; } ui_play_audio_feedback_sound(_ui_audio_feedback_back); - virtual_keyboard_globals.active = FALSE; + virtual_keyboard_set_active(FALSE); event_manager_flush(); break; diff --git a/tools/test_virtual_keyboard_input.py b/tools/test_virtual_keyboard_input.py new file mode 100644 index 0000000000..cc83f7b63e --- /dev/null +++ b/tools/test_virtual_keyboard_input.py @@ -0,0 +1,207 @@ +"""Exercise the production virtual-keyboard lifecycle and keyboard-to-pad mapping. + +Only tag lookup, profile validation and unrelated UI services are stubbed. The +fixtures need no game data or running application. +""" +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +if __package__: + from .test_custom_maps import c_block, function +else: + from test_custom_maps import c_block, function + +ROOT = Path(__file__).resolve().parents[1] + +STUBS = r''' +#include +#include +#include +#include +#include +typedef unsigned char byte, boolean, BYTE; +typedef unsigned short word; +typedef int BOOL; +typedef short SHORT; +#define TRUE 1 +#define FALSE 0 +#define NONE (-1) +#define MIN(a,b) ((a)<(b)?(a):(b)) +#define csmemset memset +#define csmemmove memmove +#define match_assert(file,line,test) assert(test) +#define match_vassert(file,line,test,message) assert(test) +#define error(...) ((void)0) +#define BITMAP_GROUP_TAG 1 +#define VIRTUAL_KEYBOARD_TAG 2 +#define virtual_keyboard_definition_get(index) ((struct virtual_keyboard_definition *)1) +enum {_error_already_a_saved_game_file_with_that_name, _error_cannot_create_saved_game_file_with_empty_name}; +static int keyboard_available=1, unique_name=1, valid_name=1, action; +static long tag_loaded(int group,const char *name) {return keyboard_available?0:NONE;} +static void event_manager_flush(void) {} +static void ui_play_audio_feedback_sound(int sound) {} +static void display_error(int code,int controller,int a,int b) {} +static unsigned long system_milliseconds(void) {return 0;} +static int saved_game_file_name_unique(const wchar_t *name) {return unique_name;} +static int player_name_clean(wchar_t *name,long count) {return valid_name;} +static size_t ustrlen(const wchar_t *s) {size_t n=0;while(s[n])n++;return n;} +static int ustrcmp(const wchar_t *a,const wchar_t *b) {while(*a && *a==*b){a++;b++;}return *a-*b;} +static void ustrncpy(wchar_t *d,const wchar_t *s,size_t n) {for(size_t i=0;i=0) input.keys[key]=1; + keyboard_gamepad(&input,&pad);return pad; +} +static void expect_menu_enter(int key) { + poll_key(-1); + XINPUT_GAMEPAD pad=poll_key(key); + assert(pad.bAnalogButtons[XINPUT_GAMEPAD_A]==255); + assert(!(pad.wButtons&XINPUT_GAMEPAD_START)); +} +static void open_keyboard(wchar_t *name,int key) { + assert(virtual_keyboard_launch(name,32,8)); + /* Launch changes input mode before the first processing frame. */ + XINPUT_GAMEPAD pad=poll_key(key); + assert(!pad.bAnalogButtons[XINPUT_GAMEPAD_A]); + assert(!(pad.wButtons&XINPUT_GAMEPAD_START)); + /* Match ui_widgets_process: it calls this only while active. */ + virtual_keyboard_process(); + pad=poll_key(key); + assert(!pad.bAnalogButtons[XINPUT_GAMEPAD_A]); + assert(!(pad.wButtons&XINPUT_GAMEPAD_START)); + poll_key(-1);pad=poll_key(key); + assert(pad.wButtons&XINPUT_GAMEPAD_START); + assert(!pad.bAnalogButtons[XINPUT_GAMEPAD_A]); +} +int main(int argc,char **argv) { + assert(argc==3);int test=atoi(argv[1]),key=atoi(argv[2])?SDL_SCANCODE_KP_ENTER:SDL_SCANCODE_RETURN; + wchar_t name[16]=L"Player"; + assert(virtual_keyboard_initialize()); + expect_menu_enter(key); + if(test==4) { + virtual_keyboard_dispose();keyboard_available=0;assert(!virtual_keyboard_initialize()); + assert(!virtual_keyboard_launch(name,sizeof(name),8)); + expect_menu_enter(key);return 0; + } + open_keyboard(name,key); + if(test==0) {action=1;virtual_keyboard_process();assert(virtual_keyboard_last_exit_saved_text());} + if(test==1) {action=2;virtual_keyboard_process();assert(!virtual_keyboard_last_exit_saved_text());} + if(test==2) virtual_keyboard_close(); + if(test==3) virtual_keyboard_dispose(); + if(test==5) { + platform_text_field(TRUE);virtual_keyboard_close(); + assert(poll_key(key).wButtons&XINPUT_GAMEPAD_START); + platform_text_field(FALSE); + } + if(test==6) { + name[0]=L'N';unique_name=0;action=1;virtual_keyboard_process(); + assert(!virtual_keyboard_last_exit_saved_text()); + } + if(test==7) {assert(virtual_keyboard_initialize());} + assert(!virtual_keyboard_active()); + /* Closing must clear typing immediately; no inactive process call. */ + XINPUT_GAMEPAD held=poll_key(key); + assert(!(held.wButtons&XINPUT_GAMEPAD_START)); + assert(!held.bAnalogButtons[XINPUT_GAMEPAD_A]); + expect_menu_enter(key); + /* A second keyboard must arm independently after its opening Enter. */ + if(test!=3) {action=0;open_keyboard(name,key);virtual_keyboard_close();expect_menu_enter(key);} + return 0; +} +''' + + +def fixture_source(keyboard=None, inputs=None): + keyboard = keyboard or (ROOT / "source/interface/virtual_keyboard.c").read_text() + inputs = inputs or (ROOT / "port/linux/src/xinput_sdl.c").read_text() + constants = keyboard[keyboard.index("enum\n{"):keyboard.index("/* ---------- macros */")] + state = c_block(keyboard, keyboard.index("struct virtual_keyboard_globals\n")) + ";\n" + layout = c_block(keyboard, keyboard.index("static char const virtual_keyboard_layout_table")) + ";\n" + # Include the production mode flags and all of their transition logic. + input_mode = inputs[inputs.index("static BOOL text_typing;"):inputs.index("/* the keys held when the game")] + lifecycle = [] + if "static void virtual_keyboard_set_active" in keyboard: + lifecycle.append(function(keyboard, "virtual_keyboard_set_active")) + for name in ("virtual_keyboard_initialize", "virtual_keyboard_dispose", "virtual_keyboard_launch", + "virtual_keyboard_active", "virtual_keyboard_last_exit_saved_text", + "virtual_keyboard_cancel", "virtual_keyboard_free_space_in_text_buffer", + "virtual_keyboard_backspace", "virtual_keyboard_close", "virtual_keyboard_select", + "virtual_keyboard_process"): + lifecycle.append(function(keyboard, name)) + return "\n".join((STUBS, constants, state, layout, + "static struct virtual_keyboard_globals virtual_keyboard_globals;", + function(inputs, "analog"), input_mode, *lifecycle, MAIN)) + + +class VirtualKeyboardInputTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + compiler = shutil.which("clang") + if not compiler: + raise unittest.SkipTest("clang is required for the production C fixture") + cls.directory = tempfile.TemporaryDirectory(prefix="halo-keyboard-input-") + cls.addClassCleanup(cls.directory.cleanup) + source = Path(cls.directory.name) / "keyboard.c" + cls.executable = source.with_suffix("") + source.write_text(fixture_source()) + subprocess.run([compiler, "-std=c11", "-fshort-wchar", "-fsanitize=address,undefined", + "-fno-omit-frame-pointer", str(source), "-o", str(cls.executable)], check=True) + + def run_case(self, case): + for keypad in (0, 1): + with self.subTest(keypad=keypad): + subprocess.run([str(self.executable), str(case), str(keypad)], check=True) + + def test_done_restores_enter_without_accepting_held_key(self): + self.run_case(0) + + def test_cancel_restores_enter(self): + self.run_case(1) + + def test_external_close_restores_enter(self): + self.run_case(2) + + def test_dispose_restores_enter(self): + self.run_case(3) + + def test_failed_launch_keeps_menu_input(self): + self.run_case(4) + + def test_separate_text_field_keeps_its_typing_mode(self): + self.run_case(5) + + def test_rejected_profile_name_restores_enter(self): + self.run_case(6) + + def test_reinitialize_restores_enter(self): + self.run_case(7) + + +if __name__ == "__main__": + unittest.main()