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..30a58cdaed 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 @@ -77,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/docs/plugins/worldmap-zoom-render.rst b/docs/plugins/worldmap-zoom-render.rst new file mode 100644 index 0000000000..2bfdb7a1a3 --- /dev/null +++ b/docs/plugins/worldmap-zoom-render.rst @@ -0,0 +1,328 @@ +worldmap-zoom-render +==================== + +.. dfhack-tool:: + :summary: Provides native composition for high-level world-map zoom. + :tags: dev + :no-command: + +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. + +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 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() + 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 + 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.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``. + 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, ``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 + 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. 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"; +} diff --git a/plugins/CMakeLists.txt b/plugins/CMakeLists.txt index f8a75436bd..0cca7d4e63 100644 --- a/plugins/CMakeLists.txt +++ b/plugins/CMakeLists.txt @@ -137,6 +137,20 @@ if(BUILD_SUPPORTED) add_subdirectory(tweak) #dfhack_plugin(workflow workflow.cpp LINK_LIBRARIES lua) dfhack_plugin(work-now work-now.cpp) + # 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 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 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) 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-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 new file mode 100644 index 0000000000..5ad0df9853 --- /dev/null +++ b/plugins/worldmap-zoom-canvas.h @@ -0,0 +1,334 @@ +/* +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. +*/ + +#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"); + } + if (sdl.set_clip(owner, &source) != 0) { + finish(false, [] {}); + return fail("SDL rejected the map crop"); + } + return true; + } + + template void finish(bool may_composite, Redraw redraw_full_map) { + if (!pass_active) + return; + // 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; + } + } + pass_active = false; + if (!restoreDestinationState()) + return; + // 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) + fail("SDL map composition failed"); + else { + ++counters.composites; + counters.composited_generation = view.generation; + } + } + } + + 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 { + pass_active = false; + } + texture = nullptr; + owner = nullptr; + valid = false; + width = height = 0; + } + + 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; + } + } + +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"); + } + 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; + } + + bool restoreDrawColor() { + return sdl.set_color(owner, saved_color[0], saved_color[1], saved_color[2], saved_color[3]) == 0; + } + + 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; + } +}; + +} // namespace worldmap_zoom diff --git a/plugins/worldmap-zoom-canvas.test.cpp b/plugins/worldmap-zoom-canvas.test.cpp new file mode 100644 index 0000000000..ece8670f63 --- /dev/null +++ b/plugins/worldmap-zoom-canvas.test.cpp @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: Zlib +// 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 "worldmap-zoom-canvas.h" + +struct MockState { + 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; + 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 = {}; + info->name = "mock"; + info->flags = SDL_RENDERER_TARGETTEXTURE; + info->max_texture_width = info->max_texture_height = 16384; + return 0; + } + SDL_Texture *create_texture(SDL_Renderer *, unsigned, int, int, int) { + ++mock.textures; + return reinterpret_cast(&texture); + } + void destroy_texture(void *) { + ++mock.destroys; + } + int texture_blend(void *, int) { + return 0; + } + int texture_scale(void *, int) { + return 0; + } + SDL_Texture *get_target(SDL_Renderer *) { + return mock.target; + } + int set_target(SDL_Renderer *, SDL_Texture *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; + +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) { + mock = {}; + view = {zoom, 4, 3, 1}; + const auto before = mock; + 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 == 80 && mock.copied_source.y == 80); + assert(mock.copied_source.w == 512 / zoom && mock.copied_source.h == 384 / zoom); + 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); + assert(canvas.stats().restore_errors == 0); + canvas.release(renderer, window_id); + assert(mock.destroys == 1); +} + +int main() { + crop_and_restore(2); + crop_and_restore(4); + + 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); + + 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(renderer, window_id); + + 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); + + mock = {}; + worldmap_zoom::MapCanvas failed(sdl); + mock.fail_target = true; + 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(renderer, window_id); + + 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(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); + + 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 new file mode 100644 index 0000000000..be76bbb2e2 --- /dev/null +++ b/plugins/worldmap-zoom-render.cpp @@ -0,0 +1,948 @@ +/* +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 +#include +#include + +#include "Core.h" +#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 "worldmap-zoom-canvas.h" +#include "worldmap-zoom-sdl.h" + +using namespace DFHack; +DFHACK_PLUGIN("worldmap-zoom-render"); +REQUIRE_GLOBAL(enabler); +REQUIRE_GLOBAL(gps); + +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_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; +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; +// 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}; +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 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_without_task = false; // guarded by jobs_mutex: idle or inline 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 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; + 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"); + 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->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 == SDL_WINDOWEVENT_FOCUS_GAINED) + window_focused = true; + } + 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)) + 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; +} + +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); + 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, SDL_PIXELFORMAT_ABGR8888, 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 = 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(port, c) && begin_canvas(this, port, c, true)) { + ++transform_depth; + INTERPOSE_NEXT(update_full_map_port)(port); + --transform_depth; + map_canvas.markFullDraw(); + ++transformed_calls; + finish_canvas(); + } else { + if (!transform_depth) + finish_canvas(); + 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(port, c) && begin_canvas(this, port, c, false)) { + ++transform_depth; + map_canvas.clearTile(x, y); + INTERPOSE_NEXT(update_map_port_tile)(port, 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) + finish_canvas(); + INTERPOSE_NEXT(update_map_port_tile)(port, x, y); + } + } + DEFINE_VMETHOD_INTERPOSE(void, render, ()) { + render_thread = sdl.thread_id(); + finish_canvas(); + 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; + } + // 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(); + finish_canvas(false); + INTERPOSE_NEXT(do_blank_screen_fill)(); + } + DEFINE_VMETHOD_INTERPOSE(void, resize, (int32_t w, int32_t h)) { + release_canvas(this); + INTERPOSE_NEXT(resize)(w, h); + } + DEFINE_VMETHOD_INTERPOSE(void, grid_resize, (int32_t w, int32_t h)) { + release_canvas(this); + INTERPOSE_NEXT(grid_resize)(w, h); + } + DEFINE_VMETHOD_INTERPOSE(void, set_fullscreen, ()) { + release_canvas(this); + INTERPOSE_NEXT(set_fullscreen)(); + } + DEFINE_VMETHOD_INTERPOSE(bool, init_video, (int32_t w, int32_t h)) { + 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); +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) + 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; + 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); + 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); + 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; + } + if (!c.enabled && build_verified) + 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); + 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"); + 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()); + 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) { + 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); + 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) { + 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); + 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) { + 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; + 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) { + 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)); + 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) { + 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)); + 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"); + queue_job([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 = SDL_WINDOWEVENT; + event.window.windowID = sdl.window_id(window); + event.window.event = SDL_WINDOWEVENT_RESIZED; + event.window.data1 = w; + event.window.data2 = h; + DFSDL::DFSDL_PushEvent(&event); + }); + return 0; +} + +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); + { + 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) { + 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; + queue_job([focused] { + SDL_Event event{}; + event.window.type = SDL_WINDOWEVENT; + event.window.windowID = watched_window.load(); + event.window.event = focused ? SDL_WINDOWEVENT_FOCUS_GAINED : SDL_WINDOWEVENT_FOCUS_LOST; + DFSDL::DFSDL_PushEvent(&event); + }); + return 0; +} + +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. + 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)) { + 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; + } + { + std::lock_guard lock(state_mutex); + last_error.clear(); + } + build_verified = true; + queue_job([] { + if (!build_verified) { + prepare_pending = false; + return; + } + map_canvas.clearError(); + 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 = true; + for (auto *hook : renderer_hooks) { + if (!hook->apply()) + ok = false; + } + 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; +} + +// 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); + 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; + 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_without_task || (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 &out, state_change_event event) { + if (event == SC_WORLD_UNLOADED || event == SC_MAP_UNLOADED) { + std::lock_guard lock(state_mutex); + camera = {}; + } + 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_without_task = true; + return CR_OK; + } + { + 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; " + "or run unload from the DFHack console.\n"); + 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 || !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; + } + return CR_OK; +} + +DFhackCExport command_result plugin_shutdown(color_ostream &) { + // 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(); + 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; +}; diff --git a/test/plugins/worldmap-zoom-render.lua b/test/plugins/worldmap-zoom-render.lua new file mode 100644 index 0000000000..5a87c73727 --- /dev/null +++ b/test/plugins/worldmap-zoom-render.lua @@ -0,0 +1,81 @@ +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 + +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