Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 tools.test_virtual_keyboard_input

- name: Compile cache
run: ccache --show-stats
Expand Down
41 changes: 41 additions & 0 deletions docs/community-maps.md
Original file line number Diff line number Diff line change
@@ -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
```
154 changes: 154 additions & 0 deletions port/linux/game/custom_maps.c
Original file line number Diff line number Diff line change
@@ -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\\<name>\\<name> path. */
#include "cseries.h"
#include "cache/cache_files.h"
#include "halo_custom_maps.h"
#include <xtl.h>

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;
}
84 changes: 71 additions & 13 deletions port/linux/game/menu_functions.c
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ their handlers open opens.
#include "text/unicode.h"

#include "halo_menus.h"
#include "halo_custom_maps.h"

#include <stdlib.h>
#include <string.h>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -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;
}
Expand All @@ -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)
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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" */
Expand Down
17 changes: 17 additions & 0 deletions port/linux/include/halo_custom_maps.h
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions port/linux/include/halo_port_capacity.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading