From 0d2c9d45f005108da15f3ec882c63957d575c553 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Fri, 2 Oct 2026 01:13:51 -0500 Subject: [PATCH 01/13] feat(client): restore account exploration from server snapshots --- client/AGENTS.md | 3 + client/CMakeLists.txt | 7 + client/src/client/command_handlers.def | 1 + client/src/client/commands.c | 28 ++++ client/src/client/gpu_player_view.c | 4 - client/src/client/main.c | 4 + client/src/client/region_exploration.c | 103 ++++++++++++ client/src/client/region_map.c | 200 +++++++++++++++--------- client/src/client/socket.c | 3 + client/src/cmake.txt | 1 + client/src/include/commands.h | 2 + client/src/include/region_exploration.h | 13 ++ client/src/include/region_map.h | 10 +- client/src/tests/region_exploration.c | 83 ++++++++++ 14 files changed, 381 insertions(+), 81 deletions(-) create mode 100644 client/src/client/region_exploration.c create mode 100644 client/src/include/region_exploration.h create mode 100644 client/src/tests/region_exploration.c diff --git a/client/AGENTS.md b/client/AGENTS.md index 6b81e66d1..b0e3e9ac7 100644 --- a/client/AGENTS.md +++ b/client/AGENTS.md @@ -34,6 +34,9 @@ - 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 is a bounded server-owned account snapshot cache, + reset on session RESET and disconnect. Replay it after region assets load; + character `.tiles` files are historical and must never be read or written. - Client user data lives below `.atrinik/.x/`. When that stable directory is first created, the client may migrate the highest valid same-major legacy directory; the migration is collision-safe and marker-backed, leaves other diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index 02a2cd6d0..f7ca01d17 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -758,6 +758,12 @@ 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) + add_test(NAME client-region-exploration COMMAND client-region-exploration-tests) + add_executable(client-compound-packet-tests src/tests/compound_packet.c src/client/commands.c @@ -1063,6 +1069,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 7d33eff70..8bcc58b84 100644 --- a/client/src/client/command_handlers.def +++ b/client/src/client/command_handlers.def @@ -29,3 +29,4 @@ 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) diff --git a/client/src/client/commands.c b/client/src/client/commands.c index 2dda09030..5e6888e53 100644 --- a/client/src/client/commands.c +++ b/client/src/client/commands.c @@ -47,6 +47,7 @@ #include #include #include +#include #include #include #include @@ -1774,3 +1775,30 @@ 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); + unsigned old_width = 0, old_height = 0; + bool geometry_changed = false; + /* Inspect the old snapshot only after validating a bounded path extent. */ + if (pos < len && data[pos] == 1 && len - pos > 1 && + memchr(data + pos + 1, 0, len - pos - 1 < 256 ? len - pos - 1 : 256) != NULL) { + region_exploration_find((const char *)data + pos + 1, &old_width, &old_height); + } + if (pos > len || !region_exploration_receive(data + pos, len - pos)) { + packet_reader_set_error(&reader, PACKET_ERROR_INVALID_ENCODING); + return; + } + if (data[pos] == 1 && old_width != 0) { + unsigned width, height; + region_exploration_find((const char *)data + pos + 1, &width, &height); + geometry_changed = width != old_width || height != old_height; + } + /* MAP snapshots add visited cells; a changed geometry requires a full + * rebuild so coordinates from the previous dimensions cannot survive. */ + region_map_exploration_refresh_map(data[pos] == 1 && !geometry_changed + ? (const char *)data + pos + 1 : NULL, + 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 309ed59cb..c9a3614e2 100644 --- a/client/src/client/gpu_player_view.c +++ b/client/src/client/gpu_player_view.c @@ -1877,7 +1877,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"); @@ -1981,7 +1980,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, @@ -3933,7 +3931,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 @@ -4572,7 +4569,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 6b9a23b45..4a04f2c02 100644 --- a/client/src/client/main.c +++ b/client/src/client/main.c @@ -940,6 +940,10 @@ 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], "--map-state-test") == 0) { bool sparse = widget_map_sparse_state_test(); bool transaction = widget_map_transaction_abort_test(); diff --git a/client/src/client/region_exploration.c b/client/src/client/region_exploration.c new file mode 100644 index 000000000..37b44cb1f --- /dev/null +++ b/client/src/client/region_exploration.c @@ -0,0 +1,103 @@ +/* Copyright 2026 The Atrinik Project */ +#include +#include +#include + +typedef struct exploration_record { + char path[256]; + unsigned width, height; + uint8_t *bits; +} exploration_record_t; +static exploration_record_t records[4096]; +static size_t records_num; + +void region_exploration_clear(void) { + for (size_t i = 0; i < records_num; i++) { + free(records[i].bits); + } + memset(records, 0, sizeof(records)); + records_num = 0; +} + +const uint8_t *region_exploration_find(const char *path, unsigned *width, unsigned *height) { + for (size_t i = 0; i < records_num; i++) { + if (strcmp(records[i].path, path) == 0) { + *width = records[i].width; + *height = records[i].height; + return records[i].bits; + } + } + return NULL; +} + +bool region_exploration_receive(const uint8_t *data, size_t len) { + if (data == NULL || len == 0) { + return false; + } + if (data[0] == 0) { + if (len != 1) { + return false; + } + region_exploration_clear(); + return true; + } + if (data[0] != 1 || len < 8) { + return false; + } + const uint8_t *end = memchr(data + 1, 0, len - 1 < 256 ? len - 1 : 256); + if (end == NULL || end == data + 1 || data[1] != '/') { + return false; + } + const uint8_t *segment = data + 2; + for (const uint8_t *p = segment; p <= end; p++) { + if (p != end && (*p < 32 || *p == 127 || *p == '\\' || *p == ':')) { + return false; + } + if (p == end || *p == '/') { + size_t n = (size_t)(p - segment); + if (n == 0 || (n == 1 && segment[0] == '.') || + (n == 2 && segment[0] == '.' && segment[1] == '.')) { + return false; + } + segment = p + 1; + } + } + size_t offset = (size_t)(end - data) + 1; + if (len - offset < 4) { + return false; + } + unsigned width = ((unsigned)data[offset] << 8) | data[offset + 1]; + unsigned height = ((unsigned)data[offset + 2] << 8) | data[offset + 3]; + if (width == 0 || height == 0 || width > 256 || height > 256) { + return false; + } + size_t bytes = ((size_t)width * height + 7) / 8; + if (len - offset - 4 != bytes) { + return false; + } + unsigned tail = (width * height) % 8; + if (tail != 0 && (data[len - 1] & (uint8_t)(0xffU << tail)) != 0) { + return false; + } + size_t index = 0; + while (index < records_num && strcmp(records[index].path, (const char *)data + 1) != 0) { + index++; + } + if (index == sizeof(records) / sizeof(records[0])) { + return false; + } + uint8_t *bits = malloc(bytes); + if (bits == NULL) { + return false; + } + memcpy(bits, data + offset + 4, bytes); + free(records[index].bits); + memcpy(records[index].path, data + 1, offset - 1); + records[index].width = width; + records[index].height = height; + records[index].bits = bits; + if (index == records_num) { + records_num++; + } + return true; +} diff --git a/client/src/client/region_map.c b/client/src/client/region_map.c index 7dddf1dde..5d63aaa2b 100644 --- a/client/src/client/region_map.c +++ b/client/src/client/region_map.c @@ -43,6 +43,7 @@ #include #include #include +#include #include #include #include @@ -50,13 +51,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; 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 +74,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 +97,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 +117,11 @@ 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; free(region_map); } @@ -216,11 +220,7 @@ 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 +752,9 @@ 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)); region_map_fow_update(region_map); } @@ -810,8 +777,6 @@ static void region_map_fow_free(region_map_t *region_map) { 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 +793,9 @@ 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_loaded = false; } static bool region_map_fow_update_regions(region_map_t *region_map, const uint32_t *color) { @@ -860,7 +805,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 +855,28 @@ static bool region_map_fow_update_regions(region_map_t *region_map, const uint32 return ret; } +static void region_map_exploration_replay(region_map_t *region_map, const char *path) { + for (size_t i = 0; i < region_map->def->num_maps; i++) { + if (path != NULL && strcmp(path, region_map->def->maps[i].path) != 0) { + continue; + } + unsigned width, height; + const uint8_t *bits = region_exploration_find(region_map->def->maps[i].path, &width, &height); + if (bits == NULL) { + continue; + } + for (unsigned y = 0; y < height; y++) { + for (unsigned x = 0; x < width; x++) { + size_t bit = (size_t)y * width + x; + if (bits[bit / 8] & (1U << (bit % 8))) { + region_map_fow_set_visited(region_map, ®ion_map->def->maps[i], NULL, x, y); + } + } + } + } + +} + void region_map_fow_update(region_map_t *region_map) { region_map_def_map_t *def_map; int rowsize, x, y; @@ -919,11 +886,16 @@ 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; } + if (!region_map->fow->exploration_loaded) { + region_map_exploration_replay(region_map, NULL); + region_map->fow->exploration_loaded = true; + } + if (region_map->fow->tiles != NULL) { region_map_fow_tile_t *tile; @@ -1103,3 +1075,85 @@ SDL_Surface *region_map_fow_surface(region_map_t *region_map) { 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) { + minimap_redraw_flag = 1; + for (region_map_t *map = region_maps; map != NULL; map = map->next) { + if (reset) { + if (map->fow->tiles != NULL) { + for (unsigned i = 0; i < utarray_len(map->fow->tiles); i++) { + region_map_fow_tile_t *tile = utarray_eltptr(map->fow->tiles, i); + free(tile->path); + } + utarray_clear(map->fow->tiles); + } + } + /* A snapshot can replace dimensions or clear old geometry. Rebuild + * from the authoritative session cache before accepting new live bits. */ + if (map->fow->bitmap != NULL) { + if (path == NULL) { + memset(map->fow->bitmap, 0, RM_MAP_FOW_BITMAP_SIZE(map)); + map->fow->exploration_loaded = false; + } else { + region_map_exploration_replay(map, path); + } + } + if (path == NULL || region_map_find_map(map, path) != NULL) { + 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_clear(); + region_map_exploration_refresh(true); +} + +#ifdef ATRINIK_WIDGET_TESTS +bool region_map_exploration_test(void) { + const uint8_t packet[] = {1, '/', 't', 0, 0, 3, 0, 2, 0x21}; + region_map_exploration_clear(); + region_map_t *map = region_map_create(); + bool ok = region_exploration_receive(packet, sizeof(packet)); + 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); + 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 is reopened with no network replay. */ + ok &= region_map_fow_is_visited(map, 4, 2); + const uint8_t replacement[] = {1, '/', 't', 0, 0, 1, 0, 1, 1}; + ok &= region_exploration_receive(replacement, sizeof(replacement)); + region_map_exploration_refresh(false); + 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); + clone->fow_zoomed = SDL_CreateSurface(8, 8, SDL_PIXELFORMAT_RGBA32); + 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); + ok &= !region_map_fow_is_visited(map, 2, 1); + region_map_free(map); + return ok; +} +#endif diff --git a/client/src/client/socket.c b/client/src/client/socket.c index a1169d607..94149064f 100644 --- a/client/src/client/socket.c +++ b/client/src/client/socket.c @@ -30,6 +30,7 @@ #include #include #include +#include #include #include #include @@ -321,6 +322,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(); @@ -419,6 +421,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) { diff --git a/client/src/cmake.txt b/client/src/cmake.txt index 1b473e309..d07eff973 100644 --- a/client/src/cmake.txt +++ b/client/src/cmake.txt @@ -40,6 +40,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/commands.h b/client/src/include/commands.h index 41fb9ad54..4e93abe9b 100644 --- a/client/src/include/commands.h +++ b/client/src/include/commands.h @@ -97,6 +97,8 @@ extern void socket_command_version(uint8_t *data, size_t len, size_t pos); 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..f28addabf --- /dev/null +++ b/client/src/include/region_exploration.h @@ -0,0 +1,13 @@ +/* Copyright 2026 The Atrinik Project */ +#ifndef REGION_EXPLORATION_H +#define REGION_EXPLORATION_H +#include +#include +#include + +/* Main-thread session cache. Returned snapshots are borrowed until the next + * successful packet or clear. Invalid packets leave all snapshots unchanged. */ +bool region_exploration_receive(const uint8_t *data, size_t len); +void region_exploration_clear(void); +const uint8_t *region_exploration_find(const char *path, unsigned *width, unsigned *height); +#endif diff --git a/client/src/include/region_map.h b/client/src/include/region_map.h index 9d0d5d7b0..2ce17823b 100644 --- a/client/src/include/region_map.h +++ b/client/src/include/region_map.h @@ -169,11 +169,10 @@ typedef struct region_map_fow { /** Reference count. */ int refcount; - char *path; - SDL_Surface *surface; uint32_t *bitmap; + bool exploration_loaded; ///< Session cache has been applied after asset loading. UT_array *tiles; } region_map_fow_t; @@ -182,6 +181,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 +265,11 @@ 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); #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..7e0336519 --- /dev/null +++ b/client/src/tests/region_exploration.c @@ -0,0 +1,83 @@ +/* Copyright 2026 The Atrinik Project */ +#include +#include +#include +#include +#define CHECK(c) do { if (!(c)) { fprintf(stderr, "line %d: %s\n", __LINE__, #c); abort(); } } while (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++] = w >> 8; + out[pos++] = w; + out[pos++] = h >> 8; + out[pos++] = h; + size_t bytes = ((size_t)w * h + 7) / 8; + memset(out + pos, 0, bytes); + return pos + bytes; +} +int main(void) { + uint8_t data[9000]; + unsigned w, h; + size_t len = packet(data, "/test", 9, 2); + data[len - 3] = 0x81; + data[len - 2] = 0x02; + data[len - 1] = 0x01; + CHECK(region_exploration_receive(data, len)); + 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(!region_exploration_receive(data, n)); + CHECK(region_exploration_find("/test", &w, &h) == bits); + } + data[len] = 0; + CHECK(!region_exploration_receive(data, len + 1)); + data[0] = 2; + CHECK(!region_exploration_receive(data, len)); + data[0] = 0; + CHECK(!region_exploration_receive(data, 2)); + CHECK(region_exploration_find("/test", &w, &h) == bits); + CHECK(region_exploration_receive(data, 1)); + CHECK(!region_exploration_find("/test", &w, &h)); + len = packet(data, "/test", 0, 1); + CHECK(!region_exploration_receive(data, len)); + len = packet(data, "/test", 257, 1); + CHECK(!region_exploration_receive(data, len)); + char path[258]; + memset(path, 'a', sizeof(path)); path[0] = '/'; path[256] = 0; + len = packet(data, path, 1, 1); + CHECK(!region_exploration_receive(data, len)); + path[255] = 0; + len = packet(data, path, 256, 256); + data[len - 1] = 0x80; + CHECK(region_exploration_receive(data, len)); + bits = region_exploration_find(path, &w, &h); + CHECK(bits && w == 256 && h == 256 && bits[8191] == 0x80); + region_exploration_clear(); + 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(!region_exploration_receive(data, len)); + } + len = packet(data, "/padding", 1, 1); + data[len - 1] = 2; + CHECK(!region_exploration_receive(data, len)); + for (unsigned i = 0; i < 4096; i++) { + snprintf(path, sizeof(path), "/map/%u", i); + len = packet(data, path, 1, 1); + CHECK(region_exploration_receive(data, len)); + } + len = packet(data, "/overflow", 1, 1); + CHECK(!region_exploration_receive(data, len)); + len = packet(data, "/map/0", 2, 2); + data[len - 1] = 8; + CHECK(region_exploration_receive(data, len)); + bits = region_exploration_find("/map/0", &w, &h); + CHECK(bits && w == 2 && h == 2 && *bits == 8); + region_exploration_clear(); + CHECK(!region_exploration_find("/map/0", &w, &h)); + CHECK(region_exploration_receive(data, len)); + region_exploration_clear(); + return 0; +} From 0c324942d491d36e0ac32a868ba84ff90a28ddc3 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Fri, 2 Oct 2026 01:16:24 -0500 Subject: [PATCH 02/13] feat(server): persist account region-map exploration --- protocol/README.md | 5 + .../atrinik/protocol/game_commands.def | 1 + .../include/atrinik/protocol/game_commands.h | 6 +- .../generated/python/atrinik_protocol/game.py | 3 +- protocol/schema/game-commands.json | 5 +- protocol/tests/test_generate.py | 4 +- server/AGENTS.md | 4 + server/doc/REGION_EXPLORATION.md | 104 +++++ server/src/cmake.txt | 1 + server/src/include/exploration.h | 20 + server/src/server/account.c | 7 +- server/src/server/exploration.c | 377 ++++++++++++++++++ server/src/socket/init.c | 2 + server/src/socket/request.c | 7 + server/src/tests/unit/server/account.c | 152 +++++++ server/src/tests/unit/server/request.c | 79 ++++ 16 files changed, 769 insertions(+), 8 deletions(-) create mode 100644 server/doc/REGION_EXPLORATION.md create mode 100644 server/src/include/exploration.h create mode 100644 server/src/server/exploration.c diff --git a/protocol/README.md b/protocol/README.md index 3f367cb13..a1d027f8d 100644 --- a/protocol/README.md +++ b/protocol/README.md @@ -9,6 +9,11 @@ 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 adds server-to-client `REGION_EXPLORATION` (ID 29), an account +exploration reset or bounded per-map bitmap. See the normative +[exploration contract](../server/doc/REGION_EXPLORATION.md) for framing, bounds, +sequencing, persistence and migration. Exact-version login rejects older peers. + Protocol v1080 adds timed celestial aggregate-light keyframes to MAP2. Its payload carries a bounded absolute game-time interval and next-endpoint samples; the client interpolates locally without periodic MAP traffic. diff --git a/protocol/generated/c/include/atrinik/protocol/game_commands.def b/protocol/generated/c/include/atrinik/protocol/game_commands.def index 647fe9696..b1a47b041 100644 --- a/protocol/generated/c/include/atrinik/protocol/game_commands.def +++ b/protocol/generated/c/include/atrinik/protocol/game_commands.def @@ -64,6 +64,7 @@ ATRINIK_SERVER_TO_CLIENT_COMMAND(25, INTERFACE, "interface") ATRINIK_SERVER_TO_CLIENT_COMMAND(26, NOTIFICATION, "notification") ATRINIK_SERVER_TO_CLIENT_COMMAND(27, KEEPALIVE, "keepalive") ATRINIK_SERVER_TO_CLIENT_COMMAND(28, PLAYER_STATUS, "player_status") +ATRINIK_SERVER_TO_CLIENT_COMMAND(29, 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 52b3df887..207c5412a 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 1080U +#define ATRINIK_PROTOCOL_VERSION 1081U #define SOCKET_VERSION ATRINIK_PROTOCOL_VERSION #define ATRINIK_PROTOCOL_ITEM_NAME_SIZE 128U @@ -103,7 +103,8 @@ typedef enum atrinik_server_to_client_command { CLIENT_CMD_NOTIFICATION = 26, CLIENT_CMD_KEEPALIVE = 27, CLIENT_CMD_PLAYER_STATUS = 28, - CLIENT_CMD_NROF = 29 + CLIENT_CMD_REGION_EXPLORATION = 29, + CLIENT_CMD_NROF = 30 } atrinik_server_to_client_command_t; /* Stable diagnostic names for server-to-client commands. */ @@ -136,5 +137,6 @@ typedef enum atrinik_server_to_client_command { #define CLIENT_CMD_NAME_NOTIFICATION "notification" #define CLIENT_CMD_NAME_KEEPALIVE "keepalive" #define CLIENT_CMD_NAME_PLAYER_STATUS "player_status" +#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 e70f85772..25515d31a 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 = 1080 +PROTOCOL_VERSION = 1081 ITEM_NAME_SIZE = 128 @@ -79,3 +79,4 @@ class ServerToClientCommand(IntEnum): NOTIFICATION = 26 KEEPALIVE = 27 PLAYER_STATUS = 28 + REGION_EXPLORATION = 29 diff --git a/protocol/schema/game-commands.json b/protocol/schema/game-commands.json index 146c05cd7..c7f38db74 100644 --- a/protocol/schema/game-commands.json +++ b/protocol/schema/game-commands.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "protocol_version": 1080, + "protocol_version": 1081, "item_name_size": 128, "item_extra_message_size": 256, "player_status": { @@ -68,6 +68,7 @@ {"id": 25, "symbol": "INTERFACE", "wire_name": "interface"}, {"id": 26, "symbol": "NOTIFICATION", "wire_name": "notification"}, {"id": 27, "symbol": "KEEPALIVE", "wire_name": "keepalive"}, - {"id": 28, "symbol": "PLAYER_STATUS", "wire_name": "player_status"} + {"id": 28, "symbol": "PLAYER_STATUS", "wire_name": "player_status"}, + {"id": 29, "symbol": "REGION_EXPLORATION", "wire_name": "region_exploration"} ] } diff --git a/protocol/tests/test_generate.py b/protocol/tests/test_generate.py index 59b739353..24995808b 100644 --- a/protocol/tests/test_generate.py +++ b/protocol/tests/test_generate.py @@ -17,12 +17,12 @@ 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"], 1080) + self.assertEqual(schema["protocol_version"], 1081) 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"]), 23) - self.assertEqual(len(schema["server_to_client"]), 29) + self.assertEqual(len(schema["server_to_client"]), 30) 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 fe755cd84..72019966b 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. - With the active celestial-v1 runtime, player persistence uses owner-bound diff --git a/server/doc/REGION_EXPLORATION.md b/server/doc/REGION_EXPLORATION.md new file mode 100644 index 000000000..5a305c12b --- /dev/null +++ b/server/doc/REGION_EXPLORATION.md @@ -0,0 +1,104 @@ +# Account region-map exploration + +The Classic server owns region-map discoveries for an authenticated account. +Characters on that account share the union, including simultaneous sessions. +A fresh client receives the same discoveries. This is separate from MAP2's +remembered geometry, current lighting and inventory region-map reveal items. + +## Discovery and ownership + +`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 the account cache on the single simulation thread. Sessions +share a store by authenticated account name; they never upload discoveries. +The last session normally saves and releases the store. Socket cleanup also +releases exceptional sessions. Failed writes retain dirty state for retry on +reconnect, and final server cleanup retries all remaining stores. + +Each map is keyed by canonical logical path, with row-major local coordinates. +Paths start with `/`, contain at most 255 bytes, and contain no empty, `.` or +`..` segment, backslash, colon or control character. Width and height are each +1–256; a store contains at most 4096 maps. Coordinates outside the map and +entries above the limit are rejected. A changed map size resets only that map's +bitmap because old coordinates no longer identify the same geometry. Changes +to region image layout do not invalidate logical map-local coordinates. + +## Durable storage + +The sidecar is `.exploration`, next to the existing +account file. Credential and character-roster rewrites cannot erase it. Missing +sidecars mean empty exploration. This is initialized runtime state: include it +in account backups and the complete mutable-datapath activation archive. + +The binary format is: + +- Eight ASCII magic/version bytes `AEXP0001`. +- Zero or more records until EOF: big-endian unsigned 16-bit path byte length, + exactly 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, least-significant-bit first within each byte. Unused bits + in the last byte must be zero. Duplicate paths are invalid. + +Readers bound the entire file to 36 MiB and the record count to 4096. Validation +is transactional: malformed/truncated/oversized data discards the staged store, +logs a diagnostic, and disables writes for that account session. The original +file is preserved for operator recovery rather than replaced with an empty one. + +Writes use the existing mode-0600 atomic file replacement API, including its +flush/fsync/rename behavior. Discovery writes are batched at most once per five +seconds; logout, socket teardown and orderly server shutdown force a save. +There is no write when nothing changed. A crash may lose the most recent +unsaved batch; failed saves keep dirty data in memory and log an error. + +## Protocol 1081 + +`REGION_EXPLORATION` is server-to-client command 29. The ordinary packet envelope +provides framing; its payload begins with an unsigned 8-bit operation: + +| Operation | Remaining payload | +| --- | --- | +| `0` RESET | Empty; clears the previous account's session cache and rendered FOW. | +| `1` MAP | NUL-terminated logical path (1–255 bytes), big-endian u16 width, big-endian u16 height, exactly `ceil(width * height / 8)` bitmap bytes. | + +The bounds and bit order match persistence. Unknown operations, truncated or +extra bytes, invalid dimensions/path/padding, and a 4097th distinct map are +rejected before mutating the client cache. MAP replaces the cached record for +its path. RESET starts every successful character session before snapshot +records, even when the account has no discoveries. Disconnect clears client +session state. Exact-version negotiation rejects incompatible older peers; +there is no client-upload command or compatibility persistence branch. + +Initial snapshots and later map revisions stream at most 32 records per map +draw, stopping when the socket queue exceeds 1 MiB or 1024 queued fragments. +Per-session revision tracking retries remaining records on later draws and +keeps simultaneous characters synchronized without exceeding the transport's +4 MiB/4096-fragment queue limits. Each map record is independently complete; +there is no unbounded staging transaction or implicit snapshot-complete marker. + +The client caches records independently of asynchronous region PNG/definition +loading, applies matching logical maps once assets are ready, and refreshes +open map/minimap clones. Normal MAP2 updates can still reveal currently visible +cells immediately. Server snapshots restore history after reconnect and region +transitions; client disks are not authoritative. + +## Migration and validation + +Existing per-character `client-maps/*.tiles` files remain untouched but are no +longer loaded or written. Previously local exploration is not imported: it has +no server-verifiable account ownership or visibility provenance. Existing +accounts begin recording newly observed cells after the upgrade. No account, +character, content artifact or initialized runtime file requires manual edits. + +Server account tests cover shared sessions, another account, reload into fresh +session state, dimensions, corruption preservation, failed-save retry and +paced replay. The request suite drives normal `draw_client_map2()` through +visible, concealed, upper-level, private and non-region cases. Client parser and +region-map integration tests cover bounded transactional decoding and delayed +assets/reconnect rendering. Protocol generation tests bind both consumers to +version 1081 and command 29. Use the workspace Classic profile for integrated +server/client tests and the supervised isolated lifecycle for runtime checks. diff --git a/server/src/cmake.txt b/server/src/cmake.txt index a63dfa5b6..31c1a39bf 100644 --- a/server/src/cmake.txt +++ b/server/src/cmake.txt @@ -67,6 +67,7 @@ set(SERVER_CORE_SOURCES src/random_maps/style.c src/random_maps/wall.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..9e6b438c6 --- /dev/null +++ b/server/src/include/exploration.h @@ -0,0 +1,20 @@ +/* Copyright 2026 The Atrinik Project + * 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. No client upload API exists. */ +void exploration_begin(socket_struct *ns); +void exploration_end(socket_struct *ns); +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 +bool exploration_visited(socket_struct *ns, const char *path, unsigned x, unsigned y); +#endif +#endif diff --git a/server/src/server/account.c b/server/src/server/account.c index 49d084ed8..a79b6ed50 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(); @@ -1095,6 +1098,7 @@ void account_login_char(socket_struct *ns, char *name) { } void account_logout_char(socket_struct *ns, player *pl) { + exploration_end(ns); char *path; account_struct account; size_t i; @@ -1142,6 +1146,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..ec81d0f31 --- /dev/null +++ b/server/src/server/exploration.c @@ -0,0 +1,377 @@ +/* Copyright 2026 The Atrinik Project + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include +#include +#include +#include +#include +#include +#include + +#define EXPLORATION_MAPS_MAX 4096U +#define EXPLORATION_DIM_MAX 256U +#define EXPLORATION_PATH_MAX 255U +#define EXPLORATION_BYTES_MAX (36U * 1024U * 1024U) +#define EXPLORATION_MAGIC "AEXP0001" + +typedef struct exploration_map { + struct exploration_map *next; + char *path; + unsigned width, height; + uint8_t *bits; + unsigned index; + uint64_t revision; +} exploration_map; + +typedef struct exploration_session { + struct exploration_session *next; + socket_struct *socket; + uint64_t seen[EXPLORATION_MAPS_MAX]; +} exploration_session; + +typedef struct exploration_account { + struct exploration_account *next; + char *name, *path; + exploration_map *maps; + exploration_session *sessions; + unsigned count; + bool valid, dirty, error_reported; + time_t last_save; +} exploration_account; + +static exploration_account *accounts; + +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) { + for (exploration_map *map = account->maps; map != NULL; map = map->next) { + if (strcmp(map->path, path) == 0) { + return map; + } + } + return NULL; +} + +static exploration_account *find_account(socket_struct *ns) { + for (exploration_account *account = accounts; account != NULL; account = account->next) { + for (exploration_session *session = account->sessions; session != NULL; + session = session->next) { + if (session->socket == ns) { + return account; + } + } + } + return NULL; +} + +static void free_maps(exploration_account *account) { + while (account->maps != NULL) { + exploration_map *map = account->maps; + account->maps = map->next; + 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); + map->next = account->maps; + account->maps = map; + map->index = account->count++; + map->revision = 1; + 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); + 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 append_u16(StringBuffer *buffer, unsigned value) { + uint8_t data[2] = {value >> 8, value & 255}; + stringbuffer_append_string_len(buffer, (const char *)data, 2); +} + +static bool save_account(exploration_account *account) { + if (!account->valid || !account->dirty) { + return !account->dirty; + } + StringBuffer *buffer = stringbuffer_new(); + stringbuffer_append_string_len(buffer, EXPLORATION_MAGIC, 8); + size_t size = 8; + for (exploration_map *map = account->maps; map != NULL; map = map->next) { + size_t len = strlen(map->path), bytes = bitmap_size(map->width, map->height); + append_u16(buffer, len); + stringbuffer_append_string_len(buffer, map->path, len); + append_u16(buffer, map->width); + append_u16(buffer, map->height); + stringbuffer_append_string_len(buffer, (const char *)map->bits, bytes); + size += 6 + len + bytes; + } + char *data = stringbuffer_finish(buffer); + 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 void send_map(socket_struct *ns, const exploration_map *map) { + packet_struct *packet = packet_new(CLIENT_CMD_REGION_EXPLORATION, 0, 256); + packet_writer_write_uint8(packet, 1); + packet_writer_write_cstring(packet, map->path); + packet_writer_write_uint16(packet, map->width); + packet_writer_write_uint16(packet, map->height); + packet_writer_write_bytes(packet, map->bits, bitmap_size(map->width, map->height)); + socket_send_packet(ns, packet); +} + +void exploration_begin(socket_struct *ns) { + if (ns == NULL || ns->account == NULL) { + return; + } + exploration_end(ns); + exploration_account *account; + for (account = accounts; account != NULL; account = account->next) { + if (strcmp(account->name, ns->account) == 0) { + break; + } + } + 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); + } + account->next = accounts; + accounts = account; + } + exploration_session *session = xcalloc(1, sizeof(*session)); + session->socket = ns; + session->next = account->sessions; + account->sessions = session; + packet_struct *packet = packet_new(CLIENT_CMD_REGION_EXPLORATION, 1, 0); + packet_writer_write_uint8(packet, 0); + socket_send_packet(ns, packet); + exploration_flush(ns, false); +} + +bool exploration_mark(socket_struct *ns, const char *path, unsigned width, + unsigned height, unsigned x, unsigned y) { + exploration_account *account = find_account(ns); + if (account == NULL || !account->valid || !valid_path(path) || width == 0 || height == 0 || + width > EXPLORATION_DIM_MAX || height > EXPLORATION_DIM_MAX || x >= width || y >= height) { + return false; + } + exploration_map *map = find_map(account, path); + if (map == NULL) { + map = add_map(account, path, width, height); + if (map == NULL) { + return false; + } + } else if (map->width != width || map->height != height) { + /* Authored geometry changed. Old coordinates have no safe meaning. */ + free(map->bits); + map->bits = xcalloc(bitmap_size(width, height), 1); + map->width = width; + map->height = height; + } + size_t bit = (size_t)y * width + x; + uint8_t mask = 1U << (bit % 8); + if (map->bits[bit / 8] & mask) { + return false; + } + map->bits[bit / 8] |= mask; + map->revision++; + account->dirty = true; + return true; +} + +void exploration_flush(socket_struct *ns, bool force) { + exploration_account *account = find_account(ns); + if (account == NULL) { + return; + } + /* A full account can exceed the transport's 4 MiB queue. Stream bounded + * batches with gameplay headroom and retry unsent revisions next draw. */ + for (exploration_session *session = account->sessions; session != NULL; + session = session->next) { + unsigned sent = 0; + for (exploration_map *map = account->maps; map != NULL; map = map->next) { + if (session->seen[map->index] == map->revision) { + continue; + } + if (sent == 32 || session->socket->packet_queue_bytes > 1024U * 1024U || + session->socket->packet_queue_count > 1024U) { + break; + } + send_map(session->socket, map); + session->seen[map->index] = map->revision; + sent++; + } + } + time_t now = time(NULL); + if (account->dirty && (force || now < account->last_save || now - account->last_save >= 5)) { + save_account(account); + } +} + +void exploration_end(socket_struct *ns) { + exploration_account **link = &accounts; + while (*link != NULL) { + exploration_account *account = *link; + exploration_session **session_link = &account->sessions; + while (*session_link != NULL && (*session_link)->socket != ns) { + session_link = &(*session_link)->next; + } + if (*session_link == NULL) { + link = &account->next; + continue; + } + exploration_session *session = *session_link; + *session_link = session->next; + free(session); + save_account(account); + if (account->sessions == NULL && !account->dirty) { + *link = account->next; + free_maps(account); + free(account->name); + free(account->path); + free(account); + } + return; + } +} + +void exploration_shutdown(void) { + while (accounts != NULL) { + exploration_account *account = accounts; + accounts = account->next; + save_account(account); + while (account->sessions != NULL) { + exploration_session *session = account->sessions; + account->sessions = session->next; + free(session); + } + free_maps(account); + free(account->name); + free(account->path); + free(account); + } +} + +#ifdef ATRINIK_TESTING +bool exploration_visited(socket_struct *ns, const char *path, unsigned x, unsigned y) { + exploration_account *account = find_account(ns); + exploration_map *map = account != NULL ? find_map(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; +} +#endif diff --git a/server/src/socket/init.c b/server/src/socket/init.c index 757df9272..dc4e2efd2 100644 --- a/server/src/socket/init.c +++ b/server/src/socket/init.c @@ -32,6 +32,7 @@ #include #include #include +#include #include #include #include @@ -130,6 +131,7 @@ void free_all_newserver(void) { * The socket. */ static void free_newsocket_internal(socket_struct *ns, bool connected) { + exploration_end(ns); socket_assets_connection_clear(ns); if (connected) { socket_destroy(ns->sc); diff --git a/server/src/socket/request.c b/server/src/socket/request.c index b170e59b3..fa4e30fcc 100644 --- a/server/src/socket/request.c +++ b/server/src/socket/request.c @@ -45,6 +45,7 @@ #include #include #include +#include #include #include #include @@ -1711,6 +1712,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; } @@ -2613,6 +2619,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 connected = CONTR(pl)->map_update_cmd == MAP_UPDATE_CMD_CONNECTED; socket_send_packet(CONTR(pl)->cs, packet_header); for (uint16_t i = 0; i < continuation_packet_count; i++) { diff --git a/server/src/tests/unit/server/account.c b/server/src/tests/unit/server/account.c index 7fbce0863..d584d1fe0 100644 --- a/server/src/tests/unit/server/account.c +++ b/server/src/tests/unit/server/account.c @@ -15,6 +15,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -294,6 +297,150 @@ 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; +} + +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, 4); /* RESET + MAP, each framed. */ + 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_geometry_change) { + 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); + exploration_end(&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_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); + 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 + static Suite *suite(void) { Suite *s = suite_create("account"); TCase *tc_core = tcase_create("Core"); @@ -302,6 +449,11 @@ static Suite *suite(void) { suite_add_tcase(s, tc_core); tcase_set_timeout(tc_core, 30); 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_geometry_change); + 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_snapshot_batches); 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 8f39972fb..96df0d1ae 100644 --- a/server/src/tests/unit/server/request.c +++ b/server/src/tests/unit/server/request.c @@ -20,6 +20,9 @@ #include #include #include +#include +#include +#include static size_t queued_command_count(socket_struct *cs, uint8_t type) { size_t count = 0; @@ -1939,6 +1942,81 @@ 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"); @@ -1986,6 +2064,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_map_rgb_cache_tracks_hue_changes_and_neutral_reset); tcase_add_test(tc_core, test_map_exit_semantic_tracks_visible_layer_and_cache_changes); tcase_add_test(tc_core, From e78f41c771dae87510f1e061b837997e6b2c261d Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Fri, 2 Oct 2026 01:18:13 -0500 Subject: [PATCH 03/13] style: format account exploration changes --- client/src/client/commands.c | 6 +++--- client/src/client/region_map.c | 10 ++++------ client/src/tests/region_exploration.c | 12 ++++++++++-- server/src/include/exploration.h | 8 ++++++-- server/src/server/exploration.c | 21 ++++++++++++--------- server/src/tests/unit/server/account.c | 3 +-- server/src/tests/unit/server/request.c | 1 - 7 files changed, 36 insertions(+), 25 deletions(-) diff --git a/client/src/client/commands.c b/client/src/client/commands.c index 5e6888e53..487f054bd 100644 --- a/client/src/client/commands.c +++ b/client/src/client/commands.c @@ -1797,8 +1797,8 @@ void socket_command_region_exploration(uint8_t *data, size_t len, size_t pos) { } /* MAP snapshots add visited cells; a changed geometry requires a full * rebuild so coordinates from the previous dimensions cannot survive. */ - region_map_exploration_refresh_map(data[pos] == 1 && !geometry_changed - ? (const char *)data + pos + 1 : NULL, - data[pos] == 0); + region_map_exploration_refresh_map( + data[pos] == 1 && !geometry_changed ? (const char *)data + pos + 1 : NULL, + data[pos] == 0); packet_reader_skip(&reader, len - pos); } diff --git a/client/src/client/region_map.c b/client/src/client/region_map.c index 5d63aaa2b..90570b616 100644 --- a/client/src/client/region_map.c +++ b/client/src/client/region_map.c @@ -219,8 +219,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); - - } /** @@ -861,7 +859,8 @@ static void region_map_exploration_replay(region_map_t *region_map, const char * continue; } unsigned width, height; - const uint8_t *bits = region_exploration_find(region_map->def->maps[i].path, &width, &height); + const uint8_t *bits = + region_exploration_find(region_map->def->maps[i].path, &width, &height); if (bits == NULL) { continue; } @@ -874,7 +873,6 @@ static void region_map_exploration_replay(region_map_t *region_map, const char * } } } - } void region_map_fow_update(region_map_t *region_map) { @@ -1147,8 +1145,8 @@ bool region_map_exploration_test(void) { region_map_t *clone = region_map_clone(map); clone->fow_zoomed = SDL_CreateSurface(8, 8, SDL_PIXELFORMAT_RGBA32); 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; + 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); diff --git a/client/src/tests/region_exploration.c b/client/src/tests/region_exploration.c index 7e0336519..f986139ac 100644 --- a/client/src/tests/region_exploration.c +++ b/client/src/tests/region_exploration.c @@ -3,7 +3,13 @@ #include #include #include -#define CHECK(c) do { if (!(c)) { fprintf(stderr, "line %d: %s\n", __LINE__, #c); abort(); } } while (0) +#define CHECK(c) \ + do { \ + if (!(c)) { \ + fprintf(stderr, "line %d: %s\n", __LINE__, #c); \ + abort(); \ + } \ + } while (0) static size_t packet(uint8_t *out, const char *path, unsigned w, unsigned h) { out[0] = 1; @@ -45,7 +51,9 @@ int main(void) { len = packet(data, "/test", 257, 1); CHECK(!region_exploration_receive(data, len)); char path[258]; - memset(path, 'a', sizeof(path)); path[0] = '/'; path[256] = 0; + memset(path, 'a', sizeof(path)); + path[0] = '/'; + path[256] = 0; len = packet(data, path, 1, 1); CHECK(!region_exploration_receive(data, len)); path[255] = 0; diff --git a/server/src/include/exploration.h b/server/src/include/exploration.h index 9e6b438c6..8aa6ffa09 100644 --- a/server/src/include/exploration.h +++ b/server/src/include/exploration.h @@ -11,8 +11,12 @@ void exploration_begin(socket_struct *ns); void exploration_end(socket_struct *ns); void exploration_shutdown(void); -bool exploration_mark(socket_struct *ns, const char *path, unsigned width, - unsigned height, unsigned x, unsigned y); +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 bool exploration_visited(socket_struct *ns, const char *path, unsigned x, unsigned y); diff --git a/server/src/server/exploration.c b/server/src/server/exploration.c index ec81d0f31..ced8b0df4 100644 --- a/server/src/server/exploration.c +++ b/server/src/server/exploration.c @@ -48,8 +48,7 @@ static size_t bitmap_size(unsigned width, unsigned height) { } static bool valid_path(const char *path) { - if (path == NULL || path[0] != '/' || strlen(path) > EXPLORATION_PATH_MAX || - path[1] == '\0') { + if (path == NULL || path[0] != '/' || strlen(path) > EXPLORATION_PATH_MAX || path[1] == '\0') { return false; } /* Paths are identities, never filesystem operands. Exclude physical/private @@ -104,8 +103,8 @@ static void free_maps(exploration_account *account) { account->count = 0; } -static exploration_map *add_map(exploration_account *account, const char *path, - unsigned width, unsigned height) { +static exploration_map * +add_map(exploration_account *account, const char *path, unsigned width, unsigned height) { if (account->count == EXPLORATION_MAPS_MAX) { return NULL; } @@ -166,8 +165,7 @@ static bool load_account(exploration_account *account) { exploration_map *map = add_map(account, path, width, height); 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)) { + ((width * height) % 8 != 0 && (map->bits[size - 1] >> ((width * height) % 8)) != 0)) { ok = false; break; } @@ -244,7 +242,8 @@ void exploration_begin(socket_struct *ns) { free(base); account->valid = load_account(account); if (!account->valid) { - LOG(ERROR, "Invalid account exploration preserved without modification: %s", + LOG(ERROR, + "Invalid account exploration preserved without modification: %s", account->path); } account->next = accounts; @@ -260,8 +259,12 @@ void exploration_begin(socket_struct *ns) { exploration_flush(ns, false); } -bool exploration_mark(socket_struct *ns, const char *path, unsigned width, - unsigned height, unsigned x, unsigned y) { +bool exploration_mark(socket_struct *ns, + const char *path, + unsigned width, + unsigned height, + unsigned x, + unsigned y) { exploration_account *account = find_account(ns); if (account == NULL || !account->valid || !valid_path(path) || width == 0 || height == 0 || width > EXPLORATION_DIM_MAX || height > EXPLORATION_DIM_MAX || x >= width || y >= height) { diff --git a/server/src/tests/unit/server/account.c b/server/src/tests/unit/server/account.c index d584d1fe0..53290faa1 100644 --- a/server/src/tests/unit/server/account.c +++ b/server/src/tests/unit/server/account.c @@ -297,7 +297,6 @@ 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")); @@ -372,7 +371,7 @@ 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'}; + 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); diff --git a/server/src/tests/unit/server/request.c b/server/src/tests/unit/server/request.c index 96df0d1ae..2ee0e8549 100644 --- a/server/src/tests/unit/server/request.c +++ b/server/src/tests/unit/server/request.c @@ -1942,7 +1942,6 @@ 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; From ba57c75b705dfcb19f7050e24e0100d71ee41c2a Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Fri, 2 Oct 2026 01:20:26 -0500 Subject: [PATCH 04/13] fix(build): require current account exploration protocol --- client/CMakeLists.txt | 4 ++-- server/CMakeLists.txt | 4 ++-- tools/tests/test_version_metadata.py | 16 ++++++++++++++++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index f7ca01d17..34527f915 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -320,8 +320,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 " 1080U$") - message(FATAL_ERROR "Client requires classic game protocol v1080") +if (NOT ATRINIK_PROTOCOL_VERSION_LINE MATCHES " 1081U$") + message(FATAL_ERROR "Client requires classic game protocol v1081") endif () if (NOT TARGET Atrinik::Core) set(LIBATRINIK_PACKAGE_LAYOUT ON CACHE BOOL "" FORCE) diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt index ed96de1a6..ed2792d8a 100644 --- a/server/CMakeLists.txt +++ b/server/CMakeLists.txt @@ -175,8 +175,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 " 1080U$") - message(FATAL_ERROR "Server requires classic game protocol v1080") +if (NOT ATRINIK_PROTOCOL_VERSION_LINE MATCHES " 1081U$") + message(FATAL_ERROR "Server requires classic game protocol v1081") endif () if (NOT TARGET Atrinik::Core) set(LIBATRINIK_PACKAGE_LAYOUT ON CACHE BOOL "" FORCE) 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") From 842d940f35180678d18ad5ffcac2d3bfd0ed5c07 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Fri, 2 Oct 2026 01:24:37 -0500 Subject: [PATCH 05/13] fix(client): cast exploration tile array entries --- client/src/client/region_map.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/client/region_map.c b/client/src/client/region_map.c index 90570b616..02ddf87d0 100644 --- a/client/src/client/region_map.c +++ b/client/src/client/region_map.c @@ -774,7 +774,6 @@ static void region_map_fow_free(region_map_t *region_map) { utarray_free(region_map->fow->tiles); region_map->fow->tiles = NULL; } - } static void region_map_fow_reset(region_map_t *region_map) { @@ -1082,7 +1081,8 @@ void region_map_exploration_refresh_map(const char *path, bool reset) { if (reset) { if (map->fow->tiles != NULL) { for (unsigned i = 0; i < utarray_len(map->fow->tiles); i++) { - region_map_fow_tile_t *tile = utarray_eltptr(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); From 632ac7b054b43ce71a215695af37e1ec6c74bab5 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Fri, 2 Oct 2026 07:20:14 -0500 Subject: [PATCH 06/13] style: align exploration copyright headers --- client/src/client/region_exploration.c | 11 ++++++++++- client/src/include/region_exploration.h | 11 ++++++++++- client/src/tests/region_exploration.c | 11 ++++++++++- server/src/include/exploration.h | 14 +++++++++++--- server/src/server/exploration.c | 14 +++++++++++--- 5 files changed, 52 insertions(+), 9 deletions(-) diff --git a/client/src/client/region_exploration.c b/client/src/client/region_exploration.c index 37b44cb1f..2c869f0c8 100644 --- a/client/src/client/region_exploration.c +++ b/client/src/client/region_exploration.c @@ -1,4 +1,13 @@ -/* Copyright 2026 The Atrinik Project */ +/************************************************************************* + * 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 diff --git a/client/src/include/region_exploration.h b/client/src/include/region_exploration.h index f28addabf..696b958f0 100644 --- a/client/src/include/region_exploration.h +++ b/client/src/include/region_exploration.h @@ -1,4 +1,13 @@ -/* Copyright 2026 The Atrinik Project */ +/************************************************************************* + * 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 diff --git a/client/src/tests/region_exploration.c b/client/src/tests/region_exploration.c index f986139ac..1524b4d3c 100644 --- a/client/src/tests/region_exploration.c +++ b/client/src/tests/region_exploration.c @@ -1,4 +1,13 @@ -/* Copyright 2026 The Atrinik Project */ +/************************************************************************* + * 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 diff --git a/server/src/include/exploration.h b/server/src/include/exploration.h index 8aa6ffa09..6a07a121b 100644 --- a/server/src/include/exploration.h +++ b/server/src/include/exploration.h @@ -1,6 +1,14 @@ -/* Copyright 2026 The Atrinik Project - * SPDX-License-Identifier: GPL-2.0-or-later - */ +/************************************************************************* + * 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 diff --git a/server/src/server/exploration.c b/server/src/server/exploration.c index ced8b0df4..bd55f79a2 100644 --- a/server/src/server/exploration.c +++ b/server/src/server/exploration.c @@ -1,6 +1,14 @@ -/* Copyright 2026 The Atrinik Project - * SPDX-License-Identifier: GPL-2.0-or-later - */ +/************************************************************************* + * 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 From 8be31d113fe45c32a78588ea48a95487ce09b204 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Fri, 2 Oct 2026 10:07:35 -0500 Subject: [PATCH 07/13] feat(protocol): add exploration cache reconciliation requests --- client/CMakeLists.txt | 4 ++-- protocol/README.md | 11 +++++++---- .../c/include/atrinik/protocol/game_commands.def | 1 + .../c/include/atrinik/protocol/game_commands.h | 6 ++++-- protocol/generated/python/atrinik_protocol/game.py | 3 ++- protocol/schema/game-commands.json | 5 +++-- protocol/tests/test_generate.py | 4 ++-- server/CMakeLists.txt | 4 ++-- 8 files changed, 23 insertions(+), 15 deletions(-) diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index 34527f915..150d707a6 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -320,8 +320,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) diff --git a/protocol/README.md b/protocol/README.md index a1d027f8d..908fb2f9d 100644 --- a/protocol/README.md +++ b/protocol/README.md @@ -9,10 +9,13 @@ 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 adds server-to-client `REGION_EXPLORATION` (ID 29), an account -exploration reset or bounded per-map bitmap. See the normative -[exploration contract](../server/doc/REGION_EXPLORATION.md) for framing, bounds, -sequencing, persistence and migration. Exact-version login rejects older peers. +Protocol v1082 synchronizes account region-map exploration using map-path +bitfields. Server-to-client `REGION_EXPLORATION` (ID 29) carries account binding, +bitmaps and sparse bit deltas. Client-to-server `REGION_EXPLORATION` (ID 23) +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 snapshot-only v1081 format is superseded. Protocol v1080 adds timed celestial aggregate-light keyframes to MAP2. Its payload carries a bounded absolute game-time interval and next-endpoint samples; diff --git a/protocol/generated/c/include/atrinik/protocol/game_commands.def b/protocol/generated/c/include/atrinik/protocol/game_commands.def index b1a47b041..85ba15ee8 100644 --- a/protocol/generated/c/include/atrinik/protocol/game_commands.def +++ b/protocol/generated/c/include/atrinik/protocol/game_commands.def @@ -34,6 +34,7 @@ ATRINIK_CLIENT_TO_SERVER_COMMAND(19, COMBAT, "combat") ATRINIK_CLIENT_TO_SERVER_COMMAND(20, TALK, "talk") ATRINIK_CLIENT_TO_SERVER_COMMAND(21, MOVE, "move") ATRINIK_CLIENT_TO_SERVER_COMMAND(22, TARGET, "target") +ATRINIK_CLIENT_TO_SERVER_COMMAND(23, REGION_EXPLORATION, "region_exploration") ATRINIK_SERVER_TO_CLIENT_COMMAND(0, MAP, "map") ATRINIK_SERVER_TO_CLIENT_COMMAND(1, DRAWINFO, "drawinfo") diff --git a/protocol/generated/c/include/atrinik/protocol/game_commands.h b/protocol/generated/c/include/atrinik/protocol/game_commands.h index 207c5412a..7747aec59 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 @@ -45,7 +45,8 @@ typedef enum atrinik_client_to_server_command { SERVER_CMD_TALK = 20, SERVER_CMD_MOVE = 21, SERVER_CMD_TARGET = 22, - SERVER_CMD_NROF = 23 + SERVER_CMD_REGION_EXPLORATION = 23, + SERVER_CMD_NROF = 24 } atrinik_client_to_server_command_t; /* Stable diagnostic names for client-to-server commands. */ @@ -72,6 +73,7 @@ typedef enum atrinik_client_to_server_command { #define SERVER_CMD_NAME_TALK "talk" #define SERVER_CMD_NAME_MOVE "move" #define SERVER_CMD_NAME_TARGET "target" +#define SERVER_CMD_NAME_REGION_EXPLORATION "region_exploration" typedef enum atrinik_server_to_client_command { CLIENT_CMD_MAP = 0, diff --git a/protocol/generated/python/atrinik_protocol/game.py b/protocol/generated/python/atrinik_protocol/game.py index 25515d31a..cec40ae5f 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 @@ -47,6 +47,7 @@ class ClientToServerCommand(IntEnum): TALK = 20 MOVE = 21 TARGET = 22 + REGION_EXPLORATION = 23 class ServerToClientCommand(IntEnum): diff --git a/protocol/schema/game-commands.json b/protocol/schema/game-commands.json index c7f38db74..185342441 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": { @@ -37,7 +37,8 @@ {"id": 19, "symbol": "COMBAT", "wire_name": "combat"}, {"id": 20, "symbol": "TALK", "wire_name": "talk"}, {"id": 21, "symbol": "MOVE", "wire_name": "move"}, - {"id": 22, "symbol": "TARGET", "wire_name": "target"} + {"id": 22, "symbol": "TARGET", "wire_name": "target"}, + {"id": 23, "symbol": "REGION_EXPLORATION", "wire_name": "region_exploration"} ], "server_to_client": [ {"id": 0, "symbol": "MAP", "wire_name": "map"}, diff --git a/protocol/tests/test_generate.py b/protocol/tests/test_generate.py index 24995808b..59c2ce1dc 100644 --- a/protocol/tests/test_generate.py +++ b/protocol/tests/test_generate.py @@ -17,11 +17,11 @@ 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"]), 23) + self.assertEqual(len(schema["client_to_server"]), 24) self.assertEqual(len(schema["server_to_client"]), 30) def test_repository_outputs_are_current(self) -> None: diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt index ed2792d8a..1c8928823 100644 --- a/server/CMakeLists.txt +++ b/server/CMakeLists.txt @@ -175,8 +175,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) From 79abccf52ff0dab31f41f269f6d923324cf4b4e2 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Fri, 2 Oct 2026 10:18:58 -0500 Subject: [PATCH 08/13] perf(server): reconcile account exploration with queued bit deltas --- server/doc/REGION_EXPLORATION.md | 187 +++++++------ server/src/include/exploration.h | 9 +- server/src/include/newserver.h | 3 + server/src/include/server.h | 6 + server/src/server/exploration.c | 358 +++++++++++++++++-------- server/src/socket/command_handlers.def | 1 + server/src/tests/unit/server/account.c | 274 +++++++++++++++++-- 7 files changed, 626 insertions(+), 212 deletions(-) diff --git a/server/doc/REGION_EXPLORATION.md b/server/doc/REGION_EXPLORATION.md index 5a305c12b..a837070e2 100644 --- a/server/doc/REGION_EXPLORATION.md +++ b/server/doc/REGION_EXPLORATION.md @@ -1,11 +1,12 @@ # Account region-map exploration -The Classic server owns region-map discoveries for an authenticated account. -Characters on that account share the union, including simultaneous sessions. -A fresh client receives the same discoveries. This is separate from MAP2's -remembered geometry, current lighting and inventory region-map reveal items. +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 ownership +## 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 @@ -14,91 +15,115 @@ 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 the account cache on the single simulation thread. Sessions -share a store by authenticated account name; they never upload discoveries. -The last session normally saves and releases the store. Socket cleanup also -releases exceptional sessions. Failed writes retain dirty state for retry on -reconnect, and final server cleanup retries all remaining stores. - -Each map is keyed by canonical logical path, with row-major local coordinates. -Paths start with `/`, contain at most 255 bytes, and contain no empty, `.` or -`..` segment, backslash, colon or control character. Width and height are each -1–256; a store contains at most 4096 maps. Coordinates outside the map and -entries above the limit are rejected. A changed map size resets only that map's -bitmap because old coordinates no longer identify the same geometry. Changes -to region image layout do not invalidate logical map-local coordinates. +`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 existing -account file. Credential and character-roster rewrites cannot erase it. Missing -sidecars mean empty exploration. This is initialized runtime state: include it -in account backups and the complete mutable-datapath activation archive. +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 binary format is: +The existing binary format remains: - Eight ASCII magic/version bytes `AEXP0001`. -- Zero or more records until EOF: big-endian unsigned 16-bit path byte length, - exactly 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, least-significant-bit first within each byte. Unused bits - in the last byte must be zero. Duplicate paths are invalid. - -Readers bound the entire file to 36 MiB and the record count to 4096. Validation -is transactional: malformed/truncated/oversized data discards the staged store, -logs a diagnostic, and disables writes for that account session. The original -file is preserved for operator recovery rather than replaced with an empty one. - -Writes use the existing mode-0600 atomic file replacement API, including its -flush/fsync/rename behavior. Discovery writes are batched at most once per five -seconds; logout, socket teardown and orderly server shutdown force a save. -There is no write when nothing changed. A crash may lose the most recent -unsaved batch; failed saves keep dirty data in memory and log an error. - -## Protocol 1081 - -`REGION_EXPLORATION` is server-to-client command 29. The ordinary packet envelope -provides framing; its payload begins with an unsigned 8-bit operation: +- 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. 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 about 82 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` remains command 29. Its payload starts +with one unsigned 8-bit operation: | Operation | Remaining payload | | --- | --- | -| `0` RESET | Empty; clears the previous account's session cache and rendered FOW. | -| `1` MAP | NUL-terminated logical path (1–255 bytes), big-endian u16 width, big-endian u16 height, exactly `ceil(width * height / 8)` bitmap bytes. | - -The bounds and bit order match persistence. Unknown operations, truncated or -extra bytes, invalid dimensions/path/padding, and a 4097th distinct map are -rejected before mutating the client cache. MAP replaces the cached record for -its path. RESET starts every successful character session before snapshot -records, even when the account has no discoveries. Disconnect clears client -session state. Exact-version negotiation rejects incompatible older peers; -there is no client-upload command or compatibility persistence branch. - -Initial snapshots and later map revisions stream at most 32 records per map -draw, stopping when the socket queue exceeds 1 MiB or 1024 queued fragments. -Per-session revision tracking retries remaining records on later draws and -keeps simultaneous characters synchronized without exceeding the transport's -4 MiB/4096-fragment queue limits. Each map record is independently complete; -there is no unbounded staging transaction or implicit snapshot-complete marker. - -The client caches records independently of asynchronous region PNG/definition -loading, applies matching logical maps once assets are ready, and refreshes -open map/minimap clones. Normal MAP2 updates can still reveal currently visible -cells immediately. Server snapshots restore history after reconnect and region -transitions; client disks are not authoritative. +| `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 23 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. ## Migration and validation -Existing per-character `client-maps/*.tiles` files remain untouched but are no -longer loaded or written. Previously local exploration is not imported: it has -no server-verifiable account ownership or visibility provenance. Existing -accounts begin recording newly observed cells after the upgrade. No account, -character, content artifact or initialized runtime file requires manual edits. - -Server account tests cover shared sessions, another account, reload into fresh -session state, dimensions, corruption preservation, failed-save retry and -paced replay. The request suite drives normal `draw_client_map2()` through -visible, concealed, upper-level, private and non-region cases. Client parser and -region-map integration tests cover bounded transactional decoding and delayed -assets/reconnect rendering. Protocol generation tests bind both consumers to -version 1081 and command 29. Use the workspace Classic profile for integrated -server/client tests and the supervised isolated lifecycle for runtime checks. +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/include/exploration.h b/server/src/include/exploration.h index 6a07a121b..ee78594bb 100644 --- a/server/src/include/exploration.h +++ b/server/src/include/exploration.h @@ -15,7 +15,8 @@ #include /* Main simulation thread only. Store keys come from authenticated sockets; - * only draw_client_map2 may grant discoveries. No client upload API exists. */ + * only draw_client_map2 may grant discoveries. Client cache reconciliation never + * mutates the authoritative account bitfields. */ void exploration_begin(socket_struct *ns); void exploration_end(socket_struct *ns); void exploration_shutdown(void); @@ -27,6 +28,12 @@ bool exploration_mark(socket_struct *ns, 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 03b57a8ab..9725044f3 100644 --- a/server/src/include/newserver.h +++ b/server/src/include/newserver.h @@ -312,6 +312,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 4e3dfebbd..e911d8938 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/exploration.c b/server/src/server/exploration.c index bd55f79a2..8e1e341a2 100644 --- a/server/src/server/exploration.c +++ b/server/src/server/exploration.c @@ -18,39 +18,58 @@ #include #include -#define EXPLORATION_MAPS_MAX 4096U +#define EXPLORATION_MAPS_MAX 10000U #define EXPLORATION_DIM_MAX 256U #define EXPLORATION_PATH_MAX 255U -#define EXPLORATION_BYTES_MAX (36U * 1024U * 1024U) +#define EXPLORATION_BYTES_MAX (96U * 1024U * 1024U) #define EXPLORATION_MAGIC "AEXP0001" typedef struct exploration_map { - struct exploration_map *next; char *path; unsigned width, height; uint8_t *bits; - unsigned index; - uint64_t revision; + 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; + struct exploration_session *next, *prev; socket_struct *socket; - uint64_t seen[EXPLORATION_MAPS_MAX]; + exploration_account *account; + exploration_map *last_map; + exploration_pending *pending, *pending_index; } exploration_session; -typedef struct exploration_account { - struct exploration_account *next; +struct exploration_account { char *name, *path; exploration_map *maps; exploration_session *sessions; unsigned count; bool valid, dirty, error_reported; time_t last_save; -} exploration_account; + 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; } @@ -80,30 +99,16 @@ static bool valid_path(const char *path) { } static exploration_map *find_map(exploration_account *account, const char *path) { - for (exploration_map *map = account->maps; map != NULL; map = map->next) { - if (strcmp(map->path, path) == 0) { - return map; - } - } - return NULL; -} - -static exploration_account *find_account(socket_struct *ns) { - for (exploration_account *account = accounts; account != NULL; account = account->next) { - for (exploration_session *session = account->sessions; session != NULL; - session = session->next) { - if (session->socket == ns) { - return account; - } - } - } - return NULL; + exploration_map *map; + TEST_COUNT(map_lookups, 1); + HASH_FIND_STR(account->maps, path, map); + return map; } static void free_maps(exploration_account *account) { - while (account->maps != NULL) { - exploration_map *map = account->maps; - account->maps = map->next; + exploration_map *map, *next; + HASH_ITER(hh, account->maps, map, next) { + HASH_DEL(account->maps, map); free(map->path); free(map->bits); free(map); @@ -121,10 +126,8 @@ add_map(exploration_account *account, const char *path, unsigned width, unsigned map->width = width; map->height = height; map->bits = xcalloc(bitmap_size(width, height), 1); - map->next = account->maps; - account->maps = map; - map->index = account->count++; - map->revision = 1; + HASH_ADD_KEYPTR(hh, account->maps, map->path, strlen(map->path), map); + account->count++; return map; } @@ -171,6 +174,7 @@ static bool load_account(exploration_account *account) { 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)) { @@ -197,7 +201,7 @@ static bool save_account(exploration_account *account) { StringBuffer *buffer = stringbuffer_new(); stringbuffer_append_string_len(buffer, EXPLORATION_MAGIC, 8); size_t size = 8; - for (exploration_map *map = account->maps; map != NULL; map = map->next) { + 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); append_u16(buffer, len); stringbuffer_append_string_len(buffer, map->path, len); @@ -206,6 +210,8 @@ static bool save_account(exploration_account *account) { stringbuffer_append_string_len(buffer, (const char *)map->bits, bytes); size += 6 + len + bytes; } + TEST_COUNT(save_records, account->count); + TEST_COUNT(save_bytes, size); char *data = stringbuffer_finish(buffer); bool ok = path_write_atomic(account->path, data, size, 0600); free(data); @@ -220,13 +226,64 @@ static bool save_account(exploration_account *account) { return ok; } -static void send_map(socket_struct *ns, const exploration_map *map) { +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); - packet_writer_write_uint8(packet, 1); - packet_writer_write_cstring(packet, map->path); - packet_writer_write_uint16(packet, map->width); - packet_writer_write_uint16(packet, map->height); - packet_writer_write_bytes(packet, map->bits, bitmap_size(map->width, map->height)); + 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); } @@ -236,11 +293,8 @@ void exploration_begin(socket_struct *ns) { } exploration_end(ns); exploration_account *account; - for (account = accounts; account != NULL; account = account->next) { - if (strcmp(account->name, ns->account) == 0) { - break; - } - } + 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); @@ -254,17 +308,18 @@ void exploration_begin(socket_struct *ns) { "Invalid account exploration preserved without modification: %s", account->path); } - account->next = accounts; - accounts = account; + HASH_ADD_KEYPTR(hh, accounts, account->name, strlen(account->name), account); } exploration_session *session = xcalloc(1, sizeof(*session)); session->socket = ns; - session->next = account->sessions; - account->sessions = session; - packet_struct *packet = packet_new(CLIENT_CMD_REGION_EXPLORATION, 1, 0); + 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); - exploration_flush(ns, false); + /* The client requests only maps in its region, with any cached bits. */ } bool exploration_mark(socket_struct *ns, @@ -273,116 +328,189 @@ bool exploration_mark(socket_struct *ns, unsigned height, unsigned x, unsigned y) { - exploration_account *account = find_account(ns); - if (account == NULL || !account->valid || !valid_path(path) || width == 0 || height == 0 || - width > EXPLORATION_DIM_MAX || height > EXPLORATION_DIM_MAX || x >= width || y >= height) { + 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_map *map = find_map(account, path); + 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; } - } else if (map->width != width || map->height != height) { - /* Authored geometry changed. Old coordinates have no safe meaning. */ - free(map->bits); - map->bits = xcalloc(bitmap_size(width, height), 1); - map->width = width; - map->height = height; } - size_t bit = (size_t)y * width + x; + 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; - map->revision++; 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 exploration_flush(socket_struct *ns, bool force) { - exploration_account *account = find_account(ns); - if (account == NULL) { +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; } - /* A full account can exceed the transport's 4 MiB queue. Stream bounded - * batches with gameplay headroom and retry unsent revisions next draw. */ - for (exploration_session *session = account->sessions; session != NULL; - session = session->next) { - unsigned sent = 0; - for (exploration_map *map = account->maps; map != NULL; map = map->next) { - if (session->seen[map->index] == map->revision) { - continue; - } - if (sent == 32 || session->socket->packet_queue_bytes > 1024U * 1024U || - session->socket->packet_queue_count > 1024U) { - break; + 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; } - send_map(session->socket, map); - session->seen[map->index] = map->revision; - sent++; } + 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); +} + void exploration_end(socket_struct *ns) { - exploration_account **link = &accounts; - while (*link != NULL) { - exploration_account *account = *link; - exploration_session **session_link = &account->sessions; - while (*session_link != NULL && (*session_link)->socket != ns) { - session_link = &(*session_link)->next; - } - if (*session_link == NULL) { - link = &account->next; - continue; - } - exploration_session *session = *session_link; - *session_link = session->next; - free(session); - save_account(account); - if (account->sessions == NULL && !account->dirty) { - *link = account->next; - free_maps(account); - free(account->name); - free(account->path); - free(account); - } + exploration_session *session = ns != NULL ? ns->exploration : NULL; + if (session == NULL) { return; } + exploration_account *account = session->account; + free_session(session); + save_account(account); + if (account->sessions == NULL && !account->dirty) { + free_account(account); + } } void exploration_shutdown(void) { while (accounts != NULL) { exploration_account *account = accounts; - accounts = account->next; save_account(account); while (account->sessions != NULL) { - exploration_session *session = account->sessions; - account->sessions = session->next; - free(session); + free_session(account->sessions); } - free_maps(account); - free(account->name); - free(account->path); - free(account); + free_account(account); } } #ifdef ATRINIK_TESTING bool exploration_visited(socket_struct *ns, const char *path, unsigned x, unsigned y) { - exploration_account *account = find_account(ns); - exploration_map *map = account != NULL ? find_map(account, path) : NULL; + 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/socket/command_handlers.def b/server/src/socket/command_handlers.def index 0fb8af540..7ee732db9 100644 --- a/server/src/socket/command_handlers.def +++ b/server/src/socket/command_handlers.def @@ -23,3 +23,4 @@ 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) diff --git a/server/src/tests/unit/server/account.c b/server/src/tests/unit/server/account.c index 53290faa1..9eb6fdebb 100644 --- a/server/src/tests/unit/server/account.c +++ b/server/src/tests/unit/server/account.c @@ -21,6 +21,7 @@ #include #include #include +#include START_TEST(test_account_provision) { const char *account_name = "scenarioaccount"; @@ -112,9 +113,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 @@ -149,10 +148,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, @@ -164,10 +162,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); @@ -306,6 +303,32 @@ static char *exploration_test_path(const char *name) { 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); @@ -334,7 +357,11 @@ START_TEST(test_exploration_account_round_trip) { 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, 4); /* RESET + MAP, each framed. */ + 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 @@ -347,7 +374,7 @@ START_TEST(test_exploration_account_round_trip) { } END_TEST -START_TEST(test_exploration_bounds_and_geometry_change) { +START_TEST(test_exploration_bounds_and_stable_layout) { char *path = exploration_test_path("explorebounds"); unlink(path); socket_struct ns = {.state = ST_PLAYING, .account = "explorebounds"}; @@ -360,7 +387,7 @@ START_TEST(test_exploration_bounds_and_geometry_change) { 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)); + ck_assert(exploration_visited(&ns, "/world", 255, 255)); exploration_end(&ns); socket_buffer_clear(&ns); unlink(path); @@ -428,6 +455,7 @@ START_TEST(test_exploration_snapshot_batches) { 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); @@ -440,6 +468,218 @@ START_TEST(test_exploration_snapshot_batches) { } 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 + static Suite *suite(void) { Suite *s = suite_create("account"); TCase *tc_core = tcase_create("Core"); @@ -449,10 +689,14 @@ static Suite *suite(void) { tcase_set_timeout(tc_core, 30); 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_geometry_change); + 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_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); From 2ea783dafa0538ae424039d42ddece1cff967b32 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Fri, 2 Oct 2026 10:18:37 -0500 Subject: [PATCH 09/13] feat(client): cache account exploration and request compact updates --- client/AGENTS.md | 10 +- client/CMakeLists.txt | 4 +- client/src/client/commands.c | 22 +- client/src/client/main.c | 1 + client/src/client/region_exploration.c | 411 ++++++++++++++++++++---- client/src/client/region_map.c | 49 ++- client/src/client/socket.c | 37 ++- client/src/include/client_socket.h | 2 + client/src/include/region_exploration.h | 17 +- client/src/include/region_map.h | 1 + client/src/tests/region_exploration.c | 172 ++++++++-- 11 files changed, 599 insertions(+), 127 deletions(-) diff --git a/client/AGENTS.md b/client/AGENTS.md index b0e3e9ac7..fc8c79d49 100644 --- a/client/AGENTS.md +++ b/client/AGENTS.md @@ -34,9 +34,13 @@ - 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 is a bounded server-owned account snapshot cache, - reset on session RESET and disconnect. Replay it after region assets load; - character `.tiles` files are historical and must never be read or written. +- 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. + RESET selects the account and rebuilds views; disconnect hides cached bits and + flushes optional private disk storage. Never import character `.tiles` files or + persist client-renderer visibility in the account cache. - Client user data lives below `.atrinik/.x/`. When that stable directory is first created, the client may migrate the highest valid same-major legacy directory; the migration is collision-safe and marker-backed, leaves other diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index 150d707a6..4eafdbcdf 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -762,7 +762,9 @@ if (BUILD_TESTING) 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) - add_test(NAME client-region-exploration COMMAND client-region-exploration-tests) + 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-compound-packet-tests src/tests/compound_packet.c diff --git a/client/src/client/commands.c b/client/src/client/commands.c index 487f054bd..3a41e2180 100644 --- a/client/src/client/commands.c +++ b/client/src/client/commands.c @@ -1779,26 +1779,14 @@ void socket_command_control(uint8_t *data, size_t len, size_t pos) { 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); - unsigned old_width = 0, old_height = 0; - bool geometry_changed = false; - /* Inspect the old snapshot only after validating a bounded path extent. */ - if (pos < len && data[pos] == 1 && len - pos > 1 && - memchr(data + pos + 1, 0, len - pos - 1 < 256 ? len - pos - 1 : 256) != NULL) { - region_exploration_find((const char *)data + pos + 1, &old_width, &old_height); - } - if (pos > len || !region_exploration_receive(data + pos, 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 (data[pos] == 1 && old_width != 0) { - unsigned width, height; - region_exploration_find((const char *)data + pos + 1, &width, &height); - geometry_changed = width != old_width || height != old_height; + if (changed) { + region_map_exploration_refresh_map(data[pos] == 0 ? NULL : (const char *)data + pos + 1, + data[pos] == 0); } - /* MAP snapshots add visited cells; a changed geometry requires a full - * rebuild so coordinates from the previous dimensions cannot survive. */ - region_map_exploration_refresh_map( - data[pos] == 1 && !geometry_changed ? (const char *)data + pos + 1 : NULL, - data[pos] == 0); packet_reader_skip(&reader, len - pos); } diff --git a/client/src/client/main.c b/client/src/client/main.c index 4a04f2c02..e79acbeff 100644 --- a/client/src/client/main.c +++ b/client/src/client/main.c @@ -1157,6 +1157,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 index 2c869f0c8..0553c786b 100644 --- a/client/src/client/region_exploration.c +++ b/client/src/client/region_exploration.c @@ -9,104 +9,407 @@ * (at your option) any later version. * ************************************************************************/ #include +#include +#include +#include +#include #include #include +#define EXPLORATION_MAP_LIMIT 10000U +#define EXPLORATION_PACKET_MAX (1U + 256U + 4U + 8192U) + typedef struct exploration_record { char path[256]; unsigned width, height; uint8_t *bits; + UT_hash_handle hh; } exploration_record_t; -static exploration_record_t records[4096]; -static size_t records_num; +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 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) { + 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) { - for (size_t i = 0; i < records_num; i++) { - free(records[i].bits); + 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); + } } - memset(records, 0, sizeof(records)); - records_num = 0; +} +static bool account_select(const uint8_t *data, size_t len) { + /* 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; + requests_clear(); + if (strcmp(cache_key, key) == 0) { + active = true; + 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[80]; + 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) { - for (size_t i = 0; i < records_num; i++) { - if (strcmp(records[i].path, path) == 0) { - *width = records[i].width; - *height = records[i].height; - return records[i].bits; - } + exploration_record_t *entry; + if (!active) { + return NULL; + } + HASH_FIND_STR(records, path, entry); + if (entry == NULL) { + return NULL; } - return NULL; + *width = entry->width; + *height = entry->height; + return entry->bits; } -bool region_exploration_receive(const uint8_t *data, size_t len) { +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) { - if (len != 1) { - return false; + bool ok = account_select(data, len); + if (changed != NULL) { + *changed = ok; } - region_exploration_clear(); - return true; + return ok; } - if (data[0] != 1 || len < 8) { + 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 || end == data + 1 || data[1] != '/') { + if (end == NULL || !logical_path((const char *)data + 1)) { return false; } - const uint8_t *segment = data + 2; - for (const uint8_t *p = segment; p <= end; p++) { - if (p != end && (*p < 32 || *p == 127 || *p == '\\' || *p == ':')) { - return false; - } - if (p == end || *p == '/') { - size_t n = (size_t)(p - segment); - if (n == 0 || (n == 1 && segment[0] == '.') || - (n == 2 && segment[0] == '.' && segment[1] == '.')) { - return false; - } - segment = p + 1; - } - } 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 = ((unsigned)data[offset] << 8) | data[offset + 1]; - unsigned height = ((unsigned)data[offset + 2] << 8) | data[offset + 3]; + 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; - if (len - offset - 4 != bytes) { - return false; - } unsigned tail = (width * height) % 8; - if (tail != 0 && (data[len - 1] & (uint8_t)(0xffU << tail)) != 0) { + 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) { + 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); + 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; + } + dirty |= any; + if (changed != NULL) { + *changed = any; + } + return true; +} + +bool region_exploration_request(const char *path) { + if (!active || !logical_path(path)) { return false; } - size_t index = 0; - while (index < records_num && strcmp(records[index].path, (const char *)data + 1) != 0) { - index++; + exploration_request_t *entry; + HASH_FIND_STR(requests, path, entry); + if (entry != NULL) { + return true; } - if (index == sizeof(records) / sizeof(records[0])) { + if (HASH_COUNT(requests) >= EXPLORATION_MAP_LIMIT) { return false; } - uint8_t *bits = malloc(bytes); - if (bits == NULL) { + entry = calloc(1, sizeof(*entry)); + if (entry == NULL) { return false; } - memcpy(bits, data + offset + 4, bytes); - free(records[index].bits); - memcpy(records[index].path, data + 1, offset - 1); - records[index].width = width; - records[index].height = height; - records[index].bits = bits; - if (index == records_num) { - records_num++; + 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 02ddf87d0..bab29bed8 100644 --- a/client/src/client/region_map.c +++ b/client/src/client/region_map.c @@ -44,6 +44,8 @@ #include #include #include +#include +#include #include #include #include @@ -753,6 +755,9 @@ static void region_map_fow_create(region_map_t *region_map) { HARD_ASSERT(region_map->fow != NULL); HARD_ASSERT(region_map->fow->bitmap == NULL); 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); } @@ -1079,6 +1084,9 @@ void region_map_exploration_refresh_map(const char *path, bool reset) { 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 = @@ -1088,8 +1096,7 @@ void region_map_exploration_refresh_map(const char *path, bool reset) { utarray_clear(map->fow->tiles); } } - /* A snapshot can replace dimensions or clear old geometry. Rebuild - * from the authoritative session cache before accepting new live bits. */ + /* Account changes rebuild views; deltas touch only the changed path. */ if (map->fow->bitmap != NULL) { if (path == NULL) { memset(map->fow->bitmap, 0, RM_MAP_FOW_BITMAP_SIZE(map)); @@ -1109,16 +1116,38 @@ void region_map_exploration_refresh(bool reset) { } void region_map_exploration_clear(void) { - region_exploration_clear(); + 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_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_map_exploration_clear(); + 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(); - bool ok = region_exploration_receive(packet, sizeof(packet)); + 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)); @@ -1133,15 +1162,17 @@ bool region_map_exploration_test(void) { return false; } region_map_fow_create(map); + 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 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)); + ok &= region_exploration_receive(replacement, sizeof(replacement), NULL); region_map_exploration_refresh(false); - ok &= region_map_fow_is_visited(map, 2, 1) && !region_map_fow_is_visited(map, 4, 2); + 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); clone->fow_zoomed = SDL_CreateSurface(8, 8, SDL_PIXELFORMAT_RGBA32); region_map_exploration_clear(); @@ -1151,7 +1182,11 @@ bool region_map_exploration_test(void) { region_map_fow_reset(map); region_map_fow_create(map); ok &= !region_map_fow_is_visited(map, 2, 1); + ok &= region_exploration_receive(reset, sizeof(reset), NULL); + region_map_exploration_refresh(true); + ok &= region_map_fow_is_visited(map, 4, 2); 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 94149064f..30cf0e943 100644 --- a/client/src/client/socket.c +++ b/client/src/client/socket.c @@ -31,6 +31,10 @@ #include #include #include +#include +#include +#include +#include #include #include #include @@ -59,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. @@ -88,6 +93,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) { @@ -100,27 +106,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); @@ -129,12 +143,16 @@ 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); + output_queue_bytes += bytes; 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); } static bool socket_thread_aborted(void) { @@ -511,6 +529,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/include/client_socket.h b/client/src/include/client_socket.h index bd52225a8..df639f681 100644 --- a/client/src/include/client_socket.h +++ b/client/src/include/client_socket.h @@ -39,6 +39,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); extern void socket_thread_start(void); diff --git a/client/src/include/region_exploration.h b/client/src/include/region_exploration.h index 696b958f0..8bb7d78ef 100644 --- a/client/src/include/region_exploration.h +++ b/client/src/include/region_exploration.h @@ -14,9 +14,20 @@ #include #include -/* Main-thread session cache. Returned snapshots are borrowed until the next - * successful packet or clear. Invalid packets leave all snapshots unchanged. */ -bool region_exploration_receive(const uint8_t *data, size_t len); +/* 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); +/* 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 2ce17823b..9636b05e6 100644 --- a/client/src/include/region_map.h +++ b/client/src/include/region_map.h @@ -268,6 +268,7 @@ 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 bool region_map_exploration_test(void); #endif diff --git a/client/src/tests/region_exploration.c b/client/src/tests/region_exploration.c index 1524b4d3c..0d5eb39aa 100644 --- a/client/src/tests/region_exploration.c +++ b/client/src/tests/region_exploration.c @@ -9,6 +9,8 @@ * (at your option) any later version. * ************************************************************************/ #include +#include +#include #include #include #include @@ -19,82 +21,182 @@ 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++] = w >> 8; - out[pos++] = w; - out[pos++] = h >> 8; - out[pos++] = h; + 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; } -int main(void) { +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); +} +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] = 0x02; - data[len - 1] = 0x01; - CHECK(region_exploration_receive(data, len)); + 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(!region_exploration_receive(data, n)); - CHECK(region_exploration_find("/test", &w, &h) == bits); + CHECK(!receive(data, n)); + CHECK(region_exploration_find("/test", &w, &h) == bits && bits[0] == 0x81); } data[len] = 0; - CHECK(!region_exploration_receive(data, len + 1)); - data[0] = 2; - CHECK(!region_exploration_receive(data, len)); - data[0] = 0; - CHECK(!region_exploration_receive(data, 2)); - CHECK(region_exploration_find("/test", &w, &h) == bits); - CHECK(region_exploration_receive(data, 1)); + 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")); + 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_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); + select_account(NULL); + 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(receive(account_b, sizeof(account_b))); CHECK(!region_exploration_find("/test", &w, &h)); + CHECK(!receive((const uint8_t *)"\0a\0x", 4)); len = packet(data, "/test", 0, 1); - CHECK(!region_exploration_receive(data, len)); + CHECK(!receive(data, len)); len = packet(data, "/test", 257, 1); - CHECK(!region_exploration_receive(data, len)); + CHECK(!receive(data, len)); char path[258]; memset(path, 'a', sizeof(path)); path[0] = '/'; path[256] = 0; len = packet(data, path, 1, 1); - CHECK(!region_exploration_receive(data, len)); + CHECK(!receive(data, len)); path[255] = 0; len = packet(data, path, 256, 256); data[len - 1] = 0x80; - CHECK(region_exploration_receive(data, len)); + CHECK(receive(data, len)); bits = region_exploration_find(path, &w, &h); CHECK(bits && w == 256 && h == 256 && bits[8191] == 0x80); - region_exploration_clear(); 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(!region_exploration_receive(data, len)); + CHECK(!receive(data, len)); } len = packet(data, "/padding", 1, 1); data[len - 1] = 2; - CHECK(!region_exploration_receive(data, len)); - for (unsigned i = 0; i < 4096; i++) { + 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, 1, 1); - CHECK(region_exploration_receive(data, len)); + 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(!region_exploration_receive(data, len)); - len = packet(data, "/map/0", 2, 2); - data[len - 1] = 8; - CHECK(region_exploration_receive(data, len)); - bits = region_exploration_find("/map/0", &w, &h); - CHECK(bits && w == 2 && h == 2 && *bits == 8); + 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 == 1 && h == 1 && bits[0] == 1); + } + region_exploration_connect("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + argv[1]); + CHECK(receive(reset, sizeof(reset)) && !region_exploration_find("/map/0", &w, &h)); region_exploration_clear(); - CHECK(!region_exploration_find("/map/0", &w, &h)); - CHECK(region_exploration_receive(data, len)); + 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); return 0; } From e20583cdd9e7c24476c446e2c090e6d8f05bab15 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Fri, 2 Oct 2026 10:22:22 -0500 Subject: [PATCH 10/13] fix: validate exploration command policy and cache filenames --- client/src/client/region_exploration.c | 2 +- server/src/tests/unit/server/request.c | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/client/src/client/region_exploration.c b/client/src/client/region_exploration.c index 0553c786b..73acab4e2 100644 --- a/client/src/client/region_exploration.c +++ b/client/src/client/region_exploration.c @@ -233,7 +233,7 @@ static bool account_select(const uint8_t *data, size_t len) { dirty = false; memcpy(cache_key, key, sizeof(key)); if (cache_directory != NULL) { - char name[80]; + char name[sizeof("exploration-.bin") + 64]; snprintf(name, sizeof(name), "exploration-%s.bin", key); cache_path = path_join(cache_directory, name); } diff --git a/server/src/tests/unit/server/request.c b/server/src/tests/unit/server/request.c index 2ee0e8549..da53c922d 100644 --- a/server/src/tests/unit/server/request.c +++ b/server/src/tests/unit/server/request.c @@ -564,6 +564,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), }; #undef COMMAND_POLICY From c612b82f4362108eae38dd98c925f8d26b3391f6 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Fri, 2 Oct 2026 10:27:45 -0500 Subject: [PATCH 11/13] perf(server): allocate exploration save buffers once --- server/src/server/exploration.c | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/server/src/server/exploration.c b/server/src/server/exploration.c index 8e1e341a2..c6ed0ba2c 100644 --- a/server/src/server/exploration.c +++ b/server/src/server/exploration.c @@ -16,7 +16,6 @@ #include #include #include -#include #define EXPLORATION_MAPS_MAX 10000U #define EXPLORATION_DIM_MAX 256U @@ -189,30 +188,38 @@ static bool load_account(exploration_account *account) { return ok; } -static void append_u16(StringBuffer *buffer, unsigned value) { - uint8_t data[2] = {value >> 8, value & 255}; - stringbuffer_append_string_len(buffer, (const char *)data, 2); +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; } - StringBuffer *buffer = stringbuffer_new(); - stringbuffer_append_string_len(buffer, EXPLORATION_MAGIC, 8); + /* 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); - append_u16(buffer, len); - stringbuffer_append_string_len(buffer, map->path, len); - append_u16(buffer, map->width); - append_u16(buffer, map->height); - stringbuffer_append_string_len(buffer, (const char *)map->bits, bytes); - size += 6 + len + bytes; + 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); - char *data = stringbuffer_finish(buffer); bool ok = path_write_atomic(account->path, data, size, 0600); free(data); if (ok) { From e2ba0c31eb162756645f011d8cb135baff4b23ec Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Fri, 2 Oct 2026 18:22:49 -0500 Subject: [PATCH 12/13] fix(client): bound account exploration replay work --- client/AGENTS.md | 8 +- client/src/client/region_exploration.c | 65 ++++++++- client/src/client/region_map.c | 168 +++++++++++++++++++----- client/src/include/region_exploration.h | 10 ++ client/src/include/region_map.h | 4 +- client/src/tests/region_exploration.c | 86 +++++++++++- server/doc/REGION_EXPLORATION.md | 25 +++- 7 files changed, 319 insertions(+), 47 deletions(-) diff --git a/client/AGENTS.md b/client/AGENTS.md index fc8c79d49..8c5f0fcdb 100644 --- a/client/AGENTS.md +++ b/client/AGENTS.md @@ -38,8 +38,12 @@ 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. - RESET selects the account and rebuilds views; disconnect hides cached bits and - flushes optional private disk storage. Never import character `.tiles` files or + 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. - Client user data lives below `.atrinik/.x/`. When that stable directory is first created, the client may migrate the highest valid same-major legacy diff --git a/client/src/client/region_exploration.c b/client/src/client/region_exploration.c index 73acab4e2..fa1e3607c 100644 --- a/client/src/client/region_exploration.c +++ b/client/src/client/region_exploration.c @@ -17,12 +17,14 @@ #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 { @@ -35,6 +37,8 @@ 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; @@ -45,6 +49,8 @@ static void requests_clear(void) { 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); @@ -197,7 +203,7 @@ void region_exploration_connect(const char *certificate, const char *directory) } } } -static bool account_select(const uint8_t *data, size_t len) { +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; @@ -221,9 +227,18 @@ static bool account_select(const uint8_t *data, size_t len) { 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(); @@ -264,11 +279,7 @@ bool region_exploration_receive(const uint8_t *data, size_t len, bool *changed) return false; } if (data[0] == 0) { - bool ok = account_select(data, len); - if (changed != NULL) { - *changed = ok; - } - return ok; + return account_select(data, len, changed); } if (!active || data[0] > 3 || len < 4) { return false; @@ -318,7 +329,8 @@ bool region_exploration_receive(const uint8_t *data, size_t len, bool *changed) exploration_record_t *entry; HASH_FIND_STR(records, (const char *)data + 1, entry); if (entry == NULL) { - if (HASH_COUNT(records) >= EXPLORATION_MAP_LIMIT) { + if (HASH_COUNT(records) >= EXPLORATION_MAP_LIMIT || + bytes > EXPLORATION_BITMAP_LIMIT - bitmap_bytes) { return false; } entry = calloc(1, sizeof(*entry)); @@ -334,6 +346,7 @@ bool region_exploration_receive(const uint8_t *data, size_t len, bool *changed) 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 @@ -354,6 +367,9 @@ bool region_exploration_receive(const uint8_t *data, size_t len, bool *changed) any |= (entry->bits[index] | mask) != entry->bits[index]; entry->bits[index] |= mask; } + if (any) { + entry->revision = ++revision; + } dirty |= any; if (changed != NULL) { *changed = any; @@ -361,6 +377,41 @@ bool region_exploration_receive(const uint8_t *data, size_t len, bool *changed) 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; diff --git a/client/src/client/region_map.c b/client/src/client/region_map.c index bab29bed8..f6eb8b18e 100644 --- a/client/src/client/region_map.c +++ b/client/src/client/region_map.c @@ -53,7 +53,7 @@ #include static UT_icd icd = {sizeof(region_map_fow_tile_t), NULL, NULL, NULL}; -static region_map_t *region_maps; +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); @@ -124,6 +124,9 @@ void region_map_free(region_map_t *region_map) { entry = &(*entry)->next; } *entry = region_map->next; + if (exploration_next == region_map) { + exploration_next = region_map->next; + } free(region_map); } @@ -797,7 +800,8 @@ static void region_map_fow_reset(region_map_t *region_map) { free(region_map->fow->bitmap); region_map->fow->bitmap = NULL; - region_map->fow->exploration_loaded = false; + 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) { @@ -857,26 +861,73 @@ static bool region_map_fow_update_regions(region_map_t *region_map, const uint32 return ret; } -static void region_map_exploration_replay(region_map_t *region_map, const char *path) { - for (size_t i = 0; i < region_map->def->num_maps; i++) { - if (path != NULL && strcmp(path, region_map->def->maps[i].path) != 0) { +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; } - unsigned width, height; - const uint8_t *bits = - region_exploration_find(region_map->def->maps[i].path, &width, &height); - if (bits == NULL) { - 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; } - for (unsigned y = 0; y < height; y++) { - for (unsigned x = 0; x < width; x++) { - size_t bit = (size_t)y * width + x; - if (bits[bit / 8] & (1U << (bit % 8))) { - region_map_fow_set_visited(region_map, ®ion_map->def->maps[i], NULL, x, y); + 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) { @@ -893,11 +944,6 @@ void region_map_fow_update(region_map_t *region_map) { return; } - if (!region_map->fow->exploration_loaded) { - region_map_exploration_replay(region_map, NULL); - region_map->fow->exploration_loaded = true; - } - if (region_map->fow->tiles != NULL) { region_map_fow_tile_t *tile; @@ -1071,6 +1117,20 @@ 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; } @@ -1081,6 +1141,10 @@ SDL_Surface *region_map_fow_surface(region_map_t *region_map) { /* 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) { @@ -1096,18 +1160,12 @@ void region_map_exploration_refresh_map(const char *path, bool reset) { utarray_clear(map->fow->tiles); } } - /* Account changes rebuild views; deltas touch only the changed path. */ if (map->fow->bitmap != NULL) { - if (path == NULL) { - memset(map->fow->bitmap, 0, RM_MAP_FOW_BITMAP_SIZE(map)); - map->fow->exploration_loaded = false; - } else { - region_map_exploration_replay(map, path); - } - } - if (path == NULL || region_map_find_map(map, path) != NULL) { - region_map_fow_update(map); + 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); } } @@ -1128,6 +1186,7 @@ static bool region_map_exploration_send(const uint8_t *data, size_t len, void *u } void region_map_exploration_service(void) { + region_map_exploration_replay_service(); region_exploration_service(region_map_exploration_send, NULL); } @@ -1162,29 +1221,74 @@ bool region_map_exploration_test(void) { 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 is reopened with no network replay. */ + 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); - clone->fow_zoomed = SDL_CreateSurface(8, 8, SDL_PIXELFORMAT_RGBA32); + 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; diff --git a/client/src/include/region_exploration.h b/client/src/include/region_exploration.h index 8bb7d78ef..211cb0d9a 100644 --- a/client/src/include/region_exploration.h +++ b/client/src/include/region_exploration.h @@ -23,6 +23,16 @@ void region_exploration_connect(const char *certificate, const char *cache_direc 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. */ diff --git a/client/src/include/region_map.h b/client/src/include/region_map.h index 9636b05e6..99ff8bbfd 100644 --- a/client/src/include/region_map.h +++ b/client/src/include/region_map.h @@ -172,7 +172,9 @@ typedef struct region_map_fow { SDL_Surface *surface; uint32_t *bitmap; - bool exploration_loaded; ///< Session cache has been applied after asset loading. + 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; diff --git a/client/src/tests/region_exploration.c b/client/src/tests/region_exploration.c index 0d5eb39aa..4a00f88fc 100644 --- a/client/src/tests/region_exploration.c +++ b/client/src/tests/region_exploration.c @@ -75,6 +75,74 @@ static char *cache_file(const char *directory) { 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); @@ -119,23 +187,32 @@ int main(int argc, char **argv) { 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); - select_account(NULL); + 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(receive(account_b, sizeof(account_b))); + 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); @@ -167,7 +244,7 @@ int main(int argc, char **argv) { sent = 0; for (unsigned i = 0; i < 10000; i++) { snprintf(path, sizeof(path), "/map/%u", i); - len = packet(data, path, 1, 1); + len = packet(data, path, 24, 24); data[len - 1] = 1; CHECK(receive(data, len)); CHECK(region_exploration_request(path)); @@ -183,7 +260,7 @@ int main(int argc, char **argv) { for (unsigned i = 0; i < 10000; i++) { snprintf(path, sizeof(path), "/map/%u", i); bits = region_exploration_find(path, &w, &h); - CHECK(bits && w == 1 && h == 1 && bits[0] == 1); + CHECK(bits && w == 24 && h == 24 && bits[71] == 1); } region_exploration_connect("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", argv[1]); @@ -198,5 +275,6 @@ int main(int argc, char **argv) { region_exploration_clear(); CHECK(remove(filename) == 0); free(filename); + replay_limits(); return 0; } diff --git a/server/doc/REGION_EXPLORATION.md b/server/doc/REGION_EXPLORATION.md index a837070e2..a7b2b0816 100644 --- a/server/doc/REGION_EXPLORATION.md +++ b/server/doc/REGION_EXPLORATION.md @@ -67,7 +67,7 @@ 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 about 82 MiB of bitmaps plus +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. @@ -111,6 +111,29 @@ 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 From 56e3631ec9eda1adb35f047a4fae72a480b5c03b Mon Sep 17 00:00:00 2001 From: Zoey Rose <3865595+zoeyrose@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:15:59 -0500 Subject: [PATCH 13/13] fix(ci): make Codecov processing non-blocking Keep builds, tests, GPU coverage execution and TLS verification required. Treat external Codecov report-processing failures as non-blocking, as approved. Validation: 44 workflow contract tests, actionlint, import history, whitespace, and independent review passed. --- .github/workflows/check.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 582b10ee3..6d8ebd648 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -991,6 +991,7 @@ jobs: flags: client-gpu use_oidc: true - name: Wait for complete client coverage processing + continue-on-error: true env: CODECOV_REPORT_API: >- https://api.codecov.io/api/v2/github/${{ github.repository_owner }}/repos/${{ github.event.repository.name }}/totals/