diff --git a/docs/custom_edition_caches.md b/docs/custom_edition_caches.md index 3f0e83a0..77a9056a 100644 --- a/docs/custom_edition_caches.md +++ b/docs/custom_edition_caches.md @@ -68,6 +68,18 @@ levels have: - `.txt`, its description: plain text, its lines shown as written. Without one, the map is described as "Halo Custom Edition map". +A machine joining a network game on a map it has not (a Custom Edition map +not in its `custom_maps`, or one of the game's own not in `maps`) leaves it +with an error that names the map and the folder to copy it into, rather +than the damaged disc error precaching a missing map gives +(`cache_files_map_present`, called as the host's settings arrive): also when +the map cannot run there (Custom Edition maps turned off, the file not a +Custom Edition cache, or `bitmaps.map`, `sounds.map` and `loc.map` missing), +and when the host names a Custom Edition map as other versions of this port +do (`levels\test\\`) and the file is in `custom_maps`. The +error's text is wrapped to its dialog, in the menus' smaller font when it +would not fit; `debug.txt` has the details. + A Custom Edition campaign map has no next level: winning it ends the game as the campaign's last level does (alone) or plays it again (network co-op), and it is not the campaign's saved game. @@ -421,7 +433,9 @@ menus (on a virtual display): New Game's CUSTOM SINGLEPLAYER `a30` played as a campaign level at the difficulty chosen; the Map screen's (LAN) listed its difficulties, set up co-op in Server Setup and showed it in the lobby as co-op. With `debug.network_test`, two machines on loopback played -`custom_maps\a30` as network co-op and `custom_maps\hugeass` as slayer. +`custom_maps\a30` as network co-op and `custom_maps\hugeass` as slayer; a +joining machine without the map, without the resource maps, or with a maps +folder missing the host's Xbox map was shown each error and left the game. ### Not tested diff --git a/port/linux/game/custom_edition_cache.c b/port/linux/game/custom_edition_cache.c index b87f9983..4563da4e 100644 --- a/port/linux/game/custom_edition_cache.c +++ b/port/linux/game/custom_edition_cache.c @@ -566,6 +566,78 @@ boolean custom_edition_level_name( !_strnicmp(level_name, CUSTOM_EDITION_LEVEL_NAME_PREFIX, csstrlen(CUSTOM_EDITION_LEVEL_NAME_PREFIX)); } +boolean custom_edition_map_file_present( + char const *map_name) +{ + char path[MAP_PATH_SIZE]; + + return custom_edition_map_path(map_name, path); +} + +boolean custom_edition_cache_present( + char const *level_name, + char *message, + long message_size) +{ + char const *name = tag_name_strip_path(level_name); + char path[MAP_PATH_SIZE]; + struct custom_edition_file file; + struct cache_file_identity identity; + enum cache_file_status status = _cache_file_status_read_failed; + short folder; + short type; + + if (!custom_edition_map_path(level_name, path)) + { + snprintf(message, message_size, "You don't have the map %.64s.map. If you have it, copy it into custom_maps.", + name); + return FALSE; + } + if (!halo_custom_edition_tag_cache()) + { + error(_error_silent, "custom edition: the map '%s' cannot be played: %s", level_name, + custom_edition_unavailable_reason()); + snprintf(message, message_size, "The map %.64s can't be played: Custom Edition maps can't run (see debug.txt).", + name); + return FALSE; + } + if (custom_edition_file_open(&file, path)) + { + status = cache_file_identify(&file.source, &identity); + custom_edition_file_close(&file); + } + if (status != _cache_file_status_ok || identity.format != _cache_file_format_custom_edition_cache) + { + error(_error_silent, "custom edition: '%s' cannot be played: %s", path, + status != _cache_file_status_ok ? cache_file_status_describe(status) : + "it is not a Halo Custom Edition cache"); + snprintf(message, message_size, "Your %.64s.map can't be played (debug.txt says why).", name); + return FALSE; + } + /* (the resource maps every Custom Edition map's tags are read from) */ + for (type = _resource_map_bitmaps; type < NUMBER_OF_RESOURCE_MAP_TYPES; type++) + { + char const *resource_name = resource_map_type_describe((enum resource_map_type)type); + char resource_path[MAP_PATH_SIZE]; + + for (folder = 0; folder < NUMBER_OF_MAPS_FOLDERS; folder++) + { + if (maps_folder_has(maps_folder(folder), resource_name, MAP_FILE_EXTENSION, resource_path)) + break; + } + if (folder == NUMBER_OF_MAPS_FOLDERS) + { + error(_error_silent, "custom edition: the map '%s' needs %s.map, which no maps folder has", level_name, + resource_name); + snprintf(message, message_size, + "The map %.64s needs Custom Edition's bitmaps.map, sounds.map and loc.map in custom_maps.", name); + return FALSE; + } + } + + return TRUE; +} + boolean custom_edition_cache_playable( char const *level_name) { diff --git a/port/linux/game/custom_edition_cache.h b/port/linux/game/custom_edition_cache.h index fc531990..89fefcfd 100644 --- a/port/linux/game/custom_edition_cache.h +++ b/port/linux/game/custom_edition_cache.h @@ -76,6 +76,20 @@ boolean custom_edition_level_name( it is then read in place, never copied to the cache partition. */ boolean custom_edition_cache_playable( char const *level_name); +/* Whether the map `map_name` (a level name or a file name) has a file in the +Custom Edition maps folders, whatever it holds. */ +boolean custom_edition_map_file_present( + char const *map_name); +/* Whether this machine can play the level `level_name` (custom_maps\), +as a network game's client must: its file present and a Custom Edition +cache, Custom Edition maps able to run, and the resource maps present. When +not, `message` (`message_size` characters) says why, for the player, and +what to do. */ +boolean custom_edition_cache_present( + char const *level_name, + char *message, + long message_size); + /* Whether the file `file_name` (custom_maps\.map) is a Custom Edition cache of a campaign map (a solo scenario, played alone or as network co-op), or of a multiplayer map (custom_edition_maps.c). */ diff --git a/port/linux/include/halo_port_limits.h b/port/linux/include/halo_port_limits.h index 823cece8..66dde866 100644 --- a/port/linux/include/halo_port_limits.h +++ b/port/linux/include/halo_port_limits.h @@ -66,7 +66,7 @@ is newer (network_client_manager.c). A host advertises it, with its netcode, in its game's advertisement's reserved bytes (network_server_message_handler.c), which hosts built before there was a version send as zeros: version 0. Raise it with any change to what the machines send each other. */ -#define HALO_PORT_NETWORK_VERSION 21 +#define HALO_PORT_NETWORK_VERSION 22 /* ... the advertisement's reserved bytes: the version (a little-endian word), then flags */ #define HALO_PORT_ADVERTISED_VERSION_OFFSET 0 diff --git a/source/cache/cache_files.c b/source/cache/cache_files.c index 244a9c35..636b16ef 100644 --- a/source/cache/cache_files.c +++ b/source/cache/cache_files.c @@ -1035,6 +1035,66 @@ void cache_files_show_multiplayer_unavailable( return; } +/* port: whether this machine has the map a network game is on (a client +joining it: network_client_manager.c); when not, tells the player which map +is missing and where to copy it, in the error the main menu shows next, +rather than the damaged disc error that precaching a map that is not there +gives (cache_files_give_time_to_precache). +A Halo Custom Edition map (custom_maps\) is looked for in the Custom +Edition maps folders (port/linux/game/custom_edition_cache.c), any other in +the game's own. */ +boolean cache_files_map_present( + char const *map_name) +{ + void platform_log(char const *format, ...); + wchar_t error_text[512]; + char const *name = tag_name_strip_path(map_name); + char message[512]; + short index; + + if (!map_name || !map_name[0]) + return TRUE; + if (custom_edition_level_name(map_name)) + { + if (custom_edition_cache_present(map_name, message, sizeof(message))) + return TRUE; + } + else + { + char path[256]; + HANDLE file; + + if (cache_files_precache_map_loaded(map_name)) + return TRUE; + snprintf(path, sizeof(path), "%s%s.map", cache_files_map_directory(), name); + file = CreateFileA(path, GENERIC_READ, 0, NULL, OPEN_EXISTING, 0, NULL); + if (file != INVALID_HANDLE_VALUE) + { + CloseHandle(file); + return TRUE; + } + /* (a host of another version of this port, which names a Custom + Edition map as the game's own maps are named) */ + if (custom_edition_map_file_present(name)) + { + snprintf(message, sizeof(message), + "The host's map %.64s is a Custom Edition map named for another version of this game.", name); + } + else + { + snprintf(message, sizeof(message), "You don't have the map %.64s.map. If you have it, copy it into maps.", + name); + } + } + platform_log("map missing: %s", message); + for (index = 0; message[index] && index < NUMBEROF(error_text) - 1; index++) + error_text[index] = (wchar_t)(unsigned char)message[index]; + error_text[index] = 0; + display_error_text_when_main_menu_loaded(error_text); + + return FALSE; +} + boolean cache_files_give_time_to_precache( char const *map_name) { diff --git a/source/cache/cache_files.h b/source/cache/cache_files.h index 6d107afa..14daec39 100644 --- a/source/cache/cache_files.h +++ b/source/cache/cache_files.h @@ -73,6 +73,8 @@ boolean cache_files_map_plays_multiplayer( void cache_files_show_multiplayer_unavailable( char const *map_name, char const *build); +boolean cache_files_map_present( + char const *map_name); unsigned long cache_files_get_checksum( void); diff --git a/source/interface/ui_widget.c b/source/interface/ui_widget.c index b2441d8f..ef959fa3 100644 --- a/source/interface/ui_widget.c +++ b/source/interface/ui_widget.c @@ -1968,6 +1968,29 @@ void display_error_when_main_menu_loaded( return; } +/* port: an error of the port's own text (as display_error_text_deferred's) +shown when the main menu is next loaded, as display_error_when_main_menu_loaded +shows one of the game's: a network game left for a reason the game has no +error for (a map missing: cache_files.c). The text is copied, when no error +is queued already. */ +void display_error_text_when_main_menu_loaded( + wchar_t const *text) +{ + static wchar_t queued_text[512]; + + if (widget_globals.main_menu_deferred_error_code != NONE) + { + error(_error_silent, "there is already an error message queued for display at the main menu; ignoring this one"); + return; + } + ustrncpy(queued_text, text, NUMBEROF(queued_text) - 1); + queued_text[NUMBEROF(queued_text) - 1] = 0; + ui_widget_port_error_pending_text = queued_text; + widget_globals.main_menu_deferred_error_code = _error_cannot_create_saved_game_file_with_empty_name; + + return; +} + void display_error_abort_to_dashboard_deferred( short error_code, boolean optional) @@ -5113,6 +5136,61 @@ static long search_and_replace( return replacements; } +/* port: the port's own error text (display_error_text_deferred), which may be +longer than a line of its dialog: broken at spaces (the strings' line break, +'\r') where a line would be wider than `width` in the font drawn with. +FALSE when a line is still wider (a word longer than a line). */ +static boolean ui_widget_port_text_wrap( + wchar_t *text, + rectangle2d const *bounds, + short width) +{ + long line_start = 0; + long last_space = NONE; + long index; + boolean fits = TRUE; + + for (index = 0; ; index++) + { + wchar_t character = text[index]; + + if (character == L'\r' || character == L'\n') + { + line_start = index + 1; + last_space = NONE; + continue; + } + if (character == L' ' || character == 0) + { + wchar_t line[256]; + long length = MIN(index - line_start, (long)NUMBEROF(line) - 1); + rectangle2d text_bounds; + rectangle2d cursor_bounds; + + csmemcpy(line, text + line_start, length * sizeof(wchar_t)); + line[length] = 0; + draw_unicode_string_compute_bounds(bounds, line, &text_bounds, &cursor_bounds); + if (cursor_bounds.x0 - bounds->x0 > width && last_space != NONE) + { + text[last_space] = L'\r'; + line_start = last_space + 1; + /* (the line begun, the word just measured: wider alone?) */ + length = MIN(index - line_start, (long)NUMBEROF(line) - 1); + csmemcpy(line, text + line_start, length * sizeof(wchar_t)); + line[length] = 0; + draw_unicode_string_compute_bounds(bounds, line, &text_bounds, &cursor_bounds); + } + if (cursor_bounds.x0 - bounds->x0 > width) + fits = FALSE; + if (character == 0) + break; + last_space = index; + } + } + + return fits; +} + static void widget_instance_render_text_box( struct widget_instance *widget, struct ui_widget_definition *definition, @@ -5249,6 +5327,41 @@ static void widget_instance_render_text_box( SECONDS_PER_MILLISECOND * 3.0f) + 1.5f) * 0.4f) * color.alpha; } draw_string_set_draw_mode(font_index, NONE, justification, 0, &color); + /* port: the port's own error text, wrapped to its dialog: the box its + background draws (narrower than the text box; its picture is padded to a + power of two, so a margin of the text's offset on each side and as much + again), and as tall as the text box less its offset above and as much + below (the dialog's footer). Text that would be taller, or wider (a word + longer than a line), is drawn in the menus' smaller font. */ + if (widget == ui_widget_port_error_text_box && ui_widget_port_error_text) + { + struct bitmap_data *background = definition->background_bitmap.index != NONE ? + bitmap_group_get_bitmap_from_sequence(definition->background_bitmap.index, 0, 0) : NULL; + short width = (short)(definition->bounds.x1 - definition->bounds.x0); + short height = (short)(definition->bounds.y1 - definition->bounds.y0 - 2 * definition->vertical_offset); + wchar_t wrapped[512]; + rectangle2d text_bounds; + rectangle2d cursor_bounds; + + if (background && background->width > 0 && background->width < width) + width = background->width; + width = (short)(width - 4 * definition->horizontal_offset); + ustrncpy(wrapped, *text, NUMBEROF(wrapped) - 1); + wrapped[NUMBEROF(wrapped) - 1] = 0; + if (!ui_widget_port_text_wrap(wrapped, &bounds, width) || + (draw_unicode_string_compute_bounds(&bounds, wrapped, &text_bounds, &cursor_bounds), + cursor_bounds.y1 - bounds.y0 > height)) + { + long small_font_index = tag_loaded('font', "ui\\small_ui"); + + if (small_font_index != NONE) + { + font_index = small_font_index; + draw_string_set_draw_mode(font_index, NONE, justification, 0, &color); + } + } + ui_widget_port_text_wrap(*text, &bounds, width); + } if (string_has_icons_to_draw(*text)) draw_string_and_hack_in_icons(&bounds, &clip, NULL, 0, *text, FALSE); else diff --git a/source/interface/ui_widget.h b/source/interface/ui_widget.h index e9412da7..c021a5e4 100644 --- a/source/interface/ui_widget.h +++ b/source/interface/ui_widget.h @@ -137,6 +137,8 @@ void display_error_deferred( void display_error_text_deferred( wchar_t const *text, short local_player_index); +void display_error_text_when_main_menu_loaded( + wchar_t const *text); void display_error_abort_to_dashboard_deferred( short error_code, boolean optional); diff --git a/source/networking/network_client_manager.c b/source/networking/network_client_manager.c index 919b2ff2..beb8e3da 100644 --- a/source/networking/network_client_manager.c +++ b/source/networking/network_client_manager.c @@ -1287,6 +1287,14 @@ boolean network_game_client_game_settings_updated( { char build[0x20]; + /* port: a map this machine has not (a Custom Edition map not in + its custom_maps folder, say): the player told which and where to + copy it (the main menu's error, in place of the failed join's), and + the game left, rather than precaching it, which would give the + damaged disc error (cache_files.c) */ + if (!network_game_is_splitscreen_local() && !cache_files_map_present(message_packet->map.name)) + return FALSE; + /* port: a map of a build this version does not play with others (its objects would not be the host's): said, and the game left */ if (!network_game_is_splitscreen_local() &&