From 872ca136dee9393a53d61a58f3c383d9b0fe35a4 Mon Sep 17 00:00:00 2001 From: Huitzilopochtli-98 <349095218+Huitzilopochtli-98@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:45:31 -0500 Subject: [PATCH 1/4] Add native world-map zoom rendering backend --- docs/about/Authors.rst | 1 + docs/changelog.txt | 1 + docs/plugins/worldmap-zoom-render.rst | 105 ++++ plugins/CMakeLists.txt | 12 + plugins/lua/worldmap-zoom-render.lua | 13 + plugins/worldmap-zoom-canvas.h | 250 +++++++++ plugins/worldmap-zoom-canvas.test.cpp | 279 ++++++++++ plugins/worldmap-zoom-render.cpp | 703 ++++++++++++++++++++++++++ test/plugins/worldmap-zoom-render.lua | 55 ++ 9 files changed, 1419 insertions(+) create mode 100644 docs/plugins/worldmap-zoom-render.rst create mode 100644 plugins/lua/worldmap-zoom-render.lua create mode 100644 plugins/worldmap-zoom-canvas.h create mode 100644 plugins/worldmap-zoom-canvas.test.cpp create mode 100644 plugins/worldmap-zoom-render.cpp create mode 100644 test/plugins/worldmap-zoom-render.lua diff --git a/docs/about/Authors.rst b/docs/about/Authors.rst index eefd440758..aa079058e6 100644 --- a/docs/about/Authors.rst +++ b/docs/about/Authors.rst @@ -88,6 +88,7 @@ Harlan Playford playfordh Hayati Ayguen hayguen Herwig Hochleitner bendlas Hevlikn Hevlikn +Huitzilopochtli-98 Huitzilopochtli-98 Ian S kremlin- IndigoFenix Jacek Konieczny Jajcus diff --git a/docs/changelog.txt b/docs/changelog.txt index dd171e8031..3388d9205c 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -55,6 +55,7 @@ Template for new versions: # Future ## New Tools +- `worldmap-zoom-render`: add a Windows SDL2 native composition service for 2x and 4x high-level world-map zoom, preserving interface scale ## New Features - `autofarm`: farm plots can now be individually excluded from autofarm management via new controls on the farm plot information sheet, which also warn when a farm's crops are being managed by autofarm diff --git a/docs/plugins/worldmap-zoom-render.rst b/docs/plugins/worldmap-zoom-render.rst new file mode 100644 index 0000000000..4fc3929500 --- /dev/null +++ b/docs/plugins/worldmap-zoom-render.rst @@ -0,0 +1,105 @@ +worldmap-zoom-render +==================== + +.. dfhack-tool:: + :summary: Provides native composition for high-level world-map zoom. + :tags: dev + :no-command: + +This Windows-only plugin provides the rendering backend for 2x and 4x world-map +zoom. It draws the native geographic layers into an SDL2 target texture, then +composites a cropped portion beneath the interface. Text and controls retain +their normal size. Supported contexts are the high-level embark Biome map and +the normal fortress world map. Other map modes are left alone. + +This is a Lua service, with no command or automatic activation. A client must +manage the camera, inverse mouse mapping, and controls. The external +`World Map Zoom project `__ +contains a reference client and validation evidence. Its released 1.0 client +has its own exact-version checks and needs adaptation for development builds; +this plugin does not replace those checks or install that client. + +The plugin is built with DFHack's version metadata and generated structures. +Before accessing renderer fields, it checks that the running DFHack's source +and structure revisions match those linked into the plugin. A rebuild therefore +uses the new build's metadata instead of a hardcoded release hash. This does not +establish compatibility with changed renderer layouts or replace runtime testing. + +Lua API +------- + +Load the service with:: + + local renderer = require('plugins.worldmap-zoom-render') + +``renderer.prepare()`` + Verify the build, resolve the required SDL2 functions, and queue hook + installation on the graphics thread. Returns ``false`` while installation + is pending and ``true`` once ready. Call again on a later frame when it + returns ``false``. Unsupported builds/renderers raise a Lua error. Check + ``status().error`` if installation does not complete. + +``renderer.setCamera(enabled, context, zoom, x, y)`` + Set the camera for the active map. Context is ``1`` for embark or ``2`` for + fortress; ``0`` is allowed when disabled. Zoom must be ``1``, ``2``, or ``4``. + Coordinates are finite offsets from the top-left corner of the native map, + in 16-pixel map cells. The visible crop is clamped to the native map bounds. + The service must be ready and the context must match the current screen + when enabling. Call ``setCamera(false, 0, 1, 0, 0)`` when the client closes. + +``renderer.status()`` + Return a table with readiness and diagnostic information. ``ready`` reports + hook installation, ``error`` contains a rendering failure, and ``context`` + identifies the current supported native screen (or ``0``). ``window_focused`` + and ``focus_epoch`` let a client cancel gestures after focus loss, even if + focus was regained between updates. Output/desktop dimensions and counters + for canvas creation, composition, and SDL-state restoration are also included. + +``renderer.snapshotUi()`` + Snapshot occupied native text/interface cells after the parent screen renders + and before drawing client controls. Use a neutral mouse position so temporary + hover labels do not become clickable interface regions. Refresh after native + interface changes or resize. + +``renderer.uiHit(x, y)`` + Return whether a text-grid cell belongs to the captured native interface. + Returns ``nil`` if the snapshot is missing or its dimensions are stale. + Out-of-bounds cells return ``true``. Clients must block map actions when the + result is ``nil`` and account for their own controls separately. + +Diagnostic helpers +------------------ + +These functions require a prepared renderer. They are intended for manual +validation, not normal client interaction: + +``renderer.capture(path)`` + Queue a PPM screenshot of the next rendered frame at a UTF-8 filesystem path. + Read ``status().last_capture`` and ``status().capture_error`` for completion. + +``renderer.profile(start)`` and ``renderer.frameProfile()`` + Start/reset bounded timing collection when ``start`` is true; return the map + draw samples or complete frame samples, respectively, in microseconds. + +``renderer.testWindow(width, height, fullscreen)`` + Queue a native window resize for testing. Fullscreen must be false; use DF's + settings for fullscreen tests. + +``renderer.testFocus(focused)`` + Queue an SDL focus event to test client gesture cancellation. + +The original external client's snake_case function names remain as aliases for +the corresponding camelCase functions. + +Lifecycle and platform limitations +---------------------------------- + +Canvas creation, composition, SDL state restoration, resize invalidation, and +texture release run on the graphics thread. World/map unload disables the +camera. Plugin shutdown detaches hooks and removes the SDL event watch there. + +The Windows module is pinned in memory until DF exits because queued graphics +callbacks and an already-fetched virtual call can outlive a logical unload. +Close DF before replacing the DLL. Linux is not built by this change: an +equivalent callback/module lifetime strategy and native validation are needed +before enabling that platform. diff --git a/plugins/CMakeLists.txt b/plugins/CMakeLists.txt index f8a75436bd..f0179b3d56 100644 --- a/plugins/CMakeLists.txt +++ b/plugins/CMakeLists.txt @@ -137,6 +137,18 @@ if(BUILD_SUPPORTED) add_subdirectory(tweak) #dfhack_plugin(workflow workflow.cpp LINK_LIBRARIES lua) dfhack_plugin(work-now work-now.cpp) + # The renderer callback lifetime is currently protected with a Windows + # module pin. Other platforms need an equivalent lifetime strategy first. + if(WIN32) + dfhack_plugin(worldmap-zoom-render worldmap-zoom-render.cpp worldmap-zoom-canvas.h LINK_LIBRARIES lua) + if(BUILD_LIBRARY AND BUILD_PLUGINS) + target_include_directories(worldmap-zoom-render PRIVATE ${SDL2_INCLUDE_DIRS}) + endif() + endif() + if(BUILD_TESTING) + add_executable(worldmap-zoom-canvas-test worldmap-zoom-canvas.test.cpp) + add_test(NAME worldmap-zoom-canvas-test COMMAND worldmap-zoom-canvas-test) + endif() dfhack_plugin(xlsxreader xlsxreader.cpp LINK_LIBRARIES lua xlsxio_read_STATIC zip expat) dfhack_plugin(zone zone.cpp LINK_LIBRARIES lua) endif(BUILD_SUPPORTED) diff --git a/plugins/lua/worldmap-zoom-render.lua b/plugins/lua/worldmap-zoom-render.lua new file mode 100644 index 0000000000..5fb3a815a0 --- /dev/null +++ b/plugins/lua/worldmap-zoom-render.lua @@ -0,0 +1,13 @@ +-- Native world-map composition service. See docs/plugins/worldmap-zoom-render.rst. +local _ENV = mkmodule('plugins.worldmap-zoom-render') + +-- Compatibility with the external World Map Zoom client API. New clients +-- should use the camelCase names documented in DFHack. +set_camera = setCamera +snapshot_ui = snapshotUi +ui_hit = uiHit +test_window = testWindow +frame_profile = frameProfile +test_focus = testFocus + +return _ENV diff --git a/plugins/worldmap-zoom-canvas.h b/plugins/worldmap-zoom-canvas.h new file mode 100644 index 0000000000..c3ad4dac92 --- /dev/null +++ b/plugins/worldmap-zoom-canvas.h @@ -0,0 +1,250 @@ +/* +Zlib License + +Copyright (c) 2026 World Map Zoom contributors + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. + +Adapted for the DFHack build from World Map Zoom 1.0.0. +*/ + +// Included after the verified SDL bindings and state in worldmap-zoom-render.cpp. +// All canvas operations run on the native graphics thread. +struct MapCanvas { + SDL_Renderer *owner = nullptr; + void *texture = nullptr; + unsigned window = 0; + int width = 0, height = 0; + bool active = false, valid = false; + uint64_t camera_epoch = 0, device_epoch = 0; + Camera view{}; + SDL_Rect map{}, source{}, viewport{}, clip{}; + void *previous_target = nullptr; + float sx = 1, sy = 1; + bool clipped = false; + unsigned char color[4]{}; + df::renderer_2d_base *native_renderer = nullptr; + df::graphic_map_portst *native_port = nullptr; + std::vector painted; + int columns = 0, x0 = 0, x1 = 0, y0 = 0, y1 = 0, painted_count = 0, required = 0; + + void error(const char *message) { + std::lock_guard lock(state_mutex); + camera.enabled = false; + last_error = message; + } + bool restore() { + bool ok = sdl.set_target(owner, previous_target) == 0; + ok = (sdl.set_scale(owner, sx, sy) == 0) && ok; + ok = (sdl.set_viewport(owner, &viewport) == 0) && ok; + ok = (sdl.set_clip(owner, clipped ? &clip : nullptr) == 0) && ok; + ok = (sdl.set_color(owner, color[0], color[1], color[2], color[3]) == 0) && ok; + float x, y; + SDL_Rect v{}, c{}; + unsigned char rgba[4]{}; + sdl.get_scale(owner, &x, &y); + sdl.get_viewport(owner, &v); + sdl.get_clip(owner, &c); + sdl.get_color(owner, &rgba[0], &rgba[1], &rgba[2], &rgba[3]); + ok = ok && sdl.get_target(owner) == previous_target && x == sx && y == sy && v.x == viewport.x && + v.y == viewport.y && v.w == viewport.w && v.h == viewport.h && + (sdl.clip_enabled(owner) != 0) == clipped && + (!clipped || (c.x == clip.x && c.y == clip.y && c.w == clip.w && c.h == clip.h)) && + std::equal(color, color + 4, rgba); + ++restore_checks; + if (!ok) { + ++restore_errors; + error("SDL canvas state restoration failed; zoom disabled"); + } + return ok; + } + void finish(bool composite = true) { + if (!active) + return; + Camera live; + { + std::lock_guard lock(state_mutex); + live = camera; + } + // A camera change can arrive as individual tile updates. Let that + // batch fill the visible crop before falling back to a full native + // draw, avoiding a redundant full draw at the start of every drag. + if (composite && !valid && live.enabled && live.context == view.context && + current_context() == view.context) { + sdl.set_color(owner, 0, 0, 0, 255); + sdl.clear(owner); + sdl.set_color(owner, color[0], color[1], color[2], color[3]); + ++transform_depth; + native_renderer->update_full_map_port(native_port); + --transform_depth; + valid = true; + } + active = false; + if (!restore()) + return; + if (composite && valid && live.enabled && live.context == view.context && + current_context() == view.context) { + if (sdl.copy(owner, texture, &source, &map) != 0) + error("SDL map composition failed; zoom disabled"); + else { + ++canvas_composites; + composited_generation = camera_epoch; + } + } + } + void release(df::renderer_2d_base *r) { + // SDL_DestroyRenderer frees its textures. Never dereference a texture + // belonging to a renderer/window that has already been replaced. + bool same = texture && r && owner == reinterpret_cast(r->sdl_renderer) && + window == sdl.window_id(reinterpret_cast(r->window)); + if (same) { + finish(false); + sdl.destroy_texture(texture); + } else + active = false; + texture = nullptr; + owner = nullptr; + valid = false; + width = height = 0; + } + bool begin(df::renderer_2d_base *r, df::graphic_map_portst *port, const Camera &c, bool full) { + auto sr = reinterpret_cast(r->sdl_renderer); + auto wid = sdl.window_id(reinterpret_cast(r->window)); + int w = r->origin_x + port->top_left_corner_x + port->dim_x * 16; + int h = r->origin_y + port->top_left_corner_y + port->dim_y * 16; + if (w < 1 || h < 1 || w > 16384 || h > 16384) { + error("Invalid map canvas dimensions"); + return false; + } + if (texture && (owner != sr || window != wid || width != w || height != h || + device_epoch != target_generation.load())) + release(r); + if (!texture) { + SDL_RendererInfo info{}; + if (sdl.renderer_info(sr, &info) != 0 || !(info.flags & 8) || + (info.max_texture_width > 0 && w > info.max_texture_width) || + (info.max_texture_height > 0 && h > info.max_texture_height)) { + error("Renderer cannot create the required map target texture"); + return false; + } + { + std::lock_guard lock(state_mutex); + renderer_name = info.name ? info.name : "unknown"; + } + texture = sdl.create_texture(sr, 0x16362004, 2, w, h); // ARGB8888 / TARGET + if (!texture) { + error("Could not create map target texture"); + return false; + } + owner = sr; + window = wid; + width = w; + height = h; + valid = false; + ++canvas_creations; + if (sdl.texture_blend(texture, 0) != 0 || sdl.texture_scale(texture, 0) != 0) { + release(r); + error("Could not configure map target texture"); + return false; + } + } + auto cg = camera_generation.load(), dg = target_generation.load(); + if (active && (camera_epoch != cg || device_epoch != dg)) + finish(false); + bool invalidated = camera_epoch != cg || device_epoch != dg || !valid; + if (camera_epoch != cg || device_epoch != dg) + valid = false; + camera_epoch = cg; + device_epoch = dg; + view = c; + map = {r->origin_x + port->top_left_corner_x, r->origin_y + port->top_left_corner_y, port->dim_x * 16, + port->dim_y * 16}; + source = { + map.x + static_cast(std::round(std::clamp(c.x, 0.0, port->dim_x * (1 - 1 / c.zoom)) * 16)), + map.y + static_cast(std::round(std::clamp(c.y, 0.0, port->dim_y * (1 - 1 / c.zoom)) * 16)), + map.w / static_cast(c.zoom), map.h / static_cast(c.zoom)}; + native_renderer = r; + native_port = port; + bool started = !active; + if (started && invalidated) { + columns = port->dim_x; + painted.assign(static_cast(columns) * port->dim_y, 0); + painted_count = 0; + x0 = (source.x - map.x) / 16; + x1 = (source.x - map.x + source.w - 1) / 16; + y0 = (source.y - map.y) / 16; + y1 = (source.y - map.y + source.h - 1) / 16; + required = (x1 - x0 + 1) * (y1 - y0 + 1); + } + if (!active) { + previous_target = sdl.get_target(sr); + sdl.get_scale(sr, &sx, &sy); + sdl.get_viewport(sr, &viewport); + sdl.get_clip(sr, &clip); + clipped = sdl.clip_enabled(sr) != 0; + sdl.get_color(sr, &color[0], &color[1], &color[2], &color[3]); + active = true; + if (sdl.set_target(sr, texture) != 0 || sdl.set_scale(sr, 1, 1) != 0 || + sdl.set_viewport(sr, nullptr) != 0 || sdl.set_clip(sr, nullptr) != 0) { + finish(false); + error("SDL rejected the map canvas; zoom disabled"); + return false; + } + } + if (full || (!valid && started)) { + if (sdl.set_color(sr, 0, 0, 0, 255) != 0 || sdl.clear(sr) != 0 || + sdl.set_color(sr, color[0], color[1], color[2], color[3]) != 0) { + finish(false); + error("Could not clear map canvas; zoom disabled"); + return false; + } + } + if (sdl.set_clip(sr, &source) != 0) { + finish(false); + error("SDL rejected map canvas clipping"); + return false; + } + return true; + } + void clear_tile(int x, int y) { + SDL_Rect cell{map.x + x * 16, map.y + y * 16, 16, 16}; + if (sdl.set_color(owner, 0, 0, 0, 255) != 0 || sdl.fill(owner, &cell) != 0 || + sdl.set_color(owner, color[0], color[1], color[2], color[3]) != 0) + error("Could not clear map tile; zoom disabled"); + } + void painted_tile(int x, int y) { + if (valid || x < x0 || x > x1 || y < y0 || y > y1) + return; + auto index = static_cast(y) * columns + x; + if (!painted[index]) { + painted[index] = 1; + if (++painted_count == required) + valid = true; + } + } +}; +static MapCanvas map_canvas; +static bool canvas_camera(df::renderer_2d_base *r, df::graphic_map_portst *p, Camera &c) { + if (transform_depth || !hooks_ready || !p || p != gps->main_map_port || r != enabler->renderer || + !p->flag.bits.active || p->dim_x < 1 || p->dim_y < 1) + return false; + { + std::lock_guard lock(state_mutex); + c = camera; + } + return c.enabled && c.zoom > 1 && c.context == current_context(); +} diff --git a/plugins/worldmap-zoom-canvas.test.cpp b/plugins/worldmap-zoom-canvas.test.cpp new file mode 100644 index 0000000000..8f9e0c9fa8 --- /dev/null +++ b/plugins/worldmap-zoom-canvas.test.cpp @@ -0,0 +1,279 @@ +// SPDX-License-Identifier: Zlib +// Exercise the production canvas state machine without a game or GPU. +#ifdef NDEBUG +#undef NDEBUG +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +struct SDL_Rect { + int x, y, w, h; +}; +struct SDL_Window {}; +struct SDL_Renderer {}; +struct SDL_RendererInfo { + const char *name; + unsigned flags; + int max_texture_width, max_texture_height; +}; +namespace df { +struct graphic_map_portst { + int top_left_corner_x = 16, top_left_corner_y = 32; + int dim_x = 32, dim_y = 24; + struct { + struct { + bool active = true; + } bits; + } flag; +}; +struct renderer_2d_base { + int origin_x = 0, origin_y = 0; + SDL_Renderer *sdl_renderer = nullptr; + SDL_Window *window = nullptr; + int full_draws = 0; + void update_full_map_port(graphic_map_portst *) { + ++full_draws; + } +}; +} // namespace df +struct Camera { + bool enabled = true; + int context = 1; + double zoom = 2, x = 4, y = 3; +}; +static std::mutex state_mutex; +static Camera camera; +static std::string last_error, renderer_name; +static std::atomic_uint64_t camera_generation{1}, target_generation{1}; +static std::atomic_uint64_t canvas_creations{0}, canvas_composites{0}, composited_generation{0}; +static std::atomic_uint64_t restore_checks{0}, restore_errors{0}; +static bool hooks_ready = true; +static thread_local int transform_depth = 0; +static int context = 1; +static int current_context() { + return context; +} +static struct { + df::graphic_map_portst *main_map_port; +} graphics; +static auto gps = &graphics; +static struct { + df::renderer_2d_base *renderer; +} engine; +static auto enabler = &engine; + +struct MockState { + void *target = nullptr; + SDL_Rect viewport{3, 5, 900, 700}, clip{7, 9, 600, 400}; + float sx = 1.5f, sy = 2.f; + bool clipped = true, fail_target = false, fail_copy = false; + std::array color{11, 22, 33, 44}; + SDL_Rect copied_source{}, copied_destination{}; + unsigned window_id = 1; + int clears = 0, copies = 0, destroys = 0, textures = 0; +} mock; +static int texture; + +struct MockSdl { + unsigned window_id(SDL_Window *) { + return mock.window_id; + } + int renderer_info(SDL_Renderer *, SDL_RendererInfo *info) { + *info = {"mock", 8, 16384, 16384}; + return 0; + } + void *create_texture(SDL_Renderer *, unsigned, int, int, int) { + ++mock.textures; + return &texture; + } + void destroy_texture(void *) { + ++mock.destroys; + } + int texture_blend(void *, int) { + return 0; + } + int texture_scale(void *, int) { + return 0; + } + void *get_target(SDL_Renderer *) { + return mock.target; + } + int set_target(SDL_Renderer *, void *target) { + if (mock.fail_target) + return -1; + mock.target = target; + return 0; + } + void get_scale(SDL_Renderer *, float *x, float *y) { + *x = mock.sx; + *y = mock.sy; + } + int set_scale(SDL_Renderer *, float x, float y) { + mock.sx = x; + mock.sy = y; + return 0; + } + void get_viewport(SDL_Renderer *, SDL_Rect *rect) { + *rect = mock.viewport; + } + int set_viewport(SDL_Renderer *, const SDL_Rect *rect) { + mock.viewport = rect ? *rect : SDL_Rect{0, 0, 1024, 768}; + return 0; + } + void get_clip(SDL_Renderer *, SDL_Rect *rect) { + *rect = mock.clip; + } + int set_clip(SDL_Renderer *, const SDL_Rect *rect) { + mock.clipped = rect != nullptr; + mock.clip = rect ? *rect : SDL_Rect{}; + return 0; + } + int clip_enabled(SDL_Renderer *) { + return mock.clipped; + } + int get_color(SDL_Renderer *, unsigned char *r, unsigned char *g, unsigned char *b, unsigned char *a) { + *r = mock.color[0]; + *g = mock.color[1]; + *b = mock.color[2]; + *a = mock.color[3]; + return 0; + } + int set_color(SDL_Renderer *, unsigned char r, unsigned char g, unsigned char b, unsigned char a) { + mock.color = {r, g, b, a}; + return 0; + } + int clear(SDL_Renderer *) { + ++mock.clears; + return 0; + } + int fill(SDL_Renderer *, const SDL_Rect *) { + return 0; + } + int copy(SDL_Renderer *, void *, const SDL_Rect *source, const SDL_Rect *dest) { + if (mock.fail_copy) + return -1; + ++mock.copies; + mock.copied_source = *source; + mock.copied_destination = *dest; + return 0; + } +} sdl; + +#include "worldmap-zoom-canvas.h" + +static SDL_Renderer renderer; +static SDL_Window window; +static df::renderer_2d_base native; +static df::graphic_map_portst port; + +static void reset() { + mock = {}; + camera = {}; + context = 1; + ++camera_generation; + ++target_generation; + last_error.clear(); + native = {}; + native.sdl_renderer = &renderer; + native.window = &window; + gps->main_map_port = &port; + enabler->renderer = &native; +} + +static bool equal(const SDL_Rect &a, const SDL_Rect &b) { + return a.x == b.x && a.y == b.y && a.w == b.w && a.h == b.h; +} + +static void crop_and_restore(double zoom) { + reset(); + camera.zoom = zoom; + const auto before = mock; + MapCanvas canvas; + assert(canvas.begin(&native, &port, camera, true)); + assert(mock.target == &texture); + assert(mock.viewport.x == 0 && mock.viewport.y == 0); + assert(mock.sx == 1 && mock.sy == 1); + canvas.valid = true; + canvas.finish(); + assert(mock.copies == 1); + assert(mock.copied_source.x == 16 + 4 * 16 && mock.copied_source.y == 32 + 3 * 16); + assert(mock.copied_source.w == 512 / zoom && mock.copied_source.h == 384 / zoom); + assert(equal(mock.copied_destination, SDL_Rect{16, 32, 512, 384})); + assert(mock.target == before.target && mock.sx == before.sx && mock.sy == before.sy); + assert(equal(mock.viewport, before.viewport) && equal(mock.clip, before.clip)); + assert(mock.clipped == before.clipped && mock.color == before.color); + canvas.release(&native); + assert(mock.destroys == 1); +} + +int main() { + crop_and_restore(2); + crop_and_restore(4); + + // Partial tile batches must finish with a full native draw before copying. + reset(); + MapCanvas partial; + assert(partial.begin(&native, &port, camera, false)); + partial.painted_tile(partial.x0, partial.y0); + partial.finish(); + assert(native.full_draws == 1 && mock.copies == 1); + partial.release(&native); + + // A complete tile batch needs no redundant full redraw. + reset(); + MapCanvas complete; + assert(complete.begin(&native, &port, camera, false)); + for (int y = complete.y0; y <= complete.y1; ++y) + for (int x = complete.x0; x <= complete.x1; ++x) + complete.painted_tile(x, y); + complete.finish(); + assert(native.full_draws == 0 && mock.copies == 1); + ++target_generation; + assert(complete.begin(&native, &port, camera, true)); + assert(mock.textures == 2 && mock.destroys == 1); + complete.release(&native); + + // Leaving the supported screen must never composite stale geographic data. + reset(); + MapCanvas stale; + assert(stale.begin(&native, &port, camera, false)); + context = 0; + stale.finish(); + assert(mock.copies == 0 && native.full_draws == 0 && !stale.active); + stale.release(&native); + + // SDL failures disable zoom instead of leaving altered graphics state active. + reset(); + MapCanvas failed; + mock.fail_target = true; + assert(!failed.begin(&native, &port, camera, true)); + assert(!camera.enabled && !last_error.empty() && !failed.active && mock.copies == 0); + mock.fail_target = false; + failed.release(&native); + + reset(); + MapCanvas copy_failed; + assert(copy_failed.begin(&native, &port, camera, true)); + copy_failed.valid = true; + mock.fail_copy = true; + copy_failed.finish(); + assert(!camera.enabled && !last_error.empty() && !copy_failed.active); + copy_failed.release(&native); + + reset(); + Camera selected; + assert(canvas_camera(&native, &port, selected)); + camera.zoom = 1; + assert(!canvas_camera(&native, &port, selected)); + std::cout << "PASS: crop at 2x/4x, state restoration, partial/complete tile batches, " + "device reset, screen changes, SDL failure recovery, and map gating\n"; +} diff --git a/plugins/worldmap-zoom-render.cpp b/plugins/worldmap-zoom-render.cpp new file mode 100644 index 0000000000..f2c978b327 --- /dev/null +++ b/plugins/worldmap-zoom-render.cpp @@ -0,0 +1,703 @@ +/* +Zlib License + +Copyright (c) 2026 World Map Zoom contributors + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. + +Adapted for the DFHack build from World Map Zoom 1.0.0. +*/ + +// Map-only native composition transform, built with DFHack's matching headers. +#define NO_DFHACK_VERSION_MACROS + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "DFHackVersion.h" +#include "PluginManager.h" +#include "VTableInterpose.h" +#include "modules/DFSDL.h" +#include "modules/Gui.h" + +#include "df/enabler.h" +#include "df/graphic.h" +#include "df/graphic_map_portst.h" +#include "df/renderer_2d.h" +#include "df/viewscreen_choose_start_sitest.h" +#include "df/viewscreen_worldst.h" + +#include "lauxlib.h" +#include "lua.h" + +#define WIN32_LEAN_AND_MEAN +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include + +using namespace DFHack; +DFHACK_PLUGIN("worldmap-zoom-render"); +REQUIRE_GLOBAL(enabler); +REQUIRE_GLOBAL(gps); + +// Resolve SDL functions from DFHack's SDL handle; no separate SDL runtime. +struct SdlApi { + void (*get_scale)(SDL_Renderer *, float *, float *) = nullptr; + int (*set_scale)(SDL_Renderer *, float, float) = nullptr; + void (*get_viewport)(SDL_Renderer *, SDL_Rect *) = nullptr; + int (*set_viewport)(SDL_Renderer *, const SDL_Rect *) = nullptr; + void (*get_clip)(SDL_Renderer *, SDL_Rect *) = nullptr; + int (*set_clip)(SDL_Renderer *, const SDL_Rect *) = nullptr; + int (*clip_enabled)(SDL_Renderer *) = nullptr; + int (*read_pixels)(SDL_Renderer *, const SDL_Rect *, unsigned, void *, int) = nullptr; + int (*output_size)(SDL_Renderer *, int *, int *) = nullptr; + void (*window_size)(SDL_Window *, int, int) = nullptr; + int (*fullscreen)(SDL_Window *, unsigned) = nullptr; + int (*display_index)(SDL_Window *) = nullptr; + int (*desktop_mode)(int, SDL_DisplayMode *) = nullptr; + unsigned (*window_id)(SDL_Window *) = nullptr; + SDL_Window *(*keyboard_focus)() = nullptr; + void (*add_event_watch)(int (*)(void *, SDL_Event *), void *) = nullptr; + void (*del_event_watch)(int (*)(void *, SDL_Event *), void *) = nullptr; + int (*renderer_info)(SDL_Renderer *, SDL_RendererInfo *) = nullptr; + void *(*get_target)(SDL_Renderer *) = nullptr; + int (*set_target)(SDL_Renderer *, void *) = nullptr; + void *(*create_texture)(SDL_Renderer *, unsigned, int, int, int) = nullptr; + void (*destroy_texture)(void *) = nullptr; + int (*texture_blend)(void *, int) = nullptr; + int (*texture_scale)(void *, int) = nullptr; + int (*get_color)(SDL_Renderer *, unsigned char *, unsigned char *, unsigned char *, + unsigned char *) = nullptr; + int (*set_color)(SDL_Renderer *, unsigned char, unsigned char, unsigned char, unsigned char) = nullptr; + int (*clear)(SDL_Renderer *) = nullptr; + int (*fill)(SDL_Renderer *, const SDL_Rect *) = nullptr; + int (*copy)(SDL_Renderer *, void *, const SDL_Rect *, const SDL_Rect *) = nullptr; +} sdl; + +struct Camera { + bool enabled = false; + int context = 0; + double zoom = 1, x = 0, y = 0; +}; +static std::mutex state_mutex; +static Camera camera; +static std::string capture_path, last_capture, last_error, capture_error; +static std::atomic_bool hooks_ready{false}; +static std::atomic_bool build_verified{false}, prepare_pending{false}; +static std::atomic_uint64_t full_calls{0}, tile_calls{0}, transformed_calls{0}; +static std::atomic_uint32_t render_thread{0}; +static std::atomic_uint64_t restore_checks{0}, restore_errors{0}; +static std::atomic_bool profiling{false}; +static std::vector profile_us; +static std::vector frame_profile_us; +static std::chrono::steady_clock::time_point frame_start; +static bool frame_started = false; +static void start_frame() { + if (profiling && !frame_started) { + frame_start = std::chrono::steady_clock::now(); + frame_started = true; + } +} +static std::vector native_ui; +static int ui_cols = 0, ui_rows = 0; +static std::atomic_int desktop_width{0}, desktop_height{0}, output_width{0}, output_height{0}; +static std::atomic_bool window_focused{false}; +static std::atomic_uint64_t focus_epoch{0}; +static std::atomic_uint32_t watched_window{0}; +static bool event_watch_installed = false; // graphics thread only +static std::string renderer_name; +static std::atomic_uint64_t camera_generation{0}, target_generation{0}, canvas_creations{0}, + canvas_composites{0}; +static std::atomic_uint64_t composited_generation{0}, presented_generation{0}; +static thread_local int transform_depth = 0; +static void remove_hooks(); +static int lua_prepare(lua_State *L); + +static bool pin_module() { + // DFHack's render callback queue may outlive a logical plugin unload. Pin + // code until process exit, while detaching hooks and releasing all activity. + // This also closes the fetched-vtable-call/unload race on the graphics thread. + static HMODULE pinned = nullptr; + if (!pinned && !GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN, + reinterpret_cast(&pin_module), &pinned)) + return false; + return true; +} + +static bool bind_sdl() { + auto h = DFSDL::obtain_library_handle(); +#define BIND(member, name) \ + sdl.member = reinterpret_cast(LookupPlugin(h, name)); \ + if (!sdl.member) \ + return false + BIND(get_scale, "SDL_RenderGetScale"); + BIND(set_scale, "SDL_RenderSetScale"); + BIND(get_viewport, "SDL_RenderGetViewport"); + BIND(set_viewport, "SDL_RenderSetViewport"); + BIND(get_clip, "SDL_RenderGetClipRect"); + BIND(set_clip, "SDL_RenderSetClipRect"); + BIND(clip_enabled, "SDL_RenderIsClipEnabled"); + BIND(read_pixels, "SDL_RenderReadPixels"); + BIND(output_size, "SDL_GetRendererOutputSize"); + BIND(window_size, "SDL_SetWindowSize"); + BIND(fullscreen, "SDL_SetWindowFullscreen"); + BIND(display_index, "SDL_GetWindowDisplayIndex"); + BIND(desktop_mode, "SDL_GetDesktopDisplayMode"); + BIND(window_id, "SDL_GetWindowID"); + BIND(keyboard_focus, "SDL_GetKeyboardFocus"); + BIND(add_event_watch, "SDL_AddEventWatch"); + BIND(del_event_watch, "SDL_DelEventWatch"); + BIND(renderer_info, "SDL_GetRendererInfo"); + BIND(get_target, "SDL_GetRenderTarget"); + BIND(set_target, "SDL_SetRenderTarget"); + BIND(create_texture, "SDL_CreateTexture"); + BIND(destroy_texture, "SDL_DestroyTexture"); + BIND(texture_blend, "SDL_SetTextureBlendMode"); + BIND(texture_scale, "SDL_SetTextureScaleMode"); + BIND(get_color, "SDL_GetRenderDrawColor"); + BIND(set_color, "SDL_SetRenderDrawColor"); + BIND(clear, "SDL_RenderClear"); + BIND(fill, "SDL_RenderFillRect"); + BIND(copy, "SDL_RenderCopy"); +#undef BIND + return true; +} + +static int watch_focus(void *, SDL_Event *event) { + // SDL_WINDOWEVENT / SDL_WINDOWEVENT_FOCUS_LOST. An epoch also catches + // loss followed by regain between two Lua updates. No mapped DF access. + if (event && event->window.type == 0x200 && event->window.windowID == watched_window.load()) { + if (event->window.event == 13) { + window_focused = false; + ++focus_epoch; + } else if (event->window.event == 12) + window_focused = true; + } + if (event && (event->window.type == 0x2000 || event->window.type == 0x2001)) + ++target_generation; + return 1; +} + +static int current_context() { + auto screen = Gui::getDFViewscreen(true); + if (auto s = virtual_cast(screen)) + return s->zoomed_in || s->page != df::choose_start_site_view_mode::Biome ? 0 : 1; + if (auto s = virtual_cast(screen)) + return s->view_mode == df::world_view_mode_type::NORMAL ? 2 : 0; + return 0; +} + +#include "worldmap-zoom-canvas.h" + +static void capture(df::renderer_2d_base *r) { + std::string path; + { + std::lock_guard lock(state_mutex); + path.swap(capture_path); + } + if (path.empty()) + return; + int w = 0, h = 0; + auto sr = reinterpret_cast(r->sdl_renderer); + sdl.output_size(sr, &w, &h); + std::string error; + if (w <= 0 || h <= 0 || w > 16384 || h > 16384) + error = "Invalid capture dimensions"; + else { + std::vector pixels(static_cast(w) * h * 4); + if (sdl.read_pixels(sr, nullptr, 0x16762004, pixels.data(), w * 4) != 0) + error = "SDL_RenderReadPixels failed"; + else + try { + std::ofstream f(std::filesystem::path(std::u8string(path.begin(), path.end())), + std::ios::binary); + f << "P6\n" << w << ' ' << h << "\n255\n"; + for (size_t i = 0; i < pixels.size(); i += 4) + f.write(reinterpret_cast(pixels.data() + i), 3); + if (!f) + error = "Could not write capture"; + } catch (const std::exception &e) { + error = std::string("Could not write capture: ") + e.what(); + } + } + std::lock_guard lock(state_mutex); + capture_error = error; + if (error.empty()) + last_capture = path; +} + +struct map_hook : df::renderer_2d_base { + using interpose_base = df::renderer_2d_base; + DEFINE_VMETHOD_INTERPOSE(void, update_full_map_port, (df::graphic_map_portst * port)) { + start_frame(); + render_thread = GetCurrentThreadId(); + ++full_calls; + const bool measure = profiling.load(); + const auto start = + measure ? std::chrono::steady_clock::now() : std::chrono::steady_clock::time_point{}; + Camera c; + if (!transform_depth && canvas_camera(this, port, c) && map_canvas.begin(this, port, c, true)) { + ++transform_depth; + INTERPOSE_NEXT(update_full_map_port)(port); + --transform_depth; + map_canvas.valid = true; + ++transformed_calls; + map_canvas.finish(); + } else { + if (!transform_depth) + map_canvas.finish(); + INTERPOSE_NEXT(update_full_map_port)(port); + } + if (measure) { + const auto us = + std::chrono::duration(std::chrono::steady_clock::now() - start).count(); + std::lock_guard lock(state_mutex); + if (profile_us.size() < 256) + profile_us.push_back(us); + } + } + DEFINE_VMETHOD_INTERPOSE(void, update_map_port_tile, + (df::graphic_map_portst * port, int32_t x, int32_t y)) { + start_frame(); + ++tile_calls; + Camera c; + if (!transform_depth && canvas_camera(this, port, c) && map_canvas.begin(this, port, c, false)) { + ++transform_depth; + map_canvas.clear_tile(x, y); + INTERPOSE_NEXT(update_map_port_tile)(port, x, y); + map_canvas.painted_tile(x, y); + --transform_depth; + ++transformed_calls; + // Keep consecutive tile updates in one target pass. The next UI + // draw or present composites it once, below the native interface. + } else { + if (!transform_depth) + map_canvas.finish(); + INTERPOSE_NEXT(update_map_port_tile)(port, x, y); + } + } + DEFINE_VMETHOD_INTERPOSE(void, render, ()) { + render_thread = GetCurrentThreadId(); + map_canvas.finish(); + int w = 0, h = 0; + sdl.output_size(reinterpret_cast(sdl_renderer), &w, &h); + output_width = w; + output_height = h; + capture(this); + INTERPOSE_NEXT(render)(); + presented_generation = composited_generation.load(); + if (profiling && frame_started) { + auto us = + std::chrono::duration(std::chrono::steady_clock::now() - frame_start) + .count(); + std::lock_guard lock(state_mutex); + if (frame_profile_us.size() < 256) + frame_profile_us.push_back(us); + if (frame_profile_us.size() == 256 && profile_us.size() == 256) + profiling = false; + } + frame_started = false; + } +#define UI_HOOK(method, signature, args) \ + DEFINE_VMETHOD_INTERPOSE(void, method, signature) { \ + start_frame(); \ + if (!transform_depth) \ + map_canvas.finish(); \ + INTERPOSE_NEXT(method) args; \ + } + UI_HOOK(update_all, (), ()) + UI_HOOK(update_tile, (int32_t x, int32_t y), (x, y)) + UI_HOOK(update_anchor_tile, (int32_t x, int32_t y), (x, y)) + UI_HOOK(update_top_tile, (int32_t x, int32_t y), (x, y)) + UI_HOOK(update_top_anchor_tile, (int32_t x, int32_t y), (x, y)) + UI_HOOK(update_viewport_tile, (df::graphic_viewportst * p, int32_t x, int32_t y), (p, x, y)) + UI_HOOK(update_full_viewport, (df::graphic_viewportst * p), (p)) +#undef UI_HOOK + DEFINE_VMETHOD_INTERPOSE(void, do_blank_screen_fill, ()) { + start_frame(); + map_canvas.finish(false); + INTERPOSE_NEXT(do_blank_screen_fill)(); + } + DEFINE_VMETHOD_INTERPOSE(void, resize, (int32_t w, int32_t h)) { + map_canvas.release(this); + INTERPOSE_NEXT(resize)(w, h); + } + DEFINE_VMETHOD_INTERPOSE(void, grid_resize, (int32_t w, int32_t h)) { + map_canvas.release(this); + INTERPOSE_NEXT(grid_resize)(w, h); + } + DEFINE_VMETHOD_INTERPOSE(void, set_fullscreen, ()) { + map_canvas.release(this); + INTERPOSE_NEXT(set_fullscreen)(); + } + DEFINE_VMETHOD_INTERPOSE(bool, init_video, (int32_t w, int32_t h)) { + map_canvas.release(this); + return INTERPOSE_NEXT(init_video)(w, h); + } +}; +IMPLEMENT_VMETHOD_INTERPOSE(map_hook, update_full_map_port); +IMPLEMENT_VMETHOD_INTERPOSE(map_hook, update_map_port_tile); +IMPLEMENT_VMETHOD_INTERPOSE(map_hook, render); +#define CANVAS_BOUNDARIES(X) \ + X(update_all) \ + X(update_tile) \ + X(update_anchor_tile) \ + X(update_top_tile) X(update_top_anchor_tile) X(update_viewport_tile) X(update_full_viewport) \ + X(do_blank_screen_fill) X(resize) X(grid_resize) X(set_fullscreen) X(init_video) +#define DECLARE_BOUNDARY(name) IMPLEMENT_VMETHOD_INTERPOSE(map_hook, name); +CANVAS_BOUNDARIES(DECLARE_BOUNDARY) +#undef DECLARE_BOUNDARY + +static void remove_hooks() { + if (enabler && enabler->renderer) + map_canvas.release(virtual_cast(enabler->renderer)); + if (event_watch_installed) { + sdl.del_event_watch(watch_focus, nullptr); + event_watch_installed = false; + } + watched_window = 0; + window_focused = false; + hooks_ready = false; + INTERPOSE_HOOK(map_hook, update_full_map_port).remove(); + INTERPOSE_HOOK(map_hook, update_map_port_tile).remove(); + INTERPOSE_HOOK(map_hook, render).remove(); +#define REMOVE_BOUNDARY(name) INTERPOSE_HOOK(map_hook, name).remove(); + CANVAS_BOUNDARIES(REMOVE_BOUNDARY) +#undef REMOVE_BOUNDARY +} + +static int lua_camera(lua_State *L) { + Camera c; + c.enabled = lua_toboolean(L, 1) != 0; + const auto context = luaL_checkinteger(L, 2); + if (context < 0 || context > 2 || (c.enabled && context == 0)) + return luaL_error(L, "Expected a supported context, 1x/2x/4x zoom, and finite coordinates"); + c.context = static_cast(context); + c.zoom = luaL_checknumber(L, 3); + c.x = luaL_checknumber(L, 4); + c.y = luaL_checknumber(L, 5); + if (!std::isfinite(c.zoom) || !std::isfinite(c.x) || !std::isfinite(c.y) || + (c.zoom != 1 && c.zoom != 2 && c.zoom != 4) || c.context < 0 || c.context > 2) + return luaL_error(L, "Expected a supported context, 1x/2x/4x zoom, and finite coordinates"); + if (c.enabled && (!build_verified || !hooks_ready || c.context != current_context())) + return luaL_error(L, "Renderer is not ready or the high-level map is no longer open"); + { + std::lock_guard lock(state_mutex); + camera = c; + } + ++camera_generation; + if (!c.enabled && build_verified) + runOnRenderThread([] { map_canvas.release(virtual_cast(enabler->renderer)); }); + if (build_verified && gps) + gps->force_full_display_count = 2; + return 0; +} + +static int zoom_status(lua_State *L) { + lua_newtable(L); +#define INT_FIELD(name, value) \ + lua_pushinteger(L, static_cast(value)); \ + lua_setfield(L, -2, name) + lua_pushboolean(L, hooks_ready); + lua_setfield(L, -2, "ready"); + INT_FIELD("full_calls", full_calls.load()); + INT_FIELD("tile_calls", tile_calls.load()); + INT_FIELD("transformed_calls", transformed_calls.load()); + INT_FIELD("render_thread", render_thread.load()); + INT_FIELD("context", build_verified ? current_context() : 0); + INT_FIELD("restore_checks", restore_checks.load()); + INT_FIELD("restore_errors", restore_errors.load()); + INT_FIELD("desktop_width", desktop_width.load()); + INT_FIELD("desktop_height", desktop_height.load()); + INT_FIELD("output_width", output_width.load()); + INT_FIELD("output_height", output_height.load()); + INT_FIELD("focus_epoch", focus_epoch.load()); + INT_FIELD("canvas_creations", canvas_creations.load()); + INT_FIELD("canvas_composites", canvas_composites.load()); + INT_FIELD("camera_generation", camera_generation.load()); + INT_FIELD("presented_generation", presented_generation.load()); + lua_pushboolean(L, window_focused.load()); + lua_setfield(L, -2, "window_focused"); + { + std::lock_guard lock(state_mutex); + INT_FIELD("profile_samples", profile_us.size()); + INT_FIELD("frame_profile_samples", frame_profile_us.size()); + lua_pushstring(L, last_error.c_str()); + lua_setfield(L, -2, "error"); + lua_pushstring(L, renderer_name.c_str()); + lua_setfield(L, -2, "sdl_backend"); + lua_pushstring(L, capture_error.c_str()); + lua_setfield(L, -2, "capture_error"); + lua_pushstring(L, last_capture.c_str()); + lua_setfield(L, -2, "last_capture"); + } + return 1; +} + +static int lua_profile(lua_State *L) { + if (!build_verified || !hooks_ready) + return luaL_error(L, "Renderer is not ready"); + std::lock_guard lock(state_mutex); + if (lua_toboolean(L, 1)) { + profile_us.clear(); + frame_profile_us.clear(); + profiling = true; + } + lua_newtable(L); + int i = 0; + for (double sample : profile_us) { + lua_pushnumber(L, sample); + lua_rawseti(L, -2, ++i); + } + return 1; +} + +static int lua_frame_profile(lua_State *L) { + if (!build_verified || !hooks_ready) + return luaL_error(L, "Renderer is not ready"); + std::lock_guard lock(state_mutex); + lua_newtable(L); + int i = 0; + for (double sample : frame_profile_us) { + lua_pushnumber(L, sample); + lua_rawseti(L, -2, ++i); + } + return 1; +} + +// Called after native parent rendering and BEFORE drawing the Lua controls. +// UI lives in the text buffers; geographic layers live in main_map_port. +static int lua_snapshot_ui(lua_State *L) { + if (!build_verified || !hooks_ready) + return luaL_error(L, "Renderer is not ready"); + ui_cols = gps->dimx; + ui_rows = gps->dimy; + const size_t count = static_cast(ui_cols) * ui_rows; + if (!ui_cols || !ui_rows || count > 4000000) + return luaL_error(L, "Invalid UI dimensions"); + native_ui.assign(count, 0); + for (size_t i = 0; i < count; ++i) { + const auto occupied = [](unsigned char ch) { return ch != 0 && ch != 32; }; + native_ui[i] = + gps->screentexpos[i] || gps->screentexpos_lower[i] || gps->screentexpos_anchored[i] || + occupied(gps->screen[i * 8]) || + (gps->top_in_use && (gps->screentexpos_top[i] || gps->screentexpos_top_lower[i] || + gps->screentexpos_top_anchored[i] || occupied(gps->screen_top[i * 8]))); + } + return 0; +} + +static int lua_ui_hit(lua_State *L) { + if (!build_verified || !hooks_ready) + return luaL_error(L, "Renderer is not ready"); + const int x = static_cast(luaL_checkinteger(L, 1)), y = static_cast(luaL_checkinteger(L, 2)); + if (ui_cols != gps->dimx || ui_rows != gps->dimy || native_ui.empty()) { + lua_pushnil(L); + return 1; + } + if (x < 0 || y < 0 || x >= ui_cols || y >= ui_rows) { + lua_pushboolean(L, true); + return 1; + } + lua_pushboolean(L, native_ui[static_cast(x) * ui_rows + y]); + return 1; +} + +static int lua_window(lua_State *L) { + if (!build_verified || !hooks_ready) + return luaL_error(L, "Renderer is not ready"); + const int w = static_cast(luaL_checkinteger(L, 1)), h = static_cast(luaL_checkinteger(L, 2)); + const bool full = lua_toboolean(L, 3) != 0; + if (full || enabler->fullscreen_state.bits.fullscreen) + return luaL_error(L, "Use native DF settings for fullscreen tests"); + if (w < 640 || h < 480 || w > 7680 || h > 4320) + return luaL_error(L, "Invalid test window dimensions"); + runOnRenderThread([w, h] { + auto r = virtual_cast(enabler->renderer); + if (!r) + return; + auto window = reinterpret_cast(r->window); + sdl.window_size(window, w, h); + // Programmatic SDL resizing emits SIZE_CHANGED, while DF handles + // RESIZED. Let the native event loop pause simulation and reshape its + // buffers, exactly as for an ordinary user window resize. + SDL_Event event{}; + event.window.type = 0x200; + event.window.windowID = sdl.window_id(window); + event.window.event = 5; + event.window.data1 = w; + event.window.data2 = h; + DFSDL::DFSDL_PushEvent(&event); + }); + return 0; +} + +static int lua_capture(lua_State *L) { + if (!build_verified || !hooks_ready) + return luaL_error(L, "Renderer is not ready"); + std::string path = luaL_checkstring(L, 1); + { + std::lock_guard lock(state_mutex); + capture_path = path; + } + gps->force_full_display_count = 2; + return 0; +} + +static int lua_test_focus(lua_State *L) { + if (!build_verified || !hooks_ready) + return luaL_error(L, "Renderer is not ready"); + const bool focused = lua_toboolean(L, 1) != 0; + runOnRenderThread([focused] { + SDL_Event event{}; + event.window.type = 0x200; + event.window.windowID = watched_window.load(); + event.window.event = focused ? 12 : 13; + DFSDL::DFSDL_PushEvent(&event); + }); + return 0; +} + +DFhackCExport CommandReg plugin_lua_commands[] = { + {"prepare", lua_prepare}, {"setCamera", lua_camera}, {"status", zoom_status}, + {"capture", lua_capture}, {"profile", lua_profile}, {"snapshotUi", lua_snapshot_ui}, + {"uiHit", lua_ui_hit}, {"testWindow", lua_window}, {"frameProfile", lua_frame_profile}, + {"testFocus", lua_test_focus}, {nullptr, nullptr}}; + +static int lua_prepare(lua_State *L) { + // Validate runtime metadata BEFORE reading mapped renderer fields or + // installing hooks. Compare with the metadata linked into this build, not a fixed release. + // Plugin metadata alone only checks the release/ABI. + for (auto [function, expected] : {std::pair{"getGitCommit", Version::git_commit()}, + std::pair{"getGitXmlCommit", Version::git_xml_commit()}}) { + lua_getglobal(L, "dfhack"); + lua_getfield(L, -1, function); + if (lua_pcall(L, 0, 1, 0) != LUA_OK) + return luaL_error(L, "Cannot verify DFHack build"); + const char *value = lua_tostring(L, -1); + if (!value || std::string(value) != expected) + return luaL_error(L, "Unsupported DFHack build/structures"); + lua_pop(L, 2); + } + if (!enabler || !virtual_cast(enabler->renderer) || !bind_sdl()) { + return luaL_error(L, "Unsupported renderer: requires the tested SDL2 renderer"); + } + if (hooks_ready && build_verified) { + lua_pushboolean(L, true); + return 1; + } + if (prepare_pending.exchange(true)) { + lua_pushboolean(L, false); + return 1; + } + if (!pin_module()) { + prepare_pending = false; + return luaL_error(L, "Could not retain the renderer module safely"); + } + { + std::lock_guard lock(state_mutex); + last_error.clear(); + } + build_verified = true; + runOnRenderThread([] { + if (!build_verified) { + prepare_pending = false; + return; + } + auto r = virtual_cast(enabler->renderer); + if (r) { + SDL_RendererInfo info{}; + if (sdl.renderer_info(reinterpret_cast(r->sdl_renderer), &info) == 0) { + std::lock_guard lock(state_mutex); + renderer_name = info.name ? info.name : "unknown"; + } + auto window = reinterpret_cast(r->window); + watched_window = sdl.window_id(window); + window_focused = sdl.keyboard_focus() == window; + if (!event_watch_installed) { + sdl.add_event_watch(watch_focus, nullptr); + event_watch_installed = true; + } + SDL_DisplayMode mode{}; + if (sdl.desktop_mode(sdl.display_index(reinterpret_cast(r->window)), &mode) == 0) { + desktop_width = mode.w; + desktop_height = mode.h; + } + } + bool ok = INTERPOSE_HOOK(map_hook, update_full_map_port).apply() && + INTERPOSE_HOOK(map_hook, update_map_port_tile).apply() && + INTERPOSE_HOOK(map_hook, render).apply(); +#define APPLY_BOUNDARY(name) ok = INTERPOSE_HOOK(map_hook, name).apply() && ok; + CANVAS_BOUNDARIES(APPLY_BOUNDARY) +#undef APPLY_BOUNDARY + if (!ok) { + remove_hooks(); + std::lock_guard lock(state_mutex); + last_error = "Could not attach renderer hooks"; + } + hooks_ready = ok; + prepare_pending = false; + if (gps) + gps->force_full_display_count = 2; + }); + lua_pushboolean(L, false); + return 1; +} + +DFhackCExport command_result plugin_init(color_ostream &, std::vector &) { + return CR_OK; +} + +DFhackCExport command_result plugin_onstatechange(color_ostream &, state_change_event event) { + if (event == SC_WORLD_UNLOADED || event == SC_MAP_UNLOADED) { + std::lock_guard lock(state_mutex); + camera = {}; + if (build_verified) + runOnRenderThread( + [] { map_canvas.release(virtual_cast(enabler->renderer)); }); + } + return CR_OK; +} + +DFhackCExport command_result plugin_shutdown(color_ostream &) { + { + std::lock_guard lock(state_mutex); + camera = {}; + } + profiling = false; + native_ui.clear(); + ui_cols = ui_rows = 0; + if (build_verified.exchange(false)) + runOnRenderThread([] { + remove_hooks(); + if (gps) + gps->force_full_display_count = 2; + }); + return CR_OK; +} diff --git a/test/plugins/worldmap-zoom-render.lua b/test/plugins/worldmap-zoom-render.lua new file mode 100644 index 0000000000..4fb62076a4 --- /dev/null +++ b/test/plugins/worldmap-zoom-render.lua @@ -0,0 +1,55 @@ +config.mode = 'title' +config.target = 'worldmap-zoom-render' + +-- The native renderer is currently built only on Windows. Do not require an +-- unavailable native module while the test runner is discovering Linux tests. +if dfhack.getOSType() ~= 'windows' then return end + +local renderer = require('plugins.worldmap-zoom-render') + +function test.rejects_invalid_camera_parameters() + for _, zoom in ipairs{0, 3, 8, math.huge, -math.huge, 0/0} do + expect.error_match('supported context', function() + renderer.setCamera(false, 0, zoom, 0, 0) + end) + end + for _, context in ipairs{-1, 3, 2^32 + 1} do + expect.error_match('supported context', function() + renderer.setCamera(false, context, 2, 0, 0) + end) + end + for _, coord in ipairs{math.huge, -math.huge, 0/0} do + expect.error_match('finite coordinates', function() + renderer.setCamera(false, 0, 2, coord, 0) + end) + expect.error_match('finite coordinates', function() + renderer.setCamera(false, 0, 2, 0, coord) + end) + end +end + +function test.disabled_camera_does_not_install_hooks() + local before = renderer.status() + for _, zoom in ipairs{1, 2, 4} do + renderer.setCamera(false, 0, zoom, 0, 0) + end + local after = renderer.status() + expect.eq(before.ready, after.ready) + expect.eq(before.canvas_creations, after.canvas_creations) + expect.eq(before.transformed_calls, after.transformed_calls) +end + +function test.camera_requires_supported_screen_and_preparation() + expect.error_match('not ready', function() + renderer.setCamera(true, 1, 2, 0, 0) + end) + expect.error_match('not ready', function() + renderer.setCamera(true, 2, 4, 0, 0) + end) +end + +function test.external_client_aliases() + expect.eq(renderer.setCamera, renderer.set_camera) + expect.eq(renderer.snapshotUi, renderer.snapshot_ui) + expect.eq(renderer.uiHit, renderer.ui_hit) +end From af4f46e90c816ae5e64a3291f693a5ab6fc97b6c Mon Sep 17 00:00:00 2001 From: Huitzilopochtli-98 <349095218+Huitzilopochtli-98@users.noreply.github.com> Date: Thu, 5 Nov 2026 07:25:41 -0500 Subject: [PATCH 2/4] Add cancellable core-owned render tasks with destruction fences --- docs/changelog.txt | 1 + docs/dev/CoreSuspender.rst | 39 +++++++ library/CMakeLists.txt | 13 +++ library/RenderThreadTask.cpp | 96 +++++++++++++++++ library/include/RenderThreadTask.h | 40 ++++++++ library/include/modules/DFSDL.h | 5 +- library/tests/render-thread-task.cpp | 147 +++++++++++++++++++++++++++ 7 files changed, 339 insertions(+), 2 deletions(-) create mode 100644 library/RenderThreadTask.cpp create mode 100644 library/include/RenderThreadTask.h create mode 100644 library/tests/render-thread-task.cpp diff --git a/docs/changelog.txt b/docs/changelog.txt index 3388d9205c..30a58cdaed 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -78,6 +78,7 @@ Template for new versions: ## Documentation ## API +- ``queueOnRenderThread``: cancellable render-task handles report completion after callback/capture destruction, allowing unloadable plugins to drain render work safely ## Lua - ``dfhack.timeout_until``: new function to schedule a callback to run on a specified in-game tick diff --git a/docs/dev/CoreSuspender.rst b/docs/dev/CoreSuspender.rst index 385ecd8446..f8c8cf563f 100644 --- a/docs/dev/CoreSuspender.rst +++ b/docs/dev/CoreSuspender.rst @@ -339,6 +339,45 @@ Caveats and pitfalls frame-length wait. Grouping operations into one critical section is still good advice for latency, not correctness. +.. _render-thread-tasks: + +Cancellable render-thread work and plugin unload +------------------------------------------------ + +``runOnRenderThread(callback)`` queues a fire-and-forget ``std::function``. +Its execution and subsequent destruction happen in the core render queue. +An unloadable plugin must account for the callback's function manager and the +destructors of any captures, as well as for its callback body. A completion flag +written inside the callback body can become visible *before* those destructors +have run. + +``queueOnRenderThread(callback)`` (``RenderThreadTask.h``, also included by +``modules/DFSDL.h``) returns a shared ``RenderThreadTask`` handle: + +- ``cancel()`` cancels a pending task and destroys its callback/captures before + returning true. It returns false if execution/cancellation already started. +- ``finished()`` reports that the callable and its captures have been destroyed, + after either execution or cancellation. A cancelled task's empty queue wrapper + can remain queued: that wrapper and its state destructor are compiled in core. +- ``succeeded()`` is true only after a callback returned normally and its + captures were destroyed. Cancelled or throwing callbacks report false. The + task catches exceptions without retaining an ``exception_ptr``, whose payload + could itself have a destructor in the caller's module. +- ``waitFor(std::chrono::milliseconds)`` waits for ``finished()`` with a timeout. + It is forbidden on the render thread or while holding a core suspend context; + use polling on later simulation frames in those contexts. A timeout gives no + permission to unload the callback's module. + +The handles track queued work only. Plugins must separately remove every native +hook/event callback on its owning thread, stop new work from being submitted, +and establish completion before permitting their module to unload. Store task +handles until then; dropping a handle alone does not cancel a queued callback. + +The world-map renderer's methodology documentation describes a concrete +application: its console unload waits without the core lock, its Lua owner can +request nonblocking cleanup and poll, and its process-shutdown path cleans up +inline on the render thread. + Reference --------- diff --git a/library/CMakeLists.txt b/library/CMakeLists.txt index 117c0f5e8e..3a4b93bd08 100644 --- a/library/CMakeLists.txt +++ b/library/CMakeLists.txt @@ -77,6 +77,7 @@ set(MAIN_HEADERS include/RemoteClient.h include/RemoteServer.h include/RemoteTools.h + include/RenderThreadTask.h include/Signal.hpp include/TileTypes.h include/Types.h @@ -117,6 +118,7 @@ set(MAIN_SOURCES RemoteClient.cpp RemoteServer.cpp RemoteTools.cpp + RenderThreadTask.cpp ) file(GLOB_RECURSE TEST_SOURCES @@ -124,6 +126,17 @@ file(GLOB_RECURSE TEST_SOURCES *test.cpp) dfhack_test(dfhack-test "${TEST_SOURCES}") +if(BUILD_TESTING) + find_package(Threads REQUIRED) + # Standalone: test the production task implementation with an injected + # transport, without requiring a game, SDL context, or a loaded DFHack core. + add_executable(render-thread-task-test tests/render-thread-task.cpp RenderThreadTask.cpp) + target_include_directories(render-thread-task-test PRIVATE include) + target_compile_definitions(render-thread-task-test PRIVATE DFHACK_EXPORT= DFHACK_IMPORT=) + target_link_libraries(render-thread-task-test PRIVATE Threads::Threads) + add_test(NAME render-thread-task-test COMMAND render-thread-task-test) +endif() + if(WIN32) set(CONSOLE_SOURCES Console-windows.cpp) else() diff --git a/library/RenderThreadTask.cpp b/library/RenderThreadTask.cpp new file mode 100644 index 0000000000..297f2d2b64 --- /dev/null +++ b/library/RenderThreadTask.cpp @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: Zlib +#include +#include +#include + +#include "RenderThreadTask.h" + +namespace DFHack { + +struct RenderThreadTask::State { + enum class Phase { pending, running, cancelling, finished }; + mutable std::mutex mutex; + std::condition_variable changed; + std::function callback; + Phase phase = Phase::pending; + bool succeeded = false; +}; + +RenderThreadTask::RenderThreadTask(std::function callback) : state(std::make_unique()) { + state->callback = std::move(callback); +} + +RenderThreadTask::~RenderThreadTask() = default; + +bool RenderThreadTask::cancel() { + std::function discarded; + { + std::lock_guard lock(state->mutex); + if (state->phase != State::Phase::pending) + return false; + state->phase = State::Phase::cancelling; + discarded = std::move(state->callback); + state->callback = {}; + } + // Captures can have nontrivial destructors. Destroy them outside the lock, + // and do not publish completion while any plugin-owned destructor remains. + discarded = {}; + { + std::lock_guard lock(state->mutex); + state->phase = State::Phase::finished; + } + state->changed.notify_all(); + return true; +} + +void RenderThreadTask::run() { + std::function callback; + { + std::lock_guard lock(state->mutex); + if (state->phase != State::Phase::pending) + return; + state->phase = State::Phase::running; + callback = std::move(state->callback); + state->callback = {}; + } + bool success = false; + try { + callback(); + success = true; + } catch (...) { + // Do not retain an exception_ptr: an exception's destructor might also + // reside in the plugin. The caller can inspect succeeded() instead. + } + callback = {}; + { + std::lock_guard lock(state->mutex); + state->succeeded = success; + state->phase = State::Phase::finished; + } + state->changed.notify_all(); +} + +bool RenderThreadTask::finished() const { + std::lock_guard lock(state->mutex); + return state->phase == State::Phase::finished; +} + +bool RenderThreadTask::succeeded() const { + std::lock_guard lock(state->mutex); + return state->phase == State::Phase::finished && state->succeeded; +} + +bool RenderThreadTask::waitFor(std::chrono::milliseconds timeout) const { + std::unique_lock lock(state->mutex); + return state->changed.wait_for(lock, timeout, [this] { return state->phase == State::Phase::finished; }); +} + +std::shared_ptr queueOnRenderThread(std::function callback) { + auto task = std::shared_ptr(new RenderThreadTask(std::move(callback))); + // This wrapper's std::function manager is compiled into core, not into the + // caller's module. A cancelled/finished task can safely remain in the queue. + runOnRenderThread([task] { task->run(); }); + return task; +} + +} // namespace DFHack diff --git a/library/include/RenderThreadTask.h b/library/include/RenderThreadTask.h new file mode 100644 index 0000000000..32b81cc0a9 --- /dev/null +++ b/library/include/RenderThreadTask.h @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: Zlib +#pragma once + +#include +#include +#include + +#include "Export.h" + +namespace DFHack { + +// Fire-and-forget work must not refer to an unloadable plugin. Use the task +// handle below when callback code or captured objects belong to a plugin. +DFHACK_EXPORT void runOnRenderThread(std::function callback); + +class DFHACK_EXPORT RenderThreadTask; +DFHACK_EXPORT std::shared_ptr queueOnRenderThread(std::function callback); + +// The implementation, queue wrapper, and callback destruction live in core. +// A finished task contains no callable or capture whose code lives in a plugin. +class DFHACK_EXPORT RenderThreadTask { +public: + ~RenderThreadTask(); + bool cancel(); + bool finished() const; + bool succeeded() const; + // Never wait on the render thread or while holding a core suspend context: + // the render thread may itself be waiting for simulation to advance. + bool waitFor(std::chrono::milliseconds timeout) const; + +private: + struct State; + std::unique_ptr state; + explicit RenderThreadTask(std::function callback); + void run(); + friend DFHACK_EXPORT std::shared_ptr + queueOnRenderThread(std::function callback); +}; + +} // namespace DFHack diff --git a/library/include/modules/DFSDL.h b/library/include/modules/DFSDL.h index 95837b6889..3e6bac6303 100644 --- a/library/include/modules/DFSDL.h +++ b/library/include/modules/DFSDL.h @@ -3,6 +3,7 @@ #include "ColorText.h" #include "Export.h" #include "PluginManager.h" +#include "RenderThreadTask.h" #include #include @@ -84,7 +85,7 @@ namespace DFHack DFHACK_EXPORT bool getClipboardTextCp437Multiline(std::vector * lines); DFHACK_EXPORT bool setClipboardTextCp437Multiline(std::string text); - // Queue a cb to be run on the render thread - DFHACK_EXPORT void runOnRenderThread(std::function cb); + // runOnRenderThread() and the cancellable queueOnRenderThread() task API + // are declared in RenderThreadTask.h. DFHACK_EXPORT void runRenderThreadCallbacks(); } diff --git a/library/tests/render-thread-task.cpp b/library/tests/render-thread-task.cpp new file mode 100644 index 0000000000..48f1bd9068 --- /dev/null +++ b/library/tests/render-thread-task.cpp @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: Zlib +#ifdef NDEBUG +#undef NDEBUG +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "RenderThreadTask.h" + +// Only the transport is substituted. The task/cancellation/fence implementation +// is library/RenderThreadTask.cpp, compiled as a separate translation unit. +static std::vector> queue; +namespace DFHack { +void runOnRenderThread(std::function callback) { + queue.push_back(std::move(callback)); +} +} // namespace DFHack + +using namespace std::chrono_literals; + +struct CallbackResource { + std::atomic_bool &destroyed; + explicit CallbackResource(std::atomic_bool &flag) : destroyed(flag) {} + ~CallbackResource() { + destroyed = true; + } +}; + +int main() { + std::atomic_bool destroyed{false}; + int calls = 0; + auto resource = std::make_shared(destroyed); + auto cancelled = DFHack::queueOnRenderThread([resource, &calls] { ++calls; }); + resource.reset(); + assert(!cancelled->finished()); + assert(cancelled->cancel()); + assert(cancelled->finished() && destroyed && !cancelled->succeeded()); + // The core queue wrapper is still alive. Executing/destroying it must not + // invoke any cancelled plugin code or retain its captured resource. + queue.front()(); + queue.clear(); + assert(calls == 0); + + destroyed = false; + resource = std::make_shared(destroyed); + auto finished = DFHack::queueOnRenderThread([resource, &calls] { ++calls; }); + resource.reset(); + queue.front()(); + assert(finished->finished() && finished->succeeded() && destroyed && calls == 1); + queue.clear(); + + std::mutex mutex; + std::condition_variable changed; + bool entered = false, release = false; + auto running = DFHack::queueOnRenderThread([&] { + std::unique_lock lock(mutex); + entered = true; + changed.notify_one(); + changed.wait(lock, [&] { return release; }); + }); + std::thread render([] { queue.front()(); }); + { + std::unique_lock lock(mutex); + changed.wait(lock, [&] { return entered; }); + } + assert(!running->cancel()); + assert(!running->waitFor(1ms)); + { + std::lock_guard lock(mutex); + release = true; + } + changed.notify_one(); + assert(running->waitFor(1000ms) && running->succeeded()); + render.join(); + queue.clear(); + + // Publishing completion before capture destruction is a use-after-unload + // bug even when callback() already returned. Block a capture's destructor + // to verify that the fence covers that interval too. + struct BlockingDestructor { + std::mutex &mutex; + std::condition_variable &changed; + bool &entered, &release; + ~BlockingDestructor() { + std::unique_lock lock(mutex); + entered = true; + changed.notify_one(); + changed.wait(lock, [&] { return release; }); + } + }; + entered = release = false; + auto blocking = + std::shared_ptr(new BlockingDestructor{mutex, changed, entered, release}); + auto destruction = DFHack::queueOnRenderThread([blocking] {}); + blocking.reset(); + std::thread render_destructor([] { queue.front()(); }); + { + std::unique_lock lock(mutex); + changed.wait(lock, [&] { return entered; }); + } + assert(!destruction->finished() && !destruction->waitFor(1ms)); + { + std::lock_guard lock(mutex); + release = true; + } + changed.notify_one(); + assert(destruction->waitFor(1000ms) && destruction->succeeded()); + render_destructor.join(); + queue.clear(); + + entered = release = false; + blocking = std::shared_ptr(new BlockingDestructor{mutex, changed, entered, release}); + auto cancelling = DFHack::queueOnRenderThread([blocking] {}); + blocking.reset(); + std::thread cancel([&] { assert(cancelling->cancel()); }); + { + std::unique_lock lock(mutex); + changed.wait(lock, [&] { return entered; }); + } + queue.front()(); // cancellation owns the payload; the queue must skip it + assert(!cancelling->finished()); + { + std::lock_guard lock(mutex); + release = true; + } + changed.notify_one(); + cancel.join(); + assert(cancelling->finished() && !cancelling->succeeded()); + queue.clear(); + + auto failed = DFHack::queueOnRenderThread([] { throw std::runtime_error("expected test failure"); }); + queue.front()(); + assert(failed->finished() && !failed->succeeded()); + queue.clear(); + std::cout << "PASS: pending cancellation, capture destruction, running-task wait, " + "post-cancel queue safety, completion fence, and exception cleanup\n"; +} From f9b0d8522bb1e0fd86c3723672af09d1ac79c163 Mon Sep 17 00:00:00 2001 From: Huitzilopochtli-98 <349095218+Huitzilopochtli-98@users.noreply.github.com> Date: Thu, 5 Nov 2026 07:25:41 -0500 Subject: [PATCH 3/4] Explain world-map composition and cleanly unload the renderer --- docs/plugins/worldmap-zoom-render.rst | 281 +++++++++-- plugins/CMakeLists.txt | 10 +- plugins/worldmap-zoom-canvas-sdl.test.cpp | 149 ++++++ plugins/worldmap-zoom-canvas.h | 486 +++++++++++-------- plugins/worldmap-zoom-canvas.test.cpp | 236 ++++----- plugins/worldmap-zoom-render.cpp | 561 +++++++++++++++------- plugins/worldmap-zoom-sdl.h | 38 ++ 7 files changed, 1209 insertions(+), 552 deletions(-) create mode 100644 plugins/worldmap-zoom-canvas-sdl.test.cpp create mode 100644 plugins/worldmap-zoom-sdl.h diff --git a/docs/plugins/worldmap-zoom-render.rst b/docs/plugins/worldmap-zoom-render.rst index 4fc3929500..9539a49235 100644 --- a/docs/plugins/worldmap-zoom-render.rst +++ b/docs/plugins/worldmap-zoom-render.rst @@ -6,32 +6,258 @@ worldmap-zoom-render :tags: dev :no-command: -This Windows-only plugin provides the rendering backend for 2x and 4x world-map -zoom. It draws the native geographic layers into an SDL2 target texture, then -composites a cropped portion beneath the interface. Text and controls retain -their normal size. Supported contexts are the high-level embark Biome map and -the normal fortress world map. Other map modes are left alone. - -This is a Lua service, with no command or automatic activation. A client must -manage the camera, inverse mouse mapping, and controls. The external -`World Map Zoom project `__ -contains a reference client and validation evidence. Its released 1.0 client -has its own exact-version checks and needs adaptation for development builds; -this plugin does not replace those checks or install that client. - -The plugin is built with DFHack's version metadata and generated structures. -Before accessing renderer fields, it checks that the running DFHack's source -and structure revisions match those linked into the plugin. A rebuild therefore -uses the new build's metadata instead of a hardcoded release hash. This does not -establish compatibility with changed renderer layouts or replace runtime testing. +Purpose and client responsibilities +----------------------------------- + +The use case is a world-map inspection window: a player enlarges a small site, +road, or biome at 2x/4x, pans around, and still reads the native interface at its +normal size. The backend supports the high-level embark Biome map and the normal +fortress world map. The initial native build is enabled on Windows, where the +external mod has game-testing evidence. + +DF draws geographic layers through ``graphic_map_portst`` and draws interface +text/controls through separate renderer methods. This service intercepts the +former and flushes its result before the latter. Changing DF's font size or +scaling a screenshot of the entire window would also enlarge the interface. + +The C++ plugin owns SDL textures, renderer hooks, and their lifetime. A Lua +world-map client owns the modal screen, world-camera movement, zoom/pan controls, +and input routing. Loading this service alone creates no window and changes no +camera. The split lets a future DFHack build compile the native interface against +its own structures while Lua clients implement the interaction. + +The complete reference client is +`gui/worldmap-zoom.lua `__. +Its camera/input helpers, installers, and GUI are maintained in that repository; +they are not installed by this backend. The released client has its own fixed +version guards and needs adaptation to drive a development build. + +Methodology +----------- + +Coordinates and composition +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A native world-map cell is 16 pixels, independent of the text-grid font. Let +``M`` be the map's native output rectangle and ``z`` be 2 or 4. The client supplies +crop offsets ``x,y`` in native map cells. The canvas clamps them to +``[0, columns*(1-1/z)]`` / ``[0, rows*(1-1/z)]``, rounds their pixel offsets, +and selects a source rectangle of size ``M.width/z`` by ``M.height/z``. +Copying that source to the original rectangle ``M`` enlarges only the map. + +For example, a 32-by-24-cell map occupies 512 by 384 pixels. At 2x, offsets +``8,6`` select its central 256-by-192-pixel area and enlarge it back to 512 by +384 pixels. At 4x, the visible area is 128 by 96 pixels. + +The texture extends from output origin ``0,0`` to the bottom-right of ``M``. +DF's original draw methods therefore keep their normal, positive coordinates. +A clip limits drawing to the selected crop. This intentionally uses a target +texture rather than a negative, scaled viewport: the latter produced a black +map on a tested Direct3D backend even though native hover still worked. + +``MapCanvas::begin`` makes the drawing sequence explicit: + +1. Validate geometry and create/reuse an opaque ARGB8888 target texture, with + nearest-neighbour scaling to preserve the pixel art. +2. Save DF's render target, scale, viewport, clip, and draw colour. +3. Select the target texture at unit scale and an origin-zero viewport. +4. Clear a full rebuild or a new, invalid crop to opaque black. Preserve the + texture for later incremental updates. Restore DF's draw colour for its draw. +5. Clip to the source crop and let DF's original map method draw normally. + +``MapCanvas::finish`` restores all saved SDL state, verifies the restoration, +then copies the crop to ``M``. Renderer hooks call it before native UI drawing +and before presentation; the interface is drawn on top at its original scale. +A blank-screen fill discards a pending map pass instead of compositing data that +DF is about to erase. Resize, fullscreen changes, video reinitialization, and +SDL device-reset events invalidate the cached texture. + +Full and incremental redraws +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +DF compares old/new map cells and often calls ``update_map_port_tile`` for only +the cells that changed. Consecutive tile calls share a target pass; compositing +once at the next UI boundary avoids one texture copy per changed cell. +A changed cell is cleared before DF redraws it so removed sites/clouds do not +leave stale pixels. + +After a camera change, unchanged cells may still be needed in a newly exposed +crop. The canvas tracks which crop cells DF has painted. If an incremental +batch is incomplete at the UI boundary, the adapter invokes one full native map +draw into the crop. A complete batch needs no extra redraw. A recursion-depth +guard routes nested native vmethod calls directly through DF while this fallback +draw is in progress. + +Each camera publication has a generation. A pending pass whose generation no +longer matches the published camera is restored/discarded rather than displaying +an old crop for a new pointer mapping. The next native redraw uses the new view. + +Input belongs to the client +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Magnifying pixels does not change DF's mouse hit testing. The client must claim +map clicks before enabling zoom, map hover coordinates back to the source crop, +and restore all temporary pointer/button fields after calling the parent. +The original inspection client blocks native map selection by default; opting +into selection requires the same inverse transform for the forwarded click. + +In native map-local pixel coordinates, with map origin ``ox,oy`` and the clamped +crop offsets converted to pixels ``cx,cy``, inverse mapping is:: + + native_x = ox + cx + (pointer_x - ox) / zoom + native_y = oy + cy + (pointer_y - oy) / zoom + +``snapshotUi``/``uiHit`` distinguish native text-grid interface cells from map +pixels. Capture the parent with a neutral pointer before drawing the client's +controls, so a temporary inspection label does not become a clickable control. +Forward UI input unchanged. Transform only map input. A stale UI snapshot returns +``nil``; the client must wait for a fresh snapshot before forwarding map input. + +Concrete client sequence +~~~~~~~~~~~~~~~~~~~~~~~~ + +Within a modal world-map client that already owns input, these are the renderer +calls for a centered 2x fortress-map preview:: + + dfhack.run_command('load', 'worldmap-zoom-render') + local renderer = require('plugins.worldmap-zoom-render') + + local function start_preview() + -- Recheck the native screen on every retry; the player may have left. + local screen = dfhack.gui.getDFViewscreen(true) + if not df.viewscreen_worldst:is_instance(screen) or + screen.view_mode ~= df.world_view_mode_type.NORMAL then + return + end + if not renderer.prepare() then + dfhack.timeout(1, 'frames', start_preview) + return + end + local port = df.global.gps.main_map_port + renderer.setCamera(true, 2, 2, port.dim_x / 4, port.dim_y / 4) + end + + local function close_preview() + renderer.setCamera(false, 0, 1, 0, 0) + end + +Call ``snapshotUi`` after the parent render and before the client's widgets. +For each map hover, apply the inverse transform above around the parent's +logic/render calls. For panning beyond the current native map, the client first +updates the native screen's ``region_cent_x/y`` under the core lock, then +publishes crop offsets relative to that native camera. Recompute the crop after +resize, and close the client if its parent screen or supported mode changes. + +Thread ownership +~~~~~~~~~~~~~~~~ + +The two-thread protocol is described in `core-suspender` and `g_src-notes`. +Viewscreens fill buffers on the simulation thread; native SDL renderer methods +consume the display buffers on the main/render thread. A ``CoreSuspender`` parks +simulation, **not rendering**, and must never be acquired by a renderer hook. + +- Lua API calls and ``plugin_onupdate`` execute under the core lock. They inspect + the native screen mode/map identity and publish a small camera snapshot. +- Renderer hooks read only that snapshot and their native display-side + renderer/map-port arguments. They never follow a viewscreen pointer or call + ``Gui::getDFViewscreen``. SDL objects and the canvas stay on this thread. +- The camera/error mutex protects short copies. The admission mutex serializes + Lua entry points against stopping. Renderer hooks never acquire admission or + the core lock, so teardown cannot create a render-to-Lua lock cycle. +- The SDL event watch touches atomics only. It records focus-loss generations + (including loss/regain between client updates) and invalidates device targets. + SDL2 dispatch/removal use the same event-watcher mutex; cleanup removes the + watch outside dispatch, on the render thread, before acknowledging completion. + +Clean unload +~~~~~~~~~~~~ + +All plugin render work uses the core-owned `render-thread-tasks` handles. The +completion fence covers both callback execution and destruction of its captures. +An empty core queue wrapper can remain queued after a cancelled task without +retaining a plugin function/manager. A callback merely setting a ``done`` atomic +at its last statement would not provide this guarantee. + +Stopping closes admission, cancels pending work, and queues a render-thread +cleanup after any running work. Cleanup restores/releases the canvas, removes +the event watch and all renderer hooks, and requests a native full redraw. +Because removal occurs on the only thread that invokes those renderer methods, +no renderer hook can be in flight when that cleanup returns. ``status().stopped`` +becomes true only after the core has destroyed the cleanup callback as well. +There is no Windows module pin and no platform-specific include in the backend. + +An ordinary console ``unload worldmap-zoom-render`` owns no core lock and can +wait for this fence. The wait is bounded: if SDL is not progressing, unload is +refused while the module stays loaded. Retrying after a frame is safe. +An unload attempted from Lua/simulation while still prepared is refused rather +than waiting with the core lock held. A Lua owner can stop asynchronously:: + + local function unload_when_stopped() + if renderer.status().stopped then + dfhack.run_command('unload', 'worldmap-zoom-render') + else + dfhack.timeout(1, 'frames', unload_when_stopped) + end + end + renderer.stop() + unload_when_stopped() + +The client must close first and cancel any pending launch retries. ``stop`` +ends the backend session; a new session requires reloading the plugin. Normal +client close uses ``setCamera(false, 0, 1, 0, 0)`` and leaves the service available. +During process shutdown, Core is already on the render thread after its SDL +loop and before native renderer destruction; cleanup runs inline, cancels +queued tasks, and waits for no future frame. + +Build and validation boundary +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The plugin checks runtime source/structure revisions against metadata linked +into its own build before enabling hooks. Rebuilding uses the current build's +metadata; it does not establish compatibility with changed renderer layouts. +The released mod has Windows in-game testing, including successful player use. +The refactored backend has also passed native title-screen API, cancellation, +physical DLL unload/reload, and prepared process-shutdown checks in an isolated +DF 53.16 instance. Full embark/fortress zoom-GUI acceptance remains a separate +test from this backend-lifecycle validation. + +The canvas CTest exercises this production header through injected SDL calls, +including state restoration/failures, coverage, invalidation, and renderer +replacement. The task CTest compiles the core implementation separately and +tests cancellation, callback/capture destruction, a callback in flight, bounded +waits, and exception cleanup. Neither test claims a native DF/graphics-driver +acceptance result. + +An optional desktop graphics check is provided in +``plugins/worldmap-zoom-canvas-sdl.test.cpp``. It runs the production canvas on +each available SDL renderer and checks every map pixel at 2x/4x against a native +1x baseline, plus unchanged interface pixels. The baseline matters: framebuffer +colour encoding can differ from the nominal draw colours on OpenGL ES 2. The +comparison stays exact; it does not hide a discrepancy with a colour tolerance. + +For example, from ``build/manual-gpu`` in an x64 Visual Studio developer shell, +using the SDL2 headers/import library downloaded by DFHack's configuration:: + + cl /std:c++20 /MD /EHsc /I"../../depends/SDL2/SDL2-2.26.2/include" ../../plugins/worldmap-zoom-canvas-sdl.test.cpp /Fe:canvas-sdl-test.exe /link "../../depends/SDL2/SDL2-2.26.2/lib/x64/SDL2.lib" + +Place the game's ``SDL2.dll`` beside the executable and run +``canvas-sdl-test.exe --require-direct3d``. The windows are hidden; no DF instance +or save is loaded. A driver without a usable context is reported as skipped; +``--require-direct3d`` fails if the original Direct3D regression was not exercised. +``--driver opengles2`` restricts the check to one renderer. This desktop test is +optional because headless build agents may have no usable graphics device. Lua API ------- Load the service with:: + dfhack.run_command('load', 'worldmap-zoom-render') local renderer = require('plugins.worldmap-zoom-render') +The explicit load also handles reopening after an unload: Lua may still have a +cached module table whose native command bindings have been disconnected. + ``renderer.prepare()`` Verify the build, resolve the required SDL2 functions, and queue hook installation on the graphics thread. Returns ``false`` while installation @@ -39,6 +265,10 @@ Load the service with:: returns ``false``. Unsupported builds/renderers raise a Lua error. Check ``status().error`` if installation does not complete. +``renderer.stop()`` + Begin nonblocking teardown and return whether the completion fence is ready. + Poll ``status().stopped`` on later frames. See the clean-unload sequence above. + ``renderer.setCamera(enabled, context, zoom, x, y)`` Set the camera for the active map. Context is ``1`` for embark or ``2`` for fortress; ``0`` is allowed when disabled. Zoom must be ``1``, ``2``, or ``4``. @@ -49,7 +279,7 @@ Load the service with:: ``renderer.status()`` Return a table with readiness and diagnostic information. ``ready`` reports - hook installation, ``error`` contains a rendering failure, and ``context`` + hook installation, ``stopped`` reports the teardown fence, ``error`` contains a rendering failure, and ``context`` identifies the current supported native screen (or ``0``). ``window_focused`` and ``focus_epoch`` let a client cancel gestures after focus loss, even if focus was regained between updates. Output/desktop dimensions and counters @@ -90,16 +320,3 @@ validation, not normal client interaction: The original external client's snake_case function names remain as aliases for the corresponding camelCase functions. - -Lifecycle and platform limitations ----------------------------------- - -Canvas creation, composition, SDL state restoration, resize invalidation, and -texture release run on the graphics thread. World/map unload disables the -camera. Plugin shutdown detaches hooks and removes the SDL event watch there. - -The Windows module is pinned in memory until DF exits because queued graphics -callbacks and an already-fetched virtual call can outlive a logical unload. -Close DF before replacing the DLL. Linux is not built by this change: an -equivalent callback/module lifetime strategy and native validation are needed -before enabling that platform. diff --git a/plugins/CMakeLists.txt b/plugins/CMakeLists.txt index f0179b3d56..0cca7d4e63 100644 --- a/plugins/CMakeLists.txt +++ b/plugins/CMakeLists.txt @@ -137,16 +137,18 @@ if(BUILD_SUPPORTED) add_subdirectory(tweak) #dfhack_plugin(workflow workflow.cpp LINK_LIBRARIES lua) dfhack_plugin(work-now work-now.cpp) - # The renderer callback lifetime is currently protected with a Windows - # module pin. Other platforms need an equivalent lifetime strategy first. + # The backend is initially enabled on the platform with native acceptance + # evidence. The canvas and task lifetime tests also run on other platforms. if(WIN32) - dfhack_plugin(worldmap-zoom-render worldmap-zoom-render.cpp worldmap-zoom-canvas.h LINK_LIBRARIES lua) + dfhack_plugin(worldmap-zoom-render worldmap-zoom-render.cpp worldmap-zoom-canvas.h worldmap-zoom-sdl.h LINK_LIBRARIES lua) if(BUILD_LIBRARY AND BUILD_PLUGINS) target_include_directories(worldmap-zoom-render PRIVATE ${SDL2_INCLUDE_DIRS}) endif() endif() - if(BUILD_TESTING) + if(BUILD_TESTING AND BUILD_LIBRARY) add_executable(worldmap-zoom-canvas-test worldmap-zoom-canvas.test.cpp) + target_include_directories(worldmap-zoom-canvas-test PRIVATE ${SDL2_INCLUDE_DIRS}) + target_compile_definitions(worldmap-zoom-canvas-test PRIVATE SDL_MAIN_HANDLED) add_test(NAME worldmap-zoom-canvas-test COMMAND worldmap-zoom-canvas-test) endif() dfhack_plugin(xlsxreader xlsxreader.cpp LINK_LIBRARIES lua xlsxio_read_STATIC zip expat) diff --git a/plugins/worldmap-zoom-canvas-sdl.test.cpp b/plugins/worldmap-zoom-canvas-sdl.test.cpp new file mode 100644 index 0000000000..b0e350d001 --- /dev/null +++ b/plugins/worldmap-zoom-canvas-sdl.test.cpp @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: Zlib +// Optional desktop/GPU regression. Link SDL2, put the game's SDL2 runtime next +// to this executable, and run it separately from DF. No game/save is loaded. +#define SDL_MAIN_HANDLED + +#include +#include +#include +#include +#include +#include + +#include + +#include "worldmap-zoom-canvas.h" +#include "worldmap-zoom-sdl.h" + +static WorldMapSdl api() { + WorldMapSdl s; + s.get_scale = SDL_RenderGetScale; + s.set_scale = SDL_RenderSetScale; + s.get_viewport = SDL_RenderGetViewport; + s.set_viewport = SDL_RenderSetViewport; + s.get_clip = SDL_RenderGetClipRect; + s.set_clip = SDL_RenderSetClipRect; + s.clip_enabled = SDL_RenderIsClipEnabled; + s.renderer_info = SDL_GetRendererInfo; + s.get_target = SDL_GetRenderTarget; + s.set_target = SDL_SetRenderTarget; + s.create_texture = SDL_CreateTexture; + s.destroy_texture = SDL_DestroyTexture; + s.texture_blend = SDL_SetTextureBlendMode; + s.texture_scale = SDL_SetTextureScaleMode; + s.get_color = SDL_GetRenderDrawColor; + s.set_color = SDL_SetRenderDrawColor; + s.clear = SDL_RenderClear; + s.fill = SDL_RenderFillRect; + s.copy = SDL_RenderCopy; + return s; +} + +static void check(bool condition, const char *message) { + if (!condition) + throw std::runtime_error(std::string(message) + ": " + SDL_GetError()); +} + +static void draw_map(SDL_Renderer *renderer) { + // Distinct, opaque 16-pixel cells make the expected magnified image exact. + for (int row = 0; row < 24; ++row) { + for (int col = 0; col < 32; ++col) { + check(SDL_SetRenderDrawColor(renderer, static_cast(20 + col * 7 % 200), + static_cast(20 + row * 11 % 200), + static_cast(90 + (row + col) % 100), SDL_ALPHA_OPAQUE) == 0, + "draw colour"); + SDL_Rect cell{col * 16, row * 16, 16, 16}; + check(SDL_RenderFillRect(renderer, &cell) == 0, "draw map cell"); + } + } +} + +static void test_driver(SDL_Renderer *renderer, SDL_Window *window, const char *name, int zoom) { + auto sdl = api(); + worldmap_zoom::MapCanvas canvas(sdl); + check(SDL_SetRenderDrawColor(renderer, 0, 0, 0, SDL_ALPHA_OPAQUE) == 0, "background"); + check(SDL_RenderClear(renderer) == 0, "clear window"); + draw_map(renderer); + const SDL_Rect ui{560, 20, 60, 30}; + check(SDL_SetRenderDrawColor(renderer, 240, 30, 30, SDL_ALPHA_OPAQUE) == 0, "baseline UI colour"); + check(SDL_RenderFillRect(renderer, &ui) == 0, "baseline UI"); + // Compare against native drawing, not nominal RGB constants: an SDL + // backend can encode the output framebuffer (e.g. sRGB on OpenGL ES 2). + std::vector baseline(640 * 480 * 4); + check(SDL_RenderReadPixels(renderer, nullptr, SDL_PIXELFORMAT_RGBA32, baseline.data(), 640 * 4) == 0, + "read native baseline"); + check(SDL_SetRenderDrawColor(renderer, 0, 0, 0, SDL_ALPHA_OPAQUE) == 0, "reset background"); + check(SDL_RenderClear(renderer) == 0, "reset window"); + const SDL_Rect map{0, 0, 512, 384}; + check(canvas.begin(renderer, SDL_GetWindowID(window), map, + worldmap_zoom::CanvasView{static_cast(zoom), 4, 3, 1}, 1, true), + "begin production canvas"); + draw_map(renderer); + canvas.markFullDraw(); + canvas.finish(true, [&] { draw_map(renderer); }); + check(canvas.error().empty(), canvas.error().c_str()); + check(canvas.stats().restore_errors == 0 && canvas.stats().composites == 1, "canvas state"); + // Draw native-size UI *after* composition, as DF's renderer does. + check(SDL_SetRenderDrawColor(renderer, 240, 30, 30, SDL_ALPHA_OPAQUE) == 0, "UI colour"); + check(SDL_RenderFillRect(renderer, &ui) == 0, "draw UI"); + std::vector pixels(640 * 480 * 4); + check(SDL_RenderReadPixels(renderer, nullptr, SDL_PIXELFORMAT_RGBA32, pixels.data(), 640 * 4) == 0, + "read composed pixels"); + for (int y = 0; y < 384; ++y) { + for (int x = 0; x < 512; ++x) { + const auto *pixel = pixels.data() + (y * 640 + x) * 4; + const auto *native = baseline.data() + (((3 * 16 + y / zoom) * 640) + 4 * 16 + x / zoom) * 4; + check(pixel[0] == native[0] && pixel[1] == native[1] && pixel[2] == native[2], + "magnified map pixel mismatch"); + } + } + for (int y = 0; y < 480; ++y) { + for (int x = 512; x < 640; ++x) { + const auto offset = (y * 640 + x) * 4; + const auto *pixel = pixels.data() + offset; + const auto *native = baseline.data() + offset; + check(pixel[0] == native[0] && pixel[1] == native[1] && pixel[2] == native[2], + "native-size UI pixel mismatch"); + } + } + canvas.release(renderer, SDL_GetWindowID(window)); + std::cout << "PASS: " << name << ' ' << zoom << "x map pixels and native-size UI\n"; +} + +int main(int argc, char **argv) { + try { + check(SDL_Init(SDL_INIT_VIDEO) == 0, "SDL video init"); + bool direct3d = false; + int tested = 0; + const std::string driver_filter = argc > 2 && std::string(argv[1]) == "--driver" ? argv[2] : ""; + for (int index = 0; index < SDL_GetNumRenderDrivers(); ++index) { + SDL_RendererInfo info{}; + check(SDL_GetRenderDriverInfo(index, &info) == 0, "renderer info"); + if (!driver_filter.empty() && driver_filter != info.name) + continue; + std::unique_ptr window( + SDL_CreateWindow("Map canvas regression", 0, 0, 640, 480, SDL_WINDOW_HIDDEN), + SDL_DestroyWindow); + check(window != nullptr, "create hidden window"); + std::unique_ptr renderer( + SDL_CreateRenderer(window.get(), index, SDL_RENDERER_TARGETTEXTURE), SDL_DestroyRenderer); + if (!renderer) { + std::cout << "SKIP: " << info.name << '\n'; + continue; + } + for (int zoom : {2, 4}) + test_driver(renderer.get(), window.get(), info.name, zoom); + direct3d = direct3d || std::string(info.name) == "direct3d"; + ++tested; + } + check(tested > 0, "no available renderer"); + if (argc > 1 && std::string(argv[1]) == "--require-direct3d") + check(direct3d, "Direct3D regression was not exercised"); + SDL_Quit(); + return 0; + } catch (const std::exception &error) { + std::cerr << error.what() << '\n'; + SDL_Quit(); + return 1; + } +} diff --git a/plugins/worldmap-zoom-canvas.h b/plugins/worldmap-zoom-canvas.h index c3ad4dac92..5ad0df9853 100644 --- a/plugins/worldmap-zoom-canvas.h +++ b/plugins/worldmap-zoom-canvas.h @@ -22,229 +22,313 @@ freely, subject to the following restrictions: Adapted for the DFHack build from World Map Zoom 1.0.0. */ -// Included after the verified SDL bindings and state in worldmap-zoom-render.cpp. -// All canvas operations run on the native graphics thread. -struct MapCanvas { - SDL_Renderer *owner = nullptr; - void *texture = nullptr; - unsigned window = 0; - int width = 0, height = 0; - bool active = false, valid = false; - uint64_t camera_epoch = 0, device_epoch = 0; - Camera view{}; - SDL_Rect map{}, source{}, viewport{}, clip{}; - void *previous_target = nullptr; - float sx = 1, sy = 1; - bool clipped = false; - unsigned char color[4]{}; - df::renderer_2d_base *native_renderer = nullptr; - df::graphic_map_portst *native_port = nullptr; - std::vector painted; - int columns = 0, x0 = 0, x1 = 0, y0 = 0, y1 = 0, painted_count = 0, required = 0; - - void error(const char *message) { - std::lock_guard lock(state_mutex); - camera.enabled = false; - last_error = message; - } - bool restore() { - bool ok = sdl.set_target(owner, previous_target) == 0; - ok = (sdl.set_scale(owner, sx, sy) == 0) && ok; - ok = (sdl.set_viewport(owner, &viewport) == 0) && ok; - ok = (sdl.set_clip(owner, clipped ? &clip : nullptr) == 0) && ok; - ok = (sdl.set_color(owner, color[0], color[1], color[2], color[3]) == 0) && ok; - float x, y; - SDL_Rect v{}, c{}; - unsigned char rgba[4]{}; - sdl.get_scale(owner, &x, &y); - sdl.get_viewport(owner, &v); - sdl.get_clip(owner, &c); - sdl.get_color(owner, &rgba[0], &rgba[1], &rgba[2], &rgba[3]); - ok = ok && sdl.get_target(owner) == previous_target && x == sx && y == sy && v.x == viewport.x && - v.y == viewport.y && v.w == viewport.w && v.h == viewport.h && - (sdl.clip_enabled(owner) != 0) == clipped && - (!clipped || (c.x == clip.x && c.y == clip.y && c.w == clip.w && c.h == clip.h)) && - std::equal(color, color + 4, rgba); - ++restore_checks; - if (!ok) { - ++restore_errors; - error("SDL canvas state restoration failed; zoom disabled"); +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +namespace worldmap_zoom { + +// World-map cells are fixed at 16 pixels in DF's map-port renderer. This is +// independent of the text font size and of the requested magnification. +constexpr int map_cell_pixels = 16; +constexpr int max_canvas_pixels = 16384; // bound allocations even if native geometry is corrupt + +struct CanvasView { + double zoom = 1, x = 0, y = 0; + uint64_t generation = 0; +}; + +struct CanvasStats { + uint64_t creations = 0, composites = 0, restore_checks = 0, restore_errors = 0; + uint64_t composited_generation = 0; +}; + +// A render-thread-only target-texture pass. This class has no DF globals, +// viewscreens, callback queue, or implicit dependencies on its includer. +// Api supplies the SDL operations, allowing tests to inject failures without +// redefining DF/SDL types. See the methodology section in the plugin docs. +template class MapCanvas { +public: + explicit MapCanvas(Api &api) : sdl(api) {} + const CanvasStats &stats() const { + return counters; + } + const std::string &error() const { + return failure; + } + uint64_t generation() const { + return view.generation; + } + bool active() const { + return pass_active; + } + void markFullDraw() { + valid = true; + } + void clearError() { + failure.clear(); + } + + bool begin(SDL_Renderer *renderer, uint32_t window_id, const SDL_Rect &map_rect, + const CanvasView &requested_view, uint64_t device_generation, bool full_draw) { + if (!failure.empty()) + return false; + // The texture uses native output coordinates: DF's unmodified draw + // methods can draw at their usual positive origin. A negative/scaled + // viewport clips incorrectly on some Direct3D backends. + if (!validGeometry(map_rect, requested_view)) + return fail("Invalid map canvas geometry or zoom"); + if (!ensureTexture(renderer, window_id, map_rect, device_generation)) + return false; + const bool new_view = view.generation != requested_view.generation || !valid; + if (pass_active && view.generation != requested_view.generation) + finish(false, [] {}); + if (!failure.empty()) + return false; + view = requested_view; + map = map_rect; + configureCrop(); + const bool starting_pass = !pass_active; + if (starting_pass && new_view) { + valid = false; + resetTileCoverage(); + } + if (starting_pass && !beginTargetPass()) + return false; + + // A full rebuild overwrites the whole crop. Incremental updates must + // preserve the existing texture between batches, but a *new* crop has + // no valid pixels yet and must start from a known background. + const bool needs_clear = full_draw || (starting_pass && !valid); + if (needs_clear && !clearTarget()) { + finish(false, [] {}); + return fail("Could not clear map canvas"); } - return ok; + if (sdl.set_clip(owner, &source) != 0) { + finish(false, [] {}); + return fail("SDL rejected the map crop"); + } + return true; } - void finish(bool composite = true) { - if (!active) + + template void finish(bool may_composite, Redraw redraw_full_map) { + if (!pass_active) return; - Camera live; - { - std::lock_guard lock(state_mutex); - live = camera; - } - // A camera change can arrive as individual tile updates. Let that - // batch fill the visible crop before falling back to a full native - // draw, avoiding a redundant full draw at the start of every drag. - if (composite && !valid && live.enabled && live.context == view.context && - current_context() == view.context) { - sdl.set_color(owner, 0, 0, 0, 255); - sdl.clear(owner); - sdl.set_color(owner, color[0], color[1], color[2], color[3]); - ++transform_depth; - native_renderer->update_full_map_port(native_port); - --transform_depth; - valid = true; + // DF may supply only changed cells. If those cells did not cover the + // newly selected crop, redraw the native map once before enlarging it. + // The adapter owns the native pointers; this class only invokes it. + if (may_composite && !valid) { + if (!clearTarget()) { + may_composite = false; + fail("Could not clear incomplete map canvas"); + } else { + redraw_full_map(); + valid = true; + } } - active = false; - if (!restore()) + pass_active = false; + if (!restoreDestinationState()) return; - if (composite && valid && live.enabled && live.context == view.context && - current_context() == view.context) { + // Restore *before* copying: the map belongs in the same native output + // rectangle as before, and subsequent UI draws must use DF's state. + if (may_composite && valid) { if (sdl.copy(owner, texture, &source, &map) != 0) - error("SDL map composition failed; zoom disabled"); + fail("SDL map composition failed"); else { - ++canvas_composites; - composited_generation = camera_epoch; + ++counters.composites; + counters.composited_generation = view.generation; } } } - void release(df::renderer_2d_base *r) { - // SDL_DestroyRenderer frees its textures. Never dereference a texture - // belonging to a renderer/window that has already been replaced. - bool same = texture && r && owner == reinterpret_cast(r->sdl_renderer) && - window == sdl.window_id(reinterpret_cast(r->window)); - if (same) { - finish(false); + + void release(SDL_Renderer *current_renderer, uint32_t current_window) { + // SDL_DestroyRenderer also destroys its textures. If DF replaced the + // renderer/window, forget the old pointer instead of double-freeing it. + const bool same_owner = texture && owner == current_renderer && window == current_window; + if (same_owner) { + finish(false, [] {}); sdl.destroy_texture(texture); - } else - active = false; + } else { + pass_active = false; + } texture = nullptr; owner = nullptr; valid = false; width = height = 0; } - bool begin(df::renderer_2d_base *r, df::graphic_map_portst *port, const Camera &c, bool full) { - auto sr = reinterpret_cast(r->sdl_renderer); - auto wid = sdl.window_id(reinterpret_cast(r->window)); - int w = r->origin_x + port->top_left_corner_x + port->dim_x * 16; - int h = r->origin_y + port->top_left_corner_y + port->dim_y * 16; - if (w < 1 || h < 1 || w > 16384 || h > 16384) { - error("Invalid map canvas dimensions"); - return false; - } - if (texture && (owner != sr || window != wid || width != w || height != h || - device_epoch != target_generation.load())) - release(r); - if (!texture) { - SDL_RendererInfo info{}; - if (sdl.renderer_info(sr, &info) != 0 || !(info.flags & 8) || - (info.max_texture_width > 0 && w > info.max_texture_width) || - (info.max_texture_height > 0 && h > info.max_texture_height)) { - error("Renderer cannot create the required map target texture"); - return false; - } - { - std::lock_guard lock(state_mutex); - renderer_name = info.name ? info.name : "unknown"; - } - texture = sdl.create_texture(sr, 0x16362004, 2, w, h); // ARGB8888 / TARGET - if (!texture) { - error("Could not create map target texture"); - return false; - } - owner = sr; - window = wid; - width = w; - height = h; - valid = false; - ++canvas_creations; - if (sdl.texture_blend(texture, 0) != 0 || sdl.texture_scale(texture, 0) != 0) { - release(r); - error("Could not configure map target texture"); - return false; - } - } - auto cg = camera_generation.load(), dg = target_generation.load(); - if (active && (camera_epoch != cg || device_epoch != dg)) - finish(false); - bool invalidated = camera_epoch != cg || device_epoch != dg || !valid; - if (camera_epoch != cg || device_epoch != dg) - valid = false; - camera_epoch = cg; - device_epoch = dg; - view = c; - map = {r->origin_x + port->top_left_corner_x, r->origin_y + port->top_left_corner_y, port->dim_x * 16, - port->dim_y * 16}; - source = { - map.x + static_cast(std::round(std::clamp(c.x, 0.0, port->dim_x * (1 - 1 / c.zoom)) * 16)), - map.y + static_cast(std::round(std::clamp(c.y, 0.0, port->dim_y * (1 - 1 / c.zoom)) * 16)), - map.w / static_cast(c.zoom), map.h / static_cast(c.zoom)}; - native_renderer = r; - native_port = port; - bool started = !active; - if (started && invalidated) { - columns = port->dim_x; - painted.assign(static_cast(columns) * port->dim_y, 0); - painted_count = 0; - x0 = (source.x - map.x) / 16; - x1 = (source.x - map.x + source.w - 1) / 16; - y0 = (source.y - map.y) / 16; - y1 = (source.y - map.y + source.h - 1) / 16; - required = (x1 - x0 + 1) * (y1 - y0 + 1); - } - if (!active) { - previous_target = sdl.get_target(sr); - sdl.get_scale(sr, &sx, &sy); - sdl.get_viewport(sr, &viewport); - sdl.get_clip(sr, &clip); - clipped = sdl.clip_enabled(sr) != 0; - sdl.get_color(sr, &color[0], &color[1], &color[2], &color[3]); - active = true; - if (sdl.set_target(sr, texture) != 0 || sdl.set_scale(sr, 1, 1) != 0 || - sdl.set_viewport(sr, nullptr) != 0 || sdl.set_clip(sr, nullptr) != 0) { - finish(false); - error("SDL rejected the map canvas; zoom disabled"); - return false; - } + + void clearTile(int x, int y) { + // A changed native cell can become transparent/empty. Clear just that + // cell so stale river/site/cloud pixels do not survive in our cache. + SDL_Rect cell{map.x + x * map_cell_pixels, map.y + y * map_cell_pixels, map_cell_pixels, + map_cell_pixels}; + const bool cleared = + sdl.set_color(owner, 0, 0, 0, SDL_ALPHA_OPAQUE) == 0 && sdl.fill(owner, &cell) == 0; + const bool color_restored = restoreDrawColor(); + if (!cleared || !color_restored) + fail("Could not clear a changed map cell"); + } + + void paintedTile(int x, int y) { + if (valid || x < first_col || x > last_col || y < first_row || y > last_row) + return; + const auto index = static_cast(y) * columns + x; + if (!painted[index]) { + painted[index] = true; + valid = ++painted_count == required_cells; } - if (full || (!valid && started)) { - if (sdl.set_color(sr, 0, 0, 0, 255) != 0 || sdl.clear(sr) != 0 || - sdl.set_color(sr, color[0], color[1], color[2], color[3]) != 0) { - finish(false); - error("Could not clear map canvas; zoom disabled"); - return false; - } + } + +private: + Api &sdl; + SDL_Renderer *owner = nullptr; + SDL_Texture *texture = nullptr; + uint32_t window = 0; + int width = 0, height = 0; + uint64_t device_epoch = 0; + bool pass_active = false, valid = false; + CanvasView view; + CanvasStats counters; + std::string failure; + SDL_Rect map{}, source{}, saved_viewport{}, saved_clip{}; + SDL_Texture *saved_target = nullptr; + float saved_scale_x = 1, saved_scale_y = 1; + bool saved_clipped = false; + std::array saved_color{}; + std::vector painted; + int columns = 0, first_col = 0, last_col = 0, first_row = 0, last_row = 0; + int painted_count = 0, required_cells = 0; + + bool fail(const char *message) { + failure = message; + return false; + } + + bool validGeometry(const SDL_Rect &rect, const CanvasView &v) const { + return rect.x >= 0 && rect.y >= 0 && rect.w > 0 && rect.h > 0 && + static_cast(rect.x) + rect.w <= max_canvas_pixels && + static_cast(rect.y) + rect.h <= max_canvas_pixels && rect.w % map_cell_pixels == 0 && + rect.h % map_cell_pixels == 0 && (v.zoom == 2 || v.zoom == 4) && std::isfinite(v.x) && + std::isfinite(v.y); + } + + bool ensureTexture(SDL_Renderer *renderer, uint32_t window_id, const SDL_Rect &rect, uint64_t epoch) { + const int wanted_width = rect.x + rect.w, wanted_height = rect.y + rect.h; + if (texture && (owner != renderer || window != window_id || width != wanted_width || + height != wanted_height || device_epoch != epoch)) + release(renderer, window_id); + if (texture) + return true; + SDL_RendererInfo info{}; + if (sdl.renderer_info(renderer, &info) != 0 || !(info.flags & SDL_RENDERER_TARGETTEXTURE) || + (info.max_texture_width > 0 && wanted_width > info.max_texture_width) || + (info.max_texture_height > 0 && wanted_height > info.max_texture_height)) + return fail("Renderer cannot create the required map target texture"); + texture = sdl.create_texture(renderer, SDL_PIXELFORMAT_ARGB8888, SDL_TEXTUREACCESS_TARGET, + wanted_width, wanted_height); + if (!texture) + return fail("Could not create map target texture"); + owner = renderer; + window = window_id; + width = wanted_width; + height = wanted_height; + device_epoch = epoch; + valid = false; + ++counters.creations; + // The native map is opaque and nearest-neighbour magnification keeps + // the original pixel art sharp. Do not blend in old window contents. + if (sdl.texture_blend(texture, SDL_BLENDMODE_NONE) != 0 || + sdl.texture_scale(texture, SDL_ScaleModeNearest) != 0) { + release(renderer, window_id); + return fail("Could not configure map target texture"); } - if (sdl.set_clip(sr, &source) != 0) { - finish(false); - error("SDL rejected map canvas clipping"); - return false; + return true; + } + + void configureCrop() { + const double cols = static_cast(map.w) / map_cell_pixels; + const double rows = static_cast(map.h) / map_cell_pixels; + const int offset_x = static_cast( + std::round(std::clamp(view.x, 0.0, cols * (1 - 1 / view.zoom)) * map_cell_pixels)); + const int offset_y = static_cast( + std::round(std::clamp(view.y, 0.0, rows * (1 - 1 / view.zoom)) * map_cell_pixels)); + source = {map.x + offset_x, map.y + offset_y, map.w / static_cast(view.zoom), + map.h / static_cast(view.zoom)}; + } + + void resetTileCoverage() { + columns = map.w / map_cell_pixels; + painted.assign(static_cast(columns) * (map.h / map_cell_pixels), false); + painted_count = 0; + first_col = (source.x - map.x) / map_cell_pixels; + last_col = (source.x - map.x + source.w - 1) / map_cell_pixels; + first_row = (source.y - map.y) / map_cell_pixels; + last_row = (source.y - map.y + source.h - 1) / map_cell_pixels; + required_cells = (last_col - first_col + 1) * (last_row - first_row + 1); + } + + bool beginTargetPass() { + saved_target = sdl.get_target(owner); + sdl.get_scale(owner, &saved_scale_x, &saved_scale_y); + sdl.get_viewport(owner, &saved_viewport); + sdl.get_clip(owner, &saved_clip); + saved_clipped = sdl.clip_enabled(owner) != 0; + sdl.get_color(owner, &saved_color[0], &saved_color[1], &saved_color[2], &saved_color[3]); + pass_active = true; // every failure below must restore the saved state + if (sdl.set_target(owner, texture) != 0 || sdl.set_scale(owner, 1, 1) != 0 || + sdl.set_viewport(owner, nullptr) != 0 || sdl.set_clip(owner, nullptr) != 0) { + finish(false, [] {}); + return fail("SDL rejected the map target pass"); } return true; } - void clear_tile(int x, int y) { - SDL_Rect cell{map.x + x * 16, map.y + y * 16, 16, 16}; - if (sdl.set_color(owner, 0, 0, 0, 255) != 0 || sdl.fill(owner, &cell) != 0 || - sdl.set_color(owner, color[0], color[1], color[2], color[3]) != 0) - error("Could not clear map tile; zoom disabled"); + + bool restoreDrawColor() { + return sdl.set_color(owner, saved_color[0], saved_color[1], saved_color[2], saved_color[3]) == 0; } - void painted_tile(int x, int y) { - if (valid || x < x0 || x > x1 || y < y0 || y > y1) - return; - auto index = static_cast(y) * columns + x; - if (!painted[index]) { - painted[index] = 1; - if (++painted_count == required) - valid = true; + + bool clearTarget() { + // SDL_RenderClear clears the entire target regardless of its clip. + // Preserve DF's draw colour so native drawing observes its usual state. + const bool cleared = sdl.set_color(owner, 0, 0, 0, SDL_ALPHA_OPAQUE) == 0 && sdl.clear(owner) == 0; + return restoreDrawColor() && cleared; + } + + bool restoreDestinationState() { + bool ok = sdl.set_target(owner, saved_target) == 0; + ok = (sdl.set_scale(owner, saved_scale_x, saved_scale_y) == 0) && ok; + ok = (sdl.set_viewport(owner, &saved_viewport) == 0) && ok; + ok = (sdl.set_clip(owner, saved_clipped ? &saved_clip : nullptr) == 0) && ok; + ok = restoreDrawColor() && ok; + // Check the state we restored, not just SDL's return values. A bad + // restore would otherwise corrupt all later interface drawing. + float x = 0, y = 0; + SDL_Rect viewport{}, clip{}; + std::array color{}; + sdl.get_scale(owner, &x, &y); + sdl.get_viewport(owner, &viewport); + sdl.get_clip(owner, &clip); + sdl.get_color(owner, &color[0], &color[1], &color[2], &color[3]); + const auto same_rect = [](const SDL_Rect &a, const SDL_Rect &b) { + return a.x == b.x && a.y == b.y && a.w == b.w && a.h == b.h; + }; + ok = ok && sdl.get_target(owner) == saved_target && x == saved_scale_x && y == saved_scale_y && + same_rect(viewport, saved_viewport) && (sdl.clip_enabled(owner) != 0) == saved_clipped && + (!saved_clipped || same_rect(clip, saved_clip)) && color == saved_color; + ++counters.restore_checks; + if (!ok) { + ++counters.restore_errors; + return fail("SDL canvas state restoration failed"); } + return true; } }; -static MapCanvas map_canvas; -static bool canvas_camera(df::renderer_2d_base *r, df::graphic_map_portst *p, Camera &c) { - if (transform_depth || !hooks_ready || !p || p != gps->main_map_port || r != enabler->renderer || - !p->flag.bits.active || p->dim_x < 1 || p->dim_y < 1) - return false; - { - std::lock_guard lock(state_mutex); - c = camera; - } - return c.enabled && c.zoom > 1 && c.context == current_context(); -} + +} // namespace worldmap_zoom diff --git a/plugins/worldmap-zoom-canvas.test.cpp b/plugins/worldmap-zoom-canvas.test.cpp index 8f9e0c9fa8..ece8670f63 100644 --- a/plugins/worldmap-zoom-canvas.test.cpp +++ b/plugins/worldmap-zoom-canvas.test.cpp @@ -1,78 +1,20 @@ // SPDX-License-Identifier: Zlib -// Exercise the production canvas state machine without a game or GPU. +// The production header is included normally. Tests inject SDL operations, not +// fake DF classes or globals; no renderer/GPU/game is required. #ifdef NDEBUG #undef NDEBUG #endif -#include #include -#include #include -#include -#include #include -#include -#include -#include -struct SDL_Rect { - int x, y, w, h; -}; -struct SDL_Window {}; -struct SDL_Renderer {}; -struct SDL_RendererInfo { - const char *name; - unsigned flags; - int max_texture_width, max_texture_height; -}; -namespace df { -struct graphic_map_portst { - int top_left_corner_x = 16, top_left_corner_y = 32; - int dim_x = 32, dim_y = 24; - struct { - struct { - bool active = true; - } bits; - } flag; -}; -struct renderer_2d_base { - int origin_x = 0, origin_y = 0; - SDL_Renderer *sdl_renderer = nullptr; - SDL_Window *window = nullptr; - int full_draws = 0; - void update_full_map_port(graphic_map_portst *) { - ++full_draws; - } -}; -} // namespace df -struct Camera { - bool enabled = true; - int context = 1; - double zoom = 2, x = 4, y = 3; -}; -static std::mutex state_mutex; -static Camera camera; -static std::string last_error, renderer_name; -static std::atomic_uint64_t camera_generation{1}, target_generation{1}; -static std::atomic_uint64_t canvas_creations{0}, canvas_composites{0}, composited_generation{0}; -static std::atomic_uint64_t restore_checks{0}, restore_errors{0}; -static bool hooks_ready = true; -static thread_local int transform_depth = 0; -static int context = 1; -static int current_context() { - return context; -} -static struct { - df::graphic_map_portst *main_map_port; -} graphics; -static auto gps = &graphics; -static struct { - df::renderer_2d_base *renderer; -} engine; -static auto enabler = &engine; +#include + +#include "worldmap-zoom-canvas.h" struct MockState { - void *target = nullptr; + SDL_Texture *target = nullptr; SDL_Rect viewport{3, 5, 900, 700}, clip{7, 9, 600, 400}; float sx = 1.5f, sy = 2.f; bool clipped = true, fail_target = false, fail_copy = false; @@ -88,12 +30,15 @@ struct MockSdl { return mock.window_id; } int renderer_info(SDL_Renderer *, SDL_RendererInfo *info) { - *info = {"mock", 8, 16384, 16384}; + *info = {}; + info->name = "mock"; + info->flags = SDL_RENDERER_TARGETTEXTURE; + info->max_texture_width = info->max_texture_height = 16384; return 0; } - void *create_texture(SDL_Renderer *, unsigned, int, int, int) { + SDL_Texture *create_texture(SDL_Renderer *, unsigned, int, int, int) { ++mock.textures; - return &texture; + return reinterpret_cast(&texture); } void destroy_texture(void *) { ++mock.destroys; @@ -104,10 +49,10 @@ struct MockSdl { int texture_scale(void *, int) { return 0; } - void *get_target(SDL_Renderer *) { + SDL_Texture *get_target(SDL_Renderer *) { return mock.target; } - int set_target(SDL_Renderer *, void *target) { + int set_target(SDL_Renderer *, SDL_Texture *target) { if (mock.fail_target) return -1; mock.target = target; @@ -168,50 +113,34 @@ struct MockSdl { } } sdl; -#include "worldmap-zoom-canvas.h" - -static SDL_Renderer renderer; -static SDL_Window window; -static df::renderer_2d_base native; -static df::graphic_map_portst port; - -static void reset() { - mock = {}; - camera = {}; - context = 1; - ++camera_generation; - ++target_generation; - last_error.clear(); - native = {}; - native.sdl_renderer = &renderer; - native.window = &window; - gps->main_map_port = &port; - enabler->renderer = &native; -} +static auto *renderer = reinterpret_cast(1); +static constexpr uint32_t window_id = 1; +static constexpr SDL_Rect map{16, 32, 512, 384}; +static worldmap_zoom::CanvasView view{2, 4, 3, 1}; static bool equal(const SDL_Rect &a, const SDL_Rect &b) { return a.x == b.x && a.y == b.y && a.w == b.w && a.h == b.h; } static void crop_and_restore(double zoom) { - reset(); - camera.zoom = zoom; + mock = {}; + view = {zoom, 4, 3, 1}; const auto before = mock; - MapCanvas canvas; - assert(canvas.begin(&native, &port, camera, true)); - assert(mock.target == &texture); - assert(mock.viewport.x == 0 && mock.viewport.y == 0); - assert(mock.sx == 1 && mock.sy == 1); - canvas.valid = true; - canvas.finish(); + worldmap_zoom::MapCanvas canvas(sdl); + assert(canvas.begin(renderer, window_id, map, view, 1, true)); + assert(mock.target == reinterpret_cast(&texture)); + assert(mock.viewport.x == 0 && mock.viewport.y == 0 && mock.sx == 1 && mock.sy == 1); + canvas.markFullDraw(); + canvas.finish(true, [] { assert(false && "A full draw does not need fallback"); }); assert(mock.copies == 1); - assert(mock.copied_source.x == 16 + 4 * 16 && mock.copied_source.y == 32 + 3 * 16); + assert(mock.copied_source.x == 80 && mock.copied_source.y == 80); assert(mock.copied_source.w == 512 / zoom && mock.copied_source.h == 384 / zoom); - assert(equal(mock.copied_destination, SDL_Rect{16, 32, 512, 384})); + assert(equal(mock.copied_destination, map)); assert(mock.target == before.target && mock.sx == before.sx && mock.sy == before.sy); assert(equal(mock.viewport, before.viewport) && equal(mock.clip, before.clip)); assert(mock.clipped == before.clipped && mock.color == before.color); - canvas.release(&native); + assert(canvas.stats().restore_errors == 0); + canvas.release(renderer, window_id); assert(mock.destroys == 1); } @@ -219,61 +148,70 @@ int main() { crop_and_restore(2); crop_and_restore(4); - // Partial tile batches must finish with a full native draw before copying. - reset(); - MapCanvas partial; - assert(partial.begin(&native, &port, camera, false)); - partial.painted_tile(partial.x0, partial.y0); - partial.finish(); - assert(native.full_draws == 1 && mock.copies == 1); - partial.release(&native); + mock = {}; + view = {2, 4, 3, 1}; + int redraws = 0; + worldmap_zoom::MapCanvas partial(sdl); + assert(partial.begin(renderer, window_id, map, view, 1, false)); + partial.paintedTile(4, 3); + partial.finish(true, [&] { ++redraws; }); + assert(redraws == 1 && mock.copies == 1); + partial.release(renderer, window_id); - // A complete tile batch needs no redundant full redraw. - reset(); - MapCanvas complete; - assert(complete.begin(&native, &port, camera, false)); - for (int y = complete.y0; y <= complete.y1; ++y) - for (int x = complete.x0; x <= complete.x1; ++x) - complete.painted_tile(x, y); - complete.finish(); - assert(native.full_draws == 0 && mock.copies == 1); - ++target_generation; - assert(complete.begin(&native, &port, camera, true)); + mock = {}; + redraws = 0; + worldmap_zoom::MapCanvas complete(sdl); + assert(complete.begin(renderer, window_id, map, view, 1, false)); + // Crop: columns [4,19], rows [3,14]. Repeated dirty cells must not count twice. + complete.paintedTile(4, 3); + complete.paintedTile(4, 3); + for (int y = 3; y <= 14; ++y) + for (int x = 4; x <= 19; ++x) + complete.paintedTile(x, y); + complete.finish(true, [&] { ++redraws; }); + assert(redraws == 0 && mock.copies == 1); + assert(complete.begin(renderer, window_id, map, view, 2, true)); assert(mock.textures == 2 && mock.destroys == 1); - complete.release(&native); + complete.release(renderer, window_id); - // Leaving the supported screen must never composite stale geographic data. - reset(); - MapCanvas stale; - assert(stale.begin(&native, &port, camera, false)); - context = 0; - stale.finish(); - assert(mock.copies == 0 && native.full_draws == 0 && !stale.active); - stale.release(&native); + mock = {}; + worldmap_zoom::MapCanvas stale(sdl); + assert(stale.begin(renderer, window_id, map, view, 1, false)); + stale.finish(false, [] { assert(false && "A disabled/stale view must not redraw"); }); + assert(mock.copies == 0 && !stale.active()); + stale.release(renderer, window_id); - // SDL failures disable zoom instead of leaving altered graphics state active. - reset(); - MapCanvas failed; + mock = {}; + worldmap_zoom::MapCanvas failed(sdl); mock.fail_target = true; - assert(!failed.begin(&native, &port, camera, true)); - assert(!camera.enabled && !last_error.empty() && !failed.active && mock.copies == 0); + assert(!failed.begin(renderer, window_id, map, view, 1, true)); + assert(!failed.error().empty() && !failed.active() && mock.copies == 0); mock.fail_target = false; - failed.release(&native); + failed.release(renderer, window_id); - reset(); - MapCanvas copy_failed; - assert(copy_failed.begin(&native, &port, camera, true)); - copy_failed.valid = true; + mock = {}; + const auto before = mock; + worldmap_zoom::MapCanvas copy_failed(sdl); + assert(copy_failed.begin(renderer, window_id, map, view, 1, true)); + copy_failed.markFullDraw(); mock.fail_copy = true; - copy_failed.finish(); - assert(!camera.enabled && !last_error.empty() && !copy_failed.active); - copy_failed.release(&native); + copy_failed.finish(true, [] {}); + assert(!copy_failed.error().empty() && !copy_failed.active()); + assert(equal(mock.viewport, before.viewport) && mock.color == before.color); + copy_failed.release(renderer, window_id); + + mock = {}; + worldmap_zoom::MapCanvas invalid(sdl); + assert(!invalid.begin(renderer, window_id, SDL_Rect{-1, 0, 512, 384}, view, 1, true)); + assert(!invalid.begin(renderer, window_id, SDL_Rect{0, 0, 32768, 384}, view, 1, true)); + assert(mock.textures == 0); - reset(); - Camera selected; - assert(canvas_camera(&native, &port, selected)); - camera.zoom = 1; - assert(!canvas_camera(&native, &port, selected)); - std::cout << "PASS: crop at 2x/4x, state restoration, partial/complete tile batches, " - "device reset, screen changes, SDL failure recovery, and map gating\n"; + mock = {}; + worldmap_zoom::MapCanvas replaced(sdl); + assert(replaced.begin(renderer, window_id, map, view, 1, true)); + // SDL already freed this texture with the old renderer. Do not free it again. + replaced.release(reinterpret_cast(2), window_id + 1); + assert(mock.destroys == 0 && !replaced.active()); + std::cout << "PASS: 2x/4x crops, SDL state, tile coverage, device reset, stale camera, " + "failure recovery, geometry bounds, and renderer replacement\n"; } diff --git a/plugins/worldmap-zoom-render.cpp b/plugins/worldmap-zoom-render.cpp index f2c978b327..5c5a5e1f4f 100644 --- a/plugins/worldmap-zoom-render.cpp +++ b/plugins/worldmap-zoom-render.cpp @@ -26,6 +26,7 @@ Adapted for the DFHack build from World Map Zoom 1.0.0. #define NO_DFHACK_VERSION_MACROS #include +#include #include #include #include @@ -38,7 +39,10 @@ Adapted for the DFHack build from World Map Zoom 1.0.0. #include #include +#include +#include +#include "Core.h" #include "DFHackVersion.h" #include "PluginManager.h" #include "VTableInterpose.h" @@ -52,66 +56,32 @@ Adapted for the DFHack build from World Map Zoom 1.0.0. #include "df/viewscreen_choose_start_sitest.h" #include "df/viewscreen_worldst.h" -#include "lauxlib.h" -#include "lua.h" - -#define WIN32_LEAN_AND_MEAN -#ifndef NOMINMAX -#define NOMINMAX -#endif -#include +#include "worldmap-zoom-canvas.h" +#include "worldmap-zoom-sdl.h" using namespace DFHack; DFHACK_PLUGIN("worldmap-zoom-render"); REQUIRE_GLOBAL(enabler); REQUIRE_GLOBAL(gps); -// Resolve SDL functions from DFHack's SDL handle; no separate SDL runtime. -struct SdlApi { - void (*get_scale)(SDL_Renderer *, float *, float *) = nullptr; - int (*set_scale)(SDL_Renderer *, float, float) = nullptr; - void (*get_viewport)(SDL_Renderer *, SDL_Rect *) = nullptr; - int (*set_viewport)(SDL_Renderer *, const SDL_Rect *) = nullptr; - void (*get_clip)(SDL_Renderer *, SDL_Rect *) = nullptr; - int (*set_clip)(SDL_Renderer *, const SDL_Rect *) = nullptr; - int (*clip_enabled)(SDL_Renderer *) = nullptr; - int (*read_pixels)(SDL_Renderer *, const SDL_Rect *, unsigned, void *, int) = nullptr; - int (*output_size)(SDL_Renderer *, int *, int *) = nullptr; - void (*window_size)(SDL_Window *, int, int) = nullptr; - int (*fullscreen)(SDL_Window *, unsigned) = nullptr; - int (*display_index)(SDL_Window *) = nullptr; - int (*desktop_mode)(int, SDL_DisplayMode *) = nullptr; - unsigned (*window_id)(SDL_Window *) = nullptr; - SDL_Window *(*keyboard_focus)() = nullptr; - void (*add_event_watch)(int (*)(void *, SDL_Event *), void *) = nullptr; - void (*del_event_watch)(int (*)(void *, SDL_Event *), void *) = nullptr; - int (*renderer_info)(SDL_Renderer *, SDL_RendererInfo *) = nullptr; - void *(*get_target)(SDL_Renderer *) = nullptr; - int (*set_target)(SDL_Renderer *, void *) = nullptr; - void *(*create_texture)(SDL_Renderer *, unsigned, int, int, int) = nullptr; - void (*destroy_texture)(void *) = nullptr; - int (*texture_blend)(void *, int) = nullptr; - int (*texture_scale)(void *, int) = nullptr; - int (*get_color)(SDL_Renderer *, unsigned char *, unsigned char *, unsigned char *, - unsigned char *) = nullptr; - int (*set_color)(SDL_Renderer *, unsigned char, unsigned char, unsigned char, unsigned char) = nullptr; - int (*clear)(SDL_Renderer *) = nullptr; - int (*fill)(SDL_Renderer *, const SDL_Rect *) = nullptr; - int (*copy)(SDL_Renderer *, void *, const SDL_Rect *, const SDL_Rect *) = nullptr; -} sdl; - +static WorldMapSdl sdl; struct Camera { bool enabled = false; int context = 0; double zoom = 1, x = 0, y = 0; + const void *port = nullptr; + const void *screen = nullptr; + uint64_t generation = 0; }; static std::mutex state_mutex; static Camera camera; +// Lua and rendering exchange snapshots/diagnostics here. Profiling vectors are +// bounded and mutex-protected; frame_start/frame_started are render-thread-only. static std::string capture_path, last_capture, last_error, capture_error; static std::atomic_bool hooks_ready{false}; static std::atomic_bool build_verified{false}, prepare_pending{false}; static std::atomic_uint64_t full_calls{0}, tile_calls{0}, transformed_calls{0}; -static std::atomic_uint32_t render_thread{0}; +static std::atomic_ulong render_thread{0}; static std::atomic_uint64_t restore_checks{0}, restore_errors{0}; static std::atomic_bool profiling{false}; static std::vector profile_us; @@ -125,6 +95,8 @@ static void start_frame() { } } static std::vector native_ui; +// UI occupancy is captured/read only by the simulation-side Lua client. It is +// separate from the SDL canvas, whose map pixels must not count as controls. static int ui_cols = 0, ui_rows = 0; static std::atomic_int desktop_width{0}, desktop_height{0}, output_width{0}, output_height{0}; static std::atomic_bool window_focused{false}; @@ -136,26 +108,117 @@ static std::atomic_uint64_t camera_generation{0}, target_generation{0}, canvas_c canvas_composites{0}; static std::atomic_uint64_t composited_generation{0}, presented_generation{0}; static thread_local int transform_depth = 0; +static worldmap_zoom::MapCanvas map_canvas(sdl); +static df::renderer_2d_base *batch_renderer = nullptr; +static df::graphic_map_portst *batch_port = nullptr; +// Admission closes before unload cancels queued work. The core task handle +// acknowledges destruction of plugin callbacks, not merely their last write. +static std::recursive_mutex jobs_mutex; +static std::vector> render_jobs; +static std::shared_ptr cleanup_job; +static std::atomic_bool closing{false}; +static bool stopped_inline = false; // only used during render-thread process shutdown + static void remove_hooks(); static int lua_prepare(lua_State *L); +static int lua_stop(lua_State *L); +static bool stopped(); + +static std::shared_ptr queue_job(std::function callback) { + std::lock_guard lock(jobs_mutex); + if (closing) + return {}; + std::erase_if(render_jobs, [](const auto &job) { return job->finished(); }); + // Allocate tracking space before making a callback reachable from core. + // A failed vector allocation must not leave untracked plugin code queued. + render_jobs.reserve(render_jobs.size() + 1); + auto job = queueOnRenderThread(std::move(callback)); + render_jobs.push_back(job); + return job; +} + +static Camera camera_snapshot() { + std::lock_guard lock(state_mutex); + return camera; +} + +// This is the only canvas-to-Lua publication path. All canvas fields are owned +// by the render thread; Lua reads the copied counters and error under the mutex. +static void publish_canvas_status() { + const auto &stats = map_canvas.stats(); + canvas_creations = stats.creations; + canvas_composites = stats.composites; + composited_generation = stats.composited_generation; + restore_checks = stats.restore_checks; + restore_errors = stats.restore_errors; + if (!map_canvas.error().empty()) { + std::lock_guard lock(state_mutex); + camera.enabled = false; + last_error = map_canvas.error(); + } +} + +static void finish_canvas(bool composite = true) { + if (!map_canvas.active()) + return; + const auto live = camera_snapshot(); + const bool current_view = live.enabled && live.generation == map_canvas.generation() && !closing; + map_canvas.finish(composite && current_view, [] { + // Native rendering re-enters our vmethods. The depth guard routes + // nested calls directly to DF while this target pass remains active. + ++transform_depth; + batch_renderer->update_full_map_port(batch_port); + --transform_depth; + }); + publish_canvas_status(); +} + +static void release_canvas(df::renderer_2d_base *renderer) { + auto native = renderer ? reinterpret_cast(renderer->sdl_renderer) : nullptr; + auto window = renderer ? sdl.window_id(reinterpret_cast(renderer->window)) : 0; + map_canvas.release(native, window); + batch_renderer = nullptr; + batch_port = nullptr; + publish_canvas_status(); +} -static bool pin_module() { - // DFHack's render callback queue may outlive a logical plugin unload. Pin - // code until process exit, while detaching hooks and releasing all activity. - // This also closes the fetched-vtable-call/unload race on the graphics thread. - static HMODULE pinned = nullptr; - if (!pinned && !GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN, - reinterpret_cast(&pin_module), &pinned)) +static bool begin_canvas(df::renderer_2d_base *renderer, df::graphic_map_portst *port, const Camera &c, + bool full_draw) { + // Only display-side map-port/renderer fields are read here. Screen mode and + // active map identity were already checked and published on the sim side. + constexpr int cell = worldmap_zoom::map_cell_pixels; + const SDL_Rect rect{renderer->origin_x + port->top_left_corner_x, + renderer->origin_y + port->top_left_corner_y, port->dim_x * cell, port->dim_y * cell}; + const worldmap_zoom::CanvasView view{c.zoom, c.x, c.y, c.generation}; + const bool begun = map_canvas.begin(reinterpret_cast(renderer->sdl_renderer), + sdl.window_id(reinterpret_cast(renderer->window)), rect, + view, target_generation.load(), full_draw); + if (begun) { + batch_renderer = renderer; + batch_port = port; + } + publish_canvas_status(); + return begun; +} + +static bool canvas_camera(df::graphic_map_portst *port, Camera &c) { + constexpr int max_cells = worldmap_zoom::max_canvas_pixels / worldmap_zoom::map_cell_pixels; + if (transform_depth || !hooks_ready || closing || !port || !port->flag.bits.active || port->dim_x < 1 || + port->dim_y < 1 || port->dim_x > max_cells || port->dim_y > max_cells) return false; - return true; + c = camera_snapshot(); + return c.enabled && c.zoom > 1 && c.port == port; } static bool bind_sdl() { auto h = DFSDL::obtain_library_handle(); + if (!h) + return false; #define BIND(member, name) \ sdl.member = reinterpret_cast(LookupPlugin(h, name)); \ if (!sdl.member) \ return false + BIND(thread_id, "SDL_ThreadID"); BIND(get_scale, "SDL_RenderGetScale"); BIND(set_scale, "SDL_RenderSetScale"); BIND(get_viewport, "SDL_RenderGetViewport"); @@ -192,18 +255,19 @@ static bool bind_sdl() { static int watch_focus(void *, SDL_Event *event) { // SDL_WINDOWEVENT / SDL_WINDOWEVENT_FOCUS_LOST. An epoch also catches // loss followed by regain between two Lua updates. No mapped DF access. - if (event && event->window.type == 0x200 && event->window.windowID == watched_window.load()) { - if (event->window.event == 13) { + if (event && event->type == SDL_WINDOWEVENT && event->window.windowID == watched_window.load()) { + if (event->window.event == SDL_WINDOWEVENT_FOCUS_LOST) { window_focused = false; ++focus_epoch; - } else if (event->window.event == 12) + } else if (event->window.event == SDL_WINDOWEVENT_FOCUS_GAINED) window_focused = true; } - if (event && (event->window.type == 0x2000 || event->window.type == 0x2001)) + if (event && (event->type == SDL_RENDER_TARGETS_RESET || event->type == SDL_RENDER_DEVICE_RESET)) ++target_generation; return 1; } +// Simulation/core-lock context only. Renderer hooks must use the published camera. static int current_context() { auto screen = Gui::getDFViewscreen(true); if (auto s = virtual_cast(screen)) @@ -213,9 +277,9 @@ static int current_context() { return 0; } -#include "worldmap-zoom-canvas.h" - static void capture(df::renderer_2d_base *r) { + // A diagnostic capture reads the composed backbuffer just before present. + // File I/O is opt-in; ordinary rendering never writes screenshots. std::string path; { std::lock_guard lock(state_mutex); @@ -231,7 +295,7 @@ static void capture(df::renderer_2d_base *r) { error = "Invalid capture dimensions"; else { std::vector pixels(static_cast(w) * h * 4); - if (sdl.read_pixels(sr, nullptr, 0x16762004, pixels.data(), w * 4) != 0) + if (sdl.read_pixels(sr, nullptr, SDL_PIXELFORMAT_ABGR8888, pixels.data(), w * 4) != 0) error = "SDL_RenderReadPixels failed"; else try { @@ -256,22 +320,22 @@ struct map_hook : df::renderer_2d_base { using interpose_base = df::renderer_2d_base; DEFINE_VMETHOD_INTERPOSE(void, update_full_map_port, (df::graphic_map_portst * port)) { start_frame(); - render_thread = GetCurrentThreadId(); + render_thread = sdl.thread_id(); ++full_calls; const bool measure = profiling.load(); const auto start = measure ? std::chrono::steady_clock::now() : std::chrono::steady_clock::time_point{}; Camera c; - if (!transform_depth && canvas_camera(this, port, c) && map_canvas.begin(this, port, c, true)) { + if (!transform_depth && canvas_camera(port, c) && begin_canvas(this, port, c, true)) { ++transform_depth; INTERPOSE_NEXT(update_full_map_port)(port); --transform_depth; - map_canvas.valid = true; + map_canvas.markFullDraw(); ++transformed_calls; - map_canvas.finish(); + finish_canvas(); } else { if (!transform_depth) - map_canvas.finish(); + finish_canvas(); INTERPOSE_NEXT(update_full_map_port)(port); } if (measure) { @@ -287,24 +351,24 @@ struct map_hook : df::renderer_2d_base { start_frame(); ++tile_calls; Camera c; - if (!transform_depth && canvas_camera(this, port, c) && map_canvas.begin(this, port, c, false)) { + if (!transform_depth && canvas_camera(port, c) && begin_canvas(this, port, c, false)) { ++transform_depth; - map_canvas.clear_tile(x, y); + map_canvas.clearTile(x, y); INTERPOSE_NEXT(update_map_port_tile)(port, x, y); - map_canvas.painted_tile(x, y); + map_canvas.paintedTile(x, y); --transform_depth; ++transformed_calls; // Keep consecutive tile updates in one target pass. The next UI // draw or present composites it once, below the native interface. } else { if (!transform_depth) - map_canvas.finish(); + finish_canvas(); INTERPOSE_NEXT(update_map_port_tile)(port, x, y); } } DEFINE_VMETHOD_INTERPOSE(void, render, ()) { - render_thread = GetCurrentThreadId(); - map_canvas.finish(); + render_thread = sdl.thread_id(); + finish_canvas(); int w = 0, h = 0; sdl.output_size(reinterpret_cast(sdl_renderer), &w, &h); output_width = w; @@ -324,75 +388,130 @@ struct map_hook : df::renderer_2d_base { } frame_started = false; } -#define UI_HOOK(method, signature, args) \ - DEFINE_VMETHOD_INTERPOSE(void, method, signature) { \ - start_frame(); \ - if (!transform_depth) \ - map_canvas.finish(); \ - INTERPOSE_NEXT(method) args; \ - } - UI_HOOK(update_all, (), ()) - UI_HOOK(update_tile, (int32_t x, int32_t y), (x, y)) - UI_HOOK(update_anchor_tile, (int32_t x, int32_t y), (x, y)) - UI_HOOK(update_top_tile, (int32_t x, int32_t y), (x, y)) - UI_HOOK(update_top_anchor_tile, (int32_t x, int32_t y), (x, y)) - UI_HOOK(update_viewport_tile, (df::graphic_viewportst * p, int32_t x, int32_t y), (p, x, y)) - UI_HOOK(update_full_viewport, (df::graphic_viewportst * p), (p)) -#undef UI_HOOK + // Any of these native interface/fortress-viewport draws may follow a + // batch of world-map tile updates. Composite the map before that UI draw. + // The same boundary is skipped during our recursive native map fallback. + DEFINE_VMETHOD_INTERPOSE(void, update_all, ()) { + start_frame(); + if (!transform_depth) + finish_canvas(); + INTERPOSE_NEXT(update_all)(); + } + DEFINE_VMETHOD_INTERPOSE(void, update_tile, (int32_t x, int32_t y)) { + start_frame(); + if (!transform_depth) + finish_canvas(); + INTERPOSE_NEXT(update_tile)(x, y); + } + DEFINE_VMETHOD_INTERPOSE(void, update_anchor_tile, (int32_t x, int32_t y)) { + start_frame(); + if (!transform_depth) + finish_canvas(); + INTERPOSE_NEXT(update_anchor_tile)(x, y); + } + DEFINE_VMETHOD_INTERPOSE(void, update_top_tile, (int32_t x, int32_t y)) { + start_frame(); + if (!transform_depth) + finish_canvas(); + INTERPOSE_NEXT(update_top_tile)(x, y); + } + DEFINE_VMETHOD_INTERPOSE(void, update_top_anchor_tile, (int32_t x, int32_t y)) { + start_frame(); + if (!transform_depth) + finish_canvas(); + INTERPOSE_NEXT(update_top_anchor_tile)(x, y); + } + DEFINE_VMETHOD_INTERPOSE(void, update_viewport_tile, + (df::graphic_viewportst * port, int32_t x, int32_t y)) { + start_frame(); + if (!transform_depth) + finish_canvas(); + INTERPOSE_NEXT(update_viewport_tile)(port, x, y); + } + DEFINE_VMETHOD_INTERPOSE(void, update_full_viewport, (df::graphic_viewportst * port)) { + start_frame(); + if (!transform_depth) + finish_canvas(); + INTERPOSE_NEXT(update_full_viewport)(port); + } DEFINE_VMETHOD_INTERPOSE(void, do_blank_screen_fill, ()) { start_frame(); - map_canvas.finish(false); + finish_canvas(false); INTERPOSE_NEXT(do_blank_screen_fill)(); } DEFINE_VMETHOD_INTERPOSE(void, resize, (int32_t w, int32_t h)) { - map_canvas.release(this); + release_canvas(this); INTERPOSE_NEXT(resize)(w, h); } DEFINE_VMETHOD_INTERPOSE(void, grid_resize, (int32_t w, int32_t h)) { - map_canvas.release(this); + release_canvas(this); INTERPOSE_NEXT(grid_resize)(w, h); } DEFINE_VMETHOD_INTERPOSE(void, set_fullscreen, ()) { - map_canvas.release(this); + release_canvas(this); INTERPOSE_NEXT(set_fullscreen)(); } DEFINE_VMETHOD_INTERPOSE(bool, init_video, (int32_t w, int32_t h)) { - map_canvas.release(this); + release_canvas(this); return INTERPOSE_NEXT(init_video)(w, h); } }; IMPLEMENT_VMETHOD_INTERPOSE(map_hook, update_full_map_port); IMPLEMENT_VMETHOD_INTERPOSE(map_hook, update_map_port_tile); IMPLEMENT_VMETHOD_INTERPOSE(map_hook, render); -#define CANVAS_BOUNDARIES(X) \ - X(update_all) \ - X(update_tile) \ - X(update_anchor_tile) \ - X(update_top_tile) X(update_top_anchor_tile) X(update_viewport_tile) X(update_full_viewport) \ - X(do_blank_screen_fill) X(resize) X(grid_resize) X(set_fullscreen) X(init_video) -#define DECLARE_BOUNDARY(name) IMPLEMENT_VMETHOD_INTERPOSE(map_hook, name); -CANVAS_BOUNDARIES(DECLARE_BOUNDARY) -#undef DECLARE_BOUNDARY +IMPLEMENT_VMETHOD_INTERPOSE(map_hook, update_all); +IMPLEMENT_VMETHOD_INTERPOSE(map_hook, update_tile); +IMPLEMENT_VMETHOD_INTERPOSE(map_hook, update_anchor_tile); +IMPLEMENT_VMETHOD_INTERPOSE(map_hook, update_top_tile); +IMPLEMENT_VMETHOD_INTERPOSE(map_hook, update_top_anchor_tile); +IMPLEMENT_VMETHOD_INTERPOSE(map_hook, update_viewport_tile); +IMPLEMENT_VMETHOD_INTERPOSE(map_hook, update_full_viewport); +IMPLEMENT_VMETHOD_INTERPOSE(map_hook, do_blank_screen_fill); +IMPLEMENT_VMETHOD_INTERPOSE(map_hook, resize); +IMPLEMENT_VMETHOD_INTERPOSE(map_hook, grid_resize); +IMPLEMENT_VMETHOD_INTERPOSE(map_hook, set_fullscreen); +IMPLEMENT_VMETHOD_INTERPOSE(map_hook, init_video); + +// One explicit registration list is shared by installation and removal. +static const std::array renderer_hooks{ + &INTERPOSE_HOOK(map_hook, update_full_map_port), + &INTERPOSE_HOOK(map_hook, update_map_port_tile), + &INTERPOSE_HOOK(map_hook, render), + &INTERPOSE_HOOK(map_hook, update_all), + &INTERPOSE_HOOK(map_hook, update_tile), + &INTERPOSE_HOOK(map_hook, update_anchor_tile), + &INTERPOSE_HOOK(map_hook, update_top_tile), + &INTERPOSE_HOOK(map_hook, update_top_anchor_tile), + &INTERPOSE_HOOK(map_hook, update_viewport_tile), + &INTERPOSE_HOOK(map_hook, update_full_viewport), + &INTERPOSE_HOOK(map_hook, do_blank_screen_fill), + &INTERPOSE_HOOK(map_hook, resize), + &INTERPOSE_HOOK(map_hook, grid_resize), + &INTERPOSE_HOOK(map_hook, set_fullscreen), + &INTERPOSE_HOOK(map_hook, init_video), +}; static void remove_hooks() { if (enabler && enabler->renderer) - map_canvas.release(virtual_cast(enabler->renderer)); + release_canvas(virtual_cast(enabler->renderer)); if (event_watch_installed) { + // SDL2 invokes/removes event watches under the same internal mutex. + // We are outside event dispatch, so return from DelEventWatch also + // excludes an event-watch invocation on a foreign SDL_PushEvent thread. sdl.del_event_watch(watch_focus, nullptr); event_watch_installed = false; } watched_window = 0; window_focused = false; hooks_ready = false; - INTERPOSE_HOOK(map_hook, update_full_map_port).remove(); - INTERPOSE_HOOK(map_hook, update_map_port_tile).remove(); - INTERPOSE_HOOK(map_hook, render).remove(); -#define REMOVE_BOUNDARY(name) INTERPOSE_HOOK(map_hook, name).remove(); - CANVAS_BOUNDARIES(REMOVE_BOUNDARY) -#undef REMOVE_BOUNDARY + for (auto *hook : renderer_hooks) + hook->remove(); } static int lua_camera(lua_State *L) { + std::lock_guard admission(jobs_mutex); + if (closing) + return luaL_error(L, "Renderer is stopping"); Camera c; c.enabled = lua_toboolean(L, 1) != 0; const auto context = luaL_checkinteger(L, 2); @@ -409,45 +528,53 @@ static int lua_camera(lua_State *L) { return luaL_error(L, "Renderer is not ready or the high-level map is no longer open"); { std::lock_guard lock(state_mutex); + if (closing) + return luaL_error(L, "Renderer is stopping"); + c.port = c.enabled ? gps->main_map_port : nullptr; + c.screen = c.enabled ? Gui::getDFViewscreen(true) : nullptr; + c.generation = ++camera_generation; camera = c; } - ++camera_generation; if (!c.enabled && build_verified) - runOnRenderThread([] { map_canvas.release(virtual_cast(enabler->renderer)); }); + queue_job([] { release_canvas(virtual_cast(enabler->renderer)); }); if (build_verified && gps) gps->force_full_display_count = 2; return 0; } static int zoom_status(lua_State *L) { + std::lock_guard admission(jobs_mutex); lua_newtable(L); -#define INT_FIELD(name, value) \ - lua_pushinteger(L, static_cast(value)); \ - lua_setfield(L, -2, name) + const auto int_field = [L](const char *name, auto value) { + lua_pushinteger(L, static_cast(value)); + lua_setfield(L, -2, name); + }; lua_pushboolean(L, hooks_ready); lua_setfield(L, -2, "ready"); - INT_FIELD("full_calls", full_calls.load()); - INT_FIELD("tile_calls", tile_calls.load()); - INT_FIELD("transformed_calls", transformed_calls.load()); - INT_FIELD("render_thread", render_thread.load()); - INT_FIELD("context", build_verified ? current_context() : 0); - INT_FIELD("restore_checks", restore_checks.load()); - INT_FIELD("restore_errors", restore_errors.load()); - INT_FIELD("desktop_width", desktop_width.load()); - INT_FIELD("desktop_height", desktop_height.load()); - INT_FIELD("output_width", output_width.load()); - INT_FIELD("output_height", output_height.load()); - INT_FIELD("focus_epoch", focus_epoch.load()); - INT_FIELD("canvas_creations", canvas_creations.load()); - INT_FIELD("canvas_composites", canvas_composites.load()); - INT_FIELD("camera_generation", camera_generation.load()); - INT_FIELD("presented_generation", presented_generation.load()); + lua_pushboolean(L, stopped()); + lua_setfield(L, -2, "stopped"); + int_field("full_calls", full_calls.load()); + int_field("tile_calls", tile_calls.load()); + int_field("transformed_calls", transformed_calls.load()); + int_field("render_thread", render_thread.load()); + int_field("context", build_verified ? current_context() : 0); + int_field("restore_checks", restore_checks.load()); + int_field("restore_errors", restore_errors.load()); + int_field("desktop_width", desktop_width.load()); + int_field("desktop_height", desktop_height.load()); + int_field("output_width", output_width.load()); + int_field("output_height", output_height.load()); + int_field("focus_epoch", focus_epoch.load()); + int_field("canvas_creations", canvas_creations.load()); + int_field("canvas_composites", canvas_composites.load()); + int_field("camera_generation", camera_generation.load()); + int_field("presented_generation", presented_generation.load()); lua_pushboolean(L, window_focused.load()); lua_setfield(L, -2, "window_focused"); { std::lock_guard lock(state_mutex); - INT_FIELD("profile_samples", profile_us.size()); - INT_FIELD("frame_profile_samples", frame_profile_us.size()); + int_field("profile_samples", profile_us.size()); + int_field("frame_profile_samples", frame_profile_us.size()); lua_pushstring(L, last_error.c_str()); lua_setfield(L, -2, "error"); lua_pushstring(L, renderer_name.c_str()); @@ -461,6 +588,9 @@ static int zoom_status(lua_State *L) { } static int lua_profile(lua_State *L) { + std::lock_guard admission(jobs_mutex); + if (closing) + return luaL_error(L, "Renderer is stopping"); if (!build_verified || !hooks_ready) return luaL_error(L, "Renderer is not ready"); std::lock_guard lock(state_mutex); @@ -479,6 +609,9 @@ static int lua_profile(lua_State *L) { } static int lua_frame_profile(lua_State *L) { + std::lock_guard admission(jobs_mutex); + if (closing) + return luaL_error(L, "Renderer is stopping"); if (!build_verified || !hooks_ready) return luaL_error(L, "Renderer is not ready"); std::lock_guard lock(state_mutex); @@ -494,6 +627,9 @@ static int lua_frame_profile(lua_State *L) { // Called after native parent rendering and BEFORE drawing the Lua controls. // UI lives in the text buffers; geographic layers live in main_map_port. static int lua_snapshot_ui(lua_State *L) { + std::lock_guard admission(jobs_mutex); + if (closing) + return luaL_error(L, "Renderer is stopping"); if (!build_verified || !hooks_ready) return luaL_error(L, "Renderer is not ready"); ui_cols = gps->dimx; @@ -514,6 +650,9 @@ static int lua_snapshot_ui(lua_State *L) { } static int lua_ui_hit(lua_State *L) { + std::lock_guard admission(jobs_mutex); + if (closing) + return luaL_error(L, "Renderer is stopping"); if (!build_verified || !hooks_ready) return luaL_error(L, "Renderer is not ready"); const int x = static_cast(luaL_checkinteger(L, 1)), y = static_cast(luaL_checkinteger(L, 2)); @@ -530,6 +669,9 @@ static int lua_ui_hit(lua_State *L) { } static int lua_window(lua_State *L) { + std::lock_guard admission(jobs_mutex); + if (closing) + return luaL_error(L, "Renderer is stopping"); if (!build_verified || !hooks_ready) return luaL_error(L, "Renderer is not ready"); const int w = static_cast(luaL_checkinteger(L, 1)), h = static_cast(luaL_checkinteger(L, 2)); @@ -538,7 +680,7 @@ static int lua_window(lua_State *L) { return luaL_error(L, "Use native DF settings for fullscreen tests"); if (w < 640 || h < 480 || w > 7680 || h > 4320) return luaL_error(L, "Invalid test window dimensions"); - runOnRenderThread([w, h] { + queue_job([w, h] { auto r = virtual_cast(enabler->renderer); if (!r) return; @@ -548,9 +690,9 @@ static int lua_window(lua_State *L) { // RESIZED. Let the native event loop pause simulation and reshape its // buffers, exactly as for an ordinary user window resize. SDL_Event event{}; - event.window.type = 0x200; + event.window.type = SDL_WINDOWEVENT; event.window.windowID = sdl.window_id(window); - event.window.event = 5; + event.window.event = SDL_WINDOWEVENT_RESIZED; event.window.data1 = w; event.window.data2 = h; DFSDL::DFSDL_PushEvent(&event); @@ -559,6 +701,9 @@ static int lua_window(lua_State *L) { } static int lua_capture(lua_State *L) { + std::lock_guard admission(jobs_mutex); + if (closing) + return luaL_error(L, "Renderer is stopping"); if (!build_verified || !hooks_ready) return luaL_error(L, "Renderer is not ready"); std::string path = luaL_checkstring(L, 1); @@ -571,26 +716,39 @@ static int lua_capture(lua_State *L) { } static int lua_test_focus(lua_State *L) { + std::lock_guard admission(jobs_mutex); + if (closing) + return luaL_error(L, "Renderer is stopping"); if (!build_verified || !hooks_ready) return luaL_error(L, "Renderer is not ready"); const bool focused = lua_toboolean(L, 1) != 0; - runOnRenderThread([focused] { + queue_job([focused] { SDL_Event event{}; - event.window.type = 0x200; + event.window.type = SDL_WINDOWEVENT; event.window.windowID = watched_window.load(); - event.window.event = focused ? 12 : 13; + event.window.event = focused ? SDL_WINDOWEVENT_FOCUS_GAINED : SDL_WINDOWEVENT_FOCUS_LOST; DFSDL::DFSDL_PushEvent(&event); }); return 0; } -DFhackCExport CommandReg plugin_lua_commands[] = { - {"prepare", lua_prepare}, {"setCamera", lua_camera}, {"status", zoom_status}, - {"capture", lua_capture}, {"profile", lua_profile}, {"snapshotUi", lua_snapshot_ui}, - {"uiHit", lua_ui_hit}, {"testWindow", lua_window}, {"frameProfile", lua_frame_profile}, - {"testFocus", lua_test_focus}, {nullptr, nullptr}}; +DFhackCExport CommandReg plugin_lua_commands[] = {{"stop", lua_stop}, + {"prepare", lua_prepare}, + {"setCamera", lua_camera}, + {"status", zoom_status}, + {"capture", lua_capture}, + {"profile", lua_profile}, + {"snapshotUi", lua_snapshot_ui}, + {"uiHit", lua_ui_hit}, + {"testWindow", lua_window}, + {"frameProfile", lua_frame_profile}, + {"testFocus", lua_test_focus}, + {nullptr, nullptr}}; static int lua_prepare(lua_State *L) { + std::lock_guard admission(jobs_mutex); + if (closing) + return luaL_error(L, "Renderer is stopping; reload the plugin before preparing again"); // Validate runtime metadata BEFORE reading mapped renderer fields or // installing hooks. Compare with the metadata linked into this build, not a fixed release. // Plugin metadata alone only checks the release/ABI. @@ -605,7 +763,7 @@ static int lua_prepare(lua_State *L) { return luaL_error(L, "Unsupported DFHack build/structures"); lua_pop(L, 2); } - if (!enabler || !virtual_cast(enabler->renderer) || !bind_sdl()) { + if (!enabler || !virtual_cast(enabler->renderer)) { return luaL_error(L, "Unsupported renderer: requires the tested SDL2 renderer"); } if (hooks_ready && build_verified) { @@ -616,20 +774,17 @@ static int lua_prepare(lua_State *L) { lua_pushboolean(L, false); return 1; } - if (!pin_module()) { - prepare_pending = false; - return luaL_error(L, "Could not retain the renderer module safely"); - } { std::lock_guard lock(state_mutex); last_error.clear(); } build_verified = true; - runOnRenderThread([] { + queue_job([] { if (!build_verified) { prepare_pending = false; return; } + map_canvas.clearError(); auto r = virtual_cast(enabler->renderer); if (r) { SDL_RendererInfo info{}; @@ -650,12 +805,11 @@ static int lua_prepare(lua_State *L) { desktop_height = mode.h; } } - bool ok = INTERPOSE_HOOK(map_hook, update_full_map_port).apply() && - INTERPOSE_HOOK(map_hook, update_map_port_tile).apply() && - INTERPOSE_HOOK(map_hook, render).apply(); -#define APPLY_BOUNDARY(name) ok = INTERPOSE_HOOK(map_hook, name).apply() && ok; - CANVAS_BOUNDARIES(APPLY_BOUNDARY) -#undef APPLY_BOUNDARY + bool ok = true; + for (auto *hook : renderer_hooks) { + if (!hook->apply()) + ok = false; + } if (!ok) { remove_hooks(); std::lock_guard lock(state_mutex); @@ -670,34 +824,109 @@ static int lua_prepare(lua_State *L) { return 1; } +// Start teardown without waiting. This can be requested from Lua, where +// blocking the sim/core-lock context would deadlock a render thread waiting for +// the next simulation acknowledgement. status().stopped is the completion fence. +static void request_stop() { + std::lock_guard lock(jobs_mutex); + const bool retry_failed_cleanup = !cleanup_job || (cleanup_job->finished() && !cleanup_job->succeeded()); + if (closing.exchange(true) && !retry_failed_cleanup) + return; + for (const auto &job : render_jobs) + job->cancel(); + { + std::lock_guard state_lock(state_mutex); + camera = {}; + } + cleanup_job = queueOnRenderThread([] { + remove_hooks(); + build_verified = false; + prepare_pending = false; + if (gps) + gps->force_full_display_count = 2; + }); +} + +static bool stopped() { + std::lock_guard lock(jobs_mutex); + return stopped_inline || (cleanup_job && cleanup_job->finished() && cleanup_job->succeeded()); +} + +static int lua_stop(lua_State *L) { + std::lock_guard admission(jobs_mutex); + request_stop(); + lua_pushboolean(L, stopped()); + return 1; +} + DFhackCExport command_result plugin_init(color_ostream &, std::vector &) { + // Function pointers are immutable once hooks can run on the render thread. + return bind_sdl() ? CR_OK : CR_FAILURE; +} + +DFhackCExport command_result plugin_onupdate(color_ostream &) { + // This callback runs on simulation, where inspecting viewscreens is legal. + // Publish only a disable decision; rendering never follows a screen pointer. + std::lock_guard lock(state_mutex); + if (camera.enabled && + (closing || camera.context != current_context() || camera.port != gps->main_map_port || + camera.screen != Gui::getDFViewscreen(true))) { + camera.enabled = false; + camera.generation = ++camera_generation; + } return CR_OK; } -DFhackCExport command_result plugin_onstatechange(color_ostream &, state_change_event event) { +DFhackCExport command_result plugin_onstatechange(color_ostream &out, state_change_event event) { if (event == SC_WORLD_UNLOADED || event == SC_MAP_UNLOADED) { std::lock_guard lock(state_mutex); camera = {}; - if (build_verified) - runOnRenderThread( - [] { map_canvas.release(virtual_cast(enabler->renderer)); }); + } + if (event != SC_BEGIN_UNLOAD) + return CR_OK; + auto &core = Core::getInstance(); + if (core.isRenderThread()) { + // Core::Shutdown runs here after the SDL loop, before DF destroys its + // renderer. There can be no renderer hook in flight on this thread. + std::lock_guard lock(jobs_mutex); + closing = true; + for (const auto &job : render_jobs) + job->cancel(); + if (cleanup_job) + cleanup_job->cancel(); + remove_hooks(); + build_verified = false; + stopped_inline = true; + return CR_OK; + } + if (stopped()) + return CR_OK; + if (!build_verified && !prepare_pending) + return CR_OK; // never prepared: no callbacks, event watch, or hooks + if (core.isSuspended()) { + out.printerr("Cannot wait for renderer cleanup while holding the core lock. " + "Call stop(), wait for status().stopped on later frames, then unload; " + "or run unload from the DFHack console.\n"); + return CR_FAILURE; + } + request_stop(); + // The console path owns no core lock. A bounded wait avoids hanging unload + // if the game has stopped servicing SDL. A timeout REFUSES unload while + // keeping the module and cleanup task alive; it never unloads pending code. + if (!cleanup_job->waitFor(std::chrono::milliseconds(2000)) || !stopped()) { + out.printerr( + "Renderer cleanup is not complete; unload refused. Retry after the next rendered frame.\n"); + return CR_FAILURE; } return CR_OK; } DFhackCExport command_result plugin_shutdown(color_ostream &) { - { - std::lock_guard lock(state_mutex); - camera = {}; - } + // SC_BEGIN_UNLOAD established that every callback/capture and renderer + // entry point is gone. No plugin lambda is queued after this return. profiling = false; native_ui.clear(); - ui_cols = ui_rows = 0; - if (build_verified.exchange(false)) - runOnRenderThread([] { - remove_hooks(); - if (gps) - gps->force_full_display_count = 2; - }); + render_jobs.clear(); + cleanup_job.reset(); return CR_OK; } diff --git a/plugins/worldmap-zoom-sdl.h b/plugins/worldmap-zoom-sdl.h new file mode 100644 index 0000000000..45009ad739 --- /dev/null +++ b/plugins/worldmap-zoom-sdl.h @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: Zlib +#pragma once + +#include + +// Bind to the SDL instance DF already owns; never load/link a second runtime. +struct WorldMapSdl { + decltype(&SDL_ThreadID) thread_id = nullptr; + decltype(&SDL_RenderGetScale) get_scale = nullptr; + decltype(&SDL_RenderSetScale) set_scale = nullptr; + decltype(&SDL_RenderGetViewport) get_viewport = nullptr; + decltype(&SDL_RenderSetViewport) set_viewport = nullptr; + decltype(&SDL_RenderGetClipRect) get_clip = nullptr; + decltype(&SDL_RenderSetClipRect) set_clip = nullptr; + decltype(&SDL_RenderIsClipEnabled) clip_enabled = nullptr; + decltype(&SDL_RenderReadPixels) read_pixels = nullptr; + decltype(&SDL_GetRendererOutputSize) output_size = nullptr; + decltype(&SDL_SetWindowSize) window_size = nullptr; + decltype(&SDL_SetWindowFullscreen) fullscreen = nullptr; + decltype(&SDL_GetWindowDisplayIndex) display_index = nullptr; + decltype(&SDL_GetDesktopDisplayMode) desktop_mode = nullptr; + decltype(&SDL_GetWindowID) window_id = nullptr; + decltype(&SDL_GetKeyboardFocus) keyboard_focus = nullptr; + decltype(&SDL_AddEventWatch) add_event_watch = nullptr; + decltype(&SDL_DelEventWatch) del_event_watch = nullptr; + decltype(&SDL_GetRendererInfo) renderer_info = nullptr; + decltype(&SDL_GetRenderTarget) get_target = nullptr; + decltype(&SDL_SetRenderTarget) set_target = nullptr; + decltype(&SDL_CreateTexture) create_texture = nullptr; + decltype(&SDL_DestroyTexture) destroy_texture = nullptr; + decltype(&SDL_SetTextureBlendMode) texture_blend = nullptr; + decltype(&SDL_SetTextureScaleMode) texture_scale = nullptr; + decltype(&SDL_GetRenderDrawColor) get_color = nullptr; + decltype(&SDL_SetRenderDrawColor) set_color = nullptr; + decltype(&SDL_RenderClear) clear = nullptr; + decltype(&SDL_RenderFillRect) fill = nullptr; + decltype(&SDL_RenderCopy) copy = nullptr; +}; From 2fcb9c906f7eb07c16ff8f52d82462102029ac40 Mon Sep 17 00:00:00 2001 From: Huitzilopochtli-98 <349095218+Huitzilopochtli-98@users.noreply.github.com> Date: Thu, 5 Nov 2026 07:44:12 -0500 Subject: [PATCH 4/4] Fence unprepared stops before permitting renderer unload --- docs/plugins/worldmap-zoom-render.rst | 8 ++++++- plugins/worldmap-zoom-render.cpp | 32 ++++++++++++++++++++------- test/plugins/worldmap-zoom-render.lua | 26 ++++++++++++++++++++++ 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/docs/plugins/worldmap-zoom-render.rst b/docs/plugins/worldmap-zoom-render.rst index 9539a49235..2bfdb7a1a3 100644 --- a/docs/plugins/worldmap-zoom-render.rst +++ b/docs/plugins/worldmap-zoom-render.rst @@ -186,10 +186,16 @@ no renderer hook can be in flight when that cleanup returns. ``status().stopped` becomes true only after the core has destroyed the cleanup callback as well. There is no Windows module pin and no platform-specific include in the backend. +Even ``stop()`` on a never-prepared service queues cleanup. The idle unload +check therefore verifies that there is no queued task or cleanup handle and +closes admission under the same mutex as Lua calls before granting unload. +The native title-screen regression stops an unprepared service, attempts unload, +waits for cleanup if needed, drains later frames with the DLL unloaded, and reloads. + An ordinary console ``unload worldmap-zoom-render`` owns no core lock and can wait for this fence. The wait is bounded: if SDL is not progressing, unload is refused while the module stays loaded. Retrying after a frame is safe. -An unload attempted from Lua/simulation while still prepared is refused rather +An unload attempted from Lua/simulation while render cleanup is pending is refused rather than waiting with the core lock held. A Lua owner can stop asynchronously:: local function unload_when_stopped() diff --git a/plugins/worldmap-zoom-render.cpp b/plugins/worldmap-zoom-render.cpp index 5c5a5e1f4f..be76bbb2e2 100644 --- a/plugins/worldmap-zoom-render.cpp +++ b/plugins/worldmap-zoom-render.cpp @@ -117,7 +117,7 @@ static std::recursive_mutex jobs_mutex; static std::vector> render_jobs; static std::shared_ptr cleanup_job; static std::atomic_bool closing{false}; -static bool stopped_inline = false; // only used during render-thread process shutdown +static bool stopped_without_task = false; // guarded by jobs_mutex: idle or inline shutdown static void remove_hooks(); static int lua_prepare(lua_State *L); @@ -829,6 +829,8 @@ static int lua_prepare(lua_State *L) { // the next simulation acknowledgement. status().stopped is the completion fence. static void request_stop() { std::lock_guard lock(jobs_mutex); + if (stopped_without_task) + return; const bool retry_failed_cleanup = !cleanup_job || (cleanup_job->finished() && !cleanup_job->succeeded()); if (closing.exchange(true) && !retry_failed_cleanup) return; @@ -849,7 +851,7 @@ static void request_stop() { static bool stopped() { std::lock_guard lock(jobs_mutex); - return stopped_inline || (cleanup_job && cleanup_job->finished() && cleanup_job->succeeded()); + return stopped_without_task || (cleanup_job && cleanup_job->finished() && cleanup_job->succeeded()); } static int lua_stop(lua_State *L) { @@ -896,13 +898,22 @@ DFhackCExport command_result plugin_onstatechange(color_ostream &out, state_chan cleanup_job->cancel(); remove_hooks(); build_verified = false; - stopped_inline = true; + stopped_without_task = true; return CR_OK; } - if (stopped()) - return CR_OK; - if (!build_verified && !prepare_pending) - return CR_OK; // never prepared: no callbacks, event watch, or hooks + { + std::lock_guard lock(jobs_mutex); + if (stopped()) + return CR_OK; + // "Never prepared" is not sufficient: stop() can itself own a queued + // cleanup callback. Serialize this check with every Lua entry point, + // then close admission before allowing an idle module to unload. + if (!build_verified && !prepare_pending && render_jobs.empty() && !cleanup_job) { + closing = true; + stopped_without_task = true; + return CR_OK; + } + } if (core.isSuspended()) { out.printerr("Cannot wait for renderer cleanup while holding the core lock. " "Call stop(), wait for status().stopped on later frames, then unload; " @@ -910,10 +921,15 @@ DFhackCExport command_result plugin_onstatechange(color_ostream &out, state_chan return CR_FAILURE; } request_stop(); + std::shared_ptr cleanup; + { + std::lock_guard lock(jobs_mutex); + cleanup = cleanup_job; + } // The console path owns no core lock. A bounded wait avoids hanging unload // if the game has stopped servicing SDL. A timeout REFUSES unload while // keeping the module and cleanup task alive; it never unloads pending code. - if (!cleanup_job->waitFor(std::chrono::milliseconds(2000)) || !stopped()) { + if (!cleanup || !cleanup->waitFor(std::chrono::milliseconds(2000)) || !stopped()) { out.printerr( "Renderer cleanup is not complete; unload refused. Retry after the next rendered frame.\n"); return CR_FAILURE; diff --git a/test/plugins/worldmap-zoom-render.lua b/test/plugins/worldmap-zoom-render.lua index 4fb62076a4..5a87c73727 100644 --- a/test/plugins/worldmap-zoom-render.lua +++ b/test/plugins/worldmap-zoom-render.lua @@ -53,3 +53,29 @@ function test.external_client_aliases() expect.eq(renderer.snapshotUi, renderer.snapshot_ui) expect.eq(renderer.uiHit, renderer.ui_hit) end + +function test.stop_without_prepare_requires_cleanup_before_unload() + -- Loading a plugin is not the only way it can own a queued callback: + -- stop() itself queues cleanup, even when no renderer hooks were prepared. + local name = 'worldmap-zoom-render' + local _, result = dfhack.run_command_silent('unload', name) + expect.eq(0, result) + _, result = dfhack.run_command_silent('load', name) + expect.eq(0, result) + renderer.stop() + _, result = dfhack.run_command_silent('unload', name) + if result ~= 0 then + -- A suspended test cannot wait synchronously for the render thread. + delay_until(function() return renderer.status().stopped end, 120) + expect.true_(renderer.status().stopped) + _, result = dfhack.run_command_silent('unload', name) + end + expect.eq(0, result) + expect.error_match('has been unloaded', function() renderer.status() end) + -- Allow core to drain queue wrappers after the DLL is gone. They must not + -- retain the unprepared stop callback or its plugin-owned function manager. + delay(2) + _, result = dfhack.run_command_silent('load', name) + expect.eq(0, result) + expect.false_(renderer.status().ready) +end