diff --git a/client/AGENTS.md b/client/AGENTS.md index 1a25e80df..f9b081faa 100644 --- a/client/AGENTS.md +++ b/client/AGENTS.md @@ -40,6 +40,17 @@ - Focused text inputs own their key-down, key-up, text-input, and text-editing events. Do not let gameplay bindings observe an event already consumed by a focused widget. +- Region-map exploration uses a hashed cache of at most 10,000 server-received + map bitfields, scoped to the pinned connection certificate and authenticated + account. Retain initial dimensions and OR incoming snapshots/patches. Request + only region-definition paths, deduplicated and paced with socket backpressure. + Bitmap allocations total at most 1 MiB; reject excess new records without + changing the cache. Replay uses one shared 65,536-unit map/byte/set-bit budget + per frame, resumes across frames, and coalesces packets by cache revision. + RESET selects the account once per connection; duplicate active RESETs are + idempotent and account switches require disconnect. Disconnect hides cached + bits and flushes optional private disk storage. Never import character `.tiles` files or + persist client-renderer visibility in the account cache. - Live movement diagnostics use normal authenticated connections and movement commands in an isolated wrapper scenario. Their closed route owns gameplay input, accepts only complete server map publications, and fails on divergence diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index cc2424173..1b1ab934c 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -284,8 +284,8 @@ endif () file(STRINGS "${atrinik_protocol_SOURCE_DIR}/generated/c/include/atrinik/protocol/game_commands.h" ATRINIK_PROTOCOL_VERSION_LINE REGEX "^#define ATRINIK_PROTOCOL_VERSION [0-9]+U$") -if (NOT ATRINIK_PROTOCOL_VERSION_LINE MATCHES " 1081U$") - message(FATAL_ERROR "Client requires classic game protocol v1081") +if (NOT ATRINIK_PROTOCOL_VERSION_LINE MATCHES " 1082U$") + message(FATAL_ERROR "Client requires classic game protocol v1082") endif () if (NOT TARGET Atrinik::Core) set(LIBATRINIK_PACKAGE_LAYOUT ON CACHE BOOL "" FORCE) @@ -758,6 +758,24 @@ if (BUILD_TESTING) target_link_libraries(client-command-queue-tests PRIVATE Atrinik::Core SDL3::SDL3) add_test(NAME client-command-queue COMMAND client-command-queue-tests) + if (CMAKE_SYSTEM_NAME STREQUAL "Linux") + add_executable(client-socket-output-tests + src/tests/socket_output.c + src/client/client_command_queue.c) + atrinik_configure_target(client-socket-output-tests) + target_compile_definitions(client-socket-output-tests PRIVATE SDL_DISABLE_OLD_NAMES) + target_include_directories(client-socket-output-tests BEFORE PRIVATE + ${CMAKE_CURRENT_BINARY_DIR}/src/include + ${CMAKE_CURRENT_SOURCE_DIR}/src/include + ${CMAKE_CURRENT_SOURCE_DIR}/src/gui/toolkit/include + ${CMAKE_CURRENT_SOURCE_DIR}/src) + target_compile_options(client-socket-output-tests PRIVATE + -ffunction-sections -fdata-sections) + target_link_options(client-socket-output-tests PRIVATE -Wl,--gc-sections) + target_link_libraries(client-socket-output-tests PRIVATE Atrinik::Core SDL3::SDL3) + add_test(NAME client-socket-output COMMAND client-socket-output-tests) + endif () + add_executable(client-keybind-input-tests src/tests/keybind_input.c src/client/keybind_event.c @@ -790,6 +808,14 @@ if (BUILD_TESTING) target_link_libraries(client-move-stream-tests PRIVATE Atrinik::Core SDL3::SDL3) add_test(NAME client-move-stream COMMAND client-move-stream-tests) + add_executable(client-region-exploration-tests + src/tests/region_exploration.c src/client/region_exploration.c) + atrinik_configure_target(client-region-exploration-tests) + target_include_directories(client-region-exploration-tests PRIVATE src/include) + target_link_libraries(client-region-exploration-tests PRIVATE Atrinik::Core OpenSSL::Crypto) + add_test(NAME client-region-exploration COMMAND client-region-exploration-tests + "${CMAKE_CURRENT_BINARY_DIR}/region-exploration-cache-test") + add_executable(client-live-movement-route-tests src/tests/live_movement_route.c src/client/live_movement_route.c) atrinik_configure_target(client-live-movement-route-tests) @@ -1188,6 +1214,7 @@ if (BUILD_TESTING) set_tests_properties(client-widget-priority-integration PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}") + add_test(NAME client-region-exploration-integration COMMAND atrinik --region-exploration-test) add_test(NAME client-map-state-integration COMMAND atrinik --map-state-test) endif () diff --git a/client/src/client/command_handlers.def b/client/src/client/command_handlers.def index 9c3f65ab1..f474afc1b 100644 --- a/client/src/client/command_handlers.def +++ b/client/src/client/command_handlers.def @@ -29,6 +29,7 @@ ATRINIK_CLIENT_COMMAND_HANDLER(INTERFACE, socket_command_interface) ATRINIK_CLIENT_COMMAND_HANDLER(NOTIFICATION, socket_command_notification) ATRINIK_CLIENT_COMMAND_HANDLER(KEEPALIVE, socket_command_keepalive) ATRINIK_CLIENT_COMMAND_HANDLER(PLAYER_STATUS, socket_command_player_status) +ATRINIK_CLIENT_COMMAND_HANDLER(REGION_EXPLORATION, socket_command_region_exploration) ATRINIK_CLIENT_COMMAND_HANDLER(ACCESS_RESULT, socket_command_access_result) ATRINIK_CLIENT_COMMAND_HANDLER(ACCESS_ADMIN_RESULT, socket_command_access_admin_result) ATRINIK_CLIENT_COMMAND_HANDLER(ACCESS_POLICY, socket_command_access_policy) diff --git a/client/src/client/commands.c b/client/src/client/commands.c index 7413abb94..8598ecfad 100644 --- a/client/src/client/commands.c +++ b/client/src/client/commands.c @@ -51,6 +51,7 @@ #include #include #include +#include #include #include #include @@ -1967,3 +1968,18 @@ void socket_command_control(uint8_t *data, size_t len, size_t pos) { SDL_RaiseWindow(ScreenWindow); } } + +void socket_command_region_exploration(uint8_t *data, size_t len, size_t pos) { + packet_reader_t reader; + packet_reader_init_at(&reader, data, len, pos); + bool changed; + if (pos > len || !region_exploration_receive(data + pos, len - pos, &changed)) { + packet_reader_set_error(&reader, PACKET_ERROR_INVALID_ENCODING); + return; + } + if (changed) { + region_map_exploration_refresh_map(data[pos] == 0 ? NULL : (const char *)data + pos + 1, + data[pos] == 0); + } + packet_reader_skip(&reader, len - pos); +} diff --git a/client/src/client/gpu_player_view.c b/client/src/client/gpu_player_view.c index 85fbe4feb..4954b9380 100644 --- a/client/src/client/gpu_player_view.c +++ b/client/src/client/gpu_player_view.c @@ -1942,7 +1942,6 @@ static bool gpu_player_view_ui_region_map_prepare(void) { region->def->num_maps = 1; region->def->maps = xcalloc(1, sizeof(*region->def->maps)); region->def->maps[0].path = xstrdup("/gpu-ui-closure"); - region->fow->path = xstrdup("gpu-ui-closure-in-memory.fow"); region->fow->bitmap = xcalloc(1, RM_MAP_FOW_BITMAP_SIZE(region)); snprintf(VS(MapData.map_path), "%s", region->def->maps[0].path); snprintf(VS(MapData.region_name), "%s", "gpu-ui-closure"); @@ -2163,7 +2162,6 @@ static bool gpu_player_view_ui_closure_run(widgetdata *map_widget, popup_destroy_all(); tooltip_dismiss(); notification_destroy(); - region_map_test_fow_persistence_set(false); metaserver_clear_data(); selected_server = metaserver_add("fixture.invalid", 13327, @@ -4178,7 +4176,6 @@ int gpu_player_view_main(int argc, char *argv[]) { wrapper_test_user_data_isolated_set(true); widget_mplayer_test_isolated_set(true); widget_render_profiler_test_isolated_set(true); - region_map_test_fow_persistence_set(false); snprintf(VS(cpl.account), "%s", "renderer-fixture"); snprintf(VS(cpl.name), "%s", "Renderer Maintainer"); #endif @@ -4884,7 +4881,6 @@ int gpu_player_view_main(int argc, char *argv[]) { #ifdef ATRINIK_WIDGET_TESTS widget_mplayer_test_isolated_set(false); widget_render_profiler_test_isolated_set(false); - region_map_test_fow_persistence_set(true); #endif if (objects_ready) { object_deinit(); diff --git a/client/src/client/main.c b/client/src/client/main.c index 559bdf80b..30bedc36e 100644 --- a/client/src/client/main.c +++ b/client/src/client/main.c @@ -1024,6 +1024,9 @@ int main(int argc, char *argv[]) { return gpu_player_view_main(argc - 1, &argv[1]); } + if (argc == 2 && strcmp(argv[1], "--region-exploration-test") == 0) { + return region_map_exploration_test() ? 0 : 1; + } if (argc == 2 && strcmp(argv[1], "--access-lifecycle-test") == 0) { return client_access_lifecycle_test() ? EXIT_SUCCESS : EXIT_FAILURE; } @@ -1309,6 +1312,7 @@ int main(int argc, char *argv[]) { DoClient(); image_face_requests_service(); + region_map_exploration_service(); } /* If not connected, walk through connection chain and/or wait for diff --git a/client/src/client/region_exploration.c b/client/src/client/region_exploration.c new file mode 100644 index 000000000..fa1e3607c --- /dev/null +++ b/client/src/client/region_exploration.c @@ -0,0 +1,466 @@ +/************************************************************************* + * Atrinik, a Multiplayer Online Role Playing Game * + * * + * Copyright 2026 The Atrinik Project * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + ************************************************************************/ +#include +#include +#include +#include +#include +#include +#include + +#define EXPLORATION_MAP_LIMIT 10000U +#define EXPLORATION_BITMAP_LIMIT (1024U * 1024U) +#define EXPLORATION_PACKET_MAX (1U + 256U + 4U + 8192U) + +typedef struct exploration_record { + char path[256]; + unsigned width, height; + uint8_t *bits; + uint64_t revision; + UT_hash_handle hh; +} exploration_record_t; +typedef struct exploration_request { + char path[256]; + struct exploration_request *next; + UT_hash_handle hh; +} exploration_request_t; +static exploration_record_t *records; +static exploration_request_t *requests, *pending, *pending_tail; +static char connection[65], cache_key[65]; +static char *cache_directory, *cache_path; +static bool active, dirty; +static size_t bitmap_bytes; +static uint64_t revision = 1; + +static void requests_clear(void) { + exploration_request_t *entry, *next; + HASH_ITER(hh, requests, entry, next) { + HASH_DEL(requests, entry); + free(entry); + } + pending = pending_tail = NULL; +} +static void records_clear(void) { + bitmap_bytes = 0; + revision++; + exploration_record_t *entry, *next; + HASH_ITER(hh, records, entry, next) { + HASH_DEL(records, entry); + free(entry->bits); + free(entry); + } +} + +/* Paths are opaque logical identifiers, never filesystem paths. */ +static bool logical_path(const char *path) { + if (path == NULL || path[0] != '/' || strlen(path) > 255) { + return false; + } + const char *segment = path + 1; + for (const char *p = segment;; p++) { + unsigned char c = (unsigned char)*p; + if (c && (c < 32 || c == 127 || c == '\\' || c == ':')) { + return false; + } + if (!c || c == '/') { + size_t n = (size_t)(p - segment); + if (!n || (n == 1 && segment[0] == '.') || + (n == 2 && segment[0] == '.' && segment[1] == '.')) { + return false; + } + segment = p + 1; + } + if (!c) { + return true; + } + } +} +static unsigned read_u16(const uint8_t *p) { + return ((unsigned)p[0] << 8) | p[1]; +} +static void write_u16(uint8_t *p, unsigned value) { + p[0] = (uint8_t)(value >> 8); + p[1] = (uint8_t)value; +} +static size_t record_encode(const exploration_record_t *entry, uint8_t *data) { + data[0] = 1; + size_t pos = strlen(entry->path) + 2; + memcpy(data + 1, entry->path, pos - 1); + write_u16(data + pos, entry->width); + write_u16(data + pos + 2, entry->height); + size_t bytes = ((size_t)entry->width * entry->height + 7) / 8; + memcpy(data + pos + 4, entry->bits, bytes); + return pos + 4 + bytes; +} +static void cache_save(void) { + if (!dirty || cache_path == NULL) { + return; + } + size_t size = 68; + exploration_record_t *entry, *next; + HASH_ITER(hh, records, entry, next) { + size += 2 + strlen(entry->path) + 6 + ((size_t)entry->width * entry->height + 7) / 8; + } + uint8_t *data = malloc(size); + if (data == NULL) { + return; + } + memcpy(data, "RXC2", 4); + memcpy(data + 4, cache_key, 64); + size_t pos = 68; + HASH_ITER(hh, records, entry, next) { + size_t len = record_encode(entry, data + pos + 2); + write_u16(data + pos, (unsigned)len); + pos += 2 + len; + } + if (path_write_atomic_existing(cache_path, data, size, 0600)) { + dirty = false; + } + free(data); +} +static void cache_load(void) { + if (cache_path == NULL) { + return; + } + FILE *file = fopen(cache_path, "rb"); + if (file == NULL) { + return; + } + uint8_t header[68], packet[EXPLORATION_PACKET_MAX]; + bool ok = fread(header, 1, sizeof(header), file) == sizeof(header) && + memcmp(header, "RXC2", 4) == 0 && memcmp(header + 4, cache_key, 64) == 0; + size_t count = 0; + while (ok) { + uint8_t length[2] = {0}; + size_t n = fread(length, 1, 2, file); + if (n == 0 && feof(file)) { + break; + } + unsigned len = read_u16(length); + if (n != 2 || ++count > EXPLORATION_MAP_LIMIT || len == 0 || len > sizeof(packet) || + fread(packet, 1, len, file) != len || packet[0] != 1 || + !region_exploration_receive(packet, len, NULL)) { + ok = false; + } + } + if (ferror(file)) { + ok = false; + } + fclose(file); + if (!ok) { + records_clear(); /* A corrupt optional cache is a complete miss. */ + } + dirty = false; +} + +void region_exploration_disconnect(void) { + cache_save(); + active = false; + requests_clear(); +} +void region_exploration_clear(void) { + region_exploration_disconnect(); + records_clear(); + free(cache_directory); + free(cache_path); + cache_directory = cache_path = NULL; + connection[0] = cache_key[0] = '\0'; + dirty = false; +} + +void region_exploration_connect(const char *certificate, const char *directory) { + region_exploration_disconnect(); + connection[0] = '\0'; + free(cache_directory); + cache_directory = NULL; + if (certificate == NULL || strlen(certificate) != 64) { + return; + } + for (size_t i = 0; i < 64; i++) { + char c = certificate[i]; + if (c >= 'A' && c <= 'F') { + c = (char)(c + ('a' - 'A')); + } + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))) { + connection[0] = '\0'; + return; + } + connection[i] = c; + } + connection[64] = '\0'; + if (directory != NULL) { + cache_directory = malloc(strlen(directory) + 1); + if (cache_directory != NULL) { + strcpy(cache_directory, directory); + } + } +} +static bool account_select(const uint8_t *data, size_t len, bool *changed) { + /* RESET is trusted only inside the authenticated, pinned connection. */ + if (connection[0] == '\0' || len < 3 || len > 257 || data[len - 1] != 0) { + return false; + } + if (memchr(data + 1, 0, len - 2) != NULL) { + return false; + } + unsigned char digest[EVP_MAX_MD_SIZE]; + unsigned digest_len; + uint8_t identity[321]; + memcpy(identity, connection, 65); + memcpy(identity + 65, data + 1, len - 1); + if (EVP_Digest(identity, 65 + len - 1, digest, &digest_len, EVP_sha256(), NULL) != 1 || + digest_len != 32) { + return false; + } + char key[65]; + for (size_t i = 0; i < 32; i++) { + static const char hex[] = "0123456789abcdef"; + key[i * 2] = hex[digest[i] >> 4]; + key[i * 2 + 1] = hex[digest[i] & 15]; + } + key[64] = 0; + if (active) { + /* A connection authenticates one account. Duplicate RESETs must not + * clear request deduplication, reload disk or invalidate live views. */ + return strcmp(cache_key, key) == 0; + } + requests_clear(); + if (changed != NULL) { + *changed = true; + } + if (strcmp(cache_key, key) == 0) { + active = true; + revision++; + return true; + } + cache_save(); + records_clear(); + free(cache_path); + cache_path = NULL; + dirty = false; + memcpy(cache_key, key, sizeof(key)); + if (cache_directory != NULL) { + char name[sizeof("exploration-.bin") + 64]; + snprintf(name, sizeof(name), "exploration-%s.bin", key); + cache_path = path_join(cache_directory, name); + } + active = true; + cache_load(); + return true; +} + +const uint8_t *region_exploration_find(const char *path, unsigned *width, unsigned *height) { + exploration_record_t *entry; + if (!active) { + return NULL; + } + HASH_FIND_STR(records, path, entry); + if (entry == NULL) { + return NULL; + } + *width = entry->width; + *height = entry->height; + return entry->bits; +} + +bool region_exploration_receive(const uint8_t *data, size_t len, bool *changed) { + if (changed != NULL) { + *changed = false; + } + if (data == NULL || len == 0) { + return false; + } + if (data[0] == 0) { + return account_select(data, len, changed); + } + if (!active || data[0] > 3 || len < 4) { + return false; + } + const uint8_t *end = memchr(data + 1, 0, len - 1 < 256 ? len - 1 : 256); + if (end == NULL || !logical_path((const char *)data + 1)) { + return false; + } + size_t offset = (size_t)(end - data) + 1; + if (data[0] == 3) { + return len == offset; /* EMPTY does not erase previously received bits. */ + } + if (len - offset < 4) { + return false; + } + unsigned width = read_u16(data + offset), height = read_u16(data + offset + 2); + if (width == 0 || height == 0 || width > 256 || height > 256) { + return false; + } + offset += 4; + size_t bytes = ((size_t)width * height + 7) / 8; + unsigned tail = (width * height) % 8; + if (data[0] == 1) { + if (len - offset != bytes || (tail && (data[len - 1] & (uint8_t)(0xffU << tail)))) { + return false; + } + } else { + if (len - offset < 2) { + return false; + } + unsigned count = read_u16(data + offset); + offset += 2; + if (count == 0 || count > bytes || len - offset != (size_t)count * 3) { + return false; + } + unsigned previous = 0; + for (unsigned i = 0; i < count; i++) { + unsigned index = read_u16(data + offset + i * 3); + uint8_t mask = data[offset + i * 3 + 2]; + if (index >= bytes || (i && index <= previous) || mask == 0 || + (tail && index + 1 == bytes && (mask & (uint8_t)(0xffU << tail)))) { + return false; + } + previous = index; + } + } + exploration_record_t *entry; + HASH_FIND_STR(records, (const char *)data + 1, entry); + if (entry == NULL) { + if (HASH_COUNT(records) >= EXPLORATION_MAP_LIMIT || + bytes > EXPLORATION_BITMAP_LIMIT - bitmap_bytes) { + return false; + } + entry = calloc(1, sizeof(*entry)); + if (entry == NULL) { + return false; + } + entry->bits = calloc(bytes, 1); + if (entry->bits == NULL) { + free(entry); + return false; + } + memcpy(entry->path, data + 1, (size_t)(end - data)); + entry->width = width; + entry->height = height; + HASH_ADD_STR(records, path, entry); + bitmap_bytes += bytes; + dirty = true; + } + /* Dimensions are metadata from the first observation, not an authored-map + * consistency contract. Retain them and merge the bounded byte prefix. */ + size_t stored = ((size_t)entry->width * entry->height + 7) / 8; + bool any = false; + size_t count = data[0] == 1 ? bytes : (len - offset) / 3; + unsigned stored_tail = (entry->width * entry->height) % 8; + for (size_t i = 0; i < count; i++) { + size_t index = data[0] == 1 ? i : read_u16(data + offset + i * 3); + uint8_t mask = data[0] == 1 ? data[offset + i] : data[offset + i * 3 + 2]; + if (index >= stored) { + continue; + } + if (stored_tail && index + 1 == stored) { + mask &= (uint8_t)((1U << stored_tail) - 1); + } + any |= (entry->bits[index] | mask) != entry->bits[index]; + entry->bits[index] |= mask; + } + if (any) { + entry->revision = ++revision; + } + dirty |= any; + if (changed != NULL) { + *changed = any; + } + return true; +} + +uint64_t region_exploration_revision(void) { + return revision; +} + +/* Each byte costs one scan unit plus at most eight visited-cell units. Do not + * split a byte: a caller with fewer than nine units resumes it next frame. */ +bool region_exploration_replay(const char *path, uint64_t since, size_t *cursor, + size_t *budget, region_exploration_visit_fn visit, void *user) { + exploration_record_t *entry; + if (!active) { + return true; + } + HASH_FIND_STR(records, path, entry); + if (entry == NULL || entry->revision <= since) { + return true; + } + size_t bytes = ((size_t)entry->width * entry->height + 7) / 8; + while (*cursor < bytes) { + uint8_t mask = entry->bits[*cursor]; + if (*budget < (mask ? 9U : 1U)) { + return false; + } + (*budget)--; + for (unsigned bit = 0; mask != 0; bit++, mask >>= 1) { + if (mask & 1U) { + size_t cell = *cursor * 8 + bit; + (*budget)--; + visit((unsigned)(cell % entry->width), (unsigned)(cell / entry->width), user); + } + } + (*cursor)++; + } + return true; +} + +bool region_exploration_request(const char *path) { + if (!active || !logical_path(path)) { + return false; + } + exploration_request_t *entry; + HASH_FIND_STR(requests, path, entry); + if (entry != NULL) { + return true; + } + if (HASH_COUNT(requests) >= EXPLORATION_MAP_LIMIT) { + return false; + } + entry = calloc(1, sizeof(*entry)); + if (entry == NULL) { + return false; + } + strcpy(entry->path, path); + HASH_ADD_STR(requests, path, entry); + if (pending_tail != NULL) { + pending_tail->next = entry; + } else { + pending = entry; + } + pending_tail = entry; + return true; +} +size_t region_exploration_service(region_exploration_send_fn send, void *user) { + size_t count = 0; + while (active && pending != NULL && count < 32) { + uint8_t data[EXPLORATION_PACKET_MAX]; + data[0] = 0; + size_t pos = strlen(pending->path) + 2; + memcpy(data + 1, pending->path, pos - 1); + unsigned width, height; + const uint8_t *bits = region_exploration_find(pending->path, &width, &height); + size_t bytes = bits != NULL ? ((size_t)width * height + 7) / 8 : 0; + write_u16(data + pos, (unsigned)bytes); + pos += 2; + if (bytes) { + memcpy(data + pos, bits, bytes); + } + if (!send(data, pos + bytes, user)) { + break; + } + pending = pending->next; + if (pending == NULL) { + pending_tail = NULL; + } + count++; + } + return count; +} diff --git a/client/src/client/region_map.c b/client/src/client/region_map.c index 7dddf1dde..f6eb8b18e 100644 --- a/client/src/client/region_map.c +++ b/client/src/client/region_map.c @@ -43,6 +43,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -50,13 +53,7 @@ #include static UT_icd icd = {sizeof(region_map_fow_tile_t), NULL, NULL, NULL}; -#ifdef ATRINIK_WIDGET_TESTS -static bool region_map_test_fow_persistence = true; - -void region_map_test_fow_persistence_set(bool enabled) { - region_map_test_fow_persistence = enabled; -} -#endif +static region_map_t *region_maps, *exploration_next; static region_map_def_t *region_map_def_new(void); static void region_map_def_load(region_map_def_t *def, const char *str); @@ -79,6 +76,8 @@ region_map_t *region_map_create(void) { region_map->def = region_map_def_new(); region_map->fow = region_map_fow_new(); + region_map->next = region_maps; + region_maps = region_map; return region_map; } @@ -100,6 +99,8 @@ region_map_t *region_map_clone(region_map_t *region_map) { clone->fow = region_map->fow; clone->fow->refcount++; + clone->next = region_maps; + region_maps = clone; return clone; } @@ -118,6 +119,14 @@ void region_map_free(region_map_t *region_map) { region_map_fow_free(region_map); free(region_map->def); free(region_map->fow); + region_map_t **entry = ®ion_maps; + while (*entry != region_map) { + entry = &(*entry)->next; + } + *entry = region_map->next; + if (exploration_next == region_map) { + exploration_next = region_map->next; + } free(region_map); } @@ -215,12 +224,6 @@ void region_map_update(region_map_t *region_map, const char *region_name) { } region_map->source_def = asset_source_start(buf, path); free(path); - - snprintf(VS(buf), "client-maps/%s.tiles", region_name); - region_map->fow->path = file_path_player(buf); - if (region_map->fow->path == NULL) { - snprintf(VS(region_map->error), "The server sent an invalid region."); - } } /** @@ -752,42 +755,12 @@ static region_map_fow_t *region_map_fow_new(void) { } static void region_map_fow_create(region_map_t *region_map) { - FILE *fp; - HARD_ASSERT(region_map->fow != NULL); - HARD_ASSERT(region_map->fow->path != NULL); HARD_ASSERT(region_map->fow->bitmap == NULL); - - fp = path_fopen(region_map->fow->path, "r"); - - if (fp != NULL) { - struct stat statbuf; - - if (fstat(fileno(fp), &statbuf) == -1) { - LOG(ERROR, "Could not stat %s: %d (%s)", region_map->fow->path, errno, strerror(errno)); - } else if ((size_t)statbuf.st_size == RM_MAP_FOW_BITMAP_SIZE(region_map)) { - region_map->fow->bitmap = xmalloc(statbuf.st_size); - - if (fread(region_map->fow->bitmap, 1, statbuf.st_size, fp) != (size_t)statbuf.st_size) { - LOG(ERROR, - "Could not read %" PRIu64 " bytes from %s: %d " - "(%s)", - (uint64_t)statbuf.st_size, - region_map->fow->path, - errno, - strerror(errno)); - free(region_map->fow->bitmap); - region_map->fow->bitmap = NULL; - } - } - - fclose(fp); - } - - if (region_map->fow->bitmap == NULL) { - region_map->fow->bitmap = xcalloc(1, RM_MAP_FOW_BITMAP_SIZE(region_map)); + region_map->fow->bitmap = xcalloc(1, RM_MAP_FOW_BITMAP_SIZE(region_map)); + for (size_t i = 0; i < region_map->def->num_maps; i++) { + region_exploration_request(region_map->def->maps[i].path); } - region_map_fow_update(region_map); } @@ -809,9 +782,6 @@ static void region_map_fow_free(region_map_t *region_map) { utarray_free(region_map->fow->tiles); region_map->fow->tiles = NULL; } - - free(region_map->fow->path); - region_map->fow->path = NULL; } static void region_map_fow_reset(region_map_t *region_map) { @@ -828,29 +798,10 @@ static void region_map_fow_reset(region_map_t *region_map) { region_map->fow_zoomed = NULL; } - if (region_map->fow->bitmap != NULL) { - FILE *fp; - - HARD_ASSERT(region_map->surface != NULL); - HARD_ASSERT(region_map->fow->path != NULL); - - fp = NULL; -#ifdef ATRINIK_WIDGET_TESTS - if (region_map_test_fow_persistence) { -#endif - fp = path_fopen(region_map->fow->path, "w"); -#ifdef ATRINIK_WIDGET_TESTS - } -#endif - - if (fp != NULL) { - fwrite(region_map->fow->bitmap, 1, RM_MAP_FOW_BITMAP_SIZE(region_map), fp); - fclose(fp); - } - - free(region_map->fow->bitmap); - region_map->fow->bitmap = NULL; - } + free(region_map->fow->bitmap); + region_map->fow->bitmap = NULL; + region_map->fow->exploration_revision = region_map->fow->exploration_target = 0; + region_map->fow->exploration_map = region_map->fow->exploration_byte = 0; } static bool region_map_fow_update_regions(region_map_t *region_map, const uint32_t *color) { @@ -860,7 +811,7 @@ static bool region_map_fow_update_regions(region_map_t *region_map, const uint32 utarray_new(regions, &ut_str_icd); - for (object *op = cpl.ob->inv; op != NULL; op = op->next) { + for (object *op = cpl.ob != NULL ? cpl.ob->inv : NULL; op != NULL; op = op->next) { if (op->itype != TYPE_REGION_MAP) { continue; } @@ -910,6 +861,75 @@ static bool region_map_fow_update_regions(region_map_t *region_map, const uint32 return ret; } +typedef struct exploration_replay_view { + region_map_t *map; + region_map_def_map_t *def; + bool changed; +} exploration_replay_view_t; + +static void region_map_exploration_visit(unsigned x, unsigned y, void *user) { + exploration_replay_view_t *view = user; + view->changed |= region_map_fow_set_visited(view->map, view->def, NULL, (int)x, (int)y); +} + +/* Only the once-per-frame service advances replay. Packet handlers and asset + * callbacks cannot replenish or bypass this shared map/byte/set-bit budget. */ +static size_t region_map_exploration_replay_service(void) { + size_t budget = 65536; + uint64_t revision = region_exploration_revision(); + region_map_t *start = exploration_next != NULL ? exploration_next : region_maps; + bool first = true; + for (region_map_t *map = start; + map != NULL && budget > 0 && (first || map != start); map = exploration_next) { + first = false; + exploration_next = map->next != NULL ? map->next : region_maps; + budget--; + region_map_fow_t *fow = map->fow; + if (map->surface == NULL || fow->bitmap == NULL || + fow->exploration_revision == revision) { + continue; + } + if (fow->exploration_target == 0) { + fow->exploration_target = revision; + } + exploration_replay_view_t view = {.map = map}; + bool yielded = false; + while (fow->exploration_map < map->def->num_maps && budget > 0) { + budget--; + view.def = &map->def->maps[fow->exploration_map]; + if (!region_exploration_replay(view.def->path, fow->exploration_revision, + &fow->exploration_byte, &budget, + region_map_exploration_visit, &view)) { + yielded = true; + break; + } + fow->exploration_map++; + fow->exploration_byte = 0; + } + if (fow->exploration_map == map->def->num_maps) { + fow->exploration_revision = fow->exploration_target; + fow->exploration_target = 0; + fow->exploration_map = 0; + } + if (view.changed) { + minimap_redraw_flag = 1; + region_map_fow_update(map); + /* Popup clones share the bitmap/surface but own their zooms. */ + for (region_map_t *clone = region_maps; clone != NULL; clone = clone->next) { + if (clone->fow == fow && clone->fow_zoomed != NULL) { + gpu_renderer_invalidate_surface(clone->fow_zoomed); + SDL_DestroySurface(clone->fow_zoomed); + clone->fow_zoomed = NULL; + } + } + } + if (yielded) { + break; /* Preserve the next view's turn even with 1-8 units left. */ + } + } + return 65536 - budget; +} + void region_map_fow_update(region_map_t *region_map) { region_map_def_map_t *def_map; int rowsize, x, y; @@ -919,7 +939,7 @@ void region_map_fow_update(region_map_t *region_map) { HARD_ASSERT(region_map->fow != NULL); HARD_ASSERT(region_map->def != NULL); - if (region_map->surface == NULL) { + if (region_map->surface == NULL || region_map->fow->bitmap == NULL) { /* Not yet loaded, nothing to do. */ return; } @@ -1097,9 +1117,180 @@ SDL_Surface *region_map_fow_surface(region_map_t *region_map) { HARD_ASSERT(region_map->fow != NULL); HARD_ASSERT(region_map->fow->surface != NULL); + if (region_map->zoom != 100 && region_map->fow_zoomed == NULL) { + /* A shared bitmap update invalidates every view's derived fog, while + * popup rendering may retain its already-selected region image. */ + SDL_Surface *zoomed = zoomSurface(region_map->fow->surface, + region_map->zoom / 100.0, + region_map->zoom / 100.0, 0); + if (zoomed != NULL && + SDL_SetSurfaceColorKey(zoomed, true, surface_map_rgb(zoomed, 255, 255, 255))) { + region_map->fow_zoomed = zoomed; + } else { + SDL_DestroySurface(zoomed); + } + } + if (region_map->fow_zoomed != NULL) { return region_map->fow_zoomed; } return region_map->fow->surface; } + +/* Reset all views, including open popup clones, before replaying a new account. + * This and packet receipt run on the main thread, never on the socket worker. */ +void region_map_exploration_refresh_map(const char *path, bool reset) { + if (path != NULL) { + /* Cache revisions coalesce all deltas until the next frame. */ + return; + } + minimap_redraw_flag = 1; + for (region_map_t *map = region_maps; map != NULL; map = map->next) { + if (reset) { + for (size_t i = 0; i < map->def->num_maps; i++) { + region_exploration_request(map->def->maps[i].path); + } + if (map->fow->tiles != NULL) { + for (unsigned i = 0; i < utarray_len(map->fow->tiles); i++) { + region_map_fow_tile_t *tile = + (region_map_fow_tile_t *)utarray_eltptr(map->fow->tiles, i); + free(tile->path); + } + utarray_clear(map->fow->tiles); + } + } + if (map->fow->bitmap != NULL) { + memset(map->fow->bitmap, 0, RM_MAP_FOW_BITMAP_SIZE(map)); + } + map->fow->exploration_revision = map->fow->exploration_target = 0; + map->fow->exploration_map = map->fow->exploration_byte = 0; + region_map_fow_update(map); + } +} + +void region_map_exploration_refresh(bool reset) { + region_map_exploration_refresh_map(NULL, reset); +} + +void region_map_exploration_clear(void) { + region_exploration_disconnect(); + region_map_exploration_refresh(true); +} + +static bool region_map_exploration_send(const uint8_t *data, size_t len, void *user) { + (void)user; + packet_struct *packet = packet_new(SERVER_CMD_REGION_EXPLORATION, len, 0); + packet_writer_write_bytes(packet, data, len); + return socket_send_packet_bounded(packet, 256U * 1024U); +} + +void region_map_exploration_service(void) { + region_map_exploration_replay_service(); + region_exploration_service(region_map_exploration_send, NULL); +} + +#ifdef ATRINIK_WIDGET_TESTS +static bool region_map_exploration_test_send(const uint8_t *data, size_t len, void *user) { + const uint8_t expected[] = {0, '/', 't', 0, 0, 1, 0x21}; + bool *ok = user; + *ok &= len == sizeof(expected) && memcmp(data, expected, sizeof(expected)) == 0; + return true; +} + +bool region_map_exploration_test(void) { + const uint8_t packet[] = {1, '/', 't', 0, 0, 3, 0, 2, 0x21}; + region_exploration_clear(); + region_exploration_connect("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + NULL); + const uint8_t reset[] = {0, 't', 0}; + bool ok = region_exploration_receive(reset, sizeof(reset), NULL); + region_map_t *map = region_map_create(); + ok &= region_exploration_receive(packet, sizeof(packet), NULL); + region_map_exploration_refresh(false); /* Arrives before either asset. */ + map->def->pixel_size = 2; + map->def->maps = xcalloc(1, sizeof(*map->def->maps)); + map->def->num_maps = 1; + map->def->maps[0].path = xstrdup("/t"); + map->def->maps[0].xpos = 4; + map->def->maps[0].ypos = 2; + map->surface = SDL_CreateSurface(16, 16, SDL_PIXELFORMAT_RGBA32); + if (map->surface == NULL) { + region_map_free(map); + region_map_exploration_clear(); + return false; + } + region_map_fow_create(map); + region_map_exploration_replay_service(); + ok &= region_exploration_service(region_map_exploration_test_send, &ok) == 1; + ok &= region_map_fow_is_visited(map, 2, 1) && region_map_fow_is_visited(map, 4, 2) && + !region_map_fow_is_visited(map, 3, 1); + region_map_fow_reset(map); + region_map_fow_create(map); + region_map_exploration_replay_service(); /* Region is reopened with no network replay. */ + ok &= region_map_fow_is_visited(map, 4, 2); + ok &= region_exploration_service(region_map_exploration_test_send, &ok) == 0; + const uint8_t replacement[] = {1, '/', 't', 0, 0, 1, 0, 1, 1}; + ok &= region_exploration_receive(replacement, sizeof(replacement), NULL); + region_map_exploration_refresh(false); + region_map_exploration_replay_service(); + ok &= region_map_fow_is_visited(map, 2, 1) && region_map_fow_is_visited(map, 4, 2); + region_map_t *clone = region_map_clone(map); + region_map_resize(clone, 100); + SDL_Surface *image = clone->zoomed; + const uint8_t delta[] = {2, '/', 't', 0, 0, 3, 0, 2, 0, 1, 0, 0, 2}; + bool changed; + ok &= region_exploration_receive(delta, sizeof(delta), &changed) && changed; + region_map_exploration_refresh_map("/t", false); + ok &= !region_map_fow_is_visited(map, 3, 1); /* Packet receipt only queues work. */ + region_map_exploration_replay_service(); + ok &= region_map_fow_is_visited(map, 3, 1) && clone->fow_zoomed == NULL; + ok &= region_map_fow_surface(clone)->w == clone->surface->w * 2 && clone->zoomed == image; + uint64_t applied = map->fow->exploration_revision; + ok &= region_exploration_receive(delta, sizeof(delta), &changed) && !changed; + region_map_exploration_replay_service(); + ok &= map->fow->exploration_revision == applied && clone->fow_zoomed != NULL; + region_map_exploration_clear(); + ok &= !region_map_fow_is_visited(map, 2, 1) && !region_map_fow_is_visited(clone, 2, 1) && + clone->fow_zoomed == NULL; + region_map_free(clone); + region_map_fow_reset(map); + region_map_fow_create(map); + region_map_exploration_replay_service(); + ok &= !region_map_fow_is_visited(map, 2, 1); + ok &= region_exploration_receive(reset, sizeof(reset), NULL); + region_map_exploration_refresh(true); + region_map_exploration_replay_service(); + ok &= region_map_fow_is_visited(map, 4, 2); + /* A dense record referenced 10,000 times cannot run synchronously in a + * packet handler or exceed one frame's shared replay budget. */ + region_map_fow_reset(map); + free(map->def->maps[0].path); + free(map->def->maps); + map->def->maps = xcalloc(10000, sizeof(*map->def->maps)); + map->def->num_maps = 10000; + for (size_t i = 0; i < 10000; i++) { + map->def->maps[i].path = xstrdup("/dense"); + } + uint8_t dense[8204] = {1, '/', 'd', 'e', 'n', 's', 'e', 0, 1, 0, 1, 0}; + memset(dense + 12, 255, 8192); + ok &= region_exploration_receive(dense, sizeof(dense), &changed) && changed; + region_map_fow_create(map); + ok &= map->fow->exploration_byte == 0 && map->fow->exploration_map == 0; + for (unsigned i = 0; i < 1000; i++) { + ok &= region_exploration_receive(reset, sizeof(reset), &changed) && !changed; + region_map_exploration_refresh_map("/dense", false); + region_map_fow_update(map); + } + ok &= map->fow->exploration_byte == 0 && map->fow->exploration_map == 0; + size_t work = region_map_exploration_replay_service(); + ok &= work <= 65536 && work > 65520 && map->fow->exploration_map == 0 && + map->fow->exploration_byte > 0 && map->fow->exploration_byte < 8192; + size_t cursor = map->fow->exploration_byte; + region_map_exploration_replay_service(); + ok &= map->fow->exploration_map > 0 || map->fow->exploration_byte > cursor; + region_map_free(map); + region_exploration_clear(); + return ok; +} +#endif diff --git a/client/src/client/socket.c b/client/src/client/socket.c index 9fbf25ecf..9650af38e 100644 --- a/client/src/client/socket.c +++ b/client/src/client/socket.c @@ -30,6 +30,11 @@ #include #include #include +#include +#include +#include +#include +#include #include #include #include @@ -58,6 +63,7 @@ static int abort_thread = 0; /* start is the first waiting item in queue, end is the most recent enqueued */ static command_buffer *output_queue_start = NULL, *output_queue_end = NULL; +static size_t output_queue_bytes; /** * Enqueue a command buffer last in a queue. @@ -77,6 +83,7 @@ static void command_buffer_enqueue(command_buffer *buf, } *queue_end = buf; + output_queue_bytes += buf->len; } /** @@ -87,6 +94,7 @@ static command_buffer *command_buffer_dequeue(command_buffer **queue_start, command_buffer *buf = *queue_start; if (buf) { + output_queue_bytes -= buf->len; *queue_start = buf->next; if (buf->next) { @@ -99,27 +107,35 @@ static command_buffer *command_buffer_dequeue(command_buffer **queue_start, return buf; } -void socket_send_packet(struct packet_struct *packet) { +bool socket_send_packet_bounded(struct packet_struct *packet, size_t queue_limit) { HARD_ASSERT(packet != NULL); if (!packet_writer_finish(packet)) { LOG(ERROR, "Refusing malformed outbound packet: %s", packet_error_string(packet->error)); packet_free(packet); - return; + return false; } if (socket_mutex == NULL) { packet_free(packet); - return; + return false; } SDL_LockMutex(socket_mutex); if (csocket.sc == NULL || abort_thread) { SDL_UnlockMutex(socket_mutex); packet_free(packet); - return; + return false; } + SDL_LockMutex(output_buffer_mutex); + size_t bytes = packet->len + 3; + if (bytes > queue_limit || output_queue_bytes > queue_limit - bytes) { + SDL_UnlockMutex(output_buffer_mutex); + SDL_UnlockMutex(socket_mutex); + packet_free(packet); + return false; + } packet_struct *packet_meta = packet_new(0, 4, 0); packet_writer_write_uint16(packet_meta, packet->len + 1); packet_writer_write_uint8(packet_meta, packet->type); @@ -128,12 +144,15 @@ void socket_send_packet(struct packet_struct *packet) { packet_free(packet_meta); command_buffer *buf2 = command_buffer_new(packet->len, packet->data); packet_free(packet); - SDL_UnlockMutex(socket_mutex); - - SDL_LockMutex(output_buffer_mutex); command_buffer_enqueue(buf1, &output_queue_start, &output_queue_end); command_buffer_enqueue(buf2, &output_queue_start, &output_queue_end); SDL_UnlockMutex(output_buffer_mutex); + SDL_UnlockMutex(socket_mutex); + return true; +} + +void socket_send_packet(struct packet_struct *packet) { + (void)socket_send_packet_bounded(packet, SIZE_MAX); } bool client_socket_send_access_auth(const char code[ACCESS_CODE_LENGTH]) { @@ -403,6 +422,7 @@ int handle_socket_shutdown(void) { SDL_UnlockMutex(socket_mutex); /* Empty all queues */ + region_map_exploration_clear(); client_command_retry_clear(); client_command_queue_clear(); bool input_statistics_reset = client_command_queue_statistics_reset(); @@ -500,6 +520,7 @@ void client_socket_deinitialize(void) { } else if (csocket.sc != NULL) { client_socket_close(&csocket); } + region_map_exploration_clear(); client_command_retry_clear(); client_command_queue_deinitialize(); if (output_buffer_mutex != NULL) { @@ -572,6 +593,11 @@ bool client_socket_open(client_socket_t *csock, goto error; } + char *marker = file_path(DIRECTORY_CACHE "/exploration/.root", "wb"); + char *directory = marker != NULL ? path_dirname(marker) : NULL; + region_exploration_connect(quic_certificate_sha256, directory); + free(directory); + free(marker); return true; error: diff --git a/client/src/cmake.txt b/client/src/cmake.txt index 23be90dc9..a50cf0c6b 100644 --- a/client/src/cmake.txt +++ b/client/src/cmake.txt @@ -46,6 +46,7 @@ set(SOURCES src/client/packet_payload.c src/client/player.c src/client/player_status.c + src/client/region_exploration.c src/client/region_map.c src/client/rich_presence.c src/client/rich_presence_policy.c diff --git a/client/src/include/client_socket.h b/client/src/include/client_socket.h index 533ca36cd..fdde102b7 100644 --- a/client/src/include/client_socket.h +++ b/client/src/include/client_socket.h @@ -41,6 +41,8 @@ struct packet_struct; /** Public API implemented in src/client/socket.c. */ extern void socket_send_packet(struct packet_struct *packet); +/** Always consumes packet; false leaves a bounded producer responsible for retry. */ +bool socket_send_packet_bounded(struct packet_struct *packet, size_t queue_limit); /** Queue the one access-auth payload without generic packet debug serialization. */ bool client_socket_send_access_auth(const char code[ACCESS_CODE_LENGTH]); diff --git a/client/src/include/commands.h b/client/src/include/commands.h index 085b173d7..9bb431e65 100644 --- a/client/src/include/commands.h +++ b/client/src/include/commands.h @@ -107,6 +107,8 @@ extern void socket_command_access_admin_result(uint8_t *data, size_t len, size_t extern void socket_command_compressed(uint8_t *data, size_t len, size_t pos); +extern void socket_command_region_exploration(uint8_t *data, size_t len, size_t pos); + extern void socket_command_control(uint8_t *data, size_t len, size_t pos); #endif diff --git a/client/src/include/region_exploration.h b/client/src/include/region_exploration.h new file mode 100644 index 000000000..211cb0d9a --- /dev/null +++ b/client/src/include/region_exploration.h @@ -0,0 +1,43 @@ +/************************************************************************* + * Atrinik, a Multiplayer Online Role Playing Game * + * * + * Copyright 2026 The Atrinik Project * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + ************************************************************************/ +#ifndef REGION_EXPLORATION_H +#define REGION_EXPLORATION_H +#include +#include +#include + +/* Main-thread-only, bounded account cache. Invalid packets never mutate it. + * Connection identity must be the certificate pinned by the actual connection. + * Optional cache_directory is an existing private directory; NULL disables disk. + * Only server-received bits enter the cache. Disconnect flushes dirty data once + * and hides retained memory until RESET authenticates the matching account. */ +void region_exploration_connect(const char *certificate, const char *cache_directory); +void region_exploration_disconnect(void); +void region_exploration_clear(void); +bool region_exploration_receive(const uint8_t *data, size_t len, bool *changed); +/* Account bitmap allocations total at most 1 MiB (10,000 normal 24x24 maps + * use 720,000 bytes). Over-limit new records are rejected transactionally. + * RESET is idempotent while active; disconnect permits a new account. */ +uint64_t region_exploration_revision(void); +typedef void (*region_exploration_visit_fn)(unsigned x, unsigned y, void *user); +/* Resume a record's set bits using a caller-owned byte cursor and shared work + * budget. True means complete; unchanged records cost no byte/cell work. + * The caller charges map lookup work and resets the cursor for the next map. */ +bool region_exploration_replay(const char *path, uint64_t since, size_t *cursor, + size_t *budget, region_exploration_visit_fn visit, void *user); +/* Borrowed storage, valid until clear or an account change. */ +const uint8_t *region_exploration_find(const char *path, unsigned *width, unsigned *height); +/* Deduplicates requests for the session; retry queue is bounded to 10000 paths. */ +bool region_exploration_request(const char *path); +typedef bool (*region_exploration_send_fn)(const uint8_t *data, size_t len, void *user); +/* At most 32 requests; a rejected send retains the pending request. */ +size_t region_exploration_service(region_exploration_send_fn send, void *user); +#endif diff --git a/client/src/include/region_map.h b/client/src/include/region_map.h index 9d0d5d7b0..99ff8bbfd 100644 --- a/client/src/include/region_map.h +++ b/client/src/include/region_map.h @@ -169,11 +169,12 @@ typedef struct region_map_fow { /** Reference count. */ int refcount; - char *path; - SDL_Surface *surface; uint32_t *bitmap; + uint64_t exploration_revision; ///< Last completely applied cache revision. + uint64_t exploration_target; ///< Revision at the beginning of this pass. + size_t exploration_map, exploration_byte; ///< Bounded replay continuation. UT_array *tiles; } region_map_fow_t; @@ -182,6 +183,7 @@ typedef struct region_map_fow { * Region map structure. */ typedef struct region_map { + struct region_map *next; ///< Main-thread live-view registry. /** * Region map image. * @@ -265,9 +267,12 @@ region_map_t *region_map_clone(region_map_t *region_map); void region_map_free(region_map_t *region_map); void region_map_update(region_map_t *region_map, const char *region_name); void region_map_fow_update(region_map_t *region_map); +void region_map_exploration_refresh(bool reset); +void region_map_exploration_refresh_map(const char *path, bool reset); +void region_map_exploration_clear(void); +void region_map_exploration_service(void); #ifdef ATRINIK_WIDGET_TESTS -/** Disable or restore fixture FOW persistence. */ -void region_map_test_fow_persistence_set(bool enabled); +bool region_map_exploration_test(void); #endif bool region_map_fow_set_visited(region_map_t *region_map, region_map_def_map_t *map, diff --git a/client/src/tests/region_exploration.c b/client/src/tests/region_exploration.c new file mode 100644 index 000000000..4a00f88fc --- /dev/null +++ b/client/src/tests/region_exploration.c @@ -0,0 +1,280 @@ +/************************************************************************* + * Atrinik, a Multiplayer Online Role Playing Game * + * * + * Copyright 2026 The Atrinik Project * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + ************************************************************************/ +#include +#include +#include +#include +#include +#include +#define CHECK(c) \ + do { \ + if (!(c)) { \ + fprintf(stderr, "line %d: %s\n", __LINE__, #c); \ + abort(); \ + } \ + } while (0) +static const char certificate[] = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +static const uint8_t reset[] = {0, 'a', 0}; +static size_t packet(uint8_t *out, const char *path, unsigned w, unsigned h) { + out[0] = 1; + size_t pos = strlen(path) + 2; + memcpy(out + 1, path, pos - 1); + out[pos++] = (uint8_t)(w >> 8); + out[pos++] = (uint8_t)w; + out[pos++] = (uint8_t)(h >> 8); + out[pos++] = (uint8_t)h; + size_t bytes = ((size_t)w * h + 7) / 8; + memset(out + pos, 0, bytes); + return pos + bytes; +} +static bool receive(const uint8_t *data, size_t len) { + return region_exploration_receive(data, len, NULL); +} +static size_t sent; +static bool blocked; +static bool send_request(const uint8_t *data, size_t len, void *user) { + (void)user; + if (blocked) { + return false; + } + CHECK(data[0] == 0); + size_t pos = strlen((const char *)data + 1) + 2; + unsigned bytes = ((unsigned)data[pos] << 8) | data[pos + 1]; + CHECK(len == pos + 2 + bytes); + if (strcmp((const char *)data + 1, "/test") == 0) { + CHECK(bytes == 3 && data[pos + 2] == 0x85 && data[pos + 3] == 2 && data[pos + 4] == 1); + } + sent++; + return true; +} +static void select_account(const char *directory) { + region_exploration_connect(certificate, directory); + CHECK(receive(reset, sizeof(reset))); +} +static char *cache_file(const char *directory) { + uint8_t identity[67], digest[EVP_MAX_MD_SIZE]; + unsigned size; + memcpy(identity, certificate, 65); + identity[65] = 'a'; + identity[66] = 0; + CHECK(EVP_Digest(identity, sizeof(identity), digest, &size, EVP_sha256(), NULL) == 1 && + size == 32); + char name[81] = "exploration-"; + for (unsigned i = 0; i < 32; i++) { + snprintf(name + 12 + i * 2, 3, "%02x", digest[i]); + } + strcat(name, ".bin"); + return path_join(directory, name); +} +static size_t visited; +static void count_visit(unsigned x, unsigned y, void *user) { + (void)user; + CHECK(x < 256 && y < 256); + visited++; +} + +static void replay_limits(void) { + region_exploration_clear(); + select_account(NULL); + uint8_t data[9000]; + char path[64]; + size_t accepted = 0; + for (unsigned i = 0; i < 10000; i++) { + snprintf(path, sizeof(path), "/attack/%u", i); + size_t len = packet(data, path, 256, 256) - 8192; + data[0] = 2; + const uint8_t sparse[] = {0, 1, 0, 0, 1}; + memcpy(data + len, sparse, sizeof(sparse)); + bool changed; + bool ok = region_exploration_receive(data, len + sizeof(sparse), &changed); + CHECK(ok == (i < 128)); /* 1 MiB, never the former 78.125 MiB. */ + CHECK(changed == ok); + accepted += ok; + } + CHECK(accepted == 128); + size_t budget = 65536, before = budget, cursor = 0; + visited = 0; + for (unsigned i = 0; i < 10000 && budget > 0; i++) { + snprintf(path, sizeof(path), "/attack/%u", i); + budget--; /* Same map-lookup charge as the view service. */ + cursor = 0; + if (!region_exploration_replay(path, 0, &cursor, &budget, count_visit, NULL)) { + break; + } + } + CHECK(before - budget <= 65536 && visited < 10 && cursor < 8192); + /* A full dense map cannot bypass a small work slice; later slices resume + * exactly, and an unchanged revision avoids even the byte scan. */ + region_exploration_clear(); + select_account(NULL); + size_t len = packet(data, "/dense", 256, 256); + memset(data + len - 8192, 255, 8192); + CHECK(receive(data, len)); + uint64_t revision = region_exploration_revision(); + visited = cursor = 0; + unsigned slices = 0; + for (;;) { + budget = 100; + before = visited; + bool done = region_exploration_replay("/dense", 0, &cursor, &budget, count_visit, NULL); + CHECK(visited - before <= 88 && budget <= 100); + slices++; + if (done) { + break; + } + CHECK(cursor > 0 && slices < 1000); + } + CHECK(visited == 65536 && slices > 1); + budget = 100; + cursor = 0; + CHECK(region_exploration_replay("/dense", revision, &cursor, &budget, count_visit, NULL)); + CHECK(visited == 65536 && budget == 100 && cursor == 0); + bool changed; + CHECK(region_exploration_receive(data, len, &changed) && !changed); + CHECK(region_exploration_revision() == revision); + region_exploration_clear(); +} +int main(int argc, char **argv) { + CHECK(argc == 2); + CHECK(path_ensure_real_directory(argv[1], 0700) == PATH_DIRECTORY_OK); + char *filename = cache_file(argv[1]); + remove(filename); + uint8_t data[9000]; + unsigned w, h; + size_t len = packet(data, "/test", 9, 2); + CHECK(!receive(data, len)); /* No account before authenticated RESET. */ + select_account(NULL); + data[len - 3] = 0x81; + data[len - 2] = 2; + data[len - 1] = 1; + bool changed; + CHECK(region_exploration_receive(data, len, &changed) && changed); + CHECK(region_exploration_receive(data, len, &changed) && !changed); + const uint8_t *bits = region_exploration_find("/test", &w, &h); + CHECK(bits && w == 9 && h == 2 && bits[0] == 0x81 && bits[1] == 2 && bits[2] == 1); + for (size_t n = 0; n < len; n++) { + CHECK(!receive(data, n)); + CHECK(region_exploration_find("/test", &w, &h) == bits && bits[0] == 0x81); + } + data[len] = 0; + CHECK(!receive(data, len + 1)); + const uint8_t patch[] = {2, '/', 't', 'e', 's', 't', 0, 0, 9, 0, 2, 0, 1, 0, 0, 4}; + for (size_t n = 0; n < sizeof(patch); n++) { + CHECK(!receive(patch, n)); + CHECK(bits[0] == 0x81); + } + CHECK(region_exploration_receive(patch, sizeof(patch), &changed) && changed && bits[0] == 0x85); + CHECK(region_exploration_receive(patch, sizeof(patch), &changed) && !changed); + uint8_t invalid_patch[] = {2, '/', 't', 'e', 's', 't', 0, 0, 9, 0, 2, 0, 2, 0, 0, 2, 0, 3, 1}; + CHECK(!receive(invalid_patch, sizeof(invalid_patch)) && + bits[0] == 0x85); /* Atomic bad final index. */ + invalid_patch[17] = 0; + CHECK(!receive(invalid_patch, sizeof(invalid_patch))); /* Duplicate. */ + invalid_patch[17] = 2; + invalid_patch[18] = 4; + CHECK(!receive(invalid_patch, sizeof(invalid_patch))); /* Tail padding. */ + const uint8_t empty[] = {3, '/', 't', 'e', 's', 't', 0}; + CHECK(region_exploration_receive(empty, sizeof(empty), &changed) && !changed && + bits[0] == 0x85); + CHECK(region_exploration_request("/test")); + CHECK(region_exploration_request("/test")); + uint64_t unchanged = region_exploration_revision(); + for (unsigned i = 0; i < 10000; i++) { + CHECK(region_exploration_receive(reset, sizeof(reset), &changed) && !changed); + } + CHECK(region_exploration_revision() == unchanged); + blocked = true; + CHECK(region_exploration_service(send_request, NULL) == 0); + blocked = false; + CHECK(region_exploration_service(send_request, NULL) == 1 && sent == 1); + CHECK(region_exploration_receive(reset, sizeof(reset), &changed) && !changed); + CHECK(region_exploration_request("/test")); + CHECK(region_exploration_service(send_request, NULL) == 0); + region_exploration_disconnect(); + CHECK(!region_exploration_find("/test", &w, &h)); + CHECK(region_exploration_service(send_request, NULL) == 0); + CHECK(region_exploration_receive(reset, sizeof(reset), &changed) && changed); + CHECK(region_exploration_find("/test", &w, &h) == bits && bits[0] == 0x85); + len = packet(data, "/test", 1, 1); + data[len - 1] = 1; + CHECK(receive(data, len)); + CHECK(region_exploration_find("/test", &w, &h) == bits && w == 9 && h == 2 && bits[0] == 0x85); + const uint8_t account_b[] = {0, 'b', 0}; + CHECK(!region_exploration_receive(account_b, sizeof(account_b), &changed) && !changed); + CHECK(region_exploration_find("/test", &w, &h) == bits); + region_exploration_connect(certificate, NULL); + CHECK(region_exploration_receive(account_b, sizeof(account_b), &changed) && changed); + CHECK(!region_exploration_find("/test", &w, &h)); + CHECK(!receive((const uint8_t *)"\0a\0x", 4)); + len = packet(data, "/test", 0, 1); + CHECK(!receive(data, len)); + len = packet(data, "/test", 257, 1); + CHECK(!receive(data, len)); + char path[258]; + memset(path, 'a', sizeof(path)); + path[0] = '/'; + path[256] = 0; + len = packet(data, path, 1, 1); + CHECK(!receive(data, len)); + path[255] = 0; + len = packet(data, path, 256, 256); + data[len - 1] = 0x80; + CHECK(receive(data, len)); + bits = region_exploration_find(path, &w, &h); + CHECK(bits && w == 256 && h == 256 && bits[8191] == 0x80); + const char *invalid[] = {"/", "/a//b", "/a/../b", "/a/./b", "/a\\b", "/a:b", "/a\nb"}; + for (size_t i = 0; i < sizeof(invalid) / sizeof(invalid[0]); i++) { + len = packet(data, invalid[i], 1, 1); + CHECK(!receive(data, len)); + } + len = packet(data, "/padding", 1, 1); + data[len - 1] = 2; + CHECK(!receive(data, len)); + region_exploration_clear(); + select_account(argv[1]); + sent = 0; + for (unsigned i = 0; i < 10000; i++) { + snprintf(path, sizeof(path), "/map/%u", i); + len = packet(data, path, 24, 24); + data[len - 1] = 1; + CHECK(receive(data, len)); + CHECK(region_exploration_request(path)); + } + CHECK(!region_exploration_request("/overflow")); + len = packet(data, "/overflow", 1, 1); + CHECK(!receive(data, len)); + CHECK(region_exploration_service(send_request, NULL) == 32); + while (region_exploration_service(send_request, NULL)) {} + CHECK(sent == 10000); + region_exploration_clear(); /* Flush persistent account cache and drop memory. */ + select_account(argv[1]); + for (unsigned i = 0; i < 10000; i++) { + snprintf(path, sizeof(path), "/map/%u", i); + bits = region_exploration_find(path, &w, &h); + CHECK(bits && w == 24 && h == 24 && bits[71] == 1); + } + region_exploration_connect("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + argv[1]); + CHECK(receive(reset, sizeof(reset)) && !region_exploration_find("/map/0", &w, &h)); + region_exploration_clear(); + FILE *file = fopen(filename, "ab"); + CHECK(file != NULL); + CHECK(fwrite("x", 1, 1, file) == 1); + CHECK(fclose(file) == 0); + select_account(argv[1]); + CHECK(!region_exploration_find("/map/0", &w, &h)); /* Corrupt suffix rolls back whole load. */ + region_exploration_clear(); + CHECK(remove(filename) == 0); + free(filename); + replay_limits(); + return 0; +} diff --git a/client/src/tests/socket_output.c b/client/src/tests/socket_output.c new file mode 100644 index 000000000..a7fd6d7e9 --- /dev/null +++ b/client/src/tests/socket_output.c @@ -0,0 +1,102 @@ +/* Copyright 2026 The Atrinik Project + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +/* Exercise the production enqueuers and dequeue accounting without a network + * connection. Function sections discard unrelated transport/lifecycle paths. */ +#define socket_is_quic socket_output_test_is_quic +#include "../client/socket.c" +#undef socket_is_quic + +#include +#include + +#define REQUIRE(condition) \ + do { \ + if (!(condition)) { \ + fprintf(stderr, "%s:%d: %s\n", __FILE__, __LINE__, #condition); \ + abort(); \ + } \ + } while (0) + +client_socket_t csocket; +server_struct *selected_server; +static unsigned int transport_marker; + +bool socket_output_test_is_quic(socket_t *socket) { + return socket == (socket_t *)&transport_marker; +} + +static void drain_output(void) { + SDL_LockMutex(output_buffer_mutex); + while (output_queue_start != NULL) { + command_buffer *buffer = command_buffer_dequeue(&output_queue_start, &output_queue_end); + command_buffer_free(buffer); + } + REQUIRE(output_queue_end == NULL); + REQUIRE(output_queue_bytes == 0); + SDL_UnlockMutex(output_buffer_mutex); +} + +static void check_access_queue(size_t payload_size) { + SDL_LockMutex(output_buffer_mutex); + REQUIRE(output_queue_start != NULL && output_queue_start->len == 3); + REQUIRE(!output_queue_start->sensitive); + REQUIRE(output_queue_end != output_queue_start); + REQUIRE(output_queue_end->len == payload_size && output_queue_end->sensitive); + REQUIRE(output_queue_bytes == 3 + payload_size); + SDL_UnlockMutex(output_buffer_mutex); +} + +static packet_struct *ordinary_packet(void) { + packet_struct *packet = packet_new(SERVER_CMD_KEEPALIVE, 1, 0); + packet_writer_write_uint8(packet, 1); + return packet; +} + +static void check_followup_sends(void) { + socket_send_packet(ordinary_packet()); + REQUIRE(output_queue_bytes == 4); + /* A full bounded queue must reject another packet without changing bytes. */ + REQUIRE(!socket_send_packet_bounded(ordinary_packet(), 4)); + REQUIRE(output_queue_bytes == 4); + drain_output(); + REQUIRE(socket_send_packet_bounded(ordinary_packet(), 4)); + REQUIRE(output_queue_bytes == 4); + drain_output(); +} + +int main(void) { + static const char code[] = "0123456789ABCDEF"; + static const char admin[] = "{\"operation\":\"list\"}"; + toolkit_import(packet); + socket_mutex = SDL_CreateMutex(); + output_buffer_mutex = SDL_CreateMutex(); + REQUIRE(socket_mutex != NULL && output_buffer_mutex != NULL); + csocket.sc = (socket_t *)&transport_marker; + + REQUIRE(client_socket_send_access_auth(code)); + check_access_queue(ACCESS_CODE_LENGTH + 1U); + drain_output(); + check_followup_sends(); + + REQUIRE(client_socket_send_access_admin(admin, sizeof(admin) - 1)); + check_access_queue(sizeof(admin)); + drain_output(); + check_followup_sends(); + + /* Both specialized producers can coexist with an ordinary queued command. */ + REQUIRE(client_socket_send_access_auth(code)); + REQUIRE(client_socket_send_access_admin(admin, sizeof(admin) - 1)); + socket_send_packet(ordinary_packet()); + REQUIRE(output_queue_bytes == 3 + ACCESS_CODE_LENGTH + 1U + 3 + sizeof(admin) + 4); + drain_output(); + check_followup_sends(); + + csocket.sc = NULL; + SDL_DestroyMutex(output_buffer_mutex); + SDL_DestroyMutex(socket_mutex); + toolkit_deinit(); + puts("socket output accounting tests passed"); + return 0; +} diff --git a/protocol/AGENTS.md b/protocol/AGENTS.md index f3f3dfe18..340525ba8 100644 --- a/protocol/AGENTS.md +++ b/protocol/AGENTS.md @@ -29,7 +29,8 @@ - Update this `AGENTS.md` in the same change when major rework alters ownership, schemas, generation, compatibility policy, consumers, or validation. -- Revision 1081 reserves retired SETUP subtype 3 and adds ACCESS_AUTH/ADMIN, - RESULT/ADMIN_RESULT/POLICY. Policy follows authenticated VERSION before - setup/resources/accounts on every connection; update all native and Python - consumers together. Raw access payloads require sensitive packet handling. +- Revision 1082 retains the published v1081 ACCESS_AUTH/ADMIN IDs 23/24 and + RESULT/ADMIN_RESULT/POLICY IDs 29/30/31; REGION_EXPLORATION uses client-to-server + ID 25 and server-to-client ID 32. Retired SETUP subtype 3 remains reserved. + Policy follows authenticated VERSION before setup/resources/accounts on every + connection; update all native and Python consumers together. Raw access payloads require sensitive packet handling. diff --git a/protocol/README.md b/protocol/README.md index dd6681d8b..048b7be94 100644 --- a/protocol/README.md +++ b/protocol/README.md @@ -9,7 +9,16 @@ The game and metaserver protocols are separate contract families. This package currently publishes only the classic game command registry. Add another family only with its own namespace, specification, version, fixtures, and validation. -Protocol v1081 replaces invitations and shared join passwords with independent +Protocol v1082 synchronizes account region-map exploration using map-path +bitfields. Server-to-client `REGION_EXPLORATION` (ID 32) carries account binding, +bitmaps and sparse bit deltas. Client-to-server `REGION_EXPLORATION` (ID 25) +requests only discoveries missing from a cached bitmap, without granting any +discovery on the server. See the [exploration contract](../server/doc/REGION_EXPLORATION.md) +for framing, bounds, persistence and lifecycle rules. Both peers require the +exact protocol version; the earlier unpublished snapshot-only exploration +format is superseded. Access command IDs from published v1081 remain unchanged. + +Protocol v1081 replaced invitations and shared join passwords with independent access codes. Authenticated `ACCESS_POLICY` precedes setup/account traffic; protected connections require `ACCESS_AUTH` before proceeding. `ACCESS_ADMIN` uses the active character's existing `/cmd_permission` grants, including diff --git a/protocol/generated/c/include/atrinik/protocol/game_commands.def b/protocol/generated/c/include/atrinik/protocol/game_commands.def index 96da1e67f..e373af5a1 100644 --- a/protocol/generated/c/include/atrinik/protocol/game_commands.def +++ b/protocol/generated/c/include/atrinik/protocol/game_commands.def @@ -36,6 +36,7 @@ ATRINIK_CLIENT_TO_SERVER_COMMAND(21, MOVE, "move") ATRINIK_CLIENT_TO_SERVER_COMMAND(22, TARGET, "target") ATRINIK_CLIENT_TO_SERVER_COMMAND(23, ACCESS_AUTH, "access_auth") ATRINIK_CLIENT_TO_SERVER_COMMAND(24, ACCESS_ADMIN, "access_admin") +ATRINIK_CLIENT_TO_SERVER_COMMAND(25, REGION_EXPLORATION, "region_exploration") ATRINIK_SERVER_TO_CLIENT_COMMAND(0, MAP, "map") ATRINIK_SERVER_TO_CLIENT_COMMAND(1, DRAWINFO, "drawinfo") @@ -69,6 +70,7 @@ ATRINIK_SERVER_TO_CLIENT_COMMAND(28, PLAYER_STATUS, "player_status") ATRINIK_SERVER_TO_CLIENT_COMMAND(29, ACCESS_RESULT, "access_result") ATRINIK_SERVER_TO_CLIENT_COMMAND(30, ACCESS_ADMIN_RESULT, "access_admin_result") ATRINIK_SERVER_TO_CLIENT_COMMAND(31, ACCESS_POLICY, "access_policy") +ATRINIK_SERVER_TO_CLIENT_COMMAND(32, REGION_EXPLORATION, "region_exploration") #ifdef ATRINIK_CLIENT_TO_SERVER_COMMAND_WAS_UNDEFINED #undef ATRINIK_CLIENT_TO_SERVER_COMMAND_WAS_UNDEFINED diff --git a/protocol/generated/c/include/atrinik/protocol/game_commands.h b/protocol/generated/c/include/atrinik/protocol/game_commands.h index 76fe50e6d..374322836 100644 --- a/protocol/generated/c/include/atrinik/protocol/game_commands.h +++ b/protocol/generated/c/include/atrinik/protocol/game_commands.h @@ -3,7 +3,7 @@ #ifndef ATRINIK_PROTOCOL_GAME_COMMANDS_H #define ATRINIK_PROTOCOL_GAME_COMMANDS_H -#define ATRINIK_PROTOCOL_VERSION 1081U +#define ATRINIK_PROTOCOL_VERSION 1082U #define SOCKET_VERSION ATRINIK_PROTOCOL_VERSION #define ATRINIK_PROTOCOL_ITEM_NAME_SIZE 128U @@ -47,7 +47,8 @@ typedef enum atrinik_client_to_server_command { SERVER_CMD_TARGET = 22, SERVER_CMD_ACCESS_AUTH = 23, SERVER_CMD_ACCESS_ADMIN = 24, - SERVER_CMD_NROF = 25 + SERVER_CMD_REGION_EXPLORATION = 25, + SERVER_CMD_NROF = 26 } atrinik_client_to_server_command_t; /* Stable diagnostic names for client-to-server commands. */ @@ -76,6 +77,7 @@ typedef enum atrinik_client_to_server_command { #define SERVER_CMD_NAME_TARGET "target" #define SERVER_CMD_NAME_ACCESS_AUTH "access_auth" #define SERVER_CMD_NAME_ACCESS_ADMIN "access_admin" +#define SERVER_CMD_NAME_REGION_EXPLORATION "region_exploration" typedef enum atrinik_server_to_client_command { CLIENT_CMD_MAP = 0, @@ -110,7 +112,8 @@ typedef enum atrinik_server_to_client_command { CLIENT_CMD_ACCESS_RESULT = 29, CLIENT_CMD_ACCESS_ADMIN_RESULT = 30, CLIENT_CMD_ACCESS_POLICY = 31, - CLIENT_CMD_NROF = 32 + CLIENT_CMD_REGION_EXPLORATION = 32, + CLIENT_CMD_NROF = 33 } atrinik_server_to_client_command_t; /* Stable diagnostic names for server-to-client commands. */ @@ -146,5 +149,6 @@ typedef enum atrinik_server_to_client_command { #define CLIENT_CMD_NAME_ACCESS_RESULT "access_result" #define CLIENT_CMD_NAME_ACCESS_ADMIN_RESULT "access_admin_result" #define CLIENT_CMD_NAME_ACCESS_POLICY "access_policy" +#define CLIENT_CMD_NAME_REGION_EXPLORATION "region_exploration" #endif diff --git a/protocol/generated/python/atrinik_protocol/game.py b/protocol/generated/python/atrinik_protocol/game.py index 0af0a13ab..a7a7dc75c 100644 --- a/protocol/generated/python/atrinik_protocol/game.py +++ b/protocol/generated/python/atrinik_protocol/game.py @@ -2,7 +2,7 @@ # Do not edit by hand. from enum import IntEnum -PROTOCOL_VERSION = 1081 +PROTOCOL_VERSION = 1082 ITEM_NAME_SIZE = 128 @@ -49,6 +49,7 @@ class ClientToServerCommand(IntEnum): TARGET = 22 ACCESS_AUTH = 23 ACCESS_ADMIN = 24 + REGION_EXPLORATION = 25 class ServerToClientCommand(IntEnum): @@ -84,3 +85,4 @@ class ServerToClientCommand(IntEnum): ACCESS_RESULT = 29 ACCESS_ADMIN_RESULT = 30 ACCESS_POLICY = 31 + REGION_EXPLORATION = 32 diff --git a/protocol/schema/game-commands.json b/protocol/schema/game-commands.json index bf6caf12f..e901c54ae 100644 --- a/protocol/schema/game-commands.json +++ b/protocol/schema/game-commands.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "protocol_version": 1081, + "protocol_version": 1082, "item_name_size": 128, "item_extra_message_size": 256, "player_status": { @@ -39,7 +39,8 @@ {"id": 21, "symbol": "MOVE", "wire_name": "move"}, {"id": 22, "symbol": "TARGET", "wire_name": "target"}, {"id": 23, "symbol": "ACCESS_AUTH", "wire_name": "access_auth"}, - {"id": 24, "symbol": "ACCESS_ADMIN", "wire_name": "access_admin"} + {"id": 24, "symbol": "ACCESS_ADMIN", "wire_name": "access_admin"}, + {"id": 25, "symbol": "REGION_EXPLORATION", "wire_name": "region_exploration"} ], "server_to_client": [ {"id": 0, "symbol": "MAP", "wire_name": "map"}, @@ -73,6 +74,7 @@ {"id": 28, "symbol": "PLAYER_STATUS", "wire_name": "player_status"}, {"id": 29, "symbol": "ACCESS_RESULT", "wire_name": "access_result"}, {"id": 30, "symbol": "ACCESS_ADMIN_RESULT", "wire_name": "access_admin_result"}, - {"id": 31, "symbol": "ACCESS_POLICY", "wire_name": "access_policy"} + {"id": 31, "symbol": "ACCESS_POLICY", "wire_name": "access_policy"}, + {"id": 32, "symbol": "REGION_EXPLORATION", "wire_name": "region_exploration"} ] } diff --git a/protocol/tests/test_generate.py b/protocol/tests/test_generate.py index 82eb6e326..8bc035e53 100644 --- a/protocol/tests/test_generate.py +++ b/protocol/tests/test_generate.py @@ -17,12 +17,33 @@ class GenerateTests(unittest.TestCase): def test_repository_schema_is_valid(self) -> None: schema = GENERATE.load_schema(ROOT / "schema/game-commands.json") - self.assertEqual(schema["protocol_version"], 1081) + self.assertEqual(schema["protocol_version"], 1082) self.assertEqual(schema["item_name_size"], 128) self.assertEqual(schema["item_extra_message_size"], 256) self.assertEqual(schema["player_status"]["max_statuses"], 48) - self.assertEqual(len(schema["client_to_server"]), 25) - self.assertEqual(len(schema["server_to_client"]), 32) + self.assertEqual(len(schema["client_to_server"]), 26) + self.assertEqual(len(schema["server_to_client"]), 33) + + def test_combined_access_and_exploration_command_ids(self) -> None: + schema = GENERATE.load_schema(ROOT / "schema/game-commands.json") + expected = { + "client_to_server": { + "ACCESS_AUTH": 23, + "ACCESS_ADMIN": 24, + "REGION_EXPLORATION": 25, + }, + "server_to_client": { + "ACCESS_RESULT": 29, + "ACCESS_ADMIN_RESULT": 30, + "ACCESS_POLICY": 31, + "REGION_EXPLORATION": 32, + }, + } + for direction, commands in expected.items(): + actual = {command["symbol"]: command["id"] for command in schema[direction]} + for symbol, command_id in commands.items(): + with self.subTest(direction=direction, symbol=symbol): + self.assertEqual(actual[symbol], command_id) def test_repository_outputs_are_current(self) -> None: schema = GENERATE.load_schema(ROOT / "schema/game-commands.json") diff --git a/server/AGENTS.md b/server/AGENTS.md index 09a2f8ad9..fe43e61c7 100644 --- a/server/AGENTS.md +++ b/server/AGENTS.md @@ -18,6 +18,10 @@ - Persist spell/skill identities as stable strings, never table positions. Review `doc/METRICS.md` and the metric registry/hooks for gameplay changes; metric names, subjects, and event semantics are durable save contracts. +- Region-map exploration is account-owned server state. Follow + [`doc/REGION_EXPLORATION.md`](doc/REGION_EXPLORATION.md): only visible public + base-level MAP2 cells grant discovery; retain account sidecars in backups and + never import untrusted client `.tiles` files. - Preserve object ownership, map activation/swap, lighting, plugin boundaries, and save transactionality. Test cleanup/rollback for lifecycle changes. - While celestial-v1 runtime activation is inactive, preserve physical private-map @@ -48,7 +52,7 @@ ## Access admission and administration -- Protocol 1081 uses mandatory authenticated access policy before setup/account +- Protocol 1082 retains mandatory authenticated access policy before setup/account traffic. Retired SETUP subtype 3 stays reserved; no join-password fallback. - `access_tokens.c` owns checked snapshot/audit/receipt/outbox transactions; `access_admin.c` owns strict bounded management JSON; `access_server.c` owns diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt index 409f35e38..ff15390c9 100644 --- a/server/CMakeLists.txt +++ b/server/CMakeLists.txt @@ -139,8 +139,8 @@ endif () file(STRINGS "${atrinik_protocol_SOURCE_DIR}/generated/c/include/atrinik/protocol/game_commands.h" ATRINIK_PROTOCOL_VERSION_LINE REGEX "^#define ATRINIK_PROTOCOL_VERSION [0-9]+U$") -if (NOT ATRINIK_PROTOCOL_VERSION_LINE MATCHES " 1081U$") - message(FATAL_ERROR "Server requires classic game protocol v1081") +if (NOT ATRINIK_PROTOCOL_VERSION_LINE MATCHES " 1082U$") + message(FATAL_ERROR "Server requires classic game protocol v1082") endif () if (NOT TARGET Atrinik::Core) set(LIBATRINIK_PACKAGE_LAYOUT ON CACHE BOOL "" FORCE) diff --git a/server/doc/LOCAL_ADMIN_SHUTDOWN.md b/server/doc/LOCAL_ADMIN_SHUTDOWN.md index c14c7872f..c59c61845 100644 --- a/server/doc/LOCAL_ADMIN_SHUTDOWN.md +++ b/server/doc/LOCAL_ADMIN_SHUTDOWN.md @@ -61,6 +61,8 @@ ATRINIK-ADMIN/1 RESULT ID saved Other terminal states are `failed` and `cancelled`. `saved` requires timer expiry and successful checked account/player/map/world-clock/map-log/journal shutdown. +Account exploration sidecars, including dirty accounts retained after socket +teardown, participate in the checked persistence result. Failure to save or publish completion causes a nonzero server exit. The updater must require **both** the matching, owner/mode-verified `saved` receipt and clean exit of the exact process/container it authenticated. A scheduled acknowledgement, diff --git a/server/doc/REGION_EXPLORATION.md b/server/doc/REGION_EXPLORATION.md new file mode 100644 index 000000000..275090a1e --- /dev/null +++ b/server/doc/REGION_EXPLORATION.md @@ -0,0 +1,155 @@ +# Account region-map exploration + +The Classic server owns a map-path-to-bitfield store for each authenticated +account. Characters share discoveries, including simultaneous sessions. Fresh +clients request the same history. Client caches and requests never grant server +exploration. This is separate from MAP2 remembered geometry, current lighting +and inventory region-map reveal items. + +## Discovery and execution cost + +`draw_client_map2()` records the resolved map identity and local tile coordinates +only after its content and visibility checks accept a public, non-unique map +cell at depth zero with FOW false and a region map in its region ancestry. +Blocked LOS, retained roof boundaries, other depths, absent content, private +maps and maps without a region map never grant persistence. Discovery cannot +load another map, change gameplay LOS, or reveal actors or lights. + +`exploration.c` owns this state on the single simulation thread. Each socket +holds its own session pointer and that session points directly to its account. +Accounts and map paths use the existing `uthash` implementation. Repeated cells +on the same map use the session's last-map cache. Unchanged discoveries perform +no account/session scan, pending-queue work or write. A newly set bit is queued +only for sessions sharing that account. + +Each session has a hashed, coalescing pending-map FIFO. Flushing one socket +visits only its own pending records, never every account, session or stored map. +At most 32 records are sent per map draw, stopping when the socket queue exceeds +1 MiB or 1024 queued fragments. Backpressure retains pending bits. Pending maps +are bounded at 10,000 per session; exhausting that bound through requests is a +protocol limit error, and an exhausted session is disconnected rather than +silently losing live discoveries. Reconnect reconciliation recovers history. + +Map paths start with `/`, contain at most 255 bytes, and contain no empty, `.` +or `..` segment, backslash, colon or ASCII control character. Width and height +are each 1–256; an account contains at most 10,000 maps. Initial dimensions +establish the row-major bitfield layout. There is no map revision or geometry +consistency check, reset, migration or comparison. Later coordinates outside +that initial layout are ignored; client rendering clips to its region image. + +## Durable storage + +The sidecar is `.exploration`, next to the account +file. Credential and roster rewrites cannot erase it. Missing sidecars mean an +empty store. Include sidecars in account backups and complete mutable-datapath +activation archives. + +The existing binary format remains: + +- Eight ASCII magic/version bytes `AEXP0001`. +- Records until EOF: big-endian unsigned 16-bit path byte length, that many path + bytes without a terminator, big-endian unsigned 16-bit width and height, then + exactly `ceil(width * height / 8)` bitmap bytes. +- Bits are row-major and least-significant-bit first in each byte. Unused tail + bits must be zero. Duplicate paths are invalid. + +Readers bound the file to 96 MiB and 10,000 records. Hash lookups detect duplicate +paths while loading. Parsing is transactional: malformed/truncated/oversized +files discard staged state, log a diagnostic and disable writes for that +account session. The original file remains available for operator recovery. + +Dirty accounts use the existing mode-0600 atomic replacement API, including +flush/fsync/rename, at most once per five seconds. Logout, socket teardown and +orderly shutdown force a save. Clean accounts do not write. Failed saves retain +dirty state for retry across reconnect and log an error. Checked logout reports +sidecar save failures alongside character and account failures. Before publishing +a shutdown receipt, the server also checks detached dirty exploration accounts; +a failed sidecar save prevents a successful receipt and clean exit. A crash may lose the +most recent unsaved batch. This intentionally remains one account snapshot, +not a per-map file tree or journal: a save is linear in that account's data, +while discovery, idle flush and region synchronization do not scan it. + +The scale test reports actual bytes and mark/save/load microseconds for 10,000 +24×24 maps and 10,000 256×256 maps. The latter has 78.125 MiB of bitmaps plus +record overhead; it is a deliberate persistence-cost bound, not a typical +region. Save/load timings are diagnostics, not hardware-dependent assertions. + +## Protocol 1082 + +Server-to-client `REGION_EXPLORATION` uses command 32. Its payload starts +with one unsigned 8-bit operation: + +| Operation | Remaining payload | +| --- | --- | +| `0` RESET | Authenticated account name, NUL terminated (1–`MAX_BUF-1` bytes). Binds the session to that account's client cache. | +| `1` BITMAP | Path cstring, width u16, height u16, complete bitmap. | +| `2` PATCH | Path cstring, width u16, height u16, count u16, then `count` pairs of byte-index u16 and OR-mask u8. | +| `3` EMPTY | Path cstring; this account has no exploration record for the requested path. | + +All multibyte integers are big-endian. Paths, dimensions and bitmap bounds match +storage. PATCH contains 1–8192 entries in strictly increasing index order, +nonzero masks, valid byte indices and no unused tail bits. BITMAP and PATCH +merge into the account cache by OR. The server chooses PATCH only when its +count-and-pair bytes are smaller than a full bitmap; otherwise it sends BITMAP. + +Client-to-server `REGION_EXPLORATION` command 25 is playing-only. Its payload is +operation `0`, path cstring, cached-byte-count u16, and that many cache bytes. +Zero count means a cache miss. For a known map a nonzero count must exactly +match its stored bitmap size, with canonical zero padding. For an unknown path, +any bounded cache up to 8192 bytes yields EMPTY, without creating a server map. + +The server computes `authoritative_bits & ~cached_bits` and queues only missing +bits. A matching cache sends no BITMAP/PATCH. Client-supplied bits never mutate +server exploration. All requests are fully validated before enqueuing state. +Unknown operations, invalid paths/lengths, trailing/truncated bytes and excess +pending requests fail through the normal bounded packet parser. + +RESET is sent for each successful character session. It carries the canonical +authenticated account name so the client can bind a cache under that identity +and the pinned server certificate. No 10,000-map snapshot is sent at login. +Once region definitions are ready, the client requests their map paths using +its cache; live discoveries arrive through the same per-session pending queue. +Different characters on the same account receive shared live bits. Transport +ordering and monotonic OR merges require no revision history, acknowledgments, +subscriptions or reconciliation journal. Exact-version login rejects older +peers. + +## Client resource bounds + +The optional client cache keeps at most 10,000 records and 1 MiB of bitmap +allocations. This fits 10,000 typical 24×24 maps (720,000 bytes), including +headroom for the small minority of larger maps. An otherwise valid new record +that exceeds either limit is rejected without evicting or modifying existing +records. Oversized optional disk caches are complete cache misses. The server +store's larger persistence limits do not imply equivalent client allocations. + +A connection authenticates one account. Repeated active RESETs for that account +leave views and request deduplication unchanged; a different account is rejected +until disconnect. Reconnecting restores hidden cached bits and schedules fresh +requests, including when the account is unchanged. + +Replay is deferred to the once-per-frame client service with a shared budget of +65,536 map lookups, bitmap bytes and visited cells. Zero bytes skip cell work; +only set bits reach the view. Each view resumes its cursor, unchanged records +are skipped by revision, and new packets coalesce into a subsequent pass without +restarting the current one. Views take turns when work spans frames. Dense or +large histories therefore appear progressively. Packet handlers and asset/FOW +updates cannot replenish this budget. Changed shared views invalidate each +popup's derived fog zoom, which is rebuilt when rendered. + +## Migration and validation + +Existing `AEXP0001` server files remain readable. Old per-character client +`client-maps/*.tiles` files remain untouched and are not imported: they have no +server-verifiable ownership or visibility provenance. No account, character, +content artifact or initialized runtime file requires manual edits. + +Server tests cover shared accounts, fresh-session reload, bounded parsing, +corruption preservation, failed-save retry, sparse responses, forged caches, +unknown maps, own-session flushing, backpressure and 10,000-map scaling. The +request suite drives normal `draw_client_map2()` through visible, concealed, +upper-level, private and non-region cases. Deterministic counters assert that +idle work and repeated discoveries perform no map/account lookup or full-store +scan. Client tests cover transactional decoding, cache identity, region loading +and incremental rendering. Use the workspace Classic profile for integrated +validation and the supervised isolated lifecycle for runtime checks. diff --git a/server/src/cmake.txt b/server/src/cmake.txt index 3aaa96033..154625381 100644 --- a/server/src/cmake.txt +++ b/server/src/cmake.txt @@ -68,6 +68,7 @@ set(SERVER_CORE_SOURCES src/random_maps/wall.c src/server/admin_shutdown.c src/server/account.c + src/server/exploration.c src/server/anim.c src/server/apply.c src/server/arch.c diff --git a/server/src/include/exploration.h b/server/src/include/exploration.h new file mode 100644 index 000000000..8285762a2 --- /dev/null +++ b/server/src/include/exploration.h @@ -0,0 +1,41 @@ +/************************************************************************* + * Atrinik, a Multiplayer Online Role Playing Game * + * * + * Copyright 2026 The Atrinik Project * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + ************************************************************************/ +/* SPDX-License-Identifier: GPL-2.0-or-later */ +#ifndef EXPLORATION_H +#define EXPLORATION_H + +#include + +/* Main simulation thread only. Store keys come from authenticated sockets; + * only draw_client_map2 may grant discoveries. Client cache reconciliation never + * mutates the authoritative account bitfields. */ +void exploration_begin(socket_struct *ns); +bool exploration_end_checked(socket_struct *ns); +void exploration_end(socket_struct *ns); +bool exploration_shutdown_checked(void); +void exploration_shutdown(void); +bool exploration_mark(socket_struct *ns, + const char *path, + unsigned width, + unsigned height, + unsigned x, + unsigned y); +void exploration_flush(socket_struct *ns, bool force); +#ifdef ATRINIK_TESTING +typedef struct exploration_test_stats { + uint64_t account_lookups, map_lookups, broadcast_sessions, pending_visits; + uint64_t sent_records, save_records, save_bytes, load_records; +} exploration_test_stats; +void exploration_stats_reset(void); +exploration_test_stats exploration_stats_get(void); +bool exploration_visited(socket_struct *ns, const char *path, unsigned x, unsigned y); +#endif +#endif diff --git a/server/src/include/newserver.h b/server/src/include/newserver.h index 7bd6ed908..dcec4aa8c 100644 --- a/server/src/include/newserver.h +++ b/server/src/include/newserver.h @@ -320,6 +320,9 @@ typedef struct socket_struct { char *account; + /** Owned account exploration session; accessed only by exploration.c. */ + struct exploration_session *exploration; + struct packet_struct *packet_recv; struct packet_struct *packet_recv_cmd; uint64_t packet_recv_cmd_base; diff --git a/server/src/include/server.h b/server/src/include/server.h index 2282bd9d3..93a2ec237 100644 --- a/server/src/include/server.h +++ b/server/src/include/server.h @@ -323,6 +323,12 @@ socket_command_quest_list(socket_struct *ns, player *pl, uint8_t *data, size_t l extern void socket_command_clear(socket_struct *ns, player *pl, uint8_t *data, size_t len, size_t pos); +extern void socket_command_region_exploration(socket_struct *ns, + player *pl, + uint8_t *data, + size_t len, + size_t pos); + extern void socket_command_move_path(socket_struct *ns, player *pl, uint8_t *data, size_t len, size_t pos); diff --git a/server/src/server/account.c b/server/src/server/account.c index dc37668b7..f723a6490 100644 --- a/server/src/server/account.c +++ b/server/src/server/account.c @@ -35,6 +35,7 @@ #include #include #include +#include #include #include #include @@ -97,7 +98,9 @@ void account_init(void) { account_auth_work_tokens = ACCOUNT_AUTH_WORK_BURST; } -void account_deinit(void) {} +void account_deinit(void) { + exploration_shutdown(); +} static bool account_auth_work_allowed(unsigned int cost) { time_t now = datetime_getutc(); @@ -1118,6 +1121,7 @@ void account_login_char(socket_struct *ns, char *name) { } bool account_logout_char_checked(socket_struct *ns, player *pl) { + bool exploration_saved = exploration_end_checked(ns); char *path; account_struct account; size_t i; @@ -1158,7 +1162,7 @@ bool account_logout_char_checked(socket_struct *ns, player *pl) { bool saved = account_save(&account, path) != 0; account_free(&account); free(path); - return saved; + return saved && exploration_saved; } void account_logout_char(socket_struct *ns, player *pl) { @@ -1170,6 +1174,7 @@ void account_character_session_start(socket_struct *ns, player *pl) { HARD_ASSERT(pl != NULL); HARD_ASSERT(ns->account != NULL); + exploration_begin(ns); char *path = account_make_path(ns->account); account_struct account; if (!account_load(&account, path)) { diff --git a/server/src/server/exploration.c b/server/src/server/exploration.c new file mode 100644 index 000000000..e6d9784ff --- /dev/null +++ b/server/src/server/exploration.c @@ -0,0 +1,536 @@ +/************************************************************************* + * Atrinik, a Multiplayer Online Role Playing Game * + * * + * Copyright 2026 The Atrinik Project * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + ************************************************************************/ +/* SPDX-License-Identifier: GPL-2.0-or-later */ + +#include +#include +#include +#include +#include +#include + +#define EXPLORATION_MAPS_MAX 10000U +#define EXPLORATION_DIM_MAX 256U +#define EXPLORATION_PATH_MAX 255U +#define EXPLORATION_BYTES_MAX (96U * 1024U * 1024U) +#define EXPLORATION_MAGIC "AEXP0001" + +typedef struct exploration_map { + char *path; + unsigned width, height; + uint8_t *bits; + UT_hash_handle hh; +} exploration_map; + +typedef struct exploration_pending { + struct exploration_pending *next, *prev; + char *path; + exploration_map *map; + uint8_t *bits; + UT_hash_handle hh; +} exploration_pending; + +typedef struct exploration_account exploration_account; + +/* Owned by its socket. Other account sessions are visited only when newly + * discovered bits must be broadcast, never to locate this session or flush it. */ +typedef struct exploration_session { + struct exploration_session *next, *prev; + socket_struct *socket; + exploration_account *account; + exploration_map *last_map; + exploration_pending *pending, *pending_index; +} exploration_session; + +struct exploration_account { + char *name, *path; + exploration_map *maps; + exploration_session *sessions; + unsigned count; + bool valid, dirty, error_reported; + time_t last_save; + UT_hash_handle hh; +}; + +static exploration_account *accounts; + +#ifdef ATRINIK_TESTING +static exploration_test_stats stats; +#define TEST_COUNT(field, count) (stats.field += (count)) +#else +#define TEST_COUNT(field, count) ((void)0) +#endif + +static size_t bitmap_size(unsigned width, unsigned height) { + return ((size_t)width * height + 7) / 8; +} + +static bool valid_path(const char *path) { + if (path == NULL || path[0] != '/' || strlen(path) > EXPLORATION_PATH_MAX || path[1] == '\0') { + return false; + } + /* Paths are identities, never filesystem operands. Exclude physical/private + * names and noncanonical traversal even when reading an operator-owned save. */ + const char *segment = path + 1; + for (const unsigned char *p = (const unsigned char *)segment;; p++) { + if (*p == '/' || *p == '\0') { + size_t len = (const char *)p - segment; + if (len == 0 || (len == 1 && segment[0] == '.') || + (len == 2 && segment[0] == '.' && segment[1] == '.')) { + return false; + } + if (*p == '\0') { + return true; + } + segment = (const char *)p + 1; + } else if (*p < 32 || *p == 127 || *p == '\\' || *p == ':') { + return false; + } + } +} + +static exploration_map *find_map(exploration_account *account, const char *path) { + exploration_map *map; + TEST_COUNT(map_lookups, 1); + HASH_FIND_STR(account->maps, path, map); + return map; +} + +static void free_maps(exploration_account *account) { + exploration_map *map, *next; + HASH_ITER(hh, account->maps, map, next) { + HASH_DEL(account->maps, map); + free(map->path); + free(map->bits); + free(map); + } + account->count = 0; +} + +static exploration_map * +add_map(exploration_account *account, const char *path, unsigned width, unsigned height) { + if (account->count == EXPLORATION_MAPS_MAX) { + return NULL; + } + exploration_map *map = xcalloc(1, sizeof(*map)); + map->path = xstrdup(path); + map->width = width; + map->height = height; + map->bits = xcalloc(bitmap_size(width, height), 1); + HASH_ADD_KEYPTR(hh, account->maps, map->path, strlen(map->path), map); + account->count++; + return map; +} + +static bool read_u16(FILE *fp, unsigned *value) { + uint8_t data[2]; + if (fread(data, 1, 2, fp) != 2) { + return false; + } + *value = ((unsigned)data[0] << 8) | data[1]; + return true; +} + +static bool load_account(exploration_account *account) { + FILE *fp = fopen(account->path, "rb"); + if (fp == NULL) { + return errno == ENOENT; + } + struct stat st; + char magic[8]; + bool ok = fstat(fileno(fp), &st) == 0 && S_ISREG(st.st_mode) && st.st_size >= 8 && + (uint64_t)st.st_size <= EXPLORATION_BYTES_MAX && + fread(magic, 1, sizeof(magic), fp) == sizeof(magic) && + memcmp(magic, EXPLORATION_MAGIC, sizeof(magic)) == 0; + while (ok) { + int first = fgetc(fp); + if (first == EOF) { + ok = !ferror(fp); + break; + } + int second = fgetc(fp); + unsigned length = ((unsigned)first << 8) | (unsigned)second; + char path[EXPLORATION_PATH_MAX + 1]; + unsigned width, height; + if (second == EOF || length == 0 || length > EXPLORATION_PATH_MAX || + fread(path, 1, length, fp) != length) { + ok = false; + break; + } + path[length] = '\0'; + if (strlen(path) != length || !valid_path(path) || find_map(account, path) != NULL || + !read_u16(fp, &width) || !read_u16(fp, &height) || width == 0 || height == 0 || + width > EXPLORATION_DIM_MAX || height > EXPLORATION_DIM_MAX) { + ok = false; + break; + } + exploration_map *map = add_map(account, path, width, height); + TEST_COUNT(load_records, 1); + size_t size = bitmap_size(width, height); + if (map == NULL || fread(map->bits, 1, size, fp) != size || + ((width * height) % 8 != 0 && (map->bits[size - 1] >> ((width * height) % 8)) != 0)) { + ok = false; + break; + } + } + fclose(fp); + if (!ok) { + free_maps(account); + } + return ok; +} + +static void write_u16(uint8_t *data, unsigned value) { + data[0] = (uint8_t)(value >> 8); + data[1] = (uint8_t)value; +} + +static bool save_account(exploration_account *account) { + if (!account->valid || !account->dirty) { + return !account->dirty; + } + /* Allocate once: incrementally growing a large string buffer can copy the + * entire account for each map, depending on the allocator. */ + size_t size = 8; + for (exploration_map *map = account->maps; map != NULL; map = map->hh.next) { + size += 6 + strlen(map->path) + bitmap_size(map->width, map->height); + } + uint8_t *data = xmalloc(size); + memcpy(data, EXPLORATION_MAGIC, 8); + size_t pos = 8; + for (exploration_map *map = account->maps; map != NULL; map = map->hh.next) { + size_t len = strlen(map->path), bytes = bitmap_size(map->width, map->height); + write_u16(data + pos, len); + pos += 2; + memcpy(data + pos, map->path, len); + pos += len; + write_u16(data + pos, map->width); + write_u16(data + pos + 2, map->height); + pos += 4; + memcpy(data + pos, map->bits, bytes); + pos += bytes; + } + TEST_COUNT(save_records, account->count); + TEST_COUNT(save_bytes, size); + bool ok = path_write_atomic(account->path, data, size, 0600); + free(data); + if (ok) { + account->dirty = false; + account->error_reported = false; + } else if (!account->error_reported) { + LOG(ERROR, "Could not save account exploration: %s", account->path); + account->error_reported = true; + } + account->last_save = time(NULL); + return ok; +} + +static exploration_pending * +pending_get(exploration_session *session, const char *path, exploration_map *map) { + exploration_pending *pending; + HASH_FIND_STR(session->pending_index, path, pending); + if (pending == NULL) { + if (HASH_COUNT(session->pending_index) >= EXPLORATION_MAPS_MAX) { + return NULL; + } + pending = xcalloc(1, sizeof(*pending)); + pending->path = xstrdup(path); + HASH_ADD_KEYPTR(hh, session->pending_index, pending->path, strlen(path), pending); + DL_APPEND(session->pending, pending); + } + if (map != NULL && pending->map == NULL) { + pending->map = map; + pending->bits = xcalloc(bitmap_size(map->width, map->height), 1); + } + return pending; +} + +static void pending_free(exploration_session *session, exploration_pending *pending) { + HASH_DEL(session->pending_index, pending); + DL_DELETE(session->pending, pending); + free(pending->path); + free(pending->bits); + free(pending); +} + +static void send_pending(socket_struct *ns, const exploration_pending *pending) { + packet_struct *packet = packet_new(CLIENT_CMD_REGION_EXPLORATION, 0, 256); + const exploration_map *map = pending->map; + if (map == NULL) { + packet_writer_write_uint8(packet, 3); + packet_writer_write_cstring(packet, pending->path); + } else { + size_t bytes = bitmap_size(map->width, map->height); + unsigned changed = 0; + for (size_t i = 0; i < bytes; i++) { + changed += pending->bits[i] != 0; + } + bool patch = 2 + (size_t)changed * 3 < bytes; + packet_writer_write_uint8(packet, patch ? 2 : 1); + packet_writer_write_cstring(packet, map->path); + packet_writer_write_uint16(packet, map->width); + packet_writer_write_uint16(packet, map->height); + if (patch) { + packet_writer_write_uint16(packet, changed); + for (size_t i = 0; i < bytes; i++) { + if (pending->bits[i] != 0) { + packet_writer_write_uint16(packet, i); + packet_writer_write_uint8(packet, pending->bits[i]); + } + } + } else { + packet_writer_write_bytes(packet, map->bits, bytes); + } + } + TEST_COUNT(sent_records, 1); + socket_send_packet(ns, packet); +} + +void exploration_begin(socket_struct *ns) { + if (ns == NULL || ns->account == NULL) { + return; + } + exploration_end(ns); + exploration_account *account; + TEST_COUNT(account_lookups, 1); + HASH_FIND_STR(accounts, ns->account, account); + if (account == NULL) { + account = xcalloc(1, sizeof(*account)); + account->name = xstrdup(ns->account); + char *base = account_make_path(ns->account); + account->path = xmalloc(strlen(base) + sizeof(".exploration")); + sprintf(account->path, "%s.exploration", base); + free(base); + account->valid = load_account(account); + if (!account->valid) { + LOG(ERROR, + "Invalid account exploration preserved without modification: %s", + account->path); + } + HASH_ADD_KEYPTR(hh, accounts, account->name, strlen(account->name), account); + } + exploration_session *session = xcalloc(1, sizeof(*session)); + session->socket = ns; + session->account = account; + DL_APPEND(account->sessions, session); + ns->exploration = session; + packet_struct *packet = packet_new(CLIENT_CMD_REGION_EXPLORATION, 0, 64); + packet_writer_write_uint8(packet, 0); + packet_writer_write_cstring(packet, ns->account); + socket_send_packet(ns, packet); + /* The client requests only maps in its region, with any cached bits. */ +} + +bool exploration_mark(socket_struct *ns, + const char *path, + unsigned width, + unsigned height, + unsigned x, + unsigned y) { + exploration_session *session = ns != NULL ? ns->exploration : NULL; + if (session == NULL || !session->account->valid || path == NULL || width == 0 || height == 0 || + width > EXPLORATION_DIM_MAX || height > EXPLORATION_DIM_MAX) { + return false; + } + exploration_account *account = session->account; + exploration_map *map = session->last_map; + if (map == NULL || strcmp(map->path, path) != 0) { + if (!valid_path(path)) { + return false; + } + map = find_map(account, path); + } + if (map == NULL) { + if (x >= width || y >= height) { + return false; + } + map = add_map(account, path, width, height); + if (map == NULL) { + return false; + } + } + session->last_map = map; + /* The initial dimensions define the bitfield layout. Do not compare map + * revisions or replace stored exploration when authored geometry changes. */ + if (x >= map->width || y >= map->height) { + return false; + } + size_t bit = (size_t)y * map->width + x; + uint8_t mask = 1U << (bit % 8); + if (map->bits[bit / 8] & mask) { + return false; + } + map->bits[bit / 8] |= mask; + account->dirty = true; + for (session = account->sessions; session != NULL; session = session->next) { + TEST_COUNT(broadcast_sessions, 1); + exploration_pending *pending = pending_get(session, map->path, map); + if (pending != NULL) { + pending->bits[bit / 8] |= mask; + } else { + /* A client that exhausts the bounded reconciliation queue must + * reconnect rather than silently miss authoritative live bits. */ + session->socket->state = ST_ZOMBIE; + } + } + return true; +} + +void socket_command_region_exploration(socket_struct *ns, + player *pl, + uint8_t *data, + size_t len, + size_t pos) { + (void)pl; + packet_reader_t reader; + packet_reader_init_at(&reader, data, len, pos); + uint8_t operation = packet_reader_read_uint8(&reader); + char path[EXPLORATION_PATH_MAX + 1]; + packet_reader_read_string(&reader, path, sizeof(path)); + uint16_t count = packet_reader_read_uint16(&reader); + packet_view_t cached = packet_reader_read_view(&reader, count); + if (!packet_reader_finish(&reader)) { + return; + } + if (operation != 0 || !valid_path(path) || count > 8192) { + packet_reader_set_error(&reader, PACKET_ERROR_INVALID_ENCODING); + return; + } + exploration_session *session = ns != NULL ? ns->exploration : NULL; + if (session == NULL || ns->state != ST_PLAYING || ns->account == NULL) { + packet_reader_set_error(&reader, PACKET_ERROR_INVALID_ENCODING); + return; + } + exploration_map *map = find_map(session->account, path); + if (map == NULL) { + if (pending_get(session, path, NULL) == NULL) { + packet_reader_set_error(&reader, PACKET_ERROR_LIMIT_EXCEEDED); + } + return; + } + size_t bytes = bitmap_size(map->width, map->height); + unsigned tail = (map->width * map->height) % 8; + if ((count != 0 && count != bytes) || + (count != 0 && tail != 0 && (cached.data[count - 1] >> tail) != 0)) { + packet_reader_set_error(&reader, PACKET_ERROR_INVALID_ENCODING); + return; + } + exploration_pending *pending = NULL; + for (size_t i = 0; i < bytes; i++) { + uint8_t missing = map->bits[i] & (count != 0 ? (uint8_t)~cached.data[i] : UINT8_MAX); + if (missing == 0) { + continue; + } + if (pending == NULL) { + pending = pending_get(session, path, map); + if (pending == NULL) { + packet_reader_set_error(&reader, PACKET_ERROR_LIMIT_EXCEEDED); + return; + } + } + pending->bits[i] |= missing; + } +} + +void exploration_flush(socket_struct *ns, bool force) { + exploration_session *session = ns != NULL ? ns->exploration : NULL; + if (session == NULL) { + return; + } + unsigned sent = 0; + while (session->pending != NULL && sent < 32 && ns->packet_queue_bytes <= 1024U * 1024U && + ns->packet_queue_count <= 1024U && ns->state != ST_DEAD && ns->state != ST_ZOMBIE) { + exploration_pending *pending = session->pending; + TEST_COUNT(pending_visits, 1); + send_pending(ns, pending); + pending_free(session, pending); + sent++; + } + exploration_account *account = session->account; + time_t now = time(NULL); + if (account->dirty && (force || now < account->last_save || now - account->last_save >= 5)) { + save_account(account); + } +} + +static void free_account(exploration_account *account) { + HASH_DEL(accounts, account); + free_maps(account); + free(account->name); + free(account->path); + free(account); +} + +static void free_session(exploration_session *session) { + while (session->pending != NULL) { + pending_free(session, session->pending); + } + session->socket->exploration = NULL; + DL_DELETE(session->account->sessions, session); + free(session); +} + +bool exploration_end_checked(socket_struct *ns) { + exploration_session *session = ns != NULL ? ns->exploration : NULL; + if (session == NULL) { + return true; + } + exploration_account *account = session->account; + free_session(session); + bool saved = save_account(account); + if (account->sessions == NULL && !account->dirty) { + free_account(account); + } + return saved; +} + +void exploration_end(socket_struct *ns) { + (void)exploration_end_checked(ns); +} + +bool exploration_shutdown_checked(void) { + bool saved = true; + while (accounts != NULL) { + exploration_account *account = accounts; + if (!save_account(account)) { + saved = false; + } + while (account->sessions != NULL) { + free_session(account->sessions); + } + free_account(account); + } + return saved; +} + +void exploration_shutdown(void) { + (void)exploration_shutdown_checked(); +} + +#ifdef ATRINIK_TESTING +bool exploration_visited(socket_struct *ns, const char *path, unsigned x, unsigned y) { + exploration_session *session = ns != NULL ? ns->exploration : NULL; + exploration_map *map = session != NULL ? find_map(session->account, path) : NULL; + if (map == NULL || x >= map->width || y >= map->height) { + return false; + } + size_t bit = (size_t)y * map->width + x; + return (map->bits[bit / 8] & (1U << (bit % 8))) != 0; +} + +void exploration_stats_reset(void) { + memset(&stats, 0, sizeof(stats)); +} + +exploration_test_stats exploration_stats_get(void) { + return stats; +} +#endif diff --git a/server/src/server/main.c b/server/src/server/main.c index f07cb79e4..87984909b 100644 --- a/server/src/server/main.c +++ b/server/src/server/main.c @@ -29,6 +29,7 @@ #include #include +#include #include #include #include @@ -378,8 +379,12 @@ void clean_tmp_files(void) { */ void server_shutdown(void) { bool ok = access_server_shutdown(); - if (!player_disconnect_all_checked()) + if (!player_disconnect_all_checked()) { ok = false; + } + if (!exploration_shutdown_checked()) { + ok = false; + } if (!clean_tmp_files_checked()) { ok = false; } diff --git a/server/src/socket/command_handlers.def b/server/src/socket/command_handlers.def index fd0dc78f0..c5c153978 100644 --- a/server/src/socket/command_handlers.def +++ b/server/src/socket/command_handlers.def @@ -23,5 +23,6 @@ ATRINIK_SERVER_COMMAND_HANDLER(COMBAT, socket_command_combat, PLAYING, true) ATRINIK_SERVER_COMMAND_HANDLER(TALK, socket_command_talk, PLAYING, true) ATRINIK_SERVER_COMMAND_HANDLER(MOVE, socket_command_move, PLAYING, true) ATRINIK_SERVER_COMMAND_HANDLER(TARGET, socket_command_target, PLAYING, true) +ATRINIK_SERVER_COMMAND_HANDLER(REGION_EXPLORATION, socket_command_region_exploration, PLAYING, true) ATRINIK_SERVER_COMMAND_HANDLER(ACCESS_AUTH, socket_command_access_auth, ACCESS, false) ATRINIK_SERVER_COMMAND_HANDLER(ACCESS_ADMIN, socket_command_access_admin, PLAYING, false) diff --git a/server/src/socket/init.c b/server/src/socket/init.c index 141c88e02..4719f8ae8 100644 --- a/server/src/socket/init.c +++ b/server/src/socket/init.c @@ -33,6 +33,7 @@ #include #include #include +#include #include #include #include @@ -141,6 +142,7 @@ void free_all_newserver(void) { * The socket. */ static void free_newsocket_internal(socket_struct *ns, bool connected) { + exploration_end(ns); access_server_cancel(ns->access_auth_job); access_server_cancel(ns->access_admin_job); socket_assets_connection_clear(ns); diff --git a/server/src/socket/request.c b/server/src/socket/request.c index 70925252d..ceee66874 100644 --- a/server/src/socket/request.c +++ b/server/src/socket/request.c @@ -45,6 +45,7 @@ #include #include #include +#include #include #include #include @@ -1670,6 +1671,11 @@ void draw_client_map2(object *pl) { mask |= MAP2_MASK_SUPPORT_HEIGHT; } } + if (depth == 0 && !tile_fow && !MAP_UNIQUE(m) && m->region != NULL && + region_find_with_map(m->region) != NULL) { + exploration_mark(CONTR(pl)->cs, m->path, MAP_WIDTH(m), MAP_HEIGHT(m), nx, ny); + } + if (!mp->fow_known || (mp->fow != 0) != tile_fow) { mask |= MAP2_MASK_FOW; } @@ -2589,6 +2595,7 @@ void draw_client_map2(object *pl) { packet_header->data[continuation_count_pos] = continuation_marker >> 8; packet_header->data[continuation_count_pos + 1] = continuation_marker & UINT8_MAX; HARD_ASSERT(packet_writer_finish(packet_header)); + exploration_flush(CONTR(pl)->cs, false); bool synchronize_time = CONTR(pl)->map_update_cmd == MAP_UPDATE_CMD_NEW || CONTR(pl)->map_update_cmd == MAP_UPDATE_CMD_CONNECTED; socket_send_packet(CONTR(pl)->cs, packet_header); diff --git a/server/src/tests/unit/server/account.c b/server/src/tests/unit/server/account.c index 630fb5e61..5236efe98 100644 --- a/server/src/tests/unit/server/account.c +++ b/server/src/tests/unit/server/account.c @@ -15,10 +15,14 @@ #include #include #include +#include +#include +#include #include #include #include #include +#include START_TEST(test_account_provision) { const char *account_name = "scenarioaccount"; @@ -110,9 +114,7 @@ START_TEST(test_account_provision_password_file_permissions) { #ifdef WIN32 unlink(password_path); - ck_assert_int_eq(path_secret_create_atomic(password_path, - password, - sizeof(password) - 1), + ck_assert_int_eq(path_secret_create_atomic(password_path, password, sizeof(password) - 1), PATH_SECRET_CREATE_OK); #endif @@ -147,10 +149,9 @@ START_TEST(test_account_provision_password_file_permissions) { static const char empty_password[] = ""; unlink(password_path); - ck_assert_int_eq(path_secret_create_atomic(password_path, - empty_password, - sizeof(empty_password) - 1), - PATH_SECRET_CREATE_OK); + ck_assert_int_eq( + path_secret_create_atomic(password_path, empty_password, sizeof(empty_password) - 1), + PATH_SECRET_CREATE_OK); ck_assert(!account_provision_from_file(account_name, password_path, character_name, @@ -162,10 +163,9 @@ START_TEST(test_account_provision_password_file_permissions) { const char valid_password[] = "local-file-8!\r\n"; #ifdef WIN32 unlink(password_path); - ck_assert_int_eq(path_secret_create_atomic(password_path, - valid_password, - sizeof(valid_password) - 1), - PATH_SECRET_CREATE_OK); + ck_assert_int_eq( + path_secret_create_atomic(password_path, valid_password, sizeof(valid_password) - 1), + PATH_SECRET_CREATE_OK); #else fd = open(password_path, O_WRONLY | O_TRUNC); ck_assert_int_ge(fd, 0); @@ -357,6 +357,409 @@ START_TEST(test_account_provision_lighting_preset_rolls_back) { } END_TEST +static char *exploration_test_path(const char *name) { + char *base = account_make_path(name); + char *path = xmalloc(strlen(base) + sizeof(".exploration")); + sprintf(path, "%s.exploration", base); + free(base); + path_ensure_directories(path); + return path; +} + +static packet_error_t +exploration_test_request(socket_struct *ns, const char *path, const uint8_t *bits, size_t bytes) { + packet_struct *packet = packet_new(SERVER_CMD_REGION_EXPLORATION, 0, 256); + packet_writer_write_uint8(packet, 0); + packet_writer_write_cstring(packet, path); + packet_writer_write_uint16(packet, bytes); + if (bytes != 0) { + packet_writer_write_bytes(packet, bits, bytes); + } + packet_reader_scope_t scope; + packet_reader_scope_begin(&scope); + socket_command_region_exploration(ns, NULL, packet->data, packet->len, 0); + packet_error_t error = packet_reader_scope_finish(&scope); + packet_free(packet); + return error; +} + +static packet_struct *exploration_test_response(socket_struct *ns) { + for (packet_struct *packet = ns->packets; packet != NULL; packet = packet->next) { + if (packet->type == CLIENT_CMD_REGION_EXPLORATION) { + return packet; + } + } + return NULL; +} + +START_TEST(test_exploration_account_round_trip) { + char *path = exploration_test_path("exploretest"); + unlink(path); + socket_struct first = {.state = ST_PLAYING, .account = "exploretest"}; + socket_struct second = {.state = ST_PLAYING, .account = "exploretest"}; + socket_struct other = {.state = ST_PLAYING, .account = "exploreother"}; + exploration_begin(&first); + ck_assert(exploration_mark(&first, "/world/start", 17, 19, 16, 18)); + ck_assert(!exploration_mark(&first, "/world/start", 17, 19, 16, 18)); + exploration_begin(&second); + ck_assert(exploration_visited(&second, "/world/start", 16, 18)); + ck_assert(exploration_mark(&second, "/world/start", 17, 19, 0, 0)); + ck_assert(exploration_visited(&first, "/world/start", 0, 0)); + exploration_begin(&other); + ck_assert(!exploration_visited(&other, "/world/start", 16, 18)); + exploration_end(&other); + exploration_end(&first); + exploration_end(&second); + socket_buffer_clear(&first); + socket_buffer_clear(&second); + socket_buffer_clear(&other); + exploration_shutdown(); + + /* Fresh process state and socket replay the account's union of characters. */ + exploration_begin(&first); + ck_assert(exploration_visited(&first, "/world/start", 16, 18)); + ck_assert(exploration_visited(&first, "/world/start", 0, 0)); + ck_assert(!exploration_visited(&first, "/world/start", 1, 0)); + ck_assert_uint_eq(first.packet_queue_count, 2); /* Identity RESET only; no login replay. */ + ck_assert_int_eq(exploration_test_request(&first, "/world/start", NULL, 0), PACKET_ERROR_NONE); + socket_buffer_clear(&first); + exploration_flush(&first, false); + ck_assert_ptr_nonnull(exploration_test_response(&first)); + struct stat st; + ck_assert_int_eq(stat(path, &st), 0); +#ifndef WIN32 + ck_assert_uint_eq(st.st_mode & 0777, 0600); +#endif + exploration_end(&first); + socket_buffer_clear(&first); + unlink(path); + free(path); +} +END_TEST + +START_TEST(test_exploration_bounds_and_stable_layout) { + char *path = exploration_test_path("explorebounds"); + unlink(path); + socket_struct ns = {.state = ST_PLAYING, .account = "explorebounds"}; + exploration_begin(&ns); + ck_assert(!exploration_mark(&ns, "/world/../secret", 1, 1, 0, 0)); + ck_assert(!exploration_mark(&ns, "relative", 1, 1, 0, 0)); + ck_assert(!exploration_mark(&ns, "/world", 0, 1, 0, 0)); + ck_assert(!exploration_mark(&ns, "/world", 257, 1, 0, 0)); + ck_assert(!exploration_mark(&ns, "/world", 1, 1, 1, 0)); + ck_assert(exploration_mark(&ns, "/world", 256, 256, 255, 255)); + ck_assert(exploration_visited(&ns, "/world", 255, 255)); + ck_assert(exploration_mark(&ns, "/world", 2, 2, 1, 1)); + ck_assert(exploration_visited(&ns, "/world", 255, 255)); + exploration_end(&ns); + socket_buffer_clear(&ns); + unlink(path); + free(path); +} +END_TEST + +START_TEST(test_exploration_corrupt_file_preserved) { + char *path = exploration_test_path("explorecorrupt"); + /* Valid magic followed by a truncated record. */ + static const uint8_t bad[] = {'A', 'E', 'X', 'P', '0', '0', '0', '1', 0, 6, '/', 'x'}; + ck_assert(path_write_atomic(path, bad, sizeof(bad), 0600)); + socket_struct ns = {.state = ST_PLAYING, .account = "explorecorrupt"}; + exploration_begin(&ns); + ck_assert(!exploration_mark(&ns, "/world", 1, 1, 0, 0)); + exploration_end(&ns); + socket_buffer_clear(&ns); + FILE *fp = fopen(path, "rb"); + ck_assert_ptr_nonnull(fp); + uint8_t actual[sizeof(bad)]; + ck_assert_uint_eq(fread(actual, 1, sizeof(actual), fp), sizeof(actual)); + ck_assert_int_eq(memcmp(actual, bad, sizeof(bad)), 0); + ck_assert_int_eq(fgetc(fp), EOF); + fclose(fp); + unlink(path); + free(path); +} +END_TEST + +START_TEST(test_exploration_failed_save_retried) { + char *path = exploration_test_path("exploreretry"); + unlink(path); + socket_struct ns = {.state = ST_PLAYING, .account = "exploreretry"}; + exploration_begin(&ns); + ck_assert(exploration_mark(&ns, "/world", 1, 1, 0, 0)); + /* Block the atomic rename with a directory, after successful empty load. */ + ck_assert_int_eq(mkdir(path, 0700), 0); + ck_assert(!exploration_end_checked(&ns)); + socket_buffer_clear(&ns); + ck_assert_int_eq(rmdir(path), 0); + exploration_begin(&ns); + ck_assert(exploration_visited(&ns, "/world", 0, 0)); + exploration_end(&ns); + socket_buffer_clear(&ns); + exploration_shutdown(); + exploration_begin(&ns); + ck_assert(exploration_visited(&ns, "/world", 0, 0)); + exploration_end(&ns); + socket_buffer_clear(&ns); + unlink(path); + free(path); +} +END_TEST + +START_TEST(test_exploration_shutdown_propagates_detached_save_failure) { + char *path = exploration_test_path("exploreshutdown"); + unlink(path); + socket_struct ns = {.state = ST_PLAYING, .account = "exploreshutdown"}; + exploration_begin(&ns); + ck_assert(exploration_mark(&ns, "/world", 1, 1, 0, 0)); + ck_assert_int_eq(mkdir(path, 0700), 0); + ck_assert(!exploration_end_checked(&ns)); + ck_assert_ptr_null(ns.exploration); + socket_buffer_clear(&ns); + ck_assert(!exploration_shutdown_checked()); + ck_assert(exploration_shutdown_checked()); /* Empty cleanup remains idempotent. */ + ck_assert_int_eq(rmdir(path), 0); + free(path); +} +END_TEST + +START_TEST(test_exploration_snapshot_batches) { + char *path = exploration_test_path("explorebatch"); + unlink(path); + socket_struct ns = {.state = ST_PLAYING, .account = "explorebatch"}; + exploration_begin(&ns); + for (unsigned i = 0; i < 40; i++) { + char name[32]; + snprintf(VS(name), "/world/map%u", i); + ck_assert(exploration_mark(&ns, name, 1, 1, 0, 0)); + } + socket_buffer_clear(&ns); + exploration_flush(&ns, true); + ck_assert_uint_eq(ns.packet_queue_count, 64); + ck_assert_uint_eq(exploration_test_response(&ns)->data[0], 1); + socket_buffer_clear(&ns); + exploration_flush(&ns, false); + ck_assert_uint_eq(ns.packet_queue_count, 16); + socket_buffer_clear(&ns); + exploration_flush(&ns, false); + ck_assert_uint_eq(ns.packet_queue_count, 0); + exploration_end(&ns); + unlink(path); + free(path); +} +END_TEST + +START_TEST(test_exploration_reconciliation_is_sparse_and_never_grants_bits) { + char *path = exploration_test_path("exploredelta"); + unlink(path); + socket_struct ns = {.state = ST_PLAYING, .account = "exploredelta"}; + exploration_begin(&ns); + packet_struct *reset = exploration_test_response(&ns); + ck_assert_ptr_nonnull(reset); + ck_assert_uint_eq(reset->data[0], 0); + ck_assert_str_eq((char *)reset->data + 1, ns.account); + ck_assert(exploration_mark(&ns, "/world/delta", 16, 16, 0, 0)); + ck_assert(exploration_mark(&ns, "/world/delta", 16, 16, 8, 0)); + exploration_flush(&ns, true); + socket_buffer_clear(&ns); + + uint8_t cached[32] = {1}; + ck_assert_int_eq(exploration_test_request(&ns, "/world/delta", cached, sizeof(cached)), + PACKET_ERROR_NONE); + exploration_flush(&ns, false); + packet_struct *response = exploration_test_response(&ns); + ck_assert_ptr_nonnull(response); + packet_reader_t reader; + packet_reader_init(&reader, response->data, response->len); + ck_assert_uint_eq(packet_reader_read_uint8(&reader), 2); + char map_path[256]; + ck_assert(packet_reader_read_string(&reader, VS(map_path))); + ck_assert_str_eq(map_path, "/world/delta"); + ck_assert_uint_eq(packet_reader_read_uint16(&reader), 16); + ck_assert_uint_eq(packet_reader_read_uint16(&reader), 16); + ck_assert_uint_eq(packet_reader_read_uint16(&reader), 1); + ck_assert_uint_eq(packet_reader_read_uint16(&reader), 1); + ck_assert_uint_eq(packet_reader_read_uint8(&reader), 1); + ck_assert(packet_reader_finish(&reader)); + socket_buffer_clear(&ns); + + cached[1] = 1; + ck_assert_int_eq(exploration_test_request(&ns, "/world/delta", cached, sizeof(cached)), + PACKET_ERROR_NONE); + exploration_flush(&ns, false); + ck_assert_uint_eq(ns.packet_queue_count, 0); + memset(cached, 255, sizeof(cached)); + ck_assert_int_eq(exploration_test_request(&ns, "/world/delta", cached, sizeof(cached)), + PACKET_ERROR_NONE); + exploration_flush(&ns, false); + ck_assert_uint_eq(ns.packet_queue_count, 0); + ck_assert(!exploration_visited(&ns, "/world/delta", 1, 0)); + + ck_assert_int_eq(exploration_test_request(&ns, "/world/unknown", cached, 1), PACKET_ERROR_NONE); + exploration_flush(&ns, false); + response = exploration_test_response(&ns); + ck_assert_ptr_nonnull(response); + ck_assert_uint_eq(response->data[0], 3); + ck_assert_str_eq((char *)response->data + 1, "/world/unknown"); + ck_assert(!exploration_visited(&ns, "/world/unknown", 0, 0)); + socket_buffer_clear(&ns); + exploration_end(&ns); + unlink(path); + free(path); +} +END_TEST + +START_TEST(test_exploration_reconciliation_rejects_malformed_requests) { + char *path = exploration_test_path("exploreinvalid"); + unlink(path); + socket_struct ns = {.state = ST_PLAYING, .account = "exploreinvalid"}; + exploration_begin(&ns); + ck_assert(exploration_mark(&ns, "/world", 1, 1, 0, 0)); + exploration_flush(&ns, true); + socket_buffer_clear(&ns); + uint8_t cached[2] = {1, 0}; + ck_assert_int_ne(exploration_test_request(&ns, "/world", cached, 2), PACKET_ERROR_NONE); + cached[0] = 255; + ck_assert_int_ne(exploration_test_request(&ns, "/world", cached, 1), PACKET_ERROR_NONE); + ck_assert_int_ne(exploration_test_request(&ns, "/../world", NULL, 0), PACKET_ERROR_NONE); + ck_assert_int_ne(exploration_test_request(&ns, "relative", NULL, 0), PACKET_ERROR_NONE); + /* Truncated, unknown-op and trailing data fail without enqueuing a response. */ + uint8_t bad[][7] = {{0}, {1, '/', 'x', 0, 0, 0}, {0, '/', 'x', 0, 0, 0, 42}}; + const size_t bad_lengths[] = {1, 6, 7}; + for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) { + packet_reader_scope_t scope; + packet_reader_scope_begin(&scope); + socket_command_region_exploration(&ns, NULL, bad[i], bad_lengths[i], 0); + ck_assert_int_ne(packet_reader_scope_finish(&scope), PACKET_ERROR_NONE); + } + ns.state = ST_LOGIN; + ck_assert_int_ne(exploration_test_request(&ns, "/world", NULL, 0), PACKET_ERROR_NONE); + ns.state = ST_PLAYING; + exploration_flush(&ns, false); + ck_assert_uint_eq(ns.packet_queue_count, 0); + ck_assert(exploration_visited(&ns, "/world", 0, 0)); + exploration_end(&ns); + unlink(path); + free(path); +} +END_TEST + +START_TEST(test_exploration_session_flush_is_local_and_backpressure_retains_bits) { + char *path = exploration_test_path("explorequeue"); + unlink(path); + socket_struct first = {.state = ST_PLAYING, .account = "explorequeue"}; + socket_struct second = {.state = ST_PLAYING, .account = "explorequeue"}; + socket_struct unrelated[64] = {0}; + char unrelated_names[64][32]; + exploration_begin(&first); + exploration_begin(&second); + for (unsigned i = 0; i < 64; i++) { + snprintf(VS(unrelated_names[i]), "exploreunrelated%02u", i); + unrelated[i].state = ST_PLAYING; + unrelated[i].account = unrelated_names[i]; + exploration_begin(&unrelated[i]); + socket_buffer_clear(&unrelated[i]); + } + socket_buffer_clear(&first); + socket_buffer_clear(&second); + exploration_stats_reset(); + ck_assert(exploration_mark(&first, "/world", 24, 24, 1, 1)); + ck_assert_uint_eq(exploration_stats_get().broadcast_sessions, 2); + first.packet_queue_bytes = 1024U * 1024U + 1; + exploration_flush(&first, true); + ck_assert_uint_eq(exploration_stats_get().pending_visits, 0); + first.packet_queue_bytes = 0; + exploration_flush(&first, false); + ck_assert_uint_eq(exploration_stats_get().pending_visits, 1); + ck_assert_ptr_nonnull(exploration_test_response(&first)); + ck_assert_uint_eq(second.packet_queue_count, 0); + for (unsigned i = 0; i < 64; i++) { + ck_assert_uint_eq(unrelated[i].packet_queue_count, 0); + } + exploration_flush(&second, false); + ck_assert_ptr_nonnull(exploration_test_response(&second)); + ck_assert_uint_eq(exploration_stats_get().pending_visits, 2); + socket_buffer_clear(&first); + socket_buffer_clear(&second); + exploration_stats_reset(); + for (unsigned i = 0; i < 1000; i++) { + ck_assert(!exploration_mark(&first, "/world", 24, 24, 1, 1)); + exploration_flush(&first, false); + exploration_flush(&second, false); + exploration_flush(&unrelated[i % 64], false); + } + exploration_test_stats stats = exploration_stats_get(); + ck_assert_uint_eq(stats.account_lookups, 0); + ck_assert_uint_eq(stats.map_lookups, 0); + ck_assert_uint_eq(stats.pending_visits, 0); + ck_assert_uint_eq(stats.broadcast_sessions, 0); + ck_assert_uint_eq(stats.save_records, 0); + exploration_end(&first); + exploration_end(&second); + for (unsigned i = 0; i < 64; i++) { + exploration_end(&unrelated[i]); + } + unlink(path); + free(path); +} +END_TEST + +START_TEST(test_exploration_ten_thousand_maps) { + unsigned dimension = _i == 0 ? 24 : 256; + char *path = exploration_test_path("explorescale"); + unlink(path); + socket_struct ns = {.state = ST_PLAYING, .account = "explorescale"}; + exploration_begin(&ns); + socket_buffer_clear(&ns); + exploration_stats_reset(); + uint64_t started = datetime_monotonic_us(); + for (unsigned i = 0; i < 10000; i++) { + char name[64]; + snprintf(VS(name), "/world/map%05u", i); + ck_assert(exploration_mark(&ns, name, dimension, dimension, dimension - 1, dimension - 1)); + } + uint64_t mark_us = datetime_monotonic_us() - started; + ck_assert_uint_eq(exploration_stats_get().account_lookups, 0); + ck_assert_uint_eq(exploration_stats_get().map_lookups, 10000); + ck_assert(!exploration_mark(&ns, "/world/overflow", 1, 1, 0, 0)); + started = datetime_monotonic_us(); + exploration_end(&ns); + uint64_t save_us = datetime_monotonic_us() - started; + exploration_test_stats saved = exploration_stats_get(); + ck_assert_uint_eq(saved.save_records, 10000); + struct stat st; + ck_assert_int_eq(stat(path, &st), 0); + ck_assert_uint_eq(saved.save_bytes, (uint64_t)st.st_size); + exploration_stats_reset(); + started = datetime_monotonic_us(); + exploration_begin(&ns); + uint64_t load_us = datetime_monotonic_us() - started; + ck_assert_uint_eq(exploration_stats_get().load_records, 10000); + ck_assert_uint_eq(exploration_stats_get().map_lookups, 10000); + ck_assert_uint_eq(ns.packet_queue_count, + 2); /* Only account identity, regardless of map count. */ + socket_buffer_clear(&ns); + ck_assert(exploration_visited(&ns, "/world/map09999", dimension - 1, dimension - 1)); + exploration_stats_reset(); + for (unsigned i = 0; i < 1000; i++) { + exploration_flush(&ns, false); + } + ck_assert_uint_eq(exploration_stats_get().pending_visits, 0); + ck_assert_uint_eq(exploration_stats_get().map_lookups, 0); + ck_assert_uint_eq(exploration_stats_get().sent_records, 0); + printf("exploration_scale maps=10000 dimension=%u disk_bytes=%" PRIu64 " mark_us=%" PRIu64 + " save_us=%" PRIu64 " load_us=%" PRIu64 "\n", + dimension, + (uint64_t)st.st_size, + mark_us, + save_us, + load_us); + fflush(stdout); + exploration_end(&ns); + unlink(path); + free(path); +} +END_TEST + START_TEST(test_checked_logout_propagates_save_failures) { const char *account_name = "shutdownproof"; const char *character_name = "Shutdown Proof"; @@ -374,6 +777,13 @@ START_TEST(test_checked_logout_propagates_save_failures) { player *pl = CONTR(ob); free(pl->cs->account); pl->cs->account = xstrdup(account_name); + char *exploration_path = exploration_test_path(account_name); + unlink(exploration_path); + if (_i == 4) { + exploration_begin(pl->cs); + ck_assert(exploration_mark(pl->cs, "/world", 1, 1, 0, 0)); + ck_assert_int_eq(mkdir(exploration_path, 0700), 0); + } /* These independent failures must survive the logout cleanup. */ player_save_fail_for_test(_i == 1); @@ -386,6 +796,12 @@ START_TEST(test_checked_logout_propagates_save_failures) { player_save_fail_for_test(false); account_fail_saves_for_test(false); ck_assert(player_disconnect_all_checked()); + if (_i == 4) { + ck_assert_int_eq(rmdir(exploration_path), 0); + } + ck_assert(exploration_shutdown_checked()); + unlink(exploration_path); + free(exploration_path); ck_assert_int_eq(unlink(account_path), 0); ck_assert_int_eq(unlink(player_path), 0); @@ -447,10 +863,20 @@ static Suite *suite(void) { tcase_add_checked_fixture(tc_core, check_test_setup, check_test_teardown); suite_add_tcase(s, tc_core); tcase_set_timeout(tc_core, 30); - tcase_add_loop_test(tc_core, test_checked_logout_propagates_save_failures, 0, 4); + tcase_add_loop_test(tc_core, test_checked_logout_propagates_save_failures, 0, 5); tcase_add_test(tc_core, test_access_preserves_normal_command_permissions); tcase_add_test(tc_core, test_access_preserves_ordinary_registration); tcase_add_test(tc_core, test_account_provision); + tcase_add_test(tc_core, test_exploration_account_round_trip); + tcase_add_test(tc_core, test_exploration_bounds_and_stable_layout); + tcase_add_test(tc_core, test_exploration_corrupt_file_preserved); + tcase_add_test(tc_core, test_exploration_failed_save_retried); + tcase_add_test(tc_core, test_exploration_shutdown_propagates_detached_save_failure); + tcase_add_test(tc_core, test_exploration_snapshot_batches); + tcase_add_test(tc_core, test_exploration_reconciliation_is_sparse_and_never_grants_bits); + tcase_add_test(tc_core, test_exploration_reconciliation_rejects_malformed_requests); + tcase_add_test(tc_core, test_exploration_session_flush_is_local_and_backpressure_retains_bits); + tcase_add_loop_test(tc_core, test_exploration_ten_thousand_maps, 0, 2); tcase_add_test(tc_core, test_account_provision_rejects_invalid_inputs); tcase_add_test(tc_core, test_account_provision_password_file_permissions); tcase_add_test(tc_core, test_account_provision_lighting_preset); diff --git a/server/src/tests/unit/server/request.c b/server/src/tests/unit/server/request.c index 448cbe8fe..60c0af3ce 100644 --- a/server/src/tests/unit/server/request.c +++ b/server/src/tests/unit/server/request.c @@ -23,6 +23,9 @@ #include #include #include +#include +#include +#include #include static size_t queued_command_count(socket_struct *cs, uint8_t type) { @@ -690,6 +693,7 @@ static const command_policy_expectation_t command_policy_expectations[] = { COMMAND_POLICY(TALK, COMMAND_PHASE_PLAYING_MASK, COMMAND_PHASE_PLAYING), COMMAND_POLICY(MOVE, COMMAND_PHASE_PLAYING_MASK, COMMAND_PHASE_PLAYING), COMMAND_POLICY(TARGET, COMMAND_PHASE_PLAYING_MASK, COMMAND_PHASE_PLAYING), + COMMAND_POLICY(REGION_EXPLORATION, COMMAND_PHASE_PLAYING_MASK, COMMAND_PHASE_PLAYING), COMMAND_POLICY(ACCESS_AUTH, 0, COMMAND_PHASE_VERSIONED), COMMAND_POLICY(ACCESS_ADMIN, COMMAND_PHASE_PLAYING_MASK, COMMAND_PHASE_PLAYING), }; @@ -2412,6 +2416,80 @@ START_TEST(test_odd_view_los_matches_emitted_map_coordinates) { } END_TEST +START_TEST(test_region_exploration_grants_only_visible_public_base_cells) { + mapstruct *base; + object *pl; + check_setup_env_pl(&base, &pl); + request_move_player(&pl, base, 12, 12); + socket_struct *cs = CONTR(pl)->cs; + free(cs->account); + cs->account = xstrdup("exploregrant"); + char *account_path = account_make_path(cs->account); + char path[HUGE_BUF]; + snprintf(VS(path), "%s.exploration", account_path); + free(account_path); + unlink(path); + exploration_begin(cs); + + region_struct region = {.name = "test", .map_first = "/world/test"}; + region_struct *original_region = base->region; + base->region = ®ion; + FREE_AND_COPY_HASH(base->path, "/world/grant"); + mapstruct *upper = get_empty_map(24, 24); + base->tile_map[TILED_UP] = upper; + upper->tile_map[TILED_DOWN] = base; + base->coords[2] = 0; + upper->coords[2] = 1; + base->level_min = upper->level_min = 0; + base->level_max = upper->level_max = 1; + upper->region = ®ion; + FREE_AND_COPY_HASH(upper->path, "/world/upper"); + object *exit = arch_get("stairs_down"); + exit->x = 13; + exit->y = 12; + ck_assert_ptr_nonnull(object_insert_map(exit, base, NULL, 0)); + object *roof = arch_get("roof_thatch"); + roof->x = 13; + roof->y = 12; + ck_assert_ptr_nonnull(object_insert_map(roof, upper, NULL, 0)); + + update_los(pl); + int ax = cs->mapx_2 + 1, ay = cs->mapy_2; + CONTR(pl)->blocked_los[ax][ay] |= BLOCKED_LOS_BLOCKED; + map_client_cache_clear(&cs->lastmap); + socket_buffer_clear(cs); + CONTR(pl)->map_update_cmd = MAP_UPDATE_CMD_SAME; + draw_client_map2(pl); + ck_assert(!exploration_visited(cs, "/world/grant", 13, 12)); + ck_assert(!exploration_visited(cs, "/world/upper", 13, 12)); + ck_assert(!exploration_visited(cs, "/world/grant", 0, 0)); + + CONTR(pl)->blocked_los[ax][ay] &= ~BLOCKED_LOS_BLOCKED; + socket_buffer_clear(cs); + draw_client_map2(pl); + ck_assert(exploration_visited(cs, "/world/grant", 13, 12)); + ck_assert(!exploration_visited(cs, "/world/upper", 13, 12)); + + FREE_AND_COPY_HASH(base->path, "/world/private"); + base->map_flags |= MAP_FLAG_UNIQUE; + socket_buffer_clear(cs); + draw_client_map2(pl); + ck_assert(!exploration_visited(cs, "/world/private", 13, 12)); + base->map_flags &= ~MAP_FLAG_UNIQUE; + FREE_AND_COPY_HASH(base->path, "/world/unmapped"); + base->region = NULL; + socket_buffer_clear(cs); + draw_client_map2(pl); + ck_assert(!exploration_visited(cs, "/world/unmapped", 13, 12)); + + exploration_end(cs); + socket_buffer_clear(cs); + base->region = original_region; + upper->region = NULL; + unlink(path); +} +END_TEST + static Suite *suite(void) { Suite *s = suite_create("request"); TCase *tc_core = tcase_create("Core"); @@ -2468,6 +2546,7 @@ static Suite *suite(void) { tcase_add_test(tc_core, test_zero_lit_roof_is_serialized_and_xray_vision_remains_authorized); tcase_add_test(tc_core, test_retained_fow_reentry_resends_zero_light_state); tcase_add_test(tc_core, test_map_exit_semantic_not_disclosed_by_boundary_geometry); + tcase_add_test(tc_core, test_region_exploration_grants_only_visible_public_base_cells); tcase_add_test(tc_core, test_timed_endpoint_tracks_rgb_only_changes_without_redundant_updates); tcase_add_test(tc_core, test_timed_endpoint_refresh_keeps_colored_scalar_present); tcase_add_test(tc_core, test_map_rgb_cache_tracks_hue_changes_and_neutral_reset); diff --git a/tools/tests/test_matching_sdk_sources.py b/tools/tests/test_matching_sdk_sources.py index 7bbee0a97..99d947d0c 100644 --- a/tools/tests/test_matching_sdk_sources.py +++ b/tools/tests/test_matching_sdk_sources.py @@ -17,7 +17,7 @@ def configure(self, component, layout, override=False, provided=False): protocol.mkdir(parents=True) header = protocol / "generated/c/include/atrinik/protocol/game_commands.h" header.parent.mkdir(parents=True) - header.write_text("#define ATRINIK_PROTOCOL_VERSION 1081U\n") + header.write_text("#define ATRINIK_PROTOCOL_VERSION 1082U\n") (protocol / "CMakeLists.txt").write_text( "add_library(protocol INTERFACE)\n" "add_library(Atrinik::Protocol ALIAS protocol)\n") diff --git a/tools/tests/test_version_metadata.py b/tools/tests/test_version_metadata.py index 6723cc475..110b9e340 100644 --- a/tools/tests/test_version_metadata.py +++ b/tools/tests/test_version_metadata.py @@ -2,6 +2,8 @@ from pathlib import Path import os +import json +import re import shutil import subprocess import tempfile @@ -265,6 +267,20 @@ def test_git_selectors_cannot_redirect_owner_metadata(self) -> None: self.assertTrue(client.startswith("5.68.0|"), client) self.assertTrue(client.endswith("|" + revision + "|false"), client) + def test_gameplay_consumers_require_current_schema_revision(self) -> None: + schema = json.loads((ROOT / "protocol/schema/game-commands.json").read_text()) + version = schema["protocol_version"] + for component in ("client", "server"): + with self.subTest(component=component): + source = (ROOT / component / "CMakeLists.txt").read_text() + match = re.search( + r'if \(NOT ATRINIK_PROTOCOL_VERSION_LINE MATCHES " ([0-9]+)U\$"\)', + source, + ) + self.assertIsNotNone(match, "Missing exact protocol compatibility gate") + self.assertEqual(int(match.group(1)), version) + self.assertIn(f"requires classic game protocol v{version}", source) + def test_real_protocol_unified_and_embedded_archive_versions(self) -> None: self.initialize_git(self.root) self.git(self.root, "tag", "v88.99.100")