diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 4ad823e696..27d5c1f8ae 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -68,6 +68,10 @@ jobs: name: Build win64 runs-on: windows-2022 steps: + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.x' - name: Install build dependencies run: | choco install sccache @@ -183,17 +187,13 @@ jobs: run: | Get-ChildItem -Recurse -File -Path "build" -Filter *.pdb | Copy-Item -Destination "pdb" - - name: Prep artifact - run: | - cd output - 7z a -ttar -so -an . | - 7z a -si -tbzip2 ../${{ steps.artifactname.outputs.name }}.tar.bz2 - name: Upload artifact if: inputs.artifact-name uses: actions/upload-artifact@v4 with: name: ${{ steps.artifactname.outputs.name }} - path: ${{ steps.artifactname.outputs.name }}.tar.bz2 + path: output + include-hidden-files: true - name: Upload PDBs if: inputs.artifact-name && inputs.cache-id == 'release' uses: actions/upload-artifact@v4 diff --git a/.github/workflows/generate-symbols.yml b/.github/workflows/generate-symbols.yml index f78da19e74..27d2286474 100644 --- a/.github/workflows/generate-symbols.yml +++ b/.github/workflows/generate-symbols.yml @@ -325,6 +325,7 @@ jobs: uses: actions/download-artifact@v4 with: name: dfhack-symbols-windows64-build + path: dfhack-windows64-build - name: Setup steamcmd id: steamcmd uses: CyberAndrii/setup-steamcmd@v1 @@ -355,7 +356,7 @@ jobs: +login $STEAM_USERNAME \ "+app_update 975370 $BETA_PARAMS validate" \ +quit - tar xjf dfhack-symbols-windows64-build.tar.bz2 -C DF_steam + cp -r dfhack-windows64-build/. DF_steam/ cp xml/symbols.xml DF_steam/hack cd DF_steam "./Dwarf Fortress.exe" & diff --git a/.github/workflows/github-release.yml b/.github/workflows/github-release.yml index 16a4602141..28a56fc609 100644 --- a/.github/workflows/github-release.yml +++ b/.github/workflows/github-release.yml @@ -58,8 +58,6 @@ jobs: run: | mkdir artifacts cd dfhack-windows64-build - tar xjf dfhack-windows64-build.tar.bz2 - rm dfhack-windows64-build.tar.bz2 zip -qr ../artifacts/dfhack-${{ steps.gettag.outputs.name }}-Windows-64bit.zip . cd ../dfhack-linux64-build mv dfhack-linux64-build.tar.bz2 ../artifacts/dfhack-${{ steps.gettag.outputs.name }}-Linux-64bit.tar.bz2 diff --git a/.github/workflows/steam-deploy.yml b/.github/workflows/steam-deploy.yml index 2fbe6849e1..ccd486bda3 100644 --- a/.github/workflows/steam-deploy.yml +++ b/.github/workflows/steam-deploy.yml @@ -70,8 +70,10 @@ jobs: run: | for name in common win64 linux64; do cd ${name}-depot - tar xjf ${name}-depot.tar.bz2 - rm ${name}-depot.tar.bz2 + if [ -f ${name}-depot.tar.bz2 ]; then + tar xjf ${name}-depot.tar.bz2 + rm ${name}-depot.tar.bz2 + fi cd .. done - name: Get short SHA of commit diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index 3fbf683b63..054c964751 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -65,9 +65,14 @@ jobs: uses: actions/download-artifact@v4 with: name: test-${{ inputs.compiler }} + path: ${{ env.DF_FOLDER }} - name: Install DFHack shell: bash - run: tar xjf test-${{ inputs.compiler }}.tar.bz2 -C ${{ env.DF_FOLDER }} + run: | + if [ -f "${{ env.DF_FOLDER }}/test-${{ inputs.compiler }}.tar.bz2" ]; then + tar xjf "${{ env.DF_FOLDER }}/test-${{ inputs.compiler }}.tar.bz2" -C "${{ env.DF_FOLDER }}" + rm "${{ env.DF_FOLDER }}/test-${{ inputs.compiler }}.tar.bz2" + fi - name: Start X server if: inputs.os == 'ubuntu' run: Xvfb :0 -screen 0 1600x1200x24 & @@ -77,7 +82,7 @@ jobs: DISPLAY: :0 TERM: xterm-256color with: - timeout_minutes: 1 + timeout_minutes: 3 command: python ci/run-tests.py --keep-status "${{ env.DF_FOLDER }}" - name: Check RPC interface run: python ci/check-rpc.py "${{ env.DF_FOLDER }}/dfhack-rpc.txt" diff --git a/.gitignore b/.gitignore index 60a0e1b6b2..eda2b226d5 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,7 @@ build/compile_commands.json build/dfhack_setarch.txt build/ImportExecutables.cmake build/Testing +build/_deps # Python binding binaries *.pyc @@ -64,6 +65,7 @@ build/CPack*Config.cmake # VSCode files .vscode +*.code-workspace # ctags file tags diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5d77a667a4..7cd0f4c1f1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,11 +20,11 @@ repos: args: ['--fix=lf'] - id: trailing-whitespace - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.36.0 + rev: 0.39.0 hooks: - id: check-github-workflows - repo: https://github.com/Lucas-C/pre-commit-hooks - rev: v1.5.5 + rev: v1.5.6 hooks: - id: forbid-tabs exclude_types: diff --git a/CMakeLists.txt b/CMakeLists.txt index ea1e613556..8a8223561e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -4,11 +4,12 @@ cmake_minimum_required(VERSION 3.18 FATAL_ERROR) cmake_policy(SET CMP0048 NEW) cmake_policy(SET CMP0074 NEW) +set(CMAKE_INSTALL_MESSAGE "LAZY") # set up versioning. -set(DF_VERSION "53.10") -set(DFHACK_RELEASE "r1") -set(DFHACK_PRERELEASE FALSE) +set(DF_VERSION "53.16") +set(DFHACK_RELEASE "r4rc1") +set(DFHACK_PRERELEASE TRUE) set(DFHACK_VERSION "${DF_VERSION}-${DFHACK_RELEASE}") set(DFHACK_ABI_VERSION 2) @@ -32,7 +33,7 @@ else(CMAKE_CONFIGURATION_TYPES) set(DFHACK_TYPE_HELP "Choose the type of build, options are: Release and RelWithDebInfo") # Prevent cmake C module attempts to overwrite our help string if(NOT CMAKE_BUILD_TYPE) - set(CMAKE_BUILD_TYPE "Release" CACHE STRING "${DFHACK_TYPE_HELP}") + set(CMAKE_BUILD_TYPE "Release" CACHE STRING "${DFHACK_TYPE_HELP}" FORCE) else(NOT CMAKE_BUILD_TYPE) set(CMAKE_BUILD_TYPE "${CMAKE_BUILD_TYPE}" CACHE STRING "${DFHACK_TYPE_HELP}") endif(NOT CMAKE_BUILD_TYPE) @@ -49,6 +50,12 @@ macro(CHECK_GCC compiler_path) if(${GCC_VERSION_OUT} VERSION_LESS "11") message(SEND_ERROR "${compiler_path} version ${GCC_VERSION_OUT} cannot be used - use GCC 11 or later") endif() + # GCC 16 currently has a defect that prevents it from compiling DFHack + # -Warray-bounds is broken in GCC 16 and we'd rather disable the compiler than remove this warning + # will reconsider when this defect is fixed in a future GCC release + if(${GCC_VERSION_OUT} VERSION_GREATER_EQUAL "16") + message(SEND_ERROR "${compiler_path} version ${GCC_VERSION_OUT} cannot be used - use GCC 15 or earlier") + endif() endmacro() if(UNIX) @@ -226,13 +233,10 @@ set(DFHACK_DATA_DESTINATION hack) ## where to install things (after the build is done, classic 'make install' or package structure) # the dfhack libraries will be installed here: -if(UNIX) - # put the lib into DF/hack - set(DFHACK_LIBRARY_DESTINATION ${DFHACK_DATA_DESTINATION}) -else() - # windows is crap, therefore we can't do nice things with it. leave the libs on a nasty pile... - set(DFHACK_LIBRARY_DESTINATION .) -endif() + +# put the lib into DF/hack +# windows will find it because dfhooks will `AddDllDirectory` the hack folder at runtime +set(DFHACK_LIBRARY_DESTINATION ${DFHACK_DATA_DESTINATION}) # external tools will be installed here: set(DFHACK_BINARY_DESTINATION .) @@ -267,7 +271,7 @@ if(UNIX) set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -m32 -march=i686") endif() string(REPLACE "-DNDEBUG" "" CMAKE_CXX_FLAGS_RELWITHDEBINFO "${CMAKE_CXX_FLAGS_RELWITHDEBINFO}") - set(CMAKE_INSTALL_RPATH ${DFHACK_LIBRARY_DESTINATION}) + set(CMAKE_INSTALL_RPATH "$ORIGIN") elseif(MSVC) # for msvc, tell it to always use 8-byte pointers to member functions to avoid confusion set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /vmg /vmm /MP") @@ -352,7 +356,7 @@ INCLUDE(FetchContent) FetchContent_Declare( fmt GIT_REPOSITORY https://github.com/fmtlib/fmt.git - GIT_TAG 790b9389ae99c4ddebdd2736a8602eca1fec684e # 12.1.0 + bugfix for MSVC warning + build time improvements + GIT_TAG 12.2.0 ) FetchContent_MakeAvailable(fmt) set(FMTLIB fmt) @@ -439,7 +443,7 @@ macro(dfhack_test name files) if(BUILD_LIBRARY AND UNIX AND NOT APPLE) # remove this once our MSVC build env has been updated add_executable(${name} ${files}) target_include_directories(${name} PUBLIC depends/googletest/googletest/include) - target_link_libraries(${name} dfhack ${FMTLIB} gtest) + target_link_libraries(${name} dfhack lua ${FMTLIB} gtest) add_test(NAME ${name} COMMAND ${name}) endif() endmacro() diff --git a/build/.gitignore b/build/.gitignore index 11f87d8c3e..0675a165cf 100644 --- a/build/.gitignore +++ b/build/.gitignore @@ -8,3 +8,4 @@ _CPack_Packages .cmake win64-cross dest +DF diff --git a/ci/download-df.sh b/ci/download-df.sh index ed8b373a25..a07f75f2a1 100755 --- a/ci/download-df.sh +++ b/ci/download-df.sh @@ -14,7 +14,7 @@ fi df_url="https://www.bay12games.com/dwarves/df_${minor}_${patch}" if test "$OS_TARGET" = "windows"; then WGET="C:/msys64/usr/bin/wget.exe" - df_url="${df_url}_win_s.zip" + df_url="${df_url}_win.zip" df_archive_name="df.zip" df_extract_cmd="unzip -d ${DF_FOLDER}" elif test "$OS_TARGET" = "ubuntu"; then diff --git a/ci/test.lua b/ci/test.lua index a4846520ec..6cc7cafc75 100644 --- a/ci/test.lua +++ b/ci/test.lua @@ -727,6 +727,10 @@ local function main(args) qerror(('Invalid test folder: "%s"'):format(config.test_dir)) end + if config.done_command then + print(('Will run "%s" after tests complete'):format(config.done_command)) + end + local counts = { tests = 0, tests_ok = 0, diff --git a/data/init/onMapLoad.default.init b/data/init/onMapLoad.default.init index 44986a044c..fb5912fea4 100644 --- a/data/init/onMapLoad.default.init +++ b/data/init/onMapLoad.default.init @@ -4,3 +4,5 @@ # Please do not edit this file directly. It will be overwritten with new # defaults when you update DFHack. Instead, add your configuration to # dfhack-config/init/onMapLoad.init + +lua require('quickfix').repair_site_id() diff --git a/depends/dfhooks b/depends/dfhooks index 481dc1a12b..5a904e30a8 160000 --- a/depends/dfhooks +++ b/depends/dfhooks @@ -1 +1 @@ -Subproject commit 481dc1a12b1264ef06ce95e331ef35cbfa0e6ace +Subproject commit 5a904e30a8bace81c662b44ec7ff076b92edafd1 diff --git a/depends/lua/src/ldo.c b/depends/lua/src/ldo.c index 65158df0b7..6325ebf468 100644 --- a/depends/lua/src/ldo.c +++ b/depends/lua/src/ldo.c @@ -38,6 +38,33 @@ #define errorstatus(s) ((s) > LUA_YIELD) +/* +** {====================================================== +** DFHACK: runtime sanity checks around C function results +** ======================================================= +*/ + +/* +** api_checknelems() compiles to nothing in release builds, so a C +** function (or a continuation function) that returns a result count +** inconsistent with the number of values actually on the stack would +** make luaD_poscall()/moveresults() read and write outside the stack, +** hard-crashing the host process. Convert that condition into a +** catchable Lua error instead. This check is two comparisons on a path +** already dominated by the call itself, so it is cheap enough to run on +** every call, including hot paths like the overlay render loop. +*/ +#define DFHack_checkcresults(L,ci,n,f) \ + if ((n) < 0 || (n) >= (L)->top - (ci)->func) \ + luaG_runerror(L, \ + "lua_CFunction %p reported invalid result count %d", (void *)(f), (n)); + + +/* +** }====================================================== +*/ + + /* ** {====================================================== ** Error-recovery functions @@ -386,6 +413,15 @@ int luaD_poscall (lua_State *L, CallInfo *ci, StkId firstResult, int nres) { } res = ci->func; /* res == final position of 1st result */ L->ci = ci->previous; /* back to caller */ + /* DFHACK: sanity-check the pointers moveresults() is about to use. A + corrupted pointer here would otherwise read or write outside the + stack and hard-crash (or silently corrupt) the host process. */ + if (firstResult < L->stack || firstResult > L->top || + res < L->stack || res >= L->stack_last) { + if (L->top > L->ci->top) + L->top = L->ci->top; /* make sure the error value has a safe home */ + luaG_runerror(L, "corrupted Lua call frame"); + } /* move results to proper place */ return moveresults(L, firstResult, res, nres, wanted); } @@ -434,6 +470,7 @@ int luaD_precall (lua_State *L, StkId func, int nresults) { n = (*f)(L); /* do the actual call */ lua_lock(L); api_checknelems(L, n); + DFHack_checkcresults(L, ci, n, f); /* DFHACK */ luaD_poscall(L, ci, L->top - n, n); return 1; } @@ -533,6 +570,7 @@ static void finishCcall (lua_State *L, int status) { n = (*ci->u.c.k)(L, status, ci->u.c.ctx); /* call continuation function */ lua_lock(L); api_checknelems(L, n); + DFHack_checkcresults(L, ci, n, ci->u.c.k); /* DFHACK */ luaD_poscall(L, ci, L->top - n, n); /* finish 'luaD_precall' */ } @@ -636,6 +674,7 @@ static void resume (lua_State *L, void *ud) { n = (*ci->u.c.k)(L, LUA_YIELD, ci->u.c.ctx); /* call continuation */ lua_lock(L); api_checknelems(L, n); + DFHack_checkcresults(L, ci, n, ci->u.c.k); /* DFHACK */ firstArg = L->top - n; /* yield results come from continuation */ } luaD_poscall(L, ci, firstArg, n); /* finish 'luaD_precall' */ diff --git a/docs/Installing.rst b/docs/Installing.rst index a687498e7e..6f925999bc 100644 --- a/docs/Installing.rst +++ b/docs/Installing.rst @@ -89,7 +89,8 @@ To download a development build from GitHub: - Click the number under "Artifacts" (or scroll down) - Click on the ``dfhack-*-build-*`` artifact for your platform to download -The artifacts are "double-zipped". That is, you will have to extract the +The Windows artifacts can be extracted directly into your DF folder. The +Linux artifacts are "double-zipped". That is, you will have to extract the initial zip file to get to the package archive. You can extract this second package the same as if you are doing a manual install (see the next section). diff --git a/docs/Tags.rst b/docs/Tags.rst index 55bfde977b..788520f3a2 100644 --- a/docs/Tags.rst +++ b/docs/Tags.rst @@ -43,7 +43,7 @@ for the tag assignment spreadsheet. - `map `: Tools that interact with the game map. - `military `: Tools that interact with the military. - `plants `: Tools that interact with grass, trees, shrubs, and crops. -- `stockpiles `: Tools that interact with stockpiles. +- `stockpile `: Tools that interact with stockpiles. - `units `: Tools that interact with units. - `workorders `: Tools that interact with workorders. diff --git a/docs/about/Authors.rst b/docs/about/Authors.rst index 8743b75a19..eefd440758 100644 --- a/docs/about/Authors.rst +++ b/docs/about/Authors.rst @@ -49,6 +49,7 @@ Clayton Hughes Clément Vuchener cvuchener Corey CoreyJ87 daedsidog daedsidog +Dailton Filho notliad Dan Amlund danamlund Daniel Brooks db48x David Nilsolm @@ -121,6 +122,7 @@ Kurik Amudnil Kévin Boissonneault KABoissonneault Lethosor lethosor LordGolias LordGolias +Magnus Anderson magnus-ISU Mark Nielson pseudodragon Mason11987 Mason11987 Matt Regul mattregul @@ -163,6 +165,7 @@ Omniclasm Ong Ying Gao ong-yinggao98 oorzkws oorzkws OwnageIsMagic OwnageIsMagic +pajawojciech pajawojciech palenerd dlmarquis PassionateAngler PassionateAngler Patrik Lundell PatrikLundell @@ -260,6 +263,7 @@ Vitaly Pronkin pronvit mifki ViTuRaS ViTuRaS Vjek vjek Vladimir Florov foxxelias +wala343 wala343 Warmist warmist Wes Malone wesQ3 Will H TSM-EVO diff --git a/docs/about/Removed.rst b/docs/about/Removed.rst index 5f73b86be3..5235327634 100644 --- a/docs/about/Removed.rst +++ b/docs/about/Removed.rst @@ -28,7 +28,8 @@ autohauler ========== An automated labor management tool that only addressed hauling labors, leaving the assignment of skilled labors entirely up to the player. Fundamentally incompatible with the work detail -system of labor management in v50 of Dwarf Fortress. +system of labor management in v50 of Dwarf Fortress. A dedicated laborer pool for unskilled +jobs (including hauling) is now provided by the `labormanager` command of `autolabor`. .. _automaterial: @@ -258,6 +259,13 @@ gui/hack-wish ============= Replaced by `gui/create-item`. +.. _gui/logcleaner: + +gui/logcleaner +============== +Removed because changes to Dwarf Fortress internals made the functionality +impossible to implement safely. + .. _gui/manager-quantity: gui/manager-quantity @@ -278,6 +286,13 @@ Tool that warned the user when the ``dfhack.init`` file did not exist. Now that ``dfhack.init`` is autogenerated in ``dfhack-config/init``, this warning is no longer necessary. +.. _logcleaner: + +logcleaner +=============== +Removed because changes to Dwarf Fortress internals made the functionality +impossible to implement safely. + .. _masspit: masspit diff --git a/docs/changelog.txt b/docs/changelog.txt index e1b0e51eec..1473e42e81 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -57,6 +57,434 @@ Template for new versions: ## New Tools ## 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 +- `autofarm`: thresholds can now be assigned to specific growth products of a plant (e.g. ``BUSH_QUARRY:LEAVES``), so crops that are grown for their growth items can be farmed to a target amount of that product + +## Fixes +- Core: Windows console editing will no longer move the cursor to the next line or scroll the screen when the length of the text being edited exceeds the screen width. +- `autofarm`: farms that span multiple biomes are now handled correctly: crops that can grow in any of the farm's biomes can be assigned to the farm, instead of only using the biome of the farm's center tile +- `labormanager`: the modern engine's labor-mismatch probe no longer enables unmanaged labors on idle units or specialists, and no longer re-probes postings that can never be claimed (#6028) +- ``RemoteFortressReader``: ensured creature and caste names and caste descriptions in ``GetCreatureRaws``/``GetPartialCreatureRaws`` are transmitted in UTF-8 instead of CP437 +- ``Units::teleport`` don't teleport units (except ghosts) into walls or impassable parts of buildings; don't teleport into or out of hidden tiles. +- ``Units::teleport`` teleport babies along with their mothers, teleport merchant wagon-riders with their wagons. don't break links between wagons & their riders. +- ``Units::teleport`` when using teleport to separate a rider/carried unit from its mount/carrying unit, clean up the links between the two. +- ``Units::teleport`` when using teleport to separate a dragged unit from a dragger unit, clean up the links between the two. + +## Misc Improvements +- ``eventful``: ``onJobCompleted`` now fires for jobs that are deleted the same tick they complete (e.g. ``HarvestPlants``), detected via the worker's Job unit action timer +- ``RemoteFortressReader``: ensured creature and caste names and caste descriptions in ``GetCreatureRaws``/``GetPartialCreatureRaws`` are transmitted in UTF-8 instead of CP437 +- ``Units::teleport`` can now remove units from cages & chains, and will cancel any associated uncage/unchain jobs. +- ``Units::teleport`` when teleporting a merchant dragging a pack animal, also teleport the animal. + +## Documentation + +## API + +## Lua +- ``dfhack.timeout_until``: new function to schedule a callback to run on a specified in-game tick + +## Removed + +# 53.16-r3 + +## New Tools + +## New Features + +## Fixes +- Fixed ``scriptmanager.getModStatePath()`` causing scripts to crash when called +- Core: Windows console will no longer hang on close (on some builds of Windows) +- ``dwarfmode.lua``: properly handle adventure arena look cursor +- `prospect`: don't warn about a missing magma sea in the pre-embark estimate when the world was generated without one +- ``Units::getProfessionName``, ``Units::getCasteProfessionName``: now detect arena adventurer for default race name +- ``World::getAdventurer``: properly detect adventurer in arena mode + +## Misc Improvements +- crash reports now include a Lua stack traceback when the game crashes while Lua code is executing (``crashlog/crash_*.txt`` on Linux, ``stderr.log`` on Windows) +- `overlay`: errors raised by a bad C function result count or corrupted Lua call stack are now reported as Lua errors instead of crashing the game + +## Documentation +- ``dfhack.world.isAdventureMode``: doesn't include adventurer arena, advise checking gamemode +- ``export-world-map``: added more format conversion examples. + +## API + +## Lua +- ``printall_recurse``: no longer recurses into untagged unions, which have no way to identify their live member; fixes crashes when printing structures that contain them, such as ``df.army_controller`` + +## Removed + +# 53.16-r2 + +## New Tools +- `export-world-map`: Export world map data for GIS and other external tools. +- `smooth-movement`: smoothly animate creature, item, and vehicle movement in the fortress viewport +- `stockflow`: (reinstated) queue manager jobs based on free space in stockpiles + +## New Features +- `autodump`: new ``undestroy`` option reverts pending item destruction while the game is still paused +- `buildingplan`: new "Do now" option marks the jobs of subsequently placed buildings as high priority +- `getplants`: new ``--dry-run`` option counts affected plants without designating, and new ``--brewable``/``--edible``/``--oil``/``--cloth``/``--dye`` trait filters restrict the selection by plant use +- `labormanager`: the `autolabor` plugin now provides a second command that manages labors through the work detail system instead of writing the labor matrix; use `` mode legacy|modern`` or the work details overlay to switch +- `labormanager`: track job posting age and show a "task starvation" notification in the DFHack warning panel when any posting sits unclaimed for over a day +- `sort`: the stress/happiness icons in the top bar are now clickable, opening a list of the citizens in that stress category; selecting a citizen centers the map on them +- `stocks`: the overlay's ``collapse all`` hotkey now toggles, expanding all categories again when everything is collapsed +- `stockpiles`: the overlay now also attaches to the minecart hauling stop conditions panel, offering import/export of route stop conditions +- `suspendmanager`: suspend deconstruction jobs that would collapse connected machinery, such as waterwheels supporting other components + +## Fixes +- Fixed broken weather lookup in ``World::ReadCurrentWeather`` +- Fixed ``dfhack.items.createItem`` making glob items of an incorrect size +- Fixed a possible hang or assertion failure when pressing a hotkey while a DFHack GUI window was open but unfocused +- Fixed persistent site data API (``dfhack.persistent.saveSiteData``/``getSiteData``) failing on newly reclaimed fortresses until the first save +- Fixed out-of-bounds color table access in ``Screen`` module when Lua pens use ``COLOR_RESET`` +- Plugin commands that throw uncaught C++ exceptions now report an error instead of crashing the game +- ``Units::isCitizen`` and ``Units::isResident`` now exclude dead units even when ``include_insane`` is set; the flag previously skipped the sanity check that also happened to exclude the dead +- ``Units::setPathGoal``: no longer crashes when passed NULL as a unit. Also fixes ``dfhack.units.setPathGoal()``. +- ``Units::teleport``: update unit occupancy on the full 3x3 footprint of EQUIPMENT units (e.g. wagons) instead of only their center tile +- ``Units::teleport``: only clear tile unit occupancy flags when no other unit of the same kind remains on the tile +- `3dveins`: fix crash when a tile's biome cannot be resolved +- `3dveins`: fix failure on embarks that cross midmap tiles +- `aquifer`: make ``--help``/``-h`` actually print help instead of running a map-wide ``list`` +- `aquifer`: make ``--skip-top`` and top-relative ``--levels`` take effect for the ``drain``, ``convert``, and ``add`` actions instead of being silently ignored +- `autochop`: fix crash when a burrow-based command names a burrow that doesn't exist +- `autodump`: ``destroy`` no longer leaves the contents of destroyed containers in limbo or crashes when destroying unit-held items +- `buildingplan`: fix roller material estimate asking for one chain per tile instead of one chain total +- `buildingplan`: fix "Unlink all" only unlinking some mechanisms (or crashing) when freeing mechanisms from the building +- `burrow`: fix ``tiles clear`` and other tile operations silently failing because burrow block z-coordinates were stored incorrectly in the ``Burrows`` module +- `dfhack-run`: on Windows, no longer mutates, hides, and closes the console it inherits when run from an existing terminal (issue #3827) +- `dig-now`: update light/outside flags of tiles below tiles that are dug into solid rather than open tiles +- `export-world-map`: fix crash exporting site data for a site whose owner has an undefined race +- `gui/quickfort`: show smoothing designations immediately after applying a blueprint +- `liquids`: allow painting liquids and obsidian into unallocated sky blocks +- `liquids`: update light/outside flags of covered tiles when painting obsidian or river sources +- `overlay`: overlay hotkeys no longer steal typed characters while the game is capturing text input, e.g. when renaming a building +- `preserve-rooms`: no longer assigns zones to dead units who still hold a noble position +- `probe`: fix malformed output of local/global map feature names +- `sort`: the clickable stress icon citizen lists no longer include dead or missing units (issue #5975) +- `sort`: fix the Places/Workshops and task searches missing workshops by their current display names (e.g. "Stoneworker's Workshop", "Screw Press") +- `stocks`: overlay now resets scroll position when collapsing categories so the item list is no longer left blank and unscrollable +- `strangemood`: updated to match behavior of current DF version +- `tiletypes`: remove plants (including trees) rooted on a tile that is painted into something that cannot host them, so cleared ground no longer regrows floating trees +- `timestream`: deal properly with units who have breathing difficulties +- `timestream`: pass the validated numeric value to the fps setter so ``set fps`` with unusual numeric strings (e.g. ``60.9``) can't be silently misapplied +- `strangemood`: fix crash when ``--id`` is given a non-numeric unit id +- `suspendmanager`: fix out-of-bounds parameter read when ``set`` is given too few arguments + +## Misc Improvements +- Added ``Coord2d`` and ``Coord3d`` C++ templates, providing a standard set operations for 2-tuples and 3-tuples of any numeric type +- Renamed the ``stockpiles`` tool tag to ``stockpile`` so it no longer collides with the `stockpiles` plugin in `gui/launcher` +- Writes to ``stderr.log`` are no longer buffered on Windows, so log output is not lost if DF crashes +- ``quickfix``: repair ``plotinfo.site_id`` on load when DF leaves it unassigned on reclaimed fortresses + +## Documentation +- Document ``autodump-destroy-here``. Remove mention of ``autodump destroy`` able to revert destruction. + +## API +- Added ``Maps::addRegionBiomeOffset`` to shift world region coordinate by region_details biome reference +- Added ``Maps::describeSurroundings`` to get surroundings classification from savagery and evilness +- Added ``MapExtras::MapCache::propagateVerticalFlags`` to update the light/outside/subterranean flags of tiles below a tile that changed between an open and a solid shape +- Added ``Maps::getCurrentWeather`` to determine the current weather on the map. +- Added ``Maps::getSiteTypeName`` to print the sites classification as a string (e.g. "mountain halls") +- Added ``Maps::getTileBlockCoord``, ``Maps::getBlockOrigin``, and ``Maps::getTileBlockOffset`` to convert between tile and block coordinates +- Added ``Maps::forEachTile`` with ``Maps::TileFilter``/``Maps::TileActions`` to scan a cuboid of tiles, matching tiles by tiletype, tiletype attributes, and designation/occupancy bits, and counting, rewriting tiletypes, editing designation/occupancy bits, spawning constructions, or invoking a callback on matching tiles +- Added ``MiscUtils::print_range`` to print ranges with prefix, separator, and suffix +- Added ``Units::breathes`` and ``Units::getBreathingState`` +- Deprecated ``World::ReadCurrentWeather`` in favor of ``Maps::getCurrentWeather`` + +## Lua +- Added ``dfhack.maps.forEachTile`` to scan a cuboid of tiles with a declarative filter and apply actions (count, set tiletype or per-tiletype replacements, set designation/occupancy fields, spawn constructions, Lua callback) in a single native call +- Added ``dfhack.maps.getTileBlockCoord``, ``dfhack.maps.getBlockOrigin``, and ``dfhack.maps.getTileBlockOffset`` to convert between tile and block coordinates +- ``widgets.Slider`` and ``widgets.RangeSlider``: fix sliders allowing values outside their number of stops (``num_stops``) when clicked or dragged with the mouse +- Fixed ``ipairs()`` on enum attribute tables (e.g. ``df.item_type.attrs``) never terminating (issue #1860) + +## Removed + +# 53.16-r1.1 + +## New Tools + +## New Features + +## Fixes +- `stockpiles` will no longer incorrectly size the "rough gem" and "cut gem" vectors, which avoids a crash when viewing stockpile settings. This potentially affects anything that uses blueprints to create a stockpile, including `gui/quantum` + +## Misc Improvements + +## Documentation + +## API + +## Lua + +## Removed + +# 53.16-r1 + +## New Tools + +## New Features + +## Fixes +- ``TextArea`` widget corrected to use ``COLOR_BLACK`` instead of ``COLOR_RESET`` as default background color + +## Misc Improvements +- a safety check was added to ``Screen::doSetTile_char`` for out of bound pen color values + +## Documentation + +## API + +## Lua + +## Removed + +# 53.15-r3 + +## New Tools + +## New Features + +## Fixes +- `buildingplan`: restore planner UI elements: hollow constructions, only engraved slabs, only empty cages, weapon count + +## Misc Improvements +- `EventManager`: add safety check to potentially avoid a DFHack crash when DF's ``reports`` table is out of order + +## Documentation +- ``dfhack.translation.generateName``: added a table containing explanations and data types for each of the parameters + +## API +- ``DFSDL``: added ``obtain_library_handle`` and ``obtain_image_library_handle`` so that a plugin can obtain DFHack's already-open handle to these libs instead of having to do it itself. +- ``Screen``: new functions ``paintMapPortTile`` and ``readMapPortTile`` to write and read world and region map tiles. +- ``Materials``: ``MaterialInfo`` constants ``NUM_BUILTIN``, ``GROUP_SIZE``, ``CREATURE_BASE``, ``FIGURE_BASE``, ``PLANT_BASE``, and ``END_BASE`` removed. New plugins should use appropriate members of the ``df::builtin_mats`` enum. + +## Lua +- Added ``Screen::paintMapPortTile`` as ``dfhack.screen.paintMapPortTile`` +- Added ``Screen::readMapPortTile`` as ``dfhack.screen.readMapPortTile`` +- Deprecated ``gui.materials.CREATURE_BASE`` and ``gui.materials.PLANT_BASE`` - scripts should instead use ``df.builtin_mats.CREATURE_1`` and ``df.builtin_mats.PLANT_1``, respectively. + +## Removed + +# 53.15-r2 + +## New Tools + +## New Features +- `stocks`: add button/hotkey for expanding all categories on stocks interface +- `buildingplan`: add a ``Pull`` button next to linked levers on a building's "Show linked buildings" tab so you can queue a high-priority pull-lever job (or cancel a queued one) without navigating to the lever + +## Fixes +- `autoclothing`: correct defect in validating material specification on command line +- `autolabor`: Fix running 1 tick less frequently than intended. +- `buildingplan`: fixed non-clickable pressure plates's triggers (issue #5736) +- `getplants`: added protective code to avoid misoperation when a plant has an invalid material (which should never happen, but...) + +## Misc Improvements +- `buildingplan`: added a slider on the weapontrap overlay +- `buildingplan`: added a small tooltip text about renaming favorites in the UI +- `buildingplan`: buildingplan can now generate work orders +- `orders`: exported orders now include a human-readable ``name`` field + +## Documentation + +## API +- Core: added ``getConfigPath()`` API for obtaining the path to user-specific configuration files +- ``widgets.RadioButton``: New button widget resembling those used in ``gui/control-panel`` + +## Lua +- Added ``dfhack.getConfigPath()`` API, proxying ``Core::getConfigPath`` + +## Removed + +# 53.15-r1 + +## New Tools + +## New Features + +## Fixes +- `autoclothing`: will no longer count gloves and pants as if they were helms +- `timestream`: do not skip ticks when a caravan is loading or unloading, and be more careful about skipping ticks when flows are active + +## Misc Improvements + +## Documentation + +## API + +## Lua + +## Removed + +# 53.14-r2 + +## New Tools + +## New Features + +## Fixes + +## Misc Improvements +- Core: attempts to delete a pool-allocated DF object will now throw an exception instead of corrupting the heap + +## Documentation + +## API + +## Lua + +## Removed +- `logcleaner`: Removed (cannot be safely implemented at this time) + +# 53.14-r1 + +## New Tools + +## New Features +- Compatibility with Dwarf Fortress 53.14 + +## Fixes + +## Misc Improvements + +## Documentation + +## API + +## Lua + +## Removed + +# 53.13-r2 + +## New Tools + +## New Features + +## Fixes +- ``Gui::makeAnnoucement``, ``Gui::showPopupAnnouncement`, and ``Gui::autoDFAnnouncement`` will no longer attempt to cull the DF announcement vector + +## Misc Improvements + +## Documentation + +## API + +## Lua + +## Removed + +# 53.13-r1 + +## New Tools + +## New Features + +## Fixes + +## Misc Improvements + +## Documentation +- updated documentation for ``autofarm`` for more clarity + +## API +- add flexible casting to ``enum_field`` to enable explicit casting to more types +- Handle units without current soul in ``Units::getFocusPenalty`` + +## Lua + +## Removed + +# 53.12-r1 + +## New Tools + +## New Features +- Compatibility with Dwarf Fortress 53.12 + +## Fixes +- Stockpile definitions in the default library will be correctly found and used (fixed missing path separator) + +## Misc Improvements + +## Documentation + +## API + +## Lua + +## Removed + +# 53.11-r3 + +## New Tools + +## New Features + +## Fixes +- Core: Windows console will always use UTF-8 regardless of system code page settings +- Steam launcher: Switch to injection strategy, allowing Dwarf Fortress and DFHack to be installed in disparate locations + +## Misc Improvements +- Make DFHack relocatable so that it doesn't depend on being fully co-installed with Dwarf Fortress + +## Documentation + +## API + +## Lua + +## Removed + +# 53.11-r2 + +## New Tools + +## New Features + +## Fixes +- `autoclothing`, `autoslab`, `tailor`: orders will no longer be created with a repetition frequency of ``NONE`` + +## Misc Improvements +- General: DFHack will unconditionally use UTF-8 for the console on Windows, now that DF forces the process effective system code page to 65001 during startup + +## Documentation + +## API + +## Lua + +## Removed + +# 53.11-r1 + +## New Tools + +## New Features + +## Fixes +- `sort`: correct misspelling of ``PERSEVERENCE``; fixes "hates combat" filter in squad selection screen + +## Misc Improvements + +## Documentation + +## API + +## Lua + +## Removed + +# 53.10-r2 + +## New Tools +- ``logcleaner``: New plugin for time-triggered clearing of combat, sparring, and hunting reports with configurable filtering and overlay UI. + +## New Features +- `orders`: added search overlay to find and navigate to matching manager orders with arrow indicators +- `sort`: added ``Uniformed`` filter to squad assignment screen to filter dwarves with mining, woodcutting, or hunting labors +- `sort`: Add death cause button to dead/missing tab in the creatures screen ## Fixes @@ -66,8 +494,14 @@ Template for new versions: ## Documentation ## API +- Added ``Items::pickGrowthPrint``: given a plant material and a growth index, returns the print variant corresponding to the current in-game time. +- Added ``Items::useStandardMaterial``: given an item type, returns true if the item is made of a specific material and false if it has a race and caste instead. +- Added ``Maps::addItemSpatter``: add a spatter of the specified item + material + growth print to the indicated tile, returning whatever amount wouldn't fit in the tile. +- Added ``Maps::addMaterialSpatter``: add a spatter of the specified material + state to the indicated tile, returning whatever amount wouldn't fit in the tile. ## Lua +- Added ``Maps::addItemSpatter`` as ``dfhack.maps.addItemSpatter``. +- Added ``Maps::addMaterialSpatter`` as ``dfhack.maps.addMaterialSpatter``. ## Removed @@ -108,6 +542,7 @@ Template for new versions: ## Documentation ## API +- ``dfhack.job.getManagerOrderName``: New function to get the display name of a manager order ## Lua diff --git a/docs/dev/Contributing.rst b/docs/dev/Contributing.rst index 7b34dc20ce..e9600bbce9 100644 --- a/docs/dev/Contributing.rst +++ b/docs/dev/Contributing.rst @@ -60,6 +60,24 @@ Code format * Braces placed at original indent level if on their own lines * ``#include`` directives should be sorted: C++ libraries first, then DFHack modules, then ``df/`` headers, then local includes. Within each category they should be sorted alphabetically. + * Avoid naming a type with a name that ends in ``st`` unless the structure is being named intentionally the same as a structure in Dwarf Fortress. If you are defining a new structure, avoid using a name that ends in ``st`` to avoid any risk of confusion. + +* For Lua: + + * Identifiers for publicly visible functions and variables should be in ``camelCase``. + +General C++ code guidelines +--------------------------- +* This project is currently built at the C++20 feature level, and C++20 features should be used when appropriate. C++23 features will be allowed once all of our build platforms support them. +* NEVER use ``using namespace`` in a header file. In source files, do not use ``using namespace std``; instead, import each STL identifier you need specifically (e.g. ``using std::string;``). +* Avoid platform specific code as much as possible. +* Avoid including ``Windows.h``; if you must, ensure that ``NOMINMAX`` and ``WIN32_LEAN_AND_MEAN`` are defined before including it. +* Do not include C headers (e.g. ````); use the C++ versions (e.g. ````) instead. +* Do not use ``std::string`` (or ``char *``) for path names; always use ``std::filesystem::path``. This avoids issues with encoding, especially on the Windows platform, which is roughly 80% of our user base. +* Do not use ``printf`` or similar functions for formatting strings; use C++ streams or ``fmt::format`` instead. We use the `fmt library `__ for formatting strings; this dependency is automatically fetched by our build system. +* Avoid out parameters; prefer returning a struct, pair, or tuple, or using ``std::optional`` instead. +* Prefer range for loops to traditional for loops when iterating over a container. +* Avoid macros when possible; prefer ``constexpr`` variables for constants and functions or templates for code generation. .. _contributing-pr-guidelines: @@ -107,6 +125,37 @@ Pull request guidelines or add "WIP" to the title. Otherwise, your pull request may be reviewed and/or merged prematurely. +* Avoid using force pushes to your pull request branch after it has been reviewed, + as this can make it difficult for reviewers to see what has changed since their + last review. If you need to make changes, consider creating a new commit instead + of amending or rebasing. We neither enforce nor recommend a "single commit" rule; if you do + choose to squash your commits, please ensure that the commit message is clear and descriptive of the changes made. + If your pull request has an unusually large number of commits, a maintainer may + request that you squash your commits into a smaller number of commits before merging. + +* All pull requests must be accompanied by a description of the changes made, and + any relevant information for reviewers. If your pull request addresses an + issue, please include a reference to that issue in the description (e.g. + "Fixes #1234"). If your pull request is related to another pull request, please + include a reference to that pull request in the description (e.g. "Related to + #1234"). + +* All pull requests which have user facing changes, including all new features, bug fixes, or + changes to existing functionality, must include an entry in the "Future" section of + the changelog for the relevant repository. If your pull request is merged, this entry + will be added to the appropriate changelog. These entries are used when preparing the release + notes for each release, so please be sure to include a clear and concise description + of the changes made. See `build-changelog` for more information on the changelog format. + Changes that do not require a changelog entry are mainly those that are purely internal, + such as refactoring not intended to change semantics, code cleanup, changes to CI implementation + or to documentation, or changes directly related to the release process. When in doubt, + assume a changelog entry will be required. + +* Pull requests that add or modify tools must include a corresponding update to the documentation + for that tool. Similarly, pull requests that add or modify either the C++ or Lua APIs + must include a corresponding update to the appropriate API documentation. + See `docs-standards` for details. + Other ways to help ================== DFHack is a software project, but there's a lot more to it than programming. diff --git a/docs/dev/CoreSuspender.rst b/docs/dev/CoreSuspender.rst new file mode 100644 index 0000000000..385ecd8446 --- /dev/null +++ b/docs/dev/CoreSuspender.rst @@ -0,0 +1,360 @@ +.. _core-suspender: + +=============================== +CoreSuspender and the core lock +=============================== + +This page describes the ``DFHack::CoreSuspender`` mechanism: what it does, how +it works internally, when you must use it, and when you must not. It is aimed +at developers working on the DFHack core library or C++ plugins. Lua-side +wrappers (``dfhack.with_suspend`` etc.) are covered in `lua-api`. + +.. contents:: Contents + :local: + +What it is +---------- + +``CoreSuspender`` is an RAII guard that gives the calling thread *exclusive, +consistent access to DF's memory and to DFHack's shared Lua state*. While a +thread holds a ``CoreSuspender``, the Dwarf Fortress simulation thread is +parked at a well-defined point in its per-frame update and cannot mutate game +state. This is the only sanctioned way for a foreign thread (console input +thread, RPC server thread, plugin-created worker thread, etc.) to read or +write ``df::global::*`` data safely. + +The name is historical and slightly misleading: DFHack no longer suspends the +process's threads with OS facilities. Instead, the simulation thread +cooperatively yields at a synchronization point once per frame, and the lock +serializes who may run while it is yielded. + +Threading model +--------------- + +Three kinds of threads matter: + +**The DF simulation thread** + Runs the game logic. DFHack hooks it via ``dfhooks_update()``, which calls + ``Core::Update()`` once per frame. During ``Core::InitSimulationThread()`` + this thread acquires ``Core::CoreSuspendMutex`` via a ``thread_local`` + ``CoreSuspenderBase`` (``MainThread::suspend()``) and effectively holds it + for the rest of the session. This is what "the core is running" means: the + simulation thread owns the lock while it executes. + +**The DF render thread (a.k.a. "main thread" in the hooks layer)** + Runs SDL event handling and screen drawing. Its id is recorded in + ``Core::df_render_thread`` by ``InitMainThread()``. This thread **can + never acquire the lock** — see `cannot-use` below. Note that it keeps + running while a suspender is held: suspending the core does *not* stop + rendering, it only parks simulation. The current implementation of the DF + render thread will generally never read or write game state — it only renders + the frame that was just computed by the simulation thread, using a double buffer + mechanism, and then pauses until the simulation thread signals that a new + frame is ready — so generally it's safe to access game state (other than certain + fields in ``df::global::enabler`` which are used by the render thread) while it's running. + + The following hooks (exported from ``library/Hooks.cpp``) are invoked on + this thread: + + - ``dfhooks_init()`` — once, before the simulation thread is started and + the main event loop begins. + - ``dfhooks_sdl_event(SDL_Event*)`` — for each SDL event; returning true + consumes the event. + - ``dfhooks_sdl_loop()`` — once per rendered frame, after mouse state is + set in ``gps`` and just before the screen buffer is presented. + - ``dfhooks_ncurses_key(int)`` — for each ncurses input character + (non-SDL builds); returning true consumes the key. (This is a legacy + event from when DF used ncurses, and is not used in SDL builds.) + - ``dfhooks_shutdown()`` — once, after the main event loop exits. + + ``dfhooks_preinit()`` is called even earlier by the chainloader, before + the main thread is initialized. ``dfhooks_update()`` and + ``dfhooks_prerender()`` run on the simulation thread instead. + +**Foreign threads** + The DFHack console/IO thread (``fIOthread``/``fInitthread``), RPC server + connection threads, and any threads spawned by plugins or tools. These are + the threads ``CoreSuspender`` exists for. + +How it works +------------ + +The state lives on ``Core`` (see ``library/include/Core.h``): + +- ``CoreSuspendMutex`` — a ``std::recursive_timed_mutex``. The simulation + thread is the standing owner; suspender threads contend on it. +- ``CoreWakeup`` — a ``std::condition_variable_any`` the simulation thread + sleeps on while tools run. +- ``ownerThread`` — atomic ``std::thread::id`` of whoever currently owns the + suspend context. Powers ``Core::isSuspended()``. Empty when the mutex is + unlocked. +- ``toolCount`` — atomic count of threads that *want* the lock, incremented + **before** blocking on the mutex. This ordering is the crux of the + protocol: a tool that is still waiting on the mutex already counts, so the + simulation thread can never observe "no work pending" while a tool is on + its way in. + +Once per frame, at the end of ``Core::Update()``, the simulation thread runs:: + + CoreWakeup.wait(MainThread::suspend(), + [this] { return toolCount.load() == 0; }); + +``condition_variable_any::wait`` evaluates the predicate *before* releasing +the mutex, so when nothing is queued this is a single atomic load — the yield +has no measurable cost in the common case. If any tool has incremented +``toolCount``, the wait releases ``CoreSuspendMutex`` and parks the thread +until the count drains to zero. + +A ``CoreSuspender`` therefore does: + +- *Construct:* increment ``toolCount``, then ``lock()`` the recursive mutex. + If the simulation thread is mid-frame, this blocks until the wait above + releases the mutex. On successful acquisition, the thread stores itself in + ``ownerThread``, remembering the previous owner. +- *Destruct:* restore the previous ``ownerThread``. If there was no previous + owner (i.e. this was the outermost release), run ``Lua::Core::Reset()``, + which verifies the shared ``lua_State`` stack is empty — every suspension + gets a clean Lua context. Then release the mutex and decrement + ``toolCount``; when it reaches zero, ``CoreWakeup`` is notified and the + simulation thread resumes its frame loop. + +Because the underlying mutex is recursive, nested ``CoreSuspender`` +construction on the same thread is legal and cheap; only the outermost +release performs the owner restore, Lua reset, and wakeup. + +Guarantees and non-guarantees +----------------------------- + +What holding the lock guarantees: + +- The simulation thread is not running game code. ``df::global::world``, the + map, unit lists, viewscreen state, etc. are stable for the duration of the + critical section. +- Only one suspender runs at a time — tools serialize on the mutex. +- Exclusive access to the shared core ``lua_State``. ``Core::getLuaState()`` + asserts ``isSuspended()``, and the ``DFHack::Lua::Core::*`` helpers assume + it. +- All queued tools complete before the next simulation frame begins — the + simulation thread waits for ``toolCount == 0``, not for a specific tool. + +What it does *not* guarantee: + +- The render thread is not suspended. It continues to draw and process SDL + events while you hold the lock. Code running under the lock can still race + with render-thread reads of DF memory (an accepted, deliberate asymmetry — + the render thread never blocks, so those reads are best-effort). +- Timeliness. Acquisition can take up to a full frame; there is no priority + or fairness among waiting tools beyond the mutex's own ordering. +- Safety during teardown. See `caveats`_. + +``Core::isSuspended()`` returns true when the calling thread currently owns +the suspend context. Notably, it also returns true on the simulation thread +during normal execution (the standing owner) — so code like +``plugin_onupdate`` or viewscreen callbacks that run inside ``Core::Update`` +may use the core Lua state directly, without constructing a suspender. + +When the lock is required +------------------------- + +You must hold the core lock when, from any thread that is not already in a +suspended context, you: + +- Read or write ``df::global::*`` objects or anything reachable from them — + game state, map blocks, units, buildings, screen/viewscreen internals. +- Touch the core ``lua_State`` — anything under ``DFHack::Lua::Core``, script + invocation, ``Lua::Core::SafeCall``, etc. +- Perform any operation that would misbehave if the world mutated mid-read + (iterating vectors of DF objects is the canonical example — a frame + boundary can realloc or free them). + +In practice, most entry points already arrange this for you: + +- **Plugin commands** are invoked under a suspender by ``Plugin::invoke`` + unless the command is registered with ``unlocked = true``. +- **RPC methods** are invoked under a suspender unless the method is + registered with ``SF_DONT_SUSPEND``. +- **Lua scripts** run under the lock; ``dfhack.with_suspend(f, ...)`` adds an + explicit critical section if you need one inside script code. +- ``plugin_onupdate``, ``plugin_onstatechange``, EventManager handlers, and + viewscreen ``feed``/``render`` callbacks execute on the simulation thread + inside ``Core::Update`` and are implicitly in a suspended context. A + ``CoreSuspender`` there is redundant but harmless (the mutex is recursive; + ``EventManager::manageEvents`` does exactly this defensively). + +So the lock is *your* responsibility mainly when you create your own thread, +register an ``unlocked`` command or ``SF_DONT_SUSPEND`` RPC method, or write +code reachable from foreign-thread callbacks that touches DF memory. + +.. _cannot-use: + +When the lock cannot be used +---------------------------- + +**On the render thread.** The ``CoreSuspenderBase`` constructor asserts +``df_render_thread != std::this_thread::get_id()``. This includes +``ConditionalCoreSuspender`` — you cannot even *attempt* acquisition there. +Beyond the assert, blocking the render thread on the mutex would stall frame +presentation and can deadlock against the parked simulation thread. This is +why hotkey guards and SDL event handling (which run on the render thread via +``dfhooks_sdl_event``) must inspect only native viewscreen state and must not +enter Lua — see the workaround in ``Gui.cpp``'s ``dfscreen_selection`` and +``Core::isRenderThread()``. To move work the other way, use +``DFHack::runOnRenderThread()``. + +**Before the core is initialized.** The same constructor asserts +``df_render_thread`` is set, which only happens in ``InitMainThread()``. A +suspender cannot be constructed in static initializers or in code that runs +before ``dfhooks_init`` completes. + +**Where blocking until the next frame boundary is unacceptable.** The mutex +is only released at the ``CoreWakeup.wait`` at the end of ``Core::Update``. +During long stretches where DF does not reach that hook (heavy save/load or +world-gen work, a frozen/backgrounded game), acquisition stalls arbitrarily. +If you only want to *opportunistically* take the lock, use +``ConditionalCoreSuspender``. + +**While holding locks that suspended-context code also needs.** Classic +lock-ordering deadlock: the code comment in ``Plugin::unload`` exists because +holding the plugin ``access`` lock while the ``CoreSuspender`` destructs can +deadlock against the simulation thread's bookkeeping. Establish and follow a +lock order; when in doubt, release other locks before acquiring the suspend. + +**While waiting on input or on another thread.** Holding the lock across +``lineedit``-style input waits, thread joins, or blocking IPC freezes the +simulation for the duration — and deadlocks outright if the thing you're +waiting on needs the simulation thread. ``Lua::RunCoreQueryLoop`` shows the +correct shape: it releases the suspend while waiting for input and +re-acquires it for each execution. On the Lua side, ``dfhack.lineedit`` may +yield the coroutine so the C++ side can drop the lock; wrapping such a call in +``dfhack.with_suspend`` forces it to block with the lock held instead. + +``A note about vmethod interposes`` +----------------------------------- + +VMethod interposes normally run in the context of the simulation thread, +or alternatively in the context of another thread that has already acquired +a ``CoreSuspender``, so they are effectively already in a suspended context. +You should therefore not construct a ``CoreSuspender`` in the implementation +of a vmethod interpose, because there is a small chance that a vmethod interpose will +instead be run on one of DF's worker threads; attempting to acquire a lock in that +context is likely to cause a deadlock. + +In the event that a vmethod interpose is called from a DF worker thread, we assume +that Bay12 has already implemented synchronization for the data that the +interposed method would have accessed. Mainly, this means that implementations +for interposed methods should avoid accessing data that is "not related" to the data +the method being interposed would normally access, as such data might not be +protected by synchronization. Reverse engineering may be required to determine +what accesses are safe from interposed methods. At the current time, we do not +know of any interposable methods that are called from DF worker threads, but this +does not mean there aren't any. + +Note also that there are a handful of interposable methods that will be called by +DF from its render (main) thread. Acquiring a lock in these methods will also +likely cause a deadlock. Mainly, these are vmethods of the ``renderer`` class and +its subclasses. Exercise caution when writing interposes for these methods: they +must not attempt to acquire the core suspend lock or directly access DF game +data other than the renderer state itself. + +``ConditionalCoreSuspender`` +---------------------------- + +``ConditionalCoreSuspender`` attempts acquisition and is testable as a bool:: + + DFHack::ConditionalCoreSuspender suspend; + if (suspend) { + // owns the lock + } else { + // lock not acquired; skip or degrade + } + +Despite its doc comment, acquisition is not strictly non-blocking: it uses +``try_lock_for(100ms)``, i.e. it will wait up to ~100 ms for the simulation +thread to reach the yield point. It fails only if the mutex is still +unavailable after that — typically because a very long frame is in progress +or another tool is hogging the lock. Use it for latency-sensitive paths where +a dropped update is better than a stall. + +Note that ``toolCount`` is incremented before the attempt and decremented on +failure, so even a *failed* conditional suspender briefly makes the +simulation thread believe a tool is queued. + +RPC batching +------------ + +``CoreService::CoreSuspend``/``CoreResume`` let a remote client pin the lock +across multiple RPC calls for atomic batch operations: ``CoreSuspend`` +allocates a ``CoreSuspender`` on the connection thread and keeps it alive +until the matching ``CoreResume``. While a client holds it, the game is +frozen — this is a sharp tool; a misbehaving or disconnected client can stall +the session. + +.. _caveats: + +Caveats and pitfalls +-------------------- + +- **Suspension is observable.** Holding the lock stalls the simulation + thread, so game time literally stops: long critical sections manifest as + hitching or a frozen UI. Batch what you need, keep sections short, and + never do I/O or sleeps inside the lock. + +- **One yield point per frame.** ``CoreSuspender`` construction can block for + up to a whole frame even when the game is running smoothly. Releasing and + immediately re-acquiring in a loop can starve the simulation thread into + running one frame per re-acquisition. + +- **Frame-boundary visibility.** The yield happens at the *end* of + ``Core::Update``, after ``doUpdate`` (state-change events, per-frame + plugin/Lua handlers) has run. A tool's critical section therefore observes + a world that is between frames: internally consistent, but already fully + processed for the previous tick. + +- **Lua state is reset on release.** The outermost unlock runs + ``Lua::Core::Reset``, which clears the shared lua_State stack (logging + "Common lua context stack top left at N after suspend" if it wasn't + empty). Do not expect anything you left on the core Lua stack to survive + the end of your critical section. + +- **Shutdown is not synchronized.** When ``Core::Shutdown`` sets + ``shutdown``, the next ``Core::Update`` releases the standing lock and the + simulation thread stops waiting on ``toolCount``. A suspender acquired + during or after teardown still acquires the mutex but the world is going + away; core teardown code (e.g. ``Plugin::unload``) checks + ``Core::isValid()``/``isMapLoaded()`` before entering the lock for this + reason. + +- **The render thread races with you.** If you mutate data that drawing code + reads asynchronously, the render thread may observe intermediate states. + Usually cosmetic, but know that "the game is frozen" is not literally true. + Mainly, this applies to accessing objects reachable from ``df::global::enabler``, + which is the control object for the renderer. + +- **``MemAccess::isSuspended()`` is unrelated.** It's a legacy stub that + always returns true; the real check is ``Core::isSuspended()``. + +- **Stale advice.** The Lua API docs state "every thread is allowed only one + suspend per DF frame" — a leftover from the pre-mutex implementation. Under + the current mechanism repeated acquisition is legal; each one just risks a + frame-length wait. Grouping operations into one critical section is still + good advice for latency, not correctness. + +Reference +--------- + +All of the following are in ``library/include/Core.h``: + +``DFHack::CoreSuspender`` + RAII guard. Constructor blocks until the lock is acquired; destructor + releases. Publicly, construction and destruction are the entire + interface — ``lock``/``unlock``/``try_lock`` are deliberately protected. + +``DFHack::ConditionalCoreSuspender`` + As above, but acquisition is attempted with a 100 ms bound and the + object converts to ``bool`` to report success. + +``Core::isSuspended()`` + True if the calling thread owns the suspend context. + +``Core::isRenderThread()`` + True on the DF render thread, where the lock is forbidden. diff --git a/docs/dev/Documentation.rst b/docs/dev/Documentation.rst index 0ba61c404d..efd2e9885f 100644 --- a/docs/dev/Documentation.rst +++ b/docs/dev/Documentation.rst @@ -163,7 +163,7 @@ And documentation for the ``autodump`` plugin might look like:: .. dfhack-tool:: :summary: Automatically set items in a stockpile to be dumped. - :tags: fort armok fps productivity items stockpiles + :tags: fort armok fps productivity items stockpile :no-command: .. dfhack-command:: autodump diff --git a/docs/dev/Lua API.rst b/docs/dev/Lua API.rst index 52fb7c778f..a5c2146634 100644 --- a/docs/dev/Lua API.rst +++ b/docs/dev/Lua API.rst @@ -681,6 +681,10 @@ Miscellaneous both from the curry call and the closure call itself. I.e., ``curry(func,a,b)(c,d)`` equals ``func(a,b,c,d)``. +* ``dfhack.dump_interrupt_timing_data()`` + + Dumps the internal log of times between interrupt callbacks, as a table of integers. + Each entry is the difference between two consecutive interrupts, in microseconds. Locking and finalization ------------------------ @@ -938,7 +942,17 @@ can be omitted. * ``dfhack.getHackPath()`` - Returns the dfhack directory path, i.e., ``".../df/hack/"``. + Returns the DFHack installation directory path (the folder where DFHack is installed). + This may be the ``hack`` folder within the DF installation, but you should not rely on this. + Specifically, the installation folder is extremely likely to be somewhere else when DFHack is installed from Steam. + Always use this function to get the DFHack installation directory path instead of hardcoding it. + +* ``dfhack.getConfigPath()`` + + Returns the DFHack config directory path (the folder where user-specific configuration files are stored). + This is currently the ``dfhack-config`` folder within the DF installation, but you should not rely on this as it is likely to change in the future. + Always use this function to get the DFHack config directory path instead of hardcoding it. + Avoid storing this value in a long-lived variable, as it's possible that in future versions of DFHack, it may be possible for the config directory to be changed at runtime. * ``dfhack.getSavePath()`` @@ -1050,7 +1064,64 @@ Translation module * ``dfhack.translation.generateName(name,language,type,major_selector,minor_selector)`` - Dynamically generate a name using the same logic the game itself uses. + Dynamically generate a random name using the same logic the game itself uses. + + **Parameters:** + + .. list-table:: + :class: dfhack-param-table + :header-rows: 1 + :widths: 21 60 19 + + * - Name + - Explanation + - Data Type + * - ``name`` + - | ``name`` property of the object that will have its name generated. This is an output parameter. + | + | Example values: + | ``df.global.world.entities.all[0].name`` + | ``df.unit.find(79).name`` + | ``dfhack.gui.getSelectedUnit().name`` + | ``df.language_name::new()`` + - ``df.language_name`` + * - ``language`` + - | Integer index of the language used for generating the name within ``df.language_translation``. + | + | Example values: + | ``df.global.world.entities.all[0].name.language`` + | ``df.unit.find(79).name.language`` + | ``dfhack.gui.getSelectedUnit().name.language`` + | ``0`` + - ``int`` + * - ``type`` + - | Integer value of the name type. This ensures that the generated name will be appropriate for the given category of object. For allowed values see ``df.language_name_type`` enum. + | + | Example values: + | ``df.global.world.entities.all[100].name.type`` + | ``df.unit.find(79).name.type`` + | ``dfhack.gui.getSelectedUnit().name.type`` + | ``df.language_name_type.Figure`` + | ``13`` + - ``df.language_name_type`` + * - ``major_selector`` + - | Section of the loaded game raws containing words used for generating the name. + | + | Example value for sites belonging to civ with id 100: + | ``df.historical_entity.find(100).entity_raw.symbols.symbols_major[df.entity_name_type.SITE]`` + | + | Example value for units: + | ``df.global.world.raws.language.word_table[0][df.language_name_category.Unit]`` + - ``df.language_word_table`` + * - ``minor_selector`` + - | Section of the loaded game raws containing words used for generating the name. + | + | Example value for sites belonging to civ with id 100: + | ``df.historical_entity.find(100).entity_raw.symbols.symbols_minor[df.entity_name_type.SITE]`` + | + | Example value for units: + | ``df.global.world.raws.language.word_table[1][df.language_name_category.Unit]`` + - ``df.language_word_table`` Gui module ---------- @@ -1430,6 +1501,10 @@ Job module Returns the job's description, as seen in the Units and Jobs screens. +* ``dfhack.job.getManagerOrderName(manager_order)`` + + Returns the manager order's description, as seen in the Work orders screen. + Hotkey module ------------- @@ -1494,12 +1569,14 @@ Units module same checks the game uses to decide game-over by extinction, with an additional sanity check. You can identify citizens, regardless of their sanity, by passing ``true`` as the optional - second parameter. + second parameter. Dead units are never considered citizens, even + when ``include_insane`` is set (though animate intelligent + undead citizens still count). * ``dfhack.units.isResident(unit[,include_insane])`` - The unit is a resident of the fortress. Same ``include_insane`` semantics as - ``isCitizen``. + The unit is a non-dead resident of the fortress. Same + ``include_insane`` semantics as ``isCitizen``. * ``dfhack.units.isFortControlled(unit)`` @@ -1632,6 +1709,7 @@ Units module * ``dfhack.units.hasExtravision(unit)`` * ``dfhack.units.isOpposedToLife(unit)`` * ``dfhack.units.isBloodsucker(unit)`` +* ``dfhack.units.breathes(unit)`` Simple checks of caste attributes that can be modified by curses. @@ -1730,6 +1808,11 @@ Units module Moves the specified unit and any riders to the target coordinates, setting tile occupancy flags appropriately. Returns true if successful. + This function correctly removes units from cages and chains, and correctly + removes the links between one unit dragging another unit. As a special + case, teleporting a merchant that is dragging a pack animal will also + teleport the pack animal. + * ``dfhack.units.getGeneralRef(unit, type)`` Searches for a ``general_ref`` with the given type. @@ -1990,6 +2073,13 @@ Units module Returns a table of the cutoffs used by the above stress level functions. +* ``dfhack.units.getBreathingState(unit)`` + + Returns a value indicating the unit's current breathing state, which can be one of the following: + * ``CANT`` (0) - The unit cannot breathe and is at risk of suffocation. + * ``TROUBLE`` (1) - The unit is having trouble breathing but is not in immediate danger. + * ``FINE`` (2) - The unit is breathing normally. + Action Timer API ~~~~~~~~~~~~~~~~ @@ -2328,7 +2418,8 @@ World module * ``dfhack.world.ReadCurrentWeather()`` - Returns the current game weather (``df.weather_type``). + Returns the current game weather (``df.weather_type``). Deprecated: use + ``dfhack.maps.getCurrentWeather()`` instead. * ``dfhack.world.SetCurrentWeather(weather)`` @@ -2345,7 +2436,12 @@ World module * ``dfhack.world.isLegends([gametype])`` Without any arguments, returns *true* if the current gametype matches. - Optionally accepts a ``gametype`` id to match against. + Optionally accepts a ``gametype`` ID to match against. + + Be advised that ``isAdventureMode()`` does not detect adventure arena (controlling + arena unit). It may be desirable to test ``df.global.gamemode == df.game_mode.ADVENTURE`` + to allow for both, or ``df.global.gametype == df.game_type.ADVENTURE_ARENA`` to check for + just adventure arena, depending on your purpose. * ``dfhack.world.getCurrentSite()`` @@ -2353,7 +2449,7 @@ World module * ``dfhack.world.getAdventurer()`` - Returns the current adventurer unit (if in adventure mode). + Returns the current adventurer unit (if in adventure mode or adventure arena) else ``nil``. .. _lua-maps: @@ -2389,6 +2485,23 @@ Maps module Like ``getTileBlock``, but if the block is not allocated, try creating it. +* ``dfhack.maps.getTileBlockCoord(coords)``, or ``getTileBlockCoord(x,y,z)`` + + Returns the *x, y, z* block coordinates of the map block containing the + given tile position. The z coordinate is a level and is preserved, not + scaled. + +* ``dfhack.maps.getBlockOrigin(coords)``, or ``getBlockOrigin(x,y,z)`` + + Returns the *x, y, z* tile coordinates of the north-west corner of the map + block with the given block coordinates. The z coordinate is a level and is + preserved, not scaled. + +* ``dfhack.maps.getTileBlockOffset(coords)``, or ``getTileBlockOffset(x,y,z)`` + + Returns the *x, y* offset (0-15) of the given tile position within its + containing map block, along with the unchanged *z* coordinate. + * ``dfhack.maps.getTileType(coords)``, or ``getTileType(x,y,z)`` Returns the tile type at the given coordinates, or *nil* if invalid. @@ -2427,6 +2540,13 @@ Maps module Returns *x, y* for use with ``getRegionBiome`` and ``getBiomeType``. +* ``dfhack.maps.getCurrentWeather(coords)`` + ``dfhack.maps.getCurrentWeather(x,y,z)`` + ``dfhack.maps.getCurrentWeather()`` + + Returns the current game weather (``df.weather_type``) at the specified tile + or at the center of the map if no argument is provided. + * ``dfhack.maps.getPlantAtTile(pos)``, or ``getPlantAtTile(x,y,z)`` Returns the plant struct that owns the tile at the specified position. @@ -2486,6 +2606,102 @@ Maps module Removes an aquifer from the given tile position. Returns *true* or *false* depending on success. +* ``dfhack.maps.addMaterialSpatter(pos, mat, matg, state, amount)`` + + Adds a material spatter to the specified map tile. If the tile is already + full of that spatter, returns the amount left over. + + Specifying a state of -1 (None) will automatically choose either Solid, + Liquid, or Gas based on the material properties and the tile temperature. + +* ``dfhack.maps.addItemSpatter(pos, i_type, i_subtype, subcat1, subcat2, print_variant, amount)`` + + Adds an item spatter to the specified map tile. If the tile is already + full of that spatter, returns the amount left over. + + For plant growths, specifying a print_variant of -1 will automatically + choose an appropriate value. For other item types, this field is ignored. + +* ``dfhack.maps.forEachTile(bounds[, filter[, actions]])`` + + Scans every tile inside a cuboid of map tiles in native code, tests each + tile against a filter, and applies one or more actions to the matching + tiles. This is dramatically faster than iterating tiles in Lua, since the + entire scan runs in a single C++ call. + + .. note:: + This API is experimental and may change in future releases. + + The *bounds* argument may be given as a ``{x1,y1,z1,x2,y2,z2}`` table, as + two coordinate values ``(pos1, pos2)``, or as six integers + ``(x1,y1,z1,x2,y2,z2)``. The cuboid is clamped to the loaded map and tiles + in unallocated map blocks are skipped. + + The *filter* argument selects which tiles are acted on. It may be a table + with the following keys (all optional; a tile must satisfy every given + criterion to match), or a function (see below), or *nil* to match all + tiles: + + - ``tiletype``/``tiletypes``: a tiletype or array of tiletypes (numbers or + ``df.tiletype`` names). The tile's tiletype must be one of them. + - ``material``, ``shape``, ``shape_basic``, ``special``, ``variant``: a + tiletype attribute value or array of values (numbers or names from + ``df.tiletype_material``, ``df.tiletype_shape``, + ``df.tiletype_shape_basic``, ``df.tiletype_special``, or + ``df.tiletype_variant`` respectively). The tile's tiletype must have one + of the listed values for that attribute. + - ``designation``: a table of ``df.tile_designation`` field names to + required values, e.g. ``{hidden=false, subterranean=true}``. + Multi-bit fields such as ``dig`` or ``flow_size`` take integer values. + - ``occupancy``: same, for ``df.tile_occupancy`` fields. + - ``filter``: a function ``fn(x, y, z, block, localx, localy, tiletype)`` + evaluated last for each candidate tile; the tile matches only if it + returns a truthy value. ``localx``/``localy`` are the tile's + coordinates within ``block`` (0-15). + + The *actions* argument says what to do with each matching tile. It may be + a table with the following keys (all optional), a function (equivalent to + ``{callback=fn}``), or *nil* to just count matches: + + - ``set_tiletype``: a tiletype (number or name) to write to matching + tiles, or a table mapping tiletypes to replacement tiletypes (keys and + values may be numbers or names). Tiles whose current tiletype is a key + in the table are rewritten to the mapped value, taking precedence over + a plain ``set_tiletype`` value; unmapped tiles fall back to it. + - ``designation``: a table of ``df.tile_designation`` field names to + values, assigned on each matching tile (e.g. ``{hidden=false}`` clears + the hidden flag). + - ``occupancy``: same, for ``df.tile_occupancy`` fields. + - ``construct``: spawn a ``df.construction`` record on each matching tile + that does not already have one. The value is a table with keys + ``item_type`` (number or ``df.item_type`` name), ``item_subtype``, + ``mat_type``, ``mat_index``, ``flags`` (a table of + ``df.construction_flags`` field names to values, e.g. + ``{no_build_item=true}``), and ``tiletype`` (a tiletype to write to the + map tile; the construction's ``original_tile`` records the tiletype + present before that write). Constructions are buffered during the scan + and merged into ``world.event.constructions`` in one sorted pass, so + bulk spawning is cheap. + - ``callback``: a function ``fn(x, y, z, block, localx, localy, + tiletype)`` invoked for each matching tile after the other actions are + applied. Returning exactly ``false`` aborts the scan. + + Returns a table with counts: ``scanned`` (tiles visited), ``matched`` + (tiles passing the filter), ``changed`` (tiles whose tiletype was + rewritten), ``constructed`` (construction records created), and + ``aborted`` (true if a callback stopped the scan early). + + Example:: + + local res = dfhack.maps.forEachTile({x1,y1,z1,x2,y2,z2}, { + material = {df.tiletype_material.STONE, df.tiletype_material.MINERAL}, + shape_basic = df.tiletype_shape_basic.Floor, + designation = {hidden = false}, + }, { + set_tiletype = df.tiletype.StoneFloorSmooth, + }) + print(res.matched) + Burrows module -------------- @@ -2842,12 +3058,9 @@ Common parameters to these functions include: * ``x``, ``y``: screen coordinates in tiles; the upper left corner of the screen is ``x = 0, y = 0`` * ``pen``: a `pen object ` -* ``map``: a boolean indicating whether to draw to a separate map buffer - (defaults to false, which is suitable for off-map text or a screen that hides - the map entirely). Note that only third-party plugins like TWBT currently - implement a separate map buffer. If no such plugins are enabled, passing - ``true`` has no effect. However, this parameter should still be used to ensure - that scripts work properly with such plugins. +* ``map``: a boolean (defaults to false) indicating whether to draw to a + separate map buffer. The Steam version uses separate map buffers with square + tiles for for all types of maps (i.e. fort, region, and world). Functions: @@ -2873,15 +3086,31 @@ Functions: * ``dfhack.screen.paintTile(pen,x,y[,char[,tile[,map]]])`` Paints a tile using given parameters. `See below ` for a - description of ``pen``. + description of ``pen``. The map argument is only supported for local maps + (i.e. fort mode and adventure mode outside of fast travel). The ``char`` and + ``tile`` arguments allow overriding the respective parts of the ``pen`` + without constructing a new pen beforehand. Returns *false* on error, e.g., if coordinates are out of bounds +* ``dfhack.screen.paintMapPortTile(pen,x,y[,char[,tile]])`` + + Paints a tile using given parameters onto the interface texpos layer of a map + port (e.g., the world map or the zoomed-in map for embark selection). The + ``char`` and ``tile`` arguments work as above. + * ``dfhack.screen.readTile(x,y[,map])`` Retrieves the contents of the specified tile from the screen buffers. Returns a `pen object `, or *nil* if invalid or TrueType. +* ``dfhack.screen.readMapPortTile(x,y)`` + + Retrieves the contents of the specified tile from the screen buffers. Returns + a `pen object `, or *nil* if invalid. + + For now only looks at the ``sites`` textpos layer. + * ``dfhack.screen.paintString(pen,x,y,text[,map])`` Paints the string starting at *x,y*. Uses the string characters @@ -3600,6 +3829,15 @@ Core context specific functions: Returns the timer id, or *nil* if unsuccessful due to world being unloaded. +* ``dfhack.timeout_until(tick,callback)`` + + Arranges for the callback to be called once the in-game + tick count (``df.global.world.frame_counter``) reaches + the specified value. The timer is canceled when the + world is unloaded, and cannot be queued until it is + loaded again. Returns the timer id, or *nil* if + unsuccessful due to world being unloaded. + * ``dfhack.timeout_active(id[,new_callback])`` Returns the active callback with the given id, or *nil* @@ -3711,6 +3949,9 @@ environment by the mandatory init file dfhack.lua: ``COLOR_GREY`` and ``COLOR_DARKGREY`` can also be spelled ``COLOR_GRAY`` and ``COLOR_DARKGRAY``. + Note: ``COLOR_RESET`` is not valid in a `Pen `, and using it in a Pen color field + will result in runtime warnings and may result in color flashing or other unexpected results. + * State change event codes, used by ``dfhack.onStateChange`` Available only in the `core context `, as is the event itself: @@ -3742,6 +3983,9 @@ environment by the mandatory init file dfhack.lua: * ``printall_recurse(obj)`` If the argument is a lua table or DF object reference, prints all fields recursively. + The fields of untagged unions are not printed since only one member of such a + union is live at a time and there is no way to identify which one, so + accessing any other member is undefined behavior. * ``copyall(obj)`` @@ -5737,7 +5981,7 @@ TextArea Functions: * ``textarea:getText()`` Returns the current text content of the ``TextArea`` widget as a string. - "\n" characters (``string.char(10)``) should be interpreted as new lines + ``\n`` characters (``string.char(10)``) should be interpreted as new lines * ``textarea:setText(text)`` @@ -6326,12 +6570,27 @@ This is a specialized subclass of CycleHotkeyLabel that has two options: ``On`` (with a value of ``true``) and ``Off`` (with a value of ``false``). The ``On`` option is rendered in green. +ConfigureButton class +--------------------- + +A 3x1 tile button with a gear symbol on it, intended to represent a configure +icon. Clicking on the icon will run the given callback. The graphics can also +be overridden to create custom buttons. + +It has the following attributes: + +:on_click: The function to run when the icon is clicked. +:pen_left: Pen or function returning a pen to overwrite the left tile of the button. +:pen_center: As above, but for the center tile (gear symbol). +:pen_right: As above, but for the right tile. + HelpButton class ---------------- -A 3x1 tile button with a question mark on it, intended to represent a help -icon. Clicking on the icon will launch `gui/launcher` with a given command -string, showing the help text for that command. +Subclass of ConfigureButton; a 3x1 tile button with a question mark on it, +intended to represent a help icon. Clicking on the icon will launch +`gui/launcher` with a given command string, showing the help text for that +command. It has the following attributes: @@ -6341,15 +6600,23 @@ It also sets the ``frame`` attribute so the button appears in the upper right corner of the parent, but you can override this to your liking if you want a different position. -ConfigureButton class ---------------------- +RadioButton class +----------------- -A 3x1 tile button with a gear mark on it, intended to represent a configure -icon. Clicking on the icon will run the given callback. +Subclass of ConfigureButton; a 3x1 tile button that resembles a radio button +(or check box in ASCII mode), identical to the ones found in +`gui/control-panel`. Clicking on the button will toggle its enabled state. It has the following attributes: -:on_click: The function on run when the icon is clicked. +:initial_state: Whether to start in the ``true`` or ``false`` state. Defaults to ``true``. +:on_change: Callback to call when state changes, including initialization. Called as ``on_change(val)``. + +It implements the following method: + +* ``RadioButton:setState(val)`` + + Sets the state to boolean ``val`` and calls ``on_change`` (if defined). BannerPanel class ----------------- @@ -6506,7 +6773,8 @@ Filter behavior: By default, the filter matches substrings that start at the beginning of a word (or after any punctuation). You can instead configure filters to match any -substring across the full text with a command like:: +substring across the full text by setting ``FILTER_FULL_TEXT`` in `gui/control-panel` +or set it for the session by running a command like:: :lua require('utils').FILTER_FULL_TEXT=true diff --git a/docs/dev/Material-encoding.rst b/docs/dev/Material-encoding.rst new file mode 100644 index 0000000000..e2506293d5 --- /dev/null +++ b/docs/dev/Material-encoding.rst @@ -0,0 +1,207 @@ +.. _material-encoding: + +##################################################### +How DF encodes materials: ``mat_type``/``mat_index`` +##################################################### + +Almost everywhere that Dwarf Fortress needs to refer to a material -- items, +item improvements, job requirements, plants, reactions, syndromes, map +features, and so on -- it does so with a pair of integers. In the names +provided by Bay 12, the pair is usually called ``material`` and +``matgloss``, although other naming is sometimes seen, such as ``subcat1``/ +``subcat2``. The ``subcat1``/``subcat2`` names usually appear on fields +that can hold *either* a material+matgloss pair *or* a race+caste pair, +with a companion ``item_type`` field determining how to interpret them. +Before we learned otherwise, the DFHack team originally called +what we now know to be a matgloss a ``mat_index`` ("material index"), and so +this term tends to be used in DFHack code instead of "matgloss". Thus, you may +see these two terms used interchangeably in DFHack code, in documentation, and +in community discussions. + +.. contents:: Contents + :local: + +The ``mat_type`` half +===================== + +``mat_type`` is an index into ``world.raws.mat_table.builtin`` +(a ``special_mat_table``, DF name ``material_infost.misc_def``), which is a +static array of pointers to ``df::material`` objects indexed by the +``builtin_mats`` enum (DF name ``MaterialType``). The enum is laid out as +follows: + +================= ===================== ============================================= +Numeric range Enum values Meaning +================= ===================== ============================================= +-1 ``NONE`` No material / invalid +0 ``INORGANIC`` ``mat_index`` selects a ``df::inorganic_raw`` +1..18 ``AMBER``..``GRIME`` True built-in materials +19..218 ``CREATURE_1..200`` Creature raw materials +219..418 ``HIST_FIG_1..200`` Historical figure creature materials +419..618 ``PLANT_1..200`` Plant raw materials +619..658 ``UNUSED01..40`` Unused; treated as built-ins, normally null +================= ===================== ============================================= + +The named built-ins are ``AMBER``, ``CORAL``, ``GLASS_GREEN``, +``GLASS_CLEAR``, ``GLASS_CRYSTAL``, ``WATER``, ``COAL``, ``POTASH``, +``ASH``, ``PEARLASH``, ``LYE``, ``MUD``, ``VOMIT``, ``SALT``, ``FILTH_B`` +(feces), ``FILTH_Y`` (urine), ``UNKNOWN_SUBSTANCE`` (semen), and ``GRIME``. +Several of these built-ins exist solely for legacy purposes and do not normally +arise in unmodded games; for example, ``FILTH_B`` and ``FILTH_Y`` only appear +in very specific situations, and ``UNKNOWN_SUBSTANCE`` was only ever seen in +connection with tentacle demons, which haven't been in the game since version +0.28.181.40d. These are materials constructed by the game itself (not loaded +from raws). The array still has a slot for every enum value, but entries outside +the named built-ins are not real materials -- for the ``CREATURE_*``, +``HIST_FIG_*``, and ``PLANT_*`` ranges the actual ``df::material`` lives +inside a raw object and ``mat_index`` is needed to find it. Entries in +the array may be null, so consumers should always check the resolved +pointer. + +The ``mat_index`` half +====================== + +The meaning of ``mat_index`` depends entirely on the ``mat_type`` range: + +* ``mat_type`` < 0 (``NONE``): no material. ``mat_index`` should also be -1. + +* ``mat_index`` == -1, any non-negative ``mat_type``: a "bare" reference to + the built-in material object ``mat_table.builtin[mat_type]``. For most + built-ins this is just the material itself, but ``(0, -1)`` is special: + it is the *inorganic wildcard*, meaning "any inorganic material", used in + contexts such as job item constraints (e.g. "any stone"). The same + ``(INORGANIC, -1)`` pair -- written ``INORGANIC:NONE`` in token form -- + is also used for the "magma" material of liquid items picked up in + minecarts. + +* ``mat_type`` == 0 (``INORGANIC``), ``mat_index`` >= 0: ``mat_index`` is + an index into ``world.raws.inorganics.all`` (a ``df::inorganic_raw``), + and the material is ``inorganic->material``. For example, + ``(0, )`` is iron. + +* 1 <= ``mat_type`` <= 18 (built-ins), ``mat_index`` >= 0: still a + built-in material; ``mat_index`` is normally ignored. The one exception + is ``COAL`` (7), where ``mat_index`` distinguishes the product: + 0 means coke and 1 means charcoal. DF has a dedicated enum for these + values, ``MatGlossCoal`` (``MATGLOSS_COAL_COKE`` = 0, + ``MATGLOSS_COAL_CHARCOAL`` = 1), though df-structures does not currently + expose it. + +* 19 <= ``mat_type`` <= 218 (``CREATURE_1``..``CREATURE_200``): + ``mat_index`` is an index into ``world.raws.creatures.all`` + (a ``df::creature_raw``), and ``mat_type - CREATURE_1`` is an index into + that creature's ``material`` vector. In other words, ``mat_type`` picks + both the table *and* which of the creature's (up to 200) defined + materials is meant, while ``mat_index`` picks the creature. + For example, dwarf bone is + ``(CREATURE_1 + , )``. + +* 219 <= ``mat_type`` <= 418 (``HIST_FIG_1``..``HIST_FIG_200``): like the + creature range, except that ``mat_index`` is a **historical figure id**, + not a vector index -- the figure is looked up by its ``id`` field in + ``world.history.figures``. The material itself is + ``creature->material[mat_type - HIST_FIG_1]`` of the figure's race + (``creature_raw::find(figure->race)``). These pairs identify materials + belonging to a *specific* historical figure. In practice they are most + often seen in body-material spatters (``spatter_common``, e.g. blood) + left by named creatures, and in products of named creatures such as + forgotten beasts, titans, and demons. + +* 419 <= ``mat_type`` <= 618 (``PLANT_1``..``PLANT_200``): + ``mat_index`` is an index into ``world.raws.plants.all`` + (a ``df::plant_raw``), and ``mat_type - PLANT_1`` is an index into that + plant's ``material`` vector. + +* ``mat_type`` > 618 (``UNUSED*``): treated as a built-in; + ``mat_table.builtin[mat_type]`` is used directly and is normally null. + +Where the materials themselves live +=================================== + +All of the above resolves to a ``df::material`` object (DF name +``material_definitionst``), which holds the material id, per-state names +and colors, flags, heat properties, and so on: + +* Built-ins: ``world.raws.mat_table.builtin[mat_type]``. +* Inorganics: embedded in ``world.raws.inorganics.all[mat_index]->material``. +* Creatures: ``world.raws.creatures.all[mat_index]->material[subtype]``. +* Plants: ``world.raws.plants.all[mat_index]->material[subtype]``. + Additionally, each ``df::plant_raw`` has a ``material_defs`` block -- + parallel ``type``/``idx`` arrays (DF names ``use_mat``/``use_matg``) + indexed by ``plant_material_def`` -- that records the canonical + ``(mat_type, mat_index)`` pair for each defined use of the plant + (``basic_mat``, ``tree``, ``drink``, ``seed``, ``thread``, ``mill``, + ``extract_vial``, ``extract_barrel``, ``extract_still_vial``). + +Where the pairs appear +====================== + +The ``mat_type``/``mat_index`` naming convention is used fairly consistently +in DF structures for individual fields, so ``MaterialInfo::decode`` can +consume any structure that exposes those members. Some representative +examples: + +* Most item types (``item_barst``, ``item_woodst``, ``item_boulderst``, + ``item_threadst``, etc.) store the pair directly in ``mat_type`` and + ``mat_index`` fields. +* ``item_body_component`` (corpses and body parts) instead stores + ``race``, ``caste``, ``hist_figure_id``, and ``unit_id``. The item + virtual methods ``getActualMaterial()`` and ``getActualMaterialIndex()`` + (DF names ``get_forced_material``/``get_forced_matgloss``) compute the + proper pair -- "an actual material, never a race/caste" -- so + ``MaterialInfo::decode(item)`` always uses those methods rather than the + raw fields. This is the dual-purpose encoding mentioned above: the raw + ``getMaterial()``/``getMaterialIndex()`` methods (DF ``get_subcat1``/ + ``get_subcat2``) return the race and caste for items like these. +* ``material_vec_ref`` is a DFHack convenience compound (not a real DF + structure) that bundles the parallel ``mat_type``/``mat_index`` vectors + DF uses for multi-material lists, e.g. in reaction products and + ``world.raws.mat_table.syndromes``/``effects``. In the actual Bay 12 + headers, these structures are parallel arrays appearing as sequential + members within a larger compound. +* ``job_item`` (work order and job requirements) uses ``mat_type``/ + ``mat_index`` for the required material, where the ``(0, -1)`` inorganic + wildcard is common. +* Historical-figure materials appear in things like body part items of + named creatures. + +Other names for the pair in DF code include ``material``/``matgloss`` +(items, improvements, contaminants), ``use_mat``/``use_matg`` (plant +material definitions), ``itemneeded_material``/``itemneeded_matgloss`` +(job items), ``dye_material``/``dye_matgloss`` (dyed improvements), +``hardens_with_water_mat``/``hardens_with_water_matg``, and +``syndrome_mat``/``syndrome_matg`` (syndrome tables). + +Working with the encoding in DFHack +=================================== + +``DFHack::MaterialInfo`` (exposed to Lua as ``dfhack.matinfo``) is the +canonical decoder for a pair. Constructing one via ``decode(type, index)``, +``decode(item)``, ``decode(material_vec_ref, idx)``, or +``decode(some_struct_pointer)`` fills in: + +* ``type``/``index``: the raw pair. +* ``material``: the resolved ``df::material`` pointer (``isValid()`` is + false when it is null). +* ``mode``: one of ``None``, ``Builtin``, ``Inorganic``, ``Creature``, or + ``Plant`` -- note that historical figure materials report ``Creature``, + distinguished by a non-null ``figure``. +* ``subtype``: the position within the creature/plant material vector. +* ``inorganic``, ``creature``, ``plant``: the resolved raw object for the + matching mode. +* ``figure``: the ``df::historical_figure`` for ``HIST_FIG_*`` pairs. + +``MaterialInfo::find`` and ``dfhack.matinfo.find`` also parse the +string-token forms produced by ``getToken()``: +``INORGANIC:`` (e.g. ``INORGANIC:IRON``), +``CREATURE::`` (e.g. ``CREATURE:DWARF:BONE``), +``PLANT::``, plain ```` for built-ins, and the +special cases ``COAL:COKE``/``COAL:CHARCOAL`` and ``NONE``. + +For plain integer pairs, ``DFHack::t_matpair`` and +``DFHack::t_matglossPair`` are simple ``{mat_type, mat_index}`` / +``{type, index}`` bundles, and ``isAnyInorganic()``/``isInorganicWildcard()`` +test for ``mat_type == 0`` and the ``(0, -1)`` wildcard respectively. + +See the `lua-api` documentation for the Lua-side ``dfhack.matinfo`` +interface. diff --git a/docs/dev/data-identity.rst b/docs/dev/data-identity.rst index ae7076941d..bfca2388e4 100644 --- a/docs/dev/data-identity.rst +++ b/docs/dev/data-identity.rst @@ -17,6 +17,13 @@ contains both a pointer to the C++ object itself and a pointer to a ``type_ident by that pointer. Note that the userdata object does not own the objects pointed to by these pointers, and the Lua engine is never responsible for managing their lifetimes. +Most identities are instances of the single template class ``type_identity_for``, which wraps the C++ type ``T``. +``type_identity_for`` derives from a runtime base class selected at compile time by inspection of ``T`` +(via ``std::is_enum``, ``std::is_pointer``, ``std::is_arithmetic``, container traits, and similar checks), +and implements the applicable virtual methods with ``if constexpr`` dispatch on the properties of ``T``. +A type can override the selection by defining a ``df_identity_base`` typedef naming the desired base; +the code generator emits this typedef for all generated compound types. + ``type_identity`` defines the following public methods: - ``byte_size``: returns the size, in bytes, of the object held @@ -33,7 +40,7 @@ never responsible for managing their lifetimes. - ``is_primitive``: indicates that ``lua_read`` will store a *copy* of the object on the Lua stack instead of a non-owning reference to it. Used for types that have direct representations in Lua: numbers, booleans, simple strings -- ``is_constructed``: indicates that creating a C++ instance of this type requires the use of a possibly nontrivial constructor. A type identity that is both primitive and constructed cannot be inserted into a container. At the moment the only type identity that is both primitive and constructed is ``stl_string_identity``, which wraps the C++ ``std::string`` type. +- ``is_constructed``: indicates that creating a C++ instance of this type requires the use of a possibly nontrivial constructor. A type identity that is both primitive and constructed cannot be inserted into a container. At the moment the only type identity that is both primitive and constructed is ``type_identity_for``, which wraps the C++ ``std::string`` type. - ``is_container``: indicates that the type is a container and thus implements the methods specific to ``container_identity`` @@ -41,7 +48,9 @@ never responsible for managing their lifetimes. - ``copy``: copy the object at ``src`` onto ``tgt``. This uses ``memmove`` for primitive types, and C++ copy-assignment (when possible) for other types -There are plethora of subclasses of ``type_identity``: +The identity class tree is divided into two layers: the *runtime base classes*, which are ordinary +(non-template) classes used for polymorphic dispatch, and the single leaf template ``type_identity_for``, +which derives from the appropriate runtime base for the C++ type ``T`` that it wraps: * ``type_identity`` the abstract base class of all type identities @@ -55,15 +64,13 @@ There are plethora of subclasses of ``type_identity``: * ``struct_identity`` C++ ``class`` or ``structure`` - * ``global_identity`` holds, as a quasiobject, handles for all of the known Dwarf Fortress program-scope static objects as if they were fields of an object called ``global`` - * ``union_identity`` C++ ``union`` * ``other_vectors_identity`` special-case identity for the categorized subvectors of objects that appears in many of Dwarf Fortress's "handler" classes * ``virtual_identity`` polymorphic C++ ``class`` or ``structure`` having a virtual table to handle virtual dispatch - * ``stl_string_identity`` ``std::string`` + * ``global_identity_base`` special-case identity for the global quasiobject * ``xlsx_file_handle_identity`` special case @@ -71,53 +78,37 @@ There are plethora of subclasses of ``type_identity``: * ``container_identity`` "containers" generally. note that all container types are homogeneous (that is, the elements of the container must all be of the same type). abstract base class - * ``bit_container_identity`` for containers that contain bools stored one element per *bit* (rather than per byte) - - * ``bit_array_identity`` Dwarf Fortress's ``BitArray`` type - - * ``stl_bit_vector_identity`` ``std::vector`` + * ``bit_container_identity`` for containers that contain bools stored one element per *bit* (rather than per byte); also provides the ``get_item``/``set_item`` virtual interface * ``buffer_container_identity`` C++ static arrays and raw C++ pointers acting as arrays of unspecified bound - * ``enum_list_attr_identity`` (template) metaobject with metadata about a C++ enumeration; may also include additional metadata - * ``ptr_container_identity`` containers that contain pointers - * ``stl_ptr_container_identity`` containers that are of the form ``std::vector`` for some ``T`` - - * ``ro_stl_container_identity`` (template) "read only containers" - - * ``ro_stl_assoc_container_identity`` (template) ``std::map`` and ``std::unordered_map`` - - * ``stl_container_identity`` (template) ``std::vector`` where ``T`` is *not* a pointer (and not ``bool``) - * ``opaque_identity`` opaque wrapper around any type, provides no functionality - * ``stl_string_identity`` ``std::string`` - * ``function_identity_base`` abstract base class for ``function_identity`` * ``function_identity`` (template) wrapper around a C++ function that can be invoked from Lua - * ``primitive_identity`` wrapper around a primitive type. primitive types are fixed-length objects with no internal structure - - * ``bool_identity`` ``bool`` - - * ``number_identity_base`` abstract base for numeric types - - * ``float_identity_base`` abstract base for floating point types - - * ``float_identity`` (template) ``double`` and ``float`` - - * ``integer_identity_base`` abstract base for integral types + * ``primitive_identity_base`` abstract base class for primitive types. primitive types are fixed-length objects with no internal structure - * ``integer_identity`` (template) ``int8_t``, ``int16_t``, ``int32_t``, ``size_t``, etc. lots of these + * ``number_identity_base`` abstract base for numeric types (and ``bool``); provides the ``isInteger`` discriminator - * ``pointer_identity`` any arbitrary C++ pointer (other than ``char*``) + * ``pointer_identity_base`` abstract base class for pointer identities; provides ``getTarget`` - * ``ptr_string_identity`` C-style (``char *``) string +* ``type_identity_for`` the leaf template that provides the identity of the C++ type ``T``. Its runtime + base class is selected by compile-time inspection of ``T`` (or by a ``df_identity_base`` typedef in ``T``), + covering all of the categories above: enums, bitfields, structs, unions, virtual classes, numbers, + pointers, C strings, ``std::string``, ``std::filesystem::path``, static arrays, containers, and opaque + types. Container categories are detected by capability rather than by type name: any type that provides + index-addressable elements (``t[i]`` yielding a ``T&``), bit-element access (a bool proxy or + ``is_set``/``set`` accessors), mapped iteration (``mapped_type`` plus ``begin``/``end``), read-only + iteration, or a ``{ size, items[] }`` table (``enum_list_attr``) is treated as the corresponding kind of + container without being listed explicitly. -Types marked with "(template)" are C++ template types, all parameterized by a single typename. + ``global_identity`` is an alias for ``type_identity_for``, where ``global_object`` is an + empty placeholder type: it holds, as a quasiobject, handles for all of the known Dwarf Fortress + program-scope static objects as if they were fields of an object called ``global``. Type identity object lifetime and mutability ============================================ diff --git a/docs/dev/g_src-notes.rst b/docs/dev/g_src-notes.rst new file mode 100644 index 0000000000..05de2a62e2 --- /dev/null +++ b/docs/dev/g_src-notes.rst @@ -0,0 +1,804 @@ +.. _g_src-notes: + +######################################### +Notes on the Bay12 ``g_src`` sources +######################################### + +This document describes the open-source portion of Dwarf Fortress that Bay12 +ships in its Linux packages (``g_src``), and how that code maps onto the +symbols and types defined in `df-structures`_ (the ``df.g_src.*.xml`` files and +related globals such as ``enabler``, ``gps``, ``gview``, ``init``, +``musicsound``). + +.. _df-structures: https://github.com/DFHack/df-structures + +Bay12 releases this code so that Linux packagers can rebuild the parts of DF +that link against distribution-specific libraries (SDL2, OpenGL, curses, etc.). + +The reference tree used here is the ``g_src`` folder from the Linux Classic build of DF 53.16 +as available for `download from Bay12 `__. Line numbers are approximate. + +.. contents:: Contents + :local: + +Source inventory and XML correspondence +======================================= + +.. list-table:: + :header-rows: 1 + + * - g_src file + - Role + - df-structures file + * - ``enabler.h``, ``enabler.cpp`` + - ``enablerst`` — SDL main loop, frame scheduler, thread coordination, + ``renderer`` base class, ``textures``, ``Either``, ``zoom_commands``, + ``texture_fullid`` + - ``df.g_src.enabler.xml`` + * - ``enabler_input.h``, ``enabler_input.cpp`` + - ``enabler_inputst`` (base of ``enablerst``), key translation, repeat + timing, macros + - ``df.g_src.enabler.xml`` (parts), ``df.g_src.keybindings.xml`` + * - ``graphics.h``, ``graphics.cpp`` + - ``graphicst`` (``gps``), ``graphic_viewportst``, + ``graphic_map_portst``, ``interface_setst``, ``texblitst``, + screen/tile buffers + - ``df.g_src.graphics.xml`` + * - ``renderer_2d.hpp`` + - ``renderer_2d_base``, ``renderer_2d`` — SDL_Renderer based tile + renderer, texture cache + - ``df.g_src.renderer_2d.xml`` + * - ``renderer_curses.cpp`` + - ``renderer_curses`` (PRINT_MODE:TEXT) + - ``df.g_src.renderer_2d.xml`` + * - ``renderer_offscreen.cpp`` + - offscreen renderer used for map renders (in olders releases, functionality currently not available) + - ``df.g_src.renderer_2d.xml`` + * - ``renderer_opengl.hpp`` + - legacy OpenGL renderer (not compiled — ``#include`` commented out in + ``enabler.cpp:194``) + - n/a + * - ``interface.h``, ``interface.cpp`` + - ``interfacest`` (``gview``), scrolling/string-entry helpers + - ``df.g_src.interface.xml``, ``df.interface.xml`` + * - ``ViewBase.h``, ``ViewBase.cpp`` + - ``viewscreenst``, ``abstract_interfacest``, ``widgets::*`` widget tree + - ``df.g_src.ViewBase.xml`` + * - ``keybindings.h``, ``keybindings.cpp`` + - ``InterfaceKey`` enum, key binding names bimaps + - ``df.g_src.keybindings.xml`` + * - ``KeybindingScreen.*`` + - keybinding-editing UI (viewscreen) + - ``df.g_src.KeybindingScreen.xml`` + * - ``init.h``, ``init.cpp`` + - ``initst`` (``init``) — init.txt/colors.txt parsing, font texpos + tables + - ``df.g_src.init.xml`` + * - ``files.h``, ``files.cpp``, ``find_files.h`` + - ``filest``, ``stringvectst``, path handling, compressed file I/O + - ``df.g_src.files.xml``, ``df.g_src.basics.xml`` + * - ``basics.h``, ``basics.cpp`` + - misc helpers, ``gamelog``/``errorlog``, ``flagarrayst`` + - ``df.g_src.basics.xml`` + * - ``texture_handler.h``, ``textures.cpp`` + - ``texture_handlerst``, ``palettest``, tile-page loading + - ``df.g_src.texture_handler.xml`` + * - ``platform.h``, ``win32_compat.cpp`` + - Win32 API shims for Linux (``GetTickCount``, ``MessageBox``, + ``DWORD``, ...) + - ``df.g_src.platform.xml`` + * - ``mail.hpp`` + - ``Chan`` channel, ``Either`` + - modeled inline in ``df.g_src.enabler.xml`` + * - ``music_and_sound_g.h``, ``music_and_sound*.cpp`` + - ``musicsoundst`` (``musicsound``), ``Song``/``MusicEvent``/ + ``MusicContext`` enums + - ``df.g_src.music_and_sound_g.xml`` + * - ``random.h``, ``random.cpp`` + - Mersenne Twister wrapper, ``trandom*``, seed stack + - ``df.random_objects.xml`` + * - ``command_line.h``, ``command_line.cpp`` + - ``command_linest`` + - ``df.g_src.command_line.xml`` + * - ``lua.h``, ``lua.cpp`` + - ``LuaState`` RAII wrapper, DF Lua globals + - not mapped (see "Other modules" below) + * - ``textlines.h`` + - ``textlinesst`` raw-file line loader + - ``df.g_src.basics.xml`` + * - ``svector.h``, ``bimap.h``, ``pool.h``, ``array_vec.hpp``, + ``template.h``, ``endian.h``, ``resize++.h`` + - internal utility containers/algorithms + - mostly not mapped (STL-internal) + * - ``dfhooks.h``, ``dfhooks.cpp`` + - the DF ↔ DFHack plugin boundary + - n/a — see "DFHack hook placement" below + +Process architecture — the two threads +====================================== + +``enabler.cpp`` implements a two-thread design. Understanding which thread +runs what is the single most important thing about ``g_src`` for DFHack. + +Boot sequence (``main``, ``enabler.cpp:1270``) +---------------------------------------------- + +1. ``SDL_Init(SDL_INIT_TIMER)`` on the **main thread**. +2. ``enabler.renderer_threadid = SDL_ThreadID()`` — the **main thread is the + renderer/event thread** (``enabler.cpp:1282``). +3. ``SDL_CreateThread(call_loop, ...)`` spawns the **simulation thread**, + which runs ``enablerst::async_loop()`` (``enabler.cpp:1265-1268``, + ``746``). +4. ``init.begin()`` parses ``init_default.txt``, ``prefs/init.txt``, + ``colors.txt`` into ``init``/``gps`` (``init.cpp:93``). +5. ``SDL_InitSubSystem(SDL_INIT_VIDEO)``, ``keybinding_init()``, keybindings + loaded (``prefs/interface.txt`` then ``data/init/interface.txt`` — prefs + first because ``load_keybindings`` does not overwrite). +6. ``enabler.loop(cmdline)`` → ``beginroutine()`` (closed source) → renderer + creation (``renderer_2d`` unless PRINT_MODE:TEXT → ``renderer_curses``) → + ``hooks_init()`` → ``eventLoop_SDL()`` (``enabler.cpp:1073-1125``). + +So: the thread that runs ``main()`` owns SDL events, the SDL window/renderer, +and presentation. The created thread owns the simulation and the +screen-buffer writes. + +The channels (``mail.hpp``, ``enabler.h:891-899``) +-------------------------------------------------- + +Three ``Chan`` queues plus one semaphore, all members of ``enablerst``: + +- ``async_tobox : Chan`` — **main → sim** commands: ``pause``, + ``start``, ``render``, ``inc`` (grant N sim frames), ``set_fps``. +- ``async_frombox : Chan`` — **sim → main** messages: ``quit``, + ``complete``, ``set_fps``, ``set_gfps``, ``push_resize``, ``pop_resize``, + ``reset_textures``, ``show_message``. +- ``async_zoom : Chan`` — **sim → main** zoom/fullscreen + requests (``zoom_display()`` writes here; the event loop drains it). +- ``async_fromcomplete : std::binary_semaphore`` — released by the main + thread after it has serviced an ``async_frombox`` request; callers block + on it (see ``override_grid_size``, ``set_fps``, ``set_gfps``, + ``show_message_box``). + +``async_msg`` uses a ``union`` for payloads: ``fps``, ``{x,y}``, or +``{text, caption, type}`` — the wide-string pointers for ``show_message`` +are raw borrowed pointers whose lifetime is guarded by the caller blocking +on ``async_fromcomplete`` until the main thread has shown the box +(``enabler.cpp:733-738``; ``enabler.h`` ``show_message_box``). + +The sim thread — ``enablerst::async_loop`` (``enabler.cpp:746``) +---------------------------------------------------------------- + +The sim thread spins: + +1. Drain ``async_tobox`` (blocking ``read`` when paused or out of frame + budget, ``try_read`` otherwise): + + - ``pause`` → set ``async_paused``, ack with ``async_msg::complete``. + - ``start`` → unpause, reset ``async_frames``. + - ``render`` → if ``ENABLERFLAG_RENDER``: ``renderer->swap_arrays()``, + ``render_things()`` (fills ``gps`` buffers), clear flag, + ``update_gfps()``; then ack ``complete``. **This is the screen-render + tick, on the sim thread.** + - ``inc`` → add to ``async_frames`` budget (clamped to ``fps*3``). + - ``set_fps`` → update local fps. + +2. If unpaused and budget remains (or ``ENABLERFLAG_MAXFPS``): call + ``mainloop()`` — the closed-source simulation + UI step — then + ``simticks++``, ``clear_text_input()``. +3. ``hooks_update()`` **every iteration**, on the sim thread + (``enabler.cpp:812``). This is where ``DFHack::Core::Update()`` runs. + +If ``mainloop()`` returns true, it posts ``async_msg::quit`` and exits; the +main thread sees ``quit`` in ``async_wait``, sets ``loopvar=0``, and both +loops end. + +The main thread — ``eventLoop_SDL`` + ``do_frame`` (``enabler.cpp:892``, ``816``) +--------------------------------------------------------------------------------- + +Each iteration: + +1. Drain ``async_zoom``; if any zooms arrive, **pause the sim first** + (``pause_async_loop`` writes ``pause`` to ``async_tobox`` and blocks in + ``async_wait`` until ``complete``). +2. ``SDL_PollEvent`` loop — again, the first event triggers + ``pause_async_loop``, so **all input processing happens while the sim + thread is parked**. For each event: ``hooks_sdl_event(&event)`` (main + thread, can consume the event entirely), then the switch: keys/wheel/ + quit → ``enabler.add_input`` (input state machine); buttons → + ``enabler.mouse_*``; window events → resize/cursor-focus/ + ``force_full_display_count``/``ENABLERFLAG_RENDER``. +3. Mouse state → ``gps.precise_mouse_x/y``, ``gps.mouse_x/y``, + ``enabler.tracking_on`` (written while sim is paused). +4. ``unpause_async_loop()``. +5. ``hooks_sdl_loop_fn()`` — main thread, just before ``do_frame``. +6. ``do_frame()``: + + - accumulate wall-clock time into ``outstanding_frames`` (sim) and + ``outstanding_gframes`` (graphics), capped at 3 gframes; + - ``inc`` command grants that many sim ticks; + - each gframe: send ``render``, ``async_wait()`` for ``complete`` + (servicing ``set_fps``/``set_gfps``/``push_resize``/``pop_resize``/ + ``reset_textures``/``show_message`` while it waits), then + ``gps.main_thread_requesting_reshape`` handling, + ``tidy_tile_cache()``, ``display()``, ``render()`` → + ``SDL_RenderPresent``. + - ``emit_logs()`` when it sleeps. + +7. ``musicsound.update()``. + +What the pause protocol actually protects +----------------------------------------- + +``pause_async_loop`` only stops ``mainloop()`` and ``render_things`` — it +does **not** lock ``gps``. The buffers are protected by *protocol*, not +mutexes: + +- ``gps.screen``/``screentexpos*`` (the "front" buffers) are written by the + sim thread inside the ``render`` command handler, *before* it posts + ``complete``. +- ``renderer::screen``/``screentexpos*`` (the "back" buffers) are read by + the main thread in ``display()``, *after* ``complete`` arrives. +- ``renderer::swap_arrays()`` (``enabler.cpp:513``) exchanges the + front/back pointer sets, so the sim thread always draws into the buffer + the renderer is *not* currently scanning. The swap runs on the sim + thread, inside the ``render`` command, and completes before ``complete`` + is acked — so there is a happens-before edge via the channel. +- The per-viewport ``screentexpos_*_old`` arrays are likewise rotated in + ``swap_arrays`` so ``display()`` can diff them against the fresh arrays + for incremental redraws (``enabler.cpp:264-398``). + +Things that are **not** safe by protocol (see "Problematic code" below): +``enabler.flag`` (``ENABLERFLAG_RENDER`` written by main, read by sim), +``enabler.async_paused`` (written by ``set_fps`` on whichever thread calls +it), ``simticks``/``gputicks``/``async_frames`` (plain ints read +cross-thread), ``gps.mouse_x``/``mouse_y`` (written by main while +"paused", read by sim at arbitrary times from viewscreen code — races are +possible because the sim thread resumes after ``unpause_async_loop``), and +input statics in ``enabler_input.cpp`` (``timeline``, ``pressed_keys``, +``modState``). + +DFHack hook placement (``dfhooks.cpp``) +--------------------------------------- + +``g_src/dfhooks.cpp`` is a tiny trampoline: ``hooks_init()`` ``dlopen``\ s +``./libdfhooks.so`` (or ``dfhooks.dll``/``libdfhooks.dylib``) and resolves +``dfhooks_init``, ``dfhooks_shutdown``, ``dfhooks_update``, +``dfhooks_prerender``, ``dfhooks_sdl_event``, ``dfhooks_ncurses_key``, +``dfhooks_sdl_loop`` by name. + +``libdfhooks`` itself is the **chainloader** (built from +``depends/dfhooks`` in the DFHack repo): it discovers ``dfhooks_*``/ +``libdfhooks_*.so`` libraries, reads each one's optional +``dfhooks_priority`` int32 (lower runs earlier; DFHack exports 100), calls +each library's optional ``dfhooks_preinit`` at load time, and fans the +other callbacks out in priority order (shutdown in reverse). Note: despite +DFHack's comment in ``library/Hooks.cpp``, ``dfhooks_init``/ +``dfhooks_preinit`` run *after* the sim thread is spawned — +``SDL_CreateThread`` precedes ``enabler.loop`` → ``hooks_init`` in +``main`` (``enabler.cpp:1289``, ``1119``). + +.. list-table:: + :header-rows: 1 + + * - g_src call site + - Exported symbol + - Runs on + - DFHack endpoint (``library/Hooks.cpp``) + * - ``enabler.loop``, after renderer creation, before event loop + - ``dfhooks_init`` + - main + - ``Core::InitMainThread`` + console/command-line checks + * - after ``eventLoop_SDL`` exits + - ``dfhooks_shutdown`` + - main + - ``Core::Shutdown`` + * - every ``async_loop`` iteration + - ``dfhooks_update`` + - **sim** + - ``Core::Update`` (plugin ``onupdate``, ``Gui::*``, etc.) + * - ``render_things``, before top viewscreen ``render()`` + - ``dfhooks_prerender`` + - **sim** + - (currently a stub) + * - every SDL event + - ``dfhooks_sdl_event`` + - **main** + - ``Core::DFH_SDL_Event`` → SDL events for tools + * - every event-loop iteration, before ``do_frame`` + - ``dfhooks_sdl_loop`` + - **main** + - ``Core::DFH_SDL_Loop`` (mouse/display side work) + * - every ncurses key (PRINT_MODE:TEXT only) + - ``dfhooks_ncurses_key`` + - main + - ``Core::DFH_ncurses_key`` + +Practical consequences: + +- Code in ``dfhooks_update`` runs on the sim thread, concurrent with the + main thread's ``display()`` — it must not touch ``renderer.*`` buffers + or SDL. ``CoreSuspender`` exists because even *this* does not stop the + main thread from scanning buffers; it suspends via the DFHack-managed + mutex. +- ``dfhooks_sdl_event``/``dfhooks_sdl_loop`` run while the sim thread is + paused **only for the duration of the event drain** — ``unpause`` + happens before ``hooks_sdl_loop_fn``/``do_frame``, so + ``dfhooks_sdl_loop`` runs concurrent with the sim thread. It is safe for + SDL calls and for reading ``gps`` display-side state, not for mutating + simulation data. +- A ``dfhooks_sdl_event`` handler that consumes an event stops DF's own + processing of it entirely (the ``continue`` at ``enabler.cpp:932``). + +``enablerst`` structure notes (``enabler.h:840``, ``df.g_src.enabler.xml``) +=========================================================================== + +``enablerst`` publicly inherits ``enabler_inputst`` — so +``enabler.add_input``, ``enabler.get_input``, keybinding and macro methods +all live on ``enabler``. + +Field-by-field: + +- ``renderer`` (``renderer*``) — the active renderer; ``renderer_2d`` in + normal SDL builds. Virtual interface in ``enabler.h:733-852``; XML + models all vmethods (``df.g_src.enabler.xml`` ````). +- Timing: ``fps``/``gfps`` are **floats** (target rates), + ``outstanding_frames``/``outstanding_gframes`` are float accumulators + drained by ``do_frame``. ``frame_timings``/``gframe_timings`` + sums + implement the 50-frame rolling average behind + ``calculate_fps``/``calculate_gfps`` (``enabler.cpp:1185-1213``). +- ``simticks``, ``gputicks`` — plain ``int``, incremented on sim and main + threads respectively; also read by the *other* thread (e.g. + ``Event::tick`` in input code) → benign-looking but technically racy. +- ``flag`` (``ENABLERFLAG_*``): ``RENDER`` = "a gframe is due; sim should + run ``render_things``"; ``MAXFPS`` = uncapped sim; ``BASIC_TEXT`` = use + the basic (non-antialiased) font texposes. +- Input: ``mouse_lbut``/``mouse_rbut``/``mouse_mbut`` (+``_down``/ + ``_lift`` edge flags), ``tracking_on`` (mouse inside a valid grid + cell), ``listening_to_text``/``last_text_input`` (32-byte UTF-8 + text-input buffer, filled by ``SDL_TEXTINPUT`` on the main thread, + cleared by sim thread each tick, read via ``get_text_input()`` by + viewscreens that want text). +- ``clock`` — approx. ``SDL_GetTicks`` refreshed per ``do_frame``; cheap + tick for GC-ish logic. +- ``command_line`` — the reconstructed command line string; the actual + ``command_linest`` is ``game->command_line`` (``df.game_g.xml`` / + ``df.g_src.command_line.xml``). +- ``fullscreen_state`` (``FullscreenState`` bitfield), + ``overridden_grid_sizes`` (deque used by + ``override_grid_size``/``release_grid_size``; note the FIXME at + ``enabler.cpp:1157`` about unmatched release calls). +- ``last_message_result`` — ``std::atomic_int``; result of the last + ``MessageBox`` call, set on the main thread in + ``win32_compat.cpp:142``. +- ``must_do_render_things_before_display`` — when a viewport reshape is + pending (``gps.main_thread_requesting_reshape``), ``render_things`` + leaves this true so ``do_frame`` performs the reshape on the main + thread before the next ``display()`` (``enabler.cpp:849-858``, + ``1253``). +- ``textures`` (``enabler_textures``/``textures`` class, + ``enabler.h:557``) — raw ``SDL_Surface`` catalog backing ``texpos`` + values: ``raws`` vector, ``free_spaces``, ``init_texture_size``, + ``uploaded`` flag. ``add_texture``, ``clone_texture``, + ``grayscale_texture``, ``delete_all_post_init_textures`` etc. in + ``textures.cpp``. +- Methods of interest: ``set_fps``/``set_gfps`` (thread-agnostic — they + forward via ``async_frombox`` when called off the renderer thread), + ``override_grid_size``/``release_grid_size``, ``zoom_display``, + ``show_message_box``, ``clean_cached_tile``, + ``get_current_interface_tile_dims``, ``main_renderer()``. + +``graphicst`` and the tile pipeline (``graphics.h:5436``, ``df.g_src.graphics.xml``) +==================================================================================== + +``gps`` is the screen-state singleton. Everything a viewscreen draws lands +in its arrays. + +The screen cell +--------------- + +- Indexing is **column-major**: cell ``(x, y)`` is at ``x*dimy + y`` + (confirmed throughout, e.g. ``addchar``, ``enabler.cpp:38``). +- ``screen`` is ``dimx*dimy*8`` bytes; 8 bytes per cell: ``s[0]`` = + character byte; ``s[1..3]`` = foreground RGB; ``s[4..6]`` = background + RGB; ``s[7]`` = type byte (``GRAPHICSTYPE_TTF``/``_TTFCONT`` — + 0xff/0xfe, currently vestigial since TTF paths are commented out). +- ``screenf``/``screenb``/``screenbright`` + ``use_old_16_colors`` are the + "pen". ``changecolor(f,b,br)`` selects the 16-color palette + (``uccolor[16][3]``); ``color3ub``/``bcolor3ub`` set true RGB + (``screen_color_*`` fields). Both are written into the cell's RGB bytes. +- ``locate(y, x)`` sets the cursor ``screenx``/``screeny`` (note **y + first**); ``addchar`` writes a cell then ``screenx += advance``. + Clipping via ``setclipping``/``clipx``/``clipy``. + +The texpos layer set +-------------------- + +For each cell there is a parallel set of ``long`` texpos arrays: + +- ``screentexpos`` — the base graphical tile (overrides the char when + ``INIT_DISPLAY_FLAG_USE_GRAPHICS``). +- ``screentexpos_lower`` — underlay (beneath text/tiles). +- ``screentexpos_anchored`` + ``_x``/``_y`` — multi-cell anchored tiles + (creatures/buildings bigger than one tile). Subordinate cells get + ``SCREENTEXPOS_FLAG_ANCHOR_SUBORDINATE`` plus packed 6-bit offsets + (``ANCHOR_X_COORD>>6``, ``ANCHOR_Y_COORD>>12``, 64x64 limit, + ``graphics.cpp:983-991``). +- ``screentexpos_flag`` per cell: ``GRAYSCALE``, ``ADDCOLOR``, + ``ANCHOR_SUBORDINATE``, ``TOP_OF_TEXT``, ``BOTTOM_OF_TEXT``, + ``ANCHOR_USE_SCREEN_COLOR`` (``graphics.h:2668-2677``). +- The same five arrays again for the **top layer** (``screen_top``, + ``screentexpos_top*``), composited over the base layer when + ``gps.top_in_use`` (e.g. announcements/tutorial overlays). +- Write order in ``update_all``: background → ``update_tile`` per cell → + ``update_anchor_tile`` → top-layer ``update_top_tile``/ + ``update_top_anchor_tile``. + +``texture_fullid`` (``enabler.h:646``/``671``) = ``{texpos, r,g,b, +br,bg,bb, flag}`` — the SDL renderer's cache key; ``flag`` is +``texture_fullid_flag`` (``TRANSPARENT_BACKGROUND``, ``DO_RECOLOR``, +``CONVERT``). + +Viewports and map ports +----------------------- + +- ``graphic_viewportst`` (``graphics.h:3319``) — a layered representation + of the map portion of the screen: ~23 parallel ``int32_t`` arrays + (``screentexpos``, ``_background``, ``_background_two``, + ``_floor_flag``, ``_liquid_flag``, ``_spatter_flag``, ``_spatter``, + ``_ramp_flag``, ``_shadow_flag``, ``_building_one``/``_two``, + ``_top_shadow``, ``_item``, ``_vehicle``, ``_projectile``, + ``_high_flow``, ``_vermin``, ``_signpost``, five ``*_creature`` + directions, ``_designation``, ``_interface``) plus a matching ``*_old`` + set for diffing. ``screen_x``/``screen_y`` are its private cursor; + ``clipx``/``clipy`` clip writes; ``flag`` has + ``GRAPHIC_VIEWPORT_FLAG_ACTIVE``. +- ``gps.main_viewport`` + ``gps.lower_viewport[LOWER_VIEWPORT_MAX]`` (=8) + — the multilevel z-view: lower viewports draw beneath the main one. + ``multilevel_depth_texpos[8]`` picks the depth shading tiles. +- ``graphic_map_portst`` (world-map viewports): base + 8 ``edge`` + 8 + ``edge2`` + detail/tunnel/river/road/site/army/interface + + ``detail_to_*`` (8 dirs) + ``site_to_s`` + ``cloud_bits`` (u64 bitmask + per cell) — plus ``_old`` twins, ``pixel_perc_x/y``, + ``top_left_corner_*``. +- Resizing flows through ``reshape_viewports(zoom_factor)`` → + ``shape_viewport_according_by_pixel_size`` (32px or + ``zoom_factor*32/128`` per tile; map ports use 16px). + +Per-frame lifecycle +------------------- + +1. Main thread ``do_frame``: ``render`` cmd → sim thread. +2. Sim thread: ``swap_arrays()`` — ``gps.*`` ↔ ``renderer.*`` pointer + exchange for every buffer, including each viewport's ``*_old`` arrays. +3. Sim thread: ``render_things()`` (``graphics.cpp:1199``): + + - walk ``gview.view`` ``child`` chain to topmost viewscreen; + - ``hooks_prerender()``; + - ``currentscreen->render(curtick)`` then + ``currentscreen->widgets.render(curtick)`` — **widgets render last, + deliberately "mostly DFHack"** (comment at line 1218). This is the + ordering DFHack's overlay relies on. + - REC/PLAY/prefix/FPS indicators. + +4. Sim acks ``complete``; main thread ``async_wait`` returns. +5. Main thread ``display()`` (``enabler.cpp:218``): if + ``force_full_display_count>0``, ``do_blank_screen_fill`` + full + viewport/map_port rebuilds; else per-cell diff between + ``screentexpos_*`` and ``*_old`` → ``update_viewport_tile``/ + ``update_map_port_tile`` only for changed cells. Then + ``update_all()`` → ``render()`` → ``SDL_RenderPresent``. + +The SDL renderer (``renderer_2d.hpp``) +-------------------------------------- + +``renderer_2d_base`` implements the ``renderer`` interface with an +``SDL_Renderer``: a ``texture_fullid → SDL_Texture*`` cache, +``update_tile`` / ``update_anchor_tile`` per-cell compositing (reading +``screen`` bytes + ``screentexpos`` + flags into a ``texture_fullid``, +looking it up in the cache, ``SDL_RenderCopy``), ``update_all`` (full +traversal), ``tidy_tile_cache`` (LRU-ish eviction), ``clean_tile_cache`` +(full flush — triggered via ``gps.do_clean_tile_cache``), ``zoom``/ +``resize``/``grid_resize``/``set_viewport_zoom_factor``/ +``set_fullscreen``. + +``screen_to_texid`` (``enabler.cpp:37``) is the canonical cell→texture +translation: char byte → font texpos (basic/large/small, or ``_top``/ +``_bot`` for half-height text rows), or ``screentexpos`` + +GRAYSCALE/ADDCOLOR color mixing. + +``init.font.*_font_texpos[256]`` (``init.h``, ``df.g_src.init.xml``) map +glyph bytes → font-page texposes, derived from ``curses_*.png`` font +sheets by ``prepare_graphics``. + +Input pipeline (``enabler_input.cpp``, ``keybindings.h``) +========================================================= + +SDL event → ``enabler.add_input`` (main thread, sim paused) → +``KeyEvent{release, EventMatch{type, mod, scancode, key|button|y}}`` → +``key_translation`` looks up ``EventMatch`` in the static ``keymap`` +(``multimap``) → ``Event``\ s go into the +``timeline`` (``set`` ordered by ``time`` then ``serial`` — one +physical press = one serial, possibly several ``InterfaceKey``\ s). + +Sim thread consumes via ``enabler_inputst::get_input(now)`` → +``std::set`` for one physical press; repeats are rescheduled +onto the timeline per ``repeatmap`` (``REPEAT_NOT``/``SLOW``/``FAST``, +tunable via ``init.input.hold_time``/``repeat_time``/``repeat_accel_*``). +``REPEAT_MWHEEL`` uses ``repeats`` as a countdown so one wheel notch +generates exactly N events. + +``InterfaceKey`` (``keybindings.h``, ~1800 constants, +``df.g_src.keybindings.xml``) is the binding vocabulary; +``bindingNames``/``displayNames``/``sdlNames``/``sdlUnicode`` bimaps +translate between keys and human strings. Macros record +``set`` lists (``prefs/macros/*.mak``), played back onto the +timeline. + +``interface.h`` adds ``standardscrolling``/``secondaryscrolling``/ +``standardstringentry`` helpers, ``INTERFACEFLAG_RETAIN_NONZERO_INPUT``, +and ``interfacest gview`` — the viewscreen stack root. + +Interface layer (``ViewBase.h``, ``interface.h``) +================================================= + +- ``viewscreenst`` (``ViewBase.h:1050``): ``child``/``parent`` links form + the screen stack (``gview.view`` is the root *member*, not a pointer). + ``breakdownlevel`` (``INTERFACE_BREAKDOWN_*``) requests teardown; + ``feed(set)``, ``logic()``, ``render(curtick)`` are the + three virtuals the loop calls; ``set_port_flags()`` lets a screen claim + viewports. +- ``interfacest::addscreen``/``removescreen`` splice the list + (``INTERFACE_PUSH_*``); ``interfacest::loop`` (``interface.cpp:870``) is + invoked from inside ``mainloop()`` and drives feed/logic/render per + screen (inferred — ``mainloop`` itself is closed source). +- ``viewscreenst::widgets`` (a ``widgets::container``) is the per-screen + widget tree — the DFHack overlay attaches widgets here. Container + types: ``container``, ``widget_stack``, ``rows_container``, + ``columns_container``, ``tabs``, ``scroll_rows``, ``radio_rows``, + ``table``, ``folder``, ``filter``, ``multifilter``, + ``params_container``; display widgets: ``text``, ``text_truncated``, + ``text_multiline``, ``nineslice``, ``anchored_tile``, + ``keybinding_display``, ``graphics_switcher``, ``character``; inputs: + ``interface_*_button``, ``better_button``, ``textbox``, ``dropdown``. + Anchoring is Godot-``Control``-style (``anchor_*`` fractions + + ``offset_*`` + ``LayoutPreset``\ s). + +Other modules DFHack touches +============================ + +- ``initst init`` — all init.txt state; ``init.display.grid_x``/ + ``grid_y`` are the *tile-grid* dimensions; ``init.display.flag`` has + ``USE_GRAPHICS``, ``TEXT``, ``NOT_RESIZABLE``, ``SOFTWARE``; + ``init.input.zoom_speed``, ``pause_zoom_no_interface_ms``. +- ``musicsoundst musicsound`` (``music_and_sound_g.h:422``) — song/ + ambience state machine (``Song``/``MusicEvent``/``MusicContext`` enums, + ``MUSICSOUND_FLAG_*``), up to ``SOUND_CHANNELNUM`` =32 channels; + ``update()`` is called from the main event loop; ``loading_files`` are + async futures. +- ``command_linest`` — ``game->command_line.original``, ``arg_vect``, + ``gen_id``/``world_seed``/``world_param``. +- ``random.h`` — ``trandom(limit)``, + ``push_trandom_uniform_seed``/``pop_trandom_uniform_seed`` (seed stack), + ``trandom_twist``. +- ``filest``/``stringvectst``/``textlinesst`` — path resolution across DF + dirs (``canon_location``/``both_locations``/ + ``with_flags(FILE_FLAG_*)``), manual ``std::string *`` vector (implemented via the ``pstringst`` class), raw-file + loader. +- ``LuaState`` (``lua.h``) — DF embeds Lua for the closed side (worldgen, + utterances); ``add_lua_df_globals`` registers ``trandom``, ``lua_log``, + ``capitalize_string_*``; ``data/init/globals.lua`` is run at state + init. Not mapped in df-structures. +- ``platform.h``/``win32_compat.cpp`` — Win32 names on Linux: + ``GetTickCount`` ≈ ``SDL_GetTicks``, ``MessageBox`` → + ``SDL_ShowMessageBox`` (with cross-thread marshaling via + ``show_message``), ``Sleep``, ``QueryPerformance*``, ``DWORD``, + ``MB_*``, ``VK_*``. Values are locally defined and may differ from real + Windows constants. + +Problematic / outdated / unsafe code +==================================== + +Confirmed defects and latent hazards. Severity tags: **[bug]** real +defect; **[ub]** undefined/unspecified behavior risk; **[fragile]** works +today but easily broken; **[stale]** dead/outdated code. + +Channels and threading +---------------------- + +- **[ub]** ``Chan::read`` waits ``cv.wait(lk)`` with no predicate; a + spurious wakeup (or a wakeup for a message another consumer took) makes + ``vals.front()``/``pop()`` run on an empty queue — UB + (``mail.hpp:34-40``). +- **[ub]** ``Chan::try_read_for_millis`` treats *any* ``wait_for`` return + of ``no_timeout`` as "queue non-empty" and pops unconditionally + (``mail.hpp:23-33``). Also only one consumer can safely read a channel; + ``try_read`` races multiple consumers by design of ``try_to_lock``. +- **[fragile]** ``Chan::write(const T &w)`` calls + ``vals.push(std::move(w))`` — moving from a const ref copies; the API + looks like it moves but doesn't (``mail.hpp:41-45``). +- **[fragile]** ``async_cmd``/``async_msg`` have a union + tag and their + ctors don't initialize the union (``enabler.h:875-899``); a reader using + the wrong member reads garbage. ``async_wait`` dispatches on ``msg`` so + this is currently consistent — but ``async_msg(show_message)`` leaves + ``fps``/``x``/``y`` uninitialized and vice versa. +- **[bug/ub]** ``enabler.flag``, ``enabler.async_paused``, ``simticks``, + ``gputicks``, ``async_frames``, ``gps.mouse_*`` are non-atomic and + cross-thread (e.g. ``flag`` write ``enabler.cpp:1005`` vs read ``784``; + ``async_paused`` write ``enabler.cpp:1219`` vs read ``757``/``800``; + ``simticks`` read on main in ``enabler_input.cpp:703``/``741``). Mostly + benign on x86, formally racy. +- **[fragile]** ``async_msg::show_message`` ships raw ``const wchar_t*`` + cross-thread; lifetime is guarded only by the sender blocking on + ``async_fromcomplete``. If anything drains ``async_frombox`` without + servicing ``show_message``, or releases the semaphore early, UAF. +- **[fragile]** ``release_grid_size`` drains *all* + ``overridden_grid_sizes`` then sends one ``zoom_resetgrid``; the + in-source FIXME notes a known push/pop mismatch ("overriding once per + movie but apparently only releasing for the last one", + ``enabler.cpp:1157``). +- **[stale]** ``enabler_input.cpp:677`` calls ``enabler.simticks.load()`` + — but ``simticks`` is a plain ``int``. This is inside ``#ifdef CURSES``; + the curses input path cannot compile as written. Indicates the curses + port bit-rotted after ``simticks`` stopped being atomic. + +Screen-buffer writes +-------------------- + +- **[bug]** ``addchar_flag(x, y, ...)`` and ``top_addchar_flag(x, y, + ...)`` write ``screentexpos_flag[screenx*dimy + screeny]`` — the + *cursor* cell, not the ``(x,y)`` argument cell (``graphics.h:11183``, + ``11309``). +- **[bug]** ``addchar(x, y, c, f, b, bright)`` (and ``top_addchar``, + ``addchar_flag``, ``top_addchar_flag`` ``(x,y)`` overloads) compute + ``fg = f + (bright ? 8 : 0)`` and then ignore it, indexing + ``uccolor[f]`` — the bright flag is dropped on these overloads + (``graphics.h:11112-11118``). +- **[ub]** the ``(x,y)`` ``addchar``/``top_addchar``/``addchar_flag``/ + ``top_addchar_flag`` overloads index ``uccolor[f]``/``uccolor[b]`` with + raw u8 parameters — any color index >15 is an out-of-bounds read + (``graphics.h:11113-11118`` etc.). +- **[fragile]** ``locate``+``addchar`` bounds-safety relies on the clip + rect: the pointer check ``s < screen_limit`` has no lower bound in the + cursor-based overloads; a negative ``screenx`` passes the check and is + only saved because ``clipx[0] >= 0``. Calling ``setclipping`` with + negative values breaks that invariant. +- **[bug]** ``gps_locator::is_free`` reads ``gps.screen[x*dimy*8 + + y*8]`` with no bounds check at all (``graphics.h:11433+``). + Over/underflow reads if called with out-of-range coordinates. +- **[fragile]** ``graphicst::dim_colors``, ``rain/snow/color_square`` + operate on the 8-byte cell layout with magic offsets; several are only + correct for the 16-color palette path. +- **[stale]** ``graphicst::gray_out_rect`` is fully commented out with a + note that ``*4`` should be ``*8`` — the 4-byte cell era ended long ago + (``graphics.h:11351-11367``). The TTF path in ``addst``/``top_addst`` + (``GRAPHICSTYPE_TTF``, ``ttf_manager``, ``ttfstr``) is similarly dead — + commented out wholesale (``graphics.cpp:482-530``, ``562-610``). + +Renderer +-------- + +- **[fragile]** ``renderer_2d_base::update_all`` uses hard-coded display + numbers (1920/1080/907) and ``SDL_GetDisplayBounds(0, ...)`` on display + 0 for title-screen/background sizing (``renderer_2d.hpp`` + ``update_all``). +- **[fragile]** ``renderer_2d_base::zoom``: ``case zoom_reset:`` sets + ``zoom_steps = 0`` then **falls through** into ``zoom_resetgrid`` → + ``compute_forced_zoom()`` (``renderer_2d.hpp:4478-4481``). Probably + intended — reset implies recompute — but there is no + ``[[fallthrough]]`` or comment. +- **[bug]** ``renderer_2d_base`` zoom/reshape math has in-source FIXMEs: + "perc introduces some error so at certain zooms it is way off (the -16 + is a cludge)" (``graphics.cpp:306-311``) — map-port origin math is + approximate and known-wrong at some zoom levels. +- **[fragile]** ``clean_cached_tile``/``clean_tile_cache``/ + ``tidy_tile_cache`` call ``SDL_DestroyTexture`` on cache entries; + entries can be null — tolerated by SDL2 but unchecked. +- **[stale]** ``renderer`` declares ``directtexcopy``/ + ``directtexcopy_old`` buffers that ``gps_allocate`` never allocates — + dead fields still mapped in ``df.g_src.enabler.xml`` + (``enabler.h:750``/``772``). +- **[stale]** ``renderer_opengl.hpp`` is ``#include``\ d out + (``enabler.cpp:194``) and ``loop()`` has a block of commented + ``renderer_opengl``/``partial``/… selection logic + (``enabler.cpp:1101-1116``); ``renderer_curses`` is ``#ifdef CURSES`` + -only. ``ttf_manager`` exists but its users are commented out. +- **[fragile]** ``renderer_2d`` ctor exits the process on init failure + (window/renderer creation) rather than reporting. + +Input +----- + +- **[fragile]** ``add_input`` synthesizes release/re-press ``KeyEvent``\ s + for all pressed keys on every modifier change + (``enabler_input.cpp:537-555``) — quirky by design; DFH code that + injects ``KeyEvent``\ s should use ``add_input_refined`` ("Use this one. + It's much nicer.", ``enabler_input.h:96``). +- **[fragile]** ``load_macro_from_file`` uses fixed 100-char ``getline`` + buffers and hand-rolled parsing; malformed files print to cout and can + partially populate ``macros`` (``enabler_input.cpp:968-1043``). +- **[bug-ish]** ``get_input``'s "3 simticks before first repeat" guard + can drop a repeat event silently (``enabler_input.cpp:782-785``). +- **[stale]** ``GetKeyDisplay`` prints to ``cout`` on missing binding and + returns "?" — fine for dev, useless in production. + +Platform layer and misc +----------------------- + +- **[bug]** ``win32_compat.cpp::MessageBox``: ``new char[64]``/ + ``new char[128]`` for title/text are **leaked on every call** — the + heap allocation is unnecessary (``SDL_ShowMessageBox`` is synchronous + and copies the strings; the comment claiming stack pointers are unsafe + is wrong) (``win32_compat.cpp:125-129``). ``wcstombs`` also truncates + at fixed sizes and loses non-ASCII. +- **[fragile]** ``platform.h`` redefines Win32 constants (``MB_*``, + ``VK_*``, ``DWORD``, etc.) by hand; several values are explicitly noted + not to match Windows. Code compiled against real Win32 values may + behave differently. +- **[stale]** ``endian.h`` byteswaps only under ``__ppc__``/ + ``__ppc64__`` — dead on all modern targets. +- **[bug-ish]** ``stringvectst`` (``files.h``/``basics.h``) manually owns + C++ STL strings, erases from the front, and can leak if a consumer directly accesses its public + data element or if ``read_file`` is used on a non-empty list. Several methods ignore allocation failure. +- **[fragile]** file/decompression helpers (``files.cpp``, + ``basics.cpp``) read sizes and resize buffers from on-disk values + without validation — malformed data can trigger huge allocations or + overruns. +- **[fragile]** ``texture_handlerst``/``palettest``/``textures`` do + manual ``new[]``/``delete[]`` and pointer bookkeeping; error paths leak + or leave dangling pointers (e.g. ``clone_texture`` pushes a NULL + surface into ``raws``, ``textures.cpp:59-71``). +- **[fragile]** ``lua.cpp``: ``lua_push_flags_member`` calls + ``std::tolower`` on possibly-signed ``char`` (UB for negative chars); + several raw ``lua_*`` stack/index manipulations assume fixed stack + positions. +- **[stale]** ``enabler.h`` still carries OpenGL-era members/comments + (``reset_textures`` semantics predate SDL_Renderer; "OpenGL state + (wrappers)" comment on ``textures``), and ``enabler.cpp``/ + ``graphics.cpp`` have extensive commented-out TTF/OpenGL/partial-print + code. +- **[fragile]** ``musicsoundst`` mixes raw ints/``DWORD``\ s, + ``svector``\ s, and ``std::future``\ s; ``SOUND_CHANNELNUM``-indexed + arrays in ``interfacest`` movie code are fixed at 200 entries with no + bounds checks (``interface.h:115``). +- **[fragile]** ``emit_logs``/``errorlog_string``/``gamelog_string`` + open/close file streams per call plus keep static ``ofstream``\ s — the + comment admits "opening/closing these too often seems to cause crashes + on linux" (``basics.cpp:54-56``). + +Quick map: g_src symbol → DFHack +================================= + +.. list-table:: + :header-rows: 1 + + * - g_src + - df-structures / DFHack + * - ``enabler`` (global ``enablerst``) + - ``df.global.enabler`` (``df/enabler.h``) + * - ``gps`` (``graphicst``) + - ``df.global.gps`` (``df/graphic.h``) + * - ``gview`` (``interfacest``) + - ``df.global.gview`` (``df/interface.h``); + ``Gui::getCurViewscreen`` + * - ``init`` (``initst``) + - ``df.global.init`` + * - ``musicsound`` + - ``df.global.musicsound`` + * - ``mainloop()`` + - not mapped — closed side; reached via ``Core::Update`` inside + ``dfhooks_update`` + * - ``hooks_*`` trampolines + - ``dfhooks_*`` exports in ``library/Hooks.cpp`` + * - ``renderer`` vtable + - ``df/renderer.h`` vmethods — interposable via ``VTableInterpose``; + the old ``Renderer::renderer_wrap`` chaining code in + ``library/modules/Renderer.cpp`` is entirely commented out + * - ``InterfaceKey`` + - ``df.interface_key`` enum; ``Screen``/``Gui`` modules + * - ``enabler.async_tobox``/``async_frombox``/``async_zoom`` + - modeled inline in ``df.g_src.enabler.xml`` (mutex+condvar+deque + members) + * - ``screentexpos*`` arrays + - ``df.gps``/``df.renderer`` pointer fields; + ``Screen::paintScreen``/``df.screen`` diff --git a/docs/dev/index.rst b/docs/dev/index.rst index 931b9bb006..98a1fcfc59 100644 --- a/docs/dev/index.rst +++ b/docs/dev/index.rst @@ -17,10 +17,14 @@ These are pages relevant to people developing for DFHack. /docs/dev/Lua API /docs/dev/overlay-dev-guide /docs/dev/Structures-intro + /docs/dev/g_src-notes + /docs/dev/Material-encoding + /docs/dev/CoreSuspender /docs/dev/data-identity /docs/dev/github-workflows /docs/dev/release-process /docs/dev/Memory-research + /docs/dev/performance-plan /docs/dev/Binpatches /docs/dev/Remote /docs/NEWS-dev diff --git a/docs/dev/performance-plan.rst b/docs/dev/performance-plan.rst new file mode 100644 index 0000000000..4d5309635f --- /dev/null +++ b/docs/dev/performance-plan.rst @@ -0,0 +1,291 @@ +========================= +Performance plan +========================= + +Working plan for improving DFHack's per-tick CPU budget. Based on +microarchitecture analysis (Intel VTune capture ``r120ue``, Jan 2026) +and code review of the Lua integration. Not user-facing documentation. + +.. contents:: + +Baseline findings +================= + +From ``r120ue`` (uarch exploration, whole-system sampling; ~24k +``CPU_CLK_UNHALTED.THREAD`` samples): + +- DFHack-side code is ~21% of sampled CPU: ``lua53.dll`` 3322, + ``dfhooks_dfhack.dll`` 1750, plugins ~110. +- DFHack owns ~11% of ``STALLS_L3_MISS`` and ~11% of DTLB + ``WALK_ACTIVE`` samples. +- Top lua53 stallers are data-layout problems, not dispatch: + ``luaH_getshortstr`` (hash-chain walk, 17 stall samples), + ``luaD_precall``, ``lua_geti``, ``luaH_next`` (``pairs`` iteration), + ``lua_getmetatable``, ``mainposition``, ``luaC_checkfinalizer``. +- Top CPU consumers are the C<->Lua transition layer: + ``luaV_execute`` 759, ``luaD_precall`` 260, ``index2addr`` 115, + ``luaV_finishget`` (metamethod resolution) 90, ``luaG_traceexec`` + 82 (the always-armed ``LUA_MASKCOUNT`` interrupt hook), + ``match``/``singlematch`` ~88 (Lua pattern matching per frame), + plus ``lua_tolstring``/``luaS_hash`` churn. +- C++ side: MSVC ``std::_Hash``/``std::_Tree`` internals, + ``std::string`` construct/destroy churn, ``matchFocusString``, + ``Units::isActive`` — chained-node STL containers have the same + miss problem as Lua's tables. + +Working hypothesis (operator model): DF's own memory traffic evicts +essentially all DFHack data from L3 between simulation ticks, so every +tick starts cold and each pointer-chase (hash chain, GC list, +metatable walk) pays full miss latency. Goal: make the per-tick cold +walk *sequential* (prefetcher-trackable) rather than pointer-chasing, +and reduce the number of misses by shortening dependent-load chains. + +Overlay processing is believed to dominate the per-tick budget; this +is consistent with the data but not yet proven — see the attribution +step below. + +Constraints and rejected directions +=================================== + +- **No PGO/LTO (deferred).** DFHack does questionable things to DF's + vtables and binary; whole-program optimization changes codegen + assumptions the interpose/binpatch machinery relies on. Would need + a dedicated validation effort before it is safe to enable. Revisit + only if cheaper items are exhausted. +- **Hugepages: optional-only.** ``VirtualAlloc(MEM_LARGE_PAGES)`` + needs ``SeLockMemoryPrivilege`` and often fails for normal users; + Linux needs THP/hugetlbfs. Steam Deck (Linux) is a significant + user population and we have no Linux profiling. Any allocator work + must function with ordinary pages; hugepages may be an opt-in + bonus path (``madvise(MADV_HUGEPAGE)`` on Linux, + ``MEM_LARGE_PAGES`` on Windows) with mandatory fallback. +- **No LLVM-JIT Lua (Ravi etc.).** The miss profile is in + ``ltable``/``lgc``/``ldo`` (data layout + transitions), not + ``lvm`` dispatch. A JIT keeps identical object layout and would + also have to reproduce our ``lua_lock`` threading patch, + ``LUA_MASKCOUNT`` interrupt hooks, ``lstate.h`` field access, + yieldable pcall, and debug-hook fidelity. Wrong tool for this + problem. +- **LuaJIT** has the best-in-class data layout but is Lua 5.1 + semantics; incompatible with our 5.3 corpus. + +Action items +============ + +Ordered by leverage-per-effort. All are architecture-independent +unless noted. + +Measurement +----------- + +1. Per-tick attribution. ``perf_counters.update_lua_ms`` / + ``update_plugin_ms`` (``Core.cpp:1668-1682``) already isolate Lua + update time; ``script-manager.lua`` prints them. Baseline a real + fort first. +2. Callsite-stack attribution on VTune captures: ``dd_callsite`` + parent links in ``dicer.db`` allow rebuilding stacks and splitting + Lua-side stalls between overlay widgets, timers, and plugin + events. (Scratch query script: ``%TEMP%/vtune_agg.py``; accepts a + ``dicer.db`` path for other captures.) +3. Lua-level: run ``profiler.lua`` scoped to the update window, or a + count hook aggregating by Proto, to identify which script + functions dominate ticks. +4. Object-level (if needed): we own the Lua source — log + ``luaC_newobj``/``luaM_realloc_`` (addr, type, size, phase) during + a session, dump live objects by walking ``allgc`` at a tick + boundary, cross-reference VTune miss addresses. + +Cheap knobs +----------- + +5. Raise the interrupt-hook interval. ``interrupt_init`` arms + ``LUA_MASKCOUNT, 256`` unconditionally (``LuaTools.cpp:508``) — + ``luaG_traceexec`` shows 82 samples. Raising the count trades + runaway-script interrupt latency for less hook dispatch. Tune and + verify interrupt still fires acceptably. +6. GC tuning on the core state (``lua_gc`` pause/stepmul) — never + configured today; only ``scripts/dwarf-op.lua`` calls + ``collectgarbage``. One-line experiments; watch memory growth. +7. Audit per-frame Lua string work: ``match``/``singlematch`` + samples imply ``string.find``/patterns in a per-frame path; + ``luaS_hash``/``lua_tolstring`` churn implies string building per + frame. Fix at the call site once attribution identifies it. +8. Audit ``pairs()`` iteration over hash-part tables in hot loops + (``luaH_next`` stalls): array-part iteration is contiguous and + prefetchable; hash-part is not. + +Structural: Lua heap layout +--------------------------- + +9. Custom ``lua_Alloc`` with type/lifetime-segregated arenas. + + - All GC objects funnel through ``luaC_newobj(L, tt, sz)`` — one + place to tag by type; other allocs via ``luaM_realloc_`` by + size class. + - Permanent bump region for everything allocated during + ``Lua::Open`` + ``require dfhack`` + script load (protos, + interned strings, metatables, fieldtables, module tables) — + init order approximates tick access order; GC's ``allgc`` walk + becomes quasi-sequential. Phase flag can live in + ``lua_extra_state`` (already used by ``dfhack_llimits.h``). + - Separate churn region for post-init garbage. + - Only 3 state-creation sites: ``Core.cpp:1394``, + ``LuaTools.cpp:1793``, ``LuaTools.test.cpp``. + - Portable: works with plain VirtualAlloc/mmap; hugepages are a + strictly optional overlay on top (see constraints). + - Caveat: ``lua_Alloc`` must implement realloc semantics + (alloc+copy+free is fine); Lua GC never moves objects + (``push_adhoc_pointer`` relies on this already). +10. Software prefetch at tick entry in ``Lua::Core::onUpdate`` + (``LuaTools.cpp:2073``): prefetch ``G(State)``, registry, and + each due coroutine's ``stack``/``ci`` before ``lua_resume`` — + portable via ``_mm_prefetch``/``__builtin_prefetch``. Cheap; + converts serial cold misses into overlapped ones. + +Binding layer (the userdata proxy question) +------------------------------------------- + +11. Kill the double lookup in the field path. + ``meta_struct_index`` → ``find_field`` → ``lookup_field`` + (``LuaTypes.cpp:422-454``) probes a Lua field-table *and* walks + the enum metatable per access. Short strings are interned, so + ``TString*`` pointer equality = name equality: a small + direct-mapped cache keyed ``(fieldtable, TString*)``, or a + per-type dense index assigned to each field name at metatable + build time, collapses this to one load. Highest-confidence fix — + hits ``luaV_finishget``/``luaH_getshortstr``/``lua_getmetatable`` + simultaneously. +12. Reduce per-access userdata churn. ``push_object_ref`` allocates + a fresh userdata (+ ``object_ref_header``: tag_ptr, tag_identity, + tag_attr, field_info) for every nested struct/container access — + ``unit.pos.x`` garbage-per-hop. Options, in increasing scope: + + a) arena allocation makes the churn cheap and local (item 9); + b) memoize refs via weak-valued cache keyed ``(ptr, identity)`` + — adds a hash probe, only worth it for expensive chains; + c) bulk getters for hot access patterns (e.g. one call returning + ``x,y,z`` for ``pos``) — semantic addition, avoids N + allocations and N metamethod hops per vector; + d) pack/shrink ``object_ref_header`` — review which of its four + pointers are needed on the common path; union-tag fields could + live in a side table populated only for unions. +13. Keep the proxy model; make the ref cheaper. A full move away + from userdata proxies isn't practical (scripts rely on + reference identity, ``__index`` polymorphism, ``_field``), but + the identity-layer work in PR #5959 already removes one virtual + dispatch from every primitive field read — that direction + (more ``if constexpr`` static knowledge in the access path) is + the right way to slim the proxy rather than replacing it. + +C++-side containers +------------------- + +14. MSVC STL ``unordered_map``/``unordered_set``/``map`` on per-tick + paths are chained-node structures with the same miss signature + (``_Fnv1a_append_value``, ``_Hash`` loops appear in the stall + list — e.g. inside ``Units::isActive``). Swap hot-path instances + for flat maps / sorted vectors / precomputed indices. +15. ``matchFocusString`` allocates ``std::string`` per call per + frame — make it allocation-free (``string_view``, cached split). + +Overlay / script layer +---------------------- + +16. Once attribution lands (item 2/3): batch the overlay render + path. ``dfhack.penarray`` bulk tile ops already exist + (``LuaApi.cpp:997+``). Per-cell ``Pen`` writes through + metamethods per frame should move to bulk APIs or C++ rendering. + If overlay is indeed the budget owner, this is likely the + largest single user-visible win. + +Verification methodology +======================== + +For each change: measure ``update_lua_ms`` / ``update_plugin_ms`` +before/after on the same save; VTune Hotspots (not uarch exploration +— less noise) filtered to the sim thread; compare +``MEMORY_ACTIVITY.STALLS_L3_MISS`` and ``MEM_LOAD_RETIRED.L3_MISS`` +inside ``lua53.dll``/``dfhooks_dfhack.dll``. Each item above is +independently revertable. + +Lua 5.4/5.5 upgrade assessment +============================== + +The ``__ipairs`` blocker is narrower than it appears: + +- ``__ipairs`` is registered on all generated metatables + (``LuaTypes.cpp`` ``SetPairsMethod`` x4; ``LuaWrapper.cpp`` + ``wtype_ipairs``/``complex_enum_ipairs``). It exists because some + proxy ``__index`` implementations never return nil for integer + keys (enum attrs — DFHack/dfhack#1860), so plain ``ipairs`` would + not terminate. +- Under 5.4, ``__ipairs`` is ignored entirely; ``ipairs`` indexes + ``t[i]`` until nil. Migration path: make every proxy ``__index`` + bounded for integer keys (containers already bound by item_count; + enum attrs need an explicit bound — the enum count is known), then + audit direct ``__ipairs`` users (one script: + ``scripts/test/fix/stuck-written-materials.lua``; plus + ``test/structures/enum_attrs.lua`` semantics). +- Real benefits for our profile: **generational GC** (young-gen + collections over small dense sets directly attack the + cold-sweep problem), ~10-25% faster VM, ``lua_newuserdatauv`` + uservalues (could fold ``object_ref_header`` into a uservalue — + see item 12d). +- Costs: semantic audit of ~500 scripts (integer/string coercion + strictness, ``math.random`` algorithm change breaks seeded + determinism, ``lua_resume`` signature change — we call it + directly in ``LuaTools.cpp:880``), bytecode format change + (``dumper.lua`` ``string.dump`` roundtrips). Moderate project. +- Recommendation: pursue items 9-12 first (larger wins per risk); + revisit 5.4 as a GC-locality lever afterward. 5.5 is not yet a + stable target; track it but don't plan against it. + +PR #5959 review (performance notes) +=================================== + +Net-positive for the hot path, with watch-items: + +- **Win:** ``type_identity_for::lua_read``/``lua_write`` inline + ``*(T*)ptr`` via ``if constexpr`` — removes the second virtual + call (old ``integer_identity_base::lua_read`` → virtual + ``read()``) on every primitive field access. This is exactly the + ``read_field``/``write_field`` hot path. +- **Watch:** ``mapped`` containers (``std::map``/ + ``unordered_map``) now get identities with O(n) ``item_pointer`` + iteration. Any struct field of map type newly exposed to Lua + (check the ``library/xml`` submodule bump) becomes O(n^2) under + ``ipairs``-style iteration — audit which types gain this. +- **Watch:** ``container_storage>`` generalizes the + "vector == vector" layout assumption to *any* + random-access pointer container (deque etc.). The assumption + holds for same-width pointer instantiations in practice, but the + blast radius widened. +- **Note:** removing ``BUILD_DFHACK_LIB`` guards lets plugins + instantiate ``container_impl``/``type_identity_for`` locally, + creating non-canonical identity objects — ``is_type_compatible`` + compares identity *pointers*, so a plugin-side local instance of + an existing identity could yield false type mismatches. Document + that identities must come from ``identity_traits``/canonical + sources only. +- ``field_error``/``get_object_internal`` newly ``DFHACK_EXPORT`` — + required by header-side template instantiation; fine. +- ``LuaTools.h`` now includes ``DataIdentity.h`` — heavier include + in every consumer TU; compile-time only. +- ``bool`` moved to ``NUMBER_IDENTITY_TRAITS`` with correct + ``isInteger()==false`` and bool-specific read/write — semantics + preserved (``type()`` still ``IDTYPE_PRIMITIVE``, name "bool"). +- ``c_string`` → ``primitive_identity_base`` preserves + "raw pointer string" write error and push-string read semantics. +- ``push_adhoc_pointer`` retains the "GC never moves objects" hack — + reinforces that any allocator work must stay non-moving. + +Suggested order +=============== + +1. Callsite attribution (who owns the Lua stalls). +2. Cheap knobs: hook count, GC tuning, string-work audit. +3. Field-lookup inline cache (item 11). +4. Arena allocator (items 9-10). +5. C++ container swaps + ``matchFocusString`` (items 14-15). +6. Overlay batching per attribution (item 16). +7. Lua 5.4 evaluation (generational GC) once 1-6 land. diff --git a/docs/plugins/autodump.rst b/docs/plugins/autodump.rst index 298619a62e..8c2110f8f2 100644 --- a/docs/plugins/autodump.rst +++ b/docs/plugins/autodump.rst @@ -8,6 +8,9 @@ autodump .. dfhack-command:: autodump-destroy-here :summary: Destroy items marked for dumping under the keyboard cursor. +.. dfhack-command:: autodump-destroy-item + :summary: Destroy the selected item. + This tool can instantly move all unforbidden items marked for dumping to the tile under the keyboard cursor. After moving the items, the dump flag is unset and the forbid flag is set, just as if it had been dumped normally. See @@ -24,19 +27,28 @@ Usage autodump [] autodump-destroy-here + autodump-destroy-item ``autodump-destroy-here`` is an alias for ``autodump destroy-here`` and is intended for use as a keybinding. +``autodump-destroy-item`` marks the currently viewed item for destruction. +The pending destruction can be canceled if the command is run again before +unpausing, or by running ``autodump undestroy``. + Options ------- ``destroy`` - Destroy instead of dumping. Doesn't require a cursor. If ``autodump`` is - called again with this option before the game is resumed, it cancels - pending destroy actions. + Destroy instead of dumping. Doesn't require a cursor. Items held by units + (e.g. equipped or in a carried container) are skipped, and the contents of + destroyed containers are destroyed along with the container. ``destroy-here`` - Destroy items marked for dumping under the cursor. + Destroy items under the cursor that are marked for dumping. +``undestroy`` + Revert pending destruction of items marked by ``destroy``, + ``destroy-here``, or ``autodump-destroy-item``. Only works while the game + is still paused; once a frame passes, the items may already be gone. ``visible`` Only process items that are not hidden. ``hidden`` @@ -48,8 +60,14 @@ Examples -------- ``autodump`` - Teleports items marked for dumping to the cursor position. + Teleports unforbidden items marked for dumping to the cursor position. ``autodump destroy`` - Destroys all unforbidden items marked for dumping + Destroys all unforbidden items marked for dumping. +``autodump destroy forbidden`` + Destroys all forbidden items marked for dumping, skipping unforbidden items. ``autodump-destroy-here`` - Destroys items on the selected tile that are marked for dumping. + Destroys unforbidden items on the selected tile that are marked for dumping. +``autodump-destroy-item`` + Destroys the item currently being viewed, or reverts its pending destruction. +``autodump undestroy`` + Reverts pending destruction of all marked items. diff --git a/docs/plugins/autofarm.rst b/docs/plugins/autofarm.rst index 8896c3117c..0d070eeedc 100644 --- a/docs/plugins/autofarm.rst +++ b/docs/plugins/autofarm.rst @@ -9,6 +9,14 @@ Periodically scan your plant stocks and assign crops to your farm plots based on which plant stocks are low (as long as you have the appropriate seeds). The target threshold for each crop type is configurable. +Farms that span multiple biomes are handled correctly: any crop that can grow +in any of the biomes covered by the farm's tiles may be assigned to that farm. + +Individual farm plots can be excluded from autofarm management via the controls +that appear on the farm plot information sheet. That sheet also provides a +shortcut to `gui/autofarm`, a convenient UI for setting crop thresholds and +enabling or disabling autofarm. + Usage ----- @@ -24,9 +32,14 @@ Usage Sets thresholds of individual plant types. You can find the identifiers for the crop types in your world by running the -following command:: +following command: ``getplants -f`` - lua "for _,plant in ipairs(df.global.world.raws.plants.all) do if plant.flags.SEED then print(plant.id) end end" +Some plants produce usable growth items (such as fruits, leaves, or bulbs) +when processed or gathered. These products normally count toward the plant's +threshold together with the plant items themselves, but you can also assign a +threshold to a specific growth product with the ``:`` syntax. +A plant is grown while either its pooled stock or any of its assigned growth +products is below the corresponding threshold. Examples -------- @@ -35,3 +48,6 @@ Examples Set the default threshold to 30. ``autofarm threshold 150 MUSHROOM_HELMET_PLUMP GRASS_TAIL_PIG`` Set the threshold for Plump Helmets and Pig Tails to 150 +``autofarm threshold 30 BUSH_QUARRY:LEAVES`` + Keep at least 30 Quarry Bush Leaves in stock, growing Quarry Bushes until + you have enough of them to process diff --git a/docs/plugins/autolabor.rst b/docs/plugins/autolabor.rst index 73d5feac53..34c997f2ff 100644 --- a/docs/plugins/autolabor.rst +++ b/docs/plugins/autolabor.rst @@ -5,6 +5,16 @@ autolabor :summary: Automatically manage dwarf labors. :tags: fort auto labors +The ``autolabor`` command runs the plugin's *legacy* engine, which writes the +labor matrix directly and bypasses the work detail system. The same plugin +also provides the `labormanager` command, a modern engine that works through +the work detail system instead. A third *monitor* mode watches the job +board and shows task starvation warnings without managing labors at all. +Only one mode can be active at a time; +``autolabor mode [legacy|modern|monitor]`` or the overlay on the work +details screen switches between them, and the selected mode persists +across save/load. + Autolabor attempts to keep as many dwarves as possible busy while allowing dwarves to specialize in specific skills. @@ -32,24 +42,21 @@ untouched by autolabor. logging level using the `debugfilter` command (setting either ``debug`` or ``trace`` level for the ``cycle`` mode) but be warned that this may generate a large amount of console spam, especially in a large fort. - When it is enabled, autolabor automatically disables the work detail system. You cannot - use autolabor and work details at the same time. If you attempt to open the work detail screen while - autolabor is active, a warning box should appear advising you that autolabor is managing labors and preventing - you from making any changes on that screen. + When it is enabled in legacy mode, autolabor automatically disables the work detail system. You cannot + use legacy autolabor and work details at the same time. The DFHack overlay panel on the work details + screen (the same panel that provides the `gui/settings-manager` save/load buttons) shows the active + mode and warns you when legacy mode is preventing changes on that screen from taking effect. - Finally, should you disable autolabor, autolabor will automatically reenable the vanilla work detail system. - However, the work detail system only updates labors when the work detail screen is open and some change is - made on that screen. Therefore, if you choose to disable autolabor, you should probably immediately - thereafter open the work details screen and make some change to force the game to recompute all labor - assignments based on the vanilla algorithm. At this time, it is not possible for autolabor to do this - automatically. + Should you disable autolabor or switch to modern mode, the vanilla work + detail system is automatically reenabled and all citizens' labors are + recomputed from the work details. Usage ----- :: - enable autolabor + autolabor enable Anything beyond this is optional - autolabor works well with the default settings. Once you have enabled it in a fortress, it stays enabled until you @@ -114,5 +121,9 @@ Advanced usage Turn off autolabor for a specific labor. ``autolabor reset-all| reset`` Return a labor (or all labors) to the default handling. +``autolabor mode [legacy|modern|monitor]`` + Show or change which engine runs when the plugin is enabled. Monitor + mode performs no labor management; it only watches the job board and + reports starving job postings via the DFHack notification panel. See `autolabor-artisans` for a differently-tuned setup. diff --git a/docs/plugins/blueprint.rst b/docs/plugins/blueprint.rst index 2d84a7ca69..3ec8d9c435 100644 --- a/docs/plugins/blueprint.rst +++ b/docs/plugins/blueprint.rst @@ -3,7 +3,7 @@ blueprint .. dfhack-tool:: :summary: Record a live game map in a quickfort blueprint. - :tags: fort design buildings map stockpiles + :tags: fort design buildings map stockpile With ``blueprint``, you can export the structure of a portion of your fortress in a blueprint file that you (or anyone else) can later play back with diff --git a/docs/plugins/buildingplan.rst b/docs/plugins/buildingplan.rst index c00569365c..e06b3586a8 100644 --- a/docs/plugins/buildingplan.rst +++ b/docs/plugins/buildingplan.rst @@ -146,6 +146,12 @@ Some building types will have other options available as well, such as a selector for how many weapons you want in weapon traps or whether you want to only build engraved slabs. +If you want placed buildings to be built as soon as possible, you can toggle +the "Do now" option (or hit :kbd:`n`). While it is on, the job for each placed +building is flagged as high priority, so dwarves will prefer it over their +regular work once the building is unsuspended. The option stays on until you +toggle it off again, and it is saved with your configuration. + Setting quality and material filters ++++++++++++++++++++++++++++++++++++ @@ -204,6 +210,18 @@ other available items (or from items produced in the future if not all items are available yet). If there are multiple item types to choose for the current building, one dialog will appear per item type. +Queueing work orders +-------------------- + +If you are planning a building but do not have the required items in stock, you can +automatically queue a manager work order to produce the missing quantity. After +selecting your desired item types and filters, press :kbd:`Ctrl`:kbd:`q` (or click +"Queue order") to generate a work order. + +`buildingplan` will attempt to automatically determine the correct job (e.g. making +a wooden bed if you are planning a bed) and will respect the material categories +you have selected in your filters. + Building status --------------- @@ -215,6 +233,9 @@ to the building. If there is a particular building that you need built ASAP, you can click on the "make top priority" button (or hit :kbd:`Ctrl`:kbd:`T`) to bump the items for this building to the front of their respective queues. +To rush the construction job itself once the building is underway, use the +"Make top priority" toggle in the `prioritize` overlay. + Note that each item type and filter configuration has its own queue, so even if an item is in queue position 1, there may be other queues that snag the needed item first. @@ -237,3 +258,8 @@ usual) unless freed via the ``Free`` buttons on the ``Show items`` tab on both buildings. This will remove the mechanism from the building and drop it onto the ground, allowing it to be reused elsewhere. There is an option to auto-free mechanisms when unlinking to perform this step automatically. + +For any linked building that is a lever, a ``Pull`` button also appears next to it +on the ``Show linked buildings`` tab, with a glyph showing the lever's current +position. Clicking it queues a high-priority ("do now") pull-lever job without +having to navigate to the lever itself; click it again to cancel the job. diff --git a/docs/plugins/export-world-map.rst b/docs/plugins/export-world-map.rst new file mode 100644 index 0000000000..33307df5df --- /dev/null +++ b/docs/plugins/export-world-map.rst @@ -0,0 +1,96 @@ +export-world-map +================ + +.. dfhack-tool:: + :summary: Export world map data for GIS and other external tools. + :tags: inspection embark map + +This plugin exports world-map information to files in the DFHack configuration +folder under the ``map-export`` directory. It must be run from the +embark-selection screen. The exports are generated at the granularity of +individual region tiles (16 x 16 per world tile). Those tiles are only generated +by DF as you scroll around on the zoomed-in embark map, which thus needs to be +done prior to running this export tool. + +The supported export targets are: + +- ``regions``: writes world tile regions and their biome information to ``regions.csv``. +- ``sites``: writes site extents and ownership/civilization information to ``sites.csv``. +- ``rivers``: writes rivers to ``rivers.csv``. +- ``elevation``: writes elevation data to ``elevation.dat`` and ``elevation.vrt``. + +Unless you have software such as ArcGIS or QGIS/GDAL available, these exports +will be of little use. + +Usage +----- + +:: + + export-world-map + export-world-map + export-world-map ... + export-world-map all + +If no topic is given, or if ``all`` is supplied, every supported export target +is run. + +Options +------- + +``--group-by-world`` + Create a folder named after the world in the ``map-export`` directory. + +``--group-by-date`` + Create a folder with name ``year-month`` inside the world folder. Implies + ``--group-by-world``. + +Examples +-------- + +``export-world-map`` + Export all supported datasets. + +``export-world-map regions`` + Export only region boundary geometries. + +``export-world-map sites rivers`` + Export site and river geometries only. + + +Notes on file formats +--------------------- + +To avoid depending on external libraries such as GDAL, the export formats are +kept as simple as possible to generate. This means raw bitmap data for the +elevation export and CSV files for the others. Before these files can be used +reasonably in software such as QGIS, they need to be assigned a coordinate +reference system (CRS) and converted into formats that can be accessed more +efficiently than CSV. While any CRS will do, WGS 84 / Pseudo-Mercator +(EPSG:3857) is the preferred choice. + +For detailed map data (``regions`` and ``rivers``), Parquet is by far the +preferred format. The elevation export provides a VRT file to facilitate the +transformation into a GeoTIFF with the aforementioned CRS. + +Assuming a recent version of GDAL built with Parquet support and with all +command-line tools in your PATH, this can be achieved with the following +commands: + +:: + + ogr2ogr -a_srs 'EPSG:3857' -of Parquet regions.parquet -oo 'GEOM_POSSIBLE_NAMES=*wkt' -oo 'KEEP_GEOM_COLUMNS=NO' -oo 'AUTODETECT_TYPE=YES' regions.csv + ogr2ogr -a_srs 'EPSG:3857' -of Parquet rivers.parquet -oo 'GEOM_POSSIBLE_NAMES=*wkt' -oo 'KEEP_GEOM_COLUMNS=NO' -oo 'AUTODETECT_TYPE=YES' rivers.csv + gdal raster convert elevation.vrt elevation.tif + +As an alternative to separate Parquet files, all vector data exports might also be imported into a single Spatialite (SQLite) file: + +:: + + ogr2ogr -a_srs 'EPSG:3857' -of SQLite world-map.db -oo 'GEOM_POSSIBLE_NAMES=*wkt' -oo 'KEEP_GEOM_COLUMNS=NO' -oo 'AUTODETECT_TYPE=YES' -dsco 'SPATIALITE=YES' -update -overwrite regions.csv + +Or into a single Geopackage file: + +:: + + ogr2ogr -a_srs 'EPSG:3857' -of GPKG world-map.gpkg -oo 'GEOM_POSSIBLE_NAMES=*wkt' -oo 'KEEP_GEOM_COLUMNS=NO' -oo 'AUTODETECT_TYPE=YES' -update -overwrite regions.csv diff --git a/docs/plugins/getplants.rst b/docs/plugins/getplants.rst index 82616b2358..3ba5a6e386 100644 --- a/docs/plugins/getplants.rst +++ b/docs/plugins/getplants.rst @@ -11,8 +11,9 @@ names. Usage ----- -``getplants [-t|-s|-f]`` - List valid tree/shrub ids, optionally restricted to the specified type. +``getplants [-t|-s|-f] []`` + List valid tree/shrub ids, optionally restricted to the specified type and + traits. ``getplants [ ...] []`` Designate trees/shrubs of the specified types for chopping/gathering. @@ -25,6 +26,10 @@ Examples Gather all plants on the map that yield seeds for farming. ``getplants NETHER_CAP -n 10`` Designate 10 nether cap trees for chopping. +``getplants --brewable -a`` + Designate all shrubs that can be brewed into alcohol. +``getplants --brewable`` + List all valid brewable plant IDs. Options ------- @@ -46,3 +51,22 @@ Options Verbose: Lists the number of (un)designations per plant. ``-n `` Number: Designate up to the specified number of plants of each species. +``-d``, ``--dry-run`` + Dry run: report how many designations would be (un)set without changing + anything. +``--brewable`` + Restrict the selection to plants that can be brewed into alcohol. +``--edible`` + Restrict the selection to plants with parts that can be eaten raw. +``--oil`` + Restrict the selection to plants with seeds, nuts, or fruits that can be + pressed into oil. +``--cloth`` + Restrict the selection to plants that yield thread for cloth. +``--dye`` + Restrict the selection to plants that yield dyes. + +Trait filters can be combined, in which case a plant that matches any of the +specified traits qualifies. They apply to the whole selection, so +``getplants --brewable -x QUARRY_BUSH`` designates every brewable plant except +quarry bushes. diff --git a/docs/plugins/labormanager.rst b/docs/plugins/labormanager.rst index 255de05c46..560057ecad 100644 --- a/docs/plugins/labormanager.rst +++ b/docs/plugins/labormanager.rst @@ -2,131 +2,193 @@ labormanager ============ .. dfhack-tool:: - :summary: Automatically manage dwarf labors. - :tags: unavailable - -Labormanager is derived from `autolabor` but uses a completely different -approach to assigning jobs to dwarves. While autolabor tries to keep as many -dwarves busy as possible, labormanager instead strives to get jobs done as -quickly as possible. - -Labormanager frequently scans the current job list, current list of dwarves, and -the map to determine how many dwarves need to be assigned to what labors in -order to meet all current labor needs without starving any particular type of -job. - -Dwarves on active military duty or dwarves assigned to burrows are left -untouched. + :summary: Automatically manage dwarf labors via work details. + :tags: fort auto labors + +``labormanager`` is the modern mode of the `autolabor` plugin. Instead of +writing the labor matrix directly, it expresses labor policy through Dwarf +Fortress's work detail system, so the game's job auction chooses which +dwarves take which jobs. The plugin's managed work details are visible on +the work details screen (``u`` -> Labor -> Work Details) with names +beginning with ``auto:``. The DFHack overlay panel on that screen (the +same panel that provides work detail save/load buttons from +`gui/settings-manager`) lets you switch modes and tune the plugin's +priorities. + +Since the job auction already prefers skilled dwarves for skilled jobs, +labormanager focuses on the things the auction does poorly: + +* **Unskilled job starvation.** The auction tends to starve jobs that + require no skill (hauling, cleaning, lever pulling, and so on) in favor of + skilled work. Labormanager maintains a laborer pool, assigned to the + ``auto:Laborers`` work detail and restricted to unskilled labors, so there + are always dwarves that *can only* take unskilled work. The pool grows and + shrinks based on how many unclaimed unskilled job postings are piling up. + +* **Skill growth and specialization.** Citizens with a useful skill are + assigned to a specialized ``auto:`` work detail covering the labors that + train that skill, so they spend their time practicing rather than hauling. + If the fortress has a guild hall dedicated to the dwarf's profession, + labormanager also protects some of that specialist's time so they can + participate in guild activities (see ``idle-reserve`` below). + +Labormanager keeps statistics on which skills the fortress actually +exercises, based on the job postings that resolve on the job board. That +history shapes specialization: + +* **No dead skills.** Skills the fort never uses (animal dissection and + gelding in most forts, for example) get no reserved work detail at all -- + a dwarf whose only talent is an unused skill stays a generalist instead + of being pinned to irrelevant work. +* **Demand-weighted specialization.** Frequently-used skills are + prioritized over rarely-used ones when choosing which specialty to + assign, and units skilled in them are protected harder from laborer + duty. +* **Quality skills.** Skills whose products carry skill-scaled quality + (smithing, crafting, cooking, mechanics, engraving, and so on) are + preferred for higher-skilled workers -- both when choosing a specialty + and when deciding who to keep out of the laborer pool. +* **Assignment stickiness.** A unit assigned to a skill detail keeps the + assignment while any work for that skill remains -- including claimed + jobs, the fetch-materials phase, and breaks -- so jobs aren't aborted + by reassignment churn. For tool-carrying labors (mining, woodcutting, + hunting) this also prevents dropping and re-equipping tools between + cycles. Assignments are recorded in the save data and restored on load. +* **Builtin tool details.** DF v50 only equips tools for members of the + game's predefined work details, so mining, woodcutting, and hunting + specialists are assigned to the builtin Miners, Woodcutters, and + Hunters details instead of plugin-created ones. Memberships added by + the plugin are tracked and removed cleanly on disable; members you + assigned yourself are never touched. +* **Strange-mood shaping.** Dwarves that can still have a strange mood + (their race has the ``STRANGE_MOODS`` token and they haven't had one + yet) and that have already started a moodable craft are steered toward + skills a strange mood can advance -- preferably valuable ones like + weaponsmithing or armorsmithing -- so a mood pays out in a useful + legendary skill. + +Workshop restrictions are honored as well: when a job is posted at a +workshop that has a permitted-worker list, the engine ensures at least +one listed worker is assigned the job's labor, and when the workshop +restricts jobs by skill level it ensures at least one worker within the +allowed range holds that labor. Units on any workshop's permitted list +are treated as reserved for that shop's work -- they are preferentially +assigned the labors its jobs require and deprioritized for the laborer +pool and unrelated specialties. + +Labormanager also reads each citizen's unmet personal needs and biases +assignments toward work that satisfies them: + +* Citizens desperate to stay occupied, acquire things, or help others are + preferentially drafted into the laborer pool, where hauling, stockpiling, + depot runs, and patient care satisfy those needs. +* Citizens needing social time (gregarious, family-oriented, or just + wanting to take it easy) are kept out of the restrictive pools so they + can idle and socialize; guilded specialists with these needs earn guild + rest sooner. +* Citizens who crave wandering are steered toward fishing, hunting, and + plant gathering; those craving excitement toward trapping and hunting; + those craving creativity toward crafts. A completely unskilled citizen + can be assigned a specialty purely on inclination. + +Dwarves on active military duty, in meetings, or assigned to burrows are +left untouched. Children are unaffected: their chores are governed by the +vanilla chores settings. .. warning:: - As with autolabor, labormanager will override any manual changes you make to - labors while it is enabled, including through other tools such as Dwarf - Therapist. Do not run both autolabor and labormanager at the same time! + While labormanager is enabled it will overwrite the membership of its + ``auto:`` work details on every cycle. Player-created work details are + left alone, but manual edits to the managed details will be lost. Usage ----- :: - enable labormanager - -Anything beyond this is optional - labormanager works well with the default -settings. Once you have enabled it in a fortress, it stays enabled until you -explicitly disable it, even if you save and reload your game. - -The default priorities for each labor vary (some labors are higher priority by -default than others). The way the plugin works is that, once it determines how -many jobs of each labor are needed, it then sorts them by adjusted priority. -(Labors other than hauling have a bias added to them based on how long it's been -since they were last used to prevent job starvation.) The labor with the highest -priority is selected, the "best fit" dwarf for that labor is assigned to that -labor, and then its priority is *halved*. This process is repeated until either -dwarves or labors run out. - -Because there is no easy way to detect how many haulers are actually needed at -any moment, the plugin always ensures that at least one dwarf is assigned to -each of the hauling labors, even if no hauling jobs are detected. At least one -dwarf is always assigned to construction removing and cleaning because these -jobs also cannot be easily detected. Lever pulling is always assigned to -everyone. Any dwarves for which there are no jobs will be assigned hauling, -lever pulling, and cleaning labors. If you use animal trainers, note that -labormanager will misbehave if you assign specific trainers to specific animals; -results are only guaranteed if you use "any trainer". - -Labormanager also sometimes assigns extra labors to currently busy dwarfs so -that when they finish their current job, they will go off and do something -useful instead of standing around waiting for a job. - -There is special handling to ensure that at least one dwarf is assigned to haul -food whenever food is detected left in a place where it will rot if not stored. -This will cause a dwarf to go idle if you have no stockpiles to haul food to. - -Dwarves who are unable to work (child, in the military, wounded, handless, -asleep, in a meeting) are entirely excluded from labor assignment. Any dwarf -explicitly assigned to a burrow will also be completely ignored by labormanager. - -The fitness algorithm for assigning jobs to dwarves generally attempts to favor -dwarves who are more skilled over those who are less skilled. It also tries to -avoid assigning female dwarfs with children to jobs that are "outside", favors -assigning "outside" jobs to dwarfs who are carrying a tool that could be used as -a weapon, and tries to minimize how often dwarves have to reequip. - -Labormanager automatically determines medical needs and reserves health care -providers as needed. Note that this may cause idling if you have injured dwarves -but no or inadequate hospital facilities. - -Hunting is never assigned without a butchery, and fishing is never assigned -without a fishery, and neither of these labors is assigned unless specifically -enabled (see below). - -The method by which labormanager determines what labor is needed for a -particular job is complicated and, in places, incomplete. In some situations, -labormanager will detect that it cannot determine what labor is required. It -will, by default, pause and print an error message on the dfhack console, -followed by the message "LABORMANAGER: Game paused so you can investigate the -above message.". If this happens, please open an :issue:`` on GitHub, -reporting the lines that immediately preceded this message. You can tell -labormanager to ignore this error and carry on by running -``labormanager pause-on-error no``, but be warned that some job may go undone in -this situation. + labormanager enable + +Anything beyond this is optional. Once enabled in a fortress, it stays +enabled until you explicitly disable it, even if you save and reload your +game. + +The same plugin binary also provides the legacy `autolabor` command, which +uses the classic labor-matrix algorithm, and a *monitor* mode that does no +labor management at all -- it only watches the job board and reports +starving job postings (see the end of this document). Only one mode can be +active at a time; use ``labormanager mode`` (or the overlay on the work +details screen) to see or change which mode will run. Examples -------- -``labormanager priority BREWER 500`` - Boost the priority of brewing jobs. -``labormanager max FISH 1`` - Only assign fishing to one dwarf at a time. Note that you also have to run - ``labormanager allow-fishing`` for any dwarves to be assigned fishing at - all. +``labormanager balance staffing`` + Prioritize preventing unskilled jobs from starving. Specialists will be + pulled into the laborer pool more aggressively when hauling backs up. +``labormanager balance skills`` + Prioritize skill growth. Specialists keep their specialized labors even + when unskilled work is piling up. +``labormanager labor MINE unmanaged`` + Leave the mining labor to the vanilla work details and any manual + assignments, while managing everything else. Advanced usage -------------- ``labormanager list`` - Show current priorities and current allocation stats. Use this command to - see the IDs for all labors. + List every labor, whether labormanager is managing it, and which labors + currently have starving (long-unclaimed) job postings. ``labormanager status`` - Show basic status information. -``labormanager priority `` - Set the priority value for labor to . -``labormanager max `` - Set the maximum number of dwarves that can be assigned to a labor. -``labormanager max none`` - Unrestrict the number of dwarves that can be assigned to a labor. -``labormanager max disable`` - Don't manage the specified labor. Dwarves who you have manually enabled this - labor on will be less likely to have managed labors assigned to them. -``labormanager reset-all|reset `` - Return a labor (or all labors) to the default priority. + Show the current balance setting, role counts, job board statistics + (how many postings resolved, their average and longest waits, and how + many are currently starving), and the most-used skills observed on the + job board. +``labormanager balance <0-4|staffing|lean-staffing|balanced|lean-skills|skills>`` + Set where the balance slider sits. Lower values grow the laborer pool + more aggressively to keep unskilled jobs staffed; higher values protect + skill specialization. This is the same control shown as a slider on the + work details overlay. +``labormanager idle-reserve <0-100>`` + Percentage of time to keep free for specialists whose profession has a + matching guild hall, so they can take part in guild demonstrations and + other off-duty activities. Defaults to 30. +``labormanager labor managed|unmanaged`` + Include or exclude a specific labor from labormanager's work details. + Unmanaged labors are entirely governed by the player's own work detail + configuration. ``labormanager allow-fishing|forbid-fishing`` - Allow/disallow fisherdwarves. *Warning* Allowing fishing tends to result in - most of the fort going fishing. Fishing is forbidden by default. + Allow/disallow fisherdwarf specialists. Fishing is forbidden by default + and also requires a fishery. ``labormanager allow-hunting|forbid-hunting`` - Allow/disallow hunterdwarves. *Warning* Allowing hunting tends to result in - as many dwarves going hunting as you have crossbows. Hunting is forbidden by - default. -``labormanager pause-on-error yes|no`` - Make labormanager pause/continue if the labor inference engine fails. See - the above section for details. + Allow/disallow hunter specialists. Hunting is forbidden by default and + also requires a butchery. +``labormanager mode [legacy|modern|monitor]`` + Show or change which engine runs when the plugin is enabled. The same + command exists under `autolabor`. In monitor mode the plugin performs + no labor management -- all work details are left to the player -- but + still tracks the job board and shows the task starvation warning. +``labormanager dump`` + Print a diagnostic snapshot of everything the engine can see: every + job and posting on the board with its flags and resolved labor, the + per-labor demand and starvation streaks, skill usage, tool counts, + work detail memberships, and each citizen's state, pins, and enabled + labors. Intended for bug reports; also available as + ``autolabor dump``, and meaningful in monitor mode. + +Monitor mode (``labormanager mode monitor`` or the work details overlay) +runs the same tracking without any labor management, for players who want +the starvation warning while keeping full manual control. + +The tracking works as follows: job postings that sit unclaimed on the job +board are timed, and if any posting goes more than a full game day (1200 +ticks) without being +claimed, a "task starvation" warning appears in the DFHack notification +panel (alongside the other fort warnings), naming the longest-starving +job and how long it has been waiting; clicking the warning zooms the map +to that job's location. The warning can be toggled in `gui/notify` under +``labor_starvation``. + +All of labormanager's accumulated state -- job posting ages, skill usage +history, starvation streaks, and the guild rest rotation -- is saved with +the fortress and restored when the save is loaded, so statistics and +rotation carry over between play sessions. diff --git a/docs/plugins/logistics.rst b/docs/plugins/logistics.rst index 5a6fc9254c..4b3605f428 100644 --- a/docs/plugins/logistics.rst +++ b/docs/plugins/logistics.rst @@ -3,7 +3,7 @@ logistics .. dfhack-tool:: :summary: Automatically mark and route items in monitored stockpiles. - :tags: fort auto animals items stockpiles + :tags: fort auto animals items stockpile Commands act upon the stockpile selected in the UI unless another stockpile identifier is specified on the commandline. diff --git a/docs/plugins/smooth-movement.rst b/docs/plugins/smooth-movement.rst new file mode 100644 index 0000000000..cdee603b89 --- /dev/null +++ b/docs/plugins/smooth-movement.rst @@ -0,0 +1,50 @@ +smooth-movement +=============== + +.. dfhack-tool:: + :summary: Smoothly animate movement in the fortress viewport. + :tags: fort interface + +This plugin makes creatures, hauled items, vehicles, and associated status +icons glide between tiles. It only changes rendering; gameplay, simulation +timing, and save data are unaffected. + +The plugin requires the SDL 2D renderer. + +Usage +----- + +:: + + enable smooth-movement + smooth-movement + smooth-movement flip on|off + smooth-movement zlevel on|off + +Running ``smooth-movement`` without arguments reports the plugin status and +current settings. Optional features are reset to their defaults whenever the +plugin is enabled. + +Examples +-------- + +``enable smooth-movement`` + Enable smooth movement on the main z-level. + +``smooth-movement flip on`` + Horizontally flip creatures so they face their direction of travel. + +``smooth-movement zlevel on`` + Also animate movement on visible z-levels below the main level. + +Settings +-------- + +``flip`` (default: off) + Flip creature sprites horizontally according to their last horizontal + movement. Items, vehicles, and designation icons keep their normal + orientation. + +``zlevel`` (default: off) + Animate movement on visible lower z-levels. Sprite flipping remains + independent of this setting. diff --git a/docs/plugins/sort.rst b/docs/plugins/sort.rst index 70a11a3bcf..9ef6f34a53 100644 --- a/docs/plugins/sort.rst +++ b/docs/plugins/sort.rst @@ -185,3 +185,10 @@ World overlay Searching is supported for the Artifacts list when viewing the world map (where you can initiate raids). + +Stress icons overlay +-------------------- + +The stress/happiness icons in the top bar are clickable. Clicking an icon opens +a searchable list of the citizens in that stress category, and selecting a +citizen from the list centers the map on them. diff --git a/docs/plugins/stockflow.rst b/docs/plugins/stockflow.rst index 95eba213c1..a32591d63f 100644 --- a/docs/plugins/stockflow.rst +++ b/docs/plugins/stockflow.rst @@ -3,21 +3,21 @@ stockflow .. dfhack-tool:: :summary: Queue manager jobs based on free space in stockpiles. - :tags: unavailable + :tags: fort auto workorders stockpile -With this plugin, the fortress bookkeeper can tally up free space in specific -stockpiles and queue jobs through the manager to produce items to fill the free +With this plugin, you can tally up free space in specific stockpiles and +automatically queue jobs through the manager to produce items to fill the free space. -When the plugin is enabled, the :kbd:`q` menu of each stockpile will have two -new options: +When the plugin is enabled, selecting a stockpile in the UI will show the +stockflow overlay panel with two options: * :kbd:`j`: Select a job to order, from an interface like the manager's screen. * :kbd:`J`: Cycle between several options for how many such jobs to order. -Whenever the bookkeeper updates stockpile records, new work orders will -be placed on the manager's queue for each such selection, reduced by the -number of identical orders already in the queue. +About once per in-game day, new work orders will be placed on the manager's +queue for each such selection, reduced by the number of identical orders +already in the queue. This plugin is similar to `workflow`, but uses stockpiles to manage job triggers instead of abstract stock quantities. @@ -31,5 +31,3 @@ Usage Display whether the plugin is enabled. ``stockflow list`` List any work order settings for your stockpiles. -``stockflow fast`` - Enqueue orders once per day instead of waiting for the bookkeeper. diff --git a/docs/plugins/stockpiles.rst b/docs/plugins/stockpiles.rst index 506b9d783c..c9b5f96bd4 100644 --- a/docs/plugins/stockpiles.rst +++ b/docs/plugins/stockpiles.rst @@ -3,7 +3,7 @@ stockpiles .. dfhack-tool:: :summary: Import, export, or modify stockpile settings. - :tags: fort design productivity stockpiles + :tags: fort design productivity stockpile Commands act upon the stockpile selected in the UI unless another stockpile identifier is specified on the commandline. diff --git a/docs/plugins/suspendmanager.rst b/docs/plugins/suspendmanager.rst index 6015e3dc11..b92c50e2f0 100644 --- a/docs/plugins/suspendmanager.rst +++ b/docs/plugins/suspendmanager.rst @@ -19,6 +19,9 @@ When enabled, ``suspendmanager`` will watch your active jobs and: which would cause the designation to be lost. - suspend construction jobs that would cave in immediately on completion, such as when building walls or floors next to grates/bars. +- suspend deconstruction jobs on machine components (gears, axles, + waterwheels, etc.) whose removal would leave part of the machine without + support and collapse it. See `gui/suspendmanager` for a graphical configuration interface. diff --git a/docs/sphinx_extensions/dfhack/tool_docs.py b/docs/sphinx_extensions/dfhack/tool_docs.py index 6c65e5918d..3d385d8656 100644 --- a/docs/sphinx_extensions/dfhack/tool_docs.py +++ b/docs/sphinx_extensions/dfhack/tool_docs.py @@ -143,7 +143,12 @@ def add_index_entries(self, name) -> None: if 'unavailable' not in tags: self.env.domaindata['all']['objects'].append(indexdata) for tag in tags: - self.env.domaindata[tag]['objects'].append(indexdata) + # tolerate tags missing from Tags.rst: the scripts submodule pin + # can lag behind tag renames in either repo + if tag in self.env.domaindata: + self.env.domaindata[tag]['objects'].append(indexdata) + else: + logger.info('%s: ignoring unregistered tag: %s', docname, tag) @staticmethod def wrap_box(*children: List[nodes.Node]) -> nodes.Admonition: @@ -173,16 +178,22 @@ def render_content(self) -> List[nodes.Node]: tags = self.options.get('tags', []) self.env.domaindata['tag-repo']['doctags'][self.env.docname] = tags for tag in tags: - tag_paragraph += [ - addnodes.pending_xref(tag, nodes.inline(text=tag), **{ - 'reftype': 'ref', - 'refdomain': 'std', - 'reftarget': tag + '-tag-index', - 'refexplicit': True, - 'refwarn': True, - }), - nodes.inline(text=' | '), - ] + # link only tags registered in Tags.rst; the scripts submodule + # pin can lag behind tag renames in either repo + if tag in self.env.domaindata: + tag_paragraph += addnodes.pending_xref(tag, + nodes.inline(text=tag), **{ + 'reftype': 'ref', + 'refdomain': 'std', + 'reftarget': tag + '-tag-index', + 'refexplicit': True, + 'refwarn': True, + }) + else: + logger.info('%s: ignoring unregistered tag: %s', + self.env.docname, tag) + tag_paragraph += nodes.inline(text=tag) + tag_paragraph += nodes.inline(text=' | ') tag_paragraph.pop() ret_nodes = [tag_paragraph] diff --git a/docs/styles/dfhack.css b/docs/styles/dfhack.css index c1a00ed908..a8a1e3237c 100644 --- a/docs/styles/dfhack.css +++ b/docs/styles/dfhack.css @@ -79,3 +79,12 @@ div.dfhack-tool-summary p:last-child, aside.dfhack-tool-summary p:last-child { margin-bottom: 0; } + +table.dfhack-param-table { + table-layout: fixed; + width: 110%; +} + +table.dfhack-param-table span.pre { + white-space: wrap; +} diff --git a/library/CMakeLists.txt b/library/CMakeLists.txt index b405469b0e..117c0f5e8e 100644 --- a/library/CMakeLists.txt +++ b/library/CMakeLists.txt @@ -50,8 +50,10 @@ set(MAIN_HEADERS include/ColorText.h include/Commands.h include/Console.h + include/CoordTemplate.h include/Core.h include/CoreDefs.h + include/Crashlog.h include/DataDefs.h include/DataFuncs.h include/DataIdentity.h @@ -60,16 +62,15 @@ set(MAIN_HEADERS include/Error.h include/Export.h include/Format.h + include/HashUtil.h include/Hooks.h include/LuaTools.h include/LuaWrapper.h include/MemAccess.h include/Memory.h include/MiscUtils.h - include/Module.h include/MemAccess.h include/MemoryPatcher.h - include/ModuleFactory.h include/PluginLua.h include/PluginManager.h include/PluginStatics.h @@ -131,7 +132,7 @@ endif() set(MAIN_SOURCES_WINDOWS ${CONSOLE_SOURCES} - Hooks.cpp + Crashlog-windows.cpp ) if(WIN32) @@ -158,7 +159,6 @@ set(MODULE_HEADERS include/modules/Designations.h include/modules/EventManager.h include/modules/Filesystem.h - include/modules/Graphic.h include/modules/Gui.h include/modules/GuiHooks.h include/modules/Hotkey.h @@ -190,7 +190,6 @@ set(MODULE_SOURCES modules/Designations.cpp modules/EventManager.cpp modules/Filesystem.cpp - modules/Graphic.cpp modules/Gui.cpp modules/Hotkey.cpp modules/Items.cpp @@ -318,8 +317,6 @@ endif() # Compilation -add_definitions(-DBUILD_DFHACK_LIB) - if(UNIX) if(CONSOLE_NO_CATCH) add_definitions(-DCONSOLE_NO_CATCH) @@ -358,21 +355,55 @@ if(DFHACK_PRERELEASE) endif() configure_file(git-describe.cmake.in ${CMAKE_CURRENT_SOURCE_DIR}/git-describe.cmake @ONLY) -if(EXISTS ${dfhack_SOURCE_DIR}/.git/index AND EXISTS ${dfhack_SOURCE_DIR}/.git/modules/library/xml/index) - add_custom_command(OUTPUT ${CMAKE_CURRENT_SOURCE_DIR}/include/git-describe.h - COMMAND ${CMAKE_COMMAND} - -D dfhack_SOURCE_DIR:STRING=${dfhack_SOURCE_DIR} - -D GIT_EXECUTABLE:STRING=${GIT_EXECUTABLE} - -P ${CMAKE_CURRENT_SOURCE_DIR}/git-describe.cmake - COMMENT "Obtaining git commit information" - DEPENDS ${dfhack_SOURCE_DIR}/.git/index - ${dfhack_SOURCE_DIR}/.git/modules/library/xml/index - ${CMAKE_CURRENT_SOURCE_DIR}/git-describe.cmake - include/git-describe.h.in - ) -endif() + +# .git is a directory in a normal repository, but a file containing +# "gitdir: " in git worktrees and submodules; resolve both. +function(dfhack_find_git_dir repo_dir out_var) + set(${out_var} "" PARENT_SCOPE) + if(IS_DIRECTORY "${repo_dir}/.git") + set(${out_var} "${repo_dir}/.git" PARENT_SCOPE) + elseif(EXISTS "${repo_dir}/.git") + file(STRINGS "${repo_dir}/.git" gitdir_line REGEX "^gitdir:" LIMIT_COUNT 1) + if(gitdir_line MATCHES "^gitdir:[ \t]*(.+)") + string(STRIP "${CMAKE_MATCH_1}" gitdir) + if(NOT IS_ABSOLUTE "${gitdir}") + get_filename_component(gitdir "${repo_dir}/${gitdir}" ABSOLUTE) + endif() + set(${out_var} "${gitdir}" PARENT_SCOPE) + endif() + endif() +endfunction() + +# Re-run git describe when HEAD or the index changes in the main repository or +# in the library/xml submodule, when they are present. The header is generated +# even outside a git checkout so the build works on plain source trees too. +set(GIT_DESCRIBE_DEPS + ${CMAKE_CURRENT_SOURCE_DIR}/git-describe.cmake + ${CMAKE_CURRENT_SOURCE_DIR}/include/git-describe.h.in +) +dfhack_find_git_dir("${dfhack_SOURCE_DIR}" DFHACK_GIT_DIR) +dfhack_find_git_dir("${dfhack_SOURCE_DIR}/library/xml" DFHACK_XML_GIT_DIR) +foreach(git_dir "${DFHACK_GIT_DIR}" "${DFHACK_XML_GIT_DIR}") + if(git_dir) + foreach(name HEAD index) + if(EXISTS "${git_dir}/${name}") + list(APPEND GIT_DESCRIBE_DEPS "${git_dir}/${name}") + endif() + endforeach() + endif() +endforeach() + +add_custom_command(OUTPUT ${CMAKE_CURRENT_SOURCE_DIR}/include/git-describe.h +COMMAND ${CMAKE_COMMAND} + -D dfhack_SOURCE_DIR:STRING=${dfhack_SOURCE_DIR} + -D GIT_EXECUTABLE:STRING=${GIT_EXECUTABLE} + -P ${CMAKE_CURRENT_SOURCE_DIR}/git-describe.cmake +COMMENT "Obtaining git commit information" +DEPENDS ${GIT_DESCRIBE_DEPS} +) add_library(dfhack SHARED ${PROJECT_SOURCES}) +target_compile_definitions(dfhack PRIVATE BUILD_DFHACK_LIB) target_include_directories(dfhack PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include ${CMAKE_CURRENT_SOURCE_DIR}/proto) get_target_property(xlsxio_INCLUDES xlsxio_read_STATIC INTERFACE_INCLUDE_DIRECTORIES) @@ -381,6 +412,7 @@ add_dependencies(dfhack generate_proto_core) add_dependencies(dfhack generate_headers) add_library(dfhack-client SHARED RemoteClient.cpp ColorText.cpp MiscUtils.cpp Error.cpp ${PROJECT_PROTO_SRCS} ${CONSOLE_SOURCES}) +target_compile_definitions(dfhack-client PRIVATE BUILD_DFHACK_LIB) target_include_directories(dfhack-client PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include ${CMAKE_CURRENT_SOURCE_DIR}/proto) add_dependencies(dfhack-client dfhack) @@ -391,16 +423,16 @@ add_executable(binpatch binpatch.cpp) target_link_libraries(binpatch dfhack-md5) if(WIN32) - set_target_properties(dfhack PROPERTIES OUTPUT_NAME "dfhooks_dfhack" ) set_target_properties(dfhack PROPERTIES COMPILE_FLAGS "/FI\"Export.h\"" ) set_target_properties(dfhack-client PROPERTIES COMPILE_FLAGS "/FI\"Export.h\"" ) else() set_target_properties(dfhack PROPERTIES COMPILE_FLAGS "-include Export.h" ) set_target_properties(dfhack-client PROPERTIES COMPILE_FLAGS "-include Export.h" ) - add_library(dfhooks_dfhack SHARED Hooks.cpp) - target_link_libraries(dfhooks_dfhack dfhack ${FMTLIB}) endif() +add_library(dfhooks_dfhack SHARED Hooks.cpp) +target_link_libraries(dfhooks_dfhack PUBLIC dfhack lua ${FMTLIB}) + # effectively disables debug builds... set_target_properties(dfhack PROPERTIES DEBUG_POSTFIX "-debug" ) @@ -450,11 +482,13 @@ if(UNIX) install(PROGRAMS ${dfhack_SOURCE_DIR}/package/linux/dfhack-run DESTINATION .) endif() - install(TARGETS dfhooks_dfhack - LIBRARY DESTINATION . - RUNTIME DESTINATION .) endif() +install(TARGETS dfhooks_dfhack + LIBRARY DESTINATION ${DFHACK_LIBRARY_DESTINATION} + RUNTIME DESTINATION ${DFHACK_LIBRARY_DESTINATION}) + + # install the main lib install(TARGETS dfhack LIBRARY DESTINATION ${DFHACK_LIBRARY_DESTINATION} @@ -464,6 +498,12 @@ install(TARGETS dfhack-run dfhack-client binpatch LIBRARY DESTINATION ${DFHACK_LIBRARY_DESTINATION} RUNTIME DESTINATION ${DFHACK_LIBRARY_DESTINATION}) +file(GENERATE OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/dfhooks_dfhack.ini + CONTENT "${DFHACK_DATA_DESTINATION}/$") + +install(FILES ${CMAKE_CURRENT_BINARY_DIR}/dfhooks_dfhack.ini + DESTINATION .) + endif(BUILD_LIBRARY) # install the offset file diff --git a/library/ColorText.cpp b/library/ColorText.cpp index bdd3e98f44..a101d795a9 100644 --- a/library/ColorText.cpp +++ b/library/ColorText.cpp @@ -35,24 +35,12 @@ ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ - -#include -#include -#include -#include -#include -#include #include +#include +#include #include "ColorText.h" -#include "MiscUtils.h" - -#include -#include -#include -#include -using namespace std; using namespace DFHack; bool color_ostream::log_errors_to_stderr = false; @@ -81,7 +69,7 @@ void color_ostream::end_batch() flush_proxy(); } -color_ostream::color_ostream() : ostream(new buffer(this)), cur_color(COLOR_RESET) +color_ostream::color_ostream() : std::ostream(new buffer(this)), cur_color(COLOR_RESET) { // } diff --git a/library/Console-windows.cpp b/library/Console-windows.cpp index 12a3e0c2eb..908508bb3b 100644 --- a/library/Console-windows.cpp +++ b/library/Console-windows.cpp @@ -46,6 +46,7 @@ POSSIBILITY OF SUCH DAMAGE. #include #include #include +#include #include #include #include @@ -73,11 +74,15 @@ namespace DFHack rawmode = 0; console_in = 0; console_out = 0; + exiting = false; ConsoleWindow = 0; default_attributes = 0; state = con_unclaimed; in_batch = false; raw_cursor = 0; + console_owned = false; + saved_cp = 0; + saved_output_cp = 0; }; virtual ~Private() { @@ -219,10 +224,7 @@ namespace DFHack { COORD pos = { (SHORT)x, (SHORT)y }; DWORD count = 0; - CONSOLE_SCREEN_BUFFER_INFO inf = { 0 }; - GetConsoleScreenBufferInfo(console_out, &inf); - SetConsoleCursorPosition(console_out, pos); - WriteConsoleA(console_out, str, len, &count, NULL); + WriteConsoleOutputCharacterA(console_out, str, len, pos, &count); } void prompt_refresh() @@ -233,9 +235,9 @@ namespace DFHack size_t len = raw_buffer.size(); int cooked_cursor = raw_cursor; - if (plen + cooked_cursor > cols) + if (plen + cooked_cursor > (cols-1)) { - int adj = std::min(plen + cooked_cursor - cols, len); + int adj = std::min(plen + cooked_cursor - (cols-1), len); buf += adj; len -= adj; cooked_cursor -= adj; @@ -283,11 +285,12 @@ namespace DFHack INPUT_RECORD rec; DWORD count; lock->unlock(); - if (ReadConsoleInputA(console_in, &rec, 1, &count) == 0) { - lock->lock(); - return Console::SHUTDOWN; - } + // shutdown() queues a dummy input record to wake this read; + // the exiting flag distinguishes the wake from real input. + BOOL success = ReadConsoleInputA(console_in, &rec, 1, &count); lock->lock(); + if (exiting || !success) + return Console::SHUTDOWN; if (rec.EventType != KEY_EVENT || !rec.Event.KeyEvent.bKeyDown) continue; switch (rec.Event.KeyEvent.wVirtualKeyCode) @@ -393,9 +396,13 @@ namespace DFHack int rawmode; HANDLE console_in; HANDLE console_out; + std::atomic exiting; HWND ConsoleWindow; HWND MainWindow; WORD default_attributes; + bool console_owned; // false if the console was inherited from a parent process + UINT saved_cp; // code pages to restore on shutdown if the console is borrowed + UINT saved_output_cp; // current state enum console_state { @@ -467,18 +474,32 @@ bool Console::init(bool) } d->MainWindow = h; - // Allocate a console! - AllocConsole(); + // Allocate a console! If the process already has one (e.g. dfhack-run + // launched from an existing terminal), the console is borrowed and must + // not be mutated or freed. + d->console_owned = AllocConsole() != FALSE; d->ConsoleWindow = GetConsoleWindow(); wlock = new std::recursive_mutex(); - HMENU hm = GetSystemMenu(d->ConsoleWindow,false); - DeleteMenu(hm, SC_CLOSE, MF_BYCOMMAND); + if (d->console_owned) + { + HMENU hm = GetSystemMenu(d->ConsoleWindow,false); + DeleteMenu(hm, SC_CLOSE, MF_BYCOMMAND); + } + + // force console code pages to utf-8 + d->saved_cp = GetConsoleCP(); + d->saved_output_cp = GetConsoleOutputCP(); + SetConsoleCP(CP_UTF8); + SetConsoleOutputCP(CP_UTF8); // set the screen buffer to be big enough to let us scroll text GetConsoleScreenBufferInfo(GetStdHandle(STD_OUTPUT_HANDLE), &coninfo); d->default_attributes = coninfo.wAttributes; - coninfo.dwSize.Y = MAX_CONSOLE_LINES; // How many lines do you want to have in the console buffer - SetConsoleScreenBufferSize(GetStdHandle(STD_OUTPUT_HANDLE), coninfo.dwSize); + if (d->console_owned) + { + coninfo.dwSize.Y = MAX_CONSOLE_LINES; // How many lines do you want to have in the console buffer + SetConsoleScreenBufferSize(GetStdHandle(STD_OUTPUT_HANDLE), coninfo.dwSize); + } // redirect unbuffered STDOUT to the console d->console_out = GetStdHandle(STD_OUTPUT_HANDLE); @@ -502,7 +523,10 @@ bool Console::init(bool) // make our own weird streams so our IO isn't redirected std::cin.tie(this); - clear(); + if (d->console_owned) + { + clear(); + } inited = true; // DOESN'T WORK - locks up DF! // ForceForegroundWindow(d->MainWindow); @@ -512,8 +536,27 @@ bool Console::init(bool) // FIXME: looks awfully empty, doesn't it? bool Console::shutdown(void) { + assert(inited); + d->exiting = true; std::lock_guard lock{*wlock}; - FreeConsole(); + // Wake any thread blocked in ReadConsoleInputA by queueing a dummy + // input record, so it is no longer inside a console call and can + // observe the exiting flag before we detach the console below. + INPUT_RECORD rec = { 0 }; + rec.EventType = FOCUS_EVENT; + DWORD written; + WriteConsoleInputA(d->console_in, &rec, 1, &written); + if (d->console_owned) + { + FreeConsole(); + } + else + { + // the console is borrowed from a parent process; restore the state + // we changed and leave it attached + SetConsoleCP(d->saved_cp); + SetConsoleOutputCP(d->saved_output_cp); + } inited = false; return true; } @@ -606,12 +649,16 @@ void Console::msleep (unsigned int msec) bool Console::hide() { + if (!inited || !d->console_owned) + return false; ShowWindow( GetConsoleWindow(), SW_HIDE ); return true; } bool Console::show() { + if (!inited || !d->console_owned) + return false; ShowWindow( GetConsoleWindow(), SW_RESTORE ); return true; } diff --git a/library/Core.cpp b/library/Core.cpp index a5cd996667..09b9660345 100644 --- a/library/Core.cpp +++ b/library/Core.cpp @@ -23,29 +23,29 @@ distribution. */ #include "Core.h" +#include "Crashlog.h" #include "Internal.h" -#include "Error.h" -#include "MemAccess.h" +#include "ColorText.h" +#include "Commands.h" +#include "Console.h" +#include "CoreDefs.h" #include "DataDefs.h" #include "Debug.h" -#include "Console.h" +#include "DFHackVersion.h" +#include "Error.h" +#include "Format.h" +#include "LuaTools.h" +#include "MemAccess.h" #include "MemoryPatcher.h" #include "MiscUtils.h" -#include "Module.h" -#include "VersionInfoFactory.h" -#include "VersionInfo.h" +#include "MiscUtils.h" #include "PluginManager.h" -#include "ModuleFactory.h" #include "RemoteServer.h" #include "RemoteTools.h" -#include "LuaTools.h" -#include "DFHackVersion.h" -#include "md5wrapper.h" -#include "Format.h" - -#include "Commands.h" +#include "VersionInfo.h" +#include "VersionInfoFactory.h" #include "modules/DFSDL.h" #include "modules/DFSteam.h" @@ -53,13 +53,14 @@ distribution. #include "modules/Filesystem.h" #include "modules/Gui.h" #include "modules/Hotkey.h" +#include "modules/Persistence.h" #include "modules/Textures.h" #include "modules/World.h" -#include "modules/Persistence.h" -#include "df/init.h" #include "df/gamest.h" +#include "df/global_objects.h" #include "df/graphic.h" +#include "df/init.h" #include "df/interfacest.h" #include "df/plotinfost.h" #include "df/viewscreen_dwarfmodest.h" @@ -68,35 +69,52 @@ distribution. #include "df/viewscreen_loadgamest.h" #include "df/viewscreen_new_regionst.h" #include "df/viewscreen_savegamest.h" -#include "df/world.h" #include "df/world_data.h" +#include "df/world.h" -#include -#include -#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include -#include +#include +#include +#include +#include +#include #include -#include -#include -#include +#include #include -#include -#include #include -#include -#include +#include #include -#include +#include +#include +#include +#include #include -#include -#include +#include +#include + +#include "md5wrapper.h" + #include +#include +#include +#include -#ifdef _WIN32 +#ifdef WIN32 #define NOMINMAX +#define WIN32_LEAN_AND_MEAN #include +#include #endif #ifdef LINUX_BUILD @@ -105,6 +123,7 @@ distribution. using namespace DFHack; using namespace df::enums; + using df::global::init; using df::global::world; using std::string; @@ -117,15 +136,7 @@ namespace DFHack { DBG_DECLARE(core, keybinding, DebugCategory::LINFO); DBG_DECLARE(core, script, DebugCategory::LINFO); - static const std::filesystem::path getConfigPath() - { - return Filesystem::getInstallDir() / "dfhack-config"; - }; - - static const std::filesystem::path getConfigDefaultsPath() - { - return Filesystem::getInstallDir() / "hack" / "data" / "dfhack-config-defaults"; - }; + Core* Core::active_instance = nullptr; class MainThread { public: @@ -492,7 +503,7 @@ void Core::getScriptPaths(std::vector *dest) if (save.size()) dest->emplace_back(df_pref_path / "save" / save / "scripts"); } - dest->emplace_back(df_install_path / "hack" / "scripts"); + dest->emplace_back(getHackPath() / "scripts"); for (auto & path : script_paths[2]) dest->emplace_back(path); for (auto & path : script_paths[1]) @@ -520,7 +531,7 @@ std::filesystem::path Core::findScript(std::string name) return {}; } -bool loadScriptPaths(color_ostream &out, bool silent = false) +bool Core::loadScriptPaths(color_ostream &out, bool silent) { std::filesystem::path filename{ getConfigPath() / "script-paths.txt" }; std::ifstream file(filename); @@ -545,7 +556,7 @@ bool loadScriptPaths(color_ostream &out, bool silent = false) getline(ss, path); if (ch == '+' || ch == '-') { - if (!Core::getInstance().addScriptPath(path, ch == '+') && !silent) + if (!addScriptPath(path, ch == '+') && !silent) out.printerr("{}:{}: Failed to add path: {}\n", filename, line, path); } else if (!silent) @@ -859,7 +870,22 @@ bool Core::loadScriptFile(color_ostream &out, std::filesystem::path fname, bool INFO(script,out) << "Running script: " << fname << std::endl; std::cerr << "Running script: " << fname << std::endl; } - std::ifstream script{ fname.c_str() }; + + auto pathlist = {getHackPath(), getHackPath().parent_path(), std::filesystem::current_path()}; + + std::filesystem::path path; + + for (auto& p : pathlist) + { + auto candidate = fname.is_relative() ? p / fname : fname; + if (std::filesystem::exists(candidate)) + { + path = candidate; + break; + } + } + + std::ifstream script{ path }; if ( !script ) { if(!silent) @@ -902,12 +928,11 @@ static void run_dfhack_init(color_ostream &out, Core *core) } // load baseline defaults - core->loadScriptFile(out, getConfigPath() / "init" / "default.dfhack.init", false); + core->loadScriptFile(out, core->getConfigPath() / "init" / "default.dfhack.init", false); // load user overrides std::vector prefixes(1, "dfhack"); - loadScriptFiles(core, out, prefixes, getConfigPath() / "init"); - + loadScriptFiles(core, out, prefixes, core->getConfigPath() / "init"); // show the terminal if requested auto L = DFHack::Core::getInstance().getLuaState(); Lua::CallLuaModuleFunction(out, L, "dfhack", "getHideConsoleOnStartup", 0, 1, @@ -929,9 +954,9 @@ static void fInitthread(IODATA * iod) // A thread function... for the interactive console. static void fIOthread(IODATA * iod) { - static const std::filesystem::path HISTORY_FILE = getConfigPath() / "dfhack.history"; - Core * core = iod->core; + std::filesystem::path HISTORY_FILE = core->getConfigPath() / "dfhack.history"; + PluginManager * plug_mgr = iod->plug_mgr; CommandHistory main_history; @@ -1012,7 +1037,6 @@ Core::Core() : plug_mgr = nullptr; errorstate = false; vinfo = 0; - memset(&(s_mods), 0, sizeof(s_mods)); // set up hotkey capture suppress_duplicate_keyboard_events = true; @@ -1054,21 +1078,26 @@ void Core::fatal (std::string output, const char * title) std::filesystem::path Core::getHackPath() { - return p->getPath() / "hack"; + return hack_path; } df::viewscreen * Core::getTopViewscreen() { return getInstance().top_viewscreen; } -bool Core::InitMainThread() { +bool Core::InitMainThread(std::filesystem::path path) { + assert(active_instance == nullptr); + + // set this instance as the active instance + active_instance = this; + // this hook is always called from DF's main (render) thread, so capture this thread id df_render_thread = std::this_thread::get_id(); + hack_path = path; Filesystem::init(); - #ifdef LINUX_BUILD - extern void dfhack_crashlog_init(); + #if defined(LINUX_BUILD) || defined(WIN32) dfhack_crashlog_init(); #endif @@ -1085,12 +1114,16 @@ bool Core::InitMainThread() { fprintf(stderr, "dfhack: redirecting stderr to stderr.log\n"); if (!freopen("stderr.log", "w", stderr)) std::cerr << "Could not redirect stderr to stderr.log" << std::endl; + // freopen makes stderr fully buffered on Windows; restore unbuffered + // writes so that stderr.log is up to date (e.g. in the event of a crash) + setvbuf(stderr, NULL, _IONBF, 0); std::cerr << "DFHack build: " << Version::git_description() << std::endl; if (strlen(Version::dfhack_run_url())) { std::cerr << "Build url: " << Version::dfhack_run_url() << std::endl; } std::cerr << "Starting with working directory: " << Filesystem::getcwd() << std::endl; + std::cerr << "Hack path: " << getHackPath() << std::endl; std::cerr << "Binding to SDL.\n"; if (!DFSDL::init(con)) { @@ -1099,16 +1132,12 @@ bool Core::InitMainThread() { } // find out what we are... - #ifdef LINUX_BUILD - const char * path = "hack/symbols.xml"; - #else - const char * path = "hack\\symbols.xml"; - #endif + std::filesystem::path symbols_path = getHackPath() / "symbols.xml"; auto local_vif = std::make_unique(); std::cerr << "Identifying DF version.\n"; try { - local_vif->loadFile(path); + local_vif->loadFile(symbols_path); } catch(Error::All & err) { @@ -1236,9 +1265,9 @@ bool Core::InitSimulationThread() { // the update hook is only called from the simulation thread, so capture this thread id df_simulation_thread = std::this_thread::get_id(); - if(started) + if (started) return true; - if(errorstate) + if (errorstate) return false; // Lock the CoreSuspendMutex until the thread exits or call Core::Shutdown @@ -1280,20 +1309,20 @@ bool Core::InitSimulationThread() std::cout << "Console disabled.\n"; } } - else if(con.init(false)) + else if (con.init(false)) std::cerr << "Console is running.\n"; else std::cerr << "Console has failed to initialize!\n"; -/* - // dump offsets to a file - std::ofstream dump("offsets.log"); - if(!dump.fail()) - { - //dump << vinfo->PrintOffsets(); - dump.close(); - } - */ - // initialize data defs + /* + // dump offsets to a file + std::ofstream dump("offsets.log"); + if(!dump.fail()) + { + //dump << vinfo->PrintOffsets(); + dump.close(); + } + */ + // initialize data defs virtual_identity::Init(this); // create config directory if it doesn't already exist @@ -1310,7 +1339,8 @@ bool Core::InitSimulationThread() else { // ensure all config file directories exist before we start copying files - for (auto &entry : default_config_files) { + for (auto& entry : default_config_files) + { // skip over files if (!entry.second) continue; @@ -1320,19 +1350,22 @@ bool Core::InitSimulationThread() } // copy files from the default tree that don't already exist in the config tree - for (auto &entry : default_config_files) { + for (auto& entry : default_config_files) + { // skip over directories if (entry.second) continue; std::filesystem::path filename = entry.first; - if (!config_files.contains(filename)) { + if (!config_files.contains(filename)) + { std::filesystem::path src_file = getConfigDefaultsPath() / filename; if (!Filesystem::isfile(src_file)) continue; std::filesystem::path dest_file = getConfigPath() / filename; std::ifstream src(src_file, std::ios::binary); std::ofstream dest(dest_file, std::ios::binary); - if (!src.good() || !dest.good()) { + if (!src.good() || !dest.good()) + { con.printerr("Copy failed: '{}'\n", filename); continue; } @@ -1343,6 +1376,17 @@ bool Core::InitSimulationThread() } } + // set lua default path if not already set + if (std::getenv("DFHACK_LUA_PATH") == nullptr) + { + std::filesystem::path lua_path = getHackPath() / "lua" / "?.lua"; +#ifdef WIN32 + _putenv_s("DFHACK_LUA_PATH", lua_path.string().c_str()); +#else + setenv("DFHACK_LUA_PATH", lua_path.string().c_str(), 1); +#endif + } + loadScriptPaths(con); // initialize common lua context @@ -1462,8 +1506,8 @@ bool Core::InitSimulationThread() } Core& Core::getInstance() { - static Core instance; - return instance; + assert(Core::active_instance != nullptr); + return *Core::active_instance; } bool Core::isSuspended(void) @@ -1473,6 +1517,18 @@ bool Core::isSuspended(void) void Core::doUpdate(color_ostream &out) { + #ifdef WIN32 + // Re-chain the top-level exception filter once DF is fully running, + // in case DF installed its own crash filter after our init. If ours + // is still installed, this is a no-op. + static bool crashlog_rearmed = false; + if (!crashlog_rearmed) + { + crashlog_rearmed = true; + dfhack_crashlog_init(); + } + #endif + Lua::Core::Reset(out, "DF code execution"); // find the current viewscreen @@ -1870,8 +1926,7 @@ void Core::onStateChange(color_ostream &out, state_change_event event) int Core::Shutdown ( void ) { - #ifdef LINUX_BUILD - extern void dfhack_crashlog_shutdown(); + #if defined(LINUX_BUILD) || defined(WIN32) dfhack_crashlog_shutdown(); #endif @@ -1893,6 +1948,7 @@ int Core::Shutdown ( void ) if (hotkey_mgr) { delete hotkey_mgr; + hotkey_mgr = nullptr; } if(plug_mgr) @@ -1900,13 +1956,17 @@ int Core::Shutdown ( void ) delete plug_mgr; plug_mgr = nullptr; } + // invalidate all modules - allModules.clear(); Textures::cleanup(); DFSDL::cleanup(); - DFSteam::cleanup(getConsole()); - memset(&(s_mods), 0, sizeof(s_mods)); + DFSteam::cleanup(); + d.reset(); + + // clear active instance + Core::active_instance = nullptr; + return -1; } @@ -2135,23 +2195,3 @@ std::string Core::GetAliasCommand(const std::string &name, bool ignore_params) return aliases[name][0]; return join_strings(" ", aliases[name]); } - -/******************************************************************************* - M O D U L E S -*******************************************************************************/ - -#define MODULE_GETTER(TYPE) \ -TYPE * Core::get##TYPE() \ -{ \ - if(errorstate) return nullptr;\ - if(!s_mods.p##TYPE)\ - {\ - std::unique_ptr mod = create##TYPE();\ - s_mods.p##TYPE = (TYPE *) mod.get();\ - allModules.push_back(std::move(mod));\ - }\ - return s_mods.p##TYPE;\ -} - -MODULE_GETTER(Materials); -MODULE_GETTER(Graphic); diff --git a/library/Crashlog-windows.cpp b/library/Crashlog-windows.cpp new file mode 100644 index 0000000000..16b32fed56 --- /dev/null +++ b/library/Crashlog-windows.cpp @@ -0,0 +1,337 @@ +/* + * DFHack crash reporting for Windows. + * + * Dwarf Fortress installs its own top-level unhandled exception filter, + * which produces crashlog.txt (a native stack trace with module/offset + * annotations). That trace is enough to identify that a crash happened + * inside the Lua VM, but not what Lua code was running. + * + * This module chains an exception filter ahead of DF's (and additionally + * registers a vectored continue handler in case our filter is itself + * replaced later). When a fatal exception occurs and the Lua VM was + * involved - lua53.dll on the native stack, or a DFHack Lua call active + * on the faulting thread - we write a Lua stack traceback to stderr.log + * before chaining to DF's filter so that its crashlog is still produced. + * + * Everything in the crash path runs in the context of a crashed process, + * so it uses bare Win32 IO only and is wrapped in SEH so that a secondary + * fault while reporting simply falls through to DF's handler. + */ + +#define WIN32_LEAN_AND_MEAN +#include + +#include +#include +#include +#include +#include + +#include "Core.h" +#include "Crashlog.h" +#include "DFHackVersion.h" + +#include "lua.h" +#include "lauxlib.h" +#include "lstate.h" + +namespace { + +std::atomic handler_active{false}; +LPTOP_LEVEL_EXCEPTION_FILTER previous_filter = nullptr; +PVOID continue_handler = nullptr; + +// image bounds of interest, resolved once at init (before anyone could crash) +struct ImageRange { const char *name; uintptr_t base, end; }; +ImageRange df_image = {"Dwarf Fortress.exe", 0, 0}; +ImageRange dfhack_image = {"dfhack.dll", 0, 0}; +ImageRange lua_image = {"lua53.dll", 0, 0}; + +void cache_image_range(ImageRange &range, const wchar_t *module_name) +{ + HMODULE m = GetModuleHandleW(module_name); + if (!m) + return; + auto dos = reinterpret_cast(m); + auto nt = reinterpret_cast( + reinterpret_cast(m) + dos->e_lfanew); + range.base = reinterpret_cast(m); + range.end = range.base + nt->OptionalHeader.SizeOfImage; +} + +const char *image_name_for(uintptr_t addr) +{ + for (const auto &r : {df_image, dfhack_image, lua_image}) + if (r.base && addr >= r.base && addr < r.end) + return r.name; + return nullptr; +} + +void log_write_str(HANDLE log, const char *str) +{ + DWORD written; + WriteFile(log, str, (DWORD)strlen(str), &written, nullptr); +} + +// Minimal output: append to stderr.log with raw Win32 IO so we depend on +// as little process state as possible. +void log_write(HANDLE log, const char *fmt, ...) +{ + char buf[8192]; + va_list args; + va_start(args, fmt); + int len = _vsnprintf(buf, sizeof(buf) - 1, fmt, args); + va_end(args); + if (len == 0) + return; + if (len < 0 || len >= (int)sizeof(buf)) + len = (int)sizeof(buf) - 1; // truncated output is still worth having + buf[sizeof(buf) - 1] = '\0'; + log_write_str(log, buf); + +} + +// Walk the faulting thread's stack looking for instruction pointers inside +// lua53.dll. Uses RtlVirtualUnwind (driven by .pdata unwind info), which does +// not need dbghelp or symbol files. +bool lua_frames_on_stack(EXCEPTION_POINTERS *ep) +{ + CONTEXT ctx = *ep->ContextRecord; + for (int i = 0; i < 200; i++) + { + if (ctx.Rip >= lua_image.base && ctx.Rip < lua_image.end) + return true; + // Return addresses land on the instruction after a call and are + // normally unaligned, so the only sane early-out here is a null + // ip. Garbage values are harmless: RtlLookupFunctionEntry returns + // NULL for them, and the iteration bound plus the caller's SEH + // guard cap the damage from a corrupt stack. + if (!ctx.Rip) + break; + + DWORD64 image_base = 0; + PRUNTIME_FUNCTION fn = RtlLookupFunctionEntry(ctx.Rip, &image_base, nullptr); + if (fn) + { + PVOID handler_data = nullptr; + DWORD64 establisher = 0; + RtlVirtualUnwind(UNW_FLAG_NHANDLER, image_base, ctx.Rip, fn, + &ctx, &handler_data, &establisher, nullptr); + } + else + { + // Leaf function: return address is at [rsp]. Unlike rip, rsp + // is architecturally required to stay 8-aligned, so a + // misaligned value here means the context is corrupt. + if (ctx.Rsp & 7) + break; + ctx.Rip = *reinterpret_cast(ctx.Rsp); + ctx.Rsp += 8; + } + } + return false; +} + +bool state_has_active_call(lua_State *L) +{ + return L && L->ci != &L->base_ci; +} + +void write_lua_traceback(HANDLE log, lua_State *L) +{ + if (!L) + return; + + // Mirror of struct lua_extra_state in depends/lua/include/dfhack_llimits.h: + // the first extraspace slot holds a pointer to the state's recursive + // critical section. If the faulting thread holds it already (e.g. the + // crash is inside a Lua call on this thread), TryEnterCriticalSection + // recurses and succeeds. If another thread is inside the VM, we note it + // and walk the state anyway - it is read-only and SEH-guarded, and an + // approximate trace is better than none. + bool locked = false; + CRITICAL_SECTION *cs = nullptr; + __try + { + cs = *reinterpret_cast(lua_getextraspace(L)); + if (cs) + locked = TryEnterCriticalSection(cs); + } + __except (EXCEPTION_EXECUTE_HANDLER) + { + cs = nullptr; + } + + __try + { + if (!locked) + log_write(log, "(could not acquire Lua state lock; " + "traceback may be inaccurate)\n"); + if (!state_has_active_call(L)) + { + log_write(log, "main lua_State %p: no active call\n", (void*)L); + } + else + { + luaL_traceback(L, L, "crash while Lua was executing", 0); + const char *tb = lua_tostring(L, -1); + if (tb) + { + log_write(log, "lua_State %p traceback:\n", (void*)L); + log_write_str(log, tb); + log_write(log, "\n"); + } + else + { + log_write(log, "lua_State %p: traceback not a string\n", (void*)L); + } + lua_pop(L, 1); + } + } + __except (EXCEPTION_EXECUTE_HANDLER) + { + log_write(log, "(lua traceback attempt faulted)\n"); + } + + if (locked) + LeaveCriticalSection(cs); +} + +void report_crash(EXCEPTION_POINTERS *ep) +{ + HANDLE log = CreateFileA("stderr.log", FILE_APPEND_DATA, + FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, + OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + if (log == INVALID_HANDLE_VALUE) + return; + + EXCEPTION_RECORD *er = ep->ExceptionRecord; + const char *fault_image = image_name_for( + reinterpret_cast(er->ExceptionAddress)); + bool in_lua = lua_frames_on_stack(ep); + + lua_State *active = DFHack::Crashlog::active_state(); + lua_State *main_state = nullptr; + __try + { + if (!DFHack::Core::noInstance()) + main_state = DFHack::Core::getInstance().getLuaState(true); + } + __except (EXCEPTION_EXECUTE_HANDLER) {} + + log_write(log, + "\n===== DFHack crash report =====\n" + "DFHack %s, DF %s\n" + "Exception 0x%08lx at %p (module: %s), thread %lu\n", + DFHack::Version::dfhack_version(), DFHack::Version::df_version(), + er->ExceptionCode, er->ExceptionAddress, + fault_image ? fault_image : "?", + GetCurrentThreadId()); + + if (er->ExceptionCode == EXCEPTION_ACCESS_VIOLATION && + er->NumberParameters >= 2) + log_write(log, "access violation %s address %p\n", + er->ExceptionInformation[0] ? "writing" : "reading", + (void*)er->ExceptionInformation[1]); + + log_write(log, + "lua53.dll on native stack: %s\n" + "thread-local active lua_State: %p\n", + in_lua ? "yes" : "no", + (void*)active); + + if (in_lua || active || state_has_active_call(main_state)) + { + write_lua_traceback(log, active ? active : main_state); + if (active && main_state && active != main_state && + state_has_active_call(main_state)) + write_lua_traceback(log, main_state); + } + else + { + log_write(log, "no Lua involvement detected\n"); + } + + log_write(log, "===== end DFHack crash report =====\n\n"); + CloseHandle(log); +} + +LONG WINAPI dfhack_unhandled_exception(EXCEPTION_POINTERS *ep) +{ + if (!handler_active.exchange(true)) + { + __try + { + report_crash(ep); + } + __except (EXCEPTION_EXECUTE_HANDLER) {} + } + + if (previous_filter) + return previous_filter(ep); + return EXCEPTION_CONTINUE_SEARCH; +} + +// Runs only if the exception was never handled and DF's own filter (or +// whoever replaced us) declined to handle it either. Produces the same +// report; the handler_active flag prevents double-reporting. +LONG WINAPI dfhack_vectored_continue(EXCEPTION_POINTERS *ep) +{ + if (!handler_active.exchange(true)) + { + __try + { + report_crash(ep); + } + __except (EXCEPTION_EXECUTE_HANDLER) {} + } + return EXCEPTION_CONTINUE_SEARCH; +} + +void chain_top_level_filter() +{ + LPTOP_LEVEL_EXCEPTION_FILTER prev = + SetUnhandledExceptionFilter(dfhack_unhandled_exception); + if (prev && prev != dfhack_unhandled_exception) + previous_filter = prev; +} + +} + +namespace DFHack { + +void dfhack_crashlog_init() +{ + cache_image_range(df_image, nullptr); + cache_image_range(dfhack_image, L"dfhack.dll"); + cache_image_range(lua_image, L"lua53.dll"); + + // Install our top-level filter, chaining to whoever was there before + // (normally DF's own crash handler). This function is also called again + // on the first update tick so that a filter DF installs later in + // startup still ends up behind us in the chain. + chain_top_level_filter(); + + // Fallback path: runs after the top-level filter if the exception was + // never handled, catching the case where our filter was overwritten. + if (!continue_handler) + continue_handler = AddVectoredContinueHandler(0, dfhack_vectored_continue); +} + +void dfhack_crashlog_shutdown() +{ + // Probe the current top-level filter by installing ours and looking at + // what was there. If ours was current, the chain tail is what should be + // restored; otherwise put back whatever we just displaced. + LPTOP_LEVEL_EXCEPTION_FILTER cur = + SetUnhandledExceptionFilter(dfhack_unhandled_exception); + SetUnhandledExceptionFilter(cur == dfhack_unhandled_exception + ? previous_filter : cur); + if (continue_handler) + { + RemoveVectoredContinueHandler(continue_handler); + continue_handler = nullptr; + } +} + +} diff --git a/library/Crashlog.cpp b/library/Crashlog.cpp index 3860e0106e..fdf20150d2 100644 --- a/library/Crashlog.cpp +++ b/library/Crashlog.cpp @@ -1,20 +1,43 @@ +// Dl_info and the SA_SIGINFO handler form need the GNU/POSIX extensions. +#ifndef _GNU_SOURCE +#define _GNU_SOURCE +#endif + #include "DFHackVersion.h" +#include "Crashlog.h" #include +#include #include #include #include #include #include +#include #include #include +#include +#include +#include +#include + +#include "Core.h" +#include "lua.h" +#include "lauxlib.h" +#include "lstate.h" const int BT_ENTRY_MAX = 25; struct CrashInfo { int backtrace_entries = 0; void* backtrace[BT_ENTRY_MAX]; int signal = 0; + int sig_code = 0; + void* fault_address = nullptr; + // The lua_State the faulting thread was executing in, if it was inside + // a DFHack Lua entry point when it crashed. + lua_State* active_lua_state = nullptr; + pid_t thread_id = 0; }; CrashInfo crash_info; @@ -23,6 +46,10 @@ std::atomic crashed = false; std::atomic crashlog_ready = false; std::atomic shutdown = false; +// Load base of liblua53, resolved at init so the crashlog thread can +// recognize Lua frames without depending on anything else being intact. +void* lua_image_base = nullptr; + // Use eventfd for async-signal safe waiting int crashlog_complete = -1; @@ -44,13 +71,19 @@ void signal_crashlog_complete() { std::thread crashlog_thread; // Force this method to be inlined so that it doesn't create a stack frame -[[gnu::always_inline]] inline void handle_signal_internal(int sig) { +[[gnu::always_inline]] inline void handle_signal_internal(int sig, siginfo_t *si) { if (shutdown.load() || crashed.exchange(true) || crashlog_ready.load()) { // Ensure the signal handler doesn't try to write a crashlog // whilst the crashlog thread is unavailable. std::quick_exit(1); } crash_info.signal = sig; + if (si) { + crash_info.sig_code = si->si_code; + crash_info.fault_address = si->si_addr; + } + crash_info.thread_id = (pid_t)syscall(SYS_gettid); + crash_info.active_lua_state = DFHack::Crashlog::active_state(); crash_info.backtrace_entries = backtrace(crash_info.backtrace, BT_ENTRY_MAX); // Signal saving of crashlog @@ -63,12 +96,12 @@ std::thread crashlog_thread; std::quick_exit(1); } -extern "C" void dfhack_crashlog_handle_signal(int sig) { - handle_signal_internal(sig); +extern "C" void dfhack_crashlog_handle_signal(int sig, siginfo_t *si, void *) { + handle_signal_internal(sig, si); } void dfhack_crashlog_handle_terminate() { - handle_signal_internal(0); + handle_signal_internal(0, nullptr); } std::string signal_name(int sig) { @@ -83,12 +116,46 @@ std::string signal_name(int sig) { return "SIGFPE"; case SIGSEGV: return "SIGSEGV"; + case SIGBUS: + return "SIGBUS"; case SIGTERM: return "SIGTERM"; } return ""; } +std::string signal_code_name(int sig, int code) { + // si_code values are only unique within their signal, so key on both. + switch (sig) { + case SIGSEGV: + switch (code) { + case SEGV_MAPERR: return "SEGV_MAPERR"; + case SEGV_ACCERR: return "SEGV_ACCERR"; + case SEGV_BNDERR: return "SEGV_BNDERR"; + } + break; + case SIGBUS: + switch (code) { + case BUS_ADRALN: return "BUS_ADRALN"; + case BUS_ADRERR: return "BUS_ADRERR"; + } + break; + case SIGILL: + switch (code) { + case ILL_ILLOPC: return "ILL_ILLOPC"; + case ILL_PRVOPC: return "ILL_PRVOPC"; + } + break; + case SIGFPE: + switch (code) { + case FPE_INTDIV: return "FPE_INTDIV"; + case FPE_FLTDIV: return "FPE_FLTDIV"; + } + break; + } + return ""; +} + std::filesystem::path get_crashlog_path() { std::time_t time = std::time(nullptr); std::tm* tm = std::localtime(&time); @@ -109,6 +176,152 @@ std::filesystem::path get_crashlog_path() { return log_path; } +// --------------------------------------------------------------------------- +// Lua crash diagnostics +// +// These run on the dedicated crashlog thread in normal (non-signal) context, +// after the native backtrace has already been written and flushed. The Lua +// state may itself be corrupt - that is often *why* we crashed - so the +// inspection is bounded by a fault guard: a segfault while tracing only +// loses the Lua section of the report. +// --------------------------------------------------------------------------- + +volatile sig_atomic_t lua_guard_armed = 0; +sigjmp_buf lua_guard_jmp; +pid_t crashlog_thread_id = 0; +const int guarded_signals[3] = {SIGSEGV, SIGBUS, SIGILL}; +struct sigaction saved_actions[3]; + +// Runs as a temporary signal disposition while the Lua state is inspected. +// Faults on the crashlog thread jump back to the guard; a fault on any other +// thread re-raises with the default disposition so the process still dies +// with its original signal. +extern "C" void lua_fault_guard(int sig, siginfo_t *, void *) { + if (lua_guard_armed && (pid_t)syscall(SYS_gettid) == crashlog_thread_id) + siglongjmp(lua_guard_jmp, sig); + signal(sig, SIG_DFL); + raise(sig); +} + +bool in_lua_image(void *addr) { + Dl_info di; + if (!dladdr(addr, &di)) + return false; + if (lua_image_base && di.dli_fbase == lua_image_base) + return true; + if (di.dli_fname) { + const char *base = std::strrchr(di.dli_fname, '/'); + base = base ? base + 1 : di.dli_fname; + if (std::strstr(base, "lua53") || std::strstr(base, "liblua")) + return true; + } + return false; +} + +bool state_has_active_call(lua_State *L) { + return L && L->ci != &L->base_ci; +} + +// Emits the Lua section of the crashlog. Must only be called between +// arming and disarming the fault guard. +void inspect_lua_state(std::ofstream &crashlog, lua_State *L) { + if (!L) + return; + + // Best-effort lock. The faulting thread is frozen inside its signal + // handler and usually still holds the state mutex, so trylock typically + // fails and we walk the state read-only - its only writer is suspended + // anyway. If we do acquire it, no other thread is inside the VM. + // Mirror of struct lua_extra_state in depends/lua/include/dfhack_llimits.h: + // the extraspace slot holds a pointer to the state's recursive mutex. + bool locked = false; + auto *mutex = *reinterpret_cast(lua_getextraspace(L)); + if (mutex) + locked = pthread_mutex_trylock(mutex) == 0; + if (!locked) + crashlog << "could not acquire Lua state lock; " + "the following is a best-effort snapshot\n"; + + if (!state_has_active_call(L)) { + crashlog << "lua_State " << (const void*)L << ": no active call\n"; + } else { + // A corrupted CallInfo chain (e.g. a cycle) would send + // luaL_traceback into an unbounded walk, which the fault guard + // cannot interrupt. Bound the chain first. + const CallInfo *ci = L->ci; + int depth = 0; + while (ci && ci != &L->base_ci && ++depth <= 65536) + ci = ci->previous; + if (ci != &L->base_ci) { + crashlog << "lua_State " << (const void*)L + << ": corrupted CallInfo chain; not safe to trace\n"; + } else { + crashlog << "lua_State " << (const void*)L << " traceback:\n"; + luaL_traceback(L, L, "crash while Lua was executing", 0); + const char *tb = lua_tostring(L, -1); + crashlog << (tb ? tb : "(traceback unavailable)") << "\n"; + lua_pop(L, 1); + } + } + + if (locked) + pthread_mutex_unlock(mutex); +} + +// Writes the Lua diagnostics section, guarded against faults in corrupted +// interpreter metadata. Fault guard handlers are installed for the duration +// of the inspection and the original dispositions are restored afterwards. +void write_lua_context(std::ofstream &crashlog) { + bool lua_on_stack = false; + for (int i = 0; i < crash_info.backtrace_entries; i++) { + if (in_lua_image(crash_info.backtrace[i])) { + lua_on_stack = true; + break; + } + } + + crashlog << "\nliblua53 on native stack: " << (lua_on_stack ? "yes" : "no") << "\n"; + crashlog << "thread-local active lua_State: " + << (const void*)crash_info.active_lua_state << "\n"; + + struct sigaction sa; + std::memset(&sa, 0, sizeof(sa)); + sa.sa_sigaction = lua_fault_guard; + sa.sa_flags = SA_SIGINFO | SA_NODEFER; + sigemptyset(&sa.sa_mask); + for (int i = 0; i < 3; i++) + sigaction(guarded_signals[i], &sa, &saved_actions[i]); + crashlog_thread_id = (pid_t)syscall(SYS_gettid); + + // Everything that dereferences the Lua state or Core singleton lives + // inside the guard: either may be pointing at corrupt memory. + int fault = sigsetjmp(lua_guard_jmp, 1); + if (fault == 0) { + lua_guard_armed = 1; + + lua_State *active = crash_info.active_lua_state; + lua_State *main_state = nullptr; + if (!DFHack::Core::noInstance()) + main_state = DFHack::Core::getInstance().getLuaState(true); + + if (!lua_on_stack && !active && !state_has_active_call(main_state)) { + crashlog << "no Lua involvement detected\n"; + } else { + inspect_lua_state(crashlog, active ? active : main_state); + if (active && main_state && active != main_state && + state_has_active_call(main_state)) + inspect_lua_state(crashlog, main_state); + } + lua_guard_armed = 0; + } else { + crashlog << "(inspecting the Lua state faulted: " + << signal_name(fault) << ")\n"; + } + + for (int i = 0; i < 3; i++) + sigaction(guarded_signals[i], &saved_actions[i], nullptr); +} + void dfhack_save_crashlog() { char** backtrace_strings = backtrace_symbols(crash_info.backtrace, crash_info.backtrace_entries); try { @@ -121,7 +334,18 @@ void dfhack_save_crashlog() { std::string signal = signal_name(crash_info.signal); if (!signal.empty()) { - crashlog << "Signal " << signal << "\n"; + crashlog << "Signal " << signal; + std::string code = signal_code_name(crash_info.signal, crash_info.sig_code); + if (!code.empty()) + crashlog << " (" << code << ")"; + else if (crash_info.sig_code) + crashlog << " (code " << crash_info.sig_code << ")"; + // si_addr is only meaningful for faults (a null deref reads as + // (nil), which is itself diagnostic). + if (crash_info.signal == SIGSEGV || crash_info.signal == SIGBUS || + crash_info.signal == SIGILL || crash_info.signal == SIGFPE) + crashlog << " at address " << crash_info.fault_address; + crashlog << " on thread " << crash_info.thread_id << "\n"; } if (crash_info.backtrace_entries >= 2 && backtrace_strings != nullptr) { @@ -133,6 +357,11 @@ void dfhack_save_crashlog() { // Make it clear if no relevant backtrace was able to be obtained crashlog << "Failed to obtain relevant backtrace\n"; } + + // Make sure the native report is on disk before touching the Lua + // state, which may itself be corrupt. + crashlog.flush(); + write_lua_context(crashlog); } catch (...) {} free(backtrace_strings); @@ -151,7 +380,7 @@ void dfhack_crashlog_thread() { std::terminate_handler term_handler = nullptr; -const int desired_signals[3] = {SIGSEGV,SIGILL,SIGABRT}; +const int desired_signals[4] = {SIGSEGV,SIGBUS,SIGILL,SIGABRT}; namespace DFHack { void dfhack_crashlog_init() { // Initialize eventfd flag @@ -159,15 +388,26 @@ namespace DFHack { crashlog_thread = std::thread(dfhack_crashlog_thread); + struct sigaction sa; + std::memset(&sa, 0, sizeof(sa)); + sa.sa_sigaction = dfhack_crashlog_handle_signal; + sa.sa_flags = SA_SIGINFO; + sigemptyset(&sa.sa_mask); for (int signal : desired_signals) { - std::signal(signal, dfhack_crashlog_handle_signal); + sigaction(signal, &sa, nullptr); } term_handler = std::set_terminate(dfhack_crashlog_handle_terminate); - // https://sourceware.org/glibc/manual/latest/html_mono/libc.html#index-backtrace-1 - // backtrace is AsyncSignal-Unsafe due to dynamic loading of libgcc_s + // Symbols used by the signal handler and the crashlog thread must be + // resolved ahead of time - lazy binding cannot run in the crash path. + // backtrace is AsyncSignal-Unsafe due to dynamic loading of libgcc_s. // Using it here ensures it is loaded before use in the signal handler. [[maybe_unused]] int _ = backtrace(crash_info.backtrace, 1); + [[maybe_unused]] lua_State *s = DFHack::Crashlog::active_state(); + [[maybe_unused]] pid_t t = (pid_t)syscall(SYS_gettid); + Dl_info di; + if (dladdr((void*)&lua_close, &di)) + lua_image_base = di.dli_fbase; } void dfhack_crashlog_shutdown() { diff --git a/library/DataDefs.cpp b/library/DataDefs.cpp index 880a8b93a5..ce11f174ed 100644 --- a/library/DataDefs.cpp +++ b/library/DataDefs.cpp @@ -134,6 +134,18 @@ const std::string compound_identity::getFullName() const return getName(); } +bool compound_identity::is_equivalent(const compound_identity* other) const +{ + if (this->byte_size() != other->byte_size() || strcmp(this->getName(), other->getName()) != 0) + return false; + + if (this->scope_parent != other->scope_parent && + !(this->scope_parent && other->scope_parent && this->scope_parent->is_equivalent(other->scope_parent))) + return false; + + return true; +} + static std::mutex *known_mutex = NULL; void compound_identity::Init(Core *core) @@ -239,7 +251,28 @@ bool struct_identity::is_subclass(const struct_identity *actual) const return false; } -const std::string pointer_identity::getFullName() const +bool struct_identity::is_equivalent(const struct_identity* other) const +{ + if (!static_cast(this)->is_equivalent(static_cast(other))) + return false; + + if (this->parent != other->parent && + !(this->parent && other->parent && this->parent->is_equivalent(other->parent))) + return false; + + const struct_field_info* f0 = this->fields; + const struct_field_info* f1 = other->fields; + + for (; f0->mode != struct_field_info::Mode::END && f1->mode != struct_field_info::Mode::END; f0++, f1++) + { + if (f0->mode != f1->mode || f0->offset != f1->offset || f0->count != f1->count || strcmp(f0->name, f1->name) != 0) + return false; + } + + return true; +} + +const std::string pointer_identity_base::getFullName() const { return (target ? target->getFullName() : std::string("void")) + "*"; } diff --git a/library/DataIdentity.cpp b/library/DataIdentity.cpp index 1bbeb64e77..cc6ea64fa6 100644 --- a/library/DataIdentity.cpp +++ b/library/DataIdentity.cpp @@ -1,50 +1,55 @@ -#include +#include "DataIdentity.h" + +#include "BitArray.h" +#include "DataDefs.h" #include +#include +#include #include +#include +#include +#include #include +#include #include -#include #include +#include -#include "DataFuncs.h" -#include "DataIdentity.h" // the space after the uses of "type" in OPAQUE_IDENTITY_TRAITS_NAME is _required_ // without it the macro generates a syntax error when type is a template specification namespace df { -#define NUMBER_IDENTITY_TRAITS(category, type, name) \ - const category##_identity identity_traits::identity(name); -#define INTEGER_IDENTITY_TRAITS(type, name) NUMBER_IDENTITY_TRAITS(integer, type, name) -#define FLOAT_IDENTITY_TRAITS(type) NUMBER_IDENTITY_TRAITS(float, type, #type) +#define NUMBER_IDENTITY_TRAITS(type, name) \ + const type_identity_for identity_traits::identity(name); #define OPAQUE_IDENTITY_TRAITS_NAME(name, ...) \ - const opaque_identity identity_traits<__VA_ARGS__ >::identity(sizeof(__VA_ARGS__), allocator_fn<__VA_ARGS__ >, name) + const type_identity_for<__VA_ARGS__ > identity_traits<__VA_ARGS__ >::identity(sizeof(__VA_ARGS__), allocator_fn<__VA_ARGS__ >, name) #define OPAQUE_IDENTITY_TRAITS(...) OPAQUE_IDENTITY_TRAITS_NAME(#__VA_ARGS__, __VA_ARGS__ ) - INTEGER_IDENTITY_TRAITS(char, "char"); - INTEGER_IDENTITY_TRAITS(signed char, "int8_t"); - INTEGER_IDENTITY_TRAITS(unsigned char, "uint8_t"); - INTEGER_IDENTITY_TRAITS(short, "int16_t"); - INTEGER_IDENTITY_TRAITS(unsigned short, "uint16_t"); - INTEGER_IDENTITY_TRAITS(int, "int32_t"); - INTEGER_IDENTITY_TRAITS(unsigned int, "uint32_t"); - INTEGER_IDENTITY_TRAITS(long, "long"); - INTEGER_IDENTITY_TRAITS(unsigned long, "unsigned long"); - INTEGER_IDENTITY_TRAITS(long long, "int64_t"); - INTEGER_IDENTITY_TRAITS(unsigned long long, "uint64_t"); - FLOAT_IDENTITY_TRAITS(float); - FLOAT_IDENTITY_TRAITS(double); - - const bool_identity identity_traits::identity; - const stl_string_identity identity_traits::identity; - const path_identity identity_traits::identity; - const ptr_string_identity identity_traits::identity; - const ptr_string_identity identity_traits::identity; - const pointer_identity identity_traits::identity; - const stl_ptr_vector_identity identity_traits >::identity; - const stl_bit_vector_identity identity_traits >::identity; - const bit_array_identity identity_traits >::identity; + NUMBER_IDENTITY_TRAITS(char, "char"); + NUMBER_IDENTITY_TRAITS(signed char, "int8_t"); + NUMBER_IDENTITY_TRAITS(unsigned char, "uint8_t"); + NUMBER_IDENTITY_TRAITS(short, "int16_t"); + NUMBER_IDENTITY_TRAITS(unsigned short, "uint16_t"); + NUMBER_IDENTITY_TRAITS(int, "int32_t"); + NUMBER_IDENTITY_TRAITS(unsigned int, "uint32_t"); + NUMBER_IDENTITY_TRAITS(long, "long"); + NUMBER_IDENTITY_TRAITS(unsigned long, "unsigned long"); + NUMBER_IDENTITY_TRAITS(long long, "int64_t"); + NUMBER_IDENTITY_TRAITS(unsigned long long, "uint64_t"); + NUMBER_IDENTITY_TRAITS(wchar_t, "wchar_t"); + NUMBER_IDENTITY_TRAITS(float, "float"); + NUMBER_IDENTITY_TRAITS(double, "double"); + NUMBER_IDENTITY_TRAITS(bool, "bool"); + const type_identity_for identity_traits::identity; + const type_identity_for identity_traits::identity; + const type_identity_for identity_traits::identity; + const type_identity_for identity_traits::identity; + const type_identity_for identity_traits::identity(nullptr); + const type_identity_for > identity_traits >::identity; + const type_identity_for > identity_traits >::identity; + const type_identity_for > identity_traits >::identity; OPAQUE_IDENTITY_TRAITS(std::condition_variable); OPAQUE_IDENTITY_TRAITS(std::fstream); @@ -57,9 +62,11 @@ namespace df { OPAQUE_IDENTITY_TRAITS(std::optional >); OPAQUE_IDENTITY_TRAITS(std::variant >); OPAQUE_IDENTITY_TRAITS(std::weak_ptr); + OPAQUE_IDENTITY_TRAITS(std::filesystem::file_time_type); + OPAQUE_IDENTITY_TRAITS(wchar_t*); const buffer_container_identity buffer_container_identity::base_instance; - const stl_container_identity > stl_vector_int32_t_identity("vector", identity_traits::get()); - const stl_container_identity > stl_vector_int16_t_identity("vector", identity_traits::get()); + const type_identity_for > stl_vector_int32_t_identity("vector", identity_traits::get()); + const type_identity_for > stl_vector_int16_t_identity("vector", identity_traits::get()); } diff --git a/library/DataStatics.cpp b/library/DataStatics.cpp index fe60e54544..af7613cdaa 100644 --- a/library/DataStatics.cpp +++ b/library/DataStatics.cpp @@ -22,8 +22,8 @@ namespace { DFHack::VersionInfo *global_table_ = DFHack::Core::getInstance().vinfo.get(); \ void * tmp_; -#define INIT_GLOBAL_FUNCTION_ITEM(type,name) \ - if (global_table_->getAddress(#name,tmp_)) name = (type*)tmp_; +#define INIT_GLOBAL_FUNCTION_ITEM(name, ...) \ + if (global_table_->getAddress(#name,tmp_)) name = (__VA_ARGS__*)tmp_; #define TID(type) (&identity_traits< type >::identity) diff --git a/library/Debug.cpp b/library/Debug.cpp index dafbeb5ce3..50bef6e538 100644 --- a/library/Debug.cpp +++ b/library/Debug.cpp @@ -66,9 +66,6 @@ void DebugManager::unregisterCategory(DebugCategory& cat) DebugRegisterBase::DebugRegisterBase(DebugCategory* cat) { - // Make sure Core lives at least as long any DebugCategory to - // allow debug prints until all Debugcategories has been destructed - Core::getInstance(); DebugManager::getInstance().registerCategory(*cat); } diff --git a/library/Hooks.cpp b/library/Hooks.cpp index 0f957fea06..761de48778 100644 --- a/library/Hooks.cpp +++ b/library/Hooks.cpp @@ -2,11 +2,47 @@ #include "Export.h" #include "df/gamest.h" +#include "df/global_objects.h" + +#ifdef _WIN32 +# define WIN32_LEAN_AND_MEAN +# include +# include +#else +# include +#endif static bool disabled = false; DFhackCExport const int32_t dfhooks_priority = 100; +static std::unique_ptr core_instance; + +static std::filesystem::path getModulePath() +{ +#ifdef _WIN32 + HMODULE module = nullptr; + GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, (LPCWSTR)getModulePath, &module); + if (!module) return std::filesystem::path(); // should never happen, but just in case, return an empty path instead of crashing + + wchar_t path[MAX_PATH]; + GetModuleFileNameW(module, path, MAX_PATH); + return std::filesystem::path(path); +#else + Dl_info info; + dladdr((const void*)getModulePath, &info); + return std::filesystem::path(info.dli_fname); +#endif +} + +static std::filesystem::path basepath{getModulePath()}; + +// called by the chainloader before the main thread is initialized and before any other hooks are called. +DFhackCExport void dfhooks_preinit(std::filesystem::path dllpath) +{ + basepath = dllpath.parent_path(); +} + // called from the main thread before the simulation thread is started // and the main event loop is initiated DFhackCExport void dfhooks_init() { @@ -16,8 +52,11 @@ DFhackCExport void dfhooks_init() { return; } + // construct DFHack core instance + core_instance = std::make_unique(); + // we need to init DF globals before we can check the commandline - if (!DFHack::Core::getInstance().InitMainThread() || !df::global::game) { + if (!core_instance->InitMainThread(std::filesystem::canonical(basepath)) || !df::global::game) { // we don't set disabled to true here so symbol generation can work return; } @@ -26,6 +65,8 @@ DFhackCExport void dfhooks_init() { if (cmdline.find("--disable-dfhack") != std::string::npos) { fprintf(stderr, "dfhack: --disable-dfhack specified on commandline; disabling\n"); disabled = true; + core_instance->Shutdown(); + core_instance.reset(); return; } @@ -36,14 +77,16 @@ DFhackCExport void dfhooks_init() { DFhackCExport void dfhooks_shutdown() { if (disabled) return; - DFHack::Core::getInstance().Shutdown(); + core_instance->Shutdown(); + // release DFHack core instance + core_instance.reset(); } // called from the simulation thread in the main event loop DFhackCExport void dfhooks_update() { if (disabled) return; - DFHack::Core::getInstance().Update(); + core_instance->Update(); } // called from the simulation thread just before adding the macro @@ -59,7 +102,7 @@ DFhackCExport void dfhooks_prerender() { DFhackCExport bool dfhooks_sdl_event(SDL_Event* event) { if (disabled) return false; - return DFHack::Core::getInstance().DFH_SDL_Event(event); + return core_instance->DFH_SDL_Event(event); } // called from the main thread just after setting mouse state in gps and just @@ -68,7 +111,7 @@ DFhackCExport void dfhooks_sdl_loop() { if (disabled) return; // TODO: wire this up to the new SDL-based console once it is merged - DFHack::Core::getInstance().DFH_SDL_Loop(); + core_instance->DFH_SDL_Loop(); } // called from the main thread for each utf-8 char read from the ncurses input @@ -78,5 +121,5 @@ DFhackCExport void dfhooks_sdl_loop() { DFhackCExport bool dfhooks_ncurses_key(int key) { if (disabled) return false; - return DFHack::Core::getInstance().DFH_ncurses_key(key); + return core_instance->DFH_ncurses_key(key); } diff --git a/library/LuaApi.cpp b/library/LuaApi.cpp index 4875b934c6..d937333b11 100644 --- a/library/LuaApi.cpp +++ b/library/LuaApi.cpp @@ -72,9 +72,11 @@ distribution. #include "df/building_stockpilest.h" #include "df/building_tradedepotst.h" #include "df/building_workshopst.h" +#include "df/builtin_mats.h" #include "df/burrow.h" #include "df/caravan_state.h" #include "df/construction.h" +#include "df/construction_flags.h" #include "df/creature_raw.h" #include "df/dfhack_material_category.h" #include "df/enabler.h" @@ -92,6 +94,7 @@ distribution. #include "df/job_item.h" #include "df/job_material_category.h" #include "df/language_word_table.h" +#include "df/manager_order.h" #include "df/material.h" #include "df/map_block.h" #include "df/nemesis_record.h" @@ -117,6 +120,7 @@ distribution. #include #include #include +#include #include #include #include @@ -236,8 +240,14 @@ static void decode_pen(lua_State *L, Pen &pen, int idx) get_char_field(L, &pen.ch, idx, "ch", 0); + // COLOR_RESET only has meaning on text consoles; substitute the Pen + // defaults so out-of-range colors can't reach the screen buffers get_int_field(L, &pen.fg, idx, "fg", 7); + if (pen.fg < 0 || pen.fg > COLOR_MAX) + pen.fg = COLOR_GREY; get_int_field(L, &pen.bg, idx, "bg", 0); + if (pen.bg < 0 || pen.bg > COLOR_MAX) + pen.bg = COLOR_BLACK; lua_getfield(L, idx, "bold"); if (lua_isnil(L, -1)) @@ -251,7 +261,11 @@ static void decode_pen(lua_State *L, Pen &pen, int idx) get_int_or_closure_field(L, &pen.tile, idx, "tile", 0); bool tcolor = get_int_field(L, &pen.tile_fg, idx, "tile_fg", 7); + if (pen.tile_fg < 0 || pen.tile_fg > COLOR_MAX) + pen.tile_fg = COLOR_GREY; tcolor = get_int_field(L, &pen.tile_bg, idx, "tile_bg", 0) || tcolor; + if (pen.tile_bg < 0 || pen.tile_bg > COLOR_MAX) + pen.tile_bg = COLOR_BLACK; if (tcolor) pen.tile_mode = Pen::TileColor; @@ -490,7 +504,7 @@ static bool decode_matinfo(lua_State *state, MaterialInfo *info, bool numpair = if (auto item = Lua::GetDFObject(state, 1)) return info->decode(item); if (auto plant = Lua::GetDFObject(state, 1)) - return info->decode(MaterialInfo::PLANT_BASE, plant->material); + return info->decode(df::builtin_mats::PLANT_1, plant->material); if (auto mvec = Lua::GetDFObject(state, 1)) return info->decode(*mvec, luaL_checkint(state, 2)); } @@ -1318,11 +1332,21 @@ static void OpenModule(lua_State *state, const char *mname, const luaL_Reg *reg2 lua_pop(state, 1); } -#define WRAPM(module, function) { #function, df::wrap_function(module::function,true) } -#define WRAP(function) { #function, df::wrap_function(function,true) } -#define WRAPN(name, function) { #name, df::wrap_function(function,true) } +#define WRAPM(module, function) { #function, df::wrap_function(module::function, #function, true) } +#define WRAP(function) { #function, df::wrap_function(function, #function, true) } +#define WRAPN(name, function) { #name, df::wrap_function(function, #function, true) } #define CWRAP(name, function) { #name, &Lua::CallWithCatchWrapper } +// The _D variants are used to indicate that a wrapped function is deprecated. +// Deprecated functions will still function but will emit a warning to the DFHack console. +// This warning is displayed only once for each distinct appearance in a script (by script path and line number). +// The additional "message" argument is displayed with the warning; it is recommended that this message +// indicate the recommended replacement or substitution. The message must not be empty. + +#define WRAPM_D(module, function, message) { #function, df::wrap_function(module::function, #function, true, message) } +#define WRAP_D(function, message) { #function, df::wrap_function(function, #function, true, message) } +#define WRAPN_D(name, function, message) { #name, df::wrap_function(function, #function, true, message) } + /***** DFHack module *****/ static string getOSType() @@ -1358,6 +1382,7 @@ static uint32_t getTickCount() { return Core::getInstance().p->getTickCount(); } static std::filesystem::path getDFPath() { return Core::getInstance().p->getPath(); } static std::filesystem::path getHackPath() { return Core::getInstance().getHackPath(); } +static std::filesystem::path getConfigPath() { return Core::getInstance().getConfigPath(); } static bool isWorldLoaded() { return Core::getInstance().isWorldLoaded(); } static bool isMapLoaded() { return Core::getInstance().isMapLoaded(); } @@ -1383,6 +1408,7 @@ static const LuaWrapper::FunctionReg dfhack_module[] = { WRAP(getDFPath), WRAP(getTickCount), WRAP(getHackPath), + WRAP(getConfigPath), WRAP(isWorldLoaded), WRAP(isMapLoaded), WRAP(isSiteLoaded), @@ -2018,6 +2044,7 @@ static const LuaWrapper::FunctionReg dfhack_job_module[] = { WRAPM(Job,isSuitableItem), WRAPM(Job,isSuitableMaterial), WRAPM(Job,getName), + WRAPM(Job,getManagerOrderName), WRAPM(Job,linkIntoWorld), WRAPM(Job,removePostings), WRAPM(Job,disconnectJobItem), @@ -2129,6 +2156,8 @@ static const luaL_Reg dfhack_textures_funcs[] = { /***** Units module *****/ +static int getBreathingState(df::unit* u) { return (int)Units::getBreathingState(u); } + static const LuaWrapper::FunctionReg dfhack_units_module[] = { WRAPM(Units, isActive), WRAPM(Units, isVisible), @@ -2181,6 +2210,7 @@ static const LuaWrapper::FunctionReg dfhack_units_module[] = { WRAPM(Units, hasExtravision), WRAPM(Units, isOpposedToLife), WRAPM(Units, isBloodsucker), + WRAPM(Units, breathes), WRAPM(Units, isDwarf), WRAPM(Units, isAnimal), WRAPM(Units, isMerchant), @@ -2253,6 +2283,7 @@ static const LuaWrapper::FunctionReg dfhack_units_module[] = { WRAPM(Units, unassignTrainer), WRAPM(Units, hasUnbailableSocialActivity), WRAPM(Units, isJobAvailable), + WRAPN(getBreathingState, getBreathingState), { NULL, NULL } }; @@ -2651,6 +2682,7 @@ static const LuaWrapper::FunctionReg dfhack_maps_module[] = { WRAPM(Maps, getWalkableGroup), WRAPM(Maps, canWalkBetween), WRAPM(Maps, spawnFlow), + WRAPM(Maps, addBlockColumns), WRAPN(hasTileAssignment, hasTileAssignment), WRAPN(getTileAssignment, getTileAssignment), WRAPN(setTileAssignment, setTileAssignment), @@ -2686,6 +2718,24 @@ static int maps_ensureTileBlock(lua_State *L) return 1; } +static int maps_getTileBlockCoord(lua_State *L) +{ + auto pos = CheckCoordXYZ(L, 1, true); + return Lua::PushPosXYZ(L, Maps::getTileBlockCoord(pos)); +} + +static int maps_getBlockOrigin(lua_State *L) +{ + auto pos = CheckCoordXYZ(L, 1, true); + return Lua::PushPosXYZ(L, Maps::getBlockOrigin(pos)); +} + +static int maps_getTileBlockOffset(lua_State *L) +{ + auto pos = CheckCoordXYZ(L, 1, true); + return Lua::PushPosXYZ(L, Maps::getTileBlockOffset(pos)); +} + static int maps_getTileType(lua_State *L) { auto pos = CheckCoordXYZ(L, 1, true); @@ -2718,6 +2768,18 @@ static int maps_getTileBiomeRgn(lua_State *L) return Lua::PushPosXY(L, Maps::getTileBiomeRgn(pos)); } +static int maps_getCurrentWeather(lua_State *L) +{ + if (lua_gettop(L) == 0) + { + lua_pushinteger(L, Maps::getCurrentWeather()); + } else { + auto pos = CheckCoordXYZ(L, 1, true); + lua_pushinteger(L, Maps::getCurrentWeather(pos)); + } + return 1; +} + static int maps_getPlantAtTile(lua_State *L) { auto pos = CheckCoordXYZ(L, 1, true); @@ -2781,21 +2843,306 @@ static int maps_removeTileAquifer(lua_State* L) return 1; } +static int maps_addMaterialSpatter(lua_State *L) +{ + int32_t rv; + df::coord pos; + + Lua::CheckDFAssign(L, &pos, 1); + int16_t mat = lua_tointeger(L, 2); + int32_t matg = lua_tointeger(L, 3); + df::matter_state state = (df::matter_state)lua_tointeger(L, 4); + int32_t amount = lua_tointeger(L, 5); + rv = Maps::addMaterialSpatter(pos, mat, matg, state, amount); + + lua_pushinteger(L, rv); + return 1; +} + +static int maps_addItemSpatter(lua_State *L) +{ + int32_t rv; + df::coord pos; + + Lua::CheckDFAssign(L, &pos, 1); + df::item_type i_type = (df::item_type)lua_tointeger(L, 2); + int16_t i_subtype = lua_tointeger(L, 3); + int16_t i_subcat1 = lua_tointeger(L, 4); + int32_t i_subcat2 = lua_tointeger(L, 5); + int32_t print_variant = lua_tointeger(L, 6); + int32_t amount = lua_tointeger(L, 7); + rv = Maps::addItemSpatter(pos, i_type, i_subtype, i_subcat1, i_subcat2, print_variant, amount); + + lua_pushinteger(L, rv); + return 1; +} + +/* + * Spec-table parsing helpers for maps_forEachTile. + */ + +// Resolve an enum value given as a number or as a DFHack enum item name. +template +static int check_enum_int(lua_State *L, int idx, const char *context) +{ + if (lua_isnumber(L, idx)) + return (int)lua_tointeger(L, idx); + if (lua_isstring(L, idx)) { + E val; + if (find_enum_item(&val, lua_tostring(L, idx))) + return (int)val; + luaL_error(L, "forEachTile: unknown %s '%s'", context, lua_tostring(L, idx)); + } + luaL_error(L, "forEachTile: %s must be a number or enum name", context); + return 0; +} + +// spec[key]: a single enum value or an array of them; inserted into out. +template +static void parse_enum_set(lua_State *L, int tbl, const char *key, + std::unordered_set &out) +{ + lua_getfield(L, tbl, key); + if (lua_istable(L, -1)) { + int spec = lua_absindex(L, -1); + int n = (int)luaL_len(L, spec); + for (int i = 1; i <= n; i++) { + lua_rawgeti(L, spec, i); + out.insert((V)check_enum_int(L, -1, key)); + lua_pop(L, 1); + } + } + else if (!lua_isnil(L, -1)) + out.insert((V)check_enum_int(L, -1, key)); + lua_pop(L, 1); +} + +// spec[key]: a table of bitfield field name -> required value. Compiles to a +// (value & mask) == bits test accumulated into mask and bits. +template +static void parse_bitfield_spec(lua_State *L, int tbl, const char *key, + uint32_t &mask, uint32_t &bits) +{ + lua_getfield(L, tbl, key); + if (lua_isnil(L, -1)) { + lua_pop(L, 1); + return; + } + luaL_checktype(L, -1, LUA_TTABLE); + int spec = lua_absindex(L, -1); + lua_pushnil(L); + while (lua_next(L, spec)) { + if (lua_type(L, -2) != LUA_TSTRING) + luaL_error(L, "forEachTile: %s field names must be strings", key); + const char *name = lua_tostring(L, -2); + unsigned idx; + if (!find_bitfield_field(&idx, name, (BF*)NULL)) + luaL_error(L, "forEachTile: unknown %s field '%s'", key, name); + int width = df::bitfield_traits::bits[idx].size; + int value; + if (lua_isboolean(L, -1)) + value = lua_toboolean(L, -1) ? 1 : 0; + else + value = (int)luaL_checkinteger(L, -1); + if (value < 0 || value >= (1 << width)) + luaL_error(L, "forEachTile: %s.%s value %d out of range", key, name, value); + uint32_t fmask = ((1u << width) - 1) << idx; + mask |= fmask; + bits |= (uint32_t)value << idx & fmask; + lua_pop(L, 1); + } + lua_pop(L, 1); +} + +// Wrap the Lua function at stack index fn as a per-tile predicate/callback. +// The function is invoked as fn(x, y, z, block, localx, localy, tiletype), +// where localx/localy are the tile's coordinates within the block. If truthy +// is set, the wrapper returns whether the function returned a truthy value +// (for filters); otherwise only a return value of exactly false stops the +// scan (for callbacks). +static std::function +wrap_lua_tile_fn(lua_State *L, int fn, bool truthy = false) +{ + return [L, fn, truthy](df::map_block *block, df::coord2d local, df::coord pos) { + lua_pushvalue(L, fn); + lua_pushinteger(L, pos.x); + lua_pushinteger(L, pos.y); + lua_pushinteger(L, pos.z); + Lua::PushDFObject(L, block); + lua_pushinteger(L, local.x); + lua_pushinteger(L, local.y); + lua_pushinteger(L, (int)block->tiletype[local.x][local.y]); + lua_call(L, 7, 1); + bool cont = truthy ? lua_toboolean(L, -1) != 0 + : !(lua_isboolean(L, -1) && !lua_toboolean(L, -1)); + lua_pop(L, 1); + return cont; + }; +} + +// If spec[key] is a function, leave it on the stack and return its absolute +// index wrapped as a tile function; otherwise return an empty std::function. +static std::function +opt_lua_tile_fn(lua_State *L, int tbl, const char *key, bool truthy = false) +{ + lua_getfield(L, tbl, key); + if (lua_isfunction(L, -1)) + return wrap_lua_tile_fn(L, lua_absindex(L, -1), truthy); + lua_pop(L, 1); + return {}; +} + +static int maps_forEachTile(lua_State *L) +{ + // Bounds forms: {x1,y1,z1,x2,y2,z2} | pos1, pos2 | x1,y1,z1,x2,y2,z2 + cuboid bounds; + int spec_base; + if (lua_isnumber(L, 1)) { + bounds = cuboid( + (int16_t)luaL_checkinteger(L, 1), (int16_t)luaL_checkinteger(L, 2), + (int16_t)luaL_checkinteger(L, 3), (int16_t)luaL_checkinteger(L, 4), + (int16_t)luaL_checkinteger(L, 5), (int16_t)luaL_checkinteger(L, 6)); + spec_base = 7; + } + else if (lua_istable(L, 1) && luaL_len(L, 1) == 6) { + int16_t v[6]; + for (int i = 0; i < 6; i++) { + lua_rawgeti(L, 1, i + 1); + v[i] = (int16_t)luaL_checkinteger(L, -1); + lua_pop(L, 1); + } + bounds = cuboid(v[0], v[1], v[2], v[3], v[4], v[5]); + spec_base = 2; + } + else { + df::coord p1, p2; + Lua::CheckDFAssign(L, &p1, 1); + Lua::CheckDFAssign(L, &p2, 2); + bounds = cuboid(p1, p2); + spec_base = 3; + } + + Maps::TileFilter filter; + Maps::TileActions actions; + + // Any Lua functions found in the spec tables are left on the stack so the + // wrapped references stay valid for the duration of the scan. + int base = lua_gettop(L); + + int fidx = spec_base, aidx = spec_base + 1; + if (lua_isfunction(L, fidx)) + filter.extra = wrap_lua_tile_fn(L, fidx, true); + else if (lua_istable(L, fidx)) { + parse_enum_set(L, fidx, "tiletype", filter.tiletypes); + parse_enum_set(L, fidx, "tiletypes", filter.tiletypes); + parse_enum_set(L, fidx, "material", filter.materials); + parse_enum_set(L, fidx, "shape", filter.shapes); + parse_enum_set(L, fidx, "shape_basic", filter.shapes_basic); + parse_enum_set(L, fidx, "special", filter.specials); + parse_enum_set(L, fidx, "variant", filter.variants); + parse_bitfield_spec(L, fidx, "designation", + filter.designation_mask, filter.designation_bits); + parse_bitfield_spec(L, fidx, "occupancy", + filter.occupancy_mask, filter.occupancy_bits); + filter.extra = opt_lua_tile_fn(L, fidx, "filter", true); + } + else if (!lua_isnoneornil(L, fidx)) + return luaL_error(L, "forEachTile: filter must be a table or function"); + + if (lua_isfunction(L, aidx)) + actions.callback = wrap_lua_tile_fn(L, aidx); + else if (lua_istable(L, aidx)) { + lua_getfield(L, aidx, "set_tiletype"); + if (lua_istable(L, -1)) { + // {[tiletype] = replacement_tiletype} map; keys and values may + // be numbers or enum names + int spec = lua_absindex(L, -1); + lua_pushnil(L); + while (lua_next(L, spec)) { + df::tiletype from = (df::tiletype)check_enum_int( + L, -2, "set_tiletype key"); + df::tiletype to = (df::tiletype)check_enum_int( + L, -1, "set_tiletype value"); + actions.tiletype_map[from] = to; + lua_pop(L, 1); + } + } + else if (!lua_isnil(L, -1)) + actions.set_tiletype = (df::tiletype)check_enum_int(L, -1, "set_tiletype"); + lua_pop(L, 1); + + parse_bitfield_spec(L, aidx, "designation", + actions.designation_clear, actions.designation_set); + parse_bitfield_spec(L, aidx, "occupancy", + actions.occupancy_clear, actions.occupancy_set); + + lua_getfield(L, aidx, "construct"); + if (lua_istable(L, -1)) { + int spec = lua_absindex(L, -1); + actions.construct = true; + lua_getfield(L, spec, "item_type"); + if (!lua_isnil(L, -1)) + actions.construct_item_type = (df::item_type)check_enum_int(L, -1, "construct.item_type"); + lua_pop(L, 1); + get_int_field(L, &actions.construct_item_subtype, spec, "item_subtype", -1); + get_int_field(L, &actions.construct_mat_type, spec, "mat_type", -1); + get_int_field(L, &actions.construct_mat_index, spec, "mat_index", -1); + lua_getfield(L, spec, "tiletype"); + if (!lua_isnil(L, -1)) + actions.construct_tiletype = (df::tiletype)check_enum_int(L, -1, "construct.tiletype"); + lua_pop(L, 1); + uint32_t fmask = 0, fbits = 0; + parse_bitfield_spec(L, spec, "flags", fmask, fbits); + actions.construct_flags = (uint8_t)fbits; + } + else if (!lua_isnil(L, -1)) + return luaL_error(L, "forEachTile: construct must be a table"); + lua_pop(L, 1); + + actions.callback = opt_lua_tile_fn(L, aidx, "callback"); + } + else if (!lua_isnoneornil(L, aidx)) + return luaL_error(L, "forEachTile: actions must be a table or function"); + + auto result = Maps::forEachTile(bounds, filter, actions); + + lua_settop(L, base); // drop wrapped spec functions + lua_newtable(L); + lua_pushinteger(L, result.tiles_scanned); + lua_setfield(L, -2, "scanned"); + lua_pushinteger(L, result.tiles_matched); + lua_setfield(L, -2, "matched"); + lua_pushinteger(L, result.tiletypes_changed); + lua_setfield(L, -2, "changed"); + lua_pushinteger(L, result.constructions_added); + lua_setfield(L, -2, "constructed"); + lua_pushboolean(L, result.aborted); + lua_setfield(L, -2, "aborted"); + return 1; +} + static const luaL_Reg dfhack_maps_funcs[] = { { "isValidTilePos", maps_isValidTilePos }, { "isTileVisible", maps_isTileVisible }, { "getTileBlock", maps_getTileBlock }, { "ensureTileBlock", maps_ensureTileBlock }, + { "getTileBlockCoord", maps_getTileBlockCoord }, + { "getBlockOrigin", maps_getBlockOrigin }, + { "getTileBlockOffset", maps_getTileBlockOffset }, { "getTileType", maps_getTileType }, { "getTileFlags", maps_getTileFlags }, { "getRegionBiome", maps_getRegionBiome }, { "getTileBiomeRgn", maps_getTileBiomeRgn }, + { "getCurrentWeather", maps_getCurrentWeather }, { "getPlantAtTile", maps_getPlantAtTile }, { "getBiomeType", maps_getBiomeType }, { "isTileAquifer", maps_isTileAquifer }, { "isTileHeavyAquifer", maps_isTileHeavyAquifer }, { "setTileAquifer", maps_setTileAquifer }, { "removeTileAquifer", maps_removeTileAquifer }, + { "addMaterialSpatter", maps_addMaterialSpatter }, + { "addItemSpatter", maps_addItemSpatter }, + { "forEachTile", maps_forEachTile }, { NULL, NULL } }; @@ -3088,6 +3435,34 @@ static int screen_readTile(lua_State *L) return 1; } +static int screen_paintMapPortTile(lua_State *L) +{ + Pen pen; + Lua::CheckPen(L, &pen, 1); + int x = luaL_checkint(L, 2); + int y = luaL_checkint(L, 3); + if (lua_gettop(L) >= 4 && !lua_isnil(L, 4)) + { + if (lua_type(L, 4) == LUA_TSTRING) + pen.ch = lua_tostring(L, 4)[0]; + else + pen.ch = luaL_checkint(L, 4); + } + if (lua_gettop(L) >= 5 && !lua_isnil(L, 5)) + pen.tile = luaL_checkint(L, 5); + lua_pushboolean(L, Screen::paintMapPortTile(pen, x, y)); + return 1; +} + +static int screen_readMapPortTile(lua_State *L) +{ + int x = luaL_checkint(L, 1); + int y = luaL_checkint(L, 2); + Pen pen = Screen::readMapPortTile(x, y, &df::graphic_map_portst::screentexpos_site); + Lua::Push(L, pen); + return 1; +} + static int screen_paintString(lua_State *L) { Pen pen; @@ -3275,6 +3650,8 @@ static const luaL_Reg dfhack_screen_funcs[] = { { "getWindowSize", screen_getWindowSize }, { "paintTile", screen_paintTile }, { "readTile", screen_readTile }, + { "paintMapPortTile", screen_paintMapPortTile }, + { "readMapPortTile", screen_readMapPortTile }, { "paintString", screen_paintString }, { "fillRect", screen_fillRect }, { "findGraphicsTile", screen_findGraphicsTile }, diff --git a/library/LuaTools.cpp b/library/LuaTools.cpp index 69242ede5f..44fe872cb8 100644 --- a/library/LuaTools.cpp +++ b/library/LuaTools.cpp @@ -33,6 +33,7 @@ distribution. #include "DataFuncs.h" #include "LuaWrapper.h" #include "LuaTools.h" +#include "Crashlog.h" #include "MiscUtils.h" #include "DFHackVersion.h" #include "PluginManager.h" @@ -505,11 +506,19 @@ volatile std::sig_atomic_t lstop = 0; static void interrupt_hook (lua_State *L, lua_Debug *ar); static void interrupt_init (lua_State *L) { - lua_sethook(L, interrupt_hook, LUA_MASKCOUNT, 256); + lua_sethook(L, interrupt_hook, LUA_MASKCOUNT, 16384); } +constexpr auto interrupt_log_buffer_size = 64; +using timer = std::chrono::high_resolution_clock; +static std::array interrupt_log_buffer; +static size_t interrupt_log_buffer_index = 0; + static void interrupt_hook (lua_State *L, lua_Debug *ar) { + interrupt_log_buffer[interrupt_log_buffer_index] = timer::now(); + interrupt_log_buffer_index = (interrupt_log_buffer_index + 1) % interrupt_log_buffer_size; + if (lstop) { lstop = 0; @@ -518,6 +527,24 @@ static void interrupt_hook (lua_State *L, lua_Debug *ar) } } +static int dfhack_dump_interrupt_timing_data(lua_State* L) +{ + int base = lua_gettop(L); + lua_createtable(L, interrupt_log_buffer_size-1, 0); + auto last = timer::now(); + for (auto idx = 0; idx < interrupt_log_buffer_size; idx++) + { + auto i = (idx + interrupt_log_buffer_index) % interrupt_log_buffer_size; + auto dur = interrupt_log_buffer[i] - last; + last = interrupt_log_buffer[i]; + if (idx>0) { + lua_pushinteger(L, std::chrono::duration_cast(dur).count()); + lua_rawseti(L, base + 1, idx); + } + } + return 1; +} + bool DFHack::Lua::Interrupt (bool force) { lua_State *L = DFHack::Core::getInstance().getLuaState(true); @@ -810,10 +837,26 @@ static int dfhack_safecall (lua_State *L) return Lua::TailPCallK(L, lua_gettop(L) - 2, LUA_MULTRET, 1, 0); } +// The lua_State through which this thread is currently executing a DFHack +// Lua entry point, or nullptr. Consulted by the crash reporter. +static thread_local lua_State *t_active_state = nullptr; + +lua_State *DFHack::Crashlog::active_state() { + return t_active_state; +} + +lua_State *DFHack::Crashlog::set_active_state(lua_State *state) { + lua_State *old = t_active_state; + t_active_state = state; + return old; +} + bool DFHack::Lua::SafeCall(color_ostream &out, lua_State *L, int nargs, int nres, bool perr) { AssertCoreSuspend(L); + Crashlog::ActiveStateGuard lua_guard(L); + int base = lua_gettop(L) - nargs; color_ostream *cur_out = Lua::GetOutput(L); @@ -975,6 +1018,8 @@ int DFHack::Lua::SafeResume(color_ostream &out, lua_State *from, lua_State *thre { AssertCoreSuspend(from); + Crashlog::ActiveStateGuard lua_guard(thread ? thread : from); + color_ostream *cur_out = Lua::GetOutput(from); set_dfhack_output(from, &out); @@ -1281,29 +1326,30 @@ bool DFHack::Lua::RunCoreQueryLoop(color_ostream &out, lua_State *state, DFHack: return (rv == LUA_OK); } -static bool init_interpreter(color_ostream &out, lua_State *state, const char* prompt, const char* hfile) +static bool init_interpreter(color_ostream &out, lua_State *state, std::string_view prompt, const std::filesystem::path& hfile) { lua_rawgetp(state, LUA_REGISTRYINDEX, &DFHACK_DFHACK_TOKEN); lua_getfield(state, -1, "interpreter"); lua_remove(state, -2); - lua_pushstring(state, prompt); - lua_pushstring(state, hfile); + lua_pushlstring(state, prompt.data(), prompt.size()); + lua_pushlstring(state, hfile.string().data(), hfile.string().size()); return true; } bool DFHack::Lua::InterpreterLoop(color_ostream &out, lua_State *state, - const char *prompt, const char *hfile) + std::string_view prompt, std::filesystem::path hfile) { if (!out.is_console()) return false; - if (!hfile) - hfile = "dfhack-config/lua.history"; - if (!prompt) + if (hfile.empty()) + hfile = DFHack::Core::getInstance().getConfigPath() / "lua.history"; + if (prompt.empty()) prompt = "lua"; - using namespace std::placeholders; - auto init_fn = std::bind(init_interpreter, _1, _2, prompt, hfile); + auto init_fn = [&](color_ostream& out, lua_State* state) { + return init_interpreter(out, state, prompt, hfile); + }; return RunCoreQueryLoop(out, state, init_fn); } @@ -1497,6 +1543,7 @@ static const luaL_Reg dfhack_funcs[] = { { "with_suspend", lua_dfhack_with_suspend }, { "open_plugin", dfhack_open_plugin }, { "curry", dfhack_curry }, + { "dump_interrupt_timing_data", dfhack_dump_interrupt_timing_data }, { NULL, NULL } }; @@ -1989,6 +2036,38 @@ int dfhack_timeout(lua_State *L) return 1; } +int dfhack_timeout_until(lua_State *L) +{ + using df::global::world; + + // Parse arguments + lua_Number tick = luaL_checknumber(L, 1); + luaL_checktype(L, 2, LUA_TFUNCTION); + lua_settop(L, 2); + + if (!Core::getInstance().isWorldLoaded()) + { + lua_pushnil(L); + return 1; + } + + int delta = tick - world->frame_counter; + + if (delta <= 0) + luaL_error(L, "Invalid timeout: %d", delta); + + // Queue the timeout + int id = next_timeout_id++; + tick_timers.insert(std::pair(tick, id)); + + lua_rawgetp(L, LUA_REGISTRYINDEX, &DFHACK_TIMEOUTS_TOKEN); + lua_swap(L); + lua_rawseti(L, -2, id); + + lua_pushinteger(L, id); + return 1; +} + int dfhack_timeout_active(lua_State *L) { int id = luaL_optint(L, 1, -1); @@ -2100,6 +2179,8 @@ static void Lua::Core::InitCoreContext(color_ostream &out) lua_pushcfunction(State, dfhack_timeout); lua_setfield(State, -2, "timeout"); + lua_pushcfunction(State, dfhack_timeout_until); + lua_setfield(State, -2, "timeout_until"); lua_pushcfunction(State, dfhack_timeout_active); lua_setfield(State, -2, "timeout_active"); diff --git a/library/LuaTools.test.cpp b/library/LuaTools.test.cpp new file mode 100644 index 0000000000..304ebf8899 --- /dev/null +++ b/library/LuaTools.test.cpp @@ -0,0 +1,74 @@ +// Regression test for https://github.com/DFHack/dfhack/issues/5450 +// (fixed by https://github.com/DFHack/dfhack/pull/5451) +// +// df::identity_traits::get() returns a pointer to a *const* instance of +// a type_identity subclass for most types. When the df_object constraint +// required a pointer to non-const type_identity, Lua::Push rejected those +// types and fell back to the bool overload, so e.g. a pointer to a vector +// arrived in Lua as a boolean. + +#include "LuaTools.h" +#include "LuaWrapper.h" + +#include +#include + +#include +#include + +// The constraint must accept types whose identity is exposed through a +// pointer-to-const. This is the compile-time half of the regression check: +// it fails to build if df_object ever becomes too strict again. +static_assert(DFHack::Lua::df_object>); +static_assert(DFHack::Lua::df_object); +static_assert(DFHack::Lua::df_object); + +class LuaPushTest : public testing::Test { +protected: + lua_State *L = nullptr; + + void SetUp() override + { + L = luaL_newstate(); + ASSERT_NE(nullptr, L); + luaL_openlibs(L); + DFHack::LuaWrapper::AttachDFGlobals(L); + } + + void TearDown() override + { + if (L) + lua_close(L); + } +}; + +TEST_F(LuaPushTest, PushVectorPointer) +{ + std::vector vec{ 7, 42 }; + + // NB: intentionally avoids df_object-constrained calls below so that + // this test still compiles if the constraint regresses - in that case + // Push() falls back to the bool overload and the checks fail at runtime + DFHack::Lua::Push(L, &vec); + + // the pointer must arrive as a wrapped DF object, not a boolean + ASSERT_EQ(LUA_TUSERDATA, lua_type(L, -1)); + ASSERT_EQ(DFHack::Lua::OBJ_REF, DFHack::Lua::IsDFObject(L, -1)); + + // the same pointer must round-trip back out of Lua + EXPECT_EQ(static_cast(&vec), + DFHack::Lua::GetDFObject(L, df::identity_traits>::get(), -1)); + + // and the pushed object must behave like the vector it wraps + lua_len(L, -1); + EXPECT_EQ(lua_Integer(vec.size()), lua_tointeger(L, -1)); + lua_pop(L, 1); + + lua_geti(L, -1, 0); + EXPECT_EQ(7, lua_tointeger(L, -1)); + lua_pop(L, 1); + + lua_geti(L, -1, 1); + EXPECT_EQ(42, lua_tointeger(L, -1)); + lua_pop(L, 1); +} diff --git a/library/LuaTypes.cpp b/library/LuaTypes.cpp index 0f990a6b84..45d1f8e8e3 100644 --- a/library/LuaTypes.cpp +++ b/library/LuaTypes.cpp @@ -115,88 +115,14 @@ void enum_identity::lua_write(lua_State *state, int fname_idx, void *ptr, int va base_type->lua_write(state, fname_idx, ptr, val_index); } -void df::integer_identity_base::lua_read(lua_State *state, int fname_idx, void *ptr) const -{ - lua_pushinteger(state, read(ptr)); -} - -void df::integer_identity_base::lua_write(lua_State *state, int fname_idx, void *ptr, int val_index) const -{ - int is_num = 0; - auto value = lua_tointegerx(state, val_index, &is_num); - if (!is_num) - field_error(state, fname_idx, "integer expected", "write"); - write(ptr, value); -} - -void df::float_identity_base::lua_read(lua_State *state, int fname_idx, void *ptr) const -{ - lua_pushnumber(state, read(ptr)); -} - -void df::float_identity_base::lua_write(lua_State *state, int fname_idx, void *ptr, int val_index) const -{ - if (!lua_isnumber(state, val_index)) - field_error(state, fname_idx, "number expected", "write"); - - write(ptr, lua_tonumber(state, val_index)); -} - -void df::bool_identity::lua_read(lua_State *state, int fname_idx, void *ptr) const -{ - lua_pushboolean(state, *(bool*)ptr); -} - -void df::bool_identity::lua_write(lua_State *state, int fname_idx, void *ptr, int val_index) const -{ - char *pb = (char*)ptr; - - if (lua_isboolean(state, val_index) || lua_isnil(state, val_index)) - *pb = lua_toboolean(state, val_index); - else if (lua_isnumber(state, val_index)) - *pb = lua_tointeger(state, val_index); - else - field_error(state, fname_idx, "boolean or number expected", "write"); -} - -void df::ptr_string_identity::lua_read(lua_State *state, int fname_idx, void *ptr) const -{ - auto pstr = (char**)ptr; - if (*pstr) - lua_pushstring(state, *pstr); - else - lua_pushnil(state); -} - -void df::ptr_string_identity::lua_write(lua_State *state, int fname_idx, void *ptr, int val_index) const -{ - field_error(state, fname_idx, "raw pointer string", "write"); -} - -void df::stl_string_identity::lua_read(lua_State *state, int fname_idx, void *ptr) const -{ - auto pstr = (std::string*)ptr; - lua_pushlstring(state, pstr->data(), pstr->size()); -} - -void df::stl_string_identity::lua_write(lua_State *state, int fname_idx, void *ptr, int val_index) const -{ - size_t size; - const char *bytes = lua_tolstring(state, val_index, &size); - if (!bytes) - field_error(state, fname_idx, "string expected", "write"); - - *(std::string*)ptr = std::string(bytes, size); -} - -void df::path_identity::lua_read(lua_State* state, int fname_idx, void* ptr) const +void DFHack::lua_read_path(lua_State* state, void* ptr) { auto ppath = (std::filesystem::path*)ptr; auto str = DFHack::Filesystem::as_string(*ppath); lua_pushlstring(state, (char*)str.data(), str.size()); } -void df::path_identity::lua_write(lua_State* state, int fname_idx, void* ptr, int val_index) const +void DFHack::lua_write_path(lua_State* state, int fname_idx, void* ptr, int val_index) { size_t size; const char* bytes = lua_tolstring(state, val_index, &size); @@ -207,12 +133,12 @@ void df::path_identity::lua_write(lua_State* state, int fname_idx, void* ptr, in *(std::filesystem::path*)ptr = std::filesystem::path(str); } -void df::pointer_identity::lua_read(lua_State *state, int fname_idx, void *ptr, const type_identity *target) +void df::pointer_identity_base::lua_read(lua_State *state, int fname_idx, void *ptr, const type_identity *target) { push_object_internal(state, target, *(void**)ptr); } -void df::pointer_identity::lua_read(lua_State *state, int fname_idx, void *ptr) const +void df::pointer_identity_base::lua_read(lua_State *state, int fname_idx, void *ptr) const { lua_read(state, fname_idx, ptr, target); } @@ -276,7 +202,7 @@ static bool is_null(lua_State *state, int val_index) !lua_touserdata(state, val_index)); } -void df::pointer_identity::lua_write(lua_State *state, int fname_idx, void *ptr, +void df::pointer_identity_base::lua_write(lua_State *state, int fname_idx, void *ptr, const type_identity *target, int val_index) { auto pptr = (void**)ptr; @@ -300,7 +226,7 @@ void df::pointer_identity::lua_write(lua_State *state, int fname_idx, void *ptr, } } -void df::pointer_identity::lua_write(lua_State *state, int fname_idx, void *ptr, int val_index) const +void df::pointer_identity_base::lua_write(lua_State *state, int fname_idx, void *ptr, int val_index) const { lua_write(state, fname_idx, ptr, target, val_index); } @@ -373,14 +299,14 @@ void ptr_container_identity::lua_item_read(lua_State *state, int fname_idx, void { auto id = (type_identity*)lua_touserdata(state, UPVAL_ITEM_ID); void *pitem = item_pointer(&df::identity_traits::identity, ptr, idx); - df::pointer_identity::lua_read(state, fname_idx, pitem, id); + df::pointer_identity_base::lua_read(state, fname_idx, pitem, id); } void ptr_container_identity::lua_item_write(lua_State *state, int fname_idx, void *ptr, int idx, int val_index) const { auto id = (type_identity*)lua_touserdata(state, UPVAL_ITEM_ID); void *pitem = item_pointer(&df::identity_traits::identity, ptr, idx); - df::pointer_identity::lua_write(state, fname_idx, pitem, id, val_index); + df::pointer_identity_base::lua_write(state, fname_idx, pitem, id, val_index); } bool ptr_container_identity::lua_insert2(lua_State *state, int fname_idx, void *ptr, int idx, int val_index) const @@ -388,7 +314,7 @@ bool ptr_container_identity::lua_insert2(lua_State *state, int fname_idx, void * auto id = (type_identity*)lua_touserdata(state, UPVAL_ITEM_ID); void *pitem = NULL; - df::pointer_identity::lua_write(state, fname_idx, &pitem, id, val_index); + df::pointer_identity_base::lua_write(state, fname_idx, &pitem, id, val_index); return insert(ptr, idx, pitem); } @@ -522,7 +448,7 @@ static void read_field(lua_State *state, const struct_field_info *field, void *p return; case struct_field_info::POINTER: - df::pointer_identity::lua_read(state, 2, ptr, field->type); + df::pointer_identity_base::lua_read(state, 2, ptr, field->type); return; case struct_field_info::CONTAINER: @@ -608,7 +534,7 @@ static void write_field(lua_State *state, const struct_field_info *field, void * return; case struct_field_info::POINTER: - df::pointer_identity::lua_write(state, 2, ptr, field->type, value_idx); + df::pointer_identity_base::lua_write(state, 2, ptr, field->type, value_idx); return; case struct_field_info::STATIC_ARRAY: @@ -1771,7 +1697,7 @@ void other_vectors_identity::build_metatable(lua_State *state) const SetPtrMethods(state, base+1, base+2); } -void global_identity::build_metatable(lua_State *state) const +void global_identity_base::build_metatable(lua_State *state) const { int base = lua_gettop(state); MakeFieldMetatable(state, this, meta_global_index, meta_global_newindex, meta_struct_next, true); @@ -1840,12 +1766,12 @@ void LuaWrapper::push_adhoc_pointer(lua_State *state, void *ptr, const type_iden { /* * HACK: relies on - * 1) pointer_identity destructor being no-op + * 1) pointer_identity_base destructor being no-op * 2) lua gc never moving objects in memory */ - void *newobj = lua_newuserdata(state, sizeof(pointer_identity)); - id = new (newobj) pointer_identity(target); + void *newobj = lua_newuserdata(state, sizeof(pointer_identity_base)); + id = new (newobj) pointer_identity_base(target); SaveInTable(state, const_cast(target), &DFHACK_PTR_IDTABLE_TOKEN); lua_pop(state, 1); diff --git a/library/LuaWrapper.cpp b/library/LuaWrapper.cpp index 9f0ecab738..0d30429297 100644 --- a/library/LuaWrapper.cpp +++ b/library/LuaWrapper.cpp @@ -35,6 +35,8 @@ distribution. // must be last due to MS stupidity #include "DataDefs.h" #include "DataIdentity.h" +#include "Debug.h" +#include "HashUtil.h" #include "LuaWrapper.h" #include "LuaTools.h" @@ -46,6 +48,11 @@ distribution. using namespace DFHack; using namespace DFHack::LuaWrapper; +namespace DFHack +{ + DBG_DECLARE(lua, deprecation, DebugCategory::LWARNING); +} + /** * Report an error while accessing a field (index = field name). */ @@ -276,8 +283,8 @@ bool LuaWrapper::is_type_compatible(lua_State *state, const type_identity *type1 { case IDTYPE_POINTER: return is_type_compatible(state, - ((const pointer_identity*)type1)->getTarget(), 0, - ((const pointer_identity*)type2)->getTarget(), 0, + ((const pointer_identity_base*)type1)->getTarget(), 0, + ((const pointer_identity_base*)type2)->getTarget(), 0, exact_equal); break; @@ -297,8 +304,8 @@ bool LuaWrapper::is_type_compatible(lua_State *state, const type_identity *type1 case IDTYPE_STL_PTR_VECTOR: { - auto b1 = (const df::stl_ptr_vector_identity*)type1; - auto b2 = (const df::stl_ptr_vector_identity*)type2; + auto b1 = (const df::ptr_container_identity*)type1; + auto b2 = (const df::ptr_container_identity*)type2; const type_identity *item1 = b1->getItemType(), *item2 = b2->getItemType(); fetch_container_details(state, meta1, &item1, NULL); @@ -314,6 +321,8 @@ bool LuaWrapper::is_type_compatible(lua_State *state, const type_identity *type1 auto b1 = (struct_identity*)type1; auto b2 = (struct_identity*)type2; + if (b1->is_equivalent(b2)) return true; + return (!exact_equal && b1->is_subclass(b2)); } @@ -373,7 +382,9 @@ void *LuaWrapper::get_object_internal(lua_State *state, const type_identity *typ if (!LookupTypeInfo(state, in_method)) // metatable -> type? return NULL; - if (type && lua_touserdata(state, -1) != type) + type_identity* othertype = static_cast(lua_touserdata(state, -1)); + + if (type && othertype != type) { /* * If valid but different type, do an intelligent comparison. @@ -1044,20 +1055,12 @@ static int meta_ptr_tostring(lua_State *state) } /** - * Metamethod: __index for enum.attrs + * Push the attr entry for the given enum item value. + * Out-of-range values resolve to the default entry. */ -static int meta_enum_attr_index(lua_State *state) +static void push_enum_attr(lua_State *state, enum_identity *id, int64_t idx) { - if (!lua_isnumber(state, 2)) - lua_rawget(state, UPVAL_FIELDTABLE); - if (!lua_isnumber(state, 2)) - luaL_error(state, "Invalid index in enum.attrs[]"); - - auto id = (enum_identity*)lua_touserdata(state, lua_upvalueindex(2)); - auto *complex = id->getComplex(); - - int64_t idx = lua_tonumber(state, 2); - if (complex) + if (auto *complex = id->getComplex()) { auto it = complex->value_index_map.find(idx); if (it != complex->value_index_map.end()) @@ -1076,6 +1079,20 @@ static int meta_enum_attr_index(lua_State *state) auto atype = id->getAttrType(); push_object_internal(state, atype, ptr + unsigned(atype->byte_size()*idx)); +} + +/** + * Metamethod: __index for enum.attrs + */ +static int meta_enum_attr_index(lua_State *state) +{ + if (!lua_isnumber(state, 2)) + lua_rawget(state, UPVAL_FIELDTABLE); + if (!lua_isnumber(state, 2)) + luaL_error(state, "Invalid index in enum.attrs[]"); + + auto id = (enum_identity*)lua_touserdata(state, lua_upvalueindex(2)); + push_enum_attr(state, id, lua_tonumber(state, 2)); return 1; } @@ -1451,6 +1468,83 @@ static int complex_enum_ipairs(lua_State *L) return 3; } +/* + * enum.attrs ipairs() support + * + * enum.attrs[] returns a default entry for out-of-range indexes, so a plain + * ipairs() would never terminate. These iterators stop at the same place as + * ipairs() on the enum itself (DFHack/dfhack#1860). + * + * upvalues for the *_ipairs functions: + * 1: enum_identity + * 2: enum_identity::ComplexData (complex enums only) + * + * upvalues for the *_inext iterators (UPVAL_TYPETABLE is required by + * push_object_internal): + * 1: DFHACK_TYPETABLE + * 2: enum_identity (plain) or enum_identity::ComplexData (complex) + * 3: enum_identity (complex only) + */ + +static int meta_enum_attr_inext(lua_State *L) +{ + auto id = (enum_identity*)lua_touserdata(L, lua_upvalueindex(2)); + int64_t i = luaL_checkint(L, 2) + 1; + if (i <= id->getLastItem()) + { + lua_pushinteger(L, i); + push_enum_attr(L, id, i); + return 2; + } + else + { + lua_pushnil(L); + return 1; + } +} + +static int meta_enum_attr_ipairs(lua_State *L) +{ + auto id = (enum_identity*)lua_touserdata(L, lua_upvalueindex(1)); + lua_rawgetp(L, LUA_REGISTRYINDEX, &DFHACK_TYPETABLE_TOKEN); + lua_pushvalue(L, lua_upvalueindex(1)); + lua_pushcclosure(L, meta_enum_attr_inext, 2); + lua_pushnil(L); + lua_pushinteger(L, id->getFirstItem() - 1); + return 3; +} + +static int complex_enum_attr_inext(lua_State *L) +{ + bool is_first = lua_isuserdata(L, 2); + int64_t i = (is_first) + ? ((enum_identity::ComplexData*)lua_touserdata(L, lua_upvalueindex(2)))->index_value_map[0] + : luaL_checkint(L, 2); + if (is_first || complex_enum_next_item_helper(L, i)) + { + auto id = (enum_identity*)lua_touserdata(L, lua_upvalueindex(3)); + lua_pushinteger(L, i); + push_enum_attr(L, id, i); + return 2; + } + else + { + lua_pushnil(L); + return 1; + } +} + +static int complex_enum_attr_ipairs(lua_State *L) +{ + lua_rawgetp(L, LUA_REGISTRYINDEX, &DFHACK_TYPETABLE_TOKEN); + lua_pushvalue(L, lua_upvalueindex(2)); + lua_pushvalue(L, lua_upvalueindex(1)); + lua_pushcclosure(L, complex_enum_attr_inext, 3); + lua_pushnil(L); + lua_pushlightuserdata(L, (void*)1); + return 3; +} + static void RenderTypeChildren(lua_State *state, const std::vector &children); @@ -1551,7 +1645,21 @@ static void FillEnumKeys(lua_State *state, int ix_meta, int ftable, enum_identit lua_pushvalue(state, base+1); lua_pushcclosure(state, meta_enum_attr_index, 3); - freeze_table(state, false, (eid->getFullName()+".attrs").c_str()); + freeze_table(state, true, (eid->getFullName()+".attrs").c_str()); + + lua_pushlightuserdata(state, eid); + if (complex) + { + lua_pushlightuserdata(state, (void*)complex); + lua_pushcclosure(state, complex_enum_attr_ipairs, 2); + } + else + { + lua_pushcclosure(state, meta_enum_attr_ipairs, 1); + } + lua_setfield(state, -2, "__ipairs"); + lua_pop(state, 1); + lua_setfield(state, ftable, "attrs"); } @@ -1865,6 +1973,51 @@ void LuaWrapper::AttachDFGlobals(lua_State *state) lua_pop(state, 1); } +namespace +{ + struct deprecation_notification + { + std::string name; + std::string source; + int line; + + bool operator==(const deprecation_notification& other) const = default; + + struct hash + { + std::size_t operator()(const deprecation_notification& n) const noexcept + { + return hash_value(n.name, n.source, n.line); + } + }; + }; + + static std::unordered_set deprecation_notification_history; +} + +void LuaWrapper::notify_deprecated(lua_State* state, std::string_view name, std::string_view message) +{ + lua_Debug ar; + std::optional notify; + + for (int depth = 0; lua_getstack(state, depth, &ar); depth++) + { + if (lua_getinfo(state, "nSl", &ar) && std::strcmp(ar.what, "Lua") == 0) + { + notify = deprecation_notification{std::string(name),ar.short_src,ar.currentline}; + break; + } + } + + if (!notify) return; + + if (!deprecation_notification_history.contains(*notify)) + { + WARN(deprecation).print("Deprecated function {} called from {} at line {}: {}\n", notify->name, notify->source, notify->line, message); + deprecation_notification_history.insert(*notify); + } +} + namespace DFHack { namespace LuaWrapper { struct LuaToken { int reserved; }; diff --git a/library/MiscUtils.cpp b/library/MiscUtils.cpp index 082657ea34..d91471803b 100644 --- a/library/MiscUtils.cpp +++ b/library/MiscUtils.cpp @@ -22,39 +22,49 @@ must not be misrepresented as being the original software. distribution. */ -#include "Internal.h" #include "Export.h" #include "MiscUtils.h" #include "ColorText.h" -#include "modules/DFSDL.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include -#ifndef LINUX_BUILD -// We don't want min and max macros +#ifdef WIN32 #define NOMINMAX - #include - // Suppress warning which occurs in header on some WinSDK versions - // See dfhack/dfhack#5147 for more information - #pragma warning(push) - #pragma warning(disable:4091) - #include - #pragma warning(pop) -#else - #include - #include - #include +#define WIN32_LEAN_AND_MEAN +#include +#include +// Suppress warning which occurs in header on some WinSDK versions +// See dfhack/dfhack#5147 for more information +#pragma warning(push) +#pragma warning(disable:4091) +#include +#pragma warning(pop) #endif -#include -#include -#include -#include -#include - -#include -#include -#include -#include +#ifdef LINUX_BUILD +#include +#include +#include +#endif NumberFormatType preferred_number_format_type = NumberFormatType::DEFAULT; @@ -499,8 +509,8 @@ uint64_t GetTimeMs64() /* Character decoding */ // See http://bjoern.hoehrmann.de/utf-8/decoder/dfa/ for details. -#define UTF8_ACCEPT 0 -#define UTF8_REJECT 12 +constexpr auto UTF8_ACCEPT = 0; +constexpr auto UTF8_REJECT = 12; static const uint8_t utf8d[] = { // The first part of the table maps bytes to character classes that @@ -653,20 +663,31 @@ std::string UTF2DF(const std::string &in) out.resize(pos); return out; } - -DFHACK_EXPORT std::string DF2CONSOLE(const std::string &in) +static bool console_is_utf8() { - bool is_utf = false; #ifdef LINUX_BUILD + static bool checked = false; + static bool is_utf = false; + if (checked) + return is_utf; + std::string locale = ""; if (getenv("LANG")) locale += getenv("LANG"); if (getenv("LC_CTYPE")) locale += getenv("LC_CTYPE"); locale = toUpper_cp437(locale); - is_utf = (locale.find("UTF-8") != std::string::npos) || - (locale.find("UTF8") != std::string::npos); + is_utf = (locale.find("UTF-8") != std::string::npos) || (locale.find("UTF8") != std::string::npos); + checked = true; + return is_utf; +#else + return true; // Since DF 53.11, Windows console is always UTF-8 #endif +} + +DFHACK_EXPORT std::string DF2CONSOLE(const std::string &in) +{ + bool is_utf = console_is_utf8(); return is_utf ? DF2UTF(in) : in; } diff --git a/library/PlugLoad.cpp b/library/PlugLoad.cpp index 336e7f50e7..d3b7560773 100644 --- a/library/PlugLoad.cpp +++ b/library/PlugLoad.cpp @@ -1,24 +1,18 @@ -#include "Core.h" #include "Debug.h" -#include "Export.h" #include "PluginManager.h" -#include "Hooks.h" #include -#include #include -#include - -#include -#include #ifdef WIN32 #define NOMINMAX +#define WIN32_LEAN_AND_MEAN #include +#include #define global_search_handle() GetModuleHandle(nullptr) #define get_function_address(plugin, function) GetProcAddress((HMODULE)plugin, function) #define clear_error() -#define load_library(fn) LoadLibraryW(fn.c_str()) +#define load_library(fn) LoadLibraryExW(fn.wstring().c_str(), NULL, LOAD_LIBRARY_SEARCH_DEFAULT_DIRS); #define close_library(handle) (!(FreeLibrary((HMODULE)handle))) #else #include diff --git a/library/PluginManager.cpp b/library/PluginManager.cpp index b7e0b2c3c4..5052b93637 100644 --- a/library/PluginManager.cpp +++ b/library/PluginManager.cpp @@ -22,36 +22,46 @@ must not be misrepresented as being the original software. distribution. */ -#include "modules/EventManager.h" -#include "modules/Filesystem.h" -#include "modules/Screen.h" -#include "modules/World.h" -#include "Internal.h" +#include "PluginManager.h" + +#include "ColorText.h" #include "Core.h" +#include "CoreDefs.h" +#include "Format.h" +#include "LuaWrapper.h" +#include "LuaTools.h" #include "MemAccess.h" -#include "PluginManager.h" +#include "MiscUtils.h" #include "RemoteServer.h" -#include "Console.h" #include "Types.h" #include "VersionInfo.h" -#include "DataDefs.h" -#include "MiscUtils.h" -#include "DFHackVersion.h" - -#include "LuaWrapper.h" -#include "LuaTools.h" - -using namespace DFHack; +#include "modules/EventManager.h" +#include "modules/Filesystem.h" +#include "modules/Screen.h" +#include "modules/World.h" +#include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include -#include -using std::string; +#include "df/viewscreen.h" + +#include +#include -#include +using namespace DFHack; +using std::string; #if defined(_LINUX) static const string plugin_suffix = ".plug.so"; @@ -484,6 +494,19 @@ command_result Plugin::invoke(color_ostream &out, const std::string & command, s if (auto cmdIt = std::ranges::find_if(commands, [&](const PluginCommand &cmd) { return cmd.name == command; }); commands.end() != cmdIt) { + // an uncaught exception from a plugin command would propagate + // through runCommand and terminate the game; report it instead + auto call = [&]() -> command_result { + try { + return cmdIt->function(out, parameters); + } catch (const std::exception &e) { + out.printerr("Exception in {} command '{}': {}\n", name, command, e.what()); + return CR_FAILURE; + } catch (...) { + out.printerr("Exception in {} command '{}'\n", name, command); + return CR_FAILURE; + } + }; // running interactive things from some other source than the console would break it if (!out.is_console() && cmdIt->interactive) cr = CR_NEEDS_CONSOLE; @@ -494,15 +517,15 @@ command_result Plugin::invoke(color_ostream &out, const std::string & command, s cr = CR_WRONG_USAGE; } else { - cr = cmdIt->function(out, parameters); + cr = call(); } } else if (cmdIt->unlocked) { - cr = cmdIt->function(out, parameters); + cr = call(); } else { CoreSuspender suspend; - cr = cmdIt->function(out, parameters); + cr = call(); } } } @@ -871,7 +894,7 @@ void PluginManager::init() loadAll(); bool any_loaded = false; - for (auto p : all_plugins) + for (auto& p : all_plugins) { if (p.second->getState() == Plugin::PS_LOADED) { diff --git a/library/Process.cpp b/library/Process.cpp index 3e3ba6db80..1ff63622f0 100644 --- a/library/Process.cpp +++ b/library/Process.cpp @@ -22,22 +22,30 @@ must not be misrepresented as being the original software. distribution. */ -#ifndef WIN32 -#ifndef _DARWIN -#include -#endif /* ! _DARWIN */ -#endif /* ! WIN32 */ +#include "Format.h" +#include "MemAccess.h" +#include "Memory.h" +#include "MemoryPatcher.h" +#include "MiscUtils.h" +#include "VersionInfo.h" +#include "VersionInfoFactory.h" + +#include "modules/Filesystem.h" + +#include +#include +#include #include -#include +#include +#include +#include #include -#include +#include +#include #include #include -#include - -#include "Format.h" -#ifndef WIN32 +#ifdef LINUX_BUILD #include #include #include @@ -53,28 +61,24 @@ distribution. #include #include #endif /* _DARWIN */ -#endif /* ! WIN32 */ -#include "Error.h" -#include "Internal.h" -#include "MemAccess.h" -#include "Memory.h" -#include "MemoryPatcher.h" -#include "MiscUtils.h" -#include "VersionInfo.h" -#include "VersionInfoFactory.h" -#include "modules/Filesystem.h" - -#ifndef WIN32 #include "md5wrapper.h" -#else /* WIN32 */ +#endif /* LINUX_BUILD */ + +#ifdef WIN32 #define _WIN32_WINNT 0x0600 #define WINVER 0x0600 + +#define NOMINMAX #define WIN32_LEAN_AND_MEAN #include #include +#include +#include +#include + +#include -#include #endif /* WIN32 */ using namespace DFHack; @@ -151,7 +155,7 @@ Process::Process(const VersionInfoFactory& known_versions) : identified(false) uint32_t pe_offset = readDWord(d->base + 0x3C); read(d->base + pe_offset, sizeof(d->pe_header), (uint8_t*)&(d->pe_header)); const size_t sectionsSize = sizeof(IMAGE_SECTION_HEADER) * d->pe_header.FileHeader.NumberOfSections; - d->sections = (IMAGE_SECTION_HEADER*)malloc(sectionsSize); + d->sections = (IMAGE_SECTION_HEADER*)std::malloc(sectionsSize); read(d->base + pe_offset + sizeof(d->pe_header), sectionsSize, (uint8_t*)(d->sections)); } catch (std::exception&) @@ -664,7 +668,7 @@ uint32_t Process::getTickCount() #endif /* WIN32 */ } -std::filesystem::path Process::getPath() +[[deprecated]] std::filesystem::path Process::getPath() { #if defined(WIN32) || !defined(_DARWIN) return Filesystem::get_initial_cwd(); diff --git a/library/RemoteClient.cpp b/library/RemoteClient.cpp index b159843761..c917a3232a 100644 --- a/library/RemoteClient.cpp +++ b/library/RemoteClient.cpp @@ -36,24 +36,25 @@ POSSIBILITY OF SUCH DAMAGE. */ -#include -#include -#include +#include +#include +#include +#include +#include +#include #include #include -#include #include -#include +#include +#include "ColorText.h" +#include "CoreDefs.h" #include "RemoteClient.h" -#include -#include "MiscUtils.h" -#include -#include -#include +#include "ActiveSocket.h" +#include "Host.h" +#include "SimpleSocket.h" -#include #include "json/json.h" diff --git a/library/RemoteServer.cpp b/library/RemoteServer.cpp index 9557a15862..aa3deb201d 100644 --- a/library/RemoteServer.cpp +++ b/library/RemoteServer.cpp @@ -35,27 +35,36 @@ ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ - -#include -#include -#include +#ifdef WIN32 +#define NOMINMAX +#endif +#include +#include +#include +#include +#include +#include #include +#include #include -#include #include -#include +#include +#include + +#include "ColorText.h" +#include "Core.h" +#include "CoreDefs.h" +#include "Debug.h" +#include "MiscUtils.h" +#include "PluginManager.h" +#include "ActiveSocket.h" +#include "Host.h" +#include "RemoteClient.h" #include "RemoteServer.h" #include "RemoteTools.h" - #include "PassiveSocket.h" -#include "PluginManager.h" -#include "MiscUtils.h" -#include "Debug.h" - -#include -#include -#include +#include "SimpleSocket.h" #include #include diff --git a/library/RemoteTools.cpp b/library/RemoteTools.cpp index dbef7a9906..30e24c51b5 100644 --- a/library/RemoteTools.cpp +++ b/library/RemoteTools.cpp @@ -35,57 +35,58 @@ ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ - -#include -#include -#include -#include -#include -#include -#include -#include - #include "RemoteTools.h" -#include "PluginManager.h" + +#include "ColorText.h" +#include "Core.h" +#include "CoreDefs.h" +#include "DataDefs.h" +#include "DFHackVersion.h" +#include "LuaTools.h" #include "MiscUtils.h" +#include "PluginManager.h" #include "VersionInfo.h" -#include "DFHackVersion.h" #include "modules/Materials.h" #include "modules/Translation.h" #include "modules/Units.h" #include "modules/World.h" -#include "LuaTools.h" - -#include "DataDefs.h" -#include "df/plotinfost.h" #include "df/adventurest.h" -#include "df/world.h" -#include "df/world_data.h" -#include "df/unit.h" -#include "df/unit_misc_trait.h" -#include "df/unit_soul.h" -#include "df/unit_skill.h" +#include "df/creature_raw.h" +#include "df/global_objects.h" +#include "df/historical_entity.h" +#include "df/historical_figure.h" +#include "df/incident.h" +#include "df/inorganic_raw.h" +#include "df/language_name.h" #include "df/material.h" #include "df/matter_state.h" -#include "df/inorganic_raw.h" -#include "df/creature_raw.h" -#include "df/plant_raw.h" #include "df/nemesis_record.h" -#include "df/historical_figure.h" -#include "df/historical_entity.h" -#include "df/squad.h" +#include "df/plant_raw.h" +#include "df/plotinfost.h" +#include "df/profession.h" #include "df/squad_position.h" -#include "df/incident.h" +#include "df/squad.h" +#include "df/unit_misc_trait.h" +#include "df/unit_skill.h" +#include "df/unit_soul.h" +#include "df/unit.h" +#include "df/world_data.h" +#include "df/world.h" #include "BasicApi.pb.h" +#include #include #include -#include - +#include +#include +#include #include +#include +#include +#include using namespace DFHack; using namespace df::enums; @@ -529,7 +530,7 @@ static command_result ListMaterials(color_ostream &stream, if (in->builtin()) { - for (int i = 0; i < MaterialInfo::NUM_BUILTIN; i++) + for (int i = 0; i < df::builtin_mats::CREATURE_1; i++) listMaterial(out, i, -1, mask); } @@ -548,7 +549,7 @@ static command_result ListMaterials(color_ostream &stream, auto praw = vec[i]; for (size_t j = 0; j < praw->material.size(); j++) - listMaterial(out, MaterialInfo::CREATURE_BASE+j, i, mask); + listMaterial(out, df::builtin_mats::CREATURE_1+j, i, mask); } } @@ -560,7 +561,7 @@ static command_result ListMaterials(color_ostream &stream, auto praw = vec[i]; for (size_t j = 0; j < praw->material.size(); j++) - listMaterial(out, MaterialInfo::PLANT_BASE+j, i, mask); + listMaterial(out, df::builtin_mats::PLANT_1+j, i, mask); } } diff --git a/library/Types.cpp b/library/Types.cpp index 1dab657c1c..21437723f1 100644 --- a/library/Types.cpp +++ b/library/Types.cpp @@ -22,24 +22,20 @@ must not be misrepresented as being the original software. distribution. */ -#include "Internal.h" -#include "Export.h" #include "MiscUtils.h" -#include "Error.h" #include "Types.h" #include "modules/Filesystem.h" #include "df/general_ref.h" +#include "df/general_ref_type.h" +#include "df/global_objects.h" #include "df/specific_ref.h" +#include "df/specific_ref_type.h" -#include -#include - -#include -#include -#include #include +#include +#include int DFHack::getdir(std::filesystem::path dir, std::vector &files) diff --git a/library/VersionInfoFactory.cpp b/library/VersionInfoFactory.cpp index b4f6eefc78..94e2560e37 100644 --- a/library/VersionInfoFactory.cpp +++ b/library/VersionInfoFactory.cpp @@ -29,6 +29,7 @@ distribution. #include #include #include +#include #include "VersionInfoFactory.h" #include "VersionInfo.h" @@ -226,9 +227,9 @@ void VersionInfoFactory::ParseVersion (TiXmlElement* entry, VersionInfo* mem) } // method // load the XML file with offsets -bool VersionInfoFactory::loadFile(string path_to_xml) +bool VersionInfoFactory::loadFile(std::filesystem::path path_to_xml) { - TiXmlDocument doc( path_to_xml.c_str() ); + TiXmlDocument doc( path_to_xml.string().c_str() ); std::cerr << "Loading " << path_to_xml << " ... "; //bool loadOkay = doc.LoadFile(); if (!doc.LoadFile()) diff --git a/library/git-describe.cmake.in b/library/git-describe.cmake.in index d7d469fe8a..ad3221a4dd 100644 --- a/library/git-describe.cmake.in +++ b/library/git-describe.cmake.in @@ -1,48 +1,61 @@ # Generated from git-describe.cmake.in set(DFHACK_BUILD_ID "@DFHACK_BUILD_ID@") -if(NOT(EXISTS "${GIT_EXECUTABLE}")) - message(FATAL_ERROR "git-describe failed: could not find git at ${GIT_EXECUTABLE}") -endif() +# Fallbacks used when the source tree is not a git checkout or when revision +# information cannot be obtained. +set(DFHACK_GIT_DESCRIPTION "@DFHACK_VERSION@-unknown") +set(DFHACK_GIT_COMMIT "unknown") +set(DFHACK_GIT_XML_EXPECTED_COMMIT "unknown") +set(DFHACK_GIT_XML_COMMIT "unknown") +set(DFHACK_GIT_TAGGED FALSE) +set(DFHACK_GIT_XML_MATCH FALSE) -execute_process(COMMAND ${GIT_EXECUTABLE} describe --tags --abbrev=8 --long - WORKING_DIRECTORY "${dfhack_SOURCE_DIR}" - OUTPUT_VARIABLE DFHACK_GIT_DESCRIPTION - OUTPUT_STRIP_TRAILING_WHITESPACE - RESULT_VARIABLE DFHACK_GIT_DESCRIPTION_RESULT) -execute_process(COMMAND ${GIT_EXECUTABLE} rev-parse HEAD - WORKING_DIRECTORY "${dfhack_SOURCE_DIR}" - OUTPUT_VARIABLE DFHACK_GIT_COMMIT - OUTPUT_STRIP_TRAILING_WHITESPACE) -execute_process(COMMAND ${GIT_EXECUTABLE} describe --tags --abbrev=8 --exact-match - WORKING_DIRECTORY "${dfhack_SOURCE_DIR}" - RESULT_VARIABLE DFHACK_GIT_TAGGED_RESULT - OUTPUT_QUIET ERROR_QUIET) +if(EXISTS "${dfhack_SOURCE_DIR}/.git") + if(NOT GIT_EXECUTABLE OR NOT EXISTS "${GIT_EXECUTABLE}") + message(WARNING "git-describe: could not find git; version information will be unavailable") + else() + execute_process(COMMAND ${GIT_EXECUTABLE} describe --tags --abbrev=8 --long + WORKING_DIRECTORY "${dfhack_SOURCE_DIR}" + OUTPUT_VARIABLE DFHACK_GIT_DESCRIPTION + OUTPUT_STRIP_TRAILING_WHITESPACE + RESULT_VARIABLE DFHACK_GIT_DESCRIPTION_RESULT) + execute_process(COMMAND ${GIT_EXECUTABLE} rev-parse HEAD + WORKING_DIRECTORY "${dfhack_SOURCE_DIR}" + OUTPUT_VARIABLE DFHACK_GIT_COMMIT + OUTPUT_STRIP_TRAILING_WHITESPACE) + execute_process(COMMAND ${GIT_EXECUTABLE} describe --tags --abbrev=8 --exact-match + WORKING_DIRECTORY "${dfhack_SOURCE_DIR}" + RESULT_VARIABLE DFHACK_GIT_TAGGED_RESULT + OUTPUT_QUIET ERROR_QUIET) -# Check library/xml for matching commits -execute_process(COMMAND ${GIT_EXECUTABLE} rev-parse HEAD:library/xml - WORKING_DIRECTORY "${dfhack_SOURCE_DIR}" - OUTPUT_VARIABLE DFHACK_GIT_XML_EXPECTED_COMMIT - OUTPUT_STRIP_TRAILING_WHITESPACE) -execute_process(COMMAND ${GIT_EXECUTABLE} rev-parse HEAD - WORKING_DIRECTORY "${dfhack_SOURCE_DIR}/library/xml" - OUTPUT_VARIABLE DFHACK_GIT_XML_COMMIT - OUTPUT_STRIP_TRAILING_WHITESPACE) + # Check library/xml for matching commits + execute_process(COMMAND ${GIT_EXECUTABLE} rev-parse HEAD:library/xml + WORKING_DIRECTORY "${dfhack_SOURCE_DIR}" + OUTPUT_VARIABLE DFHACK_GIT_XML_EXPECTED_COMMIT + OUTPUT_STRIP_TRAILING_WHITESPACE) + if(EXISTS "${dfhack_SOURCE_DIR}/library/xml/.git") + execute_process(COMMAND ${GIT_EXECUTABLE} rev-parse HEAD + WORKING_DIRECTORY "${dfhack_SOURCE_DIR}/library/xml" + OUTPUT_VARIABLE DFHACK_GIT_XML_COMMIT + OUTPUT_STRIP_TRAILING_WHITESPACE) + endif() -if(NOT(${DFHACK_GIT_DESCRIPTION_RESULT} EQUAL 0)) - message(FATAL_ERROR "git-describe failed: ${DFHACK_GIT_DESCRIPTION_RESULT}") -endif() + if(DFHACK_GIT_TAGGED_RESULT STREQUAL "0") + set(DFHACK_GIT_TAGGED TRUE) + endif() -if(${DFHACK_GIT_TAGGED_RESULT} EQUAL 0) - set(DFHACK_GIT_TAGGED TRUE) -else() - set(DFHACK_GIT_TAGGED FALSE) -endif() + if(NOT "${DFHACK_GIT_XML_COMMIT}" STREQUAL "" + AND NOT "${DFHACK_GIT_XML_COMMIT}" STREQUAL "unknown" + AND "${DFHACK_GIT_XML_COMMIT}" STREQUAL "${DFHACK_GIT_XML_EXPECTED_COMMIT}") + set(DFHACK_GIT_XML_MATCH TRUE) + endif() -if(${DFHACK_GIT_XML_COMMIT} STREQUAL ${DFHACK_GIT_XML_EXPECTED_COMMIT}) - set(DFHACK_GIT_XML_MATCH TRUE) -else() - set(DFHACK_GIT_XML_MATCH FALSE) + if(NOT DFHACK_GIT_DESCRIPTION_RESULT STREQUAL "0") + # e.g. a clone with no reachable tags; fall back to the commit hash + message(WARNING "git-describe: 'git describe' failed; using commit hash as description") + set(DFHACK_GIT_DESCRIPTION "${DFHACK_GIT_COMMIT}") + endif() + endif() endif() configure_file(${dfhack_SOURCE_DIR}/library/include/git-describe.h.in ${dfhack_SOURCE_DIR}/library/include/git-describe.h) diff --git a/library/include/BitArray.h b/library/include/BitArray.h index 360281c6b1..3de148c57a 100644 --- a/library/include/BitArray.h +++ b/library/include/BitArray.h @@ -24,14 +24,14 @@ distribution. #pragma once #include "Error.h" -#include -#include -#include #include +#include #include -#include #include +#include +#include +#include namespace DFHack { @@ -609,6 +609,8 @@ namespace DFHack template struct DfOtherVectors { + using dfhack_other_vectors = void; + std::vector & operator[](O other_id) { CHECK_INVALID_ARGUMENT(size_t(other_id) < sizeof(T) / sizeof(std::vector)); diff --git a/library/include/CoordTemplate.h b/library/include/CoordTemplate.h new file mode 100644 index 0000000000..fc98477bfe --- /dev/null +++ b/library/include/CoordTemplate.h @@ -0,0 +1,227 @@ +#pragma once + +#include +#include +#include + +#include "DataDefs.h" +#include "DataFuncs.h" +#include "Export.h" +#include "HashUtil.h" + +namespace DFHack +{ + template + constexpr T coord_default_initializer = static_cast(-30000); + + template + constexpr T coord_default_initializer = static_cast(std::numeric_limits::infinity()); + + template > + struct Coord2d + { + T x; T y; + + Coord2d() : x(initializer), y(initializer) {} + Coord2d(T x, T y) : x(x), y(y) {} + + template + explicit Coord2d(const Coord2d &other) : x(static_cast(other.x)), y(static_cast(other.y)) {}; + + bool isValid() const { return x >= 0; } + + void clear() + { + x = y = initializer; + } + + bool operator==(const Coord2d& other) const + { + return x == other.x && y == other.y; + } + bool operator!=(const Coord2d& other) const + { + return x != other.x || y != other.y; + } + bool operator<(const Coord2d& other) const + { + return x < other.x || (x == other.x && y < other.y); + } + + Coord2d operator+(const Coord2d& other) const + { + return {T(x + other.x), T(y + other.y)}; + } + Coord2d operator-(const Coord2d& other) const + { + return {T(x - other.x), T(y - other.y)}; + } + Coord2d operator/(T number) const + { + return {T(x / number), T(y / number)}; + } + Coord2d operator*(T number) const + { + return {T(x * number), T(y * number)}; + } + Coord2d operator%(T number) const requires std::integral + { + return {T(x % number), T(y % number)}; + } + Coord2d operator&(T number) const requires std::integral + { + return {T(x & number), T(y & number)}; + } + + std::size_t operator()() const + { + return hash_value(x, y); + } + + // dot product + T dotp(const Coord2d& other) const + { + return x * other.x + y * other.y; + } + + // linear interpolation between two points + Coord2d lerp(const Coord2d& other, T t) const requires std::floating_point + { + return *this + (other - *this) * t; + } + + // orthogonal projection of the point onto the line (segment) AB + Coord2d project_onto_line( + const Coord2d& A, + const Coord2d& B, + bool clamp = false + ) const requires std::floating_point + { + auto P = *this; + auto AB = B - A; + auto AP = P - A; + auto t = AP.dotp(AB) / AB.dotp(AB); + return A + AB * (clamp ? std::clamp(t, 0.0, 1.0) : t); + } + }; + + template , typename U = DFHack::Coord2d> + static const struct_field_info coord2d_fields[] = { + { struct_field_info::PRIMITIVE, "x", offsetof(U, x), &df::identity_traits::identity, 0, 0 }, + { struct_field_info::PRIMITIVE, "y", offsetof(U, y), &df::identity_traits::identity, 0, 0 }, + { struct_field_info::OBJ_METHOD, "isValid", 0, df::wrap_function(&U::isValid), 0, 0 }, + { struct_field_info::OBJ_METHOD, "clear", 0, df::wrap_function(&U::clear), 0, 0 } , + { struct_field_info::END } + }; + + template , typename U = DFHack::Coord2d> + static inline const struct_identity coord2d_identity{sizeof(U), &df::allocator_fn, nullptr, "coord2d", nullptr, coord2d_fields}; + + template > + struct Coord3d + { + T x; T y; T z; + + Coord3d() : x(initializer), y(initializer), z(initializer) {} + Coord3d(Coord2d c, T z) : x(c.x), y(c.y), z(z) {} + Coord3d(T x, T y, T z) : x(x), y(y), z(z) {} + + operator Coord2d() const + { + return {x,y}; + } + + bool isValid() const { return x >= 0; } + + void clear() + { + x = y = z = initializer; + } + + bool operator==(const Coord3d& other) const + { + return x == other.x && y == other.y && z == other.z; + } + bool operator!=(const Coord3d& other) const + { + return x != other.x || y != other.y || z != other.z; + } + bool operator<(const Coord3d& other) const + { + return x < other.x || (x == other.x && (y < other.y || (y == other.y && z < other.z))); + } + + Coord3d operator+(const Coord3d& other) const + { + return {T(x + other.x), T(y + other.y), T(z + other.z)}; + } + Coord3d operator-(const Coord3d& other) const + { + return {T(x - other.x), T(y - other.y), T(z - other.z)}; + } + Coord3d operator/(T number) const + { + return {T(x / number), T(y / number), T(z / number)}; + } + Coord3d operator*(T number) const + { + return {T(x * number), T(y * number), T(z * number)}; + } + Coord3d operator%(T number) const requires std::integral + { + return {T(x % number), T(y % number), T(z % number)}; + } + Coord3d operator&(T number) const requires std::integral + { + return {T(x & number), T(y & number), T(z & number)}; + } + + std::size_t operator()() const + { + return hash_value(x, y, z); + } + + // special weirdness used by the dig plugin + + Coord3d operator-(T number) const + { + return Coord3d(x, y, z - number); + } + Coord3d operator+(T number) const + { + return Coord3d(x, y, z + number); + } + }; + + template , typename U = DFHack::Coord3d> + static const struct_field_info coord3d_fields[] = { + { struct_field_info::PRIMITIVE, "x", offsetof(U, x), &df::identity_traits::identity, 0, 0 }, + { struct_field_info::PRIMITIVE, "y", offsetof(U, y), &df::identity_traits::identity, 0, 0 }, + { struct_field_info::PRIMITIVE, "z", offsetof(U, z), &df::identity_traits::identity, 0, 0 }, + { struct_field_info::OBJ_METHOD, "isValid", 0, df::wrap_function(&U::isValid), 0, 0 }, + { struct_field_info::OBJ_METHOD, "clear", 0, df::wrap_function(&U::clear), 0, 0 } , + { struct_field_info::END } + }; + + template , typename U = DFHack::Coord3d> + static inline const struct_identity coord3d_identity{sizeof(U), &df::allocator_fn, nullptr, "coord", nullptr, coord3d_fields}; +} + +namespace df +{ + template + struct DFHACK_EXPORT identity_traits> + { + static const bool is_primitive = false; + static const compound_identity* get() { return &DFHack::coord2d_identity; } + }; + + template + struct DFHACK_EXPORT identity_traits> + { + static const bool is_primitive = false; + static const compound_identity* get() { return &DFHack::coord3d_identity; + } + }; + +} diff --git a/library/include/Core.h b/library/include/Core.h index 1f3cea5837..0245d79df7 100644 --- a/library/include/Core.h +++ b/library/include/Core.h @@ -29,7 +29,7 @@ distribution. #include "Export.h" #include "Hooks.h" -#include "modules/Graphic.h" +#include "modules/Filesystem.h" #include #include @@ -63,7 +63,6 @@ namespace DFHack class Process; class Module; - class Materials; struct VersionInfo; class VersionInfoFactory; class PluginManager; @@ -157,18 +156,19 @@ namespace DFHack friend void ::dfhooks_sdl_loop(); friend bool ::dfhooks_ncurses_key(int key); public: - /// Get the single Core instance or make one. + /// Get the current active Core instance. will assert if none exists + /// Use noInstance() to check first if unsure static Core& getInstance(); + static bool noInstance() { return active_instance == nullptr; } + /// check if the activity lock is owned by this thread bool isSuspended(void); + /// check if this is DF's render thread, which can never acquire the + /// activity lock and therefore can never call into Lua + bool isRenderThread() { return df_render_thread == std::this_thread::get_id(); } /// Is everything OK? bool isValid(void) { return !errorstate; } - /// get the materials module - Materials * getMaterials(); - /// get the graphic module - Graphic * getGraphic(); - command_result runCommand(color_ostream &out, const std::string &command, std::vector ¶meters, bool no_autocomplete = false); command_result runCommand(color_ostream& out, const std::string& command); @@ -197,6 +197,8 @@ namespace DFHack std::map> ListAliases(); std::string GetAliasCommand(const std::string &name, bool ignore_params = false); + // note that this isn't valid until after DFHack is initialized by DF calling `dfhooks_init` + // that means that it's invalid during at-init static initialization std::filesystem::path getHackPath(); bool isWorldLoaded() { return (last_world_data_ptr != nullptr); } @@ -259,16 +261,31 @@ namespace DFHack return false; } - private: - DFHack::Console con; + // Note that this path should be treated as potentially changeable over the life of a Core instance + // Consumers should not cache this path in long-lived local variables + const std::filesystem::path getConfigPath() + { + return Filesystem::getInstallDir() / "dfhack-config"; + } + + const std::filesystem::path getConfigDefaultsPath() + { + return getHackPath() / "data" / "dfhack-config-defaults"; + } Core(); ~Core(); + private: + static Core* active_instance; + + DFHack::Console con; + + struct Private; std::unique_ptr d; - bool InitMainThread(); + bool InitMainThread(std::filesystem::path path); bool InitSimulationThread(); int Update (void); int Shutdown (void); @@ -283,6 +300,8 @@ namespace DFHack void onStateChange(color_ostream &out, state_change_event event); void handleLoadAndUnloadScripts(color_ostream &out, state_change_event event); + bool loadScriptPaths(color_ostream& out, bool silent = false); + Core(Core const&) = delete; void operator=(Core const&) = delete; @@ -296,13 +315,6 @@ namespace DFHack // FIXME: shouldn't be kept around like this std::unique_ptr vif; - // Module storage - struct - { - Materials * pMaterials; - Graphic * pGraphic; - } s_mods; - std::vector> allModules; DFHack::PluginManager *plug_mgr; // Hotkey Manager @@ -353,6 +365,8 @@ namespace DFHack uint32_t unpaused_ms; // reset to 0 on map load + std::filesystem::path hack_path; + friend class CoreService; friend class ServerConnection; friend class CoreSuspender; diff --git a/library/include/Crashlog.h b/library/include/Crashlog.h new file mode 100644 index 0000000000..c3d1f6aabd --- /dev/null +++ b/library/include/Crashlog.h @@ -0,0 +1,53 @@ +#pragma once + +struct lua_State; + +namespace DFHack { + +/** + * Installs/shuts down the platform crash reporter. Implemented + * per-platform (Crashlog.cpp on Linux, Crashlog-windows.cpp on Windows). + */ +void dfhack_crashlog_init(); +void dfhack_crashlog_shutdown(); + +/** + * Support for post-mortem crash reporting. + * + * The platform crash reporter (see Crashlog.cpp / Crashlog-windows.cpp) + * consults this to produce a Lua stack traceback when a fatal exception + * occurs while Lua code is executing. + */ +namespace Crashlog { + + /** + * The lua_State currently executing a DFHack Lua entry point + * (Lua::SafeCall, Lua::SafeResume, ...) on the calling thread, or + * nullptr if this thread is not inside a Lua call. + * + * Note that a thread's active state may be a coroutine rather than the + * main state, which is exactly what a traceback wants. + */ + lua_State *active_state(); + + /** + * Sets the calling thread's active lua_State, returning the previous + * value. Callers should generally prefer ActiveStateGuard. + */ + lua_State *set_active_state(lua_State *state); + + /** + * RAII helper to maintain active_state() around a Lua entry point. + */ + class ActiveStateGuard { + lua_State *previous; + public: + explicit ActiveStateGuard(lua_State *state) : + previous(set_active_state(state)) {} + ~ActiveStateGuard() { set_active_state(previous); } + + ActiveStateGuard(const ActiveStateGuard&) = delete; + ActiveStateGuard& operator=(const ActiveStateGuard&) = delete; + }; +} +} diff --git a/library/include/DataDefs.h b/library/include/DataDefs.h index 08d8d02097..fd7fdc6521 100644 --- a/library/include/DataDefs.h +++ b/library/include/DataDefs.h @@ -24,18 +24,20 @@ distribution. #pragma once +#include #include #include #include +#include #include #include #include #include -#include #include "BitArray.h" #include "Export.h" #include "Format.h" +#include "HashUtil.h" struct lua_State; @@ -75,6 +77,8 @@ namespace DFHack using TAllocateFn = void *(*)(void*, const void*); + template class type_identity_for; + class DFHACK_EXPORT type_identity { const size_t size; @@ -123,16 +127,16 @@ namespace DFHack constructed_identity(size_t size, const TAllocateFn alloc) : type_identity(size), allocator(alloc) {}; - virtual bool can_allocate() const { return (allocator != nullptr); } - virtual void *do_allocate() const { return allocator(nullptr,nullptr); } - virtual bool do_copy(void *tgt, const void *src) const { return allocator(tgt,src) == tgt; } - virtual bool do_destroy(void *obj) const { return allocator(nullptr,obj) == obj; } + virtual bool can_allocate() const override { return (allocator != nullptr); } + virtual void *do_allocate() const override { return allocator(nullptr,nullptr); } + virtual bool do_copy(void *tgt, const void *src) const override { return allocator(tgt,src) == tgt; } + virtual bool do_destroy(void *obj) const override { return allocator(nullptr,obj) == obj; } public: - virtual bool isPrimitive() const { return false; } - virtual bool isConstructed() const { return true; } + virtual bool isPrimitive() const override { return false; } + virtual bool isConstructed() const override { return true; } - virtual void lua_read(lua_State *state, int fname_idx, void *ptr) const; - virtual void lua_write(lua_State *state, int fname_idx, void *ptr, int val_index) const; + virtual void lua_read(lua_State *state, int fname_idx, void *ptr) const override; + virtual void lua_write(lua_State *state, int fname_idx, void *ptr, int val_index) const override; }; class DFHACK_EXPORT compound_identity : public constructed_identity { @@ -142,11 +146,12 @@ namespace DFHack static std::vector* top_scope; const char *dfhack_name; - const compound_identity *const scope_parent; static void ensure_compound_identity_init(); protected: + const compound_identity *const scope_parent; + compound_identity(size_t size, TAllocateFn alloc, const compound_identity *scope_parent, const char *dfhack_name); @@ -155,13 +160,16 @@ namespace DFHack public: const char *getName() const { return dfhack_name; } - virtual const std::string getFullName() const; + virtual const std::string getFullName() const override; const compound_identity *getScopeParent() const { return (*parent_map)[this]; } const std::vector &getScopeChildren() const { return (*children_map)[this]; } static const std::vector &getTopScope() { return *top_scope; } static void Init(Core *core); + + bool is_equivalent(const compound_identity* other) const; + }; // Bitfields @@ -180,24 +188,24 @@ namespace DFHack const int num_bits; protected: - virtual bool can_allocate() const { return true; } - virtual void *do_allocate() const { return do_allocate_pod(); } - virtual bool do_copy(void *tgt, const void *src) const { do_copy_pod(tgt, src); return true; } - virtual bool do_destroy(void *obj) const { return do_destroy_pod(obj); } + virtual bool can_allocate() const override { return true; } + virtual void *do_allocate() const override { return do_allocate_pod(); } + virtual bool do_copy(void *tgt, const void *src) const override { do_copy_pod(tgt, src); return true; } + virtual bool do_destroy(void *obj) const override { return do_destroy_pod(obj); } public: bitfield_identity(size_t size, const compound_identity *scope_parent, const char *dfhack_name, int num_bits, const bitfield_item_info *bits); - virtual identity_type type() const { return IDTYPE_BITFIELD; } + virtual identity_type type() const override { return IDTYPE_BITFIELD; } - virtual bool isConstructed() const { return false; } + virtual bool isConstructed() const override { return false; } int getNumBits() const { return num_bits; } const bitfield_item_info *getBits() const { return bits; } - virtual void build_metatable(lua_State *state) const; + virtual void build_metatable(lua_State *state) const override; }; class struct_identity; @@ -226,10 +234,10 @@ namespace DFHack const struct_identity *attr_type; protected: - virtual bool can_allocate() const { return true; } - virtual void *do_allocate() const; - virtual bool do_copy(void *tgt, const void *src) const { do_copy_pod(tgt, src); return true; } - virtual bool do_destroy(void *obj) const { return do_destroy_pod(obj); } + virtual bool can_allocate() const override { return true; } + virtual void *do_allocate() const override; + virtual bool do_copy(void *tgt, const void *src) const override { do_copy_pod(tgt, src); return true; } + virtual bool do_destroy(void *obj) const override { return do_destroy_pod(obj); } public: enum_identity(size_t size, @@ -241,7 +249,7 @@ namespace DFHack const void *attrs, const struct_identity *attr_type); enum_identity(const enum_identity *enum_type, const type_identity *override_base_type); - virtual identity_type type() const { return IDTYPE_ENUM; } + virtual identity_type type() const override { return IDTYPE_ENUM; } int64_t getFirstItem() const { return first_item_value; } int64_t getLastItem() const { return last_item_value; } @@ -253,11 +261,11 @@ namespace DFHack const void *getAttrs() const { return attrs; } const struct_identity *getAttrType() const { return attr_type; } - virtual bool isPrimitive() const { return true; } - virtual bool isConstructed() const { return false; } + virtual bool isPrimitive() const override { return true; } + virtual bool isConstructed() const override { return false; } - virtual void lua_read(lua_State *state, int fname_idx, void *ptr) const; - virtual void lua_write(lua_State *state, int fname_idx, void *ptr, int val_index) const; + virtual void lua_read(lua_State *state, int fname_idx, void *ptr) const override; + virtual void lua_write(lua_State *state, int fname_idx, void *ptr, int val_index) const override; }; struct struct_field_info_extra { @@ -296,6 +304,7 @@ namespace DFHack const struct_field_info *fields; static void ensure_struct_identity_init(); + struct_identity* parent; protected: virtual void doInit(Core *core) const override; @@ -305,7 +314,7 @@ namespace DFHack const compound_identity *scope_parent, const char *dfhack_name, const struct_identity *parent, const struct_field_info *fields); - virtual identity_type type() const { return IDTYPE_STRUCT; } + virtual identity_type type() const override { return IDTYPE_STRUCT; } const struct_identity *getParent() const { return (*parent_map)[this]; } const std::vector &getChildren() const { return (*children_map)[this]; } @@ -315,28 +324,37 @@ namespace DFHack bool is_subclass(const struct_identity *subtype) const; - virtual void build_metatable(lua_State *state) const; + virtual void build_metatable(lua_State *state) const override; + + bool is_equivalent(const struct_identity* other) const; }; - class DFHACK_EXPORT global_identity : public struct_identity { + class DFHACK_EXPORT global_identity_base : public struct_identity { public: - global_identity(const struct_field_info *fields) - : struct_identity(0,NULL,NULL,"global",NULL,fields) {} + using struct_identity::struct_identity; - virtual identity_type type() const { return IDTYPE_GLOBAL; } + virtual identity_type type() const override { return IDTYPE_GLOBAL; } - virtual void build_metatable(lua_State *state) const; + virtual void build_metatable(lua_State *state) const override; + }; + + // Placeholder type wrapped by the identity of the global "object", + // which does not correspond to an actual C++ type. + struct global_object { + using df_identity_base = global_identity_base; }; + using global_identity = type_identity_for; + class DFHACK_EXPORT union_identity : public struct_identity { public: union_identity(size_t size, TAllocateFn alloc, const compound_identity *scope_parent, const char *dfhack_name, const struct_identity *parent, const struct_field_info *fields); - virtual identity_type type() const { return IDTYPE_UNION; } + virtual identity_type type() const override { return IDTYPE_UNION; } - virtual void build_metatable(lua_State *state) const; + virtual void build_metatable(lua_State *state) const override; }; class DFHACK_EXPORT other_vectors_identity : public struct_identity { @@ -353,33 +371,9 @@ namespace DFHack const enum_identity *getIndexEnum() const { return index_enum; } - virtual void build_metatable(lua_State *state) const; + virtual void build_metatable(lua_State *state) const override; }; - namespace - { - template - struct overload : Bases ... - { - using is_transparent = void; - using Bases::operator() ...; - }; - - struct char_pointer_hash - { - auto operator()(const char* ptr) const noexcept - { - return std::hash{}(ptr); - } - }; - - using transparent_string_hash = overload< - std::hash, - std::hash, - char_pointer_hash - >; - } - #ifdef _MSC_VER using virtual_ptr = void*; #else @@ -420,7 +414,7 @@ namespace DFHack return it != lst.end() ? it->second : nullptr; } - bool can_allocate() const { return struct_identity::can_allocate() && (vtable_ptr() != nullptr); } + bool can_allocate() const override { return struct_identity::can_allocate() && (vtable_ptr() != nullptr); } void *get_vmethod_ptr(int index) const; bool set_vmethod_ptr(MemoryPatcher &patcher, int index, void *ptr) const; @@ -444,9 +438,9 @@ namespace DFHack const char *dfhack_name, const char *original_name, const virtual_identity *parent, const struct_field_info *fields, bool is_plugin = false); - ~virtual_identity(); + ~virtual_identity() override; - virtual identity_type type() const { return IDTYPE_CLASS; } + virtual identity_type type() const override { return IDTYPE_CLASS; } const char *getOriginalName() const { return original_name ? original_name : getName(); } @@ -531,6 +525,7 @@ inline int linear_index(const DFHack::enum_list_attr &lst, const st namespace df { + using DFHack::type_identity_for; using DFHack::type_identity; using DFHack::compound_identity; using DFHack::virtual_ptr; @@ -572,15 +567,23 @@ namespace df * */ + using df_pool_id_t = size_t; + template concept pooled_object = requires () { { T::pool_id } -> std::convertible_to; }; + template concept copy_assignable = std::assignable_from && std::assignable_from; template void *allocator_fn(void *out, const void *in) { - if (out) + constexpr df_pool_id_t invalid_pool_id = static_cast(-1); + // unerase type + T* _out = out ? reinterpret_cast(out) : nullptr; + const T* _in = in ? reinterpret_cast(in) : nullptr; + + if (_out) { if constexpr (copy_assignable) { - *(T*)out = *(const T*)in; + *_out = *_in; return out; } else @@ -588,13 +591,20 @@ namespace df return nullptr; } } - else if (in) + else if (_in) { + if constexpr (pooled_object) + { + if (_in->pool_id != invalid_pool_id) + { + throw std::runtime_error("Pool-allocated type cannot be deallocated with allocator_fn"); + } + } #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wdelete-non-virtual-dtor" - delete (T*)in; + delete _in; #pragma GCC diagnostic pop - return (T*)in; + return const_cast(in); } else return new T(); @@ -603,8 +613,16 @@ namespace df template struct identity_traits {}; + /* + * Compound types (structs, unions, classes) are recognized either by an + * explicit df_identity_base typedef that derives from compound_identity + * (emitted by the code generator), or by the _identity member itself + * being convertible to a compound identity. + */ template - requires requires () { { &T::_identity } -> std::convertible_to; } + requires (requires { typename T::df_identity_base; } && + std::is_base_of_v) || + requires () { { &T::_identity } -> std::convertible_to; } struct identity_traits { static const bool is_primitive = false; static const compound_identity *get() { return &T::_identity; } @@ -632,6 +650,10 @@ namespace df enum_field &operator=(EnumType ev) { value = IntType(ev); return *this; } + explicit operator IntType () const { return IntType(value); } + template + explicit operator T () const { return static_cast(IntType(value)); } + }; template @@ -849,7 +871,7 @@ namespace DFHack { template inline bool find_bitfield_field(unsigned *idx, const std::string &name, const T* = NULL) { typedef df::bitfield_traits traits; - return findBitfieldField(&idx, name, traits::bit_count, traits::bits); + return findBitfieldField(idx, name, traits::bit_count, traits::bits); } /** @@ -977,6 +999,7 @@ namespace DFHack { DFHACK_EXPORT const struct_field_info *find_union_tag(const struct_identity *structure, const struct_field_info *union_field); } + #define ENUM_ATTR(enum,attr,val) (df::enum_traits::attrs(val).attr) #define ENUM_ATTR_STR(enum,attr,val) DFHack::ifnull(ENUM_ATTR(enum,attr,val),"?") #define ENUM_KEY_STR(enum,val) (DFHack::enum_item_key(val)) @@ -993,42 +1016,5 @@ namespace DFHack { */ // Global object pointers -#include "df/global_objects.h" - #define DF_GLOBAL_VALUE(name,defval) (df::global::name ? *df::global::name : defval) #define DF_GLOBAL_FIELD(name,fname,defval) (df::global::name ? df::global::name->fname : defval) - -// A couple of headers that have to be included at once -#include "df/coord2d.h" -#include "df/coord.h" - -namespace std { - template <> - struct hash { - std::size_t operator()(const df::coord& c) const { - return c(); - } - }; -} - -template <> -struct fmt::formatter : fmt::formatter -{ - template - auto format(const df::coord& c, FormatContext& ctx) const - { - return fmt::formatter::format( - fmt::format("({}, {}, {})", c.x, c.y, c.z), ctx); - } -}; - -template <> -struct fmt::formatter : fmt::formatter -{ - template - auto format(const df::coord2d& c, FormatContext& ctx) const - { - return fmt::formatter::format( - fmt::format("({}, {})", c.x, c.y), ctx); - } -}; diff --git a/library/include/DataFuncs.h b/library/include/DataFuncs.h index ab9bb78a26..27ad5f2e7f 100644 --- a/library/include/DataFuncs.h +++ b/library/include/DataFuncs.h @@ -44,6 +44,43 @@ namespace df { template concept isPrimitive = identity_traits::is_primitive || std::is_enum_v || std::is_void_v; + namespace detail { + /* + * Marshalling for wrapped-function arguments and return values that + * avoids virtual dispatch through the type identity. Types with a + * value representation (numbers, strings, enums, etc.) are handled + * directly by the lua_*_value overloads; pointers use the static + * pointer_identity_base implementation with the target identity + * supplied explicitly; anything else falls back to the identity's + * virtual method. + */ + template + inline void marshal_from_lua(lua_State *L, int idx, T &out) + { + using DFHack::LuaWrapper::UPVAL_METHOD_NAME; + if constexpr (requires { DFHack::detail::lua_write_value(L, UPVAL_METHOD_NAME, out, idx); }) + DFHack::detail::lua_write_value(L, UPVAL_METHOD_NAME, out, idx); + else if constexpr (std::is_same_v, DFHack::pointer_identity_base>) + { + const type_identity *target = nullptr; + if constexpr (requires { df::identity_traits>::get(); }) + target = df::identity_traits>::get(); + DFHack::pointer_identity_base::lua_write(L, UPVAL_METHOD_NAME, &out, target, idx); + } + else + df::identity_traits::get()->lua_write(L, UPVAL_METHOD_NAME, &out, idx); + } + + template + inline void marshal_to_lua(lua_State *L, T &v) + { + if constexpr (requires { DFHack::detail::lua_read_value(L, v); }) + DFHack::detail::lua_read_value(L, v); + else + df::identity_traits::get()->lua_read(L, DFHack::LuaWrapper::UPVAL_METHOD_NAME, &v); + } + } + // handle call-by-reference function arguments by converting pointers to a reference // will throw if lua code tries to pass a null pointer template requires std::is_reference_v @@ -53,7 +90,7 @@ namespace df { using DFHack::LuaWrapper::UPVAL_METHOD_NAME; using Ptr = std::add_pointer_t>; Ptr ptr{}; - df::identity_traits::get()->lua_write(L, UPVAL_METHOD_NAME, &ptr, idx); + detail::marshal_from_lua(L, idx, ptr); if (ptr == nullptr) { field_error(L, UPVAL_METHOD_NAME, "cannot convert null pointer to reference", "call"); @@ -66,9 +103,8 @@ namespace df { template T get_from_lua_state(lua_State* L, int idx) { - using DFHack::LuaWrapper::UPVAL_METHOD_NAME; T val{}; - df::identity_traits::get()->lua_write(L, UPVAL_METHOD_NAME, &val, idx); + detail::marshal_from_lua(L, idx, val); return val; } @@ -76,7 +112,6 @@ namespace df { requires std::is_invocable_r_v void call_and_push_impl(lua_State* L, int base, std::index_sequence, FT fun, ET... extra) { - using DFHack::LuaWrapper::UPVAL_METHOD_NAME; if constexpr (std::is_same_v) { std::invoke(fun, extra..., (get_from_lua_state(L, base+I))...); lua_pushnil(L); @@ -84,7 +119,7 @@ namespace df { else { RT rv = std::invoke(fun, extra..., (get_from_lua_state(L, base+I))...); - df::identity_traits::get()->lua_read(L, UPVAL_METHOD_NAME, &rv); + detail::marshal_to_lua(L, rv); } } @@ -174,21 +209,33 @@ namespace df { template class function_identity : public function_identity_base { T ptr; + const std::string name; + const bool depr; + const std::string depr_message; public: using wrapper = function_wrapper; - function_identity(T ptr, bool vararg) - : function_identity_base(wrapper::num_args, vararg), ptr(ptr) {}; + function_identity(T ptr, std::string_view name, bool vararg, std::string_view depr = {}) + : function_identity_base(wrapper::num_args, vararg), ptr(ptr), name(name), depr(!depr.empty()), depr_message(depr) + {}; - virtual void invoke(lua_State *state, int base) const { wrapper::execute(state, base, ptr); } + virtual void invoke(lua_State *state, int base) const override + { + if (depr) + { + DFHack::LuaWrapper::notify_deprecated(state, name, depr_message); + } + wrapper::execute(state, base, ptr); + } }; template - inline function_identity_base *wrap_function(T ptr, bool vararg = false) { + inline function_identity_base* wrap_function(T ptr, std::string_view name = {}, bool vararg = false, std::string_view depr = {}) + { using RT = return_type::type; if constexpr (isPrimitive) - return new function_identity(ptr, vararg); + return new function_identity(ptr, name, vararg, depr); else return nullptr; }} diff --git a/library/include/DataIdentity.h b/library/include/DataIdentity.h index f8fd3c6fd7..9e014b5556 100644 --- a/library/include/DataIdentity.h +++ b/library/include/DataIdentity.h @@ -24,11 +24,15 @@ distribution. #pragma once +#include +#include #include #include +#include #include #include #include +#include #include #include #include @@ -61,24 +65,25 @@ namespace DFHack function_identity_base(int num_args, bool vararg = false) : type_identity(0), num_args(num_args), vararg(vararg) {}; - virtual identity_type type() const { return IDTYPE_FUNCTION; } + virtual identity_type type() const override { return IDTYPE_FUNCTION; } int getNumArgs() const { return num_args; } bool adjustArgs() const { return vararg; } - const std::string getFullName() const { return "function"; } + const std::string getFullName() const override { return "function"; } virtual void invoke(lua_State *state, int base) const = 0; - virtual void lua_read(lua_State *state, int fname_idx, void *ptr) const; - virtual void lua_write(lua_State *state, int fname_idx, void *ptr, int val_index) const; + virtual void lua_read(lua_State *state, int fname_idx, void *ptr) const override; + virtual void lua_write(lua_State *state, int fname_idx, void *ptr, int val_index) const override; }; - class DFHACK_EXPORT primitive_identity : public type_identity { - public: - primitive_identity(size_t size) : type_identity(size) {}; + class DFHACK_EXPORT primitive_identity_base : public type_identity { + protected: + primitive_identity_base(size_t size) : type_identity(size) {}; - virtual identity_type type() const { return IDTYPE_PRIMITIVE; } + public: + virtual identity_type type() const override { return IDTYPE_PRIMITIVE; } }; class DFHACK_EXPORT opaque_identity : public constructed_identity { @@ -88,28 +93,28 @@ namespace DFHack opaque_identity(size_t size, TAllocateFn alloc, const std::string &name) : constructed_identity(size, alloc), name(name) {}; - virtual const std::string getFullName() const { return name; } - virtual identity_type type() const { return IDTYPE_OPAQUE; } + virtual const std::string getFullName() const override { return name; } + virtual identity_type type() const override { return IDTYPE_OPAQUE; } }; - class DFHACK_EXPORT pointer_identity : public primitive_identity { + class DFHACK_EXPORT pointer_identity_base : public primitive_identity_base { const type_identity *target; public: - pointer_identity(const type_identity *target = NULL) - : primitive_identity(sizeof(void*)), target(target) {}; + pointer_identity_base(const type_identity *target = NULL) + : primitive_identity_base(sizeof(void*)), target(target) {}; - virtual identity_type type() const { return IDTYPE_POINTER; } + virtual identity_type type() const override { return IDTYPE_POINTER; } const type_identity *getTarget() const { return target; } - const std::string getFullName() const; + const std::string getFullName() const override; static void lua_read(lua_State *state, int fname_idx, void *ptr, const type_identity *target); static void lua_write(lua_State *state, int fname_idx, void *ptr, const type_identity *target, int val_index); - virtual void lua_read(lua_State *state, int fname_idx, void *ptr) const; - virtual void lua_write(lua_State *state, int fname_idx, void *ptr, int val_index) const; + virtual void lua_read(lua_State *state, int fname_idx, void *ptr) const override; + virtual void lua_write(lua_State *state, int fname_idx, void *ptr, int val_index) const override; }; class DFHACK_EXPORT container_identity : public constructed_identity { @@ -121,12 +126,12 @@ namespace DFHack container_identity(size_t size, const TAllocateFn alloc, const type_identity *item, const enum_identity *ienum = NULL) : constructed_identity(size, alloc), item(item), ienum(ienum) {}; - virtual identity_type type() const { return IDTYPE_CONTAINER; } + virtual identity_type type() const override { return IDTYPE_CONTAINER; } - const std::string getFullName() const { return getFullName(item); } + const std::string getFullName() const override { return getFullName(item); } - virtual void build_metatable(lua_State *state) const; - virtual bool isContainer() const { return true; } + virtual void build_metatable(lua_State *state) const override; + virtual bool isContainer() const override { return true; } const type_identity *getItemType() const { return item; } const type_identity *getIndexEnumType() const { return ienum; } @@ -162,15 +167,15 @@ namespace DFHack const type_identity *item, const enum_identity *ienum = NULL) : container_identity(size, alloc, item, ienum) {}; - virtual identity_type type() const { return IDTYPE_PTR_CONTAINER; } + virtual identity_type type() const override { return IDTYPE_PTR_CONTAINER; } - const std::string getFullName(const type_identity *item) const; + const std::string getFullName(const type_identity *item) const override; - virtual void lua_item_reference(lua_State *state, int fname_idx, void *ptr, int idx) const; - virtual void lua_item_read(lua_State *state, int fname_idx, void *ptr, int idx) const; - virtual void lua_item_write(lua_State *state, int fname_idx, void *ptr, int idx, int val_index) const; + virtual void lua_item_reference(lua_State *state, int fname_idx, void *ptr, int idx) const override; + virtual void lua_item_read(lua_State *state, int fname_idx, void *ptr, int idx) const override; + virtual void lua_item_write(lua_State *state, int fname_idx, void *ptr, int idx, int val_index) const override; - virtual bool lua_insert2(lua_State *state, int fname_idx, void *ptr, int idx, int val_index) const; + virtual bool lua_insert2(lua_State *state, int fname_idx, void *ptr, int idx, int val_index) const override; }; class DFHACK_EXPORT bit_container_identity : public container_identity { @@ -178,16 +183,16 @@ namespace DFHack bit_container_identity(size_t size, TAllocateFn alloc, const enum_identity *ienum = NULL) : container_identity(size, alloc, NULL, ienum) {}; - virtual identity_type type() const { return IDTYPE_BIT_CONTAINER; } + virtual identity_type type() const override { return IDTYPE_BIT_CONTAINER; } - const std::string getFullName(const type_identity *item) const; + const std::string getFullName(const type_identity *item) const override; - virtual void lua_item_reference(lua_State *state, int fname_idx, void *ptr, int idx) const; - virtual void lua_item_read(lua_State *state, int fname_idx, void *ptr, int idx) const; - virtual void lua_item_write(lua_State *state, int fname_idx, void *ptr, int idx, int val_index) const; + virtual void lua_item_reference(lua_State *state, int fname_idx, void *ptr, int idx) const override; + virtual void lua_item_read(lua_State *state, int fname_idx, void *ptr, int idx) const override; + virtual void lua_item_write(lua_State *state, int fname_idx, void *ptr, int idx, int val_index) const override; protected: - virtual void *item_pointer(const type_identity *, void *, int) const { return NULL; } + virtual void *item_pointer(const type_identity *, void *, int) const override { return NULL; } virtual bool get_item(void *ptr, int idx) const = 0; virtual void set_item(void *ptr, int idx, bool val) const = 0; @@ -197,173 +202,31 @@ namespace DFHack namespace df { using DFHack::function_identity_base; - using DFHack::primitive_identity; + using DFHack::primitive_identity_base; using DFHack::opaque_identity; - using DFHack::pointer_identity; + using DFHack::pointer_identity_base; using DFHack::container_identity; using DFHack::ptr_container_identity; using DFHack::bit_container_identity; - class DFHACK_EXPORT number_identity_base : public primitive_identity { + class DFHACK_EXPORT number_identity_base : public primitive_identity_base { const char *name; public: number_identity_base(size_t size, const char *name) - : primitive_identity(size), name(name) {}; - - const std::string getFullName() const { return name; } - - virtual void lua_read(lua_State *state, int fname_idx, void *ptr) const = 0; - virtual void lua_write(lua_State *state, int fname_idx, void *ptr, int val_index) const = 0; - - }; - - class DFHACK_EXPORT integer_identity_base : public number_identity_base { - public: - integer_identity_base(size_t size, const char *name) - : number_identity_base(size, name) {} - - virtual void lua_read(lua_State *state, int fname_idx, void *ptr) const; - virtual void lua_write(lua_State *state, int fname_idx, void *ptr, int val_index) const; - - protected: - virtual int64_t read(void *ptr) const = 0; - virtual void write(void *ptr, int64_t val) const = 0; - }; - - class DFHACK_EXPORT float_identity_base : public number_identity_base { - public: - float_identity_base(size_t size, const char *name) - : number_identity_base(size, name) {} - - virtual void lua_read(lua_State *state, int fname_idx, void *ptr) const; - virtual void lua_write(lua_State *state, int fname_idx, void *ptr, int val_index) const; - - protected: - virtual double read(void *ptr) const = 0; - virtual void write(void *ptr, double val) const = 0; - }; - - template - class integer_identity : public integer_identity_base { - public: - integer_identity(const char *name) : integer_identity_base(sizeof(T), name) {} - - protected: - virtual int64_t read(void *ptr) const { return int64_t(*(T*)ptr); } - virtual void write(void *ptr, int64_t val) const { *(T*)ptr = T(val); } - }; - - template - class float_identity : public float_identity_base { - public: - float_identity(const char *name) : float_identity_base(sizeof(T), name) {} - - protected: - virtual double read(void *ptr) const { return double(*(T*)ptr); } - virtual void write(void *ptr, double val) const { *(T*)ptr = T(val); } - }; - - class DFHACK_EXPORT bool_identity : public primitive_identity { - public: - bool_identity() : primitive_identity(sizeof(bool)) {}; - - const std::string getFullName() const { return "bool"; } - - virtual void lua_read(lua_State *state, int fname_idx, void *ptr) const; - virtual void lua_write(lua_State *state, int fname_idx, void *ptr, int val_index) const; - }; - - class DFHACK_EXPORT ptr_string_identity : public primitive_identity { - public: - ptr_string_identity() : primitive_identity(sizeof(char*)) {}; - - const std::string getFullName() const { return "char*"; } - - virtual void lua_read(lua_State *state, int fname_idx, void *ptr) const; - virtual void lua_write(lua_State *state, int fname_idx, void *ptr, int val_index) const; - }; - - class DFHACK_EXPORT stl_string_identity : public DFHack::constructed_identity { - public: - stl_string_identity() - : constructed_identity(sizeof(std::string), &allocator_fn) - {}; - - const std::string getFullName() const { return "string"; } + : primitive_identity_base(size), name(name) {}; - virtual DFHack::identity_type type() const { return DFHack::IDTYPE_PRIMITIVE; } + const std::string getFullName() const override { return name; } - virtual bool isPrimitive() const { return true; } - - virtual void lua_read(lua_State *state, int fname_idx, void *ptr) const; - virtual void lua_write(lua_State *state, int fname_idx, void *ptr, int val_index) const; + virtual bool isInteger() const { return false; } }; - class DFHACK_EXPORT path_identity : public DFHack::constructed_identity { - public: - path_identity() - : constructed_identity(sizeof(std::filesystem::path), &allocator_fn) - { - }; - - const std::string getFullName() const { return "path"; } - - virtual DFHack::identity_type type() const { return DFHack::IDTYPE_PRIMITIVE; } - - virtual bool isPrimitive() const { return true; } - - virtual void lua_read(lua_State* state, int fname_idx, void* ptr) const; - virtual void lua_write(lua_State* state, int fname_idx, void* ptr, int val_index) const; - }; - - - class DFHACK_EXPORT stl_ptr_vector_identity : public ptr_container_identity { - public: - using container = std::vector; - - /* - * This class assumes that std::vector is equivalent - * in layout and behavior to std::vector for any T. - */ - - stl_ptr_vector_identity(const type_identity *item = NULL, const enum_identity *ienum = NULL) - : ptr_container_identity(sizeof(container), &df::allocator_fn, item, ienum) - {}; - - const std::string getFullName(const type_identity *item) const { - return "vector" + ptr_container_identity::getFullName(item); - } - - virtual DFHack::identity_type type() const { return DFHack::IDTYPE_STL_PTR_VECTOR; } - - virtual bool resize(void *ptr, int size) const { - (*(container*)ptr).resize(size); - return true; - } - virtual bool erase(void *ptr, int size) const { - auto &ct = *(container*)ptr; - ct.erase(ct.begin()+size); - return true; - } - virtual bool insert(void *ptr, int idx, void *item) const { - auto &ct = *(container*)ptr; - ct.insert(ct.begin()+idx, item); - return true; - } - - protected: - virtual int item_count(void *ptr, CountMode) const { - return (int)((container*)ptr)->size(); - }; - virtual void *item_pointer(const type_identity *, void *ptr, int idx) const { - return &(*(container*)ptr)[idx]; - } - }; - -// Due to export issues, this stuff can only work in the main dll -#ifdef BUILD_DFHACK_LIB - class buffer_container_identity : public container_identity { + /* + * Identity for statically-sized arrays (C arrays and std::array). + * Unlike other container identities the item count is fixed, + * so instances can also serve as the identity of ad-hoc buffers. + */ + class DFHACK_EXPORT buffer_container_identity : public container_identity { int size; public: @@ -375,250 +238,648 @@ namespace df : container_identity(0, NULL, item, ienum), size(size) {} - size_t byte_size() const { return getItemType()->byte_size()*size; } + size_t byte_size() const override { return getItemType()->byte_size()*size; } - const std::string getFullName(const type_identity *item) const; + using container_identity::getFullName; + const std::string getFullName(const type_identity *item) const override; int getSize() const { return size; } - virtual DFHack::identity_type type() const { return DFHack::IDTYPE_BUFFER; } + virtual DFHack::identity_type type() const override { return DFHack::IDTYPE_BUFFER; } static const buffer_container_identity base_instance; protected: - virtual int item_count(void *ptr, CountMode) const { return size; } - virtual void *item_pointer(const type_identity *item, void *ptr, int idx) const { + virtual int item_count(void *ptr, CountMode) const override { return size; } + virtual void *item_pointer(const type_identity *item, void *ptr, int idx) const override { return ((uint8_t*)ptr) + idx * item->byte_size(); } }; -#endif +} - template - class stl_container_identity : public container_identity { - const char *name; +namespace DFHack +{ + namespace detail + { + /* + * Compile-time type inspection used by type_identity_for to pick + * the appropriate behavior for the wrapped C++ type. + */ - public: - stl_container_identity(const char *name, const type_identity *item, const enum_identity *ienum = NULL) - : container_identity(sizeof(T), &allocator_fn, item, ienum), name(name) - {} + template struct is_std_array : std::false_type {}; + template struct is_std_array> : std::true_type {}; - const std::string getFullName(const type_identity *item) const { - return name + container_identity::getFullName(item); - } + template struct is_bit_array : std::false_type {}; + template struct is_bit_array> : std::true_type {}; - virtual bool resize(void *ptr, int size) const { - (*(T*)ptr).resize(size); - return true; - } - virtual bool erase(void *ptr, int size) const { - auto &ct = *(T*)ptr; - ct.erase(ct.begin()+size); - return true; - } - virtual bool insert(void *ptr, int idx, void *item) const { - auto &ct = *(T*)ptr; - ct.insert(ct.begin()+idx, *(typename T::value_type*)item); - return true; - } - virtual bool lua_insert2(lua_State* state, int fname_idx, void* ptr, int idx, int val_index) const - { - using VT = typename T::value_type; - VT tmp{}; - auto id = (type_identity*)lua_touserdata(state, DFHack::LuaWrapper::UPVAL_ITEM_ID); - auto pitem = DFHack::LuaWrapper::get_object_internal(state, id, val_index, false); - bool useTemporary = (!pitem && id->isPrimitive()); - - if (useTemporary) - { - pitem = &tmp; - id->lua_write(state, fname_idx, pitem, val_index); - } + template struct is_enum_field : std::false_type {}; + template struct is_enum_field> : std::true_type {}; - if (id != item || !pitem) - DFHack::LuaWrapper::field_error(state, fname_idx, "incompatible object type", "insert"); + // C strings are represented by char* and const char* + template + concept c_string = std::is_same_v || std::is_same_v; - return insert(ptr, idx, pitem); - } + /* + * Container capability probes. These describe the behavioral axes + * that the container identity implementation dispatches on, so any + * type with a vector-like, array-like, bit-like, or associative + * interface is handled without being listed explicitly. + */ + template + concept has_value_type = requires { typename T::value_type; }; + + // element count is available either as size() or as a size member + template + concept sized = requires(const T& t) { { t.size() } -> std::convertible_to; } + || requires(const T& t) { { t.size } -> std::convertible_to; }; + + // dense random-access storage: t[i] yields a reference whose + // address is a value_type* (the vector/array contract) + template + concept random_access = has_value_type && sized && requires(T& t, int i) { + { &t[i] } -> std::convertible_to; + }; - protected: - virtual int item_count(void *ptr, CountMode) const { return (int)((T*)ptr)->size(); } - virtual void *item_pointer(const type_identity *item, void *ptr, int idx) const { - return &(*(T*)ptr)[idx]; - } - }; + // indexable bit elements through a proxy object (vector and similar) + template + concept bit_indexable = has_value_type && sized && !random_access && + requires(T& t, int i, bool v) { + { t[i] } -> std::convertible_to; + t[i] = v; + }; + + // bit elements through explicit accessors; by convention such + // containers report byte counts from size() and resize() (BitArray). + // BitArray indexes by the enum type and is matched by name. + template + concept bit_accessors = is_bit_array::value || + (sized && requires(T& t, int i, bool v) { + { t.is_set(i) } -> std::convertible_to; + t.set(i, v); + }); + + // key-mapped iteration (std::map, std::unordered_map, and similar) + template + concept mapped = has_value_type && requires { typename T::mapped_type; } && + requires(T& t) { t.begin(); t.end(); }; + + // iterable but not index-addressable: read-only sequential access + // (std::set and similar) + template + concept ro_sequence = has_value_type && sized && + requires(T& t) { t.begin(); t.end(); } && + !random_access && !bit_indexable && !mapped; + + // an aggregate exposing a fixed { size, items[] } table (enum_list_attr) + template + concept item_list = sized && requires(const T& t, int i) { t.items[i]; }; + + // bit containers of either flavor; mapped types that happen to + // satisfy the proxy probes (e.g. map) are excluded + template + concept bit_container = (bit_indexable || bit_accessors) && !mapped; + + // sequence containers with mutable, index-addressable elements + template + concept seq_container = random_access && + !std::is_pointer_v; + + // containers of pointer elements use pointer-container semantics + // and are stored internally as containers of void* + template + concept ptr_container = random_access && + std::is_pointer_v; + + // read-only containers accessed by iteration + template + concept assoc_container = ro_sequence || mapped; + + template + concept any_container = seq_container || ptr_container || + bit_container || assoc_container || item_list; + + // mutable-sequence operations + template + concept resizable = requires(T& t, int n) { t.resize(n); }; + + template + concept index_erasable = requires(T& t, int i) { t.erase(t.begin() + i); }; + + template + concept index_insertable = has_value_type && + requires(T& t, int i, typename T::value_type v) { t.insert(t.begin() + i, v); }; + + template + concept stl_string = std::is_same_v; + + template + concept fs_path = std::is_same_v; + + // df::bitfield_traits exists only for generated bitfield types + template + concept df_bitfield = requires { typename df::bitfield_traits::base_type; }; + + // generated compound types (struct/union/class) carry a static + // _identity member + template + concept has_identity_member = requires { T::_identity; }; + + // DfOtherVectors marks its descendants with a nested typedef + template + concept other_vectors = requires { typename T::dfhack_other_vectors; }; + + // an explicit df_identity_base typedef always wins; this lets the + // code generator (and hand-written types) pick the base directly + template + concept has_explicit_base = requires { typename T::df_identity_base; }; + + // The underlying storage actually manipulated by a container identity. + // Pointer-element containers are manipulated as containers of void*, + // and BitArray is assumed layout-equivalent to BitArray, + // matching the assumptions of the original code. + template struct container_storage { using type = T; }; + template struct container_storage> { using type = BitArray; }; + template class C, typename E, typename... A> + requires (sizeof(C) > 0) + struct container_storage> { using type = C; }; + template using container_storage_t = typename container_storage::type; + + template struct base_tag { using type = B; }; -#ifdef BUILD_DFHACK_LIB - template - class ro_stl_container_identity : public container_identity { - protected: - const char *name; - - public: - ro_stl_container_identity(const char *name, const type_identity *item, const enum_identity *ienum = NULL) - : container_identity(sizeof(T), &allocator_fn, item, ienum), name(name) - {} + /* + * The intermediate base for container-like T; implements the + * container_identity virtuals using constexpr dispatch on T. + */ + template + class container_impl : public std::conditional_t< + bit_container, bit_container_identity, + std::conditional_t, ptr_container_identity, container_identity>> { + using cbase = std::conditional_t< + bit_container, bit_container_identity, + std::conditional_t, ptr_container_identity, container_identity>>; + using storage = container_storage_t; + + const char *name; + const type_identity *key_id; + + // item tables like enum_list_attr describe static external + // data and have no allocator + static constexpr TAllocateFn alloc_fn() { + if constexpr (item_list) + return NULL; + else + return &df::allocator_fn; + } - const std::string getFullName(const type_identity *item) const { - return name + container_identity::getFullName(item); - } + static size_t element_count(const storage &ct) { + if constexpr (requires { { ct.size() } -> std::convertible_to; }) + return (size_t)ct.size(); + else + return (size_t)ct.size; + } - virtual bool is_readonly() const { return true; } - virtual bool resize(void *ptr, int size) const { return false; } - virtual bool erase(void *ptr, int size) const { return false; } - virtual bool insert(void *ptr, int idx, void *item) const { return false; } + public: + // sequence and read-only containers + container_impl(const char *name, const type_identity *item, const enum_identity *ienum = NULL) + requires (!bit_container && !mapped) + : cbase(sizeof(storage), alloc_fn(), item, ienum), name(name), key_id(NULL) + {} + + // bit containers take an index enum instead of an item identity + container_impl(const char *name, const enum_identity *ienum) + requires bit_container + : cbase(sizeof(storage), alloc_fn(), ienum), name(name), key_id(NULL) + {} + + // mapped containers additionally take a key identity + container_impl(const char *name, const type_identity *key, const type_identity *item) + requires mapped + : cbase(sizeof(storage), alloc_fn(), item, NULL), name(name), key_id(key) + {} + + const std::string getFullName() const override { return getFullName(this->item); } + + const std::string getFullName(const type_identity *item) const override { + if constexpr (mapped) + return std::string(name) + "<" + key_id->getFullName() + ", " + item->getFullName() + ">"; + else if constexpr (is_bit_array::value) + return "BitArray<>"; + else + return std::string(name) + cbase::getFullName(item); + } - protected: - virtual int item_count(void *ptr, CountMode) const { return (int)((T*)ptr)->size(); } - virtual void *item_pointer(const type_identity *item, void *ptr, int idx) const { - auto iter = (*(T*)ptr).begin(); - for (; idx > 0; idx--) ++iter; - return (void*)&*iter; - } - }; + virtual bool is_readonly() const override { + return assoc_container; + } - template - class ro_stl_assoc_container_identity : public ro_stl_container_identity { - const type_identity *key_identity; - const type_identity *item_identity; + virtual bool resize(void *ptr, int size) const override { + if constexpr (bit_accessors) { + ((storage*)ptr)->resize((size+7)/8); + return true; + } + else if constexpr (resizable) { + (*(storage*)ptr).resize(size); + return true; + } + else + return false; + } + virtual bool erase(void *ptr, int index) const override { + if constexpr (index_erasable) { + auto &ct = *(storage*)ptr; + ct.erase(ct.begin()+index); + return true; + } + else + return false; + } + virtual bool insert(void *ptr, int index, void *pitem) const override { + if constexpr (ptr_container && index_insertable) { + auto &ct = *(storage*)ptr; + ct.insert(ct.begin()+index, pitem); + return true; + } + else if constexpr (!bit_container && index_insertable) { + auto &ct = *(storage*)ptr; + ct.insert(ct.begin()+index, *(typename T::value_type*)pitem); + return true; + } + else + return false; + } + virtual bool lua_insert2(lua_State* state, int fname_idx, void* ptr, int idx, int val_index) const override { + if constexpr (seq_container && index_insertable) { + using VT = typename T::value_type; + VT tmp{}; + auto id = (type_identity*)lua_touserdata(state, DFHack::LuaWrapper::UPVAL_ITEM_ID); + auto pitem = DFHack::LuaWrapper::get_object_internal(state, id, val_index, false); + bool useTemporary = (!pitem && id->isPrimitive()); + + if (useTemporary) + { + pitem = &tmp; + id->lua_write(state, fname_idx, pitem, val_index); + } + + if (id != this->item || !pitem) + DFHack::LuaWrapper::field_error(state, fname_idx, "incompatible object type", "insert"); + + return insert(ptr, idx, pitem); + } + else + return cbase::lua_insert2(state, fname_idx, ptr, idx, val_index); + } - public: - ro_stl_assoc_container_identity(const char *name, const type_identity *key, const type_identity *item) - : ro_stl_container_identity(name, item), - key_identity(key), - item_identity(item) - {} + protected: + virtual int item_count(void *ptr, container_identity::CountMode cnt) const override { + if constexpr (bit_accessors) + return cnt == container_identity::COUNT_LEN ? (int)(element_count(*(storage*)ptr) * 8) : -1; + else if constexpr (item_list) + return cnt == container_identity::COUNT_WRITE ? 0 : (int)element_count(*(storage*)ptr); + else + return (int)element_count(*(storage*)ptr); + } + virtual void *item_pointer(const type_identity *item, void *ptr, int idx) const override { + if constexpr (bit_container) + return NULL; + else if constexpr (item_list) + return (void*)&((storage*)ptr)->items[idx]; + else if constexpr (mapped) { + auto iter = (*(storage*)ptr).begin(); + for (; idx > 0; idx--) ++iter; + return (void*)&iter->second; + } + else if constexpr (ro_sequence) { + auto iter = (*(storage*)ptr).begin(); + for (; idx > 0; idx--) ++iter; + return (void*)&*iter; + } + else + return &(*(storage*)ptr)[idx]; + } + // get_item/set_item only exist in bit_container_identity; for + // other bases these are ordinary (unused) member functions, and + // they implicitly override the virtuals for bit containers. + bool get_item(void *ptr, int idx) const { + if constexpr (bit_accessors) + return ((storage*)ptr)->is_set(idx); + else if constexpr (bit_indexable) + return (*(storage*)ptr)[idx]; + else + return false; + } + void set_item(void *ptr, int idx, bool val) const { + if constexpr (bit_accessors) + ((storage*)ptr)->set(idx, val); + else if constexpr (bit_indexable) + (*(storage*)ptr)[idx] = val; + } + }; - virtual const std::string getFullName(const type_identity*) const override { - return std::string(ro_stl_assoc_container_identity::name) + "<" + key_identity->getFullName() + ", " + item_identity->getFullName() + ">"; + template + constexpr auto select_identity_base() { + if constexpr (has_explicit_base) + return base_tag{}; + else if constexpr (std::is_enum_v || is_enum_field::value) + return base_tag{}; + else if constexpr (df_bitfield) + return base_tag{}; + else if constexpr (has_identity_member) { + if constexpr (std::is_union_v) + return base_tag{}; + else if constexpr (other_vectors) + return base_tag{}; + else if constexpr (std::is_polymorphic_v) + return base_tag{}; + else + return base_tag{}; + } + else if constexpr (c_string) + return base_tag{}; + else if constexpr (std::is_same_v) + return base_tag{}; + else if constexpr (std::is_pointer_v) + return base_tag{}; + else if constexpr (std::is_arithmetic_v) + return base_tag{}; + else if constexpr (stl_string || fs_path) + return base_tag{}; + else if constexpr (std::is_array_v || is_std_array::value) + return base_tag{}; + else if constexpr (any_container) + return base_tag>{}; + else if constexpr (std::is_class_v) + return base_tag{}; + else + static_assert(!sizeof(T*), "type_identity_for: no identity category for this type"); } + } - protected: - virtual void *item_pointer(const type_identity *item, void *ptr, int idx) const override { - auto iter = (*(T*)ptr).begin(); - for (; idx > 0; idx--) ++iter; - return (void*)&iter->second; - } - }; + template + using identity_base_of_t = typename decltype(detail::select_identity_base())::type; - class bit_array_identity : public bit_container_identity { - public: + DFHACK_EXPORT void lua_read_path(lua_State *state, void *ptr); + DFHACK_EXPORT void lua_write_path(lua_State *state, int fname_idx, void *ptr, int val_index); + + namespace detail { /* - * This class assumes that BitArray is equivalent - * in layout and behavior to BitArray for any T. + * Per-category overrides of type_identity properties, selected at + * compile time by constraint resolution. Categories with no + * matching overload fall back to the identity base class's + * implementation in type_identity_for's virtuals. */ - using container = BitArray; + // type() overrides + template requires (stl_string || fs_path) + constexpr identity_type type_override() { return IDTYPE_PRIMITIVE; } + template requires ptr_container + constexpr identity_type type_override() { return IDTYPE_STL_PTR_VECTOR; } - bit_array_identity(const enum_identity *ienum = NULL) - : bit_container_identity(sizeof(container), &allocator_fn, ienum) - {} + // getFullName() overrides + template requires c_string + constexpr const char *name_override() { return "char*"; } + template requires stl_string + constexpr const char *name_override() { return "string"; } + template requires fs_path + constexpr const char *name_override() { return "path"; } - virtual const std::string getFullName(const type_identity *item) const { - return "BitArray<>"; - } + // isPrimitive() override + template requires (stl_string || fs_path) + constexpr bool primitive_override() { return true; } - virtual bool resize(void *ptr, int size) const { - ((container*)ptr)->resize((size+7)/8); - return true; + /* + * Marshalling of primitive values between C++ objects and Lua. + * Overload resolution on the exact deduced type of the C++ object + * selects the implementation, so support for a new value type is + * added by writing a new overload here. Types with no matching + * overload fall back to the identity base class's implementation + * (reference semantics). All parameters are deduced, so implicit + * conversions of the wrapped type are never considered. + */ + template requires std::same_as + inline void lua_read_value(lua_State *state, const T &v) { + lua_pushboolean(state, v); } - - protected: - virtual int item_count(void *ptr, CountMode cnt) const { - return cnt == COUNT_LEN ? ((container*)ptr)->size() * 8 : -1; + template requires (std::integral && !std::same_as) + inline void lua_read_value(lua_State *state, const T &v) { + lua_pushinteger(state, int64_t(v)); } - virtual bool get_item(void *ptr, int idx) const { - return ((container*)ptr)->is_set(idx); + template requires std::floating_point + inline void lua_read_value(lua_State *state, const T &v) { + lua_pushnumber(state, double(v)); } - virtual void set_item(void *ptr, int idx, bool val) const { - ((container*)ptr)->set(idx, val); + // enums marshal through their underlying type, matching + // enum_identity's forwarding to base_type + template requires std::is_enum_v + inline void lua_read_value(lua_State *state, const T &v) { + lua_read_value(state, static_cast>(v)); } - }; -#endif - - class DFHACK_EXPORT stl_bit_vector_identity : public bit_container_identity { - public: - using container = std::vector; - - stl_bit_vector_identity(const enum_identity *ienum = NULL) - : bit_container_identity(sizeof(container), &df::allocator_fn, ienum) - {} - - const std::string getFullName(const type_identity *item) const { - return "vector" + bit_container_identity::getFullName(item); + template requires c_string + inline void lua_read_value(lua_State *state, const T &v) { + if (v) + lua_pushstring(state, v); + else + lua_pushnil(state); } - - virtual bool resize(void *ptr, int size) const { - (*(container*)ptr).resize(size); - return true; + template requires stl_string + inline void lua_read_value(lua_State *state, const T &v) { + lua_pushlstring(state, v.data(), v.size()); + } + template requires fs_path + inline void lua_read_value(lua_State *state, const T &v) { + lua_read_path(state, const_cast(&v)); } - protected: - virtual int item_count(void *ptr, CountMode) const { - return (int)((container*)ptr)->size(); + template requires std::same_as + inline void lua_write_value(lua_State *state, int fname_idx, T &out, int val_index) { + if (lua_isboolean(state, val_index) || lua_isnil(state, val_index)) + out = lua_toboolean(state, val_index); + else if (lua_isnumber(state, val_index)) + out = lua_tointeger(state, val_index) != 0; + else + LuaWrapper::field_error(state, fname_idx, "boolean or number expected", "write"); } - virtual bool get_item(void *ptr, int idx) const { - return (*(container*)ptr)[idx]; + template requires (std::integral && !std::same_as) + inline void lua_write_value(lua_State *state, int fname_idx, T &out, int val_index) { + int is_num = 0; + auto value = lua_tointegerx(state, val_index, &is_num); + if (!is_num) + LuaWrapper::field_error(state, fname_idx, "integer expected", "write"); + out = T(value); } - virtual void set_item(void *ptr, int idx, bool val) const { - (*(container*)ptr)[idx] = val; + template requires std::floating_point + inline void lua_write_value(lua_State *state, int fname_idx, T &out, int val_index) { + if (!lua_isnumber(state, val_index)) + LuaWrapper::field_error(state, fname_idx, "number expected", "write"); + out = T(lua_tonumber(state, val_index)); } - }; + template requires std::is_enum_v + inline void lua_write_value(lua_State *state, int fname_idx, T &out, int val_index) { + std::underlying_type_t tmp; + lua_write_value(state, fname_idx, tmp, val_index); + out = T(tmp); + } + template requires c_string + inline void lua_write_value(lua_State *state, int fname_idx, T &, int) { + LuaWrapper::field_error(state, fname_idx, "raw pointer string", "write"); + } + template requires stl_string + inline void lua_write_value(lua_State *state, int fname_idx, T &out, int val_index) { + size_t size; + const char *bytes = lua_tolstring(state, val_index, &size); + if (!bytes) + LuaWrapper::field_error(state, fname_idx, "string expected", "write"); + out.assign(bytes, size); + } + template requires fs_path + inline void lua_write_value(lua_State *state, int fname_idx, T &out, int val_index) { + lua_write_path(state, fname_idx, &out, val_index); + } + } + + /* + * The identity of the C++ type T. The base class is selected by + * compile-time inspection of T (or an explicit df_identity_base + * typedef in T), and the remaining behavior is implemented here + * with constexpr dispatch on the properties of T. + */ + template + class type_identity_for : public identity_base_of_t { + using base = identity_base_of_t; -#ifdef BUILD_DFHACK_LIB - template - class enum_list_attr_identity : public container_identity { public: - using container = enum_list_attr; + // forwards everything to the base class constructor; covers all + // compound identities, arrays, and opaque types + template + requires (sizeof...(Args) > 0 && std::is_constructible_v) + explicit type_identity_for(Args&&... args) : base(std::forward(args)...) {} + + // numbers take only a name; the size comes from T + explicit type_identity_for(const char *name) + requires std::is_same_v + : base(sizeof(T), name) {} + + // C strings + type_identity_for() + requires detail::c_string + : base(sizeof(T)) {} + + // std::string and std::filesystem::path + type_identity_for() + requires (detail::stl_string || detail::fs_path) + : base(sizeof(T), &df::allocator_fn) {} + + // pointers derive the target from identity_traits, or take an + // explicit target (used for void*) + type_identity_for() + requires (std::is_pointer_v && std::is_same_v) + : base(df::identity_traits>::get()) {} + + // named sequence/set containers + type_identity_for(const char *name, const type_identity *item, const enum_identity *ienum = NULL) + requires (std::is_same_v> && + !detail::bit_container && !detail::mapped) + : base(name, item, ienum) {} + + // named mapped containers additionally take a key identity + type_identity_for(const char *name, const type_identity *key, const type_identity *item) + requires detail::mapped + : base(name, key, item) {} + + // containers of pointer elements + type_identity_for(const type_identity *item = NULL, const enum_identity *ienum = NULL) + requires detail::ptr_container + : base("vector", item, ienum) {} + + // std::vector and BitArray + type_identity_for(const enum_identity *ienum = NULL) + requires detail::bit_container + : base("vector", ienum) {} + + // the global object wraps no actual type + explicit type_identity_for(const struct_field_info *fields) + requires std::is_same_v + : base(0, NULL, NULL, "global", NULL, fields) {} + + virtual identity_type type() const override { + if constexpr (requires { detail::type_override(); }) + return detail::type_override(); + else + return base::type(); + } - enum_list_attr_identity(const type_identity *item) - : container_identity(sizeof(container), NULL, item, NULL) - {} + virtual const std::string getFullName() const override { + if constexpr (requires { detail::name_override(); }) + return detail::name_override(); + else + return base::getFullName(); + } - const std::string getFullName(const type_identity *item) const { - return "enum_list_attr" + container_identity::getFullName(item); + virtual bool isPrimitive() const override { + if constexpr (requires { detail::primitive_override(); }) + return detail::primitive_override(); + else + return base::isPrimitive(); } - protected: - virtual int item_count(void *ptr, CountMode cm) const { - return cm == COUNT_WRITE ? 0 : (int)((container*)ptr)->size; + // isInteger only exists in number_identity_base; for other bases + // this is an ordinary member function, and it implicitly overrides + // the virtual for numbers. + bool isInteger() const { + return std::is_integral_v && !std::is_same_v; + } + + virtual void lua_read(lua_State *state, int fname_idx, void *ptr) const override { + if constexpr (requires { detail::lua_read_value(state, *(const T*)ptr); }) + detail::lua_read_value(state, *(const T*)ptr); + else + base::lua_read(state, fname_idx, ptr); } - virtual void *item_pointer(const type_identity *item, void *ptr, int idx) const { - return (void*)&((container*)ptr)->items[idx]; + + virtual void lua_write(lua_State *state, int fname_idx, void *ptr, int val_index) const override { + if constexpr (requires { detail::lua_write_value(state, fname_idx, *(T*)ptr, val_index); }) + detail::lua_write_value(state, fname_idx, *(T*)ptr, val_index); + else + base::lua_write(state, fname_idx, ptr, val_index); } }; -#endif +} + +namespace df +{ + using DFHack::type_identity_for; -#define NUMBER_IDENTITY_TRAITS(category, type) \ +#define NUMBER_IDENTITY_TRAITS(type) \ template<> struct DFHACK_EXPORT identity_traits { \ static const bool is_primitive = true; \ - static const category##_identity identity; \ - static const category##_identity_base *get() { return &identity; } \ + static const type_identity_for identity; \ + static const type_identity_for *get() { return &identity; } \ }; -#define INTEGER_IDENTITY_TRAITS(type) NUMBER_IDENTITY_TRAITS(integer, type) -#define FLOAT_IDENTITY_TRAITS(type) NUMBER_IDENTITY_TRAITS(float, type) - // the space after the use of "type" in OPAQUE_IDENTITY_TRAITS is _required_ // without it the macro generates a syntax error when type is a template specification #define OPAQUE_IDENTITY_TRAITS(...) \ template<> struct DFHACK_EXPORT identity_traits<__VA_ARGS__ > { \ - static const opaque_identity identity; \ - static const opaque_identity *get() { return &identity; } \ - }; - - INTEGER_IDENTITY_TRAITS(char); - INTEGER_IDENTITY_TRAITS(signed char); - INTEGER_IDENTITY_TRAITS(unsigned char); - INTEGER_IDENTITY_TRAITS(short); - INTEGER_IDENTITY_TRAITS(unsigned short); - INTEGER_IDENTITY_TRAITS(int); - INTEGER_IDENTITY_TRAITS(unsigned int); - INTEGER_IDENTITY_TRAITS(long); - INTEGER_IDENTITY_TRAITS(unsigned long); - INTEGER_IDENTITY_TRAITS(long long); - INTEGER_IDENTITY_TRAITS(unsigned long long); - FLOAT_IDENTITY_TRAITS(float); - FLOAT_IDENTITY_TRAITS(double); + static const type_identity_for<__VA_ARGS__ > identity; \ + static const type_identity_for<__VA_ARGS__ > *get() { return &identity; } \ + }; + + NUMBER_IDENTITY_TRAITS(char); + NUMBER_IDENTITY_TRAITS(signed char); + NUMBER_IDENTITY_TRAITS(unsigned char); + NUMBER_IDENTITY_TRAITS(short); + NUMBER_IDENTITY_TRAITS(unsigned short); + NUMBER_IDENTITY_TRAITS(int); + NUMBER_IDENTITY_TRAITS(unsigned int); + NUMBER_IDENTITY_TRAITS(long); + NUMBER_IDENTITY_TRAITS(unsigned long); + NUMBER_IDENTITY_TRAITS(long long); + NUMBER_IDENTITY_TRAITS(unsigned long long); + NUMBER_IDENTITY_TRAITS(wchar_t); + NUMBER_IDENTITY_TRAITS(float); + NUMBER_IDENTITY_TRAITS(double); + NUMBER_IDENTITY_TRAITS(bool); + OPAQUE_IDENTITY_TRAITS(wchar_t*); OPAQUE_IDENTITY_TRAITS(std::condition_variable); OPAQUE_IDENTITY_TRAITS(std::fstream); OPAQUE_IDENTITY_TRAITS(std::mutex); @@ -630,6 +891,7 @@ namespace df OPAQUE_IDENTITY_TRAITS(std::optional >); OPAQUE_IDENTITY_TRAITS(std::variant >); OPAQUE_IDENTITY_TRAITS(std::weak_ptr); + OPAQUE_IDENTITY_TRAITS(std::filesystem::file_time_type); #ifdef BUILD_DFHACK_LIB template @@ -643,54 +905,46 @@ namespace df }; #endif - template<> struct DFHACK_EXPORT identity_traits { - static const bool is_primitive = true; - static const bool_identity identity; - static const bool_identity *get() { return &identity; } - }; - template<> struct DFHACK_EXPORT identity_traits { static const bool is_primitive = true; - static const stl_string_identity identity; - static const stl_string_identity *get() { return &identity; } + static const type_identity_for identity; + static const type_identity_for *get() { return &identity; } }; template<> struct DFHACK_EXPORT identity_traits { static const bool is_primitive = true; - static const path_identity identity; - static const path_identity* get() { return &identity; } + static const type_identity_for identity; + static const type_identity_for* get() { return &identity; } }; template<> struct DFHACK_EXPORT identity_traits { static const bool is_primitive = true; - static const ptr_string_identity identity; - static const ptr_string_identity *get() { return &identity; } + static const type_identity_for identity; + static const type_identity_for *get() { return &identity; } }; template<> struct DFHACK_EXPORT identity_traits { static const bool is_primitive = true; - static const ptr_string_identity identity; - static const ptr_string_identity *get() { return &identity; } + static const type_identity_for identity; + static const type_identity_for *get() { return &identity; } }; template<> struct DFHACK_EXPORT identity_traits { static const bool is_primitive = true; - static const pointer_identity identity; - static const pointer_identity *get() { return &identity; } + static const type_identity_for identity; + static const type_identity_for *get() { return &identity; } }; template<> struct DFHACK_EXPORT identity_traits > { - static const stl_ptr_vector_identity identity; - static const stl_ptr_vector_identity *get() { return &identity; } + static const type_identity_for > identity; + static const type_identity_for > *get() { return &identity; } }; template<> struct DFHACK_EXPORT identity_traits > { - static const stl_bit_vector_identity identity; - static const stl_bit_vector_identity *get() { return &identity; } + static const type_identity_for > identity; + static const type_identity_for > *get() { return &identity; } }; #undef NUMBER_IDENTITY_TRAITS -#undef INTEGER_IDENTITY_TRAITS -#undef FLOAT_IDENTITY_TRAITS #undef OPAQUE_IDENTITY_TRAITS // Container declarations @@ -703,7 +957,7 @@ namespace df template struct identity_traits { static const bool is_primitive = true; - static const pointer_identity *get(); + static const pointer_identity_base *get(); }; #ifdef BUILD_DFHACK_LIB @@ -711,13 +965,18 @@ namespace df static const container_identity *get(); }; + template struct identity_traits> + { + static const container_identity* get(); + }; + template struct identity_traits > { static const container_identity *get(); }; #endif template struct identity_traits > { - static const stl_ptr_vector_identity *get(); + static const ptr_container_identity *get(); }; // explicit specializations for these two types @@ -755,7 +1014,7 @@ namespace df }; template<> struct identity_traits > { - static const bit_array_identity identity; + static const type_identity_for > identity; static const bit_container_identity *get() { return &identity; } }; @@ -777,47 +1036,53 @@ namespace df #ifdef BUILD_DFHACK_LIB template inline const enum_identity *identity_traits >::get() { - static const enum_identity identity(identity_traits::get(), identity_traits::get()); + static const type_identity_for > identity(identity_traits::get(), identity_traits::get()); return &identity; } #endif template - inline const pointer_identity *identity_traits::get() { - static const pointer_identity identity(identity_traits::get()); + inline const pointer_identity_base *identity_traits::get() { + static const type_identity_for identity; return &identity; } #ifdef BUILD_DFHACK_LIB template inline const container_identity *identity_traits::get() { - static const buffer_container_identity identity(sz, identity_traits::get()); + static const type_identity_for identity(sz, identity_traits::get()); + return &identity; + } + + template + inline const container_identity* identity_traits>::get() + { + static const type_identity_for > identity(static_cast(sz), df::identity_traits::get()); return &identity; } template inline const container_identity *identity_traits >::get() { - using container = std::vector; - static const stl_container_identity identity("vector", identity_traits::get()); + static const type_identity_for > identity("vector", identity_traits::get()); return &identity; } #endif template - inline const stl_ptr_vector_identity *identity_traits >::get() { - static const stl_ptr_vector_identity identity(identity_traits::get()); + inline const ptr_container_identity *identity_traits >::get() { + static const type_identity_for > identity(identity_traits::get()); return &identity; } // explicit specializations for these two types // for availability in plugins - extern const DFHACK_EXPORT stl_container_identity > stl_vector_int32_t_identity; + extern const DFHACK_EXPORT type_identity_for > stl_vector_int32_t_identity; inline const container_identity* identity_traits >::get() { return &stl_vector_int32_t_identity; } - extern const DFHACK_EXPORT stl_container_identity > stl_vector_int16_t_identity; + extern const DFHACK_EXPORT type_identity_for > stl_vector_int16_t_identity; inline const container_identity* identity_traits >::get() { return &stl_vector_int16_t_identity; } @@ -825,56 +1090,50 @@ namespace df #ifdef BUILD_DFHACK_LIB template inline const container_identity *identity_traits >::get() { - using container = std::deque; - static const stl_container_identity identity("deque", identity_traits::get()); + static const type_identity_for > identity("deque", identity_traits::get()); return &identity; } template inline const container_identity *identity_traits >::get() { - using container = std::set; - static const ro_stl_container_identity identity("set", identity_traits::get()); + static const type_identity_for > identity("set", identity_traits::get()); return &identity; } template inline const container_identity* identity_traits >::get() { - using container = std::unordered_set; - static const ro_stl_container_identity identity("unordered_set", identity_traits::get()); + static const type_identity_for > identity("unordered_set", identity_traits::get()); return &identity; } template inline const container_identity *identity_traits>::get() { - using container = std::map; - static const ro_stl_assoc_container_identity identity("map", identity_traits::get(), identity_traits::get()); + static const type_identity_for > identity("map", identity_traits::get(), identity_traits::get()); return &identity; } template inline const container_identity *identity_traits>::get() { - using container = std::unordered_map; - static const ro_stl_assoc_container_identity identity("unordered_map", identity_traits::get(), identity_traits::get()); + static const type_identity_for > identity("unordered_map", identity_traits::get(), identity_traits::get()); return &identity; } template inline const bit_container_identity *identity_traits >::get() { - static const bit_array_identity identity(identity_traits::get()); + static const type_identity_for > identity(identity_traits::get()); return &identity; } template inline const container_identity *identity_traits >::get() { - using container = DfArray; - static const stl_container_identity identity("DfArray", identity_traits::get()); + static const type_identity_for > identity("DfArray", identity_traits::get()); return &identity; } template inline const container_identity *identity_traits >::get() { - static const enum_list_attr_identity identity(identity_traits::get()); + static const type_identity_for > identity("enum_list_attr", identity_traits::get()); return &identity; } #endif diff --git a/library/include/Debug.h b/library/include/Debug.h index 48e661acc4..632d4910b6 100644 --- a/library/include/Debug.h +++ b/library/include/Debug.h @@ -183,7 +183,7 @@ class DFHACK_EXPORT DebugCategory final { }; /*! - * Fetch a steam object proxy object for output. It also adds standard + * Fetch a stream object proxy object for output. It also adds standard * message components like time and plugin and category names to the line. * * User must make sure that the line is terminated with a line end. @@ -194,6 +194,13 @@ class DFHACK_EXPORT DebugCategory final { */ ostream_proxy_prefix getStream(const level msgLevel) const { + // if the core instance is unavailable, use stderr as a fallback + if (Core::noInstance()) + { + static color_ostream_wrapper fallback{std::cerr}; + return {*this,fallback,msgLevel}; + } + return {*this,Core::getInstance().getConsole(),msgLevel}; } /*! diff --git a/library/include/HashUtil.h b/library/include/HashUtil.h new file mode 100644 index 0000000000..7e2619d504 --- /dev/null +++ b/library/include/HashUtil.h @@ -0,0 +1,46 @@ +#ifndef HASHUTIL_H +#define HASHUTIL_H + +// some utility classes for dealing with hashes + +namespace +{ + // borrowed from Boost + template + inline void hash_combine(std::size_t& seed, const T& v) + { + std::hash hasher; + seed ^= hasher(v) + 0x9e3779b97f4a7c15ull + (seed << 12) + (seed >> 4); + } + + template + inline std::size_t hash_value(const Args&... args) + { + std::size_t seed = 0; + (hash_combine(seed, args), ...); + return seed; + } + + template + struct overload : Bases ... + { + using is_transparent = void; + using Bases::operator() ...; + }; + + struct char_pointer_hash + { + auto operator()(const char* ptr) const noexcept + { + return std::hash{}(ptr); + } + }; + + using transparent_string_hash = overload< + std::hash, + std::hash, + char_pointer_hash + >; +} + +#endif diff --git a/library/include/Hooks.h b/library/include/Hooks.h index 6945de2ea6..5f49d8daba 100644 --- a/library/include/Hooks.h +++ b/library/include/Hooks.h @@ -26,6 +26,7 @@ distribution. union SDL_Event; +DFhackCExport void dfhooks_preinit(std::filesystem::path dllpath); DFhackCExport void dfhooks_init(); DFhackCExport void dfhooks_shutdown(); DFhackCExport void dfhooks_update(); diff --git a/library/include/LuaTools.h b/library/include/LuaTools.h index 93853468e4..f929c562f3 100644 --- a/library/include/LuaTools.h +++ b/library/include/LuaTools.h @@ -24,20 +24,24 @@ distribution. #pragma once +#include #include -#include -#include -#include #include +#include +#include +#include #include #include #include -#include +#include #include "Core.h" #include "ColorText.h" #include "DataDefs.h" +#include "DataIdentity.h" +#include "df/coord.h" +#include "df/coord2d.h" #include "df/interface_key.h" #include @@ -287,7 +291,7 @@ namespace DFHack::Lua { * Uses RunCoreQueryLoop internally. */ DFHACK_EXPORT bool InterpreterLoop(color_ostream &out, lua_State *state, - const char *prompt = NULL, const char *hfile = NULL); + std::string_view prompt = {}, std::filesystem::path hfile = {}); /** * Run an interactive prompt loop. All access to the lua state diff --git a/library/include/LuaWrapper.h b/library/include/LuaWrapper.h index 7576be7a11..9c9c3d6ee7 100644 --- a/library/include/LuaWrapper.h +++ b/library/include/LuaWrapper.h @@ -25,7 +25,8 @@ distribution. #pragma once #include -#include + +#include "DataDefs.h" /** * Internal header file of the lua wrapper. @@ -138,13 +139,13 @@ namespace LuaWrapper { /** * Report an error while accessing a field (index = field name). */ - [[noreturn]] void field_error(lua_State *state, int index, const char *err, const char *mode); + [[noreturn]] DFHACK_EXPORT void field_error(lua_State *state, int index, const char *err, const char *mode); /* * If is_method is true, these use UPVAL_TYPETABLE to save a hash lookup. */ void push_object_internal(lua_State *state, const type_identity *type, void *ptr, bool in_method = true); - void *get_object_internal(lua_State *state, const type_identity *type, int val_index, bool exact_type, bool in_method = true); + DFHACK_EXPORT void *get_object_internal(lua_State *state, const type_identity *type, int val_index, bool exact_type, bool in_method = true); void push_adhoc_pointer(lua_State *state, void *ptr, const type_identity *target); @@ -235,5 +236,7 @@ namespace LuaWrapper { void IndexStatics(lua_State *state, int meta_idx, int ftable_idx, struct_identity *pstruct); - void AttachDFGlobals(lua_State *state); + DFHACK_EXPORT void AttachDFGlobals(lua_State *state); + + DFHACK_EXPORT void notify_deprecated(lua_State* state, std::string_view name, std::string_view message); }} diff --git a/library/include/MiscUtils.h b/library/include/MiscUtils.h index e8d904e81b..2676bb3d49 100644 --- a/library/include/MiscUtils.h +++ b/library/include/MiscUtils.h @@ -413,6 +413,42 @@ inline bool static_add_to_map(CT *pmap, const typename CT::key_type key, const t /* * MISC */ +template< + std::ranges::input_range Range, + std::invocable> Callable> +void print_range( + std::ostream &out, + const Range &elements, + Callable&& print_element, + const std::string_view prefix = "[", + const std::string_view separator = ", ", + const std::string_view suffix = "]" +){ + out << prefix; + auto it = std::ranges::begin(elements); + auto end = std::ranges::end(elements); + + if (it != end) { + print_element(out, *it); + for (++it; it != end; ++it) { + out << separator; + print_element(out, *it); + } + } + out << suffix; +} + +template +void print_range( + std::ostream &out, + const Range& elements, + const std::string_view prefix = "[", + const std::string_view separator = ", ", + const std::string_view suffix = "]" +){ + auto print_element = [](std::ostream &out, auto& e) { out << e; }; + print_range(out, elements, print_element, prefix, separator, suffix); +} DFHACK_EXPORT bool split_string(std::vector *out, const std::string &str, const std::string &separator, diff --git a/library/include/Module.h b/library/include/Module.h deleted file mode 100644 index 7770e13970..0000000000 --- a/library/include/Module.h +++ /dev/null @@ -1,59 +0,0 @@ -/* -https://github.com/peterix/dfhack -Copyright (c) 2009-2012 Petr Mrázek (peterix@gmail.com) - -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. -*/ - -#pragma once - -#ifndef MODULE_H_INCLUDED -#define MODULE_H_INCLUDED - -#include "Export.h" -namespace DFHack -{ - /** - * The parent class for all DFHack modules - * \ingroup grp_modules - */ - class DFHACK_EXPORT Module - { - public: - virtual ~Module(){}; - virtual bool Start(){return true;};// default start... - virtual bool Finish() = 0;// everything should have a Finish() - /* - // should Context call Finish when Resume is called? - virtual bool OnResume() - { - Finish(); - return true; - }; - // Finish when map change is detected? - // TODO: implement - virtual bool OnMapChange() - { - return false; - }; - */ - }; -} -#endif //MODULE_H_INCLUDED diff --git a/library/include/ModuleFactory.h b/library/include/ModuleFactory.h deleted file mode 100644 index c99e7b3289..0000000000 --- a/library/include/ModuleFactory.h +++ /dev/null @@ -1,38 +0,0 @@ -/* -https://github.com/peterix/dfhack -Copyright (c) 2009-2012 Petr Mrázek (peterix@gmail.com) - -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. -*/ - -#pragma once - -#ifndef MODULE_FACTORY_H_INCLUDED -#define MODULE_FACTORY_H_INCLUDED - -#include - -namespace DFHack -{ - class Module; - std::unique_ptr createMaterials(); - std::unique_ptr createGraphic(); -} -#endif diff --git a/library/include/PluginLua.h b/library/include/PluginLua.h index 16d447b7bf..f04262c4d7 100644 --- a/library/include/PluginLua.h +++ b/library/include/PluginLua.h @@ -14,6 +14,6 @@ DFhackCExport const DFHack::EventReg plugin_lua_events[] = #define DFHACK_LUA_COMMAND(name) { #name, name } -#define DFHACK_LUA_FUNCTION(name) { #name, df::wrap_function(name,true) } +#define DFHACK_LUA_FUNCTION(name) { #name, df::wrap_function(name,#name,true) } #define DFHACK_LUA_EVENT(name) { #name, &name##_event } #define DFHACK_LUA_END { NULL, NULL } diff --git a/library/include/Types.h b/library/include/Types.h index f367946e56..17114faf6d 100644 --- a/library/include/Types.h +++ b/library/include/Types.h @@ -30,9 +30,11 @@ distribution. #include #include "Export.h" -#include "DataDefs.h" +#include "df/coord.h" +#include "df/coord2d.h" #include "df/general_ref_type.h" +#include "df/global_objects.h" #include "df/specific_ref_type.h" namespace df { @@ -47,16 +49,16 @@ namespace DFHack { int16_t type; int32_t index; - bool operator<(const t_matglossPair &b) const + bool operator<(const t_matglossPair& b) const { if (type != b.type) return (type < b.type); return (index < b.index); } - bool operator==(const t_matglossPair &b) const + bool operator==(const t_matglossPair& b) const { return (type == b.type) && (index == b.index); } - bool operator!=(const t_matglossPair &b) const + bool operator!=(const t_matglossPair& b) const { return (type != b.type) || (index != b.index); } @@ -95,10 +97,14 @@ namespace DFHack std::string name; uint32_t xpNxtLvl; }; +} - typedef std::pair rect2d; +namespace DFHack +{ + using rect2d = std::pair; - inline rect2d intersect(rect2d a, rect2d b) { + inline rect2d intersect(rect2d a, rect2d b) + { df::coord2d g1 = a.first, g2 = a.second; df::coord2d c1 = b.first, c2 = b.second; df::coord2d rc1 = df::coord2d(std::max(g1.x, c1.x), std::max(g1.y, c1.y)); @@ -106,28 +112,70 @@ namespace DFHack return rect2d(rc1, rc2); } - inline rect2d mkrect_xy(int x1, int y1, int x2, int y2) { + inline rect2d mkrect_xy(int x1, int y1, int x2, int y2) + { return rect2d(df::coord2d(x1, y1), df::coord2d(x2, y2)); } - inline rect2d mkrect_wh(int x, int y, int w, int h) { - return rect2d(df::coord2d(x, y), df::coord2d(x+w-1, y+h-1)); + inline rect2d mkrect_wh(int x, int y, int w, int h) + { + return rect2d(df::coord2d(x, y), df::coord2d(x + w - 1, y + h - 1)); } - inline df::coord2d rect_size(const rect2d &rect) { - return rect.second - rect.first + df::coord2d(1,1); + inline df::coord2d rect_size(const rect2d& rect) + { + return rect.second - rect.first + df::coord2d(1, 1); } - DFHACK_EXPORT int getdir(std::filesystem::path dir, std::vector &files); - DFHACK_EXPORT bool hasEnding (std::string const &fullString, std::string const &ending); + DFHACK_EXPORT int getdir(std::filesystem::path dir, std::vector& files); + DFHACK_EXPORT bool hasEnding(std::string const& fullString, std::string const& ending); - DFHACK_EXPORT df::general_ref *findRef(std::vector &vec, df::general_ref_type type); - DFHACK_EXPORT bool removeRef(std::vector &vec, df::general_ref_type type, int id); + DFHACK_EXPORT df::general_ref* findRef(std::vector& vec, df::general_ref_type type); + DFHACK_EXPORT bool removeRef(std::vector& vec, df::general_ref_type type, int id); - DFHACK_EXPORT df::item *findItemRef(std::vector &vec, df::general_ref_type type); - DFHACK_EXPORT df::building *findBuildingRef(std::vector &vec, df::general_ref_type type); - DFHACK_EXPORT df::unit *findUnitRef(std::vector &vec, df::general_ref_type type); + DFHACK_EXPORT df::item* findItemRef(std::vector& vec, df::general_ref_type type); + DFHACK_EXPORT df::building* findBuildingRef(std::vector& vec, df::general_ref_type type); + DFHACK_EXPORT df::unit* findUnitRef(std::vector& vec, df::general_ref_type type); - DFHACK_EXPORT df::specific_ref *findRef(std::vector &vec, df::specific_ref_type type); - DFHACK_EXPORT bool removeRef(std::vector &vec, df::specific_ref_type type, void *ptr); -}// namespace DFHack + DFHACK_EXPORT df::specific_ref* findRef(std::vector& vec, df::specific_ref_type type); + DFHACK_EXPORT bool removeRef(std::vector& vec, df::specific_ref_type type, void* ptr); + +} + +// A couple of headers that have to be included at once +#include "df/coord2d.h" +#include "df/coord.h" + +namespace std +{ + template <> + struct hash + { + std::size_t operator()(const df::coord& c) const + { + return c(); + } + }; +} + +template <> +struct fmt::formatter : fmt::formatter +{ + template + auto format(const df::coord& c, FormatContext& ctx) const + { + return fmt::formatter::format( + fmt::format("({}, {}, {})", c.x, c.y, c.z), ctx); + } +}; + +template <> +struct fmt::formatter : fmt::formatter +{ + template + auto format(const df::coord2d& c, FormatContext& ctx) const + { + return fmt::formatter::format( + fmt::format("({}, {})", c.x, c.y), ctx); + } +}; diff --git a/library/include/VersionInfoFactory.h b/library/include/VersionInfoFactory.h index 060d622ecd..92a5f94e10 100644 --- a/library/include/VersionInfoFactory.h +++ b/library/include/VersionInfoFactory.h @@ -26,6 +26,7 @@ distribution. #pragma once #include +#include #include "Export.h" @@ -38,7 +39,7 @@ namespace DFHack public: VersionInfoFactory(); ~VersionInfoFactory(); - bool loadFile( std::string path_to_xml); + bool loadFile( std::filesystem::path path_to_xml); bool isInErrorState() const {return error;}; std::shared_ptr getVersionInfoByMD5(std::string md5string) const; std::shared_ptr getVersionInfoByPETimestamp(uintptr_t timestamp) const; diff --git a/library/include/df/custom/coord.methods.inc b/library/include/df/custom/coord.methods.inc index 77dbb766e3..9f1eede717 100644 --- a/library/include/df/custom/coord.methods.inc +++ b/library/include/df/custom/coord.methods.inc @@ -1,5 +1,5 @@ -coord(const coord2d &c, uint16_t _z) : x(c.x), y(c.y), z(_z) {} -coord(uint16_t _x, uint16_t _y, uint16_t _z) : x(_x), y(_y), z(_z) {} +coord(const coord2d &c, int16_t _z) : x(c.x), y(c.y), z(_z) {} +coord(int16_t _x, int16_t _y, int16_t _z) : x(_x), y(_y), z(_z) {} operator coord2d() const { return coord2d(x,y); } diff --git a/library/include/df/custom/coord2d.methods.inc b/library/include/df/custom/coord2d.methods.inc index 512149ce36..c828b0c47e 100644 --- a/library/include/df/custom/coord2d.methods.inc +++ b/library/include/df/custom/coord2d.methods.inc @@ -1,4 +1,4 @@ -coord2d(uint16_t _x, uint16_t _y) : x(_x), y(_y) {} +coord2d(int16_t _x, int16_t _y) : x(_x), y(_y) {} bool isValid() const { return x >= 0; } void clear() { x = y = -30000; } diff --git a/library/include/df/custom/tile_bitmask.methods.inc b/library/include/df/custom/tile_bitmask.methods.inc index b991819b94..ade00e7218 100644 --- a/library/include/df/custom/tile_bitmask.methods.inc +++ b/library/include/df/custom/tile_bitmask.methods.inc @@ -6,11 +6,11 @@ inline uint16_t &operator[] (int y) } void clear() { - memset(bits,0,sizeof(bits)); + bits.fill(0); } void set_all() { - memset(bits,0xFF,sizeof(bits)); + bits.fill(-1); } inline bool getassignment( const df::coord2d &xy ) { diff --git a/library/include/git-describe.h.in b/library/include/git-describe.h.in index 761fe4ca5f..60dd514887 100644 --- a/library/include/git-describe.h.in +++ b/library/include/git-describe.h.in @@ -1,7 +1,7 @@ -#cmakedefine DFHACK_GIT_DESCRIPTION "@DFHACK_GIT_DESCRIPTION@" -#cmakedefine DFHACK_GIT_COMMIT "@DFHACK_GIT_COMMIT@" -#cmakedefine DFHACK_GIT_XML_EXPECTED_COMMIT "@DFHACK_GIT_XML_EXPECTED_COMMIT@" -#cmakedefine DFHACK_GIT_XML_COMMIT "@DFHACK_GIT_XML_COMMIT@" +#define DFHACK_GIT_DESCRIPTION "@DFHACK_GIT_DESCRIPTION@" +#define DFHACK_GIT_COMMIT "@DFHACK_GIT_COMMIT@" +#define DFHACK_GIT_XML_EXPECTED_COMMIT "@DFHACK_GIT_XML_EXPECTED_COMMIT@" +#define DFHACK_GIT_XML_COMMIT "@DFHACK_GIT_XML_COMMIT@" #define DFHACK_BUILD_ID "@DFHACK_BUILD_ID@" #cmakedefine DFHACK_GIT_TAGGED #cmakedefine DFHACK_GIT_XML_MATCH diff --git a/library/include/modules/Burrows.h b/library/include/modules/Burrows.h index dbd2e51dd6..2662826aad 100644 --- a/library/include/modules/Burrows.h +++ b/library/include/modules/Burrows.h @@ -23,11 +23,15 @@ distribution. */ #pragma once + +#include + #include "Export.h" #include "DataDefs.h" #include "modules/Maps.h" -#include +#include "df/coord.h" +#include "df/coord2d.h" /** * \defgroup grp_burrows Burrows module and its types diff --git a/library/include/modules/DFSDL.h b/library/include/modules/DFSDL.h index 393877e095..95837b6889 100644 --- a/library/include/modules/DFSDL.h +++ b/library/include/modules/DFSDL.h @@ -1,12 +1,13 @@ #pragma once -#include "Export.h" #include "ColorText.h" +#include "Export.h" +#include "PluginManager.h" #include #include +#include #include -#include struct SDL_Surface; struct SDL_Rect; @@ -16,17 +17,6 @@ struct SDL_Window; union SDL_Event; using SDL_Keycode = int32_t; -namespace DFHack -{ - struct DFTileSurface - { - bool paintOver; // draw over original tile? - SDL_Surface* surface; // from where it should be drawn - SDL_Rect* rect; // from which coords (NULL to draw whole surface) - SDL_Rect* dstResize; // if not NULL dst rect will be resized (x/y/w/h will be added to original dst) - }; -} - /** * The DFSDL module - provides access to SDL functions without actually * requiring build-time linkage to SDL @@ -46,6 +36,13 @@ namespace DFHack::DFSDL */ void cleanup(); + /** + * Obtain DFHack's handle to the SDL or IMG libraries, in case a plugin needs + * to map a SDL API not mapped here + */ + DFHACK_EXPORT DFLibrary* obtain_library_handle(); + DFHACK_EXPORT DFLibrary* obtain_image_library_handle(); + DFHACK_EXPORT SDL_Surface* DFIMG_Load(const char* file); DFHACK_EXPORT SDL_Surface* DFSDL_CreateRGBSurface(uint32_t flags, int width, int height, int depth, uint32_t Rmask, uint32_t Gmask, uint32_t Bmask, uint32_t Amask); DFHACK_EXPORT SDL_Surface* DFSDL_CreateRGBSurfaceFrom(void* pixels, int width, int height, int depth, int pitch, uint32_t Rmask, uint32_t Gmask, uint32_t Bmask, uint32_t Amask); diff --git a/library/include/modules/DFSteam.h b/library/include/modules/DFSteam.h index e604294f8a..7080a94e2e 100644 --- a/library/include/modules/DFSteam.h +++ b/library/include/modules/DFSteam.h @@ -24,7 +24,7 @@ bool init(DFHack::color_ostream& out); /** * Call this when DFHack is being unloaded. */ -void cleanup(DFHack::color_ostream& out); +void cleanup(); DFHACK_EXPORT void launchSteamDFHackIfNecessary(DFHack::color_ostream& out); diff --git a/library/include/modules/Designations.h b/library/include/modules/Designations.h index 914605e58a..5eb0e7cb86 100644 --- a/library/include/modules/Designations.h +++ b/library/include/modules/Designations.h @@ -1,5 +1,7 @@ #pragma once +#include "df/coord.h" + namespace df { struct plant; } diff --git a/library/include/modules/Graphic.h b/library/include/modules/Graphic.h deleted file mode 100644 index 9fa498a7cf..0000000000 --- a/library/include/modules/Graphic.h +++ /dev/null @@ -1,59 +0,0 @@ -/* -https://github.com/peterix/dfhack -Copyright (c) 2009-2012 Petr Mr�zek (peterix@gmail.com) - -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. -*/ - -/******************************************************************************* - GRAPHIC - Changing tile cache -*******************************************************************************/ -#pragma once -#ifndef CL_MOD_GRAPHIC -#define CL_MOD_GRAPHIC - -#include -#include "Export.h" -#include "Module.h" -#include - -namespace DFHack -{ - // forward declaration used here instead of including DFSDL.h to reduce inclusion loading - struct DFTileSurface; - - class DFHACK_EXPORT Graphic : public Module - { - public: - bool Finish() - { - return true; - } - bool Register(DFTileSurface* (*func)(int,int)); - bool Unregister(DFTileSurface* (*func)(int,int)); - DFTileSurface* Call(int x, int y); - - private: - std::vector funcs; - }; -} - -#endif diff --git a/library/include/modules/Items.h b/library/include/modules/Items.h index b4235df11d..52af19e680 100644 --- a/library/include/modules/Items.h +++ b/library/include/modules/Items.h @@ -170,6 +170,9 @@ DFHACK_EXPORT int getItemBaseValue(int16_t item_type, int16_t item_subtype, int1 // Gets the value of a specific item, taking into account civ values and trade agreements if a caravan is given. DFHACK_EXPORT int getValue(df::item *item, df::caravan_state *caravan = NULL); +// Automatically choose a growth print variant for the specified plant growth subtype+material +DFHACK_EXPORT int32_t pickGrowthPrint(int16_t subtype, int16_t mat, int32_t matg); + DFHACK_EXPORT bool createItem(std::vector &out_items, df::unit *creator, df::item_type type, int16_t item_subtype, int16_t mat_type, int32_t mat_index, bool no_floor = false, int32_t count = 1); @@ -186,6 +189,9 @@ DFHACK_EXPORT bool markForTrade(df::item *item, df::building_tradedepotst *depot // Returns true if an active caravan will pay extra for the given item. DFHACK_EXPORT bool isRequestedTradeGood(df::item *item, df::caravan_state *caravan = NULL); +// DF standard_material_itemtype - returns true if item has material/matgloss, false if race+caste +DFHACK_EXPORT bool usesStandardMaterial(df::item_type item_type); + // Returns true if the item can currently be melted. If game_ui, then able to be marked is enough. DFHACK_EXPORT bool canMelt(df::item *item, bool game_ui = false); // Marks the item for melting. diff --git a/library/include/modules/Job.h b/library/include/modules/Job.h index 25c357bec7..acb37169c3 100644 --- a/library/include/modules/Job.h +++ b/library/include/modules/Job.h @@ -41,6 +41,7 @@ namespace df struct job_item_filter; struct building; struct unit; + struct manager_order; } namespace DFHack @@ -117,6 +118,7 @@ namespace DFHack int mat_index, df::item_type itype); DFHACK_EXPORT std::string getName(df::job *job); + DFHACK_EXPORT std::string getManagerOrderName(df::manager_order *order); struct JobDeleter { void operator()(df::job *ptr) const { diff --git a/library/include/modules/MapCache.h b/library/include/modules/MapCache.h index 0d63627389..d6f44b12d4 100644 --- a/library/include/modules/MapCache.h +++ b/library/include/modules/MapCache.h @@ -68,8 +68,8 @@ struct BiomeInfo { int16_t layer_stone[MAX_LAYERS]; }; -typedef uint8_t t_veintype[16][16]; -typedef df::tiletype t_tilearr[16][16]; +using t_veintype = arr40d; +using t_tilearr = arr40d; class BlockInfo { @@ -424,7 +424,7 @@ class DFHACK_EXPORT MapCache Block *BlockAt(DFCoord blockcoord); /// get the map block at a tile coord. Block *BlockAtTile(DFCoord coord) { - return BlockAt(df::coord(coord.x>>4,coord.y>>4,coord.z)); + return BlockAt(Maps::getTileBlockCoord(coord)); } bool ensureBlockAt(df::coord coord) @@ -433,6 +433,11 @@ class DFHACK_EXPORT MapCache return b ? b->Allocate() : false; } + /// Propagate the light, outside, and subterranean flags of the tile at + /// pos to the tiles below it. Call after the tile at pos changed + /// between an open and a solid shape. + void propagateVerticalFlags(DFCoord pos); + /// delete the block from memory void discardBlock(Block *block); @@ -616,6 +621,7 @@ class DFHACK_EXPORT MapCache uint32_t y_tmax; uint32_t z_max; std::vector biomes; + std::map biome_index; std::map region_details; std::map blocks; }; diff --git a/library/include/modules/Maps.h b/library/include/modules/Maps.h index 052dbe3aab..6d85b35648 100644 --- a/library/include/modules/Maps.h +++ b/library/include/modules/Maps.h @@ -34,12 +34,23 @@ distribution. #include "modules/Materials.h" +#include +#include +#include + #include "df/biome_type.h" #include "df/block_flags.h" +#include "df/coord.h" +#include "df/coord2d.h" #include "df/feature_type.h" +#include "df/flow_info.h" #include "df/flow_type.h" +#include "df/item_type.h" +#include "df/matter_state.h" #include "df/tile_dig_designation.h" #include "df/tiletype.h" +#include "df/weather_type.h" +#include "df/world_site.h" namespace df { struct block_square_event; @@ -125,28 +136,32 @@ enum BiomeOffset { */ typedef df::block_flags t_blockflags; +template +using arr40d = std::array, 16>; + /** * 16x16 array of tile types * \ingroup grp_maps */ -typedef df::tiletype tiletypes40d [16][16]; +using tiletypes40d = arr40d; /** * 16x16 array used for squashed block materials * \ingroup grp_maps */ -typedef int16_t t_blockmaterials [16][16]; +using t_blockmaterials = arr40d; /** * 16x16 array of designation flags * \ingroup grp_maps */ typedef df::tile_designation t_designation; -typedef t_designation designations40d [16][16]; +using designations40d = arr40d; + /** * 16x16 array of occupancy flags * \ingroup grp_maps */ typedef df::tile_occupancy t_occupancy; -typedef t_occupancy occupancies40d [16][16]; +using occupancies40d = arr40d; /** * array of 16 biome indexes valid for the block * \ingroup grp_maps @@ -156,7 +171,7 @@ typedef uint8_t biome_indices40d [9]; * 16x16 array of temperatures * \ingroup grp_maps */ -typedef uint16_t t_temperatures [16][16]; +using t_temperatures = arr40d; /** * Index a tile array by a 2D coordinate, clipping it to mod 16. @@ -338,6 +353,18 @@ inline df::map_block *getBlock (df::coord pos) { return getBlock(pos.x, pos.y, p inline df::map_block *getTileBlock (df::coord pos) { return getTileBlock(pos.x, pos.y, pos.z); } inline df::map_block *ensureTileBlock (df::coord pos) { return ensureTileBlock(pos.x, pos.y, pos.z); } +// Get the coordinate of the map block containing the given tile position. +// The z coordinate is a level and is preserved, not scaled. +inline df::coord getTileBlockCoord(df::coord pos) { return df::coord(pos.x >> 4, pos.y >> 4, pos.z); } + +// Get the tile position of a map block's origin (north-west corner), given the +// block's coordinate. The z coordinate is a level and is preserved, not scaled. +inline df::coord getBlockOrigin(df::coord pos) { return df::coord(pos.x << 4, pos.y << 4, pos.z); } + +// Get a tile position's offset within its containing map block. The returned +// x and y are in the range 0-15; z is preserved (blocks span a single level). +inline df::coord getTileBlockOffset(df::coord pos) { return df::coord(pos.x & 15, pos.y & 15, pos.z); } + extern DFHACK_EXPORT df::tiletype *getTileType(int32_t x, int32_t y, int32_t z); extern DFHACK_EXPORT df::tile_designation *getTileDesignation(int32_t x, int32_t y, int32_t z); extern DFHACK_EXPORT df::tile_occupancy *getTileOccupancy(int32_t x, int32_t y, int32_t z); @@ -346,14 +373,23 @@ inline df::tiletype *getTileType(df::coord pos) { return getTileType(pos.x, pos. inline df::tile_designation *getTileDesignation(df::coord pos) { return getTileDesignation(pos.x, pos.y, pos.z); } inline df::tile_occupancy *getTileOccupancy(df::coord pos) { return getTileOccupancy(pos.x, pos.y, pos.z); } +// shift world region coordinate by region_details biome reference +DFHACK_EXPORT df::coord2d addRegionBiomeOffset(df::coord2d world_pos, int8_t offset_dir); // Returns biome info about the specified world region. DFHACK_EXPORT df::region_map_entry *getRegionBiome(df::coord2d rgn_pos); +// Returns world region coordinates of the BiomeOffset neighbor of a world +// region cell, clipped to the world boundaries. Idx must be in 0-8. +DFHACK_EXPORT df::coord2d getBiomeRgnPos(df::coord2d rgn_pos, int idx); + // Returns biome world region coordinates for the given tile within given block. DFHACK_EXPORT df::coord2d getBlockTileBiomeRgn(df::map_block *block, df::coord2d pos); inline df::coord2d getTileBiomeRgn(df::coord pos) { return getBlockTileBiomeRgn(getTileBlock(pos), pos); } +DFHACK_EXPORT df::weather_type getCurrentWeather(df::coord pos); +DFHACK_EXPORT df::weather_type getCurrentWeather(); + // Enables per-frame updates for liquid flow and/or temperature. DFHACK_EXPORT void enableBlockUpdates(df::map_block *blk, bool flow = false, bool temperature = false); @@ -372,6 +408,10 @@ extern DFHACK_EXPORT bool SortBlockEvents(df::map_block *block, std::vector *priorities = 0 ); +// Add spatters at the specified location, returning the amount that couldn't be placed (e.g. due to overflow) +extern DFHACK_EXPORT int32_t addMaterialSpatter (df::coord pos, int16_t mat, int32_t matg, df::matter_state state, int32_t amount); +extern DFHACK_EXPORT int32_t addItemSpatter (df::coord pos, df::item_type i_type, int16_t i_subtype, int16_t i_subcat1, int32_t i_subcat2, int32_t print_variant, int32_t amount); + // Remove a block event from the block by address. extern DFHACK_EXPORT bool RemoveBlockEvent(int32_t x, int32_t y, int32_t z, df::block_square_event *which ); extern DFHACK_EXPORT bool RemoveBlockEvent(uint32_t x, uint32_t y, uint32_t z, df::block_square_event *which ); // TODO: deprecate me @@ -400,6 +440,125 @@ DFHACK_EXPORT bool removeTileAquifer(int32_t x, int32_t y, int32_t z); inline bool removeTileAquifer(df::coord pos) { return removeTileAquifer(pos.x, pos.y, pos.z); } DFHACK_EXPORT int removeAreaAquifer(df::coord pos1, df::coord pos2, std::function filter = [](df::coord pos, df::map_block *block) { return true; }); + +DFHACK_EXPORT void addBlockColumns(int32_t new_height); + +// Get surroundings classification from savagery and evilness +DFHACK_EXPORT const char* describeSurroundings(int savagery, int evilness); + +/** + * A single function does not merit a "Sites" module, hence we collect site functions here in the meantime. + */ + +// Get the classification string (e.g. "town", "hillocs", "tower", etc.) for a site +DFHACK_EXPORT const char* getSiteTypeName(df::world_site *site); + +/** + * Filter used by Maps::forEachTile to decide which tiles are acted on. + * A tile matches iff it satisfies every criterion that is enabled (non-empty + * set, non-zero mask, or a custom predicate that accepts it). A filter with + * no enabled criteria matches every tile. + * \ingroup grp_maps + */ +struct DFHACK_EXPORT TileFilter { + /// Allowed tiletypes; empty means any tiletype matches. + std::unordered_set tiletypes; + /// Allowed df::tiletype_material attribute values; empty means any. + std::unordered_set materials; + /// Allowed df::tiletype_shape attribute values; empty means any. + std::unordered_set shapes; + /// Allowed df::tiletype_shape_basic attribute values; empty means any. + std::unordered_set shapes_basic; + /// Allowed df::tiletype_special attribute values; empty means any. + std::unordered_set specials; + /// Allowed df::tiletype_variant attribute values; empty means any. + std::unordered_set variants; + /// Designation requirement: (designation.whole & mask) == bits. + /// A zero mask matches any designation. + uint32_t designation_mask = 0; + uint32_t designation_bits = 0; + /// Occupancy requirement: (occupancy.whole & mask) == bits. + /// A zero mask matches any occupancy. + uint32_t occupancy_mask = 0; + uint32_t occupancy_bits = 0; + /// Optional extra predicate, evaluated last for each candidate tile. + /// Arguments are the map block, the tile-local coordinates, and the + /// global coordinates. + std::function extra; + + /// Check whether the given tile matches this filter. + bool matches(df::map_block *block, df::coord2d local, df::coord pos) const; +}; + +/** + * Actions applied by Maps::forEachTile to every tile that passes the filter. + * All enabled actions run for each matching tile, in the order the fields + * are declared below; the callback runs last. + * \ingroup grp_maps + */ +struct DFHACK_EXPORT TileActions { + /// If set, matching tiles are changed to this tiletype, unless their + /// current tiletype is listed in tiletype_map (which takes precedence). + std::optional set_tiletype; + /// Per-tiletype replacements: a matching tile whose current tiletype is + /// a key in this map is changed to the mapped value instead of + /// set_tiletype. Tiles with unmapped tiletypes fall back to + /// set_tiletype if it is set, and are otherwise left unchanged. + std::unordered_map tiletype_map; + /// Designation update: whole = (whole & ~clear) | set. + uint32_t designation_set = 0; + uint32_t designation_clear = 0; + /// Occupancy update: whole = (whole & ~clear) | set. + uint32_t occupancy_set = 0; + uint32_t occupancy_clear = 0; + /// If true, a df::construction record is spawned at each matching tile + /// that does not already have one, using the fields below as a template. + bool construct = false; + /// Template fields for spawned constructions. + df::item_type construct_item_type = df::item_type::NONE; + int16_t construct_item_subtype = -1; + int16_t construct_mat_type = -1; + int32_t construct_mat_index = -1; + /// Raw value copied to df::construction::flags.whole. + uint8_t construct_flags = 0; + /// If set, the map tile's tiletype is rewritten to this when its + /// construction is spawned. The construction's original_tile records + /// the tiletype present immediately before this write. + std::optional construct_tiletype; + /// Called for each matching tile after the other actions are applied. + /// Return false to abort the scan early. + std::function callback; +}; + +/** + * Statistics produced by Maps::forEachTile. + * \ingroup grp_maps + */ +struct DFHACK_EXPORT TileScanResult { + /// Tiles examined inside allocated map blocks. + int64_t tiles_scanned = 0; + /// Tiles that matched the filter. + int64_t tiles_matched = 0; + /// Tiles whose tiletype was rewritten. + int64_t tiletypes_changed = 0; + /// Construction records created. + int64_t constructions_added = 0; + /// True if the scan stopped early because the callback returned false. + bool aborted = false; +}; + +/** + * Iterate every tile inside a cuboid of map tiles, test each one against a + * filter, and apply the enabled actions to the matching tiles. + * + * The cuboid is clamped to the loaded map; tiles in unallocated blocks are + * skipped. Iteration is block-major for speed. Constructions are buffered + * during the scan and merged into world.event.constructions in a single + * sorted pass at the end. TileScanResult::tiles_matched doubles as a + * count of matching tiles when no other action is enabled. + */ +DFHACK_EXPORT TileScanResult forEachTile( + const cuboid &bounds, const TileFilter &filter, const TileActions &actions); } } #endif diff --git a/library/include/modules/Materials.h b/library/include/modules/Materials.h index 2c3be4e731..652ed5fc2a 100644 --- a/library/include/modules/Materials.h +++ b/library/include/modules/Materials.h @@ -30,12 +30,13 @@ distribution. * @ingroup grp_modules */ #include "Export.h" -#include "Module.h" #include "DataDefs.h" #include "df/craft_material_class.h" +#include "df/item_type.h" -namespace df { +namespace df +{ struct item; struct plant_raw; struct creature_raw; @@ -54,28 +55,25 @@ namespace df { namespace DFHack { - struct t_matpair { + struct t_matpair + { int16_t mat_type; int32_t mat_index; t_matpair(int16_t type = -1, int32_t index = -1) - : mat_type(type), mat_index(index) {} + : mat_type(type), mat_index(index) + {} }; - struct DFHACK_EXPORT MaterialInfo { - static const int NUM_BUILTIN = 19; - static const int GROUP_SIZE = 200; - static const int CREATURE_BASE = NUM_BUILTIN; - static const int FIGURE_BASE = NUM_BUILTIN + GROUP_SIZE; - static const int PLANT_BASE = NUM_BUILTIN + GROUP_SIZE*2; - static const int END_BASE = NUM_BUILTIN + GROUP_SIZE*3; - + struct DFHACK_EXPORT MaterialInfo + { int16_t type; int32_t index; - df::material *material; + df::material* material; - enum Mode { + enum Mode + { None, Builtin, Inorganic, @@ -85,16 +83,16 @@ namespace DFHack Mode mode; int16_t subtype; - df::inorganic_raw *inorganic; - df::creature_raw *creature; - df::plant_raw *plant; + df::inorganic_raw* inorganic; + df::creature_raw* creature; + df::plant_raw* plant; - df::historical_figure *figure; + df::historical_figure* figure; public: MaterialInfo(int16_t type = -1, int32_t index = -1) { decode(type, index); } - MaterialInfo(const t_matpair &mp) { decode(mp.mat_type, mp.mat_index); } - template MaterialInfo(T *ptr) { decode(ptr); } + MaterialInfo(const t_matpair& mp) { decode(mp.mat_type, mp.mat_index); } + template MaterialInfo(T* ptr) { decode(ptr); } bool isValid() const { return material != NULL; } @@ -108,25 +106,27 @@ namespace DFHack bool isInorganicWildcard() const { return isAnyInorganic() && isBuiltin(); } bool decode(int16_t type, int32_t index = -1); - bool decode(df::item *item); - bool decode(const df::material_vec_ref &vr, int idx); - bool decode(const t_matpair &mp) { return decode(mp.mat_type, mp.mat_index); } + bool decode(df::item* item); + bool decode(const df::material_vec_ref& vr, int idx); + bool decode(const t_matpair& mp) { return decode(mp.mat_type, mp.mat_index); } - template bool decode(T *ptr) { + template bool decode(T* ptr) + { // Assume and exploit a certain naming convention return ptr ? decode(ptr->mat_type, ptr->mat_index) : decode(-1); } - bool find(const std::string &token); - bool find(const std::vector &tokens); + bool find(const std::string& token); + bool find(const std::vector& tokens); - bool findBuiltin(const std::string &token); - bool findInorganic(const std::string &token); - bool findPlant(const std::string &token, const std::string &subtoken); - bool findCreature(const std::string &token, const std::string &subtoken); + bool findBuiltin(const std::string& token); + bool findInorganic(const std::string& token); + bool findPlant(const std::string& token, const std::string& subtoken); + bool findCreature(const std::string& token, const std::string& subtoken); - bool findProduct(df::material *material, const std::string &name); - bool findProduct(const MaterialInfo &info, const std::string &name) { + bool findProduct(df::material* material, const std::string& name); + bool findProduct(const MaterialInfo& info, const std::string& name) + { return findProduct(info.material, name); } @@ -135,35 +135,38 @@ namespace DFHack bool isAnyCloth() const; - void getMatchBits(df::job_item_flags1 &ok, df::job_item_flags1 &mask) const; - void getMatchBits(df::job_item_flags2 &ok, df::job_item_flags2 &mask) const; - void getMatchBits(df::job_item_flags3 &ok, df::job_item_flags3 &mask) const; + void getMatchBits(df::job_item_flags1& ok, df::job_item_flags1& mask) const; + void getMatchBits(df::job_item_flags2& ok, df::job_item_flags2& mask) const; + void getMatchBits(df::job_item_flags3& ok, df::job_item_flags3& mask) const; df::craft_material_class getCraftClass(); - bool matches(const MaterialInfo &mat) const + bool matches(const MaterialInfo& mat) const { if (!mat.isValid()) return true; return (type == mat.type) && - (mat.index == -1 || index == mat.index); + (mat.index == -1 || index == mat.index); } - bool matches(const df::job_material_category &cat) const; - bool matches(const df::dfhack_material_category &cat) const; - bool matches(const df::job_item &jitem, - df::item_type itype = df::item_type::NONE) const; + bool matches(const df::job_material_category& cat) const; + bool matches(const df::dfhack_material_category& cat) const; + bool matches(const df::job_item& jitem, + df::item_type itype = df::item_type::NONE) const; }; - DFHACK_EXPORT bool parseJobMaterialCategory(df::job_material_category *cat, const std::string &token); - DFHACK_EXPORT bool parseJobMaterialCategory(df::dfhack_material_category *cat, const std::string &token); + DFHACK_EXPORT bool parseJobMaterialCategory(df::job_material_category* cat, const std::string& token); + DFHACK_EXPORT bool parseJobMaterialCategory(df::dfhack_material_category* cat, const std::string& token); - inline bool operator== (const MaterialInfo &a, const MaterialInfo &b) { + inline bool operator== (const MaterialInfo& a, const MaterialInfo& b) + { return a.type == b.type && a.index == b.index; } - inline bool operator!= (const MaterialInfo &a, const MaterialInfo &b) { + inline bool operator!= (const MaterialInfo& a, const MaterialInfo& b) + { return a.type != b.type || a.index != b.index; } - inline bool operator< (const MaterialInfo &a, const MaterialInfo &b) { + inline bool operator< (const MaterialInfo& a, const MaterialInfo& b) + { return a.type < b.type || (a.type == b.type && a.index < b.index); } @@ -345,38 +348,18 @@ namespace DFHack t_materialIndex mat_index; uint32_t flags; }; - /** - * The Materials module - * \ingroup grp_modules - * \ingroup grp_materials - */ - class DFHACK_EXPORT Materials : public Module + + + namespace Materials { - public: - Materials(); - ~Materials(); - bool Finish(); - - std::vector race; - std::vector raceEx; - std::vector color; - std::vector other; - std::vector alldesc; - - bool CopyInorganicMaterials (std::vector & inorganic); - bool CopyOrganicMaterials (std::vector & organic); - bool CopyWoodMaterials (std::vector & tree); - bool CopyPlantMaterials (std::vector & plant); - - bool ReadCreatureTypes (void); - bool ReadCreatureTypesEx (void); - bool ReadDescriptorColors(void); - bool ReadOthers (void); - - bool ReadAllMaterials(void); - - std::string getType(const t_material & mat); - std::string getDescription(const t_material & mat); - }; + /** + * The Materials module + * \ingroup grp_modules + * \ingroup grp_materials + */ + + std::string getType(const t_material& mat); + std::string getDescription(const t_material& mat); + } } #endif diff --git a/library/include/modules/Screen.h b/library/include/modules/Screen.h index 1a6ab569ad..173cda6aa8 100644 --- a/library/include/modules/Screen.h +++ b/library/include/modules/Screen.h @@ -32,6 +32,7 @@ distribution. #include "df/viewscreen.h" #include "df/graphic_viewportst.h" +#include "df/graphic_map_portst.h" #include #include @@ -200,6 +201,12 @@ namespace DFHack /// Retrieves one screen tile from the buffer DFHACK_EXPORT Pen readTile(int x, int y, bool map = false, int32_t * df::graphic_viewportst::*texpos_field = NULL); + /// Paint one world map tile with the given pen + DFHACK_EXPORT bool paintMapPortTile(const Pen &pen, int x, int y, int32_t * df::graphic_map_portst::*texpos_field = NULL); + + /// Retrieves one world map tile from the buffer + DFHACK_EXPORT Pen readMapPortTile(int x, int y, int32_t * df::graphic_map_portst::*texpos_field); + /// Paint a string onto the screen. Ignores ch and tile of pen. DFHACK_EXPORT bool paintString(const Pen &pen, int x, int y, const std::string &text, bool map = false); diff --git a/library/include/modules/Textures.h b/library/include/modules/Textures.h index b820c3332d..c2038c20b3 100644 --- a/library/include/modules/Textures.h +++ b/library/include/modules/Textures.h @@ -32,7 +32,7 @@ DFHACK_EXPORT TexposHandle loadTexture(SDL_Surface* surface, bool reserved = fal * Load tileset from image file. * Return vector of handles to obtain valid texposes. */ -DFHACK_EXPORT std::vector loadTileset(const std::string& file, +DFHACK_EXPORT std::vector loadTileset(const std::filesystem::path file, int tile_px_w = TILE_WIDTH_PX, int tile_px_h = TILE_HEIGHT_PX, bool reserved = false); diff --git a/library/include/modules/Units.h b/library/include/modules/Units.h index b13a75d6a3..569471d9f0 100644 --- a/library/include/modules/Units.h +++ b/library/include/modules/Units.h @@ -78,7 +78,7 @@ DFHACK_EXPORT bool isActive(df::unit *unit); DFHACK_EXPORT bool isVisible(df::unit *unit); // Unit is a non-dead (optionally sane) citizen of fort. DFHACK_EXPORT bool isCitizen(df::unit *unit, bool include_insane = false); -// Long-term resident, not the hostile type. +// Long-term (non-dead) resident, not the hostile type. DFHACK_EXPORT bool isResident(df::unit *unit, bool include_insane = false); // Similar to isCitizen, but includes tame animals. Will reveal ambushers for the fort, etc. DFHACK_EXPORT bool isFortControlled(df::unit *unit); @@ -154,6 +154,7 @@ DFHACK_EXPORT bool isPet(df::unit *unit); DFHACK_EXPORT bool hasExtravision(df::unit *unit); DFHACK_EXPORT bool isOpposedToLife(df::unit *unit); DFHACK_EXPORT bool isBloodsucker(df::unit *unit); +DFHACK_EXPORT bool breathes(df::unit* unit); // Same race as fort (even if active werebeast). DFHACK_EXPORT bool isDwarf(df::unit *unit); @@ -199,10 +200,10 @@ DFHACK_EXPORT df::unit *getUnitByNobleRole(std::string noble); inline auto citizensRange(std::vector &vec, bool exclude_residents = false, bool include_insane = false) { return vec | std::views::filter([=](df::unit *unit) { - if (isDead(unit) || !isActive(unit)) - return false; - return isCitizen(unit, include_insane) || - (!exclude_residents && isResident(unit, include_insane)); + // isCitizen and isResident both reject dead units + return isActive(unit) && + (isCitizen(unit, include_insane) || + (!exclude_residents && isResident(unit, include_insane))); }); } @@ -365,5 +366,8 @@ DFHACK_EXPORT void setActionTimers(color_ostream &out, df::unit *unit, int32_t a DFHACK_EXPORT void setGroupActionTimers(color_ostream &out, df::unit *unit, int32_t amount, df::unit_action_type_group affectedActionTypeGroup); DFHACK_EXPORT df::unit* get_cached_unit_by_global_id(int32_t id, int32_t& index); + +enum class breathing_state { CANT = 0, TROUBLE, FINE }; +DFHACK_EXPORT breathing_state getBreathingState(df::unit* unit); } } diff --git a/library/include/modules/World.h b/library/include/modules/World.h index a82ceef9a4..6d84df6f0c 100644 --- a/library/include/modules/World.h +++ b/library/include/modules/World.h @@ -31,9 +31,15 @@ distribution. * @ingroup grp_modules */ +#include "DataDefs.h" #include "Export.h" + #include "modules/Persistence.h" -#include "DataDefs.h" + +#include "df/game_mode.h" +#include "df/game_type.h" +#include "df/unit.h" +#include "df/weather_type.h" namespace df @@ -75,7 +81,7 @@ namespace DFHack DFHACK_EXPORT uint32_t ReadCurrentYear(); DFHACK_EXPORT uint32_t ReadCurrentMonth(); DFHACK_EXPORT uint32_t ReadCurrentDay(); - DFHACK_EXPORT uint8_t ReadCurrentWeather(); + DFHACK_EXPORT df::weather_type ReadCurrentWeather(); // deprecated DFHACK_EXPORT void SetCurrentWeather(uint8_t weather); DFHACK_EXPORT bool ReadGameMode(t_gamemodes& rd); DFHACK_EXPORT bool WriteGameMode(const t_gamemodes & wr); // this is very dangerous diff --git a/library/lua/dfhack.lua b/library/lua/dfhack.lua index 0dadc4fa9c..aaf58e214e 100644 --- a/library/lua/dfhack.lua +++ b/library/lua/dfhack.lua @@ -367,6 +367,26 @@ local function print_fields(value, seen, indent, prefix) return 0 end +-- Recursing into the members of an untagged union is unsafe: only one member +-- is live at a time and there is no tag to identify which one, so accessing +-- any of the others is undefined behavior and can crash the game. Tagged +-- unions are safe to iterate since their iterators only produce the live +-- member. +local function is_untagged_union(value) + if df.isvalid(value) ~= 'ref' then + return false + end + local vtype = value._type + if type(vtype) ~= 'table' or not vtype._union then + return false + end + -- a tagged union's iterator produces at most the one live member; an + -- untagged union's iterator produces all of them + local count = 0 + safe_iterate(value, pairs, function() count = count + 1 end) + return count > 1 +end + -- This should be same as print_array but userdata doesn't compare equal even if -- they hold same pointer. local function print_userdata(printfn, value, seen, indent) @@ -375,10 +395,15 @@ local function print_userdata(printfn, value, seen, indent) dfhack.println(strvalue) if seen[strvalue] then dfhack.print(prefix) - dfhack.println('\n') + dfhack.println('') return 0 end seen[strvalue] = true + if is_untagged_union(value) then + dfhack.print(prefix) + dfhack.println('') + return 0 + end return print_fields(value, seen, indent, prefix) end @@ -387,7 +412,7 @@ local function print_array(printfn, value, seen, indent) dfhack.println(tostring(value)) if seen[value] then dfhack.print(prefix) - dfhack.println('\n') + dfhack.println('') return 0 end seen[value] = true diff --git a/library/lua/gui/dwarfmode.lua b/library/lua/gui/dwarfmode.lua index a0e50532e6..f856e6281b 100644 --- a/library/lua/gui/dwarfmode.lua +++ b/library/lua/gui/dwarfmode.lua @@ -39,7 +39,7 @@ end ---@return df.coord|nil function getCursorPos() - if dfhack.world.isAdventureMode() then + if df.global.gamemode == df.game_mode.ADVENTURE then if a_look.open then return copyall(a_look.cursor) end @@ -49,7 +49,10 @@ function getCursorPos() end function setCursorPos(cursor) - if dfhack.world.isAdventureMode() then + if df.global.gamemode == df.game_mode.ADVENTURE then + if not a_look.open then + return + end a_look.cursor = copyall(cursor) else df.global.cursor = copyall(cursor) @@ -57,13 +60,14 @@ function setCursorPos(cursor) end function clearCursorPos() - if dfhack.world.isAdventureMode() then + if df.global.gamemode == df.game_mode.ADVENTURE then if not a_look.open then return end local u = dfhack.world.getAdventurer() - if u and u.pos:isValid() then - a_look.cursor = copyall(u.pos) + local pos = u and dfhack.units.getPosition(u) + if pos then -- recenter cursor on adventurer + a_look.cursor = copyall(pos) end else df.global.cursor = xyz2pos(nil) diff --git a/library/lua/gui/materials.lua b/library/lua/gui/materials.lua index 7429a78237..99e4e6449b 100644 --- a/library/lua/gui/materials.lua +++ b/library/lua/gui/materials.lua @@ -8,8 +8,9 @@ local dlg = require('gui.dialogs') ARROW = string.char(26) -CREATURE_BASE = 19 -PLANT_BASE = 419 +-- For backwards compatibility with older scripts +CREATURE_BASE = df.builtin_mats.CREATURE_1 +PLANT_BASE = df.builtin_mats.PLANT_1 MaterialDialog = defclass(MaterialDialog, gui.FramedScreen) @@ -127,7 +128,7 @@ function MaterialDialog:initCreatureMode() local choices = {} for i,v in ipairs(df.global.world.raws.creatures.all) do - self:addObjectChoice(choices, v, v.name[0], CREATURE_BASE, i) + self:addObjectChoice(choices, v, v.name[0], df.builtin_mats.CREATURE_1, i) end self:pushContext('Creature materials', choices) @@ -137,7 +138,7 @@ function MaterialDialog:initPlantMode() local choices = {} for i,v in ipairs(df.global.world.raws.plants.all) do - self:addObjectChoice(choices, v, v.name, PLANT_BASE, i) + self:addObjectChoice(choices, v, v.name, df.builtin_mats.PLANT_1, i) end self:pushContext('Plant materials', choices) diff --git a/library/lua/gui/textures.lua b/library/lua/gui/textures.lua index 44d4f0de30..b97a10e439 100644 --- a/library/lua/gui/textures.lua +++ b/library/lua/gui/textures.lua @@ -8,16 +8,16 @@ local _ENV = mkmodule('gui.textures') -- Use these handles if you need to get dfhack standard textures. ---@type table local texpos_handles = { - green_pin = dfhack.textures.loadTileset('hack/data/art/green-pin.png', 8, 12, true), - red_pin = dfhack.textures.loadTileset('hack/data/art/red-pin.png', 8, 12, true), - icons = dfhack.textures.loadTileset('hack/data/art/icons.png', 8, 12, true), - on_off = dfhack.textures.loadTileset('hack/data/art/on-off.png', 8, 12, true), - control_panel = dfhack.textures.loadTileset('hack/data/art/control-panel.png', 8, 12, true), - border_thin = dfhack.textures.loadTileset('hack/data/art/border-thin.png', 8, 12, true), - border_medium = dfhack.textures.loadTileset('hack/data/art/border-medium.png', 8, 12, true), - border_bold = dfhack.textures.loadTileset('hack/data/art/border-bold.png', 8, 12, true), - border_panel = dfhack.textures.loadTileset('hack/data/art/border-panel.png', 8, 12, true), - border_window = dfhack.textures.loadTileset('hack/data/art/border-window.png', 8, 12, true), + green_pin = dfhack.textures.loadTileset(dfhack.getHackPath()..'/data/art/green-pin.png', 8, 12, true), + red_pin = dfhack.textures.loadTileset(dfhack.getHackPath()..'/data/art/red-pin.png', 8, 12, true), + icons = dfhack.textures.loadTileset(dfhack.getHackPath()..'/data/art/icons.png', 8, 12, true), + on_off = dfhack.textures.loadTileset(dfhack.getHackPath()..'/data/art/on-off.png', 8, 12, true), + control_panel = dfhack.textures.loadTileset(dfhack.getHackPath()..'/data/art/control-panel.png', 8, 12, true), + border_thin = dfhack.textures.loadTileset(dfhack.getHackPath()..'/data/art/border-thin.png', 8, 12, true), + border_medium = dfhack.textures.loadTileset(dfhack.getHackPath()..'/data/art/border-medium.png', 8, 12, true), + border_bold = dfhack.textures.loadTileset(dfhack.getHackPath()..'/data/art/border-bold.png', 8, 12, true), + border_panel = dfhack.textures.loadTileset(dfhack.getHackPath()..'/data/art/border-panel.png', 8, 12, true), + border_window = dfhack.textures.loadTileset(dfhack.getHackPath()..'/data/art/border-window.png', 8, 12, true), } -- Get valid texpos for preloaded texture in tileset diff --git a/library/lua/gui/widgets.lua b/library/lua/gui/widgets.lua index e7870f88e8..726b52003a 100644 --- a/library/lua/gui/widgets.lua +++ b/library/lua/gui/widgets.lua @@ -16,8 +16,9 @@ Label = require('gui.widgets.labels.label') Scrollbar = require('gui.widgets.scrollbar') WrappedLabel = require('gui.widgets.labels.wrapped_label') TooltipLabel = require('gui.widgets.labels.tooltip_label') -HelpButton = require('gui.widgets.buttons.help_button') ConfigureButton = require('gui.widgets.buttons.configure_button') +HelpButton = require('gui.widgets.buttons.help_button') +RadioButton = require('gui.widgets.buttons.radio_button') BannerPanel = require('gui.widgets.containers.banner_panel') TextButton = require('gui.widgets.buttons.text_button') CycleHotkeyLabel = require('gui.widgets.labels.cycle_hotkey_label') diff --git a/library/lua/gui/widgets/buttons/configure_button.lua b/library/lua/gui/widgets/buttons/configure_button.lua index b93b1e18b2..7d865b27ad 100644 --- a/library/lua/gui/widgets/buttons/configure_button.lua +++ b/library/lua/gui/widgets/buttons/configure_button.lua @@ -1,3 +1,5 @@ +-- A 3x1 tile button with a gear symbol on it. Clicking on it will run a callback + local textures = require('gui.textures') local Panel = require('gui.widgets.containers.panel') local Label = require('gui.widgets.labels.label') @@ -6,10 +8,10 @@ local to_pen = dfhack.pen.parse local button_pen_left = to_pen{fg=COLOR_CYAN, tile=curry(textures.tp_control_panel, 7) or nil, ch=string.byte('[')} +local button_pen_center = to_pen{ + tile=curry(textures.tp_control_panel, 10) or nil, ch=15} -- gear/masterwork symbol local button_pen_right = to_pen{fg=COLOR_CYAN, tile=curry(textures.tp_control_panel, 8) or nil, ch=string.byte(']')} -local configure_pen_center = to_pen{ - tile=curry(textures.tp_control_panel, 10) or nil, ch=15} -- gear/masterwork symbol --------------------- -- ConfigureButton -- @@ -17,6 +19,9 @@ local configure_pen_center = to_pen{ ---@class widgets.ConfigureButton.attrs: widgets.Panel.attrs ---@field on_click? function +---@field pen_left dfhack.pen|fun(): dfhack.pen +---@field pen_center dfhack.pen|fun(): dfhack.pen +---@field pen_right dfhack.pen|fun(): dfhack.pen ---@class widgets.ConfigureButton.attrs.partial: widgets.ConfigureButton.attrs @@ -27,27 +32,40 @@ local configure_pen_center = to_pen{ ConfigureButton = defclass(ConfigureButton, Panel) ConfigureButton.ATTRS{ + frame={t=0, l=0, w=3, h=1}, on_click=DEFAULT_NIL, + pen_left=button_pen_left, + pen_center=button_pen_center, + pen_right=button_pen_right, } -function ConfigureButton:preinit(init_table) - init_table.frame = init_table.frame or {} - init_table.frame.h = init_table.frame.h or 1 - init_table.frame.w = init_table.frame.w or 3 -end - function ConfigureButton:init() + self.frame.h = self.frame.h or 1 + self.frame.w = self.frame.w or 3 + self:addviews{ Label{ + view_id='label', frame={t=0, l=0, w=3, h=1}, text={ - {tile=button_pen_left}, - {tile=configure_pen_center}, - {tile=button_pen_right}, + {tile=self.pen_left}, + {tile=self.pen_center}, + {tile=self.pen_right}, }, on_click=self.on_click, }, } end +function ConfigureButton:postinit() + local l = self.subviews.label + + l.on_click = self.on_click + l.pen_left = self.pen_left + l.pen_center = self.pen_center + l.pen_right = self.pen_right + + l:setText({{tile=self.pen_left}, {tile=self.pen_center}, {tile=self.pen_right}}) +end + return ConfigureButton diff --git a/library/lua/gui/widgets/buttons/help_button.lua b/library/lua/gui/widgets/buttons/help_button.lua index 9f9b7dc989..9fa45f7a70 100644 --- a/library/lua/gui/widgets/buttons/help_button.lua +++ b/library/lua/gui/widgets/buttons/help_button.lua @@ -1,53 +1,35 @@ +-- A 3x1 tile button with a question mark on it. Clicking on it will show help text for a command + local textures = require('gui.textures') -local Panel = require('gui.widgets.containers.panel') -local Label = require('gui.widgets.labels.label') +local ConfigureButton = require('gui.widgets.buttons.configure_button') -local to_pen = dfhack.pen.parse +local help_pen_center = dfhack.pen.parse{ + tile=curry(textures.tp_control_panel, 9) or nil, ch=string.byte('?')} ---------------- -- HelpButton -- ---------------- ----@class widgets.HelpButton.attrs: widgets.Panel.attrs +---@class widgets.HelpButton.attrs: widgets.ConfigureButton.attrs ---@field command? string ---@class widgets.HelpButton.attrs.partial: widgets.HelpButton.attrs ----@class widgets.HelpButton: widgets.Panel, widgets.HelpButton.attrs ----@field super widgets.Panel +---@class widgets.HelpButton: widgets.ConfigureButton, widgets.HelpButton.attrs +---@field super widgets.ConfigureButton ---@field ATTRS widgets.HelpButton.attrs|fun(attributes: widgets.HelpButton.attrs.partial) ---@overload fun(init_table: widgets.HelpButton.attrs.partial): self -HelpButton = defclass(HelpButton, Panel) +HelpButton = defclass(HelpButton, ConfigureButton) HelpButton.ATTRS{ frame={t=0, r=1, w=3, h=1}, command=DEFAULT_NIL, + pen_center=help_pen_center, } -local button_pen_left = to_pen{fg=COLOR_CYAN, - tile=curry(textures.tp_control_panel, 7) or nil, ch=string.byte('[')} -local button_pen_right = to_pen{fg=COLOR_CYAN, - tile=curry(textures.tp_control_panel, 8) or nil, ch=string.byte(']')} -local help_pen_center = to_pen{ - tile=curry(textures.tp_control_panel, 9) or nil, ch=string.byte('?')} - function HelpButton:init() - self.frame.w = self.frame.w or 3 - self.frame.h = self.frame.h or 1 - local command = self.command .. ' ' - - self:addviews{ - Label{ - frame={t=0, l=0, w=3, h=1}, - text={ - {tile=button_pen_left}, - {tile=help_pen_center}, - {tile=button_pen_right}, - }, - on_click=function() dfhack.run_command('gui/launcher', command) end, - }, - } + self.on_click = function() dfhack.run_command('gui/launcher', command) end end return HelpButton diff --git a/library/lua/gui/widgets/buttons/radio_button.lua b/library/lua/gui/widgets/buttons/radio_button.lua new file mode 100644 index 0000000000..d27d54edc3 --- /dev/null +++ b/library/lua/gui/widgets/buttons/radio_button.lua @@ -0,0 +1,59 @@ +-- A 3x1 tile button that toggles state when clicked + +local textures = require('gui.textures') +local ConfigureButton = require('gui.widgets.buttons.configure_button') + +local to_pen = dfhack.pen.parse + +local enabled_pen_left = to_pen{fg=COLOR_CYAN, + tile=curry(textures.tp_control_panel, 1) or nil, ch=string.byte('[')} +local enabled_pen_center = to_pen{fg=COLOR_LIGHTGREEN, + tile=curry(textures.tp_control_panel, 2) or nil, ch=251} -- check mark +local enabled_pen_right = to_pen{fg=COLOR_CYAN, + tile=curry(textures.tp_control_panel, 3) or nil, ch=string.byte(']')} +local disabled_pen_left = to_pen{fg=COLOR_CYAN, + tile=curry(textures.tp_control_panel, 4) or nil, ch=string.byte('[')} +local disabled_pen_center = to_pen{fg=COLOR_RED, + tile=curry(textures.tp_control_panel, 5) or nil, ch=string.byte('x')} +local disabled_pen_right = to_pen{fg=COLOR_CYAN, + tile=curry(textures.tp_control_panel, 6) or nil, ch=string.byte(']')} + +----------------- +-- RadioButton -- +----------------- + +---@class widgets.RadioButton.attrs: widgets.ConfigureButton.attrs +---@field initial_state boolean +---@field on_change? fun(val: boolean) + +---@class widgets.RadioButton.attrs.partial: widgets.RadioButton.attrs + +---@class widgets.RadioButton: widgets.ConfigureButton, widgets.RadioButton.attrs +---@field super widgets.ConfigureButton +---@field ATTRS widgets.RadioButton.attrs|fun(attributes: widgets.RadioButton.attrs.partial) +---@overload fun(init_table: widgets.RadioButton.attrs.partial): self +RadioButton = defclass(RadioButton, ConfigureButton) + +RadioButton.ATTRS{ + initial_state=true, + on_change=DEFAULT_NIL, +} + +function RadioButton:setState(val) + self.toggle_state = not not val + + if self.on_change then + self.on_change(self.toggle_state) + end +end + +function RadioButton:init() + self.on_click = function() self:setState(not self.toggle_state) end + self.pen_left = function() return self.toggle_state and enabled_pen_left or disabled_pen_left end + self.pen_center = function() return self.toggle_state and enabled_pen_center or disabled_pen_center end + self.pen_right = function() return self.toggle_state and enabled_pen_right or disabled_pen_right end + + self:setState(self.initial_state) +end + +return RadioButton diff --git a/library/lua/gui/widgets/range_slider.lua b/library/lua/gui/widgets/range_slider.lua index 9b44e0fd54..0f53906c8c 100644 --- a/library/lua/gui/widgets/range_slider.lua +++ b/library/lua/gui/widgets/range_slider.lua @@ -1,4 +1,4 @@ -local core = require('gui.widgets.slide_core') +local _SliderCore = require('gui.widgets.slide_core') -------------------------------- -- RangeSlider @@ -16,11 +16,11 @@ local core = require('gui.widgets.slide_core') ---@class widgets.RangeSlider.initTable: widgets.RangeSlider.attrs ---@field num_stops integer ----@class widgets.RangeSlider: widgets.Widget, widgets.RangeSlider.attrs ----@field super widgets.Widget +---@class widgets.RangeSlider: widgets._SliderCore, widgets.RangeSlider.attrs +---@field super widgets._SliderCore ---@field ATTRS widgets.RangeSlider.attrs|fun(attributes: widgets.RangeSlider.attrs.partial) ---@overload fun(init_table: widgets.RangeSlider.initTable): self -RangeSlider = defclass(RangeSlider, slide_core) +RangeSlider = defclass(RangeSlider, _SliderCore) RangeSlider.ATTRS{ get_left_idx_fn=DEFAULT_NIL, get_right_idx_fn=DEFAULT_NIL, @@ -44,7 +44,7 @@ function RangeSlider:onInput(keys) local left_pos = width_per_idx*(left_idx-1) local right_pos = width_per_idx*(right_idx-1) + 4 if x < left_pos then - self.on_left_change(self.get_left_idx_fn() - 1) + self.on_left_change(self:clamp_idx(self.get_left_idx_fn() - 1)) elseif x < left_pos+3 then self.is_dragging_target = 'left' self.is_dragging_idx = x - left_pos @@ -55,7 +55,7 @@ function RangeSlider:onInput(keys) self.is_dragging_target = 'right' self.is_dragging_idx = x - right_pos else - self.on_right_change(self.get_right_idx_fn() + 1) + self.on_right_change(self:clamp_idx(self.get_right_idx_fn() + 1)) end return true end diff --git a/library/lua/gui/widgets/slide_core.lua b/library/lua/gui/widgets/slide_core.lua index 450954b9c1..84ef12427a 100644 --- a/library/lua/gui/widgets/slide_core.lua +++ b/library/lua/gui/widgets/slide_core.lua @@ -3,46 +3,50 @@ local Widget = require('gui.widgets.widget') local to_pen = dfhack.pen.parse -------------------------------- --- slide_core +-- _SliderCore -------------------------------- ----@class widgets.slide_core.attrs: widgets.Widget.attrs +---@class widgets._SliderCore.attrs: widgets.Widget.attrs ---@field num_stops integer ---@field is_single boolean ---@field w integer ----@class widgets.slide_core.attrs.partial: widgets.slide_core.attrs +---@class widgets._SliderCore.attrs.partial: widgets._SliderCore.attrs ----@class widgets.slide_core.initTable: widgets.slide_core.attrs +---@class widgets._SliderCore.initTable: widgets._SliderCore.attrs ---@field num_stops integer ----@class widgets.slide_core: widgets.Widget, widgets.slide_core.attrs +---@class widgets._SliderCore: widgets.Widget, widgets._SliderCore.attrs ---@field super widgets.Widget ----@field ATTRS widgets.slide_core.attrs|fun(attributes: widgets.slide_core.attrs.partial) ----@overload fun(init_table: widgets.slide_core.initTable): self -slide_core = defclass(slide_core, Widget) -slide_core.ATTRS{ +---@field ATTRS widgets._SliderCore.attrs|fun(attributes: widgets._SliderCore.attrs.partial) +---@overload fun(init_table: widgets._SliderCore.initTable): self +_SliderCore = defclass(_SliderCore, Widget) +_SliderCore.ATTRS{ num_stops=DEFAULT_NIL, is_single=DEFAULT_NIL, w=DEFAULT_NIL } -function slide_core:preinit(init_table) +function _SliderCore:preinit(init_table) init_table.frame = init_table.frame or {} init_table.frame.h = init_table.frame.h or 1 end -function slide_core:init() +function _SliderCore:init() local min_stops = self:get_min_stops() if self.num_stops < min_stops then error(('too few stops, expected at least %s'):format(min_stops)) end self.is_dragging_target = nil -- 'left', 'right', or 'both' self.is_dragging_idx = nil -- offset from leftmost dragged tile end -function slide_core:get_min_stops() +function _SliderCore:get_min_stops() return self.is_single and 1 or 2 end +function _SliderCore:clamp_idx(idx) + return math.max(1, math.min(self.num_stops, idx)) +end + local function do_drag(self, width_per_idx) local x = self.frame_body:localXY(dfhack.screen.getMousePos()) local cur_pos = x - self.is_dragging_idx @@ -50,6 +54,7 @@ local function do_drag(self, width_per_idx) cur_pos = math.min(width_per_idx*(self.num_stops-1)+7, cur_pos) local offset = self.is_dragging_target == 'right' and -2 or 1 local new_idx = math.max(0, cur_pos+offset)//width_per_idx + 1 + new_idx = self:clamp_idx(new_idx) local new_left_idx, new_right_idx if self.is_dragging_target == 'right' then new_right_idx = new_idx @@ -96,7 +101,7 @@ local SLIDER_TAB_LEFT = to_pen{ch=60, fg=COLOR_BLACK, bg=COLOR_YELLOW} local SLIDER_TAB_CENTER = to_pen{ch=9, fg=COLOR_BLACK, bg=COLOR_YELLOW} local SLIDER_TAB_RIGHT = to_pen{ch=62, fg=COLOR_BLACK, bg=COLOR_YELLOW} -function slide_core:onRenderBody(dc, rect) +function _SliderCore:onRenderBody(dc, rect) local left_idx, right_idx if self.get_idx_fn ~= nil then left_idx = self.get_idx_fn() @@ -160,4 +165,4 @@ function slide_core:onRenderBody(dc, rect) end end -return slide_core +return _SliderCore diff --git a/library/lua/gui/widgets/slider.lua b/library/lua/gui/widgets/slider.lua index 65ce28a5c7..480f4787f8 100644 --- a/library/lua/gui/widgets/slider.lua +++ b/library/lua/gui/widgets/slider.lua @@ -1,4 +1,4 @@ -local core = require('gui.widgets.slide_core') +local _SliderCore = require('gui.widgets.slide_core') -------------------------------- -- Slider @@ -15,11 +15,11 @@ local core = require('gui.widgets.slide_core') ---@class widgets.Slider.initTable: widgets.Slider.attrs ---@field num_stops integer ----@class widgets.Slider: widgets.Widget, widgets.Slider.attrs ----@field super widgets.Widget +---@class widgets.Slider: widgets._SliderCore, widgets.Slider.attrs +---@field super widgets._SliderCore ---@field ATTRS widgets.Slider.attrs|fun(attributes: widgets.Slider.attrs.partial) ---@overload fun(init_table: widgets.Slider.initTable): self -Slider = defclass(Slider, slide_core) +Slider = defclass(Slider, _SliderCore) Slider.ATTRS{ get_idx_fn=DEFAULT_NIL, on_change=DEFAULT_NIL, @@ -41,7 +41,7 @@ function Slider:onInput(keys) local left_pos = width_per_idx*(left_idx-1) local right_pos = width_per_idx*(right_idx-1) + 4 if x < left_pos then - self.on_change(self.get_idx_fn() - 1) + self.on_change(self:clamp_idx(self.get_idx_fn() - 1)) elseif x < left_pos+3 then self.is_dragging_target = 'left' self.is_dragging_idx = x - left_pos @@ -52,7 +52,7 @@ function Slider:onInput(keys) self.is_dragging_target = 'right' self.is_dragging_idx = x - right_pos else - self.on_change(self.get_idx_fn() + 1) + self.on_change(self:clamp_idx(self.get_idx_fn() + 1)) end return true end diff --git a/library/lua/gui/widgets/text_area/text_area_content.lua b/library/lua/gui/widgets/text_area/text_area_content.lua index 83099fac76..cd35f751ee 100644 --- a/library/lua/gui/widgets/text_area/text_area_content.lua +++ b/library/lua/gui/widgets/text_area/text_area_content.lua @@ -37,7 +37,7 @@ function TextAreaContent:init() self.cursor = nil self.main_pen = dfhack.pen.parse({ - bg=COLOR_RESET, + bg=COLOR_BLACK, bold=true }, self.text_pen) diff --git a/library/lua/helpdb.lua b/library/lua/helpdb.lua index 0bd64d29cc..5af55f5697 100644 --- a/library/lua/helpdb.lua +++ b/library/lua/helpdb.lua @@ -5,8 +5,8 @@ local _ENV = mkmodule('helpdb') local argparse = require('argparse') -- paths -local RENDERED_PATH = 'hack/docs/docs/tools/' -local TAG_DEFINITIONS = 'hack/docs/docs/Tags.txt' +local RENDERED_PATH = dfhack.getHackPath() .. '/docs/docs/tools/' +local TAG_DEFINITIONS = dfhack.getHackPath() .. '/docs/docs/Tags.txt' -- used when reading help text embedded in script sources local SCRIPT_DOC_BEGIN = '[====[' diff --git a/library/lua/quickfix.lua b/library/lua/quickfix.lua index f46d03ef66..59880862b2 100644 --- a/library/lua/quickfix.lua +++ b/library/lua/quickfix.lua @@ -27,4 +27,21 @@ function set_entity_race_references() print(('quickfix: fixed %d unset entity race reference(s)'):format(count)) end +-- on reclaimed fortresses DF fails to assign plotinfo.site_id until the +-- first save, breaking anything that needs the site id; fortress_site is +-- set at embark, so restore the invariant from it +-- called from onMapLoad.default.init +function repair_site_id() + local plotinfo = df.global.plotinfo + if plotinfo.site_id ~= -1 then + return + end + local site = plotinfo.main.fortress_site + if not site then + return + end + plotinfo.site_id = site.id + print(('quickfix: repaired unassigned site_id (now %d)'):format(site.id)) +end + return _ENV diff --git a/library/lua/script-manager.lua b/library/lua/script-manager.lua index 80774c53e8..cc4a02c6cd 100644 --- a/library/lua/script-manager.lua +++ b/library/lua/script-manager.lua @@ -246,7 +246,7 @@ function getModSourcePath(mod_id) end function getModStatePath(mod_id) - local path = ('dfhack-config/mods/%s/'):format(mod_id) + local path = (dfhack.getConfigPath() .. ('/mods/%s/')):format(mod_id) if not dfhack.filesystem.mkdir_recursive(path) then error(('failed to create mod state directory: "%s"'):format(path)) end diff --git a/library/lua/tile-material.lua b/library/lua/tile-material.lua index c0fe2e7cb6..5c847c463d 100644 --- a/library/lua/tile-material.lua +++ b/library/lua/tile-material.lua @@ -194,7 +194,7 @@ function GetTreeMat(x, y, z) for _, tree in ipairs(df.global.world.plants.all) do if tree.tree_info ~= nil then if coordInTree(pos, tree) then - return dfhack.matinfo.decode(419, tree.material) + return dfhack.matinfo.decode(df.builtin_mats.PLANT_1, tree.material) end end end @@ -209,7 +209,7 @@ function GetShrubMat(x, y, z) for _, shrub in ipairs(df.global.world.plants.all) do if shrub.tree_info == nil then if shrub.pos.x == pos.x and shrub.pos.y == pos.y and shrub.pos.z == pos.z then - return dfhack.matinfo.decode(419, shrub.material) + return dfhack.matinfo.decode(df.builtin_mats.PLANT_1, shrub.material) end end end diff --git a/library/modules/Buildings.cpp b/library/modules/Buildings.cpp index e5a0af116b..ddedbbc1b6 100644 --- a/library/modules/Buildings.cpp +++ b/library/modules/Buildings.cpp @@ -29,7 +29,6 @@ distribution. #include "MemAccess.h" #include "Types.h" #include "Error.h" -#include "ModuleFactory.h" #include "Core.h" #include "TileTypes.h" #include "MiscUtils.h" diff --git a/library/modules/Burrows.cpp b/library/modules/Burrows.cpp index 17ab0389d4..548ef98d87 100644 --- a/library/modules/Burrows.cpp +++ b/library/modules/Burrows.cpp @@ -204,7 +204,9 @@ df::block_burrow *Burrows::getBlockMask(df::burrow *burrow, df::map_block *block prev->next = link; df::coord base(world->map.region_x*3,world->map.region_y*3,world->map.region_z); - df::coord pos = base + block->map_pos/16; + // map_pos x/y are tile coordinates while z is already a z-level + df::coord pos(base.x + block->map_pos.x/16, + base.y + block->map_pos.y/16, base.z + block->map_pos.z); burrow->block_x.push_back(pos.x); burrow->block_y.push_back(pos.y); @@ -225,7 +227,9 @@ bool Burrows::deleteBlockMask(df::burrow *burrow, df::map_block *block, df::bloc return false; df::coord base(world->map.region_x*3,world->map.region_y*3,world->map.region_z); - df::coord pos = base + block->map_pos/16; + // map_pos x/y are tile coordinates while z is already a z-level + df::coord pos(base.x + block->map_pos.x/16, + base.y + block->map_pos.y/16, base.z + block->map_pos.z); destroyBurrowMask(mask); diff --git a/library/modules/DFSDL.cpp b/library/modules/DFSDL.cpp index f23cde5511..01b89720cf 100644 --- a/library/modules/DFSDL.cpp +++ b/library/modules/DFSDL.cpp @@ -25,6 +25,17 @@ using std::vector; static DFLibrary *g_sdl_handle = nullptr; static DFLibrary *g_sdl_image_handle = nullptr; + +DFLibrary* DFHack::DFSDL::obtain_library_handle() +{ + return g_sdl_handle; +} + +DFLibrary* DFHack::DFSDL::obtain_image_library_handle() +{ + return g_sdl_image_handle; +} + static const vector SDL_LIBS { #ifdef WIN32 "SDL2.dll" diff --git a/library/modules/DFSteam.cpp b/library/modules/DFSteam.cpp index 10074eef29..9c61b2a7fc 100644 --- a/library/modules/DFSteam.cpp +++ b/library/modules/DFSteam.cpp @@ -1,11 +1,30 @@ -#include "Internal.h" - #include "modules/DFSteam.h" #include "Debug.h" #include "PluginManager.h" +#include "ColorText.h" +#include "Core.h" + +#include +#include +#include +#include +#include + #include "df/gamest.h" +#include + +#ifdef WIN32 +#define NOMINMAX +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include +#include +#include +#endif namespace DFHack { @@ -35,17 +54,20 @@ bool (*g_SteamAPI_RestartAppIfNecessary)(uint32_t unOwnAppID) = nullptr; void* (*g_SteamInternal_FindOrCreateUserInterface)(int, const char*) = nullptr; bool (*g_SteamAPI_ISteamApps_BIsAppInstalled)(void *iSteamApps, uint32_t appID) = nullptr; -static void bind_all(color_ostream& out, DFLibrary* handle) { -#define bind(name) \ - if (!handle) { \ - g_##name = nullptr; \ - } else { \ - g_##name = (decltype(g_##name))LookupPlugin(handle, #name); \ - if (!g_##name) { \ - WARN(dfsteam, out).print("steam library function not found: " #name "\n"); \ - } \ +template +static void bind_(color_ostream& out, DFLibrary* handle, const char* name, Ptr& func_ptr) { + if (!handle) { + func_ptr = nullptr; + } else { + func_ptr = (Ptr)LookupPlugin(handle, name); + if (!func_ptr) { + WARN(dfsteam, out).print("steam library function not found: {}\n", name); } + } +} +static void bind_all(color_ostream& out, DFLibrary* handle) { +#define bind(name) bind_(out, handle, #name, g_##name) bind(SteamAPI_Init); bind(SteamAPI_Shutdown); bind(SteamAPI_GetHSteamUser); @@ -56,6 +78,16 @@ static void bind_all(color_ostream& out, DFLibrary* handle) { #undef bind } +static void unbind_all() +{ + g_SteamAPI_Init = nullptr; + g_SteamAPI_Shutdown = nullptr; + g_SteamAPI_GetHSteamUser = nullptr; + g_SteamInternal_FindOrCreateUserInterface = nullptr; + g_SteamAPI_RestartAppIfNecessary = nullptr; + g_SteamAPI_ISteamApps_BIsAppInstalled = nullptr; +} + bool DFSteam::init(color_ostream& out) { char *steam_client_launch = getenv("SteamClientLaunch"); if (!steam_client_launch || strncmp(steam_client_launch, "1", 2) != 0) { @@ -84,7 +116,7 @@ bool DFSteam::init(color_ostream& out) { return true; } -void DFSteam::cleanup(color_ostream& out) { +void DFSteam::cleanup() { if (!g_steam_handle) return; @@ -94,15 +126,12 @@ void DFSteam::cleanup(color_ostream& out) { ClosePlugin(g_steam_handle); g_steam_handle = nullptr; - bind_all(out, nullptr); + unbind_all(); g_steam_initialized = false; } #ifdef WIN32 -#include -#include -#include static bool is_running_on_wine() { typedef const char* (CDECL wine_get_version)(void); @@ -157,13 +186,13 @@ static bool launchDFHack(color_ostream& out) { si.cb = sizeof(si); ZeroMemory(&pi, sizeof(pi)); - static LPCWSTR procname = L"hack/launchdf.exe"; + auto procpath = Core::getInstance().getHackPath() / "launchdf.exe"; static const char * env = "\0"; // note that the environment must be explicitly zeroed out and not NULL, // otherwise the launched process will inherit this process's environment, // and the Steam API in the launchdf process will think it is in DF's context. - BOOL res = CreateProcessW(procname, + BOOL res = CreateProcessW(procpath.wstring().c_str(), NULL, NULL, NULL, FALSE, 0, (LPVOID)env, NULL, &si, &pi); return !!res; @@ -208,9 +237,10 @@ static bool launchDFHack(color_ostream& out) { return false; } else if (pid == 0) { // child process - static const char * command = "hack/launchdf"; + auto procpath = Core::getInstance().getHackPath() / "launchdf"; + auto command = procpath.string(); unsetenv("SteamAppId"); - execl(command, command, NULL); + execl(command.c_str(), command.c_str(), NULL); _exit(EXIT_FAILURE); } diff --git a/library/modules/EventManager.cpp b/library/modules/EventManager.cpp index 3c926645a2..2a5f678a0b 100644 --- a/library/modules/EventManager.cpp +++ b/library/modules/EventManager.cpp @@ -31,6 +31,7 @@ #include "df/report.h" #include "df/plotinfost.h" #include "df/unit.h" +#include "df/unit_action.h" #include "df/unit_flags1.h" #include "df/unit_inventory_item.h" #include "df/unit_report_type.h" @@ -224,12 +225,28 @@ static std::vector startedJobs; struct JobCompleteData { int32_t id; int32_t completion_timer; - uint32_t flags_bits_repeat; + df::job_flags flags; }; +/* + * Some jobs are driven by the worker's Job unit action timer rather than by + * job.completion_timer, which stays -1 for the job's entire lifetime (e.g. + * HarvestPlants). DF deletes these jobs in the same tick the action expires, + * so they are never observed with completion_timer == 0. Snapshot the worker's + * action timer for claimed jobs that are in their work phase but whose + * completion_timer isn't counting down; when such a job disappears, a timer + * that was within reach of expiring during the elapsed ticks indicates a + * completion rather than a cancellation. + */ +struct ActionDrivenJobData { + int32_t unit_id; + int32_t unit_index; // cache for Units::get_cached_unit_by_global_id + int32_t action_timer; // -1 = worker's Job action not observed yet +}; static std::unordered_map seenJobs; static std::vector prevJobs; +static std::unordered_map actionDrivenJobs; //active units static unordered_set activeUnits; @@ -312,6 +329,7 @@ void DFHack::EventManager::onStateChange(color_ostream& out, state_change_event startedJobs.clear(); seenJobs.clear(); prevJobs.clear(); + actionDrivenJobs.clear(); tickQueue.clear(); livingUnits.clear(); buildings.clear(); @@ -560,10 +578,34 @@ static void manageJobCompletedEvent(color_ostream& out) { || (seenJob->general_refs.size() != job.general_refs.size())) { seenIt->second = Job::JobUniquePtr(Job::cloneJobStruct(&job, true)); } - } else if (job.completion_timer != -1) { + } else if (job.completion_timer != -1 || job.flags.bits.working) { // Restrict additions to seenJobs to jobs that we know have started. seenJobs.emplace(job.id, Job::JobUniquePtr(Job::cloneJobStruct(&job, true))); } + + if (job.completion_timer == -1 && job.flags.bits.working) { + auto ait = actionDrivenJobs.find(job.id); + if (ait == actionDrivenJobs.end()) { + if (df::unit* worker = Job::getWorker(&job)) + ait = actionDrivenJobs.emplace(job.id, + ActionDrivenJobData{worker->id, -1, -1}).first; + } + if (ait != actionDrivenJobs.end()) { + auto& data = ait->second; + df::unit* unit = Units::get_cached_unit_by_global_id(data.unit_id, data.unit_index); + if (!unit || unit->job.current_job != &job) { + // worker lost or swapped; re-register on a later poll + actionDrivenJobs.erase(ait); + } else { + for (auto action : unit->actions) { + if (action->type == unit_action_type::Job) { + data.action_timer = action->data.job.timer; + break; + } + } + } + } + } /* * We still need to push back all jobs to maintain the invariant of the * algorithm used with prevJobs and nowJobs. @@ -590,7 +632,7 @@ static void manageJobCompletedEvent(color_ostream& out) { * 1 in nowJobs is less than the smallest ID in prevJobs, * which breaks the algorithm. */ - nowJobs.emplace_back(job.id, job.completion_timer, (bool)job.flags.bits.repeat); + nowJobs.emplace_back(job.id, job.completion_timer, job.flags); } // Do we want this check? @@ -663,6 +705,7 @@ static void manageJobCompletedEvent(color_ostream& out) { auto prevIt = prevJobs.begin(); auto nowIt = nowJobs.begin(); + int32_t tick = df::global::world->frame_counter; /* * Iterate through two ordered sets, prevJobs and nowJobs, where job IDs in nowJobs are invariably * greater than or equal to job IDs in prevJobs. The algorithm maintains the invariant that for each @@ -674,7 +717,27 @@ static void manageJobCompletedEvent(color_ostream& out) { auto& prevJob = *prevIt; if (nowIt == nowJobs.end() || prevJob.id != nowIt->id) { // job ID is in prevJobs. ID does not exist in nowJobs. // recently finished or cancelled job - if (!prevJob.flags_bits_repeat && prevJob.completion_timer == 0) { + bool completed = prevJob.completion_timer == 0; + if (!completed) { + /* + * Jobs whose work is tracked by the worker's Job unit action + * are deleted the same tick they complete, before + * completion_timer ever reaches 0. If the last observed action + * timer was close enough to expiring that it could have hit 0 + * during the elapsed ticks, count it as a completion. + */ + auto ait = actionDrivenJobs.find(prevJob.id); + if (ait != actionDrivenJobs.end() + && prevJob.flags.bits.working + && !prevJob.flags.bits.suspend + && !prevJob.flags.bits.item_lost + && ait->second.action_timer >= 0 + && ait->second.action_timer <= tick - eventLastTick[EventType::JOB_COMPLETED]) { + completed = true; + } + } + actionDrivenJobs.erase(prevJob.id); + if (completed && !prevJob.flags.bits.repeat) { // It should be in seenJobs. auto seenIt = seenJobs.find(prevJob.id); if (seenIt != seenJobs.end()) { @@ -688,7 +751,7 @@ static void manageJobCompletedEvent(color_ostream& out) { } } else { // prevIt job ID and nowIt job ID are equal. // could have just finished if it's a repeat job - if (prevJob.flags_bits_repeat && prevJob.completion_timer == 0 + if (prevJob.flags.bits.repeat && prevJob.completion_timer == 0 && nowIt->completion_timer == -1) { // It should be in seenJobs. auto seenIt = seenJobs.find(prevJob.id); @@ -1136,6 +1199,11 @@ static void manageUnitAttackEvent(color_ostream& out) { multimap copy(handlers[EventType::UNIT_ATTACK].begin(), handlers[EventType::UNIT_ATTACK].end()); std::vector& reports = df::global::world->status.reports; size_t idx = df::report::binsearch_index(reports, lastReportUnitAttack, false); + if (idx >= reports.size()) + { + WARN(log, out).print("manageUnitAttackEvent: last reported unit attack lookup failed ({} -> {})\n", lastReportUnitAttack, idx); + return; + } // returns the index to the key equal to or greater than the key provided idx = reports[idx]->id == lastReportUnitAttack ? idx + 1 : idx; // we need the index after (where the new stuff is) diff --git a/library/modules/Filesystem.cpp b/library/modules/Filesystem.cpp index 6d49c6a3b7..226d7bc830 100644 --- a/library/modules/Filesystem.cpp +++ b/library/modules/Filesystem.cpp @@ -46,16 +46,18 @@ SOFTWARE. */ #include -#include -#include #include +#include #include +#include #include "modules/DFSDL.h" #include "modules/Filesystem.h" +#include "df/global_objects.h" #include "df/init.h" + using namespace DFHack; static bool initialized = false; diff --git a/library/modules/Graphic.cpp b/library/modules/Graphic.cpp deleted file mode 100644 index b55ee83ed4..0000000000 --- a/library/modules/Graphic.cpp +++ /dev/null @@ -1,93 +0,0 @@ -/* -https://github.com/peterix/dfhack -Copyright (c) 2009-2012 Petr Mr�zek (peterix@gmail.com) - -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. -*/ - - -#include "Internal.h" - -#include -#include -#include -#include -#include -using namespace std; - -#include "modules/Graphic.h" -#include "Error.h" -#include "VersionInfo.h" -#include "MemAccess.h" -#include "MiscUtils.h" -#include "ModuleFactory.h" -#include "Core.h" - -using namespace DFHack; - -std::unique_ptr DFHack::createGraphic() -{ - return std::make_unique(); -} - -bool Graphic::Register(DFTileSurface* (*func)(int,int)) -{ - funcs.push_back(func); - return true; -} - -bool Graphic::Unregister(DFTileSurface* (*func)(int,int)) -{ - if ( funcs.empty() ) return false; - - vector::iterator it = funcs.begin(); - while ( it != funcs.end() ) - { - if ( *it == func ) - { - funcs.erase(it); - return true; - } - it++; - } - - return false; -} - -// This will return first DFTileSurface it can get (or NULL if theres none) -DFTileSurface* Graphic::Call(int x, int y) -{ - if ( funcs.empty() ) return NULL; - - DFTileSurface* temp = NULL; - - vector::iterator it = funcs.begin(); - while ( it != funcs.end() ) - { - temp = (*it)(x,y); - if ( temp != NULL ) - { - return temp; - } - it++; - } - - return NULL; -} diff --git a/library/modules/Gui.cpp b/library/modules/Gui.cpp index 68d63e68de..96a7ab5472 100644 --- a/library/modules/Gui.cpp +++ b/library/modules/Gui.cpp @@ -30,7 +30,6 @@ distribution. #include "VersionInfo.h" #include "Types.h" #include "Error.h" -#include "ModuleFactory.h" #include "Core.h" #include "Debug.h" #include "PluginManager.h" @@ -100,15 +99,15 @@ distribution. #include #include #include +#include using std::string; using std::vector; using namespace DFHack; -const size_t MAX_REPORTS_SIZE = 3000; // DF clears old reports to maintain this vector size -const int32_t RECENT_REPORT_TICKS = 500; // used by UNIT_COMBAT_REPORT_ALL_ACTIVE -const int32_t ANNOUNCE_LINE_DURATION = 100; // time to display each line in announcement bar; 2 sec at 50 GFPS -const int16_t ANNOUNCE_DISPLAY_TIME = 2000; // DF uses this value for most announcements; 40 sec at 50 GFPS +static constexpr int32_t RECENT_REPORT_TICKS = 500; // used by UNIT_COMBAT_REPORT_ALL_ACTIVE +static constexpr int32_t ANNOUNCE_LINE_DURATION = 100; // time to display each line in announcement bar; 2 sec at 50 GFPS +static constexpr int16_t ANNOUNCE_DISPLAY_TIME = 2000; // DF uses this value for most announcements; 40 sec at 50 GFPS namespace DFHack { @@ -1067,6 +1066,33 @@ bool Gui::any_job_hotkey(df::viewscreen *top) || workshop_job_hotkey(top); } +// Resolves the selection reported by a DFHack viewscreen, if it can be +// queried safely on this thread. Returns the selection (possibly null) when +// the question is answered, or std::nullopt when the caller should continue +// to the native UI checks. +// +// getSelected*() on a lua-backed screen enters Lua through CoreSuspender, +// which asserts or deadlocks on the render thread, where hotkey guards run. +// A focused lua screen owns the selection, so we must report none; a +// defocused lua screen is transparent to the UI below, so the native checks +// still apply. +template +static std::optional dfscreen_selection(df::viewscreen *top, + R (dfhack_viewscreen::*method)()) +{ + auto dfscreen = dfhack_viewscreen::try_cast(top); + if (!dfscreen) + return std::nullopt; + + if (!dfscreen->is_lua_screen() || !Core::getInstance().isRenderThread()) + return (dfscreen->*method)(); + + if (dfscreen->isFocused()) + return nullptr; + + return std::nullopt; +} + df::job *Gui::getAnyWorkshopJob(df::viewscreen *top) { auto bld = getAnyBuilding(top); if (!bld) @@ -1086,8 +1112,8 @@ df::job *Gui::getSelectedWorkshopJob(color_ostream &out, bool quiet) { } df::job *Gui::getAnyJob(df::viewscreen *top) { - if (auto dfscreen = dfhack_viewscreen::try_cast(top)) - return dfscreen->getSelectedJob(); + if (auto sel = dfscreen_selection(top, &dfhack_viewscreen::getSelectedJob)) + return *sel; if (matchFocusString("dwarfmode/Info/JOBS")) { auto &cri_job = game->main_interface.info.jobs.cri_job; @@ -1126,8 +1152,8 @@ df::job *Gui::getSelectedJob(color_ostream &out, bool quiet) df::unit *Gui::getAnyUnit(df::viewscreen *top) { - if (auto dfscreen = dfhack_viewscreen::try_cast(top)) - return dfscreen->getSelectedUnit(); + if (auto sel = dfscreen_selection(top, &dfhack_viewscreen::getSelectedUnit)) + return *sel; if (game->main_interface.view_sheets.open && game->main_interface.view_sheets.active_sheet == view_sheet_type::UNIT) @@ -1419,8 +1445,8 @@ df::item *Gui::getAnyItem(df::viewscreen *top) { using df::global::game; - if (auto dfscreen = dfhack_viewscreen::try_cast(top)) - return dfscreen->getSelectedItem(); + if (auto sel = dfscreen_selection(top, &dfhack_viewscreen::getSelectedItem)) + return *sel; if (game->main_interface.view_sheets.open && game->main_interface.view_sheets.active_sheet == view_sheet_type::ITEM) @@ -1570,8 +1596,8 @@ bool Gui::any_stockpile_hotkey(df::viewscreen* top) } df::building_stockpilest* Gui::getAnyStockpile(df::viewscreen* top) { - if (auto dfscreen = dfhack_viewscreen::try_cast(top)) - return dfscreen->getSelectedStockpile(); + if (auto sel = dfscreen_selection(top, &dfhack_viewscreen::getSelectedStockpile)) + return *sel; if (game->main_interface.bottom_mode_selected == main_bottom_mode_type::STOCKPILE) return game->main_interface.stockpile.cur_bld; @@ -1593,8 +1619,8 @@ bool Gui::any_civzone_hotkey(df::viewscreen* top) { } df::building_civzonest *Gui::getAnyCivZone(df::viewscreen* top) { - if (auto dfscreen = dfhack_viewscreen::try_cast(top)) - return dfscreen->getSelectedCivZone(); + if (auto sel = dfscreen_selection(top, &dfhack_viewscreen::getSelectedCivZone)) + return *sel; if (game->main_interface.bottom_mode_selected == main_bottom_mode_type::ZONE) return game->main_interface.civzone.cur_bld; @@ -1613,8 +1639,8 @@ df::building_civzonest *Gui::getSelectedCivZone(color_ostream &out, bool quiet) df::building *Gui::getAnyBuilding(df::viewscreen *top) { - if (auto dfscreen = dfhack_viewscreen::try_cast(top)) - return dfscreen->getSelectedBuilding(); + if (auto sel = dfscreen_selection(top, &dfhack_viewscreen::getSelectedBuilding)) + return *sel; if (game->main_interface.view_sheets.open && game->main_interface.view_sheets.active_sheet == view_sheet_type::BUILDING) @@ -1688,8 +1714,8 @@ df::plant *Gui::getAnyPlant(df::viewscreen *top) { using df::global::cursor; - if (auto dfscreen = dfhack_viewscreen::try_cast(top)) - return dfscreen->getSelectedPlant(); + if (auto sel = dfscreen_selection(top, &dfhack_viewscreen::getSelectedPlant)) + return *sel; if (Gui::dwarfmode_hotkey(top) && has_cursor()) { @@ -1928,18 +1954,6 @@ DFHACK_EXPORT int Gui::makeAnnouncement(df::announcement_type type, df::announce world->status.display_timer = ANNOUNCE_DISPLAY_TIME; } - // Delete excess reports - while (reports.size() > MAX_REPORTS_SIZE) - { // Report destructor - if (reports[0] != NULL) - { - if (reports[0]->flags.bits.announcement) - erase_from_vector(world->status.announcements, &df::report::id, reports[0]->id); - delete reports[0]; - } - reports.erase(reports.begin()); - } - return world->status.reports.size() - 1; } @@ -2032,14 +2046,6 @@ void Gui::showPopupAnnouncement(std::string message, int color, bool bright) auto &popups = world->status.popups; popups.push_back(popup); - // Delete excess popups - while (popups.size() > MAX_REPORTS_SIZE) - { - if (popups[0] != NULL) - delete popups[0]; - popups.erase(popups.begin()); - } - Gui::MTB_clean(&world->status.mega_text); Gui::MTB_parse(&world->status.mega_text, popups[0]->text); Gui::MTB_set_width(&world->status.mega_text); @@ -2268,18 +2274,6 @@ bool Gui::autoDFAnnouncement(df::announcement_infost info, string message) world->status.display_timer = info.display_timer; } - // Delete excess reports - while (reports.size() > MAX_REPORTS_SIZE) - { // Report destructor - if (reports[0] != NULL) - { - if (reports[0]->flags.bits.announcement) - erase_from_vector(world->status.announcements, &df::report::id, reports[0]->id); - delete reports[0]; - } - reports.erase(reports.begin()); - } - if (*gamemode == game_mode::DWARF || // Did dwarf announcement or UCR (*gamemode == game_mode::ADVENTURE && a_flags.bits.A_DISPLAY) || // Did adventure announcement (a_flags.bits.DO_MEGA && !adv_unconscious)) // Did popup diff --git a/library/modules/Items.cpp b/library/modules/Items.cpp index 6acd3b9401..dcd83fce88 100644 --- a/library/modules/Items.cpp +++ b/library/modules/Items.cpp @@ -28,7 +28,6 @@ distribution. #include "Internal.h" #include "MemAccess.h" #include "MiscUtils.h" -#include "ModuleFactory.h" #include "Types.h" #include "VersionInfo.h" @@ -1752,6 +1751,37 @@ int Items::getValue(df::item *item, df::caravan_state *caravan) { return value; } +// Automatically choose a growth print variant for the specified plant growth subtype+material +int32_t Items::pickGrowthPrint(int16_t subtype, int16_t mat, int32_t matg) +{ + int growth_print = -1; + // Make sure it's made of a valid plant material, then grab its definition + if (mat >= df::builtin_mats::PLANT_1 && mat <= df::builtin_mats::PLANT_200 && matg >= 0 && (unsigned)matg < world->raws.plants.all.size()) + { + auto plant_def = world->raws.plants.all[matg]; + // Make sure it subtype is also valid + if (subtype >= 0 && (unsigned)subtype < plant_def->growths.size()) + { + auto growth_def = plant_def->growths[subtype]; + // Try and find a growth print matching the current time + // (in practice, only tree leaves use this for autumn color changes) + for (size_t i = 0; i < growth_def->prints.size(); i++) + { + auto print_def = growth_def->prints[i]; + if (print_def->timing_start <= *df::global::cur_year_tick && *df::global::cur_year_tick <= print_def->timing_end) + { + growth_print = i; + break; + } + } + // If we didn't find one, then pick the first one (if it exists) + if (growth_print == -1 && !growth_def->prints.empty()) + growth_print = 0; + } + } + return growth_print; +} + bool Items::createItem(vector &out_items, df::unit *unit, df::item_type item_type, int16_t item_subtype, int16_t mat_type, int32_t mat_index, bool no_floor, int32_t count) { // Based on Quietust's plugins/createitem.cpp @@ -1774,6 +1804,7 @@ bool Items::createItem(vector &out_items, df::unit *unit, df::item_t case POWDER_MISC: case LIQUID_MISC: case DRINK: + case GLOB: prod->product_dimension = 150; break; case THREAD: @@ -1802,34 +1833,7 @@ bool Items::createItem(vector &out_items, df::unit *unit, df::item_t for (auto out_item : out_items) { // Plant growths need a valid "growth print", otherwise they behave oddly if (auto growth = virtual_cast(out_item)) - { - int growth_print = -1; - // Make sure it's made of a valid plant material, then grab its definition - if (growth->mat_type >= 419 && growth->mat_type <= 618 && growth->mat_index >= 0 && (unsigned)growth->mat_index < world->raws.plants.all.size()) - { - auto plant_def = world->raws.plants.all[growth->mat_index]; - // Make sure it subtype is also valid - if (growth->subtype >= 0 && (unsigned)growth->subtype < plant_def->growths.size()) - { - auto growth_def = plant_def->growths[growth->subtype]; - // Try and find a growth print matching the current time - // (in practice, only tree leaves use this for autumn color changes) - for (size_t i = 0; i < growth_def->prints.size(); i++) - { - auto print_def = growth_def->prints[i]; - if (print_def->timing_start <= *df::global::cur_year_tick && *df::global::cur_year_tick <= print_def->timing_end) - { - growth_print = i; - break; - } - } - // If we didn't find one, then pick the first one (if it exists) - if (growth_print == -1 && !growth_def->prints.empty()) - growth_print = 0; - } - } - growth->growth_print = growth_print; - } + growth->growth_print = pickGrowthPrint(growth->subtype, growth->mat_type, growth->mat_index); if (!no_floor) out_item->moveToGround(pos.x, pos.y, pos.z); } @@ -1962,17 +1966,10 @@ bool Items::isRequestedTradeGood(df::item *item, df::caravan_state *caravan) { return false; } -/// When called with game_ui = true, this is equivalent to Bay12's itemst::meltable() -/// (i.e., returning true if and only if the item has a "designate for melting" button in game) -bool Items::canMelt(df::item *item, bool game_ui) { - CHECK_NULL_POINTER(item); - MaterialInfo mat(item); - if (mat.getCraftClass() != craft_material_class::Metal) - return false; - - switch(item->getType()) +bool Items::usesStandardMaterial(df::item_type item_type) +{ + switch(item_type) { using namespace df::enums::item_type; - // These are not meltable, even if made from metal case CORPSE: case CORPSEPIECE: case REMAINS: @@ -1984,8 +1981,20 @@ bool Items::canMelt(df::item *item, bool game_ui) { case EGG: return false; default: - break; + return true; } +} + +/// When called with game_ui = true, this is equivalent to Bay12's itemst::meltable() +/// (i.e., returning true if and only if the item has a "designate for melting" button in game) +bool Items::canMelt(df::item *item, bool game_ui) { + CHECK_NULL_POINTER(item); + MaterialInfo mat(item); + if (mat.getCraftClass() != craft_material_class::Metal) + return false; + + if (!usesStandardMaterial(item->getType())) + return false; if (item->flags.bits.artifact) return false; diff --git a/library/modules/Job.cpp b/library/modules/Job.cpp index 3c70807325..9d1c6ed2eb 100644 --- a/library/modules/Job.cpp +++ b/library/modules/Job.cpp @@ -49,6 +49,7 @@ distribution. #include "df/job_list_link.h" #include "df/job_postingst.h" #include "df/job_restrictionst.h" +#include "df/manager_order.h" #include "df/plotinfost.h" #include "df/specific_ref.h" #include "df/unit.h" @@ -57,6 +58,7 @@ distribution. #include #include #include +#include #include using namespace DFHack; @@ -401,27 +403,23 @@ bool DFHack::Job::disconnectJobGeneralRef(df::job *job, df::general_ref *ref) { } bool DFHack::Job::removeJob(df::job* job) { - using df::global::world; CHECK_NULL_POINTER(job); - // cancel_job below does not clean up all refs, so we have to do some work + // remove_job below does not clean up all refs, so we have to do some work for (auto &item_ref : job->items) { disconnectJobItem(job, item_ref); if (item_ref) delete item_ref; } job->items.resize(0); - // call the job cancel vmethod graciously provided by The Toady One. - // job_handler::cancel_job calls job::~job, and then deletes job (this has + // job_handlerst_remove_job calls job::~job, and then deletes job (this has // been confirmed by disassembly). + auto fp = df::global::job_handlerst_remove_job; + CHECK_NULL_POINTER(fp); - // HACK: GCC (starting around GCC 10 targeting C++20 as of v50.09) optimizes - // out the vmethod call here regardless of optimization level, so we need to - // invoke the vmethod manually through a pointer, as the Lua wrapper does. - // `volatile` does not seem to be necessary but is included for good - // measure. - volatile auto cancel_job_method = &df::job_handler::cancel_job; - (world->jobs.*cancel_job_method)(job); + using FT = std::function; + auto f = reinterpret_cast(fp); + (*f)(job); return true; } @@ -686,3 +684,27 @@ std::string Job::getName(df::job *job) return desc; } + +std::string Job::getManagerOrderName(df::manager_order *order) +{ + CHECK_NULL_POINTER(order); + + std::string desc; + auto button = df::allocate(); + button->mstring = order->reaction_name; + button->jobtype = order->job_type; + button->itemtype = order->item_type; + button->subtype = order->item_subtype; + button->material = order->mat_type; + button->matgloss = order->mat_index; + button->specflag = order->specflag; + button->job_item_flag = order->material_category; + button->specdata = order->specdata; + button->art_specifier_id1 = order->art_spec.id; + button->art_specifier_id2 = order->art_spec.subid; + + button->text(&desc); + delete button; + + return desc; +} diff --git a/library/modules/Kitchen.cpp b/library/modules/Kitchen.cpp index be1415d90d..fd1bfe002f 100644 --- a/library/modules/Kitchen.cpp +++ b/library/modules/Kitchen.cpp @@ -14,7 +14,6 @@ using namespace std; #include "Types.h" #include "Error.h" #include "modules/Kitchen.h" -#include "ModuleFactory.h" #include "Core.h" using namespace DFHack; @@ -40,7 +39,7 @@ void Kitchen::debug_print(color_ostream &out) plotinfo->kitchen.mat_types[i], plotinfo->kitchen.mat_indices[i], plotinfo->kitchen.exc_types[i].whole, - (plotinfo->kitchen.mat_types[i] >= 419 && plotinfo->kitchen.mat_types[i] <= 618) ? world->raws.plants.all[plotinfo->kitchen.mat_indices[i]]->id : "n/a" + (plotinfo->kitchen.mat_types[i] >= df::builtin_mats::PLANT_1 && plotinfo->kitchen.mat_types[i] <= df::builtin_mats::PLANT_200) ? world->raws.plants.all[plotinfo->kitchen.mat_indices[i]]->id : "n/a" ); } out.print("\n"); diff --git a/library/modules/MapCache.cpp b/library/modules/MapCache.cpp index c01f5bf576..8f8f2eb4c7 100644 --- a/library/modules/MapCache.cpp +++ b/library/modules/MapCache.cpp @@ -30,7 +30,6 @@ distribution. #include "Error.h" #include "MemAccess.h" #include "MiscUtils.h" -#include "ModuleFactory.h" #include "VersionInfo.h" #include "modules/Buildings.h" @@ -97,8 +96,6 @@ const BiomeInfo MapCache::biome_stub = { -1, -1, -1, -1, -1, -1, -1, -1 } }; -#define COPY(a,b) memcpy(&a,&b,sizeof(a)) - MapExtras::Block::Block(MapCache *parent, DFCoord _bcoord) : parent(parent), designated_tiles{} @@ -124,20 +121,19 @@ void MapExtras::Block::init() if(block) { - COPY(designation, block->designation); - COPY(occupancy, block->occupancy); - - COPY(temp1, block->temperature_1); - COPY(temp2, block->temperature_2); + designation = block->designation; + occupancy = block->occupancy; + temp1 = block->temperature_1; + temp2 = block->temperature_2; valid = true; } else { - memset(designation,0,sizeof(designation)); - memset(occupancy,0,sizeof(occupancy)); - memset(temp1,0,sizeof(temp1)); - memset(temp2,0,sizeof(temp2)); + designation.fill({}); + occupancy.fill({}); + temp1.fill({}); + temp2.fill({}); } } @@ -199,10 +195,10 @@ void MapExtras::Block::init_tiles(bool basemat) MapExtras::Block::TileInfo::TileInfo() { dirty_raw.clear(); - memset(raw_tiles,0,sizeof(raw_tiles)); + raw_tiles.fill({}); ice_info = NULL; con_info = NULL; - memset(base_tiles,0,sizeof(base_tiles)); + base_tiles.fill({}); } MapExtras::Block::TileInfo::~TileInfo() @@ -219,6 +215,15 @@ void MapExtras::Block::TileInfo::init_iceinfo() ice_info = new IceInfo(); } +template +constexpr T arr40d_neg1() { + T tmp{}; + std::remove_reference_t tmp2{}; + tmp2.fill(-1); + tmp.fill(tmp2); + return tmp; +}; + void MapExtras::Block::TileInfo::init_coninfo() { if (con_info) @@ -226,17 +231,17 @@ void MapExtras::Block::TileInfo::init_coninfo() con_info = new ConInfo(); con_info->constructed.clear(); - COPY(con_info->tiles, base_tiles); - memset(con_info->mat_type, -1, sizeof(con_info->mat_type)); - memset(con_info->mat_index, -1, sizeof(con_info->mat_index)); + con_info->tiles = base_tiles; + con_info->mat_type = arr40d_neg1(); + con_info->mat_index = arr40d_neg1(); } MapExtras::Block::BasematInfo::BasematInfo() { vein_dirty.clear(); - memset(mat_type,0,sizeof(mat_type)); - memset(mat_index,-1,sizeof(mat_index)); - memset(veinmat,-1,sizeof(veinmat)); + mat_type.fill({}); + mat_index = arr40d_neg1(); + veinmat = arr40d_neg1(); } bool MapExtras::Block::setFlagAt(df::coord2d p, df::tile_designation::Mask mask, bool set) @@ -482,7 +487,7 @@ void MapExtras::Block::ParseTiles(TileInfo *tiles) tiletypes40d icetiles; BlockInfo::SquashFrozenLiquids(block, icetiles); - COPY(tiles->raw_tiles, block->tiletype); + tiles->raw_tiles = block->tiletype; for (int x = 0; x < 16; x++) { @@ -599,7 +604,7 @@ void MapExtras::Block::WriteTiles(TileInfo *tiles) if (tiles->ice_info && tiles->ice_info->dirty.has_assignments()) { - df::tiletype (*newtiles)[16] = (tiles->con_info ? tiles->con_info->tiles : tiles->base_tiles); + auto newtiles = (tiles->con_info ? tiles->con_info->tiles : tiles->base_tiles); for (int i = block->block_events.size()-1; i >= 0; i--) { @@ -647,8 +652,8 @@ void MapExtras::Block::ParseBasemats(TileInfo *tiles, BasematInfo *bmats) info.prepare(this); - COPY(bmats->veinmat, info.veinmats); - COPY(bmats->veintype, info.veintype); + bmats->veinmat = info.veinmats; + bmats->veintype = info.veintype; for (int x = 0; x < 16; x++) { @@ -780,7 +785,7 @@ bool MapExtras::Block::Write () if(dirty_designations) { - COPY(block->designation, designation); + block->designation = designation; block->flags.bits.designated = true; block->dsgn_check_cooldown = 0; dirty_designations = false; @@ -799,13 +804,13 @@ bool MapExtras::Block::Write () } if(dirty_temperatures) { - COPY(block->temperature_1, temp1); - COPY(block->temperature_2, temp2); + block->temperature_1 = temp1; + block->temperature_2 = temp2; dirty_temperatures = false; } if(dirty_occupancies) { - COPY(block->occupancy, occupancy); + block->occupancy = occupancy; dirty_occupancies = false; } return true; @@ -938,7 +943,7 @@ t_matpair MapExtras::BlockInfo::getBaseMaterial(df::tiletype tt, df::coord2d pos case ROOT: case TREE: case PLANT: - rv.mat_type = MaterialInfo::PLANT_BASE; + rv.mat_type = df::builtin_mats::PLANT_1; if (auto plant = plants[block->map_pos + df::coord(x,y,0)]) { if (auto raw = df::plant_raw::find(plant->material)) @@ -953,7 +958,7 @@ t_matpair MapExtras::BlockInfo::getBaseMaterial(df::tiletype tt, df::coord2d pos case GRASS_DARK: case GRASS_DRY: case GRASS_DEAD: - rv.mat_type = MaterialInfo::PLANT_BASE; + rv.mat_type = df::builtin_mats::PLANT_1; if (auto raw = df::plant_raw::find(grass[x][y])) { rv.mat_type = raw->material_defs.type[plant_material_def::basic_mat]; @@ -1035,8 +1040,8 @@ void MapExtras::BlockInfo::SquashVeins(df::map_block *mb, t_blockmaterials & mat { std::vector veins; Maps::SortBlockEvents(mb,&veins); - memset(materials,-1,sizeof(materials)); - memset(veintype, 0, sizeof(t_veintype)); + materials = arr40d_neg1(); + veintype.fill({}); for (uint32_t x = 0;x<16;x++) for (uint32_t y = 0; y< 16;y++) { @@ -1055,7 +1060,7 @@ void MapExtras::BlockInfo::SquashFrozenLiquids(df::map_block *mb, tiletypes40d & { std::vector ices; Maps::SortBlockEvents(mb,NULL,&ices); - memset(frozen,0,sizeof(frozen)); + frozen.fill({}); for (uint32_t x = 0; x < 16; x++) for (uint32_t y = 0; y < 16; y++) { for (size_t i = 0; i < ices.size(); i++) @@ -1090,7 +1095,7 @@ void MapExtras::BlockInfo::SquashGrass(df::map_block *mb, t_blockmaterials &mate { std::vector grasses; Maps::SortBlockEvents(mb, NULL, NULL, NULL, &grasses); - memset(materials,-1,sizeof(materials)); + materials = arr40d_neg1(); for (uint32_t x = 0; x < 16; x++) for (uint32_t y = 0; y < 16; y++) { int amount = 0; @@ -1107,7 +1112,7 @@ void MapExtras::BlockInfo::SquashGrass(df::map_block *mb, t_blockmaterials &mate int MapExtras::Block::biomeIndexAt(df::coord2d p) { - if (!block) + if (!block || !world->world_data) return -1; auto des = index_tile(designation,p); @@ -1115,9 +1120,15 @@ int MapExtras::Block::biomeIndexAt(df::coord2d p) if (idx >= 9) return -1; idx = block->region_offset[idx]; - if (idx >= parent->biomes.size()) + if (idx >= 9) return -1; - return idx; + // Biome indices are relative to the block's own region_pos, which may + // differ from the map's anchor region on embarks that cross midmap + // cell boundaries. + auto it = parent->biome_index.find(Maps::getBiomeRgnPos(block->region_pos, idx)); + if (it == parent->biome_index.end()) + return -1; + return it->second; } const BiomeInfo &Block::biomeInfoAt(df::coord2d p) @@ -1239,57 +1250,95 @@ MapExtras::MapCache::MapCache() valid = 0; Maps::getSize(x_bmax, y_bmax, z_max); x_tmax = x_bmax*16; y_tmax = y_bmax*16; - std::vector geoidx; - std::vector > layer_mats; - validgeo = Maps::ReadGeology(&layer_mats, &geoidx); valid = true; + validgeo = false; if (auto data = df::global::world->world_data) { + validgeo = true; + for (size_t i = 0; i < data->midmap_data.region_details.size(); i++) { auto info = data->midmap_data.region_details[i]; region_details[info->pos] = info; } - } - biomes.resize(layer_mats.size()); + // Biome indices in map block designations are relative to each + // block's own region_pos, so the biome list must cover the + // neighborhood of every cell the map touches: embarks can span + // multiple midmap cells. The set of reachable cells is the + // neighborhood rectangle of all block cells, clipped to the world. + df::coord2d lo(data->world_width, data->world_height); + df::coord2d hi(-1, -1); - for (size_t i = 0; i < layer_mats.size(); i++) - { - biomes[i].pos = geoidx[i]; - biomes[i].biome = Maps::getRegionBiome(geoidx[i]); - biomes[i].details = region_details[geoidx[i]]; + for (auto block : world->map.map_blocks) + { + if (!block) + continue; + lo.x = std::min(lo.x, block->region_pos.x - 1); + lo.y = std::min(lo.y, block->region_pos.y - 1); + hi.x = std::max(hi.x, block->region_pos.x + 1); + hi.y = std::max(hi.y, block->region_pos.y + 1); + } + + if (hi.x < 0) + { + // no map blocks; fall back to the map anchor region + lo.x = world->map.region_x / 16 - 1; + lo.y = world->map.region_y / 16 - 1; + hi.x = lo.x + 2; + hi.y = lo.y + 2; + } - biomes[i].geo_index = biomes[i].biome ? biomes[i].biome->geo_index : -1; - biomes[i].geobiome = df::world_geo_biome::find(biomes[i].geo_index); + lo.x = std::max(lo.x, 0); + lo.y = std::max(lo.y, 0); + hi.x = std::min(hi.x, data->world_width - 1); + hi.y = std::min(hi.y, data->world_height - 1); - biomes[i].lava_stone = -1; - biomes[i].default_soil = -1; - biomes[i].default_stone = -1; + for (int16_t y = lo.y; y <= hi.y; y++) + { + for (int16_t x = lo.x; x <= hi.x; x++) + { + df::coord2d pos(x, y); + biome_index[pos] = (int)biomes.size(); + biomes.push_back(BiomeInfo()); - if (biomes[i].details) - biomes[i].lava_stone = biomes[i].details->lava_stone; + auto &info = biomes.back(); + info.pos = pos; + info.biome = Maps::getRegionBiome(pos); + info.details = region_details[pos]; - memset(biomes[i].layer_stone, -1, sizeof(biomes[i].layer_stone)); + info.geo_index = info.biome ? info.biome->geo_index : -1; + info.geobiome = df::world_geo_biome::find(info.geo_index); - for (size_t j = 0; j < std::min(BiomeInfo::MAX_LAYERS,layer_mats[i].size()); j++) - { - biomes[i].layer_stone[j] = layer_mats[i][j]; + info.lava_stone = info.details ? info.details->lava_stone : -1; + info.default_soil = -1; + info.default_stone = -1; - auto raw = df::inorganic_raw::find(layer_mats[i][j]); - if (!raw) - continue; + memset(info.layer_stone, -1, sizeof(info.layer_stone)); - bool is_soil = raw->flags.is_set(inorganic_flags::SOIL_ANY); - if (is_soil) - biomes[i].default_soil = layer_mats[i][j]; - else if (biomes[i].default_stone == -1) - biomes[i].default_stone = layer_mats[i][j]; - } + if (info.geobiome) + { + auto &geolayers = info.geobiome->layers; - while (layer_mats[i].size() < 16) - layer_mats[i].push_back(-1); + for (size_t j = 0; j < std::min(BiomeInfo::MAX_LAYERS, geolayers.size()); j++) + { + int16_t mat = geolayers[j]->mat_index; + info.layer_stone[j] = mat; + + auto raw = df::inorganic_raw::find(mat); + if (!raw) + continue; + + bool is_soil = raw->flags.is_set(inorganic_flags::SOIL_ANY); + if (is_soil) + info.default_soil = mat; + else if (info.default_stone == -1) + info.default_stone = mat; + } + } + } + } } } @@ -1363,6 +1412,50 @@ void MapExtras::MapCache::discardBlock(Block *block) delete block; } +void MapExtras::MapCache::propagateVerticalFlags(DFCoord pos) +{ + df::tiletype_shape shape = tileShape(tiletypeAt(pos)); + bool open = shape == tiletype_shape::EMPTY || shape == tiletype_shape::RAMP_TOP; + + df::tile_designation td = designationAt(pos); + if (open) + { + if (!ensureBlockAt(DFCoord(pos.x, pos.y, pos.z+1))) { + // only the sky above + td.bits.light = true; + td.bits.outside = true; + td.bits.subterranean = false; + } + } + else + { + // a solid tile covers the column below; subterranean is a property + // of each tile and is preserved + td.bits.light = false; + td.bits.outside = false; + } + + for (int32_t z = pos.z - 1; + z >= 0 && ensureBlockAt(DFCoord(pos.x, pos.y, z)); + --z) + { + DFCoord pos_below(pos.x, pos.y, z); + df::tile_designation td_below = designationAt(pos_below); + bool sub = open ? td.bits.subterranean : td_below.bits.subterranean; + if (td_below.bits.light == td.bits.light + && td_below.bits.outside == td.bits.outside + && td_below.bits.subterranean == sub) + break; + td_below.bits.light = td.bits.light; + td_below.bits.outside = td.bits.outside; + td_below.bits.subterranean = sub; + setDesignationAt(pos_below, td_below); + shape = tileShape(tiletypeAt(pos_below)); + if (shape != tiletype_shape::EMPTY && shape != tiletype_shape::RAMP_TOP) + break; + } +} + void MapExtras::MapCache::resetTags() { for (auto it = blocks.begin(); it != blocks.end(); ++it) diff --git a/library/modules/Maps.cpp b/library/modules/Maps.cpp index 7e5707fccb..cd6b6fa46c 100644 --- a/library/modules/Maps.cpp +++ b/library/modules/Maps.cpp @@ -31,43 +31,62 @@ distribution. #include "Error.h" #include "MemAccess.h" #include "MiscUtils.h" -#include "ModuleFactory.h" +#include "TileTypes.h" #include "VersionInfo.h" #include "modules/Buildings.h" +#include "modules/Constructions.h" #include "modules/MapCache.h" #include "modules/Maps.h" #include "df/biome_type.h" -#include "df/block_burrow.h" #include "df/block_burrow_link.h" +#include "df/block_burrow.h" +#include "df/block_column_print_infost.h" #include "df/block_square_event_grassst.h" -#include "df/building.h" +#include "df/block_square_event_item_spatterst.h" +#include "df/block_square_event_material_spatterst.h" +#include "df/block_square_event_spoorst.h" #include "df/building_type.h" +#include "df/building.h" #include "df/builtin_mats.h" #include "df/burrow.h" +#include "df/construction.h" +#include "df/entity_plot_invasion_mapst.h" #include "df/feature_init.h" #include "df/feature_map_shellst.h" #include "df/feature_mapst.h" #include "df/flow_info.h" -#include "df/map_block.h" +#include "df/historical_entity.h" +#include "df/invasion_info.h" #include "df/map_block_column.h" -#include "df/plant.h" +#include "df/map_block.h" +#include "df/material.h" #include "df/plant_root_tile.h" #include "df/plant_tree_info.h" #include "df/plant_tree_tile.h" +#include "df/plant.h" +#include "df/plot_invasion_mapst.h" +#include "df/plotinfost.h" #include "df/region_map_entry.h" -#include "df/world.h" +#include "df/site_map_infost.h" +#include "df/weather_type.h" #include "df/world_data.h" #include "df/world_geo_biome.h" #include "df/world_geo_layer.h" #include "df/world_region_details.h" +#include "df/world_site_type.h" +#include "df/world_site.h" #include "df/world_underground_region.h" +#include "df/world.h" #include "df/z_level_flags.h" + +#include #include #include #include +#include #include #include #include @@ -421,6 +440,22 @@ df::tile_occupancy *Maps::getTileOccupancy(int32_t x, int32_t y, int32_t z) return block ? &block->occupancy[x&15][y&15] : NULL; } +df::coord2d Maps::addRegionBiomeOffset(df::coord2d world_pos, int8_t offset_dir) { + // Note 1: textual order of offfsets is upside down compared to the keypad order used by the biome offset + // Note 2: also upside down compared to (fort) map block region offsets + constexpr auto biome_offset = std::to_array>({ + {-1, 1}, {0, 1}, {1, 1}, + {-1, 0}, {0, 0}, {1, 0}, + {-1,-1}, {0,-1}, {1,-1} + }); + + auto [diff_x, diff_y] = biome_offset[std::clamp(offset_dir, (int8_t)1, (int8_t)9) - 1]; + return { + (int16_t)std::clamp(world_pos.x + diff_x,0,world->world_data->world_width - 1), + (int16_t)std::clamp(world_pos.y + diff_y,0,world->world_data->world_height - 1) + }; +} + df::region_map_entry *Maps::getRegionBiome(df::coord2d rgn_pos) { auto data = world->world_data; @@ -651,11 +686,256 @@ bool Maps::SortBlockEvents(df::map_block *block, if (priorities) priorities->push_back((df::block_square_event_designation_priorityst *)evt); break; + default: + assert("Unhandled block event type" && false); // FIXME temporary - replace with NONE case after structure are updated + break; } } return true; } +// Based on worldst::add_material_spatter_tile_capped +int32_t Maps::addMaterialSpatter (df::coord pos, int16_t mat, int32_t matg, df::matter_state state, int32_t amount) +{ + // Hardcoded maximum + int32_t cap = 255; + + // Sanity checks + if (amount > cap) + amount = cap; + // DF doesn't handle negative numbers, so disallow them + if (amount < 0) + amount = 0; + + // DF rejects materials of NONE:* + if (mat == -1) + return amount; + + // Extra check: make sure the material correctly exists + MaterialInfo matinfo(mat, matg); + if (!matinfo.isValid()) + return amount; + + df::map_block *block = Maps::getTileBlock(pos); + if (!block) + return amount; + + int16_t bx = pos.x & 0xF, by = pos.y & 0xF; + + // Extra check: specify state == NONE to auto-pick based on tile temperature + // Note that this won't choose POWDER/PASTE/PRESSED + if (state == df::matter_state::None) + { + uint16_t tile = block->temperature_1[bx][by]; + uint16_t melt = matinfo.material->heat.melting_point; + uint16_t boil = matinfo.material->heat.boiling_point; + if (boil != 60001 && tile >= boil) + state = df::matter_state::Gas; + else if (melt != 60001 && tile >= melt) + state = df::matter_state::Liquid; + else + state = df::matter_state::Solid; + } + + if (amount > 0) + { + // scan all SPOOR events and clear the PRESENT flag if the type is HFID_COMBINEDCASTE_BP, ITEMT_ITEMST_ORIENT, or MESS + for (size_t i = 0; i < block->block_events.size(); i++) + { + df::block_square_event *evt = block->block_events[i]; + if (evt->getType() != block_square_event_type::spoor) + continue; + auto spoor = (df::block_square_event_spoorst *)evt; + if (!spoor->info.flags[bx][by].bits.present) + continue; + if (spoor->info.type[bx][by] == df::spoor_type::HFID_COMBINEDCASTE_BP || + spoor->info.type[bx][by] == df::spoor_type::ITEMT_ITEMST_ORIENT || + spoor->info.type[bx][by] == df::spoor_type::MESS) + spoor->info.flags[bx][by].bits.present = false; + } + } + + // Find existing matching material spatter + df::block_square_event_material_spatterst *spatter = nullptr; + // DF: get_material_spatter_event_even_if_empty(...) + for (int i = block->block_events.size() - 1; i >= 0; i--) + { + df::block_square_event *evt = block->block_events[i]; + if (evt->getType() != block_square_event_type::material_spatter) + continue; + auto spt = (df::block_square_event_material_spatterst *)evt; + if (spt->mat_type == mat && spt->mat_index == matg && + spt->mat_state == state) + { + spatter = spt; + break; + } + } + + // If we didn't find one, make a new one + if (!spatter) + { + spatter = df::allocate(); + spatter->mat_type = mat; + spatter->mat_index = matg; + spatter->mat_state = state; + spatter->amount.fill({}); + spatter->min_temperature = spatter->max_temperature = 60001; + + uint16_t melt = matinfo.material->heat.melting_point; + uint16_t boil = matinfo.material->heat.boiling_point; + + switch (state) + { using namespace df::enums::matter_state; + case Solid: + case Powder: + case Paste: + case Pressed: + if (melt != 60001) + boil = melt; + spatter->max_temperature = boil; + break; + case Liquid: + if (melt != 60001 && melt != 0) + spatter->min_temperature = melt - 1; + spatter->max_temperature = boil; + break; + // Can't really have gas spatters, but DF has this check + // presumably, DF could convert this into a flow + case Gas: + if (boil != 60001 && boil != 0) + spatter->min_temperature = boil - 1; + else if (melt != 60001 && melt != 0) + spatter->min_temperature = melt - 1; + break; + case None: + // impossible + break; + } + // DF doesn't check heatdam/colddam/ignite points here + block->block_events.push_back(spatter); + } + + int32_t newamount = spatter->amount[bx][by] + amount; + if (newamount > cap) + { + amount = newamount - cap; + newamount = cap; + } + else + amount = 0; + + spatter->amount[bx][by] = (uint8_t)newamount; + block->flags.bits.may_have_material_spatter = 1; + + return amount; +} + +// Based on worldst::add_item_spatter_tile_capped +int32_t Maps::addItemSpatter (df::coord pos, df::item_type i_type, int16_t i_subtype, int16_t i_subcat1, int32_t i_subcat2, int32_t print_variant, int32_t amount) +{ + // DF passes this as a parameter, but it's always the same + int32_t cap = 10000; + + // Sanity checks + if (amount > cap) + amount = cap; + // DF doesn't handle negative numbers, so disallow them + if (amount < 0) + amount = 0; + + df::map_block *block = Maps::getTileBlock(pos); + if (!block) + return amount; + + int16_t bx = pos.x & 0xF, by = pos.y & 0xF; + + if (amount > 0) + { + // scan all SPOOR events and clear the PRESENT flag if the type is HFID_COMBINEDCASTE_BP, ITEMT_ITEMST_ORIENT, or MESS + for (size_t i = 0; i < block->block_events.size(); i++) + { + df::block_square_event *evt = block->block_events[i]; + if (evt->getType() != block_square_event_type::spoor) + continue; + auto spoor = (df::block_square_event_spoorst *)evt; + if (!spoor->info.flags[bx][by].bits.present) + continue; + if (spoor->info.type[bx][by] == df::spoor_type::HFID_COMBINEDCASTE_BP || + spoor->info.type[bx][by] == df::spoor_type::ITEMT_ITEMST_ORIENT || + spoor->info.type[bx][by] == df::spoor_type::MESS) + spoor->info.flags[bx][by].bits.present = false; + } + } + + // Allow auto-selecting growth print for plant growths + if (i_type == df::item_type::PLANT_GROWTH && print_variant == -1) + print_variant = Items::pickGrowthPrint(i_subtype, i_subcat1, i_subcat2); + + // Find existing matching item spatter + df::block_square_event_item_spatterst *spatter = nullptr; + // DF: get_item_spatter_event_even_if_empty(...) + for (int i = block->block_events.size() - 1; i >= 0; i--) + { + df::block_square_event *evt = block->block_events[i]; + if (evt->getType() != block_square_event_type::item_spatter) + continue; + auto spt = (df::block_square_event_item_spatterst *)evt; + if (spt->item_type == i_type && spt->item_subtype == i_subtype && + spt->mattype == i_subcat1 && spt->matindex == i_subcat2 && + spt->print_variant == print_variant) + { + spatter = spt; + break; + } + } + + // If we didn't find one, make a new one + if (!spatter) + { + spatter = df::allocate(); + spatter->item_type = i_type; + spatter->item_subtype = i_subtype; + spatter->mattype = i_subcat1; + spatter->matindex = i_subcat2; + spatter->print_variant = print_variant; + spatter->amount.fill({}); + spatter->flag.fill({}); + spatter->min_temperature = spatter->max_temperature = 60001; + + if (Items::usesStandardMaterial(i_type)) + { + MaterialInfo info(i_subcat1, i_subcat2); + if (info.isValid()) + { + uint16_t melt = info.material->heat.melting_point; + uint16_t boil = info.material->heat.melting_point; + if (melt != 60001) + spatter->max_temperature = melt; + else + spatter->max_temperature = boil; + // DF doesn't look at the heatdam/colddam/ignite temperatures + } + } + block->block_events.push_back(spatter); + } + + int32_t newamount = spatter->amount[bx][by] + amount; + if (newamount > cap) + { + amount = newamount - cap; + newamount = cap; + } + else + amount = 0; + + spatter->amount[bx][by] = newamount; + spatter->flag[bx][by].bits.season_full_timer = 7; + block->flags.bits.may_have_item_spatter = 1; + + return amount; +} + inline bool RemoveBlockEventInline(int32_t x, int32_t y, int32_t z, df::block_square_event * which) { df::map_block *block = Maps::getBlock(x, y, z); @@ -686,8 +966,11 @@ static df::coord2d biome_offsets[9] = { df::coord2d(-1,1), df::coord2d(0,1), df::coord2d(1,1) }; -inline df::coord2d getBiomeRgnPos(df::coord2d base, int idx) +df::coord2d Maps::getBiomeRgnPos(df::coord2d base, int idx) { + if (!world->world_data || idx < 0 || idx >= eBiomeCount) + return df::coord2d(); + auto r = base + biome_offsets[idx]; int world_width = world->world_data->world_width; @@ -713,6 +996,28 @@ df::coord2d Maps::getBlockTileBiomeRgn(df::map_block *block, df::coord2d pos) return df::coord2d(); } +df::weather_type Maps::getCurrentWeather(df::coord pos) +{ + if (df::global::current_weather){ + auto &map = world->map; + auto [biome_x, biome_y] = Maps::getTileBiomeRgn(pos); + const int weather_x = biome_x - map.region_x / 16 + 1; + const int weather_y = biome_y - map.region_y / 16 + 1; + return (*df::global::current_weather)[weather_x][weather_y]; + } + return df::weather_type::None; +} + +df::weather_type Maps::getCurrentWeather() +{ + auto &map = world->map; + if (map.x_count > 0 && map.y_count > 0 && map.z_count > 0) { + return Maps::getCurrentWeather(df::coord( map.x_count / 2, map.y_count / 2, map.z_count / 2 )); + } else { + return df::weather_type::None; + } +} + /* * Layer geology */ @@ -1274,3 +1579,350 @@ int Maps::removeAreaAquifer(df::coord pos1, df::coord pos2, std::functionsurroundings{ + "Serene", "Mirthful", "Joyous Wilds", + "Calm", "Wilderness", "Untamed Wilds", + "Sinister", "Haunted", "Terrifying" + }; + auto savagery_index = savagery < 33 ? 0 : (savagery > 65 ? 2 : 1); + auto evilness_index = evilness < 33 ? 0 : (evilness > 65 ? 2 : 1); + return surroundings[3 * evilness_index + savagery_index]; +} + +void Maps::addBlockColumns(int32_t new_height) +{ + auto quantity = new_height - world->map.z_count_block; + if (quantity <= 0) + return; + + auto world = df::global::world; + int32_t z_count_block = world->map.z_count_block; + df::map_block**** block_index = world->map.block_index; + + cuboid last_air_layer( + 0, 0, world->map.z_count_block - 1, + world->map.x_count_block - 1, world->map.y_count_block - 1, world->map.z_count_block - 1); + + last_air_layer.forCoord([&] (df::coord bpos) { + // Allocate a new block column and copy over data from the old + df::map_block** blockColumn = + new df::map_block * [z_count_block + quantity]; + std::memcpy(blockColumn, block_index[bpos.x][bpos.y], + z_count_block * sizeof(df::map_block*)); + delete[] block_index[bpos.x][bpos.y]; + block_index[bpos.x][bpos.y] = blockColumn; + + df::map_block* last_air_block = blockColumn[bpos.z]; + for (int32_t count = 0; count < quantity; count++) + { + df::map_block* air_block = new df::map_block(); + std::fill(&air_block->tiletype[0][0], + &air_block->tiletype[0][0] + (16 * 16), + df::tiletype::OpenSpace); + + // Set block positions properly (based on prior air layer) + air_block->map_pos = last_air_block->map_pos + df::coord{0, 0, int16_t(count + 1)}; + air_block->region_pos = last_air_block->region_pos; + + // Copy other potentially important metadata from prior air + // layer + air_block->lighting = last_air_block->lighting; + air_block->temperature_1 = last_air_block->temperature_1; + air_block->temperature_2 = last_air_block->temperature_2; + air_block->region_offset = last_air_block->region_offset; + + // Create tile designations to inform lighting and + // outside markers + df::tile_designation designation{}; + designation.bits.light = true; + designation.bits.outside = true; + std::fill(&air_block->designation[0][0], + &air_block->designation[0][0] + (16 * 16), designation); + + blockColumn[z_count_block + count] = air_block; + world->map.map_blocks.push_back(air_block); + + // deal with map_block_column stuff even though it'd probably be + // fine + df::map_block_column* column = + world->map.column_index[bpos.x][bpos.y]; + if (!column) + { + continue; + } + df::block_column_print_infost* glyphs = new df::block_column_print_infost; + glyphs->x = {0,1,2,3}; + glyphs->y = {0,0,0,0}; + glyphs->tile = {'e','x','p','^'}; + column->unmined_glyphs.push_back(glyphs); + } + return true; + }); + + // Update global z level flags + df::z_level_flags* flags = new df::z_level_flags[z_count_block + quantity]; + memcpy(flags, world->map_extras.z_level_flags, + z_count_block * sizeof(df::z_level_flags)); + for (int32_t count = 0; count < quantity; count++) + { + flags[z_count_block + count].whole = 0; + flags[z_count_block + count].bits.update = 1; + } + world->map.z_count_block += quantity; + world->map.z_count += quantity; + delete[] world->map_extras.z_level_flags; + world->map_extras.z_level_flags = flags; + + auto updateInvasionMap = [](int32_t new_height, df::plot_invasion_mapst & map) -> void + { + if (map.blockz == 0) + return; // Unused invasion map + if (map.blockz >= new_height) + return; // No change required + + cuboid blocks(0, 0, 0, map.blockx - 1, map.blocky - 1, 0); + blocks.forCoord([&] (df::coord bpos) { + // Create new vertical block + df::pim_blockst** new_block = new df::pim_blockst * [new_height](); + std::memcpy(new_block, map.block_index[bpos.x][bpos.y], map.blockz * sizeof(df::pim_blockst*)); + // Fill new block with nullptr (no information) + std::fill_n(&new_block[map.blockz], new_height - map.blockz, nullptr); + delete[] map.block_index[bpos.x][bpos.y]; + map.block_index[bpos.x][bpos.y] = new_block; + return true; + }); + + map.blockz = new_height; + }; + + auto plotinfo = df::global::plotinfo; + + for (auto& invasion : plotinfo->invasions.list) + { + updateInvasionMap(world->map.z_count, invasion->map); + } + for (auto& entity : world->entities.all) + { + for (auto& map : entity->plot_invasion_map | std::views::filter([&](df::entity_plot_invasion_mapst* map) { return map->site_id == plotinfo->site_id; })) + { + updateInvasionMap(world->map.z_count, map->map); + } + } +} + + + +// reverse engineered from DF 50.13 (FUN_140d82ca0, likely sitest::get_site_type_name) +const char* Maps::getSiteTypeName(df::world_site *site) { + using wst = df::enums::world_site_type::world_site_type; + switch (site->type) { + case wst::PlayerFortress: + case wst::MountainHalls: + if (site->min_depth == 0 && (0 < site->max_depth)){ + return "fortress"; + } + if (site->min_depth > 0) { + return "mountain halls"; + } + return "hillocks"; + + case wst::DarkFortress: { + bool has_market = site->flag.is_set(df::enums::site_flag_type::HAS_MARKET); + return has_market ? "fortress" : "pits"; + } + + case wst::Cave: + return "cave"; + + case wst::ForestRetreat: + return "forest retreat"; + + case wst::Town: { + bool has_market = site->flag.is_set(df::enums::site_flag_type::HAS_MARKET); + return has_market ? "town" : "hamlet"; + } + + case wst::ImportantLocation: + return "important location"; + + case wst::LairShrine: + if (site->subtype_info) { + switch (site->subtype_info->lair_type) { + case df::enums::lair_type::LABYRINTH: + return "labyrinth"; + case df::enums::lair_type::SHRINE: + return "shrine"; + default: + break; + } + } + return "lair"; + + case wst::Fortress: + if (site->subtype_info) { + switch (site->subtype_info->fortress_type) { + case df::enums::fortress_type::TOWER: + return "tower"; + case df::enums::fortress_type::MONASTERY: + return "monastery"; + case df::enums::fortress_type::FORT: + return "fort"; + default: + return "castle"; + } + } + return "fortress"; + + case wst::Camp: + return "camp"; + + case wst::Monument: + if (site->subtype_info) { + switch (site->subtype_info->monument_type) { + case df::enums::monument_type::TOMB: + return "tomb"; + case df::enums::monument_type::VAULT: + return "vault"; + default: + break; + } + } + return "monument"; + + default: + return "site"; + } +} + +/* + * Filtered tile scan + */ + +bool Maps::TileFilter::matches(df::map_block *block, df::coord2d local, df::coord pos) const +{ + df::tiletype tt = block->tiletype[local.x][local.y]; + + if (!tiletypes.empty() && !tiletypes.count(tt)) + return false; + if (!materials.empty() && !materials.count(int16_t(tileMaterial(tt)))) + return false; + if (!shapes.empty() && !shapes.count(int16_t(tileShape(tt)))) + return false; + if (!shapes_basic.empty() && + !shapes_basic.count(int16_t(tileShapeBasic(tileShape(tt))))) + return false; + if (!specials.empty() && !specials.count(int16_t(tileSpecial(tt)))) + return false; + if (!variants.empty() && !variants.count(int16_t(tileVariant(tt)))) + return false; + + if (designation_mask && + (block->designation[local.x][local.y].whole & designation_mask) != designation_bits) + return false; + if (occupancy_mask && + (block->occupancy[local.x][local.y].whole & occupancy_mask) != occupancy_bits) + return false; + + if (extra && !extra(block, local, pos)) + return false; + + return true; +} + +Maps::TileScanResult Maps::forEachTile( + const cuboid &bounds, const TileFilter &filter, const TileActions &actions) +{ + TileScanResult result; + if (!IsValid()) + return result; + + // Construction records are buffered during the scan and merged at the + // end, so a bulk spawn costs one sorted merge instead of one insertion + // per tile. The records are plain data; no df::construction objects are + // allocated until the merge. + struct PendingConstruction { + df::coord pos; + df::tiletype original_tile; + }; + std::vector pending; + bool aborted = false; + + bounds.forBlock([&](df::map_block *block, cuboid area) { + int bx = block->map_pos.x, by = block->map_pos.y, bz = block->map_pos.z; + int lx1 = area.x_min - bx, lx2 = area.x_max - bx; + int ly1 = area.y_min - by, ly2 = area.y_max - by; + + for (int lx = lx1; lx <= lx2 && !aborted; lx++) { + for (int ly = ly1; ly <= ly2 && !aborted; ly++) { + df::coord2d local(lx, ly); + df::coord pos(bx + lx, by + ly, bz); + result.tiles_scanned++; + + if (!filter.matches(block, local, pos)) + continue; + result.tiles_matched++; + + if (actions.set_tiletype || !actions.tiletype_map.empty()) { + df::tiletype &cur = block->tiletype[lx][ly]; + auto it = actions.tiletype_map.find(cur); + if (it != actions.tiletype_map.end()) { + cur = it->second; + result.tiletypes_changed++; + } + else if (actions.set_tiletype) { + cur = *actions.set_tiletype; + result.tiletypes_changed++; + } + } + + if (actions.designation_set || actions.designation_clear) { + auto &des = block->designation[lx][ly]; + des.whole = (des.whole & ~actions.designation_clear) | actions.designation_set; + } + if (actions.occupancy_set || actions.occupancy_clear) { + auto &occ = block->occupancy[lx][ly]; + occ.whole = (occ.whole & ~actions.occupancy_clear) | actions.occupancy_set; + } + + if (actions.construct && !Constructions::findAtTile(pos)) { + pending.push_back({ pos, block->tiletype[lx][ly] }); + if (actions.construct_tiletype) { + block->tiletype[lx][ly] = *actions.construct_tiletype; + result.tiletypes_changed++; + } + } + + if (actions.callback && !actions.callback(block, local, pos)) + aborted = true; + } + } + return !aborted; + }); + result.aborted = aborted; + + if (!pending.empty()) { + auto &vec = world->event.constructions; + auto pos_less = [](const df::construction *a, const df::construction *b) { + return a->pos < b->pos; + }; + size_t old_size = vec.size(); + for (auto &p : pending) { + auto *con = new df::construction(); + con->pos = p.pos; + con->item_type = actions.construct_item_type; + con->item_subtype = actions.construct_item_subtype; + con->mat_type = actions.construct_mat_type; + con->mat_index = actions.construct_mat_index; + con->flags.whole = actions.construct_flags; + con->original_tile = p.original_tile; + vec.push_back(con); + } + std::sort(vec.begin() + old_size, vec.end(), pos_less); + std::inplace_merge(vec.begin(), vec.begin() + old_size, vec.end(), pos_less); + result.constructions_added = (int64_t)pending.size(); + } + + return result; +} diff --git a/library/modules/Materials.cpp b/library/modules/Materials.cpp index d5358f6139..f91882337a 100644 --- a/library/modules/Materials.cpp +++ b/library/modules/Materials.cpp @@ -28,7 +28,6 @@ distribution. #include "VersionInfo.h" #include "MemAccess.h" #include "Error.h" -#include "ModuleFactory.h" #include "Core.h" #include "MiscUtils.h" @@ -68,7 +67,7 @@ using namespace df::enums; using df::global::world; using df::global::plotinfo; -bool MaterialInfo::decode(df::item *item) +bool MaterialInfo::decode(df::item* item) { if (!item) return decode(-1); @@ -76,7 +75,7 @@ bool MaterialInfo::decode(df::item *item) return decode(item->getActualMaterial(), item->getActualMaterialIndex()); } -bool MaterialInfo::decode(const df::material_vec_ref &vr, int idx) +bool MaterialInfo::decode(const df::material_vec_ref& vr, int idx) { if (size_t(idx) >= vr.mat_type.size() || size_t(idx) >= vr.mat_index.size()) return decode(-1); @@ -94,21 +93,22 @@ bool MaterialInfo::decode(int16_t type, int32_t index) inorganic = NULL; plant = NULL; creature = NULL; figure = NULL; - if (type < 0) { + if (type < 0) + { mode = None; return false; } - auto &raws = world->raws; + auto& raws = world->raws; - if (size_t(type) >= sizeof(raws.mat_table.builtin)/sizeof(void*)) + if (size_t(type) >= sizeof(raws.mat_table.builtin) / sizeof(void*)) return false; if (index < 0) { material = raws.mat_table.builtin[type]; } - else if (type == 0) + else if (type == df::builtin_mats::INORGANIC) { mode = Inorganic; inorganic = df::inorganic_raw::find(index); @@ -116,23 +116,23 @@ bool MaterialInfo::decode(int16_t type, int32_t index) return false; material = &inorganic->material; } - else if (type < CREATURE_BASE) + else if (type < df::builtin_mats::CREATURE_1) { material = raws.mat_table.builtin[type]; } - else if (type < FIGURE_BASE) + else if (type <= df::builtin_mats::CREATURE_200) { mode = Creature; - subtype = type-CREATURE_BASE; + subtype = type - df::builtin_mats::CREATURE_1; creature = df::creature_raw::find(index); if (!creature || size_t(subtype) >= creature->material.size()) return false; material = creature->material[subtype]; } - else if (type < PLANT_BASE) + else if (type <= df::builtin_mats::HIST_FIG_200) { mode = Creature; - subtype = type-FIGURE_BASE; + subtype = type - df::builtin_mats::HIST_FIG_1; figure = df::historical_figure::find(index); if (!figure) return false; @@ -141,10 +141,10 @@ bool MaterialInfo::decode(int16_t type, int32_t index) return false; material = creature->material[subtype]; } - else if (type < END_BASE) + else if (type <= df::builtin_mats::PLANT_200) { mode = Plant; - subtype = type-PLANT_BASE; + subtype = type - df::builtin_mats::PLANT_1; plant = df::plant_raw::find(index); if (!plant || size_t(subtype) >= plant->material.size()) return false; @@ -158,24 +158,24 @@ bool MaterialInfo::decode(int16_t type, int32_t index) return (material != NULL); } -bool MaterialInfo::find(const std::string &token) +bool MaterialInfo::find(const std::string& token) { std::vector items; split_string(&items, token, ":"); return find(items); } -bool MaterialInfo::find(const std::vector &items) +bool MaterialInfo::find(const std::vector& items) { if (items.empty()) return false; if (items[0] == "INORGANIC" && items.size() > 1) - return findInorganic(vector_get(items,1)); + return findInorganic(vector_get(items, 1)); if (items[0] == "CREATURE_MAT" || items[0] == "CREATURE") - return findCreature(vector_get(items,1), vector_get(items,2)); + return findCreature(vector_get(items, 1), vector_get(items, 2)); if (items[0] == "PLANT_MAT" || items[0] == "PLANT") - return findPlant(vector_get(items,1), vector_get(items,2)); + return findPlant(vector_get(items, 1), vector_get(items, 2)); if (items.size() == 1) { @@ -188,7 +188,8 @@ bool MaterialInfo::find(const std::vector &items) } else if (items.size() == 2) { - if (items[0] == "COAL" && findBuiltin(items[0])) { + if (items[0] == "COAL" && findBuiltin(items[0])) + { if (items[1] == "COKE") this->index = 0; else if (items[1] == "CHARCOAL") @@ -206,18 +207,19 @@ bool MaterialInfo::find(const std::vector &items) return false; } -bool MaterialInfo::findBuiltin(const std::string &token) +bool MaterialInfo::findBuiltin(const std::string& token) { if (token.empty()) return decode(-1); - if (token == "NONE") { + if (token == "NONE") + { decode(-1); return true; } - auto &raws = world->raws; - for (int i = 0; i < NUM_BUILTIN; i++) + auto& raws = world->raws; + for (int i = 0; i < df::builtin_mats::CREATURE_1; i++) { auto obj = raws.mat_table.builtin[i]; if (obj && obj->id == token) @@ -226,34 +228,35 @@ bool MaterialInfo::findBuiltin(const std::string &token) return decode(-1); } -bool MaterialInfo::findInorganic(const std::string &token) +bool MaterialInfo::findInorganic(const std::string& token) { if (token.empty()) return decode(-1); - if (token == "NONE") { + if (token == "NONE") + { decode(0, -1); return true; } - auto &raws = world->raws; + auto& raws = world->raws; for (size_t i = 0; i < raws.inorganics.all.size(); i++) { - df::inorganic_raw *p = raws.inorganics.all[i]; + df::inorganic_raw* p = raws.inorganics.all[i]; if (p->id == token) return decode(0, i); } return decode(-1); } -bool MaterialInfo::findPlant(const std::string &token, const std::string &subtoken) +bool MaterialInfo::findPlant(const std::string& token, const std::string& subtoken) { if (token.empty()) return decode(-1); - auto &raws = world->raws; + auto& raws = world->raws; for (size_t i = 0; i < raws.plants.all.size(); i++) { - df::plant_raw *p = raws.plants.all[i]; + df::plant_raw* p = raws.plants.all[i]; if (p->id != token) continue; @@ -263,39 +266,39 @@ bool MaterialInfo::findPlant(const std::string &token, const std::string &subtok for (size_t j = 0; j < p->material.size(); j++) if (p->material[j]->id == subtoken) - return decode(PLANT_BASE+j, i); + return decode(df::builtin_mats::PLANT_1 + j, i); break; } return decode(-1); } -bool MaterialInfo::findCreature(const std::string &token, const std::string &subtoken) +bool MaterialInfo::findCreature(const std::string& token, const std::string& subtoken) { if (token.empty() || subtoken.empty()) return decode(-1); - auto &raws = world->raws; + auto& raws = world->raws; for (size_t i = 0; i < raws.creatures.all.size(); i++) { - df::creature_raw *p = raws.creatures.all[i]; + df::creature_raw* p = raws.creatures.all[i]; if (p->creature_id != token) continue; for (size_t j = 0; j < p->material.size(); j++) if (p->material[j]->id == subtoken) - return decode(CREATURE_BASE+j, i); + return decode(df::builtin_mats::CREATURE_1 + j, i); break; } return decode(-1); } -bool MaterialInfo::findProduct(df::material *material, const std::string &name) +bool MaterialInfo::findProduct(df::material* material, const std::string& name) { if (!material || name.empty()) return decode(-1); - auto &pids = material->reaction_product.id; + auto& pids = material->reaction_product.id; for (size_t i = 0; i < pids.size(); i++) if ((*pids[i]) == name) return decode(material->reaction_product.material, i); @@ -311,9 +314,11 @@ std::string MaterialInfo::getToken() const if (!material) return fmt::format("INVALID:{}:{}", type, index); - switch (mode) { + switch (mode) + { case Builtin: - if (material->id == "COAL") { + if (material->id == "COAL") + { if (index == 0) return "COAL:COKE"; else if (index == 1) @@ -382,10 +387,10 @@ bool MaterialInfo::isAnyCloth() const material->flags.is_set(THREAD_PLANT) || material->flags.is_set(SILK) || material->flags.is_set(YARN) - ); + ); } -bool MaterialInfo::matches(const df::job_material_category &cat) const +bool MaterialInfo::matches(const df::job_material_category& cat) const { if (!material) return false; @@ -414,7 +419,7 @@ bool MaterialInfo::matches(const df::job_material_category &cat) const return false; } -bool MaterialInfo::matches(const df::dfhack_material_category &cat) const +bool MaterialInfo::matches(const df::dfhack_material_category& cat) const { if (!material) return false; @@ -443,7 +448,7 @@ bool MaterialInfo::matches(const df::dfhack_material_category &cat) const #undef TEST -bool MaterialInfo::matches(const df::job_item &jitem, df::item_type itype) const +bool MaterialInfo::matches(const df::job_item& jitem, df::item_type itype) const { if (!isValid()) return false; @@ -460,11 +465,11 @@ bool MaterialInfo::matches(const df::job_item &jitem, df::item_type itype) const mask2.whole &= ~xmask2.whole; return bits_match(jitem.flags1.whole, ok1.whole, mask1.whole) && - bits_match(jitem.flags2.whole, ok2.whole, mask2.whole) && - bits_match(jitem.flags3.whole, ok3.whole, mask3.whole); + bits_match(jitem.flags2.whole, ok2.whole, mask2.whole) && + bits_match(jitem.flags3.whole, ok3.whole, mask3.whole); } -void MaterialInfo::getMatchBits(df::job_item_flags1 &ok, df::job_item_flags1 &mask) const +void MaterialInfo::getMatchBits(df::job_item_flags1& ok, df::job_item_flags1& mask) const { ok.whole = mask.whole = 0; if (!isValid()) return; @@ -486,10 +491,10 @@ void MaterialInfo::getMatchBits(df::job_item_flags1 &ok, df::job_item_flags1 &ma TEST(processable_to_vial, structural && FLAG(plant, plant_raw_flags::EXTRACT_VIAL)); TEST(processable_to_barrel, structural && FLAG(plant, plant_raw_flags::EXTRACT_BARREL)); TEST(solid, !(MAT_FLAG(ALCOHOL_PLANT) || - MAT_FLAG(ALCOHOL_CREATURE) || - MAT_FLAG(LIQUID_MISC_PLANT) || - MAT_FLAG(LIQUID_MISC_CREATURE) || - MAT_FLAG(LIQUID_MISC_OTHER))); + MAT_FLAG(ALCOHOL_CREATURE) || + MAT_FLAG(LIQUID_MISC_PLANT) || + MAT_FLAG(LIQUID_MISC_CREATURE) || + MAT_FLAG(LIQUID_MISC_OTHER))); TEST(tameable_vermin, false); TEST(sharpenable, MAT_FLAG(IS_STONE)); TEST(milk, linear_index(material->reaction_product.id, std::string("CHEESE_MAT")) >= 0); @@ -497,7 +502,7 @@ void MaterialInfo::getMatchBits(df::job_item_flags1 &ok, df::job_item_flags1 &ma //04000000 - "milkable" - vtable[107],1,1 } -void MaterialInfo::getMatchBits(df::job_item_flags2 &ok, df::job_item_flags2 &mask) const +void MaterialInfo::getMatchBits(df::job_item_flags2& ok, df::job_item_flags2& mask) const { ok.whole = mask.whole = 0; if (!isValid()) return; @@ -511,15 +516,15 @@ void MaterialInfo::getMatchBits(df::job_item_flags2 &ok, df::job_item_flags2 &ma TEST(glass_making, MAT_FLAG(CRYSTAL_GLASSABLE)); TEST(fire_safe, material->heat.melting_point > 11000 - && material->heat.boiling_point > 11000 - && material->heat.ignite_point > 11000 - && material->heat.heatdam_point > 11000 - && (material->heat.colddam_point == 60001 || material->heat.colddam_point < 11000)); + && material->heat.boiling_point > 11000 + && material->heat.ignite_point > 11000 + && material->heat.heatdam_point > 11000 + && (material->heat.colddam_point == 60001 || material->heat.colddam_point < 11000)); TEST(magma_safe, material->heat.melting_point > 12000 - && material->heat.boiling_point > 12000 - && material->heat.ignite_point > 12000 - && material->heat.heatdam_point > 12000 - && (material->heat.colddam_point == 60001 || material->heat.colddam_point < 12000)); + && material->heat.boiling_point > 12000 + && material->heat.ignite_point > 12000 + && material->heat.heatdam_point > 12000 + && (material->heat.colddam_point == 60001 || material->heat.colddam_point < 12000)); TEST(deep_material, FLAG(inorganic, inorganic_flags::SPECIAL)); TEST(non_economic, !inorganic || !(plotinfo && vector_get(plotinfo->economic_stone, index))); @@ -537,7 +542,7 @@ void MaterialInfo::getMatchBits(df::job_item_flags2 &ok, df::job_item_flags2 &ma TEST(yarn, MAT_FLAG(YARN)); } -void MaterialInfo::getMatchBits(df::job_item_flags3 &ok, df::job_item_flags3 &mask) const +void MaterialInfo::getMatchBits(df::job_item_flags3& ok, df::job_item_flags3& mask) const { ok.whole = mask.whole = 0; if (!isValid()) return; @@ -549,7 +554,7 @@ void MaterialInfo::getMatchBits(df::job_item_flags3 &ok, df::job_item_flags3 &ma #undef FLAG #undef TEST -bool DFHack::parseJobMaterialCategory(df::job_material_category *cat, const std::string &token) +bool DFHack::parseJobMaterialCategory(df::job_material_category* cat, const std::string& token) { cat->whole = 0; @@ -565,7 +570,7 @@ bool DFHack::parseJobMaterialCategory(df::job_material_category *cat, const std: return true; } -bool DFHack::parseJobMaterialCategory(df::dfhack_material_category *cat, const std::string &token) +bool DFHack::parseJobMaterialCategory(df::dfhack_material_category* cat, const std::string& token) { cat->whole = 0; @@ -600,32 +605,14 @@ bool DFHack::isStoneInorganic(int material) return true; } -std::unique_ptr DFHack::createMaterials() -{ - return std::make_unique(); -} - -Materials::Materials() -{ -} - -Materials::~Materials() -{ -} - -bool Materials::Finish() -{ - return true; -} - t_matgloss::t_matgloss() { - fore = 0; - back = 0; - bright = 0; + fore = 0; + back = 0; + bright = 0; - value = 0; - wall_tile = 0; + value = 0; + wall_tile = 0; boulder_tile = 0; } @@ -643,240 +630,7 @@ bool t_matglossInorganic::isGem() return is_gem; } -bool Materials::CopyInorganicMaterials (std::vector & inorganic) -{ - size_t size = world->raws.inorganics.all.size(); - inorganic.clear(); - inorganic.reserve (size); - for (size_t i = 0; i < size;i++) - { - df::inorganic_raw *orig = world->raws.inorganics.all[i]; - t_matglossInorganic mat; - mat.id = orig->id; - mat.name = orig->material.stone_name; - - mat.ore_types = orig->metal_ore.mat_index; - mat.ore_chances = orig->metal_ore.probability; - mat.strand_types = orig->thread_metal.mat_index; - mat.strand_chances = orig->thread_metal.probability; - mat.value = orig->material.material_value; - mat.wall_tile = orig->material.tile; - mat.boulder_tile = orig->material.item_symbol; - mat.fore = orig->material.basic_color[0]; - mat.bright = orig->material.basic_color[1]; - mat.is_gem = orig->material.flags.is_set(material_flags::IS_GEM); - inorganic.push_back(mat); - } - return true; -} - -bool Materials::CopyOrganicMaterials (std::vector & organic) -{ - size_t size = world->raws.plants.all.size(); - organic.clear(); - organic.reserve (size); - for (size_t i = 0; i < size;i++) - { - t_matgloss mat; - mat.id = world->raws.plants.all[i]->id; - organic.push_back(mat); - } - return true; -} - -bool Materials::CopyWoodMaterials (std::vector & tree) -{ - size_t size = world->raws.plants.trees.size(); - tree.clear(); - tree.reserve (size); - for (size_t i = 0; i < size;i++) - { - t_matgloss mat; - mat.id = world->raws.plants.trees[i]->id; - tree.push_back(mat); - } - return true; -} - -bool Materials::CopyPlantMaterials (std::vector & plant) -{ - size_t size = world->raws.plants.bushes.size(); - plant.clear(); - plant.reserve (size); - for (size_t i = 0; i < size;i++) - { - t_matgloss mat; - mat.id = world->raws.plants.bushes[i]->id; - plant.push_back(mat); - } - return true; -} - -bool Materials::ReadCreatureTypes (void) -{ - size_t size = world->raws.creatures.all.size(); - race.clear(); - race.reserve (size); - for (size_t i = 0; i < size;i++) - { - t_matgloss mat; - mat.id = world->raws.creatures.all[i]->creature_id; - race.push_back(mat); - } - return true; -} - -bool Materials::ReadOthers(void) -{ - other.clear(); - FOR_ENUM_ITEMS(builtin_mats, i) - { - t_matglossOther mat; - mat.id = world->raws.mat_table.builtin[i]->id; - other.push_back(mat); - } - return true; -} - -bool Materials::ReadDescriptorColors (void) -{ - size_t size = world->raws.descriptors.colors.size(); - - color.clear(); - if(size == 0) - return false; - color.reserve(size); - for (size_t i = 0; i < size;i++) - { - df::descriptor_color *c = world->raws.descriptors.colors[i]; - t_descriptor_color col; - col.id = c->id; - col.name = c->name; - col.red = c->red; - col.green = c->green; - col.blue = c->blue; - color.push_back(col); - } - - size = world->raws.descriptors.patterns.size(); - alldesc.clear(); - alldesc.reserve(size); - for (size_t i = 0; i < size;i++) - { - t_matgloss mat; - mat.id = world->raws.descriptors.patterns[i]->id; - alldesc.push_back(mat); - } - return true; -} - -bool Materials::ReadCreatureTypesEx (void) -{ - size_t size = world->raws.creatures.all.size(); - raceEx.clear(); - raceEx.reserve (size); - for (size_t i = 0; i < size; i++) - { - df::creature_raw *cr = world->raws.creatures.all[i]; - t_creaturetype mat; - mat.id = cr->creature_id; - mat.tile_character = cr->creature_tile; - mat.tilecolor.fore = cr->color[0]; - mat.tilecolor.back = cr->color[1]; - mat.tilecolor.bright = cr->color[2]; - - size_t sizecas = cr->caste.size(); - for (size_t j = 0; j < sizecas;j++) - { - df::caste_raw *ca = cr->caste[j]; - /* caste name */ - t_creaturecaste caste; - caste.id = ca->caste_id; - caste.singular = ca->caste_name[0]; - caste.plural = ca->caste_name[1]; - caste.adjective = ca->caste_name[2]; - - // color mod reading - // Caste + offset > color mod vector - auto & colorings = ca->color_modifiers; - size_t sizecolormod = colorings.size(); - caste.ColorModifier.resize(sizecolormod); - for(size_t k = 0; k < sizecolormod;k++) - { - // color mod [0] -> color list - auto & indexes = colorings[k]->pattern_index; - size_t sizecolorlist = indexes.size(); - caste.ColorModifier[k].colorlist.resize(sizecolorlist); - for(size_t l = 0; l < sizecolorlist; l++) - caste.ColorModifier[k].colorlist[l] = indexes[l]; - // color mod [color_modifier_part_offset] = string part - caste.ColorModifier[k].part = colorings[k]->part; - caste.ColorModifier[k].startdate = colorings[k]->start_date; - caste.ColorModifier[k].enddate = colorings[k]->end_date; - } - - // body parts - caste.bodypart.clear(); - size_t sizebp = ca->body_info.body_parts.size(); - for (size_t k = 0; k < sizebp; k++) - { - df::body_part_raw *bp = ca->body_info.body_parts[k]; - t_bodypart part; - part.id = bp->token; - part.category = bp->category; - caste.bodypart.push_back(part); - } - using namespace df::enums::mental_attribute_type; - using namespace df::enums::physical_attribute_type; - for (int32_t k = 0; k < 7; k++) - { - auto & physical = ca->attributes.phys_att_range; - caste.strength[k] = physical[STRENGTH][k]; - caste.agility[k] = physical[AGILITY][k]; - caste.toughness[k] = physical[TOUGHNESS][k]; - caste.endurance[k] = physical[ENDURANCE][k]; - caste.recuperation[k] = physical[RECUPERATION][k]; - caste.disease_resistance[k] = physical[DISEASE_RESISTANCE][k]; - - auto & mental = ca->attributes.ment_att_range; - caste.analytical_ability[k] = mental[ANALYTICAL_ABILITY][k]; - caste.focus[k] = mental[FOCUS][k]; - caste.willpower[k] = mental[WILLPOWER][k]; - caste.creativity[k] = mental[CREATIVITY][k]; - caste.intuition[k] = mental[INTUITION][k]; - caste.patience[k] = mental[PATIENCE][k]; - caste.memory[k] = mental[MEMORY][k]; - caste.linguistic_ability[k] = mental[LINGUISTIC_ABILITY][k]; - caste.spatial_sense[k] = mental[SPATIAL_SENSE][k]; - caste.musicality[k] = mental[MUSICALITY][k]; - caste.kinesthetic_sense[k] = mental[KINESTHETIC_SENSE][k]; - caste.empathy[k] = mental[EMPATHY][k]; - caste.social_awareness[k] = mental[SOCIAL_AWARENESS][k]; - } - mat.castes.push_back(caste); - } - for (size_t j = 0; j < world->raws.creatures.all[i]->material.size(); j++) - { - t_creatureextract extract; - extract.id = world->raws.creatures.all[i]->material[j]->id; - mat.extract.push_back(extract); - } - raceEx.push_back(mat); - } - return true; -} - -bool Materials::ReadAllMaterials(void) -{ - bool ok = true; - ok &= this->ReadCreatureTypes(); - ok &= this->ReadCreatureTypesEx(); - ok &= this->ReadDescriptorColors(); - ok &= this->ReadOthers(); - return ok; -} - -std::string Materials::getDescription(const t_material & mat) +std::string Materials::getDescription(const t_material& mat) { MaterialInfo mi(mat.mat_type, mat.mat_index); if (mi.creature) @@ -889,7 +643,7 @@ std::string Materials::getDescription(const t_material & mat) // type of material only so we know which vector to retrieve // This is completely worthless now -std::string Materials::getType(const t_material & mat) +std::string Materials::getType(const t_material& mat) { MaterialInfo mi(mat.mat_type, mat.mat_index); switch (mi.mode) diff --git a/library/modules/Random.cpp b/library/modules/Random.cpp index f0d2054c9e..d6d682d921 100644 --- a/library/modules/Random.cpp +++ b/library/modules/Random.cpp @@ -35,7 +35,6 @@ using namespace std; #include "VersionInfo.h" #include "MemAccess.h" #include "Types.h" -#include "ModuleFactory.h" #include "Core.h" #include "Error.h" #include "VTableInterpose.h" diff --git a/library/modules/Renderer.cpp b/library/modules/Renderer.cpp index e134fa4f3c..dc80390960 100644 --- a/library/modules/Renderer.cpp +++ b/library/modules/Renderer.cpp @@ -2,6 +2,8 @@ #include "MiscUtils.h" #include "modules/Renderer.h" +#include "df/global_objects.h" + using namespace DFHack; using df::global::enabler; using df::global::gps; diff --git a/library/modules/Screen.cpp b/library/modules/Screen.cpp index d629c4b8cf..133caa82b2 100644 --- a/library/modules/Screen.cpp +++ b/library/modules/Screen.cpp @@ -28,7 +28,6 @@ distribution. #include "VersionInfo.h" #include "Types.h" #include "Error.h" -#include "ModuleFactory.h" #include "Core.h" #include "PluginManager.h" #include "LuaTools.h" @@ -55,6 +54,8 @@ distribution. #include "df/renderer.h" #include "df/plant.h" +#include +#include #include #include #include @@ -136,13 +137,8 @@ static bool doSetTile_map(const Pen &pen, int x, int y, int32_t * df::graphic_vi return true; } -static bool doSetTile_default(const Pen &pen, int x, int y, bool map, int32_t * df::graphic_viewportst::*texpos_field) +static bool doSetTile_char(const Pen &pen, int x, int y, bool use_graphics) { - bool use_graphics = Screen::inGraphicsMode(); - - if (map && use_graphics) - return doSetTile_map(pen, x, y, texpos_field); - if (x < 0 || x >= gps->dimx || y < 0 || y >= gps->dimy) return false; @@ -183,17 +179,21 @@ static bool doSetTile_default(const Pen &pen, int x, int y, bool map, int32_t * *flag &= flag_mask; } - uint8_t fg = pen.fg | (pen.bold << 3); - uint8_t bg = pen.bg; + // out-of-range colors (including COLOR_RESET, which only has meaning + // on text consoles) get the Pen defaults, grey text on black, so + // they can't reach the buffers + uint8_t fg = (pen.fg < 0 || pen.fg > COLOR_MAX ? COLOR_GREY : pen.fg) + | (pen.bold << 3); + uint8_t bg = pen.bg < 0 || pen.bg > COLOR_MAX ? COLOR_BLACK : pen.bg; if (pen.tile_mode == Screen::Pen::CharColor) *flag |= 2; // SCREENTEXPOS_FLAG_ADDCOLOR else if (pen.tile_mode == Screen::Pen::TileColor) { *flag |= 1; // SCREENTEXPOS_FLAG_GRAYSCALE if (pen.tile_fg) - fg = pen.tile_fg; + fg = pen.tile_fg < 0 || pen.tile_fg > COLOR_MAX ? COLOR_GREY : pen.tile_fg; if (pen.tile_bg) - bg = pen.tile_bg; + bg = pen.tile_bg < 0 || pen.tile_bg > COLOR_MAX ? COLOR_BLACK : pen.tile_bg; } if (pen.tile && use_graphics) { @@ -215,18 +215,29 @@ static bool doSetTile_default(const Pen &pen, int x, int y, bool map, int32_t * *texpos_lower = df::global::init->texpos_border_interior; // basic black background } - auto rgb_fg = &gps->uccolor[fg][0]; - auto rgb_bg = &gps->uccolor[bg][0]; - screen[1] = rgb_fg[0]; - screen[2] = rgb_fg[1]; - screen[3] = rgb_fg[2]; - screen[4] = rgb_bg[0]; - screen[5] = rgb_bg[1]; - screen[6] = rgb_bg[2]; + if (fg >= 0 && fg <= COLOR_MAX) + std::ranges::copy(gps->uccolor[fg], &screen[1]); + else + WARN(screen).print("in doSetTile_char, fg {} out of range\n", fg); + + if (bg >= 0 && bg <= COLOR_MAX) + std::ranges::copy(gps->uccolor[bg], &screen[4]); + else + WARN(screen).print("in doSetTile_char, bg {} out of range\n", bg); return true; } +static bool doSetTile_default(const Pen &pen, int x, int y, bool map, int32_t * df::graphic_viewportst::*texpos_field) +{ + bool use_graphics = Screen::inGraphicsMode(); + + if (map && use_graphics) + return doSetTile_map(pen, x, y, texpos_field); + + return doSetTile_char(pen, x, y, use_graphics); +} + GUI_HOOK_DEFINE(Screen::Hooks::set_tile, doSetTile_default); static bool doSetTile(const Pen &pen, int x, int y, bool map, int32_t * df::graphic_viewportst::*texpos_field = NULL) { @@ -287,12 +298,7 @@ static uint8_t to_16_bit_color(uint8_t *rgb) { return 0; } -static Pen doGetTile_default(int x, int y, bool map, int32_t * df::graphic_viewportst::*texpos_field = NULL) { - bool use_graphics = Screen::inGraphicsMode(); - - if (map && use_graphics) - return doGetTile_map(x, y, texpos_field); - +static Pen doGetTile_char(int x, int y, bool use_graphics) { if (x < 0 || x >= gps->dimx || y < 0 || y >= gps->dimy) return Pen(0, 0, 0, -1); @@ -352,6 +358,14 @@ static Pen doGetTile_default(int x, int y, bool map, int32_t * df::graphic_viewp return ret; } +static Pen doGetTile_default(int x, int y, bool map, int32_t * df::graphic_viewportst::*texpos_field = NULL) { + bool use_graphics = Screen::inGraphicsMode(); + + if (map && use_graphics) + return doGetTile_map(x, y, texpos_field); + return doGetTile_char(x, y, use_graphics); +} + GUI_HOOK_DEFINE(Screen::Hooks::get_tile, doGetTile_default); static Pen doGetTile(int x, int y, bool map, int32_t * df::graphic_viewportst::*texpos_field = NULL) { @@ -365,6 +379,64 @@ Pen Screen::readTile(int x, int y, bool map, int32_t * df::graphic_viewportst::* return doGetTile(x, y, map, texpos_field); } +bool Screen::paintMapPortTile(const Pen &pen, int x, int y, int32_t * df::graphic_map_portst::*texpos_field) +{ + if (!gps || !pen.valid()) return false; + + bool use_graphics = Screen::inGraphicsMode(); + if (!use_graphics) + return doSetTile_char(pen, x, y, use_graphics); + + if (!texpos_field) + texpos_field = &df::graphic_map_portst::screentexpos_interface; + + auto &vp = gps->main_map_port; + if (x < 0 || x >= vp->dim_x || y < 0 || y >= vp->dim_y) + return false; + + size_t max_index = vp->dim_y * vp->dim_x - 1; + size_t index = (y * vp->dim_x) + x; + + if (index > max_index) + return false; + + long texpos = pen.tile; + if (!texpos && pen.ch) + texpos = init->font.large_font_texpos[(uint8_t)pen.ch]; + (vp->*texpos_field)[index] = texpos; + return true; +} + +Pen Screen::readMapPortTile(int x, int y, int32_t * df::graphic_map_portst::*texpos_field) +{ + CHECK_NULL_POINTER(texpos_field) + + if (!gps) return Pen(0,0,0,-1); + + bool use_graphics = Screen::inGraphicsMode(); + + if (!use_graphics) + return doGetTile_char(x, y, use_graphics); + + auto &vp = gps->main_map_port; + + if (x < 0 || x >= vp->dim_x || y < 0 || y >= vp->dim_y) + return Pen(0, 0, 0, -1); + + size_t max_index = vp->dim_x * vp->dim_y - 1; + size_t index = (y * vp->dim_x) + x; + + if (index < 0 || index > max_index) + return Pen(0, 0, 0, -1); + + auto tile = (vp->*texpos_field)[index]; + + char ch = 0; + uint8_t fg = 0; + uint8_t bg = 0; + return Pen(ch, fg, bg, tile, false); +} + bool Screen::paintString(const Pen &pen, int x, int y, const std::string &text, bool map) { auto dim = getWindowSize(); diff --git a/library/modules/Textures.cpp b/library/modules/Textures.cpp index 21642a9bbe..dfa4c458fe 100644 --- a/library/modules/Textures.cpp +++ b/library/modules/Textures.cpp @@ -14,6 +14,7 @@ #include "VTableInterpose.h" #include "df/enabler.h" +#include "df/global_objects.h" #include "df/viewscreen_adopt_regionst.h" #include "df/viewscreen_loadgamest.h" #include "df/viewscreen_new_arenast.h" @@ -54,7 +55,7 @@ static ReservedRange reserved_range{}; static std::unordered_map g_handle_to_texpos; static std::unordered_map g_handle_to_reserved_texpos; static std::unordered_map g_handle_to_surface; -static std::unordered_map> g_tileset_to_handles; +static std::unordered_map> g_tileset_to_handles; static std::vector g_delayed_regs; static std::mutex g_adding_mutex; static std::atomic loading_state = false; @@ -195,14 +196,14 @@ TexposHandle Textures::loadTexture(SDL_Surface* surface, bool reserved) { return handle; } -std::vector Textures::loadTileset(const std::string& file, int tile_px_w, +std::vector Textures::loadTileset(const std::filesystem::path file, int tile_px_w, int tile_px_h, bool reserved) { if (g_tileset_to_handles.contains(file)) return g_tileset_to_handles[file]; if (!enabler) return std::vector{}; - SDL_Surface* surface = DFIMG_Load(file.c_str()); + SDL_Surface* surface = DFIMG_Load(file.string().c_str()); if (!surface) { ERR(textures).printerr("unable to load textures from '{}'\n", file); return std::vector{}; diff --git a/library/modules/Translation.cpp b/library/modules/Translation.cpp index 38ae2c51ba..097de85cc9 100644 --- a/library/modules/Translation.cpp +++ b/library/modules/Translation.cpp @@ -27,7 +27,6 @@ distribution. #include "VersionInfo.h" #include "MemAccess.h" #include "Types.h" -#include "ModuleFactory.h" #include "Core.h" #include "Error.h" #include "DataDefs.h" diff --git a/library/modules/Units.cpp b/library/modules/Units.cpp index d8bfebdc97..4c382266c3 100644 --- a/library/modules/Units.cpp +++ b/library/modules/Units.cpp @@ -27,11 +27,12 @@ distribution. #include "Internal.h" #include "MemAccess.h" #include "MiscUtils.h" -#include "ModuleFactory.h" +#include "TileTypes.h" #include "Types.h" #include "VersionInfo.h" #include "modules/Items.h" +#include "modules/Job.h" #include "modules/Maps.h" #include "modules/Materials.h" #include "modules/Translation.h" @@ -39,10 +40,14 @@ distribution. #include "modules/World.h" #include "df/activity_entry.h" +#include "df/building_cagest.h" +#include "df/building_chainst.h" +#include "df/buildingitemst.h" #include "df/burrow.h" #include "df/caste_raw.h" #include "df/creature_interaction_effect_display_namest.h" #include "df/creature_raw.h" +#include "df/creature_raw_flags.h" #include "df/curse_attr_change.h" #include "df/entity_position.h" #include "df/entity_position_assignment.h" @@ -90,6 +95,7 @@ distribution. #include "df/unit_wound_layerst.h" #include "df/world.h" #include "df/world_site.h" +#include "df/wrestle_state_type.h" #include #include @@ -99,6 +105,7 @@ distribution. #include #include #include +#include #include using std::max; @@ -146,7 +153,10 @@ bool Units::isCitizen(df::unit *unit, bool include_insane) { // Copied from the conditions used to decide game over, // except that the game appears to let melancholy/raving // dwarves count as citizens. - if (unit->flags1.whole & exclude_flags1 || + // include_insane relaxes only the sanity check; the dead are + // never citizens (intelligent undead are animate, not dead) + if (isDead(unit) || + unit->flags1.whole & exclude_flags1 || unit->flags2.whole & exclude_flags2 || (!include_insane && !isSane(unit))) return false; @@ -155,7 +165,7 @@ bool Units::isCitizen(df::unit *unit, bool include_insane) { bool Units::isResident(df::unit *unit, bool include_insane) { CHECK_NULL_POINTER(unit); - if (!include_insane && !isSane(unit)) + if (isDead(unit) || (!include_insane && !isSane(unit))) return false; return isOwnCiv(unit) && @@ -550,6 +560,12 @@ bool Units::isBloodsucker(df::unit *unit) { CHECK_NULL_POINTER(unit); return IS_ACTIVE_CASTE_FLAG(BLOODSUCKER); } + +bool Units::breathes(df::unit* unit) +{ + CHECK_NULL_POINTER(unit); + return !IS_ACTIVE_CASTE_FLAG(NOBREATHE); +} #undef IS_ACTIVE_CASTE_FLAG bool Units::isDwarf(df::unit *unit) { @@ -766,17 +782,83 @@ df::coord Units::getPosition(df::unit *unit) { bool Units::teleport(df::unit *unit, df::coord target_pos) { // Make sure source and dest map blocks are valid - auto old_occ = Maps::getTileOccupancy(unit->pos); + // getPosition resolves through a containing cage, so the source tile is + // correct even for caged units + const df::coord src_pos = getPosition(unit); + auto old_occ = Maps::getTileOccupancy(src_pos); auto new_occ = Maps::getTileOccupancy(target_pos); if (!old_occ || !new_occ) return false; + if (!unit->flags3.bits.ghostly) { + auto new_tt = Maps::getTileType(target_pos); + if (tileShapeBasic(tileShape(*new_tt)) == df::tiletype_shape_basic::Wall) + return false; + + using namespace df::enums::tile_building_occ; + if (auto b = new_occ->bits.building; + b == Obstacle || b == Well || b == Impassable || b == Dynamic) + return false; + } + + // these won't return nullptr because we already got occupancies from these coords. + if (Maps::getTileDesignation(src_pos)->bits.hidden) + return false; + if (Maps::getTileDesignation(target_pos)->bits.hidden) + return false; + + auto is_wagon = [](df::unit *unit) { + if (!unit) + return false; + auto craw = df::creature_raw::find(unit->race); + return craw && craw->flags.is_set(df::creature_raw_flags::EQUIPMENT_WAGON); + }; + + // EQUIPMENT units (e.g. wagons) occupy a 3x3 footprint centered on their + // position; all other units occupy just their position tile + auto unit_extent = [&](df::unit *unit) { + if (is_wagon(unit)) + return 1; + return 0; + }; + + const int extent = unit_extent(unit); + + auto for_each_occupied_tile = [&](df::coord center, auto &&fn) { + for (int dy = -extent; dy <= extent; ++dy) + for (int dx = -extent; dx <= extent; ++dx) { + df::coord tile(center.x+dx, center.y+dy, center.z); + if (auto occ = Maps::getTileOccupancy(tile)) + fn(tile, *occ); + } + }; + + // Occupancy flags are per-tile, not per-unit: only clear a flag when no + // other unit of the same kind still occupies the tile, matching how the + // game itself updates the flags when a unit leaves a tile + std::unordered_set grounded_tiles, standing_tiles; + for (auto other : world->units.active) { + // caged units and in-flight projectiles don't set tile occupancy, so + // they must not keep occupancy flags alive on their pos tile + if (other == unit || other->flags1.bits.caged || + other->flags1.bits.projectile) + continue; + auto &tiles = other->flags1.bits.on_ground ? grounded_tiles : standing_tiles; + const int other_extent = unit_extent(other); + for (int dy = -other_extent; dy <= other_extent; ++dy) + for (int dx = -other_extent; dx <= other_extent; ++dx) + tiles.emplace(other->pos.x+dx, other->pos.y+dy, other->pos.z); + } + // Clear appropriate occupancy flags at old tile - if (unit->flags1.bits.on_ground) - // This is potentially wrong, but the game will recompute this as needed - old_occ->bits.unit_grounded = false; - else - old_occ->bits.unit = false; + for_each_occupied_tile(src_pos, [&](df::coord tile, df::tile_occupancy &occ) { + if (unit->flags1.bits.on_ground) { + if (!grounded_tiles.contains(tile)) + occ.bits.unit_grounded = false; + } else if (!standing_tiles.contains(tile)) { + occ.bits.unit = false; + } + }); // Clear unit projectile info if (unit->flags1.bits.projectile) { @@ -791,24 +873,112 @@ bool Units::teleport(df::unit *unit, df::coord target_pos) } // If there's already somebody standing at the destination, then force the unit to lay down - if (new_occ->bits.unit) + // (except riders never lay down) + if (new_occ->bits.unit && !unit->flags1.bits.rider) unit->flags1.bits.on_ground = true; // Set appropriate occupancy flags at new tile - if (unit->flags1.bits.on_ground) - new_occ->bits.unit_grounded = true; - else - new_occ->bits.unit = true; + for_each_occupied_tile(target_pos, [&](df::coord, df::tile_occupancy &occ) { + if (unit->flags1.bits.on_ground) + occ.bits.unit_grounded = true; + else + occ.bits.unit = true; + }); // Move unit to destination unit->pos = target_pos; unit->idle_area = target_pos; - // Move unit's riders (including babies) to destination - if (unit->flags1.bits.ridden) + // Move unit's riders (including babies and wounded) to destination + if (unit->flags1.bits.ridden || unit->flags4.bits.carrying_somebody) { for (auto rider : world->units.other[units_other_id::ANY_RIDER]) if (rider->relationship_ids[unit_relationship_type::RiderMount] == unit->id) - rider->pos = unit->pos; + // Wagon riders need special handling -- they are not at the center of the wagon, + // and the draft animal riders are not even in the extent of the wagon. + // Unified this case with the normal case by maintaining the rider's delta-pos from + // the mount, and recursively teleporting instead of just setting the rider's pos. + Units::teleport(rider, (rider->pos - src_pos) + unit->pos); + } + + // if the unit itself was a rider and it is not on the same tile as its mount, break that link. + // (except do nothing to wagon-riders). + if (unit->flags1.bits.rider) { + auto mount = df::unit::find(unit->relationship_ids[unit_relationship_type::RiderMount]); + if (mount && unit->pos != mount->pos && !is_wagon(mount)) { + unit->flags1.bits.rider = false; + unit->relationship_ids[unit_relationship_type::RiderMount] = -1; + // units.other.* are not sorted; do not use erase_from_vector(). + std::erase(world->units.other[units_other_id::ANY_RIDER], unit); + mount->flags1.bits.ridden = false; + mount->flags4.bits.carrying_somebody = false; + // rebuild the ridden flags; mothers can carry more than one baby. + for (auto rider : world->units.other[units_other_id::ANY_RIDER]) { + if (rider->relationship_ids[unit_relationship_type::RiderMount] == mount->id) { + if (rider->profession == df::profession::BABY || (mount->job.current_job + && mount->job.current_job->job_type == job_type::RecoverWounded)) + mount->flags4.bits.carrying_somebody = true; + else + mount->flags1.bits.ridden = true; + } + } + } + } + + auto delete_cagee_jobs = [](df::unit* unit, df::building* bld) { + std::vector jobs_to_delete; + for (auto job: bld->jobs) + if (findUnitRef(job->general_refs, df::general_ref_type::UNIT_CAGEE) == unit) + jobs_to_delete.push_back(job); + for (auto job: jobs_to_delete) + Job::removeJob(job); + }; + + if (auto cage = getContainer(unit)) { + auto bld = strict_virtual_cast(Items::getHolderBuilding(cage)); + if (bld && bld->contained_items.size() > 0 && bld->contained_items[0]->item == cage) { + delete_cagee_jobs(unit, bld); + std::erase(bld->assigned_units, unit->id); + bld->cage_flags.bits.check_occupants = true; + } + removeRef(cage->general_refs, df::general_ref_type::CONTAINS_UNIT, unit->id); + removeRef(unit->general_refs, df::general_ref_type::CONTAINED_IN_ITEM, cage->id); + unit->flags1.bits.caged = false; + } + + if (auto bld = strict_virtual_cast + (findBuildingRef(unit->general_refs, df::general_ref_type::BUILDING_CHAIN))) + { + delete_cagee_jobs(unit, bld); + if (bld->chained == unit) + bld->chained = nullptr; + if (bld->assigned == unit) + bld->assigned = nullptr; + removeRef(unit->general_refs, df::general_ref_type::BUILDING_CHAIN, bld->id); + unit->flags1.bits.chained = false; + } + + auto break_dragging_links = [&target_pos](df::unit* dragger, df::unit* draggee) { + if (!dragger || !draggee) + return; + if (dragger->flags1.bits.merchant && draggee->flags1.bits.merchant) { + // this cannot infinite-recurse because at least one of the units is already at target_pos. + if (dragger->pos != target_pos) + Units::teleport(dragger, target_pos); + if (draggee->pos != target_pos) + Units::teleport(draggee, target_pos); + return; + } + if ((isCitizen(dragger, false) || isResident(dragger, false)) && dragger->job.current_job != NULL) + Job::removeJob(dragger->job.current_job); + dragger->relationship_ids[unit_relationship_type::Draggee] = -1; + draggee->relationship_ids[unit_relationship_type::Dragger] = -1; + }; + + if (auto draggee = df::unit::find(unit->relationship_ids[unit_relationship_type::Draggee])) + break_dragging_links(unit, draggee); + if (auto dragger = df::unit::find(unit->relationship_ids[unit_relationship_type::Dragger])) + break_dragging_links(dragger, unit); + return true; } @@ -977,6 +1147,7 @@ void Units::setAutomaticProfessions(df::unit* unit) { // functionality reverse-engineered from DF's unitst::set_goal void Units::setPathGoal(df::unit *unit, df::coord pos, df::unit_path_goal goal) { + CHECK_NULL_POINTER(unit); if (unit->path.dest != pos || unit->path.goal != goal) { unit->path.dest = pos; @@ -2027,6 +2198,9 @@ int32_t Units::getFocusPenalty(df::unit* unit, need_type_set need_types) { CHECK_NULL_POINTER(unit); int max_penalty = INT_MAX; + if (!unit->status.current_soul) { + return max_penalty; + } auto& needs = unit->status.current_soul->personality.needs; for (auto const need : needs) { if (need_types.test(need->id)) { @@ -2325,3 +2499,18 @@ df::unit* Units::get_cached_unit_by_global_id(int32_t id, int32_t& index) index = binsearch_index(vector, &df::unit::id, id); return index != -1 ? vector[index] : nullptr; } + +// reverse engineered from df's unitst::breathingstate +Units::breathing_state Units::getBreathingState(df::unit* unit) +{ + using enum breathing_state; + + if (!Units::breathes(unit)) return FINE; + if (unit->flags1.bits.drowning) return CANT; + + auto& wrestle_items = unit->status.wrestle_items; + if (std::any_of(wrestle_items.begin(), wrestle_items.end(), [] (auto w) { return w->advantage < 0 && w->state == df::wrestle_state_type::Choke; })) + return CANT; + + return unit->flags2.bits.breathing_problem ? ( unit->flags2.bits.breathing_good ? TROUBLE : CANT ) : FINE; +} diff --git a/library/modules/World.cpp b/library/modules/World.cpp index d19f61fcee..d4fd2e0682 100644 --- a/library/modules/World.cpp +++ b/library/modules/World.cpp @@ -29,6 +29,7 @@ distribution. #include "modules/Gui.h" #include "modules/Translation.h" #include "modules/Units.h" +#include "modules/Maps.h" #include "modules/World.h" #include "modules/Translation.h" @@ -37,6 +38,7 @@ distribution. #include "df/map_block.h" #include "df/plotinfost.h" #include "df/viewscreen_dwarfmodest.h" +#include "df/weather_type.h" #include "df/world.h" #include "df/world_data.h" #include "df/world_site.h" @@ -153,11 +155,9 @@ uint32_t World::ReadCurrentDay() return ((ReadCurrentTick() / 1200) % 28) + 1; } -uint8_t World::ReadCurrentWeather() +df::weather_type World::ReadCurrentWeather() { - if (df::global::current_weather) - return (*df::global::current_weather)[2][2]; - return 0; + return Maps::getCurrentWeather(); } void World::SetCurrentWeather(uint8_t weather) @@ -208,7 +208,7 @@ bool World::isLegends(df::game_type t) } df::unit * World::getAdventurer() { - if (!isAdventureMode() || !world) + if (!world || !df::global::gamemode || *df::global::gamemode != game_mode::ADVENTURE) return NULL; return world->units.adv_unit; @@ -217,8 +217,15 @@ df::unit * World::getAdventurer() { int32_t World::GetCurrentSiteId() { if (!plotinfo) return -1; - if (isFortressMode()) - return plotinfo->site_id; + if (isFortressMode()) { + // on a reclaimed fortress, site_id isn't assigned until the first + // save; fortress_site is set at embark, so use it as a fallback + if (plotinfo->site_id >= 0) + return plotinfo->site_id; + if (auto site = plotinfo->main.fortress_site) + return site->id; + return -1; + } if (auto adv = getAdventurer(); adv && world->world_data) { DEBUG(world).print("searching for adventure site\n"); auto & world_map = world->map; diff --git a/library/xml b/library/xml index 3826f45ef0..2e38688ea0 160000 --- a/library/xml +++ b/library/xml @@ -1 +1 @@ -Subproject commit 3826f45ef0fad7bd3357a6d55d5c9d28b56614c2 +Subproject commit 2e38688ea0e4e20f3a7dd48eabe77458b3a9c19f diff --git a/package/launchdf.cpp b/package/launchdf.cpp index 5a850c6cf9..3b07abf99a 100644 --- a/package/launchdf.cpp +++ b/package/launchdf.cpp @@ -10,6 +10,9 @@ #include "steam_api.h" #include +#include +#include +#include #define xstr(s) str(s) #define str(s) #s @@ -234,10 +237,85 @@ bool waitForDF(bool nowait) { #endif +constexpr const char* old_filelist[] { + "hack", + "stonesense", +#ifdef WIN32 + "binpatch.exe", + "dfhack-run.exe", + "allegro-5.2.dll", + "allegro_color-5.2.dll", + "allegro_font-5.2.dll", + "allegro_image-5.2.dll", + "allegro_primitives-5.2.dll", + "allegro_ttf-5.2.dll", + "allegro-5.2.dll", + "dfhack-client.dll", + "dfhooks_dfhack.dll", + "lua53.dll", + "protobuf-lite.dll" +#else + "binpatch", + "dfhack-run", + "liballegro-5.2.so", + "liballegro_color-5.2.so", + "liballegro_font-5.2.so", + "liballegro_image-5.2.so", + "liballegro_primitives-5.2.so", + "liballegro_ttf-5.2.so", + "liballegro-5.2.so", + "libdfhack-client.so", + "libdfhooks_dfhack.so", + "liblua53.so", + "libprotobuf-lite.so" +#endif +}; + +bool check_for_old_install(std::filesystem::path df_path) +{ + for (auto file : old_filelist) + { + std::filesystem::path p = df_path / file; + if (std::filesystem::exists(p)) + return true; + } + return false; +} + +void remove_old_install(std::filesystem::path df_path) +{ + std::string message{ + "Removing legacy files:" + }; + + for (auto file : old_filelist) + { + std::error_code ec; + + std::filesystem::path p = df_path / file; + + if (std::filesystem::is_directory(p)) + std::filesystem::remove_all(p, ec); + else if (std::filesystem::is_regular_file(p)) + std::filesystem::remove(p, ec); + else + continue; + + message += "\n" + p.string() + ": " + (ec ? "failed to remove - " + ec.message() : "removed successfully"); + } +#ifdef WIN32 + MessageBoxW(NULL, std::wstring(message.begin(), message.end()).c_str(), L"Legacy Install Cleanup", 0); +#endif +} + #ifdef WIN32 int WINAPI wWinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance, _In_ LPWSTR lpCmdLine, _In_ int nShowCmd) { #else int main(int argc, char* argv[]) { +#endif +#ifdef WIN32 + // force UTF-8 + std::setlocale(LC_ALL, ".utf8"); #endif // initialize steam context if (SteamAPI_RestartAppIfNecessary(DFHACK_STEAM_APPID)) { @@ -265,50 +343,136 @@ int main(int argc, char* argv[]) { } #ifdef WIN32 - if (is_running_on_wine()) { - // attempt launch via steam client - LPCWSTR err = launch_via_steam_posix(); - - if (err != NULL) - // steam client launch failed, attempt fallback launch - err = launch_direct(); - - if (err != NULL) - { - MessageBoxW(NULL, err, NULL, 0); - exit(1); - } - exit(0); - } + bool wine_detected = is_running_on_wine(); #endif - // steam detected and not running in wine + bool df_detected = SteamApps()->BIsAppInstalled(DF_STEAM_APPID); - if (!SteamApps()->BIsAppInstalled(DF_STEAM_APPID)) { + if (!df_detected) { // Steam DF is not installed. Assume DF is installed in same directory as DFHack and do a fallback launch exit(wrap_launch(launch_direct) ? 0 : 1); } - // obtain DF app path + // obtain DF and DFHack app paths - char buf[2048] = ""; + auto get_app_path_from_steam = [] (AppId_t appid) -> std::optional { + constexpr auto BUFSIZE = 2048; + char buf[BUFSIZE] = ""; + int bytes = SteamApps()->GetAppInstallDir(appid, (char*)&buf, BUFSIZE); + if (bytes <= 0) + return std::nullopt; + // steam API includes one or more null terminators after the path, so trim those off + for (; bytes > 0 && buf[bytes-1] == '\0'; bytes--); + return std::string(buf, bytes); + }; - int b1 = SteamApps()->GetAppInstallDir(DFHACK_STEAM_APPID, (char*)&buf, 2048); - std::string dfhack_install_folder = (b1 != -1) ? std::string(buf) : ""; + auto opt_dfhack_install_folder = get_app_path_from_steam(DFHACK_STEAM_APPID); + auto opt_df_install_folder = get_app_path_from_steam(DF_STEAM_APPID); + + if (opt_dfhack_install_folder && opt_df_install_folder && (*opt_df_install_folder != *opt_dfhack_install_folder)) + { + auto& dfhack_install_folder = *opt_dfhack_install_folder; + auto& df_install_folder = *opt_df_install_folder; - int b2 = SteamApps()->GetAppInstallDir(DF_STEAM_APPID, (char*)&buf, 2048); - std::string df_install_folder = (b2 != -1) ? std::string(buf) : ""; +#ifdef WIN32 + constexpr auto dfhooks_dll_name = "dfhooks.dll"; + constexpr auto dfhook_dfhack_dll_name = "dfhooks_dfhack.dll"; +#else + constexpr auto dfhooks_dll_name = "libdfhooks.so"; + constexpr auto dfhook_dfhack_dll_name = "libdfhooks_dfhack.so"; +#endif + // DF and DFHack are not co-installed (modern case) + // inject dfhooks.dll and dfhooks_dfhack.ini into DF install folder + std::filesystem::path dfhooks_dll_src = dfhack_install_folder / dfhooks_dll_name; + std::filesystem::path dfhooks_dll_dst = df_install_folder / dfhooks_dll_name; + std::filesystem::path dfhooks_ini_dst = df_install_folder / "dfhooks_dfhack.ini"; + std::filesystem::path dfhooks_dfhack_dll_src = dfhack_install_folder / "hack" / dfhook_dfhack_dll_name; + std::error_code ec; - if (df_install_folder != dfhack_install_folder) { - // DF and DFHack are not installed in the same library + std::filesystem::copy(dfhooks_dll_src, dfhooks_dll_dst, std::filesystem::copy_options::update_existing, ec); + if (!ec) + { + std::string indirection; + if (std::filesystem::exists(dfhooks_ini_dst)) + { + std::ifstream ini(dfhooks_ini_dst); + std::getline(ini, indirection); + } + + if (indirection != dfhooks_dfhack_dll_src.string()) + { + std::ofstream ini(dfhooks_ini_dst); + ini << dfhooks_dfhack_dll_src.string() << std::endl; + } + } + else + { #ifdef WIN32 - MessageBoxW(NULL, L"DFHack and Dwarf Fortress must be installed in the same Steam library.\nAborting.", NULL, 0); + std::wstring message{ + L"Failed to inject DFHack into Dwarf Fortress\n\n" + L"Details:\n" + dfhooks_dll_src.wstring() + + L" -> " + dfhooks_dll_dst.wstring() + + L"\n\nError code: " + std::to_wstring(ec.value()) + + L"\nError message: " + std::filesystem::relative(ec.message()).wstring() + }; + + MessageBoxW(NULL, message.c_str(), NULL, 0); #else - notify("DFHack and Dwarf Fortress must be installed in the same Steam library.\nAborting."); + std::string message{ + "Failed to inject DFHack into Dwarf Fortress\n\n" + "Details:\n" + dfhooks_dll_src.string() + + " -> " + dfhooks_dll_dst.string() + + "\n\nError code: " + std::to_string(ec.value()) + + "\nError message: " + std::filesystem::relative(ec.message()).string() + }; + + notify(message.c_str()); #endif - exit(1); + exit(1); + } + bool dirty = check_for_old_install(df_install_folder); + if (dirty) + { +#ifdef WIN32 + int ok = MessageBoxW(NULL, L"A legacy install of DFHack has been detected in the Dwarf Fortress folder. This likely means that you have installed DFHack with the old Steam client (or manually). This legacy installation will almost certainly interfere with using DFHack. Do you want to remove the old files now? (recommended)", L"Legacy DFHack Install Detected", MB_OKCANCEL); + + if (ok == IDOK) + remove_old_install(df_install_folder); +#else + std::string filelist; + for (auto file : old_filelist) + if (std::filesystem::exists(df_install_folder / file)) + filelist += (filelist.empty() ? "" : std::string(",")) + file; + + std::string message{ + "A legacy install of DFHack has been detected in the Dwarf Fortress directory.This likely means that you have installed DFHack with the old Steam client (or manually).This installation will almost certainly interfere with using DFHack. \n\n" + "To remove these files, run the following command: rm -r " + df_install_folder.string() + "/{ " + filelist + "}\n\n" + }; + + notify(message.c_str()); +#endif + } + } + +#ifdef WIN32 + if (wine_detected) + { + // attempt launch via steam client + LPCWSTR err = launch_via_steam_posix(); + + if (err != NULL) + // steam client launch failed, attempt fallback launch + err = launch_direct(); + + if (err != NULL) + { + MessageBoxW(NULL, err, NULL, 0); + exit(1); + } + exit(0); } +#endif if (!wrap_launch(launch_via_steam)) exit(1); @@ -329,6 +493,5 @@ int main(int argc, char* argv[]) { usleep(1000000); #endif } - exit(0); } diff --git a/plugins/3dveins.cpp b/plugins/3dveins.cpp index 07c1ca795d..30f270fbc0 100644 --- a/plugins/3dveins.cpp +++ b/plugins/3dveins.cpp @@ -8,6 +8,7 @@ #include "modules/Random.h" #include "modules/World.h" +#include "df/global_objects.h" #include "df/inorganic_raw.h" #include "df/map_block.h" #include "df/world.h" @@ -654,7 +655,9 @@ bool GeoBiome::init_layers() GeoLayer *VeinGenerator::mapLayer(Block *pb, df::coord2d tile) { int idx = pb->biomeIndexAt(tile); - GeoBiome *biome = biome_by_idx.at(idx); + if (idx < 0) + return NULL; + GeoBiome *biome = biome_by_idx[idx]; int lidx = pb->layerIndexAt(tile); if (unsigned(lidx) >= biome->layers.size()) diff --git a/plugins/Brushes.h b/plugins/Brushes.h index 14a4188e76..38847cac5a 100644 --- a/plugins/Brushes.h +++ b/plugins/Brushes.h @@ -5,6 +5,8 @@ #include #include +#include "modules/MapCache.h" + typedef vector coord_vec; class Brush { @@ -92,8 +94,8 @@ class BlockBrush : public Brush coord_vec points(MapExtras::MapCache & mc, DFHack::DFCoord start) { coord_vec v; - DFHack::DFCoord blockc = start / 16; - DFHack::DFCoord iterc = blockc * 16; + DFHack::DFCoord blockc = DFHack::Maps::getTileBlockCoord(start); + DFHack::DFCoord iterc = DFHack::Maps::getBlockOrigin(blockc); if( !mc.testCoord(start) ) return v; auto starty = iterc.y; diff --git a/plugins/CMakeLists.txt b/plugins/CMakeLists.txt index 355cc0ac4c..f8a75436bd 100644 --- a/plugins/CMakeLists.txt +++ b/plugins/CMakeLists.txt @@ -27,6 +27,10 @@ set_source_files_properties( Brushes.h PROPERTIES HEADER_FILE_ONLY TRUE ) # Plugins # If you are adding a plugin that you do not intend to commit to the DFHack repo, # see instructions for adding "external" plugins at the end of this file. +# +# CAVEAT: currently (June 2026) DFHack's core code will only autoload plugins from +# the hack/plugins directory. They cannot be autoloaded from mod directories. +# This has been explicit policy for several years. # Example plugin that uses protobufs # proto file must be in the proto/ folder @@ -47,7 +51,7 @@ if(BUILD_SUPPORTED) dfhack_plugin(autoclothing autoclothing.cpp LINK_LIBRARIES lua) dfhack_plugin(design design.cpp LINK_LIBRARIES lua) dfhack_plugin(autodump autodump.cpp) - dfhack_plugin(autofarm autofarm.cpp) + dfhack_plugin(autofarm autofarm.cpp LINK_LIBRARIES lua) #dfhack_plugin(autogems autogems.cpp LINK_LIBRARIES jsoncpp_static) add_subdirectory(autolabor) dfhack_plugin(autonestbox autonestbox.cpp LINK_LIBRARIES lua) @@ -77,6 +81,7 @@ if(BUILD_SUPPORTED) #add_subdirectory(embark-assistant) dfhack_plugin(edgescroll edgescroll.cpp) dfhack_plugin(eventful eventful.cpp LINK_LIBRARIES lua) + dfhack_plugin(export-world-map export-world-map.cpp LINK_LIBRARIES lua) dfhack_plugin(fastdwarf fastdwarf.cpp) dfhack_plugin(filltraffic filltraffic.cpp) dfhack_plugin(fix-occupancy fix-occupancy.cpp LINK_LIBRARIES lua) @@ -117,8 +122,9 @@ if(BUILD_SUPPORTED) #dfhack_plugin(siege-engine siege-engine.cpp LINK_LIBRARIES lua) dfhack_plugin(sort sort.cpp LINK_LIBRARIES lua) #dfhack_plugin(steam-engine steam-engine.cpp) + add_subdirectory(smooth-movement) dfhack_plugin(spectate spectate.cpp LINK_LIBRARIES lua) - #dfhack_plugin(stockflow stockflow.cpp LINK_LIBRARIES lua) + dfhack_plugin(stockflow stockflow.cpp LINK_LIBRARIES lua) add_subdirectory(stockpiles) dfhack_plugin(stocks stocks.cpp LINK_LIBRARIES lua) dfhack_plugin(strangemood strangemood.cpp) diff --git a/plugins/Plugins.cmake b/plugins/Plugins.cmake index 82439f69a5..7408e44031 100644 --- a/plugins/Plugins.cmake +++ b/plugins/Plugins.cmake @@ -124,7 +124,7 @@ macro(dfhack_plugin) target_include_directories(${PLUGIN_NAME} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/proto") target_link_libraries(${PLUGIN_NAME} protobuf-lite) endif() - target_link_libraries(${PLUGIN_NAME} dfhack dfhack-version ${FMTLIB} ${PLUGIN_LINK_LIBRARIES}) + target_link_libraries(${PLUGIN_NAME} dfhack dfhack-version lua ${FMTLIB} ${PLUGIN_LINK_LIBRARIES}) if(UNIX) set(PLUGIN_COMPILE_FLAGS "${PLUGIN_COMPILE_FLAGS} ${PLUGIN_COMPILE_FLAGS_GCC}") @@ -141,6 +141,10 @@ macro(dfhack_plugin) set_target_properties(${PLUGIN_NAME} PROPERTIES SUFFIX .plug.dll) endif() + if (UNIX) + set_target_properties(${PLUGIN_NAME} PROPERTIES INSTALL_RPATH "$ORIGIN/..") + endif() + install(TARGETS ${PLUGIN_NAME} LIBRARY DESTINATION ${DFHACK_PLUGIN_DESTINATION} RUNTIME DESTINATION ${DFHACK_PLUGIN_DESTINATION}) diff --git a/plugins/aquifer.cpp b/plugins/aquifer.cpp index afb8bbe54f..98968a9496 100644 --- a/plugins/aquifer.cpp +++ b/plugins/aquifer.cpp @@ -3,9 +3,11 @@ #include "PluginManager.h" #include "PluginLua.h" #include "TileTypes.h" +#include "Types.h" #include "modules/Maps.h" +#include "df/global_objects.h" #include "df/map_block.h" #include "df/world.h" @@ -149,6 +151,7 @@ static void get_z_range(color_ostream &out, int & minz, int & maxz, const df::co else maxz = get_max_aq_z(out, pos1, pos2) - skip_top; + maxz = std::min((int)pos2.z, maxz); minz = std::max((int)pos1.z, maxz - levels + 1); DEBUG(log,out).print("calculated z range: minz={}, maxz={}\n", minz, maxz); @@ -186,11 +189,32 @@ static void aquifer_list(color_ostream &out, df::coord pos1, df::coord pos2, int } } +// adjust the pos range to honor --skip-top and top-relative --levels, +// which are only meaningful when the target is the whole map (--all). +// for drain/convert the reference level is the topmost existing aquifer; +// for add it is the topmost level that can hold an aquifer tile. +static bool apply_top_relative_z(color_ostream &out, df::coord & pos1, df::coord & pos2, + int skip_top, int levels, bool top_is_aq) +{ + int minz = 0, maxz = -1; + get_z_range(out, minz, maxz, pos1, pos2, levels, top_is_aq, skip_top); + if (maxz < minz) + return false; + pos1.z = minz; + pos2.z = maxz; + return true; +} + static int aquifer_drain(color_ostream &out, string aq_type, - df::coord pos1, df::coord pos2, int skip_top, int levels, bool leaky) + df::coord pos1, df::coord pos2, int skip_top, int levels, bool leaky, bool top_relative) { - DEBUG(log,out).print("entering aquifer_drain: aq_type={}, pos1={}, pos2={}, skip_top={}, levels={}, leaky={}\n", - aq_type, pos1, pos2, skip_top, levels, leaky); + DEBUG(log,out).print("entering aquifer_drain: aq_type={}, pos1={}, pos2={}, skip_top={}, levels={}, leaky={}, top_relative={}\n", + aq_type, pos1, pos2, skip_top, levels, leaky, top_relative); + + if (top_relative && !apply_top_relative_z(out, pos1, pos2, skip_top, levels, true)) { + DEBUG(log, out).print("no aquifer levels in range after skip_top/levels\n"); + return 0; + } const bool all = aq_type == "all"; const bool heavy_state = aq_type == "heavy"; @@ -209,10 +233,15 @@ static int aquifer_drain(color_ostream &out, string aq_type, } static int aquifer_convert(color_ostream &out, string aq_type, - df::coord pos1, df::coord pos2, int skip_top, int levels, bool leaky) + df::coord pos1, df::coord pos2, int skip_top, int levels, bool leaky, bool top_relative) { - DEBUG(log,out).print("entering aquifer_convert: aq_type={}, pos1={}, pos2={}, skip_top={}, levels={}, leaky={}\n", - aq_type, pos1, pos2, skip_top, levels, leaky); + DEBUG(log,out).print("entering aquifer_convert: aq_type={}, pos1={}, pos2={}, skip_top={}, levels={}, leaky={}, top_relative={}\n", + aq_type, pos1, pos2, skip_top, levels, leaky, top_relative); + + if (top_relative && !apply_top_relative_z(out, pos1, pos2, skip_top, levels, true)) { + DEBUG(log, out).print("no aquifer levels in range after skip_top/levels\n"); + return 0; + } const bool heavy_state = aq_type == "heavy"; @@ -230,10 +259,15 @@ static int aquifer_convert(color_ostream &out, string aq_type, } static int aquifer_add(color_ostream &out, string aq_type, - df::coord pos1, df::coord pos2, int skip_top, int levels, bool leaky) + df::coord pos1, df::coord pos2, int skip_top, int levels, bool leaky, bool top_relative) { - DEBUG(log,out).print("entering aquifer_add: aq_type={}, pos1={}, pos2={}, skip_top={}, levels={}, leaky={}\n", - aq_type, pos1, pos2, skip_top, levels, leaky); + DEBUG(log,out).print("entering aquifer_add: aq_type={}, pos1={}, pos2={}, skip_top={}, levels={}, leaky={}, top_relative={}\n", + aq_type, pos1, pos2, skip_top, levels, leaky, top_relative); + + if (top_relative && !apply_top_relative_z(out, pos1, pos2, skip_top, levels, false)) { + DEBUG(log, out).print("no levels in range after skip_top/levels\n"); + return 0; + } const bool heavy_state = aq_type == "heavy"; diff --git a/plugins/autochop.cpp b/plugins/autochop.cpp index 811a3d1cb0..f85bbdd756 100644 --- a/plugins/autochop.cpp +++ b/plugins/autochop.cpp @@ -301,7 +301,7 @@ static int32_t estimate_logs(const df::plant *plant) { return 0; MaterialInfo mi; - mi.decode(MaterialInfo::PLANT_BASE, plant->material); + mi.decode(df::builtin_mats::PLANT_1, plant->material); bool is_shroom = mi.plant->flags.is_set(df::plant_raw_flags::TREE_HAS_MUSHROOM_CAP); int32_t trunks = 0, parent_dir = 0; diff --git a/plugins/autoclothing.cpp b/plugins/autoclothing.cpp index 89a45ee15f..d9b718f0ba 100644 --- a/plugins/autoclothing.cpp +++ b/plugins/autoclothing.cpp @@ -20,6 +20,7 @@ #include "df/item_helmst.h" #include "df/item_pantsst.h" #include "df/item_shoesst.h" +#include "df/itemdef_handlerst.h" #include "df/itemdef_armorst.h" #include "df/itemdef_glovesst.h" #include "df/itemdef_helmst.h" @@ -64,8 +65,8 @@ enum ConfigValues { struct ClothingRequirement; command_result autoclothing(color_ostream &out, vector ¶meters); static void do_autoclothing(); -static bool validateMaterialCategory(ClothingRequirement *requirement); -static bool setItem(string name, ClothingRequirement *requirement); +static bool validateMaterialCategory(ClothingRequirement& requirement); +static std::optional setItem(string name); static void generate_control(color_ostream &out); static bool isAvailableItem(df::item *item); @@ -87,11 +88,11 @@ struct ClothingRequirement { int16_t needed_per_citizen = 0; map total_needed_per_race; - bool matches(ClothingRequirement *b) { - return b->jobType == this->jobType - && b->itemType == this->itemType - && b->item_subtype == this->item_subtype - && b->material_category.whole == this->material_category.whole; + bool operator==(ClothingRequirement& b) const { + return b.jobType == this->jobType + && b.itemType == this->itemType + && b.item_subtype == this->item_subtype + && b.material_category.whole == this->material_category.whole; } string Serialize() { @@ -127,32 +128,41 @@ struct ClothingRequirement { stream >> needed_per_citizen; } - bool SetFromParameters(color_ostream &out, vector ¶meters) + //FIXME: when C++23, use std::expected here + static std::optional createFromParameters(color_ostream &out, vector ¶meters) { - if (parameters[0] == "clear") { // handle the clear case - if (!set_bitfield_field(&material_category, parameters[1], 1)) - out << "Unrecognized material type: " << parameters[1] << endl; - if (!setItem(parameters[2], this)) { - out << "Unrecognized item name or token: " << parameters[2] << endl; - return false; - } - else if (!validateMaterialCategory(this)) { - out << parameters[1] << " is not a valid material category for " << parameters[2] << endl; - return false; - } - return true; + df::job_material_category material_category; + if (parameters.size() < 1) return std::nullopt; + + size_t idx = 0; + if (parameters[0] == "clear") idx++; + + if (parameters.size() < idx + 1) return std::nullopt; + + if (!set_bitfield_field(&material_category, parameters[idx], 1)) + { + out << "Unrecognized material type: " << parameters[idx] << endl; + return std::nullopt; } - if (!set_bitfield_field(&material_category, parameters[0], 1)) - out << "Unrecognized material type: " << parameters[0] << endl; - if (!setItem(parameters[1], this)) { - out << "Unrecognized item name or token: " << parameters[1] << endl; - return false; + + auto req = setItem(parameters[idx + 1]); + + if (!req) + { + out << "Unrecognized item name or token: " << parameters[idx+1] << endl; + return std::nullopt; } - else if (!validateMaterialCategory(this)) { - out << parameters[0] << " is not a valid material category for " << parameters[1] << endl; - return false; + + req->material_category = material_category; + + if (!validateMaterialCategory(*req)) { + out << parameters[idx] << " is not a valid material category for " << parameters[idx+1] << endl; + return std::nullopt; + } + else + { + return req; } - return true; } string ToReadableLabel() { @@ -311,94 +321,98 @@ DFhackCExport command_result plugin_onupdate(color_ostream &out) { return CR_OK; } -static bool setItemFromName(string name, ClothingRequirement *requirement) +std::optional setItemFromName(string name) { -#define SEARCH_ITEM_RAWS(rawType, job, item) \ -for (auto &itemdef : world->raws.itemdefs.rawType) { \ - string fullName = itemdef->adjective.empty() ? itemdef->name : itemdef->adjective + " " + itemdef->name; \ - if (fullName == name) { \ - requirement->jobType = job_type::job; \ - requirement->itemType = item_type::item; \ - requirement->item_subtype = itemdef->subtype; \ - return true; \ - } \ -} - SEARCH_ITEM_RAWS(armor, MakeArmor, ARMOR); - SEARCH_ITEM_RAWS(gloves, MakeGloves, GLOVES); - SEARCH_ITEM_RAWS(shoes, MakeShoes, SHOES); - SEARCH_ITEM_RAWS(helms, MakeHelm, HELM); - SEARCH_ITEM_RAWS(pants, MakePants, PANTS); - return false; + auto SEARCH_ITEM_RAWS = [&name](FT df::itemdef_handlerst:: * rawType, df::job_type job, df::item_type item) -> std::optional + { + auto& itemdefs = world->raws.itemdefs.*rawType; + auto it = std::find_if(itemdefs.begin(), itemdefs.end(), [&name] (auto& itemdef) { + string fullName = itemdef->adjective.empty() ? itemdef->name : itemdef->adjective + " " + itemdef->name; + return fullName == name; + }); + if (it != itemdefs.end()) + { + auto& itemdef = *it; + return ClothingRequirement{.jobType = job, .itemType = item, .item_subtype = itemdef->subtype}; + } + return std::nullopt; + }; + + if (auto v = SEARCH_ITEM_RAWS(&df::itemdef_handlerst::armor, df::job_type::MakeArmor, df::item_type::ARMOR)) + return v; + if (auto v = SEARCH_ITEM_RAWS(&df::itemdef_handlerst::gloves, df::job_type::MakeGloves, df::item_type::GLOVES)) + return v; + if (auto v = SEARCH_ITEM_RAWS(&df::itemdef_handlerst::shoes, df::job_type::MakeShoes, df::item_type::SHOES)) + return v; + if (auto v = SEARCH_ITEM_RAWS(&df::itemdef_handlerst::helms, df::job_type::MakeHelm, df::item_type::HELM)) + return v; + if (auto v = SEARCH_ITEM_RAWS(&df::itemdef_handlerst::pants, df::job_type::MakePants, df::item_type::PANTS)) + return v; + return std::nullopt; } -static bool setItemFromToken(string token, ClothingRequirement *requirement) { +static std::optional setItemFromToken(string token) { ItemTypeInfo itemInfo; if (!itemInfo.find(token)) - return false; + return std::nullopt; switch (itemInfo.type) { case item_type::ARMOR: - requirement->jobType = job_type::MakeArmor; - break; + return ClothingRequirement{.jobType = job_type::MakeArmor, .itemType = itemInfo.type, .item_subtype = itemInfo.subtype}; case item_type::GLOVES: - requirement->jobType = job_type::MakeGloves; - break; + return ClothingRequirement{.jobType = job_type::MakeGloves, .itemType = itemInfo.type, .item_subtype = itemInfo.subtype}; case item_type::SHOES: - requirement->jobType = job_type::MakeShoes; - break; + return ClothingRequirement{.jobType = job_type::MakeShoes, .itemType = itemInfo.type, .item_subtype = itemInfo.subtype}; case item_type::HELM: - requirement->jobType = job_type::MakeHelm; - break; + return ClothingRequirement{.jobType = job_type::MakeHelm, .itemType = itemInfo.type, .item_subtype = itemInfo.subtype}; case item_type::PANTS: - requirement->jobType = job_type::MakePants; - break; + return ClothingRequirement{.jobType = job_type::MakePants, .itemType = itemInfo.type, .item_subtype = itemInfo.subtype}; default: - return false; + return std::nullopt; } - requirement->itemType = itemInfo.type; - requirement->item_subtype = itemInfo.subtype; - return true; } -static bool setItem(string name, ClothingRequirement *requirement) { - return setItemFromName(name, requirement) || setItemFromToken(name, requirement); +static std::optional setItem(string name) +{ + if (auto v = setItemFromName(name)) return v; + return setItemFromToken(name); } -static bool armorFlagsMatch(BitArray *flags, df::job_material_category *category) { - if (flags->is_set(df::armor_general_flags::SOFT) && - (category->bits.cloth || category->bits.yarn || category->bits.silk) +static bool armorFlagsMatch(BitArray& flags, df::job_material_category& category) { + if (flags.is_set(df::armor_general_flags::SOFT) && + (category.bits.cloth || category.bits.yarn || category.bits.silk) ) return true; - else if (flags->is_set(df::armor_general_flags::BARRED) && category->bits.bone) + else if (flags.is_set(df::armor_general_flags::BARRED) && category.bits.bone) return true; - else if (flags->is_set(df::armor_general_flags::SCALED) && category->bits.shell) + else if (flags.is_set(df::armor_general_flags::SCALED) && category.bits.shell) return true; - return flags->is_set(df::armor_general_flags::LEATHER) && category->bits.leather; + return flags.is_set(df::armor_general_flags::LEATHER) && category.bits.leather; } -static bool validateMaterialCategory(ClothingRequirement *requirement) { - auto itemDef = Items::getSubtypeDef(requirement->itemType, requirement->item_subtype); - switch (requirement->itemType) +static bool validateMaterialCategory(ClothingRequirement& requirement) { + auto itemDef = Items::getSubtypeDef(requirement.itemType, requirement.item_subtype); + switch (requirement.itemType) { case item_type::ARMOR: if (STRICT_VIRTUAL_CAST_VAR(armor, df::itemdef_armorst, itemDef)) - return armorFlagsMatch(&armor->props.flags, &requirement->material_category); + return armorFlagsMatch(armor->props.flags, requirement.material_category); break; case item_type::GLOVES: if (STRICT_VIRTUAL_CAST_VAR(armor, df::itemdef_glovesst, itemDef)) - return armorFlagsMatch(&armor->props.flags, &requirement->material_category); + return armorFlagsMatch(armor->props.flags, requirement.material_category); break; case item_type::SHOES: if (STRICT_VIRTUAL_CAST_VAR(armor, df::itemdef_shoesst, itemDef)) - return armorFlagsMatch(&armor->props.flags, &requirement->material_category); + return armorFlagsMatch(armor->props.flags, requirement.material_category); break; case item_type::HELM: if (STRICT_VIRTUAL_CAST_VAR(armor, df::itemdef_helmst, itemDef)) - return armorFlagsMatch(&armor->props.flags, &requirement->material_category); + return armorFlagsMatch(armor->props.flags, requirement.material_category); break; case item_type::PANTS: if (STRICT_VIRTUAL_CAST_VAR(armor, df::itemdef_pantsst, itemDef)) - return armorFlagsMatch(&armor->props.flags, &requirement->material_category); + return armorFlagsMatch(armor->props.flags, requirement.material_category); break; default: break; @@ -448,12 +462,15 @@ command_result autoclothing(color_ostream &out, vector ¶meters) } // Create a new requirement from the available parameters. - ClothingRequirement newRequirement; - if (!newRequirement.SetFromParameters(out, parameters)) + auto newRequirementOpt = ClothingRequirement::createFromParameters(out, parameters); + if (!newRequirementOpt) return CR_WRONG_USAGE; + + auto& newRequirement = *newRequirementOpt; + // All checks are passed. Now we either show or set the amount. bool settingSize = false; - bool matchedExisting = false; + if (parameters.size() > 2) { if (parameters[0] == "clear") { newRequirement.needed_per_citizen = 0; @@ -471,28 +488,30 @@ command_result autoclothing(color_ostream &out, vector ¶meters) } } - for (size_t i = clothingOrders.size(); i-- > 0;) { - if (!clothingOrders[i].matches(&newRequirement)) - continue; - matchedExisting = true; - if (settingSize) { - if (newRequirement.needed_per_citizen == 0) { - clothingOrders.erase(clothingOrders.begin() + i); - if (parameters[0] == "clear") - out << "Unset " << parameters[1] << " " << parameters[2] << endl; - else - out << "Unset " << parameters[0] << " " << parameters[1] << endl; + auto it = std::find(clothingOrders.begin(), clothingOrders.end(), newRequirement); + if (it != clothingOrders.end()) + { + if (settingSize) + { + if (newRequirement.needed_per_citizen == 0) + { + clothingOrders.erase(it); + if (parameters[0] == "clear") + out << "Unset " << parameters[1] << " " << parameters[2] << endl; + else + out << "Unset " << parameters[0] << " " << parameters[1] << endl; } - else { - clothingOrders[i] = newRequirement; + else + { + *it = newRequirement; out << "Set " << parameters[0] << " " << parameters[1] << " to " << parameters[2] << endl; } } else - out << parameters[0] << " " << parameters[1] << " is set to " << clothingOrders[i].needed_per_citizen << endl; - break; + out << parameters[0] << " " << parameters[1] << " is set to " << it->needed_per_citizen << endl; } - if (!matchedExisting) { + else + { if (settingSize) { if (newRequirement.needed_per_citizen == 0) out << parameters[0] << " " << parameters[1] << " already unset." << endl; @@ -613,6 +632,7 @@ static void add_clothing_orders() { newOrder->material_category = clothingOrder.material_category; newOrder->amount_left = amount; newOrder->amount_total = amount; + newOrder->frequency = df::workquota_frequency_type::OneTime; world->manager_orders.all.push_back(newOrder); } } @@ -786,7 +806,7 @@ static void generate_control(color_ostream &out) { } map availableGloves; - for (auto glove : world->items.other.HELM) { + for (auto glove : world->items.other.GLOVES) { if (!isAvailableItem(glove)) continue; availableGloves[glove->maker_race]++; @@ -797,7 +817,7 @@ static void generate_control(color_ostream &out) { } map availablePants; - for (auto pants : world->items.other.HELM) { + for (auto pants : world->items.other.PANTS) { if (!isAvailableItem(pants)) continue; availablePants[pants->maker_race]++; diff --git a/plugins/autodump.cpp b/plugins/autodump.cpp index 4376a888d0..994b86b02d 100644 --- a/plugins/autodump.cpp +++ b/plugins/autodump.cpp @@ -72,9 +72,43 @@ static constexpr uint32_t proj_flags = ( df::projectile_flags::mask_no_collide ); +// Original flags of items marked for destruction, so the marking can be +// reverted while the game is still paused (once a frame passes, the garbage +// collector may have already deleted the items) +static map pending_destroy; +static int last_frame = 0; + +static void reset_pending_destroy() +{ + if (world->frame_counter != last_frame) + { + last_frame = world->frame_counter; + pending_destroy.clear(); + } +} + +// mark an item for destruction, along with anything it contains, so that +// contained items are not left pointing at a deleted container +static void mark_for_destroy(df::item *itm) +{ + if (!pending_destroy.contains(itm->id)) + pending_destroy[itm->id] = itm->flags; + + itm->flags.bits.garbage_collect = true; + // Cosmetic changes: make them disappear from view instantly. + itm->flags.bits.forbid = true; + itm->flags.bits.hidden = true; + + vector contained_items; + Items::getContainedItems(itm, &contained_items); + for (auto child : contained_items) + mark_for_destroy(child); +} + static command_result autodump_main(color_ostream &out, vector ¶meters) { // Command line options. bool destroy = false; + bool undestroy = false; bool here = false; bool need_visible = false; bool need_hidden = false; @@ -86,6 +120,8 @@ static command_result autodump_main(color_ostream &out, vector ¶mete destroy = true; else if (p == "destroy-here") destroy = here = true; + else if (p == "undestroy") + undestroy = true; else if (p == "visible") need_visible = true; else if (p == "hidden") @@ -105,6 +141,29 @@ static command_result autodump_main(color_ostream &out, vector ¶mete return CR_FAILURE; } + reset_pending_destroy(); + + if (undestroy) + { // Revert items marked for destruction while the game is still paused. + int restored_total = 0; + for (auto &entry : pending_destroy) + { + // Items already garbage collected or deleted by another plugin + // are no longer in items.all, so find() returns NULL for them. + df::item *itm = df::item::find(entry.first); + if (!itm) + continue; + itm->flags.bits.garbage_collect = entry.second.bits.garbage_collect; + itm->flags.bits.hidden = entry.second.bits.hidden; + itm->flags.bits.dump = entry.second.bits.dump; + itm->flags.bits.forbid = entry.second.bits.forbid; + restored_total++; + } + pending_destroy.clear(); + out.print("Done. {} items unmarked for destruction.\n", restored_total); + return CR_OK; + } + int dumped_total = 0; bool make_projectile = false; @@ -176,10 +235,18 @@ static command_result autodump_main(color_ostream &out, vector ¶mete else { // Destroy if (here && itm->pos != pos_cursor) continue; - itm->flags.bits.garbage_collect = true; - // Cosmetic changes: make them disappear from view instantly. - itm->flags.bits.forbid = true; - itm->flags.bits.hidden = true; + // Don't destroy items in unit inventories (including items inside + // carried containers); deleting them leaves unit equipment refs + // dangling. Matches the check in autodump-destroy-item. + bool held_by_unit = itm->flags.bits.in_inventory; + for (auto ref : itm->general_refs) + { + if (ref->getType() == general_ref_type::UNIT_HOLDER) + held_by_unit = true; + } + if (held_by_unit) + continue; + mark_for_destroy(itm); } dumped_total++; } @@ -202,9 +269,6 @@ command_result df_autodump_destroy_here(color_ostream &out, vector ¶ return autodump_main(out, args); } -static map pending_destroy; -static int last_frame = 0; - command_result df_autodump_destroy_item(color_ostream &out, vector ¶meters) { if (!parameters.empty()) @@ -215,18 +279,14 @@ command_result df_autodump_destroy_item(color_ostream &out, vector ¶ return CR_FAILURE; // Allow undoing the destroy. - if (world->frame_counter != last_frame) - { - last_frame = world->frame_counter; - pending_destroy.clear(); - } + reset_pending_destroy(); - if (pending_destroy.count(item->id)) + if (pending_destroy.contains(item->id)) { df::item_flags old_flags = pending_destroy[item->id]; pending_destroy.erase(item->id); - item->flags.bits.garbage_collect = false; + item->flags.bits.garbage_collect = old_flags.bits.garbage_collect; item->flags.bits.hidden = old_flags.bits.hidden; item->flags.bits.dump = old_flags.bits.dump; item->flags.bits.forbid = old_flags.bits.forbid; diff --git a/plugins/autofarm.cpp b/plugins/autofarm.cpp index 65a08123d9..2881bfcc96 100644 --- a/plugins/autofarm.cpp +++ b/plugins/autofarm.cpp @@ -1,8 +1,12 @@ #include "Debug.h" +#include "LuaTools.h" #include "PluginManager.h" +#include "PluginLua.h" +#include "modules/Buildings.h" #include "modules/Items.h" #include "modules/Maps.h" +#include "modules/Materials.h" #include "modules/World.h" #include "df/biome_type.h" @@ -16,14 +20,17 @@ #include "df/item_plant_growthst.h" #include "df/item_seedsst.h" #include "df/items_other_id.h" +#include "df/material.h" +#include "df/material_flags.h" +#include "df/plant_growth.h" #include "df/plant_raw.h" #include "df/plant_raw_flags.h" #include "df/plotinfost.h" #include "df/unit.h" #include "df/world.h" +#include #include -#include using namespace DFHack; using namespace df::enums; @@ -51,7 +58,15 @@ class AutoFarm { std::map thresholds; int defaultThreshold = 50; + // thresholds for specific growth products of a plant, keyed by + // (plant index, growth id). growth items whose growth has no explicit + // threshold count toward the plant's pooled threshold instead + std::map, int> growth_thresholds; + std::map lastCounts; + // stockpile growth item counts, keyed by (plant index, growth id). + // populated for reporting; untracked growths also count toward lastCounts + std::map, int> lastGrowthCounts; public: void initialize() @@ -59,7 +74,10 @@ class AutoFarm { thresholds.clear(); defaultThreshold = 50; + growth_thresholds.clear(); + lastCounts.clear(); + lastGrowthCounts.clear(); } void setThreshold(int id, int val) @@ -72,6 +90,46 @@ class AutoFarm { return (thresholds.count(id) > 0) ? thresholds[id] : defaultThreshold; } + void removeThreshold(int id) + { + thresholds.erase(id); + } + + void setGrowthThreshold(int plant_id, const std::string& growth_id, int val) + { + growth_thresholds[std::make_pair(plant_id, growth_id)] = val; + } + + void removeGrowthThreshold(int plant_id, const std::string& growth_id) + { + growth_thresholds.erase(std::make_pair(plant_id, growth_id)); + } + + const std::map, int>& getGrowthThresholds() + { + return growth_thresholds; + } + + const std::map, int>& getLastGrowthCounts() + { + return lastGrowthCounts; + } + + int getDefault() + { + return defaultThreshold; + } + + const std::map& getThresholds() + { + return thresholds; + } + + const std::map& getLastCounts() + { + return lastCounts; + } + void setDefault(int val) { defaultThreshold = val; @@ -194,6 +252,110 @@ class AutoFarm { return raw ? raw->name : "NONE"; } + // whether the growth's material produces stockpilable growth items; + // only these are ever produced by processing jobs and gathering, so + // they are the only growths that can be meaningfully tracked + static bool is_stockpile_growth(df::plant_growth* growth) + { + MaterialInfo mat(growth->mat_type, growth->mat_index); + return mat.isValid() && + mat.material->flags.is_set(df::material_flags::STOCKPILE_PLANT_GROWTH); + } + + // the id of the stockpile growth at the given index, or "" if the + // growth doesn't exist or isn't stockpilable + static std::string stockpile_growth_id(df::plant_raw* plant, int32_t subtype) + { + if (subtype >= 0 && (size_t)subtype < plant->growths.size() && + is_stockpile_growth(plant->growths[subtype])) + return plant->growths[subtype]->id; + return ""; + } + + static bool has_stockpile_growth(df::plant_raw* plant, const std::string& growth_id) + { + for (auto g : plant->growths) + if (g->id == growth_id && is_stockpile_growth(g)) + return true; + return false; + } + + // gathers the set of biomes covered by the farm's tiles, since a farm may + // span multiple biomes and DF allows planting any crop that can grow in + // any of them (in the tiles of the respective biome) + std::set get_farm_biomes(df::building_farmplotst* farm) + { + std::set biomes; + for (int32_t x = farm->x1; x <= farm->x2; x++) + { + for (int32_t y = farm->y1; y <= farm->y2; y++) + { + if (!Buildings::containsTile(farm, df::coord2d(x, y))) + continue; + auto des = Maps::getTileDesignation(x, y, farm->z); + if (!des) + continue; + if (des->bits.subterranean) + { + // any subterranean tile can grow any crop that grows + // underground + biomes.insert(df::biome_type::SUBTERRANEAN_WATER); + biomes.insert(df::biome_type::SUBTERRANEAN_CHASM); + biomes.insert(df::biome_type::SUBTERRANEAN_LAVA); + } + else if (Maps::getRegionBiome(Maps::getTileBiomeRgn(df::coord(x, y, farm->z)))) + { + df::coord2d region(Maps::getTileBiomeRgn(df::coord(x, y, farm->z))); + biomes.insert(Maps::getBiomeType(region.x, region.y)); + } + } + } + return biomes; + } + +public: + bool is_farm_excluded(int32_t farm_id) + { + auto item = World::GetPersistentSiteData("autofarm/excluded_farm/" + std::to_string(farm_id)); + return item.isValid() && item.ival(0) != 0; + } + + void set_farm_excluded(int32_t farm_id, bool excluded) + { + const std::string key = "autofarm/excluded_farm/" + std::to_string(farm_id); + auto item = World::GetPersistentSiteData(key); + if (excluded) + { + if (!item.isValid()) + item = World::AddPersistentSiteData(key); + if (item.isValid()) + item.ival(0) = 1; + } + else if (item.isValid()) + { + World::DeletePersistentData(item); + } + } + + std::vector get_excluded_farms() + { + std::vector items; + std::vector ret; + World::GetPersistentSiteData(&items, "autofarm/excluded_farm/", true); + for (auto& i : items) + { + if (!i.isValid() || i.ival(0) == 0) + continue; + int32_t farm_id = atoi(i.key().substr(strlen("autofarm/excluded_farm/")).c_str()); + auto bld = df::building::find(farm_id); + // ignore entries left behind by destroyed farm plots + if (bld && bld->getType() == df::building_type::FarmPlot) + ret.push_back(farm_id); + } + return ret; + } + +private: void set_farm(color_ostream& out, int new_plant_id, df::building_farmplotst* farm, int season) { int old_plant_id = farm->plant_id[season]; @@ -206,54 +368,100 @@ class AutoFarm { } } - void set_farms(color_ostream& out, const std::set& plants, const std::vector& farms) +public: + void set_farms(color_ostream& out, const std::map>& plants, + const std::vector>>& farms) { - // this algorithm attempts to change as few farms as possible, while ensuring that - // the number of farms planting each eligible plant is "as equal as possible" + // this algorithm attempts to change as few farms as possible, while + // ensuring that the number of farms planting each plant is "as equal + // as possible". unlike previously, each farm may have a different set + // of plants that it can grow, since a farm can span multiple biomes int season = *df::global::cur_season; - if (farms.empty() || plants.empty()) + // the plants that each farm is eligible to grow, and the list of + // farms assigned to each plant + std::map> eligible; + std::map> assigned; + std::map counters; + std::vector unassigned; + + for (auto& [farm, biomes] : farms) { - // if no more plants were requested, fallow all farms - // if there were no farms, do nothing - for (auto farm : farms) + for (auto biome : biomes) { - set_farm(out, -1, farm, season); + auto it = plants.find(biome); + if (it != plants.end()) + eligible[farm].insert(it->second.begin(), it->second.end()); } - return; + for (auto plant : eligible[farm]) + assigned[plant]; // ensure every eligible plant can receive farms } - int min = farms.size() / plants.size(); // the number of farms that should plant each eligible plant, rounded down - int extra = farms.size() - min * plants.size(); // the remainder that cannot be evenly divided + // keep the current assignment for farms whose crop is still eligible + for (auto& [farm, biomes] : farms) + { + int cur = farm->plant_id[season]; + if (cur >= 0 && eligible[farm].count(cur)) + { + assigned[cur].push_back(farm); + counters[cur]++; + } + else + { + unassigned.push_back(farm); + } + } - std::map counters; - std::queue toChange; + // assign farms with the fewest options first so that constrained + // farms don't get crowded out by more flexible ones + std::sort(unassigned.begin(), unassigned.end(), + [&](df::building_farmplotst* a, df::building_farmplotst* b) + { + return eligible[a].size() < eligible[b].size(); + }); - for (auto farm : farms) + for (auto farm : unassigned) { - int o = farm->plant_id[season]; - if (plants.count(o) == 0 || counters[o] > min || (counters[o] == min && extra == 0)) - toChange.push(farm); // this farm is an excess instance for the plant it is currently planting - else + if (eligible[farm].empty()) { - if (counters[o] == min) - extra--; // allocate off one of the remainder farms - counters[o]++; + set_farm(out, -1, farm, season); + continue; } + int best = *std::min_element(eligible[farm].begin(), eligible[farm].end(), + [&](int a, int b) { return counters[a] < counters[b]; }); + set_farm(out, best, farm, season); + assigned[best].push_back(farm); + counters[best]++; } - for (auto n : plants) + // rebalance: move farms from overrepresented plants to + // underrepresented ones while their counts differ by more than one. + // each move strictly decreases the variance, so this terminates + for (bool moved = true; moved;) { - int c = counters[n]; - while (toChange.size() > 0 && (c < min || (c == min && extra > 0))) + moved = false; + for (auto& [src, src_farms] : assigned) { - // pick one of the excess farms and change it to plant this plant - df::building_farmplotst* farm = toChange.front(); - set_farm(out, n, farm, season); - toChange.pop(); - if (c++ == min) - extra--; + for (auto& [dst, dst_farms] : assigned) + { + if (counters[src] <= counters[dst] + 1) + continue; + auto it = std::find_if(src_farms.begin(), src_farms.end(), + [&](df::building_farmplotst* f) { return eligible[f].count(dst); }); + if (it == src_farms.end()) + continue; + auto farm = *it; + set_farm(out, dst, farm, season); + src_farms.erase(it); + dst_farms.push_back(farm); + counters[src]--; + counters[dst]++; + moved = true; + break; + } + if (moved) + break; } } } @@ -277,7 +485,9 @@ class AutoFarm { // have to scan both items[PLANT] and items[PLANT_GROWTH] because agricultural products can be either - auto count = [&, this](df::item* i) { + lastGrowthCounts.clear(); + + auto count_plant = [&, this](df::item* i) { auto mat = i->getMaterialIndex(); if ((i->flags.whole & bad_flags) == 0 && plantable_plants.count(mat) > 0) @@ -286,45 +496,73 @@ class AutoFarm { } }; + auto count_growth = [&, this](df::item* i) { + auto g = virtual_cast(i); + auto mat = i->getMaterialIndex(); + if (!g || (i->flags.whole & bad_flags) != 0 || + plantable_plants.count(mat) == 0) + return; + df::plant_raw* plant = world->raws.plants.all[mat]; + const std::string gid = stockpile_growth_id(plant, g->subtype); + const auto key = std::make_pair(mat, gid); + if (!gid.empty() && growth_thresholds.count(key)) + { + // tracked growths count only toward their own threshold + lastGrowthCounts[key] += i->getStackSize(); + } + else + { + if (!gid.empty()) + // unconfigured stockpile growths are tracked + // separately for reporting, but still count toward + // the plant's pooled threshold + lastGrowthCounts[key] += i->getStackSize(); + lastCounts[mat] += i->getStackSize(); + } + }; + for (auto i : world->items.other[df::items_other_id::PLANT]) - count(i); + count_plant(i); for (auto i : world->items.other[df::items_other_id::PLANT_GROWTH]) - count(i); + count_growth(i); std::map> plants; for (auto& plantable : plantable_plants) { df::plant_raw* plant = world->raws.plants.all[plantable.first]; - if (lastCounts[plant->index] < getThreshold(plant->index)) + // a plant is in demand when its pooled stock is under threshold + // or any explicitly tracked growth of it is under threshold + bool under = lastCounts[plant->index] < getThreshold(plant->index); + if (!under) + for (auto& gt : growth_thresholds) + { + if (gt.first.first == plant->index && + lastGrowthCounts[gt.first] < gt.second) + { + under = true; + break; + } + } + if (under) for (auto biome : plantable.second) { plants[biome].insert(plant->index); } } - std::map> farms; + std::vector>> farms; for (auto& bb : world->buildings.other[df::buildings_other_id::FARM_PLOT]) { auto farm = virtual_cast(bb); - if (farm->flags.bits.exists) + if (farm->flags.bits.exists && !is_farm_excluded(farm->id)) { - df::biome_type biome; - if (Maps::getTileDesignation(bb->centerx, bb->centery, bb->z)->bits.subterranean) - biome = biome_type::SUBTERRANEAN_WATER; - else { - df::coord2d region(Maps::getTileBiomeRgn(df::coord(bb->centerx, bb->centery, bb->z))); - biome = Maps::getBiomeType(region.x, region.y); - } - farms[biome].push_back(farm); + farms.emplace_back(farm, get_farm_biomes(farm)); } } - for (auto& ff : farms) - { - set_farms(out, plants[ff.first], ff.second); - } + set_farms(out, plants, farms); out << std::flush; } @@ -346,6 +584,14 @@ class AutoFarm { auto plant = world->raws.plants.all[th.first]; out << plant->id << " limit " << getThreshold(th.first) << " current 0" << '\n'; } + + for (auto& gt : growth_thresholds) + { + auto plant = world->raws.plants.all[gt.first.first]; + out << plant->id << ':' << gt.first.second << " limit " << gt.second + << " current " << lastGrowthCounts[gt.first] << '\n'; + } + out << "Default: " << defaultThreshold << '\n'; out << std::flush; @@ -386,15 +632,32 @@ class AutoFarm { const auto allPlants = world->raws.plants.all; const std::string id = i.val(); const int val = i.ival(0); - const auto plant = std::find_if(std::begin(allPlants), std::end(allPlants), [id](df::plant_raw* p) { return p->id == id; }); - if (plant != std::end(allPlants)) + const auto colon = id.find(':'); + const std::string plant_id = id.substr(0, colon); + const auto plant = std::find_if(std::begin(allPlants), std::end(allPlants), [plant_id](df::plant_raw* p) { return p->id == plant_id; }); + if (plant == std::end(allPlants)) + { + WARN(control, out).print("threshold for unknown plant {} in saved configuration ignored\n", id); + } + else if (colon == std::string::npos) { setThreshold((*plant)->index, val); INFO(control, out).print("threshold of {} for plant {} in saved configuration loaded\n", val, id); } else { - WARN(control, out).print("threshold for unknown plant {} in saved configuration ignored\n", id); + // "PLANT:GROWTH" entries hold thresholds for specific + // stockpile growth products of the plant + const std::string growth_id = id.substr(colon + 1); + if (has_stockpile_growth(*plant, growth_id)) + { + setGrowthThreshold((*plant)->index, growth_id, val); + INFO(control, out).print("threshold of {} for growth {} in saved configuration loaded\n", val, id); + } + else + { + WARN(control, out).print("threshold for unknown growth {} in saved configuration ignored\n", id); + } } } } @@ -419,6 +682,15 @@ class AutoFarm { cfgThreshold.val() = plantID; cfgThreshold.ival(0) = t.second; } + + for (const auto& t : growth_thresholds) + { + const std::string productID = world->raws.plants.all[t.first.first]->id + ":" + t.first.second; + const std::string keyName = "autofarm/threshold/" + productID; + PersistentDataItem cfgThreshold = World::AddPersistentSiteData(keyName); + cfgThreshold.val() = productID; + cfgThreshold.ival(0) = t.second; + } } }; @@ -490,19 +762,29 @@ static command_result setThresholds(color_ostream& out, std::vector std::string id = parameters[i]; std::transform(id.begin(), id.end(), id.begin(), ::toupper); + // a threshold may apply to a plant ("PLANT") or to a specific + // stockpile growth product of a plant ("PLANT:GROWTH") + const auto colon = id.find(':'); + const std::string plant_id = id.substr(0, colon); + const std::string growth_id = colon == std::string::npos ? "" : id.substr(colon + 1); + bool ok = false; for (auto plant : world->raws.plants.all) { - if (plant->flags.is_set(df::plant_raw_flags::SEED) && (plant->id == id)) - { + if (!plant->flags.is_set(df::plant_raw_flags::SEED) || (plant->id != plant_id)) + continue; + if (growth_id.empty()) autofarmInstance->setThreshold(plant->index, val); - ok = true; + else if (AutoFarm::has_stockpile_growth(plant, growth_id)) + autofarmInstance->setGrowthThreshold(plant->index, growth_id, val); + else break; - } + ok = true; + break; } if (!ok) { - out << "Cannot find plant with id " << id << '\n' << std::flush; + out << "Cannot find plant or growth with id " << id << '\n' << std::flush; return CR_WRONG_USAGE; } } @@ -545,3 +827,101 @@ static command_result autofarm(color_ostream& out, std::vector& par return CR_OK; } + +static void autofarm_setDefaultThreshold(color_ostream& out, int32_t val) +{ + autofarmInstance->setDefault(val); + autofarmInstance->save_state(out); +} + +static void autofarm_setThreshold(color_ostream& out, int32_t plant_index, int32_t val) +{ + autofarmInstance->setThreshold(plant_index, val); + autofarmInstance->save_state(out); +} + +static void autofarm_removeThreshold(color_ostream& out, int32_t plant_index) +{ + autofarmInstance->removeThreshold(plant_index); + autofarmInstance->save_state(out); +} + +static void autofarm_setGrowthThreshold(color_ostream& out, int32_t plant_index, std::string growth_id, int32_t val) +{ + autofarmInstance->setGrowthThreshold(plant_index, growth_id, val); + autofarmInstance->save_state(out); +} + +static void autofarm_removeGrowthThreshold(color_ostream& out, int32_t plant_index, std::string growth_id) +{ + autofarmInstance->removeGrowthThreshold(plant_index, growth_id); + autofarmInstance->save_state(out); +} + +static bool autofarm_isFarmExcluded(int32_t farm_id) +{ + return autofarmInstance->is_farm_excluded(farm_id); +} + +static void autofarm_setFarmExcluded(color_ostream& out, int32_t farm_id, bool excluded) +{ + autofarmInstance->set_farm_excluded(farm_id, excluded); +} + +// pushes a nested table of the form {plant_index = {growth_id = count}} for a +// map keyed by (plant_index, growth_id) +static void push_growth_map(lua_State* L, const std::map, int>& m) +{ + lua_newtable(L); + for (auto& [key, val] : m) + { + lua_pushinteger(L, key.first); + lua_gettable(L, -2); + if (lua_isnil(L, -1)) + { + lua_pop(L, 1); + lua_newtable(L); + lua_pushinteger(L, key.first); + lua_pushvalue(L, -2); + lua_settable(L, -4); + } + Lua::Push(L, key.second); + lua_pushinteger(L, val); + lua_settable(L, -3); + lua_pop(L, 1); + } +} + +static int autofarm_getData(lua_State* L) +{ + lua_newtable(L); + int ctable = lua_gettop(L); + Lua::SetField(L, autofarmInstance->getDefault(), ctable, "default_threshold"); + Lua::Push(L, autofarmInstance->getThresholds()); + lua_setfield(L, ctable, "thresholds"); + push_growth_map(L, autofarmInstance->getGrowthThresholds()); + lua_setfield(L, ctable, "growth_thresholds"); + Lua::Push(L, autofarmInstance->getLastCounts()); + lua_setfield(L, ctable, "last_counts"); + push_growth_map(L, autofarmInstance->getLastGrowthCounts()); + lua_setfield(L, ctable, "growth_counts"); + Lua::Push(L, autofarmInstance->get_excluded_farms()); + lua_setfield(L, ctable, "excluded_farms"); + return 1; +} + +DFHACK_PLUGIN_LUA_FUNCTIONS { + DFHACK_LUA_FUNCTION(autofarm_setDefaultThreshold), + DFHACK_LUA_FUNCTION(autofarm_setThreshold), + DFHACK_LUA_FUNCTION(autofarm_removeThreshold), + DFHACK_LUA_FUNCTION(autofarm_setGrowthThreshold), + DFHACK_LUA_FUNCTION(autofarm_removeGrowthThreshold), + DFHACK_LUA_FUNCTION(autofarm_isFarmExcluded), + DFHACK_LUA_FUNCTION(autofarm_setFarmExcluded), + DFHACK_LUA_END +}; + +DFHACK_PLUGIN_LUA_COMMANDS { + DFHACK_LUA_COMMAND(autofarm_getData), + DFHACK_LUA_END +}; diff --git a/plugins/autolabor/CMakeLists.txt b/plugins/autolabor/CMakeLists.txt index 721654d202..1f3ce779a4 100644 --- a/plugins/autolabor/CMakeLists.txt +++ b/plugins/autolabor/CMakeLists.txt @@ -1,15 +1,26 @@ project(autolabor) # A list of source files set(COMMON_SRCS + joblabormapper.cpp + laborcommon.cpp + workdetails.cpp + legacyengine.cpp + modernengine.cpp ) # A list of headers -set(COMMON_HDRS laborstatemap.h +set(COMMON_HDRS + laborstatemap.h + joblabormapper.h + laborcommon.h + workdetails.h + engines.h + legacyengine.h + modernengine.h + monitorengine.h ) set_source_files_properties(${COMMON_HDRS} PROPERTIES HEADER_FILE_ONLY TRUE) # mash them together (headers are marked as headers and nothing will try to compile them) list(APPEND COMMON_SRCS ${COMMON_HDRS}) -#dfhack_plugin(labormanager labormanager.cpp joblabormapper.cpp ${COMMON_SRCS}) - dfhack_plugin(autolabor autolabor.cpp ${COMMON_SRCS} LINK_LIBRARIES lua) diff --git a/plugins/autolabor/autolabor.cpp b/plugins/autolabor/autolabor.cpp index d0cec796fb..12d7cf1c84 100644 --- a/plugins/autolabor/autolabor.cpp +++ b/plugins/autolabor/autolabor.cpp @@ -1,1242 +1,428 @@ -#include "laborstatemap.h" +/* + * autolabor plugin: unified labor management for Dwarf Fortress. + * + * One plugin binary exposes two commands: + * + * autolabor legacy mode: controls the labor matrix directly, bypassing + * the work detail system (the pre-v50 behavior). + * labormanager modern mode: expresses labor policy through DF's work + * details so the game's job auction does the assignment. + * + * A third mode, monitor, only watches the job board for starving postings + * and performs no labor management. + * + * The active mode is persisted per fort and can be switched from either + * command (" mode legacy|modern"), from the work details overlay, or + * implicitly by enabling through one of the commands. + */ -#include "Debug.h" +#include "engines.h" +#include "joblabormapper.h" +#include "laborcommon.h" +#include "legacyengine.h" +#include "modernengine.h" +#include "monitorengine.h" +#include "workdetails.h" + +#include "Core.h" +#include "LuaTools.h" +#include "PluginLua.h" #include "PluginManager.h" -#include "modules/Units.h" #include "modules/World.h" -#include "modules/MapCache.h" -#include "modules/Items.h" -#include "modules/Units.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include using namespace DFHack; using namespace df::enums; +using namespace autolabor; DFHACK_PLUGIN("autolabor"); -REQUIRE_GLOBAL(plotinfo); -REQUIRE_GLOBAL(world); -REQUIRE_GLOBAL(game); +DFHACK_PLUGIN_IS_ENABLED(is_enabled); -#define ARRAY_COUNT(array) (sizeof(array)/sizeof((array)[0])) +command_result autolabor_cmd(color_ostream &out, std::vector ¶ms); +command_result labormanager_cmd(color_ostream &out, std::vector ¶ms); -/* - * Autolabor module for dfhack - * - * The idea behind this module is to constantly adjust labors so that the right dwarves - * are assigned to new tasks. The key is that, for almost all labors, once a dwarf begins - * a job it will finish that job even if the associated labor is removed. Thus the - * strategy is to frequently decide, for each labor, which dwarves should possibly take - * a new job for that labor if it comes in and which shouldn't, and then set the labors - * appropriately. The updating should happen as often as can be reasonably done without - * causing lag. - * - * The obvious thing to do is to just set each labor on a single idle dwarf who is best - * suited to doing new jobs of that labor. This works in a way, but it leads to a lot - * of idle dwarves since only one dwarf will be dispatched for each labor in an update - * cycle, and dwarves that finish tasks will wait for the next update before being - * dispatched. An improvement is to also set some labors on dwarves that are currently - * doing a job, so that they will immediately take a new job when they finish. The - * details of which dwarves should have labors set is mostly a heuristic. - * - * A complication to the above simple scheme is labors that have associated equipment. - * Enabling/disabling these labors causes dwarves to change equipment, and disabling - * them in the middle of a job may cause the job to be abandoned. Those labors - * (mining, hunting, and woodcutting) need to be handled carefully to minimize churn. - */ +static WorkDetailManager wdm; +static LegacyEngine legacy_engine(&wdm); +static ModernEngine modern_engine(&wdm); +static MonitorEngine monitor_engine; +static LaborEngine *active_engine = NULL; -DFHACK_PLUGIN_IS_ENABLED(enable_autolabor); - -namespace DFHack { - DBG_DECLARE(autolabor, cycle, DebugCategory::LINFO); -} - -static std::vector state_count(NUM_STATE); - -static PersistentDataItem config; - -command_result autolabor (color_ostream &out, std::vector & parameters); - -static bool isOptionEnabled(unsigned flag) +static LaborEngine *mode_engine(int mode) { - return config.isValid() && (config.ival(0) & flag) != 0; + if (mode == MODE_MODERN) + return &modern_engine; + if (mode == MODE_MONITOR) + return &monitor_engine; + return &legacy_engine; } -enum ConfigFlags { - CF_ENABLED = 1, -}; - -static void setOptionEnabled(ConfigFlags flag, bool on) +// switch the running engine to the given mode; safe when disabled +static void activate_mode(color_ostream &out, int mode) { - if (!config.isValid()) + store_engine_mode(mode); + LaborEngine *want = mode_engine(mode); + if (active_engine == want) return; - - if (on) - config.ival(0) |= flag; - else - config.ival(0) &= ~flag; + if (active_engine) + active_engine->disable(out); + active_engine = want; + if (is_enabled) + active_engine->enable(out); } - -static void generate_labor_to_skill_map(); - -enum labor_mode { - DISABLE, - HAULERS, - AUTOMATIC, -}; - -struct labor_info -{ - PersistentDataItem config; - - bool is_exclusive; - int active_dwarfs; - - labor_mode mode() { return (labor_mode) config.ival(0); } - - void set_mode(labor_mode mode) { config.ival(0) = mode; } - - int minimum_dwarfs() { return config.ival(1); } - void set_minimum_dwarfs(int minimum_dwarfs) { config.ival(1) = minimum_dwarfs; } - - int maximum_dwarfs() { return config.ival(2); } - void set_maximum_dwarfs(int maximum_dwarfs) { config.ival(2) = maximum_dwarfs; } - - int talent_pool() { return config.ival(3); } - void set_talent_pool(int talent_pool) { config.ival(3) = talent_pool; } -}; - -struct labor_default +static bool can_run(color_ostream &out) { - labor_mode mode; - bool is_exclusive; - int minimum_dwarfs; - int maximum_dwarfs; - int active_dwarfs; -}; - -// The percentage of the dwarves assigned as haulers at any one time. -static int hauler_pct = 33; - -// The maximum percentage of dwarves who will be allowed to be idle. -// Decreasing this will encourage autolabor to keep dwarves busy, -// at the expense of making it harder for dwarves to specialize in -// specific skills. -static int idler_pct = 10; - -static std::vector labor_infos; - -static const struct labor_default default_labor_infos[] = { - /* MINE */ {AUTOMATIC, true, 2, 200, 0}, - /* HAUL_STONE */ {HAULERS, false, 1, 200, 0}, - /* HAUL_WOOD */ {HAULERS, false, 1, 200, 0}, - /* HAUL_BODY */ {HAULERS, false, 1, 200, 0}, - /* HAUL_FOOD */ {HAULERS, false, 1, 200, 0}, - /* HAUL_REFUSE */ {HAULERS, false, 1, 200, 0}, - /* HAUL_ITEM */ {HAULERS, false, 1, 200, 0}, - /* HAUL_FURNITURE */ {HAULERS, false, 1, 200, 0}, - /* HAUL_ANIMAL */ {HAULERS, false, 1, 200, 0}, - /* CLEAN */ {HAULERS, false, 1, 200, 0}, - /* CUTWOOD */ {AUTOMATIC, true, 1, 200, 0}, - /* CARPENTER */ {AUTOMATIC, false, 1, 200, 0}, - /* STONECUTTER */ {AUTOMATIC, false, 1, 200, 0}, - /* STONE_CARVER */ {AUTOMATIC, false, 1, 200, 0}, - /* MASON */ {AUTOMATIC, false, 1, 200, 0}, - /* ANIMALTRAIN */ {AUTOMATIC, false, 1, 200, 0}, - /* ANIMALCARE */ {AUTOMATIC, false, 1, 200, 0}, - /* DIAGNOSE */ {AUTOMATIC, false, 1, 200, 0}, - /* SURGERY */ {AUTOMATIC, false, 1, 200, 0}, - /* BONE_SETTING */ {AUTOMATIC, false, 1, 200, 0}, - /* SUTURING */ {AUTOMATIC, false, 1, 200, 0}, - /* DRESSING_WOUNDS */ {AUTOMATIC, false, 1, 200, 0}, - /* FEED_WATER_CIVILIANS */ {AUTOMATIC, false, 200, 200, 0}, - /* RECOVER_WOUNDED */ {HAULERS, false, 1, 200, 0}, - /* BUTCHER */ {AUTOMATIC, false, 1, 200, 0}, - /* TRAPPER */ {AUTOMATIC, false, 1, 200, 0}, - /* DISSECT_VERMIN */ {AUTOMATIC, false, 1, 200, 0}, - /* LEATHER */ {AUTOMATIC, false, 1, 200, 0}, - /* TANNER */ {AUTOMATIC, false, 1, 200, 0}, - /* BREWER */ {AUTOMATIC, false, 1, 200, 0}, - /* ALCHEMIST */ {AUTOMATIC, false, 1, 200, 0}, - /* SOAP_MAKER */ {AUTOMATIC, false, 1, 200, 0}, - /* WEAVER */ {AUTOMATIC, false, 1, 200, 0}, - /* CLOTHESMAKER */ {AUTOMATIC, false, 1, 200, 0}, - /* MILLER */ {AUTOMATIC, false, 1, 200, 0}, - /* PROCESS_PLANT */ {AUTOMATIC, false, 1, 200, 0}, - /* MAKE_CHEESE */ {AUTOMATIC, false, 1, 200, 0}, - /* MILK */ {AUTOMATIC, false, 1, 200, 0}, - /* COOK */ {AUTOMATIC, false, 1, 200, 0}, - /* PLANT */ {AUTOMATIC, false, 1, 200, 0}, - /* HERBALIST */ {AUTOMATIC, false, 1, 200, 0}, - /* FISH */ {AUTOMATIC, false, 1, 1, 0}, - /* CLEAN_FISH */ {AUTOMATIC, false, 1, 200, 0}, - /* DISSECT_FISH */ {AUTOMATIC, false, 1, 200, 0}, - /* HUNT */ {AUTOMATIC, true, 1, 1, 0}, - /* SMELT */ {AUTOMATIC, false, 1, 200, 0}, - /* FORGE_WEAPON */ {AUTOMATIC, false, 1, 200, 0}, - /* FORGE_ARMOR */ {AUTOMATIC, false, 1, 200, 0}, - /* FORGE_FURNITURE */ {AUTOMATIC, false, 1, 200, 0}, - /* METAL_CRAFT */ {AUTOMATIC, false, 1, 200, 0}, - /* CUT_GEM */ {AUTOMATIC, false, 1, 200, 0}, - /* ENCRUST_GEM */ {AUTOMATIC, false, 1, 200, 0}, - /* WOOD_CRAFT */ {AUTOMATIC, false, 1, 200, 0}, - /* STONE_CRAFT */ {AUTOMATIC, false, 1, 200, 0}, - /* BONE_CARVE */ {AUTOMATIC, false, 1, 200, 0}, - /* GLASSMAKER */ {AUTOMATIC, false, 1, 200, 0}, - /* EXTRACT_STRAND */ {AUTOMATIC, false, 1, 200, 0}, - /* SIEGECRAFT */ {AUTOMATIC, false, 1, 200, 0}, - /* SIEGEOPERATE */ {AUTOMATIC, false, 1, 200, 0}, - /* BOWYER */ {AUTOMATIC, false, 1, 200, 0}, - /* MECHANIC */ {AUTOMATIC, false, 1, 200, 0}, - /* POTASH_MAKING */ {AUTOMATIC, false, 1, 200, 0}, - /* LYE_MAKING */ {AUTOMATIC, false, 1, 200, 0}, - /* DYER */ {AUTOMATIC, false, 1, 200, 0}, - /* BURN_WOOD */ {AUTOMATIC, false, 1, 200, 0}, - /* OPERATE_PUMP */ {AUTOMATIC, false, 1, 200, 0}, - /* SHEARER */ {AUTOMATIC, false, 1, 200, 0}, - /* SPINNER */ {AUTOMATIC, false, 1, 200, 0}, - /* POTTERY */ {AUTOMATIC, false, 1, 200, 0}, - /* GLAZING */ {AUTOMATIC, false, 1, 200, 0}, - /* PRESSING */ {AUTOMATIC, false, 1, 200, 0}, - /* BEEKEEPING */ {AUTOMATIC, false, 1, 200, 0}, - /* WAX_WORKING */ {AUTOMATIC, false, 1, 200, 0}, - /* HANDLE_VEHICLES */ {HAULERS, false, 1, 200, 0}, - /* HAUL_TRADE */ {HAULERS, false, 1, 200, 0}, - /* PULL_LEVER */ {HAULERS, false, 1, 200, 0}, - /* UNUSED_13 */ {DISABLE, false, 1, 0, 0}, - /* HAUL_WATER */ {HAULERS, false, 1, 200, 0}, - /* GELD */ {AUTOMATIC, false, 1, 200, 0}, - /* BUILD_ROAD */ {AUTOMATIC, false, 1, 200, 0}, - /* BUILD_CONSTRUCTION */ {AUTOMATIC, false, 1, 200, 0}, - /* PAPERMAKING */ {AUTOMATIC, false, 1, 200, 0}, - /* BOOKBINDING */ {AUTOMATIC, false, 1, 200, 0}, - /* UNUSED_20 */ {DISABLE, false, 0, 0, 0}, - /* UNUSED_21 */ {DISABLE, false, 0, 0, 0}, - /* UNUSED_22 */ {DISABLE, false, 0, 0, 0}, - /* UNUSED_23 */ {DISABLE, false, 0, 0, 0}, - /* UNUSED_24 */ {DISABLE, false, 0, 0, 0}, - /* UNUSED_25 */ {DISABLE, false, 0, 0, 0}, - /* UNUSED_26 */ {DISABLE, false, 0, 0, 0}, - /* UNUSED_27 */ {DISABLE, false, 0, 0, 0}, - /* UNUSED_28 */ {DISABLE, false, 0, 0, 0}, - /* UNUSED_29 */ {DISABLE, false, 0, 0, 0}, - /* UNUSED_30 */ {DISABLE, false, 0, 0, 0}, -}; - -static const int responsibility_penalties[] = { - 0, /* LAW_MAKING */ - 0, /* LAW_ENFORCEMENT */ - 3000, /* RECEIVE_DIPLOMATS */ - 0, /* MEET_WORKERS */ - 1000, /* MANAGE_PRODUCTION */ - 3000, /* TRADE */ - 1000, /* ACCOUNTING */ - 0, /* ESTABLISH_COLONY_TRADE_AGREEMENTS */ - 0, /* MAKE_INTRODUCTIONS */ - 0, /* MAKE_PEACE_AGREEMENTS */ - 0, /* MAKE_TOPIC_AGREEMENTS */ - 0, /* COLLECT_TAXES */ - 0, /* ESCORT_TAX_COLLECTOR */ - 0, /* EXECUTIONS */ - 0, /* TAME_EXOTICS */ - 0, /* RELIGION */ - 0, /* ATTACK_ENEMIES */ - 0, /* PATROL_TERRITORY */ - 0, /* MILITARY_GOALS */ - 0, /* MILITARY_STRATEGY */ - 0, /* UPGRADE_SQUAD_EQUIPMENT */ - 0, /* EQUIPMENT_MANIFESTS */ - 0, /* SORT_AMMUNITION */ - 0, /* BUILD_MORALE */ - 5000 /* HEALTH_MANAGEMENT */ -}; - -struct dwarf_info_t -{ - int highest_skill; - int total_skill; - int mastery_penalty; - int assigned_jobs; - dwarf_state state; - bool has_exclusive_labor; - int noble_penalty; // penalty for assignment due to noble status - bool medical; // this dwarf has medical responsibility - bool trader; // this dwarf has trade responsibility - bool diplomacy; // this dwarf meets with diplomats -}; - -static void cleanup_state() -{ - enable_autolabor = false; - labor_infos.clear(); - - // reinstate DF's work detail system - game->external_flag.bits.automatic_professions_disabled = false; -} - -static void reset_labor(df::unit_labor labor) -{ - labor_infos[labor].set_minimum_dwarfs(default_labor_infos[labor].minimum_dwarfs); - labor_infos[labor].set_maximum_dwarfs(default_labor_infos[labor].maximum_dwarfs); - labor_infos[labor].set_talent_pool(200); - labor_infos[labor].set_mode(default_labor_infos[labor].mode); -} - -static void init_state() -{ - config = World::GetPersistentSiteData("autolabor/config"); - if (config.isValid() && config.ival(0) == -1) - config.ival(0) = 0; - - enable_autolabor = isOptionEnabled(CF_ENABLED); - - if (!enable_autolabor) - return; - - // bypass DF's work detail system - game->external_flag.bits.automatic_professions_disabled = true; - - auto cfg_haulpct = World::GetPersistentSiteData("autolabor/haulpct"); - if (cfg_haulpct.isValid()) - { - hauler_pct = cfg_haulpct.ival(0); - } - else + if (!Core::getInstance().isMapLoaded() || !World::isFortressMode()) { - hauler_pct = 33; - } - - // Load labors from save - labor_infos.resize(ARRAY_COUNT(default_labor_infos)); - - std::vector items; - World::GetPersistentSiteData(&items, "autolabor/labors/", true); - - for (auto& p : items) - { - std::string key = p.key(); - df::unit_labor labor = (df::unit_labor) atoi(key.substr(strlen("autolabor/labors/")).c_str()); - if (labor >= 0 && size_t(labor) < labor_infos.size()) - { - labor_infos[labor].config = p; - labor_infos[labor].is_exclusive = default_labor_infos[labor].is_exclusive; - labor_infos[labor].active_dwarfs = 0; - } - } - - // Add default labors for those not in save - for (size_t i = 0; i < ARRAY_COUNT(default_labor_infos); i++) { - if (labor_infos[i].config.isValid()) - continue; - - std::stringstream name; - name << "autolabor/labors/" << i; - - labor_infos[i].config = World::AddPersistentSiteData(name.str()); - - labor_infos[i].is_exclusive = default_labor_infos[i].is_exclusive; - labor_infos[i].active_dwarfs = 0; - reset_labor((df::unit_labor) i); + out.printerr("Cannot run {} without a loaded fort.\n", plugin_name); + return false; } - - generate_labor_to_skill_map(); - + return true; } -static df::job_skill labor_to_skill[ENUM_LAST_ITEM(unit_labor) + 1]; - -static void generate_labor_to_skill_map() +DFhackCExport command_result plugin_init(color_ostream &out, + std::vector &commands) { - // Generate labor -> skill mapping - - for (int i = 0; i <= ENUM_LAST_ITEM(unit_labor); i++) - labor_to_skill[i] = job_skill::NONE; - - FOR_ENUM_ITEMS(job_skill, skill) - { - int labor = ENUM_ATTR(job_skill, labor, skill); - if (labor != unit_labor::NONE) - { - /* - assert(labor >= 0); - assert(labor < ARRAY_COUNT(labor_to_skill)); - */ - - labor_to_skill[labor] = skill; - } - } + commands.push_back(PluginCommand( + "autolabor", + "Automatically manage dwarf labors (legacy labor matrix mode).", + autolabor_cmd)); + commands.push_back(PluginCommand( + "labormanager", + "Automatically manage dwarf labors (work detail mode).", + labormanager_cmd)); + return CR_OK; } - -static void enable_plugin(color_ostream &out) +DFhackCExport command_result plugin_shutdown(color_ostream &out) { - if (!config.isValid()) - { - config = World::AddPersistentSiteData("autolabor/config"); - config.ival(0) = 0; - } - - setOptionEnabled(CF_ENABLED, true); - enable_autolabor = true; - out << "Enabling autolabor." << std::endl; - - cleanup_state(); - init_state(); + if (active_engine && is_enabled && Core::getInstance().isMapLoaded()) + active_engine->disable(out); + active_engine = NULL; + return CR_OK; } -static void disable_plugin(color_ostream& out) +DFhackCExport command_result plugin_load_site_data(color_ostream &out) { - if (config.isValid()) - setOptionEnabled(CF_ENABLED, false); + load_plugin_config(); + wdm.reset(); + init_labor_to_skill(); - enable_autolabor = false; - out << "Disabling autolabor." << std::endl; + is_enabled = config_flag(CF_ENABLED); + active_engine = NULL; + if (is_enabled) + activate_mode(out, engine_mode()); - cleanup_state(); + return CR_OK; } -DFhackCExport command_result plugin_init ( color_ostream &out, std::vector &commands) +DFhackCExport command_result plugin_onstatechange(color_ostream &out, + state_change_event event) { - // initialize labor infos table from default table - if(ARRAY_COUNT(default_labor_infos) != ENUM_LAST_ITEM(unit_labor) + 1) + if (event == SC_MAP_UNLOADED) { - out.printerr("autolabor: labor size mismatch\n"); - return CR_FAILURE; + legacy_engine.map_unload(); + modern_engine.map_unload(); + monitor_engine.map_unload(); + active_engine = NULL; + // is_enabled stays set; CF_ENABLED persists the user's intent and + // plugin_load_site_data will re-enable the engine on the next map } - - commands.push_back(PluginCommand( - "autolabor", - "Automatically manage dwarf labors.", - autolabor)); - - init_state(); - return CR_OK; } -DFhackCExport command_result plugin_shutdown ( color_ostream &out ) +DFhackCExport command_result plugin_onupdate(color_ostream &out) { - cleanup_state(); - + if (is_enabled && active_engine) + active_engine->update(out); return CR_OK; } -// sorting objects -struct dwarfinfo_sorter +DFhackCExport command_result plugin_enable(color_ostream &out, bool enable) { - dwarfinfo_sorter(std::vector & info):dwarf_info(info){}; - bool operator() (int i,int j) - { - if (dwarf_info[i].state == IDLE && dwarf_info[j].state != IDLE) - return true; - if (dwarf_info[i].state != IDLE && dwarf_info[j].state == IDLE) - return false; - return dwarf_info[i].mastery_penalty > dwarf_info[j].mastery_penalty; - }; - std::vector & dwarf_info; -}; -struct laborinfo_sorter -{ - bool operator() (int i,int j) - { - if (labor_infos[i].mode() != labor_infos[j].mode()) - return labor_infos[i].mode() < labor_infos[j].mode(); - if (labor_infos[i].is_exclusive != labor_infos[j].is_exclusive) - return labor_infos[i].is_exclusive; - if (labor_infos[i].maximum_dwarfs() != labor_infos[j].maximum_dwarfs()) - return labor_infos[i].maximum_dwarfs() < labor_infos[j].maximum_dwarfs(); - return false; - }; -}; + if (!can_run(out)) + return CR_FAILURE; -struct values_sorter -{ - values_sorter(std::vector & values):values(values){}; - bool operator() (int i,int j) + if (enable && !is_enabled) { - return values[i] > values[j]; - }; - std::vector & values; -}; - - -static void assign_labor(unit_labor::unit_labor labor, - int n_dwarfs, - std::vector& dwarf_info, - bool trader_requested, - std::vector& dwarfs, - bool has_butchers, - bool has_fishery, - color_ostream& out) -{ - df::job_skill skill = labor_to_skill[labor]; - - if (labor_infos[labor].mode() != AUTOMATIC) - return; - - int best_skill = 0; - - std::vector values(n_dwarfs); - std::vector candidates; - std::map dwarf_skill; - std::map dwarf_skillxp; - std::vector previously_enabled(n_dwarfs); - - // Find candidate dwarfs, and calculate a preference value for each dwarf - for (int dwarf = 0; dwarf < n_dwarfs; dwarf++) - { - if (dwarf_info[dwarf].state == CHILD) - continue; - if (dwarf_info[dwarf].state == MILITARY) - continue; - if (dwarf_info[dwarf].trader && trader_requested) - continue; - if (dwarf_info[dwarf].diplomacy) - continue; - - if (labor_infos[labor].is_exclusive && dwarf_info[dwarf].has_exclusive_labor) - continue; - - int value = dwarf_info[dwarf].mastery_penalty; - - if (skill != job_skill::NONE) - { - int skill_level = 0; - int skill_experience = 0; - - for (auto s : dwarfs[dwarf]->status.souls[0]->skills) - { - if (s->id == skill) - { - skill_level = s->rating; - skill_experience = s->experience; - break; - } - } - - dwarf_skill[dwarf] = skill_level; - dwarf_skillxp[dwarf] = skill_experience; - - if (best_skill < skill_level) - best_skill = skill_level; - - value += skill_level * 100; - value += skill_experience / 20; - if (skill_level > 0 || skill_experience > 0) - value += 200; - if (skill_level >= 15) - value += 1000 * (skill_level - 14); - } - else - { - dwarf_skill[dwarf] = 0; - } - - if (dwarfs[dwarf]->status.labors[labor]) - { - value += 5; - if (labor_infos[labor].is_exclusive) - value += 350; - } - - if (dwarf_info[dwarf].has_exclusive_labor) - value -= 500; - - // bias by happiness - - //value += dwarfs[dwarf]->status.happiness; - - values[dwarf] = value; - - candidates.push_back(dwarf); - - } - - int pool = labor_infos[labor].talent_pool(); - if (pool < 200 && candidates.size() > 1 && size_t(abs(pool)) < candidates.size()) - { - // Sort in descending order - std::sort(candidates.begin(), candidates.end(), [&](const int lhs, const int rhs) -> bool { - if (dwarf_skill[lhs] == dwarf_skill[rhs]) - if (pool > 0) - return dwarf_skillxp[lhs] > dwarf_skillxp[rhs]; - else - return dwarf_skillxp[lhs] < dwarf_skillxp[rhs]; - else - if (pool > 0) - return dwarf_skill[lhs] > dwarf_skill[rhs]; - else - return dwarf_skill[lhs] < dwarf_skill[rhs]; - }); - - // Check if all dwarves have equivalent skills, usually zero - int first_dwarf = candidates[0]; - int last_dwarf = candidates[candidates.size() - 1]; - if (dwarf_skill[first_dwarf] == dwarf_skill[last_dwarf] && - dwarf_skillxp[first_dwarf] == dwarf_skillxp[last_dwarf]) - { - // There's no difference in skill, so change nothing - } - else - { - // Trim down to our top (or not) talents - candidates.resize(abs(pool)); - } - } - - // Sort candidates by preference value - values_sorter ivs(values); - std::sort(candidates.begin(), candidates.end(), ivs); - - // Disable the labor on everyone - for (int dwarf = 0; dwarf < n_dwarfs; dwarf++) - { - if (dwarf_info[dwarf].state == CHILD) - continue; - - previously_enabled[dwarf] = dwarfs[dwarf]->status.labors[labor]; - dwarfs[dwarf]->status.labors[labor] = false; - } - - int min_dwarfs = labor_infos[labor].minimum_dwarfs(); - int max_dwarfs = labor_infos[labor].maximum_dwarfs(); - - // Special - don't assign hunt without a butchers, or fish without a fishery - if (unit_labor::HUNT == labor && !has_butchers) - min_dwarfs = max_dwarfs = 0; - if (unit_labor::FISH == labor && !has_fishery) - min_dwarfs = max_dwarfs = 0; - - // If there are enough idle dwarves to choose from, enter an aggressive assignment - // mode. "Enough" idle dwarves is defined as 2 or 10% of the total number of dwarves, - // whichever is higher. - // - // In aggressive mode, we will always pick at least one idle dwarf for each skill, - // in order to try to get the idle dwarves to start doing something. We also pick - // any dwarf more preferable to the idle dwarf, since we'd rather have a more - // preferable dwarf do a new job if one becomes available (probably because that - // dwarf just finished a job). - // - // In non-aggressive mode, only dwarves that are good at a labor will be assigned - // to it. Dwarves good at nothing, or nothing that needs doing, will tend to get - // assigned to hauling by the hauler code. If there are no hauling jobs to do, - // they will sit around idle and when enough build up they will trigger aggressive - // mode again. - bool aggressive_mode = state_count[IDLE] >= 2 && state_count[IDLE] >= n_dwarfs * idler_pct / 100; - - /* - * Assign dwarfs to this labor. We assign at least the minimum number of dwarfs, in - * order of preference, and then assign additional dwarfs that meet any of these conditions: - * - We are in aggressive mode and have not yet assigned an idle dwarf - * - The dwarf is good at this skill - * - The labor is mining, hunting, or woodcutting and the dwarf currently has it enabled. - * We stop assigning dwarfs when we reach the maximum allowed. - * Note that only idle and busy dwarfs count towards the number of dwarfs. "Other" dwarfs - * (sleeping, eating, on break, etc.) will have labors assigned, but will not be counted. - * Military and children/nobles will not have labors assigned. - * Dwarfs with the "health management" responsibility are always assigned DIAGNOSIS. - */ - for (size_t i = 0; i < candidates.size() && labor_infos[labor].active_dwarfs < max_dwarfs; i++) - { - int dwarf = candidates[i]; - - if (dwarf_info[dwarf].trader && trader_requested) - continue; - if (dwarf_info[dwarf].diplomacy) - continue; - - assert(dwarf >= 0); - assert(dwarf < n_dwarfs); - - bool preferred_dwarf = false; - if (dwarf_skillxp[dwarf] > 0 && dwarf_skill[dwarf] >= best_skill / 2) - preferred_dwarf = true; - if (previously_enabled[dwarf] && labor_infos[labor].is_exclusive && dwarf_info[dwarf].state == EXCLUSIVE) - preferred_dwarf = true; - if (dwarf_info[dwarf].medical && labor == df::unit_labor::DIAGNOSE) - preferred_dwarf = true; - - if (labor_infos[labor].active_dwarfs >= min_dwarfs && !preferred_dwarf && !aggressive_mode) - continue; - - if (!dwarfs[dwarf]->status.labors[labor]) - dwarf_info[dwarf].assigned_jobs++; - - if (Units::isValidLabor(dwarfs[dwarf], labor)) - dwarfs[dwarf]->status.labors[labor] = true; - - if (labor_infos[labor].is_exclusive) - { - dwarf_info[dwarf].has_exclusive_labor = true; - // all the exclusive labors require equipment so this should force the dorf to reequip if needed - dwarfs[dwarf]->uniform.pickup_flags.bits.update = 1; - } - - TRACE(cycle, out).print("Dwarf {} \"{}\" assigned {}: value {} {} {}\n", - dwarf, dwarfs[dwarf]->name.first_name, ENUM_KEY_STR(unit_labor, labor), values[dwarf], - dwarf_info[dwarf].trader ? "(trader)" : "", - dwarf_info[dwarf].diplomacy ? "(diplomacy)" : ""); - - if (dwarf_info[dwarf].state == IDLE || dwarf_info[dwarf].state == BUSY || dwarf_info[dwarf].state == EXCLUSIVE) - labor_infos[labor].active_dwarfs++; - - if (dwarf_info[dwarf].state == IDLE) - aggressive_mode = false; - } -} - -static const int32_t CYCLE_TICKS = 61; -static int32_t cycle_timestamp = 0; // world->frame_counter at last cycle - -DFhackCExport command_result plugin_load_site_data (color_ostream &out) { - cycle_timestamp = 0; - cleanup_state(); - init_state(); - return CR_OK; -} - -DFhackCExport command_result plugin_onstatechange(color_ostream &out, state_change_event event) -{ - switch (event) { - case SC_MAP_UNLOADED: - cleanup_state(); - break; - default: - break; + set_config_flag(CF_ENABLED, true); + is_enabled = true; + activate_mode(out, engine_mode()); + } + else if (!enable && is_enabled) + { + set_config_flag(CF_ENABLED, false); + is_enabled = false; + if (active_engine) + active_engine->disable(out); + active_engine = NULL; } return CR_OK; } -DFhackCExport command_result plugin_onupdate ( color_ostream &out ) +// handle " mode" / " mode legacy|modern"; returns true if handled +static bool mode_command(color_ostream &out, std::vector ¶meters) { - if(!world || !world->map.block_index || !enable_autolabor) - { - return CR_OK; - } - - if (world->frame_counter - cycle_timestamp <= CYCLE_TICKS) - return CR_OK; - - cycle_timestamp = world->frame_counter; - - std::vector dwarfs; - - bool has_butchers = false; - bool has_fishery = false; - bool trader_requested = false; - - for (auto& build : world->buildings.all) - { - auto type = build->getType(); - if (building_type::Workshop == type) - { - df::workshop_type subType = (df::workshop_type)build->getSubtype(); - if (workshop_type::Butchers == subType) - has_butchers = true; - if (workshop_type::Fishery == subType) - has_fishery = true; - } - else if (building_type::TradeDepot == type) - { - df::building_tradedepotst* depot = (df::building_tradedepotst*) build; - trader_requested = trader_requested || depot->trade_flags.bits.trader_requested; - TRACE(cycle,out).print("{}", trader_requested - ? "Trade depot found and trader requested, trader will be excluded from all labors.\n" - : "Trade depot found but trader is not requested.\n" - ); - } - } + if (parameters.empty() || parameters[0] != "mode") + return false; - for (auto& cre : world->units.active) + if (parameters.size() == 1) { - if (Units::isCitizen(cre)) - { - if (cre->burrows.size() > 0) - continue; // dwarfs assigned to active burrows are skipped entirely - dwarfs.push_back(cre); - } + out << "Mode: " + << (engine_mode() == MODE_MODERN ? "modern (labormanager)" : + engine_mode() == MODE_MONITOR ? "monitor (starvation warnings)" : + "legacy (autolabor)") + << std::endl; + return true; } - int n_dwarfs = dwarfs.size(); - - if (n_dwarfs == 0) - return CR_OK; - - std::vector dwarf_info(n_dwarfs); - - // Find total skill and highest skill for each dwarf. More skilled dwarves shouldn't be used for minor tasks. - - for (int dwarf = 0; dwarf < n_dwarfs; dwarf++) + int want = -1; + if (parameters.size() == 2) { - if (dwarfs[dwarf]->status.souls.size() <= 0) - continue; - - // compute noble penalty - - int noble_penalty = 0; - - df::historical_figure* hf = df::historical_figure::find(dwarfs[dwarf]->hist_figure_id); - if(hf!=NULL) //can be NULL. E.g. script created citizens - for (auto& hfelink : hf->entity_links) - { - if (hfelink->getType() == df::histfig_entity_link_type::POSITION) - { - df::histfig_entity_link_positionst *epos = - (df::histfig_entity_link_positionst*) hfelink; - df::historical_entity* entity = df::historical_entity::find(epos->entity_id); - if (!entity) - continue; - df::entity_position_assignment* assignment = binsearch_in_vector(entity->positions.assignments, epos->assignment_id); - if (!assignment) - continue; - df::entity_position* position = binsearch_in_vector(entity->positions.own, assignment->position_id); - if (!position) - continue; - - for (int n = 0; n < 25; n++) - if (position->responsibilities[n]) - noble_penalty += responsibility_penalties[n]; - - if (position->responsibilities[df::entity_position_responsibility::HEALTH_MANAGEMENT]) - dwarf_info[dwarf].medical = true; - - if (position->responsibilities[df::entity_position_responsibility::TRADE]) - dwarf_info[dwarf].trader = true; - - } - - } - - dwarf_info[dwarf].noble_penalty = noble_penalty; - - // identify dwarfs who are needed for meetings and mark them for exclusion - - for (auto& act : plotinfo->activities) - { - if (!act) continue; - bool p1 = act->unit_actor == dwarfs[dwarf]->id; - bool p2 = act->unit_noble == dwarfs[dwarf]->id; - - if (p1 || p2) - { - dwarf_info[dwarf].diplomacy = true; - DEBUG(cycle, out).print("Dwarf {} \"{}\" has a meeting, will be cleared of all labors\n", - dwarf, dwarfs[dwarf]->name.first_name); - break; - } - } - - for (auto& skill : dwarfs[dwarf]->status.souls[0]->skills) - { - df::job_skill_class skill_class = ENUM_ATTR(job_skill, type, skill->id); - - int skill_level = skill->rating; - - // Track total & highest skill among normal/medical skills. (We don't care about personal or social skills.) - - if (skill_class != job_skill_class::Normal && skill_class != job_skill_class::Medical) - continue; - - if (dwarf_info[dwarf].highest_skill < skill_level) - dwarf_info[dwarf].highest_skill = skill_level; - dwarf_info[dwarf].total_skill += skill_level; - } + if (parameters[1] == "legacy") + want = MODE_LEGACY; + else if (parameters[1] == "modern") + want = MODE_MODERN; + else if (parameters[1] == "monitor") + want = MODE_MONITOR; } - // Calculate a base penalty for using each dwarf for a task he isn't good at. - - for (int dwarf = 0; dwarf < n_dwarfs; dwarf++) + if (want == -1) { - dwarf_info[dwarf].mastery_penalty -= 40 * dwarf_info[dwarf].highest_skill; - dwarf_info[dwarf].mastery_penalty -= 10 * dwarf_info[dwarf].total_skill; - dwarf_info[dwarf].mastery_penalty -= dwarf_info[dwarf].noble_penalty; - - FOR_ENUM_ITEMS(unit_labor, labor) - { - if (labor == unit_labor::NONE) - continue; - - if (labor_infos[labor].is_exclusive && dwarfs[dwarf]->status.labors[labor]) - dwarf_info[dwarf].mastery_penalty -= 100; - } + out.printerr("Syntax: mode [legacy|modern|monitor]\n"); + return true; } - // Find the activity state for each dwarf. It's important to get this right - a dwarf who we think is IDLE but - // can't work will gum everything up. In the future I might add code to auto-detect slacker dwarves. - - state_count.clear(); - state_count.resize(NUM_STATE); + if (!can_run(out)) + return true; - for (int dwarf = 0; dwarf < n_dwarfs; dwarf++) - { - if (Units::isBaby(dwarfs[dwarf]) || - Units::isChild(dwarfs[dwarf]) || - dwarfs[dwarf]->profession == profession::DRUNK) - { - dwarf_info[dwarf].state = CHILD; - } - else if (ENUM_ATTR(profession, military, dwarfs[dwarf]->profession)) - dwarf_info[dwarf].state = MILITARY; - else if (dwarfs[dwarf]->job.current_job == NULL) - { - if (Units::getMiscTrait(dwarfs[dwarf], misc_trait_type::Migrant)) - dwarf_info[dwarf].state = OTHER; - else if (dwarfs[dwarf]->specific_refs.size() > 0) - dwarf_info[dwarf].state = OTHER; - else - dwarf_info[dwarf].state = IDLE; - } - else - { - int job = dwarfs[dwarf]->job.current_job->job_type; - if (job >= 0 && size_t(job) < ARRAY_COUNT(dwarf_states)) - dwarf_info[dwarf].state = dwarf_states[job]; - else - { - WARN(cycle, out).print("Dwarf {} \"{}\" has unknown job {}\n", dwarf, dwarfs[dwarf]->name.first_name, job); - dwarf_info[dwarf].state = OTHER; - } - } - - state_count[dwarf_info[dwarf].state]++; + activate_mode(out, want); + out << "Mode: " << parameters[1] << std::endl; + return true; +} - TRACE(cycle, out).print("Dwarf {} \"{}\": penalty {}, state {}\n", - dwarf, dwarfs[dwarf]->name.first_name, dwarf_info[dwarf].mastery_penalty, state_names[dwarf_info[dwarf].state]); - } +command_result autolabor_cmd(color_ostream &out, std::vector ¶meters) +{ + if (!can_run(out)) + return CR_FAILURE; - std::vector labors; + if (mode_command(out, parameters)) + return CR_OK; - FOR_ENUM_ITEMS(unit_labor, labor) + if (parameters.size() == 1 && parameters[0] == "dump") { - if (labor == unit_labor::NONE) - continue; - - labor_infos[labor].active_dwarfs = 0; - - labors.push_back(labor); + out.print("=== autolabor state dump ===\n"); + out.print("enabled={} mode={}\n", (int)is_enabled, engine_mode()); + autolabor::dump_engine_state(out); + return CR_OK; } - laborinfo_sorter lasorter; - std::sort(labors.begin(), labors.end(), lasorter); - // Handle DISABLED skills (just bookkeeping). - // Note that autolabor should *NEVER* enable or disable a skill that has been marked as DISABLED, for any reason. - // The user has told us that they want manage this skill manually, and we must respect that. - for (auto& labor: labors) + if (parameters.size() == 1 && + (parameters[0] == "0" || parameters[0] == "enable" || + parameters[0] == "1" || parameters[0] == "disable")) { - if (labor_infos[labor].mode() != DISABLE) - continue; - - for (int dwarf = 0; dwarf < n_dwarfs; dwarf++) + bool enable = parameters[0] == "1" || parameters[0] == "enable"; + if (enable) { - if (dwarfs[dwarf]->status.labors[labor]) - { - if (labor_infos[labor].is_exclusive) - dwarf_info[dwarf].has_exclusive_labor = true; - - dwarf_info[dwarf].assigned_jobs++; - } + store_engine_mode(MODE_LEGACY); + if (is_enabled) + activate_mode(out, MODE_LEGACY); } + return plugin_enable(out, enable); } - // Handle all skills except those marked HAULERS - - for (auto& labor : labors) - { - assign_labor(labor, n_dwarfs, dwarf_info, trader_requested, dwarfs, has_butchers, has_fishery, out); - } + if (legacy_engine.command(out, parameters)) + return CR_OK; - // Set about 1/3 of the dwarfs as haulers. The haulers have all HAULER labors enabled. Having a lot of haulers helps - // make sure that hauling jobs are handled quickly rather than building up. + out.print("Automatically assigns labors to dwarves by writing the labor\n" + "matrix directly (legacy mode). Activate with 'autolabor enable',\n" + "deactivate with 'autolabor disable'.\n" + "Current state: {}, mode: {}.\n", (int)is_enabled, + engine_mode() == MODE_MODERN ? "modern" : "legacy"); + return CR_OK; +} - int num_haulers = state_count[IDLE] + (state_count[BUSY] + state_count[EXCLUSIVE]) * hauler_pct / 100; +command_result labormanager_cmd(color_ostream &out, std::vector ¶meters) +{ + if (!can_run(out)) + return CR_FAILURE; - if (num_haulers < 1) - num_haulers = 1; + if (mode_command(out, parameters)) + return CR_OK; - std::vector hauler_ids; - for (int dwarf = 0; dwarf < n_dwarfs; dwarf++) + if (parameters.size() == 1 && + (parameters[0] == "0" || parameters[0] == "enable" || + parameters[0] == "1" || parameters[0] == "disable")) { - if ((dwarf_info[dwarf].trader && trader_requested) || - dwarf_info[dwarf].diplomacy) + bool enable = parameters[0] == "1" || parameters[0] == "enable"; + if (enable) { - FOR_ENUM_ITEMS(unit_labor, labor) - { - if (labor == unit_labor::NONE) - continue; - if (labor_infos[labor].mode() != HAULERS) - continue; - dwarfs[dwarf]->status.labors[labor] = false; - } - if (dwarf_info[dwarf].state == IDLE || dwarf_info[dwarf].state == BUSY || dwarf_info[dwarf].state == EXCLUSIVE) - { - num_haulers--; - } - continue; + store_engine_mode(MODE_MODERN); + if (is_enabled) + activate_mode(out, MODE_MODERN); } - - if (dwarf_info[dwarf].state == IDLE || dwarf_info[dwarf].state == BUSY || dwarf_info[dwarf].state == EXCLUSIVE) - hauler_ids.push_back(dwarf); - } - dwarfinfo_sorter sorter(dwarf_info); - // Idle dwarves come first, then we sort from least-skilled to most-skilled. - std::sort(hauler_ids.begin(), hauler_ids.end(), sorter); - - // don't set any haulers if everyone is off drinking or something - if (hauler_ids.size() == 0) { - num_haulers = 0; + return plugin_enable(out, enable); } - FOR_ENUM_ITEMS(unit_labor, labor) + if (parameters.size() == 1 && parameters[0] == "dump") { - if (labor == unit_labor::NONE) - continue; - - if (labor_infos[labor].mode() != HAULERS) - continue; - - for (int i = 0; i < num_haulers; i++) - { - assert(size_t(i) < hauler_ids.size()); - - int dwarf = hauler_ids[i]; - - assert(dwarf >= 0); - assert(dwarf < n_dwarfs); - dwarfs[dwarf]->status.labors[labor] = true; - dwarf_info[dwarf].assigned_jobs++; - - if (dwarf_info[dwarf].state == IDLE || dwarf_info[dwarf].state == BUSY || dwarf_info[dwarf].state == EXCLUSIVE) - labor_infos[labor].active_dwarfs++; - - TRACE(cycle, out).print("Dwarf {} \"{}\" assigned {}: hauler\n", - dwarf, dwarfs[dwarf]->name.first_name, ENUM_KEY_STR(unit_labor, labor)); - } + out.print("=== autolabor state dump ===\n"); + out.print("enabled={} mode={}\n", (int)is_enabled, engine_mode()); + autolabor::dump_engine_state(out); + return CR_OK; + } - for (size_t i = num_haulers; i < hauler_ids.size(); i++) - { - assert(i < hauler_ids.size()); + // in monitor mode the monitor engine handles status/list; everything + // else (balance, labor config, ...) falls through to the modern dialect + if (engine_mode() == MODE_MONITOR && monitor_engine.command(out, parameters)) + return CR_OK; - int dwarf = hauler_ids[i]; + if (modern_engine.command(out, parameters)) + return CR_OK; - assert(dwarf >= 0); - assert(dwarf < n_dwarfs); + out.print("Automatically assigns labors to dwarves through the work detail\n" + "system (modern mode). Activate with 'labormanager enable',\n" + "deactivate with 'labormanager disable'.\n" + "Current state: {}, mode: {}.\n", (int)is_enabled, + engine_mode() == MODE_MODERN ? "modern" : "legacy"); + return CR_OK; +} - dwarfs[dwarf]->status.labors[labor] = false; - } - } +// --------------------------------------------------------------------------- +// Lua API for the work details overlay (plugins.autolabor.*) +// --------------------------------------------------------------------------- - return CR_OK; +static int autolabor_getMode(lua_State *L) +{ + Lua::Push(L, engine_mode()); + return 1; } -void print_labor (df::unit_labor labor, color_ostream &out) +static int autolabor_getStatus(lua_State *L) { - std::string labor_name = ENUM_KEY_STR(unit_labor, labor); - out << labor_name << ": "; - for (int i = 0; i < 20 - (int)labor_name.length(); i++) - out << ' '; - if (labor_infos[labor].mode() == DISABLE) - out << "disabled" << std::endl; - else - { - if (labor_infos[labor].mode() == HAULERS) - out << "haulers"; - else - out << "minimum " << labor_infos[labor].minimum_dwarfs() << ", maximum " << labor_infos[labor].maximum_dwarfs() - << ", pool " << labor_infos[labor].talent_pool(); - out << ", currently " << labor_infos[labor].active_dwarfs << " dwarfs" << std::endl; - } + std::string status; + if (is_enabled && active_engine == &modern_engine) + status = modern_engine.status_line(); + Lua::Push(L, status); + return 1; } -DFhackCExport command_result plugin_enable ( color_ostream &out, bool enable ) +static int autolabor_getBalanceStops(lua_State *L) { - if (!Core::getInstance().isMapLoaded() || !World::isFortressMode()) { - out.printerr("Cannot enable {} without a loaded fort.\n", plugin_name); - return CR_FAILURE; - } + for (int i = 0; i < NUM_BALANCE_STOPS; i++) + Lua::Push(L, balance_stop_name(i)); + return NUM_BALANCE_STOPS; +} - if (enable && !enable_autolabor) - { - enable_plugin(out); - } - else if(!enable && enable_autolabor) - { - disable_plugin(out); - } +static int autolabor_getBalance(lua_State *L) +{ + Lua::Push(L, balance()); + return 1; +} - return CR_OK; +static void autolabor_setBalance(color_ostream &out, int32_t stop) +{ + if (can_run(out)) + store_balance(stop); } -command_result autolabor (color_ostream &out, std::vector & parameters) +static int autolabor_getIdleReserve(lua_State *L) { - if (!Core::getInstance().isMapLoaded() || !World::isFortressMode()) { - out.printerr("Cannot run {} without a loaded fort.\n", plugin_name); - return CR_FAILURE; - } + Lua::Push(L, idle_reserve()); + return 1; +} - if (parameters.size() == 1 && - (parameters[0] == "0" || parameters[0] == "enable" || - parameters[0] == "1" || parameters[0] == "disable")) +// starving job postings on the board; only tracked while the modern or +// monitor engine is running. returns the count plus, when nonzero, the +// caption, age (ticks), and map position of the longest-starving posting +static int autolabor_getStarvingJobs(lua_State *L) +{ + int count = 0; + std::string name; + int32_t wait = 0; + df::coord pos; + if (is_enabled && active_engine == &modern_engine) { - bool enable = (parameters[0] == "1" || parameters[0] == "enable"); - - return plugin_enable(out, enable); + count = modern_engine.starving_jobs(); + name = modern_engine.oldest_starving_name(); + wait = modern_engine.oldest_starving_wait(); + pos = modern_engine.oldest_starving_pos(); } - else if (parameters.size() == 2 && parameters[0] == "haulpct") + else if (is_enabled && active_engine == &monitor_engine) { - if (!enable_autolabor) - { - out << "Error: The plugin is not enabled." << std::endl; - return CR_FAILURE; - } - - int pct = atoi (parameters[1].c_str()); - hauler_pct = pct; - return CR_OK; + count = monitor_engine.starving_jobs(); + name = monitor_engine.oldest_starving_name(); + wait = monitor_engine.oldest_starving_wait(); + pos = monitor_engine.oldest_starving_pos(); } - else if (parameters.size() >= 2 && parameters.size() <= 4) + Lua::Push(L, count); + if (count > 0) { - if (!enable_autolabor) - { - out << "Error: The plugin is not enabled." << std::endl; - return CR_FAILURE; - } - - df::unit_labor labor = unit_labor::NONE; - - FOR_ENUM_ITEMS(unit_labor, test_labor) - { - if (parameters[0] == ENUM_KEY_STR(unit_labor, test_labor)) - labor = test_labor; - } - - if (labor == unit_labor::NONE) - { - out.printerr("Could not find labor {}.\n", parameters[0]); - return CR_WRONG_USAGE; - } - - if (parameters[1] == "haulers") - { - labor_infos[labor].set_mode(HAULERS); - print_labor(labor, out); - return CR_OK; - } - if (parameters[1] == "disable") - { - labor_infos[labor].set_mode(DISABLE); - print_labor(labor, out); - return CR_OK; - } - if (parameters[1] == "reset") - { - reset_labor(labor); - print_labor(labor, out); - return CR_OK; - } - - int minimum = atoi (parameters[1].c_str()); - int maximum = 200; - int pool = 200; - - if (parameters.size() >= 3) - maximum = atoi (parameters[2].c_str()); - if (parameters.size() == 4) - pool = std::stoi(parameters[3]); - - if (maximum < minimum || maximum < 0 || minimum < 0) - { - out.printerr("Syntax: autolabor [] [], {} > {}\n", maximum, minimum); - return CR_WRONG_USAGE; - } - - labor_infos[labor].set_minimum_dwarfs(minimum); - labor_infos[labor].set_maximum_dwarfs(maximum); - labor_infos[labor].set_talent_pool(pool); - labor_infos[labor].set_mode(AUTOMATIC); - print_labor(labor, out); - - return CR_OK; + Lua::Push(L, name); + Lua::Push(L, (int32_t)wait); + Lua::Push(L, (int32_t)pos.x); + Lua::Push(L, (int32_t)pos.y); + Lua::Push(L, (int32_t)pos.z); + return 6; } - else if (parameters.size() == 1 && parameters[0] == "reset-all") - { - if (!enable_autolabor) - { - out << "Error: The plugin is not enabled." << std::endl; - return CR_FAILURE; - } + return 1; +} - for (size_t i = 0; i < labor_infos.size(); i++) - { - reset_labor((df::unit_labor) i); - } - out << "All labors reset." << std::endl; - return CR_OK; - } - else if (parameters.size() == 1 && (parameters[0] == "list" || parameters[0] == "status")) - { - if (!enable_autolabor) - { - out << "Error: The plugin is not enabled." << std::endl; - return CR_FAILURE; - } +static void autolabor_setIdleReserve(color_ostream &out, int32_t pct) +{ + if (can_run(out)) + store_idle_reserve(pct); +} - bool need_comma = 0; - for (int i = 0; i < NUM_STATE; i++) - { - if (state_count[i] == 0) - continue; - if (need_comma) - out << ", "; - out << state_count[i] << ' ' << state_names[i]; - need_comma = 1; - } - out << std::endl; +// returns a list of unit_labor enum names that the engines' static labor +// data doesn't cover; used by the labor coverage test in scripts/test +static int autolabor_checkLaborCoverage(lua_State *L) +{ + init_labor_to_skill(); + std::vector missing; + legacy_labor_coverage(missing); + modern_labor_coverage(missing); + Lua::Push(L, missing); + return 1; +} - if (parameters[0] == "list") - { - FOR_ENUM_ITEMS(unit_labor, labor) - { - if (labor == unit_labor::NONE) - continue; +// returns a list of job_type enum names with no entry in the job to labor +// table; used by the job coverage test in scripts/test +static int autolabor_checkJobCoverage(lua_State *L) +{ + std::vector missing; + JobLaborMapper mapper; + mapper.job_coverage(missing); + Lua::Push(L, missing); + return 1; +} - print_labor(labor, out); - } - } +static void autolabor_setMode(color_ostream &out, int32_t mode) +{ + if (!can_run(out)) + return; + if (mode != MODE_LEGACY && mode != MODE_MODERN && mode != MODE_MONITOR) + return; + activate_mode(out, mode); +} - return CR_OK; - } - else - { - out.print("Automatically assigns labors to dwarves.\n" - "Activate with 'enable autolabor', deactivate with 'disable autolabor'.\n" - "Current state: {}.\n", enable_autolabor); +DFHACK_PLUGIN_LUA_FUNCTIONS { + DFHACK_LUA_FUNCTION(autolabor_setMode), + DFHACK_LUA_FUNCTION(autolabor_setBalance), + DFHACK_LUA_FUNCTION(autolabor_setIdleReserve), + DFHACK_LUA_END +}; - return CR_OK; - } -} +DFHACK_PLUGIN_LUA_COMMANDS { + DFHACK_LUA_COMMAND(autolabor_getMode), + DFHACK_LUA_COMMAND(autolabor_getStatus), + DFHACK_LUA_COMMAND(autolabor_getBalance), + DFHACK_LUA_COMMAND(autolabor_getBalanceStops), + DFHACK_LUA_COMMAND(autolabor_getIdleReserve), + DFHACK_LUA_COMMAND(autolabor_getStarvingJobs), + DFHACK_LUA_COMMAND(autolabor_checkLaborCoverage), + DFHACK_LUA_COMMAND(autolabor_checkJobCoverage), + DFHACK_LUA_END +}; diff --git a/plugins/autolabor/engines.h b/plugins/autolabor/engines.h new file mode 100644 index 0000000000..b236f34f89 --- /dev/null +++ b/plugins/autolabor/engines.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include + +#include "ColorText.h" +#include "PluginManager.h" + +using namespace DFHack; + +// One labor-management strategy. Exactly one engine is active at a time, +// selected by the plugin's persisted mode. Engines are stateless between +// update() calls except for persisted config and small persistent caches. +class LaborEngine { +public: + virtual ~LaborEngine() {} + + // Called when the plugin is enabled in this engine's mode (including on + // map load). Must leave the fort's labor state consistent. + virtual void enable(color_ostream &out) = 0; + + // Called when the plugin is disabled or the mode is switched away. + // Must undo any labor-affecting state so vanilla behaves normally. + virtual void disable(color_ostream &out) = 0; + + // Called every frame while active; engines throttle internally. + virtual void update(color_ostream &out) = 0; + + // Handle the engine's command dialect. Returns false if the command + // isn't recognized so the caller can try shared handling. + virtual bool command(color_ostream &out, std::vector ¶meters) = 0; +}; diff --git a/plugins/autolabor/joblabormapper.cpp b/plugins/autolabor/joblabormapper.cpp index c1d773e046..95942d20d8 100644 --- a/plugins/autolabor/joblabormapper.cpp +++ b/plugins/autolabor/joblabormapper.cpp @@ -15,8 +15,18 @@ */ +#include "joblabormapper.h" + +#include +#include +#include +#include + +#include "Core.h" #include "DataDefs.h" +#include "Debug.h" #include "MiscUtils.h" + #include "modules/Materials.h" #include @@ -26,31 +36,23 @@ #include #include #include - +#include #include - #include #include #include - +#include #include #include - #include #include #include - +#include #include - #include - #include - #include -#include -#include - using namespace std; using std::string; using std::endl; @@ -59,8 +61,19 @@ using namespace df::enums; using df::global::plotinfo; using df::global::world; -#include "labormanager.h" -#include "joblabormapper.h" +namespace DFHack { + DBG_DECLARE(autolabor, mapper, DebugCategory::LWARNING); +} + +// stand-ins for the old labormanager debug hooks; these only fire on +// failure-to-map paths that indicate a gap in the labor tables +template +static void jlm_debug(fmt::format_string fmt, Args&&... args) +{ + WARN(mapper, Core::getInstance().getConsole()).print("{}", + fmt::format(fmt, std::forward(args)...)); +} +static void jlm_debug_pause() {} static df::unit_labor hauling_labor_map[] = { @@ -302,8 +315,6 @@ class jlfunc_construct_bld : public jlfunc case df::building_type::Workshop: { df::building_workshopst* ws = (df::building_workshopst*) bld; - if (ws->design && !ws->design->flags.bits.designed) - return df::unit_labor::ARCHITECT; if (ws->type == df::workshop_type::Custom) { df::building_def* def = df::building_def::find(ws->custom_type); @@ -316,8 +327,6 @@ class jlfunc_construct_bld : public jlfunc case df::building_type::Furnace: { df::building_furnacest* frn = (df::building_furnacest*) bld; - if (frn->design && !frn->design->flags.bits.designed) - return df::unit_labor::ARCHITECT; if (frn->type == df::furnace_type::Custom) { df::building_def* def = df::building_def::find(frn->custom_type); @@ -343,8 +352,6 @@ class jlfunc_construct_bld : public jlfunc case df::building_type::Windmill: { df::building_actual* b = (df::building_actual*) bld; - if (b->design && !b->design->flags.bits.designed) - return df::unit_labor::ARCHITECT; return construction_build_labor(b); } break; @@ -397,9 +404,9 @@ class jlfunc_construct_bld : public jlfunc return df::unit_labor::BUILD_ROAD; } - debug("LABORMANAGER: Cannot deduce labor for construct building job of type %s\n", - ENUM_KEY_STR(building_type, bld->getType()).c_str()); - debug_pause(); + jlm_debug("LABORMANAGER: Cannot deduce labor for construct building job of type {}\n", + ENUM_KEY_STR(building_type, bld->getType())); + jlm_debug_pause(); return df::unit_labor::NONE; } @@ -512,9 +519,9 @@ class jlfunc_destroy_bld : public jlfunc return df::unit_labor::SIEGECRAFT; } - debug("LABORMANAGER: Cannot deduce labor for destroy building job of type %s\n", - ENUM_KEY_STR(building_type, type).c_str()); - debug_pause(); + jlm_debug("LABORMANAGER: Cannot deduce labor for destroy building job of type {}\n", + ENUM_KEY_STR(building_type, type)); + jlm_debug_pause(); return df::unit_labor::NONE; } @@ -536,6 +543,10 @@ class jlfunc_make : public jlfunc { case df::workshop_type::Craftsdwarfs: { + // v50: stone blocks are stonecutting work, even when they are + // made at a craftsdwarf's workshop + if (j->job_type == df::job_type::ConstructBlocks) + return df::unit_labor::STONECUTTER; df::item_type jobitem = j->job_items.elements[0]->item_type; switch (jobitem) { @@ -550,8 +561,8 @@ class jlfunc_make : public jlfunc return df::unit_labor::BONE_CARVE; else { - debug("LABORMANAGER: Cannot deduce labor for make crafts job (not bone)\n"); - debug_pause(); + jlm_debug("LABORMANAGER: Cannot deduce labor for make crafts job (not bone)\n"); + jlm_debug_pause(); return df::unit_labor::NONE; } case df::item_type::WOOD: @@ -561,14 +572,20 @@ class jlfunc_make : public jlfunc case df::item_type::SKIN_TANNED: return df::unit_labor::LEATHER; default: - debug("LABORMANAGER: Cannot deduce labor for make crafts job, item type %s\n", - ENUM_KEY_STR(item_type, jobitem).c_str()); - debug_pause(); + jlm_debug("LABORMANAGER: Cannot deduce labor for make crafts job, item type {}\n", + ENUM_KEY_STR(item_type, jobitem)); + jlm_debug_pause(); return df::unit_labor::NONE; } } case df::workshop_type::Masons: - return df::unit_labor::MASON; + // v50: the stoneworker's workshop no longer uses the masonry + // labor; stonecutters cut blocks there and stone carvers make + // everything else (masonry is now only for constructing + // buildings out of stone) + if (j->job_type == df::job_type::ConstructBlocks) + return df::unit_labor::STONECUTTER; + return df::unit_labor::STONE_CARVER; case df::workshop_type::Carpenters: return df::unit_labor::CARPENTER; case df::workshop_type::Leatherworks: @@ -581,9 +598,9 @@ class jlfunc_make : public jlfunc case df::workshop_type::MetalsmithsForge: return metaltype; default: - debug("LABORMANAGER: Cannot deduce labor for make job, workshop type %s\n", - ENUM_KEY_STR(workshop_type, type).c_str()); - debug_pause(); + jlm_debug("LABORMANAGER: Cannot deduce labor for make job, workshop type {}\n", + ENUM_KEY_STR(workshop_type, type)); + jlm_debug_pause(); return df::unit_labor::NONE; } } @@ -596,16 +613,16 @@ class jlfunc_make : public jlfunc case df::furnace_type::GlassFurnace: return df::unit_labor::GLASSMAKER; default: - debug("LABORMANAGER: Cannot deduce labor for make job, furnace type %s\n", - ENUM_KEY_STR(furnace_type, type).c_str()); - debug_pause(); + jlm_debug("LABORMANAGER: Cannot deduce labor for make job, furnace type {}\n", + ENUM_KEY_STR(furnace_type, type)); + jlm_debug_pause(); return df::unit_labor::NONE; } } - debug("LABORMANAGER: Cannot deduce labor for make job, building type %s\n", - ENUM_KEY_STR(building_type, bld->getType()).c_str()); - debug_pause(); + jlm_debug("LABORMANAGER: Cannot deduce labor for make job, building type {}\n", + ENUM_KEY_STR(building_type, bld->getType())); + jlm_debug_pause(); return df::unit_labor::NONE; } @@ -685,9 +702,9 @@ JobLaborMapper::JobLaborMapper() jlfunc* jlf_no_labor = jlf_const(df::unit_labor::NONE); - job_to_labor_table[df::job_type::CarveFortification] = jlf_const(df::unit_labor::DETAIL); - job_to_labor_table[df::job_type::DetailWall] = jlf_const(df::unit_labor::DETAIL); - job_to_labor_table[df::job_type::DetailFloor] = jlf_const(df::unit_labor::DETAIL); + job_to_labor_table[df::job_type::CarveFortification] = jlf_const(df::unit_labor::STONECUTTER); + job_to_labor_table[df::job_type::DetailWall] = jlf_const(df::unit_labor::ENGRAVER); + job_to_labor_table[df::job_type::DetailFloor] = jlf_const(df::unit_labor::ENGRAVER); job_to_labor_table[df::job_type::Dig] = jlf_const(df::unit_labor::MINE); job_to_labor_table[df::job_type::CarveUpwardStaircase] = jlf_const(df::unit_labor::MINE); job_to_labor_table[df::job_type::CarveDownwardStaircase] = jlf_const(df::unit_labor::MINE); @@ -719,19 +736,16 @@ JobLaborMapper::JobLaborMapper() job_to_labor_table[df::job_type::CatchLiveLandAnimal] = jlf_const(df::unit_labor::HUNT); job_to_labor_table[df::job_type::CatchLiveFish] = jlf_const(df::unit_labor::FISH); job_to_labor_table[df::job_type::ReturnKill] = jlf_no_labor; - job_to_labor_table[df::job_type::CheckChest] = jlf_no_labor; job_to_labor_table[df::job_type::StoreOwnedItem] = jlf_no_labor; job_to_labor_table[df::job_type::PlaceItemInTomb] = jlf_const(df::unit_labor::HAUL_BODY); job_to_labor_table[df::job_type::StoreItemInStockpile] = jlf_hauling; job_to_labor_table[df::job_type::StoreItemInBag] = jlf_hauling; - job_to_labor_table[df::job_type::StoreItemInHospital] = jlf_hauling; job_to_labor_table[df::job_type::StoreWeapon] = jlf_hauling; job_to_labor_table[df::job_type::StoreArmor] = jlf_hauling; job_to_labor_table[df::job_type::StoreItemInBarrel] = jlf_hauling; job_to_labor_table[df::job_type::StoreItemInBin] = jlf_hauling; job_to_labor_table[df::job_type::SeekArtifact] = jlf_no_labor; job_to_labor_table[df::job_type::SeekInfant] = jlf_no_labor; - job_to_labor_table[df::job_type::AttendParty] = jlf_no_labor; job_to_labor_table[df::job_type::GoShopping] = jlf_no_labor; job_to_labor_table[df::job_type::GoShoppingSpecific] = jlf_no_labor; job_to_labor_table[df::job_type::Clean] = jlf_const(df::unit_labor::CLEAN); @@ -828,7 +842,6 @@ JobLaborMapper::JobLaborMapper() job_to_labor_table[df::job_type::LoadStoneTrap] = jlf_const(df::unit_labor::MECHANIC); job_to_labor_table[df::job_type::LoadWeaponTrap] = jlf_const(df::unit_labor::MECHANIC); job_to_labor_table[df::job_type::CleanTrap] = jlf_const(df::unit_labor::MECHANIC); - job_to_labor_table[df::job_type::CastSpell] = jlf_no_labor; job_to_labor_table[df::job_type::LinkBuildingToTrigger] = jlf_const(df::unit_labor::MECHANIC); job_to_labor_table[df::job_type::PullLever] = jlf_const(df::unit_labor::PULL_LEVER); job_to_labor_table[df::job_type::ExtractFromPlants] = jlf_const(df::unit_labor::HERBALIST); @@ -893,7 +906,7 @@ JobLaborMapper::JobLaborMapper() job_to_labor_table[df::job_type::ApplyCast] = jlf_const(df::unit_labor::BONE_SETTING); job_to_labor_table[df::job_type::CustomReaction] = new jlfunc_custom(); job_to_labor_table[df::job_type::ConstructSlab] = jlf_make_furniture; - job_to_labor_table[df::job_type::EngraveSlab] = jlf_const(df::unit_labor::DETAIL); + job_to_labor_table[df::job_type::EngraveSlab] = jlf_const(df::unit_labor::ENGRAVER); job_to_labor_table[df::job_type::ShearCreature] = jlf_const(df::unit_labor::SHEARER); job_to_labor_table[df::job_type::SpinThread] = jlf_const(df::unit_labor::SPINNER); job_to_labor_table[df::job_type::PenLargeAnimal] = jlf_const(df::unit_labor::HAUL_ANIMALS); @@ -907,7 +920,7 @@ JobLaborMapper::JobLaborMapper() job_to_labor_table[df::job_type::ReportCrime] = jlf_no_labor; job_to_labor_table[df::job_type::ExecuteCriminal] = jlf_no_labor; job_to_labor_table[df::job_type::TrainAnimal] = jlf_const(df::unit_labor::ANIMALTRAIN); - job_to_labor_table[df::job_type::CarveTrack] = jlf_const(df::unit_labor::DETAIL); + job_to_labor_table[df::job_type::CarveTrack] = jlf_const(df::unit_labor::STONECUTTER); job_to_labor_table[df::job_type::PushTrackVehicle] = jlf_const(df::unit_labor::HANDLE_VEHICLES); job_to_labor_table[df::job_type::PlaceTrackVehicle] = jlf_const(df::unit_labor::HANDLE_VEHICLES); job_to_labor_table[df::job_type::StoreItemInVehicle] = jlf_hauling; @@ -927,8 +940,45 @@ JobLaborMapper::JobLaborMapper() job_to_labor_table[df::job_type::HeistItem] = jlf_no_labor; // added for 47.04 - see #1561 job_to_labor_table[df::job_type::InterrogateSubject] = jlf_no_labor; // added for 47.04 - see #1561 job_to_labor_table[df::job_type::AcceptHeistItem] = jlf_no_labor; // added for 47.04 - see #1561 + + // v50 additions + job_to_labor_table[df::job_type::SmoothWall] = jlf_const(df::unit_labor::STONECUTTER); + job_to_labor_table[df::job_type::SmoothFloor] = jlf_const(df::unit_labor::STONECUTTER); + job_to_labor_table[df::job_type::PolishStones] = jlf_const(df::unit_labor::STONE_CRAFT); + job_to_labor_table[df::job_type::ConstructBag] = jlf_const(df::unit_labor::LEATHER); + job_to_labor_table[df::job_type::EncrustWithStones] = jlf_const(df::unit_labor::ENCRUST_GEM); + job_to_labor_table[df::job_type::StoreSquadEquipmentItem] = jlf_no_labor; + job_to_labor_table[df::job_type::MixDye] = jlf_const(df::unit_labor::DYER); + job_to_labor_table[df::job_type::DyeLeather] = jlf_const(df::unit_labor::DYER); + job_to_labor_table[df::job_type::ConstructBoltThrowerParts] = jlf_const(df::unit_labor::SIEGECRAFT); + job_to_labor_table[df::job_type::LoadBoltThrower] = jlf_const(df::unit_labor::SIEGEOPERATE); + job_to_labor_table[df::job_type::FireBoltThrower] = jlf_const(df::unit_labor::SIEGEOPERATE); + job_to_labor_table[df::job_type::UNUSED_31] = jlf_no_labor; + job_to_labor_table[df::job_type::UNUSED_32] = jlf_no_labor; + job_to_labor_table[df::job_type::UNUSED_33] = jlf_no_labor; + job_to_labor_table[df::job_type::UNUSED_34] = jlf_no_labor; + job_to_labor_table[df::job_type::UNUSED_35] = jlf_no_labor; + job_to_labor_table[df::job_type::UNUSED_36] = jlf_no_labor; + job_to_labor_table[df::job_type::UNUSED_37] = jlf_no_labor; + job_to_labor_table[df::job_type::UNUSED_38] = jlf_no_labor; + job_to_labor_table[df::job_type::UNUSED_39] = jlf_no_labor; + job_to_labor_table[df::job_type::UNUSED_40] = jlf_no_labor; }; +void JobLaborMapper::job_coverage(std::vector &missing) +{ + FOR_ENUM_ITEMS(job_type, jt) + { + if (jt < 0) + continue; + // a job type with no table entry is invisible to labor management; + // DF releases that add job types must add a mapping (or jlf_no_labor) + if (!job_to_labor_table.count(jt)) + missing.push_back(ENUM_KEY_STR(job_type, jt) + + ": no entry in job to labor table"); + } +} + df::unit_labor JobLaborMapper::find_job_labor(df::job* j) { if (j->job_type == df::job_type::CustomReaction) @@ -948,8 +998,8 @@ df::unit_labor JobLaborMapper::find_job_labor(df::job* j) df::unit_labor labor; if (job_to_labor_table.count(j->job_type) == 0) { - debug("LABORMANAGER: job has no job to labor table entry: %s (%d)\n", ENUM_KEY_STR(job_type, j->job_type).c_str(), j->job_type); - debug_pause(); + jlm_debug("LABORMANAGER: job has no job to labor table entry: {} ({})\n", ENUM_KEY_STR(job_type, j->job_type), int(j->job_type)); + jlm_debug_pause(); labor = df::unit_labor::NONE; } else { diff --git a/plugins/autolabor/joblabormapper.h b/plugins/autolabor/joblabormapper.h index 03e06a67f4..a117de384c 100644 --- a/plugins/autolabor/joblabormapper.h +++ b/plugins/autolabor/joblabormapper.h @@ -1,15 +1,17 @@ #pragma once #include +#include +#include -using namespace DFHack; -using namespace df::enums; - #include "df/job.h" #include "df/job_type.h" #include "df/unit_labor.h" +using namespace DFHack; +using namespace df::enums; + class jlfunc; @@ -27,5 +29,9 @@ class JobLaborMapper { df::unit_labor find_job_labor(df::job* j); + // appends names of job_type enum items with no entry in the labor + // table; used by the job coverage self-test + void job_coverage(std::vector &missing); + }; diff --git a/plugins/autolabor/laborcommon.cpp b/plugins/autolabor/laborcommon.cpp new file mode 100644 index 0000000000..7c4a5ef1bc --- /dev/null +++ b/plugins/autolabor/laborcommon.cpp @@ -0,0 +1,451 @@ +#include "laborcommon.h" + +#include +#include +#include + +#include "joblabormapper.h" + +#include "Debug.h" + +#include "modules/Units.h" +#include "modules/World.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace DFHack; +using namespace df::enums; + +using df::global::plotinfo; +using df::global::world; + +namespace DFHack { + DBG_DECLARE(autolabor, labor_probe, DebugCategory::LINFO); +} + +namespace autolabor { + +PersistentDataItem plugin_config; + +void load_plugin_config() +{ + plugin_config = World::GetPersistentSiteData("autolabor/config", true); + if (plugin_config.isValid() && plugin_config.ival(0) == -1) + plugin_config.ival(0) = 0; +} + +bool config_flag(unsigned flag) +{ + return plugin_config.isValid() && (plugin_config.ival(0) & flag) != 0; +} + +void set_config_flag(unsigned flag, bool on) +{ + if (!plugin_config.isValid()) + return; + if (on) + plugin_config.ival(0) |= flag; + else + plugin_config.ival(0) &= ~flag; +} + +int engine_mode() +{ + if (!plugin_config.isValid()) + return MODE_LEGACY; + int m = plugin_config.get_int(1); + return (m >= MODE_LEGACY && m <= MODE_MONITOR) ? m : MODE_LEGACY; +} + +void store_engine_mode(int mode) +{ + if (plugin_config.isValid()) + plugin_config.ival(1) = mode; +} + +int balance() +{ + if (!plugin_config.isValid()) + return NUM_BALANCE_STOPS / 2; + int v = plugin_config.get_int(2); + if (v < 0 || v >= NUM_BALANCE_STOPS) + v = NUM_BALANCE_STOPS / 2; // default: balanced + return v; +} + +void store_balance(int v) +{ + if (plugin_config.isValid() && v >= 0 && v < NUM_BALANCE_STOPS) + plugin_config.ival(2) = v; +} + +static const char * const balance_names[] = { + "staffing", "lean-staffing", "balanced", "lean-skills", "skills" +}; + +const char *balance_stop_name(int i) +{ + if (i < 0 || i >= NUM_BALANCE_STOPS) + return NULL; + return balance_names[i]; +} + +int idle_reserve() +{ + if (!plugin_config.isValid()) + return 30; + int v = plugin_config.get_int(3); + return (v < 0 || v > 100) ? 30 : v; +} + +void store_idle_reserve(int v) +{ + if (plugin_config.isValid() && v >= 0 && v <= 100) + plugin_config.ival(3) = v; +} + +df::job_skill labor_to_skill[NUM_LABORS]; + +void init_labor_to_skill() +{ + for (int i = 0; i < NUM_LABORS; i++) + labor_to_skill[i] = job_skill::NONE; + + FOR_ENUM_ITEMS(job_skill, skill) + { + int labor = ENUM_ATTR(job_skill, labor, skill); + if (labor >= 0 && labor < NUM_LABORS) + labor_to_skill[labor] = skill; + } +} + +bool is_exclusive_labor(df::unit_labor l) +{ + // labors that carry tools; toggling them causes equipment churn + switch (l) { + case unit_labor::MINE: + case unit_labor::CUTWOOD: + case unit_labor::HUNT: + return true; + default: + return false; + } +} + +BuildingScan scan_buildings() +{ + BuildingScan scan; + + for (auto build : world->buildings.all) + { + auto type = build->getType(); + if (building_type::Workshop == type) + { + df::workshop_type subType = (df::workshop_type)build->getSubtype(); + if (workshop_type::Butchers == subType) + scan.has_butchers = true; + if (workshop_type::Fishery == subType) + scan.has_fishery = true; + } + else if (building_type::TradeDepot == type) + { + df::building_tradedepotst* depot = + strict_virtual_cast(build); + if (depot) + scan.trader_requested = + scan.trader_requested || depot->trade_flags.bits.trader_requested; + } + } + + return scan; +} + +static const int responsibility_penalties[] = { + 0, /* LAW_MAKING */ + 0, /* LAW_ENFORCEMENT */ + 3000, /* RECEIVE_DIPLOMATS */ + 0, /* MEET_WORKERS */ + 1000, /* MANAGE_PRODUCTION */ + 3000, /* TRADE */ + 1000, /* ACCOUNTING */ + 0, /* ESTABLISH_COLONY_TRADE_AGREEMENTS */ + 0, /* MAKE_INTRODUCTIONS */ + 0, /* MAKE_PEACE_AGREEMENTS */ + 0, /* MAKE_TOPIC_AGREEMENTS */ + 0, /* COLLECT_TAXES */ + 0, /* ESCORT_TAX_COLLECTOR */ + 0, /* EXECUTIONS */ + 0, /* TAME_EXOTICS */ + 0, /* RELIGION */ + 0, /* ATTACK_ENEMIES */ + 0, /* PATROL_TERRITORY */ + 0, /* MILITARY_GOALS */ + 0, /* MILITARY_STRATEGY */ + 0, /* UPGRADE_SQUAD_EQUIPMENT */ + 0, /* EQUIPMENT_MANIFESTS */ + 0, /* SORT_AMMUNITION */ + 0, /* BUILD_MORALE */ + 5000 /* HEALTH_MANAGEMENT */ +}; + +PositionInfo unit_position_info(df::unit *u) +{ + PositionInfo info; + + df::historical_figure* hf = df::historical_figure::find(u->hist_figure_id); + if (!hf) // can be NULL, e.g. script-created citizens + return info; + + for (auto hfelink : hf->entity_links) + { + if (hfelink->getType() != df::histfig_entity_link_type::POSITION) + continue; + + df::histfig_entity_link_positionst *epos = + (df::histfig_entity_link_positionst*) hfelink; + df::historical_entity* entity = df::historical_entity::find(epos->entity_id); + if (!entity) + continue; + df::entity_position_assignment* assignment = + binsearch_in_vector(entity->positions.assignments, epos->assignment_id); + if (!assignment) + continue; + df::entity_position* position = + binsearch_in_vector(entity->positions.own, assignment->position_id); + if (!position) + continue; + + for (int n = 0; n < 25; n++) + if (position->responsibilities[n]) + info.noble_penalty += responsibility_penalties[n]; + + if (position->responsibilities[df::entity_position_responsibility::HEALTH_MANAGEMENT]) + info.medical = true; + + if (position->responsibilities[df::entity_position_responsibility::TRADE]) + info.trader = true; + } + + return info; +} + +bool in_diplomacy_meeting(df::unit *u) +{ + for (auto act : plotinfo->activities) + { + if (!act) continue; + if (act->unit_actor == u->id || act->unit_noble == u->id) + return true; + } + return false; +} + +bool is_assignable(df::unit *u) +{ + return Units::isOwnCiv(u) && + Units::isOwnGroup(u) && + Units::isActive(u) && + !u->flags2.bits.visitor && + !u->flags3.bits.ghostly && + ENUM_ATTR(profession, can_assign_labor, u->profession); +} + +dwarf_state get_dwarf_state(df::unit *u) +{ + if (Units::isBaby(u) || Units::isChild(u) || + u->profession == profession::DRUNK) + return dwarf_state::CHILD; + + if (ENUM_ATTR(profession, military, u->profession)) + return dwarf_state::MILITARY; + + if (!u->job.current_job) + { + if (Units::getMiscTrait(u, misc_trait_type::Migrant) || + u->specific_refs.size() > 0) + return dwarf_state::OTHER; + return dwarf_state::IDLE; + } + + int job = u->job.current_job->job_type; + if (job >= 0 && size_t(job) < dwarf_state_count) + return dwarf_states[job]; + + return dwarf_state::OTHER; +} + +int top_skill(df::unit *u, df::job_skill *skill_out) +{ + int best = 0; + df::job_skill best_skill = job_skill::NONE; + + if (u->status.souls.size() > 0) + { + for (auto skill : u->status.souls[0]->skills) + { + df::job_skill_class skill_class = ENUM_ATTR(job_skill, type, skill->id); + if (skill_class != job_skill_class::Normal && + skill_class != job_skill_class::Medical) + continue; + + if (best < skill->rating) + { + best = skill->rating; + best_skill = skill->id; + } + } + } + + if (skill_out) + *skill_out = best_skill; + return best; +} + +int total_skill(df::unit *u) +{ + int total = 0; + if (u->status.souls.size() > 0) + { + for (auto skill : u->status.souls[0]->skills) + { + df::job_skill_class skill_class = ENUM_ATTR(job_skill, type, skill->id); + if (skill_class != job_skill_class::Normal && + skill_class != job_skill_class::Medical) + continue; + total += skill->rating; + } + } + return total; +} + +// --------------------------------------------------------------------------- +// labor probe +// +// The v50 stoneworker labor split (stonecutter / stone carver / mason / +// engraver / stonecrafter) was re-derived from documentation rather than +// verified against the game. The game only lets a unit claim a job if it +// has the job's true gating labor enabled, so logging which of the disputed +// labors are enabled on each observed worker lets A/B testing identify the +// real mapping (e.g. a unit making rock blocks with STONECUTTER on and +// MASON off confirms the mapping; an enabled mask that consistently +// disagrees with `mapped` flags a wrong guess). +// --------------------------------------------------------------------------- + +const std::vector disputed_labors = { + df::unit_labor::STONECUTTER, + df::unit_labor::STONE_CARVER, + df::unit_labor::MASON, + df::unit_labor::ENGRAVER, + df::unit_labor::STONE_CRAFT, +}; + +static JobLaborMapper *probe_mapper = nullptr; +// (job id, unit id) -> enabled-labor bitmask; only new or changed +// combinations are logged so the channel doesn't spam every cycle +static std::map, int> probe_seen; +// (unit id, job type) pairs already warned about for a mapped-labor +// mismatch; each unit/job-type combination warns once +static std::set> probe_warned; + +void probe_labor_observation(df::unit *u) +{ + df::job *j = u->job.current_job; + if (!j) + return; + if (!probe_mapper) + probe_mapper = new JobLaborMapper(); + + df::unit_labor mapped = probe_mapper->find_job_labor(j); + + // strong mismatch signal independent of the disputed set: the unit is + // doing the job without the labor we mapped it to. the game gates job + // claiming on the real labor, so either our mapping is wrong or the job + // accepts multiple labors and we picked the wrong one -- either way the + // unit's enabled labors hold the better candidate. (a claimed job can + // also outlive a labor removal, so this is only a *possible* mismatch) + if (mapped > unit_labor::NONE && mapped < NUM_LABORS && + !u->status.labors[mapped]) + { + auto wkey = std::make_pair(u->id, (int32_t)j->job_type); + if (!probe_warned.count(wkey)) + { + probe_warned.insert(wkey); + std::stringstream es; + bool comma = false; + FOR_ENUM_ITEMS(unit_labor, l) + { + if (l == unit_labor::NONE || !u->status.labors[l]) + continue; + if (comma) + es << ','; + es << ENUM_KEY_STR(unit_labor, l); + comma = true; + } + INFO(labor_probe).print( + "possible labor mapping mismatch: unit {} ({}) is doing job " + "{} {} (mapped {}) without that labor enabled; enabled " + "labors: {{{}}}\n", + u->id, Units::getReadableName(u), j->id, + ENUM_KEY_STR(job_type, j->job_type), + ENUM_KEY_STR(unit_labor, mapped), + comma ? es.str() : std::string("NONE")); + } + } + + const int n_disputed = disputed_labors.size(); + int mask = 0; + bool in_cluster = false; + for (int i = 0; i < n_disputed; i++) + { + if (disputed_labors[i] == mapped) + in_cluster = true; + if (u->status.labors[disputed_labors[i]]) + mask |= 1 << i; + } + if (!in_cluster) + return; + + auto key = std::make_pair(j->id, u->id); + auto seen = probe_seen.find(key); + if (seen != probe_seen.end() && seen->second == mask) + return; + probe_seen[key] = mask; + + std::stringstream ss; + bool comma = false; + for (int i = 0; i < n_disputed; i++) + { + if (!(mask & (1 << i))) + continue; + if (comma) + ss << ','; + ss << ENUM_KEY_STR(unit_labor, disputed_labors[i]); + comma = true; + } + if (!comma) + ss << "NONE"; + + TRACE(labor_probe).print( + "unit {} ({}) job {} {}: mapped={} enabled={{{}}}\n", + u->id, Units::getReadableName(u), j->id, + ENUM_KEY_STR(job_type, j->job_type), + ENUM_KEY_STR(unit_labor, mapped), ss.str()); +} + +} diff --git a/plugins/autolabor/laborcommon.h b/plugins/autolabor/laborcommon.h new file mode 100644 index 0000000000..56a823c538 --- /dev/null +++ b/plugins/autolabor/laborcommon.h @@ -0,0 +1,124 @@ +#pragma once + +#include "laborstatemap.h" + +#include +#include + +#include "modules/Persistence.h" + +#include +#include +#include + +using namespace DFHack; +using namespace df::enums; + +// Shared infrastructure for the autolabor plugin's two engines. + +namespace autolabor { + +// --------------------------------------------------------------------------- +// plugin-global persisted config ("autolabor/config" site data) +// ival(0): flags ival(1): mode ival(2): balance stop (0-4) +// ival(3): idle reserve pct +// --------------------------------------------------------------------------- + +extern PersistentDataItem plugin_config; +void load_plugin_config(); + +enum ConfigFlags { + CF_ENABLED = 1, + CF_ALLOW_FISHING = 2, + CF_ALLOW_HUNTING = 4, +}; + +enum EngineMode { + MODE_LEGACY = 0, // classic autolabor: writes unit->status.labors directly + MODE_MODERN = 1, // labormanager: works through the work detail system + MODE_MONITOR = 2, // starvation warnings only; no labor management +}; + +bool config_flag(unsigned flag); +void set_config_flag(unsigned flag, bool on); + +int engine_mode(); +void store_engine_mode(int mode); + +int balance(); // 0..4 slider stop; 0 = max staffing +void store_balance(int v); +int idle_reserve(); // percent idle time reserved for guilded specialists +void store_idle_reserve(int v); + +const int NUM_BALANCE_STOPS = 5; +// CLI/overlay label for a balance stop; NULL if out of range +const char *balance_stop_name(int i); + +// --------------------------------------------------------------------------- + +const int NUM_LABORS = ENUM_LAST_ITEM(unit_labor) + 1; + +// labor_to_skill[l] is the job_skill trained by labor l, or job_skill::NONE +// for labors that require no skill. +extern df::job_skill labor_to_skill[]; +void init_labor_to_skill(); + +inline bool is_unskilled(df::unit_labor l) { + return l >= 0 && l < NUM_LABORS && labor_to_skill[l] == job_skill::NONE; +} + +// does the labor require a tool (and thus churn equipment when toggled)? +bool is_exclusive_labor(df::unit_labor l); + +struct BuildingScan { + bool has_butchers = false; + bool has_fishery = false; + bool trader_requested = false; +}; +BuildingScan scan_buildings(); + +struct PositionInfo { + int noble_penalty = 0; + bool medical = false; + bool trader = false; +}; +// noble status, medical/trade responsibilities from entity positions +PositionInfo unit_position_info(df::unit *u); + +// is the unit currently involved in a diplomatic/noble meeting? +bool in_diplomacy_meeting(df::unit *u); + +// --------------------------------------------------------------------------- +// labor coverage self-checks +// --------------------------------------------------------------------------- + +// Appends names of unit_labor enum items that the engine's static labor +// data doesn't cover (e.g. a labor added by a new DF release that has no +// entry in a fixed-size table). Used by the autolabor_checkLaborCoverage +// lua function; an empty result means every labor is handled. +void legacy_labor_coverage(std::vector &missing); +void modern_labor_coverage(std::vector &missing); + +// can the unit take labors at all (own civ/group, active, not visitor/ghost)? +bool is_assignable(df::unit *u); + +// classify what the unit is currently doing +dwarf_state get_dwarf_state(df::unit *u); + +// highest rating in a Normal or Medical skill; also reports the skill id +int top_skill(df::unit *u, df::job_skill *skill_out = nullptr); +int total_skill(df::unit *u); + +// labors whose v50 job->labor mappings were re-derived rather than verified +// against the game (the stoneworker split); the labor probe and the modern +// engine's mismatch escalation both work from this list +extern const std::vector disputed_labors; + +// labor-probe instrumentation: if the unit is doing a job that maps to a +// labor whose job->labor mapping is disputed, log which of the disputed +// labors are enabled on the unit (via the "labor_probe" debug channel). +// the true gating labor must be enabled on the worker, so comparing +// observations across units identifies the labor the game actually uses. +void probe_labor_observation(df::unit *u); + +} diff --git a/plugins/autolabor/labormanager.cpp b/plugins/autolabor/labormanager.cpp deleted file mode 100644 index f72be79913..0000000000 --- a/plugins/autolabor/labormanager.cpp +++ /dev/null @@ -1,2145 +0,0 @@ -/* -* Labor manager (formerly Autolabor 2) module for dfhack -* -* */ - - -#include -#include -#include - -#include -#include -#include -#include -#include - -#include "modules/Units.h" -#include "modules/World.h" -#include "modules/Maps.h" -#include "modules/MapCache.h" -#include "modules/Items.h" - -// DF data structure definition headers -#include "DataDefs.h" -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "labormanager.h" -#include "joblabormapper.h" - -#include "laborstatemap.h" - -using namespace std; -using std::string; -using std::endl; -using namespace DFHack; -using namespace df::enums; -using df::global::plotinfo; -using df::global::world; - -#define ARRAY_COUNT(array) (sizeof(array)/sizeof((array)[0])) - -DFHACK_PLUGIN_IS_ENABLED(enable_labormanager); - -static bool print_debug = 0; -static bool pause_on_error = 1; - -static std::vector state_count(5); - -static PersistentDataItem config; - -enum ConfigFlags { - CF_ENABLED = 1, - CF_ALLOW_FISHING = 2, - CF_ALLOW_HUNTING = 4, -}; - -// Value of 0 for max dwarfs means uncapped. -const int MAX_DWARFS_NONE = 0; -// Value < 0 for max dwarfs means don't manager the labor. -const int MAX_DWARFS_UNMANAGED = -1; - - -// Here go all the command declarations... -// mostly to allow having the mandatory stuff on top of the file and commands on the bottom -command_result labormanager(color_ostream &out, std::vector & parameters); - -// A plugin must be able to return its name and version. -// The name string provided must correspond to the filename - labormanager.plug.so or labormanager.plug.dll in this case -DFHACK_PLUGIN("labormanager"); - -static void generate_labor_to_skill_map(); - -struct labor_info -{ - PersistentDataItem config; - - int active_dwarfs; - int idle_dwarfs; - int busy_dwarfs; - - int priority() const { return config.ival(1); } - void set_priority(int priority) { config.ival(1) = priority; } - - bool is_unmanaged() const { return maximum_dwarfs() == MAX_DWARFS_UNMANAGED; } - int maximum_dwarfs() const { return config.ival(2); } - void set_maximum_dwarfs(int maximum_dwarfs) { config.ival(2) = maximum_dwarfs; } - - int time_since_last_assigned() const - { - return (*df::global::cur_year - config.ival(3)) * 403200 + *df::global::cur_year_tick - config.ival(4); - } - - void mark_assigned() { - config.ival(3) = (*df::global::cur_year); - config.ival(4) = (*df::global::cur_year_tick); - } - -}; - -enum tools_enum { - TOOL_NONE, TOOL_PICK, TOOL_AXE, TOOL_CROSSBOW, - TOOLS_MAX -}; - -struct labor_default -{ - int priority; - int maximum_dwarfs; - tools_enum tool; -}; - -static std::vector labor_infos; - -static const struct labor_default default_labor_infos[] = { - /* MINE */ {100, 0, TOOL_PICK}, - /* HAUL_STONE */ {100, 0, TOOL_NONE}, - /* HAUL_WOOD */ {100, 0, TOOL_NONE}, - /* HAUL_BODY */ {1000, 0, TOOL_NONE}, - /* HAUL_FOOD */ {500, 0, TOOL_NONE}, - /* HAUL_REFUSE */ {200, 0, TOOL_NONE}, - /* HAUL_ITEM */ {100, 0, TOOL_NONE}, - /* HAUL_FURNITURE */ {100, 0, TOOL_NONE}, - /* HAUL_ANIMAL */ {100, 0, TOOL_NONE}, - /* CLEAN */ {100, 0, TOOL_NONE}, - /* CUTWOOD */ {100, 0, TOOL_AXE}, - /* CARPENTER */ {100, 0, TOOL_NONE}, - /* DETAIL */ {100, 0, TOOL_NONE}, - /* MASON */ {100, 0, TOOL_NONE}, - /* ARCHITECT */ {100, 0, TOOL_NONE}, - /* ANIMALTRAIN */ {100, 0, TOOL_NONE}, - /* ANIMALCARE */ {100, 0, TOOL_NONE}, - /* DIAGNOSE */ {1000, 0, TOOL_NONE}, - /* SURGERY */ {1000, 0, TOOL_NONE}, - /* BONE_SETTING */ {1000, 0, TOOL_NONE}, - /* SUTURING */ {1000, 0, TOOL_NONE}, - /* DRESSING_WOUNDS */ {1000, 0, TOOL_NONE}, - /* FEED_WATER_CIVILIANS */ {1000, 0, TOOL_NONE}, - /* RECOVER_WOUNDED */ {200, 0, TOOL_NONE}, - /* BUTCHER */ {500, 0, TOOL_NONE}, - /* TRAPPER */ {100, 0, TOOL_NONE}, - /* DISSECT_VERMIN */ {100, 0, TOOL_NONE}, - /* LEATHER */ {100, 0, TOOL_NONE}, - /* TANNER */ {100, 0, TOOL_NONE}, - /* BREWER */ {100, 0, TOOL_NONE}, - /* ALCHEMIST */ {100, 0, TOOL_NONE}, - /* SOAP_MAKER */ {100, 0, TOOL_NONE}, - /* WEAVER */ {100, 0, TOOL_NONE}, - /* CLOTHESMAKER */ {100, 0, TOOL_NONE}, - /* MILLER */ {100, 0, TOOL_NONE}, - /* PROCESS_PLANT */ {100, 0, TOOL_NONE}, - /* MAKE_CHEESE */ {100, 0, TOOL_NONE}, - /* MILK */ {100, 0, TOOL_NONE}, - /* COOK */ {100, 0, TOOL_NONE}, - /* PLANT */ {100, 0, TOOL_NONE}, - /* HERBALIST */ {100, 0, TOOL_NONE}, - /* FISH */ {100, 0, TOOL_NONE}, - /* CLEAN_FISH */ {100, 0, TOOL_NONE}, - /* DISSECT_FISH */ {100, 0, TOOL_NONE}, - /* HUNT */ {100, 0, TOOL_CROSSBOW}, - /* SMELT */ {100, 0, TOOL_NONE}, - /* FORGE_WEAPON */ {100, 0, TOOL_NONE}, - /* FORGE_ARMOR */ {100, 0, TOOL_NONE}, - /* FORGE_FURNITURE */ {100, 0, TOOL_NONE}, - /* METAL_CRAFT */ {100, 0, TOOL_NONE}, - /* CUT_GEM */ {100, 0, TOOL_NONE}, - /* ENCRUST_GEM */ {100, 0, TOOL_NONE}, - /* WOOD_CRAFT */ {100, 0, TOOL_NONE}, - /* STONE_CRAFT */ {100, 0, TOOL_NONE}, - /* BONE_CARVE */ {100, 0, TOOL_NONE}, - /* GLASSMAKER */ {100, 0, TOOL_NONE}, - /* EXTRACT_STRAND */ {100, 0, TOOL_NONE}, - /* SIEGECRAFT */ {100, 0, TOOL_NONE}, - /* SIEGEOPERATE */ {100, 0, TOOL_NONE}, - /* BOWYER */ {100, 0, TOOL_NONE}, - /* MECHANIC */ {100, 0, TOOL_NONE}, - /* POTASH_MAKING */ {100, 0, TOOL_NONE}, - /* LYE_MAKING */ {100, 0, TOOL_NONE}, - /* DYER */ {100, 0, TOOL_NONE}, - /* BURN_WOOD */ {100, 0, TOOL_NONE}, - /* OPERATE_PUMP */ {100, 0, TOOL_NONE}, - /* SHEARER */ {100, 0, TOOL_NONE}, - /* SPINNER */ {100, 0, TOOL_NONE}, - /* POTTERY */ {100, 0, TOOL_NONE}, - /* GLAZING */ {100, 0, TOOL_NONE}, - /* PRESSING */ {100, 0, TOOL_NONE}, - /* BEEKEEPING */ {100, 0, TOOL_NONE}, - /* WAX_WORKING */ {100, 0, TOOL_NONE}, - /* PUSH_HAUL_VEHICLES */ {100, 0, TOOL_NONE}, - /* HAUL_TRADE */ {1000, 0, TOOL_NONE}, - /* PULL_LEVER */ {1000, 0, TOOL_NONE}, - /* UNUSED_13 */ {0, 0, TOOL_NONE}, - /* HAUL_WATER */ {100, 0, TOOL_NONE}, - /* GELD */ {100, 0, TOOL_NONE}, - /* BUILD_ROAD */ {100, 0, TOOL_NONE}, - /* BUILD_CONSTRUCTION */ {100, 0, TOOL_NONE}, - /* PAPERMAKING */ {100, 0, TOOL_NONE}, - /* BOOKBINDING */ {100, 0, TOOL_NONE} -}; - - - -struct dwarf_info_t -{ - df::unit* dwarf; - dwarf_state state; - - bool clear_all; - - bool has_tool[TOOLS_MAX]; - - int high_skill; - - bool has_children; - bool armed; - - int unmanaged_labors_assigned; - - df::unit_labor using_labor; - - dwarf_info_t(df::unit* dw) : dwarf(dw), state(OTHER), - clear_all(false), high_skill(0), has_children(false), armed(false), - unmanaged_labors_assigned(0), using_labor(df::unit_labor::NONE) - { - for (int e = TOOL_NONE; e < TOOLS_MAX; e++) - has_tool[e] = false; - } - - ~dwarf_info_t() - { - } - -}; - -color_ostream* debug_stream; - -void debug(const char* fmt, ...) -{ - if (debug_stream) - { - va_list args; - va_start(args, fmt); - debug_stream->vprint(fmt, args); - va_end(args); - } -} - -void debug_pause() -{ - if (pause_on_error) - { - debug("LABORMANAGER: Game paused so you can investigate the above message.\nUse 'labormanager pause-on-error no' to disable autopausing.\n"); - *df::global::pause_state = true; - } -} - -static JobLaborMapper* labor_mapper = 0; - -static bool initialized = false; - -static bool isOptionEnabled(unsigned flag) -{ - return config.isValid() && (config.ival(0) & flag) != 0; -} - -static void setOptionEnabled(ConfigFlags flag, bool on) -{ - if (!config.isValid()) - return; - - if (on) - config.ival(0) |= flag; - else - config.ival(0) &= ~flag; -} - -static void cleanup_state() -{ - enable_labormanager = false; - labor_infos.clear(); - initialized = false; -} - -static void reset_labor(df::unit_labor labor) -{ - labor_infos[labor].set_priority(default_labor_infos[labor].priority); - labor_infos[labor].set_maximum_dwarfs(default_labor_infos[labor].maximum_dwarfs); -} - -static void init_state() -{ - config = World::GetPersistentData("labormanager/2.0/config"); - if (config.isValid() && config.ival(0) == -1) - config.ival(0) = 0; - - enable_labormanager = isOptionEnabled(CF_ENABLED); - - if (!enable_labormanager) - return; - - // Load labors from save - labor_infos.resize(ARRAY_COUNT(default_labor_infos)); - - std::vector items; - World::GetPersistentData(&items, "labormanager/2.0/labors/", true); - - for (auto p = items.begin(); p != items.end(); p++) - { - string key = p->key(); - df::unit_labor labor = (df::unit_labor) atoi(key.substr(strlen("labormanager/2.0/labors/")).c_str()); - if (labor >= 0 && size_t(labor) < labor_infos.size()) - { - labor_infos[labor].config = *p; - labor_infos[labor].active_dwarfs = 0; - } - } - - // Add default labors for those not in save - for (size_t i = 0; i < ARRAY_COUNT(default_labor_infos); i++) { - if (labor_infos[i].config.isValid()) - continue; - - std::stringstream name; - name << "labormanager/2.0/labors/" << i; - - labor_infos[i].config = World::AddPersistentData(name.str()); - labor_infos[i].mark_assigned(); - labor_infos[i].active_dwarfs = 0; - reset_labor((df::unit_labor) i); - } - - initialized = true; - -} - -static df::job_skill labor_to_skill[ENUM_LAST_ITEM(unit_labor) + 1]; - -static void generate_labor_to_skill_map() -{ - // Generate labor -> skill mapping - - for (int i = 0; i <= ENUM_LAST_ITEM(unit_labor); i++) - labor_to_skill[i] = job_skill::NONE; - - FOR_ENUM_ITEMS(job_skill, skill) - { - int labor = ENUM_ATTR(job_skill, labor, skill); - if (labor != unit_labor::NONE) - { - /* - assert(labor >= 0); - assert(labor < ARRAY_COUNT(labor_to_skill)); - */ - - labor_to_skill[labor] = skill; - } - } -} - -struct skill_attr_weight { - int phys_attr_weights[6]; - int mental_attr_weights[13]; -}; - -static struct skill_attr_weight skill_attr_weights[ENUM_LAST_ITEM(job_skill) + 1] = -{ - // S A T E R D AA F W C I P M LA SS M KS E SA - { { 1, 0, 1, 1, 0, 0 }, { 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* MINING */, - { { 1, 1, 0, 1, 0, 0 }, { 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* WOODCUTTING */, - { { 1, 1, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* CARPENTRY */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* ENGRAVE_STONE */, - { { 1, 1, 0, 1, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* MASONRY */, - { { 0, 1, 1, 1, 0, 0 }, { 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 1, 0 } } /* ANIMALTRAIN */, - { { 0, 1, 0, 0, 0, 0 }, { 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0 } } /* ANIMALCARE */, - { { 0, 1, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* DISSECT_FISH */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* DISSECT_VERMIN */, - { { 0, 1, 0, 1, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* PROCESSFISH */, - { { 0, 1, 0, 1, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* BUTCHER */, - { { 0, 1, 0, 0, 0, 0 }, { 1, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* TRAPPING */, - { { 0, 1, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* TANNER */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* WEAVING */, - { { 1, 1, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* BREWING */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* ALCHEMY */, - { { 0, 1, 0, 0, 0, 0 }, { 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* CLOTHESMAKING */, - { { 1, 1, 0, 1, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* MILLING */, - { { 1, 1, 0, 1, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* PROCESSPLANTS */, - { { 1, 1, 0, 1, 0, 0 }, { 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* CHEESEMAKING */, - { { 1, 1, 0, 1, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* MILK */, - { { 0, 1, 0, 0, 0, 0 }, { 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* COOK */, - { { 1, 1, 0, 1, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* PLANT */, - { { 0, 1, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 } } /* HERBALISM */, - { { 1, 1, 0, 0, 0, 0 }, { 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 } } /* FISH */, - { { 1, 0, 1, 1, 0, 0 }, { 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* SMELT */, - { { 1, 1, 0, 1, 0, 0 }, { 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* EXTRACT_STRAND */, - { { 1, 1, 0, 1, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* FORGE_WEAPON */, - { { 1, 1, 0, 1, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* FORGE_ARMOR */, - { { 1, 1, 0, 1, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* FORGE_FURNITURE */, - { { 0, 1, 0, 0, 0, 0 }, { 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* CUTGEM */, - { { 0, 1, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* ENCRUSTGEM */, - { { 0, 1, 0, 0, 0, 0 }, { 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* WOODCRAFT */, - { { 0, 1, 0, 0, 0, 0 }, { 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* STONECRAFT */, - { { 1, 1, 0, 1, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* METALCRAFT */, - { { 1, 1, 0, 1, 0, 0 }, { 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* GLASSMAKER */, - { { 1, 1, 0, 1, 0, 0 }, { 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* LEATHERWORK */, - { { 0, 1, 0, 0, 0, 0 }, { 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* BONECARVE */, - { { 1, 1, 1, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* AXE */, - { { 1, 1, 1, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* SWORD */, - { { 1, 1, 1, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* DAGGER */, - { { 1, 1, 1, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* MACE */, - { { 1, 1, 1, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* HAMMER */, - { { 1, 1, 1, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* SPEAR */, - { { 1, 1, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* CROSSBOW */, - { { 1, 1, 1, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* SHIELD */, - { { 1, 0, 1, 1, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* ARMOR */, - { { 1, 1, 0, 1, 0, 0 }, { 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* SIEGECRAFT */, - { { 1, 0, 1, 1, 0, 0 }, { 1, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* SIEGEOPERATE */, - { { 0, 1, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* BOWYER */, - { { 1, 1, 1, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* PIKE */, - { { 1, 1, 1, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* WHIP */, - { { 1, 1, 0, 0, 0, 0 }, { 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* BOW */, - { { 1, 1, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* BLOWGUN */, - { { 1, 1, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* THROW */, - { { 1, 1, 0, 1, 0, 0 }, { 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* MECHANICS */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* MAGIC_NATURE */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* SNEAK */, - { { 0, 0, 0, 0, 0, 0 }, { 1, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* DESIGNBUILDING */, - { { 0, 1, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0 } } /* DRESS_WOUNDS */, - { { 0, 0, 0, 0, 0, 0 }, { 1, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0 } } /* DIAGNOSE */, - { { 0, 1, 0, 0, 0, 0 }, { 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* SURGERY */, - { { 1, 1, 0, 0, 0, 0 }, { 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* SET_BONE */, - { { 0, 1, 0, 0, 0, 0 }, { 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* SUTURE */, - { { 0, 1, 0, 1, 0, 0 }, { 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* CRUTCH_WALK */, - { { 1, 0, 1, 1, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* WOOD_BURNING */, - { { 1, 0, 1, 1, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* LYE_MAKING */, - { { 1, 0, 1, 1, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* SOAP_MAKING */, - { { 1, 0, 1, 1, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* POTASH_MAKING */, - { { 1, 1, 0, 1, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* DYER */, - { { 1, 0, 1, 1, 0, 0 }, { 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* OPERATE_PUMP */, - { { 1, 1, 0, 1, 0, 0 }, { 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* SWIMMING */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 1 } } /* PERSUASION */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 1 } } /* NEGOTIATION */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 1 } } /* JUDGING_INTENT */, - { { 0, 0, 0, 0, 0, 0 }, { 1, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0 } } /* APPRAISAL */, - { { 0, 0, 0, 0, 0, 0 }, { 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1 } } /* ORGANIZATION */, - { { 0, 0, 0, 0, 0, 0 }, { 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 } } /* RECORD_KEEPING */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 1 } } /* LYING */, - { { 0, 1, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 } } /* INTIMIDATION */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 1 } } /* CONVERSATION */, - { { 0, 1, 0, 0, 0, 0 }, { 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 } } /* COMEDY */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 1 } } /* FLATTERY */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0 } } /* CONSOLE */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 1 } } /* PACIFY */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* TRACKING */, - { { 0, 1, 0, 0, 0, 0 }, { 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 } } /* KNOWLEDGE_ACQUISITION */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 1, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 } } /* CONCENTRATION */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* DISCIPLINE */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0 } } /* SITUATIONAL_AWARENESS */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* WRITING */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* PROSE */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* POETRY */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* READING */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* SPEAKING */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* COORDINATION */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* BALANCE */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 1 } } /* LEADERSHIP */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 1 } } /* TEACHING */, - { { 1, 1, 1, 0, 0, 0 }, { 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* MELEE_COMBAT */, - { { 1, 1, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* RANGED_COMBAT */, - { { 1, 1, 1, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* WRESTLING */, - { { 1, 0, 1, 1, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* BITE */, - { { 1, 1, 1, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* GRASP_STRIKE */, - { { 1, 1, 1, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* STANCE_STRIKE */, - { { 0, 1, 0, 1, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* DODGING */, - { { 1, 1, 1, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* MISC_WEAPON */, - { { 0, 0, 0, 0, 0, 0 }, { 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* KNAPPING */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* MILITARY_TACTICS */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* SHEARING */, - { { 1, 1, 0, 1, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* SPINNING */, - { { 0, 1, 0, 0, 0, 0 }, { 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* POTTERY */, - { { 0, 1, 0, 0, 0, 0 }, { 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* GLAZING */, - { { 1, 1, 0, 1, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* PRESSING */, - { { 1, 1, 0, 1, 0, 0 }, { 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* BEEKEEPING */, - { { 0, 1, 0, 0, 0, 0 }, { 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0 } } /* WAX_WORKING */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* CLIMBING */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* GELD */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* DANCE */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* MAKE_MUSIC */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* SING_MUSIC */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* PLAY_KEYBOARD_INSTRUMENT */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* PLAY_STRINGED_INSTRUMENT */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* PLAY_WIND_INSTRUMENT */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* PLAY_PERCUSSION_INSTRUMENT */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* CRITICAL_THINKING */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* LOGIC */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* MATHEMATICS */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* ASTRONOMY */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* CHEMISTRY */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* GEOGRAPHY */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* OPTICS_ENGINEER */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* FLUID_ENGINEER */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* PAPERMAKING */, - { { 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } } /* BOOKBINDING */ -}; - -static void enable_plugin(color_ostream &out) -{ - if (!config.isValid()) - { - config = World::AddPersistentData("labormanager/2.0/config"); - config.ival(0) = 0; - } - - setOptionEnabled(CF_ENABLED, true); - enable_labormanager = true; - out << "Enabling the plugin." << endl; - - cleanup_state(); - init_state(); -} - -DFhackCExport command_result plugin_init(color_ostream &out, std::vector &commands) -{ - // initialize labor infos table from default table - if (ARRAY_COUNT(default_labor_infos) != ENUM_LAST_ITEM(unit_labor) + 1) - return CR_FAILURE; - - // Fill the command list with your commands. - commands.push_back(PluginCommand( - "labormanager", - "Automatically manage dwarf labors.", - labormanager)); - - generate_labor_to_skill_map(); - - labor_mapper = new JobLaborMapper(); - - init_state(); - - return CR_OK; -} - -DFhackCExport command_result plugin_shutdown(color_ostream &out) -{ - cleanup_state(); - - delete labor_mapper; - - return CR_OK; -} - -class AutoLaborManager { - color_ostream& out; - -public: - AutoLaborManager(color_ostream& o) : out(o) - { - dwarf_info.clear(); - } - - ~AutoLaborManager() - { - for (auto d = dwarf_info.begin(); d != dwarf_info.end(); d++) - { - delete (*d); - } - } - - dwarf_info_t* add_dwarf(df::unit* u) - { - dwarf_info_t* dwarf = new dwarf_info_t(u); - dwarf_info.push_back(dwarf); - return dwarf; - } - - -private: - bool has_butchers; - bool has_fishery; - bool trader_requested; - - int dig_count; - int tree_count; - int plant_count; - int detail_count; - - bool labors_changed; - - int tool_count[TOOLS_MAX]; - int tool_in_use[TOOLS_MAX]; - - int cnt_recover_wounded; - int cnt_diagnosis; - int cnt_immobilize; - int cnt_dressing; - int cnt_cleaning; - int cnt_surgery; - int cnt_suture; - int cnt_setting; - int cnt_traction; - int cnt_crutch; - - int need_food_water; - - int priority_food; - - std::map labor_needed; - std::map labor_in_use; - std::map labor_outside; - std::vector dwarf_info; - std::list available_dwarfs; - std::list busy_dwarfs; - -private: - void set_labor(dwarf_info_t* dwarf, df::unit_labor labor, bool value) - { - if (labor >= 0 && labor <= ENUM_LAST_ITEM(unit_labor) && !labor_infos[labor].is_unmanaged()) - { - if (!Units::isValidLabor(dwarf->dwarf, labor)) - { - debug("WARN(labormanager): Attempted to %s dwarf %s with ineligible labor %s\n", - value ? "set" : "unset", - dwarf->dwarf->name.first_name.c_str(), - ENUM_KEY_STR(unit_labor, labor).c_str()); - return; - } - bool old = dwarf->dwarf->status.labors[labor]; - dwarf->dwarf->status.labors[labor] = value; - if (old != value) - { - labors_changed = true; - - tools_enum tool = default_labor_infos[labor].tool; - if (tool != TOOL_NONE) - tool_in_use[tool] += value ? 1 : -1; - } - } - } - - void process_job(df::job* j) - { - if (j->flags.bits.suspend || j->flags.bits.item_lost) - return; - - int worker = -1; - int bld = -1; - - for (auto ref : j->general_refs) - { - if (ref->getType() == df::general_ref_type::UNIT_WORKER) - worker = ((df::general_ref_unit_workerst *)ref)->unit_id; - if (ref->getType() == df::general_ref_type::BUILDING_HOLDER) - bld = ((df::general_ref_building_holderst *)ref)->building_id; - } - - if (bld != -1) - { - df::building* b = binsearch_in_vector(world->buildings.all, bld); - - // check if this job is the first nonsuspended job on this building; if not, ignore it - // (except for farms and trade depots) - - if (b->getType() != df::building_type::FarmPlot && - b->getType() != df::building_type::TradeDepot) - { - int fjid = -1; - for (size_t jn = 0; jn < b->jobs.size(); jn++) - { - if (b->jobs[jn]->flags.bits.suspend) - continue; - fjid = b->jobs[jn]->id; - break; - } - if (fjid != j->id) - return; - } - } - - df::unit_labor labor = labor_mapper->find_job_labor(j); - - if (labor != df::unit_labor::NONE && !labor_infos[labor].is_unmanaged()) - { - labor_needed[labor]++; - if (worker == -1) - { - if (j->pos.isValid()) - { - df::tile_designation* d = Maps::getTileDesignation(j->pos); - if (d->bits.outside) - labor_outside[labor] = true; - } - } - else { - labor_infos[labor].mark_assigned(); - labor_in_use[labor]++; - } - - } - } - - void scan_buildings() - { - has_butchers = false; - has_fishery = false; - trader_requested = false; - labors_changed = false; - - for (auto b = world->buildings.all.begin(); b != world->buildings.all.end(); b++) - { - df::building *build = *b; - auto type = build->getType(); - if (building_type::Workshop == type) - { - df::workshop_type subType = (df::workshop_type)build->getSubtype(); - if (workshop_type::Butchers == subType) - has_butchers = true; - if (workshop_type::Fishery == subType) - has_fishery = true; - } - else if (building_type::TradeDepot == type) - { - df::building_tradedepotst* depot = (df::building_tradedepotst*) build; - trader_requested = depot->trade_flags.bits.trader_requested; - - if (print_debug) - { - if (trader_requested) - out.print("Trade depot found and trader requested, trader will be excluded from all labors.\n"); - else - out.print("Trade depot found but trader is not requested.\n"); - } - - } - } - } - - void count_map_designations() - { - dig_count = 0; - tree_count = 0; - plant_count = 0; - detail_count = 0; - - for (size_t i = 0; i < world->map.map_blocks.size(); ++i) - { - df::map_block* bl = world->map.map_blocks[i]; - - if (!bl->flags.bits.designated) - continue; - - for (int x = 0; x < 16; x++) - for (int y = 0; y < 16; y++) - { - if (bl->designation[x][y].bits.hidden) - { - df::coord p = bl->map_pos; - if (! Maps::isTileVisible(p.x, p.y, p.z-1)) - continue; - } - - df::tile_dig_designation dig = bl->designation[x][y].bits.dig; - if (dig != df::enums::tile_dig_designation::No) - { - df::tiletype tt = bl->tiletype[x][y]; - df::tiletype_material ttm = ENUM_ATTR(tiletype, material, tt); - df::tiletype_shape tts = ENUM_ATTR(tiletype, shape, tt); - if (ttm == df::enums::tiletype_material::TREE) - tree_count++; - else if (tts == df::enums::tiletype_shape::SHRUB) - plant_count++; - else - dig_count++; - } - if (bl->designation[x][y].bits.smooth != 0) - detail_count++; - } - } - - if (print_debug) - out.print("Dig count = %d, Cut tree count = %d, gather plant count = %d, detail count = %d\n", dig_count, tree_count, plant_count, detail_count); - - } - - void count_tools() - { - for (int e = TOOL_NONE; e < TOOLS_MAX; e++) - { - tool_count[e] = 0; - tool_in_use[e] = 0; - } - - priority_food = 0; - - df::item_flags bad_flags; - bad_flags.whole = 0; - -#define F(x) bad_flags.bits.x = true; - F(dump); F(forbid); F(garbage_collect); - F(hostile); F(on_fire); F(rotten); F(trader); - F(in_building); F(construction); -#undef F - - for (auto item : world->items.other.IN_PLAY) { - if (item->flags.bits.dump && !labor_infos[df::unit_labor::HAUL_REFUSE].is_unmanaged()) - labor_needed[df::unit_labor::HAUL_REFUSE]++; - - if (item->flags.whole & bad_flags.whole) - continue; - - df::item_type t = item->getType(); - - if (item->materialRots() && t != df::item_type::CORPSEPIECE && t != df::item_type::CORPSE && item->getRotTimer() > 1) - priority_food++; - - if (!item->isWeapon()) - continue; - - df::itemdef_weaponst* weapondef = ((df::item_weaponst*)item)->subtype; - df::job_skill weaponsk = (df::job_skill) weapondef->skill_melee; - df::job_skill weaponsk2 = (df::job_skill) weapondef->skill_ranged; - if (weaponsk == df::job_skill::AXE) - tool_count[TOOL_AXE]++; - else if (weaponsk == df::job_skill::MINING) - tool_count[TOOL_PICK]++; - else if (weaponsk2 == df::job_skill::CROSSBOW) - tool_count[TOOL_CROSSBOW]++; - } - - } - - void collect_job_list() - { - for (df::job_list_link* jll = world->jobs.list.next; jll; jll = jll->next) - { - df::job* j = jll->item; - if (!j) - continue; - process_job(j); - } - - for (auto jp = world->jobs.postings.begin(); jp != world->jobs.postings.end(); jp++) - { - if ((*jp)->flags.bits.dead) - continue; - - process_job((*jp)->job); - } - - } - - void collect_dwarf_list() - { - state_count.clear(); - state_count.resize(NUM_STATE); - - for (auto u = world->units.active.begin(); u != world->units.active.end(); ++u) - { - df::unit* cre = *u; - - // following tests shamelessly stolen from Dwarf Manipulator plugin - - bool isAssignable = - (Units::isOwnCiv(cre)) && - (Units::isOwnGroup(cre)) && - (Units::isActive(cre)) && - (!cre->flags2.bits.visitor) && - (!cre->flags3.bits.ghostly) && - (ENUM_ATTR(profession, can_assign_labor, cre->profession)); - - if (isAssignable) - { - dwarf_info_t* dwarf = add_dwarf(cre); - - df::historical_figure* hf = df::historical_figure::find(dwarf->dwarf->hist_figure_id); - for (size_t i = 0; i < hf->entity_links.size(); i++) - { - df::histfig_entity_link* hfelink = hf->entity_links.at(i); - if (hfelink->getType() == df::histfig_entity_link_type::POSITION) - { - df::histfig_entity_link_positionst *epos = - (df::histfig_entity_link_positionst*) hfelink; - df::historical_entity* entity = df::historical_entity::find(epos->entity_id); - if (!entity) - continue; - df::entity_position_assignment* assignment = binsearch_in_vector(entity->positions.assignments, epos->assignment_id); - if (!assignment) - continue; - df::entity_position* position = binsearch_in_vector(entity->positions.own, assignment->position_id); - if (!position) - continue; - - if (position->responsibilities[df::entity_position_responsibility::TRADE]) - if (trader_requested) - dwarf->clear_all = true; - } - - } - - // identify dwarfs who are needed for meetings and mark them for exclusion - - for (size_t i = 0; i < plotinfo->activities.size(); ++i) - { - df::activity_info *act = plotinfo->activities[i]; - if (!act) continue; - - bool p1 = act->unit_actor == dwarf->dwarf; - bool p2 = act->unit_noble == dwarf->dwarf; - - if (p1 || p2) - { - df::unit* other = p1 ? act->unit_noble : act->unit_actor; - if (other && !(!Units::isActive(other) || - (other->job.current_job && - (other->job.current_job->job_type == df::job_type::Sleep || - other->job.current_job->job_type == df::job_type::Rest)) || - ENUM_ATTR(profession, military, other->profession))) { - dwarf->clear_all = true; - if (print_debug) - out.print("Dwarf \"%s\" has a meeting, will be cleared of all labors\n", dwarf->dwarf->name.first_name.c_str()); - break; - } - else - { - if (print_debug) - out.print("Dwarf \"%s\" has a meeting, but with someone who can't make the meeting.\n", dwarf->dwarf->name.first_name.c_str()); - } - } - } - - // check to see if dwarf has minor children - - for (auto u2 = world->units.active.begin(); u2 != world->units.active.end(); ++u2) - { - if ((*u2)->relationship_ids[df::unit_relationship_type::Mother] == dwarf->dwarf->id && - Units::isActive(*u2) && - ((*u2)->profession == df::profession::CHILD || (*u2)->profession == df::profession::BABY)) - { - dwarf->has_children = true; - if (print_debug) - out.print("Dwarf %s has minor children\n", dwarf->dwarf->name.first_name.c_str()); - break; - } - } - - // check if dwarf has an axe, pick, or crossbow - - for (size_t j = 0; j < dwarf->dwarf->inventory.size(); j++) - { - df::unit_inventory_item* plotinfo = dwarf->dwarf->inventory[j]; - if (plotinfo->mode == df::unit_inventory_item::Weapon && plotinfo->item->isWeapon()) - { - dwarf->armed = true; - df::itemdef_weaponst* weapondef = ((df::item_weaponst*)(plotinfo->item))->subtype; - df::job_skill weaponsk = (df::job_skill) weapondef->skill_melee; - df::job_skill rangesk = (df::job_skill) weapondef->skill_ranged; - if (weaponsk == df::job_skill::AXE) - { - dwarf->has_tool[TOOL_AXE] = true; - } - else if (weaponsk == df::job_skill::MINING) - { - dwarf->has_tool[TOOL_PICK] = true; - } - else if (rangesk == df::job_skill::CROSSBOW) - { - dwarf->has_tool[TOOL_CROSSBOW] = true; - } - } - } - - // Find the activity state for each dwarf - - bool is_migrant = false; - dwarf_state state = OTHER; - - for (auto p = dwarf->dwarf->status.misc_traits.begin(); p < dwarf->dwarf->status.misc_traits.end(); p++) - { - if ((*p)->id == misc_trait_type::Migrant) - is_migrant = true; - } - - if (dwarf->dwarf->social_activities.size() > 0) - { - if (print_debug) - out.print("Dwarf %s is engaged in a social activity. Info only.\n", dwarf->dwarf->name.first_name.c_str()); - } - - if (dwarf->dwarf->profession == profession::BABY || - dwarf->dwarf->profession == profession::CHILD || - dwarf->dwarf->profession == profession::DRUNK) - { - state = CHILD; - } - - else if (ENUM_ATTR(profession, military, dwarf->dwarf->profession)) - state = MILITARY; - - else if (dwarf->dwarf->burrows.size() > 0) - state = OTHER; // dwarfs assigned to burrows are treated as if permanently busy - - else if (dwarf->dwarf->job.current_job == NULL) - { - if (is_migrant || dwarf->dwarf->flags1.bits.chained || dwarf->dwarf->flags1.bits.caged) - { - state = OTHER; - dwarf->clear_all = true; - } - else if (dwarf->dwarf->status2.limbs_grasp_count == 0) - { - state = OTHER; // dwarfs unable to grasp are incapable of nearly all labors - dwarf->clear_all = true; - if (print_debug) - out.print("Dwarf %s is disabled, will not be assigned labors\n", dwarf->dwarf->name.first_name.c_str()); - } - else - { - state = IDLE; - } - } - else - { - df::job_type job = dwarf->dwarf->job.current_job->job_type; - if (job >= 0 && size_t(job) < ARRAY_COUNT(dwarf_states)) - state = dwarf_states[job]; - else - { - out.print("Dwarf \"%s\" has unknown job %i\n", dwarf->dwarf->name.first_name.c_str(), job); - debug_pause(); - state = OTHER; - } - if (state == BUSY) - { - df::unit_labor labor = labor_mapper->find_job_labor(dwarf->dwarf->job.current_job); - - dwarf->using_labor = labor; - - if (labor != df::unit_labor::NONE) - { - labor_infos[labor].busy_dwarfs++; - if (default_labor_infos[labor].tool != TOOL_NONE) - { - tool_in_use[default_labor_infos[labor].tool]++; - } - } - } - } - - dwarf->state = state; - - dwarf->unmanaged_labors_assigned = 0; - - FOR_ENUM_ITEMS(unit_labor, l) - { - if (l == df::unit_labor::NONE) - continue; - if (dwarf->dwarf->status.labors[l]) - if (state == IDLE) - labor_infos[l].idle_dwarfs++; - if (labor_infos[l].is_unmanaged()) - dwarf->unmanaged_labors_assigned++; - } - - - if (print_debug) - out.print("Dwarf \"%s\": state %s %d\n", dwarf->dwarf->name.first_name.c_str(), state_names[dwarf->state], dwarf->clear_all); - - state_count[dwarf->state]++; - - // determine if dwarf has medical needs - if (dwarf->dwarf->health && !( - // on-duty military will not necessarily break to get minor injuries attended - ENUM_ATTR(profession, military, dwarf->dwarf->profession) || - // babies cannot currently receive health care even if they need it - dwarf->dwarf->profession == profession::BABY) - ) - { - if (dwarf->dwarf->health->flags.bits.should_not_move) - cnt_recover_wounded++; - if (dwarf->dwarf->health->flags.bits.rq_diagnosis) - cnt_diagnosis++; - if (dwarf->dwarf->health->flags.bits.rq_immobilize) - cnt_immobilize++; - if (dwarf->dwarf->health->flags.bits.rq_dressing) - cnt_dressing++; - if (dwarf->dwarf->health->flags.bits.rq_cleaning) - cnt_cleaning++; - if (dwarf->dwarf->health->flags.bits.rq_surgery) - cnt_surgery++; - if (dwarf->dwarf->health->flags.bits.rq_suture) - cnt_suture++; - if (dwarf->dwarf->health->flags.bits.rq_setting) - cnt_setting++; - if (dwarf->dwarf->health->flags.bits.rq_traction) - cnt_traction++; - if (dwarf->dwarf->health->flags.bits.rq_crutch) - cnt_crutch++; - } - - if (dwarf->dwarf->counters2.hunger_timer > 60000 || dwarf->dwarf->counters2.thirst_timer > 40000) - need_food_water++; - - // find dwarf's highest effective skill - - int high_skill = 0; - - FOR_ENUM_ITEMS(unit_labor, labor) - { - if (labor == df::unit_labor::NONE || labor_infos[labor].is_unmanaged()) - continue; - - df::job_skill skill = labor_to_skill[labor]; - if (skill != df::job_skill::NONE) - { - int skill_level = Units::getNominalSkill(dwarf->dwarf, skill, false); - high_skill = std::max(high_skill, skill_level); - } - } - - dwarf->high_skill = high_skill; - - - // clear labors of dwarfs with clear_all set - - if (dwarf->clear_all) - { - FOR_ENUM_ITEMS(unit_labor, labor) - { - if (labor == unit_labor::NONE || labor_infos[labor].is_unmanaged()) - continue; - if (Units::isValidLabor(dwarf->dwarf, labor)) - set_labor(dwarf, labor, false); - } - } - else { - if (state == IDLE) - available_dwarfs.push_back(dwarf); - - if (state == BUSY) - busy_dwarfs.push_back(dwarf); - } - } - - } - } - - void release_dwarf_list() - { - while (!dwarf_info.empty()) { - auto d = dwarf_info.begin(); - delete *d; - dwarf_info.erase(d); - } - available_dwarfs.clear(); - busy_dwarfs.clear(); - } - - int score_labor(dwarf_info_t* d, df::unit_labor labor) - { - int skill_level = 0; - int xp = 0; - int attr_weight = 0; - - if (labor != df::unit_labor::NONE) - { - df::job_skill skill = labor_to_skill[labor]; - if (skill != df::job_skill::NONE) - { - skill_level = Units::getEffectiveSkill(d->dwarf, skill); - xp = Units::getExperience(d->dwarf, skill, false); - - for (int pa = 0; pa < 6; pa++) - attr_weight += (skill_attr_weights[skill].phys_attr_weights[pa]) * (d->dwarf->body.physical_attrs[pa].value - 1000); - - for (int ma = 0; ma < 13; ma++) - attr_weight += (skill_attr_weights[skill].mental_attr_weights[ma]) * (d->dwarf->status.current_soul->mental_attrs[ma].value - 1000); - } - - } - - int score = skill_level * 1000 - (d->high_skill - skill_level) * 2000 + (xp / (skill_level + 5) * 10) + attr_weight; - - if (labor != df::unit_labor::NONE) - { - if (d->dwarf->status.labors[labor]) - { - if (labor == df::unit_labor::OPERATE_PUMP) - score += 50000; - else - score += 25000; - } - if (default_labor_infos[labor].tool != TOOL_NONE && - d->has_tool[default_labor_infos[labor].tool]) - score += 10000000; - if (d->has_children && labor_outside[labor]) - score -= 15000; - if (d->armed && labor_outside[labor]) - score += 5000; - } - - // Favor/disfavor RECOVER_WOUNDED based on ALTRUISM personality facet - - if (labor == df::unit_labor::RECOVER_WOUNDED) - { - int altruism = d->dwarf->status.current_soul->personality.traits[df::personality_facet_type::ALTRUISM]; - if (altruism >= 61) - score += 5000; - else if (altruism <= 24) - score -= 50000; - } - - // Favor/disfavor BUTCHER (covers slaughtering), HAUL_ANIMALS (covers caging), and CUTWOOD based on NATURE value - - if (labor == df::unit_labor::BUTCHER || labor == df::unit_labor::HAUL_ANIMALS || labor == df::unit_labor::CUTWOOD) - { - int nature = 0; - for (auto i = d->dwarf->status.current_soul->personality.values.begin(); - i != d->dwarf->status.current_soul->personality.values.end(); - i++) - { - if ((*i)->type == df::value_type::NATURE) - nature = (*i)->strength; - } - - if (nature <= -11) - score += 5000; - else if (nature >= 26) - score -= 50000; - } - - // This should reweight assigning CUTWOOD jobs based on a citizen's ethic toward killing plants - - if (labor == df::unit_labor::CUTWOOD) - { - if (auto culture = df::cultural_identity::find(d->dwarf->cultural_identity)) - { - auto ethics = culture->ethic[df::ethic_type::KILL_PLANT]; - if (ethics != df::ethic_response::NOT_APPLICABLE && ethics != df::ethic_response::REQUIRED) - score += 10000 * (df::ethic_response::ACCEPTABLE - ethics); - } - } - - score -= Units::computeMovementSpeed(d->dwarf); - - // significantly disfavor dwarves who have unmanaged labors assigned - score -= 1000 * d->unmanaged_labors_assigned; - - return score; - } - -public: - void process() - { - if (*df::global::process_dig || *df::global::process_jobs) - return; - - release_dwarf_list(); - - dig_count = tree_count = plant_count = detail_count = 0; - cnt_recover_wounded = cnt_diagnosis = cnt_immobilize = cnt_dressing = cnt_cleaning = cnt_surgery = cnt_suture = - cnt_setting = cnt_traction = cnt_crutch = 0; - need_food_water = 0; - - labor_needed.clear(); - - for (int e = 0; e < TOOLS_MAX; e++) - tool_count[e] = 0; - - trader_requested = false; - - FOR_ENUM_ITEMS(unit_labor, l) - { - if (l == df::unit_labor::NONE) - continue; - - labor_infos[l].active_dwarfs = labor_infos[l].busy_dwarfs = labor_infos[l].idle_dwarfs = 0; - } - - // scan for specific buildings of interest - - scan_buildings(); - - // count number of squares designated for dig, wood cutting, detailing, and plant harvesting - - count_map_designations(); - - // collect current job list - - collect_job_list(); - - // count number of picks and axes available for use - - count_tools(); - - // collect list of dwarfs - - collect_dwarf_list(); - - // add job entries for designation-related jobs - - labor_needed[df::unit_labor::MINE] += dig_count; - labor_needed[df::unit_labor::CUTWOOD] += tree_count; - labor_needed[df::unit_labor::DETAIL] += detail_count; - labor_needed[df::unit_labor::HERBALIST] += plant_count; - - // add job entries for health care - - labor_needed[df::unit_labor::RECOVER_WOUNDED] += cnt_recover_wounded; - labor_needed[df::unit_labor::DIAGNOSE] += cnt_diagnosis; - labor_needed[df::unit_labor::BONE_SETTING] += cnt_immobilize; - labor_needed[df::unit_labor::DRESSING_WOUNDS] += cnt_dressing; - labor_needed[df::unit_labor::DRESSING_WOUNDS] += cnt_cleaning; - labor_needed[df::unit_labor::SURGERY] += cnt_surgery; - labor_needed[df::unit_labor::SUTURING] += cnt_suture; - labor_needed[df::unit_labor::BONE_SETTING] += cnt_setting; - labor_needed[df::unit_labor::BONE_SETTING] += cnt_traction; - labor_needed[df::unit_labor::BONE_SETTING] += cnt_crutch; - - labor_needed[df::unit_labor::FEED_WATER_CIVILIANS] += need_food_water; - - // add entries for hauling jobs - - labor_needed[df::unit_labor::HAUL_STONE] += world->stockpile.num_jobs[1]; - labor_needed[df::unit_labor::HAUL_WOOD] += world->stockpile.num_jobs[2]; - labor_needed[df::unit_labor::HAUL_ITEM] += world->stockpile.num_jobs[3]; - labor_needed[df::unit_labor::HAUL_ITEM] += world->stockpile.num_jobs[4]; - labor_needed[df::unit_labor::HAUL_BODY] += world->stockpile.num_jobs[5]; - labor_needed[df::unit_labor::HAUL_FOOD] += world->stockpile.num_jobs[6]; - labor_needed[df::unit_labor::HAUL_REFUSE] += world->stockpile.num_jobs[7]; - labor_needed[df::unit_labor::HAUL_FURNITURE] += world->stockpile.num_jobs[8]; - labor_needed[df::unit_labor::HAUL_ANIMALS] += world->stockpile.num_jobs[9]; - - labor_needed[df::unit_labor::HAUL_STONE] += (world->stockpile.num_jobs[1] >= world->stockpile.num_haulers[1]) ? 1 : 0; - labor_needed[df::unit_labor::HAUL_WOOD] += (world->stockpile.num_jobs[2] >= world->stockpile.num_haulers[2]) ? 1 : 0; - labor_needed[df::unit_labor::HAUL_ITEM] += (world->stockpile.num_jobs[3] >= world->stockpile.num_haulers[3]) ? 1 : 0; - labor_needed[df::unit_labor::HAUL_BODY] += (world->stockpile.num_jobs[5] >= world->stockpile.num_haulers[5]) ? 1 : 0; - labor_needed[df::unit_labor::HAUL_FOOD] += (world->stockpile.num_jobs[6] >= world->stockpile.num_haulers[6]) ? 1 : 0; - labor_needed[df::unit_labor::HAUL_REFUSE] += (world->stockpile.num_jobs[7] >= world->stockpile.num_haulers[7]) ? 1 : 0; - labor_needed[df::unit_labor::HAUL_FURNITURE] += (world->stockpile.num_jobs[8] >= world->stockpile.num_haulers[8]) ? 1 : 0; - labor_needed[df::unit_labor::HAUL_ANIMALS] += (world->stockpile.num_jobs[9] >= world->stockpile.num_haulers[9]) ? 1 : 0; - - int binjobs = world->stockpile.num_jobs[4] + ((world->stockpile.num_jobs[4] >= world->stockpile.num_haulers[4]) ? 1 : 0); - - labor_needed[df::unit_labor::HAUL_ITEM] += binjobs; - labor_needed[df::unit_labor::HAUL_FOOD] += priority_food; - - // add entries for vehicle hauling - - for (auto v = world->vehicles.all.begin(); v != world->vehicles.all.end(); v++) - if ((*v)->route_id != -1) - labor_needed[df::unit_labor::HANDLE_VEHICLES]++; - - // add fishing & hunting - - labor_needed[df::unit_labor::FISH] = - (isOptionEnabled(CF_ALLOW_FISHING) && has_fishery) ? 1 : 0; - - labor_needed[df::unit_labor::HUNT] = - (isOptionEnabled(CF_ALLOW_HUNTING) && has_butchers) ? 1 : 0; - - /* add animal trainers */ - for (auto a = df::global::plotinfo->training.training_assignments.begin(); - a != df::global::plotinfo->training.training_assignments.end(); - a++) - { - labor_needed[df::unit_labor::ANIMALTRAIN]++; - // note: this doesn't test to see if the trainer is actually needed, and thus will overallocate trainers. bleah. - } - - /* set requirements to zero for labors with currently idle dwarfs, and remove from requirement dwarfs actually working */ - - FOR_ENUM_ITEMS(unit_labor, l) { - if (l == df::unit_labor::NONE) - continue; - - if (!labor_infos[l].is_unmanaged()) - { - int before = labor_needed[l]; - - labor_needed[l] = max(0, labor_needed[l] - labor_in_use[l]); - - if (default_labor_infos[l].tool != TOOL_NONE) - labor_needed[l] = std::min(labor_needed[l], tool_count[default_labor_infos[l].tool] - tool_in_use[default_labor_infos[l].tool]); - - if (print_debug && before != labor_needed[l]) - out.print("labor %s reduced from %d to %d\n", ENUM_KEY_STR(unit_labor, l).c_str(), before, labor_needed[l]); - } - else - { - labor_needed[l] = 0; - } - } - - /* assign food haulers for rotting food items */ - if (!labor_infos[df::unit_labor::HAUL_FOOD].is_unmanaged()) - { - if (priority_food > 0 && labor_infos[df::unit_labor::HAUL_FOOD].idle_dwarfs > 0) - priority_food = 1; - - if (print_debug) - out.print("priority food count = %d\n", priority_food); - - while (!available_dwarfs.empty() && priority_food > 0) - { - std::list::iterator bestdwarf = available_dwarfs.begin(); - - int best_score = INT_MIN; - - for (std::list::iterator k = available_dwarfs.begin(); k != available_dwarfs.end(); k++) - { - dwarf_info_t* d = (*k); - - if (Units::isValidLabor(d->dwarf, df::unit_labor::HAUL_FOOD)) - { - int score = score_labor(d, df::unit_labor::HAUL_FOOD); - - if (score > best_score) - { - bestdwarf = k; - best_score = score; - } - } - } - - if (best_score > INT_MIN) - { - if (print_debug) - out.print("LABORMANAGER: assign \"%s\" labor %s score=%d (priority food)\n", (*bestdwarf)->dwarf->name.first_name.c_str(), ENUM_KEY_STR(unit_labor, df::unit_labor::HAUL_FOOD).c_str(), best_score); - - FOR_ENUM_ITEMS(unit_labor, l) - { - if (l == df::unit_labor::NONE) - continue; - if (Units::isValidLabor((*bestdwarf)->dwarf, l)) - set_labor(*bestdwarf, l, l == df::unit_labor::HAUL_FOOD); - } - - available_dwarfs.erase(bestdwarf); - priority_food--; - } - else - break; - - } - } - else - { - priority_food = 0; - } - - if (print_debug) - { - for (auto i = labor_needed.begin(); i != labor_needed.end(); i++) - { - out.print("labor_needed [%s] = %d, busy = %d, outside = %d, idle = %d\n", ENUM_KEY_STR(unit_labor, i->first).c_str(), i->second, - labor_infos[i->first].busy_dwarfs, labor_outside[i->first], labor_infos[i->first].idle_dwarfs); - } - } - - std::map base_priority; - priority_queue> pq; - priority_queue> pq2; - - for (auto i = labor_needed.begin(); i != labor_needed.end(); i++) - { - df::unit_labor l = i->first; - if (l == df::unit_labor::NONE || labor_infos[l].is_unmanaged()) - continue; - - const int user_specified_max_dwarfs = labor_infos[l].maximum_dwarfs(); - - if (user_specified_max_dwarfs != MAX_DWARFS_NONE && i->second > user_specified_max_dwarfs) - { - i->second = user_specified_max_dwarfs; - } - - int priority = labor_infos[l].priority(); - - priority += labor_infos[l].time_since_last_assigned() / 12; - priority -= labor_infos[l].busy_dwarfs; - - base_priority[l] = priority; - - if (i->second > 0) - { - pq.push(make_pair(priority, l)); - } - } - - if (print_debug) - out.print("available count = %zu, distinct labors needed = %zu\n", available_dwarfs.size(), pq.size()); - - std::map to_assign; - - to_assign.clear(); - - size_t av = available_dwarfs.size(); - - while (!pq.empty() && av > 0) - { - df::unit_labor labor = pq.top().second; - int priority = pq.top().first; - to_assign[labor]++; - pq.pop(); - av--; - - if (print_debug) - out.print("Will assign: %s priority %d (%d)\n", ENUM_KEY_STR(unit_labor, labor).c_str(), priority, to_assign[labor]); - - if (--labor_needed[labor] > 0) - { - priority -= 10; - pq2.push(make_pair(priority, labor)); - } - - if (pq.empty()) - while (!pq2.empty()) - { - pq.push(pq2.top()); - pq2.pop(); - } - } - - while (!pq2.empty()) - { - pq.push(pq2.top()); - pq2.pop(); - } - - int canary = (1 << df::unit_labor::HAUL_STONE) | - (1 << df::unit_labor::HAUL_WOOD) | - (1 << df::unit_labor::HAUL_BODY) | - (1 << df::unit_labor::HAUL_FOOD) | - (1 << df::unit_labor::HAUL_REFUSE) | - (1 << df::unit_labor::HAUL_ITEM) | - (1 << df::unit_labor::HAUL_FURNITURE) | - (1 << df::unit_labor::HAUL_ANIMALS); - - while (!available_dwarfs.empty()) - { - std::list::iterator bestdwarf = available_dwarfs.begin(); - - int best_score = INT_MIN; - df::unit_labor best_labor = df::unit_labor::NONE; - - for (auto j = to_assign.begin(); j != to_assign.end(); j++) - { - if (j->second <= 0) - continue; - - df::unit_labor labor = j->first; - - for (std::list::iterator k = available_dwarfs.begin(); k != available_dwarfs.end(); k++) - { - dwarf_info_t* d = (*k); - if (Units::isValidLabor(d->dwarf, labor)) - { - int score = score_labor(d, labor); - if (score > best_score) - { - bestdwarf = k; - best_score = score; - best_labor = labor; - } - } - } - } - - if (best_labor == df::unit_labor::NONE) - break; - - if (print_debug) - out.print("assign \"%s\" labor %s score=%d\n", (*bestdwarf)->dwarf->name.first_name.c_str(), ENUM_KEY_STR(unit_labor, best_labor).c_str(), best_score); - - FOR_ENUM_ITEMS(unit_labor, l) - { - if (l == df::unit_labor::NONE) - continue; - - tools_enum t = default_labor_infos[l].tool; - - if (l == best_labor && - Units::isValidLabor((*bestdwarf)->dwarf, l) && - (t == TOOL_NONE || tool_in_use[t] < tool_count[t])) - { - set_labor(*bestdwarf, l, true); - if (t != TOOL_NONE && !((*bestdwarf)->has_tool[t])) - { - df::job_type j; - j = df::job_type::NONE; - - if ((*bestdwarf)->dwarf->job.current_job) - j = (*bestdwarf)->dwarf->job.current_job->job_type; - - if (print_debug) - out.print("LABORMANAGER: asking %s to pick up tools, current job %s\n", (*bestdwarf)->dwarf->name.first_name.c_str(), ENUM_KEY_STR(job_type, j).c_str()); - - (*bestdwarf)->dwarf->uniform.pickup_flags.bits.update = true; - labors_changed = true; - } - } - else if (l == df::unit_labor::CLEAN && best_score < 0) - { - if (Units::isValidLabor((*bestdwarf)->dwarf, l)) - set_labor(*bestdwarf, l, true); - } - else if ((*bestdwarf)->state == IDLE) - { - if (Units::isValidLabor((*bestdwarf)->dwarf, l)) - set_labor(*bestdwarf, l, false); - } - } - - if (best_labor == df::unit_labor::HAUL_FOOD && priority_food > 0) - priority_food--; - - if (best_labor >= df::unit_labor::HAUL_STONE && best_labor <= df::unit_labor::HAUL_ANIMALS) - canary &= ~(1 << best_labor); - - if (best_labor != df::unit_labor::NONE) - { - labor_infos[best_labor].active_dwarfs++; - to_assign[best_labor]--; - } - - busy_dwarfs.push_back(*bestdwarf); - available_dwarfs.erase(bestdwarf); - } - - for (auto d = busy_dwarfs.begin(); d != busy_dwarfs.end(); d++) - { - int current_score = score_labor(*d, (*d)->using_labor); - - FOR_ENUM_ITEMS(unit_labor, l) - { - if (l == df::unit_labor::NONE || labor_infos[l].is_unmanaged()) - continue; - if (l == (*d)->using_labor) - continue; - if (labor_needed[l] <= 0) - continue; - if (!Units::isValidLabor((*d)->dwarf, l)) - continue; - - int score = score_labor(*d, l); - - if (l == df::unit_labor::HAUL_FOOD && priority_food > 0) - score += 1000000; - - if (score > current_score) - { - tools_enum t = default_labor_infos[l].tool; - if (t == TOOL_NONE || (*d)->has_tool[t]) - { - set_labor(*d, l, true); - } - - if (score < 0) - set_labor(*d, df::unit_labor::CLEAN, true); - - if ((*d)->using_labor != df::unit_labor::NONE && - (score > current_score + 5000 || base_priority[(*d)->using_labor] < base_priority[l]) && - default_labor_infos[(*d)->using_labor].tool == TOOL_NONE) - set_labor(*d, (*d)->using_labor, false); - } - } - } - - - dwarf_info_t* canary_dwarf = 0; - - for (auto di = busy_dwarfs.begin(); di != busy_dwarfs.end(); di++) - if (!(*di)->clear_all) - { - canary_dwarf = *di; - break; - } - - if (canary_dwarf) - { - - FOR_ENUM_ITEMS(unit_labor, l) - { - if (l >= df::unit_labor::HAUL_STONE && l <= df::unit_labor::HAUL_ANIMALS && - canary & (1 << l) && - Units::isValidLabor(canary_dwarf->dwarf, l)) - set_labor(canary_dwarf, l, true); - } - - set_labor(canary_dwarf, df::unit_labor::CLEAN, true); - - /* Set HAUL_WATER so we can detect ponds that need to be filled ponds. */ - - if (!labor_infos[df::unit_labor::HAUL_WATER].is_unmanaged()) - { - set_labor(canary_dwarf, df::unit_labor::HAUL_WATER, true); - } - - if (print_debug) - out.print("Setting %s as the hauling canary\n", canary_dwarf->dwarf->name.first_name.c_str()); - } - else - { - if (print_debug) - out.print("No dwarf available to set as the hauling canary!\n"); - } - - /* Assign any leftover dwarfs to "standard" labors */ - - if (print_debug) - out.print("After assignment, %zu dwarfs left over\n", available_dwarfs.size()); - - for (auto d = available_dwarfs.begin(); d != available_dwarfs.end(); d++) - { - FOR_ENUM_ITEMS(unit_labor, l) - { - if (l == df::unit_labor::NONE || labor_infos[l].is_unmanaged()) - continue; - - if (Units::isValidLabor((*d)->dwarf, l)) - set_labor(*d, l, - (l >= df::unit_labor::HAUL_STONE && l <= df::unit_labor::HAUL_ANIMALS) || - l == df::unit_labor::CLEAN || - l == df::unit_labor::HAUL_WATER || - l == df::unit_labor::PULL_LEVER || - l == df::unit_labor::HAUL_TRADE); - } - } - - /* check for dwarfs assigned no labors and assign them the bucket list if there are */ - for (auto d = dwarf_info.begin(); d != dwarf_info.end(); d++) - { - if ((*d)->state == CHILD) - continue; - - bool any = false; - FOR_ENUM_ITEMS(unit_labor, l) - { - if (l == df::unit_labor::NONE) - continue; - if ((*d)->dwarf->status.labors[l]) - { - any = true; - break; - } - } - - if (!labor_infos[df::unit_labor::PULL_LEVER].is_unmanaged()) - { - set_labor(*d, df::unit_labor::PULL_LEVER, true); - } - - if (any) continue; - - FOR_ENUM_ITEMS(unit_labor, l) - { - if (l == df::unit_labor::NONE || labor_infos[l].is_unmanaged()) - continue; - - if (to_assign[l] > 0 || l == df::unit_labor::CLEAN) - set_labor(*d, l, true); - } - } - - - /* set reequip on any dwarfs who are carrying tools needed by others */ - - for (auto d = dwarf_info.begin(); d != dwarf_info.end(); d++) - { - if ((*d)->dwarf->job.current_job && (*d)->dwarf->job.current_job->job_type == df::job_type::PickupEquipment) - continue; - - if ((*d)->dwarf->uniform.pickup_flags.bits.update) - continue; - - FOR_ENUM_ITEMS(unit_labor, l) - { - if (l == df::unit_labor::NONE || labor_infos[l].is_unmanaged()) - continue; - - tools_enum t = default_labor_infos[l].tool; - if (t == TOOL_NONE) - continue; - - bool has_tool = (*d)->has_tool[t]; - bool needs_tool = (*d)->dwarf->status.labors[l]; - - if ((needs_tool && !has_tool) || - (has_tool && !needs_tool && tool_in_use[t] >= tool_count[t])) - { - df::job_type j = df::job_type::NONE; - - if ((*d)->dwarf->job.current_job) - j = (*d)->dwarf->job.current_job->job_type; - - if (print_debug) - out.print("LABORMANAGER: asking %s to %s tools, current job %s, %d %d \n", (*d)->dwarf->name.first_name.c_str(), (has_tool) ? "drop" : "pick up", ENUM_KEY_STR(job_type, j).c_str(), has_tool, needs_tool); - - (*d)->dwarf->uniform.pickup_flags.bits.update = true; - labors_changed = true; - - if (needs_tool) - tool_in_use[t]++; - } - } - } - - release_dwarf_list(); - - if (labors_changed) - { - *df::global::process_dig = true; - *df::global::process_jobs = true; - } - - if (print_debug) { - *df::global::pause_state = true; - } - - print_debug = 0; - - } - -}; - - -DFhackCExport command_result plugin_onstatechange(color_ostream &out, state_change_event event) -{ - switch (event) { - case SC_MAP_LOADED: - cleanup_state(); - init_state(); - break; - case SC_MAP_UNLOADED: - cleanup_state(); - break; - default: - break; - } - - return CR_OK; -} - -DFhackCExport command_result plugin_onupdate(color_ostream &out) -{ - // static int step_count = 0; - // check run conditions - if (!initialized || !world || !world->map.block_index || !enable_labormanager) - { - // give up if we shouldn't be running' - return CR_OK; - } - - // if (++step_count < 60) - // return CR_OK; - - if (*df::global::process_jobs) - return CR_OK; - - // step_count = 0; - - debug_stream = &out; - AutoLaborManager alm(out); - alm.process(); - - return CR_OK; -} - -void print_labor(df::unit_labor labor, color_ostream &out) -{ - string labor_name = ENUM_KEY_STR(unit_labor, labor); - out << labor_name << ": "; - for (int i = 0; i < 20 - (int)labor_name.length(); i++) - out << ' '; - const auto& labor_info = labor_infos[labor]; - if (labor_info.is_unmanaged()) - { - out << "UNMANAGED"; - } - else - { - out << "priority " << labor_info.priority(); - - if (labor_info.maximum_dwarfs() == MAX_DWARFS_NONE) - out << ", no maximum"; - else - out << ", maximum " << labor_info.maximum_dwarfs(); - } - out << ", currently " << labor_info.active_dwarfs << " dwarfs (" - << labor_info.busy_dwarfs << " busy, " - << labor_info.idle_dwarfs << " idle)" - << endl; -} - -df::unit_labor lookup_labor_by_name(std::string name) -{ - // We should accept incorrect casing, there is no ambiguity. - std::transform(name.begin(), name.end(), name.begin(), ::toupper); - - FOR_ENUM_ITEMS(unit_labor, test_labor) - { - if (name == ENUM_KEY_STR(unit_labor, test_labor)) - { - return test_labor; - } - } - - return df::unit_labor::NONE; -} - -DFhackCExport command_result plugin_enable(color_ostream &out, bool enable) -{ - if (!Core::getInstance().isWorldLoaded()) { - out.printerr("World is not loaded: please load a fort first.\n"); - return CR_FAILURE; - } - - if (enable && !enable_labormanager) - { - enable_plugin(out); - } - else if (!enable && enable_labormanager) - { - enable_labormanager = false; - setOptionEnabled(CF_ENABLED, false); - - out << "LaborManager is disabled." << endl; - } - - return CR_OK; -} - -command_result labormanager(color_ostream &out, std::vector & parameters) -{ - if (!Core::getInstance().isWorldLoaded()) { - out.printerr("World is not loaded: please load a game first.\n"); - return CR_FAILURE; - } - - if (parameters.size() == 1 && - (parameters[0] == "enable" || parameters[0] == "disable")) - { - bool enable = (parameters[0] == "enable"); - return plugin_enable(out, enable); - } - else if (parameters.size() == 3 && - (parameters[0] == "max" || parameters[0] == "priority")) - { - if (!enable_labormanager) - { - out << "Error: The plugin is not enabled." << endl; - return CR_FAILURE; - } - - df::unit_labor labor = lookup_labor_by_name(parameters[1]); - - if (labor == df::unit_labor::NONE) - { - out.printerr("Could not find labor %s.\n", parameters[0].c_str()); - return CR_WRONG_USAGE; - } - - int v; - - if (parameters[2] == "none") - v = MAX_DWARFS_NONE; - else if (parameters[2] == "disable" || parameters[2] == "unmanaged") - v = MAX_DWARFS_UNMANAGED; - else - v = atoi(parameters[2].c_str()); - - if (parameters[0] == "max") - labor_infos[labor].set_maximum_dwarfs(v); - else if (parameters[0] == "priority") - labor_infos[labor].set_priority(v); - - print_labor(labor, out); - return CR_OK; - } - else if (parameters.size() == 2 && parameters[0] == "reset") - { - if (!enable_labormanager) - { - out << "Error: The plugin is not enabled." << endl; - return CR_FAILURE; - } - - df::unit_labor labor = lookup_labor_by_name(parameters[1]); - - if (labor == df::unit_labor::NONE) - { - out.printerr("Could not find labor %s.\n", parameters[0].c_str()); - return CR_WRONG_USAGE; - } - reset_labor(labor); - print_labor(labor, out); - return CR_OK; - } - else if (parameters.size() == 1 && (parameters[0] == "allow-fishing" || parameters[0] == "forbid-fishing")) - { - if (!enable_labormanager) - { - out << "Error: The plugin is not enabled." << endl; - return CR_FAILURE; - } - - setOptionEnabled(CF_ALLOW_FISHING, (parameters[0] == "allow-fishing")); - return CR_OK; - } - else if (parameters.size() == 1 && (parameters[0] == "allow-hunting" || parameters[0] == "forbid-hunting")) - { - if (!enable_labormanager) - { - out << "Error: The plugin is not enabled." << endl; - return CR_FAILURE; - } - - setOptionEnabled(CF_ALLOW_HUNTING, (parameters[0] == "allow-hunting")); - return CR_OK; - } - else if (parameters.size() == 1 && parameters[0] == "reset-all") - { - if (!enable_labormanager) - { - out << "Error: The plugin is not enabled." << endl; - return CR_FAILURE; - } - - for (size_t i = 0; i < labor_infos.size(); i++) - { - reset_labor((df::unit_labor) i); - } - out << "All labors reset." << endl; - return CR_OK; - } - else if (parameters.size() == 1 && (parameters[0] == "list" || parameters[0] == "status")) - { - if (!enable_labormanager) - { - out << "Error: The plugin is not enabled." << endl; - return CR_FAILURE; - } - - bool need_comma = 0; - for (int i = 0; i < NUM_STATE; i++) - { - if (state_count[i] == 0) - continue; - if (need_comma) - out << ", "; - out << state_count[i] << ' ' << state_names[i]; - need_comma = 1; - } - out << endl; - - if (parameters[0] == "list") - { - FOR_ENUM_ITEMS(unit_labor, labor) - { - if (labor == unit_labor::NONE) - continue; - - print_labor(labor, out); - } - } - - return CR_OK; - } - else if (parameters.size() == 2 && parameters[0] == "pause-on-error") - { - if (!enable_labormanager) - { - out << "Error: The plugin is not enabled." << endl; - return CR_FAILURE; - } - - pause_on_error = parameters[1] == "yes" || parameters[1] == "true"; - - return CR_OK; - } - else if (parameters.size() == 1 && parameters[0] == "debug") - { - if (!enable_labormanager) - { - out << "Error: The plugin is not enabled." << endl; - return CR_FAILURE; - } - - print_debug = 1; - - return CR_OK; - } - else - { - out.print("Automatically assigns labors to dwarves.\n" - "Activate with 'labormanager enable', deactivate with 'labormanager disable'.\n" - "Current state: %s.\n", enable_labormanager ? "enabled" : "disabled"); - - return CR_OK; - } -} diff --git a/plugins/autolabor/labormanager.h b/plugins/autolabor/labormanager.h deleted file mode 100644 index c958900b1a..0000000000 --- a/plugins/autolabor/labormanager.h +++ /dev/null @@ -1,4 +0,0 @@ -#pragma once - -void debug(const char* fmt, ...); -void debug_pause(); diff --git a/plugins/autolabor/laborstatemap.h b/plugins/autolabor/laborstatemap.h index 3757011d63..e944c815a4 100644 --- a/plugins/autolabor/laborstatemap.h +++ b/plugins/autolabor/laborstatemap.h @@ -30,7 +30,7 @@ using namespace df::enums; const int NUM_STATE = 6; -char const* state_names[] { +static char const* const state_names[] { "IDLE", "BUSY", "EXCLUSIVE", diff --git a/plugins/autolabor/legacyengine.cpp b/plugins/autolabor/legacyengine.cpp new file mode 100644 index 0000000000..5193411bef --- /dev/null +++ b/plugins/autolabor/legacyengine.cpp @@ -0,0 +1,951 @@ +/* + * Legacy engine for autolabor: direct labor matrix manipulation. + * + * This preserves the pre-v50 behavior of the autolabor plugin. It bypasses + * the work detail system by setting automatic_professions_disabled and + * writes unit->status.labors directly each cycle. + */ + +#include "laborcommon.h" +#include "legacyengine.h" + +#include +#include +#include +#include +#include + +#include "Debug.h" +#include "PluginManager.h" + +#include "modules/Units.h" +#include "modules/World.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace DFHack; +using namespace df::enums; + +using df::global::plotinfo; +using df::global::world; +using df::global::game; + +#define ARRAY_COUNT(array) (sizeof(array)/sizeof((array)[0])) + +namespace DFHack { + DBG_DECLARE(autolabor, legacy_cycle, DebugCategory::LINFO); +} + +using autolabor::plugin_config; +using autolabor::config_flag; +using autolabor::labor_to_skill; + +static std::vector state_count(NUM_STATE); + +enum labor_mode { + DISABLE, + HAULERS, + AUTOMATIC, +}; + +struct labor_info +{ + PersistentDataItem config; + + bool is_exclusive; + int active_dwarfs; + + labor_mode mode() { return (labor_mode) config.ival(0); } + void set_mode(labor_mode mode) { config.ival(0) = mode; } + + int minimum_dwarfs() { return config.ival(1); } + void set_minimum_dwarfs(int minimum_dwarfs) { config.ival(1) = minimum_dwarfs; } + + int maximum_dwarfs() { return config.ival(2); } + void set_maximum_dwarfs(int maximum_dwarfs) { config.ival(2) = maximum_dwarfs; } + + int talent_pool() { return config.ival(3); } + void set_talent_pool(int talent_pool) { config.ival(3) = talent_pool; } +}; + +struct labor_default +{ + labor_mode mode; + bool is_exclusive; + int minimum_dwarfs; + int maximum_dwarfs; + int active_dwarfs; +}; + +// The percentage of the dwarves assigned as haulers at any one time. +static int hauler_pct = 33; + +// The maximum percentage of dwarves who will be allowed to be idle. +// Decreasing this will encourage autolabor to keep dwarves busy, +// at the expense of making it harder for dwarves to specialize in +// specific skills. +static int idler_pct = 10; + +static std::vector labor_infos; + +static const struct labor_default default_labor_infos[] = { + /* MINE */ {AUTOMATIC, true, 2, 200, 0}, + /* HAUL_STONE */ {HAULERS, false, 1, 200, 0}, + /* HAUL_WOOD */ {HAULERS, false, 1, 200, 0}, + /* HAUL_BODY */ {HAULERS, false, 1, 200, 0}, + /* HAUL_FOOD */ {HAULERS, false, 1, 200, 0}, + /* HAUL_REFUSE */ {HAULERS, false, 1, 200, 0}, + /* HAUL_ITEM */ {HAULERS, false, 1, 200, 0}, + /* HAUL_FURNITURE */ {HAULERS, false, 1, 200, 0}, + /* HAUL_ANIMAL */ {HAULERS, false, 1, 200, 0}, + /* CLEAN */ {HAULERS, false, 1, 200, 0}, + /* CUTWOOD */ {AUTOMATIC, true, 1, 200, 0}, + /* CARPENTER */ {AUTOMATIC, false, 1, 200, 0}, + /* STONECUTTER */ {AUTOMATIC, false, 1, 200, 0}, + /* STONE_CARVER */ {AUTOMATIC, false, 1, 200, 0}, + /* MASON */ {AUTOMATIC, false, 1, 200, 0}, + /* ANIMALTRAIN */ {AUTOMATIC, false, 1, 200, 0}, + /* ANIMALCARE */ {AUTOMATIC, false, 1, 200, 0}, + /* DIAGNOSE */ {AUTOMATIC, false, 1, 200, 0}, + /* SURGERY */ {AUTOMATIC, false, 1, 200, 0}, + /* BONE_SETTING */ {AUTOMATIC, false, 1, 200, 0}, + /* SUTURING */ {AUTOMATIC, false, 1, 200, 0}, + /* DRESSING_WOUNDS */ {AUTOMATIC, false, 1, 200, 0}, + /* FEED_WATER_CIVILIANS */ {AUTOMATIC, false, 200, 200, 0}, + /* RECOVER_WOUNDED */ {HAULERS, false, 1, 200, 0}, + /* BUTCHER */ {AUTOMATIC, false, 1, 200, 0}, + /* TRAPPER */ {AUTOMATIC, false, 1, 200, 0}, + /* DISSECT_VERMIN */ {AUTOMATIC, false, 1, 200, 0}, + /* LEATHER */ {AUTOMATIC, false, 1, 200, 0}, + /* TANNER */ {AUTOMATIC, false, 1, 200, 0}, + /* BREWER */ {AUTOMATIC, false, 1, 200, 0}, + /* ALCHEMIST */ {AUTOMATIC, false, 1, 200, 0}, + /* SOAP_MAKER */ {AUTOMATIC, false, 1, 200, 0}, + /* WEAVER */ {AUTOMATIC, false, 1, 200, 0}, + /* CLOTHESMAKER */ {AUTOMATIC, false, 1, 200, 0}, + /* MILLER */ {AUTOMATIC, false, 1, 200, 0}, + /* PROCESS_PLANT */ {AUTOMATIC, false, 1, 200, 0}, + /* MAKE_CHEESE */ {AUTOMATIC, false, 1, 200, 0}, + /* MILK */ {AUTOMATIC, false, 1, 200, 0}, + /* COOK */ {AUTOMATIC, false, 1, 200, 0}, + /* PLANT */ {AUTOMATIC, false, 1, 200, 0}, + /* HERBALIST */ {AUTOMATIC, false, 1, 200, 0}, + /* FISH */ {AUTOMATIC, false, 1, 1, 0}, + /* CLEAN_FISH */ {AUTOMATIC, false, 1, 200, 0}, + /* DISSECT_FISH */ {AUTOMATIC, false, 1, 200, 0}, + /* HUNT */ {AUTOMATIC, true, 1, 1, 0}, + /* SMELT */ {AUTOMATIC, false, 1, 200, 0}, + /* FORGE_WEAPON */ {AUTOMATIC, false, 1, 200, 0}, + /* FORGE_ARMOR */ {AUTOMATIC, false, 1, 200, 0}, + /* FORGE_FURNITURE */ {AUTOMATIC, false, 1, 200, 0}, + /* METAL_CRAFT */ {AUTOMATIC, false, 1, 200, 0}, + /* CUT_GEM */ {AUTOMATIC, false, 1, 200, 0}, + /* ENCRUST_GEM */ {AUTOMATIC, false, 1, 200, 0}, + /* WOOD_CRAFT */ {AUTOMATIC, false, 1, 200, 0}, + /* STONE_CRAFT */ {AUTOMATIC, false, 1, 200, 0}, + /* BONE_CARVE */ {AUTOMATIC, false, 1, 200, 0}, + /* GLASSMAKER */ {AUTOMATIC, false, 1, 200, 0}, + /* EXTRACT_STRAND */ {AUTOMATIC, false, 1, 200, 0}, + /* SIEGECRAFT */ {AUTOMATIC, false, 1, 200, 0}, + /* SIEGEOPERATE */ {AUTOMATIC, false, 1, 200, 0}, + /* BOWYER */ {AUTOMATIC, false, 1, 200, 0}, + /* MECHANIC */ {AUTOMATIC, false, 1, 200, 0}, + /* POTASH_MAKING */ {AUTOMATIC, false, 1, 200, 0}, + /* LYE_MAKING */ {AUTOMATIC, false, 1, 200, 0}, + /* DYER */ {AUTOMATIC, false, 1, 200, 0}, + /* BURN_WOOD */ {AUTOMATIC, false, 1, 200, 0}, + /* OPERATE_PUMP */ {AUTOMATIC, false, 1, 200, 0}, + /* SHEARER */ {AUTOMATIC, false, 1, 200, 0}, + /* SPINNER */ {AUTOMATIC, false, 1, 200, 0}, + /* POTTERY */ {AUTOMATIC, false, 1, 200, 0}, + /* GLAZING */ {AUTOMATIC, false, 1, 200, 0}, + /* PRESSING */ {AUTOMATIC, false, 1, 200, 0}, + /* BEEKEEPING */ {AUTOMATIC, false, 1, 200, 0}, + /* WAX_WORKING */ {AUTOMATIC, false, 1, 200, 0}, + /* HANDLE_VEHICLES */ {HAULERS, false, 1, 200, 0}, + /* HAUL_TRADE */ {HAULERS, false, 1, 200, 0}, + /* PULL_LEVER */ {HAULERS, false, 1, 200, 0}, + /* UNUSED_13 */ {DISABLE, false, 1, 0, 0}, + /* HAUL_WATER */ {HAULERS, false, 1, 200, 0}, + /* GELD */ {AUTOMATIC, false, 1, 200, 0}, + /* BUILD_ROAD */ {AUTOMATIC, false, 1, 200, 0}, + /* BUILD_CONSTRUCTION */ {AUTOMATIC, false, 1, 200, 0}, + /* PAPERMAKING */ {AUTOMATIC, false, 1, 200, 0}, + /* BOOKBINDING */ {AUTOMATIC, false, 1, 200, 0}, + /* UNUSED_20 */ {DISABLE, false, 0, 0, 0}, + /* UNUSED_21 */ {DISABLE, false, 0, 0, 0}, + /* UNUSED_22 */ {DISABLE, false, 0, 0, 0}, + /* UNUSED_23 */ {DISABLE, false, 0, 0, 0}, + /* UNUSED_24 */ {DISABLE, false, 0, 0, 0}, + /* UNUSED_25 */ {DISABLE, false, 0, 0, 0}, + /* UNUSED_26 */ {DISABLE, false, 0, 0, 0}, + /* UNUSED_27 */ {DISABLE, false, 0, 0, 0}, + /* UNUSED_28 */ {DISABLE, false, 0, 0, 0}, + /* UNUSED_29 */ {DISABLE, false, 0, 0, 0}, + /* UNUSED_30 */ {DISABLE, false, 0, 0, 0}, +}; + +// the table is indexed by labor enum value: one entry per item from 0 to +// the last labor (NONE is -1 and has no entry). a DF release that adds or +// removes labor types breaks the build here until the table is updated. +static_assert(ARRAY_COUNT(default_labor_infos) == + (int)ENUM_LAST_ITEM(unit_labor) + 1, + "default_labor_infos must have one entry per unit_labor enum item"); + +struct dwarf_info_t +{ + int highest_skill; + int total_skill; + int mastery_penalty; + int assigned_jobs; + dwarf_state state; + bool has_exclusive_labor; + int noble_penalty; // penalty for assignment due to noble status + bool medical; // this dwarf has medical responsibility + bool trader; // this dwarf has trade responsibility + bool diplomacy; // this dwarf meets with diplomats +}; + +static const int32_t CYCLE_TICKS = 61; +static int32_t cycle_timestamp = 0; // world->frame_counter at last cycle + +static void reset_labor(df::unit_labor labor) +{ + labor_infos[labor].set_minimum_dwarfs(default_labor_infos[labor].minimum_dwarfs); + labor_infos[labor].set_maximum_dwarfs(default_labor_infos[labor].maximum_dwarfs); + labor_infos[labor].set_talent_pool(200); + labor_infos[labor].set_mode(default_labor_infos[labor].mode); +} + +namespace autolabor { + +void legacy_labor_coverage(std::vector &missing) +{ + int enum_count = 0; + FOR_ENUM_ITEMS(unit_labor, l) + { + if (l == unit_labor::NONE) + continue; + enum_count++; + // default_labor_infos is indexed by labor enum value; a labor added + // by a new DF release has no default and would be invisible here + if ((size_t)l >= ARRAY_COUNT(default_labor_infos)) + missing.push_back(ENUM_KEY_STR(unit_labor, l) + + ": no entry in legacy labor defaults"); + } + // a labor removed from the enum leaves stale entries behind + if (enum_count != (int)ARRAY_COUNT(default_labor_infos)) + missing.push_back("labor defaults table has " + + std::to_string(ARRAY_COUNT(default_labor_infos)) + + " entries for " + std::to_string(enum_count) + + " labor types"); +} + +} // namespace autolabor + +void LegacyEngine::enable(color_ostream &out) +{ + // purge any state left behind by the modern engine + wdm->reset(); + wdm->shutdown(); + + auto cfg_haulpct = World::GetPersistentSiteData("autolabor/haulpct"); + hauler_pct = cfg_haulpct.isValid() ? cfg_haulpct.ival(0) : 33; + + // Load labors from save + labor_infos.resize(ARRAY_COUNT(default_labor_infos)); + + std::vector items; + World::GetPersistentSiteData(&items, "autolabor/labors/", true); + + for (auto& p : items) + { + std::string key = p.key(); + df::unit_labor labor = (df::unit_labor) atoi(key.substr(strlen("autolabor/labors/")).c_str()); + if (labor >= 0 && size_t(labor) < labor_infos.size()) + { + labor_infos[labor].config = p; + labor_infos[labor].is_exclusive = default_labor_infos[labor].is_exclusive; + labor_infos[labor].active_dwarfs = 0; + } + } + + // Add default labors for those not in save + for (size_t i = 0; i < ARRAY_COUNT(default_labor_infos); i++) { + if (labor_infos[i].config.isValid()) + continue; + + std::stringstream name; + name << "autolabor/labors/" << i; + + labor_infos[i].config = World::AddPersistentSiteData(name.str()); + + labor_infos[i].is_exclusive = default_labor_infos[i].is_exclusive; + labor_infos[i].active_dwarfs = 0; + reset_labor((df::unit_labor) i); + } + + // bypass DF's work detail system + game->external_flag.bits.automatic_professions_disabled = true; + + cycle_timestamp = 0; + initialized = true; + out << "Enabling autolabor (legacy mode)." << std::endl; +} + +void LegacyEngine::disable(color_ostream &out) +{ + initialized = false; + labor_infos.clear(); + + // reinstate DF's work detail system and force a recompute so the + // labor matrix is rebuilt from the player's work details immediately + game->external_flag.bits.automatic_professions_disabled = false; + for (auto u : world->units.active) + if (Units::isCitizen(u)) + Units::setAutomaticProfessions(u); + + out << "Disabling autolabor." << std::endl; +} + +void LegacyEngine::map_unload() +{ + initialized = false; + labor_infos.clear(); + if (game) + game->external_flag.bits.automatic_professions_disabled = false; +} + +// sorting objects +struct dwarfinfo_sorter +{ + dwarfinfo_sorter(std::vector & info):dwarf_info(info){}; + bool operator() (int i,int j) + { + if (dwarf_info[i].state == IDLE && dwarf_info[j].state != IDLE) + return true; + if (dwarf_info[i].state != IDLE && dwarf_info[j].state == IDLE) + return false; + return dwarf_info[i].mastery_penalty > dwarf_info[j].mastery_penalty; + }; + std::vector & dwarf_info; +}; +struct laborinfo_sorter +{ + bool operator() (int i,int j) + { + if (labor_infos[i].mode() != labor_infos[j].mode()) + return labor_infos[i].mode() < labor_infos[j].mode(); + if (labor_infos[i].is_exclusive != labor_infos[j].is_exclusive) + return labor_infos[i].is_exclusive; + if (labor_infos[i].maximum_dwarfs() != labor_infos[j].maximum_dwarfs()) + return labor_infos[i].maximum_dwarfs() < labor_infos[j].maximum_dwarfs(); + return false; + }; +}; + +struct values_sorter +{ + values_sorter(std::vector & values):values(values){}; + bool operator() (int i,int j) + { + return values[i] > values[j]; + }; + std::vector & values; +}; + + +static void assign_labor(unit_labor::unit_labor labor, + int n_dwarfs, + std::vector& dwarf_info, + bool trader_requested, + std::vector& dwarfs, + bool has_butchers, + bool has_fishery, + color_ostream& out) +{ + df::job_skill skill = labor_to_skill[labor]; + + if (labor_infos[labor].mode() != AUTOMATIC) + return; + + int best_skill = 0; + + std::vector values(n_dwarfs); + std::vector candidates; + std::map dwarf_skill; + std::map dwarf_skillxp; + std::vector previously_enabled(n_dwarfs); + + // Find candidate dwarfs, and calculate a preference value for each dwarf + for (int dwarf = 0; dwarf < n_dwarfs; dwarf++) + { + if (dwarf_info[dwarf].state == CHILD) + continue; + if (dwarf_info[dwarf].state == MILITARY) + continue; + if (dwarf_info[dwarf].trader && trader_requested) + continue; + if (dwarf_info[dwarf].diplomacy) + continue; + + if (labor_infos[labor].is_exclusive && dwarf_info[dwarf].has_exclusive_labor) + continue; + + int value = dwarf_info[dwarf].mastery_penalty; + + if (skill != job_skill::NONE) + { + int skill_level = 0; + int skill_experience = 0; + + for (auto s : dwarfs[dwarf]->status.souls[0]->skills) + { + if (s->id == skill) + { + skill_level = s->rating; + skill_experience = s->experience; + break; + } + } + + dwarf_skill[dwarf] = skill_level; + dwarf_skillxp[dwarf] = skill_experience; + + if (best_skill < skill_level) + best_skill = skill_level; + + value += skill_level * 100; + value += skill_experience / 20; + if (skill_level > 0 || skill_experience > 0) + value += 200; + if (skill_level >= 15) + value += 1000 * (skill_level - 14); + } + else + { + dwarf_skill[dwarf] = 0; + } + + if (dwarfs[dwarf]->status.labors[labor]) + { + value += 5; + if (labor_infos[labor].is_exclusive) + value += 350; + } + + if (dwarf_info[dwarf].has_exclusive_labor) + value -= 500; + + // bias by happiness + + //value += dwarfs[dwarf]->status.happiness; + + values[dwarf] = value; + + candidates.push_back(dwarf); + + } + + int pool = labor_infos[labor].talent_pool(); + if (pool < 200 && candidates.size() > 1 && size_t(abs(pool)) < candidates.size()) + { + // Sort in descending order + std::sort(candidates.begin(), candidates.end(), [&](const int lhs, const int rhs) -> bool { + if (dwarf_skill[lhs] == dwarf_skill[rhs]) + if (pool > 0) + return dwarf_skillxp[lhs] > dwarf_skillxp[rhs]; + else + return dwarf_skillxp[lhs] < dwarf_skillxp[rhs]; + else + if (pool > 0) + return dwarf_skill[lhs] > dwarf_skill[rhs]; + else + return dwarf_skill[lhs] < dwarf_skill[rhs]; + }); + + // Check if all dwarves have equivalent skills, usually zero + int first_dwarf = candidates[0]; + int last_dwarf = candidates[candidates.size() - 1]; + if (dwarf_skill[first_dwarf] == dwarf_skill[last_dwarf] && + dwarf_skillxp[first_dwarf] == dwarf_skillxp[last_dwarf]) + { + // There's no difference in skill, so change nothing + } + else + { + // Trim down to our top (or not) talents + candidates.resize(abs(pool)); + } + } + + // Sort candidates by preference value + values_sorter ivs(values); + std::sort(candidates.begin(), candidates.end(), ivs); + + // Disable the labor on everyone + for (int dwarf = 0; dwarf < n_dwarfs; dwarf++) + { + if (dwarf_info[dwarf].state == CHILD) + continue; + + previously_enabled[dwarf] = dwarfs[dwarf]->status.labors[labor]; + dwarfs[dwarf]->status.labors[labor] = false; + } + + int min_dwarfs = labor_infos[labor].minimum_dwarfs(); + int max_dwarfs = labor_infos[labor].maximum_dwarfs(); + + // Special - don't assign hunt without a butchers, or fish without a fishery + if (unit_labor::HUNT == labor && !has_butchers) + min_dwarfs = max_dwarfs = 0; + if (unit_labor::FISH == labor && !has_fishery) + min_dwarfs = max_dwarfs = 0; + + // If there are enough idle dwarves to choose from, enter an aggressive assignment + // mode. "Enough" idle dwarves is defined as 2 or 10% of the total number of dwarves, + // whichever is higher. + // + // In aggressive mode, we will always pick at least one idle dwarf for each skill, + // in order to try to get the idle dwarves to start doing something. We also pick + // any dwarf more preferable to the idle dwarf, since we'd rather have a more + // preferable dwarf do a new job if one becomes available (probably because that + // dwarf just finished a job). + // + // In non-aggressive mode, only dwarves that are good at a labor will be assigned + // to it. Dwarves good at nothing, or nothing that needs doing, will tend to get + // assigned to hauling by the hauler code. If there are no hauling jobs to do, + // they will sit around idle and when enough build up they will trigger aggressive + // mode again. + bool aggressive_mode = state_count[IDLE] >= 2 && state_count[IDLE] >= n_dwarfs * idler_pct / 100; + + /* + * Assign dwarfs to this labor. We assign at least the minimum number of dwarfs, in + * order of preference, and then assign additional dwarfs that meet any of these conditions: + * - We are in aggressive mode and have not yet assigned an idle dwarf + * - The dwarf is good at this skill + * - The labor is mining, hunting, or woodcutting and the dwarf currently has it enabled. + * We stop assigning dwarfs when we reach the maximum allowed. + * Note that only idle and busy dwarfs count towards the number of dwarfs. "Other" dwarfs + * (sleeping, eating, on break, etc.) will have labors assigned, but will not be counted. + * Military and children/nobles will not have labors assigned. + * Dwarfs with the "health management" responsibility are always assigned DIAGNOSIS. + */ + for (size_t i = 0; i < candidates.size() && labor_infos[labor].active_dwarfs < max_dwarfs; i++) + { + int dwarf = candidates[i]; + + if (dwarf_info[dwarf].trader && trader_requested) + continue; + if (dwarf_info[dwarf].diplomacy) + continue; + + bool preferred_dwarf = false; + if (dwarf_skillxp[dwarf] > 0 && dwarf_skill[dwarf] >= best_skill / 2) + preferred_dwarf = true; + if (previously_enabled[dwarf] && labor_infos[labor].is_exclusive && dwarf_info[dwarf].state == EXCLUSIVE) + preferred_dwarf = true; + if (dwarf_info[dwarf].medical && labor == df::unit_labor::DIAGNOSE) + preferred_dwarf = true; + + if (labor_infos[labor].active_dwarfs >= min_dwarfs && !preferred_dwarf && !aggressive_mode) + continue; + + if (!dwarfs[dwarf]->status.labors[labor]) + dwarf_info[dwarf].assigned_jobs++; + + if (Units::isValidLabor(dwarfs[dwarf], labor)) + dwarfs[dwarf]->status.labors[labor] = true; + + if (labor_infos[labor].is_exclusive) + { + dwarf_info[dwarf].has_exclusive_labor = true; + // all the exclusive labors require equipment so this should force the dorf to reequip if needed + dwarfs[dwarf]->uniform.pickup_flags.bits.update = 1; + } + + TRACE(legacy_cycle, out).print("Dwarf {} \"{}\" assigned {}: value {} {} {}\n", + dwarf, dwarfs[dwarf]->name.first_name, ENUM_KEY_STR(unit_labor, labor), values[dwarf], + dwarf_info[dwarf].trader ? "(trader)" : "", + dwarf_info[dwarf].diplomacy ? "(diplomacy)" : ""); + + if (dwarf_info[dwarf].state == IDLE || dwarf_info[dwarf].state == BUSY || dwarf_info[dwarf].state == EXCLUSIVE) + labor_infos[labor].active_dwarfs++; + + if (dwarf_info[dwarf].state == IDLE) + aggressive_mode = false; + } +} + +void LegacyEngine::update(color_ostream &out) +{ + if (!world || !world->map.block_index || !initialized) + return; + + if (world->frame_counter - cycle_timestamp < CYCLE_TICKS) + return; + + cycle_timestamp = world->frame_counter; + + std::vector dwarfs; + + auto bscan = autolabor::scan_buildings(); + bool has_butchers = bscan.has_butchers; + bool has_fishery = bscan.has_fishery; + bool trader_requested = bscan.trader_requested; + + for (auto& cre : world->units.active) + { + if (Units::isCitizen(cre)) + { + if (cre->burrows.size() > 0) + continue; // dwarfs assigned to active burrows are skipped entirely + dwarfs.push_back(cre); + } + } + + int n_dwarfs = dwarfs.size(); + + if (n_dwarfs == 0) + return; + + std::vector dwarf_info(n_dwarfs); + + // Find total skill and highest skill for each dwarf. More skilled dwarves shouldn't be used for minor tasks. + + for (int dwarf = 0; dwarf < n_dwarfs; dwarf++) + { + if (dwarfs[dwarf]->status.souls.size() <= 0) + continue; + + // compute noble penalty + + auto pos = autolabor::unit_position_info(dwarfs[dwarf]); + dwarf_info[dwarf].noble_penalty = pos.noble_penalty; + dwarf_info[dwarf].medical = pos.medical; + dwarf_info[dwarf].trader = pos.trader; + + // identify dwarfs who are needed for meetings and mark them for exclusion + + if (autolabor::in_diplomacy_meeting(dwarfs[dwarf])) + { + dwarf_info[dwarf].diplomacy = true; + DEBUG(legacy_cycle, out).print("Dwarf {} \"{}\" has a meeting, will be cleared of all labors\n", + dwarf, dwarfs[dwarf]->name.first_name); + } + + dwarf_info[dwarf].total_skill = autolabor::total_skill(dwarfs[dwarf]); + dwarf_info[dwarf].highest_skill = autolabor::top_skill(dwarfs[dwarf]); + } + + // Calculate a base penalty for using each dwarf for a task he isn't good at. + + for (int dwarf = 0; dwarf < n_dwarfs; dwarf++) + { + dwarf_info[dwarf].mastery_penalty -= 40 * dwarf_info[dwarf].highest_skill; + dwarf_info[dwarf].mastery_penalty -= 10 * dwarf_info[dwarf].total_skill; + dwarf_info[dwarf].mastery_penalty -= dwarf_info[dwarf].noble_penalty; + + FOR_ENUM_ITEMS(unit_labor, labor) + { + if (labor == unit_labor::NONE) + continue; + + if (labor_infos[labor].is_exclusive && dwarfs[dwarf]->status.labors[labor]) + dwarf_info[dwarf].mastery_penalty -= 100; + } + } + + // Find the activity state for each dwarf. It's important to get this right - a dwarf who we think is IDLE but + // can't work will gum everything up. In the future I might add code to auto-detect slacker dwarves. + + state_count.clear(); + state_count.resize(NUM_STATE); + + for (int dwarf = 0; dwarf < n_dwarfs; dwarf++) + { + dwarf_info[dwarf].state = autolabor::get_dwarf_state(dwarfs[dwarf]); + if (dwarf_info[dwarf].state == OTHER && + dwarfs[dwarf]->job.current_job && + (dwarfs[dwarf]->job.current_job->job_type < 0 || + size_t(dwarfs[dwarf]->job.current_job->job_type) >= dwarf_state_count)) + { + WARN(legacy_cycle, out).print("Dwarf {} \"{}\" has unknown job {}\n", + dwarf, dwarfs[dwarf]->name.first_name, int(dwarfs[dwarf]->job.current_job->job_type)); + } + + state_count[dwarf_info[dwarf].state]++; + + autolabor::probe_labor_observation(dwarfs[dwarf]); + + TRACE(legacy_cycle, out).print("Dwarf {} \"{}\": penalty {}, state {}\n", + dwarf, dwarfs[dwarf]->name.first_name, dwarf_info[dwarf].mastery_penalty, state_names[dwarf_info[dwarf].state]); + } + + std::vector labors; + + FOR_ENUM_ITEMS(unit_labor, labor) + { + if (labor == unit_labor::NONE) + continue; + + labor_infos[labor].active_dwarfs = 0; + + labors.push_back(labor); + } + laborinfo_sorter lasorter; + std::sort(labors.begin(), labors.end(), lasorter); + + // Handle DISABLED skills (just bookkeeping). + // Note that autolabor should *NEVER* enable or disable a skill that has been marked as DISABLED, for any reason. + // The user has told us that they want manage this skill manually, and we must respect that. + for (auto& labor: labors) + { + if (labor_infos[labor].mode() != DISABLE) + continue; + + for (int dwarf = 0; dwarf < n_dwarfs; dwarf++) + { + if (dwarfs[dwarf]->status.labors[labor]) + { + if (labor_infos[labor].is_exclusive) + dwarf_info[dwarf].has_exclusive_labor = true; + + dwarf_info[dwarf].assigned_jobs++; + } + } + } + + // Handle all skills except those marked HAULERS + + for (auto& labor : labors) + { + assign_labor(labor, n_dwarfs, dwarf_info, trader_requested, dwarfs, has_butchers, has_fishery, out); + } + + // Set about 1/3 of the dwarfs as haulers. The haulers have all HAULER labors enabled. Having a lot of haulers helps + // make sure that hauling jobs are handled quickly rather than building up. + + int num_haulers = state_count[IDLE] + (state_count[BUSY] + state_count[EXCLUSIVE]) * hauler_pct / 100; + + if (num_haulers < 1) + num_haulers = 1; + + std::vector hauler_ids; + for (int dwarf = 0; dwarf < n_dwarfs; dwarf++) + { + if ((dwarf_info[dwarf].trader && trader_requested) || + dwarf_info[dwarf].diplomacy) + { + FOR_ENUM_ITEMS(unit_labor, labor) + { + if (labor == unit_labor::NONE) + continue; + if (labor_infos[labor].mode() != HAULERS) + continue; + dwarfs[dwarf]->status.labors[labor] = false; + } + if (dwarf_info[dwarf].state == IDLE || dwarf_info[dwarf].state == BUSY || dwarf_info[dwarf].state == EXCLUSIVE) + { + num_haulers--; + } + continue; + } + + if (dwarf_info[dwarf].state == IDLE || dwarf_info[dwarf].state == BUSY || dwarf_info[dwarf].state == EXCLUSIVE) + hauler_ids.push_back(dwarf); + } + dwarfinfo_sorter sorter(dwarf_info); + // Idle dwarves come first, then we sort from least-skilled to most-skilled. + std::sort(hauler_ids.begin(), hauler_ids.end(), sorter); + + // don't set any haulers if everyone is off drinking or something + if (hauler_ids.size() == 0) { + num_haulers = 0; + } + + FOR_ENUM_ITEMS(unit_labor, labor) + { + if (labor == unit_labor::NONE) + continue; + + if (labor_infos[labor].mode() != HAULERS) + continue; + + for (int i = 0; i < num_haulers; i++) + { + int dwarf = hauler_ids[i]; + + dwarfs[dwarf]->status.labors[labor] = true; + dwarf_info[dwarf].assigned_jobs++; + + if (dwarf_info[dwarf].state == IDLE || dwarf_info[dwarf].state == BUSY || dwarf_info[dwarf].state == EXCLUSIVE) + labor_infos[labor].active_dwarfs++; + + TRACE(legacy_cycle, out).print("Dwarf {} \"{}\" assigned {}: hauler\n", + dwarf, dwarfs[dwarf]->name.first_name, ENUM_KEY_STR(unit_labor, labor)); + } + + for (size_t i = num_haulers; i < hauler_ids.size(); i++) + { + int dwarf = hauler_ids[i]; + + dwarfs[dwarf]->status.labors[labor] = false; + } + } +} + +static void print_labor (df::unit_labor labor, color_ostream &out) +{ + std::string labor_name = ENUM_KEY_STR(unit_labor, labor); + out << labor_name << ": "; + for (int i = 0; i < 20 - (int)labor_name.length(); i++) + out << ' '; + if (labor_infos[labor].mode() == DISABLE) + out << "disabled" << std::endl; + else + { + if (labor_infos[labor].mode() == HAULERS) + out << "haulers"; + else + out << "minimum " << labor_infos[labor].minimum_dwarfs() << ", maximum " << labor_infos[labor].maximum_dwarfs() + << ", pool " << labor_infos[labor].talent_pool(); + out << ", currently " << labor_infos[labor].active_dwarfs << " dwarfs" << std::endl; + } +} + +bool LegacyEngine::command(color_ostream &out, std::vector & parameters) +{ + if (parameters.empty()) + return false; + if (!initialized) + { + out << "Error: The plugin is not enabled in legacy mode." << std::endl; + return true; + } + + if (parameters.size() == 2 && parameters[0] == "haulpct") + { + int pct = atoi (parameters[1].c_str()); + hauler_pct = pct; + return true; + } + else if (parameters.size() >= 2 && parameters.size() <= 4) + { + df::unit_labor labor = unit_labor::NONE; + + FOR_ENUM_ITEMS(unit_labor, test_labor) + { + if (parameters[0] == ENUM_KEY_STR(unit_labor, test_labor)) + labor = test_labor; + } + + if (labor == unit_labor::NONE) + { + out.printerr("Could not find labor {}.\n", parameters[0]); + return true; + } + + if (parameters[1] == "haulers") + { + labor_infos[labor].set_mode(HAULERS); + print_labor(labor, out); + return true; + } + if (parameters[1] == "disable") + { + labor_infos[labor].set_mode(DISABLE); + print_labor(labor, out); + return true; + } + if (parameters[1] == "reset") + { + reset_labor(labor); + print_labor(labor, out); + return true; + } + + int minimum = atoi (parameters[1].c_str()); + int maximum = 200; + int pool = 200; + + if (parameters.size() >= 3) + maximum = atoi (parameters[2].c_str()); + if (parameters.size() == 4) + pool = std::stoi(parameters[3]); + + if (maximum < minimum || maximum < 0 || minimum < 0) + { + out.printerr("Syntax: autolabor [] [], {} > {}\n", maximum, minimum); + return true; + } + + labor_infos[labor].set_minimum_dwarfs(minimum); + labor_infos[labor].set_maximum_dwarfs(maximum); + labor_infos[labor].set_talent_pool(pool); + labor_infos[labor].set_mode(AUTOMATIC); + print_labor(labor, out); + + return true; + } + else if (parameters.size() == 1 && parameters[0] == "reset-all") + { + for (size_t i = 0; i < labor_infos.size(); i++) + { + reset_labor((df::unit_labor) i); + } + out << "All labors reset." << std::endl; + return true; + } + else if (parameters.size() == 1 && (parameters[0] == "list" || parameters[0] == "status")) + { + bool need_comma = 0; + for (int i = 0; i < NUM_STATE; i++) + { + if (state_count[i] == 0) + continue; + if (need_comma) + out << ", "; + out << state_count[i] << ' ' << state_names[i]; + need_comma = 1; + } + out << std::endl; + + if (parameters[0] == "list") + { + FOR_ENUM_ITEMS(unit_labor, labor) + { + if (labor == unit_labor::NONE) + continue; + + print_labor(labor, out); + } + } + + return true; + } + + return false; +} diff --git a/plugins/autolabor/legacyengine.h b/plugins/autolabor/legacyengine.h new file mode 100644 index 0000000000..28336cf135 --- /dev/null +++ b/plugins/autolabor/legacyengine.h @@ -0,0 +1,24 @@ +#pragma once + +#include "engines.h" +#include "workdetails.h" + +// The classic autolabor engine: decides which units should have each labor +// and writes unit->status.labors directly. While enabled it sets +// game->external_flag.automatic_professions_disabled so the vanilla work +// detail system does not fight the plugin's writes. +class LegacyEngine : public LaborEngine { +public: + LegacyEngine(WorkDetailManager *wdm) : wdm(wdm) {} + + void enable(color_ostream &out) override; + void disable(color_ostream &out) override; + void update(color_ostream &out) override; + bool command(color_ostream &out, std::vector ¶meters) override; + + void map_unload(); + +private: + WorkDetailManager *wdm; + bool initialized = false; +}; diff --git a/plugins/autolabor/modernengine.cpp b/plugins/autolabor/modernengine.cpp new file mode 100644 index 0000000000..0fc7af63e2 --- /dev/null +++ b/plugins/autolabor/modernengine.cpp @@ -0,0 +1,2554 @@ +/* + * Modern engine for the autolabor plugin ("labormanager" command). + * + * Expresses labor policy through DF's work detail system rather than writing + * the labor matrix directly. The v50+ job auction already prefers skilled + * workers for skilled jobs, so this engine focuses on what the auction does + * badly: starving jobs that require no skill, and giving skilled dwarves + * room to practice and teach. + */ + +#include "joblabormapper.h" +#include "modernengine.h" +#include "monitorengine.h" + +#include +#include +#include +#include +#include +#include + +#include "Debug.h" +#include "MiscUtils.h" +#include "PluginManager.h" + +#include "modules/Job.h" +#include "modules/Units.h" +#include "modules/World.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace DFHack; +using namespace df::enums; + +using df::global::plotinfo; +using df::global::world; +using df::global::game; + +namespace DFHack { + DBG_DECLARE(autolabor, modern_cycle, DebugCategory::LINFO); + DBG_DECLARE(autolabor, tool_detail, DebugCategory::LINFO); + DBG_DECLARE(autolabor, assign, DebugCategory::LINFO); + DBG_EXTERN(autolabor, labor_probe); +} + +using namespace autolabor; + +static const int32_t CYCLE_TICKS = 61; +static int32_t cycle_timestamp = 0; + +// a posting that sits unclaimed for this many cycles counts as starving +static const int STARVE_CYCLES = 5; +// a posting on the board longer than this (one game day) triggers a +// task starvation warning in the DFHack notification panel +static const int32_t STARVE_TICKS = 1200; +// specialists work this many consecutive cycles before earning rest +static const int BUSY_LIMIT = 10; + +static const std::string LABORER_DETAIL = "auto:Laborers"; +static const std::string CARE_DETAIL = "auto:Care"; +static const std::string SKILL_PREFIX = "auto:Skill:"; + +// labors that should stay available to every unit even when specialized; +// these are covered by the Care detail so flag-restricted units keep them +static bool is_care_labor(df::unit_labor l) +{ + switch (l) { + case unit_labor::RECOVER_WOUNDED: + case unit_labor::FEED_WATER_CIVILIANS: + case unit_labor::HAUL_WATER: + return true; + default: + return false; + } +} + +enum tools_enum { + TOOL_NONE, TOOL_PICK, TOOL_AXE, TOOL_CROSSBOW, + TOOLS_MAX +}; + +static tools_enum labor_tool(df::unit_labor l) +{ + switch (l) { + case unit_labor::MINE: return TOOL_PICK; + case unit_labor::CUTWOOD: return TOOL_AXE; + case unit_labor::HUNT: return TOOL_CROSSBOW; + default: return TOOL_NONE; + } +} + +// a skill whose labors include a tool labor (mining, woodcutting, +// hunting); toggling these makes the unit drop and re-equip its tool +static bool is_tool_skill(df::job_skill s) +{ + FOR_ENUM_ITEMS(unit_labor, l) + if (l != unit_labor::NONE && labor_to_skill[l] == s && + is_exclusive_labor(l)) + return true; + return false; +} + +// a work detail whose icon marks it as one of the tool-carrying details: +// the builtin Miners/Woodcutters/Hunters, or a managed fallback for one +// of those skills (managed details reuse the same icons) +static bool is_tool_detail(df::work_detail *wd) +{ + if (!wd) + return false; + switch (wd->icon) { + case work_detail_icon_type::MINERS: + case work_detail_icon_type::WOODCUTTERS: + case work_detail_icon_type::HUNTERS: + return true; + default: + return false; + } +} + +namespace autolabor { + +void modern_labor_coverage(std::vector &missing) +{ + FOR_ENUM_ITEMS(unit_labor, l) + { + if (l == unit_labor::NONE) + continue; + // the exclusive-labor check and the tool-cap mapping must agree; + // a labor that needs a tool without being flagged would let the + // engine oversubscribe equipment + if (is_exclusive_labor(l) != (labor_tool(l) != TOOL_NONE)) + missing.push_back(ENUM_KEY_STR(unit_labor, l) + + ": is_exclusive_labor/labor_tool disagree"); + } +} + +} // namespace autolabor + +// --------------------------------------------------------------------------- +// per-labor managed flag ("autolabor/modern/labors/", ival(0) != 0 managed) +// --------------------------------------------------------------------------- + +static bool labor_managed(df::unit_labor l) +{ + std::stringstream key; + key << "autolabor/modern/labors/" << int(l); + auto item = World::GetPersistentSiteData(key.str()); + return !item.isValid() || item.ival(0) != 0; +} + +static void set_labor_managed(df::unit_labor l, bool managed) +{ + std::stringstream key; + key << "autolabor/modern/labors/" << int(l); + auto item = World::GetPersistentSiteData(key.str(), true); + if (item.isValid()) + item.ival(0) = managed ? 1 : 0; +} + +// migrate "unmanaged" marks from the old labormanager/2.0 config once +static void migrate_old_config() +{ + auto marker = World::GetPersistentSiteData("autolabor/modern/migrated", true); + if (!marker.isValid() || marker.ival(0) == 1) + return; + marker.ival(0) = 1; + + std::vector items; + World::GetPersistentWorldData(&items, "labormanager/2.0/labors/", true); + for (auto &p : items) + { + std::string key = p.key(); + int labor = atoi(key.substr(strlen("labormanager/2.0/labors/")).c_str()); + // old config: maximum_dwarfs in ival(2), -1 meant unmanaged + if (labor >= 0 && labor < NUM_LABORS && p.ival(2) == -1) + set_labor_managed((df::unit_labor)labor, false); + } +} + +// --------------------------------------------------------------------------- +// per-unit scan info +// --------------------------------------------------------------------------- + +// how a unit's unmet needs (personality.needs, focus_level < 0) bias the +// role assignment. pull values are scaled 0..50. +struct NeedBias { + int laborer_pull = 0; // hauling/care work satisfies the need + int idle_pull = 0; // needs downtime (socialize, rest) + std::map favored; // skills that satisfy unmet needs +}; + +struct UnitInfo { + df::unit *u = nullptr; + dwarf_state state = OTHER; + df::job_skill skill = job_skill::NONE; // top Normal/Medical skill + int rating = 0; + int total = 0; + int noble_penalty = 0; + bool trader = false; + bool diplomacy = false; + bool excluded = false; // cannot take assignments at all + NeedBias needs; +}; + +// per-unit persistent tracking for guild-hall rest rotation +struct SpecTrack { + int busy_streak = 0; + int rest_left = 0; + // the skill detail the unit was last assigned to; tool labors stick to + // their holders so they don't drop and re-equip tools between cycles + df::job_skill detail_skill = df::job_skill::NONE; +}; + +static JobLaborMapper *labor_mapper = nullptr; + +// per-posting tracking: how long it's been on the board and which skill +// the job exercises (job pointers go bad once a posting is dead, so the +// skill is recorded up front) +struct PostingTrack { + int32_t first_seen = 0; + df::job_skill skill = df::job_skill::NONE; +}; +static std::map posting_track; // posting idx -> info + +// job-history skill usage, decayed per cycle so recently-used skills +// outweigh ones the fort abandoned +static std::map skill_usage; +static std::map unclaimed_streak; // labor -> cycles +// oldest unclaimed posting per labor; the job pointer is only valid within +// the cycle that scan_demand filled it (jobs aren't stable across cycles) +static std::map unclaimed_job; +static std::map unclaimed_age; + +// labor-mismatch escalation state, keyed by the mapped labor. When a posting +// starves despite eligible units holding its mapped labor, we warn and then +// enable the disputed labors on idle dwarves one at a time; whichever labor +// unlocks the job identifies the labor the game actually gates it on. +struct LaborEscalation { + int32_t job_id = -1; + df::job_type jtype = df::job_type::NONE; + std::string job_name; + std::vector candidates; + size_t next = 0; + std::map> writes; // unit -> labors we set +}; +static std::map labor_escalations; +// job ids that already completed a probe round; a posting that can never +// be claimed would otherwise re-escalate every cycle and probe forever +static std::set probed_jobs; +static std::map labor_work; // live postings per labor, claimed or not +// tool-detail membership as of the end of the last applied cycle; diffs +// flag assignments made outside our reconcile calls (game or player) +static std::map> tool_snapshot; +static std::map unit_track; // unit id -> tracking +static std::vector managed_labor_cache; + +// status for the overlay +static int stat_laborers = 0; +static int stat_specialists = 0; +static int stat_resting = 0; +static int stat_generalists = 0; + +// job-board starvation stats (since the engine was last enabled) +static int stat_starving = 0; // live postings older than STARVE_TICKS +static int32_t stat_oldest_wait = 0; // age of the oldest live posting +static df::job_type stat_oldest_job = df::job_type::NONE; // oldest posting's job +static std::string stat_oldest_reaction; // custom reaction code, if any +static df::coord stat_oldest_pos; // oldest posting's job location +static int stat_resolved = 0; // postings that left the board +static int64_t stat_resolved_ticks = 0; // total ticks they spent on the board +static int32_t stat_max_wait = 0; // longest time on the board + +// workshop profile constraints collected during the demand scan: a job at +// a shop with a permitted-worker list needs a listed worker assigned the +// job's labor; a nondefault skill range needs an in-range worker +struct ShopNeed { + df::unit_labor labor = df::unit_labor::NONE; + std::set permitted; // empty = no worker restriction + int32_t min_level = 0; + int32_t max_level = df::skill_rating::Legendary5; +}; +static std::vector shop_needs; +// unit id -> skills exercised by jobs at shops where the unit is on the +// permitted list; units on any list are dispreferred for other labors so +// they stay available for their shop's work +static std::map> shop_reserved; +static std::set permitted_anywhere; + +static bool has_worker(df::job *j) +{ + for (auto ref : j->general_refs) + if (ref->getType() == df::general_ref_type::UNIT_WORKER) + return true; + return false; +} + +// --------------------------------------------------------------------------- +// working-state persistence +// --------------------------------------------------------------------------- + +// The engine's accumulated state (posting ages, skill usage history, +// starvation streaks, guild rest rotation, stats) is written to site data +// every cycle so it's included whenever the fort is saved, then restored +// on enable after a load. + +// does work remain for the skill? any live posting (claimed or not) for +// the skill's labors, the unit's own current job mapping to it, or a +// PickupEquipment job (a tool job suspends into it while the unit fetches +// its tool, and the claimed posting may not be visible to either other +// test then) +static bool skill_demand_remains(df::job_skill skill, df::unit *u) +{ + FOR_ENUM_ITEMS(unit_labor, l) + if (l != unit_labor::NONE && labor_to_skill[l] == skill && + labor_work[l] > 0) + return true; + if (u->job.current_job) + { + if (u->job.current_job->job_type == df::job_type::PickupEquipment) + return true; + df::unit_labor jl = labor_mapper->find_job_labor(u->job.current_job); + if (jl >= 0 && jl < NUM_LABORS && labor_to_skill[jl] == skill) + return true; + } + return false; +} + +// remove the labors an escalation experiment enabled and rebuild each +// affected unit's labor matrix from its work details +static void revert_escalation(LaborEscalation &esc) +{ + for (auto &kv : esc.writes) + if (auto u = df::unit::find(kv.first)) + { + for (auto m : kv.second) + u->status.labors[m] = false; + Units::setAutomaticProfessions(u); + } + esc.writes.clear(); +} + +static std::vector split_str(const std::string &s, char delim) +{ + std::vector out; + std::string cur; + for (char c : s) + { + if (c == delim) + { + out.push_back(cur); + cur.clear(); + } + else + cur += c; + } + out.push_back(cur); + return out; +} + +static void save_state() +{ + auto p = World::GetPersistentSiteData("autolabor/engine-state", true); + if (!p.isValid()) + return; + + std::stringstream ss; + ss << "stats:" << stat_resolved << ',' << stat_resolved_ticks << ',' + << stat_max_wait << ';'; + ss << "post:"; + for (auto &kv : posting_track) + ss << kv.first << ',' << kv.second.first_seen << ',' + << (int)kv.second.skill << '|'; + ss << ";usage:"; + for (auto &kv : skill_usage) + ss << (int)kv.first << ',' << int(kv.second * 1000) << '|'; + ss << ";streak:"; + for (auto &kv : unclaimed_streak) + ss << kv.first << ',' << kv.second << '|'; + ss << ";track:"; + for (auto &kv : unit_track) + ss << kv.first << ',' << kv.second.busy_streak << ',' + << kv.second.rest_left << ',' << (int)kv.second.detail_skill + << '|'; + p.val() = ss.str(); +} + +static void restore_state() +{ + auto p = World::GetPersistentSiteData("autolabor/engine-state"); + if (!p.isValid()) + return; + + for (auto &sec : split_str(p.val(), ';')) + { + size_t colon = sec.find(':'); + if (colon == std::string::npos) + continue; + std::string name = sec.substr(0, colon); + std::string body = sec.substr(colon + 1); + + if (name == "stats") + { + auto f = split_str(body, ','); + if (f.size() == 3) + { + stat_resolved = atoi(f[0].c_str()); + stat_resolved_ticks = atoll(f[1].c_str()); + stat_max_wait = atoi(f[2].c_str()); + } + } + else if (name == "post") + { + for (auto &e : split_str(body, '|')) + { + auto f = split_str(e, ','); + if (f.size() == 3) + { + PostingTrack t; + t.first_seen = atoi(f[1].c_str()); + t.skill = (df::job_skill)atoi(f[2].c_str()); + posting_track[atoi(f[0].c_str())] = t; + } + } + } + else if (name == "usage") + { + for (auto &e : split_str(body, '|')) + { + auto f = split_str(e, ','); + if (f.size() == 2) + skill_usage[(df::job_skill)atoi(f[0].c_str())] = + atoi(f[1].c_str()) / 1000.0; + } + } + else if (name == "streak") + { + for (auto &e : split_str(body, '|')) + { + auto f = split_str(e, ','); + if (f.size() == 2) + unclaimed_streak[atoi(f[0].c_str())] = atoi(f[1].c_str()); + } + } + else if (name == "track") + { + for (auto &e : split_str(body, '|')) + { + auto f = split_str(e, ','); + if (f.size() >= 3) + { + SpecTrack t; + t.busy_streak = atoi(f[1].c_str()); + t.rest_left = atoi(f[2].c_str()); + if (f.size() > 3) + t.detail_skill = + (df::job_skill)atoi(f[3].c_str()); + unit_track[atoi(f[0].c_str())] = t; + } + } + } + } +} + +void ModernEngine::enable(color_ostream &out) +{ + game->external_flag.bits.automatic_professions_disabled = false; + + if (!labor_mapper) + labor_mapper = new JobLaborMapper(); + init_labor_to_skill(); + migrate_old_config(); + + managed_labor_cache.assign(NUM_LABORS, true); + FOR_ENUM_ITEMS(unit_labor, l) + if (l != unit_labor::NONE) + managed_labor_cache[l] = labor_managed(l); + + wdm->reset(); + posting_track.clear(); + skill_usage.clear(); + unclaimed_streak.clear(); + unclaimed_job.clear(); + unclaimed_age.clear(); + for (auto &kv : labor_escalations) + revert_escalation(kv.second); + labor_escalations.clear(); + probed_jobs.clear(); + unit_track.clear(); + cycle_timestamp = 0; + stat_starving = 0; + stat_oldest_wait = 0; + stat_oldest_job = df::job_type::NONE; + stat_oldest_reaction.clear(); + stat_resolved = 0; + stat_resolved_ticks = 0; + stat_max_wait = 0; + restore_state(); + initialized = true; + + out << "Enabling labormanager (work detail mode)." << std::endl; +} + +void ModernEngine::disable(color_ostream &out) +{ + initialized = false; + for (auto &kv : labor_escalations) + revert_escalation(kv.second); + labor_escalations.clear(); + wdm->shutdown(); + out << "Disabling labormanager." << std::endl; +} + +void ModernEngine::map_unload() +{ + initialized = false; + posting_track.clear(); + skill_usage.clear(); + unclaimed_streak.clear(); + unclaimed_job.clear(); + unclaimed_age.clear(); + labor_escalations.clear(); + probed_jobs.clear(); + unit_track.clear(); + stat_starving = 0; + stat_oldest_wait = 0; + stat_oldest_job = df::job_type::NONE; + stat_oldest_reaction.clear(); + stat_resolved = 0; + stat_resolved_ticks = 0; + stat_max_wait = 0; +} + +int ModernEngine::starving_jobs() +{ + return initialized ? stat_starving : 0; +} + +df::job_type ModernEngine::oldest_starving_job() +{ + return initialized && stat_starving > 0 ? stat_oldest_job + : df::job_type::NONE; +} + +int32_t ModernEngine::oldest_starving_wait() +{ + return initialized && stat_starving > 0 ? stat_oldest_wait : 0; +} + +df::coord ModernEngine::oldest_starving_pos() +{ + return initialized && stat_starving > 0 ? stat_oldest_pos + : df::coord(); +} + +// resolve a custom reaction's code to the reaction's own name; "" if the +// code is empty or the reaction can't be found +static std::string reaction_display_name(const std::string &code) +{ + if (!code.empty()) + for (auto r : df::reaction::get_vector()) + if (r->code == code && !r->name.empty()) + return r->name; + return ""; +} + +// display name for a job: for custom reactions, resolve the job's reaction +// code to the reaction's own name +static std::string job_display_name(df::job *j) +{ + if (j->job_type == df::job_type::CustomReaction) + { + std::string name = reaction_display_name(j->reaction_name); + if (!name.empty()) + return name; + } + const char *cap = ENUM_ATTR(job_type, caption, j->job_type); + return cap ? cap : ENUM_KEY_STR(job_type, j->job_type); +} + +// display name for the oldest starving job: for custom reactions, resolve +// the job's reaction code to the reaction's own name +static std::string oldest_starving_name() +{ + if (stat_oldest_job == df::job_type::CustomReaction) + { + std::string name = reaction_display_name(stat_oldest_reaction); + if (!name.empty()) + return name; + } + const char *cap = ENUM_ATTR(job_type, caption, stat_oldest_job); + return cap ? cap : ENUM_KEY_STR(job_type, stat_oldest_job); +} + +std::string ModernEngine::oldest_starving_name() +{ + return initialized && stat_starving > 0 ? ::oldest_starving_name() : ""; +} + +std::string ModernEngine::status_line() +{ + std::stringstream ss; + ss << stat_laborers << " laborers, " << stat_specialists << " specialists"; + if (stat_resting > 0) + ss << " (" << stat_resting << " on guild rest)"; + ss << ", " << stat_generalists << " generalists"; + return ss.str(); +} + +// --------------------------------------------------------------------------- +// demand scan +// --------------------------------------------------------------------------- + +static void reconcile(WorkDetailManager *wdm, df::work_detail *wd, + const std::set &desired); +static void reconcile_builtin(WorkDetailManager *wdm, df::work_detail *wd, + const std::set &desired); +static df::work_detail_icon_type skill_icon(df::job_skill skill); + +static void scan_demand(std::map &backlog) +{ + backlog.clear(); + labor_work.clear(); + unclaimed_job.clear(); + unclaimed_age.clear(); + stat_starving = 0; + stat_oldest_wait = 0; + stat_oldest_job = df::job_type::NONE; + stat_oldest_reaction.clear(); + shop_needs.clear(); + shop_reserved.clear(); + permitted_anywhere.clear(); + + // every unit on a workshop's permitted list is reserved for that + // shop's work; the labors each shop actually needs come from the + // postings below + for (auto b : world->buildings.all) + { + df::workshop_profile *prof = b->getWorkshopProfile(); + if (prof && !prof->permitted_workers.empty()) + permitted_anywhere.insert(prof->permitted_workers.begin(), + prof->permitted_workers.end()); + } + + std::set live_postings; + + for (auto jp : world->jobs.postings) + { + df::job *j = jp->job; + if (jp->flags.bits.dead || !j) + { + TRACE(assign).print("posting {}: skipped, dead={} job={}\n", + jp->idx, (int)jp->flags.bits.dead, j ? "set" : "null"); + continue; + } + if (j->flags.bits.suspend || j->flags.bits.item_lost) + { + TRACE(assign).print("posting {}: {} skipped, suspend={} item_lost={}\n", + jp->idx, job_display_name(j), + (int)j->flags.bits.suspend, (int)j->flags.bits.item_lost); + continue; + } + + // work exists for this labor even when every posting is claimed; + // tool-labor stickiness keys off this so a unit fetching its tool + // or taking a break doesn't lose the detail and drop the tool + df::unit_labor labor = labor_mapper->find_job_labor(j); + if (labor >= 0 && labor < NUM_LABORS) + labor_work[labor]++; + + TRACE(assign).print("posting {}: {} -> labor {} ({})\n", + jp->idx, job_display_name(j), + labor >= 0 && labor < NUM_LABORS ? + ENUM_KEY_STR(unit_labor, labor) : "none", + has_worker(j) ? "claimed" : "unclaimed"); + + if (has_worker(j)) + continue; + + live_postings.insert(jp->idx); + + if (!posting_track.count(jp->idx)) + { + PostingTrack t; + t.first_seen = world->frame_counter; + if (labor >= 0 && labor < NUM_LABORS) + t.skill = labor_to_skill[labor]; + posting_track[jp->idx] = t; + } + + // starvation stats: how long has this posting been on the board + int32_t age = world->frame_counter - posting_track[jp->idx].first_seen; + if (age > stat_oldest_wait) + { + stat_oldest_wait = age; + stat_oldest_job = j->job_type; + stat_oldest_reaction = j->reaction_name; + stat_oldest_pos = j->pos; + } + if (age >= STARVE_TICKS) + stat_starving++; + + if (labor < 0 || labor >= NUM_LABORS) + continue; + backlog[labor]++; + if (age >= unclaimed_age[labor]) + { + unclaimed_age[labor] = age; + unclaimed_job[labor] = j; + } + + // a job at a shop with a permitted-worker list or a nondefault + // skill range needs a qualifying worker holding the labor + if (df::building *bld = Job::getHolder(j)) + { + df::workshop_profile *prof = bld->getWorkshopProfile(); + if (prof && (!prof->permitted_workers.empty() || + prof->min_level > 0 || + prof->max_level < df::skill_rating::Legendary5)) + { + ShopNeed need; + need.labor = labor; + need.permitted.insert(prof->permitted_workers.begin(), + prof->permitted_workers.end()); + need.min_level = prof->min_level; + need.max_level = prof->max_level; + shop_needs.push_back(need); + if (labor_to_skill[labor] != job_skill::NONE) + for (auto uid : prof->permitted_workers) + shop_reserved[uid].insert(labor_to_skill[labor]); + } + } + } + + // postings that left the board (assigned, cancelled, or suspended): + // record how long they spent on the board and the skill they exercised + for (auto it = posting_track.begin(); it != posting_track.end();) + { + if (!live_postings.count(it->first)) + { + int32_t wait = world->frame_counter - it->second.first_seen; + stat_resolved++; + stat_resolved_ticks += wait; + stat_max_wait = std::max(stat_max_wait, wait); + if (it->second.skill != df::job_skill::NONE) + skill_usage[it->second.skill] += 1.0; + it = posting_track.erase(it); + } + else + ++it; + } + + // decay usage so recently-used skills outweigh abandoned ones + for (auto &kv : skill_usage) + kv.second *= 0.995; + + // stockpile job counters catch hauling demand that hasn't spawned jobs yet + backlog[unit_labor::HAUL_STONE] += world->stockpile.num_jobs[1]; + backlog[unit_labor::HAUL_WOOD] += world->stockpile.num_jobs[2]; + backlog[unit_labor::HAUL_ITEM] += world->stockpile.num_jobs[3]; + backlog[unit_labor::HAUL_ITEM] += world->stockpile.num_jobs[4]; + backlog[unit_labor::HAUL_BODY] += world->stockpile.num_jobs[5]; + backlog[unit_labor::HAUL_FOOD] += world->stockpile.num_jobs[6]; + backlog[unit_labor::HAUL_REFUSE] += world->stockpile.num_jobs[7]; + backlog[unit_labor::HAUL_FURNITURE] += world->stockpile.num_jobs[8]; + backlog[unit_labor::HAUL_ANIMALS] += world->stockpile.num_jobs[9]; + + for (auto v : world->vehicles.all) + if (v->route_id != -1) + backlog[unit_labor::HANDLE_VEHICLES]++; + + // track how many consecutive cycles each labor has had unclaimed work + FOR_ENUM_ITEMS(unit_labor, l) + { + if (l == unit_labor::NONE) + continue; + if (backlog[l] > 0) + unclaimed_streak[l]++; + else + unclaimed_streak[l] = 0; + if (backlog[l] > 0 || unclaimed_streak[l] > 0) + { + TRACE(assign).print("labor {}: {} unclaimed, streak {}, managed={}, {}\n", + ENUM_KEY_STR(unit_labor, l), backlog[l], unclaimed_streak[l], + (int)(l >= 0 && l < NUM_LABORS && managed_labor_cache[l]), + is_unskilled(l) ? "unskilled" : + ENUM_KEY_STR(job_skill, labor_to_skill[l])); + } + } +} + +// --------------------------------------------------------------------------- +// guild halls +// --------------------------------------------------------------------------- + +static std::set guilded_professions() +{ + std::set profs; + + auto site = df::world_site::find(plotinfo->site_id); + if (!site) + return profs; + + for (auto ab : site->buildings) + { + if (auto gh = virtual_cast(ab)) + { + df::profession p = gh->contents.profession; + if (p != profession::NONE) + profs.insert(p); + } + } + + return profs; +} + +// does this skill's profession (or an ancestor profession) have a guild hall? +static bool is_guilded(df::job_skill skill, const std::set &guilded) +{ + df::profession p = (df::profession)ENUM_ATTR(job_skill, profession, skill); + while (p != profession::NONE) + { + if (guilded.count(p)) + return true; + p = (df::profession)ENUM_ATTR(profession, parent, p); + } + return false; +} + +// --------------------------------------------------------------------------- +// tools +// --------------------------------------------------------------------------- + +static void count_tools(int tool_count[TOOLS_MAX]) +{ + for (int e = 0; e < TOOLS_MAX; e++) + tool_count[e] = 0; + + df::item_flags bad_flags; + bad_flags.whole = 0; +#define F(x) bad_flags.bits.x = true; + F(dump); F(forbid); F(garbage_collect); + F(hostile); F(on_fire); F(rotten); F(trader); + F(in_building); F(construction); +#undef F + + for (auto item : world->items.other.IN_PLAY) + { + if (item->flags.whole & bad_flags.whole) + continue; + if (!item->isWeapon()) + continue; + + df::itemdef_weaponst* weapondef = ((df::item_weaponst*)item)->subtype; + if ((df::job_skill)weapondef->skill_melee == df::job_skill::MINING) + tool_count[TOOL_PICK]++; + else if ((df::job_skill)weapondef->skill_melee == df::job_skill::AXE) + tool_count[TOOL_AXE]++; + else if ((df::job_skill)weapondef->skill_ranged == df::job_skill::CROSSBOW) + tool_count[TOOL_CROSSBOW]++; + } +} + +// --------------------------------------------------------------------------- +// needs +// --------------------------------------------------------------------------- + +static int rating_in_skill(df::unit *u, df::job_skill skill) +{ + if (u->status.current_soul) + for (auto s : u->status.current_soul->skills) + if (s->id == skill) + return s->rating; + return 0; +} + +// craft labors satisfy the BeCreative / CraftObject / ThinkAbstractly needs +static const df::unit_labor craft_labors[] = { + unit_labor::WOOD_CRAFT, unit_labor::STONE_CRAFT, unit_labor::BONE_CARVE, + unit_labor::METAL_CRAFT, unit_labor::GLASSMAKER, unit_labor::POTTERY, + unit_labor::BOWYER, unit_labor::CLOTHESMAKER, unit_labor::WEAVER, + unit_labor::LEATHER, unit_labor::CUT_GEM, unit_labor::ENCRUST_GEM, + unit_labor::PAPERMAKING, unit_labor::BOOKBINDING, unit_labor::WAX_WORKING, + unit_labor::EXTRACT_STRAND, unit_labor::SPINNER, unit_labor::DYER, +}; + +// labors whose products carry a quality modifier that scales with the +// worker's skill -- skilled specialists matter most for these +static const df::unit_labor quality_labors[] = { + unit_labor::CARPENTER, unit_labor::STONECUTTER, unit_labor::STONE_CARVER, + unit_labor::ENGRAVER, unit_labor::MASON, unit_labor::MECHANIC, + unit_labor::WOOD_CRAFT, unit_labor::STONE_CRAFT, unit_labor::BONE_CARVE, + unit_labor::METAL_CRAFT, unit_labor::FORGE_WEAPON, unit_labor::FORGE_ARMOR, + unit_labor::FORGE_FURNITURE, unit_labor::GLASSMAKER, unit_labor::POTTERY, + unit_labor::GLAZING, unit_labor::BOWYER, unit_labor::CLOTHESMAKER, + unit_labor::LEATHER, unit_labor::CUT_GEM, unit_labor::ENCRUST_GEM, + unit_labor::SIEGECRAFT, unit_labor::COOK, unit_labor::PAPERMAKING, + unit_labor::BOOKBINDING, +}; + +// labors whose skills a strange mood can raise to legendary +static const df::unit_labor moodable_labors[] = { + unit_labor::CARPENTER, unit_labor::STONECUTTER, unit_labor::STONE_CARVER, + unit_labor::WOOD_CRAFT, unit_labor::BONE_CARVE, unit_labor::METAL_CRAFT, + unit_labor::FORGE_WEAPON, unit_labor::FORGE_ARMOR, + unit_labor::FORGE_FURNITURE, unit_labor::BOWYER, unit_labor::MECHANIC, + unit_labor::MASON, unit_labor::CLOTHESMAKER, unit_labor::WEAVER, + unit_labor::LEATHER, unit_labor::TANNER, unit_labor::CUT_GEM, + unit_labor::ENCRUST_GEM, unit_labor::GLASSMAKER, unit_labor::POTTERY, + unit_labor::GLAZING, unit_labor::WAX_WORKING, unit_labor::SMELT, + unit_labor::SIEGECRAFT, +}; + +static bool is_labor_skill(const df::unit_labor *labors, size_t count, + df::job_skill skill) +{ + for (size_t i = 0; i < count; i++) + if (labor_to_skill[labors[i]] == skill) + return true; + return false; +} + +// products of these skills carry skill-scaled quality modifiers +static bool is_quality_skill(df::job_skill skill) +{ + return is_labor_skill(quality_labors, + sizeof(quality_labors)/sizeof(df::unit_labor), skill); +} + +static bool has_moodable_skill(df::unit *u) +{ + for (auto l : moodable_labors) + if (rating_in_skill(u, labor_to_skill[l]) > 0) + return true; + return false; +} + +// frequently-used skills are worth specializing more aggressively +static int usage_bonus(df::job_skill skill) +{ + auto it = skill_usage.find(skill); + if (it == skill_usage.end()) + return 0; + return int(std::min(it->second, 20.0) * 5); // 0..100 +} + +static NeedBias scan_needs(df::unit *u) +{ + NeedBias bias; + if (!u->status.current_soul) + return bias; + + for (auto need : u->status.current_soul->personality.needs) + { + // focus_level runs -999 (desperate) to 400 (satisfied) + if (need->focus_level >= 0) + continue; + int urgency = std::min(-need->focus_level, 400) / 8; // 0..50 + if (!urgency) + continue; + + switch (need->id) + { + // staying occupied, acquiring things (stockpiling, hauling to the + // depot), and helping (patients, prisoners, wounded) are all + // satisfied by unskilled laborer work + case need_type::StayOccupied: + case need_type::AcquireObject: + bias.laborer_pull += urgency; + break; + // helping: patient care is laborer work, and leading guildhall + // demonstrations needs idle time + case need_type::HelpSomebody: + bias.laborer_pull += urgency; + bias.idle_pull += urgency / 2; + break; + // social needs are satisfied by free time; keep these units out of + // the restrictive pools so they can idle and socialize, and let + // guilded ones earn rest (and teach) sooner + case need_type::Socialize: + case need_type::MakeMerry: + case need_type::TakeItEasy: + case need_type::BeWithFamily: + case need_type::BeWithFriends: + case need_type::HearEloquence: + case need_type::LearnSomething: // watching demos is guild idle time + bias.idle_pull += urgency; + break; + // wandering satisfied by fishing, hunting, plant gathering + case need_type::Wander: + bias.favored[labor_to_skill[unit_labor::FISH]] += urgency; + bias.favored[labor_to_skill[unit_labor::HUNT]] += urgency; + bias.favored[labor_to_skill[unit_labor::HERBALIST]] += urgency; + break; + // excitement: catching vermin, danger (hunting); performing and + // socializing are downtime activities + case need_type::Excitement: + bias.favored[labor_to_skill[unit_labor::TRAPPER]] += urgency; + bias.favored[labor_to_skill[unit_labor::HUNT]] += urgency; + bias.idle_pull += urgency / 2; + break; + // creativity: artwork and crafts + case need_type::BeCreative: + case need_type::CraftObject: + case need_type::ThinkAbstractly: + for (auto l : craft_labors) + bias.favored[labor_to_skill[l]] += urgency; + break; + default: + break; + } + } + return bias; +} + +// --------------------------------------------------------------------------- +// main cycle +// --------------------------------------------------------------------------- + +void ModernEngine::update(color_ostream &out) +{ + if (!world || !world->map.block_index || !initialized) + return; + if (world->frame_counter - cycle_timestamp < CYCLE_TICKS) + return; + cycle_timestamp = world->frame_counter; + + auto bscan = scan_buildings(); + int tool_count[TOOLS_MAX]; + count_tools(tool_count); + + std::map backlog; + scan_demand(backlog); + + auto guilded = guilded_professions(); + + // members of tool details before this cycle's changes, for logging + std::set tool_members; + std::vector tool_details; + for (auto icon : {work_detail_icon_type::MINERS, + work_detail_icon_type::WOODCUTTERS, + work_detail_icon_type::HUNTERS}) + if (auto *wd = wdm->find_builtin(icon)) + tool_details.push_back(wd); + for (auto wd : wdm->managed_details()) + if (is_tool_detail(wd)) + tool_details.push_back(wd); + for (auto wd : tool_details) + { + tool_members.insert(wd->assigned_units.begin(), + wd->assigned_units.end()); + // diff against the post-apply snapshot: our own changes were + // already logged by reconcile, so anything here is an external + // write (the game or the player) that we need to see + std::set cur(wd->assigned_units.begin(), + wd->assigned_units.end()); + auto &snap = tool_snapshot[wd]; + for (int32_t id : cur) + if (!snap.count(id)) + { + TRACE(tool_detail).print( + "tool detail {}: +unit {} EXTERNAL\n", wd->name, id); + } + for (int32_t id : snap) + if (!cur.count(id)) + { + TRACE(tool_detail).print( + "tool detail {}: -unit {} EXTERNAL\n", wd->name, id); + } + } + + // --- collect citizens --------------------------------------------------- + + std::vector units; + for (auto cre : world->units.active) + { + if (!is_assignable(cre) || cre->burrows.size() > 0) + continue; + + UnitInfo info; + info.u = cre; + info.state = get_dwarf_state(cre); + info.rating = top_skill(cre, &info.skill); + info.total = total_skill(cre); + + auto pos = unit_position_info(cre); + info.noble_penalty = pos.noble_penalty; + info.trader = pos.trader && bscan.trader_requested; + info.diplomacy = in_diplomacy_meeting(cre); + + info.excluded = info.diplomacy || info.trader || + info.state == CHILD || info.state == MILITARY || + info.state == OTHER || cre->status2.limbs_grasp_count == 0; + + info.needs = scan_needs(cre); + + // observe units fetching a tool while the engine (or the player) + // has them in a tool detail; a member=0 line with a tool + // detail_skill means the detail assignment was already lost + if (cre->job.current_job && + cre->job.current_job->job_type == df::job_type::PickupEquipment) + { + auto tk = unit_track.find(cre->id); + df::job_skill pinned = tk != unit_track.end() ? + tk->second.detail_skill : job_skill::NONE; + bool member = tool_members.count(cre->id) > 0; + if (member || is_tool_skill(pinned)) + { + TRACE(tool_detail).print( + "unit {} ({}) PickupEquipment: detail_skill={} member={}\n", + cre->id, Units::getReadableName(cre), + pinned == job_skill::NONE ? "NONE" : + ENUM_KEY_STR(job_skill, pinned), + (int)member); + } + } + + probe_labor_observation(cre); + + units.push_back(info); + } + + if (units.empty()) + return; + + int n = units.size(); + std::map unit_idx; + for (int i = 0; i < n; i++) + unit_idx[units[i].u->id] = i; + + // --- priority weights from the balance slider --------------------------- + + double starve_w = double(NUM_BALANCE_STOPS - 1 - balance()) / (NUM_BALANCE_STOPS - 1); + double skill_w = double(balance()) / (NUM_BALANCE_STOPS - 1); + + // skills that map to a managed labor AND are actually used by the fort + // (per posting history); a citizen whose top skill has no labor (e.g. + // teaching) or whose skill is never exercised (gelders, animal + // dissectors in most forts) stays a generalist rather than getting a + // reserved detail + std::set viable_skills; + FOR_ENUM_ITEMS(unit_labor, l) + if (l != unit_labor::NONE && managed_labor_cache[l] && !is_unskilled(l) && + skill_usage[labor_to_skill[l]] >= 0.5) + viable_skills.insert(labor_to_skill[l]); + + { + std::string vs; + for (auto s : viable_skills) + { + if (!vs.empty()) + vs += ", "; + vs += ENUM_KEY_STR(job_skill, s); + } + TRACE(assign).print("viable skills ({}): {}\n", viable_skills.size(), vs); + } + + auto skill_allowed = [&](df::job_skill s) { + if (s == labor_to_skill[unit_labor::FISH]) + return config_flag(CF_ALLOW_FISHING) && bscan.has_fishery; + if (s == labor_to_skill[unit_labor::HUNT]) + return config_flag(CF_ALLOW_HUNTING) && bscan.has_butchers; + return true; + }; + + // units pinned to a skill detail keep the pin while work remains -- + // the laborer pool must not pull them out of their detail and abort + // a claimed job (or make a tool user drop their tool). demand is the + // gate, not viability: a freshly drafted unit has no usage history yet + std::set pinned; + for (auto &kv : unit_track) + { + df::job_skill pin = kv.second.detail_skill; + if (pin == job_skill::NONE || !skill_allowed(pin)) + continue; + auto it = unit_idx.find(kv.first); + if (it == unit_idx.end()) + continue; + UnitInfo &info = units[it->second]; + if (!info.excluded && skill_demand_remains(pin, info.u)) + pinned.insert(kv.first); + } + + // --- laborer pool sizing ------------------------------------------------ + + int unskilled_backlog = 0; + FOR_ENUM_ITEMS(unit_labor, l) + { + if (l == unit_labor::NONE || !is_unskilled(l)) + continue; + if (!managed_labor_cache[l]) + continue; + int count = backlog[l]; + if (unclaimed_streak[l] >= STARVE_CYCLES) + count *= 2; // starving labors get doubled weight + unskilled_backlog += count; + } + + int laborer_target = 1 + unskilled_backlog / 2 + + int(std::round(n * 0.25 * starve_w)); + laborer_target = std::min(laborer_target, n); + + // --- pick laborers ------------------------------------------------------ + // pool score: lower is a better laborer; skilled dwarves are protected in + // proportion to the skill-priority weight + + std::vector order; + std::map pool_score; + for (int i = 0; i < n; i++) + { + if (units[i].excluded) + continue; + int score = 0; + switch (units[i].state) { + case IDLE: score = 0; break; + case BUSY: score = 200; break; + case EXCLUSIVE: score = 400; break; + default: score = 800; break; + } + score += units[i].total * 10; + // skilled specialists are protected in proportion to the skill + // weight; quality-affecting and frequently-used skills count extra + // since losing their time costs the fort more + int prot = units[i].rating * 40; + if (is_quality_skill(units[i].skill)) + prot = prot * 3 / 2; + prot += usage_bonus(units[i].skill); + score += int(prot * skill_w * 4); + score += units[i].noble_penalty / 100; + // units whose unmet needs are satisfied by hauling/patient care get + // pulled in; units needing social downtime get pushed out + score -= units[i].needs.laborer_pull * 4; + score += units[i].needs.idle_pull * 4; + // permitted workers stay available for their shop's jobs rather + // than getting drafted into the laborer pool + if (permitted_anywhere.count(units[i].u->id)) + score += 150; + pool_score[i] = score; + order.push_back(i); + } + std::sort(order.begin(), order.end(), + [&](int a, int b) { return pool_score[a] < pool_score[b]; }); + + std::set laborer_ids; + for (int i = 0; i < (int)order.size() && (int)laborer_ids.size() < laborer_target; i++) + if (!pinned.count(units[order[i]].u->id)) + laborer_ids.insert(units[order[i]].u->id); + + // --- specialists -------------------------------------------------------- + // every remaining citizen with a usable skill gets pinned to that skill's + // detail; guilded specialists rotate through rest periods + + std::map> skill_members; // skill -> unit ids + std::set specialist_ids; + stat_resting = 0; + + for (int i = 0; i < n; i++) + { + UnitInfo &info = units[i]; + + df::job_skill prev = job_skill::NONE; + auto tk = unit_track.find(info.u->id); + if (tk != unit_track.end()) + prev = tk->second.detail_skill; + + if (info.excluded || laborer_ids.count(info.u->id)) + { + if (tk != unit_track.end()) + tk->second.detail_skill = job_skill::NONE; + TRACE(assign).print( + "unit {} ({}): skipped, excluded={} laborer={}\n", + info.u->id, Units::getReadableName(info.u), + (int)info.excluded, (int)laborer_ids.count(info.u->id)); + continue; + } + + // pick the specialty. normally the unit's top skill; need-favored + // skills and fort usage stats can pull the pick elsewhere. + // mood-shaping: a unit that can still have a strange mood and has + // started a moodable craft gets steered toward moodable skills -- + // preferably valuable ones -- so a mood pays out in a skill the + // fort wants. needs a caste STRANGE_MOODS token and no spent mood. + bool mood_prot = + Units::casteFlagSet(info.u->race, info.u->caste, + df::caste_raw_flags::STRANGE_MOODS) && + !info.u->flags1.bits.had_mood && has_moodable_skill(info.u); + + df::job_skill chosen = job_skill::NONE; + + // assignment stickiness: a unit holding a skill detail keeps it + // while work for that skill remains, so claimed jobs aren't + // aborted by a rescore. the demand test covers the PickupEquipment + // window so tool users don't drop and re-equip mid-fetch + if (prev != job_skill::NONE && skill_allowed(prev)) + { + bool demand = skill_demand_remains(prev, info.u); + if (demand) + chosen = prev; + if (is_tool_skill(prev)) + { + TRACE(tool_detail).print( + "unit {} ({}) tool pin {}: allowed=1 demand={} job={} -> {}\n", + info.u->id, Units::getReadableName(info.u), + ENUM_KEY_STR(job_skill, prev), (int)demand, + info.u->job.current_job ? + ENUM_KEY_STR(job_type, + info.u->job.current_job->job_type) : "none", + chosen == prev ? "kept" : "released"); + } + else + { + TRACE(assign).print( + "unit {} ({}) pin {}: demand={} job={} -> {}\n", + info.u->id, Units::getReadableName(info.u), + ENUM_KEY_STR(job_skill, prev), (int)demand, + info.u->job.current_job ? + ENUM_KEY_STR(job_type, + info.u->job.current_job->job_type) : "none", + chosen == prev ? "kept" : "released"); + } + } + + int best_score = -1; + auto consider = [&](df::job_skill want, int bonus) { + if (want == job_skill::NONE || !viable_skills.count(want) || + !skill_allowed(want)) + return; + int r = rating_in_skill(info.u, want); + int score = r * 100 + bonus + usage_bonus(want) + + (is_quality_skill(want) ? r * 20 : 0); + // permitted workers are dispreferred for specialties their + // shop doesn't need and preferred for the ones it does + if (permitted_anywhere.count(info.u->id)) + score += shop_reserved[info.u->id].count(want) ? 150 : -150; + if (score > best_score) + { + best_score = score; + chosen = want; + } + }; + + if (chosen == job_skill::NONE) + { + if (mood_prot) + { + // candidates restricted to moodable skills; quality-affecting + // skills get a large bonus so the pick trends toward valuable + // mood outcomes, and need-favored moodable skills still count + for (auto l : moodable_labors) + { + df::job_skill want = labor_to_skill[l]; + int bonus = is_quality_skill(want) ? 300 : 100; + auto it = info.needs.favored.find(want); + if (it != info.needs.favored.end()) + bonus += it->second * 20; + consider(want, bonus); + } + } + else + { + if (info.skill != job_skill::NONE && info.rating > 0) + consider(info.skill, 0); + for (auto &kv : info.needs.favored) + consider(kv.first, kv.second * 20); + } + } + if (chosen == job_skill::NONE) + { + // drop any stale tool-labor pin + if (tk != unit_track.end()) + tk->second.detail_skill = job_skill::NONE; + TRACE(assign).print( + "unit {} ({}): no specialty (top={} rating={} mood_prot={})\n", + info.u->id, Units::getReadableName(info.u), + info.skill == job_skill::NONE ? "NONE" : + ENUM_KEY_STR(job_skill, info.skill), + info.rating, (int)mood_prot); + continue; + } + TRACE(assign).print("unit {} ({}): specialist {}\n", + info.u->id, Units::getReadableName(info.u), + ENUM_KEY_STR(job_skill, chosen)); + + specialist_ids.insert(info.u->id); + + // guild-hall rest rotation; units with unmet social/helping needs + // earn rest (and the chance to teach or learn) sooner + SpecTrack &track = unit_track[info.u->id]; + bool guilded_unit = is_guilded(chosen, guilded); + if (guilded_unit) + { + int busy_limit = std::max(4, BUSY_LIMIT - info.needs.idle_pull / 5); + if (info.u->job.current_job) + track.busy_streak++; + else + track.busy_streak = 0; + + if (track.rest_left > 0) + { + track.rest_left--; + stat_resting++; + continue; // resting: keep flag, join no skill detail + } + if (track.busy_streak >= busy_limit) + { + int reserve = idle_reserve(); + track.rest_left = std::max(1, + busy_limit * reserve / std::max(1, 100 - reserve)); + track.busy_streak = 0; + stat_resting++; + continue; + } + } + + skill_members[chosen].push_back(info.u->id); + track.detail_skill = chosen; + } + + // --- apprentices -------------------------------------------------------- + // sustained backlog on a skilled labor: pull in unskilled generalists so + // the job gets done and they train up + + bool any_starving = false; + FOR_ENUM_ITEMS(unit_labor, l) + if (l != unit_labor::NONE && unclaimed_streak[l] >= STARVE_CYCLES && + managed_labor_cache[l] && !is_unskilled(l)) + { + any_starving = true; + TRACE(assign).print("starving labor {}: streak {}, backlog {}, managed={}\n", + ENUM_KEY_STR(unit_labor, l), unclaimed_streak[l], backlog[l], + (int)(l < NUM_LABORS && managed_labor_cache[l])); + } + + for (int i = 0; i < n; i++) + { + UnitInfo &info = units[i]; + // laborers are eligible: a starving skilled labor outranks hauling, + // and a big unskilled backlog could otherwise fill the pool with + // everyone and leave nobody to draft + if (info.excluded || specialist_ids.count(info.u->id)) + { + if (any_starving) + { + TRACE(assign).print( + "unit {} ({}): apprentice skip, excluded={} specialist={}\n", + info.u->id, Units::getReadableName(info.u), + (int)info.excluded, + (int)specialist_ids.count(info.u->id)); + } + continue; + } + if (info.state != IDLE && info.state != BUSY) + { + if (any_starving) + { + TRACE(assign).print( + "unit {} ({}): apprentice skip, state={}\n", + info.u->id, Units::getReadableName(info.u), + (int)info.state); + } + continue; + } + + // find the most-starving skilled labor this unit could learn + df::job_skill want = job_skill::NONE; + int worst_streak = STARVE_CYCLES; + FOR_ENUM_ITEMS(unit_labor, labor) + { + if (labor == unit_labor::NONE || !managed_labor_cache[labor] || + is_unskilled(labor)) + continue; + if (unclaimed_streak[labor] < worst_streak) + continue; + df::job_skill skill = labor_to_skill[labor]; + tools_enum tool = labor_tool(labor); + if (tool != TOOL_NONE && + (int)skill_members[skill].size() >= tool_count[tool]) + continue; + if (!Units::isValidLabor(info.u, labor)) + continue; + want = skill; + worst_streak = unclaimed_streak[labor]; + } + if (want != job_skill::NONE) + { + skill_members[want].push_back(info.u->id); + laborer_ids.erase(info.u->id); + specialist_ids.insert(info.u->id); // restrict them to the detail + // record the pin so tool-labor drafts survive the fetch cycle: + // once they claim the posting it leaves the unclaimed backlog + // and this pass would not pick them again + unit_track[info.u->id].detail_skill = want; + TRACE(assign).print("unit {} ({}): apprentice {}\n", + info.u->id, Units::getReadableName(info.u), + ENUM_KEY_STR(job_skill, want)); + } + else if (any_starving) + { + TRACE(assign).print( + "unit {} ({}): eligible but no draftable starving labor\n", + info.u->id, Units::getReadableName(info.u)); + } + } + + // starving labors that still have no members in their skill detail + FOR_ENUM_ITEMS(unit_labor, l) + { + if (l == unit_labor::NONE || unclaimed_streak[l] < STARVE_CYCLES || + !managed_labor_cache[l] || is_unskilled(l)) + continue; + df::job_skill sk = labor_to_skill[l]; + if (skill_members[sk].empty()) + { + int eligible = 0; + for (auto &u : units) + if (!u.excluded && Units::isValidLabor(u.u, l)) + eligible++; + TRACE(assign).print( + "starving labor {}: no units drafted into {} " + "({} eligible citizens)\n", + ENUM_KEY_STR(unit_labor, l), ENUM_KEY_STR(job_skill, sk), + eligible); + } + } + + // --- workshop restrictions ---------------------------------------------- + // a posting at a shop with a permitted-worker list needs a listed worker + // holding the job's labor; a nondefault skill range needs an in-range + // holder. only managed skilled labors need enforcement -- unskilled and + // unmanaged labors stay available to everyone via the EverybodyDoesThis + // details + + for (auto &need : shop_needs) + { + df::unit_labor labor = need.labor; + if (labor < 0 || labor >= NUM_LABORS || !managed_labor_cache[labor] || + is_unskilled(labor)) + continue; + df::job_skill skill = labor_to_skill[labor]; + if (skill == job_skill::NONE) + continue; + + auto &members = skill_members[skill]; + auto is_member = [&](int32_t uid) { + return std::find(members.begin(), members.end(), uid) != + members.end(); + }; + auto in_range = [&](int i) { + int r = rating_in_skill(units[i].u, skill); + return r >= need.min_level && r <= need.max_level; + }; + + bool permit_ok = need.permitted.empty(); + bool range_ok = need.min_level <= 0 && + need.max_level >= df::skill_rating::Legendary5; + TRACE(assign).print( + "shop need: labor {}, permitted={}, range [{},{}], members={}\n", + ENUM_KEY_STR(unit_labor, labor), need.permitted.size(), + need.min_level, need.max_level, members.size()); + for (auto uid : members) + { + auto it = unit_idx.find(uid); + if (it == unit_idx.end()) + continue; + if (!permit_ok && need.permitted.count(uid)) + permit_ok = true; + if (!range_ok && in_range(it->second)) + range_ok = true; + } + if (permit_ok && range_ok) + continue; + + // pick the best qualifying citizen; satisfy each constraint with a + // unit that also covers the other one when possible + auto satisfy = [&](bool must_permit, bool must_range) { + int best = -1, best_i = -1; + for (int i = 0; i < n; i++) + { + if (units[i].excluded || is_member(units[i].u->id) || + !Units::isValidLabor(units[i].u, labor)) + continue; + if (must_permit && !need.permitted.count(units[i].u->id)) + continue; + if (must_range && !in_range(i)) + continue; + int score = rating_in_skill(units[i].u, skill) * 100; + if (need.permitted.count(units[i].u->id)) + score += 500; + if (in_range(i)) + score += 300; + if (score > best) + { + best = score; + best_i = i; + } + } + if (best_i < 0) + { + TRACE(assign).print( + "shop need {}: no qualifying citizen (permit={} range={})\n", + ENUM_KEY_STR(unit_labor, labor), (int)must_permit, + (int)must_range); + return; + } + int32_t uid = units[best_i].u->id; + TRACE(assign).print("shop need {}: drafted unit {} ({})\n", + ENUM_KEY_STR(unit_labor, labor), uid, + Units::getReadableName(units[best_i].u)); + members.push_back(uid); + specialist_ids.insert(uid); + unit_track[uid].detail_skill = skill; + // shop work takes priority over the laborer pool + laborer_ids.erase(uid); + }; + + if (!permit_ok) + satisfy(true, false); + // the permitted pick may have covered the range too + if (!range_ok) + { + for (auto uid : members) + { + auto it = unit_idx.find(uid); + if (it != unit_idx.end() && in_range(it->second)) + { + range_ok = true; + break; + } + } + if (!range_ok) + satisfy(false, true); + } + } + + // --- apply -------------------------------------------------------------- + + std::set specialized_ids = laborer_ids; + specialized_ids.insert(specialist_ids.begin(), specialist_ids.end()); + + // Laborers: unskilled labors for the pool (Everybody so generalists keep + // them too; membership is what matters for flag-restricted units) + auto *wd_laborers = wdm->ensure_detail(LABORER_DETAIL, + work_detail_icon_type::HAULERS, work_detail_mode::EverybodyDoesThis); + FOR_ENUM_ITEMS(unit_labor, l) + if (l != unit_labor::NONE && is_unskilled(l) && managed_labor_cache[l]) + wdm->cover(wd_laborers, l, true); + reconcile(wdm, wd_laborers, laborer_ids); + + // Care: safety labors for all specialized units + auto *wd_care = wdm->ensure_detail(CARE_DETAIL, + work_detail_icon_type::ORDERLIES, work_detail_mode::EverybodyDoesThis); + FOR_ENUM_ITEMS(unit_labor, l) + if (l != unit_labor::NONE && is_care_labor(l) && managed_labor_cache[l]) + wdm->cover(wd_care, l, true); + reconcile(wdm, wd_care, specialized_ids); + + // per-skill details + std::set live_skill_details; + for (auto &kv : skill_members) + { + df::job_skill skill = kv.first; + std::set members(kv.second.begin(), kv.second.end()); + if (members.empty()) + continue; + + // in v50 only the predefined details trigger tool equipping, so + // tool-skill specialists go into the builtin Miners/Woodcutters/ + // Hunters detail rather than a managed one (falls back to a + // managed detail if the builtin is somehow missing) + if (is_tool_skill(skill)) + { + if (auto *wd = wdm->find_builtin(skill_icon(skill))) + { + reconcile_builtin(wdm, wd, members); + continue; + } + } + + const char *cap = ENUM_ATTR(job_skill, caption_noun, skill); + std::string name = SKILL_PREFIX + (cap ? cap : ENUM_KEY_STR(job_skill, skill)); + live_skill_details.insert(name); + TRACE(assign).print("detail {}: {} members\n", name, members.size()); + + auto *wd = wdm->ensure_detail(name, skill_icon(skill), + work_detail_mode::OnlySelectedDoesThis); + FOR_ENUM_ITEMS(unit_labor, l) + if (l != unit_labor::NONE && managed_labor_cache[l] && + labor_to_skill[l] == skill) + wdm->cover(wd, l, true); + reconcile(wdm, wd, members); + } + + // delete skill details that no longer have members + for (auto wd : wdm->managed_details()) + if (wd->name.compare(0, SKILL_PREFIX.size(), SKILL_PREFIX) == 0 && + !live_skill_details.count(wd->name)) + wdm->delete_detail(wd); + + // specialization flags + for (int i = 0; i < n; i++) + wdm->set_specialized(units[i].u, specialized_ids.count(units[i].u->id)); + // also clear flags on units no longer assignable (burrows, gone military) + for (int32_t id : std::set(wdm->flagged_units())) + if (auto u = df::unit::find(id)) + if (!is_assignable(u) || u->burrows.size() > 0) + wdm->set_specialized(u, false); + + wdm->commit(); + + // snapshot tool-detail memberships post-apply so next cycle's diff + // only flags writes that happened outside the engine + for (auto wd : tool_details) + tool_snapshot[wd] = std::set(wd->assigned_units.begin(), + wd->assigned_units.end()); + + // --- labor mapping mismatch escalation --------------------------------- + // A posting that starves while an eligible unit holds its mapped labor + // suggests the mapping is wrong (or every enabled holder is somehow + // ineligible). Warn once, then enable labors on idle dwarves one per + // cycle until the job is claimed; whichever labor unlocks it exposes the + // labor the game actually gates the job on. + + FOR_ENUM_ITEMS(unit_labor, l) + { + // tool labors are never probed: enabling one would send the unit + // chasing a pick/axe/crossbow it doesn't need. and a posting must be + // at least 50 ticks old before it can count as starved: some of the + // game's labor assignment passes only run on 50-tick boundaries + if (l == unit_labor::NONE || !managed_labor_cache[l] || + is_unskilled(l) || is_exclusive_labor(l) || backlog[l] == 0 || + unclaimed_streak[l] < STARVE_CYCLES || unclaimed_age[l] < 50 || + labor_escalations.count(l) || !unclaimed_job.count(l)) + continue; + // if every unit holding the labor is busy on another job, the + // posting is starved by staffing, not by a mapping problem -- + // only probe when an enabled holder is actually free to take it + int enabled = 0, idle_enabled = 0; + for (auto &info : units) + if (!info.excluded && info.u->status.labors[l]) + { + enabled++; + if (info.state == IDLE) + idle_enabled++; + } + if (!idle_enabled) + continue; + df::job *j = unclaimed_job[l]; + // custom reactions map through the reaction's declared skill, which + // the game always honors; there is nothing for probing to discover. + // PlantSeeds/HarvestPlants are likewise confirmed mappings (PLANT) + if (j->job_type == df::job_type::CustomReaction || + j->job_type == df::job_type::PlantSeeds || + j->job_type == df::job_type::HarvestPlants) + continue; + // a posting that already finished a probe round can never be + // claimed; starting another escalation for it would probe forever + if (probed_jobs.count(j->id)) + continue; + LaborEscalation esc; + esc.job_id = j->id; + esc.jtype = j->job_type; + esc.job_name = job_display_name(j); + // test the mapped labor on an idle dwarf first: distinguishes + // "wrong labor" from "right labor, but every holder was busy" + esc.candidates.push_back(l); + // the disputed set follows; unmanaged labors belong to the player's + // own work details, so the probe must not touch them + for (auto m : disputed_labors) + if (m != l && managed_labor_cache[m] && !is_exclusive_labor(m)) + esc.candidates.push_back(m); + INFO(labor_probe).print( + "possible labor mapping mismatch: posting {} ({}) maps to {} " + "and {} eligible unit(s) have it enabled ({} idle), yet it has " + "gone unclaimed for {} cycles; probing labors on idle dwarves\n", + j->id, esc.job_name, + ENUM_KEY_STR(unit_labor, l), enabled, idle_enabled, + unclaimed_streak[l]); + labor_escalations[l] = esc; + } + + for (auto it = labor_escalations.begin(); it != labor_escalations.end();) + { + df::unit_labor l = it->first; + LaborEscalation &esc = it->second; + + // job pointers aren't stable across cycles; re-resolve each time + df::job *j = nullptr; + bool posted = false; + for (auto jp : world->jobs.postings) + if (jp->job && jp->job->id == esc.job_id && !jp->flags.bits.dead) + { + j = jp->job; + posted = true; + break; + } + if (!j) + for (auto jl = world->jobs.list.next; jl; jl = jl->next) + if (jl->item && jl->item->id == esc.job_id) + { + j = jl->item; + break; + } + + if (posted && j && !has_worker(j)) + { + // still waiting: re-assert experimental labors (a work-detail + // recompute can clear status.labors between our writes), then + // put the next untried candidate on its own idle dwarf so the + // labor that unlocks the job is unambiguous + for (auto wi = esc.writes.begin(); wi != esc.writes.end();) + { + if (auto u = df::unit::find(wi->first)) + { + // a unit that already claimed a job can't take the + // target posting anyway; re-asserting only lets it + // chain more work its details don't cover + if (!u->job.current_job) + for (auto m : wi->second) + u->status.labors[m] = true; + ++wi; + } + else + wi = esc.writes.erase(wi); + } + + if (esc.next < esc.candidates.size()) + { + // specialists are pinned to a skill detail; probing one + // would enable work its detail is meant to forbid + df::unit *idle = nullptr; + for (auto &info : units) + if (info.state == IDLE && !info.excluded && + !specialist_ids.count(info.u->id) && + !esc.writes.count(info.u->id)) + { + idle = info.u; + break; + } + // if no idle dwarf is free to experiment on, next stays put + // and we retry next cycle + if (idle) + { + df::unit_labor m = esc.candidates[esc.next++]; + idle->status.labors[m] = true; + esc.writes[idle->id].insert(m); + TRACE(assign).print( + "mismatch probe: enabled {} on unit {} ({}) for job " + "{} (mapped {})\n", + ENUM_KEY_STR(unit_labor, m), idle->id, + Units::getReadableName(idle), esc.job_id, + ENUM_KEY_STR(unit_labor, l)); + } + ++it; + } + else + { + WARN(labor_probe).print( + "job {} ({}) still unclaimed after probing all disputed " + "labors (mapped {}); the gating labor may lie outside " + "the probed set\n", + esc.job_id, esc.job_name, + ENUM_KEY_STR(unit_labor, l)); + probed_jobs.insert(esc.job_id); + revert_escalation(esc); + it = labor_escalations.erase(it); + } + continue; + } + + // resolved one way or another + if (j && has_worker(j)) + { + df::unit *worker = nullptr; + for (auto ref : j->general_refs) + if (ref->getType() == df::general_ref_type::UNIT_WORKER) + { + worker = df::unit::find( + ((df::general_ref_unit_workerst*)ref)->unit_id); + break; + } + std::set exp; + if (worker && esc.writes.count(worker->id)) + exp = esc.writes[worker->id]; + + std::stringstream es; + bool comma = false; + if (worker) + FOR_ENUM_ITEMS(unit_labor, wl) + { + if (wl == unit_labor::NONE || !worker->status.labors[wl]) + continue; + if (comma) + es << ','; + es << ENUM_KEY_STR(unit_labor, wl); + comma = true; + } + std::string enabled = comma ? es.str() : "NONE"; + + std::stringstream xs; + bool xcomma = false; + for (auto m : exp) + { + if (xcomma) + xs << ','; + xs << ENUM_KEY_STR(unit_labor, m); + xcomma = true; + } + std::string experiment = xcomma ? xs.str() : "none"; + + if (worker && worker->status.labors[l]) + { + // claimant holds the mapped labor: the mapping is fine and + // the starvation was eligibility/staffing, not a mis-map + INFO(labor_probe).print( + "job {} ({}) was claimed by unit {} ({}) holding the " + "mapped labor {}; not a mismatch (experimental labors " + "on claimant: {}; enabled: {{{}}})\n", + esc.job_id, esc.job_name, + worker->id, Units::getReadableName(worker), + ENUM_KEY_STR(unit_labor, l), experiment, enabled); + } + else if (worker) + { + WARN(labor_probe).print( + "labor mapping mismatch confirmed: job {} ({}) was " + "claimed by unit {} ({}) which does not hold the mapped " + "labor {}; enabled labors: {{{}}}, experimental labors " + "on claimant: {}\n", + esc.job_id, esc.job_name, + worker->id, Units::getReadableName(worker), + ENUM_KEY_STR(unit_labor, l), enabled, experiment); + } + else + { + INFO(labor_probe).print( + "job {} ({}) was claimed but its worker could not be " + "identified (mapped {})\n", + esc.job_id, esc.job_name, + ENUM_KEY_STR(unit_labor, l)); + } + } + else + { + WARN(labor_probe).print( + "job {} ({}) left the board unclaimed while probing its " + "mapped labor {}\n", + esc.job_id, esc.job_name, + ENUM_KEY_STR(unit_labor, l)); + } + + probed_jobs.insert(esc.job_id); + revert_escalation(esc); + it = labor_escalations.erase(it); + } + + save_state(); + + stat_laborers = laborer_ids.size(); + stat_specialists = specialist_ids.size(); + stat_generalists = n - stat_laborers - stat_specialists; + + DEBUG(modern_cycle, out).print( + "cycle: {} units, {} laborers, {} specialists ({} resting), backlog={}\n", + n, stat_laborers, stat_specialists, stat_resting, unskilled_backlog); +} + +// reconcile a detail's membership with the desired id set +static void reconcile(WorkDetailManager *wdm, df::work_detail *wd, + const std::set &desired) +{ + bool trace = is_tool_detail(wd); + // remove stale members + for (auto it = wd->assigned_units.begin(); it != wd->assigned_units.end();) + { + if (!desired.count(*it)) + { + int32_t id = *it; + it = wd->assigned_units.erase(it); + if (auto u = df::unit::find(id)) + { + if (trace) + { + TRACE(tool_detail).print( + "tool detail {}: -unit {} ({})\n", + wd->name, id, Units::getReadableName(u)); + } + wdm->touch(u); + } + } + else + ++it; + } + // add new members + for (int32_t id : desired) + { + bool had = std::binary_search(wd->assigned_units.begin(), + wd->assigned_units.end(), id); + wdm->set_membership(wd, id, true); + if (trace && !had) + if (auto u = df::unit::find(id)) + { + TRACE(tool_detail).print( + "tool detail {}: +unit {} ({})\n", + wd->name, id, Units::getReadableName(u)); + } + } +} + +// reconcile a builtin detail's membership with the desired id set, but +// only ever remove memberships we created: player-assigned members of +// builtin details are left alone +static void reconcile_builtin(WorkDetailManager *wdm, df::work_detail *wd, + const std::set &desired) +{ + for (auto it = wd->assigned_units.begin(); it != wd->assigned_units.end();) + { + int32_t id = *it; + if (!desired.count(id) && wdm->is_borrowed(wd, id)) + { + it = wd->assigned_units.erase(it); + wdm->unborrow(wd, id); + if (auto u = df::unit::find(id)) + { + TRACE(tool_detail).print( + "tool detail {}: -unit {} ({})\n", + wd->name, id, Units::getReadableName(u)); + wdm->touch(u); + } + } + else + ++it; + } + for (int32_t id : desired) + { + if (!std::binary_search(wd->assigned_units.begin(), + wd->assigned_units.end(), id)) + { + wdm->set_membership(wd, id, true); + wdm->borrow(wd, id); + if (auto u = df::unit::find(id)) + { + TRACE(tool_detail).print( + "tool detail {}: +unit {} ({})\n", + wd->name, id, Units::getReadableName(u)); + } + } + } +} + +static df::work_detail_icon_type skill_icon(df::job_skill skill) +{ + df::unit_labor l = (df::unit_labor)ENUM_ATTR(job_skill, labor, skill); + switch (l) { + case unit_labor::MINE: return work_detail_icon_type::MINERS; + case unit_labor::CUTWOOD: return work_detail_icon_type::WOODCUTTERS; + case unit_labor::HUNT: return work_detail_icon_type::HUNTERS; + case unit_labor::PLANT: return work_detail_icon_type::PLANTERS; + case unit_labor::FISH: return work_detail_icon_type::FISHERMEN; + case unit_labor::HERBALIST: return work_detail_icon_type::PLANT_GATHERERS; + case unit_labor::SIEGEOPERATE: return work_detail_icon_type::SIEGE_OPERATORS; + default: return work_detail_icon_type::CUSTOM_1; + } +} + +// --------------------------------------------------------------------------- +// state dump ("labormanager dump") -- diagnostic snapshot for bug reports +// --------------------------------------------------------------------------- + +namespace autolabor { + +void dump_engine_state(color_ostream &out) +{ + if (!world || !world->map.block_index) + { + out.print("no map loaded\n"); + return; + } + if (!labor_mapper) + labor_mapper = new JobLaborMapper(); + init_labor_to_skill(); + if (managed_labor_cache.empty()) + { + managed_labor_cache.assign(NUM_LABORS, true); + FOR_ENUM_ITEMS(unit_labor, l) + if (l != unit_labor::NONE) + managed_labor_cache[l] = labor_managed(l); + } + + // -- config -------------------------------------------------------------- + out.print("-- config --\n"); + out.print("balance={} idle_reserve={} fishing={} hunting={}\n", + balance_stop_name(balance()), idle_reserve(), + (int)config_flag(CF_ALLOW_FISHING), (int)config_flag(CF_ALLOW_HUNTING)); + out.print("automatic_professions_disabled={}\n", + (int)game->external_flag.bits.automatic_professions_disabled); + + // -- every job on the board, whether or not it has a posting ------------- + out.print("-- jobs.list --\n"); + int njobs = 0; + for (df::job_list_link *link = world->jobs.list.next; link; + link = link->next) + { + df::job *j = link->item; + if (!j) + continue; + njobs++; + df::unit_labor labor = labor_mapper->find_job_labor(j); + std::string btype = "none"; + if (df::building *bld = Job::getHolder(j)) + btype = ENUM_KEY_STR(building_type, bld->getType()); + out.print( + " job {} {}: labor={} posting={} susp={} lost={} work={} " + "repeat={} worker={} at {}\n", + j->id, ENUM_KEY_STR(job_type, j->job_type), + labor >= 0 && labor < NUM_LABORS ? + ENUM_KEY_STR(unit_labor, labor) : "unmapped", + j->posting_index, (int)j->flags.bits.suspend, + (int)j->flags.bits.item_lost, (int)j->flags.bits.working, + (int)j->flags.bits.repeat, (int)has_worker(j), btype); + } + out.print(" {} jobs total\n", njobs); + + // -- the auction board ---------------------------------------------------- + out.print("-- postings --\n"); + int live = 0, dead = 0; + for (auto jp : world->jobs.postings) + { + if (jp->flags.bits.dead || !jp->job) + { + dead++; + continue; + } + live++; + df::job *j = jp->job; + out.print(" posting {}: {} susp={} lost={} worker={} age={}\n", + jp->idx, ENUM_KEY_STR(job_type, j->job_type), + (int)j->flags.bits.suspend, (int)j->flags.bits.item_lost, + (int)has_worker(j), + posting_track.count(jp->idx) ? + world->frame_counter - posting_track[jp->idx].first_seen : -1); + } + out.print(" {} live, {} dead\n", live, dead); + + // -- demand --------------------------------------------------------------- + out.print("-- demand --\n"); + FOR_ENUM_ITEMS(unit_labor, l) + { + if (l == unit_labor::NONE) + continue; + int claimed = labor_work.count(l) ? labor_work.at(l) : 0; + if (claimed == 0 && unclaimed_streak[l] == 0) + continue; + out.print(" {}: live={}, streak={}, managed={} ({})\n", + ENUM_KEY_STR(unit_labor, l), claimed, unclaimed_streak[l], + (int)managed_labor_cache[l], + is_unskilled(l) ? "unskilled" : + ENUM_KEY_STR(job_skill, labor_to_skill[l])); + } + out.print("starving={} oldest={} ({} ticks), resolved={}, " + "avg_wait={}, max_wait={}\n", + stat_starving, + stat_oldest_job == df::job_type::NONE ? "none" : + ENUM_KEY_STR(job_type, stat_oldest_job), + stat_oldest_wait, stat_resolved, + stat_resolved ? stat_resolved_ticks / stat_resolved : 0, + stat_max_wait); + + // -- skills --------------------------------------------------------------- + out.print("-- skills --\n"); + out.print("usage:"); + for (auto &kv : skill_usage) + if (kv.second >= 0.01) + out.print(" {}={:.1f}", ENUM_KEY_STR(job_skill, kv.first), + kv.second); + out.print("\n"); + + // -- shop constraints ------------------------------------------------------ + if (!shop_needs.empty()) + { + out.print("-- shop needs --\n"); + for (auto &need : shop_needs) + out.print(" {}: permitted={} range=[{},{}]\n", + ENUM_KEY_STR(unit_labor, need.labor), need.permitted.size(), + need.min_level, need.max_level); + } + + // -- tools ------------------------------------------------------------------ + int tool_count[TOOLS_MAX]; + count_tools(tool_count); + out.print("tools: picks={} axes={} crossbows={}\n", + tool_count[TOOL_PICK], tool_count[TOOL_AXE], tool_count[TOOL_CROSSBOW]); + + // -- work details ------------------------------------------------------------- + out.print("-- work details --\n"); + for (auto wd : plotinfo->labor_info.work_details) + { + std::string members; + for (int32_t id : wd->assigned_units) + { + if (!members.empty()) + members += ","; + members += std::to_string(id); + } + out.print(" '{}' icon={} mode={} no_modify={} managed={} " + "members=[{}]\n", + wd->name, ENUM_KEY_STR(work_detail_icon_type, wd->icon), + (int)wd->flags.bits.mode, (int)wd->flags.bits.no_modify, + (int)(wd->name.compare(0, 5, "auto:") == 0), members); + } + + // -- citizens ------------------------------------------------------------------ + out.print("-- citizens --\n"); + for (auto cre : world->units.active) + { + if (!Units::isCitizen(cre)) + continue; + // which details hold this unit + std::string det; + for (auto wd : plotinfo->labor_info.work_details) + if (std::binary_search(wd->assigned_units.begin(), + wd->assigned_units.end(), cre->id)) + { + if (!det.empty()) + det += "|"; + det += wd->name; + } + // enabled skilled labors + std::string labors; + FOR_ENUM_ITEMS(unit_labor, l) + if (l != unit_labor::NONE && cre->status.labors[l] && + labor_to_skill[l] != job_skill::NONE) + { + if (!labors.empty()) + labors += "|"; + labors += ENUM_KEY_STR(unit_labor, l); + } + auto tk = unit_track.find(cre->id); + out.print( + " {} ({}): assignable={} burrows={} state={} specialized={} " + "pin={} busy={} rest={} job={} details=[{}] " + "skilled_labors=[{}]\n", + cre->id, Units::getReadableName(cre), + (int)is_assignable(cre), cre->burrows.size(), + (int)get_dwarf_state(cre), + (int)cre->flags4.bits.only_do_assigned_jobs, + tk != unit_track.end() && tk->second.detail_skill != job_skill::NONE ? + ENUM_KEY_STR(job_skill, tk->second.detail_skill) : "NONE", + tk != unit_track.end() ? tk->second.busy_streak : 0, + tk != unit_track.end() ? tk->second.rest_left : 0, + cre->job.current_job ? + ENUM_KEY_STR(job_type, cre->job.current_job->job_type) : "none", + det, labors); + if (!is_assignable(cre)) + out.print( + " not assignable: own_civ={} own_group={} active={} " + "visitor={} ghost={} can_assign={} profession={}\n", + (int)Units::isOwnCiv(cre), (int)Units::isOwnGroup(cre), + (int)Units::isActive(cre), (int)cre->flags2.bits.visitor, + (int)cre->flags3.bits.ghostly, + (int)ENUM_ATTR(profession, can_assign_labor, cre->profession), + ENUM_KEY_STR(profession, cre->profession)); + else if (get_dwarf_state(cre) == dwarf_state::OTHER || + get_dwarf_state(cre) == dwarf_state::CHILD) + out.print(" state detail: migrant={} specific_refs={} " + "profession={}\n", + (int)(Units::getMiscTrait(cre, misc_trait_type::Migrant) != + nullptr), + cre->specific_refs.size(), + ENUM_KEY_STR(profession, cre->profession)); + } +} + +} // namespace autolabor + +// --------------------------------------------------------------------------- +// command dialect +// --------------------------------------------------------------------------- + +bool ModernEngine::command(color_ostream &out, std::vector ¶meters) +{ + if (parameters.empty()) + return false; + + if (managed_labor_cache.empty()) + { + managed_labor_cache.assign(NUM_LABORS, true); + FOR_ENUM_ITEMS(unit_labor, l) + if (l != unit_labor::NONE) + managed_labor_cache[l] = labor_managed(l); + } + + if (parameters.size() == 2 && parameters[0] == "balance") + { + bool ok = false; + for (int i = 0; i < NUM_BALANCE_STOPS; i++) + if (parameters[1] == balance_stop_name(i)) + { + store_balance(i); + ok = true; + } + if (!ok) + { + int v = atoi(parameters[1].c_str()); + if (v >= 0 && v < NUM_BALANCE_STOPS) + { + store_balance(v); + ok = true; + } + } + if (!ok) + { + out.printerr("Syntax: labormanager balance <0-{}|{}|...|{}>\n", + NUM_BALANCE_STOPS - 1, balance_stop_name(0), + balance_stop_name(NUM_BALANCE_STOPS - 1)); + return true; + } + out << "Balance: " << balance_stop_name(balance()) << std::endl; + return true; + } + else if (parameters.size() == 2 && parameters[0] == "idle-reserve") + { + int v = atoi(parameters[1].c_str()); + if (v < 0 || v > 100) + { + out.printerr("Syntax: labormanager idle-reserve <0-100>\n"); + return true; + } + store_idle_reserve(v); + out << "Idle reserve: " << v << "%" << std::endl; + return true; + } + else if (parameters.size() == 3 && parameters[0] == "labor") + { + df::unit_labor labor = unit_labor::NONE; + FOR_ENUM_ITEMS(unit_labor, test) + if (parameters[1] == ENUM_KEY_STR(unit_labor, test)) + labor = test; + if (labor == unit_labor::NONE) + { + out.printerr("Could not find labor {}.\n", parameters[1]); + return true; + } + bool managed = parameters[2] == "managed"; + if (!managed && parameters[2] != "unmanaged") + { + out.printerr("Syntax: labormanager labor managed|unmanaged\n"); + return true; + } + set_labor_managed(labor, managed); + managed_labor_cache[labor] = managed; + wdm->touch_all(); + wdm->commit(); + out << ENUM_KEY_STR(unit_labor, labor) << ": " + << (managed ? "managed" : "unmanaged") << std::endl; + return true; + } + else if (parameters.size() == 1 && + (parameters[0] == "allow-fishing" || parameters[0] == "forbid-fishing")) + { + set_config_flag(CF_ALLOW_FISHING, parameters[0] == "allow-fishing"); + return true; + } + else if (parameters.size() == 1 && + (parameters[0] == "allow-hunting" || parameters[0] == "forbid-hunting")) + { + set_config_flag(CF_ALLOW_HUNTING, parameters[0] == "allow-hunting"); + return true; + } + else if (parameters.size() == 1 && (parameters[0] == "list" || parameters[0] == "status")) + { + out << "Balance: " << balance_stop_name(balance()) + << ", idle reserve " << idle_reserve() << "%" << std::endl; + out << status_line() << std::endl; + out << "job board: " << stat_resolved << " postings resolved"; + if (stat_resolved > 0) + out << ", avg wait " << (stat_resolved_ticks / stat_resolved) + << " ticks, longest " << stat_max_wait << " ticks"; + if (stat_starving > 0) + out << "; " << stat_starving << " starving (> " << STARVE_TICKS + << " ticks, oldest " << stat_oldest_wait << ")"; + out << std::endl; + // most-used skills drive specialization priorities + std::vector> top_used; + for (auto &kv : skill_usage) + if (kv.second >= 0.5) + top_used.push_back(kv); + std::sort(top_used.begin(), top_used.end(), + [](const std::pair &a, + const std::pair &b) { + return a.second > b.second; + }); + if (!top_used.empty()) + { + out << "top skills in use:"; + for (size_t i = 0; i < top_used.size() && i < 5; i++) + out << " " << ENUM_KEY_STR(job_skill, top_used[i].first) + << "(" << int(top_used[i].second) << ")"; + out << std::endl; + } + if (parameters[0] == "list") + { + FOR_ENUM_ITEMS(unit_labor, l) + { + if (l == unit_labor::NONE) + continue; + out << ENUM_KEY_STR(unit_labor, l) << ": " + << (managed_labor_cache[l] ? "managed" : "unmanaged"); + if (unclaimed_streak[l] >= STARVE_CYCLES) + out << " (starved " << unclaimed_streak[l] << " cycles)"; + out << std::endl; + } + } + return true; + } + + return false; +} + +// --------------------------------------------------------------------------- +// monitor mode +// --------------------------------------------------------------------------- +// shares the posting-tracking machinery and persisted state above, but +// performs no labor management -- it just watches the job board and feeds +// the task starvation notification. + +void MonitorEngine::enable(color_ostream &out) +{ + // undo the legacy engine's flag if switching straight from it, so + // vanilla work details govern labors again + game->external_flag.bits.automatic_professions_disabled = false; + + if (!labor_mapper) + labor_mapper = new JobLaborMapper(); + init_labor_to_skill(); + + posting_track.clear(); + skill_usage.clear(); + stat_starving = 0; + stat_oldest_wait = 0; + stat_oldest_job = df::job_type::NONE; + stat_oldest_reaction.clear(); + stat_resolved = 0; + stat_resolved_ticks = 0; + stat_max_wait = 0; + restore_state(); + initialized = true; + + out << "Enabling labor monitor (starvation warnings only)." << std::endl; +} + +void MonitorEngine::disable(color_ostream &out) +{ + initialized = false; + out << "Disabling labor monitor." << std::endl; +} + +void MonitorEngine::map_unload() +{ + initialized = false; + posting_track.clear(); + skill_usage.clear(); + stat_starving = 0; + stat_oldest_wait = 0; + stat_oldest_job = df::job_type::NONE; + stat_oldest_reaction.clear(); + stat_resolved = 0; + stat_resolved_ticks = 0; + stat_max_wait = 0; +} + +void MonitorEngine::update(color_ostream &out) +{ + if (!world || !world->map.block_index || !initialized) + return; + if (world->frame_counter - cycle_timestamp < CYCLE_TICKS) + return; + cycle_timestamp = world->frame_counter; + + // the demand scan does the posting-age tracking and starvation stats; + // the per-labor backlog it also fills is unused here + std::map backlog; + scan_demand(backlog); + save_state(); +} + +int MonitorEngine::starving_jobs() +{ + return initialized ? stat_starving : 0; +} + +df::job_type MonitorEngine::oldest_starving_job() +{ + return initialized && stat_starving > 0 ? stat_oldest_job + : df::job_type::NONE; +} + +int32_t MonitorEngine::oldest_starving_wait() +{ + return initialized && stat_starving > 0 ? stat_oldest_wait : 0; +} + +df::coord MonitorEngine::oldest_starving_pos() +{ + return initialized && stat_starving > 0 ? stat_oldest_pos + : df::coord(); +} + +std::string MonitorEngine::oldest_starving_name() +{ + return initialized && stat_starving > 0 ? ::oldest_starving_name() : ""; +} + +bool MonitorEngine::command(color_ostream &out, std::vector ¶meters) +{ + if (parameters.size() == 1 && + (parameters[0] == "list" || parameters[0] == "status")) + { + out << "labor monitor: " << stat_starving << " starving postings (> " + << STARVE_TICKS << " ticks"; + if (stat_starving > 0 && stat_oldest_job != df::job_type::NONE) + out << ", oldest " << ::oldest_starving_name() + << " at " << stat_oldest_wait << " ticks"; + out << ")" << std::endl; + out << stat_resolved << " postings resolved"; + if (stat_resolved > 0) + out << ", avg wait " << (stat_resolved_ticks / stat_resolved) + << " ticks, longest " << stat_max_wait << " ticks"; + out << std::endl; + return true; + } + return false; +} diff --git a/plugins/autolabor/modernengine.h b/plugins/autolabor/modernengine.h new file mode 100644 index 0000000000..4ae915fb83 --- /dev/null +++ b/plugins/autolabor/modernengine.h @@ -0,0 +1,58 @@ +#pragma once + +#include "engines.h" +#include "laborcommon.h" +#include "workdetails.h" + +#include + +// The modern engine: works through DF's work detail system instead of +// writing the labor matrix directly. It maintains plugin-owned work details +// (named "auto: ...", visible and editable in the vanilla UI) and uses the +// per-unit only_do_assigned_jobs flag to create dedicated pools: +// +// - "auto: Laborers": unskilled labors only; members are specialized so +// the game's job auction always has units that can only take unskilled +// work, preventing starvation of hauling/cleaning jobs. +// - "auto: ": one per exercised skill; members are specialized to +// jobs that train that skill. Specialists whose profession has a guild +// hall get protected idle time to participate in guild activities. +// +// The balance slider trades laborer coverage against specialist protection. +class ModernEngine : public LaborEngine { +public: + ModernEngine(WorkDetailManager *wdm) : wdm(wdm) {} + + void enable(color_ostream &out) override; + void disable(color_ostream &out) override; + void update(color_ostream &out) override; + bool command(color_ostream &out, std::vector ¶meters) override; + + void map_unload(); + + // status summary for the overlay, e.g. "3 laborers, 5 specialists" + std::string status_line(); + + // number of live job postings that have been on the board longer than + // one game day (drives the task starvation notification) + int starving_jobs(); + + // the job type, age (ticks), location, and display name of the + // longest-starving posting, for the starvation notification + df::job_type oldest_starving_job(); + int32_t oldest_starving_wait(); + df::coord oldest_starving_pos(); + std::string oldest_starving_name(); + +private: + WorkDetailManager *wdm; + bool initialized = false; +}; + +namespace autolabor { + +// prints a snapshot of the engine's observed state to the console for +// diagnostics (the 'dump' command); usable in modern or monitor mode +void dump_engine_state(color_ostream &out); + +} // namespace autolabor diff --git a/plugins/autolabor/monitorengine.h b/plugins/autolabor/monitorengine.h new file mode 100644 index 0000000000..3b0717e649 --- /dev/null +++ b/plugins/autolabor/monitorengine.h @@ -0,0 +1,34 @@ +#pragma once + +#include "engines.h" +#include "laborcommon.h" + +#include +#include + +// Monitor mode: watches the job board and feeds the task starvation +// notification, but performs no labor management at all -- everything is +// left to the player's own work details and the labor matrix. +// +// The implementation lives in modernengine.cpp so it shares the +// posting-tracking machinery (posting ages, starvation stats, skill usage +// history, persisted state) with the modern engine; switching between +// modes keeps the accumulated history. +class MonitorEngine : public LaborEngine { +public: + void enable(color_ostream &out) override; + void disable(color_ostream &out) override; + void update(color_ostream &out) override; + bool command(color_ostream &out, std::vector ¶meters) override; + + void map_unload(); + + int starving_jobs(); + df::job_type oldest_starving_job(); + int32_t oldest_starving_wait(); + df::coord oldest_starving_pos(); + std::string oldest_starving_name(); + +private: + bool initialized = false; +}; diff --git a/plugins/autolabor/workdetails.cpp b/plugins/autolabor/workdetails.cpp new file mode 100644 index 0000000000..5e86039634 --- /dev/null +++ b/plugins/autolabor/workdetails.cpp @@ -0,0 +1,324 @@ +#include "workdetails.h" + +#include + +#include + +#include "modules/Units.h" +#include "modules/World.h" + +#include +#include +#include + +using namespace DFHack; +using namespace df::enums; + +using df::global::plotinfo; +using df::global::world; + +const char * const WorkDetailManager::NAME_PREFIX = "auto:"; + +static std::vector &details() +{ + return plotinfo->labor_info.work_details; +} + +bool WorkDetailManager::is_managed(df::work_detail *wd) +{ + return wd && wd->name.compare(0, strlen(NAME_PREFIX), NAME_PREFIX) == 0; +} + +void WorkDetailManager::reset() +{ + dirty.clear(); + flagged.clear(); + borrowed.clear(); + recompute_all = false; + flagged_cfg = World::GetPersistentSiteData("autolabor/flagged"); + borrowed_cfg = World::GetPersistentSiteData("autolabor/borrowed"); + load_flagged(); + load_borrowed(); +} + +void WorkDetailManager::load_flagged() +{ + flagged.clear(); + if (!flagged_cfg.isValid()) + return; + size_t count = flagged_cfg.data_size() / PersistentDataItem::int28_size; + for (size_t i = 0; i < count; i++) + flagged.insert(flagged_cfg.get_int28(i * PersistentDataItem::int28_size)); +} + +void WorkDetailManager::save_flagged() +{ + if (!flagged_cfg.isValid()) + flagged_cfg = World::AddPersistentSiteData("autolabor/flagged"); + if (!flagged_cfg.isValid()) + return; + + size_t off = 0; + for (int32_t id : flagged) + { + flagged_cfg.set_int28(off, id); + off += PersistentDataItem::int28_size; + } + flagged_cfg.val().resize(off); +} + +void WorkDetailManager::load_borrowed() +{ + borrowed.clear(); + if (!borrowed_cfg.isValid()) + return; + size_t count = borrowed_cfg.data_size() / (2 * PersistentDataItem::int28_size); + for (size_t i = 0; i < count; i++) + { + int32_t icon = borrowed_cfg.get_int28(i * 2 * PersistentDataItem::int28_size); + int32_t uid = borrowed_cfg.get_int28((i * 2 + 1) * PersistentDataItem::int28_size); + borrowed[icon].insert(uid); + } +} + +void WorkDetailManager::save_borrowed() +{ + if (!borrowed_cfg.isValid()) + borrowed_cfg = World::AddPersistentSiteData("autolabor/borrowed"); + if (!borrowed_cfg.isValid()) + return; + + size_t off = 0; + for (auto &kv : borrowed) + for (int32_t id : kv.second) + { + borrowed_cfg.set_int28(off, kv.first); + borrowed_cfg.set_int28(off + PersistentDataItem::int28_size, id); + off += 2 * PersistentDataItem::int28_size; + } + borrowed_cfg.val().resize(off); +} + +std::vector WorkDetailManager::managed_details() +{ + std::vector out; + for (auto wd : details()) + if (is_managed(wd)) + out.push_back(wd); + return out; +} + +df::work_detail *WorkDetailManager::find_detail(const std::string &name) +{ + for (auto wd : details()) + if (is_managed(wd) && wd->name == name) + return wd; + return NULL; +} + +df::work_detail *WorkDetailManager::find_builtin(df::work_detail_icon_type icon) +{ + // builtin details occupy a no_modify prefix run in the vector (the + // same convention the work-details screen uses); matching on icon + // distinguishes Miners/Woodcutters/Hunters from the other builtins + for (auto wd : details()) + if (!is_managed(wd) && wd->flags.bits.no_modify && wd->icon == icon) + return wd; + return NULL; +} + +df::work_detail *WorkDetailManager::ensure_detail(const std::string &name, + df::work_detail_icon_type icon, df::work_detail_mode mode) +{ + if (auto wd = find_detail(name)) + return wd; + + auto wd = df::allocate(); + wd->name = name; + wd->icon = icon; + wd->flags.bits.mode = mode; + details().push_back(wd); + + // a new non-Everybody detail clears its covered labors globally + if (mode != work_detail_mode::EverybodyDoesThis) + touch_all(); + else + recompute_all = true; + + return wd; +} + +void WorkDetailManager::delete_detail(df::work_detail *wd) +{ + auto &vec = details(); + for (auto it = vec.begin(); it != vec.end(); ++it) + { + if (*it == wd) + { + for (int32_t id : wd->assigned_units) + if (auto u = df::unit::find(id)) + touch(u); + vec.erase(it); + delete wd; + touch_all(); + return; + } + } +} + +void WorkDetailManager::cover(df::work_detail *wd, df::unit_labor labor, bool on) +{ + if (labor < 0 || labor > ENUM_LAST_ITEM(unit_labor)) + return; + if (wd->allowed_labors[labor] != on) + { + wd->allowed_labors[labor] = on; + // coverage changes affect every unit, not just members + recompute_all = true; + } +} + +void WorkDetailManager::set_membership(df::work_detail *wd, int32_t unit_id, bool want) +{ + auto &vec = wd->assigned_units; + auto it = std::lower_bound(vec.begin(), vec.end(), unit_id); + bool has = (it != vec.end() && *it == unit_id); + + if (want && !has) + { + vec.insert(it, unit_id); + if (auto u = df::unit::find(unit_id)) + touch(u); + } + else if (!want && has) + { + vec.erase(it); + if (auto u = df::unit::find(unit_id)) + touch(u); + } +} + +void WorkDetailManager::set_specialized(df::unit *u, bool on) +{ + if (!u) + return; + + if (on) + { + if (!u->flags4.bits.only_do_assigned_jobs) + { + u->flags4.bits.only_do_assigned_jobs = true; + touch(u); + } + flagged.insert(u->id); + } + else + { + // only strip flags we set ourselves + if (flagged.count(u->id)) + { + flagged.erase(u->id); + if (u->flags4.bits.only_do_assigned_jobs) + { + u->flags4.bits.only_do_assigned_jobs = false; + touch(u); + } + } + } +} + +bool WorkDetailManager::is_borrowed(df::work_detail *wd, int32_t unit_id) const +{ + auto it = borrowed.find(wd ? wd->icon : -1); + return it != borrowed.end() && it->second.count(unit_id); +} + +void WorkDetailManager::borrow(df::work_detail *wd, int32_t unit_id) +{ + if (wd) + borrowed[wd->icon].insert(unit_id); +} + +void WorkDetailManager::unborrow(df::work_detail *wd, int32_t unit_id) +{ + if (wd) + borrowed[wd->icon].erase(unit_id); +} + +void WorkDetailManager::touch(df::unit *u) +{ + if (u) + dirty.insert(u->id); +} + +void WorkDetailManager::touch_all() +{ + recompute_all = true; +} + +void WorkDetailManager::commit() +{ + if (recompute_all) + { + dirty.clear(); + for (auto u : world->units.active) + if (Units::isCitizen(u)) + touch(u); + recompute_all = false; + } + + for (int32_t id : dirty) + if (auto u = df::unit::find(id)) + Units::setAutomaticProfessions(u); + dirty.clear(); + + save_flagged(); + save_borrowed(); +} + +void WorkDetailManager::shutdown() +{ + // remove all managed details + for (auto wd : managed_details()) + delete_detail(wd); + + // drop memberships we added to builtin details + for (auto wd : details()) + { + if (is_managed(wd)) + continue; + auto it = borrowed.find(wd->icon); + if (it == borrowed.end()) + continue; + auto &vec = wd->assigned_units; + for (auto uit = vec.begin(); uit != vec.end();) + { + if (it->second.count(*uit)) + { + int32_t id = *uit; + uit = vec.erase(uit); + if (auto u = df::unit::find(id)) + touch(u); + } + else + ++uit; + } + } + borrowed.clear(); + save_borrowed(); + + // clear specialization flags we set on any still-living unit + for (int32_t id : flagged) + if (auto u = df::unit::find(id)) + if (u->flags4.bits.only_do_assigned_jobs) + { + u->flags4.bits.only_do_assigned_jobs = false; + touch(u); + } + flagged.clear(); + save_flagged(); + + // detail removal affects everyone + touch_all(); + commit(); +} diff --git a/plugins/autolabor/workdetails.h b/plugins/autolabor/workdetails.h new file mode 100644 index 0000000000..aef777689d --- /dev/null +++ b/plugins/autolabor/workdetails.h @@ -0,0 +1,107 @@ +#pragma once + +#include +#include +#include +#include + +#include "modules/Persistence.h" + +#include +#include +#include +#include + +using namespace DFHack; +using namespace df::enums; + +// Manages the plugin-owned work details through which the modern engine +// expresses assignments. Managed details are identified by an "auto:" name +// prefix so the player can see (and, with caveats, edit) what the tool is +// doing in the vanilla work details screen. +// +// The plugin never writes to unit->status.labors directly; all changes go +// through work detail membership and df::unit flags4.only_do_assigned_jobs, +// followed by a call to Units::setAutomaticProfessions() which asks the game +// to recompute the labor matrix. + +class WorkDetailManager { +public: + static const char * const NAME_PREFIX; // "auto:" + + // Rescan labor_info.work_details, adopting any surviving managed details + // and reloading the flagged-unit set. Call on map load before use. + void reset(); + + // Remove all managed details, clear specialization flags we set, and + // recompute every citizen. Call when the modern engine is disabled. + void shutdown(); + + // Find a managed detail by exact name (including prefix). + df::work_detail *find_detail(const std::string &name); + + // Find a builtin (predefined, non-managed) detail by icon type, e.g. + // the vanilla Miners detail. Needed because in DF v50 only the + // predefined details trigger tool equipping for their members. + df::work_detail *find_builtin(df::work_detail_icon_type icon); + + // Find or create a managed detail with the given name, icon, and mode. + // Coverage (allowed_labors) is only set on creation; callers that change + // coverage or mode afterwards must call touch_all() since pass 1 of the + // game's labor computation clears covered labors for every unit. + df::work_detail *ensure_detail(const std::string &name, + df::work_detail_icon_type icon, df::work_detail_mode mode); + + // Delete a managed detail entirely (labor becomes unrestricted again). + void delete_detail(df::work_detail *wd); + + // Set the detail's coverage of a labor. Requires touch_all() semantics; + // we just mark every citizen dirty. + void cover(df::work_detail *wd, df::unit_labor labor, bool on); + + // Add/remove a unit from a detail's assigned_units (kept sorted). + void set_membership(df::work_detail *wd, int32_t unit_id, bool want); + + // Mark a unit as only doing assigned work (the game's "specialized" + // flag). We track which units we flagged so shutdown() doesn't strip + // flags the player set themselves. + void set_specialized(df::unit *u, bool on); + + // Queue a unit for labor recomputation at commit(). + void touch(df::unit *u); + + // Queue every citizen for recomputation; needed when detail coverage or + // mode changes since non-Everybody details clear their labors globally. + void touch_all(); + + // Apply queued recomputations via Units::setAutomaticProfessions(). + void commit(); + + // Enumerate details whose name starts with the managed prefix. + std::vector managed_details(); + + // Unit ids we have flagged as only_do_assigned_jobs. + const std::set &flagged_units() const { return flagged; } + + // Track memberships we added to builtin (non-managed) details, keyed + // by the detail's icon type, so we only ever remove memberships we + // created and never strip a player's own assignments. + bool is_borrowed(df::work_detail *wd, int32_t unit_id) const; + void borrow(df::work_detail *wd, int32_t unit_id); + void unborrow(df::work_detail *wd, int32_t unit_id); + + bool is_managed(df::work_detail *wd); + +private: + void load_flagged(); + void save_flagged(); + void load_borrowed(); + void save_borrowed(); + + std::set dirty; + std::set flagged; + std::map> borrowed; // icon -> unit ids + bool recompute_all = false; + PersistentDataItem flagged_cfg; + PersistentDataItem borrowed_cfg; +}; diff --git a/plugins/autoslab.cpp b/plugins/autoslab.cpp index 08c4c837d1..e8c06d575d 100644 --- a/plugins/autoslab.cpp +++ b/plugins/autoslab.cpp @@ -141,6 +141,7 @@ static void createSlabJob(df::unit *unit) order->specdata.hist_figure_id = unit->hist_figure_id; order->amount_left = 1; order->amount_total = 1; + order->frequency = df::workquota_frequency_type::OneTime; world->manager_orders.all.push_back(order); } diff --git a/plugins/blueprint.cpp b/plugins/blueprint.cpp index f21198c9d5..d1b7a502b4 100644 --- a/plugins/blueprint.cpp +++ b/plugins/blueprint.cpp @@ -6,6 +6,7 @@ */ #include "Console.h" +#include "Core.h" #include "DataDefs.h" #include "DataFuncs.h" #include "DataIdentity.h" @@ -59,8 +60,6 @@ using namespace DFHack; DFHACK_PLUGIN("blueprint"); REQUIRE_GLOBAL(world); -static const string BLUEPRINT_USER_DIR = "dfhack-config/blueprints/"; - namespace DFHack { DBG_DECLARE(blueprint,log); } @@ -119,10 +118,10 @@ struct blueprint_options { }; static const struct_field_info blueprint_options_fields[] = { { struct_field_info::PRIMITIVE, "help", offsetof(blueprint_options, help), &df::identity_traits::identity, 0, 0 }, - { struct_field_info::SUBSTRUCT, "start", offsetof(blueprint_options, start), &df::coord::_identity, 0, 0 }, + { struct_field_info::SUBSTRUCT, "start", offsetof(blueprint_options, start), df::identity_traits::get(), 0, 0}, { struct_field_info::PRIMITIVE, "format", offsetof(blueprint_options, format), df::identity_traits::get(), 0, 0 }, { struct_field_info::PRIMITIVE, "nometa", offsetof(blueprint_options, nometa), &df::identity_traits::identity, 0, 0 }, - { struct_field_info::SUBSTRUCT, "playback_start", offsetof(blueprint_options, playback_start), &df::coord2d::_identity, 0, 0 }, + { struct_field_info::SUBSTRUCT, "playback_start", offsetof(blueprint_options, playback_start), df::identity_traits::get(),0, 0 }, { struct_field_info::PRIMITIVE, "playback_start_comment", offsetof(blueprint_options, playback_start_comment), df::identity_traits::get(), 0, 0 }, { struct_field_info::PRIMITIVE, "split_strategy", offsetof(blueprint_options, split_strategy), df::identity_traits::get(), 0, 0 }, { struct_field_info::PRIMITIVE, "width", offsetof(blueprint_options, width), &df::identity_traits::identity, 0, 0 }, @@ -1370,9 +1369,9 @@ static const char * get_tile_zone(color_ostream &out, const df::coord &pos, cons static bool create_output_dir(color_ostream &out, const blueprint_options &opts) { - string basename = BLUEPRINT_USER_DIR + opts.name; - size_t last_slash = basename.find_last_of("/"); - string parent_path = basename.substr(0, last_slash); + std::filesystem::path BLUEPRINT_USER_DIR = Core::getInstance().getConfigPath() / "blueprints"; + std::filesystem::path basename = BLUEPRINT_USER_DIR / opts.name; + std::filesystem::path parent_path = basename.parent_path(); // create output directory if it doesn't already exist if (!Filesystem::mkdir_recursive(parent_path)) { diff --git a/plugins/buildingplan/buildingplan.cpp b/plugins/buildingplan/buildingplan.cpp index 6f5a13a8c7..8af8992450 100644 --- a/plugins/buildingplan/buildingplan.cpp +++ b/plugins/buildingplan/buildingplan.cpp @@ -13,6 +13,7 @@ #include "df/construction_type.h" #include "df/burrow.h" +#include "df/global_objects.h" #include "df/item.h" #include "df/job_item.h" #include "df/organic_mat_category.h" @@ -167,7 +168,7 @@ static void load_organic_material_cache(df::organic_mat_category cat) { static void load_material_cache() { auto &raws = world->raws; - for (int i = 1; i < DFHack::MaterialInfo::NUM_BUILTIN; ++i) + for (int i = 1; i < df::builtin_mats::CREATURE_1; ++i) if (raws.mat_table.builtin[i]) cache_matched(i, -1); diff --git a/plugins/changeitem.cpp b/plugins/changeitem.cpp index e07db44570..c5437f79a7 100644 --- a/plugins/changeitem.cpp +++ b/plugins/changeitem.cpp @@ -211,7 +211,7 @@ command_result df_changeitem(color_ostream &out, vector & parameters) // changeitem doesn't do stuff with map blocks... { MapCache MC; - Block * b = MC.BlockAt(pos_cursor / 16); + Block * b = MC.BlockAtTile(pos_cursor); if(!b) { out.printerr("Cursor is in an invalid/uninitialized area. Place it over a floor.\n"); diff --git a/plugins/cleanconst.cpp b/plugins/cleanconst.cpp index 9e05ecae46..12fa7f1293 100644 --- a/plugins/cleanconst.cpp +++ b/plugins/cleanconst.cpp @@ -2,11 +2,14 @@ // and flags the constructions to recreate their components upon disassembly #include "Console.h" +#include "DataDefs.h" #include "Export.h" #include "PluginManager.h" +#include "Types.h" + #include "modules/Maps.h" -#include "DataDefs.h" +#include "df/global_objects.h" #include "df/item.h" #include "df/world.h" #include "df/construction.h" diff --git a/plugins/createitem.cpp b/plugins/createitem.cpp index d813d8e44b..703e6a4001 100644 --- a/plugins/createitem.cpp +++ b/plugins/createitem.cpp @@ -16,7 +16,6 @@ #include "df/building.h" #include "df/caste_raw.h" #include "df/creature_raw.h" -#include "df/game_type.h" #include "df/item.h" #include "df/plant_growth.h" #include "df/plant_raw.h" @@ -31,7 +30,6 @@ using namespace df::enums; DFHACK_PLUGIN("createitem"); REQUIRE_GLOBAL(world); -REQUIRE_GLOBAL(gametype); REQUIRE_GLOBAL(cur_year_tick); int dest_container = -1, dest_building = -1; @@ -403,9 +401,8 @@ command_result df_createitem (color_ostream &out, vector ¶meters) { auto unit = Gui::getSelectedUnit(out, true); if (!unit) { auto pos = Gui::getCursorPos(); - if (*gametype == game_type::ADVENTURE_ARENA || World::isAdventureMode()) + if ( (unit = World::getAdventurer()) ) { // Use the adventurer unit - unit = World::getAdventurer(); move_to_cursor = pos.isValid(); } else if (pos.isValid()) diff --git a/plugins/debug.cpp b/plugins/debug.cpp index 51e4a8300b..d0e5e0f5a0 100644 --- a/plugins/debug.cpp +++ b/plugins/debug.cpp @@ -21,6 +21,7 @@ redistribute it freely, subject to the following restrictions: distribution. */ +#include "Core.h" #include "PluginManager.h" #include "DebugManager.h" #include "Debug.h" @@ -352,7 +353,9 @@ struct FilterManager : public std::map //! Current configuration version implemented by the code constexpr static Json::UInt configVersion{1}; //! Path to the configuration file - constexpr static const char* configPath{"dfhack-config/runtime-debug.json"}; + const inline std::filesystem::path getConfigPath() const { + return DFHack::Core::getInstance().getConfigPath() / "runtime-debug.json"; + } //! Get reference to the singleton static FilterManager& getInstance() noexcept @@ -434,8 +437,6 @@ struct FilterManager : public std::map DebugManager::categorySignal_t::Connection connection_; }; -constexpr const char* FilterManager::configPath; - FilterManager::~FilterManager() { } @@ -443,6 +444,7 @@ FilterManager::~FilterManager() command_result FilterManager::loadConfig(DFHack::color_ostream& out) noexcept { nextId_ = 1; + auto configPath = getConfigPath(); if (!Filesystem::isfile(configPath)) return CR_OK; try { @@ -463,6 +465,7 @@ command_result FilterManager::loadConfig(DFHack::color_ostream& out) noexcept command_result FilterManager::saveConfig(DFHack::color_ostream& out) const noexcept { + auto configPath = getConfigPath(); try { DEBUG(command, out) << "Save config to '" << configPath << "'" << std::endl; JsonArchive archive; diff --git a/plugins/devel/check-structures-sanity/dispatch.cpp b/plugins/devel/check-structures-sanity/dispatch.cpp index 162ea7bbd4..200db9f326 100644 --- a/plugins/devel/check-structures-sanity/dispatch.cpp +++ b/plugins/devel/check-structures-sanity/dispatch.cpp @@ -3,6 +3,7 @@ #include #include +#include "df/global_objects.h" #include "df/large_integer.h" Checker::Checker(color_ostream & out) : @@ -302,14 +303,12 @@ void Checker::dispatch_primitive(const QueueItem & item, const CheckedStructure FAIL("invalid value for bool: " << int(val)); } } - else if (dynamic_cast(cs.identity)) + else if (auto num_identity = dynamic_cast(cs.identity)) { - check_possible_pointer(item, cs); + if (num_identity->isInteger()) + check_possible_pointer(item, cs); // TODO check ints? - } - else if (dynamic_cast(cs.identity)) - { // TODO check floats? } else @@ -335,7 +334,7 @@ void Checker::dispatch_pointer(const QueueItem & item, const CheckedStructure & } QueueItem target_item(item.path, target_ptr); - auto target = static_cast(cs.identity)->getTarget(); + auto target = static_cast(cs.identity)->getTarget(); if (!target) { check_unknown_pointer(target_item); diff --git a/plugins/devel/check-structures-sanity/types.cpp b/plugins/devel/check-structures-sanity/types.cpp index 4c8a8f6ae2..84257a6ef7 100644 --- a/plugins/devel/check-structures-sanity/types.cpp +++ b/plugins/devel/check-structures-sanity/types.cpp @@ -156,24 +156,24 @@ bool CheckedStructure::has_type_at_offset(const CheckedStructure & type, size_t const type_identity *Checker::wrap_in_stl_ptr_vector(const type_identity *base) { - static std::map> wrappers; + static std::map>>> wrappers; auto it = wrappers.find(base); if (it != wrappers.end()) { return it->second.get(); } - return (wrappers[base] = std::make_unique(base, nullptr)).get(); + return (wrappers[base] = std::make_unique>>(base, nullptr)).get(); } const type_identity *Checker::wrap_in_pointer(const type_identity *base) { - static std::map> wrappers; + static std::map> wrappers; auto it = wrappers.find(base); if (it != wrappers.end()) { return it->second.get(); } - return (wrappers[base] = std::make_unique(base)).get(); + return (wrappers[base] = std::make_unique(base)).get(); } std::map> known_types_by_size; diff --git a/plugins/devel/check-structures-sanity/validate.cpp b/plugins/devel/check-structures-sanity/validate.cpp index d6137eff1f..37e739bdb4 100644 --- a/plugins/devel/check-structures-sanity/validate.cpp +++ b/plugins/devel/check-structures-sanity/validate.cpp @@ -1,5 +1,7 @@ #include "check-structures-sanity.h" +#include "df/global_objects.h" + #ifdef WIN32 #define WIN32_LEAN_AND_MEAN #define _WIN32_WINNT 0x0501 diff --git a/plugins/devel/dumpmats.cpp b/plugins/devel/dumpmats.cpp index 91b70a2676..b7bc7d4350 100644 --- a/plugins/devel/dumpmats.cpp +++ b/plugins/devel/dumpmats.cpp @@ -1,16 +1,17 @@ // Dump all hardcoded materials #include "Console.h" +#include "DataDefs.h" #include "Export.h" #include "PluginManager.h" -#include "DataDefs.h" -#include "df/world.h" -#include "df/material.h" #include "df/builtin_mats.h" -#include "df/matter_state.h" #include "df/descriptor_color.h" +#include "df/global_objects.h" #include "df/item_type.h" +#include "df/material.h" +#include "df/matter_state.h" #include "df/strain_type.h" +#include "df/world.h" using std::string; using std::vector; diff --git a/plugins/devel/eventExample.cpp b/plugins/devel/eventExample.cpp index 3b0bd860d4..c9715a6875 100644 --- a/plugins/devel/eventExample.cpp +++ b/plugins/devel/eventExample.cpp @@ -1,4 +1,7 @@ +#include +#include + #include "PluginManager.h" #include "DataDefs.h" @@ -8,6 +11,7 @@ #include "df/caste_body_info.h" #include "df/construction.h" #include "df/coord.h" +#include "df/global_objects.h" #include "df/item.h" #include "df/item_actual.h" #include "df/job.h" @@ -16,9 +20,6 @@ #include "df/unit_wound_layerst.h" #include "df/world.h" -#include -#include - using namespace DFHack; using namespace std; diff --git a/plugins/devel/frozen.cpp b/plugins/devel/frozen.cpp index a6d862a5a5..e97a7ebf0a 100644 --- a/plugins/devel/frozen.cpp +++ b/plugins/devel/frozen.cpp @@ -6,6 +6,7 @@ #include "modules/Maps.h" #include "df/block_square_event_frozen_liquidst.h" +#include "df/global_objects.h" #include "df/map_block.h" #include "df/world.h" diff --git a/plugins/devel/kittens.cpp b/plugins/devel/kittens.cpp index d9dbf27015..c328492fb5 100644 --- a/plugins/devel/kittens.cpp +++ b/plugins/devel/kittens.cpp @@ -53,6 +53,7 @@ uint64_t timeLast = 0; command_result kittens (color_ostream &out, vector & parameters); command_result ktimer (color_ostream &out, vector & parameters); +command_result throwtest (color_ostream &out, vector & parameters); command_result trackmenu (color_ostream &out, vector & parameters); command_result trackpos (color_ostream &out, vector & parameters); command_result trackstate (color_ostream &out, vector & parameters); @@ -68,6 +69,7 @@ DFhackCExport command_result plugin_init ( color_ostream &out, std::vector & parameters) return CR_OK; } +command_result throwtest (color_ostream &out, vector & parameters) +{ + if (!parameters.empty() && parameters[0] == "nonstd") + throw 42; + throw std::runtime_error("throwtest: test exception"); +} + command_result kittens (color_ostream &out, vector & parameters) { if (parameters.size() >= 1) diff --git a/plugins/devel/stockcheck.cpp b/plugins/devel/stockcheck.cpp index acd2b512f1..1e8a55d1a0 100644 --- a/plugins/devel/stockcheck.cpp +++ b/plugins/devel/stockcheck.cpp @@ -79,7 +79,7 @@ struct StockpileInfo { DFCoord cursor (x,y,z); uint32_t tileX = x % 16; uint32_t tileY = y % 16; - MapExtras::Block * b = mc.BlockAt(cursor/16); + MapExtras::Block * b = mc.BlockAtTile(cursor); if(b && b->is_valid()) { auto &block = *b->getRaw(); diff --git a/plugins/devel/tiles.cpp b/plugins/devel/tiles.cpp index e087962876..85023fc56e 100644 --- a/plugins/devel/tiles.cpp +++ b/plugins/devel/tiles.cpp @@ -111,8 +111,8 @@ class BlockBrush : public Brush coord_vec points(MapCache & mc, DFHack::DFCoord start) { coord_vec v; - DFHack::DFCoord blockc = start % 16; - DFHack::DFCoord iterc = blockc * 16; + DFHack::DFCoord blockc = Maps::getTileBlockCoord(start); + DFHack::DFCoord iterc = Maps::getBlockOrigin(blockc); if( !mc.testCoord(start) ) return v; @@ -445,7 +445,7 @@ command_result df_tiles (Core * c, vector & parameters) mcache.setTemp2At(*iter,10015); mcache.setDesignationAt(*iter,a); - Block * b = mcache.BlockAt((*iter)/16); + Block * b = mcache.BlockAtTile(*iter); DFHack::t_blockflags bf = b->BlockFlags(); bf.bits.liquid_1 = true; bf.bits.liquid_2 = true; @@ -506,7 +506,7 @@ command_result df_tiles (Core * c, vector & parameters) } mcache.setDesignationAt(current,des); } - seen_blocks.insert(mcache.BlockAt((*iter) / 16)); + seen_blocks.insert(mcache.BlockAtTile(*iter)); iter++; } set ::iterator biter = seen_blocks.begin(); diff --git a/plugins/devel/tilesieve.cpp b/plugins/devel/tilesieve.cpp index 9f21d98368..2360a0574a 100644 --- a/plugins/devel/tilesieve.cpp +++ b/plugins/devel/tilesieve.cpp @@ -1,16 +1,18 @@ // This is a generic plugin that does nothing useful apart from acting as an example... of a plugin that does nothing :D -// some headers required for a plugin. Nothing special, just the basics. -#include -#include -#include #include -// DF data structure definition headers + +#include "Console.h" #include "DataDefs.h" +#include "Export.h" +#include "PluginManager.h" +#include "TileTypes.h" + #include "modules/Maps.h" + +#include "df/global_objects.h" #include "df/map_block.h" #include "df/world.h" -#include "TileTypes.h" using namespace DFHack; using namespace df::enums; diff --git a/plugins/dig-now.cpp b/plugins/dig-now.cpp index 259978f9bf..2bb2ea6fee 100644 --- a/plugins/dig-now.cpp +++ b/plugins/dig-now.cpp @@ -200,47 +200,15 @@ struct dig_now_options { static struct_identity _identity; }; static const struct_field_info dig_now_options_fields[] = { - { struct_field_info::PRIMITIVE, "help", offsetof(dig_now_options, help), &df::identity_traits::identity, 0, 0 }, - { struct_field_info::SUBSTRUCT, "start", offsetof(dig_now_options, start), &df::coord::_identity, 0, 0 }, - { struct_field_info::SUBSTRUCT, "end", offsetof(dig_now_options, end), &df::coord::_identity, 0, 0 }, - { struct_field_info::SUBSTRUCT, "boulder_percents", offsetof(dig_now_options, boulder_percents), &boulder_percent_options::_identity, 0, 0 }, - { struct_field_info::SUBSTRUCT, "dump_pos", offsetof(dig_now_options, dump_pos), &df::coord::_identity, 0, 0 }, + { struct_field_info::PRIMITIVE, "help", offsetof(dig_now_options, help), &df::identity_traits::identity, 0, 0 }, + { struct_field_info::SUBSTRUCT, "start", offsetof(dig_now_options, start), df::identity_traits::get(), 0, 0 }, + { struct_field_info::SUBSTRUCT, "end", offsetof(dig_now_options, end), df::identity_traits::get(), 0, 0 }, + { struct_field_info::SUBSTRUCT, "boulder_percents", offsetof(dig_now_options, boulder_percents), &boulder_percent_options::_identity, 0, 0 }, + { struct_field_info::SUBSTRUCT, "dump_pos", offsetof(dig_now_options, dump_pos), df::identity_traits::get(), 0, 0 }, { struct_field_info::END } }; struct_identity dig_now_options::_identity(sizeof(dig_now_options), &df::allocator_fn, NULL, "dig_now_options", NULL, dig_now_options_fields); -// propagate light, outside, and subterranean flags to open tiles below this one -static void propagate_vertical_flags(MapExtras::MapCache &map, - const DFCoord &pos) { - df::tile_designation td = map.designationAt(pos); - - if (!map.ensureBlockAt(DFCoord(pos.x, pos.y, pos.z+1))) { - // only the sky above - td.bits.light = true; - td.bits.outside = true; - td.bits.subterranean = false; - } - - int32_t zlevel = pos.z; - df::tiletype_shape shape = - tileShape(map.tiletypeAt(DFCoord(pos.x, pos.y, zlevel))); - while ((shape == df::tiletype_shape::EMPTY - || shape == df::tiletype_shape::RAMP_TOP) - && map.ensureBlockAt(DFCoord(pos.x, pos.y, --zlevel))) { - DFCoord pos_below(pos.x, pos.y, zlevel); - df::tile_designation td_below = map.designationAt(pos_below); - if (td_below.bits.light == td.bits.light - && td_below.bits.outside == td.bits.outside - && td_below.bits.subterranean == td.bits.subterranean) - break; - td_below.bits.light = td.bits.light; - td_below.bits.outside = td.bits.outside; - td_below.bits.subterranean = td.bits.subterranean; - map.setDesignationAt(pos_below, td_below); - shape = tileShape(map.tiletypeAt(pos_below)); - } -} - static bool can_dig_default(df::tiletype tt) { df::tiletype_shape shape = tileShape(tt); return shape == df::tiletype_shape::WALL || @@ -496,7 +464,7 @@ static bool dig_tile(color_ostream &out, MapExtras::MapCache &map, dig_tile(out, map, pos_below, td_below, dug_tiles); } clean_ramps(map, pos); - propagate_vertical_flags(map, pos); + map.propagateVerticalFlags(pos); return true; } } else { @@ -539,7 +507,7 @@ static bool dig_tile(color_ostream &out, MapExtras::MapCache &map, map.setTiletypeAt(pos_above, get_target_type(tt, df::tiletype_shape::RAMP_TOP)); remove_ramp_top(map, DFCoord(pos.x, pos.y, pos.z+2)); - propagate_vertical_flags(map, DFCoord(pos.x, pos.y, pos.z + 1)); + map.propagateVerticalFlags(DFCoord(pos.x, pos.y, pos.z + 1)); } } break; diff --git a/plugins/dig.cpp b/plugins/dig.cpp index 1be2a9a116..55f33663ae 100644 --- a/plugins/dig.cpp +++ b/plugins/dig.cpp @@ -68,7 +68,7 @@ static bool is_painting_warm = false; static bool is_painting_damp = false; DFhackCExport command_result plugin_init ( color_ostream &out, std::vector &commands) { - textures = Textures::loadTileset("hack/data/art/damp_dig_map.png", 32, 32, true); + textures = Textures::loadTileset(Core::getInstance().getHackPath() / "data" / "art" / "damp_dig_map.png", 32, 32, true); commands.push_back(PluginCommand( "digv", @@ -496,7 +496,7 @@ bool dig (MapExtras::MapCache & MCache, ) { DFCoord at (x,y,z); - auto b = MCache.BlockAt(at/16); + auto b = MCache.BlockAtTile(at); if(!b || !b->is_valid()) return false; if(x == 0 || x == x_max * 16 - 1) diff --git a/plugins/eventful.cpp b/plugins/eventful.cpp index 8dd3bbc85d..859649c470 100644 --- a/plugins/eventful.cpp +++ b/plugins/eventful.cpp @@ -1,3 +1,7 @@ +#include +#include +#include + #include "LuaTools.h" #include "PluginManager.h" #include "PluginLua.h" @@ -9,6 +13,7 @@ #include "df/building_furnacest.h" #include "df/building_workshopst.h" #include "df/construction.h" +#include "df/global_objects.h" #include "df/item.h" #include "df/item_actual.h" #include "df/job.h" @@ -22,11 +27,6 @@ #include "df/unit_wound.h" #include "df/world.h" -#include -#include -#include -#include - using std::vector; using std::string; using std::stack; diff --git a/plugins/export-world-map.cpp b/plugins/export-world-map.cpp new file mode 100644 index 0000000000..880c44497b --- /dev/null +++ b/plugins/export-world-map.cpp @@ -0,0 +1,878 @@ +#include "CoordTemplate.h" +#include "Debug.h" +#include "Error.h" +#include "MiscUtils.h" +#include "PluginLua.h" +#include "PluginManager.h" + +#include "modules/Maps.h" +#include "modules/Translation.h" + +#include "df/coord2d.h" +#include "df/creature_raw.h" +#include "df/entity_raw.h" +#include "df/historical_entity.h" +#include "df/map_block.h" +#include "df/region_map_entry.h" +#include "df/site_map_infost.h" +#include "df/world_data.h" +#include "df/world_landmass.h" +#include "df/world_region_details.h" +#include "df/world_region.h" +#include "df/world_river.h" +#include "df/world.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using std::string; +using std::vector; + +using namespace DFHack; + +DFHACK_PLUGIN("export-world-map"); + +REQUIRE_GLOBAL(world); + +namespace DFHack { + DBG_DECLARE(exportmap, log, DebugCategory::LINFO); + DBG_DECLARE(exportmap, warning, DebugCategory::LWARNING); +} + +template<> +struct std::hash +{ + std::size_t operator()(const df::coord2d& pos) const noexcept + { + // hashing is easy, if values are smaller than the hash + return ((std::size_t)pos.x << 16) | (std::size_t)pos.y; + } +}; + +// were only dealing with 2D coordinates in this file +using coord = df::coord2d; + +constexpr int wdim = 768; // dimension of a world tile +constexpr int rdim = 48; // dimension of a region tile + +/** + * Takes a range of coordinates interpreted as global region tile coordinates + * (i.e. 16 region tiles per world tile) and emits a WKT path in GIS-compatible + * local tile coordinates (negative y-coordinates, 48 map units per region tile) + */ +static void print_path(std::ostream &out, const std::span path) { + auto scale = rdim; + assert(path.size()); + auto print_point = [scale](std::ostream &out, const coord &pos){ + out << scale * pos.x << " " << -scale * pos.y;}; + print_range(out, path, print_point, "(", ",", ")"); +} + +// format various values separated by the separator character (ends with a separator) +static auto print_csv_line(std::ostream &out, char sep, auto ...args) { + ([&]{ out << args << sep; }() ,...); +} + +static command_result do_command(color_ostream &out, vector ¶meters); +static command_result export_regions(color_ostream &out); +static command_result export_sites(color_ostream &out); +static command_result export_rivers(color_ostream &out); +static command_result export_elevation(color_ostream &out); + +struct Config { + bool group_by_world = false; + bool group_by_date = false; +}; + +static Config config{}; + +static auto getWorldFolderName() { + auto& world_name = world->world_data->name; + return fmt::format("{} ({})", + DF2UTF(Translation::translateName(&world_name, true)), + DF2UTF(Translation::translateName(&world_name, false))); +} + +static auto getDateFolderName() { + return fmt::format("{}-{}", + *df::global::cur_year, + *df::global::cur_year_tick / 33600 + 1); +} + +static std::ofstream open_output_file( + const std::filesystem::path& filename, + std::ios_base::openmode mode = std::ios::out | std::ios::trunc) +{ + auto base = Core::getInstance().getConfigPath() / "map-export"; + if (config.group_by_world || config.group_by_date) { + base /= std::filesystem::path(getWorldFolderName()); + } + if (config.group_by_date) { + base /= std::filesystem::path(getDateFolderName()); + } + std::filesystem::create_directories(base); + return std::ofstream(base / filename, mode); +} + + +DFhackCExport command_result plugin_init(color_ostream &out, std::vector &commands) { + DEBUG(log,out).print("initializing {}\n", plugin_name); + + commands.push_back(PluginCommand( + plugin_name, + "Export the world map.", + do_command)); + + return CR_OK; +} + +static command_result do_command(color_ostream &out, vector ¶meters) +{ + CoreSuspender suspend; + + if (!Core::getInstance().isWorldLoaded()){ + out.printerr("This command requires a world to be loaded\n"); + return CR_WRONG_USAGE; + } + + auto has_param = [¶meters](const string &option) { + return std::ranges::find(parameters, option) != parameters.end(); + }; + + config = {}; + if (has_param("--group-by-world")) { + config.group_by_world = true; + } + if (has_param("--group-by-date")) { + config.group_by_date = true; + } + + const bool run_all = parameters.empty() || has_param("all"); + + auto result = CR_WRONG_USAGE; + const auto run_if_selected = [&](std::string name, command_result(*export_fn)(color_ostream &out)) { + if (result != CR_FAILURE && (run_all || has_param(name))) + { + result = export_fn(out); + } + }; + + run_if_selected("regions", &export_regions); + run_if_selected("sites", &export_sites); + run_if_selected("rivers", &export_rivers); + run_if_selected("elevation", &export_elevation); + + return result; +} + +/********************************************************************** */ +/* Site Export */ +/********************************************************************** */ + + +static command_result export_sites(color_ostream &out) +{ + out.print("exporting sites ... "); + out.flush(); + const auto start{std::chrono::steady_clock::now()}; + + // ensure that we have an output file + auto out_file = open_output_file("sites.csv"); + if (!out_file) { + return CR_FAILURE; + } + + // If you change anything in this vector, don't forget to change the + // corresponding comments and arguments in the call to print_csv_line below + constexpr auto headings = std::to_array({ + "site_id", "civ_id", "created_year", "cur_owner_id", "type", + "site_name_df", "site_name_en", "civ_name_df", "civ_name_en", "site_government_df", "site_government_en", "owner_race" + }); + print_range(out_file, headings,"",";",";boundary_wkt\n"); + + #define TRANSLATE_DF_EN(guard, name_object)\ + guard ? DF2UTF(Translation::translateName(&name_object, false)) : "NONE",\ + guard ? DF2UTF(Translation::translateName(&name_object, true)) : "NONE" + + for (auto const site : world->world_data->sites) + { + auto civ = df::historical_entity::find(site->civ_id); + auto owner = df::historical_entity::find(site->cur_owner_id); + + df::creature_raw *race = nullptr; + if (owner){ + race = df::creature_raw::find(owner->race); + if (!race) { + DEBUG(warning, out).print("owner ({}) of site ({}) has undefined race ({})\n", owner->id, site->id, owner->race); + if (civ) + race = df::creature_raw::find(civ->race); + } + } + + print_csv_line(out_file, ';', + // "site_id", "civ_id", "created_year", "cur_owner_id", "type", + site->id, + site->civ_id, + site->created_year, + site->cur_owner_id, + DFHack::Maps::getSiteTypeName(site), + // "site_name_df", "site_name_en", "civ_name_df", "civ_name_en", "site_government_df", "site_government_en", "owner_race" + TRANSLATE_DF_EN(true, site->name), + TRANSLATE_DF_EN(civ, civ->name), + TRANSLATE_DF_EN(owner, owner->name), + race ? race->name[2] : "NONE" + ); + const std::array path{ + coord(site->global_min_x, site->global_min_y), + coord(site->global_max_x+1, site->global_min_y), + coord(site->global_max_x+1, site->global_max_y+1), + coord(site->global_min_x, site->global_max_y+1), + coord(site->global_min_x, site->global_min_y) + }; + print_range(out_file, std::array, 1>{path}, print_path , "POLYGON(", ",", ")\n" ); + } + + const auto finish{std::chrono::steady_clock::now()}; + const std::chrono::duration elapsed_seconds{finish - start}; + out.print("done in {:2f} s !\n", elapsed_seconds.count()); + + return CR_OK; +} + +/********************************************************************** */ +/* Region Map Export */ +/********************************************************************** */ + +// reverse lex-ordering (topmost leftmost tile is smallest) +bool region_order(coord p1, coord p2) { + return p1.y < p2.y || (p1.y == p2.y && p1.x < p2.x); +}; + +enum class direction : int { North = 0, West = 1, South = 2, East = 3 }; + +static constexpr direction turn_left(direction dir) { + return (direction)(((int)dir+1) % 4); +} + +static constexpr direction turn_right(direction dir) { + return (direction)(((int)dir+3) % 4); +} + +static coord as_offset(direction dir) { + switch (dir) { + case direction::North: + return { 0, -1 }; + case direction::West: + return { -1, 0 }; + case direction::South: + return { 0, 1 }; + case direction::East: + return { 1, 0 }; + default: + abort(); + } +} + +static coord advance(coord pos, direction dir) { + return pos + as_offset(dir); +} + + +/** + * Look-ahead relative to the direction of movement: + * + * L ↑ R | L + * - + - → + → + * ↑ | R + */ +static std::pair ahead(const std::vector &component, coord pos, direction dir) { + auto test = [&](int16_t x, int16_t y){ + coord offset{x,y}; + return std::ranges::binary_search(component, pos + offset, region_order); + }; + + switch (dir) { + case direction::North: + return { test(-1,-1), test(0,-1)}; + case direction::West: + return { test(-1,0), test(-1,-1)}; + case direction::South: + return { test(0,0), test(-1, 0)}; + case direction::East: + return { test(0,-1), test(0, 0)}; + default: + abort(); + } +} + +// standard DFS-based connected components algorithm. +static auto connected_components(std::vector ®ion) { + + static std::array directions{ + as_offset(direction::North), + as_offset(direction::West), + as_offset(direction::South), + as_offset(direction::East) + }; + + // component_assignment[i] is the component id of region[i] (0 means unassigned) + std::vector component_assignment; + component_assignment.resize(region.size(),0); + + // (indices of) region tiles in the current component that have been discovered but not yet explored + std::deque agenda; + unsigned int current_component = 0; + + for (size_t i = 0; i < region.size(); ++i) { + if (component_assignment[i]) { + // skip region tiles that have already been assigned a component + continue; + } else { + // start a new component for tiles that haven't been assigned yet + ++current_component; + component_assignment[i] = current_component; + agenda.push_back(i); + } + while(!agenda.empty()) { + auto pos_idx = agenda.front(); agenda.pop_front(); + auto pos = region[pos_idx]; + + for (const auto& offset : directions) { + const auto it = std::ranges::lower_bound(region, pos + offset, region_order); + if (it != region.end() && *it == pos + offset) { + auto n_idx = std::distance(region.begin(), it); + if (component_assignment[n_idx] == 0) { + component_assignment[n_idx] = current_component; + agenda.push_back(n_idx); + } + } + } + } + } + + // check that all parts of the region are accounted for + assert(std::ranges::all_of(component_assignment, [](int comp){ return comp > 0;})); + + // distribute region tiles according to their component assignment (preserves region order) + std::vector> components; + components.resize(current_component); + for (size_t i = 0; i < region.size(); ++i) { + components.at(component_assignment.at(i) - 1).push_back(region.at(i)); + } + + return components; +} + + +// create outlines around connected components using a clockwise walk around the perimeter +// (exploits that components do not have inclusions) +static auto create_outlines(std::vector> components) { + std::vector> paths; + for (auto const &component : components) { + + // start at the NW corner of the west-most tile of the northmost row... + auto start = component.at(0); + std::vector path; + path.push_back(start); + + // ... ensuring that a step to the east is a valid clockwise step along the boundary. + auto current_direction = direction::East; + auto current_position = advance(start,current_direction); + + while (current_position != start) + { + auto [left, right] = ahead(component, current_position, current_direction); + if (left && right) { + // in front of a wall: turn left + path.push_back(current_position); + current_direction = turn_left(current_direction); + } + else if (!left && !right) { + // no walls ahead: turn right + path.push_back(current_position); + current_direction = turn_right(current_direction); + } + else if (left && !right) { + // diagonal step: turn right (following the outline of the inclusion) + // this does not seem to happen with the maps currently generated by DF + path.push_back(current_position); + current_direction = turn_right(current_direction); + } + // case !left && right requires no turn; advance the position in all cases + current_position = advance(current_position, current_direction); + } + // close the path + path.push_back(current_position); + paths.push_back(std::move(path)); + path.clear(); + } + assert(paths.size() > 0); + return paths; +} + +static command_result export_regions(color_ostream &out) +{ + out.print("{} / {} region map tiles loaded\n", + world->world_data->midmap_data.region_details.size(), + world->world_data->world_width * world->world_data->world_height + ); + out.print("exporting map ... "); + out.flush(); + const auto start{std::chrono::steady_clock::now()}; + + // ensure that we have an output file + auto out_file = open_output_file("regions.csv"); + if (!out_file) { + return CR_FAILURE; + } + + // If you change anything in this vector, don't forget to change the + // corresponding comments and arguments in the call to print_csv_line below + constexpr auto headings = std::to_array({ + "world_x", "world_y", "num_tiles", "num_components", "biome_type", + "region_id", "geo_index", "region_name_en", "region_name_df", "landmass_id", "landmass_name_en", "landmass_name_df", + "evilness", "savagery", "volcanism", "drainage", "temperature", "vegetation", "rainfall", "salinity", + "surroundings", "elevation", "reanimating", "has_bogeymen" + }); + print_range(out_file, headings,"",";",";boundary_wkt\n" ); + + /* Preprocessing: cluster region tiles by the world tile used for the biome information */ + + // map world tile coord -> vector of region tiles referencing world title for biome information + std::unordered_map> world_tile_region; + + // iterating over the region details allows the user to do partial map exports + // by manually scrolling on the zoomed embark selection map + for (auto const region_details : world->world_data->midmap_data.region_details) { + auto &world_pos = region_details->pos; + for (int region_x = 0; region_x < 16; ++region_x) { + for (int region_y = 0; region_y < 16; ++region_y) + { + auto biome_tile = Maps::addRegionBiomeOffset(world_pos, region_details->biome[region_x][region_y]); + world_tile_region[biome_tile].emplace_back(world_pos * 16 + coord(region_x, region_y)); + } + } + } + + // once every region tile has been assigned the world tile it references for its metadata + // everything else can be done in parallel + std::mutex out_mutex; + std::vector out_lines; + out_lines.reserve(world_tile_region.size()); + + std::for_each( + std::execution::par, world_tile_region.begin(), world_tile_region.end(), + [&out_lines,&out_mutex](auto &wtr) + { + auto& [biome_tile, region] = wtr; + + std::ostringstream out_line; + + assert(region.size() > 0); + + // sorting the region provides O(log n) membership test. + std::ranges::sort(region, region_order); + + /** + * Phase I : compute the connected components of the world tile region + * using DFS algorithm. (except for the southern and eastern map edge, + * all world tile regions should have a single component) + */ + + auto components = connected_components(region); + + /* Phase II : create paths by clockwise traversal along the outside of every component */ + /** + * Note: DF uses "picture coordinates" (positive y values go "south") + * while in GIS software positve y values go "north". Thus, [print_path] + * negates the y-coordinates, turning the clockwise traversals into + * counterclockwise traversals as specified by WKT. + * https://en.wikipedia.org/wiki/Well-known_text_representation_of_geometry + */ + + auto paths = create_outlines(components); + + /* Phase III: output the CSV line */ + + auto& region_map_entry = world->world_data->region_map[biome_tile.x][biome_tile.y]; + auto world_region = df::world_region::find(region_map_entry.region_id); + auto landmass = df::world_landmass::find(region_map_entry.landmass_id); + + print_csv_line(out_line, ';', + // "world_x", "world_y", "num_tiles", "num_components", "biome_type", + biome_tile.x, + biome_tile.y, + region.size(), + components.size(), + ENUM_KEY_STR(biome_type,Maps::getBiomeType(biome_tile.x, biome_tile.y)), + // "region_id", "geo_index", "region_name_en", "region_name_df", "landmass_id", "landmass_name_en", "landmass_name_df" + region_map_entry.region_id, + region_map_entry.geo_index, + world_region ? DF2UTF(Translation::translateName(&world_region->name, true)) : "NONE", + world_region ? DF2UTF(Translation::translateName(&world_region->name, false)) : "NONE", + region_map_entry.landmass_id, + landmass ? DF2UTF(Translation::translateName(&landmass->name, true)) : "NONE", + landmass ? DF2UTF(Translation::translateName(&landmass->name, false)) : "NONE", + // "evilness", "savagery", "volcanism", "drainage", "temperature", "vegetation", "rainfall", "salinity" + region_map_entry.evilness, + region_map_entry.savagery, + region_map_entry.volcanism, + region_map_entry.drainage, + region_map_entry.temperature, + region_map_entry.vegetation, + region_map_entry.rainfall, + region_map_entry.salinity, + // "surroundings", "elevation", "reanimating", "has_bogeymen" + Maps::describeSurroundings(region_map_entry.savagery, region_map_entry.evilness), + region_map_entry.elevation, + world_region->reanimating, + world_region->has_bogeymen + ); + + // output geometry as WKT + if (paths.size() == 1) { + print_range(out_line, paths, print_path , "POLYGON(", ",", ")\n" ); + } else { + print_range(out_line, paths, print_path , "MULTIPOLYGON((", "),(", "))\n" ); + } + + std::lock_guard lock(out_mutex); + out_lines.push_back(std::move(out_line)); + }); + + for (const auto& line : out_lines) { + out_file << line.view(); + } + + const auto finish{std::chrono::steady_clock::now()}; + const std::chrono::duration elapsed_seconds{finish - start}; + out.print("done in {:2f} s !\n", elapsed_seconds.count()); + return CR_OK; +} + +/********************************************************************** */ +/* River Export */ +/********************************************************************** */ + +struct river_tile { + using polygon_t = std::vector>; + polygon_t polygon; +}; + +/** + * To get reasonably-looking river confluences, we project the centroid of all + * river gates onto the line segments between the river gates and then + * interpolate between the centroid and the projection point. + */ +static void fix_confluence_tiles(river_tile::polygon_t& polygon) +{ + assert(polygon.size() > 4 && polygon.size() % 2 == 0); + + auto centroid = Coord2d( + std::reduce(polygon.begin(), polygon.end(), Coord2d(0, 0), std::plus>()) + ) / static_cast(polygon.size()); + + river_tile::polygon_t inset_polygon; + + for (size_t i = 0; i + 1 < polygon.size(); i += 2) { + auto pair_start = polygon[i]; + auto pair_end = polygon[i + 1]; + auto next_pair_start = polygon[(i + 2) % polygon.size()]; + + inset_polygon.emplace_back(pair_start); + inset_polygon.emplace_back(pair_end); + + auto projection = centroid.project_onto_line( + Coord2d(pair_end), Coord2d(next_pair_start), true + ); + auto inset_point = projection.lerp(centroid, 0.6); + inset_polygon.emplace_back(Coord2d(inset_point)); + } + + polygon = std::move(inset_polygon); +} + + + +struct gate { + int active,min,max; + + static gate get( + const df::world_region_details *const region_details, + int region_x, int region_y, direction dir + ) { + auto& vertical = region_details->rivers_vertical; + auto& horizontal = region_details->rivers_horizontal; + switch (dir) { + case direction::North: + return { + vertical.active[region_x][region_y], + vertical.x_min[region_x][region_y], + vertical.x_max[region_x][region_y] + }; + case direction::West: + return { + horizontal.active[region_x][region_y], + horizontal.y_min[region_x][region_y], + horizontal.y_max[region_x][region_y] + }; + case direction::South: + return { + vertical.active[region_x][region_y+1], + vertical.x_min[region_x][region_y+1], + vertical.x_max[region_x][region_y+1] + }; + case direction::East: + return { + horizontal.active[region_x+1][region_y], + horizontal.y_min[region_x+1][region_y], + horizontal.y_max[region_x+1][region_y] + }; + default: + assert(false); + return {}; + } + } + + bool is_valid() const { + return active != 0 && min != -30000 && max != -30000; + } +}; + + +static bool is_land(const df::world_region_details *const region_details, int16_t region_x, int16_t region_y) { + CHECK_NULL_POINTER(region_details); + auto biome_tile = Maps::addRegionBiomeOffset(region_details->pos, region_details->biome[region_x][region_y]); + auto region_map_entry = Maps::getRegionBiome(biome_tile); + CHECK_NULL_POINTER(region_map_entry); + return region_map_entry->elevation >= 100 && !region_map_entry->flags.is_set(df::enums::region_map_entry_flags::is_lake); +} + +static command_result export_rivers(color_ostream &out) +{ + out.print("exporting rivers ... "); + out.flush(); + const auto start{std::chrono::steady_clock::now()}; + + // ensure that we have an output file + auto out_file = open_output_file("rivers.csv"); + if (!out_file) { + return CR_FAILURE; + } + + + /** + * In DwarfFortress, a world tile can only have at most one river. As a + * consequence, rivers do not end at a confluence point but they already + * change their name when they enter the world tile containing the + * confluence. Grouping river tiles by world tile allows us to only output + * one multipolygon feature per river. + */ + + // create lookup table for rivers based on world tile coordinates (index into world_data->rivers) + std::unordered_map world_river; + + // assign river end first, so that it can be overridden by proper path elements + for (size_t r_idx = 0; r_idx < df::global::world->world_data->rivers.size(); ++r_idx) { + auto river = df::global::world->world_data->rivers[r_idx]; + world_river[river->end_pos] = r_idx; + } + + for (size_t r_idx = 0; r_idx < df::global::world->world_data->rivers.size(); ++r_idx) { + auto river = df::global::world->world_data->rivers[r_idx]; + for (size_t i = 0; i < river->path.size(); ++i) { + auto pos = river->path[i]; + world_river[pos] = r_idx; + } + } + + // river idx -> river tiles + std::unordered_map> tile_index; + + for (auto const region_details : world->world_data->midmap_data.region_details) { + auto [world_x, world_y] = region_details->pos; + for (int region_x = 0; region_x < 16; ++region_x) { + for (int region_y = 0; region_y < 16; ++region_y) + { + Coord2d base = { world_x * wdim + region_x * rdim, world_y * wdim + region_y * rdim }; + + auto north = gate::get(region_details, region_x, region_y, direction::North); + auto west = gate::get(region_details, region_x, region_y, direction::West); + auto south = gate::get(region_details, region_x, region_y, direction::South); + auto east = gate::get(region_details, region_x, region_y, direction::East); + + // skip tiles without any gates + if (!(north.is_valid() || west.is_valid() || south.is_valid() || east.is_valid())) + continue; + + // skip any river tiles that are on oceans or lakes + if (!is_land(region_details, region_x, region_y)) + continue; + + river_tile tile; + + if (north.is_valid()) { + tile.polygon.emplace_back(base.x + north.max, base.y); + tile.polygon.emplace_back(base.x + north.min, base.y); + } + if (west.is_valid()) { + tile.polygon.emplace_back(base.x, base.y + west.min); + tile.polygon.emplace_back(base.x, base.y + west.max); + } + if (south.is_valid()) { + tile.polygon.emplace_back(base.x + south.min, base.y + rdim); + tile.polygon.emplace_back(base.x + south.max, base.y + rdim); + } + if (east.is_valid()) { + tile.polygon.emplace_back(base.x + rdim, base.y + east.max); + tile.polygon.emplace_back(base.x + rdim, base.y + east.min); + } + + if (tile.polygon.size() > 4) { + fix_confluence_tiles(tile.polygon); + } + + // locate the river using world coordinates and assign the tile + auto r_idx = world_river.at({world_x, world_y}); + tile_index[r_idx].push_back(std::move(tile)); + } + } + } + + + // generate output + out_file << "name_df;name_en;geometry_wkt\n"; + + for (auto& [r_idx, river_tiles] : tile_index) { + auto river = world->world_data->rivers.at(r_idx); + print_csv_line(out_file, ';', + DF2UTF(Translation::translateName(&river->name, false)), + DF2UTF(Translation::translateName(&river->name, true)) + ); + out_file << "MULTIPOLYGON("; + bool first = true; + for (auto &tile : river_tiles) { + // close the polygon + tile.polygon.emplace_back(*tile.polygon.begin()); + auto print_position = [](std::ostream &out, Coord2d pos) { + out << pos.x << " " << -pos.y; + }; + if (first) { + first = false; + } else { + out_file << ","; + } + print_range(out_file, tile.polygon, print_position, "((", ",", "))"); + } + out_file << ")\n"; + } + + const auto finish{std::chrono::steady_clock::now()}; + const std::chrono::duration elapsed_seconds{finish - start}; + out.print("done in {:2f} s !\n", elapsed_seconds.count()); + + return CR_OK; +} + +/********************************************************************** */ +/* Elevation Map Export */ +/********************************************************************** */ + +template +class matrix { + std::size_t ncols; + std::size_t nrows; + std::vector _data; + +public: + matrix(std::size_t cols, std::size_t rows) : ncols(cols), nrows(rows), _data(cols * rows) {}; + + T& operator()(std::size_t col, std::size_t row) { + return _data[row * ncols + col]; + }; + + T* data() { return _data.data(); }; + std::size_t size() { return _data.size(); }; +}; + + +static command_result export_elevation(color_ostream &out) +{ + out.print("exporting elevation ... "); + out.flush(); + const auto start{std::chrono::steady_clock::now()}; + + // ensure that we have an output file + auto data_file = open_output_file("elevation.dat", std::ios::out | std::ios::trunc | std::ios::binary); + auto vrt_file = open_output_file("elevation.vrt"); + if (!data_file || !vrt_file) { + return CR_FAILURE; + } + + auto world_width = world->world_data->world_width * 16; + auto world_height = world->world_data->world_height * 16; + + matrix height_map(world_width, world_height); + + for (auto const region_details : world->world_data->midmap_data.region_details) { + auto world_x = region_details->pos.x; + auto world_y = region_details->pos.y; + for (int region_x = 0; region_x < 16; ++region_x) { + for (int region_y = 0; region_y < 16; ++region_y) + { + auto elevation = region_details->elevation[region_x][region_y]; + height_map(16 * world_x + region_x, 16 * world_y + region_y) = elevation; + } + } + } + + data_file.write(reinterpret_cast(height_map.data()), height_map.size() * sizeof(int16_t)); + + // provide an interpretation for the elevation map consistent with the remaining exports + const std::string vrtTemplate = +R"( +EPSG:3857 +0,48,0,0,0,-48 + + elevation.dat + 0 + 2 + {LINE_OFFSET} + LSB + + +)"; + + auto vrt = std::regex_replace(vrtTemplate, std::regex("\\{WIDTH\\}"), std::to_string(world_width)); + vrt = std::regex_replace(vrt, std::regex("\\{HEIGHT\\}"), std::to_string(world_height)); + vrt = std::regex_replace(vrt, std::regex("\\{LINE_OFFSET\\}"), std::to_string(world_width * sizeof(int16_t))); + vrt_file << vrt; + + const auto finish{std::chrono::steady_clock::now()}; + const std::chrono::duration elapsed_seconds{finish - start}; + out.print("done in {:2f} s !\n", elapsed_seconds.count()); + + return CR_OK; +} + +DFHACK_PLUGIN_LUA_FUNCTIONS { + DFHACK_LUA_FUNCTION(getWorldFolderName), + DFHACK_LUA_FUNCTION(getDateFolderName), + DFHACK_LUA_END +}; diff --git a/plugins/filltraffic.cpp b/plugins/filltraffic.cpp index 23952d8ac2..b1fa075483 100644 --- a/plugins/filltraffic.cpp +++ b/plugins/filltraffic.cpp @@ -27,9 +27,10 @@ command_result restrictLiquid(color_ostream &out, std::vector & par command_result restrictIce(color_ostream &out, std::vector & params); //Forward Declarations for Utility Functions +constexpr auto coord_max = std::numeric_limits::max(); command_result setAllMatching(color_ostream &out, checkTile checkProc, DFCoord minCoord = DFCoord(0, 0, 0), - DFCoord maxCoord = DFCoord(0xFFFF, 0xFFFF, 0xFFFF)); + DFCoord maxCoord = DFCoord(coord_max, coord_max, coord_max)); void allHigh(DFCoord coord, MapExtras::MapCache & map); void allNormal(DFCoord coord, MapExtras::MapCache & map); diff --git a/plugins/flows.cpp b/plugins/flows.cpp index c13e355b6c..c99d21c33b 100644 --- a/plugins/flows.cpp +++ b/plugins/flows.cpp @@ -5,6 +5,7 @@ #include "PluginManager.h" #include "DataDefs.h" +#include "df/global_objects.h" #include "df/world.h" #include "df/map_block.h" #include "df/tile_liquid.h" diff --git a/plugins/getplants.cpp b/plugins/getplants.cpp index 69eba4b1d1..3cbb3381c1 100644 --- a/plugins/getplants.cpp +++ b/plugins/getplants.cpp @@ -1,3 +1,5 @@ +#include + #include "Debug.h" #include "PluginManager.h" #include "TileTypes.h" @@ -7,6 +9,7 @@ #include "modules/Materials.h" #include "modules/Random.h" +#include "df/global_objects.h" #include "df/map_block.h" #include "df/map_block_column.h" #include "df/material.h" @@ -21,8 +24,6 @@ #include "df/world_object_data.h" #include "df/world_site.h" -#include - using std::string; using std::vector; using std::set; @@ -49,6 +50,77 @@ enum class selectability { Unselected }; +// Trait categories for the --brewable/--edible/--oil/--cloth/--dye filters. +// Brewable and oil-bearing materials are identified by the reaction products +// that the vanilla brewing and pressing reactions require (DRINK_MAT and +// PRESS_LIQUID_MAT respectively). +enum plant_trait { + trait_none = 0, + trait_brewable = 1 << 0, + trait_edible = 1 << 1, + trait_oil = 1 << 2, + trait_cloth = 1 << 3, + trait_dye = 1 << 4, +}; + +static bool materialHasProduct(const df::material *mat, const std::string &name) { + if (!mat) + return false; + for (const auto &id : mat->reaction_product.id) { + if (*id == name) + return true; + } + return false; +} + +static df::material *plantMatDef(const df::plant_raw *plant, int16_t def) { + const DFHack::MaterialInfo mi(plant->material_defs.type[def], plant->material_defs.idx[def]); + return mi.isValid() ? mi.material : nullptr; +} + +static bool growthMatchesTraits(const df::plant_growth *growth, unsigned traits) { + const DFHack::MaterialInfo mat(growth->mat_type, growth->mat_index); + if (!mat.isValid()) + return false; + return ((traits & trait_brewable) && materialHasProduct(mat.material, "DRINK_MAT")) || + ((traits & trait_oil) && materialHasProduct(mat.material, "PRESS_LIQUID_MAT")) || + ((traits & trait_edible) && mat.material->flags.is_set(material_flags::EDIBLE_RAW)) || + ((traits & trait_dye) && mat.material->flags.is_set(material_flags::IS_DYE)); +} + +// Does the plant have a part matching any of the given trait categories? +static bool plantMatchesTraits(const df::plant_raw *plant, unsigned traits) { + if ((traits & trait_cloth) && plant->flags.is_set(plant_raw_flags::THREAD)) + return true; + + df::material *basic = plantMatDef(plant, plant_material_def::basic_mat); + if (basic && + (((traits & trait_brewable) && materialHasProduct(basic, "DRINK_MAT")) || + ((traits & trait_edible) && basic->flags.is_set(material_flags::EDIBLE_RAW)) || + ((traits & trait_dye) && basic->flags.is_set(material_flags::IS_DYE)))) + return true; + + // Seeds and nuts are milled into paste when their material yields a press liquid + if ((traits & trait_oil) && + materialHasProduct(plantMatDef(plant, plant_material_def::seed), "PRESS_LIQUID_MAT")) + return true; + + if (traits & trait_dye) { + for (int16_t def : {plant_material_def::mill, plant_material_def::extract_vial, + plant_material_def::extract_barrel, plant_material_def::extract_still_vial}) { + df::material *mat = plantMatDef(plant, def); + if (mat && mat->flags.is_set(material_flags::IS_DYE)) + return true; + } + } + + for (auto *growth : plant->growths) { + if (growthMatchesTraits(growth, traits)) + return true; + } + return false; +} + // Determination of whether seeds can be collected is somewhat messy: // - Growths of type SEEDS are collected only if they are edible either raw or cooked. // - Growths of type PLANT_GROWTH are collected provided the STOCKPILE_PLANT_GROWTH @@ -246,13 +318,15 @@ bool picked(const df::plant* plant, int32_t growth_subtype, int32_t growth_densi return false; } -bool designate(color_ostream& out, const df::plant* plant, bool farming) { +bool designate(color_ostream& out, const df::plant* plant, bool farming, bool dry_run) { TRACE(log, out).print("Attempting to designate {} at ({}, {}, {})\n", world->raws.plants.all[plant->material]->id, plant->pos.x, plant->pos.y, plant->pos.z); + auto mark = dry_run ? Designations::canMarkPlant : Designations::markPlant; + if (!farming) { bool istree = (tileMaterial(Maps::getTileBlock(plant->pos)->tiletype[plant->pos.x % 16][plant->pos.y % 16]) == tiletype_material::TREE); if (istree) - return Designations::markPlant(plant); + return mark(plant); } df::plant_raw* plant_raw = world->raws.plants.all[plant->material]; @@ -260,7 +334,7 @@ bool designate(color_ostream& out, const df::plant* plant, bool farming) { if (basic_mat.material->flags.is_set(material_flags::EDIBLE_RAW) || basic_mat.material->flags.is_set(material_flags::EDIBLE_COOKED)) { - return Designations::markPlant(plant); + return mark(plant); } if (plant_raw->flags.is_set(plant_raw_flags::THREAD) || @@ -269,14 +343,14 @@ bool designate(color_ostream& out, const df::plant* plant, bool farming) { plant_raw->flags.is_set(plant_raw_flags::EXTRACT_BARREL) || plant_raw->flags.is_set(plant_raw_flags::EXTRACT_STILL_VIAL)) { if (!farming) { - return Designations::markPlant(plant); + return mark(plant); } } if (basic_mat.material->reaction_product.id.size() > 0 || basic_mat.material->reaction_class.size() > 0) { if (!farming) { - return Designations::markPlant(plant); + return mark(plant); } } @@ -314,7 +388,7 @@ bool designate(color_ostream& out, const df::plant* plant, bool farming) { if ((!farming || seedSource) && ripe(plant->pos.x, plant->pos.y, plant->pos.z, plant_raw->growths[i]->timing_1, plant_raw->growths[i]->timing_2) && !picked(plant, i, plant_raw->growths[i]->density)) - return Designations::markPlant(plant); + return mark(plant); } return false; @@ -325,7 +399,8 @@ command_result df_getplants(color_ostream& out, vector & parameters) { std::vector plantSelections; std::vector collectionCount; set plantNames; - bool deselect = false, exclude = false, treesonly = false, shrubsonly = false, all = false, verbose = false, farming = false; + bool deselect = false, exclude = false, treesonly = false, shrubsonly = false, all = false, verbose = false, farming = false, dry_run = false; + unsigned traits = trait_none; size_t maxCount = 999999; int count = 0; @@ -356,6 +431,18 @@ command_result df_getplants(color_ostream& out, vector & parameters) { verbose = true; else if (parameters[i] == "-f") farming = true; + else if (parameters[i] == "-d" || parameters[i] == "--dry-run") + dry_run = true; + else if (parameters[i] == "--brewable") + traits |= trait_brewable; + else if (parameters[i] == "--edible") + traits |= trait_edible; + else if (parameters[i] == "--oil") + traits |= trait_oil; + else if (parameters[i] == "--cloth") + traits |= trait_cloth; + else if (parameters[i] == "--dye") + traits |= trait_dye; else if (parameters[i] == "-n") { if (parameters.size() > i + 1) { maxCount = atoi(parameters[i + 1].c_str()); @@ -392,14 +479,30 @@ command_result df_getplants(color_ostream& out, vector & parameters) { return CR_WRONG_USAGE; } + std::vector traitMatches(world->raws.plants.all.size(), false); + if (traits != trait_none) { + for (size_t i = 0; i < world->raws.plants.all.size(); i++) + traitMatches[i] = plantMatchesTraits(world->raws.plants.all[i], traits); + } + for (size_t i = 0; i < world->raws.plants.all.size(); i++) { df::plant_raw* plant = world->raws.plants.all[i]; if (all) { plantSelections[i] = selectablePlant(out, plant, farming); + if (traits != trait_none && !traitMatches[i]) + plantSelections[i] = selectability::Nonselectable; } else if (plantNames.find(plant->id) != plantNames.end()) { plantNames.erase(plant->id); plantSelections[i] = selectablePlant(out, plant, farming); + if (traits != trait_none && !traitMatches[i] && + (plantSelections[i] == selectability::Selectable || + plantSelections[i] == selectability::OutOfSeason)) + { + out.printerr("{} does not match the specified trait filters\n", plant->id); + plantSelections[i] = selectability::Nonselectable; + continue; + } switch (plantSelections[i]) { case selectability::Grass: out.printerr("{} is a grass and cannot be gathered\n", plant->id); @@ -446,6 +549,8 @@ command_result df_getplants(color_ostream& out, vector & parameters) { out.print("Valid plant IDs:\n"); for (size_t i = 0; i < world->raws.plants.all.size(); i++) { df::plant_raw* plant = world->raws.plants.all[i]; + if (traits != trait_none && !traitMatches[i]) + continue; switch (selectablePlant(out, plant, farming)) { case selectability::Grass: case selectability::Nonselectable: @@ -478,52 +583,65 @@ command_result df_getplants(color_ostream& out, vector & parameters) { } count = 0; - for (size_t i = 0; i < world->plants.all.size(); i++) { - const df::plant* plant = world->plants.all[i]; + for (auto* plant : world->plants.all) + { df::map_block* cur = Maps::getTileBlock(plant->pos); - TRACE(log, out).print("Examining {} at ({}, {}, {}) [index={}]\n", world->raws.plants.all[plant->material]->id, plant->pos.x, plant->pos.y, plant->pos.z, (int)i); + auto mat = plant->material; + if (mat < 0 || mat >= int16_t(world->raws.plants.all.size())) + { + WARN(log, out).print("plant with invalid material {} in plant vector", mat); + continue; + } + + TRACE(log, out).print("Examining {} at ({}, {}, {})\n", world->raws.plants.all[mat]->id, plant->pos.x, plant->pos.y, plant->pos.z); int x = plant->pos.x % 16; int y = plant->pos.y % 16; - if (plantSelections[plant->material] == selectability::OutOfSeason || - plantSelections[plant->material] == selectability::Selectable) { + if (plantSelections[mat] == selectability::OutOfSeason || + plantSelections[mat] == selectability::Selectable) + { if (exclude || - plantSelections[plant->material] == selectability::OutOfSeason) + plantSelections[mat] == selectability::OutOfSeason) continue; } - else { + else + { if (!exclude) continue; } + if (traits != trait_none && !traitMatches[mat]) + continue; df::tiletype tt = cur->tiletype[x][y]; - df::tiletype_material mat = tileMaterial(tt); + df::tiletype_material tile_mat = tileMaterial(tt); if ((treesonly || tt != tiletype::Shrub) && ENUM_ATTR(plant_type, is_shrub, plant->type)) continue; - if ((shrubsonly || mat != tiletype_material::TREE) && !ENUM_ATTR(plant_type, is_shrub, plant->type)) + if ((shrubsonly || tile_mat != tiletype_material::TREE) && !ENUM_ATTR(plant_type, is_shrub, plant->type)) continue; if (cur->designation[x][y].bits.hidden) continue; - if (collectionCount[plant->material] >= maxCount) + if (collectionCount[mat] >= maxCount) continue; - if (deselect && Designations::unmarkPlant(plant)) { - collectionCount[plant->material]++; + if (deselect && (dry_run ? Designations::canUnmarkPlant(plant) : Designations::unmarkPlant(plant))) + { + collectionCount[mat]++; ++count; } - if (!deselect && designate(out, plant, farming)) { - DEBUG(log, out).print("Designated {} at ({}, {}, {}), {}\n", world->raws.plants.all[plant->material]->id, plant->pos.x, plant->pos.y, plant->pos.z, (int)i); - collectionCount[plant->material]++; + if (!deselect && designate(out, plant, farming, dry_run)) + { + DEBUG(log, out).print("Designated {} at ({}, {}, {})\n", world->raws.plants.all[mat]->id, plant->pos.x, plant->pos.y, plant->pos.z); + collectionCount[mat]++; ++count; } } if (count && verbose) { for (size_t i = 0; i < plantSelections.size(); i++) { if (collectionCount[i] > 0) - out.print("Updated {} {} designations.\n", collectionCount[i], world->raws.plants.all[i]->id); + out.print("{} {} {} designations.\n", dry_run ? "Would update" : "Updated", collectionCount[i], world->raws.plants.all[i]->id); } out.print("\n"); } - out.print("Updated {} plant designations.\n", count); + out.print("{} {} plant designations.\n", dry_run ? "Would update" : "Updated", count); return CR_OK; } diff --git a/plugins/infinite-sky.cpp b/plugins/infinite-sky.cpp index 402a4a7a7b..4977dd3e30 100644 --- a/plugins/infinite-sky.cpp +++ b/plugins/infinite-sky.cpp @@ -11,6 +11,7 @@ #include "df/block_column_print_infost.h" #include "df/construction.h" #include "df/entity_plot_invasion_mapst.h" +#include "df/global_objects.h" #include "df/historical_entity.h" #include "df/invasion_info.h" #include "df/map_block.h" @@ -136,132 +137,10 @@ static void constructionEventHandler(color_ostream &out, void *ptr) { doInfiniteSky(out, 1); } -void addBlockColumns(color_ostream& out, int32_t quantity) { - int32_t z_count_block = world->map.z_count_block; - df::map_block ****block_index = world->map.block_index; - - cuboid last_air_layer( - 0, 0, world->map.z_count_block - 1, - world->map.x_count_block - 1, world->map.y_count_block - 1, world->map.z_count_block - 1); - - last_air_layer.forCoord([&](df::coord bpos) { - // Allocate a new block column and copy over data from the old - df::map_block **blockColumn = - new df::map_block *[z_count_block + quantity]; - memcpy(blockColumn, block_index[bpos.x][bpos.y], - z_count_block * sizeof(df::map_block *)); - delete[] block_index[bpos.x][bpos.y]; - block_index[bpos.x][bpos.y] = blockColumn; - - df::map_block *last_air_block = blockColumn[bpos.z]; - for (int32_t count = 0; count < quantity; count++) { - df::map_block *air_block = new df::map_block(); - std::fill(&air_block->tiletype[0][0], - &air_block->tiletype[0][0] + (16 * 16), - df::tiletype::OpenSpace); - - // Set block positions properly (based on prior air layer) - air_block->map_pos = last_air_block->map_pos; - air_block->map_pos.z += count + 1; - air_block->region_pos = last_air_block->region_pos; - - // Copy other potentially important metadata from prior air - // layer - std::memcpy(air_block->lighting, last_air_block->lighting, - sizeof(air_block->lighting)); - std::memcpy(air_block->temperature_1, last_air_block->temperature_1, - sizeof(air_block->temperature_1)); - std::memcpy(air_block->temperature_2, last_air_block->temperature_2, - sizeof(air_block->temperature_2)); - std::memcpy(air_block->region_offset, last_air_block->region_offset, - sizeof(air_block->region_offset)); - - // Create tile designations to inform lighting and - // outside markers - df::tile_designation designation{}; - designation.bits.light = true; - designation.bits.outside = true; - std::fill(&air_block->designation[0][0], - &air_block->designation[0][0] + (16 * 16), designation); - - blockColumn[z_count_block + count] = air_block; - world->map.map_blocks.push_back(air_block); - - // deal with map_block_column stuff even though it'd probably be - // fine - df::map_block_column *column = - world->map.column_index[bpos.x][bpos.y]; - if (!column) { - DEBUG(cycle, out) - .print("{}, line {}: column is null ({}).\n", __FILE__, __LINE__, bpos); - continue; - } - df::block_column_print_infost *glyphs = new df::block_column_print_infost; - glyphs->x[0] = 0; - glyphs->x[1] = 1; - glyphs->x[2] = 2; - glyphs->x[3] = 3; - glyphs->y[0] = 0; - glyphs->y[1] = 0; - glyphs->y[2] = 0; - glyphs->y[3] = 0; - glyphs->tile[0] = 'e'; - glyphs->tile[1] = 'x'; - glyphs->tile[2] = 'p'; - glyphs->tile[3] = '^'; - column->unmined_glyphs.push_back(glyphs); - } - return true; - }); - - // Update global z level flags - df::z_level_flags *flags = new df::z_level_flags[z_count_block + quantity]; - memcpy(flags, world->map_extras.z_level_flags, - z_count_block * sizeof(df::z_level_flags)); - for (int32_t count = 0; count < quantity; count++) { - flags[z_count_block + count].whole = 0; - flags[z_count_block + count].bits.update = 1; - } - world->map.z_count_block += quantity; - world->map.z_count += quantity; - delete[] world->map_extras.z_level_flags; - world->map_extras.z_level_flags = flags; -} - -void updateInvasionMap(color_ostream &out, int32_t new_height, df::plot_invasion_mapst& map) { - if (map.blockz == 0) - return; // Unused invasion map - if (map.blockz >= new_height) - return; // No change required - - cuboid blocks(0, 0, 0, map.blockx - 1, map.blocky - 1, 0); - blocks.forCoord([&](df::coord bpos) { - // Create new vertical block - df::pim_blockst **new_block = new df::pim_blockst *[new_height](); - memcpy(new_block, map.block_index[bpos.x][bpos.y], map.blockz * sizeof(df::pim_blockst*)); - // Fill new block with nullptr (no information) - std::fill_n(&new_block[map.blockz], new_height - map.blockz, nullptr); - delete[] map.block_index[bpos.x][bpos.y]; - map.block_index[bpos.x][bpos.y] = new_block; - return true; - }); - - map.blockz = new_height; -} -void doInfiniteSky(color_ostream &out, int32_t quantity) { - addBlockColumns(out, quantity); - - for (auto& invasion : plotinfo->invasions.list) { - updateInvasionMap(out, world->map.z_count, invasion->map); - } - for (auto& entity : world->entities.all) { - for (auto& map : entity->plot_invasion_map) { - if (map->site_id != plotinfo->site_id) - continue; - updateInvasionMap(out, world->map.z_count, map->map); - } - } +void doInfiniteSky(color_ostream& out, int32_t quantity) +{ + Maps::addBlockColumns(world->map.z_count_block + quantity); } struct infinitesky_options { diff --git a/plugins/lair.cpp b/plugins/lair.cpp index ac5b88c4f9..dcfe3863fb 100644 --- a/plugins/lair.cpp +++ b/plugins/lair.cpp @@ -3,6 +3,7 @@ #include "modules/Maps.h" +#include "df/global_objects.h" #include "df/map_block.h" #include "df/world.h" diff --git a/plugins/liquids.cpp b/plugins/liquids.cpp index a814bbd5b0..ae0b0ef6c3 100644 --- a/plugins/liquids.cpp +++ b/plugins/liquids.cpp @@ -58,7 +58,8 @@ using namespace df::enums; DFHACK_PLUGIN("liquids"); REQUIRE_GLOBAL(world); -static const char * HISTORY_FILE = "dfhack-config/liquids.history"; +auto constexpr HISTORY_FILE = "liquids.history"; + CommandHistory liquids_hist; command_result df_liquids (color_ostream &out, vector & parameters); @@ -66,7 +67,7 @@ command_result df_liquids_here (color_ostream &out, vector & parameters DFhackCExport command_result plugin_init ( color_ostream &out, std::vector &commands) { - liquids_hist.load(HISTORY_FILE); + liquids_hist.load(DFHack::Core::getInstance().getConfigPath() / HISTORY_FILE); commands.push_back(PluginCommand( "liquids", "Place magma, water or obsidian.", @@ -82,7 +83,7 @@ DFhackCExport command_result plugin_init ( color_ostream &out, std::vector is_valid()) + return; + if (!block->Allocate()) + return; + for (int x = 0; x < 16; x++) + for (int y = 0; y < 16; y++) + block->setTiletypeAt(df::coord2d(x, y), tiletype::OpenSpace); +} + +// ensure the target map blocks exist so that liquids and obsidian can be +// placed in unallocated sky blocks +static void ensure_brush_blocks(MapExtras::MapCache &mcache, + const OperationMode &mode, const DFCoord &cursor) +{ + switch (mode.brush) + { + case B_POINT: + case B_BLOCK: + ensure_block(mcache, cursor); + break; + case B_RANGE: + { + // RectangleBrush paints the size-sized box extending + // forward from the cursor; note that z stays a map level + // so only x and y are converted to block units + DFCoord lo = Maps::getTileBlockCoord(cursor); + DFCoord hi = Maps::getTileBlockCoord(cursor + mode.size - DFCoord(1,1,1)); + for (int32_t z = cursor.z; z < cursor.z + mode.size.z; z++) + for (int32_t x = lo.x; x <= hi.x; x++) + for (int32_t y = lo.y; y <= hi.y; y++) + ensure_block(mcache, Maps::getBlockOrigin(DFCoord(x, y, z))); + } + break; + case B_COLUMN: + // ensure blocks upward while the column stays open, mirroring + // ColumnBrush's stopping condition + for (int32_t z = cursor.z; z < (int32_t)mcache.maxZ(); z++) + { + DFCoord pos(cursor.x, cursor.y, z); + ensure_block(mcache, pos); + df::tiletype tt = mcache.tiletypeAt(pos); + if (!DFHack::LowPassable(tt) && + !(z == cursor.z && DFHack::HighPassable(tt))) + break; + } + break; + case B_FLOOD: + // flood fill only travels through water tiles, which unallocated + // blocks can never contain + break; + } +} + static void print_prompt(std::ostream &str, OperationMode &cur_mode) { str <<"[" << paint_mode_name[cur_mode.paint] << ":" << brush_name[cur_mode.brush]; @@ -445,6 +504,11 @@ command_result df_liquids_execute(color_ostream &out, OperationMode &cur_mode, d } MapCache mcache; + + // allocate any unallocated target blocks so that liquids and obsidian + // can be placed in the sky + ensure_brush_blocks(mcache, cur_mode, cursor); + coord_vec all_tiles = brush->points(mcache,cursor); // Force the game to recompute its walkability cache @@ -464,6 +528,7 @@ command_result df_liquids_execute(color_ostream &out, OperationMode &cur_mode, d des.bits.flow_size = 0; des.bits.flow_forbid = false; mcache.setDesignationAt(*iter, des); + mcache.propagateVerticalFlags(*iter); iter ++; } break; @@ -474,6 +539,7 @@ command_result df_liquids_execute(color_ostream &out, OperationMode &cur_mode, d while (iter != all_tiles.end()) { mcache.setTiletypeAt(*iter, findRandomVariant(tiletype::LavaFloor1)); + mcache.propagateVerticalFlags(*iter); iter ++; } break; @@ -493,9 +559,10 @@ command_result df_liquids_execute(color_ostream &out, OperationMode &cur_mode, d mcache.setTemp2At(*iter,10015); mcache.setDesignationAt(*iter,a); - Block * b = mcache.BlockAt((*iter)/16); + Block * b = mcache.BlockAtTile(*iter); b->enableBlockUpdates(true); + mcache.propagateVerticalFlags(*iter); iter++; } break; @@ -523,9 +590,8 @@ command_result df_liquids_execute(color_ostream &out, OperationMode &cur_mode, d while (iter != all_tiles.end()) { DFHack::DFCoord current = *iter; // current tile coord - DFHack::DFCoord curblock = current /16; // current block coord // check if the block is actually there - auto block = mcache.BlockAt(curblock); + auto block = mcache.BlockAtTile(current); if(!block) { iter ++; diff --git a/plugins/lua/aquifer.lua b/plugins/lua/aquifer.lua index d5255ccd33..e9eab45cb8 100644 --- a/plugins/lua/aquifer.lua +++ b/plugins/lua/aquifer.lua @@ -84,7 +84,7 @@ function parse_commandline(args) {'z', 'cur-zlevel', handler=function() opts.curz = true end}, }) - if help or positionals[1] == 'help' then + if opts.help or positionals[1] == 'help' then print(dfhack.script_help()) return false end @@ -135,11 +135,11 @@ function parse_commandline(args) local modified = 0 if action == 'drain' then - modified = aquifer_drain(aq_type or 'all', pos1, pos2, opts.skip_top, opts.levels, opts.leaky) + modified = aquifer_drain(aq_type or 'all', pos1, pos2, opts.skip_top, opts.levels, opts.leaky, opts.all) elseif action == 'convert' then - modified = aquifer_convert(aq_type, pos1, pos2, opts.skip_top, opts.levels, opts.leaky) + modified = aquifer_convert(aq_type, pos1, pos2, opts.skip_top, opts.levels, opts.leaky, opts.all) elseif action == 'add' then - modified = aquifer_add(aq_type, pos1, pos2, opts.skip_top, opts.levels, opts.leaky) + modified = aquifer_add(aq_type, pos1, pos2, opts.skip_top, opts.levels, opts.leaky, opts.all) else qerror(('invalid action: %s'):format(action)) end diff --git a/plugins/lua/autochop.lua b/plugins/lua/autochop.lua index a1a7368821..768bee61a4 100644 --- a/plugins/lua/autochop.lua +++ b/plugins/lua/autochop.lua @@ -38,6 +38,9 @@ local function do_set_burrow_config(var_name, val, burrows) end for _,bspec in ipairs(burrows) do local config = autochop_getBurrowConfig(bspec) + if not config then + qerror(('burrow not found: %s'):format(bspec)) + end config[var_name] = val autochop_setBurrowConfig(config.id, config.chop, config.clearcut, config.protect_brewable, config.protect_edible, diff --git a/plugins/lua/autofarm.lua b/plugins/lua/autofarm.lua new file mode 100644 index 0000000000..1278aa99dd --- /dev/null +++ b/plugins/lua/autofarm.lua @@ -0,0 +1,184 @@ +local _ENV = mkmodule('plugins.autofarm') + +local gui = require('gui') +local overlay = require('plugins.overlay') +local widgets = require('gui.widgets') + +local function get_farm() + local bld = dfhack.gui.getSelectedBuilding(true) + if bld and bld:getType() == df.building_type.FarmPlot then + return bld + end +end + +local function farm_is_managed(farm) + return farm and autofarm_isFarmExcluded and + not autofarm_isFarmExcluded(farm.id) +end + +-------------------------------- +-- FarmControlsOverlay +-- +-- toggle strip shown on the farm plot info sheet: enables/disables autofarm +-- globally, marks the inspected farm as managed or manually controlled, and +-- provides a link to the autofarm configuration GUI + +FarmControlsOverlay = defclass(FarmControlsOverlay, overlay.OverlayWidget) +FarmControlsOverlay.ATTRS{ + desc='Adds autofarm controls to the farm plot info sheet.', + default_pos={x=-43, y=13}, + version=3, + default_enabled=true, + viewscreens='dwarfmode/ViewSheets/BUILDING/FarmPlot', + frame={w=50, h=2}, + frame_background=gui.CLEAR_PEN, +} + +function FarmControlsOverlay:init() + self:addviews{ + widgets.ToggleHotkeyLabel{ + view_id='autofarm_enabled', + frame={t=0, l=0}, + label='autofarm is', + key='CUSTOM_CTRL_A', + options={{value=true, label='enabled', pen=COLOR_GREEN}, + {value=false, label='disabled', pen=COLOR_RED}}, + on_change=function(val) setEnabled(val) end, + }, + widgets.ToggleHotkeyLabel{ + view_id='farm_managed', + frame={t=1, l=0}, + label='this farm is', + key='CUSTOM_CTRL_M', + enabled=function() return isEnabled() end, + options={{value=true, label='managed by autofarm', pen=COLOR_GREEN}, + {value=false, label='managed manually', pen=COLOR_YELLOW}}, + on_change=function(val) + local farm = get_farm() + if farm then autofarm_setFarmExcluded(farm.id, not val) end + end, + }, + widgets.TextButton{ + frame={t=0, r=0, w=18, h=1}, + label='configure', + key='CUSTOM_CTRL_S', + on_activate=function() dfhack.run_script('gui/autofarm') end, + }, + } +end + +function FarmControlsOverlay:render(dc) + self.subviews.autofarm_enabled:setOption(isEnabled()) + self.subviews.farm_managed:setOption(farm_is_managed(get_farm())) + FarmControlsOverlay.super.render(self, dc) +end + +-------------------------------- +-- FarmCoverOverlay +-- +-- opaque panel shown over the crop list of farms managed by autofarm. warns +-- the player that manual crop selection is overridden by autofarm and +-- provides links to the autofarm configuration GUI and to per-farm control + +local function get_crop_name(plant_id) + if not plant_id or plant_id < 0 then return 'fallow' end + local raw = df.plant_raw.find(plant_id) + return raw and raw.name or 'unknown' +end + +FarmCoverOverlay = defclass(FarmCoverOverlay, overlay.OverlayWidget) +FarmCoverOverlay.ATTRS{ + desc='Covers the crop list of farm plots managed by autofarm.', + default_pos={x=-40, y=16}, + version=4, + default_enabled=true, + viewscreens='dwarfmode/ViewSheets/BUILDING/FarmPlot', + frame={w=57, h=22}, + frame_style=gui.FRAME_MEDIUM, + frame_background=gui.CLEAR_PEN, + visible=function() + local farm = get_farm() + return isEnabled() and farm_is_managed(farm) and + farm:getBuildStage() == farm:getMaxBuildStage() + end, +} + +function FarmCoverOverlay:init() + self:addviews{ + widgets.Label{ + frame={t=1, l=2}, + text='This farm is managed by autofarm', + text_pen=COLOR_LIGHTGREEN, + }, + widgets.Label{ + view_id='now_planting', + frame={t=2, l=2}, + text='Now planting: ?', + }, + widgets.WrappedLabel{ + frame={t=3, l=2, r=2}, + text_to_wrap='Crops are chosen automatically based on your'.. + ' configured stock thresholds. Manual crop selection'.. + ' will be overridden.', + }, + widgets.WrappedLabel{ + view_id='fallow_note', + frame={t=6, l=2, r=2}, + text_pen=COLOR_YELLOW, + }, + widgets.TextButton{ + frame={b=4, l=2, r=2, h=1}, + label='configure autofarm', + key='CUSTOM_CTRL_S', + on_activate=function() dfhack.run_script('gui/autofarm') end, + }, + widgets.TextButton{ + frame={b=3, l=2, r=2, h=1}, + label='manage this farm manually', + key='CUSTOM_CTRL_M', + on_activate=function() + local farm = get_farm() + if farm then autofarm_setFarmExcluded(farm.id, true) end + end, + }, + } +end + +function FarmCoverOverlay:render(dc) + local farm = get_farm() + if farm then + self.subviews.now_planting:setText{ + 'Now planting: ', + {text=get_crop_name(farm.plant_id[df.global.cur_season]), + pen=COLOR_LIGHTCYAN}, + } + local all_fallow = true + for i=0,3 do + if farm.plant_id[i] >= 0 then + all_fallow = false + break + end + end + self.subviews.fallow_note:setText(all_fallow and + 'All seasons are fallow: no plantable seeds available, or all'.. + ' crops are above their thresholds.' or '') + end + FarmCoverOverlay.super.render(self, dc) +end + +-- swallow mouse input over the covered area so that the underlying crop +-- list cannot be manipulated while autofarm is in control +function FarmCoverOverlay:onInput(keys) + if FarmCoverOverlay.super.onInput(self, keys) then return true end + if (keys._MOUSE_L or keys._MOUSE_R) and self:getMousePos() then + return true + end + return false +end + +OVERLAY_WIDGETS = { + farm_controls=FarmControlsOverlay, + farm_cover=FarmCoverOverlay, +} + +return _ENV diff --git a/plugins/lua/autolabor.lua b/plugins/lua/autolabor.lua index 8cedf0bb19..7f3116291e 100644 --- a/plugins/lua/autolabor.lua +++ b/plugins/lua/autolabor.lua @@ -1,52 +1,6 @@ local _ENV = mkmodule('plugins.autolabor') -local gui = require('gui') -local overlay = require('plugins.overlay') -local widgets = require('gui.widgets') - -AutolaborOverlay = defclass(AutolaborOverlay, overlay.OverlayWidget) -AutolaborOverlay.ATTRS{ - desc='Adds information to the work details screen about whether work details are enabled.', - default_pos={x=7,y=-13}, - default_enabled=true, - viewscreens='dwarfmode/Info/LABOR/WORK_DETAILS', - frame={w=29, h=5}, - frame_style=gui.MEDIUM_FRAME, - frame_background=gui.CLEAR_PEN, -} - -function AutolaborOverlay:init() - self:addviews{ - widgets.Label{ - frame={t=0, l=0}, - text_pen=COLOR_LIGHTRED, - text='DFHack autolabor is active!', - visible=isEnabled, - }, - widgets.Label{ - frame={t=0, l=0}, - text_pen=COLOR_LIGHTRED, - text='Dwarf Therapist is active!', - visible=function() return not isEnabled() end, - }, - widgets.Label{ - frame={t=1, l=0}, - text_pen=COLOR_WHITE, - text={ - 'Any changes made on this', NEWLINE, - 'screen will have no effect.' - }, - }, - } -end - -function AutolaborOverlay:render(dc) - if not df.global.game.external_flag.automatic_professions_disabled then return end - AutolaborOverlay.super.render(self, dc) -end - -OVERLAY_WIDGETS = { - overlay=AutolaborOverlay, -} +-- The labor automation controls are part of the work details overlay in +-- scripts/gui/settings-manager.lua. return _ENV diff --git a/plugins/lua/blueprint.lua b/plugins/lua/blueprint.lua index 8c765d563f..58e7421c51 100644 --- a/plugins/lua/blueprint.lua +++ b/plugins/lua/blueprint.lua @@ -204,7 +204,7 @@ end -- returns the name of the output file for the given context function get_filename(opts, phase, ordinal) - local fullname = 'dfhack-config/blueprints/' .. opts.name + local fullname = dfhack.getConfigPath() .. '/blueprints/' .. opts.name local _,_,basename = opts.name:find('([^/]+)/*$') if not basename then -- should not happen since opts.name should already be validated diff --git a/plugins/lua/buildingplan.lua b/plugins/lua/buildingplan.lua index d6eb5fb9ae..d094f59c9d 100644 --- a/plugins/lua/buildingplan.lua +++ b/plugins/lua/buildingplan.lua @@ -59,6 +59,19 @@ function is_suspendmanager_enabled() return require('plugins.suspendmanager').isEnabled() end +-- The flag lives on the job, which exists as soon as the building is +-- designated, so it can be set before the job is unsuspended and it persists +-- with the save like any other job flag. +function setDoNow(bld, val) + if not bld or #bld.jobs == 0 then return false end + bld.jobs[0].flags.do_now = val + return true +end + +function getDoNow(bld) + return bld ~= nil and #bld.jobs > 0 and bld.jobs[0].flags.do_now +end + function get_num_filters(btype, subtype, custom) local filters = dfhack.buildings.getFiltersByType({}, btype, subtype, custom) return filters and #filters or 0 diff --git a/plugins/lua/buildingplan/planneroverlay.lua b/plugins/lua/buildingplan/planneroverlay.lua index f0fbe17de4..352286f626 100644 --- a/plugins/lua/buildingplan/planneroverlay.lua +++ b/plugins/lua/buildingplan/planneroverlay.lua @@ -11,10 +11,37 @@ local utils = require('utils') local widgets = require('gui.widgets') require('dfhack.buildings') -config = config or json.open('dfhack-config/buildingplan.json') +config = config or json.open(dfhack.getConfigPath() .. '/buildingplan.json') local uibs = df.global.buildreq +local ITEM_TO_JOB = { + [df.item_type.BED] = 'ConstructBed', [df.item_type.DOOR] = 'ConstructDoor', + [df.item_type.CABINET] = 'ConstructCabinet', [df.item_type.TABLE] = 'ConstructTable', + [df.item_type.CHAIR] = 'ConstructThrone', [df.item_type.BOX] = 'ConstructChest', + [df.item_type.ARMORSTAND] = 'ConstructArmorStand', [df.item_type.WEAPONRACK] = 'ConstructWeaponRack', + [df.item_type.STATUE] = 'ConstructStatue', [df.item_type.COFFIN] = 'ConstructCoffin', + [df.item_type.HATCH_COVER] = 'ConstructHatchCover', [df.item_type.GRATE] = 'ConstructGrate', + [df.item_type.QUERN] = 'ConstructQuern', [df.item_type.MILLSTONE] = 'ConstructMillstone', + [df.item_type.TRACTION_BENCH] = 'ConstructTractionBench', [df.item_type.SLAB] = 'ConstructSlab', + [df.item_type.ANVIL] = 'ForgeAnvil', [df.item_type.WINDOW] = 'MakeWindow', + [df.item_type.CAGE] = 'MakeCage', [df.item_type.BARREL] = 'MakeBarrel', + [df.item_type.BUCKET] = 'MakeBucket', [df.item_type.ANIMALTRAP] = 'MakeAnimalTrap', + [df.item_type.CHAIN] = 'MakeChain', [df.item_type.FLASK] = 'MakeFlask', + [df.item_type.GOBLET] = 'MakeGoblet', [df.item_type.BLOCKS] = 'ConstructBlocks', +} + +local JOB_DEFAULTS = { + ConstructBed='wood', ConstructTractionBench='wood', MakeCage='wood', + MakeBarrel='wood', MakeBucket='wood', MakeAnimalTrap='wood', + ForgeAnvil='iron', MakeChain='iron', MakeFlask='iron', MakeWindow='glass' +} + +local VALID_MAT_CATS = { + wood=true, bone=true, shell=true, horn=true, pearl=true, tooth=true, + leather=true, silk=true, yarn=true, cloth=true, plant=true +} + reset_counts_flag = false editing_filters_flag = false @@ -148,7 +175,7 @@ local function is_interior(bounds, x, y) y ~= bounds.y1 and y ~= bounds.y2 end --- adjusted from CycleHotkeyLabel on the planner panel +-- adjusted from WeaponSpiketrapPanel (HotKey & Slider) on the planner panel local weapon_quantity = 1 local function get_quantity(filter, hollow, bounds) @@ -181,7 +208,8 @@ local function get_quantity(filter, hollow, bounds) end return quantity * count end - return quantity * get_selected_volume(bounds) + -- job item quantities are totals for the whole building, not per-tile + return quantity end local function cur_building_has_no_area() @@ -248,7 +276,7 @@ local function has_direction_panel() and uibs.building_subtype == df.trap_type.TrackStop) end -local pressure_plate_panel_frame = {t=4, h=37, w=46, r=28} +local pressure_plate_panel_frame = {t=4, h=38, w=50, r=28} local function has_pressure_plate_panel() return is_pressure_plate() @@ -410,6 +438,8 @@ function ItemLine:get_item_line_text() end self.note = string.char(192) .. self.note -- character 192 is "└" + self.quantity = quantity + return ('%d %s%s'):format(quantity, self.desc, quantity == 1 and '' or 's') end @@ -610,7 +640,7 @@ function PlannerOverlay:init() local main_panel = widgets.Panel{ view_id='main', - frame={t=1, l=0, r=0, h=14}, + frame={t=1, l=0, r=0, h=15}, frame_style=gui.FRAME_INTERIOR_MEDIUM, frame_background=gui.CLEAR_PEN, visible=self:callback('is_not_minimized'), @@ -658,6 +688,43 @@ function PlannerOverlay:init() local buildingplan = require('plugins.buildingplan') + -- WeaponSpiketrapPanel defined outside of main_panel, otherwise addviews breaks -> addviews expects table + local WeaponSpiketrapPanel = defclass(WeaponSpiketrapPanel, widgets.Panel) + WeaponSpiketrapPanel.ATTRS{ + view_id='weapons', + visible=is_weapon_or_spike_trap, + } + + function WeaponSpiketrapPanel:init() + self.options = utils.tabulate(function(i) return {label='('..i..')', value=i, pen=COLOR_YELLOW} end, 1, 10) + + self:addviews{ + widgets.CycleHotkeyLabel{ + view_id='weapons_hotkey', + frame={b=5, l=1, w=28}, + key='CUSTOM_T', + key_back='CUSTOM_SHIFT_T', + label='Number of weapons:', + options=self.options, + initial_option=weapon_quantity, + on_change=function(val) + weapon_quantity = val + end + }, + + widgets.Slider{ + view_id='weapons_slider', + frame={b=7, l=4, w=35}, + num_stops=#self.options, + get_idx_fn=function() return weapon_quantity end, + on_change=function(val) + weapon_quantity = val + self.subviews.weapons_hotkey:setOption(val) + end + } + } + end + main_panel:addviews{ widgets.Label{ frame={}, @@ -683,7 +750,7 @@ function PlannerOverlay:init() on_clear_filter=self:callback('clear_filter')}, widgets.CycleHotkeyLabel{ view_id='hollow', - frame={b=4, l=1, w=21}, + frame={b=5, l=1, w=21}, key='CUSTOM_H', label='Hollow area:', visible=is_construction, @@ -694,7 +761,7 @@ function PlannerOverlay:init() }, widgets.CycleHotkeyLabel{ view_id='stairs_top_subtype', - frame={b=7, l=1, w=30}, + frame={b=8, l=1, w=30}, key='CUSTOM_R', label='Top stair type: ', visible=is_multi_level_stairs, @@ -706,7 +773,7 @@ function PlannerOverlay:init() }, widgets.CycleHotkeyLabel { view_id='stairs_bottom_subtype', - frame={b=6, l=1, w=30}, + frame={b=7, l=1, w=30}, key='CUSTOM_B', label='Bottom Stair Type:', visible=is_multi_level_stairs, @@ -718,7 +785,7 @@ function PlannerOverlay:init() }, widgets.CycleHotkeyLabel{ view_id='stairs_only_subtype', - frame={b=7, l=1, w=30}, + frame={b=8, l=1, w=30}, key='CUSTOM_R', label='Single level stair:', visible=is_single_level_stairs, @@ -728,19 +795,12 @@ function PlannerOverlay:init() {label='Down', value=df.construction_type.DownStair}, }, }, - widgets.CycleHotkeyLabel { -- TODO: this thing also needs a slider - view_id='weapons', - frame={b=4, l=1, w=28}, - key='CUSTOM_T', - key_back='CUSTOM_SHIFT_T', - label='Number of weapons:', - visible=is_weapon_or_spike_trap, - options=utils.tabulate(function(i) return {label='('..i..')', value=i, pen=COLOR_YELLOW} end, 1, 10), - on_change=function(val) weapon_quantity = val end, - }, + + WeaponSpiketrapPanel{}, + widgets.ToggleHotkeyLabel { view_id='engraved', - frame={b=4, l=1, w=22}, + frame={b=5, l=1, w=22}, key='CUSTOM_T', label='Engraved only:', visible=is_slab, @@ -750,7 +810,7 @@ function PlannerOverlay:init() }, widgets.ToggleHotkeyLabel { view_id='empty', - frame={b=4, l=1, w=22}, + frame={b=5, l=1, w=22}, key='CUSTOM_T', label='Empty only:', visible=is_cage, @@ -758,9 +818,32 @@ function PlannerOverlay:init() buildingplan.setSpecial(uibs.building_type, uibs.building_subtype, uibs.custom_type, 'empty', val) end, }, + widgets.ToggleHotkeyLabel{ + view_id='do_now', + -- b=4 in the left column is the favorites divider row; the first + -- free row of the options block is b=3 in the right column + frame={b=3, l=24, w=25}, + key='CUSTOM_N', + label='Do now:', + initial_option=self.state.do_now or false, + on_change=function(val) + self.state.do_now = val + config:write() + end, + }, widgets.Panel{ visible=function() return #get_cur_filters() > 0 end, subviews={ + widgets.HotkeyLabel{ + frame={b=3, l=1, w=22}, + key='CUSTOM_CTRL_Q', + label='Queue order', + on_activate=function() self:queue_order(self.selected) end, + visible=function() + local item = self.subviews['item'..tostring(self.selected)] + return item and item.available and item.quantity and (item.available < item.quantity) + end + }, widgets.HotkeyLabel{ frame={b=2, l=1, w=22}, key='CUSTOM_F', @@ -841,7 +924,7 @@ function PlannerOverlay:init() local error_panel = widgets.ResizingPanel{ view_id='errors', - frame={t=15, l=0, r=0}, + frame={t=16, l=0, r=0}, frame_style=gui.BOLD_FRAME, frame_background=gui.CLEAR_PEN, visible=self:callback('is_not_minimized'), @@ -903,7 +986,7 @@ function PlannerOverlay:init() local favorites_panel = widgets.Panel{ view_id='favorites', - frame={t=15, l=0, r=0, h=9}, + frame={t=16, l=0, r=0, h=9}, frame_style=gui.FRAME_INTERIOR_MEDIUM, frame_background=gui.CLEAR_PEN, visible=self:callback('show_favorites'), @@ -940,7 +1023,7 @@ function PlannerOverlay:init() is_selected_fn=make_is_selected_filter('0') }, widgets.CycleHotkeyLabel { view_id='slot_select', - frame={b=0, l=2}, + frame={b=2, l=2}, key='CUSTOM_X', key_back='CUSTOM_SHIFT_X', label='next/previous slot', @@ -950,11 +1033,16 @@ function PlannerOverlay:init() on_change=function(val) self.selected_favorite = val end, }, widgets.HotkeyLabel{ - frame={b=0, l=28}, + frame={b=2, l=28}, label="set/apply selected", key='CUSTOM_Y', on_activate=function () self:save_restore_filter(self.selected_favorite) end, }, + widgets.TooltipLabel { + frame={b=0, l=2}, + show_tooltip=true, + text="Shift+click to edit the label of a favorite", + }, } } @@ -974,7 +1062,7 @@ function PlannerOverlay:show_favorites() end function PlannerOverlay:show_hide_favorites(new) - local errors_frame = {t=15+(new and 9 or 0), l=0, r=0} + local errors_frame = {t=16+(new and 9 or 0), l=0, r=0} self.subviews.errors.frame = errors_frame self:updateLayout() end @@ -1043,6 +1131,74 @@ function PlannerOverlay:clear_filter(idx) desc=require('plugins.buildingplan').clearFilter(uibs.building_type, uibs.building_subtype, uibs.custom_type, idx-1) end +function PlannerOverlay:queue_order(idx) + local item = self.subviews['item'..tostring(idx)] + if not item or not item.available or not item.quantity or item.available >= item.quantity then return end + local missing = item.quantity - item.available + if missing <= 0 then return end + + local filter = get_cur_filters()[idx] + + local job_name = "ConstructBlocks" + local item_type = nil + if filter.item_type and filter.item_type ~= -1 then + item_type = filter.item_type + elseif filter.vector_id and filter.vector_id ~= -1 then + local mapping_vector = { + [df.job_item_vector_id.ANY_WEAPON] = df.item_type.WEAPON, + [df.job_item_vector_id.ANY_ARMOR] = df.item_type.ARMOR, + } + item_type = mapping_vector[filter.vector_id] + end + + if item_type and ITEM_TO_JOB[item_type] then + job_name = ITEM_TO_JOB[item_type] + end + + local order_json = { + job = job_name, + amount_total = missing + } + + local buildingplan = require('plugins.buildingplan') + local cats_list = {} + if buildingplan.hasFilter(uibs.building_type, uibs.building_subtype, uibs.custom_type, idx - 1) then + local cats = buildingplan.getMaterialMaskFilter(uibs.building_type, uibs.building_subtype, uibs.custom_type, idx - 1) + for cat, enabled in pairs(cats) do + if enabled and cat ~= 'unset' then + table.insert(cats_list, cat) + end + end + end + + if #cats_list == 0 then + -- df manager requires a material category for generic jobs or it queues "unknown material" orders + table.insert(cats_list, JOB_DEFAULTS[job_name] or 'stone') + end + + local mat_cats = {} + for _, cat in ipairs(cats_list) do + if VALID_MAT_CATS[cat] then + table.insert(mat_cats, cat) + elseif cat == 'stone' then + order_json.material = "INORGANIC" + elseif cat == 'glass' then + order_json.material = "GLASS_GREEN" + elseif cat == 'metal' or cat == 'iron' then + order_json.material = "IRON" + end + end + + if #mat_cats > 0 then + order_json.material_category = mat_cats + end + + dfhack.run_command_silent('workorder', json.encode(order_json)) + + local desc = item.desc or "item" + dfhack.gui.showAnnouncement('Work order queued for ' .. tostring(missing) .. ' ' .. desc .. '.', COLOR_YELLOW, true) +end + local function get_placement_data() local direction = uibs.direction local bounds = get_selected_bounds() @@ -1369,6 +1525,11 @@ function PlannerOverlay:place_building(placement_data, chosen_items) end end buildingplan.addPlannedBuilding(bld) + -- the job already exists at designation time; flagging it now means it + -- is posted with do_now whenever it gets unsuspended + if self.state.do_now then + buildingplan.setDoNow(bld, true) + end end buildingplan.scheduleCycle() uibs.selection_pos:clear() diff --git a/plugins/lua/buildingplan/unlink_mechanisms.lua b/plugins/lua/buildingplan/unlink_mechanisms.lua index 86ae80940f..d6a676fdd1 100644 --- a/plugins/lua/buildingplan/unlink_mechanisms.lua +++ b/plugins/lua/buildingplan/unlink_mechanisms.lua @@ -4,6 +4,7 @@ local dialogs = require('gui.dialogs') local overlay = require("plugins.overlay") local utils = require("utils") local widgets = require("gui.widgets") +local lever = reqscript("lever") local function mech_iter(b) --iterate mechanisms backwards local t = b.contained_items @@ -35,6 +36,40 @@ local function get_mech_target(m) --mechanism target building if exists return i and df.building.find(m.general_refs[i].building_id) or nil end +local function is_lever(b) --building is a lever + return b and b._type == df.building_trapst and b.trap_type == df.trap_type.Lever +end + +local function get_pull_job(b) --pending pull job on lever, or nil + for _, j in ipairs(b.jobs) do + if j.job_type == df.job_type.PullLever then + return j + end + end +end + +local ASCII_LEVER_OFF = string.char(0x95) --ò +local ASCII_LEVER_ON = string.char(0xA2) --ó + +local function get_lever_state_char(lever) --lever position glyph for the current tileset + -- match the current mode because ASCII and premium lever glyphs differ in directions + if dfhack.screen.inGraphicsMode() then + return lever.state == 0 and "/" or "\\" + end + return lever.state == 0 and ASCII_LEVER_OFF or ASCII_LEVER_ON +end + +local function get_pull_label(lever) --button label and pen for the lever's pull state + local state_char = get_lever_state_char(lever) --current lever position + local job = get_pull_job(lever) + if not job then + return "Pull "..state_char, COLOR_WHITE + elseif dfhack.job.getWorker(job) then + return "Pulling "..state_char, COLOR_GREEN --a citizen has taken the job + end + return "Queued "..state_char, COLOR_YELLOW --queued, not yet taken +end + local function has_link_tab(b) --linked building tab exists if not b then return @@ -148,7 +183,7 @@ local valid_build = { MechLinkOverlay = defclass(MechLinkOverlay, overlay.OverlayWidget) MechLinkOverlay.ATTRS { - desc = "Allows unlinking mechanisms from buildings.", + desc = "Allows unlinking mechanisms and pulling linked levers from buildings.", default_enabled = true, default_pos = {x=-41, y=-4}, frame = {w=56, h=27}, @@ -303,6 +338,55 @@ function MechLinkOverlay:activate_button(n) end end +function MechLinkOverlay:get_pull_button(n, ensure) + local button = self.subviews["pull_"..n] + if not button and ensure then + self:addviews + { + widgets.TextButton + { + view_id = "pull_"..n, + frame = {t=0, r=17, w=11, h=1}, + label = "", --set per-frame in update_buttons + on_activate = function() self:activate_pull(n) end, + visible = false, + }, + } + button = self.subviews["pull_"..n] + button:updateLayout(self.frame_body) + end + + return button +end + +function MechLinkOverlay:pull_target(n) --linked lever for button n, or nil + local button = self:get_pull_button(n) + if not button then + return + end + + local idx = self:idx_from_offset(button.frame.t) + if idx > 0 and idx < #self.building.contained_items then + local target = get_mech_target(self.building.contained_items[idx].item) + if is_lever(target) then + return target + end + end +end + +function MechLinkOverlay:activate_pull(n) + local target = self:pull_target(n) + if not target then + return + end + local job = get_pull_job(target) + if job then + dfhack.job.removeJob(job) --cancel queued pull + else + lever.leverPullJob(target, true) --do now + end +end + function MechLinkOverlay:ask_unlink_all() local saved_mode = self.subviews.unlink_mode:getOptionValue() local message = { @@ -318,8 +402,17 @@ function MechLinkOverlay:ask_unlink_all() local free_src = (saved_mode % 2 == 1) local free_dst = (saved_mode // 2 > 0) + -- freeing a mechanism removes it from contained_items and shifts the + -- indices of the entries after it, so resolve items before mutating + local items = {} for _, idx in ipairs(self.links) do - local item = self.building.contained_items[idx].item + local entry = self.building.contained_items[idx] + if entry then + table.insert(items, entry.item) + end + end + + for _, item in ipairs(items) do local target = get_mech_target(item) if target then @@ -356,6 +449,20 @@ function MechLinkOverlay:update_buttons() button.visible = true end button:updateLayout() + + local pbutton = self:get_pull_button(i, true) + pbutton.visible = false + local target = idx > 0 and idx < bci_len and + get_mech_target(self.building.contained_items[idx].item) + if is_lever(target) then + local label, pen = get_pull_label(target) + pbutton:setLabel(label) + pbutton.label.text_pen = pen + pbutton.frame.t = offset + pbutton.frame.r = h_offset + 9 + pbutton.visible = true + end + pbutton:updateLayout() end local b = (self.frame.h % 3) == 1 and #self.links >= self.num_buttons and 0 or 1 @@ -371,6 +478,10 @@ function MechLinkOverlay:preUpdateLayout(parent_rect) if button then button.visible = false end + local pbutton = self:get_pull_button(i) + if pbutton then + pbutton.visible = false + end end local h = parent_rect.height - 49 diff --git a/plugins/lua/dig.lua b/plugins/lua/dig.lua index babdb3b7ab..e8cf3eb723 100644 --- a/plugins/lua/dig.lua +++ b/plugins/lua/dig.lua @@ -4,7 +4,7 @@ local gui = require('gui') local overlay = require('plugins.overlay') local widgets = require('gui.widgets') -local toolbar_textures = dfhack.textures.loadTileset('hack/data/art/damp_dig_toolbar.png', 8, 12, true) +local toolbar_textures = dfhack.textures.loadTileset(dfhack.getHackPath()..'/data/art/damp_dig_toolbar.png', 8, 12, true) local main_if = df.global.game.main_interface local selection_rect = df.global.selection_rect @@ -365,6 +365,7 @@ DesignatedOverlay.ATTRS{ 'dwarfmode/Designate/TRACK', 'dwarfmode/Designate/FORTIFY', 'dwarfmode/Designate/ERASE', + 'dwarfmode/dfhack/lua/quickfort', }, default_enabled=true, frame={w=0, h=0}, diff --git a/plugins/lua/dwarfmonitor.lua b/plugins/lua/dwarfmonitor.lua index d98bfd80f5..40ed1c71df 100644 --- a/plugins/lua/dwarfmonitor.lua +++ b/plugins/lua/dwarfmonitor.lua @@ -5,7 +5,7 @@ local guidm = require('gui.dwarfmode') local overlay = require('plugins.overlay') local utils = require('utils') -local DWARFMONITOR_CONFIG_FILE = 'dfhack-config/dwarfmonitor.json' +local DWARFMONITOR_CONFIG_FILE = dfhack.getConfigPath() .. '/dwarfmonitor.json' -- ------------- -- -- WeatherWidget -- @@ -117,12 +117,10 @@ end function MiseryWidget:overlay_onupdate() local counts, num_colors = {}, #self.colors - for _,unit in ipairs(df.global.world.units.active) do - if not dfhack.units.isCitizen(unit, true) then goto continue end + for _,unit in ipairs(dfhack.units.getCitizens(true, true)) do local stress_category = math.min(num_colors, dfhack.units.getStressCategory(unit)+1) counts[stress_category] = (counts[stress_category] or 0) + 1 - ::continue:: end local width = 2 + num_colors - 1 -- 'H:' plus the slashes diff --git a/plugins/lua/export-world-map.lua b/plugins/lua/export-world-map.lua new file mode 100644 index 0000000000..672f141e43 --- /dev/null +++ b/plugins/lua/export-world-map.lua @@ -0,0 +1,12 @@ +local _ENV = mkmodule('plugins.export-world-map') + +--[[ + +Native functions: + +- getWorldFolderName() +- getDateFolderName() + +]] + +return _ENV diff --git a/plugins/lua/hotkeys.lua b/plugins/lua/hotkeys.lua index d0cecb5ba0..5c04fba2df 100644 --- a/plugins/lua/hotkeys.lua +++ b/plugins/lua/hotkeys.lua @@ -5,7 +5,7 @@ local helpdb = require('helpdb') local overlay = require('plugins.overlay') local widgets = require('gui.widgets') -local logo_textures = dfhack.textures.loadTileset('hack/data/art/logo.png', 8, 12, true) +local logo_textures = dfhack.textures.loadTileset(dfhack.getHackPath()..'/data/art/logo.png', 8, 12, true) local function get_command(cmdline) local first_word = cmdline:trim():split(' +')[1] diff --git a/plugins/lua/orders.lua b/plugins/lua/orders.lua index 2774bd80ee..a470e3e2ce 100644 --- a/plugins/lua/orders.lua +++ b/plugins/lua/orders.lua @@ -38,7 +38,7 @@ local function do_import() dismiss_on_select2=false, on_select2=function(_, choice) if choice.text:startswith('library/') then return end - local fname = 'dfhack-config/orders/'..choice.text..'.json' + local fname = dfhack.getConfigPath() .. '/orders/' .. choice.text .. '.json' if not dfhack.filesystem.isfile(fname) then return end dialogs.showYesNoPrompt('Delete orders file?', 'Are you sure you want to delete "' .. fname .. '"?', nil, @@ -74,10 +74,11 @@ local mi = df.global.game.main_interface OrdersOverlay = defclass(OrdersOverlay, overlay.OverlayWidget) OrdersOverlay.ATTRS{ desc='Adds import, export, and other functions to the manager orders screen.', - default_pos={x=53,y=-6}, + default_pos={x=41,y=-6}, default_enabled=true, viewscreens='dwarfmode/Info/WORK_ORDERS/Default', frame={w=43, h=4}, + version=1, } function OrdersOverlay:init() @@ -709,11 +710,344 @@ function QuantityRightClickOverlay:onInput(keys) end end +-- +-- OrdersSearchOverlay +-- + +local ORDER_HEIGHT = 3 +local TABS_WIDTH_THRESHOLD = 155 +local LIST_START_Y_ONE_TABS_ROW = 8 +local LIST_START_Y_TWO_TABS_ROWS = 10 +local BOTTOM_MARGIN = 9 +local ARROW_X = 10 + +local SELECTED_PEN = dfhack.pen.parse{fg=COLOR_BLACK, bg=COLOR_WHITE, bold=true} +local MATCH_PEN = dfhack.pen.parse{fg=COLOR_WHITE, bg=COLOR_BLACK, bold=true} + +local function perform_search(text) + local matches = {} + + if text == '' then + return matches + end + + local orders = df.global.world.manager_orders.all + for i = 0, #orders - 1 do + local order = orders[i] + local search_key = dfhack.job.getManagerOrderName(order) + if search_key and utils.search_text(search_key, text) then + table.insert(matches, i) + end + end + + return matches +end + +local function concat_order_names() + local orders = df.global.world.manager_orders.all + if #orders == 0 then return "" end + + local names = {} + for i = 0, #orders - 1 do + local name = dfhack.job.getManagerOrderName(orders[i]) + table.insert(names, name or "") + end + + return table.concat(names, "|") +end + +local function getListStartY() + local rect = gui.get_interface_rect() + + if rect.width >= TABS_WIDTH_THRESHOLD then + return LIST_START_Y_ONE_TABS_ROW + else + return LIST_START_Y_TWO_TABS_ROWS + end +end + +local function getViewportSize() + local rect = gui.get_interface_rect() + local list_start_y = getListStartY() + + local available_height = rect.height - list_start_y - BOTTOM_MARGIN + return math.floor(available_height / ORDER_HEIGHT) +end + +local function getVisibleOrderIndices() + local orders = df.global.world.manager_orders.all + local scroll_pos = mi.info.work_orders.scroll_position_work_orders + + if #orders == 0 then return 0, -1 end + + local viewport_size = getViewportSize() + local viewport_start = scroll_pos + local viewport_end = scroll_pos + viewport_size - 1 + + -- Handle end-of-list case + if viewport_end >= #orders then + viewport_end = #orders - 1 + viewport_start = math.max(0, viewport_end - viewport_size + 1) + end + + return viewport_start, viewport_end +end + +local function calculateOrderY(order_idx) + local orders = df.global.world.manager_orders.all + + if #orders == 0 or order_idx < 0 or order_idx >= #orders then + return nil + end + + local viewport_start, viewport_end = getVisibleOrderIndices() + + -- Check if order is in viewport + if order_idx < viewport_start or order_idx > viewport_end then + return nil + end + + local list_start_y = getListStartY() + local pos_in_viewport = order_idx - viewport_start + + return list_start_y + (pos_in_viewport * ORDER_HEIGHT) +end + +OrdersSearchOverlay = defclass(OrdersSearchOverlay, overlay.OverlayWidget) +OrdersSearchOverlay.ATTRS{ + desc='Adds a search box to find and navigate to matching manager orders.', + default_pos={x=85, y=-6}, + default_enabled=true, + viewscreens='dwarfmode/Info/WORK_ORDERS/Default', + frame={w=26, h=4}, + overlay_onupdate_max_freq_seconds=1, +} + +function OrdersSearchOverlay:init() + local main_panel = widgets.Panel{ + view_id='main_panel', + frame={t=0, l=0, r=0, h=4}, + frame_style=gui.MEDIUM_FRAME, + frame_background=gui.CLEAR_PEN, + frame_title='Search', + visible=function() return not self.minimized end, + subviews={ + widgets.EditField{ + view_id='filter', + frame={t=0, l=0}, + key='CUSTOM_ALT_S', + on_change=self:callback('update_filter'), + on_submit=self:callback('on_submit'), + on_submit2=self:callback('on_submit2'), + }, + widgets.HotkeyLabel{ + frame={t=1, l=0}, + label='prev', + key='CUSTOM_ALT_P', + auto_width=true, + on_activate=self:callback('cycle_match', -1), + enabled=function() return #self.matched_indices > 0 end, + }, + widgets.HotkeyLabel{ + frame={t=1, l=12}, + label='next', + key='CUSTOM_ALT_N', + auto_width=true, + on_activate=self:callback('cycle_match', 1), + enabled=function() return #self.matched_indices > 0 end, + }, + }, + } + + local minimized_panel = widgets.Panel{ + frame={t=0, r=0, w=3, h=1}, + subviews={ + widgets.Label{ + frame={t=0, l=0, w=1, h=1}, + text='[', + text_pen=COLOR_RED, + visible=function() return self.minimized end, + }, + widgets.Label{ + frame={t=0, l=1, w=1, h=1}, + text={{text=function() return self.minimized and string.char(31) or string.char(30) end}}, + text_pen=dfhack.pen.parse{fg=COLOR_BLACK, bg=COLOR_GREY}, + text_hpen=dfhack.pen.parse{fg=COLOR_BLACK, bg=COLOR_WHITE}, + on_click=function() self.minimized = not self.minimized end, + }, + widgets.Label{ + frame={t=0, r=0, w=1, h=1}, + text=']', + text_pen=COLOR_RED, + visible=function() return self.minimized end, + }, + }, + } + + self:addviews{ + main_panel, + minimized_panel, + } + + self.minimized = false + self.matched_indices = {} + self.current_match_idx = 0 + self.cached_order_names = nil +end + +function OrdersSearchOverlay:overlay_onupdate() + if self.minimized then return end + + local current_order_names = concat_order_names() + if current_order_names ~= self.cached_order_names then + self.cached_order_names = current_order_names + self:update_filter() + end +end + +function OrdersSearchOverlay:update_filter() + local text = self.subviews.filter.text + self.matched_indices = perform_search(text) + self.current_match_idx = 0 + + if text == '' then + self.subviews.main_panel.frame_title = 'Search' + else + self.subviews.main_panel.frame_title = 'Search' .. self:get_match_text() + end +end + +function OrdersSearchOverlay:on_submit() + self:cycle_match(1) + self.subviews.filter:setFocus(true) +end + +function OrdersSearchOverlay:on_submit2() + self:cycle_match(-1) + self.subviews.filter:setFocus(true) +end + +function OrdersSearchOverlay:cycle_match(direction) + local search_text = self.subviews.filter.text + + local new_matches = perform_search(search_text) + + if #new_matches == 0 then + self.matched_indices = {} + self.current_match_idx = 0 + self.subviews.main_panel.frame_title = 'Search' + return + end + + local new_match_idx = self.current_match_idx + direction + + if new_match_idx > #new_matches then + new_match_idx = 1 + elseif new_match_idx < 1 then + new_match_idx = #new_matches + end + + self.matched_indices = new_matches + self.current_match_idx = new_match_idx + + -- Scroll to the selected match only if not already visible + local order_idx = self.matched_indices[self.current_match_idx] + local viewport_start, viewport_end = getVisibleOrderIndices() + if order_idx < viewport_start or order_idx > viewport_end then + mi.info.work_orders.scroll_position_work_orders = order_idx + end + + self.subviews.main_panel.frame_title = 'Search' .. self:get_match_text() +end + +function OrdersSearchOverlay:get_match_text() + local total_matches = #self.matched_indices + + if total_matches == 0 then + return '' + end + + if self.current_match_idx == 0 then + return string.format(': %d matches', total_matches) + end + + return string.format(': %d of %d', self.current_match_idx, total_matches) +end + +local function is_mouse_key(keys) + return keys._MOUSE_L + or keys._MOUSE_R + or keys._MOUSE_M + or keys.CONTEXT_SCROLL_UP + or keys.CONTEXT_SCROLL_DOWN + or keys.CONTEXT_SCROLL_PAGEUP + or keys.CONTEXT_SCROLL_PAGEDOWN +end + +function OrdersSearchOverlay:onInput(keys) + if mi.job_details.open then return end + + local filter_field = self.subviews.filter + if not filter_field then return false end + + -- Unfocus search on right-click + if keys._MOUSE_R and filter_field.focus then + filter_field:setFocus(false) + return true + end + + -- Let parent handle input first (for HotkeyLabel clicks and widget interactions) + if OrdersSearchOverlay.super.onInput(self, keys) then + return true + end + + -- Unfocus search on left-click when focused (for workshop and number of times changes) + -- And let the click pass through + if keys._MOUSE_L and filter_field.focus then + filter_field:setFocus(false) + return false + end + + -- Only consume input if search field has focus and it's not a mouse key + -- This allows scrolling, navigation, and mouse interaction in the orders list + if filter_field.focus and not is_mouse_key(keys) then + return true + end + + return false +end + +function OrdersSearchOverlay:render(dc) + if mi.job_details.open then return end + OrdersSearchOverlay.super.render(self, dc) + self:render_highlights(dc) +end + +function OrdersSearchOverlay:render_highlights(dc) + if #self.matched_indices == 0 then return end + + local selected_order_idx = self.current_match_idx > 0 and + self.matched_indices[self.current_match_idx] or nil + + for _, match_order_idx in ipairs(self.matched_indices) do + local match_y = calculateOrderY(match_order_idx) + + if match_y then + local pen = (match_order_idx == selected_order_idx) and SELECTED_PEN or MATCH_PEN + + dc:seek(ARROW_X, match_y):string('|', pen) + dc:seek(ARROW_X, match_y + 1):string('>', pen) + dc:seek(ARROW_X, match_y + 2):string('|', pen) + end + end +end + -- ------------------- OVERLAY_WIDGETS = { recheck=RecheckOverlay, importexport=OrdersOverlay, + search=OrdersSearchOverlay, skillrestrictions=SkillRestrictionOverlay, laborrestrictions=LaborRestrictionsOverlay, conditionsrightclick=ConditionsRightClickOverlay, diff --git a/plugins/lua/overlay.lua b/plugins/lua/overlay.lua index c1d1bcb434..95437c7993 100644 --- a/plugins/lua/overlay.lua +++ b/plugins/lua/overlay.lua @@ -6,7 +6,7 @@ local scriptmanager = require('script-manager') local utils = require('utils') local widgets = require('gui.widgets') -local OVERLAY_CONFIG_FILE = 'dfhack-config/overlay.json' +local OVERLAY_CONFIG_FILE = dfhack.getConfigPath() .. '/overlay.json' local OVERLAY_WIDGETS_VAR = 'OVERLAY_WIDGETS' local GLOBAL_KEY = 'OVERLAY' @@ -486,6 +486,10 @@ local function matches_focus_strings(db_entry, vs_name, vs) if dfhack.gui.matchFocusString(fs, vs) then return true end + local lua_focus = fs:match('^[^/]+/(dfhack/lua/.*)$') + if lua_focus and dfhack.gui.matchFocusString(lua_focus) then + return true + end end end return matched @@ -530,7 +534,25 @@ local function _feed_viewscreen_widgets(vs_name, vs, keys) return false end +-- when the native UI is capturing text input (renaming a building, editing a +-- workshop job filter, etc.), overlay widgets must not consume keys, since +-- their bound hotkeys would otherwise steal typed characters (#5764) +local function is_native_text_entry_active() + local mi = df.global.game.main_interface + local vs = mi.view_sheets + return mi.entering_building_name or + vs.building_entering_nickname or + vs.unit_overview_entering_nickname or + vs.unit_overview_entering_profession_nickname or + vs.entering_building_job_filter or + vs.entering_gen_work_order_num or + vs.entering_wq_number +end + function feed_viewscreen_widgets(vs_name, vs, keys) + if is_native_text_entry_active() then + return false + end if not _feed_viewscreen_widgets(vs_name, vs, keys) and not _feed_viewscreen_widgets('all', nil, keys) then return false diff --git a/plugins/lua/sort.lua b/plugins/lua/sort.lua index bdad4ae90e..5c22fb6486 100644 --- a/plugins/lua/sort.lua +++ b/plugins/lua/sort.lua @@ -293,7 +293,7 @@ local function get_mental_stability(unit) local emotionally_obsessive = unit.status.current_soul.personality.traits.EMOTIONALLY_OBSESSIVE local humor = unit.status.current_soul.personality.traits.HUMOR local love_propensity = unit.status.current_soul.personality.traits.LOVE_PROPENSITY - local perseverence = unit.status.current_soul.personality.traits.PERSEVERENCE + local perseverance = unit.status.current_soul.personality.traits.PERSEVERANCE local politeness = unit.status.current_soul.personality.traits.POLITENESS local privacy = unit.status.current_soul.personality.traits.PRIVACY local stress_vulnerability = unit.status.current_soul.personality.traits.STRESS_VULNERABILITY @@ -315,7 +315,7 @@ local function get_mental_stability(unit) + (anxiety_propensity * -0.06) + (bravery * 0.06) + (cheer_propensity * 0.41) + (curious * -0.06) + (discord * 0.14) + (dutifulness * -0.03) + (emotionally_obsessive * -0.13) - + (humor * -0.05) + (love_propensity * 0.15) + (perseverence * -0.07) + + (humor * -0.05) + (love_propensity * 0.15) + (perseverance * -0.07) + (politeness * -0.14) + (privacy * 0.03) + (stress_vulnerability * -0.20) + (tolerant * -0.11) @@ -1029,12 +1029,29 @@ function SquadFilterOverlay:init() local left_panel = widgets.Panel{ view_id='left_panel', - frame={t=1, b=0, l=0, w=NARROW_WIDTH-4}, + frame={t=0, b=0, l=0, w=NARROW_WIDTH-4}, visible=true, subviews={ + widgets.HotkeyLabel{ + view_id='toggle_all', + frame={t=0, l=0}, + key='CUSTOM_SHIFT_A', + label='Toggle all', + on_activate=function() + local target = self.subviews.military:getOptionValue() == 'exclude' and 'include' or 'exclude' + self.subviews.military:setOption(target) + self.subviews.officials:setOption(target) + self.subviews.nobles:setOption(target) + self.subviews.infant:setOption(target) + self.subviews.unstable:setOption(target) + self.subviews.maimed:setOption(target) + self.subviews.labor_conflict:setOption(target) + poke_list() + end, + }, widgets.CycleHotkeyLabel{ view_id='military', - frame={t=0, l=0}, + frame={t=1, l=0}, key='CUSTOM_SHIFT_Q', label='Other squads:', options={ @@ -1047,7 +1064,7 @@ function SquadFilterOverlay:init() }, widgets.CycleHotkeyLabel{ view_id='officials', - frame={t=1, l=0}, + frame={t=2, l=0}, key='CUSTOM_SHIFT_O', label=' Officials:', options={ @@ -1060,7 +1077,7 @@ function SquadFilterOverlay:init() }, widgets.CycleHotkeyLabel{ view_id='nobles', - frame={t=2, l=0}, + frame={t=3, l=0}, key='CUSTOM_SHIFT_N', label=' Nobility:', options={ @@ -1076,12 +1093,25 @@ function SquadFilterOverlay:init() local right_panel = widgets.Panel{ view_id='right_panel', - frame={t=1, b=0, r=2, w=NARROW_WIDTH-4}, + frame={t=0, b=0, r=2, w=NARROW_WIDTH-4}, visible=false, subviews={ widgets.CycleHotkeyLabel{ - view_id='infant', + view_id='labor_conflict', frame={t=0, l=0}, + key='CUSTOM_SHIFT_U', + label=' Uniformed:', + options={ + {label='Include', value='include', pen=COLOR_GREEN}, + {label='Only', value='only', pen=COLOR_YELLOW}, + {label='Exclude', value='exclude', pen=COLOR_LIGHTRED}, + }, + initial_option='include', + on_change=poke_list, + }, + widgets.CycleHotkeyLabel{ + view_id='infant', + frame={t=1, l=0}, key='CUSTOM_SHIFT_M', label='With infants:', options={ @@ -1094,7 +1124,7 @@ function SquadFilterOverlay:init() }, widgets.CycleHotkeyLabel{ view_id='unstable', - frame={t=1, l=0}, + frame={t=2, l=0}, key='CUSTOM_SHIFT_D', label='Hates combat:', options={ @@ -1107,7 +1137,7 @@ function SquadFilterOverlay:init() }, widgets.CycleHotkeyLabel{ view_id='maimed', - frame={t=2, l=0}, + frame={t=3, l=0}, key='CUSTOM_SHIFT_I', label=' Maimed:', options={ @@ -1125,21 +1155,6 @@ function SquadFilterOverlay:init() frame_style=gui.FRAME_MEDIUM, frame_background=gui.CLEAR_PEN, subviews={ - widgets.HotkeyLabel{ - frame={t=0, w=NARROW_WIDTH-3}, - key='CUSTOM_SHIFT_A', - label='Toggle all filters', - on_activate=function() - local target = self.subviews.military:getOptionValue() == 'exclude' and 'include' or 'exclude' - self.subviews.military:setOption(target) - self.subviews.officials:setOption(target) - self.subviews.nobles:setOption(target) - self.subviews.infant:setOption(target) - self.subviews.unstable:setOption(target) - self.subviews.maimed:setOption(target) - poke_list() - end, - }, left_panel, widgets.Label{ view_id='shifter', @@ -1167,9 +1182,8 @@ function SquadFilterOverlay:init() main_panel, widgets.Divider{ view_id='divider', - frame={l=NARROW_WIDTH-1, w=1, t=2}, + frame={l=NARROW_WIDTH-1, w=1, t=0}, frame_style=gui.FRAME_MEDIUM, - frame_style_t=false, visible=false, }, widgets.HelpButton{ @@ -1253,6 +1267,12 @@ local function is_maimed(unit) unit.status2.limbs_stand_count == 0 end +local function has_labor_conflict(unit) + return unit.status.labors[df.unit_labor.MINE] or + unit.status.labors[df.unit_labor.CUTWOOD] or + unit.status.labors[df.unit_labor.HUNT] +end + local function filter_matches(unit, filter) if filter.military == 'only' and not is_in_military(unit) then return false end if filter.military == 'exclude' and is_in_military(unit) then return false end @@ -1266,6 +1286,8 @@ local function filter_matches(unit, filter) if filter.unstable == 'exclude' and is_unstable(unit) then return false end if filter.maimed == 'only' and not is_maimed(unit) then return false end if filter.maimed == 'exclude' and is_maimed(unit) then return false end + if filter.labor_conflict == 'only' and not has_labor_conflict(unit) then return false end + if filter.labor_conflict == 'exclude' and has_labor_conflict(unit) then return false end return true end @@ -1281,6 +1303,7 @@ function do_squad_filter(unit) infant=self.subviews.infant:getOptionValue(), unstable=self.subviews.unstable:getOptionValue(), maimed=self.subviews.maimed:getOptionValue(), + labor_conflict=self.subviews.labor_conflict:getOptionValue(), } return filter_matches(unit, filter) end @@ -1294,6 +1317,7 @@ OVERLAY_WIDGETS = { candidates=require('plugins.sort.info').CandidatesOverlay, interrogation=require('plugins.sort.info').InterrogationOverlay, conviction=require('plugins.sort.info').ConvictionOverlay, + deathcause_button=require('plugins.sort.deathcause_button').DeathCauseOverlay, location_selector=require('plugins.sort.locationselector').LocationSelectorOverlay, -- TODO: maybe rewrite for 50.12 -- burrow_assignment=require('plugins.sort.unitselector').BurrowAssignmentOverlay, @@ -1302,6 +1326,7 @@ OVERLAY_WIDGETS = { places=require('plugins.sort.places').PlacesOverlay, elevate_barony=require('plugins.sort.diplomacy').DiplomacyOverlay, elevate_barony_preferences=require('plugins.sort.diplomacy').PreferenceOverlay, + stress_icons=require('plugins.sort.stressicons').StressIconsOverlay, } dfhack.onStateChange[GLOBAL_KEY] = function(sc) diff --git a/plugins/lua/sort/deathcause_button.lua b/plugins/lua/sort/deathcause_button.lua new file mode 100644 index 0000000000..b149601a7a --- /dev/null +++ b/plugins/lua/sort/deathcause_button.lua @@ -0,0 +1,78 @@ +local _ENV = mkmodule('plugins.sort.deathcause_button') + +local dialogs = require('gui.dialogs') +local overlay = require('plugins.overlay') +local widgets = require('gui.widgets') + +DeathCauseOverlay = defclass(DeathCauseOverlay, overlay.OverlayWidget) +DeathCauseOverlay.ATTRS{ + desc='Adds a button to view death cause on the dead/missing tab.', + default_pos={x=50, y=-7}, + default_enabled=true, + viewscreens='dwarfmode/Info/CREATURES/DECEASED', + frame={w=21, h=1}, +} + +function DeathCauseOverlay:init() + local deathcause = reqscript('deathcause') + + local function get_selected_unit() + -- Navigate to the creatures/deceased widget hierarchy: + -- list_widget - the main deceased list + -- ├─ children[0]: scrollbar widget + -- └─ children[1]: container widget (list_container) + -- ├─ grandchildren[0]: header + -- ├─ grandchildren[1]: header or other UI + -- └─ grandchildren[2]: scrollable rows container (scrollable_list) + -- └─ rows: row widgets (each row = one unit in the list) + -- └─ row.children[x]: unit widget + -- └─ unit_widget.u: pointer to the df.unit object + + local creatures = df.global.game.main_interface.info.creatures + local list_widget = dfhack.gui.getWidget(creatures, 'Tabs', 'Dead/Missing') + if not list_widget then return nil end + + local children = dfhack.gui.getWidgetChildren(list_widget) + local list_container = children[1] + local grandchildren = dfhack.gui.getWidgetChildren(list_container) + + local scrollable_list = grandchildren[2] + if not scrollable_list then + return nil + end + + local rows = dfhack.gui.getWidgetChildren(scrollable_list) + + local cursor_idx = list_widget.cursor_idx or 0 + + if cursor_idx >= 0 and cursor_idx < #rows then + local row = rows[cursor_idx + 1] + + local ok, unit = pcall(function() return dfhack.gui.getWidget(row, 0).u end) + if ok and unit then + return unit + end + end + + return nil + end + + self:addviews{ + widgets.TextButton{ + frame={t=0, l=0}, + label='Show death cause', + key='CUSTOM_D', + on_activate=function() + local unit = get_selected_unit() + if not unit then + dialogs.showMessage('Death Cause', 'No unit selected.') + return + end + local cause = deathcause.getDeathCause(unit) + dialogs.showMessage('Death Cause', dfhack.df2console(cause)) + end, + }, + } +end + +return _ENV diff --git a/plugins/lua/sort/info.lua b/plugins/lua/sort/info.lua index 4f0462dfb7..413b51f249 100644 --- a/plugins/lua/sort/info.lua +++ b/plugins/lua/sort/info.lua @@ -29,6 +29,10 @@ local function get_task_search_key(cri_unit) table.insert(result, bld.name) local btype = bld:getType() if btype == df.building_type.Workshop then + -- the display name may differ from the enum item name (e.g. + -- Masons -> "Stoneworker's Workshop") and is the raw-defined + -- name for custom workshops + table.insert(result, dfhack.buildings.getName(bld)) table.insert(result, df.workshop_type.attrs[bld.type].name or '') table.insert(result, df.workshop_type[bld.type]) elseif btype == df.building_type.Furnace then diff --git a/plugins/lua/sort/places.lua b/plugins/lua/sort/places.lua index c353d5a948..2b42be60c3 100644 --- a/plugins/lua/sort/places.lua +++ b/plugins/lua/sort/places.lua @@ -153,6 +153,12 @@ local function get_workshop_search_key(workshop) end table.insert(result, workshop.name) + + -- the display name may differ from the enum item name (e.g. Masons -> + -- "Stoneworker's Workshop") and is the raw-defined name for custom + -- workshops + table.insert(result, dfhack.buildings.getName(workshop)) + table.insert(result, df.workshop_type.attrs[workshop.type].name or '') table.insert(result, df.workshop_type[workshop.type]) diff --git a/plugins/lua/sort/stressicons.lua b/plugins/lua/sort/stressicons.lua new file mode 100644 index 0000000000..1971326e6e --- /dev/null +++ b/plugins/lua/sort/stressicons.lua @@ -0,0 +1,102 @@ +local _ENV = mkmodule('plugins.sort.stressicons') + +local dialogs = require('gui.dialogs') +local overlay = require('plugins.overlay') + +-- indexed by stress category (0-6, where 0 is most stressed) +local STRESS_CATEGORY_NAMES = { + [0]='Miserable', 'Unhappy', 'Displeased', 'Content', 'Pleased', 'Happy', + 'Ecstatic', +} + +-- single-unit equivalent of the dfhack.units.getCitizens(true, true) filter +local function is_listable(unit) + return dfhack.units.isCitizen(unit, true) and dfhack.units.isActive(unit) +end + +local function show_stress_units(category) + local choices = {} + -- living, on-map citizens only (excluding residents), matching the game's + -- own stress icon counts + for _,unit in ipairs(dfhack.units.getCitizens(true, true)) do + if dfhack.units.getStressCategory(unit) == category then + table.insert(choices, { + text=dfhack.units.getReadableName(unit), + unit_id=unit.id, + }) + end + end + local name = STRESS_CATEGORY_NAMES[category] + if #choices == 0 then + dialogs.showMessage('Citizens: ' .. name, + ('No citizens are currently %s.'):format(name:lower())) + return + end + dialogs.ListBox{ + frame_title='Citizens: ' .. name, + with_filter=true, + choices=choices, + on_select=function(_, choice) + -- re-resolve in case the unit died or left the map while the list + -- was open; df.unit.find still resolves dead units + local unit = df.unit.find(choice.unit_id) + if not unit or not is_listable(unit) then return end + dfhack.gui.revealInDwarfmodeMap(unit.pos.x, unit.pos.y, unit.pos.z, true, true) + end, + }:show() +end + +-- invisible hotspot over the stress icons in the top bar; clicking an icon +-- opens a list of the citizens in that stress category +StressIconsOverlay = defclass(StressIconsOverlay, overlay.OverlayWidget) +StressIconsOverlay.ATTRS{ + desc='Click a stress icon in the top bar to list the citizens in that stress category.', + default_pos={x=1, y=1}, + default_enabled=true, + viewscreens={'dwarfmode/Default'}, + overlay_onupdate_max_freq_seconds=1, + frame={w=1, h=1}, +} + +function StressIconsOverlay:overlay_onupdate() + self.icon_rects = nil + local dimx = df.global.gps.dimx + -- the 'Pop' label immediately precedes the stress icons in the top bar; + -- scan right to left so a fortress name containing 'Pop' doesn't win + local pop_x + for x = math.min(dimx-3, 200), 0, -1 do + if dfhack.screen.readTile(x, 0).ch == 80 and -- 'P' + dfhack.screen.readTile(x+1, 0).ch == 111 and -- 'o' + dfhack.screen.readTile(x+2, 0).ch == 112 then -- 'p' + pop_x = x + break + end + end + if not pop_x then return end + -- the icons are drawn in 3-column cells right of the 'Pop' block, with the + -- happiest category first + local rects = {} + for i = 0, 6 do + local x1 = pop_x + 4 + i * 3 + if x1 + 1 >= dimx then break end + table.insert(rects, {x1=x1, y1=0, x2=x1+2, y2=2, category=6-i}) + end + self.icon_rects = rects + self.frame = {l=pop_x+4, t=0, w=#rects*3, h=3} +end + +function StressIconsOverlay:onInput(keys) + if not keys._MOUSE_L or not self.icon_rects then return false end + local mx, my = dfhack.screen.getMousePos() + if not mx then return false end + for _,rect in ipairs(self.icon_rects) do + if my >= rect.y1 and my <= rect.y2 and + mx >= rect.x1 and mx <= rect.x2 then + show_stress_units(rect.category) + return true + end + end + return false +end + +return _ENV diff --git a/plugins/lua/spectate.lua b/plugins/lua/spectate.lua index 953895eab0..1e0d81f917 100644 --- a/plugins/lua/spectate.lua +++ b/plugins/lua/spectate.lua @@ -75,7 +75,7 @@ end local function load_state() local state = get_default_state() - local config_file = json.open('dfhack-config/spectate.json') + local config_file = json.open(dfhack.getConfigPath() .. '/spectate.json') for key in pairs(config_file.data) do if state[key] == nil then config_file.data[key] = nil diff --git a/plugins/lua/stockflow.lua b/plugins/lua/stockflow.lua index dc44d23045..d1880cf310 100644 --- a/plugins/lua/stockflow.lua +++ b/plugins/lua/stockflow.lua @@ -1,11 +1,31 @@ local _ENV = mkmodule('plugins.stockflow') local gui = require "gui" +local overlay = require('plugins.overlay') +local widgets = require('gui.widgets') reaction_list = reaction_list or {} saved_orders = saved_orders or {} jobs_to_create = jobs_to_create or {} +local PERSIST_KEY = 'stockflow/orders' + +local function serialize_orders(orders) + local stored = {} + for spid, spec in pairs(orders) do + stored[tostring(spid)] = { + name = spec.name, + order_number = spec.order_number, + trigger_number = spec.trigger_number, + } + end + return stored +end + +local function persist_orders() + dfhack.persistent.saveSiteData(PERSIST_KEY, serialize_orders(saved_orders)) +end + triggers = { {filled = false, divisor = 1, name = "Per empty space"}, {filled = true, divisor = 1, name = "Per stored item"}, @@ -18,12 +38,6 @@ triggers = { {name = "Never"}, } -entry_ints = { - stockpile_id = 1, - order_number = 2, - trigger_number = 3, -} - FirstRow = 3 CenterCol = 38 ExtraLines = 9 @@ -49,8 +63,8 @@ function clear_caches() jobs_to_create = {} end -function trigger_name(cache) - local trigger = triggers[cache.entry.ints[entry_ints.trigger_number]] +function trigger_name(spec) + local trigger = triggers[spec.trigger_number] return trigger and trigger.name or "Never" end @@ -58,7 +72,7 @@ function list_orders() local listed = false for _, spec in pairs(saved_orders) do local num = spec.stockpile.stockpile_number - local name = spec.entry.value + local name = spec.name local trigger = trigger_name(spec) print("Stockpile #"..num, name, trigger) listed = true @@ -66,12 +80,12 @@ function list_orders() if not listed then print("No manager jobs have been set for your stockpiles.") - print("Use j in a stockpile menu to create one...") + print("Select a stockpile and use the stockflow overlay to create one...") end end --- Save the stockpile jobs for later creation. --- Called when the bookkeeper starts updating stockpile records. +-- Gather the stockpile jobs for later creation. +-- Called at the start of each periodic update. function start_bookkeeping() local result = {} for reaction_id, quantity in pairs(check_stockpiles()) do @@ -84,8 +98,8 @@ function start_bookkeeping() jobs_to_create = result end --- Insert any saved jobs. --- Called when the bookkeeper finishes updating stockpile records. +-- Insert the gathered jobs. +-- Called at the end of each periodic update. function finish_bookkeeping() for reaction, amount in pairs(jobs_to_create) do create_orders(reaction_list[reaction].order, amount) @@ -100,65 +114,75 @@ function stockpile_settings(sp) return "No job selected", "" end - return order.entry.value, trigger_name(order) + return order.name, trigger_name(order) end -- Toggle the trigger condition for a stockpile. function toggle_trigger(sp) local saved = saved_orders[sp.id] if saved then - saved.entry.ints[entry_ints.trigger_number] = (saved.entry.ints[entry_ints.trigger_number] % #triggers) + 1 - saved.entry:save() + saved.trigger_number = (saved.trigger_number % #triggers) + 1 + persist_orders() end end function collect_orders() local result = {} - local entries = dfhack.persistent.get_all("stockflow/entry", true) - if entries then - for _, entry in ipairs(entries) do - local spid = entry.ints[entry_ints.stockpile_id] - local stockpile = df.building.find(spid) - if stockpile then - local order_number = entry.ints[entry_ints.order_number] - if reaction_list[order_number] and entry.value == reaction_list[order_number].name then - result[spid] = { - stockpile = stockpile, - entry = entry, - } - else - -- Todo: Search reaction_list for the name. - -- This can happen when loading an old save in a new version. - -- It's even possible that the reaction has been removed. - local found = false - for number, reaction in ipairs(reaction_list) do - if reaction.name == entry.value then - print("Adjusting stockflow entry for stockpile #"..stockpile.stockpile_number..": "..entry.value.." ("..order_number.." => "..number..")") - entry.ints[entry_ints.order_number] = number - entry:save() - result[spid] = { - stockpile = stockpile, - entry = entry, - } - - found = true - break - end + local dirty = false + local stored = dfhack.persistent.getSiteData(PERSIST_KEY) or {} + for spid_str, spec in pairs(stored) do + local spid = tonumber(spid_str) + local stockpile = spid and df.building.find(spid) + if stockpile and not df.building_stockpilest:is_instance(stockpile) then + stockpile = nil + end + if stockpile then + local order_number = spec.order_number + local trigger_number = spec.trigger_number or 1 + if reaction_list[order_number] and spec.name == reaction_list[order_number].name then + result[spid] = { + stockpile = stockpile, + name = spec.name, + order_number = order_number, + trigger_number = trigger_number, + } + else + -- Search reaction_list for the name. + -- This can happen when loading an old save in a new version. + -- It's even possible that the reaction has been removed. + local found = false + for number, reaction in ipairs(reaction_list) do + if reaction.name == spec.name then + print("Adjusting stockflow entry for stockpile #"..stockpile.stockpile_number..": "..spec.name.." ("..tostring(order_number).." => "..number..")") + result[spid] = { + stockpile = stockpile, + name = spec.name, + order_number = number, + trigger_number = trigger_number, + } + + dirty = true + found = true + break end + end - if not found then - print("Unmatched stockflow entry for stockpile #"..stockpile.stockpile_number..": "..entry.value.." ("..order_number..")") - end + if not found then + print("Unmatched stockflow entry for stockpile #"..stockpile.stockpile_number..": "..tostring(spec.name).." ("..tostring(order_number)..")") + dirty = true end - else - -- The stockpile no longer exists. - -- Perhaps it has been deleted, or perhaps this is a different fortress. - -- print("Missing stockflow pile "..spid) - entry:delete() end + else + -- The stockpile no longer exists. + -- Perhaps it has been deleted, or perhaps this is a different fortress. + dirty = true end end + if dirty then + dfhack.persistent.saveSiteData(PERSIST_KEY, serialize_orders(result)) + end + return result end @@ -178,6 +202,7 @@ function reaction_entry(reactions, job_type, values, name) local order = df.manager_order:new() -- These defaults differ from the newly created order's. order:assign{ + frequency = df.workquota_frequency_type.OneTime, job_type = job_type, item_type = -1, item_subtype = -1, @@ -819,7 +844,7 @@ function screen:onRenderBody(dc) dc:string(": Select", COLOR_WHITE) dc:seek(CenterCol, FirstRow + self.page_size + 2) - dc:key("SETUPGAME_SAVE_PROFILE_ABORT"):string(": No order", COLOR_WHITE) + dc:key("CUSTOM_SHIFT_N"):string(": No order", COLOR_WHITE) -- Reaction lines. for _, item in ipairs(self.displayed) do @@ -836,7 +861,7 @@ function screen:onInput(keys) if selected then store_order(self.stockpile, selected.index) end - elseif keys.SETUPGAME_SAVE_PROFILE_ABORT then + elseif keys.CUSTOM_SHIFT_N then self:dismiss() clear_order(self.stockpile) elseif keys.STANDARDSCROLL_UP then @@ -986,10 +1011,9 @@ function screen:refilter() end function clear_order(stockpile) - local saved = saved_orders[stockpile.id] - if saved then - saved.entry:delete() + if saved_orders[stockpile.id] then saved_orders[stockpile.id] = nil + persist_orders() end end @@ -998,23 +1022,17 @@ function store_order(stockpile, order_number) -- print("Setting stockpile #"..stockpile.stockpile_number.." to "..name.." (#"..order_number..")") local saved = saved_orders[stockpile.id] if saved then - saved.entry.value = name - saved.entry.ints[entry_ints.order_number] = order_number - saved.entry:save() + saved.name = name + saved.order_number = order_number else saved_orders[stockpile.id] = { stockpile = stockpile, - entry = dfhack.persistent.save{ - key = "stockflow/entry/"..stockpile.id, - value = name, - ints = { - stockpile.id, - order_number, - 1, - }, - }, + name = name, + order_number = order_number, + trigger_number = 1, } end + persist_orders() end -- Compare the job specification of two orders. @@ -1066,6 +1084,7 @@ end -- Place a new copy of the order onto the manager's queue. function create_orders(order, amount) local new_order = order:new() + new_order.frequency = df.workquota_frequency_type.OneTime amount = math.floor(amount) new_order.amount_left = amount new_order.amount_total = amount @@ -1104,9 +1123,9 @@ end function check_stockpiles(verbose) local result = {} for _, spec in pairs(saved_orders) do - local trigger = triggers[spec.entry.ints[entry_ints.trigger_number]] + local trigger = triggers[spec.trigger_number] if trigger and trigger.divisor then - local reaction = spec.entry.ints[entry_ints.order_number] + local reaction = spec.order_number local filled, empty = check_pile(spec.stockpile, verbose) local amount = math.floor((trigger.filled and filled or empty) / trigger.divisor) result[reaction] = (result[reaction] or 0) + amount @@ -1201,4 +1220,66 @@ function matches_stockpile(item, settings) return true end +-------------------- +-- Overlay +-------------------- + +local function selected_stockpile() + return dfhack.gui.getSelectedStockpile(true) +end + +StockflowOverlay = defclass(StockflowOverlay, overlay.OverlayWidget) +StockflowOverlay.ATTRS{ + desc='Displays and adjusts stockflow work order settings for the selected stockpile.', + default_pos={x=5, y=-7}, + default_enabled=true, + version=1, + viewscreens='dwarfmode/Stockpile/Some/Default', + visible=isEnabled, + frame={w=55, h=4}, +} + +function StockflowOverlay:init() + self:addviews{ + widgets.Panel{ + frame={t=0, l=0, r=0, b=0}, + frame_style=gui.MEDIUM_FRAME, + frame_title='Stockflow', + frame_background=gui.CLEAR_PEN, + subviews={ + widgets.HotkeyLabel{ + frame={t=0, l=0, r=0}, + key='CUSTOM_J', + label=function() + local sp = selected_stockpile() + local name = sp and stockpile_settings(sp) or 'No job selected' + return 'Order: '..name + end, + on_activate=function() + local sp = selected_stockpile() + if sp then select_order(sp) end + end, + }, + widgets.HotkeyLabel{ + frame={t=1, l=0, r=0}, + key='CUSTOM_SHIFT_J', + label=function() + local sp = selected_stockpile() + if not sp then return 'Trigger: Never' end + local _, trigger = stockpile_settings(sp) + if #trigger == 0 then trigger = 'Never' end + return 'Trigger: '..trigger + end, + on_activate=function() + local sp = selected_stockpile() + if sp then toggle_trigger(sp) end + end, + }, + }, + }, + } +end + +OVERLAY_WIDGETS = {stockflow=StockflowOverlay} + return _ENV diff --git a/plugins/lua/stockpiles.lua b/plugins/lua/stockpiles.lua index ac48ed6fc9..518cbf62fb 100644 --- a/plugins/lua/stockpiles.lua +++ b/plugins/lua/stockpiles.lua @@ -7,8 +7,8 @@ local logistics = require('plugins.logistics') local overlay = require('plugins.overlay') local widgets = require('gui.widgets') -local STOCKPILES_DIR = 'dfhack-config/stockpiles' -local STOCKPILES_LIBRARY_DIR = 'hack/data/stockpiles' +local STOCKPILES_DIR = dfhack.getConfigPath() .. '/stockpiles' +local STOCKPILES_LIBRARY_DIR = dfhack.getHackPath() .. '/data/stockpiles' local BAD_FILENAME_REGEX = '[^%w._]' @@ -252,15 +252,14 @@ local function get_import_choices() return filenames end -local function do_import() - local sp = dfhack.gui.getSelectedStockpile(true) +local function do_import(make_opts, title) local dlg local function get_dlg() return dlg end dlg = dialogs.ListBox{ - frame_title='Import/Delete Stockpile Settings', + frame_title=title or 'Import/Delete Stockpile Settings', with_filter=true, choices=get_import_choices(), - on_select=function(_, choice) import_settings(choice.text, {id=sp and sp.id}) end, + on_select=function(_, choice) import_settings(choice.text, make_opts()) end, dismiss_on_select2=false, on_select2=function(_, choice) if choice.text:startswith('library/') then return end @@ -281,15 +280,37 @@ local function do_import() }:show() end -local function do_export() - local sp = dfhack.gui.getSelectedStockpile(true) +local function do_export(make_opts, title) dialogs.InputBox{ - frame_title='Export Stockpile Settings', + frame_title=title or 'Export Stockpile Settings', text='Please enter a filename', - on_input=function(text) export_settings(text, {id=sp and sp.id}) end, + on_input=function(text) export_settings(text, make_opts()) end, }:show() end +local function do_stockpile_import() + local sp = dfhack.gui.getSelectedStockpile(true) + do_import(function() return {id=sp and sp.id} end) +end + +local function do_stockpile_export() + local sp = dfhack.gui.getSelectedStockpile(true) + do_export(function() return {id=sp and sp.id} end) +end + +local function get_route_target() + local hsc = df.global.game.main_interface.hauling_stop_conditions + return {route_id=hsc.route_id, stop_id=hsc.stop_id} +end + +local function do_route_import() + do_import(get_route_target, 'Import/Delete Stop Settings') +end + +local function do_route_export() + do_export(get_route_target, 'Export Stop Settings') +end + -------------------- -- ConfigModal -------------------- @@ -448,13 +469,13 @@ function StockpilesOverlay:init() label='import', auto_width=true, key='CUSTOM_CTRL_I', - on_activate=do_import, + on_activate=do_stockpile_import, }, widgets.HotkeyLabel{ frame={t=0, l=16}, label='export', auto_width=true, key='CUSTOM_CTRL_E', - on_activate=do_export, + on_activate=do_stockpile_export, }, widgets.Panel{ frame={t=1, l=0}, @@ -592,6 +613,45 @@ function StockpilesOverlay:onInput(keys) return StockpilesOverlay.super.onInput(self, keys) end -OVERLAY_WIDGETS = {overlay=StockpilesOverlay} +-------------------- +-- HaulingOverlay +-------------------- + +HaulingOverlay = defclass(HaulingOverlay, overlay.OverlayWidget) +HaulingOverlay.ATTRS{ + desc='Shows a panel when minecart route stop conditions are being edited.', + default_pos={x=5, y=40}, + default_enabled=true, + version=1, + viewscreens='dwarfmode/HaulingStopConditions', + frame={w=49, h=3}, +} + +function HaulingOverlay:init() + self:addviews{ + widgets.Panel{ + view_id='main', + frame_style=gui.MEDIUM_FRAME, + frame_background=gui.CLEAR_PEN, + subviews={ + widgets.HotkeyLabel{ + frame={t=0, l=0}, + label='import', + auto_width=true, + key='CUSTOM_CTRL_I', + on_activate=do_route_import, + }, widgets.HotkeyLabel{ + frame={t=0, l=16}, + label='export', + auto_width=true, + key='CUSTOM_CTRL_E', + on_activate=do_route_export, + }, + }, + }, + } +end + +OVERLAY_WIDGETS = {overlay=StockpilesOverlay, hauling=HaulingOverlay} return _ENV diff --git a/plugins/lua/stocks.lua b/plugins/lua/stocks.lua index 2bfd4bb052..a854311b84 100644 --- a/plugins/lua/stocks.lua +++ b/plugins/lua/stocks.lua @@ -6,15 +6,45 @@ local widgets = require('gui.widgets') local stocks = df.global.game.main_interface.stocks +-- these are only safe to call while the stocks page is open; guard against +-- stray activations as the page closes local function collapse_all() + if not stocks.open then return end local num_sections = #stocks.current_type_a_expanded for idx=0,num_sections-1 do stocks.current_type_a_expanded[idx] = false end stocks.i_height = num_sections * 3 + -- the collapsed list is much shorter; reset the scroll position so the + -- view isn't left pointing past the end of the list + stocks.scroll_position_item = 0 +end + +local function expand_all() + if not stocks.open then return end + local num_sections = #stocks.current_type_a_expanded + for idx=0,num_sections-1 do + stocks.current_type_a_expanded[idx] = true + end + local num_items = #stocks.current_type_i_list + stocks.i_height = (num_items + num_sections) * 3 +end + +local function all_collapsed() + if not stocks.open then return true end + for idx=0,#stocks.current_type_a_expanded-1 do + if stocks.current_type_a_expanded[idx] then return false end + end + return true +end + +local function toggle_all() + if not stocks.open then return end + if all_collapsed() then expand_all() else collapse_all() end end local function remove_empty() + if not stocks.open then return end local empties = {} for itype,v in ipairs(stocks.storeamount) do if v == 0 and stocks.badamount[itype] == 0 then @@ -27,6 +57,10 @@ local function remove_empty() for idx=#stocks.filtered_type_list-1,0,-1 do if empties[stocks.filtered_type_list[idx]] then stocks.filtered_type_list:erase(idx) end end + -- removing types shortens the type list; keep the scroll position in bounds + if stocks.scroll_position_type >= #stocks.filtered_type_list then + stocks.scroll_position_type = math.max(0, #stocks.filtered_type_list - 1) + end end -- ------------------- @@ -48,9 +82,17 @@ function StocksOverlay:init() self:addviews{ widgets.HotkeyLabel{ frame={t=0, l=0}, - label='collapse all', + label=function() + return all_collapsed() and 'expand all' or 'collapse all' + end, key='CUSTOM_CTRL_X', - on_activate=collapse_all, + on_activate=toggle_all, + }, + widgets.HotkeyLabel{ + frame={t=1, l=0}, + label='expand all', + key='CUSTOM_CTRL_Z', + on_activate=expand_all, }, widgets.HotkeyLabel{ frame={t=2, l=0}, diff --git a/plugins/lua/suspendmanager.lua b/plugins/lua/suspendmanager.lua index a7de42e5a0..c0672f8bfb 100644 --- a/plugins/lua/suspendmanager.lua +++ b/plugins/lua/suspendmanager.lua @@ -37,7 +37,9 @@ local function getSelectedBuildingJob() -- Find if the building is being constructed for _, job in ipairs(building.jobs) do - if job.job_type == df.job_type.ConstructBuilding then + if job.job_type == df.job_type.ConstructBuilding + or (job.job_type == df.job_type.DestroyBuilding + and job.flags.suspend) then return job end end @@ -155,7 +157,7 @@ end -- suspend overlay (formerly in unsuspend.lua) -local textures = dfhack.textures.loadTileset('hack/data/art/unsuspend.png', 32, 32, true) +local textures = dfhack.textures.loadTileset(dfhack.getHackPath()..'/data/art/unsuspend.png', 32, 32, true) local ok, buildingplan = pcall(require, 'plugins.buildingplan') if not ok then diff --git a/plugins/lua/timestream.lua b/plugins/lua/timestream.lua index 6f5b68ddf6..91398e355d 100644 --- a/plugins/lua/timestream.lua +++ b/plugins/lua/timestream.lua @@ -15,7 +15,7 @@ local function do_set(setting_name, arg) if setting_name ~= 'fps' or not numarg then qerror('must specify setting and value') end - timestream_setFps(arg) + timestream_setFps(numarg) print(('set %s to %s'):format(setting_name, timestream_getFps())) end diff --git a/plugins/orders.cpp b/plugins/orders.cpp index 4bdff21fe2..174c645454 100644 --- a/plugins/orders.cpp +++ b/plugins/orders.cpp @@ -4,6 +4,7 @@ #include "PluginManager.h" #include "modules/Filesystem.h" +#include "modules/Job.h" #include "modules/Materials.h" #include "modules/World.h" @@ -44,8 +45,15 @@ DFHACK_PLUGIN("orders"); REQUIRE_GLOBAL(world); -static const std::string ORDERS_DIR = "dfhack-config/orders"; -static const std::string ORDERS_LIBRARY_DIR = "hack/data/orders"; +static const std::filesystem::path get_orders_dir() +{ + return Core::getInstance().getConfigPath() / "orders"; +} + +static std::filesystem::path get_orders_library_dir() +{ + return Core::getInstance().getHackPath() / "data" / "orders"; +} static command_result orders_command(color_ostream & out, std::vector & parameters); @@ -135,7 +143,7 @@ static command_result orders_command(color_ostream & out, std::vector files; - if (0 < Filesystem::listdir_recursive(ORDERS_LIBRARY_DIR, files, 0, false)) { + if (0 < Filesystem::listdir_recursive(get_orders_library_dir(), files, 0, false)) { // if the library directory doesn't exist, just skip it return; } @@ -163,7 +171,7 @@ static command_result orders_list_command(color_ostream & out) // support subdirs so we can identify and ignore subdirs with ".json" names. // also listdir_recursive will alphabetize the list for us. std::map files; - Filesystem::listdir_recursive(ORDERS_DIR, files, 0, false); + Filesystem::listdir_recursive(get_orders_dir(), files, 0, false); for (auto& it : files) { if (it.second) @@ -376,6 +384,7 @@ static command_result orders_export_command(color_ostream & out, const std::stri order["art"] = art; } + order["name"] = Job::getManagerOrderName(it); order["amount_left"] = it->amount_left; order["amount_total"] = it->amount_total; order["is_validated"] = bool(it->status.bits.validated); @@ -504,9 +513,9 @@ static command_result orders_export_command(color_ostream & out, const std::stri orders.append(order); } - Filesystem::mkdir(ORDERS_DIR); + Filesystem::mkdir(get_orders_dir()); - std::ofstream file(ORDERS_DIR + "/" + name + ".json"); + std::ofstream file(get_orders_dir() / ( name + ".json")); file << orders << std::endl; @@ -924,8 +933,7 @@ static command_result orders_import_command(color_ostream & out, const std::stri return CR_WRONG_USAGE; } - const std::string filename((is_library ? ORDERS_LIBRARY_DIR : ORDERS_DIR) + - "/" + fname + ".json"); + auto filename((is_library ? get_orders_library_dir() : get_orders_dir()) / (fname + ".json")); Json::Value orders; { diff --git a/plugins/pathable.cpp b/plugins/pathable.cpp index d5983bb019..3fd1eaaad1 100644 --- a/plugins/pathable.cpp +++ b/plugins/pathable.cpp @@ -42,7 +42,7 @@ namespace DFHack { static std::vector textures; DFhackCExport command_result plugin_init(color_ostream &out, std::vector &commands) { - textures = Textures::loadTileset("hack/data/art/pathable.png", 32, 32, true); + textures = Textures::loadTileset(Core::getInstance().getHackPath() / "data" / "art" / "pathable.png", 32, 32, true); return CR_OK; } diff --git a/plugins/probe.cpp b/plugins/probe.cpp index f49e167867..bd71346fb7 100644 --- a/plugins/probe.cpp +++ b/plugins/probe.cpp @@ -1,11 +1,16 @@ +#include +#include +#include + #include "LuaTools.h" +#include "MiscUtils.h" #include "PluginManager.h" #include "TileTypes.h" #include "modules/Gui.h" -#include "modules/Materials.h" #include "modules/MapCache.h" #include "modules/Maps.h" +#include "modules/Materials.h" #include "df/block_square_event_grassst.h" #include "df/block_square_event_world_constructionst.h" @@ -15,6 +20,8 @@ #include "df/civzone_type.h" #include "df/construction_type.h" #include "df/furnace_type.h" +#include "df/global_objects.h" +#include "df/inorganic_raw.h" #include "df/item.h" #include "df/map_block.h" #include "df/region_map_entry.h" @@ -86,10 +93,8 @@ static void describeTile(color_ostream &out, df::tiletype tiletype) { } static command_result df_probe(color_ostream &out, vector & parameters) { - DFHack::Materials *Materials = Core::getInstance().getMaterials(); - vector inorganic; - bool hasmats = Materials->CopyInorganicMaterials(inorganic); + auto& inorganic = world->raws.inorganics.all; if (!Maps::IsValid()) { out.printerr("Map is not available!\n"); @@ -122,7 +127,7 @@ static command_result df_probe(color_ostream &out, vector & parameters) uint32_t blockY = cursor.y / 16; uint32_t tileY = cursor.y % 16; - MapExtras::Block * b = mc.BlockAt(cursor/16); + MapExtras::Block * b = mc.BlockAtTile(cursor); if (!b || !b->is_valid()) { out.printerr("No data.\n"); return CR_OK; @@ -173,29 +178,25 @@ static command_result df_probe(color_ostream &out, vector & parameters) out << "geolayer: " << des.bits.geolayer_index << std::endl; int16_t base_rock = mc.layerMaterialAt(cursor); - if (base_rock != -1) { + if (base_rock != -1) + { out << "Layer material: " << std::dec << base_rock; - if(hasmats) - out << " / " << inorganic[base_rock].id - << " / " - << inorganic[base_rock].name - << std::endl; - else - out << std::endl; + out << " / " << inorganic[base_rock]->id + << " / " + << inorganic[base_rock]->material.stone_name + << std::endl; } int16_t vein_rock = mc.veinMaterialAt(cursor); - if (vein_rock != -1) { + if (vein_rock != -1) + { out << "Vein material (final): " << std::dec << vein_rock; - if(hasmats) - out << " / " << inorganic[vein_rock].id - << " / " - << inorganic[vein_rock].name - << " (" - << ENUM_KEY_STR(inclusion_type,b->veinTypeAt(cursor)) - << ")" - << std::endl; - else - out << std::endl; + out << " / " << inorganic[vein_rock]->id + << " / " + << inorganic[vein_rock]->material.stone_name + << " (" + << ENUM_KEY_STR(inclusion_type, b->veinTypeAt(cursor)) + << ")" + << std::endl; } MaterialInfo minfo(mc.baseMaterialAt(cursor)); if (minfo.isValid()) @@ -255,7 +256,7 @@ static command_result df_probe(color_ostream &out, vector & parameters) out.print(" {:4}", block.local_feature); out.print(" ({:2})", static_cast(local.type)); out.print(" addr {}", static_cast(local.origin)); - out.print(" {}", sa_feature(local.type)); + out.print(" {}\n", sa_feature(local.type)); } PRINT_FLAG( des, feature_global ); if(global.type != -1) @@ -264,7 +265,7 @@ static command_result df_probe(color_ostream &out, vector & parameters) out.print(" {:4}", block.global_feature); out.print(" ({:2})", static_cast(global.type)); out.print(" {}", static_cast(global.origin)); - out.print(" {}", sa_feature(global.type)); + out.print(" {}\n", sa_feature(global.type)); } out << "local feature idx: " << block.local_feature << std::endl; @@ -309,17 +310,6 @@ static command_result df_probe(color_ostream &out, vector & parameters) return CR_OK; } -union Subtype { - int16_t subtype; - df::civzone_type civzone_type; - df::furnace_type furnace_type; - df::workshop_type workshop_type; - df::construction_type construction_type; - df::shop_type shop_type; - df::siegeengine_type siegeengine_type; - df::trap_type trap_type; -}; - static command_result df_bprobe(color_ostream &out, vector & parameters) { auto bld = Gui::getSelectedBuilding(out); if (!bld) @@ -329,7 +319,7 @@ static command_result df_bprobe(color_ostream &out, vector & parameters) bld->getName(&name); auto bld_type = bld->getType(); - Subtype subtype{bld->getSubtype()}; + int16_t subtype{bld->getSubtype()}; int32_t custom = bld->getCustomType(); out.print("Building {:<4}, \"{}\", type {} ({})", @@ -342,46 +332,46 @@ static command_result df_bprobe(color_ostream &out, vector & parameters) switch (bld_type) { case df::building_type::Civzone: out.print(", subtype {} ({})", - ENUM_KEY_STR(civzone_type, subtype.civzone_type), - subtype.subtype); + ENUM_KEY_STR(civzone_type, static_cast(subtype)), + subtype); break; case df::building_type::Furnace: out.print(", subtype {} ({})", - ENUM_KEY_STR(furnace_type, subtype.furnace_type), - subtype.subtype); - if (subtype.furnace_type == df::furnace_type::Custom) + ENUM_KEY_STR(furnace_type, static_cast(subtype)), + subtype); + if (static_cast(subtype) == df::furnace_type::Custom) out.print(", custom type {} ({})", world->raws.buildings.all[custom]->code, custom); break; case df::building_type::Workshop: out.print(", subtype {} ({})", - ENUM_KEY_STR(workshop_type, subtype.workshop_type), - subtype.subtype); - if (subtype.workshop_type == df::workshop_type::Custom) + ENUM_KEY_STR(workshop_type, static_cast(subtype)), + subtype); + if (subtype == static_cast(df::workshop_type::Custom)) out.print(", custom type {} ({})", world->raws.buildings.all[custom]->code, custom); break; case df::building_type::Construction: out.print(", subtype {} ({})", - ENUM_KEY_STR(construction_type, subtype.construction_type), - subtype.subtype); + ENUM_KEY_STR(construction_type, static_cast(subtype)), + subtype); break; case df::building_type::Shop: out.print(", subtype {} ({})", - ENUM_KEY_STR(shop_type, subtype.shop_type), - subtype.subtype); + ENUM_KEY_STR(shop_type, static_cast(subtype)), + subtype); break; case df::building_type::SiegeEngine: out.print(", subtype {} ({})", - ENUM_KEY_STR(siegeengine_type, subtype.siegeengine_type), - subtype.subtype); + ENUM_KEY_STR(siegeengine_type, static_cast(subtype)), + subtype); break; case df::building_type::Trap: out.print(", subtype {} ({})", - ENUM_KEY_STR(trap_type, subtype.trap_type), - subtype.subtype); + ENUM_KEY_STR(trap_type, static_cast(subtype)), + subtype); break; case df::building_type::NestBox: { @@ -391,8 +381,8 @@ static command_result df_bprobe(color_ostream &out, vector & parameters) break; } default: - if (subtype.subtype != -1) - out.print(", subtype {}", subtype.subtype); + if (subtype != -1) + out.print(", subtype {}", subtype); break; } diff --git a/plugins/prospector.cpp b/plugins/prospector.cpp index 035d6cdee1..427d8d34e9 100644 --- a/plugins/prospector.cpp +++ b/plugins/prospector.cpp @@ -385,7 +385,8 @@ bool estimate_underground(color_ostream &out, EmbarkTileLayout &tile, df::world_ if (!sea_found) { - out.printerr("Could not find magma sea; depth may be incorrect.\n"); + if (world->worldgen.worldgen_parms.have_bottom_layer_1) + out.printerr("Could not find magma sea; depth may be incorrect.\n"); tile.min_z = tile.base_z; } @@ -610,9 +611,7 @@ static command_result embark_prospector(color_ostream &out, } if (options.hidden) { - DFHack::Materials *mats = Core::getInstance().getMaterials(); printVeins(out, veinMats, options); - mats->Finish(); } out << "Embark depth: " << (world_bottom.upper_z-world_bottom.lower_z+1) << " "; @@ -635,8 +634,6 @@ static command_result map_prospector(color_ostream &con, Maps::getSize(x_max, y_max, z_max); MapExtras::MapCache map; - DFHack::Materials *mats = Core::getInstance().getMaterials(); - DFHack::t_feature blockFeatureGlobal; DFHack::t_feature blockFeatureLocal; @@ -894,9 +891,6 @@ static command_result map_prospector(color_ostream &con, printMats(con, treeMats, world->raws.plants.all, options); } - // Cleanup - mats->Finish(); - return CR_OK; } diff --git a/plugins/remotefortressreader/remotefortressreader.cpp b/plugins/remotefortressreader/remotefortressreader.cpp index 60c04ac25b..3145ca1add 100644 --- a/plugins/remotefortressreader/remotefortressreader.cpp +++ b/plugins/remotefortressreader/remotefortressreader.cpp @@ -304,15 +304,16 @@ DFhackCExport command_result plugin_onupdate(color_ostream &out) return CR_OK; } -uint16_t fletcher16(uint8_t const *data, size_t bytes) +uint16_t fletcher16(const void *data_, size_t bytes) { + auto data = static_cast(data_); uint16_t sum1 = 0xff, sum2 = 0xff; while (bytes) { size_t tlen = bytes > 20 ? 20 : bytes; bytes -= tlen; do { - sum2 += sum1 += *data++; + sum2 += sum1 += static_cast(*data++); } while (--tlen); sum1 = (sum1 & 0xff) + (sum1 >> 8); sum2 = (sum2 & 0xff) + (sum2 >> 8); @@ -335,7 +336,7 @@ void ConvertDfColor(int16_t index, RemoteFortressReader::ColorDefinition * out) out->set_blue(gps->uccolor[index][2]); } -void ConvertDfColor(int16_t in[3], RemoteFortressReader::ColorDefinition * out) +void ConvertDfColor(std::array& in, RemoteFortressReader::ColorDefinition * out) { int index = in[0] | (8 * in[2]); ConvertDfColor(index, out); @@ -623,7 +624,7 @@ static command_result CheckHashes(color_ostream &stream, const EmptyMessage *in) for (size_t i = 0; i < world->map.map_blocks.size(); i++) { df::map_block * block = world->map.map_blocks[i]; - fletcher16((uint8_t*)(block->tiletype), 16 * 16 * sizeof(df::enums::tiletype::tiletype)); + fletcher16((block->tiletype).data(), 16 * 16 * sizeof(df::enums::tiletype::tiletype)); } clock_t end = clock(); double elapsed_secs = double(end - start) / CLOCKS_PER_SEC; @@ -633,12 +634,12 @@ static command_result CheckHashes(color_ostream &stream, const EmptyMessage *in) void CopyMat(RemoteFortressReader::MatPair * mat, int type, int index) { - if (type >= MaterialInfo::FIGURE_BASE && type < MaterialInfo::PLANT_BASE) + if (type >= df::builtin_mats::HIST_FIG_1 && type <= df::builtin_mats::HIST_FIG_200) { df::historical_figure * figure = df::historical_figure::find(index); if (figure) { - type -= MaterialInfo::GROUP_SIZE; + type = (type - df::builtin_mats::HIST_FIG_1) + df::builtin_mats::CREATURE_1; index = figure->race; } } @@ -654,7 +655,7 @@ bool IsTiletypeChanged(DFCoord pos) uint16_t hash; df::map_block * block = Maps::getBlock(pos); if (block) - hash = fletcher16((uint8_t*)(block->tiletype), 16 * 16 * (sizeof(df::enums::tiletype::tiletype))); + hash = fletcher16((block->tiletype).data(), 16 * 16 * (sizeof(df::enums::tiletype::tiletype))); else hash = 0; if (hashes[pos] != hash) @@ -672,7 +673,7 @@ bool IsDesignationChanged(DFCoord pos) uint16_t hash; df::map_block * block = Maps::getBlock(pos); if (block) - hash = fletcher16((uint8_t*)(block->designation), 16 * 16 * (sizeof(df::tile_designation))); + hash = fletcher16((block->designation).data(), 16 * 16 * (sizeof(df::tile_designation))); else hash = 0; if (waterHashes[pos] != hash) @@ -816,9 +817,9 @@ static command_result GetMaterialList(color_ostream &stream, const EmptyMessage MaterialInfo mat; for (size_t i = 0; i < raws->inorganics.all.size(); i++) { - mat.decode(0, i); + mat.decode(df::builtin_mats::INORGANIC, i); MaterialDefinition *mat_def = out->add_material_list(); - mat_def->mutable_mat_pair()->set_mat_type(0); + mat_def->mutable_mat_pair()->set_mat_type(df::builtin_mats::INORGANIC); mat_def->mutable_mat_pair()->set_mat_index(i); mat_def->set_id(mat.getToken()); mat_def->set_name(DF2UTF(mat.toString())); //find the name at cave temperature; @@ -827,11 +828,11 @@ static command_result GetMaterialList(color_ostream &stream, const EmptyMessage ConvertDFColorDescriptor(raws->inorganics.all[i]->material.state_color[GetState(&raws->inorganics.all[i]->material)], mat_def->mutable_state_color()); } } - for (int i = 0; i < 19; i++) + for (int i = 0; i < df::builtin_mats::CREATURE_1; i++) { int k = -1; - if (i == 7) - k = 1;// for coal. + if (i == df::builtin_mats::COAL) + k = 1;// for coke and charcoal for (int j = -1; j <= k; j++) { mat.decode(i, j); @@ -851,9 +852,9 @@ static command_result GetMaterialList(color_ostream &stream, const EmptyMessage df::creature_raw * creature = raws->creatures.all[i]; for (size_t j = 0; j < creature->material.size(); j++) { - mat.decode(j + MaterialInfo::CREATURE_BASE, i); + mat.decode(j + df::builtin_mats::CREATURE_1, i); MaterialDefinition *mat_def = out->add_material_list(); - mat_def->mutable_mat_pair()->set_mat_type(j + 19); + mat_def->mutable_mat_pair()->set_mat_type(j + df::builtin_mats::CREATURE_1); mat_def->mutable_mat_pair()->set_mat_index(i); mat_def->set_id(mat.getToken()); mat_def->set_name(DF2UTF(mat.toString())); //find the name at cave temperature; @@ -868,9 +869,9 @@ static command_result GetMaterialList(color_ostream &stream, const EmptyMessage df::plant_raw * plant = raws->plants.all[i]; for (size_t j = 0; j < plant->material.size(); j++) { - mat.decode(j + 419, i); + mat.decode(j + df::builtin_mats::PLANT_1, i); MaterialDefinition *mat_def = out->add_material_list(); - mat_def->mutable_mat_pair()->set_mat_type(j + 419); + mat_def->mutable_mat_pair()->set_mat_type(j + df::builtin_mats::PLANT_1); mat_def->mutable_mat_pair()->set_mat_index(i); mat_def->set_id(mat.getToken()); mat_def->set_name(DF2UTF(mat.toString())); //find the name at cave temperature; @@ -2095,14 +2096,14 @@ static void SetRegionTile(RegionTile * out, df::region_map_entry * e1) auto plantMat = out->add_plant_materials(); plantMat->set_mat_index(pop->plant); - plantMat->set_mat_type(419); + plantMat->set_mat_type(df::builtin_mats::PLANT_1); } else if (pop->type == world_population_type::Tree) { auto plantMat = out->add_tree_materials(); plantMat->set_mat_index(pop->plant); - plantMat->set_mat_type(419); + plantMat->set_mat_type(df::builtin_mats::PLANT_1); } } #if DF_VERSION_INT >= 43005 @@ -2625,15 +2626,15 @@ static command_result GetPartialCreatureRaws(color_ostream &stream, const ListRe send_creature->set_index(i); send_creature->set_creature_id(orig_creature->creature_id); - send_creature->add_name(orig_creature->name[0]); - send_creature->add_name(orig_creature->name[1]); - send_creature->add_name(orig_creature->name[2]); + send_creature->add_name(DF2UTF(orig_creature->name[0])); + send_creature->add_name(DF2UTF(orig_creature->name[1])); + send_creature->add_name(DF2UTF(orig_creature->name[2])); - send_creature->add_general_baby_name(orig_creature->general_baby_name[0]); - send_creature->add_general_baby_name(orig_creature->general_baby_name[1]); + send_creature->add_general_baby_name(DF2UTF(orig_creature->general_baby_name[0])); + send_creature->add_general_baby_name(DF2UTF(orig_creature->general_baby_name[1])); - send_creature->add_general_child_name(orig_creature->general_child_name[0]); - send_creature->add_general_child_name(orig_creature->general_child_name[1]); + send_creature->add_general_child_name(DF2UTF(orig_creature->general_child_name[0])); + send_creature->add_general_child_name(DF2UTF(orig_creature->general_child_name[1])); send_creature->set_creature_tile(orig_creature->creature_tile); send_creature->set_creature_soldier_tile(orig_creature->creature_soldier_tile); @@ -2653,15 +2654,15 @@ static command_result GetPartialCreatureRaws(color_ostream &stream, const ListRe send_caste->set_caste_id(orig_caste->caste_id); - send_caste->add_caste_name(orig_caste->caste_name[0]); - send_caste->add_caste_name(orig_caste->caste_name[1]); - send_caste->add_caste_name(orig_caste->caste_name[2]); + send_caste->add_caste_name(DF2UTF(orig_caste->caste_name[0])); + send_caste->add_caste_name(DF2UTF(orig_caste->caste_name[1])); + send_caste->add_caste_name(DF2UTF(orig_caste->caste_name[2])); - send_caste->add_baby_name(orig_caste->baby_name[0]); - send_caste->add_baby_name(orig_caste->baby_name[1]); + send_caste->add_baby_name(DF2UTF(orig_caste->baby_name[0])); + send_caste->add_baby_name(DF2UTF(orig_caste->baby_name[1])); - send_caste->add_child_name(orig_caste->child_name[0]); - send_caste->add_child_name(orig_caste->child_name[1]); + send_caste->add_child_name(DF2UTF(orig_caste->child_name[0])); + send_caste->add_child_name(DF2UTF(orig_caste->child_name[1])); send_caste->set_gender(orig_caste->sex); for (size_t partIndex = 0; partIndex < orig_caste->body_info.body_parts.size(); partIndex++) @@ -2780,7 +2781,7 @@ static command_result GetPartialCreatureRaws(color_ostream &stream, const ListRe } } - send_caste->set_description(orig_caste->description); + send_caste->set_description(DF2UTF(orig_caste->description)); send_caste->set_adult_size(orig_caste->misc.adult_size); } diff --git a/plugins/smooth-movement/CMakeLists.txt b/plugins/smooth-movement/CMakeLists.txt new file mode 100644 index 0000000000..acb0650a7e --- /dev/null +++ b/plugins/smooth-movement/CMakeLists.txt @@ -0,0 +1,5 @@ +dfhack_plugin(smooth-movement smooth-movement.cpp LINK_LIBRARIES lua) +if(BUILD_PLUGINS) + target_include_directories(smooth-movement PRIVATE ${SDL2_INCLUDE_DIRS}) +endif() +dfhack_test(smooth-movement-test test_visual_animation_manager.cpp) diff --git a/plugins/smooth-movement/smooth-movement.cpp b/plugins/smooth-movement/smooth-movement.cpp new file mode 100644 index 0000000000..bf32590fc2 --- /dev/null +++ b/plugins/smooth-movement/smooth-movement.cpp @@ -0,0 +1,978 @@ +// SPDX-License-Identifier: Zlib + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "Core.h" +#include "MemAccess.h" +#include "PluginManager.h" +#include "VTableInterpose.h" + +#include "modules/DFSDL.h" + +#include "df/coord2d.h" +#include "df/global_objects.h" +#include "df/graphic.h" +#include "df/graphic_viewportst.h" +#include "df/renderer_2d_base.h" +#include "df/texture_fullid.h" +#include "df/viewport_spatter_flag.h" + +#include "visual_animation.h" + +using namespace DFHack; + +DFHACK_PLUGIN("smooth-movement"); +DFHACK_PLUGIN_IS_ENABLED(is_enabled); + +REQUIRE_GLOBAL(gps); +REQUIRE_GLOBAL(window_x); +REQUIRE_GLOBAL(window_y); +REQUIRE_GLOBAL(window_z); + +namespace { + +using tile_coverage = std::set; + +// Runtime harness for the engine-owned visual state; gameplay data is never +// read. +decltype(&SDL_RenderCopyF) render_copy_f = nullptr; +decltype(&SDL_RenderCopyExF) render_copy_ex_f = nullptr; +decltype(&SDL_RenderFillRect) render_fill_rect = nullptr; +decltype(&SDL_GetRenderDrawColor) get_render_draw_color = nullptr; +decltype(&SDL_SetRenderDrawColor) set_render_draw_color = nullptr; + +visual_animation_manager animation_manager; +tile_coverage previous_coverage; +uint64_t visual_context_revision = 0; +const void *previous_viewport = nullptr; +struct visual_context { + int32_t z_level; + df::coord2d viewport_dimensions; + df::coord2d clip_min; + df::coord2d clip_max; + int32_t zoom; + df::coord2d origin; + df::coord2d screen_dimensions; + + bool operator==(const visual_context &) const = default; +}; + +visual_context previous_visual_context{}; +bool has_visual_context = false; +// Map scroll (window_x/window_y) is tracked separately from the reset +// signature: a pure pan is followed (movements are translated) instead of +// triggering a full reset, so it must NOT bump the context revision. It only +// invalidates the viewport-space blackout coverage from the prior frame. +df::coord2d previous_pan = df::coord2d(0, 0); +bool has_pan_context = false; +bool flip_enabled = false; +bool zlevel_enabled = false; + +// DF's native renderer draws 32-pixel tiles at viewport zoom 128. +constexpr int32_t native_tile_pixels = 32; +constexpr int32_t native_viewport_zoom = 128; + +void update_visual_context(const df::renderer_2d_base *renderer, const df::graphic_viewportst *vp) { + // window_x/window_y are deliberately excluded: a horizontal/vertical scroll + // is followed, not reset. window_z (z-level) stays, since a z change is not + // followable. + const visual_context context = { + .z_level = window_z ? *window_z : 0, + .viewport_dimensions = df::coord2d(vp->dim_x, vp->dim_y), + // DF clip arrays hold inclusive minimum and maximum coordinates. + .clip_min = df::coord2d(vp->clipx[0], vp->clipy[0]), + .clip_max = df::coord2d(vp->clipx[1], vp->clipy[1]), + .zoom = renderer->viewport_zoom_factor, + .origin = df::coord2d(renderer->origin_x, renderer->origin_y), + .screen_dimensions = df::coord2d(gps->dimx, gps->dimy), + }; + const bool changed = + !has_visual_context || previous_viewport != vp || previous_visual_context != context; + if (changed) { + ++visual_context_revision; + previous_coverage.clear(); + } + previous_viewport = vp; + previous_visual_context = context; + has_visual_context = true; + + // On a pure pan the reset signature is unchanged, but last frame's blackout + // coverage is in the old viewport frame, so discard it (the engine repaints + // the whole scrolled viewport anyway). + const df::coord2d pan(window_x ? *window_x : 0, window_y ? *window_y : 0); + if (!has_pan_context || previous_pan != pan) + previous_coverage.clear(); + previous_pan = pan; + has_pan_context = true; +} + +using viewport_layer_member = int32_t *df::graphic_viewportst::*; + +struct visual_layer_buffer { + viewport_visual_layer layer; + viewport_layer_member current; + viewport_layer_member previous; +}; + +constexpr size_t visual_layer_count = static_cast(viewport_visual_layer::count); +constexpr std::array visual_layer_buffers = { + visual_layer_buffer{viewport_visual_layer::right, + &df::graphic_viewportst::screentexpos_right_creature, + &df::graphic_viewportst::screentexpos_right_creature_old}, + visual_layer_buffer{viewport_visual_layer::center, &df::graphic_viewportst::screentexpos, + &df::graphic_viewportst::screentexpos_old}, + visual_layer_buffer{viewport_visual_layer::left, + &df::graphic_viewportst::screentexpos_left_creature, + &df::graphic_viewportst::screentexpos_left_creature_old}, + visual_layer_buffer{viewport_visual_layer::upright, + &df::graphic_viewportst::screentexpos_upright_creature, + &df::graphic_viewportst::screentexpos_upright_creature_old}, + visual_layer_buffer{viewport_visual_layer::up, + &df::graphic_viewportst::screentexpos_up_creature, + &df::graphic_viewportst::screentexpos_up_creature_old}, + visual_layer_buffer{viewport_visual_layer::upleft, + &df::graphic_viewportst::screentexpos_upleft_creature, + &df::graphic_viewportst::screentexpos_upleft_creature_old}, + visual_layer_buffer{viewport_visual_layer::vehicle, + &df::graphic_viewportst::screentexpos_vehicle, + &df::graphic_viewportst::screentexpos_vehicle_old}, + visual_layer_buffer{viewport_visual_layer::item, &df::graphic_viewportst::screentexpos_item, + &df::graphic_viewportst::screentexpos_item_old}, + visual_layer_buffer{viewport_visual_layer::designation, + &df::graphic_viewportst::screentexpos_designation, + &df::graphic_viewportst::screentexpos_designation_old}}; + +constexpr bool valid_visual_layer_buffers() { + uint16_t layers = 0; + for (const auto &buffer : visual_layer_buffers) { + const uint16_t layer = uint16_t(1U << static_cast(buffer.layer)); + if (layers & layer) + return false; + layers |= layer; + } + return layers == uint16_t((1U << visual_layer_count) - 1); +} + +static_assert(valid_visual_layer_buffers()); + +template auto visual_layers(Viewport *vp, bool previous = false) { + using layer_pointer = std::conditional_t, const int32_t *, int32_t *>; + std::array layers{}; + for (const auto &buffer : visual_layer_buffers) + layers[static_cast(buffer.layer)] = + vp->*(previous ? buffer.previous : buffer.current); + return layers; +} + +viewport_visual_animation_input animation_input(df::graphic_viewportst *vp) { + const df::graphic_viewportst *const_viewport = vp; + return {vp, + df::coord2d(vp->dim_x, vp->dim_y), + visual_context_revision, + visual_layers(const_viewport), + visual_layers(const_viewport, true), + df::coord2d(window_x ? *window_x : 0, window_y ? *window_y : 0)}; +} + +// The layer buffers are freed and nulled without clearing the active flag. +bool viewport_readable(df::graphic_viewportst *vp) { + return vp != nullptr && vp->flag.bits.active && animation_input(vp).valid(); +} + +int32_t tile_size(int32_t zoom) { + return std::max(1, zoom * native_tile_pixels / native_viewport_zoom); +} + +int32_t tile_pixel(int32_t tile, int32_t origin, int32_t zoom) { + return tile_size(zoom) * tile + origin; +} + +bool inside_clip(const df::graphic_viewportst *vp, df::coord2d pos) { + return pos.x >= vp->clipx[0] && pos.x <= vp->clipx[1] && pos.y >= vp->clipy[0] && + pos.y <= vp->clipy[1]; +} + +bool inside_clip(const df::graphic_viewportst *vp, int32_t x, int32_t y) { + return inside_clip(vp, df::coord2d(x, y)); +} + +bool has_fire(const df::graphic_viewportst *vp, int32_t x, int32_t y) { + return vp->screentexpos_spatter_flag != nullptr && + vp->screentexpos_spatter_flag[x * vp->dim_y + y].bits.fire_frame_type != 0; +} + +template class scoped_value_restorest { + T &value; + T saved; + + public: + explicit scoped_value_restorest(T &value, T replacement = T{}) + : value(value), saved(std::exchange(value, std::move(replacement))) { + static_assert(std::is_nothrow_move_assignable_v); + } + + ~scoped_value_restorest() noexcept { value = std::move(saved); } + + scoped_value_restorest(const scoped_value_restorest &) = delete; + scoped_value_restorest &operator=(const scoped_value_restorest &) = delete; + scoped_value_restorest(scoped_value_restorest &&) = delete; + scoped_value_restorest &operator=(scoped_value_restorest &&) = delete; +}; + +template void with_zeroed_values(const Callback &callback) { callback(); } + +template +void with_zeroed_values(const Callback &callback, T &value, Values &...values) { + scoped_value_restorest zero(value); + with_zeroed_values(callback, values...); +} + +struct render_proxy { + viewport_visual_layer layer; + DFHack::Coord2d source; + df::coord2d target; + int32_t texpos; + float progress; + SDL_Texture *texture; + bool mirrored = false; + int32_t mirror_shift = 0; + tile_coverage coverage; + visual_movement_id movement_id = no_visual_movement; +}; + +struct render_coverage { + tile_coverage all; + std::array(visual_render_group::count)> groups; + std::unordered_map selected; +}; + +struct viewport_render { + df::graphic_viewportst *viewport; + std::vector proxies; + render_coverage coverage; +}; + +constexpr uint16_t visual_layer_bit(viewport_visual_layer layer) { + return uint16_t(1U << static_cast(layer)); +} + +uint16_t selected_mask(const std::unordered_map &selected, int32_t index) { + const auto found = selected.find(index); + return found == selected.end() ? 0 : found->second; +} + +template +void with_suppressed_visual_layers(const std::array &layers, + int32_t index, uint16_t mask, const Callback &callback) { + if constexpr (Layer == visual_layer_count) + callback(); + else if (mask & (1U << Layer)) { + scoped_value_restorest zero(layers[Layer][index]); + with_suppressed_visual_layers(layers, index, mask, callback); + } else + with_suppressed_visual_layers(layers, index, mask, callback); +} + +template +void with_base_suppressed(df::graphic_viewportst *vp, int32_t index, const Callback &callback) { + with_zeroed_values(callback, vp->screentexpos_background[index], + vp->screentexpos_floor_flag[index], vp->screentexpos_background_two[index], + vp->screentexpos_liquid_flag[index], vp->screentexpos_spatter_flag[index], + vp->screentexpos_spatter[index], vp->screentexpos_ramp_flag[index], + vp->screentexpos_shadow_flag[index], vp->screentexpos_building_one[index]); +} + +template +void with_main_suppressed(df::graphic_viewportst *vp, int32_t index, const Callback &callback) { + with_base_suppressed(vp, index, + [&] { with_zeroed_values(callback, vp->screentexpos_vermin[index]); }); +} + +template +void with_upper_suppressed(df::graphic_viewportst *vp, int32_t index, const Callback &callback) { + with_main_suppressed(vp, index, [&] { + with_zeroed_values(callback, vp->screentexpos_building_two[index], + vp->screentexpos_projectile[index], vp->screentexpos_high_flow[index], + vp->screentexpos_top_shadow[index], vp->screentexpos_signpost[index]); + }); +} + +void redraw_viewport_tile(df::renderer_2d_base *renderer, const viewport_render &viewport, + int32_t x, int32_t y, bool defer_interface) { + df::graphic_viewportst *vp = viewport.viewport; + const int32_t index = x * vp->dim_y + y; + const auto redraw = [&] { renderer->update_viewport_tile(vp, x, y); }; + const auto stage = [&] { + with_suppressed_visual_layers(visual_layers(vp), index, + selected_mask(viewport.coverage.selected, index), redraw); + }; + // The interface layer is the shading for levels below the camera. + // A staged tile has a sprite drawn over it afterwards, so draw_interface_only + // places it instead. + if (!defer_interface || vp->screentexpos_interface == nullptr) + stage(); + else + with_zeroed_values(stage, vp->screentexpos_interface[index]); +} + +// Every buffer the interface-only pass zeroes has to exist before it can be +// zeroed. +bool interface_pass_readable(const df::graphic_viewportst *vp) { + return vp != nullptr && vp->screentexpos_interface != nullptr && + vp->screentexpos_background != nullptr && vp->screentexpos_floor_flag != nullptr && + vp->screentexpos_background_two != nullptr && vp->screentexpos_liquid_flag != nullptr && + vp->screentexpos_spatter_flag != nullptr && vp->screentexpos_spatter != nullptr && + vp->screentexpos_ramp_flag != nullptr && vp->screentexpos_shadow_flag != nullptr && + vp->screentexpos_building_one != nullptr && vp->screentexpos_vermin != nullptr && + vp->screentexpos_building_two != nullptr && vp->screentexpos_projectile != nullptr && + vp->screentexpos_high_flow != nullptr && vp->screentexpos_signpost != nullptr; +} + +// Runs after the proxies so the shading covers them rather than sitting +// underneath. +void draw_interface_only(df::renderer_2d_base *renderer, df::graphic_viewportst *vp, + df::coord2d pos) { + if (!interface_pass_readable(vp)) + return; + const int32_t index = pos.x * vp->dim_y + pos.y; + const auto redraw = [&] { renderer->update_viewport_tile(vp, pos.x, pos.y); }; + const auto without_visuals = [&] { + with_suppressed_visual_layers(visual_layers(vp), index, + uint16_t((1U << visual_layer_count) - 1), redraw); + }; + with_zeroed_values(without_visuals, vp->screentexpos_background[index], + vp->screentexpos_floor_flag[index], vp->screentexpos_background_two[index], + vp->screentexpos_liquid_flag[index], vp->screentexpos_spatter_flag[index], + vp->screentexpos_spatter[index], vp->screentexpos_ramp_flag[index], + vp->screentexpos_shadow_flag[index], vp->screentexpos_building_one[index], + vp->screentexpos_vermin[index], vp->screentexpos_building_two[index], + vp->screentexpos_projectile[index], vp->screentexpos_high_flow[index], + vp->screentexpos_signpost[index]); +} + +void redraw_world_tile(df::renderer_2d_base *renderer, + const std::vector &viewports, + const tile_coverage &staged, int32_t x, int32_t y) { + // The stage pass repaints everything above the lowest across the staged + // tiles, after the proxies. + const bool staged_tile = staged.count(df::coord2d(x, y)) != 0; + for (const viewport_render &viewport : viewports) { + if (inside_clip(viewport.viewport, x, y)) + redraw_viewport_tile(renderer, viewport, x, y, staged_tile); + if (staged_tile) + break; + } +} + +constexpr uint16_t visual_layers_through_group(visual_render_group group) { + uint16_t mask = 0; + for (const auto &descriptor : visual_layer_descriptors) + if (descriptor.render_group != visual_render_group::designation && + static_cast(descriptor.render_group) <= static_cast(group)) + mask |= visual_layer_bit(descriptor.layer); + return mask; +} + +void redraw_above(df::renderer_2d_base *renderer, df::graphic_viewportst *vp, int32_t x, int32_t y, + visual_render_group group, + const std::unordered_map &selected) { + const int32_t index = x * vp->dim_y + y; + const auto redraw = [&] { renderer->update_viewport_tile(vp, x, y); }; + const auto suppress_visuals = [&] { + const auto stage = [&] { + with_suppressed_visual_layers( + visual_layers(vp), index, + selected_mask(selected, index) | visual_layers_through_group(group), redraw); + }; + // The interface layer sits above every group, so each group's redraw would + // paint it again. draw_interface_only places it once, after the sprites. + if (vp->screentexpos_interface == nullptr) + stage(); + else + with_zeroed_values(stage, vp->screentexpos_interface[index]); + }; + if (group == visual_render_group::item || group == visual_render_group::vehicle) + with_base_suppressed(vp, index, suppress_visuals); + else if (group == visual_render_group::main) + with_main_suppressed(vp, index, suppress_visuals); + else + with_upper_suppressed(vp, index, suppress_visuals); +} + +SDL_Texture *cached_texture(df::renderer_2d_base *renderer, int32_t texpos, + bool transparent_background = true) { + if (texpos == 0) + return nullptr; + df::texture_fullid texture_id; + texture_id.texpos = texpos; + texture_id.r = texture_id.g = texture_id.b = 1.0f; + texture_id.br = texture_id.bg = texture_id.bb = 0.0f; + texture_id.flag = + transparent_background ? df::texture_fullid_flag::mask_transparent_background : 0; + const auto texture = renderer->tile_cache.tile_cache.find(texture_id); + return texture == renderer->tile_cache.tile_cache.end() + ? nullptr + : static_cast(texture->second); +} + +// The render_copy_ex_f null check is defensive only, not a graceful-degradation +// path. `bind` aborts load_sdl on any missing symbol and plugin_enable then +// refuses the render hook. +void render_copy_maybe_mirrored(SDL_Renderer *renderer, SDL_Texture *texture, + const SDL_FRect &destination, bool mirrored) { + if (mirrored && render_copy_ex_f != nullptr) { + render_copy_ex_f(renderer, texture, nullptr, &destination, 0.0, nullptr, + SDL_FLIP_HORIZONTAL); + return; + } + render_copy_f(renderer, texture, nullptr, &destination); +} + +void draw_proxy(df::renderer_2d_base *renderer, const render_proxy &proxy) { + const int32_t zoom = renderer->viewport_zoom_factor; + const DFHack::Coord2d target{ + tile_pixel(proxy.target.x, renderer->origin_x, zoom), + tile_pixel(proxy.target.y, renderer->origin_y, zoom)}; + const float tile_size = float(::tile_size(zoom)); + const float source_x = target.x + (proxy.source.x - proxy.target.x) * tile_size; + const float source_y = target.y + (proxy.source.y - proxy.target.y) * tile_size; + const float mirror_offset = float(proxy.mirror_shift) * tile_size; + const SDL_FRect destination = { + source_x + (target.x - source_x) * proxy.progress + mirror_offset, + source_y + (target.y - source_y) * proxy.progress, tile_size, tile_size}; + render_copy_maybe_mirrored(static_cast(renderer->sdl_renderer), proxy.texture, + destination, proxy.mirrored); +} + +std::vector collect_proxies(df::renderer_2d_base *renderer, + df::graphic_viewportst *vp, bool movement_enabled) { + std::vector proxies; + auto layers = visual_layers(vp); + auto previous_layers = visual_layers(vp, true); + for (const auto visual_layer : visual_layer_draw_order) { + const size_t layer = static_cast(visual_layer); + for (int32_t y = 0; y < vp->dim_y; ++y) { + for (int32_t x = 0; x < vp->dim_x; ++x) { + const int32_t index = x * vp->dim_y + y; + const int32_t texpos = layers[layer][index]; + if (texpos == 0 || !movement_enabled) + continue; + const auto movement = animation_manager.get_movement( + vp, static_cast(layer), x, y); + if (!movement.active) + continue; + const DFHack::Coord2d target{float(x), float(y)}; + const auto movement_delta = movement.source - target; + const int32_t inherited_source_x = + inherited_visual_source_tile(x, movement.source.x, x); + const int32_t inherited_source_y = + inherited_visual_source_tile(y, movement.source.y, y); + const bool inherited_source_in_bounds = + inherited_source_x >= 0 && inherited_source_x < vp->dim_x && + inherited_source_y >= 0 && inherited_source_y < vp->dim_y; + if (!visual_layer_moves_independently(visual_layer)) { + bool anchored = false; + for (const render_proxy &anchor : proxies) { + // Creature fragments lie no more than one tile from their center layer. + if (anchor.layer == viewport_visual_layer::center && + std::abs(anchor.target.x - x) <= 1 && + std::abs(anchor.target.y - y) <= 1 && + (visual_layer != viewport_visual_layer::designation + ? anchor.movement_id == movement.movement_id + : (anchor.source - + DFHack::Coord2d{float(anchor.target.x), float(anchor.target.y)} == + movement_delta && + anchor.progress == movement.progress))) + anchored = true; + } + if (!anchored) + continue; + } + if ((visual_layer == viewport_visual_layer::item || + visual_layer == viewport_visual_layer::designation) && + movement.inherited) { + if (visual_layer == viewport_visual_layer::item && + vp->screentexpos_old[index] != 0) + continue; + if (!inherited_source_in_bounds) + continue; + const int32_t source = inherited_source_x * vp->dim_y + inherited_source_y; + if (!visual_moved_between_tiles(visual_layer, layers[layer], + previous_layers[layer], source, index)) + continue; + } + if (!visual_layer_moves_independently(visual_layer) && + visual_layer != viewport_visual_layer::designation && movement.inherited) { + const bool fragment_moved = + inherited_source_in_bounds && + visual_moved_between_tiles( + visual_layer, layers[layer], previous_layers[layer], + inherited_source_x * vp->dim_y + inherited_source_y, index); + if (!fragment_moved) { + const auto &descriptor = visual_layer_descriptor(visual_layer); + const DFHack::Coord2d fragment_target{x, y}; + bool owns_fragment = false; + for (const render_proxy &anchor : proxies) + if (anchor.layer == viewport_visual_layer::center && + DFHack::Coord2d{anchor.target.x, anchor.target.y} == + fragment_target + + DFHack::Coord2d{descriptor.anchor_offset} && + anchor.movement_id == movement.movement_id) + owns_fragment = true; + if (!owns_fragment) + continue; + } + } + + // Items, vehicles and designations keep their vanilla orientation. + const auto &mirror_descriptor = visual_layer_descriptor(visual_layer); + const visual_render_group group = get_visual_render_group(visual_layer); + const bool mirror_eligible = + flip_enabled && + (group == visual_render_group::main || group == visual_render_group::upper); + // Facing is read from the anchor tile so every fragment of one creature + // agrees. + const df::coord2d anchor(x + mirror_descriptor.anchor_offset.x, + y + mirror_descriptor.anchor_offset.y); + const bool mirrored = mirror_eligible && + animation_manager.get_facing(vp, anchor) != native_sprite_facing; + // The anchor's own layer has no offset, so it flips in place. + const int32_t mirror_shift = + mirrored ? mirrored_tile_x(x, x + mirror_descriptor.anchor_offset.x) - x : 0; + render_proxy proxy = {static_cast(layer), + movement.source, + df::coord2d(x, y), + texpos, + movement.progress, + nullptr, + mirrored, + mirror_shift, + {}}; + bool blocked = false; + const int32_t coverage_min_x = int32_t(std::floor(std::min(proxy.source.x, float(x)))); + const int32_t coverage_max_x = int32_t(std::ceil(std::max(proxy.source.x, float(x)))); + const int32_t coverage_min_y = int32_t(std::floor(std::min(proxy.source.y, float(y)))); + const int32_t coverage_max_y = int32_t(std::ceil(std::max(proxy.source.y, float(y)))); + for (int32_t coverage_x = coverage_min_x; coverage_x <= coverage_max_x; + ++coverage_x) { + for (int32_t coverage_y = coverage_min_y; coverage_y <= coverage_max_y; + ++coverage_y) { + if (!inside_clip(vp, coverage_x, coverage_y)) { + blocked = true; + break; + } + if (get_visual_render_group(proxy.layer) == visual_render_group::main && + has_fire(vp, coverage_x, coverage_y)) { + blocked = true; + break; + } + proxy.coverage.emplace(coverage_x, coverage_y); + } + if (blocked) + break; + } + if (blocked) + continue; + if (proxy.mirror_shift != 0) { + tile_coverage mirrored_coverage; + for (const auto &tile : proxy.coverage) + mirrored_coverage.emplace(tile.x + proxy.mirror_shift, tile.y); + for (const auto &tile : mirrored_coverage) { + if (!inside_clip(vp, tile.x, tile.y)) { + blocked = true; + break; + } + if (get_visual_render_group(proxy.layer) == visual_render_group::main && + has_fire(vp, tile.x, tile.y)) { + blocked = true; + break; + } + proxy.coverage.insert(tile); + } + if (blocked) + continue; + } + + proxy.texture = cached_texture(renderer, texpos); + if (proxy.texture == nullptr) + continue; + proxy.movement_id = movement.movement_id; + proxies.push_back(std::move(proxy)); + } + } + } + + // A creature that has stopped still needs its mirrored sprite painted each + // frame. Otherwise the engine repaints it natively and the two orientations + // alternate between steps. A fragment's tile is its anchor minus the layer's + // centre offset, inverting the moving path. + if (flip_enabled) { + for (int32_t anchor_x = 0; anchor_x < vp->dim_x; ++anchor_x) { + for (int32_t anchor_y = 0; anchor_y < vp->dim_y; ++anchor_y) { + const df::coord2d anchor(anchor_x, anchor_y); + if (animation_manager.get_facing(vp, anchor) == native_sprite_facing) + continue; + for (const auto visual_layer : visual_layer_draw_order) { + const visual_render_group group = get_visual_render_group(visual_layer); + if (group != visual_render_group::main && + group != visual_render_group::upper) + continue; + const auto &descriptor = visual_layer_descriptor(visual_layer); + const int32_t x = anchor.x - descriptor.anchor_offset.x; + const int32_t y = anchor.y - descriptor.anchor_offset.y; + if (x < 0 || x >= vp->dim_x || y < 0 || y >= vp->dim_y) + continue; + const size_t layer = static_cast(visual_layer); + const int32_t texpos = layers[layer][x * vp->dim_y + y]; + if (texpos == 0) + continue; + bool already_drawn = false; + for (const render_proxy &existing : proxies) + if (existing.layer == visual_layer && existing.target.x == x && + existing.target.y == y) + already_drawn = true; + if (already_drawn) + continue; + + // source == target at progress 1.0 draws in place, moved only by + // mirror_shift. + render_proxy proxy = {visual_layer, + {float(x), float(y)}, + df::coord2d(x, y), + texpos, + 1.0f, + nullptr, + true, + mirrored_tile_x(x, anchor.x) - x, + {}}; + // The sprite lands on x+mirror_shift, so that interval must be + // repaintable. The shift has either sign, so order the interval ends + // first. + const int32_t coverage_first = std::min(x, x + proxy.mirror_shift); + const int32_t coverage_last = std::max(x, x + proxy.mirror_shift); + bool blocked = false; + for (int32_t coverage_x = coverage_first; coverage_x <= coverage_last; + ++coverage_x) { + if (!inside_clip(vp, coverage_x, y) || + (group == visual_render_group::main && has_fire(vp, coverage_x, y))) { + blocked = true; + break; + } + proxy.coverage.emplace(coverage_x, y); + } + if (blocked) + continue; + + proxy.texture = cached_texture(renderer, texpos); + if (proxy.texture == nullptr) + continue; + proxies.push_back(std::move(proxy)); + } + } + } + } + return proxies; +} + +render_coverage collect_coverage(const std::vector &proxies, int32_t dim_y) { + render_coverage coverage; + for (const render_proxy &proxy : proxies) { + coverage.all.insert(proxy.coverage.begin(), proxy.coverage.end()); + // TODO: use std::mdspan when C++23 available + coverage.selected[proxy.target.x * dim_y + proxy.target.y] |= visual_layer_bit(proxy.layer); + auto &group = coverage.groups[static_cast(get_visual_render_group(proxy.layer))]; + group.insert(proxy.coverage.begin(), proxy.coverage.end()); + } + return coverage; +} + +std::vector active_viewports() { + std::vector viewports; + if (gps == nullptr) + return viewports; + for (size_t lower = std::size(gps->lower_viewport); lower-- > 0;) { + df::graphic_viewportst *vp = gps->lower_viewport[lower]; + if (viewport_readable(vp)) + viewports.push_back(vp); + } + if (viewport_readable(gps->main_viewport)) + viewports.push_back(gps->main_viewport); + return viewports; +} + +std::vector +collect_viewport_renders(df::renderer_2d_base *renderer, + const std::vector &viewports) { + std::vector renders; + renders.reserve(viewports.size()); + for (df::graphic_viewportst *vp : viewports) { + const bool movement_enabled = zlevel_enabled || vp == gps->main_viewport; + viewport_render render = {vp, collect_proxies(renderer, vp, movement_enabled), {}}; + render.coverage = collect_coverage(render.proxies, vp->dim_y); + renders.push_back(std::move(render)); + } + return renders; +} + +tile_coverage collect_viewport_coverage(const std::vector &viewports) { + tile_coverage coverage; + for (const viewport_render &viewport : viewports) + coverage.insert(viewport.coverage.all.begin(), viewport.coverage.all.end()); + return coverage; +} + +void draw_interpolation_stages(df::renderer_2d_base *renderer, df::graphic_viewportst *vp, + const std::vector &proxies, + const render_coverage &coverage) { + for (size_t index = 0; index < coverage.groups.size(); ++index) { + const auto group = static_cast(index); + for (const render_proxy &proxy : proxies) + if (get_visual_render_group(proxy.layer) == group) + draw_proxy(renderer, proxy); + if (group == visual_render_group::designation) + continue; + for (const auto &[x, y] : coverage.groups[index]) + redraw_above(renderer, vp, x, y, group, coverage.selected); + } +} + +void redraw_viewport_tiles(df::renderer_2d_base *renderer, const viewport_render &viewport, + const tile_coverage &coverage) { + df::graphic_viewportst *vp = viewport.viewport; + for (const df::coord2d &tile : coverage) { + if (!inside_clip(vp, tile)) + continue; + redraw_viewport_tile(renderer, viewport, tile.x, tile.y, true); + } +} + +void draw_viewport_interpolation_stages(df::renderer_2d_base *renderer, + const std::vector &viewports, + const tile_coverage &coverage) { + for (size_t index = 0; index < viewports.size(); ++index) { + // A lower z-level's proxy must be covered by the next viewport's fog and + // terrain. Reapply that viewport before its own proxies, matching DF's + // lower-to-main draw order. + if (index > 0) + redraw_viewport_tiles(renderer, viewports[index], coverage); + const viewport_render &viewport = viewports[index]; + draw_interpolation_stages(renderer, viewport.viewport, viewport.proxies, viewport.coverage); + // A viewport shades everything drawn beneath it, so this covers every + // staged tile. Restricting it to the tiles this viewport has sprites on + // would not deepen with distance. + for (const df::coord2d &tile : coverage) { + if (inside_clip(viewport.viewport, tile)) + draw_interface_only(renderer, viewport.viewport, tile); + } + } +} + +bool has_mirrored_viewport_facing(const std::vector &viewports) { + for (const df::graphic_viewportst *vp : viewports) + if (animation_manager.has_mirrored_facing(vp)) + return true; + return false; +} + +void render_interpolated_world(df::renderer_2d_base *renderer) { + df::graphic_viewportst *vp = gps ? gps->main_viewport : nullptr; + const std::vector viewports = active_viewports(); + + if (vp != nullptr) + update_visual_context(renderer, vp); + const uint32_t now_ms = Core::getInstance().p->getTickCount(); + animation_manager.begin_frame(now_ms); + for (df::graphic_viewportst *viewport : viewports) + animation_manager.synchronize_viewport(animation_input(viewport)); + animation_manager.end_frame(); + + if (!viewport_readable(vp) || renderer->sdl_renderer == nullptr) + return; + const bool animation_redraw = + zlevel_enabled ? animation_manager.requires_full_redraw() + : animation_manager.has_active_movement(vp) || !previous_coverage.empty(); + if (!animation_redraw && (!flip_enabled || !has_mirrored_viewport_facing(viewports))) + return; + + std::vector viewport_renders = collect_viewport_renders(renderer, viewports); + tile_coverage coverage = collect_viewport_coverage(viewport_renders); + + SDL_Renderer *sdl_renderer = static_cast(renderer->sdl_renderer); + const int32_t zoom = renderer->viewport_zoom_factor; + const int32_t tile_size = ::tile_size(zoom); + + tile_coverage redraw_coverage = coverage; + redraw_coverage.insert(previous_coverage.begin(), previous_coverage.end()); + Uint8 old_r = 0, old_g = 0, old_b = 0, old_a = 255; + get_render_draw_color(sdl_renderer, &old_r, &old_g, &old_b, &old_a); + set_render_draw_color(sdl_renderer, 0, 0, 0, 255); + for (const auto &[x, y] : redraw_coverage) { + if (!inside_clip(vp, x, y)) + continue; + const SDL_Rect tile_rect = {tile_pixel(x, renderer->origin_x, zoom), + tile_pixel(y, renderer->origin_y, zoom), tile_size, tile_size}; + render_fill_rect(sdl_renderer, &tile_rect); + } + set_render_draw_color(sdl_renderer, old_r, old_g, old_b, old_a); + + for (const auto &[x, y] : redraw_coverage) { + if (inside_clip(vp, x, y)) + redraw_world_tile(renderer, viewport_renders, coverage, x, y); + } + draw_viewport_interpolation_stages(renderer, viewport_renders, coverage); + + previous_coverage = std::move(coverage); +} + +struct renderer_hook : df::renderer_2d_base { + typedef df::renderer_2d_base interpose_base; + DEFINE_VMETHOD_INTERPOSE(void, update_all, ()); +}; + +IMPLEMENT_VMETHOD_INTERPOSE(renderer_hook, update_all); + +void renderer_hook::interpose_fn_update_all() { + // update_all is the existing UI stage, so world correction must run first. + render_interpolated_world(this); + INTERPOSE_NEXT(update_all)(); +} + +void clear_sdl_bindings() { + render_copy_f = nullptr; + render_copy_ex_f = nullptr; + render_fill_rect = nullptr; + get_render_draw_color = nullptr; + set_render_draw_color = nullptr; +} + +bool load_sdl(color_ostream &out) { + clear_sdl_bindings(); + DFLibrary *sdl_handle = DFSDL::obtain_library_handle(); +#define bind(name, target) \ + target = reinterpret_cast(LookupPlugin(sdl_handle, #name)); \ + if (target == nullptr) { \ + out.printerr("smooth-movement: SDL2 function unavailable: " #name "\n"); \ + clear_sdl_bindings(); \ + return false; \ + } + bind(SDL_RenderCopyF, render_copy_f); + bind(SDL_RenderCopyExF, render_copy_ex_f); + bind(SDL_RenderFillRect, render_fill_rect); + bind(SDL_GetRenderDrawColor, get_render_draw_color); + bind(SDL_SetRenderDrawColor, set_render_draw_color); +#undef bind + return true; +} + +void reset_state() { + animation_manager = visual_animation_manager(); + previous_coverage.clear(); + visual_context_revision = 0; + previous_viewport = nullptr; + previous_visual_context = {}; + has_visual_context = false; + previous_pan = {}; + has_pan_context = false; + flip_enabled = false; + zlevel_enabled = false; +} + +command_result status_command(color_ostream &out, std::vector ¶meters) { + if (parameters.empty()) { + out.print("smooth-movement: {}\n", is_enabled ? "enabled" : "disabled"); + out.print("sprite flipping: {}\n", flip_enabled ? "on" : "off"); + out.print("lower z-level animation: {}\n", zlevel_enabled ? "on" : "off"); + return CR_OK; + } + if (parameters[0] == "flip") { + if (parameters.size() == 1) { + out.print("sprite flipping: {}\n", flip_enabled ? "on" : "off"); + return CR_OK; + } + // A toggle changes the screen without changing anything DF knows, so DF + // will not repaint. OFF matters most: the render path stops touching tiles + // it painted every frame. The last mirrored frame would persist. Same flush + // plugin_enable(false) uses. + if (parameters.size() == 2 && parameters[1] == "on") { + flip_enabled = true; + if (gps != nullptr) + ++gps->force_full_display_count; + out.print("smooth-movement: sprite flipping enabled\n"); + return CR_OK; + } + if (parameters.size() == 2 && parameters[1] == "off") { + flip_enabled = false; + if (gps != nullptr) + ++gps->force_full_display_count; + out.print("smooth-movement: sprite flipping disabled\n"); + return CR_OK; + } + return CR_WRONG_USAGE; + } + if (parameters[0] == "zlevel") { + if (parameters.size() == 1) { + out.print("lower z-level animation: {}\n", zlevel_enabled ? "on" : "off"); + return CR_OK; + } + if (parameters.size() == 2 && parameters[1] == "on") + zlevel_enabled = true; + else if (parameters.size() == 2 && parameters[1] == "off") + zlevel_enabled = false; + else + return CR_WRONG_USAGE; + if (gps != nullptr) + ++gps->force_full_display_count; + out.print("smooth-movement: lower z-level animation {}\n", + zlevel_enabled ? "enabled" : "disabled"); + return CR_OK; + } + return CR_WRONG_USAGE; +} + +} // namespace + +DFhackCExport command_result plugin_init(color_ostream &, std::vector &commands) { + commands.emplace_back("smooth-movement", "Smoothly animate movement in the fortress viewport.", + status_command); + return CR_OK; +} + +DFhackCExport command_result plugin_enable(color_ostream &out, bool enable) { + if (is_enabled == enable) + return CR_OK; + if (enable) { + reset_state(); + if (!load_sdl(out)) + return CR_FAILURE; + if (!INTERPOSE_HOOK(renderer_hook, update_all).apply()) { + out.printerr("smooth-movement: could not hook the 2D renderer\n"); + clear_sdl_bindings(); + return CR_FAILURE; + } + } else { + INTERPOSE_HOOK(renderer_hook, update_all).remove(); + reset_state(); + clear_sdl_bindings(); + if (gps != nullptr) + ++gps->force_full_display_count; + } + is_enabled = enable; + out.print("smooth-movement: {}\n", enable ? "enabled" : "disabled"); + return CR_OK; +} + +DFhackCExport command_result plugin_shutdown(color_ostream &out) { + return plugin_enable(out, false); +} diff --git a/plugins/smooth-movement/test_visual_animation_manager.cpp b/plugins/smooth-movement/test_visual_animation_manager.cpp new file mode 100644 index 0000000000..53dd7b22b2 --- /dev/null +++ b/plugins/smooth-movement/test_visual_animation_manager.cpp @@ -0,0 +1,820 @@ +// SPDX-License-Identifier: Zlib + +#ifdef NDEBUG +#undef NDEBUG +#endif + +#include +#include +#include +#include + +#include "visual_animation.h" + +namespace { + +template +struct test_grid { + static constexpr int32_t dimension = Dimension; + + std::array tiles{}; + + int32_t &at(int32_t x, int32_t y) { return tiles[x * dimension + y]; } + int32_t &operator[](size_t index) { return tiles[index]; } + const int32_t *data() const { return tiles.data(); } + void fill(int32_t value) { tiles.fill(value); } +}; + +viewport_visual_animation_input make_input(const void *viewport, int32_t dimension, + const int32_t *empty) { + viewport_visual_animation_input input; + input.viewport = static_cast(viewport); + input.dimensions = df::coord2d(dimension, dimension); + input.context_revision = 1; + input.current.fill(empty); + input.previous.fill(empty); + return input; +} + +template +viewport_visual_animation_input make_input(const void *viewport, + const test_grid &empty) { + return make_input(viewport, Dimension, empty.data()); +} + +void set_layer(viewport_visual_animation_input &input, viewport_visual_layer layer, + const int32_t *current, const int32_t *previous) { + const size_t index = static_cast(layer); + input.current[index] = current; + input.previous[index] = previous; +} + +template +void set_layer(viewport_visual_animation_input &input, viewport_visual_layer layer, + const test_grid ¤t, const test_grid &previous) { + set_layer(input, layer, current.data(), previous.data()); +} + +template +bool moved_between_tiles(viewport_visual_layer layer, const test_grid ¤t, + const test_grid &previous, df::coord2d source, + df::coord2d target) { + return visual_moved_between_tiles(layer, current.data(), previous.data(), + source.x * Dimension + source.y, + target.x * Dimension + target.y); +} + +template +void clear_layers(viewport_visual_animation_input &input, const test_grid &empty) { + input.current.fill(empty.data()); + input.previous.fill(empty.data()); +} + +void run_frame(visual_animation_manager &manager, const viewport_visual_animation_input &input, + uint32_t now_ms) { + manager.begin_frame(now_ms); + manager.synchronize_viewport(input); + manager.end_frame(); +} + +} // namespace + +int main() { + visual_animation_manager manager; + manager.begin_frame(1000); + assert(manager.get_frame_time_ms() == 1000); + assert(manager.get_frame_delta_ms() == 0); + + manager.begin_frame(1020); + assert(manager.get_frame_time_ms() == 1020); + assert(manager.get_frame_delta_ms() == 20); + + manager.begin_frame(1020); + assert(manager.get_frame_delta_ms() == 0); + + visual_animation_manager rollover; + rollover.begin_frame(std::numeric_limits::max() - 5); + rollover.begin_frame(3); + assert(rollover.get_frame_delta_ms() == 9); + // Facing rule: only a horizontal component changes facing. + assert(facing_after_move(1, visual_facing::west) == visual_facing::east); + assert(facing_after_move(-1, visual_facing::east) == visual_facing::west); + // dy is not an input, so a diagonal is only ever the sign of dx. + // The four real diagonals run end to end through the manager further down. + assert(facing_after_move(1, visual_facing::east) == visual_facing::east); + assert(facing_after_move(-1, visual_facing::west) == visual_facing::west); + // Pure vertical and idle carry the previous facing (sticky). + assert(facing_after_move(0, visual_facing::west) == visual_facing::west); + assert(facing_after_move(0, visual_facing::east) == visual_facing::east); + + constexpr int32_t mirror_anchor_x = 5; + constexpr int32_t mirror_right_x = mirror_anchor_x + 1; + constexpr int32_t mirror_left_x = mirror_anchor_x - 1; + assert(mirrored_tile_x(mirror_anchor_x, mirror_anchor_x) == mirror_anchor_x); + assert(mirrored_tile_x(mirror_right_x, mirror_anchor_x) == mirror_left_x); + assert(mirrored_tile_x(mirror_left_x, mirror_anchor_x) == mirror_right_x); + // The formula is a general reflection, so it holds for offsets no layer can + // express. + assert(mirrored_tile_x(mirror_anchor_x + 3, mirror_anchor_x) == mirror_anchor_x - 3); + // The horizontal anchor offset bounds the real mirror shift to -2, 0 or +2. + for (const auto &descriptor : visual_layer_descriptors) + assert(descriptor.anchor_offset.x >= -1 && descriptor.anchor_offset.x <= 1); + // Reflection is self-inverse. + assert(mirrored_tile_x(mirrored_tile_x(mirror_right_x, mirror_anchor_x), mirror_anchor_x) == + mirror_right_x); + assert(mirrored_tile_x(mirrored_tile_x(mirror_anchor_x + 3, mirror_anchor_x), + mirror_anchor_x) == mirror_anchor_x + 3); + + { + constexpr int32_t dim = 4; + constexpr int32_t creature_texpos = 77; + constexpr uint32_t initial_frame_ms = 1000; + constexpr uint32_t frame_interval_ms = 16; + int32_t empty[dim * dim] = {}; + int32_t before[dim * dim] = {}; + int32_t west_after[dim * dim] = {}; + const int viewport_token{}; + const void *viewport = &viewport_token; + + before[2 * dim + 2] = creature_texpos; + west_after[1 * dim + 2] = creature_texpos; // moved west: x 2 -> 1 + + // Moving west sets west facing on the target tile. + { + visual_animation_manager manager; + auto input = make_input(viewport, dim, empty); + set_layer(input, viewport_visual_layer::center, before, empty); + run_frame(manager, input, initial_frame_ms); + assert(!manager.has_active_movement(viewport)); + set_layer(input, viewport_visual_layer::center, west_after, before); + run_frame(manager, input, initial_frame_ms + frame_interval_ms); + assert(manager.has_active_movement(viewport)); + assert(manager.get_facing(viewport, 1, 2) == visual_facing::west); + } + + // Moving east sets east facing. + // East is neither the grid default nor the source facing, so the assertion + // is not vacuous. + { + visual_animation_manager manager; + auto input = make_input(viewport, dim, empty); + set_layer(input, viewport_visual_layer::center, before, empty); + run_frame(manager, input, initial_frame_ms); + set_layer(input, viewport_visual_layer::center, west_after, before); + run_frame(manager, input, initial_frame_ms + frame_interval_ms); + assert(manager.get_facing(viewport, 1, 2) == visual_facing::west); + set_layer(input, viewport_visual_layer::center, before, west_after); + run_frame(manager, input, initial_frame_ms + 2 * frame_interval_ms); + assert(manager.get_facing(viewport, 2, 2) == visual_facing::east); + } + + // The mirrored flag gates the render path's early return. + // It must rise only for a genuinely mirrored creature and fall when that + // tile empties. + { + visual_animation_manager manager; + auto input = make_input(viewport, dim, empty); + set_layer(input, viewport_visual_layer::center, before, empty); + run_frame(manager, input, initial_frame_ms); + assert(!manager.has_mirrored_facing(viewport)); + // Moving west matches the art, so nothing is mirrored yet. + set_layer(input, viewport_visual_layer::center, west_after, before); + run_frame(manager, input, initial_frame_ms + frame_interval_ms); + assert(manager.get_facing(viewport, 1, 2) == visual_facing::west); + assert(!manager.has_mirrored_facing(viewport)); + // Moving east faces away from the art and raises the flag. + set_layer(input, viewport_visual_layer::center, before, west_after); + run_frame(manager, input, initial_frame_ms + 2 * frame_interval_ms); + assert(manager.get_facing(viewport, 2, 2) == visual_facing::east); + assert(manager.has_mirrored_facing(viewport)); + // The creature leaves: the tile clears and so does the flag. + set_layer(input, viewport_visual_layer::center, empty, before); + run_frame(manager, input, initial_frame_ms + 3 * frame_interval_ms); + assert(manager.get_facing(viewport, 2, 2) == native_sprite_facing); + assert(!manager.has_mirrored_facing(viewport)); + assert(!manager.has_mirrored_facing(nullptr)); + } + + // Pure vertical movement carries the existing facing to the new tile. + { + visual_animation_manager manager; + auto input = make_input(viewport, dim, empty); + set_layer(input, viewport_visual_layer::center, before, empty); + run_frame(manager, input, initial_frame_ms); + set_layer(input, viewport_visual_layer::center, west_after, before); + run_frame(manager, input, initial_frame_ms + frame_interval_ms); + assert(manager.get_facing(viewport, 1, 2) == visual_facing::west); + int32_t up[dim * dim] = {}; + up[1 * dim + 1] = creature_texpos; // north: (1,2) -> (1,1), no horizontal component + set_layer(input, viewport_visual_layer::center, up, west_after); + run_frame(manager, input, initial_frame_ms + 2 * frame_interval_ms); + assert(manager.get_facing(viewport, 1, 1) == visual_facing::west); + } + + // Out-of-range and unknown viewports fall back to the native facing. + { + visual_animation_manager manager; + auto input = make_input(viewport, dim, empty); + set_layer(input, viewport_visual_layer::center, before, empty); + run_frame(manager, input, initial_frame_ms); + assert(manager.get_facing(viewport, -1, 0) == native_sprite_facing); + assert(manager.get_facing(viewport, dim, 0) == native_sprite_facing); + assert(manager.get_facing(nullptr, 0, 0) == native_sprite_facing); + } + + // Each rendered z-level has its own viewport buffers; tracking one must not + // suppress another. + { + const int lower_token = 0; + const int main_token = 0; + const void *lower_viewport = &lower_token; + const void *main_viewport = &main_token; + visual_animation_manager z_levels; + auto lower_input = make_input(lower_viewport, dim, empty); + auto main_input = make_input(main_viewport, dim, empty); + set_layer(lower_input, viewport_visual_layer::center, before, empty); + set_layer(main_input, viewport_visual_layer::center, before, empty); + z_levels.begin_frame(initial_frame_ms); + z_levels.synchronize_viewport(lower_input); + z_levels.synchronize_viewport(main_input); + z_levels.end_frame(); + set_layer(lower_input, viewport_visual_layer::center, west_after, before); + set_layer(main_input, viewport_visual_layer::center, west_after, before); + z_levels.begin_frame(initial_frame_ms + frame_interval_ms); + z_levels.synchronize_viewport(lower_input); + z_levels.synchronize_viewport(main_input); + z_levels.end_frame(); + assert( + z_levels.get_movement(lower_viewport, viewport_visual_layer::center, 1, 2).active); + assert( + z_levels.get_movement(main_viewport, viewport_visual_layer::center, 1, 2).active); + } + } + + // All four diagonals through the manager, so every step carries a real dy as + // well as a dx. The chain alternates direction, so no assertion can pass by + // inheriting the previous facing. + { + constexpr int32_t diag_dim = 5; + constexpr int32_t creature_texpos = 77; + constexpr uint32_t initial_frame_ms = 1000; + constexpr uint32_t frame_interval_ms = 16; + int32_t diag_empty[diag_dim * diag_dim] = {}; + int32_t start[diag_dim * diag_dim] = {}; + int32_t north_east[diag_dim * diag_dim] = {}; + int32_t south_west[diag_dim * diag_dim] = {}; + int32_t south_east[diag_dim * diag_dim] = {}; + int32_t north_west[diag_dim * diag_dim] = {}; + const int diag_token = 0; + const void *diag_viewport = &diag_token; + + start[2 * diag_dim + 2] = creature_texpos; // (2,2) + north_east[3 * diag_dim + 1] = creature_texpos; // (2,2) -> (3,1): dx +1, dy -1 + south_west[2 * diag_dim + 2] = creature_texpos; // (3,1) -> (2,2): dx -1, dy +1 + south_east[3 * diag_dim + 3] = creature_texpos; // (2,2) -> (3,3): dx +1, dy +1 + north_west[2 * diag_dim + 2] = creature_texpos; // (3,3) -> (2,2): dx -1, dy -1 + + visual_animation_manager diagonal; + auto input = make_input(diag_viewport, diag_dim, diag_empty); + set_layer(input, viewport_visual_layer::center, start, diag_empty); + run_frame(diagonal, input, initial_frame_ms); + assert(diagonal.get_facing(diag_viewport, 2, 2) == native_sprite_facing); + + set_layer(input, viewport_visual_layer::center, north_east, start); + run_frame(diagonal, input, initial_frame_ms + frame_interval_ms); + assert(diagonal.get_facing(diag_viewport, 3, 1) == visual_facing::east); + + // West is the grid default, so the westward legs also assert a movement was + // registered. Otherwise an untracked step leaving the tile at its default + // would pass. + set_layer(input, viewport_visual_layer::center, south_west, north_east); + run_frame(diagonal, input, initial_frame_ms + 2 * frame_interval_ms); + assert(diagonal.get_movement(diag_viewport, viewport_visual_layer::center, 2, 2).active); + assert(diagonal.get_facing(diag_viewport, 2, 2) == visual_facing::west); + + set_layer(input, viewport_visual_layer::center, south_east, south_west); + run_frame(diagonal, input, initial_frame_ms + 3 * frame_interval_ms); + assert(diagonal.get_facing(diag_viewport, 3, 3) == visual_facing::east); + + set_layer(input, viewport_visual_layer::center, north_west, south_east); + run_frame(diagonal, input, initial_frame_ms + 4 * frame_interval_ms); + assert(diagonal.get_movement(diag_viewport, viewport_visual_layer::center, 2, 2).active); + assert(diagonal.get_facing(diag_viewport, 2, 2) == visual_facing::west); + } + + // Facing is keyed by SCREEN tile, so a scroll moves the creatures out from + // under it. The two outcomes differ fundamentally: a landed shift has a known + // delta and is translated. An abandoned shift never identifies a delta, so + // the grid can only be dropped. + { + using pan_grid = test_grid<4>; + constexpr int32_t creature_texpos = 77; + constexpr uint32_t frame_interval_ms = 16; + pan_grid pan_empty; + pan_grid at_one; + pan_grid at_two; + pan_grid unmatched_a; + pan_grid unmatched_b; + const int pan_token = 0; + const void *pan_viewport = &pan_token; + + at_one.at(1, 1) = creature_texpos; + at_two.at(2, 1) = creature_texpos; // steps east, x 1 -> 2, so it faces east + unmatched_a.at(2, 1) = 78; + unmatched_b.at(2, 1) = 79; + + // LANDED: the shift is recognized, so facing follows the buffers. + { + visual_animation_manager landed; + auto input = make_input(pan_viewport, pan_empty); + set_layer(input, viewport_visual_layer::center, at_one, pan_empty); + run_frame(landed, input, 1000); + set_layer(input, viewport_visual_layer::center, at_two, at_one); + run_frame(landed, input, 1000 + frame_interval_ms); + assert(landed.get_facing(pan_viewport, 2, 1) == visual_facing::east); + assert(landed.has_mirrored_facing(pan_viewport)); + + // Frame A: the scroll is announced but the buffers have not moved yet. + input.pan = df::coord2d(1, 0); + set_layer(input, viewport_visual_layer::center, at_two, at_two); + run_frame(landed, input, 1000 + 2 * frame_interval_ms); + assert(landed.get_facing(pan_viewport, 2, 1) == visual_facing::east); + + // Frame B: the buffers shift east by one and the majority-match test + // recognizes it. + set_layer(input, viewport_visual_layer::center, at_one, at_two); + run_frame(landed, input, 1000 + 3 * frame_interval_ms); + assert(landed.get_facing(pan_viewport, 1, 1) == visual_facing::east); + assert(landed.get_facing(pan_viewport, 2, 1) == native_sprite_facing); + assert(landed.has_mirrored_facing(pan_viewport)); + } + + // ABANDONED: the shift never shows up in the buffers, so no delta is ever + // identified. The grid must be back at the default while the tile is still + // OCCUPIED. The empty-tile sweep cannot reach that case, so only an + // explicit reset clears it. + { + visual_animation_manager abandoned; + auto input = make_input(pan_viewport, pan_empty); + set_layer(input, viewport_visual_layer::center, at_one, pan_empty); + run_frame(abandoned, input, 2000); + set_layer(input, viewport_visual_layer::center, at_two, at_one); + run_frame(abandoned, input, 2000 + frame_interval_ms); + assert(abandoned.get_facing(pan_viewport, 2, 1) == visual_facing::east); + + // Changed buffers keep the failed majority-match test running every + // frame. It tolerates four before giving up on the fifth. + input.pan = df::coord2d(1, 0); + constexpr int32_t pending_frame_count = 4; + for (int32_t frame = 0; frame < pending_frame_count; ++frame) { + set_layer(input, viewport_visual_layer::center, at_two, + frame % 2 == 0 ? unmatched_a : unmatched_b); + run_frame(abandoned, input, 2000 + uint32_t(frame + 2) * frame_interval_ms); + // Still pending, so the facing survives. + // The assertion after the giving-up frame therefore tests the reset, + // not an empty grid. + assert(abandoned.get_facing(pan_viewport, 2, 1) == visual_facing::east); + assert(abandoned.has_mirrored_facing(pan_viewport)); + } + set_layer(input, viewport_visual_layer::center, at_two, unmatched_a); + run_frame(abandoned, input, + 2000 + uint32_t(pending_frame_count + 2) * frame_interval_ms); + assert(abandoned.get_facing(pan_viewport, 2, 1) == native_sprite_facing); + assert(!abandoned.has_mirrored_facing(pan_viewport)); + } + } + + // Regression: A and B move in the same frame, chained -- A's target tile is + // B's source tile. A's write must not corrupt B's read of its own pre-frame + // facing. A is sent east first, so its facing differs from the default B + // carries and a swap shows. + { + constexpr int32_t chase_dim = 5; + constexpr int32_t creature_texpos = 77; + constexpr int32_t companion_texpos = 88; + int32_t chase_empty[chase_dim * chase_dim] = {}; + int32_t frame_a[chase_dim * chase_dim] = {}; + int32_t frame_b[chase_dim * chase_dim] = {}; + int32_t frame_c[chase_dim * chase_dim] = {}; + const int chase_token = 0; + const void *chase_viewport = &chase_token; + + frame_a[1 * chase_dim + 1] = creature_texpos; // A at (1,1) + frame_a[2 * chase_dim + 2] = companion_texpos; // B at (2,2), stationary + frame_b[2 * chase_dim + 1] = creature_texpos; // A steps east: (1,1) -> (2,1) + frame_b[2 * chase_dim + 2] = companion_texpos; // B unchanged + + // Both step south in the same frame: shared_movement_delta needs two tiles + // on the same delta. + frame_c[2 * chase_dim + 2] = creature_texpos; // A: (2,1) -> (2,2) + frame_c[2 * chase_dim + 3] = companion_texpos; // B: (2,2) -> (2,3) + + visual_animation_manager manager; + auto input = make_input(chase_viewport, chase_dim, chase_empty); + set_layer(input, viewport_visual_layer::center, frame_a, chase_empty); + run_frame(manager, input, 1000); + set_layer(input, viewport_visual_layer::center, frame_b, frame_a); + run_frame(manager, input, 1016); + assert(manager.get_facing(chase_viewport, 2, 1) == visual_facing::east); + assert(manager.get_facing(chase_viewport, 2, 2) == native_sprite_facing); + + set_layer(input, viewport_visual_layer::center, frame_c, frame_b); + run_frame(manager, input, 1032); + assert(manager.get_facing(chase_viewport, 2, 2) == visual_facing::east); + // B carries its own default forward, not A's, though A wrote (2,2) earlier + // in the same pass. + assert(manager.get_facing(chase_viewport, 2, 3) == native_sprite_facing); + } + + // Regression: a vacated source tile is reoccupied the same frame by an + // UNTRACKED creature. Nothing targets that tile, so no target write clears + // it, and it is not empty either. Only an explicit, order-independent source + // clear restores the default there. + { + constexpr int32_t gap_dim = 5; + constexpr int32_t creature_texpos = 77; + constexpr int32_t companion_texpos = 88; + constexpr int32_t untracked_texpos = 55; + int32_t gap_empty[gap_dim * gap_dim] = {}; + int32_t frame_a[gap_dim * gap_dim] = {}; + int32_t frame_b[gap_dim * gap_dim] = {}; + int32_t frame_c[gap_dim * gap_dim] = {}; + const int gap_token = 0; + const void *gap_viewport = &gap_token; + + // E is a companion so that D's later departure shares a delta with E's own + // move. shared_movement_delta engages only once two tiles move on the same + // delta. + frame_a[1 * gap_dim + 1] = creature_texpos; // D at (1,1) + frame_a[2 * gap_dim + 2] = companion_texpos; // E at (2,2), stationary companion + frame_b[2 * gap_dim + 1] = + creature_texpos; // D steps east: (1,1) -> (2,1), facing differs from default + frame_b[2 * gap_dim + 2] = companion_texpos; // E unchanged + + // D and E both step south, chained, and F appears at D's just-vacated tile + // the same frame. previous[(2,1)] was occupied by D, so the empty-cell + // fallback cannot see F. No shared-delta source matches F's texpos either, + // so its arrival registers no movement. + frame_c[2 * gap_dim + 2] = creature_texpos; // D: (2,1) -> (2,2) + frame_c[2 * gap_dim + 3] = companion_texpos; // E: (2,2) -> (2,3) + frame_c[2 * gap_dim + 1] = untracked_texpos; // F appears at (2,1), untracked + + visual_animation_manager manager; + auto input = make_input(gap_viewport, gap_dim, gap_empty); + set_layer(input, viewport_visual_layer::center, frame_a, gap_empty); + run_frame(manager, input, 1000); + set_layer(input, viewport_visual_layer::center, frame_b, frame_a); + run_frame(manager, input, 1016); + assert(manager.get_facing(gap_viewport, 2, 1) == visual_facing::east); + + set_layer(input, viewport_visual_layer::center, frame_c, frame_b); + run_frame(manager, input, 1032); + assert(!manager.get_movement(gap_viewport, viewport_visual_layer::center, 2, 1).active); + // F must not inherit D's stale east facing, though the tile is occupied + // rather than empty. + assert(manager.get_facing(gap_viewport, 2, 1) == native_sprite_facing); + assert(manager.get_facing(gap_viewport, 2, 2) == visual_facing::east); + assert(manager.get_facing(gap_viewport, 2, 3) == native_sprite_facing); + } + + constexpr uint32_t animation_duration_ms = 100; + assert(animation_progress(animation_duration_ms, 0, animation_duration_ms) == 1.0f); + assert(inherited_visual_source_tile(0, 0, 1) == -1); + assert(inherited_visual_source_tile(2, 0, 1) == 1); + assert(visual_layer_descriptor(viewport_visual_layer::right).anchor_offset.x == -1); + assert(visual_layer_descriptor(viewport_visual_layer::left).anchor_offset.x == 1); + assert(visual_layer_descriptor(viewport_visual_layer::upright).anchor_offset.x == -1 && + visual_layer_descriptor(viewport_visual_layer::upright).anchor_offset.y == 1); + assert(visual_layer_descriptor(viewport_visual_layer::up).anchor_offset.x == 0 && + visual_layer_descriptor(viewport_visual_layer::up).anchor_offset.y == 1); + assert(visual_layer_descriptor(viewport_visual_layer::upleft).anchor_offset.x == 1 && + visual_layer_descriptor(viewport_visual_layer::upleft).anchor_offset.y == 1); + + // A multi-tile fragment follows its exact anchor in a crowded scene. + { + constexpr int32_t crowded_dimension = 5; + constexpr int32_t anchor_texpos = 11; + constexpr int32_t neighbor_texpos = 22; + const DFHack::Coord2d anchor_source(1, 2); + const DFHack::Coord2d anchor_target(2, 2); + const DFHack::Coord2d neighbor_source(3, 2); + const DFHack::Coord2d neighbor_target(3, 1); + constexpr uint32_t initial_time_ms = 1000; + constexpr uint32_t movement_time_ms = 1016; + test_grid empty; + test_grid before; + test_grid after; + before.at(anchor_source.x, anchor_source.y) = anchor_texpos; + before.at(neighbor_source.x, neighbor_source.y) = neighbor_texpos; + after.at(anchor_target.x, anchor_target.y) = anchor_texpos; + after.at(neighbor_target.x, neighbor_target.y) = neighbor_texpos; + const int viewport_token = 0; + const void *crowded_viewport = &viewport_token; + visual_animation_manager crowded; + auto crowded_input = make_input(crowded_viewport, empty); + set_layer(crowded_input, viewport_visual_layer::center, before, empty); + run_frame(crowded, crowded_input, initial_time_ms); + set_layer(crowded_input, viewport_visual_layer::center, after, before); + run_frame(crowded, crowded_input, movement_time_ms); + const auto anchor = crowded.get_movement(crowded_viewport, + viewport_visual_layer::center, + anchor_target.x, anchor_target.y); + const auto fragment_target = + anchor_target - DFHack::Coord2d( + visual_layer_descriptor(viewport_visual_layer::right).anchor_offset); + const auto fragment = crowded.get_movement(crowded_viewport, + viewport_visual_layer::right, + fragment_target.x, fragment_target.y); + assert(anchor.active && fragment.active && fragment.inherited); + assert(fragment.movement_id == anchor.movement_id); + assert(fragment.source == DFHack::Coord2d(anchor_target)); + assert(fragment.progress == anchor.progress); + } + + using small_grid = test_grid<3>; + constexpr int32_t creature_texpos = 42; + small_grid empty; + small_grid current; + small_grid previous; + const void *viewport = reinterpret_cast(uintptr_t(1)); + auto input = make_input(viewport, empty); + set_layer(input, viewport_visual_layer::center, current, previous); + + visual_animation_manager movement; + constexpr uint32_t movement_start_time_ms = 2000; + run_frame(movement, input, movement_start_time_ms - 10); + assert(!movement.requires_full_redraw()); + + previous.at(0, 1) = creature_texpos; + current.at(1, 1) = creature_texpos; + run_frame(movement, input, movement_start_time_ms); + auto render = movement.get_movement(viewport, viewport_visual_layer::center, 1, 1); + assert(render.active); + assert(render.source.x == 0 && render.source.y == 1); + assert(render.progress == 0.0f); + assert(movement.requires_full_redraw()); + + previous = current; + set_layer(input, viewport_visual_layer::center, current, previous); + run_frame(movement, input, movement_start_time_ms + animation_duration_ms / 2); + render = movement.get_movement(viewport, viewport_visual_layer::center, 1, 1); + assert(render.active); + assert(render.progress == 0.5f); + + run_frame(movement, input, movement_start_time_ms + animation_duration_ms); + assert(!movement.get_movement(viewport, viewport_visual_layer::center, 1, 1).active); + assert(movement.requires_full_redraw()); + + run_frame(movement, input, movement_start_time_ms + animation_duration_ms + 20); + assert(!movement.requires_full_redraw()); + + visual_animation_manager ambiguous; + run_frame(ambiguous, input, 2990); + previous.fill(0); + previous[0 * 3 + 1] = 42; + previous[1 * 3 + 0] = 42; + set_layer(input, viewport_visual_layer::center, current, previous); + run_frame(ambiguous, input, 3000); + assert(!ambiguous.get_movement(viewport, viewport_visual_layer::center, 1, 1).active); + + // A handler and led animal form an occupied chain: each enters the other's + // old space. + visual_animation_manager convoy; + current.fill(0); + previous.fill(0); + run_frame(convoy, input, 3490); + previous[0 * 3 + 1] = 41; + previous[1 * 3 + 1] = 42; + current[1 * 3 + 1] = 41; + current[2 * 3 + 1] = 42; + run_frame(convoy, input, 3500); + const auto animal = convoy.get_movement(viewport, viewport_visual_layer::center, 1, 1); + const auto handler = convoy.get_movement(viewport, viewport_visual_layer::center, 2, 1); + assert(animal.active && animal.source.x == 0 && animal.source.y == 1); + assert(handler.active && handler.source.x == 1 && handler.source.y == 1); + + // A multi-tile fragment can use its own movement or its mapped center tile as + // proof of ownership. + for (const auto layer : + {viewport_visual_layer::right, viewport_visual_layer::left, viewport_visual_layer::upright, + viewport_visual_layer::up, viewport_visual_layer::upleft}) { + current.fill(0); + previous.fill(0); + previous[0 * 3 + 1] = 50; + current[1 * 3 + 1] = 50; + assert(moved_between_tiles(layer, current, previous, {0, 1}, {1, 1})); + previous[1 * 3 + 1] = 50; + assert(!moved_between_tiles(layer, current, previous, {0, 1}, {1, 1})); + } + + visual_animation_manager context; + current.fill(0); + previous.fill(0); + run_frame(context, input, 4000); + previous[0 * 3 + 1] = 42; + current[1 * 3 + 1] = 42; + run_frame(context, input, 4010); + assert(context.get_movement(viewport, viewport_visual_layer::center, 1, 1).active); + + ++input.context_revision; + run_frame(context, input, 4020); + assert(!context.get_movement(viewport, viewport_visual_layer::center, 1, 1).active); + + // Camera-pan handling. window_x/window_y change at input time but the buffers + // shift on a later render frame, so the manager must (a) NOT create movements + // from the buffer shift itself (the floating-sprite bug), and (b) translate + // in-flight movements on the frame the shift lands. + small_grid pan_current; + small_grid pan_previous; + small_grid pan_empty; + auto pan_input = make_input(viewport, pan_empty); + set_layer(pan_input, viewport_visual_layer::center, pan_current, pan_previous); + + // FLOAT REGRESSION: a stationary creature, pan announced at frame A, buffers + // shift at frame B. Frame B's buffers look exactly like a real move + // ((1,1)->(0,1) with a unique source) — the manager must recognize it as the + // pending pan and create NO movement. + visual_animation_manager floaty; + pan_previous[1 * 3 + 1] = 42; + pan_current[1 * 3 + 1] = 42; + run_frame(floaty, pan_input, 4990); + pan_input.pan = df::coord2d(1, 0); // frame A: window scrolled, buffers unchanged + run_frame(floaty, pan_input, 5000); + assert(!floaty.get_movement(viewport, viewport_visual_layer::center, 1, 1).active); + pan_previous[1 * 3 + 1] = 42; // frame B: buffers apply the shift + pan_current.fill(0); + pan_current[0 * 3 + 1] = 42; + run_frame(floaty, pan_input, 5010); + assert(!floaty.get_movement(viewport, viewport_visual_layer::center, 0, 1).active); + + // FOLLOW: an in-flight movement survives the announce frame untouched and is + // translated on the frame the buffers shift, so the sprite tracks the + // scrolled world. + visual_animation_manager panner; + pan_current.fill(0); + pan_previous.fill(0); + pan_input.pan = df::coord2d(0, 0); + run_frame(panner, pan_input, 5990); + pan_previous.at(0, 1) = creature_texpos; // creature steps (0,1) -> (1,1) + pan_current.at(1, 1) = creature_texpos; + run_frame(panner, pan_input, 6000); + auto moved = panner.get_movement(viewport, viewport_visual_layer::center, 1, 1); + assert(moved.active && moved.source.x == 0 && moved.source.y == 1); + + pan_input.pan = df::coord2d(1, 0); // frame A: pan announced, buffers unchanged + pan_previous[0 * 3 + 1] = 0; + pan_previous[1 * 3 + 1] = 42; // previous now matches current (stationary at (1,1)) + run_frame(panner, pan_input, 6010); + moved = panner.get_movement(viewport, viewport_visual_layer::center, 1, 1); + assert(moved.active && moved.source.x == 0); // untouched: still anchored to the old frame + + pan_current.fill(0); // frame B: buffers shift east by one + pan_current[0 * 3 + 1] = 42; + run_frame(panner, pan_input, 6020); + assert(!panner.get_movement(viewport, viewport_visual_layer::center, 1, 1).active); + auto followed = panner.get_movement(viewport, viewport_visual_layer::center, 0, 1); + assert(followed.active && followed.source.x == -1 && followed.source.y == 1); + + // SAME-FRAME: pan announced and buffers shifted in the same call — translated + // immediately. + visual_animation_manager same_frame; + pan_current.fill(0); + pan_previous.fill(0); + pan_input.pan = df::coord2d(0, 0); + run_frame(same_frame, pan_input, 6990); + pan_previous[0 * 3 + 1] = 42; + pan_current[1 * 3 + 1] = 42; + run_frame(same_frame, pan_input, 7000); + assert(same_frame.get_movement(viewport, viewport_visual_layer::center, 1, 1).active); + pan_input.pan = df::coord2d(1, 0); + pan_previous = pan_current; + pan_current.fill(0); + pan_current[0 * 3 + 1] = 42; + run_frame(same_frame, pan_input, 7010); + followed = same_frame.get_movement(viewport, viewport_visual_layer::center, 0, 1); + assert(followed.active && followed.source.x == -1); + + // A change that is NOT a pure pan (context revision bump) still resets, even + // with in-flight work. + visual_animation_manager reset_on_zoom; + pan_current.fill(0); + pan_previous.fill(0); + pan_input.pan = df::coord2d(0, 0); + pan_input.context_revision = 1; + run_frame(reset_on_zoom, pan_input, 8000); + pan_previous[0 * 3 + 1] = 42; + pan_current[1 * 3 + 1] = 42; + run_frame(reset_on_zoom, pan_input, 8010); + assert(reset_on_zoom.get_movement(viewport, viewport_visual_layer::center, 1, 1).active); + pan_input.context_revision = 2; // e.g. zoom / z-level / resize + run_frame(reset_on_zoom, pan_input, 8020); + assert(!reset_on_zoom.get_movement(viewport, viewport_visual_layer::center, 1, 1).active); + + // Status fragments inherit nearby center motion even while their texture + // flashes. + small_grid status_current; + small_grid status_previous; + current.fill(0); + previous.fill(0); + input.context_revision = 1; + clear_layers(input, empty); + set_layer(input, viewport_visual_layer::center, current, previous); + set_layer(input, viewport_visual_layer::item, status_current, status_previous); + set_layer(input, viewport_visual_layer::designation, status_current, status_previous); + visual_animation_manager companion; + constexpr uint32_t companion_start_time_ms = 9000; + run_frame(companion, input, companion_start_time_ms - 10); + previous[0 * 3 + 1] = 42; + current[1 * 3 + 1] = 42; + status_previous[0 * 3 + 0] = 90; + status_current[1 * 3 + 0] = 91; + assert(moved_between_tiles(viewport_visual_layer::designation, status_current, status_previous, + {0, 0}, {1, 0})); + status_previous[0 * 3 + 0] = 0; + assert(moved_between_tiles(viewport_visual_layer::designation, status_current, status_previous, + {0, 0}, {1, 0})); + assert(!moved_between_tiles(viewport_visual_layer::item, status_current, status_previous, + {0, 0}, {1, 0})); + status_previous[1 * 3 + 0] = 80; + assert(!moved_between_tiles(viewport_visual_layer::designation, status_current, status_previous, + {0, 0}, {1, 0})); + status_previous[0 * 3 + 0] = 90; + status_previous[1 * 3 + 0] = 0; + run_frame(companion, input, companion_start_time_ms); + auto status = companion.get_movement(viewport, viewport_visual_layer::designation, 1, 0); + assert(status.active && !status.inherited && status.source.x == 0 && status.source.y == 0); + const auto carried_item = companion.get_movement(viewport, viewport_visual_layer::item, 1, 0); + assert(carried_item.active && carried_item.inherited); + previous = current; + status_current[1 * 3 + 0] = 92; + run_frame(companion, input, companion_start_time_ms + animation_duration_ms / 2); + status = companion.get_movement(viewport, viewport_visual_layer::designation, 1, 0); + assert(status.active && status.progress == 0.5f); + + // Divergent nearby creature movements make companion ownership ambiguous, so + // the overlay snaps. + current.fill(0); + previous.fill(0); + status_current.fill(0); + status_previous.fill(0); + visual_animation_manager crowd; + run_frame(crowd, input, 9990); + previous[0 * 3 + 0] = 41; + current[0 * 3 + 1] = 41; + previous[2 * 3 + 2] = 42; + current[2 * 3 + 1] = 42; + status_current[1 * 3 + 1] = 99; + run_frame(crowd, input, 10000); + assert(!crowd.get_movement(viewport, viewport_visual_layer::designation, 1, 1).active); + status_previous[1 * 3 + 0] = 90; + status_current[1 * 3 + 1] = 91; + run_frame(crowd, input, 10010); + status = crowd.get_movement(viewport, viewport_visual_layer::designation, 1, 1); + assert(status.active); + assert(!status.inherited); + assert(status.source.x == 1 && status.source.y == 0); + + // Wheelbarrows use the item layer and the same independent adjacent-movement + // detection. + current.fill(0); + previous.fill(0); + clear_layers(input, empty); + set_layer(input, viewport_visual_layer::item, current, previous); + visual_animation_manager item; + run_frame(item, input, 10990); + previous[0 * 3 + 1] = 77; + current[1 * 3 + 1] = 77; + run_frame(item, input, 11000); + const auto item_move = item.get_movement(viewport, viewport_visual_layer::item, 1, 1); + assert(item_move.active && item_move.source.x == 0 && item_move.source.y == 1); + + // Minecart graphics can change texpos while moving; vehicle identity is tile + // occupancy. + current.fill(0); + previous.fill(0); + clear_layers(input, empty); + set_layer(input, viewport_visual_layer::vehicle, current, previous); + visual_animation_manager vehicle; + constexpr int32_t vehicle_texpos_before = 77; + constexpr int32_t vehicle_texpos_during_move = 78; + constexpr int32_t vehicle_texpos_stationary = 79; + constexpr int32_t vehicle_texpos_after_move = 80; + constexpr uint32_t vehicle_start_time_ms = 12000; + run_frame(vehicle, input, vehicle_start_time_ms - 10); + previous.at(0, 1) = vehicle_texpos_before; + current.at(1, 1) = vehicle_texpos_during_move; + run_frame(vehicle, input, vehicle_start_time_ms); + assert(vehicle.get_movement(viewport, viewport_visual_layer::vehicle, 1, 1).active); + previous = current; + current.at(1, 1) = vehicle_texpos_stationary; + run_frame(vehicle, input, vehicle_start_time_ms + animation_duration_ms / 2); + const auto cart = vehicle.get_movement(viewport, viewport_visual_layer::vehicle, 1, 1); + assert(cart.active && cart.progress == 0.5f); + previous = current; + current.fill(0); + current.at(2, 1) = vehicle_texpos_after_move; + run_frame(vehicle, input, vehicle_start_time_ms + animation_duration_ms / 2 + 10); + const auto chained = vehicle.get_movement(viewport, viewport_visual_layer::vehicle, 2, 1); + assert(chained.active && chained.source.x > 0.0f && chained.source.x < 1.0f && + chained.progress == 0.0f); +} diff --git a/plugins/smooth-movement/visual_animation.h b/plugins/smooth-movement/visual_animation.h new file mode 100644 index 0000000000..53596d995f --- /dev/null +++ b/plugins/smooth-movement/visual_animation.h @@ -0,0 +1,894 @@ +// SPDX-License-Identifier: Zlib + +#ifndef SMOOTH_MOVEMENT_VISUAL_ANIMATION_H +#define SMOOTH_MOVEMENT_VISUAL_ANIMATION_H + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "CoordTemplate.h" +#include "DataDefs.h" +#include "df/coord2d.h" + +namespace df { +struct graphic_viewportst; +} + +enum class viewport_visual_layer : uint8_t { + right, + center, + left, + upright, + up, + upleft, + vehicle, + item, + designation, + count +}; + +enum class visual_render_group : uint8_t { item, vehicle, main, upper, designation, count }; + +struct visual_layer_descriptor { + viewport_visual_layer layer; + visual_render_group render_group; + bool moves_independently; + bool matches_any_previous; + DFHack::Coord2d anchor_offset; +}; + +const std::array visual_layer_descriptors = { + visual_layer_descriptor{.layer = viewport_visual_layer::right, + .render_group = visual_render_group::main, + .moves_independently = false, + .matches_any_previous = false, + .anchor_offset = {-1, 0}}, + visual_layer_descriptor{.layer = viewport_visual_layer::center, + .render_group = visual_render_group::main, + .moves_independently = true, + .matches_any_previous = false, + .anchor_offset = {0, 0}}, + visual_layer_descriptor{.layer = viewport_visual_layer::left, + .render_group = visual_render_group::main, + .moves_independently = false, + .matches_any_previous = false, + .anchor_offset = {1, 0}}, + visual_layer_descriptor{.layer = viewport_visual_layer::upright, + .render_group = visual_render_group::upper, + .moves_independently = false, + .matches_any_previous = false, + .anchor_offset = {-1, 1}}, + visual_layer_descriptor{.layer = viewport_visual_layer::up, + .render_group = visual_render_group::upper, + .moves_independently = false, + .matches_any_previous = false, + .anchor_offset = {0, 1}}, + visual_layer_descriptor{.layer = viewport_visual_layer::upleft, + .render_group = visual_render_group::upper, + .moves_independently = false, + .matches_any_previous = false, + .anchor_offset = {1, 1}}, + visual_layer_descriptor{.layer = viewport_visual_layer::vehicle, + .render_group = visual_render_group::vehicle, + .moves_independently = true, + .matches_any_previous = true, + .anchor_offset = {0, 0}}, + visual_layer_descriptor{.layer = viewport_visual_layer::item, + .render_group = visual_render_group::item, + .moves_independently = true, + .matches_any_previous = false, + .anchor_offset = {0, 0}}, + visual_layer_descriptor{.layer = viewport_visual_layer::designation, + .render_group = visual_render_group::designation, + .moves_independently = false, + .matches_any_previous = true, + .anchor_offset = {0, 0}}}; + +constexpr std::array visual_layer_draw_order = { + viewport_visual_layer::center, viewport_visual_layer::item, + viewport_visual_layer::vehicle, viewport_visual_layer::right, + viewport_visual_layer::left, viewport_visual_layer::upright, + viewport_visual_layer::up, viewport_visual_layer::upleft, + viewport_visual_layer::designation}; + +constexpr bool valid_visual_layer_draw_order() { + uint16_t layers = 0; + for (const auto layer : visual_layer_draw_order) { + const uint16_t bit = uint16_t(1U << static_cast(layer)); + if (layers & bit) + return false; + layers |= bit; + } + return layers == uint16_t((1U << static_cast(viewport_visual_layer::count)) - 1); +} + +static_assert(valid_visual_layer_draw_order()); + +inline const visual_layer_descriptor &visual_layer_descriptor(viewport_visual_layer layer) { + return visual_layer_descriptors[static_cast(layer)]; +} + +inline visual_render_group get_visual_render_group(viewport_visual_layer layer) { + return visual_layer_descriptor(layer).render_group; +} + +inline bool visual_layer_moves_independently(viewport_visual_layer layer) { + return visual_layer_descriptor(layer).moves_independently; +} + +inline bool visual_layer_tracks_own_movement(viewport_visual_layer layer) { + const auto &descriptor = visual_layer_descriptor(layer); + return descriptor.moves_independently || + descriptor.render_group == visual_render_group::designation; +} + +inline bool visual_layer_matches(viewport_visual_layer layer, int32_t current, int32_t previous) { + return visual_layer_descriptor(layer).matches_any_previous ? previous != 0 + : previous == current; +} + +struct viewport_visual_animation_input { + const df::graphic_viewportst *viewport = nullptr; + df::coord2d dimensions = df::coord2d(0, 0); + uint64_t context_revision = 0; + std::array(viewport_visual_layer::count)> current{}; + std::array(viewport_visual_layer::count)> previous{}; + // Current map-scroll offset (window_x/window_y). A pure pan does not bump + // context_revision. Only a hint: it changes at input time, the buffers shift + // on a later render frame. + df::coord2d pan = df::coord2d(0, 0); + + bool valid() const { + if (viewport == nullptr || dimensions.x <= 0 || dimensions.y <= 0) + return false; + for (size_t layer = 0; layer < current.size(); ++layer) { + if (current[layer] == nullptr || previous[layer] == nullptr) + return false; + } + return true; + } +}; + +using visual_movement_id = uint64_t; +constexpr visual_movement_id no_visual_movement = 0; + +struct visual_movement_render { + bool active = false; + DFHack::Coord2d source{0.0f, 0.0f}; + float progress = 1.0f; + bool inherited = false; + visual_movement_id movement_id = no_visual_movement; +}; + +inline float animation_progress(uint32_t now_ms, uint32_t start_time_ms, uint32_t duration_ms) { + const float linear = std::min(1.0f, float(now_ms - start_time_ms) / duration_ms); + return linear * linear * (3.0f - 2.0f * linear); +} + +inline bool visual_moved_between_tiles(viewport_visual_layer layer, const int32_t *current, + const int32_t *previous, int32_t source, int32_t target) { + return previous[target] == 0 && current[source] == 0 && + (layer == viewport_visual_layer::designation || previous[source] != 0); +} + +inline int32_t inherited_visual_source_tile(int32_t overlay_target, float center_source, + float center_target) { + return overlay_target + int32_t(std::lround(center_source - center_target)); +} + +enum class visual_facing : int8_t { east = 0, west = 1 }; + +// DF creature art faces west, so only east needs flipping. Also the default and +// cleared value. +constexpr visual_facing native_sprite_facing = visual_facing::west; + +// Sticky facing: only a horizontal component changes it. +constexpr visual_facing facing_after_move(int32_t dx, visual_facing previous) { + if (dx > 0) + return visual_facing::east; + if (dx < 0) + return visual_facing::west; + return previous; +} + +// anchor_offset.x is only -1, 0 or +1, so a creature is at most three columns wide +// here. +constexpr int32_t mirrored_tile_x(int32_t piece_x, int32_t anchor_x) { + return anchor_x - (piece_x - anchor_x); +} + +class visual_animation_manager { + struct movement { + viewport_visual_layer layer; + int32_t texpos; + DFHack::Coord2d source; + df::coord2d target; + uint32_t start_time_ms; + visual_movement_id id; + }; + + struct viewport_animation { + const df::graphic_viewportst *viewport = nullptr; + df::coord2d dimensions = df::coord2d(0, 0); + uint64_t context_revision = 0; + bool has_context = false; + bool seen = false; + std::vector movements; + // One facing per tile, not per unit: the viewport exposes one creature + // texpos per tile. + std::vector facing; + // Stationary mirrored creatures are repainted every frame; this is the + // cheap pre-check. + bool has_mirrored = false; + df::coord2d pan = df::coord2d(0, 0); + bool has_pan = false; + // Window scrolls not yet observed in the buffers, oldest first. + // A signed total would cancel on a reversing drag while both shifts are + // still owed. + std::vector pending; + // Redraws no prefix has matched. + int32_t pending_frames = 0; + // Redraws spent waiting for the buffers to move at all. + int32_t pending_age = 0; + // Redraws left in which new-movement detection stays suppressed after + // scroll activity. + int32_t suppress_frames = 0; + // Buffer contents last seen, to recognize a repeat of them. + std::optional buffer_signature; + // Set while the previous buffer still belongs to a view that has been left + // behind. + bool previous_view_stale = false; + }; + + uint32_t frame_time_ms = 0; + uint32_t frame_delta_ms = 0; + bool has_frame = false; + bool force_full_redraw = false; + std::vector viewports; + visual_movement_id next_movement_id = 1; + + visual_movement_id allocate_movement_id() { + const auto id = next_movement_id++; + if (next_movement_id == no_visual_movement) + next_movement_id = 1; + return id; + } + + // Tuned visual transition length; this is not a DF timing contract. + static constexpr uint32_t movement_duration_ms = 100; + // Scrolling faster than detection keeps up: give up rather than test ever + // more prefixes. + static constexpr size_t max_pending_shifts = 8; + // Bounds the wait on a scroll that never lands, so suppression cannot stick + // forever. These are observed recovery limits, not viewport limits. + static constexpr int32_t max_pending_age_frames = 120; + // A majority of visible, position-bearing sprites must confirm a scroll. + static constexpr double min_scroll_match_ratio = 0.5; + // Wait for buffer settling before treating changes as creature movement. + static constexpr int32_t scroll_settle_redraws = 2; + // Give up when a queued scroll does not match this many redraws. + static constexpr int32_t max_unmatched_scroll_redraws = 4; + + static void clear_pending(viewport_animation &state) { + state.pending.clear(); + state.pending_frames = 0; + state.pending_age = 0; + } + + static void abandon_pending(viewport_animation &state) { + state.movements.clear(); + clear_pending(state); + } + + static void reset_facing(viewport_animation &state) { + std::fill(state.facing.begin(), state.facing.end(), int8_t(native_sprite_facing)); + state.has_mirrored = false; + } + + static void reset_tracking(viewport_animation &state) { + abandon_pending(state); + state.suppress_frames = 0; + } + + // Identifies the buffer contents this frame, to tell a redrawn viewport from + // a repeated one. + static uint64_t compute_buffer_signature(const viewport_visual_animation_input &input) { + // FNV-1a. Only ever compared against the previous frame's value, never + // stored. + constexpr uint64_t fnv_offset_basis = 0xcbf29ce484222325ULL; + constexpr uint64_t fnv_prime = 0x100000001b3ULL; + uint64_t hash = fnv_offset_basis; + const int32_t tile_count = input.dimensions.x * input.dimensions.y; + for (size_t layer = 0; layer < input.current.size(); ++layer) { + if (!visual_layer_tracks_own_movement(static_cast(layer))) + continue; + for (int32_t i = 0; i < tile_count; ++i) { + hash = (hash ^ uint64_t(uint32_t(input.current[layer][i]))) * fnv_prime; + hash = (hash ^ uint64_t(uint32_t(input.previous[layer][i]))) * fnv_prime; + } + } + return hash; + } + + // Fraction of tracked sprites consistent with a buffer shift: + // current[x]==previous[x+dwx]. Negative when there is nothing to compare. + static double shift_match_ratio(const viewport_visual_animation_input &input, int32_t dwx, + int32_t dwy) { + int32_t considered = 0; + int32_t matches = 0; + for (size_t layer = 0; layer < input.current.size(); ++layer) { + const auto id = static_cast(layer); + if (!visual_layer_tracks_own_movement(id)) + continue; + // A layer matching any non-zero previous carries no position, so it would + // vote for every hypothesis and carry an unapplied scroll over the bar. + if (visual_layer_descriptor(id).matches_any_previous) + continue; + const int32_t *current = input.current[layer]; + const int32_t *previous = input.previous[layer]; + for (int32_t x = 0; x < input.dimensions.x; ++x) { + const int32_t sx = x + dwx; + if (sx < 0 || sx >= input.dimensions.x) + continue; + for (int32_t y = 0; y < input.dimensions.y; ++y) { + const int32_t texpos = current[x * input.dimensions.y + y]; + if (texpos == 0) + continue; + const int32_t sy = y + dwy; + if (sy < 0 || sy >= input.dimensions.y) + continue; + ++considered; + if (visual_layer_matches(id, texpos, previous[sx * input.dimensions.y + sy])) + ++matches; + } + } + } + if (considered == 0) + return -1.0; + return double(matches) / double(considered); + } + + static df::coord2d shared_movement_delta(std::span current, + std::span previous, + int32_t dim_x, int32_t dim_y) { + df::coord2d best(0, 0); + int32_t best_count = 1; + bool ambiguous = false; + for (int32_t dx = -1; dx <= 1; ++dx) { + for (int32_t dy = -1; dy <= 1; ++dy) { + if (dx == 0 && dy == 0) + continue; + int32_t count = 0; + for (int32_t x = 0; x < dim_x; ++x) { + const int32_t source_x = x + dx; + if (source_x < 0 || source_x >= dim_x) + continue; + for (int32_t y = 0; y < dim_y; ++y) { + const int32_t source_y = y + dy; + if (source_y < 0 || source_y >= dim_y) + continue; + const int32_t target = x * dim_y + y; + const int32_t texpos = current[target]; + if (texpos != 0 && previous[target] != texpos && + previous[source_x * dim_y + source_y] == texpos) + ++count; + } + } + if (count > best_count) { + best_count = count; + best = df::coord2d(dx, dy); + ambiguous = false; + } else if (count == best_count && count > 1) + ambiguous = true; + } + } + return ambiguous ? df::coord2d(0, 0) : best; + } + + viewport_animation &get_viewport(const viewport_visual_animation_input &input) { + for (viewport_animation &state : viewports) { + if (state.viewport == input.viewport) + return state; + } + viewports.emplace_back(); + viewports.back().viewport = input.viewport; + return viewports.back(); + } + + float movement_progress(uint32_t start_time_ms) const { + return animation_progress(frame_time_ms, start_time_ms, movement_duration_ms); + } + + public: + visual_animation_manager() = default; + + void begin_frame(uint32_t now_ms) { + frame_delta_ms = has_frame ? now_ms - frame_time_ms : 0; + frame_time_ms = now_ms; + has_frame = true; + force_full_redraw = false; + // Keep one final full redraw when the last movement expires. + for (viewport_animation &state : viewports) { + state.seen = false; + if (!state.movements.empty()) + force_full_redraw = true; + } + } + + void synchronize_viewport(const viewport_visual_animation_input &input) { + if (input.viewport == nullptr) + return; + viewport_animation &state = get_viewport(input); + state.seen = true; + + if (!input.valid()) { + reset_tracking(state); + state.has_context = false; + state.has_mirrored = false; + return; + } + + // Only a replaced view leaves a previous buffer belonging somewhere else; a + // first sighting does not. + const bool view_switched = + state.has_context && + (state.context_revision != input.context_revision || state.dimensions != input.dimensions); + const bool context_changed = !state.has_context || view_switched; + // The scroll delta is queued here as a hint; the buffers are + // hypothesis-tested each frame to find where it lands. Detection stays + // suppressed until then: a shifted buffer makes every panned creature look + // like a real move. + if (state.has_pan && state.pan != input.pan) { + if (state.pending.size() >= max_pending_shifts) { + // Same give-up as the other two sites: the owed shifts are unknowable + // now. + abandon_pending(state); + reset_facing(state); + } + state.pending.emplace_back(input.pan - state.pan); + state.pending_frames = 0; + state.suppress_frames = scroll_settle_redraws; + } + state.context_revision = input.context_revision; + state.dimensions = input.dimensions; + state.has_context = true; + state.pan = input.pan; + state.has_pan = true; + if (context_changed) { + state.facing.assign(size_t(input.dimensions.x) * size_t(input.dimensions.y), + int8_t(native_sprite_facing)); + state.has_mirrored = false; + } + // This hook runs per frame; the viewport is recomputed only when it + // changes, and while paused hardly at all. Re-reading a landed scroll steps + // every sprite by a tile. + const uint64_t signature = compute_buffer_signature(input); + const bool buffers_advanced = + !state.buffer_signature || *state.buffer_signature != signature; + state.buffer_signature = signature; + + if (context_changed) { + // Skips the recompute sweep, so clear has_mirrored here or a stale true + // survives. + state.has_mirrored = false; + reset_tracking(state); + // window_z, zoom and resize change at input time; the buffers cross + // later. This reset covers only the input frame, not the crossing itself. + if (view_switched) + state.previous_view_stale = true; + return; + } + + // On the crossing frame `current` is the new view and `previous` the old + // one, so a sprite on each side, a tile apart, reads as one that moved + // between them. + const bool crossed_views = buffers_advanced && state.previous_view_stale; + if (buffers_advanced) + state.previous_view_stale = false; + // The new view is drawn at the current window, so a queued scroll is + // already in it. Left queued it would never match, and suppress everything + // until it aged out. + if (crossed_views) + clear_pending(state); + + bool translated = false; + df::coord2d landed_shift(0, 0); + if (buffers_advanced && !crossed_views && !state.pending.empty()) { + // Queued scrolls land in order and may coalesce, so each hypothesis is a + // prefix. Shortest first: over-retiring leaves owed shifts to be read as + // movement. + df::coord2d landed(0, 0); + size_t landed_count = 0; + bool any_data = false; + df::coord2d shift(0, 0); + for (size_t count = 1; count <= state.pending.size(); ++count) { + shift = shift + state.pending[count - 1]; + // A prefix netting to zero is indistinguishable from "nothing landed + // yet". Accepting it would retire shifts the buffers have still to + // apply. + if (shift == df::coord2d{0, 0}) + continue; + const double ratio = shift_match_ratio(input, shift.x, shift.y); + // Emptiness is per-prefix: a long one can push every sprite out of + // range while a shorter one still has something to say. + if (ratio < 0.0) + continue; + any_data = true; + if (ratio >= min_scroll_match_ratio) { + landed = shift; + landed_count = count; + break; + } + } + if (!any_data) { + // Nothing visible to anchor the test on: nothing to animate either. + abandon_pending(state); + reset_facing(state); + } else if (landed_count > 0) { + // Re-anchor in-flight movements and drop anything scrolled off-screen. + const int32_t dwx = landed.x; + const int32_t dwy = landed.y; + state.movements.erase( + std::remove_if(state.movements.begin(), state.movements.end(), + [&](movement &movement) { + movement.source.x -= dwx; + movement.source.y -= dwy; + movement.target.x -= dwx; + movement.target.y -= dwy; + return movement.target.x < 0 || + movement.target.x >= input.dimensions.x || + movement.target.y < 0 || + movement.target.y >= input.dimensions.y; + }), + state.movements.end()); + // Facing describes creatures still on screen, so translate it rather + // than drop it. + if (state.facing.size() == + size_t(input.dimensions.x) * size_t(input.dimensions.y)) { + std::vector shifted(state.facing.size(), int8_t(native_sprite_facing)); + for (int32_t x = 0; x < input.dimensions.x; ++x) { + const int32_t sx = x + dwx; + if (sx < 0 || sx >= input.dimensions.x) + continue; + for (int32_t y = 0; y < input.dimensions.y; ++y) { + const int32_t sy = y + dwy; + if (sy < 0 || sy >= input.dimensions.y) + continue; + shifted[x * input.dimensions.y + y] = + state.facing[sx * input.dimensions.y + sy]; + } + } + state.facing.swap(shifted); + } + state.pending.erase(state.pending.begin(), + state.pending.begin() + std::ptrdiff_t(landed_count)); + state.pending_frames = 0; + state.pending_age = 0; + // The scroll is accounted for; the settle window must not block the + // rebased pass. + state.suppress_frames = 0; + landed_shift = landed; + translated = true; + } else if (shift_match_ratio(input, 0, 0) >= min_scroll_match_ratio && + ++state.pending_age <= max_pending_age_frames) { + // The buffers have not moved yet, so the scroll is still in flight. + state.pending_frames = 0; + } else if (++state.pending_frames > max_unmatched_scroll_redraws) { + // The shift never showed up recognizably: fall back to the safe reset. + abandon_pending(state); + // The delta was never identified, so the grid cannot be translated. + reset_facing(state); + // It may yet land, so do not resume detection on the very next redraw. + state.suppress_frames = scroll_settle_redraws; + } + } + + const bool suppress = !buffers_advanced || crossed_views || !state.pending.empty() || + state.suppress_frames > 0; + // The countdown measures redraws, not frames, so a repeated viewport must + // not spend it. + if (buffers_advanced && state.suppress_frames > 0) + --state.suppress_frames; + + // On the landing frame `previous` is still framed on the pre-scroll view. + // Rebasing it by the landed delta keeps a creature that walked during the + // scroll. + auto previous_layers = input.previous; + std::vector> rebased_previous; + if (translated && !suppress) { + rebased_previous.resize(input.previous.size()); + for (size_t layer = 0; layer < input.previous.size(); ++layer) { + if (!visual_layer_tracks_own_movement(static_cast(layer))) + continue; + rebased_previous[layer].assign( + size_t(input.dimensions.x) * size_t(input.dimensions.y), 0); + for (int32_t x = 0; x < input.dimensions.x; ++x) { + const int32_t sx = x + landed_shift.x; + if (sx < 0 || sx >= input.dimensions.x) + continue; + for (int32_t y = 0; y < input.dimensions.y; ++y) { + const int32_t sy = y + landed_shift.y; + if (sy < 0 || sy >= input.dimensions.y) + continue; + rebased_previous[layer][x * input.dimensions.y + y] = + input.previous[layer][sx * input.dimensions.y + sy]; + } + } + previous_layers[layer] = rebased_previous[layer].data(); + } + } + if (!suppress) { + const int32_t tile_count = input.dimensions.x * input.dimensions.y; + std::vector claimed_sources(tile_count); + const size_t existing_movement_count = state.movements.size(); + // A chained movement's source may already have been rewritten this frame. + const std::vector facing_at_frame_start = state.facing; + // Source clears are deferred until every movement this frame is + // registered. A source can be another movement's target in the same frame + // -- a chain. + std::vector facing_target_written; + std::vector pending_facing_source_clears; + if (state.facing.size() == size_t(input.dimensions.x) * size_t(input.dimensions.y)) + facing_target_written.assign(state.facing.size(), 0); + for (size_t layer = 0; layer < input.current.size(); ++layer) { + if (!visual_layer_tracks_own_movement(static_cast(layer))) + continue; + std::fill(claimed_sources.begin(), claimed_sources.end(), 0); + const int32_t *current = input.current[layer]; + const int32_t *previous = previous_layers[layer]; + const auto shared_delta = + static_cast(layer) == viewport_visual_layer::center + ? shared_movement_delta( + std::span(current, size_t(tile_count)), + std::span(previous, size_t(tile_count)), input.dimensions.x, + input.dimensions.y) + : df::coord2d(0, 0); + for (int32_t x = 0; x < input.dimensions.x; ++x) { + for (int32_t y = 0; y < input.dimensions.y; ++y) { + const int32_t target = x * input.dimensions.y + y; + const int32_t texpos = current[target]; + if (texpos == 0) + continue; + if (static_cast(layer) == + viewport_visual_layer::item && + previous_layers[static_cast(viewport_visual_layer::center)] + [target] != 0) + continue; + + int32_t source = -1; + int32_t candidate_count = 0; + if (shared_delta.x != 0 || shared_delta.y != 0) { + const int32_t source_x = x + shared_delta.x; + const int32_t source_y = y + shared_delta.y; + if (source_x >= 0 && source_x < input.dimensions.x && source_y >= 0 && + source_y < input.dimensions.y) { + const int32_t candidate = source_x * input.dimensions.y + source_y; + if (!claimed_sources[candidate] && previous[target] != texpos && + previous[candidate] == texpos) { + source = candidate; + candidate_count = 1; + } + } + } + // Otherwise require a unique same-sprite move between empty cells. + if (candidate_count == 0 && previous[target] == 0) { + for (int32_t dx = -1; dx <= 1; ++dx) { + for (int32_t dy = -1; dy <= 1; ++dy) { + if (dx == 0 && dy == 0) + continue; + const int32_t source_x = x + dx; + const int32_t source_y = y + dy; + if (source_x < 0 || source_x >= input.dimensions.x || + source_y < 0 || source_y >= input.dimensions.y) + continue; + const int32_t candidate = + source_x * input.dimensions.y + source_y; + if (!claimed_sources[candidate] && + visual_layer_matches( + static_cast(layer), texpos, + previous[candidate]) && + current[candidate] == 0) { + source = candidate; + ++candidate_count; + } + } + } + } + if (candidate_count != 1) + continue; + + claimed_sources[source] = 1; + DFHack::Coord2d visual_source{ + float(source / input.dimensions.y), float(source % input.dimensions.y)}; + for (size_t i = 0; i < existing_movement_count; ++i) { + const movement &movement = state.movements[i]; + if (movement.layer != static_cast(layer) || + movement.target.x != visual_source.x || + movement.target.y != visual_source.y) + continue; + const float progress = movement_progress(movement.start_time_ms); + visual_source = movement.source.lerp( + {float(movement.target.x), float(movement.target.y)}, progress); + break; + } + state.movements.push_back({static_cast(layer), + texpos, + visual_source, + df::coord2d(x, y), + frame_time_ms, + allocate_movement_id()}); + if (static_cast(layer) == + viewport_visual_layer::center && + state.facing.size() == + size_t(input.dimensions.x) * size_t(input.dimensions.y) && + !facing_at_frame_start.empty() && + facing_at_frame_start.size() == state.facing.size()) { + const int32_t source_tile_x = source / input.dimensions.y; + const int32_t target_index = x * input.dimensions.y + y; + state.facing[target_index] = int8_t(facing_after_move( + x - source_tile_x, + static_cast(facing_at_frame_start[source]))); + facing_target_written[size_t(target_index)] = 1; + pending_facing_source_clears.push_back(source); + } + } + } + } + // A source vacates its tile only if no movement this frame claimed it as + // a target. + if (!facing_target_written.empty()) { + for (int32_t pending_source : pending_facing_source_clears) { + if (!facing_target_written[size_t(pending_source)]) + state.facing[size_t(pending_source)] = int8_t(native_sprite_facing); + } + } + } + state.movements.erase( + std::remove_if( + state.movements.begin(), state.movements.end(), + [&](const movement &movement) { + const size_t layer = static_cast(movement.layer); + const int32_t target = + movement.target.x * input.dimensions.y + movement.target.y; + const int32_t current = input.current[layer][target]; + return frame_time_ms - movement.start_time_ms >= movement_duration_ms || + current == 0 || + !visual_layer_matches(movement.layer, current, movement.texpos); + }), + state.movements.end()); + // has_mirrored is recomputed here rather than maintained at every write + // site. + if (state.facing.size() == size_t(input.dimensions.x) * size_t(input.dimensions.y)) { + const int32_t *center_current = + input.current[static_cast(viewport_visual_layer::center)]; + bool any_mirrored = false; + for (size_t i = 0; i < state.facing.size(); ++i) { + if (center_current[i] == 0) + state.facing[i] = int8_t(native_sprite_facing); + else if (state.facing[i] != int8_t(native_sprite_facing)) + any_mirrored = true; + } + state.has_mirrored = any_mirrored; + } + if (!state.movements.empty()) + force_full_redraw = true; + } + + void end_frame() { + viewports.erase( + std::remove_if(viewports.begin(), viewports.end(), + [](const viewport_animation &state) { return !state.seen; }), + viewports.end()); + for (const viewport_animation &state : viewports) { + if (!state.movements.empty()) + force_full_redraw = true; + } + } + + uint32_t get_frame_time_ms() const { return frame_time_ms; } + + uint32_t get_frame_delta_ms() const { return frame_delta_ms; } + + visual_facing get_facing(const void *viewport, df::coord2d pos) const { + for (const viewport_animation &state : viewports) { + if (state.viewport != viewport) + continue; + if (pos.x < 0 || pos.x >= state.dimensions.x || pos.y < 0 || + pos.y >= state.dimensions.y) + break; + const size_t index = size_t(pos.x) * size_t(state.dimensions.y) + size_t(pos.y); + if (index >= state.facing.size()) + break; + return static_cast(state.facing[index]); + } + return native_sprite_facing; + } + + visual_facing get_facing(const void *viewport, int32_t x, int32_t y) const { + return get_facing(viewport, df::coord2d(x, y)); + } + + bool has_mirrored_facing(const void *viewport) const { + for (const viewport_animation &state : viewports) { + if (state.viewport != viewport) + continue; + return state.has_mirrored; + } + return false; + } + + bool requires_full_redraw() const { return force_full_redraw; } + + bool has_active_movement(const void *viewport) const { + for (const viewport_animation &state : viewports) { + if (state.viewport == viewport) + return !state.movements.empty(); + } + return false; + } + + visual_movement_render get_movement(const void *viewport, viewport_visual_layer layer, + int32_t target_x, int32_t target_y) const { + for (const viewport_animation &state : viewports) { + if (state.viewport != viewport) + continue; + const movement *companion = nullptr; + bool ambiguous = false; + const auto &descriptor = visual_layer_descriptor(layer); + const DFHack::Coord2d target(target_x, target_y); + const bool creature_fragment = descriptor.render_group == visual_render_group::main || + descriptor.render_group == visual_render_group::upper; + for (const movement &movement : state.movements) { + if (movement.layer == layer && movement.target.x == target_x && + movement.target.y == target_y) { + return {true, movement.source, movement_progress(movement.start_time_ms), false, + movement.id}; + } + if (layer == viewport_visual_layer::vehicle || + layer == viewport_visual_layer::center || + movement.layer != viewport_visual_layer::center) + continue; + if (creature_fragment) { + const DFHack::Coord2d movement_target(movement.target.x, + movement.target.y); + if (movement_target == + target + DFHack::Coord2d(descriptor.anchor_offset)) { + companion = &movement; + break; + } + continue; + } + if (std::abs(movement.target.x - target_x) > 1 || + std::abs(movement.target.y - target_y) > 1) + continue; + if (companion != nullptr && (companion->source.x - companion->target.x != + movement.source.x - movement.target.x || + companion->source.y - companion->target.y != + movement.source.y - movement.target.y || + companion->start_time_ms != movement.start_time_ms)) + ambiguous = true; + else if (companion == nullptr) + companion = &movement; + } + if (ambiguous) + return {}; + if (companion != nullptr) + return {true, + {target_x + companion->source.x - companion->target.x, + target_y + companion->source.y - companion->target.y}, + movement_progress(companion->start_time_ms), + true, + companion->id}; + break; + } + return {}; + } +}; + +#endif // SMOOTH_MOVEMENT_VISUAL_ANIMATION_H diff --git a/plugins/stockflow.cpp b/plugins/stockflow.cpp index 524bd16896..a14aafadcc 100644 --- a/plugins/stockflow.cpp +++ b/plugins/stockflow.cpp @@ -4,31 +4,22 @@ * or set AUTOENABLE to true. */ -#include "uicommon.h" #include "LuaTools.h" +#include "PluginManager.h" -#include "df/building_stockpilest.h" -#include "df/job.h" -#include "df/viewscreen_dwarfmodest.h" - -#include "modules/Gui.h" #include "modules/Maps.h" #include "modules/World.h" -using namespace DFHack; -using namespace std; +#include "df/global_objects.h" +#include "df/world.h" -using df::building_stockpilest; +using namespace DFHack; DFHACK_PLUGIN("stockflow"); #define AUTOENABLE false DFHACK_PLUGIN_IS_ENABLED(enabled); -REQUIRE_GLOBAL(gps); REQUIRE_GLOBAL(world); -REQUIRE_GLOBAL(plotinfo); - -bool fast = false; /* * Lua interface. @@ -37,50 +28,18 @@ bool fast = false; class LuaHelper { public: void cycle(color_ostream &out) { - bool found = false; - - if (fast) { - // Ignore the bookkeeper; either gather or enqueue orders every cycle. - found = !bookkeeping; - } else { - // Gather orders when the bookkeeper starts updating stockpile records, - // and enqueue them when the job is done. - for (df::job_list_link* link = &world->jobs.list; link != NULL; link = link->next) { - if (link->item == NULL) continue; - if (link->item->job_type == job_type::UpdateStockpileRecords) { - found = true; - break; - } - } - } - - if (found) { - // Entice the bookkeeper to spend less time update records. - plotinfo->nobles.bookkeeper_precision += plotinfo->nobles.bookkeeper_precision >> 3; - if (!bookkeeping) { - command_method("start_bookkeeping", out); - bookkeeping = true; - } - } else { - // Entice the bookkeeper to update records more often. - plotinfo->nobles.bookkeeper_precision -= plotinfo->nobles.bookkeeper_precision >> 5; - plotinfo->nobles.bookkeeper_cooldown -= plotinfo->nobles.bookkeeper_cooldown >> 2; - if (bookkeeping) { - command_method("finish_bookkeeping", out); - bookkeeping = false; - } - } + // Gather orders and enqueue them in a single pass. This used to be + // gated on the bookkeeper's UpdateStockpileRecords job, but that job + // does not run reliably due to a base game bug. + command_method("start_bookkeeping", out); + command_method("finish_bookkeeping", out); } void init() { - stockpile_id = -1; initialized = false; - bookkeeping = false; } bool reset(color_ostream &out, bool load) { - stockpile_id = -1; - bookkeeping = false; if (load) { return initialized = command_method("initialize_world", out); } else if (initialized) { @@ -93,122 +52,18 @@ class LuaHelper { bool command_method(const char *method, color_ostream &out) { // Calls a lua function with no parameters. - - auto L = Lua::Core::State; - Lua::StackUnwinder top(L); - - if (!lua_checkstack(L, 1)) - return false; - - if (!Lua::PushModulePublic(out, L, "plugins.stockflow", method)) - return false; - - if (!Lua::SafeCall(out, L, 0, 0)) - return false; - - return true; - } - - bool stockpile_method(const char *method, building_stockpilest *sp) { - // Combines the select_order and toggle_trigger method calls, - // because they share the same signature. - auto L = Lua::Core::State; - color_ostream_proxy out(Core::getInstance().getConsole()); - - Lua::StackUnwinder top(L); - - if (!lua_checkstack(L, 2)) - return false; - - if (!Lua::PushModulePublic(out, L, "plugins.stockflow", method)) - return false; - - Lua::Push(L, sp); - - if (!Lua::SafeCall(out, L, 1, 0)) - return false; - - // Invalidate the string cache. - stockpile_id = -1; - - return true; - } - - bool collect_settings(building_stockpilest *sp) { - // Find strings representing the job to order, and the trigger condition. - // There might be a memory leak here; C++ is odd like that. - auto L = Lua::Core::State; - color_ostream_proxy out(Core::getInstance().getConsole()); - - CoreSuspender suspend; - Lua::StackUnwinder top(L); - - if (!lua_checkstack(L, 2)) - return false; - - if (!Lua::PushModulePublic(out, L, "plugins.stockflow", "stockpile_settings")) - return false; - - Lua::Push(L, sp); - - if (!Lua::SafeCall(out, L, 1, 2)) - return false; - - if (!lua_isstring(L, -1)) - return false; - - current_trigger = lua_tostring(L, -1); - lua_pop(L, 1); - - if (!lua_isstring(L, -1)) - return false; - - current_job = lua_tostring(L, -1); - lua_pop(L, 1); - - stockpile_id = sp->id; - - return true; - } - - void draw(building_stockpilest *sp) { - if (sp->id != stockpile_id) { - if (!collect_settings(sp)) { - Core::printerr("Stockflow job collection failed!\n"); - return; - } - } - - auto dims = Gui::getDwarfmodeViewDims(); - int left_margin = dims.menu_x1 + 1; - int x = left_margin; - int y = dims.y2 - 2; // below autodump, automelt, autotrade, stocks, stockpiles - - int links = 0; - links += sp->links.give_to_pile.size(); - links += sp->links.take_from_pile.size(); - links += sp->links.give_to_workshop.size(); - links += sp->links.take_from_workshop.size(); - if (links + 12 >= y) - y += 1; - - OutputHotkeyString(x, y, current_job, "j", true, left_margin, COLOR_WHITE, COLOR_LIGHTRED); - if (*current_trigger) - OutputHotkeyString(x, y, current_trigger, " J", true, left_margin, COLOR_WHITE, COLOR_LIGHTRED); + return Lua::CallLuaModuleFunction(out, "plugins.stockflow", method); } - private: - long stockpile_id; bool initialized; - bool bookkeeping; - const char *current_job; - const char *current_trigger; }; static LuaHelper helper; -#define DELTA_TICKS 600 +// Once per in-game day, more or less. A prime number is used so that +// periodic tools don't all fire on the same tick. +constexpr auto DELTA_TICKS = 1187; DFhackCExport command_result plugin_onupdate(color_ostream &out) { if (!Maps::IsValid()) @@ -225,69 +80,7 @@ DFhackCExport command_result plugin_onupdate(color_ostream &out) { return CR_OK; } - -/* - * Interface hooks - */ -struct stockflow_hook : public df::viewscreen_dwarfmodest { - typedef df::viewscreen_dwarfmodest interpose_base; - - bool handleInput(set *input) { - if (Gui::inRenameBuilding()) - return false; - - building_stockpilest *sp = get_selected_stockpile(); - if (!sp) - return false; - - if (input->count(interface_key::CUSTOM_J)) { - // Select a new order for this stockpile. - if (!helper.stockpile_method("select_order", sp)) { - Core::printerr("Stockflow order selection failed!\n"); - } - - return true; - } else if (input->count(interface_key::CUSTOM_SHIFT_J)) { - // Toggle the order trigger for this stockpile. - if (!helper.stockpile_method("toggle_trigger", sp)) { - Core::printerr("Stockflow trigger toggle failed!\n"); - } - - return true; - } - - return false; - } - - DEFINE_VMETHOD_INTERPOSE(void, feed, (set *input)) { - if (!handleInput(input)) - INTERPOSE_NEXT(feed)(input); - } - - DEFINE_VMETHOD_INTERPOSE(void, render, ()) { - INTERPOSE_NEXT(render)(); - - building_stockpilest *sp = get_selected_stockpile(); - if (sp) - helper.draw(sp); - } -}; - -IMPLEMENT_VMETHOD_INTERPOSE(stockflow_hook, feed); -IMPLEMENT_VMETHOD_INTERPOSE(stockflow_hook, render); - - -static bool apply_hooks(color_ostream &out, bool enabling) { - if (enabling && !gps) { - out.printerr("Stockflow needs graphics.\n"); - return false; - } - - if (!INTERPOSE_HOOK(stockflow_hook, feed).apply(enabling) || !INTERPOSE_HOOK(stockflow_hook, render).apply(enabling)) { - out.printerr("Could not {} stockflow hooks!\n", enabling? "insert": "remove"); - return false; - } - +static bool apply_state(color_ostream &out, bool enabling) { if (!helper.reset(out, enabling && Maps::IsValid())) { out.printerr("Could not reset stockflow world data!\n"); return false; @@ -296,18 +89,13 @@ static bool apply_hooks(color_ostream &out, bool enabling) { return true; } -static command_result stockflow_cmd(color_ostream &out, vector & parameters) { +static command_result stockflow_cmd(color_ostream &out, std::vector & parameters) { bool desired = enabled; if (parameters.size() == 1) { if (parameters[0] == "enable" || parameters[0] == "on" || parameters[0] == "1") { desired = true; - fast = false; } else if (parameters[0] == "disable" || parameters[0] == "off" || parameters[0] == "0") { desired = false; - fast = false; - } else if (parameters[0] == "fast" || parameters[0] == "always" || parameters[0] == "2") { - desired = true; - fast = true; } else if (parameters[0] == "usage" || parameters[0] == "help" || parameters[0] == "?") { return CR_WRONG_USAGE; } else if (parameters[0] == "list") { @@ -331,12 +119,12 @@ static command_result stockflow_cmd(color_ostream &out, vector & parame } if (desired != enabled) { - if (!apply_hooks(out, desired)) { + if (!apply_state(out, desired)) { return CR_FAILURE; } } - out.print("Stockflow is {} {}{}.\n", (desired == enabled)? "currently": "now", desired? "enabled": "disabled", fast? ", in fast mode": ""); + out.print("Stockflow is {} {}.\n", (desired == enabled)? "currently": "now", desired? "enabled": "disabled"); enabled = desired; return CR_OK; } @@ -362,7 +150,7 @@ DFhackCExport command_result plugin_enable(color_ostream& out, bool enable) { /* Accept the "enable stockflow"/"disable stockflow" syntax, where available. */ /* Same as "stockflow enable"/"stockflow disable" except without the status line. */ if (enable != enabled) { - if (!apply_hooks(out, enable)) { + if (!apply_state(out, enable)) { return CR_FAILURE; } @@ -375,7 +163,7 @@ DFhackCExport command_result plugin_enable(color_ostream& out, bool enable) { DFhackCExport command_result plugin_init(color_ostream &out, std::vector &commands) { helper.init(); if (AUTOENABLE) { - if (!apply_hooks(out, true)) { + if (!apply_state(out, true)) { return CR_FAILURE; } diff --git a/plugins/stockpiles/OrganicMatLookup.cpp b/plugins/stockpiles/OrganicMatLookup.cpp index 1392e8c12c..b722883804 100644 --- a/plugins/stockpiles/OrganicMatLookup.cpp +++ b/plugins/stockpiles/OrganicMatLookup.cpp @@ -5,6 +5,7 @@ #include "df/creature_raw.h" #include "df/caste_raw.h" +#include "df/global_objects.h" #include "df/world.h" using namespace DFHack; diff --git a/plugins/stockpiles/StockpileSerializer.cpp b/plugins/stockpiles/StockpileSerializer.cpp index 46efd95044..f1b1a3bd3e 100644 --- a/plugins/stockpiles/StockpileSerializer.cpp +++ b/plugins/stockpiles/StockpileSerializer.cpp @@ -320,7 +320,7 @@ static void unserialize_list_itemdef(color_ostream& out, const char* subcat, boo } static bool serialize_list_quality(color_ostream& out, FuncWriteExport add_value, - const bool(&quality_list)[7]) { + const std::array &quality_list) { using df::enums::item_quality::item_quality; using quality_traits = df::enum_traits; @@ -337,12 +337,12 @@ static bool serialize_list_quality(color_ostream& out, FuncWriteExport add_value return all; } -static void quality_clear(bool(&pile_list)[7]) { - std::fill(pile_list, pile_list + 7, false); +static void quality_clear(std::array &pile_list) { + pile_list.fill(false); } static void unserialize_list_quality(color_ostream& out, const char* subcat, bool all, bool val, const vector& filters, - FuncReadImport read_value, int32_t list_size, bool(&pile_list)[7]) { + FuncReadImport read_value, int32_t list_size, std::array &pile_list) { if (all) { for (auto idx = 0; idx < 7; ++idx) { string id = ENUM_KEY_STR(item_quality, (df::item_quality)idx); @@ -1984,24 +1984,24 @@ void StockpileSettingsSerializer::read_gems(color_ostream& out, DeserializeMode std::bind(&StockpileSettings::gems, mBuffer), mSettings->flags.whole, mSettings->flags.mask_gems, - [&]() { + [&] () { pgems.cut_other_mats.clear(); pgems.cut_mats.clear(); pgems.rough_other_mats.clear(); pgems.rough_mats.clear(); }, - [&](bool all, char val) { - auto & bgems = mBuffer.gems(); + [&] (bool all, char val) { + auto& bgems = mBuffer.gems(); unserialize_list_material(out, "mats/rough", all, val, filters, gem_mat_is_allowed, - [&](const size_t& idx) -> const string& { return bgems.rough_mats(idx); }, + [&] (const size_t& idx) -> const string& { return bgems.rough_mats(idx); }, bgems.rough_mats_size(), pgems.rough_mats); unserialize_list_material(out, "mats/cut", all, val, filters, gem_cut_mat_is_allowed, - [&](const size_t& idx) -> const string& { return bgems.cut_mats(idx); }, + [&] (const size_t& idx) -> const string& { return bgems.cut_mats(idx); }, bgems.cut_mats_size(), pgems.cut_mats); - const size_t builtin_size = std::extentraws.mat_table.builtin)>::value; + const size_t builtin_size = world->raws.mat_table.builtin.size(); pgems.rough_other_mats.resize(builtin_size, '\0'); pgems.cut_other_mats.resize(builtin_size, '\0'); if (all) { diff --git a/plugins/stockpiles/StockpileUtils.h b/plugins/stockpiles/StockpileUtils.h index e78ffccd8c..4899001d5f 100644 --- a/plugins/stockpiles/StockpileUtils.h +++ b/plugins/stockpiles/StockpileUtils.h @@ -5,6 +5,7 @@ #include "df/world.h" #include "df/creature_raw.h" +#include "df/global_objects.h" #include "df/plant_raw.h" // Utility Functions {{{ diff --git a/plugins/stockpiles/stockpiles.cpp b/plugins/stockpiles/stockpiles.cpp index 7165449cca..f9df8d7367 100644 --- a/plugins/stockpiles/stockpiles.cpp +++ b/plugins/stockpiles/stockpiles.cpp @@ -9,6 +9,7 @@ #include "df/building.h" #include "df/building_stockpilest.h" +#include "df/global_objects.h" #include "df/hauling_route.h" #include "df/hauling_stop.h" diff --git a/plugins/stonesense b/plugins/stonesense index 50190a34de..b71216ddfc 160000 --- a/plugins/stonesense +++ b/plugins/stonesense @@ -1 +1 @@ -Subproject commit 50190a34de760732a4d8f926f28df9048fd0a5e2 +Subproject commit b71216ddfc54e20ad48fb26f9ba1d82c7656b6ec diff --git a/plugins/strangemood.cpp b/plugins/strangemood.cpp index 2ab221195d..403dfaa6e9 100644 --- a/plugins/strangemood.cpp +++ b/plugins/strangemood.cpp @@ -1,8 +1,10 @@ // Triggers a strange mood using (mostly) the same logic used in-game +// Last updated 2026/09/17 against version 53.16 #include "Console.h" #include "DataDefs.h" #include "Export.h" +#include "MiscUtils.h" #include "PluginManager.h" #include "modules/Gui.h" @@ -21,6 +23,7 @@ #include "df/entity_raw.h" #include "df/general_ref_unit_workerst.h" #include "df/historical_entity.h" +#include "df/history_event_change_hf_moodst.h" #include "df/inorganic_raw.h" #include "df/item.h" #include "df/job.h" @@ -172,7 +175,13 @@ command_result df_strangemood (color_ostream &out, vector & parameters) out.printerr("No unit id specified!\n"); return CR_WRONG_USAGE; } - unit = df::unit::find(std::stoi(parameters[i])); + int id = string_to_int(parameters[i], -1); + if (id < 0) + { + out.printerr("Invalid unit id: '{}'\n", parameters[i]); + return CR_WRONG_USAGE; + } + unit = df::unit::find(id); if (!unit) return CR_FAILURE; } @@ -500,6 +509,32 @@ command_result df_strangemood (color_ostream &out, vector & parameters) unit->mood = type; unit->mood_copy = unit->mood; Gui::showAutoAnnouncement(announcement_type::STRANGE_MOOD, unit->pos, msg, color, bright); + unit->create_nemesis(0, 1, false); + + if (df::global::hist_event_next_id && df::global::cur_year && df::global::cur_year_tick) + { + auto histevent = df::allocate(); + if (!world->worldgen.worldgen_parms.reveal_all_history) + histevent->flags.set(df::history_event_flags::hidden); + histevent->id = (*df::global::hist_event_next_id)++; + histevent->reason = df::history_event_reason::none; + histevent->histfig = unit->hist_figure_id; + histevent->mood = unit->mood; + histevent->site = plotinfo->site_id; + if (auto cur_site = df::world_site::find(plotinfo->site_id)) + { + histevent->region_pos.x = cur_site->pos.x; + histevent->region_pos.y = cur_site->pos.y; + } + histevent->region = -1; + histevent->layer = -1; + histevent->year = *df::global::cur_year; + histevent->seconds = *df::global::cur_year_tick; + insert_into_vector(world->history.events, &df::history_event::id, (df::history_event *)histevent); + // TODO: add to active history event collections if necessary + // TODO: add to active mission report if necessary + histevent->add_to_classes(); + } // TODO: make sure unit drops any wrestle items unit->job.mood_timeout = 50000; @@ -697,7 +732,7 @@ command_result df_strangemood (color_ostream &out, vector & parameters) filter = NULL; continue; } - if (filter->getMaterial() != 0) + if (filter->getMaterial() != builtin_mats::INORGANIC) { filter = NULL; continue; @@ -708,7 +743,7 @@ command_result df_strangemood (color_ostream &out, vector & parameters) continue; } MaterialInfo mat(filter->getMaterial(), filter->getMaterialIndex()); - if (!mat.inorganic->flags.is_set(inorganic_flags::DEEP_SPECIAL)) + if (!mat.inorganic || !mat.inorganic->flags.is_set(inorganic_flags::DEEP_SPECIAL)) { filter = NULL; continue; @@ -719,8 +754,8 @@ command_result df_strangemood (color_ostream &out, vector & parameters) { job->job_items.elements.push_back(item = new df::job_item()); item->item_type = item_type::CLOTH; - item->mat_type = filter->getMaterial(); - item->mat_index = filter->getMaterialIndex(); + item->mat_type = filter->getActualMaterial(); + item->mat_index = filter->getActualMaterialIndex(); item->quantity = base_item_count * 10000; item->min_dimension = 10000; } @@ -784,13 +819,18 @@ command_result df_strangemood (color_ostream &out, vector & parameters) filter = NULL; continue; } - if (filter->getMaterial() != 0) + if (filter->getMaterial() != builtin_mats::INORGANIC) { filter = NULL; continue; } MaterialInfo mat(filter->getMaterial(), filter->getMaterialIndex()); - if (!mat.inorganic->flags.is_set(inorganic_flags::DEEP_SPECIAL)) + if (!mat.material || !mat.material->flags.is_set(material_flags::IS_METAL)) + { + filter = NULL; + continue; + } + if (!mat.inorganic || !mat.inorganic->flags.is_set(inorganic_flags::DEEP_SPECIAL)) { filter = NULL; continue; @@ -801,8 +841,8 @@ command_result df_strangemood (color_ostream &out, vector & parameters) { job->job_items.elements.push_back(item = new df::job_item()); item->item_type = item_type::BAR; - item->mat_type = filter->getMaterial(); - item->mat_index = filter->getMaterialIndex(); + item->mat_type = filter->getActualMaterial(); + item->mat_index = filter->getActualMaterialIndex(); item->quantity = base_item_count * 150; // BUGFIX - the game does not adjust here! item->min_dimension = 150; } @@ -810,7 +850,7 @@ command_result df_strangemood (color_ostream &out, vector & parameters) { job->job_items.elements.push_back(item = new df::job_item()); item->item_type = item_type::BAR; - item->mat_type = 0; + item->mat_type = builtin_mats::INORGANIC; vector mats; if (soul) { @@ -819,8 +859,12 @@ command_result df_strangemood (color_ostream &out, vector & parameters) df::unit_preference *pref = soul->preferences[i]; if (pref->flags.bits.visible && pref->type == df::unitpref_type::LikeMaterial && - pref->mattype == 0 && getCreatedMetalBars(pref->matindex) > 0) - mats.push_back(pref->matindex); + pref->mattype == builtin_mats::INORGANIC) + { + MaterialInfo mat(pref->mattype, pref->matindex); + if (mat.material && mat.material->flags.is_set(material_flags::IS_METAL) && getCreatedMetalBars(pref->matindex)) + mats.push_back(pref->matindex); + } } } if (mats.size()) @@ -834,7 +878,7 @@ command_result df_strangemood (color_ostream &out, vector & parameters) case job_skill::ENCRUSTGEM: job->job_items.elements.push_back(item = new df::job_item()); item->item_type = item_type::ROUGH; - item->mat_type = 0; + item->mat_type = builtin_mats::INORGANIC; item->quantity = base_item_count; break; @@ -1081,15 +1125,23 @@ command_result df_strangemood (color_ostream &out, vector & parameters) item->mat_type = mat_type; item->flags2.whole = flags2.whole; item->quantity = 1; - if (item_type == item_type::BAR) + switch(item_type) { - item->quantity *= 150; // BUGFIX - the game does not adjust here! + case item_type::BAR: item->min_dimension = 150; - } - if (item_type == item_type::CLOTH) - { - item->quantity *= 10000; // BUGFIX - the game does not adjust here! + item->quantity *= 150; // BUGFIX - the game does not adjust here! + break; + case item_type::THREAD: // yes, the game actually checks this item type + item->min_dimension = 15000; + item->quantity *= 15000; // but it doesn't adjust quantity + break; + case item_type::CLOTH: item->min_dimension = 10000; + item->quantity *= 10000; // BUGFIX - the game does not adjust here! + break; + default: + // no adjustment + break; } } } diff --git a/plugins/suspendmanager.cpp b/plugins/suspendmanager.cpp index 13a37d1901..1560409af9 100644 --- a/plugins/suspendmanager.cpp +++ b/plugins/suspendmanager.cpp @@ -16,6 +16,9 @@ #include "df/item.h" #include "df/job.h" #include "df/job_item_ref.h" +#include "df/machine.h" +#include "df/machine_info.h" +#include "df/machine_nodest.h" #include "df/map_block.h" #include "df/tile_designation.h" #include "df/tile_occupancy.h" @@ -23,10 +26,12 @@ #include #include +#include #include #include #include #include +#include #include using std::string; @@ -79,6 +84,8 @@ enum Reason { UNSUPPORTED = 6, // Has an unmovable item on top of the building job ITEM_IN_JOB = 7, + // Removing the building would orphan machine components + DANGER_OF_COLLAPSE = 8, }; inline bool isExternalReason(Reason reason) { @@ -101,6 +108,8 @@ static string reasonToString(Reason reason) { return "Would collapse immediately"; case Reason::ITEM_IN_JOB: return "Blocked by an unmovable item"; + case Reason::DANGER_OF_COLLAPSE: + return "Would collapse machinery"; default: return "External reason"; } @@ -541,6 +550,73 @@ class SuspendManager { return true; } + // A machine node is anchored only if its entire footprint sits on + // non-open tiles. Nodes hanging over open space survive only through + // graph connectivity to an anchored node; the stability pass destroys + // any connected component with no anchor (verified in-game). + static bool isAnchoredToTerrain(df::building *bld) { + for (auto x = bld->x1; x <= bld->x2; ++x) { + for (auto y = bld->y1; y <= bld->y2; ++y) { + auto tile_type = Maps::getTileType(x, y, bld->z); + if (!tile_type || tileShape(*tile_type) == df::enums::tiletype_shape::EMPTY) + return false; + } + } + return true; + } + + // Would removing this building's node leave a connected component of + // its machine with no anchored node? + static bool wouldOrphanMachine(df::building *bld) { + auto machine_info = bld->getMachineInfo(); + if (!machine_info) + return false; + auto machine = df::machine::find(machine_info->machine_id); + if (!machine) + return false; + + auto &nodes = machine->components; + int target = -1; + for (size_t i = 0; i < nodes.size(); ++i) { + if (nodes[i] && nodes[i]->building_id == bld->id) { + target = i; + break; + } + } + if (target < 0 || nodes.size() == 1) + return false; + + // union-find over the remaining nodes + vector parent(nodes.size()); + std::iota(parent.begin(), parent.end(), 0); + std::function root = [&](int i) { + while (parent[i] != i) { + parent[i] = parent[parent[i]]; + i = parent[i]; + } + return i; + }; + for (size_t i = 0; i < nodes.size(); ++i) { + if ((int)i == target || !nodes[i]) + continue; + for (auto c : nodes[i]->connections) { + if (c < 0 || c >= (int)nodes.size() || c == target || !nodes[c]) + continue; + parent[root(i)] = root(c); + } + } + + std::unordered_map anchored; + for (size_t i = 0; i < nodes.size(); ++i) { + if ((int)i == target || !nodes[i]) + continue; + auto other = df::building::find(nodes[i]->building_id); + int r = root(i); + anchored[r] = anchored[r] || (other && isAnchoredToTerrain(other)); + } + return std::ranges::any_of(anchored, [](auto &p) { return !p.second; }); + } + void suspendBuilding(df::building *building, Reason reason){ for (auto job : building->jobs) if (job->job_type == df::job_type::ConstructBuilding) @@ -657,6 +733,13 @@ class SuspendManager { // check carving/detailing jobs and suspend buildings over them preserveDesigations(job); + if (job->job_type == df::job_type::DestroyBuilding) { + auto building = Job::getHolder(job); + if (building && wouldOrphanMachine(building)) + suspensions[job->id] = Reason::DANGER_OF_COLLAPSE; + continue; + } + // remaining checks only apply to construction jobs if (!isConstructionJob(job)) continue; @@ -708,7 +791,7 @@ class SuspendManager { Reason reason; - for (auto job : df::global::world->jobs.list | std::views::filter(isConstructionJob)) + for (auto job : df::global::world->jobs.list | std::views::filter(isSuspendableJob)) { if (job->flags.bits.suspend && !suspensions.contains(job->id)) { unsuspend(job); // suspended for no reason @@ -728,6 +811,10 @@ class SuspendManager { return job->job_type == job_type::ConstructBuilding; } + static bool isSuspendableJob(df::job *job) { + return isConstructionJob(job) || job->job_type == job_type::DestroyBuilding; + } + bool keptSuspended(df::job *job) { Reason reason; if (tryGetReason(job,reason) && !isExternalReason(reason)) @@ -890,25 +977,20 @@ static command_result do_command(color_ostream &out, vector ¶meters) return plugin_enable(out,true); } else if (parameters[0] == "disable") { return plugin_enable(out,false); - } else if (parameters[0] == "set" && parameters[1] == "preventblocking") { + } else if (parameters.size() == 3 && parameters[0] == "set" && parameters[1] == "preventblocking") { if (parameters[2] == "true") { suspendmanager_instance->prevent_blocking = true; config.set_bool(CONFIG_PREVENT_BLOCKING, true); - if (is_enabled) { - do_cycle(out); - out.print("{}", suspendmanager_instance->getStatus(out)); - } - return CR_OK; } else if (parameters[2] == "false") { suspendmanager_instance->prevent_blocking = false; config.set_bool(CONFIG_PREVENT_BLOCKING, false); - if (is_enabled) { - do_cycle(out); - out.print("{}", suspendmanager_instance->getStatus(out)); - } - return CR_OK; } else return CR_WRONG_USAGE; + if (is_enabled) { + do_cycle(out); + out.print("{}", suspendmanager_instance->getStatus(out)); + } + return CR_OK; } else { return CR_WRONG_USAGE; } diff --git a/plugins/tailor.cpp b/plugins/tailor.cpp index efd9044024..1018999ec4 100644 --- a/plugins/tailor.cpp +++ b/plugins/tailor.cpp @@ -663,6 +663,7 @@ class Tailor { order->amount_total = c; order->status.bits.validated = false; order->status.bits.active = false; + order->frequency = df::workquota_frequency_type::OneTime; order->id = world->manager_orders.manager_order_next_id++; world->manager_orders.all.push_back(order); diff --git a/plugins/tiletypes.cpp b/plugins/tiletypes.cpp index 8c8407d189..c0b30639a9 100644 --- a/plugins/tiletypes.cpp +++ b/plugins/tiletypes.cpp @@ -45,6 +45,11 @@ using std::set; #include "modules/MapCache.h" #include "modules/Maps.h" +#include "df/coord2d.h" +#include "df/map_block_column.h" +#include "df/plant.h" +#include "df/plant_tree_info.h" +#include "df/plant_type.h" #include "df/tile_dig_designation.h" #include "df/world.h" @@ -68,9 +73,9 @@ struct tiletypes_options { static struct_identity _identity; }; static const struct_field_info tiletypes_options_fields[] = { - { struct_field_info::PRIMITIVE, "help", offsetof(tiletypes_options, help), &df::identity_traits::identity, 0, 0 }, - { struct_field_info::PRIMITIVE, "quiet", offsetof(tiletypes_options, quiet), &df::identity_traits::identity, 0, 0 }, - { struct_field_info::SUBSTRUCT, "cursor", offsetof(tiletypes_options, cursor), &df::coord::_identity, 0, 0 }, + { struct_field_info::PRIMITIVE, "help", offsetof(tiletypes_options, help), &df::identity_traits::identity, 0, 0 }, + { struct_field_info::PRIMITIVE, "quiet", offsetof(tiletypes_options, quiet), &df::identity_traits::identity, 0, 0 }, + { struct_field_info::SUBSTRUCT, "cursor", offsetof(tiletypes_options, cursor), df::identity_traits::get(), 0, 0}, { struct_field_info::END } }; struct_identity tiletypes_options::_identity(sizeof(tiletypes_options), &df::allocator_fn, NULL, "tiletypes_options", NULL, tiletypes_options_fields); @@ -82,7 +87,8 @@ static const std::map, df::til }; static const uint16_t UNDERGROUND_TEMP = 10015; -static const char * HISTORY_FILE = "dfhack-config/tiletypes.history"; +static const std::filesystem::path get_history_file() { return Core::getInstance().getConfigPath() / "tiletypes.history"; } + CommandHistory tiletypes_hist; command_result df_tiletypes (color_ostream &out, vector & parameters); @@ -92,7 +98,7 @@ command_result df_tiletypes_here_point (color_ostream &out, vector & pa DFhackCExport command_result plugin_init ( color_ostream &out, std::vector &commands) { - tiletypes_hist.load(HISTORY_FILE); + tiletypes_hist.load(get_history_file()); commands.push_back(PluginCommand("tiletypes", "Paints tiles of specified types onto the map.", df_tiletypes, true, true)); commands.push_back(PluginCommand("tiletypes-command", "Run tiletypes commands (seperated by ' ; ')", df_tiletypes_command)); commands.push_back(PluginCommand("tiletypes-here", "Repeat tiletypes command at cursor (with brush)", df_tiletypes_here)); @@ -102,7 +108,7 @@ DFhackCExport command_result plugin_init ( color_ostream &out, std::vector tree_info) // grown tree; pos is the trunk base + return material == tiletype_material::TREE + || material == tiletype_material::ROOT + || material == tiletype_material::MUSHROOM; + if (plant->type == plant_type::DRY_TREE || plant->type == plant_type::WET_TREE) + // unsprouted tree; lives on a ground tile until it grows + return shape != tiletype_shape::NONE && shape != tiletype_shape::EMPTY + && material != tiletype_material::AIR; + // shrubs and saplings + return shape == tiletype_shape::SHRUB || shape == tiletype_shape::SAPLING; +} + +// delete a plant record, including tree_info for grown trees; the tree's +// remaining tiles are left as-is for the user to paint over or collapse +static void removePlant(df::plant *plant) +{ + vector *vec = nullptr; + switch (plant->type) + { // Remove from the per-type vector + case plant_type::DRY_TREE: vec = &world->plants.tree_dry; break; + case plant_type::WET_TREE: vec = &world->plants.tree_wet; break; + case plant_type::DRY_PLANT: vec = &world->plants.shrub_dry; break; + case plant_type::WET_PLANT: vec = &world->plants.shrub_wet; break; + } + if (vec) + std::erase(*vec, plant); + + auto col = Maps::getBlockColumn((plant->pos.x / 48) * 3, (plant->pos.y / 48) * 3); + if (col) + std::erase(col->plants, plant); + + if (plant->tree_info) + { + delete plant->tree_info->body; + delete plant->tree_info->roots; + delete plant->tree_info; + } + + std::erase(world->plants.all, plant); + delete plant; +} + +// remove the plant rooted at pos if the painted tile can no longer host it +static void removePlantIfUnsupported(const df::coord &pos) +{ + auto plant = Maps::getPlantAtTile(pos); + if (!plant) + return; + auto tt = Maps::getTileType(pos); + if (!tt || !plantTileSupported(plant, *tt)) + removePlant(plant); +} + static bool paintTileProcessing(MapExtras::Block* block, const df::coord2d& blockPos, const TileType& target) { df::tiletype source = block->tiletypeAt(blockPos); df::tile_designation des = block->DesignationAt(blockPos); @@ -1068,6 +1132,17 @@ static PaintResult paintArea(MapExtras::MapCache& map, const df::coord& pos1, co else if (target.aquifer > 0) Maps::setAreaAquifer(pos1, pos2, target.aquifer == 2, filter); + df::coord lo(std::min(pos1.x, pos2.x), std::min(pos1.y, pos2.y), std::min(pos1.z, pos2.z)); + df::coord hi(std::max(pos1.x, pos2.x), std::max(pos1.y, pos2.y), std::max(pos1.z, pos2.z)); + for (int16_t z = lo.z; z <= hi.z; z++) + for (int16_t x = lo.x; x <= hi.x; x++) + for (int16_t y = lo.y; y <= hi.y; y++) + { + df::coord pos(x, y, z); + if (filter(pos, nullptr)) + removePlantIfUnsupported(pos); + } + // force the game to recompute its walkability cache on the next tick world->reindex_pathfinding = true; } @@ -1114,6 +1189,8 @@ static PaintResult paintTile(MapExtras::MapCache &map, const df::coord &pos, else if (target.aquifer > 0) Maps::setTileAquifer(pos, target.aquifer == 2); + removePlantIfUnsupported(pos); + // force the game to recompute its walkability cache on the next tick world->reindex_pathfinding = true; } diff --git a/plugins/timestream.cpp b/plugins/timestream.cpp index deb6c45e4d..2742416760 100644 --- a/plugins/timestream.cpp +++ b/plugins/timestream.cpp @@ -33,12 +33,18 @@ #include "df/building_nest_boxst.h" #include "df/building_trapst.h" #include "df/buildingitemst.h" +#include "df/caravan_state.h" +#include "df/flow_info.h" +#include "df/flow_type.h" #include "df/init.h" #include "df/item_eggst.h" +#include "df/plotinfost.h" #include "df/unit.h" #include "df/world.h" #include +#include +#include using std::string; using std::vector; @@ -52,6 +58,8 @@ REQUIRE_GLOBAL(cur_year_tick); REQUIRE_GLOBAL(cur_year_tick_advmode); REQUIRE_GLOBAL(init); REQUIRE_GLOBAL(world); +REQUIRE_GLOBAL(flows); +REQUIRE_GLOBAL(plotinfo); namespace DFHack { // for configuration-related logging @@ -314,12 +322,100 @@ static int32_t clamp_coverage(int32_t timeskip) { return timeskip; } +using df_tick_type = std::remove_reference_t; + +static df_tick_type flow_next_required_tick(int flow_index) +{ + using namespace df::enums::flow_type; + + const auto flow = (*flows)[flow_index]; + + if (flow == nullptr || flow->flags.bits.DEAD) + return std::numeric_limits::max(); + + const auto cur_tick = *cur_year_tick; + + struct update_parameters_t { + int speed; + int cycle; + }; + + const auto [speed, cycle] = [flow] () -> update_parameters_t { + switch (flow->type) + { + case ItemCloud: + case MaterialDust: + case MaterialGas: + case MaterialVapor: + if (flow->flags.bits.CREEPING) + return update_parameters_t{10, 100}; + else + return update_parameters_t{1, 5}; + case Dragonfire: + case Fire: + case Web: + return update_parameters_t{1, 3}; + default: + return update_parameters_t{10, 100}; + } + }(); + + const int stride = cycle / speed; + + const int phase = (flow_index % stride) * speed; + + const int cur_phase = cur_tick % cycle; + + return cur_tick + (phase - cur_phase) + ((cur_phase > phase) ? cycle : 0); +} + +static df_tick_type flows_next_required_tick() { + if (flows == nullptr || flows->empty()) + return std::numeric_limits::max(); + + auto flow_indices = std::views::iota(0, (int)flows->size()); + auto next_flow = std::ranges::min(flow_indices, {}, flow_next_required_tick); + + return flow_next_required_tick(next_flow); +} + +static bool detect_caravans() +{ + auto& caravans = df::global::plotinfo->caravans; + return std::any_of(caravans.begin(), caravans.end(), [](auto caravan) { + if (caravan->trade_state != df::caravan_state::T_trade_state::AtDepot) + return false; + auto car_civ = caravan->entity; + auto& units = world->units.active; + return std::any_of(units.begin(), units.end(), [car_civ] (auto un) { + return (un->civ_id == car_civ + && DFHack::Units::isMerchant(un) + && std::any_of(un->inventory.begin(), un->inventory.end(), [] (auto inv_item) { + return inv_item->item && inv_item->item->flags.bits.trader; + })); + }); + }); +} + +static bool breathing_difficulties() +{ + auto& units = df::global::world->units.all; + return std::any_of(units.begin(), units.end(), [] (auto u) { return Units::getBreathingState(u) != Units::breathing_state::FINE; }); +} + static int32_t clamp_timeskip(int32_t timeskip) { if (timeskip <= 0) return 0; + // timeskip cannot be applied when caravans are loading/unloading because we don't know how to jog that timer + if (detect_caravans()) + return 0; + // timeskip cannot be applied when any unit has breathing difficulties + if (breathing_difficulties()) + return 0; int32_t next_tick = *cur_year_tick + 1; timeskip = std::min(timeskip, get_next_trigger_year_tick(next_tick) - next_tick); timeskip = std::min(timeskip, get_next_birthday(next_tick) - next_tick); + timeskip = std::min(timeskip, flows_next_required_tick() - next_tick); return clamp_coverage(timeskip); } @@ -331,22 +427,21 @@ static void increment_counter(T *obj, FT T::*field, int32_t timeskip) { } template -static void decrement_counter(T *obj, FT T::*field, int32_t timeskip) { +static void decrement_counter(T *obj, FT T::*field, int32_t timeskip, int32_t floor = 1) { if (obj->*field <= 0) return; // TODO: check for overflow/underflow int32_t cur_val = static_cast(obj->*field); - obj->*field = static_cast(std::max(1, cur_val - timeskip)); + obj->*field = static_cast(std::max(floor, cur_val - timeskip)); } static void adjust_unit_counters(df::unit * unit, int32_t timeskip) { auto * c1 = &unit->counters; + decrement_counter(c1, &df::unit::T_counters::job_counter, timeskip); decrement_counter(c1, &df::unit::T_counters::swap_counter, timeskip); - decrement_counter(c1, &df::unit::T_counters::winded, timeskip); decrement_counter(c1, &df::unit::T_counters::stunned, timeskip); - decrement_counter(c1, &df::unit::T_counters::unconscious, timeskip); - decrement_counter(c1, &df::unit::T_counters::suffocation, timeskip); + decrement_counter(c1, &df::unit::T_counters::unconscious, timeskip, 2); decrement_counter(c1, &df::unit::T_counters::webbed, timeskip); decrement_counter(c1, &df::unit::T_counters::soldier_mood_countdown, timeskip); decrement_counter(c1, &df::unit::T_counters::pain, timeskip); diff --git a/scripts b/scripts index ff1b95a7b4..d8685b1536 160000 --- a/scripts +++ b/scripts @@ -1 +1 @@ -Subproject commit ff1b95a7b4e97be9a94218162c099dd95eaf4680 +Subproject commit d8685b153603aa39c03ad2500f4b0419b427804f diff --git a/test/library/class_extended.lua b/test/library/class_extended.lua new file mode 100644 index 0000000000..a0c6db5338 --- /dev/null +++ b/test/library/class_extended.lua @@ -0,0 +1,296 @@ +config.target = 'core' + +function test.defclass_basic() + -- Test basic class creation + local MyClass = defclass(nil) + expect.eq(MyClass.__index, MyClass) + expect.table_eq(MyClass.ATTRS, {}) + expect.eq(MyClass.super, nil) + expect.ne(getmetatable(MyClass), nil) +end + +function test.defclass_with_parent() + -- Test class creation with parent + local ParentClass = defclass(nil) + local ChildClass = defclass(nil, ParentClass) + + expect.eq(ChildClass.super, ParentClass) + expect.eq(getmetatable(ChildClass).__index, ParentClass) +end + +function test.defclass_attrs() + -- Test class with ATTRS + local MyClass = defclass(nil) + MyClass.ATTRS { + attr1 = 'default1', + attr2 = 'default2', + } + + expect.eq(MyClass.ATTRS.attr1, 'default1') + expect.eq(MyClass.ATTRS.attr2, 'default2') +end + +function test.mkinstance() + -- Test basic instance creation + local MyClass = defclass(nil) + local instance = mkinstance(MyClass, {value = 100}) + + expect.eq(instance.value, 100) + expect.eq(getmetatable(instance), MyClass) +end + +function test.instance_inheritance() + -- Test instance method inheritance + local ParentClass = defclass(nil) + function ParentClass:parent_method() + return 'parent' + end + + local ChildClass = defclass(nil, ParentClass) + function ChildClass:child_method() + return 'child' + end + + local instance = ChildClass({}) + expect.eq(instance:parent_method(), 'parent') + expect.eq(instance:child_method(), 'child') +end + +function test.init_method() + -- Test init method + local MyClass = defclass(nil) + MyClass.ATTRS { + value = 0, + } + + function MyClass:init(init_table) + self.value = (init_table.value or 0) * 2 + end + + local instance = MyClass({value = 5}) + expect.eq(instance.value, 10) +end + +function test.preinit_postinit() + -- Test preinit and postinit methods + local call_order = {} + + local MyClass = defclass(nil) + function MyClass:preinit(init_table) + table.insert(call_order, 'preinit') + end + + function MyClass:init(init_table) + table.insert(call_order, 'init') + end + + function MyClass:postinit(init_table) + table.insert(call_order, 'postinit') + end + + MyClass({}) + + expect.eq(#call_order, 3) + expect.eq(call_order[1], 'preinit') + expect.eq(call_order[2], 'init') + expect.eq(call_order[3], 'postinit') +end + +function test.inheritance_init_order() + -- Test init order with inheritance + local call_order = {} + + local ParentClass = defclass(nil) + function ParentClass:preinit(init_table) + table.insert(call_order, 'parent_preinit') + end + + function ParentClass:init(init_table) + table.insert(call_order, 'parent_init') + end + + function ParentClass:postinit(init_table) + table.insert(call_order, 'parent_postinit') + end + + local ChildClass = defclass(nil, ParentClass) + function ChildClass:preinit(init_table) + table.insert(call_order, 'child_preinit') + end + + function ChildClass:init(init_table) + table.insert(call_order, 'child_init') + end + + function ChildClass:postinit(init_table) + table.insert(call_order, 'child_postinit') + end + + ChildClass({}) + + expect.eq(#call_order, 6) + expect.eq(call_order[1], 'child_preinit') + expect.eq(call_order[2], 'parent_preinit') + expect.eq(call_order[3], 'parent_init') + expect.eq(call_order[4], 'child_init') + expect.eq(call_order[5], 'parent_postinit') + expect.eq(call_order[6], 'child_postinit') +end + +function test.callback_method() + -- Test callback method + local MyClass = defclass(nil) + function MyClass:test_method(arg) + return arg * 2 + end + + local instance = MyClass({}) + local cb = instance:callback('test_method') + + expect.eq(cb(5), 10) +end + +function test.cb_getfield() + -- Test cb_getfield method + local MyClass = defclass(nil) + local instance = MyClass() + instance.value = 42 + + local getter = instance:cb_getfield('value') + expect.eq(getter(), 42) +end + +function test.cb_setfield() + -- Test cb_setfield method + local MyClass = defclass(nil) + local instance = MyClass() + instance.value = 42 + + local setter = instance:cb_setfield('value') + setter(100) + expect.eq(instance.value, 100) +end + +function test.assign_method() + -- Test assign method + local MyClass = defclass(nil) + local instance = MyClass({value = 1}) + + instance:assign({value = 2, new_field = 3}) + expect.eq(instance.value, 2) + expect.eq(instance.new_field, 3) +end + +-- function test.invoke_before() +-- -- Test invoke_before method +-- local call_order = {} + +-- local MyClass = defclass(nil) +-- function MyClass:test_method() +-- table.insert(call_order, 'original') +-- end + +-- function MyClass:invoke_before_test_method() +-- table.insert(call_order, 'before') +-- end + +-- local instance = MyClass({}) +-- instance:invoke_before('test_method') + +-- expect.eq(#call_order, 2) +-- expect.eq(call_order[1], 'before') +-- expect.eq(call_order[2], 'original') +-- end + +-- function test.invoke_after() +-- -- Test invoke_after method +-- local call_order = {} + +-- local MyClass = defclass(nil) +-- function MyClass:test_method() +-- table.insert(call_order, 'original') +-- end + +-- function MyClass:invoke_after_test_method() +-- table.insert(call_order, 'after') +-- end + +-- local instance = MyClass() +-- instance:invoke_after('test_method') + +-- expect.eq(#call_order, 2) +-- expect.eq(call_order[1], 'original') +-- expect.eq(call_order[2], 'after') +-- end + +function test.attrs_meta() + -- Test ATTRS metatable behavior + local MyClass = defclass(nil) + MyClass.ATTRS { + attr1 = 'value1', + attr2 = 'value2', + } + + -- Test that ATTRS can be called to add attributes + MyClass.ATTRS { + attr3 = 'value3', + } + + expect.eq(MyClass.ATTRS.attr1, 'value1') + expect.eq(MyClass.ATTRS.attr2, 'value2') + expect.eq(MyClass.ATTRS.attr3, 'value3') +end + +function test.default_nil() + -- Test DEFAULT_NIL behavior + local MyClass = defclass(nil) + MyClass.ATTRS { + optional = DEFAULT_NIL, + required = 'default', + } + + local instance1 = MyClass({}) + expect.eq(instance1.optional, nil) + expect.eq(instance1.required, 'default') + + local instance2 = MyClass({optional = 'provided'}) + expect.eq(instance2.optional, 'provided') + expect.eq(instance2.required, 'default') +end + +function test.class_reload() + -- Test that classes can be reloaded + local MyClass = defclass(nil) + MyClass.ATTRS { + version = 1, + } + + -- Simulate reload by updating ATTRS + MyClass.ATTRS { + version = 2, + new_attr = 'new', + } + + expect.eq(MyClass.ATTRS.version, 2) + expect.eq(MyClass.ATTRS.new_attr, 'new') +end + +function test.instance_field_access() + -- Test instance field access patterns + local MyClass = defclass(nil) + MyClass.ATTRS { + public_field = 'public', + } + + function MyClass:init(init_table) + self.private_field = 'private' + end + + local instance = MyClass({}) + expect.eq(instance.public_field, 'public') + expect.eq(instance.private_field, 'private') + + -- Test setting new fields + instance.dynamic_field = 'dynamic' + expect.eq(instance.dynamic_field, 'dynamic') +end diff --git a/test/library/dfhack_core.lua b/test/library/dfhack_core.lua new file mode 100644 index 0000000000..cdcadc4dce --- /dev/null +++ b/test/library/dfhack_core.lua @@ -0,0 +1,217 @@ +config.target = 'core' + +function test.safe_index() + -- Test basic indexing + local t = {a = 1, b = {c = 2}} + expect.eq(safe_index(t, 'a'), 1) + expect.eq(safe_index(t, 'b', 'c'), 2) + + -- Test nil handling + expect.eq(safe_index(nil, 'a'), nil) + expect.eq(safe_index(t, 'nonexistent'), nil) + expect.eq(safe_index(t, 'b', 'nonexistent'), nil) + + -- Test nested nil handling + expect.eq(safe_index(t, 'b', 'c', 'd'), nil) +end + +local function test_ensure_key() + local t = {} + + -- Test creating new key + local result = ensure_key(t, 'new_key') + expect.table_eq(result, {}) + expect.table_eq(t.new_key, {}) + + -- Test existing key + t.existing = {value = 1} + result = ensure_key(t, 'existing') + expect.eq(result, t.existing) + expect.eq(t.existing.value, 1) + + -- Test with default value + result = ensure_key(t, 'with_default', {default = true}) + expect.table_eq(result, {default = true}) +end + +function test.ensure_key() + test_ensure_key() +end + +-- local function test_ensure_keys() +-- local t = {} + +-- -- Test creating nested keys +-- local result = ensure_keys(t, 'level1', 'level2', 'level3') +-- expect.eq(result, t.level1.level2.level3) +-- expect.table_eq(t.level1, {}) +-- expect.table_eq(t.level1.level2, {}) +-- expect.table_eq(t.level1.level2.level3, {}) + +-- -- Test partial existing path +-- t.level1.level2.level3 = {existing = true} +-- result = ensure_keys(t, 'level1', 'level2', 'level3') +-- expect.eq(result, t.level1.level2.level3) +-- expect.true_(result.existing) +-- end +-- +-- function test.ensure_keys() +-- test_ensure_keys() +-- end + +local function test_copyall() + local t = {a = 1, b = 2, c = {nested = true}} + local copy = copyall(t) + + expect.table_eq(copy, t) + expect.ne(copy, t) -- Different table reference + expect.eq(copy.c, t.c) -- Shallow copy +end + +function test.copyall() + test_copyall() +end + +local function test_pos_functions() + -- Test pos2xyz + local pos = {x = 10, y = 20, z = 30} + local x, y, z = pos2xyz(pos) + expect.eq(x, 10) + expect.eq(y, 20) + expect.eq(z, 30) + + -- Test pos2xyz with invalid pos + local invalid_pos = {x = -30000} + expect.eq(pos2xyz(invalid_pos), nil) + + -- Test xyz2pos + local new_pos = xyz2pos(5, 10, 15) + expect.eq(new_pos.x, 5) + expect.eq(new_pos.y, 10) + expect.eq(new_pos.z, 15) + + -- Test xyz2pos with nil + local nil_pos = xyz2pos(nil, nil, nil) + expect.eq(nil_pos.x, -30000) + expect.eq(nil_pos.y, -30000) + expect.eq(nil_pos.z, -30000) + + -- Test same_xyz + expect.true_(same_xyz(pos, pos)) + expect.false_(same_xyz(pos, {x = 10, y = 20, z = 25})) + + -- Test pos2xy + local x2, y2 = pos2xy(pos) + expect.eq(x2, 10) + expect.eq(y2, 20) + + -- Test xy2pos + local pos2d = xy2pos(3, 7) + expect.eq(pos2d.x, 3) + expect.eq(pos2d.y, 7) + + -- Test same_xy + expect.true_(same_xy(pos, pos)) + expect.true_(same_xy(pos, {x = 10, y = 20, z = 99})) + expect.false_(same_xy(pos, {x = 10, y = 25, z = 30})) +end + +function test.pos_functions() + test_pos_functions() +end + +local function test_string_extensions() + -- Test startswith + expect.true_(("hello world"):startswith("hello")) + expect.false_(("hello world"):startswith("world")) + expect.true_(("test"):startswith("test")) + + -- Test endswith + expect.true_(("hello world"):endswith("world")) + expect.false_(("hello world"):endswith("hello")) + expect.true_(("test"):endswith("test")) + + -- Test split + local parts = ("a,b,c"):split(",") + expect.eq(parts[1], "a") + expect.eq(parts[2], "b") + expect.eq(parts[3], "c") + + -- Test split with default delimiter + local words = ("hello world test"):split() + expect.eq(words[1], "hello") + expect.eq(words[2], "world") + expect.eq(words[3], "test") + + -- Test trim + expect.eq((" hello "):trim(), "hello") + expect.eq(("\t\nworld\n\t"):trim(), "world") + + -- Test wrap + local wrapped = ("This is a long string that needs to be wrapped"):wrap(20) + expect.true_(#wrapped > 20) -- Should be multiple lines + + -- Test escape_pattern + local escaped = ("a+b*c"):escape_pattern() + expect.true_(escaped:find("%+")) + expect.true_(escaped:find("%*")) +end + +function test.string_extensions() + test_string_extensions() +end + +local function test_with_finalize() + local cleanup_called = false + local result + + dfhack.with_finalize( + function() cleanup_called = true end, + function() result = "success" end + ) + + expect.true_(cleanup_called) + expect.eq(result, "success") +end + +function test.with_finalize() + test_with_finalize() +end + +local function test_with_onerror() + local cleanup_called = false + local ok, err = pcall(function() + dfhack.with_onerror( + function() cleanup_called = true end, + function() error("test error") end + ) + end) + + expect.false_(ok) + expect.true_(cleanup_called) +end + +function test.with_onerror() + test_with_onerror() +end + +-- don't know how to test this function +-- local function test_mkmodule() +-- -- Test that mkmodule creates proper module structure +-- local test_module = mkmodule('test.temp_module') +-- expect.table_eq(test_module, {}) +-- end + +-- function test.mkmodule() +-- test_mkmodule() +-- end + +local function test_mkinstance() + local MyClass = defclass(nil) + local instance = mkinstance(MyClass, {value = 100}) + expect.eq(instance.value, 100) +end + +function test.mkinstance() + test_mkinstance() +end diff --git a/test/library/gui_basics.lua b/test/library/gui_basics.lua new file mode 100644 index 0000000000..451199edc8 --- /dev/null +++ b/test/library/gui_basics.lua @@ -0,0 +1,289 @@ +config.target = 'core' + +local gui = require 'gui' + +function test.mkdims_xy() + local dims = gui.mkdims_xy(10, 20, 30, 40) + expect.eq(dims.x1, 10) + expect.eq(dims.y1, 20) + expect.eq(dims.x2, 30) + expect.eq(dims.y2, 40) + expect.eq(dims.width, 21) -- 30-10+1 + expect.eq(dims.height, 21) -- 40-20+1 +end + +function test.mkdims_wh() + local dims = gui.mkdims_wh(10, 20, 15, 25) + expect.eq(dims.x1, 10) + expect.eq(dims.y1, 20) + expect.eq(dims.x2, 24) -- 10+15-1 + expect.eq(dims.y2, 44) -- 20+25-1 + expect.eq(dims.width, 15) + expect.eq(dims.height, 25) +end + +function test.parse_inset() + -- Test with table + local l,t,r,b = gui.parse_inset({l = 5, r = 10, t = 3, b = 7}) + expect.eq(l, 5); + expect.eq(t, 3) + expect.eq(r, 10) + expect.eq(b, 7) + + -- Test with single value + local l,t,r,b = gui.parse_inset(8) + expect.eq(l, 8) + expect.eq(t, 8) + expect.eq(r, 8) + expect.eq(b, 8) + + -- Test with x/y shorthand + local l,t,r,b = gui.parse_inset({x = 4, y = 6}) + expect.eq(l, 4) + expect.eq(t, 6) + expect.eq(r, 4) + expect.eq(b, 6) +end + +function test.inset_frame() + local rect = gui.mkdims_wh(0, 0, 100, 50) + local inset = gui.inset_frame(rect, {l = 10, r = 10, t = 5, b = 5}) + + expect.eq(inset.x1, 10) + expect.eq(inset.y1, 5) + expect.eq(inset.x2, 89) -- 100-10-1 + expect.eq(inset.y2, 44) -- 50-5-1 + expect.eq(inset.width, 80) + expect.eq(inset.height, 40) +end + +function test.is_in_rect() + local rect = gui.mkdims_wh(10, 10, 20, 20) + + expect.true_(gui.is_in_rect(rect, 15, 15)) + expect.true_(gui.is_in_rect(rect, 10, 10)) + expect.true_(gui.is_in_rect(rect, 29, 29)) + expect.false_(gui.is_in_rect(rect, 9, 15)) + expect.false_(gui.is_in_rect(rect, 15, 9)) + expect.false_(gui.is_in_rect(rect, 30, 15)) + expect.false_(gui.is_in_rect(rect, 15, 30)) +end + +function test.ViewRect_basic() + local view_rect = gui.ViewRect{rect = gui.mkdims_wh(0, 0, 50, 30)} + + expect.eq(view_rect.x1, 0) + expect.eq(view_rect.y1, 0) + expect.eq(view_rect.x2, 49) + expect.eq(view_rect.y2, 29) + expect.eq(view_rect.width, 50) + expect.eq(view_rect.height, 30) +end + +function test.ViewRect_isDefunct() + local valid_rect = gui.ViewRect{rect = gui.mkdims_wh(0, 0, 50, 30)} + expect.false_(valid_rect:isDefunct()) + + local defunct_rect = gui.ViewRect{rect = gui.mkdims_wh(0, 0, 0, 0)} + expect.true_(defunct_rect:isDefunct()) +end + +function test.ViewRect_inClipGlobalXY() + local view_rect = gui.ViewRect{rect = gui.mkdims_wh(10, 10, 20, 20)} + + expect.true_(view_rect:inClipGlobalXY(15, 15)) + expect.true_(view_rect:inClipGlobalXY(10, 10)) + expect.true_(view_rect:inClipGlobalXY(29, 29)) + expect.false_(view_rect:inClipGlobalXY(9, 15)) + expect.false_(view_rect:inClipGlobalXY(15, 30)) +end + +function test.ViewRect_inClipLocalXY() + local view_rect = gui.ViewRect{rect = gui.mkdims_wh(10, 10, 20, 20)} + + expect.true_(view_rect:inClipLocalXY(5, 5)) -- Global 15,15 + expect.true_(view_rect:inClipLocalXY(0, 0)) -- Global 10,10 + expect.false_(view_rect:inClipLocalXY(-1, 5)) -- Global 9,15 + expect.false_(view_rect:inClipLocalXY(5, 20)) -- Global 15,30 +end + +function test.ViewRect_localXY() + local view_rect = gui.ViewRect{rect = gui.mkdims_wh(10, 10, 20, 20)} + + local lx, ly = view_rect:localXY(15, 25) + expect.eq(lx, 5) + expect.eq(ly, 15) +end + +function test.ViewRect_globalXY() + local view_rect = gui.ViewRect{rect = gui.mkdims_wh(10, 10, 20, 20)} + + local gx, gy = view_rect:globalXY(5, 15) + expect.eq(gx, 15) + expect.eq(gy, 25) +end + +function test.ViewRect_viewport() + local view_rect = gui.ViewRect{rect = gui.mkdims_wh(0, 0, 100, 100)} + local viewport = view_rect:viewport(10, 10, 20, 20) + + expect.eq(viewport.x1, 10) + expect.eq(viewport.y1, 10) + expect.eq(viewport.x2, 29) + expect.eq(viewport.y2, 29) + expect.eq(viewport.width, 20) + expect.eq(viewport.height, 20) +end + +function test.Painter_basic() + local painter = gui.Painter{rect = gui.mkdims_wh(0, 0, 50, 30)} + + expect.eq(painter.x, 0) + expect.eq(painter.y, 0) + expect.true_(painter:isValidPos()) +end + +function test.Painter_cursor() + local painter = gui.Painter{rect = gui.mkdims_wh(10, 10, 50, 30)} + + local cx, cy = painter:cursor() + expect.eq(cx, 0) + expect.eq(cy, 0) + + painter:seek(5, 10) + cx, cy = painter:cursor() + expect.eq(cx, 5) + expect.eq(cy, 10) +end + +function test.Painter_seek() + local painter = gui.Painter{rect = gui.mkdims_wh(0, 0, 50, 30)} + + painter:seek(10, 20) + expect.eq(painter.x, 10) + expect.eq(painter.y, 20) + + painter:seek(5) -- Only x + expect.eq(painter.x, 5) + expect.eq(painter.y, 20) -- y unchanged +end + +function test.Painter_advance() + local painter = gui.Painter{rect = gui.mkdims_wh(0, 0, 50, 30)} + + painter:advance(10, 5) + expect.eq(painter.x, 10) + expect.eq(painter.y, 5) + + painter:advance(5) -- Only x + expect.eq(painter.x, 15) + expect.eq(painter.y, 5) -- y unchanged +end + +function test.Painter_newline() + local painter = gui.Painter{rect = gui.mkdims_wh(0, 0, 50, 30)} + + painter:seek(10, 5) + painter:newline(3) + + expect.eq(painter.x, 3) + expect.eq(painter.y, 6) +end + +function test.Painter_viewport() + local painter = gui.Painter{rect = gui.mkdims_wh(0, 0, 100, 100)} + local viewport_painter = painter:viewport(10, 10, 20, 20) + + expect.eq(viewport_painter.x, 10) + expect.eq(viewport_painter.y, 10) + expect.eq(viewport_painter.width, 20) + expect.eq(viewport_painter.height, 20) +end + +function test.View_basic() + local view = gui.View{} + + expect.table_eq(view.subviews, {}) + expect.eq(view.focus, false) + expect.eq(#view.focus_group, 1) +end + +function test.View_addviews() + local parent = gui.View{} + local child1 = gui.View{view_id = 'child1'} + local child2 = gui.View{view_id = 'child2'} + + parent:addviews({child1, child2}) + + expect.eq(#parent.subviews, 2) + expect.eq(parent.subviews.child1, child1) + expect.eq(parent.subviews.child2, child2) + expect.eq(child1.parent_view, parent) + expect.eq(child2.parent_view, parent) +end + +function test.View_getPreferredFocusState() + local view = gui.View{} + expect.false_(view:getPreferredFocusState()) +end + +function test.View_setFocus() + local view = gui.View{} + + expect.false_(view.focus) + view:setFocus(true) + expect.true_(view.focus) + view:setFocus(false) + expect.false_(view.focus) +end + +function test.View_assign() + local view = gui.View{} + view:assign({custom_field = 'value', another_field = 42}) + + expect.eq(view.custom_field, 'value') + expect.eq(view.another_field, 42) +end + +function test.View_callback() + local view = gui.View{} + function view:test_method(arg) + return arg * 2 + end + + local cb = view:callback('test_method') + expect.eq(cb(5), 10) +end + +function test.View_cb_getfield() + local view = gui.View{} + view.test_field = 'test_value' + + local getter = view:cb_getfield('test_field') + expect.eq(getter(), 'test_value') +end + +function test.View_cb_setfield() + local view = gui.View{} + view.test_field = 'initial' + + local setter = view:cb_setfield('test_field') + setter('updated') + expect.eq(view.test_field, 'updated') +end + +-- do not know how to test this function - it has no specified behavior and is not used +-- function test.compute_frame_rect() +-- local rect = gui.compute_frame_rect(100, 50, {w = 80, h = 40}) + +-- expect.eq(rect.width, 80) +-- expect.eq(rect.height, 40) +-- expect.ge(rect.x1, 0) +-- expect.ge(rect.y1, 0) +-- end + +function test.blink_visible() + -- Test that blink_visible returns boolean + local result = gui.blink_visible(100) + expect.eq(type(result), 'boolean') +end diff --git a/test/library/maps.lua b/test/library/maps.lua new file mode 100644 index 0000000000..50ed66bb2e --- /dev/null +++ b/test/library/maps.lua @@ -0,0 +1,247 @@ +config.mode = 'fortress' +config.target = 'core' + +local function first_block() + return df.global.world.map.map_blocks[0] +end + +local function block_bounds(block) + local p = block.map_pos + return {p.x, p.y, p.z, p.x + 15, p.y + 15, p.z} +end + +function test.getTileBlockCoord() + local block = first_block() + local p = block.map_pos + local pos = xyz2pos(p.x + 5, p.y + 7, p.z) + + local bx, by, bz = dfhack.maps.getTileBlockCoord(pos) + expect.eq(p.x // 16, bx) + expect.eq(p.y // 16, by) + -- z is a level, not scaled by the conversion + expect.eq(p.z, bz) + + -- x,y,z positional argument form works too + local bx2, by2, bz2 = dfhack.maps.getTileBlockCoord(pos.x, pos.y, pos.z) + expect.eq(bx, bx2) + expect.eq(by, by2) + expect.eq(bz, bz2) + + -- every tile in the block resolves to the same block coord, which agrees + -- with the block actually returned by the block lookups + for dx = 0, 15 do for dy = 0, 15 do + local tx, ty, tz = dfhack.maps.getTileBlockCoord(p.x + dx, p.y + dy, p.z) + expect.eq(bx, tx) + expect.eq(by, ty) + expect.eq(bz, tz) + end end + expect.eq(dfhack.maps.getTileBlock(pos), + dfhack.maps.getBlock(bx, by, bz)) +end + +function test.getBlockOrigin() + local block = first_block() + local p = block.map_pos + + local ox, oy, oz = dfhack.maps.getBlockOrigin(p.x // 16, p.y // 16, p.z) + expect.eq(p.x, ox) + expect.eq(p.y, oy) + -- z is a level, not scaled by the conversion + expect.eq(p.z, oz) +end + +function test.getTileBlockOffset() + local block = first_block() + local p = block.map_pos + local pos = xyz2pos(p.x + 5, p.y + 7, p.z) + + local tx, ty, tz = dfhack.maps.getTileBlockOffset(pos) + expect.eq(5, tx) + expect.eq(7, ty) + expect.eq(p.z, tz) + + -- block origin + offset reconstructs the tile position + local bx, by, bz = dfhack.maps.getTileBlockCoord(pos) + local ox, oy, oz = dfhack.maps.getBlockOrigin(bx, by, bz) + expect.eq(pos.x, ox + tx) + expect.eq(pos.y, oy + ty) + expect.eq(pos.z, oz + tz) +end + +function test.forEachTile_counts_tiles() + local block = first_block() + local res = dfhack.maps.forEachTile(block_bounds(block)) + expect.eq(256, res.scanned) + expect.eq(256, res.matched) + expect.eq(0, res.changed) + expect.eq(0, res.constructed) + expect.false_(res.aborted) +end + +function test.forEachTile_filters_by_attribute() + local block = first_block() + local want = 0 + for x = 0, 15 do for y = 0, 15 do + if df.tiletype.attrs[block.tiletype[x][y]].material == + df.tiletype_material.STONE then + want = want + 1 + end + end end + + local res = dfhack.maps.forEachTile(block_bounds(block), { + material = {df.tiletype_material.STONE}, + }) + expect.eq(want, res.matched) +end + +function test.forEachTile_filters_by_tiletype() + local block = first_block() + local tt = block.tiletype[0][0] + local want = 0 + for x = 0, 15 do for y = 0, 15 do + if block.tiletype[x][y] == tt then want = want + 1 end + end end + + local res = dfhack.maps.forEachTile(block_bounds(block), {tiletype = tt}) + expect.eq(want, res.matched) +end + +function test.forEachTile_set_tiletype() + local block = first_block() + local p = block.map_pos + local orig = block.tiletype[0][0] + return dfhack.with_finalize(function() + block.tiletype[0][0] = orig + end, function() + local res = dfhack.maps.forEachTile( + {p.x, p.y, p.z, p.x, p.y, p.z}, nil, + {set_tiletype = df.tiletype.OpenSpace}) + expect.eq(1, res.matched) + expect.eq(1, res.changed) + expect.eq(df.tiletype.OpenSpace, block.tiletype[0][0]) + end) +end + +function test.forEachTile_tiletype_map() + local block = first_block() + local p = block.map_pos + local orig = block.tiletype[0][0] + return dfhack.with_finalize(function() + block.tiletype[0][0] = orig + end, function() + -- a map entry for a different tiletype must not touch this tile + local res = dfhack.maps.forEachTile( + {p.x, p.y, p.z, p.x, p.y, p.z}, nil, + {set_tiletype = {[df.tiletype.ConstructedWallLRUD] = + df.tiletype.OpenSpace}}) + expect.eq(1, res.matched) + expect.eq(0, res.changed) + expect.eq(orig, block.tiletype[0][0]) + + -- matching key rewrites the tile + local res2 = dfhack.maps.forEachTile( + {p.x, p.y, p.z, p.x, p.y, p.z}, nil, + {set_tiletype = {[orig] = df.tiletype.OpenSpace}}) + expect.eq(1, res2.changed) + expect.eq(df.tiletype.OpenSpace, block.tiletype[0][0]) + end) +end + +function test.forEachTile_designation_action_and_predicate() + local block = first_block() + local p = block.map_pos + local des = block.designation[0][0] + local orig = des.hidden + return dfhack.with_finalize(function() + des.hidden = orig + end, function() + local res = dfhack.maps.forEachTile( + {p.x, p.y, p.z, p.x, p.y, p.z}, nil, + {designation = {hidden = not orig}}) + expect.eq(1, res.matched) + expect.eq(not orig, des.hidden) + + -- the same spec used as a predicate now matches + local res2 = dfhack.maps.forEachTile( + {p.x, p.y, p.z, p.x, p.y, p.z}, + {designation = {hidden = not orig}}) + expect.eq(1, res2.matched) + end) +end + +function test.forEachTile_callback() + local block = first_block() + local seen = 0 + local res = dfhack.maps.forEachTile(block_bounds(block), nil, + function(x, y, z, block_arg, localx, localy, tt) + seen = seen + 1 + expect.eq(block.map_pos.x, block_arg.map_pos.x) + expect.eq(x % 16, localx) + expect.eq(y % 16, localy) + expect.eq(block.tiletype[localx][localy], tt) + end) + expect.eq(256, seen) + expect.eq(256, res.matched) +end + +function test.forEachTile_callback_abort() + local block = first_block() + local calls = 0 + local res = dfhack.maps.forEachTile(block_bounds(block), nil, { + callback = function() + calls = calls + 1 + return false + end, + }) + expect.eq(1, calls) + expect.eq(1, res.matched) + expect.true_(res.aborted) +end + +function test.forEachTile_construct() + local block = first_block() + local p = block.map_pos + local orig = block.tiletype[0][0] + local pos = xyz2pos(p.x, p.y, p.z) + return dfhack.with_finalize(function() + block.tiletype[0][0] = orig + -- findAtTile can't remove; drop the record from the vector directly + local vec = df.global.world.event.constructions + for i = 0, #vec - 1 do + local c = vec[i] + if c.pos.x == p.x and c.pos.y == p.y and c.pos.z == p.z then + vec:erase(i) + break + end + end + end, function() + local res = dfhack.maps.forEachTile( + {p.x, p.y, p.z, p.x, p.y, p.z}, nil, { + construct = { + item_type = df.item_type.BLOCKS, + mat_type = 0, + mat_index = 0, + tiletype = df.tiletype.ConstructedFloor, + flags = {no_build_item = true}, + }, + }) + expect.eq(1, res.constructed) + expect.eq(1, res.changed) + expect.eq(df.tiletype.ConstructedFloor, block.tiletype[0][0]) + + local con = dfhack.constructions.findAtTile(pos) + expect.ne(nil, con) + if con then + expect.eq(df.item_type.BLOCKS, con.item_type) + expect.eq(orig, con.original_tile) + expect.true_(con.flags.no_build_item) + end + + -- a second scan must not duplicate the construction + local res2 = dfhack.maps.forEachTile( + {p.x, p.y, p.z, p.x, p.y, p.z}, nil, { + construct = {item_type = df.item_type.BLOCKS, mat_type = 0, mat_index = 0}, + }) + expect.eq(0, res2.constructed) + end) +end diff --git a/test/library/print.lua b/test/library/print.lua index 79243d8989..ea2244d3e8 100644 --- a/test/library/print.lua +++ b/test/library/print.lua @@ -190,6 +190,31 @@ function test.printall_recurse() end) end +function test.printall_recurse_untagged_union() + local t = df.army_controller:new() + dfhack.with_temp_object(t, function() + printall_recurse(t.data) + expect.eq(3, mock_print.call_count) + validate_patterns(1, + {'^ 0 then + unit = u + break + end + end + expect.ne(unit, nil) + + local oldpath = utils.clone(unit.path, true) + + local retval = dfhack.units.setPathGoal(unit, xyz2pos(1, 2, 3), df.unit_path_goal.None) + expect.nil_(retval) + expect.eq(df.unit_path_goal.None, unit.path.goal) + expect.table_eq(xyz2pos(1, 2, 3), utils.clone(unit.path.dest, true)) + expect.table_eq({ x = {}, y = {}, z = {} }, utils.clone(unit.path.path, true)) + + unit.path:assign(oldpath) + expect.eq(oldpath.goal, unit.path.goal) + expect.table_eq(oldpath.dest, utils.clone(unit.path.dest, true)) + expect.table_eq(oldpath.path, utils.clone(unit.path.path, true)) +end diff --git a/test/plugins/3dveins.lua b/test/plugins/3dveins.lua new file mode 100644 index 0000000000..406d6817e9 --- /dev/null +++ b/test/plugins/3dveins.lua @@ -0,0 +1,18 @@ +config.mode = 'fortress' +config.target = '3dveins' + +function test.rewrites_veins() + local output, status = dfhack.run_command_silent('3dveins') + expect.eq(CR_OK, status) + expect.str_find('Writing tiles', output) +end + +function test.verbose_option() + local _, status = dfhack.run_command_silent('3dveins', 'verbose') + expect.eq(CR_OK, status) +end + +function test.bad_option_is_wrong_usage() + local _, status = dfhack.run_command_silent('3dveins', 'bogus') + expect.eq(CR_WRONG_USAGE, status) +end diff --git a/test/plugins/aquifer.lua b/test/plugins/aquifer.lua new file mode 100644 index 0000000000..b47d36fbcb --- /dev/null +++ b/test/plugins/aquifer.lua @@ -0,0 +1,152 @@ +config.mode = 'fortress' +config.target = 'aquifer' + +local aquifer = require('plugins.aquifer') + +local function capture(fn) + local calls = {} + mock.patch({ + {aquifer, 'aquifer_list', function(pos1, pos2, levels, leaky) + calls.list = {pos1=pos1, pos2=pos2, levels=levels, leaky=leaky} + end}, + {aquifer, 'aquifer_drain', function(aq, pos1, pos2, skip, levels, leaky, all) + calls.drain = {aq=aq, pos1=pos1, pos2=pos2, skip=skip, + levels=levels, leaky=leaky, all=all} + return 7 + end}, + {aquifer, 'aquifer_convert', function(aq, pos1, pos2, skip, levels, leaky, all) + calls.convert = {aq=aq, pos1=pos1, pos2=pos2, skip=skip, + levels=levels, leaky=leaky, all=all} + return 5 + end}, + {aquifer, 'aquifer_add', function(aq, pos1, pos2, skip, levels, leaky, all) + calls.add = {aq=aq, pos1=pos1, pos2=pos2, skip=skip, + levels=levels, leaky=leaky, all=all} + return 3 + end}, + }, fn) + return calls +end + +function test.help_returns_false() + -- regression test: --help used to run a map-wide list instead + local called = false + mock.patch({ + {aquifer, 'aquifer_list', function() called = true end}, + -- helpdb can't resolve the script entry when called via require() + {dfhack, 'script_help', function() return 'test help' end}, + }, function() + expect.false_(aquifer.parse_commandline({'--help'})) + expect.false_(aquifer.parse_commandline({'-h'})) + expect.false_(aquifer.parse_commandline({'help'})) + end) + expect.false_(called) +end + +function test.list_defaults_to_all_levels() + local calls = capture(function() + expect.true_(aquifer.parse_commandline({'list'})) + end) + expect.eq(0, calls.list.pos1.x) + expect.eq(df.global.world.map.z_count - 1, calls.list.pos2.z) +end + +function test.list_cur_zlevel() + local calls = capture(function() + expect.true_(aquifer.parse_commandline({'list', '--cur-zlevel'})) + end) + local curz = df.global.window_z + expect.eq(curz, calls.list.pos1.z) + expect.eq(curz, calls.list.pos2.z) +end + +function test.coords_normalize_min_max() + local calls = capture(function() + expect.true_(aquifer.parse_commandline( + {'list', '9,9,100', '3,4,99'})) + end) + expect.eq(3, calls.list.pos1.x) + expect.eq(9, calls.list.pos2.x) + expect.eq(4, calls.list.pos1.y) + expect.eq(9, calls.list.pos2.y) + expect.eq(99, calls.list.pos1.z) + expect.eq(100, calls.list.pos2.z) +end + +function test.single_coord_defaults_to_cursor_or_same_pos() + local calls = capture(function() + expect.true_(aquifer.parse_commandline({'list', '3,4,99'})) + end) + expect.eq(3, calls.list.pos1.x) + if df.global.window_z == 99 then + -- cursor would need to be on z 99; either way pos2 is consistent + expect.true_(calls.list.pos2.x == 3 or calls.list.pos2.x ~= nil) + end +end + +function test.drain_requires_no_aquifer_type() + local calls = capture(function() + expect.true_(aquifer.parse_commandline({'drain', '--cur-zlevel'})) + end) + expect.eq('all', calls.drain.aq) + expect.eq(0, calls.drain.skip) +end + +function test.add_and_convert_require_aquifer_type() + capture(function() + expect.error_match('must specify an aquifer type', + function() aquifer.parse_commandline({'add', '--cur-zlevel'}) end) + expect.error_match('must specify an aquifer type', + function() aquifer.parse_commandline({'convert', '--cur-zlevel'}) end) + end) +end + +function test.add_light_with_skip_and_levels() + local calls = capture(function() + expect.true_(aquifer.parse_commandline( + {'add', 'light', '--cur-zlevel', '--skip-top', '2'})) + end) + expect.eq('light', calls.add.aq) + expect.eq(2, calls.add.skip) + expect.eq(1, calls.add.levels) +end + +function test.levels_option_with_zup() + -- --zup keeps the bottom N levels of the range + local calls = capture(function() + expect.true_(aquifer.parse_commandline( + {'drain', 'heavy', '0,0,90', '10,10,95', '--zup', '--levels', '2'})) + end) + expect.eq(90, calls.drain.pos1.z) + expect.eq(91, calls.drain.pos2.z) + expect.eq(2, calls.drain.levels) + expect.eq('heavy', calls.drain.aq) +end + +function test.levels_option_with_zdown() + -- --zdown keeps the top N levels of the range + local calls = capture(function() + expect.true_(aquifer.parse_commandline( + {'drain', 'heavy', '0,0,90', '10,10,95', '--zdown', '--levels', '2'})) + end) + expect.eq(94, calls.drain.pos1.z) + expect.eq(95, calls.drain.pos2.z) + expect.eq(2, calls.drain.levels) +end + +function test.invalid_levels_and_skip_top_rejected() + expect.error(function() + aquifer.parse_commandline({'drain', '--levels', '0'}) + end) + expect.error(function() + aquifer.parse_commandline({'drain', '--skip-top', '-1'}) + end) +end + +function test.unknown_action_falls_back_to_list() + -- positionals that aren't actions are treated as coords; an unparseable + -- coord raises an error through argparse + expect.error(function() + aquifer.parse_commandline({'bogus', '--cur-zlevel'}) + end) +end diff --git a/test/plugins/autobutcher.lua b/test/plugins/autobutcher.lua new file mode 100644 index 0000000000..236272a58c --- /dev/null +++ b/test/plugins/autobutcher.lua @@ -0,0 +1,83 @@ +config.mode = 'fortress' +config.target = 'autobutcher' + +local autobutcher = require('plugins.autobutcher') + +local function parse(args) + -- emulate the autobutcher_options struct passed by the C++ command handler + local opts = {races={insert=function(self, pos, v) table.insert(self, v) end}} + autobutcher.parse_commandline(opts, args) + return opts +end + +function test.help() + expect.true_(parse{'help'}.help) + expect.true_(parse{'-h'}.help) + expect.true_(parse{'--help'}.help) +end + +function test.simple_commands() + for _,cmd in ipairs{'now', 'autowatch', 'noautowatch', 'list', + 'list_export'} do + expect.eq(cmd, parse{cmd}.command) + end +end + +function test.watch_races() + local opts = parse{'watch', 'CAT', 'BIRD_CROW'} + expect.eq('watch', opts.command) + expect.eq(2, #opts.races) + expect.eq('CAT', opts.races[1].value) + expect.eq('BIRD_CROW', opts.races[2].value) +end + +function test.watch_all_and_new() + expect.true_(parse{'watch', 'all'}.races_all) + expect.true_(parse{'unwatch', 'new'}.races_new) + expect.true_(parse{'forget', 'all'}.races_all) +end + +function test.watch_requires_races() + expect.error_match('missing list of races', function() + parse{'watch'} end) +end + +function test.target() + local opts = parse{'target', '1', '2', '3', '4', 'CAT'} + expect.eq('target', opts.command) + expect.eq(1, opts.fk) + expect.eq(2, opts.mk) + expect.eq(3, opts.fa) + expect.eq(4, opts.ma) + expect.eq(1, #opts.races) + expect.eq('CAT', opts.races[1].value) +end + +function test.target_zero_allowed() + local opts = parse{'target', '0', '0', '0', '0', 'all'} + expect.eq(0, opts.fk) + expect.true_(opts.races_all) +end + +function test.target_rejects_bad_numbers() + expect.error_match('non%-negative integer', function() + parse{'target', '-1', '0', '0', '0', 'all'} end) + expect.error_match('non%-negative integer', function() + parse{'target', '1.5', '0', '0', '0', 'all'} end) + expect.error_match('non%-negative integer', function() + parse{'target', 'x', '0', '0', '0', 'all'} end) +end + +function test.target_requires_all_counts() + expect.error(function() parse{'target', '1', '2', '3'} end) +end + +function test.target_requires_races() + expect.error_match('missing list of races', function() + parse{'target', '1', '2', '3', '4'} end) +end + +function test.unrecognized_command() + expect.error_match('unrecognized command', function() + parse{'bogus'} end) +end diff --git a/test/plugins/autochop.lua b/test/plugins/autochop.lua new file mode 100644 index 0000000000..9cfbb9ffc8 --- /dev/null +++ b/test/plugins/autochop.lua @@ -0,0 +1,111 @@ +config.mode = 'fortress' +config.target = 'autochop' + +local autochop = require('plugins.autochop') + +local function with_targets(fn) + local old_max, old_min = autochop.autochop_getTargets() + return dfhack.with_finalize( + function() + autochop.autochop_setTargets(old_max, old_min) + autochop.autochop_undesignate() + end, + fn) +end + +local function with_burrow(fn) + local burrows = df.global.plotinfo.burrows + local b = df.burrow:new() + b.id = burrows.next_id + burrows.next_id = burrows.next_id + 1 + b.name = 'DFHACK_TEST_AUTOCHOP' + burrows.list:insert('#', b) + return dfhack.with_finalize( + function() + for i = #burrows.list - 1, 0, -1 do + if burrows.list[i] == b then + burrows.list:erase(i) + end + end + end, + function() fn(b) end) +end + +function test.target_command_sets_max_and_min() + with_targets(function() + expect.true_(autochop.parse_commandline('target', '50', '40')) + local max, min = autochop.autochop_getTargets() + expect.eq(50, max) + expect.eq(40, min) + end) +end + +function test.target_defaults_min_to_80_percent_of_max() + with_targets(function() + expect.true_(autochop.parse_commandline('target', '200')) + local max, min = autochop.autochop_getTargets() + expect.eq(200, max) + expect.eq(160, min) + end) +end + +function test.target_rejects_invalid_values() + expect.error_match('non%-negative integer', + function() autochop.setTargets('-1') end) + expect.error_match('non%-negative integer', + function() autochop.setTargets('notanumber') end) + expect.error_match('between 0 and the maximum', + function() autochop.setTargets('50', '60') end) + expect.error_match('between 0 and the maximum', + function() autochop.setTargets('50', '-1') end) +end + +function test.chop_and_nochop_toggle_burrow() + with_burrow(function(b) + expect.true_(autochop.parse_commandline('chop', b.name)) + expect.eq(1, autochop.autochop_getBurrowConfig(b.id).chop) + expect.true_(autochop.parse_commandline('nochop', b.name)) + expect.eq(0, autochop.autochop_getBurrowConfig(b.id).chop) + end) +end + +function test.clearcut_and_noclearcut_toggle_burrow() + with_burrow(function(b) + expect.true_(autochop.parse_commandline('clearcut', b.name)) + expect.eq(1, autochop.autochop_getBurrowConfig(b.id).clearcut) + expect.true_(autochop.parse_commandline('noclear', b.name)) + expect.eq(0, autochop.autochop_getBurrowConfig(b.id).clearcut) + end) +end + +function test.protect_sets_each_listed_type() + with_burrow(function(b) + expect.true_(autochop.parse_commandline('protect', 'brewable,edible', b.name)) + local config = autochop.autochop_getBurrowConfig(b.id) + expect.eq(1, config.protect_brewable) + expect.eq(1, config.protect_edible) + expect.eq(0, config.protect_cookable) + expect.true_(autochop.parse_commandline('unprotect', 'edible', b.name)) + config = autochop.autochop_getBurrowConfig(b.id) + expect.eq(1, config.protect_brewable) + expect.eq(0, config.protect_edible) + end) +end + +function test.burrow_command_requires_a_burrow() + expect.error_match('no target burrows', + function() autochop.parse_commandline('chop') end) + expect.error_match('burrow not found', + function() autochop.parse_commandline('chop', 'DFHACK_NONEXISTENT_BURROW') end) +end + +function test.help_and_unknown_command_return_false() + expect.false_(autochop.parse_commandline('help')) + expect.false_(autochop.parse_commandline('--help')) + expect.false_(autochop.parse_commandline('bogus')) +end + +function test.status_and_no_command_return_true() + expect.true_(autochop.parse_commandline()) + expect.true_(autochop.parse_commandline('status')) +end diff --git a/test/plugins/autoclothing.lua b/test/plugins/autoclothing.lua new file mode 100644 index 0000000000..8d02af8789 --- /dev/null +++ b/test/plugins/autoclothing.lua @@ -0,0 +1,20 @@ +config.mode = 'fortress' +config.target = 'autoclothing' + +function test.status() + local output = dfhack.run_command_silent('autoclothing') + expect.str_find('Automatic clothing management is currently', output) + expect.str_find('automatic clothing orders', output) +end + +function test.wrong_arg_count_shows_usage() + local output, status = dfhack.run_command_silent('autoclothing', 'bogus') + expect.true_(output:find('Wrong number', 1, true) ~= nil + or status ~= 0) +end + +function test.invalid_requirement_rejected() + local output, status = dfhack.run_command_silent('autoclothing', + 'bogus', 'bogus') + expect.ne(0, status) +end diff --git a/test/plugins/autodump.lua b/test/plugins/autodump.lua new file mode 100644 index 0000000000..bd2f1c0eed --- /dev/null +++ b/test/plugins/autodump.lua @@ -0,0 +1,147 @@ +config.mode = 'fortress' +config.target = 'autodump' + +local dwarfmode = require('gui.dwarfmode') + +local function find_floor_pos() + for _, block in ipairs(df.global.world.map.map_blocks) do + for x = 0, 15 do + for y = 0, 15 do + local tt = block.tiletype[x][y] + local des = block.designation[x][y] + local occ = block.occupancy[x][y] + if df.tiletype.attrs[tt].shape == df.tiletype_shape.FLOOR + and not des.hidden and occ.building == 0 then + return block.map_pos.x + x, block.map_pos.y + y, + block.map_pos.z + end + end + end + end +end + +local function set_cursor(x, y, z) + dwarfmode.setCursorPos(xyz2pos(x, y, z)) +end + +local function clear_cursor() + dwarfmode.clearCursorPos() +end + +local function find_dumpable_item() + for _, item in ipairs(df.global.world.items.other.IN_PLAY) do + if not item.flags.dump and not item.flags.construction + and not item.flags.in_building and not item.flags.artifact + and not item.flags.in_job and not item.flags.forbid + and not item.flags.owned and not item.flags.in_inventory then + return item + end + end +end + +local function block_has_item(block, id) + for _, bid in ipairs(block.items) do + if bid == id then return true end + end + return false +end + +local function items_sorted(block) + local prev = -1 + for _, id in ipairs(block.items) do + if id <= prev then return false end + prev = id + end + return true +end + +function test.dump_moves_item_to_cursor() + local x, y, z = find_floor_pos() + local item = find_dumpable_item() + expect.ne(nil, x, 'test needs a revealed floor tile') + expect.ne(nil, item, 'test needs a dumpable item') + local ix, iy, iz = dfhack.items.getPosition(item) + expect.ne(nil, ix, 'test item needs a real position') + local old_block = dfhack.maps.getTileBlock(ix, iy, iz) + local new_block = dfhack.maps.getTileBlock(x, y, z) + + return dfhack.with_finalize(function() + item.flags.dump = false + clear_cursor() + end, function() + set_cursor(x, y, z) + item.flags.dump = true + + local _, status = dfhack.run_command_silent('autodump') + expect.eq(CR_OK, status) + -- item was teleported to the cursor and marked as dumped + expect.eq(x, item.pos.x) + expect.eq(y, item.pos.y) + expect.eq(z, item.pos.z) + expect.false_(item.flags.dump) + expect.true_(item.flags.forbid) + expect.true_(item.flags.on_ground) + expect.false_(item.flags.in_inventory) + expect.false_(item.flags.in_building) + + -- the destination tile's occupancy and the map_block item + -- vectors must reflect the move + local _, occ = dfhack.maps.getTileFlags(x, y, z) + expect.true_(occ.item) + expect.true_(block_has_item(new_block, item.id), + 'item id should be in the new map_block.items') + if old_block ~= new_block then + expect.false_(block_has_item(old_block, item.id), + 'item id should be removed from the old map_block.items') + end + expect.true_(items_sorted(new_block), + 'map_block.items should remain sorted') + end) +end + +function test.conflicting_filters_is_wrong_usage() + local output, status = dfhack.run_command_silent('autodump', 'visible', 'hidden') + expect.eq(CR_WRONG_USAGE, status) + expect.str_find("both hidden and visible", output) +end + +function test.bad_option_is_wrong_usage() + local _, status = dfhack.run_command_silent('autodump', 'bogus') + expect.eq(CR_WRONG_USAGE, status) +end + +function test.destroy_and_undestroy() + local item = find_dumpable_item() + expect.ne(nil, item, 'test needs a dumpable item') + + local was_paused = dfhack.world.ReadPauseState() + return dfhack.with_finalize(function() + dfhack.world.SetPauseState(was_paused) + dfhack.run_command_silent('autodump', 'undestroy') + item.flags.dump = false + item.flags.garbage_collect = false + item.flags.forbid = false + item.flags.hidden = false + end, function() + item.flags.dump = true + -- undestroy only restores marks made in the same frame, so the + -- game must stay paused between destroy and undestroy + dfhack.world.SetPauseState(true) + + local _, status = dfhack.run_command_silent('autodump', 'destroy') + expect.eq(CR_OK, status) + expect.true_(item.flags.garbage_collect) + expect.true_(item.flags.forbid) + expect.true_(item.flags.hidden) + + local output, status2 = dfhack.run_command_silent('autodump', 'undestroy') + expect.eq(CR_OK, status2) + expect.str_find('unmarked for destruction', output) + expect.false_(item.flags.garbage_collect) + expect.false_(item.flags.forbid) + expect.false_(item.flags.hidden) + -- undestroy restores the pre-destroy flags, including the dump + -- flag we set ourselves + expect.true_(item.flags.dump) + end) +end diff --git a/test/plugins/autofarm.lua b/test/plugins/autofarm.lua new file mode 100644 index 0000000000..5dfb0a6348 --- /dev/null +++ b/test/plugins/autofarm.lua @@ -0,0 +1,104 @@ +config.mode = 'fortress' +config.target = 'autofarm' + +-- autofarm enabled state persists; leave it disabled +config.wrapper = function(test_fn) + return dfhack.with_finalize(function() + dfhack.run_command_silent('autofarm', 'disable') + end, test_fn) +end + +function test.status() + local output = dfhack.run_command_silent('autofarm', 'status') + expect.ne('', output) +end + +function test.enable_disable() + dfhack.run_command_silent('autofarm', 'enable') + local output = dfhack.run_command_silent('autofarm', 'status') + expect.str_find('Active', output) + dfhack.run_command_silent('autofarm', 'disable') + output = dfhack.run_command_silent('autofarm', 'status') + expect.str_find('Stopped', output) +end + +function test.runonce() + local output, status = dfhack.run_command_silent('autofarm', 'runonce') + expect.eq(0, status) +end + +function test.unknown_arg_shows_usage() + local output, status = dfhack.run_command_silent('autofarm', 'bogus') + expect.true_(output:find('autofarm', 1, true) ~= nil or status ~= 0) +end + +function test.threshold_rejects_bad_plant() + local output, status = dfhack.run_command_silent('autofarm', + 'threshold', '-1', '5') + expect.true_(output:find('Cannot find plant', 1, true) ~= nil + or status ~= 0) +end + +local function find_plant(id) + for _,plant in ipairs(df.global.world.raws.plants.all) do + if plant.id == id then return plant end + end +end + +function test.threshold_growth() + local plugin = require('plugins.autofarm') + -- quarry bushes have a stockpile LEAVES growth in vanilla + local output, status = dfhack.run_command_silent('autofarm', + 'threshold', '30', + 'BUSH_QUARRY:LEAVES') + expect.eq(0, status) + local plant = find_plant('BUSH_QUARRY') + local data = plugin.autofarm_getData() + expect.eq(30, data.growth_thresholds[plant.index].LEAVES) + plugin.autofarm_removeGrowthThreshold(plant.index, 'LEAVES') + plugin.autofarm_removeThreshold(plant.index) +end + +function test.threshold_rejects_bad_growth() + local output, status = dfhack.run_command_silent('autofarm', + 'threshold', '5', + 'BUSH_QUARRY:NOPE') + expect.true_(output:find('Cannot find plant or growth', 1, true) ~= nil + or status ~= 0) + -- growths that are not stockpile-marked are also rejected + output, status = dfhack.run_command_silent('autofarm', + 'threshold', '5', + 'BUCKWHEAT:FLOWERS') + expect.true_(output:find('Cannot find plant or growth', 1, true) ~= nil + or status ~= 0) +end + +function test.growth_threshold_funcs() + local plugin = require('plugins.autofarm') + local plant = find_plant('BUSH_QUARRY') + plugin.autofarm_setGrowthThreshold(plant.index, 'LEAVES', 42) + local data = plugin.autofarm_getData() + expect.eq(42, data.growth_thresholds[plant.index].LEAVES) + plugin.autofarm_removeGrowthThreshold(plant.index, 'LEAVES') + data = plugin.autofarm_getData() + expect.nil_(data.growth_thresholds[plant.index]) +end + +function test.farm_exclusion() + local plugin = require('plugins.autofarm') + expect.false_(plugin.autofarm_isFarmExcluded(-1)) + plugin.autofarm_setFarmExcluded(-1, true) + expect.true_(plugin.autofarm_isFarmExcluded(-1)) + plugin.autofarm_setFarmExcluded(-1, false) + expect.false_(plugin.autofarm_isFarmExcluded(-1)) +end + +function test.get_data() + local data = require('plugins.autofarm').autofarm_getData() + expect.eq('number', type(data.default_threshold)) + expect.eq('table', type(data.thresholds)) + expect.eq('table', type(data.growth_thresholds)) + expect.eq('table', type(data.last_counts)) + expect.eq('table', type(data.growth_counts)) + expect.eq('table', type(data.excluded_farms)) +end diff --git a/test/plugins/autolabor.lua b/test/plugins/autolabor.lua new file mode 100644 index 0000000000..bca5aa04f0 --- /dev/null +++ b/test/plugins/autolabor.lua @@ -0,0 +1,37 @@ +config.mode = 'fortress' +config.target = 'autolabor' + +-- autolabor enabled state and labor configs persist; leave it off and +-- restore the test labor's defaults +config.wrapper = function(test_fn) + return dfhack.with_finalize(function() + dfhack.run_command_silent('autolabor', 'MINE', 'reset') + dfhack.run_command_silent('autolabor', 'disable') + end, test_fn) +end + +function test.no_args_shows_help() + local output = dfhack.run_command_silent('autolabor') + expect.ne('', output) +end + +function test.labor_command_requires_enable() + dfhack.run_command_silent('autolabor', 'disable') + local output = dfhack.run_command_silent('autolabor', 'MINE', 'haulers') + expect.str_find('not enabled', output) +end + +function test.set_labor_mode() + dfhack.run_command_silent('autolabor', 'enable') + local output = dfhack.run_command_silent('autolabor', 'MINE', 'haulers') + expect.str_find('MINE.-haulers', output) + output = dfhack.run_command_silent('autolabor', 'MINE', 'reset') + expect.str_find('MINE.-minimum', output) +end + +function test.unknown_labor_errors() + dfhack.run_command_silent('autolabor', 'enable') + local output = dfhack.run_command_silent('autolabor', 'BOGUS_LABOR', + 'haulers') + expect.str_find('Could not find labor', output) +end diff --git a/test/plugins/autonestbox.lua b/test/plugins/autonestbox.lua new file mode 100644 index 0000000000..997feb054b --- /dev/null +++ b/test/plugins/autonestbox.lua @@ -0,0 +1,37 @@ +config.mode = 'fortress' +config.target = 'autonestbox' + +local autonestbox = require('plugins.autonestbox') + +local function parse(args) + local opts = {} + autonestbox.parse_commandline(opts, args) + return opts +end + +function test.now_flag() + expect.true_(parse({'now'}).now) +end + +function test.help_flag() + expect.true_(parse({'help'}).help) + expect.true_(parse({'--help'}).help) + expect.true_(parse({'-h'}).help) +end + +function test.help_does_not_set_now() + local opts = parse({'help', 'now'}) + expect.true_(opts.help) + expect.nil_(opts.now) +end + +function test.unknown_positionals_ignored() + local opts = parse({'bogus'}) + expect.nil_(opts.now) + expect.nil_(opts.help) +end + +function test.mixed_positionals() + local opts = parse({'bogus', 'now', 'other'}) + expect.true_(opts.now) +end diff --git a/test/plugins/blueprint.lua b/test/plugins/blueprint.lua index a08844bbee..d5ebd2031d 100644 --- a/test/plugins/blueprint.lua +++ b/test/plugins/blueprint.lua @@ -243,16 +243,16 @@ end function test.get_filename() local opts = {name='a', split_strategy='none'} - expect.eq('dfhack-config/blueprints/a.csv', b.get_filename(opts, 'dig', 1)) + expect.eq(dfhack.getConfigPath() .. '/blueprints/a.csv', b.get_filename(opts, 'dig', 1)) opts = {name='a/', split_strategy='none'} - expect.eq('dfhack-config/blueprints/a/a.csv', b.get_filename(opts, 'dig', 1)) + expect.eq(dfhack.getConfigPath() .. '/blueprints/a/a.csv', b.get_filename(opts, 'dig', 1)) opts = {name='a', split_strategy='phase'} - expect.eq('dfhack-config/blueprints/a-1-dig.csv', b.get_filename(opts, 'dig', 1)) + expect.eq(dfhack.getConfigPath() .. '/blueprints/a-1-dig.csv', b.get_filename(opts, 'dig', 1)) opts = {name='a/', split_strategy='phase'} - expect.eq('dfhack-config/blueprints/a/a-5-dig.csv', b.get_filename(opts, 'dig', 5)) + expect.eq(dfhack.getConfigPath() .. '/blueprints/a/a-5-dig.csv', b.get_filename(opts, 'dig', 5)) expect.error_match('could not parse basename', function() b.get_filename({name='', split_strategy='none'}) diff --git a/test/plugins/buildingplan.lua b/test/plugins/buildingplan.lua new file mode 100644 index 0000000000..f2f48e92f2 --- /dev/null +++ b/test/plugins/buildingplan.lua @@ -0,0 +1,70 @@ +config.mode = 'fortress' +config.target = 'buildingplan' + +local buildingplan = require('plugins.buildingplan') + +local function find_floor_pos() + for _, unit in ipairs(df.global.world.units.active) do + for dx = -4, 4 do + for dy = -4, 4 do + local pos = xyz2pos(unit.pos.x + dx, unit.pos.y + dy, unit.pos.z) + local tt = dfhack.maps.getTileType(pos) + local block = dfhack.maps.getTileBlock(pos) + if tt and block + and df.tiletype.attrs[tt].shape == df.tiletype_shape.FLOOR + and block.occupancy[pos.x % 16][pos.y % 16].building == 0 then + return pos + end + end + end + end +end + +local function place_box(pos) + return dfhack.buildings.constructBuilding{ + pos=pos, + type=df.building_type.Box, subtype=-1, custom=-1, + width=1, height=1, + filters=dfhack.buildings.getFiltersByType({}, df.building_type.Box, -1, -1), + } +end + +function test.set_get_do_now() + local pos = find_floor_pos() + expect.ne(nil, pos, 'test needs a free floor tile') + local bld = place_box(pos) + expect.ne(nil, bld, 'failed to place test building') + dfhack.with_finalize( + function() dfhack.buildings.deconstruct(bld) end, + function() + expect.false_(buildingplan.getDoNow(bld)) + expect.true_(buildingplan.setDoNow(bld, true)) + expect.true_(buildingplan.getDoNow(bld)) + expect.true_(bld.jobs[0].flags.do_now) + expect.true_(buildingplan.setDoNow(bld, false)) + expect.false_(buildingplan.getDoNow(bld)) + end) +end + +function test.do_now_invalid_input() + expect.false_(buildingplan.setDoNow(nil, true)) + expect.false_(buildingplan.getDoNow(nil)) +end + +function test.do_now_flag_survives_registration() + local pos = find_floor_pos() + expect.ne(nil, pos, 'test needs a free floor tile') + local bld = place_box(pos) + expect.ne(nil, bld, 'failed to place test building') + dfhack.with_finalize( + function() dfhack.buildings.deconstruct(bld) end, + function() + buildingplan.addPlannedBuilding(bld) + expect.true_(buildingplan.isPlannedBuilding(bld)) + expect.true_(buildingplan.setDoNow(bld, true)) + -- the flag is set on the job directly, so buildingplan's own + -- suspend/cycle handling must not clear it + buildingplan.doCycle() + expect.true_(buildingplan.getDoNow(bld)) + end) +end diff --git a/test/plugins/burrow.lua b/test/plugins/burrow.lua new file mode 100644 index 0000000000..dc557cd89d --- /dev/null +++ b/test/plugins/burrow.lua @@ -0,0 +1,102 @@ +config.mode = 'fortress' +config.target = 'burrow' + +local burrow = require('plugins.burrow') + +local function with_burrow(fn, name) + local burrows = df.global.plotinfo.burrows + -- burrow name lookups return the first match, so remove any test burrows + -- leaked by previous runs before creating ours + name = name or 'DFHACK_TEST_BURROW' + for i = #burrows.list - 1, 0, -1 do + if burrows.list[i].name == name then + dfhack.burrows.clearTiles(burrows.list[i]) + burrows.list[i]:delete() + burrows.list:erase(i) + end + end + local b = df.burrow:new() + b.id = burrows.next_id + burrows.next_id = burrows.next_id + 1 + b.name = name + burrows.list:insert('#', b) + return dfhack.with_finalize( + function() + for i = #burrows.list - 1, 0, -1 do + if burrows.list[i] == b then + burrows.list:erase(i) + end + end + dfhack.burrows.clearTiles(b) + dfhack.burrows.clearUnits(b) + b:delete() + end, + function() fn(b) end) +end + +local function assigned(b, x, y, z) + return dfhack.burrows.isAssignedTile(b, xyz2pos(x, y, z)) +end + +function test.tiles_box_add_and_remove() + with_burrow(function(b) + expect.true_(burrow.parse_commandline( + 'tiles', 'box-add', b.name, '10,10,120', '12,12,120')) + expect.true_(assigned(b, 10, 10, 120)) + expect.true_(assigned(b, 12, 12, 120)) + expect.false_(assigned(b, 9, 10, 120)) + expect.false_(assigned(b, 10, 10, 119)) + + expect.true_(burrow.parse_commandline( + 'tiles', 'box-remove', b.name, '11,11,120', '12,12,120')) + expect.true_(assigned(b, 10, 10, 120)) + expect.false_(assigned(b, 12, 12, 120)) + end) +end + +function test.tiles_clear() + with_burrow(function(b) + burrow.parse_commandline('tiles', 'box-add', b.name, '10,10,120', '12,12,120') + expect.true_(assigned(b, 10, 10, 120)) + expect.true_(burrow.parse_commandline('tiles', 'clear', b.name)) + expect.false_(assigned(b, 10, 10, 120)) + end) +end + +function test.tiles_add_copies_from_other_burrow() + with_burrow(function(src) + burrow.parse_commandline('tiles', 'box-add', src.name, '20,20,120', '21,21,120') + with_burrow(function(dst) + expect.true_(burrow.parse_commandline( + 'tiles', 'add', dst.name, src.name)) + expect.true_(assigned(dst, 20, 20, 120)) + expect.true_(assigned(src, 20, 20, 120)) + end, 'DFHACK_TEST_BURROW_DST') + end, 'DFHACK_TEST_BURROW_SRC') +end + +function test.units_add_copies_from_other_burrow() + -- create a source burrow with a unit assigned directly + with_burrow(function(src) + local unit = dfhack.units.getCitizens()[1] + if not unit then return end + dfhack.burrows.setAssignedUnit(src, unit, true) + expect.true_(dfhack.burrows.isAssignedUnit(src, unit)) + with_burrow(function(dst) + expect.true_(burrow.parse_commandline( + 'units', 'add', dst.name, src.name)) + expect.true_(dfhack.burrows.isAssignedUnit(dst, unit)) + expect.true_(burrow.parse_commandline( + 'units', 'remove', dst.name, src.name)) + expect.false_(dfhack.burrows.isAssignedUnit(dst, unit)) + expect.true_(dfhack.burrows.isAssignedUnit(src, unit)) + end, 'DFHACK_TEST_BURROW_DST') + end, 'DFHACK_TEST_BURROW_SRC') +end + +function test.help_and_bad_mode_return_false() + expect.false_(burrow.parse_commandline('help')) + expect.false_(burrow.parse_commandline('--help')) + expect.false_(burrow.parse_commandline('bogus', 'clear')) + expect.false_(burrow.parse_commandline('tiles', 'bogus')) +end diff --git a/test/plugins/changeitem.lua b/test/plugins/changeitem.lua new file mode 100644 index 0000000000..0efc69530a --- /dev/null +++ b/test/plugins/changeitem.lua @@ -0,0 +1,85 @@ +config.mode = 'fortress' +config.target = 'changeitem' + +local dwarfmode = require('gui.dwarfmode') + +local function find_floor_item_pos() + for _, item in ipairs(df.global.world.items.other.IN_PLAY) do + if not item.flags.hidden and not item.flags.in_inventory + and not item.flags.in_job and not item.flags.construction then + local x, y, z = dfhack.items.getPosition(item) + if x then return item, x, y, z end + end + end +end + +local function set_cursor(x, y, z) + dwarfmode.setCursorPos(xyz2pos(x, y, z)) +end + +local function clear_cursor() + dwarfmode.clearCursorPos() +end + +function test.here_quality_changes_item() + local item, x, y, z = find_floor_item_pos() + expect.ne(nil, item, 'test needs an item on the ground') + local orig_quality = item.quality + + return dfhack.with_finalize(function() + item.quality = orig_quality + clear_cursor() + end, function() + set_cursor(x, y, z) + local output, status = dfhack.run_command_silent('changeitem', + 'here', 'q', '4') + expect.eq(CR_OK, status) + expect.str_find('items processed', output) + expect.eq(4, item.quality) + end) +end + +function test.here_empty_tile_processes_nothing() + -- find a floor tile and put the cursor on it; if it happens to hold + -- items the count is still reported + local block = df.global.world.map.map_blocks[0] + local x, y, z = block.map_pos.x, block.map_pos.y, block.map_pos.z + + return dfhack.with_finalize(clear_cursor, function() + set_cursor(x, y, z) + local output, status = dfhack.run_command_silent('changeitem', 'here') + expect.eq(CR_OK, status) + expect.str_find('items processed', output) + end) +end + +function test.here_no_cursor_is_failure() + return dfhack.with_finalize(clear_cursor, function() + clear_cursor() + local output, status = dfhack.run_command_silent('changeitem', 'here') + expect.eq(CR_FAILURE, status) + expect.str_find('Cursor position not found', output) + end) +end + +function test.no_selection_is_failure() + local output, status = dfhack.run_command_silent('changeitem') + expect.eq(CR_FAILURE, status) + expect.str_find('No item selected', output) +end + +function test.material_missing_arg_is_wrong_usage() + local output, status = dfhack.run_command_silent('changeitem', 'm') + expect.eq(CR_WRONG_USAGE, status) + expect.str_find('no material specified', output) +end + +function test.bad_quality_is_wrong_usage() + local _, status = dfhack.run_command_silent('changeitem', 'q', '9') + expect.eq(CR_WRONG_USAGE, status) +end + +function test.bad_option_is_wrong_usage() + local _, status = dfhack.run_command_silent('changeitem', 'bogus') + expect.eq(CR_WRONG_USAGE, status) +end diff --git a/test/plugins/changelayer.lua b/test/plugins/changelayer.lua new file mode 100644 index 0000000000..f3bfb55b1b --- /dev/null +++ b/test/plugins/changelayer.lua @@ -0,0 +1,33 @@ +config.mode = 'fortress' +config.target = 'changelayer' + +local dwarfmode = require('gui.dwarfmode') + +function test.no_material_is_wrong_usage() + local output, status = dfhack.run_command_silent('changelayer') + expect.eq(CR_WRONG_USAGE, status) + expect.str_find('specify a material', output) +end + +function test.bad_material_is_failure() + local output, status = dfhack.run_command_silent('changelayer', 'BOGUSMAT') + expect.eq(CR_FAILURE, status) + expect.str_find('No such material', output) +end + +function test.no_cursor_is_failure() + return dfhack.with_finalize(function() + dwarfmode.clearCursorPos() + end, function() + dwarfmode.clearCursorPos() + local output, status = dfhack.run_command_silent('changelayer', + 'GRANITE') + expect.eq(CR_FAILURE, status) + expect.str_find('No cursor', output) + end) +end + +function test.help_is_wrong_usage() + local _, status = dfhack.run_command_silent('changelayer', '?') + expect.eq(CR_WRONG_USAGE, status) +end diff --git a/test/plugins/cleanconst.lua b/test/plugins/cleanconst.lua new file mode 100644 index 0000000000..3d06657b18 --- /dev/null +++ b/test/plugins/cleanconst.lua @@ -0,0 +1,11 @@ +config.mode = 'fortress' +config.target = 'cleanconst' + +-- cleanconst scans all items for bogus construction flags. On a healthy map +-- it cleans nothing but still prints its summary; the value here is +-- exercising the full item scan for crashes. +function test.clean_run() + local output, status = dfhack.run_command_silent('cleanconst') + expect.eq(CR_OK, status) + expect.str_find('Done%. %d+ construction items cleaned up%.', output) +end diff --git a/test/plugins/cleaners.lua b/test/plugins/cleaners.lua new file mode 100644 index 0000000000..1136e69634 --- /dev/null +++ b/test/plugins/cleaners.lua @@ -0,0 +1,48 @@ +config.mode = 'fortress' +config.target = 'cleaners' + +-- the plugin's command is `clean` (the plugin binary is named cleaners). +-- each subcommand prints a summary only when it actually removed something, +-- so on a clean map the output is empty but the status is still CR_OK. + +local function expect_clean_ok(output, status, pattern) + expect.eq(CR_OK, status) + if #output > 0 then + expect.str_find(pattern, output) + end +end + +function test.clean_map() + local output, status = dfhack.run_command_silent('clean', 'map') + expect_clean_ok(output, status, 'Cleaned %d+ of %d+ map blocks%.') +end + +function test.clean_units() + local output, status = dfhack.run_command_silent('clean', 'units') + expect_clean_ok(output, status, 'Removed %d+ contaminants from %d+ creatures%.') +end + +function test.clean_items() + local output, status = dfhack.run_command_silent('clean', 'items') + expect_clean_ok(output, status, 'Removed %d+ contaminants from %d+ items%.') +end + +function test.clean_plants() + local output, status = dfhack.run_command_silent('clean', 'plants') + expect_clean_ok(output, status, 'Removed %d+ contaminants from %d+ plants%.') +end + +function test.clean_all() + local _, status = dfhack.run_command_silent('clean', 'all') + expect.eq(CR_OK, status) +end + +function test.no_args_is_wrong_usage() + local _, status = dfhack.run_command_silent('clean') + expect.eq(CR_WRONG_USAGE, status) +end + +function test.unknown_option_is_wrong_usage() + local _, status = dfhack.run_command_silent('clean', 'bogus') + expect.eq(CR_WRONG_USAGE, status) +end diff --git a/test/plugins/cleanowned.lua b/test/plugins/cleanowned.lua new file mode 100644 index 0000000000..78e4fc28a6 --- /dev/null +++ b/test/plugins/cleanowned.lua @@ -0,0 +1,20 @@ +config.mode = 'fortress' +config.target = 'cleanowned' + +-- `cleanowned dryrun` scans all owned items on the map and prints what it +-- would confiscate without mutating anything; the value here is exercising +-- the full item scan plus the per-item report path. +function test.dryrun_is_side_effect_free() + local _, status = dfhack.run_command_silent('cleanowned', 'dryrun') + expect.eq(CR_OK, status) +end + +function test.dryrun_with_filters() + local _, status = dfhack.run_command_silent('cleanowned', 'dryrun', 'scattered', 'x', 'X', 'all', 'nodump') + expect.eq(CR_OK, status) +end + +function test.unknown_option_is_wrong_usage() + local _, status = dfhack.run_command_silent('cleanowned', 'bogus') + expect.eq(CR_WRONG_USAGE, status) +end diff --git a/test/plugins/createitem.lua b/test/plugins/createitem.lua new file mode 100644 index 0000000000..cfb1b1b59b --- /dev/null +++ b/test/plugins/createitem.lua @@ -0,0 +1,95 @@ +config.mode = 'fortress' +config.target = 'createitem' + +local dwarfmode = require('gui.dwarfmode') + +local function find_floor_pos() + for _, block in ipairs(df.global.world.map.map_blocks) do + for x = 0, 15 do + for y = 0, 15 do + local tt = block.tiletype[x][y] + local des = block.designation[x][y] + local occ = block.occupancy[x][y] + if df.tiletype.attrs[tt].shape == df.tiletype_shape.FLOOR + and not des.hidden and occ.building == 0 then + return block.map_pos.x + x, block.map_pos.y + y, + block.map_pos.z + end + end + end + end +end + +local function set_cursor(x, y, z) + dwarfmode.setCursorPos(xyz2pos(x, y, z)) +end + +local function clear_cursor() + dwarfmode.clearCursorPos() +end + +local function count_items_at(x, y, z) + local n = 0 + for _, item in ipairs(df.global.world.items.other.IN_PLAY) do + -- item.pos is unreliable for contained items; getPosition is not + local ix, iy, iz = dfhack.items.getPosition(item) + if ix == x and iy == y and iz == z then + n = n + 1 + end + end + return n +end + +function test.creates_item_at_cursor() + local x, y, z = find_floor_pos() + expect.ne(nil, x, 'test needs a revealed floor tile') + local before = count_items_at(x, y, z) + + local created + return dfhack.with_finalize(function() + if created then dfhack.items.remove(created) end + clear_cursor() + dfhack.run_command_silent('createitem', 'floor') + end, function() + set_cursor(x, y, z) + local _, status = dfhack.run_command_silent('createitem', + 'BOULDER', 'INORGANIC:GRANITE') + expect.eq(CR_OK, status) + expect.eq(before + 1, count_items_at(x, y, z)) + + for _, item in ipairs(df.global.world.items.other.IN_PLAY) do + local ix, iy, iz = dfhack.items.getPosition(item) + if ix == x and iy == y and iz == z + and item:getType() == df.item_type.BOULDER then + created = item + break + end + end + expect.ne(nil, created, 'created boulder should exist') + end) +end + +function test.floor_mode_sets_destination() + local output, status = dfhack.run_command_silent('createitem', 'floor') + expect.eq(CR_OK, status) + expect.str_find('placed on the floor', output) +end + +function test.no_args_is_wrong_usage() + local _, status = dfhack.run_command_silent('createitem') + expect.eq(CR_WRONG_USAGE, status) +end + +function test.bad_item_type_is_failure() + local output, status = dfhack.run_command_silent('createitem', + 'BOGUS', 'INORGANIC:GRANITE') + expect.eq(CR_FAILURE, status) + expect.str_find('valid item type', output) +end + +function test.bad_material_is_failure() + local output, status = dfhack.run_command_silent('createitem', + 'BOULDER', 'BOGUSMAT') + expect.eq(CR_FAILURE, status) + expect.str_find('Unrecognized material', output) +end diff --git a/test/plugins/cursecheck.lua b/test/plugins/cursecheck.lua new file mode 100644 index 0000000000..bd701c071a --- /dev/null +++ b/test/plugins/cursecheck.lua @@ -0,0 +1,20 @@ +config.mode = 'fortress' +config.target = 'cursecheck' + +function test.reports_map_curse_count() + local output = dfhack.run_command_silent('cursecheck') + expect.str_find('%d+ cursed creatures on map', output) +end + +function test.help_shows_usage() + local output, status = dfhack.run_command_silent('cursecheck', 'help') + expect.true_(output:find('cursecheck', 1, true) ~= nil + or output:find('Usage', 1, true) ~= nil + or output:find('usage', 1, true) ~= nil + or status ~= 0) +end + +function test.detail_still_reports_count() + local output = dfhack.run_command_silent('cursecheck', 'detail') + expect.str_find('cursed creatures on map', output) +end diff --git a/test/plugins/deramp.lua b/test/plugins/deramp.lua new file mode 100644 index 0000000000..80d64d8ba8 --- /dev/null +++ b/test/plugins/deramp.lua @@ -0,0 +1,89 @@ +config.mode = 'fortress' +config.target = 'deramp' + +local function find_ramp_pos() + for _, block in ipairs(df.global.world.map.map_blocks) do + for x = 0, 15 do + for y = 0, 15 do + local tt = block.tiletype[x][y] + if df.tiletype.attrs[tt].shape == df.tiletype_shape.RAMP + and not block.designation[x][y].hidden then + return block, x, y + end + end + end + end +end + +local function tiletype_at(x, y, z) + return dfhack.maps.getTileType(x, y, z) +end + +-- getTileBlock returns live refs into the map data, so field writes +-- through it mutate the map directly +local function set_tiletype(x, y, z, tt) + dfhack.maps.getTileBlock(x, y, z).tiletype[x % 16][y % 16] = tt +end + +function test.removes_designated_ramp() + local block, x, y = find_ramp_pos() + expect.ne(nil, block, 'test needs a revealed ramp tile') + local ax, ay, az = block.map_pos.x + x, block.map_pos.y + y, + block.map_pos.z + + local des = block.designation[x][y] + local orig_dig = des.dig + -- ramps are two tiles tall; the usable-ramp cap is RampTop at z+1 + local above_before = tiletype_at(ax, ay, az + 1) + return dfhack.with_finalize(function() + block.designation[x][y].dig = orig_dig + end, function() + -- mark the ramp for removal, as if the player had designated it + des.dig = df.tile_dig_designation.Default + local _, status = dfhack.run_command_silent('deramp') + expect.eq(CR_OK, status) + local shape = df.tiletype.attrs[block.tiletype[x][y]].shape + expect.ne(df.tiletype_shape.RAMP, shape) + -- the tile above must be cleared iff it was the ramp's cap + local above_after = tiletype_at(ax, ay, az + 1) + if above_before == df.tiletype.RampTop then + expect.eq(df.tiletype.OpenSpace, above_after) + else + expect.eq(above_before, above_after) + end + end) +end + +function test.leaves_non_ramptop_above() + -- fabricate the second case: a ramp whose z+1 tile is not RampTop + local block, x, y = find_ramp_pos() + expect.ne(nil, block, 'test needs a revealed ramp tile') + local ax, ay, az = block.map_pos.x + x, block.map_pos.y + y, + block.map_pos.z + + local des = block.designation[x][y] + local orig_dig = des.dig + local orig_above = tiletype_at(ax, ay, az + 1) + return dfhack.with_finalize(function() + block.designation[x][y].dig = orig_dig + set_tiletype(ax, ay, az + 1, orig_above) + end, function() + set_tiletype(ax, ay, az + 1, + df.tiletype.StoneFloorSmooth) + des.dig = df.tile_dig_designation.Default + local _, status = dfhack.run_command_silent('deramp') + expect.eq(CR_OK, status) + expect.eq(df.tiletype.StoneFloorSmooth, + tiletype_at(ax, ay, az + 1)) + end) +end + +function test.no_designations_is_ok() + local _, status = dfhack.run_command_silent('deramp') + expect.eq(CR_OK, status) +end + +function test.arg_is_wrong_usage() + local _, status = dfhack.run_command_silent('deramp', 'bogus') + expect.eq(CR_WRONG_USAGE, status) +end diff --git a/test/plugins/dig-now.lua b/test/plugins/dig-now.lua new file mode 100644 index 0000000000..9a3ae9da86 --- /dev/null +++ b/test/plugins/dig-now.lua @@ -0,0 +1,96 @@ +config.mode = 'fortress' +config.target = 'dig-now' + +local dignow = require('plugins.dig-now') + +local function make_opts() + return { + boulder_percents={}, + dump_pos={}, + start={}, + ['end']={}, + } +end + +function test.clean_sets_zero_percentages() + local opts = make_opts() + dignow.parse_commandline(opts, '--clean') + expect.eq(0, opts.boulder_percents.layer) + expect.eq(0, opts.boulder_percents.vein) + expect.eq(0, opts.boulder_percents.small_cluster) + expect.eq(0, opts.boulder_percents.deep) +end + +function test.everywhere_sets_full_percentages() + local opts = make_opts() + dignow.parse_commandline(opts, '--everywhere') + expect.eq(100, opts.boulder_percents.layer) + expect.eq(100, opts.boulder_percents.deep) +end + +function test.percentages_custom_values() + local opts = make_opts() + dignow.parse_commandline(opts, '--percentages', '0,33,100,100') + expect.eq(0, opts.boulder_percents.layer) + expect.eq(33, opts.boulder_percents.vein) + expect.eq(100, opts.boulder_percents.small_cluster) + expect.eq(100, opts.boulder_percents.deep) +end + +function test.percentages_rejects_bad_values() + local opts = make_opts() + expect.error_match('invalid percentages', + function() dignow.parse_commandline(opts, '-p', '0,33,101,100') end) + expect.error_match('invalid percentages', + function() dignow.parse_commandline(opts, '-p', '0,33,-5,100') end) + expect.error(function() + dignow.parse_commandline(opts, '-p', '0,33,100') end) +end + +function test.coords_normalize_start_end() + local opts = make_opts() + dignow.parse_commandline(opts, '9,8,95', '3,4,94') + expect.eq(3, opts.start.x) + expect.eq(9, opts['end'].x) + expect.eq(4, opts.start.y) + expect.eq(8, opts['end'].y) + expect.eq(94, opts.start.z) + expect.eq(95, opts['end'].z) +end + +function test.single_coord_sets_same_end() + local opts = make_opts() + dignow.parse_commandline(opts, '5,6,94') + expect.eq(5, opts.start.x) + expect.eq(5, opts['end'].x) + expect.eq(6, opts['end'].y) + expect.eq(94, opts['end'].z) +end + +function test.cur_zlevel_covers_whole_map() + local opts = make_opts() + dignow.parse_commandline(opts, '--cur-zlevel') + local map = df.global.world.map + expect.eq(0, opts.start.x) + expect.eq(map.x_count - 1, opts['end'].x) + expect.eq(map.y_count - 1, opts['end'].y) + expect.eq(df.global.window_z, opts.start.z) + expect.eq(df.global.window_z, opts['end'].z) +end + +function test.help_flag_leaves_opts_untouched() + local opts = make_opts() + dignow.parse_commandline(opts, '--help') + expect.true_(opts.help) + expect.nil_(opts.start.x) + dignow.parse_commandline(opts, 'help') + expect.true_(opts.help) +end + +function test.dump_pos_parses_coords() + local opts = make_opts() + dignow.parse_commandline(opts, '--dump', '1,2,90') + expect.eq(1, opts.dump_pos.x) + expect.eq(2, opts.dump_pos.y) + expect.eq(90, opts.dump_pos.z) +end diff --git a/test/plugins/dig.lua b/test/plugins/dig.lua new file mode 100644 index 0000000000..7f19e7d8a5 --- /dev/null +++ b/test/plugins/dig.lua @@ -0,0 +1,30 @@ +config.mode = 'fortress' +config.target = 'dig' + +local gui = require('gui') +local overlay = require('plugins.overlay') + +local TestScreen = defclass(TestScreen, gui.ZScreen) +TestScreen.ATTRS{focus_path='quickfort'} + +function test.ascii_designations_visible_in_quickfort() + require('plugins.dig') + overlay.rescan() + local widget = overlay.get_state().db['dig.asciidesignated'].widget + expect.eq('dwarfmode/dfhack/lua/quickfort', + widget.viewscreens[#widget.viewscreens]) + + local render = mock.func() + local screen = TestScreen{} + screen:show() + dfhack.with_finalize( + function() screen:dismiss() end, + function() + mock.patch(widget, 'render', render, function() + overlay.render_viewscreen_widgets( + 'viewscreen_dwarfmodest', + dfhack.gui.getViewscreenByType(df.viewscreen_dwarfmodest, 0)) + end) + end) + expect.eq(1, render.call_count) +end diff --git a/test/plugins/dwarfvet.lua b/test/plugins/dwarfvet.lua new file mode 100644 index 0000000000..6e454a77d4 --- /dev/null +++ b/test/plugins/dwarfvet.lua @@ -0,0 +1,42 @@ +config.mode = 'fortress' +config.target = 'dwarfvet' + +local function status_output() + local output, status = dfhack.run_command_silent('dwarfvet', 'status') + expect.eq(CR_OK, status) + return output +end + +function test.status_reports_state_and_patients() + dfhack.run_command_silent('disable', 'dwarfvet') + local output = status_output() + expect.str_find('dwarfvet is not running', output) + expect.str_find('The following animals are receiving treatment:', output) + + dfhack.run_command_silent('enable', 'dwarfvet') + output = status_output() + expect.str_find('dwarfvet is running', output) + dfhack.run_command_silent('disable', 'dwarfvet') +end + +function test.bare_command_is_status() + local output, status = dfhack.run_command_silent('dwarfvet') + expect.eq(CR_OK, status) + expect.str_find('dwarfvet is ', output) +end + +function test.unknown_command_is_wrong_usage() + local _, status = dfhack.run_command_silent('dwarfvet', 'bogus') + expect.eq(CR_WRONG_USAGE, status) +end + +function test.now_runs_checkup() + -- checkup scans hospital zones and animal patients; safe on any fort + dfhack.run_command_silent('enable', 'dwarfvet') + return dfhack.with_finalize(function() + dfhack.run_command_silent('disable', 'dwarfvet') + end, function() + local _, status = dfhack.run_command_silent('dwarfvet', 'now') + expect.eq(CR_OK, status) + end) +end diff --git a/test/plugins/export-world-map.lua b/test/plugins/export-world-map.lua new file mode 100644 index 0000000000..dc914f2e8b --- /dev/null +++ b/test/plugins/export-world-map.lua @@ -0,0 +1,29 @@ +config.mode = 'fortress' +config.target = 'export-world-map' + +local export_dir = dfhack.getConfigPath() .. '/map-export' + +local function sites_csv() + local f = io.open(export_dir .. '/sites.csv') + if not f then return nil end + local content = f:read('*a') + f:close() + return content +end + +function test.sites_export_writes_csv() + return dfhack.with_finalize(function() + os.remove(export_dir .. '/sites.csv') + end, function() + local _, status = dfhack.run_command_silent('export-world-map', 'sites') + expect.eq(CR_OK, status) + local content = sites_csv() + expect.ne(nil, content, 'sites.csv was not written') + expect.true_(#content > 0, 'sites.csv is empty') + end) +end + +function test.unknown_topic_is_wrong_usage() + local _, status = dfhack.run_command_silent('export-world-map', 'bogus-topic') + expect.eq(CR_WRONG_USAGE, status) +end diff --git a/test/plugins/filltraffic.lua b/test/plugins/filltraffic.lua new file mode 100644 index 0000000000..9257e71106 --- /dev/null +++ b/test/plugins/filltraffic.lua @@ -0,0 +1,119 @@ +config.mode = 'fortress' +config.target = 'filltraffic' + +local dwarfmode = require('gui.dwarfmode') + +local function find_floor_pos() + for _, block in ipairs(df.global.world.map.map_blocks) do + for x = 0, 15 do + for y = 0, 15 do + local tt = block.tiletype[x][y] + local shape = df.tiletype.attrs[tt].shape + local des = block.designation[x][y] + local occ = block.occupancy[x][y] + if shape == df.tiletype_shape.FLOOR and not des.hidden + and occ.building == 0 then + return block.map_pos.x + x, block.map_pos.y + y, + block.map_pos.z + end + end + end + end +end + +local function traffic_at(x, y, z) + return dfhack.maps.getTileBlock(x, y, z).designation[x % 16][y % 16].traffic +end + +local function set_cursor(x, y, z) + dwarfmode.setCursorPos(xyz2pos(x, y, z)) +end + +local function clear_cursor() + dwarfmode.clearCursorPos() +end + +function test.fill_high_then_restore() + local x, y, z = find_floor_pos() + expect.ne(nil, x, 'test needs a revealed floor tile') + + return dfhack.with_finalize(function() + set_cursor(x, y, z) + -- always leave the tile normal + dfhack.run_command_silent('filltraffic', 'N') + clear_cursor() + end, function() + set_cursor(x, y, z) + local _, status = dfhack.run_command_silent('filltraffic', 'H') + expect.eq(CR_OK, status) + expect.eq(df.tile_traffic.High, traffic_at(x, y, z)) + + local _, status2 = dfhack.run_command_silent('filltraffic', 'N') + expect.eq(CR_OK, status2) + expect.eq(df.tile_traffic.Normal, traffic_at(x, y, z)) + end) +end + +function test.same_type_is_failure() + local x, y, z = find_floor_pos() + expect.ne(nil, x) + + return dfhack.with_finalize(clear_cursor, function() + set_cursor(x, y, z) + -- the tile is already Normal, so filling Normal is a no-op failure + local output, status = dfhack.run_command_silent('filltraffic', 'N') + expect.eq(CR_FAILURE, status) + expect.str_find('already set to the target', output) + end) +end + +function test.no_cursor_is_failure() + clear_cursor() + -- the cursor_hotkey command guard rejects before the plugin runs + local output, status = dfhack.run_command_silent('filltraffic', 'H') + expect.eq(CR_WRONG_USAGE, status) + expect.str_find('unsuitable UI state', output) +end + +function test.wall_is_failure() + -- find a revealed wall tile + local wx, wy, wz + for _, block in ipairs(df.global.world.map.map_blocks) do + for x = 0, 15 do + for y = 0, 15 do + local tt = block.tiletype[x][y] + if df.tiletype.attrs[tt].shape == df.tiletype_shape.WALL + and not block.designation[x][y].hidden then + wx, wy, wz = block.map_pos.x + x, + block.map_pos.y + y, block.map_pos.z + end + end + end + end + expect.ne(nil, wx, 'test needs a revealed wall tile') + + return dfhack.with_finalize(clear_cursor, function() + set_cursor(wx, wy, wz) + local output, status = dfhack.run_command_silent('filltraffic', 'H') + expect.eq(CR_FAILURE, status) + expect.str_find('wall', output) + end) +end + +function test.bad_option_is_wrong_usage() + local _, status = dfhack.run_command_silent('filltraffic', 'Q') + expect.eq(CR_WRONG_USAGE, status) +end + +function test.multi_char_option_is_wrong_usage() + local _, status = dfhack.run_command_silent('filltraffic', 'HH') + expect.eq(CR_WRONG_USAGE, status) +end + +function test.restrictice_and_restrictliquids() + -- restrict traffic on ice/liquid tiles; safe full-map passes + local _, status = dfhack.run_command_silent('restrictice') + expect.eq(CR_OK, status) + local _, status2 = dfhack.run_command_silent('restrictliquids') + expect.eq(CR_OK, status2) +end diff --git a/test/plugins/fix-occupancy.lua b/test/plugins/fix-occupancy.lua new file mode 100644 index 0000000000..d91b4b943d --- /dev/null +++ b/test/plugins/fix-occupancy.lua @@ -0,0 +1,30 @@ +config.mode = 'fortress' +config.target = 'fix-occupancy' + +local fo = require('plugins.fix-occupancy') + +local function any_map_pos() + local block = df.global.world.map.map_blocks[0] + return xyz2pos(block.map_pos.x + 8, block.map_pos.y + 8, + block.map_pos.z) +end + +function test.fix_map_dry_run_leaves_occupancy() + local pos = any_map_pos() + local block = dfhack.maps.getTileBlock(pos) + local before = block.occupancy[pos.x % 16][pos.y % 16].whole + fo.fix_map(true) + expect.eq(before, block.occupancy[pos.x % 16][pos.y % 16].whole) +end + +function test.fix_tile_dry_run() + local pos = any_map_pos() + fo.fix_tile(pos, true) + -- smoke: ran without error + expect.true_(true) +end + +function test.fix_map_run() + fo.fix_map(false) + expect.true_(true) +end diff --git a/test/plugins/flows.lua b/test/plugins/flows.lua new file mode 100644 index 0000000000..7973f905f9 --- /dev/null +++ b/test/plugins/flows.lua @@ -0,0 +1,10 @@ +config.mode = 'fortress' +config.target = 'flows' + +function test.counts_liquids() + local output, status = dfhack.run_command_silent('flows') + expect.eq(CR_OK, status) + expect.str_find('Blocks with liquid_1', output) + expect.str_find('Water tiles:', output) + expect.str_find('Magma tiles:', output) +end diff --git a/test/plugins/getplants.lua b/test/plugins/getplants.lua new file mode 100644 index 0000000000..95012e7769 --- /dev/null +++ b/test/plugins/getplants.lua @@ -0,0 +1,123 @@ +config.target = 'getplants' +config.mode = 'fortress' + +local function find_raw(id) + for _, raw in ipairs(df.global.world.raws.plants.all) do + if raw.id == id then return raw end + end +end + +local function run(...) + return dfhack.run_command_silent('getplants', ...) +end + +-- count shrubs carrying a gather designation (designation tile == plant tile +-- for shrubs, unlike trees) +local function count_marked(mat_idx) + local n = 0 + for _, p in ipairs(df.global.world.plants.all) do + if (mat_idx == nil or p.material == mat_idx) and + df.plant_type.attrs[p.type].is_shrub + then + local blk = dfhack.maps.getTileBlock(p.pos) + if blk and blk.designation[p.pos.x % 16][p.pos.y % 16].dig == + df.tile_dig_designation.Default + then + n = n + 1 + end + end + end + return n +end + +local function mat_has_product(mat, product) + if not mat then return false end + for _, id in ipairs(mat.reaction_product.id) do + if id.value == product then return true end + end + return false +end + +-- independent reimplementation of the brewable check used by --brewable +local function raw_is_brewable(raw) + local mi = dfhack.matinfo.decode( + raw.material_defs.type[df.plant_material_def.basic_mat], + raw.material_defs.idx[df.plant_material_def.basic_mat]) + if mat_has_product(mi.material, 'DRINK_MAT') then return true end + for _, g in ipairs(raw.growths) do + local gm = dfhack.matinfo.decode(g.mat_type, g.mat_index) + if mat_has_product(gm.material, 'DRINK_MAT') then return true end + end + return false +end + +function test.dry_run() + local before = count_marked() + local out = run('-s', '-a', '-n', '2', '-d') + local n_dry = tonumber(out:match('Would update (%d+) plant designations')) + expect.ne(nil, n_dry) + -- nothing may have been designated + expect.eq(before, count_marked()) + + -- a real run must update exactly the number the dry run reported + local out_real = run('-s', '-a', '-n', '2') + local n_real = tonumber(out_real:match('Updated (%d+) plant designations')) + expect.eq(n_dry, n_real) + expect.eq(before + n_real, count_marked()) + + -- dry-run clear reports the currently marked count without clearing + local out_cdry = run('-s', '-a', '-c', '-d') + local n_cdry = tonumber(out_cdry:match('Would update (%d+) plant designations')) + expect.eq(count_marked(), n_cdry) + expect.eq(before + n_real, count_marked()) + + local out_clear = run('-s', '-a', '-c') + local n_clear = tonumber(out_clear:match('Updated (%d+) plant designations')) + expect.eq(n_cdry, n_clear) + expect.eq(0, count_marked()) +end + +function test.trait_filters() + -- find a brewable and a non-brewable non-tree non-grass species + local brewable_id, other_id + for _, raw in ipairs(df.global.world.raws.plants.all) do + if not raw.flags.TREE and not raw.flags.GRASS then + if raw_is_brewable(raw) then + brewable_id = brewable_id or raw.id + else + other_id = other_id or raw.id + end + end + end + expect.ne(nil, brewable_id) + expect.ne(nil, other_id) + + -- a matching species is accepted by the filter + local out = run(brewable_id, '--brewable', '-d') + expect.str_find('Would update', out) + + -- a non-matching species is rejected with a specific message + local out2 = run(other_id, '--brewable', '-d') + expect.str_find('does not match the specified trait filters', out2) + + -- every species in the filtered ID listing must be brewable + local listed = run('--brewable') + local listed_count = 0 + for id in listed:gmatch('%* %(%a+%) ([%w_%-]+)') do + listed_count = listed_count + 1 + local raw = find_raw(id) + expect.true_(raw and raw_is_brewable(raw), id) + end + expect.true_(listed_count > 0) +end + +function test.cloth_listing() + local listed = run('--cloth') + local listed_count = 0 + for id in listed:gmatch('%* %(%a+%) ([%w_%-]+)') do + listed_count = listed_count + 1 + local raw = find_raw(id) + expect.true_(raw and raw.flags.THREAD, id) + end + expect.true_(listed_count > 0) +end diff --git a/test/plugins/infinite-sky.lua b/test/plugins/infinite-sky.lua new file mode 100644 index 0000000000..a799f79ebd --- /dev/null +++ b/test/plugins/infinite-sky.lua @@ -0,0 +1,29 @@ +config.mode = 'fortress' +config.target = 'infinite-sky' + +function test.status() + local output = dfhack.run_command_silent('infinite-sky') + expect.str_find('Construction monitoring is', output) +end + +function test.help_shows_usage() + local output, status = dfhack.run_command_silent('infinite-sky', 'help') + expect.true_(output:find('infinite%-sky') ~= nil + or output:find('z%-level', 1, true) ~= nil + or output:find('Usage', 1, true) ~= nil + or output:find('usage', 1, true) ~= nil) +end + +function test.rejects_non_numeric() + local output, status = dfhack.run_command_silent('infinite-sky', 'bogus') + expect.true_(output:find('positive integer', 1, true) ~= nil + or output:find('invalid', 1, true) ~= nil + or status ~= 0) +end + +function test.rejects_non_positive() + local output, status = dfhack.run_command_silent('infinite-sky', '-3') + expect.true_(output:find('positive integer', 1, true) ~= nil + or output:find('invalid', 1, true) ~= nil + or status ~= 0) +end diff --git a/test/plugins/kittens.lua b/test/plugins/kittens.lua new file mode 100644 index 0000000000..9c3cbd2fac --- /dev/null +++ b/test/plugins/kittens.lua @@ -0,0 +1,31 @@ +config.mode = 'fortress' +config.target = 'kittens' + +-- kittens is a developer plugin and is not built unless BUILD_DEV_PLUGINS is +-- on, so these tests are skipped on builds where throwtest is unavailable +local function has_throwtest() + for _, plugin in ipairs(dfhack.internal.listPlugins()) do + if plugin == 'kittens' then + for _, command in ipairs(dfhack.internal.listCommands(plugin)) do + if command == 'throwtest' then + return true + end + end + end + end + return false +end + +function test.exception_returns_failure() + if not has_throwtest() then return end + local output, status = dfhack.run_command_silent('throwtest') + expect.eq(CR_FAILURE, status) + expect.str_find('test exception', output) +end + +function test.nonstd_exception_returns_failure() + if not has_throwtest() then return end + local output, status = dfhack.run_command_silent('throwtest nonstd') + expect.eq(CR_FAILURE, status) + expect.str_find("Exception in kittens command", output) +end diff --git a/test/plugins/liquids.lua b/test/plugins/liquids.lua new file mode 100644 index 0000000000..3b121a06c2 --- /dev/null +++ b/test/plugins/liquids.lua @@ -0,0 +1,341 @@ +config.mode = 'fortress' +config.target = 'liquids' + +local dwarfmode = require('gui.dwarfmode') +local liquids = require('plugins.liquids') + +local function find_floor_pos() + for _, block in ipairs(df.global.world.map.map_blocks) do + for x = 0, 15 do + for y = 0, 15 do + local tt = block.tiletype[x][y] + local des = block.designation[x][y] + local occ = block.occupancy[x][y] + if df.tiletype.attrs[tt].shape == df.tiletype_shape.FLOOR + and not des.hidden and des.flow_size == 0 + and occ.building == 0 then + return block.map_pos.x + x, block.map_pos.y + y, + block.map_pos.z + end + end + end + end +end + +local function set_cursor(x, y, z) + dwarfmode.setCursorPos(xyz2pos(x, y, z)) +end + +local function clear_cursor() + dwarfmode.clearCursorPos() +end + +-- wait for any screen above the map to go away (e.g. a lingering load +-- screen, or the prerelease warning shown by dev builds on map load) +-- so that commands guarded by the cursor_hotkey UI check can run +local function ensure_dwarfmode_view() + delay_until(function() + local top = dfhack.gui.getCurViewscreen() + if df.viewscreen_dwarfmodest:is_instance(top) then return true end + if type(top.dismiss) == 'function' then + top:dismiss() + end + return false + end) +end + +local function des_at(x, y, z) + return select(1, dfhack.maps.getTileFlags(x, y, z)) +end + +function test.liquids_here_spawns_magma_at_cursor() + local x, y, z = find_floor_pos() + expect.ne(nil, x, 'test needs a revealed dry floor tile') + + local was_paused = dfhack.world.ReadPauseState() + return dfhack.with_finalize(function() + local des = des_at(x, y, z) + des.flow_size = 0 + dfhack.world.SetPauseState(was_paused) + clear_cursor() + end, function() + -- pause so the magma cannot spread before we clean it up + dfhack.world.SetPauseState(true) + ensure_dwarfmode_view() + set_cursor(x, y, z) + + local _, status = dfhack.run_command_silent('liquids-here') + expect.eq(CR_OK, status) + local des = des_at(x, y, z) + expect.eq(7, des.flow_size) + -- liquid_type is a 1-bit field exposed to Lua as a boolean + expect.eq(true, des.liquid_type) + end) +end + +local OPEN_SHAPES = { + [df.tiletype_shape.EMPTY] = true, + [df.tiletype_shape.RAMP_TOP] = true, +} + +-- find an open tile directly above another open, sky-lit tile +local function find_stacked_open_tiles() + for _, block in ipairs(df.global.world.map.map_blocks) do + local below = block.map_pos.z >= 1 and + dfhack.maps.getTileBlock(block.map_pos.x, block.map_pos.y, + block.map_pos.z - 1) or nil + if below then + for x = 0, 15 do + for y = 0, 15 do + local des_below = below.designation[x][y] + if des_below.light and des_below.outside + and OPEN_SHAPES[df.tiletype.attrs[ + block.tiletype[x][y]].shape] + and OPEN_SHAPES[df.tiletype.attrs[ + below.tiletype[x][y]].shape] then + return block.map_pos.x + x, block.map_pos.y + y, + block.map_pos.z + end + end + end + end + end +end + +-- find an open, sky-lit tile in the topmost allocated block of a map +-- column whose column to the east also reaches that z (needed by the +-- range test) +local function find_sky_top_pos() + for _, block in ipairs(df.global.world.map.map_blocks) do + local bx, by, bz = block.map_pos.x, block.map_pos.y, block.map_pos.z + if not dfhack.maps.getTileBlock(bx, by, bz + 1) + and dfhack.maps.getTileBlock(bx + 16, by, bz) then + for x = 0, 15 do + for y = 0, 15 do + local tt = block.tiletype[x][y] + local des = block.designation[x][y] + if df.tiletype.attrs[tt].shape == df.tiletype_shape.EMPTY + and des.light and des.outside then + return bx + x, by + y, bz + end + end + end + end + end +end + +-- detach a block from block_index so its position reads as unallocated; +-- ensureTileBlock can then materialize it from the block below in the +-- same column. returns a function that restores the original block +local function unallocate_block(x, y, z) + local column = df.global.world.map.block_index + [math.floor(x / 16)][math.floor(y / 16)] + local block = column[z] + column[z] = nil + expect.ne(nil, block, 'test needs an allocated block to detach') + expect.eq(nil, dfhack.maps.getTileBlock(x, y, z), + 'test setup could not detach the block') + return function() column[z] = block end +end + +-- snapshot the light/outside/subterranean flags of the open column below +-- pos; paint may propagate coverage down through all of it +local function snapshot_column_flags(x, y, z) + local column = {} + for dz = z, 0, -1 do + local b = dfhack.maps.getTileBlock(x, y, dz) + if not b or not OPEN_SHAPES[ + df.tiletype.attrs[b.tiletype[x % 16][y % 16]].shape] then + break + end + local d = b.designation[x % 16][y % 16] + column[dz] = {block=b, light=d.light, outside=d.outside, + subterranean=d.subterranean} + end + return column +end + +local function restore_column_flags(x, y, column) + for dz, orig in pairs(column) do + local d = orig.block.designation[x % 16][y % 16] + d.light = orig.light + d.outside = orig.outside + d.subterranean = orig.subterranean + end +end + +function test.paint_obsidian_covers_tiles_below() + local x, y, z = find_stacked_open_tiles() + expect.ne(nil, x, 'test needs vertically adjacent open, lit tiles') + + local block = dfhack.maps.getTileBlock(x, y, z) + local orig_tt = block.tiletype[x % 16][y % 16] + local orig_des_whole = block.designation[x % 16][y % 16].whole + local column = snapshot_column_flags(x, y, z - 1) + + return dfhack.with_finalize(function() + block.tiletype[x % 16][y % 16] = orig_tt + block.designation[x % 16][y % 16].whole = orig_des_whole + restore_column_flags(x, y, column) + end, function() + liquids.paint(xyz2pos(x, y, z), 'point', 'obsidian') + + expect.eq(df.tiletype.LavaWall, dfhack.maps.getTileType(x, y, z)) + local des = des_at(x, y, z - 1) + expect.false_(des.light) + expect.false_(des.outside) + end) +end + +function test.paint_obsidian_floor_covers_tiles_below() + local x, y, z = find_stacked_open_tiles() + expect.ne(nil, x, 'test needs vertically adjacent open, lit tiles') + + local block = dfhack.maps.getTileBlock(x, y, z) + local orig_tt = block.tiletype[x % 16][y % 16] + local orig_des_whole = block.designation[x % 16][y % 16].whole + local column = snapshot_column_flags(x, y, z - 1) + + return dfhack.with_finalize(function() + block.tiletype[x % 16][y % 16] = orig_tt + block.designation[x % 16][y % 16].whole = orig_des_whole + restore_column_flags(x, y, column) + end, function() + liquids.paint(xyz2pos(x, y, z), 'point', 'obsidian_floor') + + local tt = dfhack.maps.getTileType(x, y, z) + expect.eq(df.tiletype_shape.FLOOR, df.tiletype.attrs[tt].shape) + expect.eq(df.tiletype_material.LAVA_STONE, + df.tiletype.attrs[tt].material) + local des = des_at(x, y, z - 1) + expect.false_(des.light) + expect.false_(des.outside) + end) +end + +function test.paint_river_source_covers_tiles_below() + local x, y, z = find_stacked_open_tiles() + expect.ne(nil, x, 'test needs vertically adjacent open, lit tiles') + + local block = dfhack.maps.getTileBlock(x, y, z) + local orig_tt = block.tiletype[x % 16][y % 16] + local orig_des_whole = block.designation[x % 16][y % 16].whole + local column = snapshot_column_flags(x, y, z - 1) + + local was_paused = dfhack.world.ReadPauseState() + return dfhack.with_finalize(function() + block.tiletype[x % 16][y % 16] = orig_tt + block.designation[x % 16][y % 16].whole = orig_des_whole + restore_column_flags(x, y, column) + dfhack.world.SetPauseState(was_paused) + end, function() + -- pause so the spawned water cannot spread before we clean it up + dfhack.world.SetPauseState(true) + liquids.paint(xyz2pos(x, y, z), 'point', 'riversource') + + expect.eq(df.tiletype.RiverSource, dfhack.maps.getTileType(x, y, z)) + local des = des_at(x, y, z - 1) + expect.false_(des.light) + expect.false_(des.outside) + end) +end + +function test.paint_water_allocates_sky_block() + local x, y, z = find_sky_top_pos() + expect.ne(nil, x, 'test needs a top-of-column sky block') + + -- detach the block so its position is unallocated; the paint should + -- recreate it via ensureTileBlock + local restore_block = unallocate_block(x, y, z) + + local was_paused = dfhack.world.ReadPauseState() + return dfhack.with_finalize(function() + -- swap the original block back in; the block allocated by the + -- paint remains in map_blocks but is no longer referenced + restore_block() + dfhack.world.SetPauseState(was_paused) + end, function() + -- pause so the spawned water cannot spread before we clean it up + dfhack.world.SetPauseState(true) + liquids.paint(xyz2pos(x, y, z), 'point', 'water', 7) + + local block = dfhack.maps.getTileBlock(x, y, z) + expect.ne(nil, block, 'paint did not allocate the block') + local des = des_at(x, y, z) + expect.eq(7, des.flow_size) + -- liquid_type is a 1-bit field exposed to Lua as a boolean + expect.eq(false, des.liquid_type) + end) +end + +function test.paint_obsidian_allocates_sky_block() + local x, y, z = find_sky_top_pos() + expect.ne(nil, x, 'test needs a top-of-column sky block') + + local restore_block = unallocate_block(x, y, z) + + -- the painted wall also covers open tiles in the block below + local column = snapshot_column_flags(x, y, z - 1) + return dfhack.with_finalize(function() + restore_block() + restore_column_flags(x, y, column) + end, function() + liquids.paint(xyz2pos(x, y, z), 'point', 'obsidian') + + local block = dfhack.maps.getTileBlock(x, y, z) + expect.ne(nil, block, 'paint did not allocate the block') + expect.eq(df.tiletype.LavaWall, dfhack.maps.getTileType(x, y, z)) + end) +end + +function test.paint_water_range_into_unallocated_sky() + local x, y, z = find_sky_top_pos() + expect.ne(nil, x, 'test needs adjacent top-of-column sky blocks') + + -- the range brush paints forward from the cursor, so a 4x1x1 range + -- starting near the block edge paints two tiles in each block + local bx = x - (x % 16) + local x0, y0 = bx + 14, y + local restore_west = unallocate_block(bx, y0, z) + local restore_east = unallocate_block(bx + 16, y0, z) + + local was_paused = dfhack.world.ReadPauseState() + return dfhack.with_finalize(function() + restore_east() + restore_west() + dfhack.world.SetPauseState(was_paused) + end, function() + -- pause so the spawned water cannot spread before we clean it up + dfhack.world.SetPauseState(true) + liquids.paint(xyz2pos(x0, y0, z), 'range', 'water', 5, + xyz2pos(4, 1, 1)) + + for dx = 0, 3 do + local des = des_at(x0 + dx, y0, z) + expect.eq(5, des.flow_size, + ('tile (%d,%d,%d)'):format(x0 + dx, y0, z)) + end + end) +end + +function test.liquids_needs_console() + -- the interactive liquid spawner requires a console; the dispatcher + -- rejects non-console invocations before the plugin runs + local _, status = dfhack.run_command_silent('liquids') + expect.eq(CR_NEEDS_CONSOLE, status) +end + +function test.liquids_here_help_is_wrong_usage() + local _, status = dfhack.run_command_silent('liquids-here', '?') + expect.eq(CR_WRONG_USAGE, status) +end + +function test.liquids_here_no_cursor_is_failure() + return dfhack.with_finalize(clear_cursor, function() + clear_cursor() + -- the cursor_hotkey command guard rejects before the plugin runs + local output, status = dfhack.run_command_silent('liquids-here') + expect.eq(CR_WRONG_USAGE, status) + expect.str_find('unsuitable UI state', output) + end) +end diff --git a/test/plugins/logistics.lua b/test/plugins/logistics.lua new file mode 100644 index 0000000000..cb942ed10e --- /dev/null +++ b/test/plugins/logistics.lua @@ -0,0 +1,125 @@ +config.mode = 'fortress' +config.target = 'logistics' + +local logistics = require('plugins.logistics') + +local saved_autoretrain = logistics.logistics_getFeature('autoretrain') +local saved_configs = {} + +config.wrapper = function(test_fn) + return dfhack.with_finalize(function() + logistics.logistics_setFeature(saved_autoretrain, 'autoretrain') + for sp_number, configs in pairs(saved_configs) do + for _,c in ipairs(configs) do + logistics.logistics_setStockpileConfig( + c.stockpile_number, c.melt == 1, c.trade == 1, + c.dump == 1, c.train == 1, c.forbid, + c.melt_masterworks == 1) + end + end + end, test_fn) +end + +local function snapshot_config(sp_number) + if saved_configs[sp_number] then return end + saved_configs[sp_number] = + logistics.logistics_getStockpileConfigs(sp_number) or {} +end + +local function get_first_stockpile() + local stockpiles = df.global.world.buildings.other.STOCKPILE + if #stockpiles == 0 then return nil end + return stockpiles[0].stockpile_number +end + +local function get_stats_for(sp_number) + for _,sp in ipairs(logistics.getStockpileData().sp_stats) do + if sp.sp_number == sp_number then return sp end + end +end + +function test.help() + expect.false_(logistics.parse_commandline{'help'}) + expect.false_(logistics.parse_commandline{'-h'}) +end + +function test.status() + expect.true_(logistics.parse_commandline{}) + expect.true_(logistics.parse_commandline{'status'}) +end + +function test.unrecognized_command() + expect.false_(logistics.parse_commandline{'bogus'}) +end + +function test.autoretrain_toggle() + expect.true_(logistics.parse_commandline{'enable', 'autoretrain'}) + expect.true_(logistics.logistics_getFeature('autoretrain')) + expect.true_(logistics.parse_commandline{'disable', 'autoretrain'}) + expect.false_(logistics.logistics_getFeature('autoretrain')) +end + +function test.unknown_feature() + expect.error_match('unknown feature', function() + logistics.parse_commandline{'enable', 'bogus_feature'} end) +end + +function test.missing_feature() + expect.error(function() + logistics.parse_commandline{'enable'} end) +end + +function test.getStockpileData() + local data = logistics.getStockpileData() + expect.eq(#df.global.world.buildings.other.STOCKPILE, + #data.sp_stats) + -- totals are the sum of the per-stockpile stats + for _,desig in ipairs{'melt', 'trade', 'dump', 'train', 'forbid'} do + local designated, designatable = 0, 0 + for _,sp in ipairs(data.sp_stats) do + designated = designated + sp[desig].designated + designatable = designatable + sp[desig].designatable + end + expect.eq(designated, data.sp_totals[desig].designated) + expect.eq(designatable, data.sp_totals[desig].designatable) + end +end + +function test.add_and_clear_config() + local sp_number = get_first_stockpile() + if not sp_number then return end + snapshot_config(sp_number) + expect.true_(logistics.parse_commandline{ + 'add', 'melt', '-s', tostring(sp_number)}) + local sp = get_stats_for(sp_number) + expect.true_(sp.melt.enabled) + expect.false_(sp.trade.enabled) + expect.true_(logistics.parse_commandline{ + 'add', 'trade', 'dump', '-s', tostring(sp_number)}) + sp = get_stats_for(sp_number) + expect.true_(sp.melt.enabled) + expect.true_(sp.trade.enabled) + expect.true_(sp.dump.enabled) + expect.true_(logistics.parse_commandline{ + 'clear', '-s', tostring(sp_number)}) + sp = get_stats_for(sp_number) + expect.false_(sp.melt.enabled) + expect.false_(sp.trade.enabled) + expect.false_(sp.dump.enabled) +end + +function test.add_invalid_stockpile() + expect.printerr_match('invalid stockpile', function() + logistics.parse_commandline{'add', 'melt', '-s', '999999'} end) +end + +function test.cycle() + -- a cycle can designate items if any stockpile configs are enabled; + -- only run it when the fort has none so no state is mutated + for _,desig in ipairs{'melt', 'trade', 'dump', 'train', 'forbid', 'claim'} do + if logistics.getStockpileData().sp_totals[desig].enabled_count > 0 then + return + end + end + expect.true_(logistics.parse_commandline{'now'}) +end diff --git a/test/plugins/misery.lua b/test/plugins/misery.lua new file mode 100644 index 0000000000..82b38d3621 --- /dev/null +++ b/test/plugins/misery.lua @@ -0,0 +1,60 @@ +config.mode = 'fortress' +config.target = 'misery' + +local misery = require('plugins.misery') + +local saved_factor = misery.misery_getFactor() + +config.wrapper = function(test_fn) + return dfhack.with_finalize(function() + if saved_factor >= 2 then + misery.misery_setFactor(saved_factor) + end + end, test_fn) +end + +function test.status() + expect.true_(misery.parse_commandline('status')) + expect.true_(misery.parse_commandline()) +end + +function test.help() + expect.false_(misery.parse_commandline('help')) + expect.false_(misery.parse_commandline('-h')) + expect.false_(misery.parse_commandline('--help')) +end + +function test.set_factor() + expect.true_(misery.parse_commandline('5')) + expect.eq(5, misery.misery_getFactor()) + expect.true_(misery.parse_commandline('2')) + expect.eq(2, misery.misery_getFactor()) +end + +function test.set_factor_below_minimum() + misery.misery_setFactor(5) + -- the C++ layer rejects the value with a console error message rather + -- than failing the command + misery.misery_setFactor(1) + expect.eq(5, misery.misery_getFactor()) + expect.true_(misery.parse_commandline('1')) + expect.eq(5, misery.misery_getFactor()) +end + +function test.clear() + expect.true_(misery.parse_commandline('clear')) +end + +function test.unrecognized_command() + expect.false_(misery.parse_commandline('bogus')) +end + +function test.status_output() + misery.misery_setFactor(5) + local lines = {} + mock.patch({{misery, 'print', function(line) + table.insert(lines, line) end}}, function() + misery.status() + end) + expect.str_find('misery factor is: 5', table.concat(lines, '\n')) +end diff --git a/test/plugins/nestboxes.lua b/test/plugins/nestboxes.lua new file mode 100644 index 0000000000..86a3f42265 --- /dev/null +++ b/test/plugins/nestboxes.lua @@ -0,0 +1,43 @@ +config.mode = 'fortress' +config.target = 'nestboxes' + +-- burrow config is persisted to the site; restore whatever was set +local saved_burrow_name + +config.wrapper = function(test_fn) + local output = dfhack.run_command_silent('nestboxes') + saved_burrow_name = output:match('inside burrow: ([^\n]+)') + return dfhack.with_finalize(function() + if saved_burrow_name then + dfhack.run_command_silent('nestboxes', 'burrow', + saved_burrow_name) + else + dfhack.run_command_silent('nestboxes', 'all') + end + end, test_fn) +end + +function test.status_reports_protection_scope() + local output = dfhack.run_command_silent('nestboxes') + expect.true_(output:find('nestboxes is', 1, true) ~= nil) + expect.true_(output:find('protecting', 1, true) ~= nil + or output:find('disabled', 1, true) ~= nil) +end + +function test.burrow_unknown_name_clears_scope() + dfhack.run_command_silent('nestboxes', 'burrow', + 'dfhack-test-nonexistent-burrow') + local output = dfhack.run_command_silent('nestboxes') + expect.true_(output:find('inside burrow', 1, true) == nil) +end + +function test.all_clears_burrow_scope() + dfhack.run_command_silent('nestboxes', 'all') + local output = dfhack.run_command_silent('nestboxes') + expect.true_(output:find('inside burrow', 1, true) == nil) +end + +function test.unknown_command_shows_usage() + local output, status = dfhack.run_command_silent('nestboxes', 'bogus') + expect.true_(output:find('nestboxes', 1, true) ~= nil or status ~= 0) +end diff --git a/test/plugins/orders.lua b/test/plugins/orders.lua index ab2ad3235e..dab396ba96 100644 --- a/test/plugins/orders.lua +++ b/test/plugins/orders.lua @@ -1,7 +1,7 @@ config.mode = 'fortress' config.target = 'orders' -local FILE_PATH_PATTERN = 'dfhack-config/orders/%s.json' +local FILE_PATH_PATTERN = dfhack.getConfigPath() .. '/orders/%s.json' local BACKUP_FILE_NAME = 'tmp-backup' local BACKUP_FILE_PATH = FILE_PATH_PATTERN:format(BACKUP_FILE_NAME) @@ -190,6 +190,7 @@ function test.import_export_reaction_condition() } ], "job" : "CustomReaction", + "name" : "Make soap from tallow", "reaction" : "MAKE_SOAP_FROM_TALLOW" } ] diff --git a/test/plugins/overlay.lua b/test/plugins/overlay.lua new file mode 100644 index 0000000000..0f7e5d1a48 --- /dev/null +++ b/test/plugins/overlay.lua @@ -0,0 +1,47 @@ +config.mode = 'fortress' +config.target = 'overlay' + +local overlay = require('plugins.overlay') + +-- overlay enable state persists via save_config; restore originals +local saved_enabled + +config.wrapper = function(test_fn) + saved_enabled = {} + return dfhack.with_finalize(function() + for name, enabled in pairs(saved_enabled) do + dfhack.run_command_silent('overlay', + enabled and 'enable' or 'disable', + name) + end + end, test_fn) +end + +local function save_widget_state(name) + local cfg = overlay.get_state().config[name] + saved_enabled[name] = cfg and cfg.enabled or false +end + +-- overlay_command suppresses prints under run_command_silent (quiet +-- flag), so verify state changes directly instead of output +function test.list_succeeds() + local output, status = dfhack.run_command_silent('overlay', 'list') + expect.eq(0, status) +end + +function test.enable_disable_widget() + overlay.rescan() + local db = overlay.get_state().db + local name = next(db) + expect.ne(nil, name) + save_widget_state(name) + dfhack.run_command_silent('overlay', 'disable', name) + expect.false_(overlay.get_state().config[name].enabled) + dfhack.run_command_silent('overlay', 'enable', name) + expect.true_(overlay.get_state().config[name].enabled) +end + +function test.unknown_command_shows_usage() + local output, status = dfhack.run_command_silent('overlay', 'bogus') + expect.true_(output:find('overlay', 1, true) ~= nil or status ~= 0) +end diff --git a/test/plugins/plant.lua b/test/plugins/plant.lua new file mode 100644 index 0000000000..2079713505 --- /dev/null +++ b/test/plugins/plant.lua @@ -0,0 +1,118 @@ +config.mode = 'fortress' +config.target = 'plant' + +local plant = require('plugins.plant') + +-- parse_commandline takes C++-allocated outputs: an options table, two +-- coords, and a vector filter. stl vectors can't be created from +-- Lua, so borrow an int32 vector field from a temporary burrow. +local function parse(args) + local opts = {} + local pos1, pos2 = xyz2pos(0, 0, -30000), xyz2pos(0, 0, -30000) + local b = df.burrow:new() + local ok, err = pcall(plant.parse_commandline, opts, pos1, pos2, b.block_x, args) + -- copy out before deleting the burrow; the vector dangles with it + local filter = {} + for i = 0, #b.block_x - 1 do filter[i + 1] = b.block_x[i] end + local result = {opts=opts, pos1=pos1, pos2=pos2, filter=filter} + b:delete() + if not ok then error(err, 0) end + return result +end + +local function find_valid_plant() + for _,p in ipairs(df.global.world.raws.plants.bushes) do + if p.id and #p.id > 0 then return p end + end +end + +function test.modes_set_flags() + expect.eq(-2, parse({'list'}).opts.plant_idx) + expect.true_(parse({'grow'}).opts.grow) + expect.true_(parse({'remove'}).opts.del) +end + +function test.create_requires_plant_id() + expect.error_match('Must specify plant_id', + function() parse({'create'}) end) +end + +function test.create_resolves_plant_by_name_and_index() + local p = find_valid_plant() + if not p then return end + local by_name = parse({'create', p.id}) + expect.true_(by_name.opts.create) + expect.eq(p.index, by_name.opts.plant_idx) + expect.eq(p.index, parse({'create', tostring(p.index)}).opts.plant_idx) +end + +function test.create_unknown_plant_errors() + expect.error_match('Plant raw not found', + function() parse({'create', 'DFHACK_NO_SUCH_PLANT'}) end) +end + +function test.invalid_and_missing_modes_error() + expect.error_match('Specify mode', function() parse({}) end) + expect.error_match('Invalid mode', function() parse({'bogus'}) end) +end + +function test.too_many_positionals_error() + expect.error_match('Too many positionals', + function() parse({'grow', '1,1,100', '2,2,100', 'extra'}) end) +end + +function test.age_option_parsing() + expect.eq(40320*3-1, parse({'grow', '--age', 'tree'}).opts.age) + expect.eq(40320*5-1, parse({'grow', '--age', '5'}).opts.age) + expect.eq(40320*1250-1, parse({'grow', '--age', '9999'}).opts.age) + expect.error_match('Invalid age', + function() parse({'grow', '--age', 'bogus'}) end) +end + +function test.filter_and_exclude_populate_vector() + local p = find_valid_plant() + if not p then return end + local res = parse({'remove', '--filter', p.id}) + expect.eq(1, #res.filter) + expect.eq(p.index, res.filter[1]) + res = parse({'remove', '--exclude', p.id}) + expect.true_(res.opts.filter_ex) +end + +function test.double_filter_errors() + expect.error_match('Filter already defined', + function() + local p = find_valid_plant() + if not p then error('skip') end + parse({'remove', '--filter', p.id, '--exclude', p.id}) + end) +end + +function test.option_flags() + local opts = parse({'remove', '-s', '-p', '-t', '-d', '-z', '-n', '-c'}).opts + expect.true_(opts.shrubs) + expect.true_(opts.saplings) + expect.true_(opts.trees) + expect.true_(opts.dead) + expect.true_(opts.zlevel) + expect.true_(opts.dry_run) + expect.true_(opts.force) +end + +function test.coords_assigned_to_positions() + local res = parse({'grow', '3,4,100', '7,8,101'}) + expect.eq(3, res.pos1.x) + expect.eq(4, res.pos1.y) + expect.eq(100, res.pos1.z) + expect.eq(7, res.pos2.x) + expect.eq(8, res.pos2.y) + expect.eq(101, res.pos2.z) +end + +function test.create_uses_third_positional_as_pos1() + local p = find_valid_plant() + if not p then return end + local res = parse({'create', p.id, '9,10,102'}) + expect.eq(9, res.pos1.x) + expect.eq(102, res.pos1.z) +end diff --git a/test/plugins/preserve-rooms.lua b/test/plugins/preserve-rooms.lua new file mode 100644 index 0000000000..47a350d91f --- /dev/null +++ b/test/plugins/preserve-rooms.lua @@ -0,0 +1,49 @@ +config.mode = 'fortress' +config.target = 'preserve-rooms' + +local pr = require('plugins.preserve-rooms') + +-- feature enabled state persists across sessions; restore it +local saved_features + +config.wrapper = function(test_fn) + local features = pr.preserve_rooms_getState() + saved_features = {} + for k, v in pairs(features) do + saved_features[k] = v + end + return dfhack.with_finalize(function() + for k, v in pairs(saved_features) do + pr.preserve_rooms_setFeature(v, k) + end + end, test_fn) +end + +function test.status_lists_features() + local output = dfhack.run_command_silent('preserve-rooms', 'status') + expect.str_find('track%-missions', output) + expect.str_find('track%-roles', output) +end + +function test.enable_disable_feature() + pr.preserve_rooms_setFeature(false, 'track-missions') + dfhack.run_command_silent('preserve-rooms', 'enable', 'track-missions') + local features = pr.preserve_rooms_getState() + expect.true_(features['track-missions']) + dfhack.run_command_silent('preserve-rooms', 'disable', 'track-missions') + features = pr.preserve_rooms_getState() + expect.false_(features['track-missions']) +end + +function test.enable_unknown_feature_errors() + local output, status = dfhack.run_command_silent('preserve-rooms', + 'enable', 'bogus-feature') + expect.true_(output:find('unknown feature', 1, true) ~= nil) +end + +function test.reset_feature() + dfhack.run_command_silent('preserve-rooms', 'reset', 'track-roles') + local output, status = dfhack.run_command_silent('preserve-rooms', + 'reset', 'bogus-feature') + expect.true_(output:find('unknown feature', 1, true) ~= nil) +end diff --git a/test/plugins/preserve-tombs.lua b/test/plugins/preserve-tombs.lua new file mode 100644 index 0000000000..d5dfbb4dbc --- /dev/null +++ b/test/plugins/preserve-tombs.lua @@ -0,0 +1,34 @@ +config.mode = 'fortress' +config.target = 'preserve-tombs' + +-- enabled state persists; capture it and restore afterwards +local was_enabled + +config.wrapper = function(test_fn) + local output = dfhack.run_command_silent('preserve-tombs') + was_enabled = output:find('currently enabled', 1, true) ~= nil + return dfhack.with_finalize(function() + dfhack.run_command_silent(was_enabled and 'enable' or 'disable', + 'preserve-tombs') + end, test_fn) +end + +function test.status_reports_state() + dfhack.run_command_silent('disable', 'preserve-tombs') + local output = dfhack.run_command_silent('preserve-tombs') + expect.str_find('preserve%-tombs is currently disabled', output) +end + +function test.now_requires_enable() + dfhack.run_command_silent('disable', 'preserve-tombs') + local output = dfhack.run_command_silent('preserve-tombs', 'now') + expect.str_find('Cannot update', output) +end + +function test.enable_then_now() + dfhack.run_command_silent('enable', 'preserve-tombs') + local output = dfhack.run_command_silent('preserve-tombs') + expect.str_find('preserve%-tombs is currently enabled', output) + output = dfhack.run_command_silent('preserve-tombs', 'now') + expect.str_find('Updated tomb assignments', output) +end diff --git a/test/plugins/probe.lua b/test/plugins/probe.lua new file mode 100644 index 0000000000..078cef0098 --- /dev/null +++ b/test/plugins/probe.lua @@ -0,0 +1,66 @@ +config.mode = 'fortress' +config.target = 'probe' + +local saved_cursor + +config.wrapper = function(test_fn) + saved_cursor = xyz2pos(df.global.cursor.x, df.global.cursor.y, + df.global.cursor.z) + return dfhack.with_finalize(function() + df.global.cursor.x = saved_cursor.x + df.global.cursor.y = saved_cursor.y + df.global.cursor.z = saved_cursor.z + end, test_fn) +end + +local function set_cursor(pos) + df.global.cursor.x = pos.x + df.global.cursor.y = pos.y + df.global.cursor.z = pos.z +end + +local function first_map_block() + return df.global.world.map.map_blocks[0] +end + +local function any_map_pos() + local block = first_map_block() + return xyz2pos(block.map_pos.x + 8, block.map_pos.y + 8, + block.map_pos.z) +end + +function test.probe_requires_cursor() + set_cursor(xyz2pos(-30000, -30000, -30000)) + local output = dfhack.run_command_silent('probe') + expect.str_find('No cursor', output) +end + +function test.probe_reports_tiletype() + set_cursor(any_map_pos()) + local output = dfhack.run_command_silent('probe') + expect.str_find('tiletype', output) + expect.str_find('dig', output) +end + +-- regression: probe must describe the block actually under the cursor, not a +-- block at a different z-level +function test.probe_reports_correct_block() + local block = first_map_block() + set_cursor(any_map_pos()) + local output = dfhack.run_command_silent('probe') + + local tiletype = tonumber(output:match('tiletype: (%-?%d+)')) + expect.eq(block.tiletype[8][8], tiletype) + + local local_idx = tonumber(output:match('local feature idx: (%-?%d+)')) + local global_idx = tonumber(output:match('global feature idx: (%-?%d+)')) + expect.eq(block.local_feature, local_idx) + expect.eq(block.global_feature, global_idx) +end + +function test.cprobe_requires_unit() + set_cursor(xyz2pos(-30000, -30000, -30000)) + local output = dfhack.run_command_silent('cprobe') + -- fails without a selected creature + expect.ne('', output) +end diff --git a/test/plugins/prospector.lua b/test/plugins/prospector.lua new file mode 100644 index 0000000000..42b0016f60 --- /dev/null +++ b/test/plugins/prospector.lua @@ -0,0 +1,55 @@ +config.mode = 'fortress' +config.target = 'prospector' + +local prospector = require('plugins.prospector') + +local function parse(args) + local opts = {} + prospector.parse_commandline(opts, args) + return opts +end + +function test.help_flag() + expect.true_(parse({'--help'}).help) + expect.true_(parse({'-h'}).help) +end + +function test.all_and_hell_set_hidden() + expect.true_(parse({'all'}).hidden) + expect.nil_(parse({'all'}).tube) + local opts = parse({'hell'}) + expect.true_(opts.hidden) + expect.true_(opts.tube) +end + +function test.unknown_keyword_errors() + expect.error_match('unknown keyword', + function() prospector.parse_commandline({}, {'bogus'}) end) +end + +function test.show_sections_set_flags() + local opts = parse({'-s', 'gems,ores'}) + expect.true_(opts.gems) + expect.true_(opts.ores) + -- selecting any section disables the rest + expect.false_(opts.summary) + expect.false_(opts.layers) +end + +function test.show_all_sections() + local opts = parse({'-s', 'summary,liquids,layers,features,ores,gems,veins,shrubs,trees'}) + for _,s in ipairs{'summary', 'liquids', 'layers', 'features', 'ores', + 'gems', 'veins', 'shrubs', 'trees'} do + expect.true_(opts[s], 'expected section flag set: '..s) + end +end + +function test.unknown_show_section_errors() + expect.error_match('unknown report section', + function() prospector.parse_commandline({}, {'-s', 'bogus'}) end) +end + +function test.values_flag() + expect.true_(parse({'-v'}).value) + expect.true_(parse({'--values'}).value) +end diff --git a/test/plugins/regrass.lua b/test/plugins/regrass.lua new file mode 100644 index 0000000000..73953564f3 --- /dev/null +++ b/test/plugins/regrass.lua @@ -0,0 +1,71 @@ +config.mode = 'fortress' +config.target = 'regrass' + +local regrass = require('plugins.regrass') + +local function parse(args) + local opts, pos1, pos2 = {}, {}, {} + return regrass.parse_commandline(opts, pos1, pos2, args), opts, pos1, pos2 +end + +function test.plant_requires_force() + expect.error_match('without %-%-force', + function() parse({'--plant', 'GRASS'}) end) +end + +function test.plant_with_force_resolves_grass_by_name() + local grasses = df.global.world.raws.plants.grasses + if #grasses == 0 then return end + local grass = grasses[0] + local _, opts = parse({'--force', '--plant', grass.id}) + expect.eq(grass.index, opts.forced_plant) +end + +function test.plant_with_force_resolves_grass_by_index() + local _, opts = parse({'--force', '--plant', '0'}) + expect.eq(0, opts.forced_plant) +end + +function test.unknown_plant_errors() + expect.error_match('Plant raw not found', + function() parse({'--force', '--plant', 'NO_SUCH_PLANT_XYZ'}) end) +end + +function test.list_flag_forces_plant_listing() + local _, opts = parse({'--list'}) + expect.eq(-2, opts.forced_plant) +end + +function test.force_without_plant_picks_random_grass() + if #df.global.world.raws.plants.grasses == 0 then return end + local _, opts = parse({'--force'}) + expect.true_(type(opts.forced_plant) == 'number') + expect.true_(opts.forced_plant >= 0) +end + +function test.option_flags_set() + local _, opts = parse({'--max', '--new', '--ashes', '--buildings', + '--mud', '--block', '--zlevel'}) + expect.true_(opts.max_grass) + expect.true_(opts.new_grass) + expect.true_(opts.ashes) + expect.true_(opts.buildings) + expect.true_(opts.mud) + expect.true_(opts.block) + expect.true_(opts.zlevel) +end + +function test.too_many_positionals_error() + expect.error_match('Too many positionals', + function() parse({'1,1,1', '2,2,2', '3,3,3'}) end) +end + +function test.positionals_assign_coords() + local _, _, pos1, pos2 = parse({'10,11,95', '20,21,96'}) + expect.eq(10, pos1.x) + expect.eq(11, pos1.y) + expect.eq(95, pos1.z) + expect.eq(20, pos2.x) + expect.eq(21, pos2.y) + expect.eq(96, pos2.z) +end diff --git a/test/plugins/reveal.lua b/test/plugins/reveal.lua new file mode 100644 index 0000000000..7c9fe6a45f --- /dev/null +++ b/test/plugins/reveal.lua @@ -0,0 +1,78 @@ +config.mode = 'fortress' +config.target = 'reveal' + +-- reveal/unreveal mutate every map block's hidden flag, and the plugin +-- force-pauses the game while the map is revealed. Every test that reveals +-- must unreveal in a finalize so a failed check can't leave the game +-- revealed (and stuck paused) for the rest of the suite. +local function ensure_unrevealed() + dfhack.run_command_silent('unreveal') +end + +local function count_hidden() + local count = 0 + for _, block in ipairs(df.global.world.map.map_blocks) do + for x = 0, 15 do + for y = 0, 15 do + if block.designation[x][y].hidden then + count = count + 1 + end + end + end + end + return count +end + +function test.reveal_and_unreveal() + local before = count_hidden() + expect.true_(before > 0, 'test needs at least one hidden tile on the map') + + return dfhack.with_finalize(ensure_unrevealed, function() + local output, status = dfhack.run_command_silent('reveal') + expect.eq(CR_OK, status) + expect.str_find('Map revealed%.', output) + -- blocks containing trigger events (encased horrors, treasure veins) + -- stay hidden by design, so compare counts rather than spot tiles + expect.lt(count_hidden(), before) + + output, status = dfhack.run_command_silent('unreveal') + expect.eq(CR_OK, status) + expect.eq(before, count_hidden()) + end) +end + +function test.reveal_twice_fails() + return dfhack.with_finalize(ensure_unrevealed, function() + dfhack.run_command_silent('reveal') + local output, status = dfhack.run_command_silent('reveal') + expect.eq(CR_FAILURE, status) + expect.str_find('already revealed', output) + end) +end + +function test.unreveal_nothing_fails() + local output, status = dfhack.run_command_silent('unreveal') + expect.eq(CR_FAILURE, status) + expect.str_find('nothing to revert', output) +end + +function test.demon_is_disabled() + local output, status = dfhack.run_command_silent('reveal', 'demon') + expect.eq(CR_FAILURE, status) + expect.str_find('currently disabled', output) +end + +function test.revtoggle_toggles() + local before = count_hidden() + expect.true_(before > 0, 'test needs at least one hidden tile on the map') + + return dfhack.with_finalize(ensure_unrevealed, function() + local _, status = dfhack.run_command_silent('revtoggle') + expect.eq(CR_OK, status) + expect.lt(count_hidden(), before) + + _, status = dfhack.run_command_silent('revtoggle') + expect.eq(CR_OK, status) + expect.eq(before, count_hidden()) + end) +end diff --git a/test/plugins/seedwatch.lua b/test/plugins/seedwatch.lua new file mode 100644 index 0000000000..8af3e7ebab --- /dev/null +++ b/test/plugins/seedwatch.lua @@ -0,0 +1,111 @@ +config.mode = 'fortress' +config.target = 'seedwatch' + +local seedwatch = require('plugins.seedwatch') + +local saved_targets = {} +do + local watch_map = seedwatch.seedwatch_getData() + for k, v in pairs(watch_map) do + saved_targets[k] = v + end +end + +config.wrapper = function(test_fn) + return dfhack.with_finalize(function() + -- restore original targets and zero out any watch entries that the + -- test created for previously unwatched plants (there is no API to + -- remove a watch entry entirely) + local watch_map = seedwatch.seedwatch_getData() + for k in pairs(watch_map) do + if saved_targets[k] == nil then saved_targets[k] = 0 end + end + for k, v in pairs(saved_targets) do + local plant = df.global.world.raws.plants.all[k] + if plant then + seedwatch.seedwatch_setTarget(plant.id, v) + end + end + end, test_fn) +end + +-- finds a seed-bearing non-tree plant, matching the set of plant ids that +-- seedwatch manages (see plugin_load_world_data in seedwatch.cpp) +local function get_seed_plant() + local plants = df.global.world.raws.plants.all + for i = 0, #plants - 1 do + local plant = plants[i] + if plant.material_defs.type[df.plant_material_def.seed] ~= -1 and + not plant.flags.TREE then + return i, plant.id + end + end +end + +local function all_targets() + local watch_map = seedwatch.seedwatch_getData() + return watch_map +end + +function test.status() + expect.true_(seedwatch.parse_commandline('status')) + expect.true_(seedwatch.parse_commandline()) +end + +function test.help() + expect.false_(seedwatch.parse_commandline('help')) + expect.false_(seedwatch.parse_commandline('-h')) +end + +function test.set_all_targets() + expect.true_(seedwatch.parse_commandline('all', '50')) + local targets = all_targets() + local count = 0 + for _ in pairs(targets) do count = count + 1 end + expect.gt(count, 0) + for _, v in pairs(targets) do + expect.eq(50, v) + end +end + +function test.clear() + expect.true_(seedwatch.parse_commandline('clear')) + for _, v in pairs(all_targets()) do + expect.eq(0, v) + end +end + +function test.set_single_plant() + local idx, id = get_seed_plant() + if not idx then return end + expect.true_(seedwatch.parse_commandline(id, '17')) + expect.eq(17, all_targets()[idx]) +end + +function test.target_floored() + local idx, id = get_seed_plant() + if not idx then return end + expect.true_(seedwatch.parse_commandline(id, '9.9')) + expect.eq(9, all_targets()[idx]) +end + +function test.unknown_plant() + local idx = get_seed_plant() + -- the C++ layer prints an error message but the command does not fail + expect.true_(seedwatch.parse_commandline( + 'THIS_PLANT_DOES_NOT_EXIST', '5')) + if idx then + expect.ne(5, all_targets()[idx]) + end +end + +function test.negative_target() + local _, id = get_seed_plant() + if not id then return end + expect.error_match('non%-negative integer', function() + seedwatch.parse_commandline(id, '-5') end) +end + +function test.missing_target() + expect.false_(seedwatch.parse_commandline('all')) +end diff --git a/test/plugins/showmood.lua b/test/plugins/showmood.lua new file mode 100644 index 0000000000..12478db3be --- /dev/null +++ b/test/plugins/showmood.lua @@ -0,0 +1,14 @@ +config.mode = 'fortress' +config.target = 'showmood' + +function test.reports_no_active_mood() + -- the test fort has no strange mood in progress + local output, status = dfhack.run_command_silent('showmood') + expect.eq(CR_OK, status) + expect.str_find('No strange moods currently active', output) +end + +function test.arg_is_wrong_usage() + local _, status = dfhack.run_command_silent('showmood', 'bogus') + expect.eq(CR_WRONG_USAGE, status) +end diff --git a/test/plugins/sort.lua b/test/plugins/sort.lua new file mode 100644 index 0000000000..d417d31960 --- /dev/null +++ b/test/plugins/sort.lua @@ -0,0 +1,163 @@ +config.mode = 'fortress' +config.target = 'sort' + +local gui = require('gui') +local overlay = require('plugins.overlay') + +local function get_widget() + return overlay.get_state().db['sort.stress_icons'].widget +end + +local function get_citizen_counts() + local counts = {} + for _,unit in ipairs(dfhack.units.getCitizens(true, true)) do + local cat = dfhack.units.getStressCategory(unit) + counts[cat] = (counts[cat] or 0) + 1 + end + return counts +end + +-- rects are stored happiest-first, so category c is at index 7-c +local function rect_for_category(w, cat) + if not w.icon_rects then qerror('icon_rects not populated') end + return w.icon_rects[7 - cat] +end + +local function click_at(x, y) + local handled + mock.patch(dfhack.screen, 'getMousePos', mock.func(x, y), function() + handled = get_widget():onInput{_MOUSE_L=true} + end) + return handled +end + +local function dismiss_top() + gui.simulateInput(dfhack.gui.getCurViewscreen(true), 'LEAVESCREEN') +end + +local function synth_rects() + local rects = {} + for i = 0, 6 do + local x1 = 42 + i * 3 + table.insert(rects, {x1=x1, y1=0, x2=x1+2, y2=2, category=6-i}) + end + return rects +end + +config.wrapper = function(test_fn) + overlay.rescan() + local w = get_widget() + -- give the top bar a few frames to render so the anchor scan finds 'Pop'; + -- environments where the vanilla screen is never rendered (e.g. headless + -- Windows CI) fall back to synthetic rects so the click/list behavior is + -- still exercised + for _ = 1, 20 do + w:overlay_onupdate() + if w.icon_rects then break end + delay() + end + w.scan_unavailable = not w.icon_rects + if w.scan_unavailable then + print('NOTE: top bar not rendered; testing with synthetic icon rects') + w.icon_rects = synth_rects() + end + return dfhack.with_finalize(function() + -- dismiss any screens the test left open + while (dfhack.gui.getCurFocus(true)[1] or ''):match('^dfhack/') do + dismiss_top() + end + end, test_fn) +end + +function test.icon_rects_detected() + local w = get_widget() + if w.scan_unavailable then + -- the vanilla top bar is never rendered in this environment, so the + -- screen scan cannot be verified here (covered by other platforms) + return + end + expect.eq(7, #w.icon_rects) + expect.eq(6, w.icon_rects[1].category) + expect.eq(0, w.icon_rects[7].category) + -- each rect covers a 3-wide, 3-tall cell in the top bar + for _,rect in ipairs(w.icon_rects) do + expect.eq(0, rect.y1) + expect.eq(2, rect.y2) + expect.eq(2, rect.x2 - rect.x1) + end +end + +function test.click_outside_icons_does_nothing() + local w = get_widget() + local before = dfhack.gui.getCurFocus(true)[1] + expect.false_(click_at(0, 30)) + expect.eq(before, dfhack.gui.getCurFocus(true)[1]) +end + +function test.click_icon_opens_filtered_list() + local w = get_widget() + local counts = get_citizen_counts() + local cat + for c = 0, 6 do + if (counts[c] or 0) > 0 then cat = c break end + end + expect.ne(nil, cat, 'no citizens with a stress category') + + local rect = rect_for_category(w, cat) + expect.true_(click_at(rect.x1 + 1, 1)) + expect.eq('dfhack/lua/ListBox', dfhack.gui.getCurFocus(true)[1]) + dismiss_top() +end + +function test.empty_category_shows_message() + local w = get_widget() + local counts = get_citizen_counts() + local cat + for c = 0, 6 do + if not counts[c] or counts[c] == 0 then cat = c break end + end + if not cat then + -- all categories populated; nothing to test + return + end + local rect = rect_for_category(w, cat) + expect.true_(click_at(rect.x1 + 1, 1)) + expect.true_((dfhack.gui.getCurFocus(true)[1] or ''):match('^dfhack/lua/') ~= nil) + dismiss_top() +end + +function test.selecting_unit_centers_map() + local w = get_widget() + local counts = get_citizen_counts() + local cat + for c = 0, 6 do + if (counts[c] or 0) > 0 then cat = c break end + end + expect.ne(nil, cat, 'no citizens with a stress category') + + local rect = rect_for_category(w, cat) + expect.true_(click_at(rect.x1 + 1, 1)) + expect.eq('dfhack/lua/ListBox', dfhack.gui.getCurFocus(true)[1]) + + local reveal_spy = mock.observe_func(function() end) + mock.patch(dfhack.gui, 'revealInDwarfmodeMap', reveal_spy, + function() + gui.simulateInput(dfhack.gui.getCurViewscreen(true), 'SELECT') + end) + expect.eq(1, reveal_spy.call_count) + local args = reveal_spy.call_args[1] + local called_with = {x=args[1], y=args[2], z=args[3]} + + expect.ne(nil, called_with) + -- the revealed position must be a real citizen's position in that bucket + local matched = false + for _,unit in ipairs(dfhack.units.getCitizens(true, true)) do + if dfhack.units.getStressCategory(unit) == cat and + unit.pos.x == called_with.x and unit.pos.y == called_with.y and + unit.pos.z == called_with.z then + matched = true + break + end + end + expect.true_(matched) +end diff --git a/test/plugins/spectate.lua b/test/plugins/spectate.lua new file mode 100644 index 0000000000..e6887d1f6f --- /dev/null +++ b/test/plugins/spectate.lua @@ -0,0 +1,51 @@ +config.mode = 'fortress' +config.target = 'spectate' + +require('plugins.spectate') + +function test.status_shows_settings() + local output = dfhack.run_command_silent('spectate') + expect.true_(output:find('auto%-unpause') ~= nil + or output:find('follow%-seconds') ~= nil) +end + +function test.set_boolean() + dfhack.run_command_silent('spectate', 'set', 'auto-unpause', 'true') + local output = dfhack.run_command_silent('spectate') + expect.str_find('auto%-unpause.-true', output) + dfhack.run_command_silent('spectate', 'set', 'auto-unpause', 'false') + output = dfhack.run_command_silent('spectate') + expect.str_find('auto%-unpause.-false', output) +end + +function test.set_number() + dfhack.run_command_silent('spectate', 'set', 'follow-seconds', '42') + local output = dfhack.run_command_silent('spectate') + expect.str_find('follow%-seconds.-42', output) + dfhack.run_command_silent('spectate', 'set', 'follow-seconds', '10') +end + +function test.set_toggle_value() + dfhack.run_command_silent('spectate', 'set', 'auto-unpause', 'false') + dfhack.run_command_silent('spectate', 'set', 'auto-unpause', 'toggle') + local output = dfhack.run_command_silent('spectate') + expect.str_find('auto%-unpause.-true', output) +end + +function test.set_unknown_option() + local output, status = dfhack.run_command_silent('spectate', 'set', + 'bogus-key', '1') + expect.true_(output:find('unknown option', 1, true) ~= nil) +end + +function test.set_missing_key() + local output, status = dfhack.run_command_silent('spectate', 'set') + expect.true_(output:find('missing key', 1, true) ~= nil) +end + +function test.unknown_command_shows_usage() + local output, status = dfhack.run_command_silent('spectate', 'boguscmd') + expect.true_(output:find('status', 1, true) ~= nil + or output:find('Usage', 1, true) ~= nil + or output:find('usage', 1, true) ~= nil) +end diff --git a/test/plugins/stocks.lua b/test/plugins/stocks.lua new file mode 100644 index 0000000000..0dba35c00a --- /dev/null +++ b/test/plugins/stocks.lua @@ -0,0 +1,87 @@ +config.mode = 'fortress' +config.target = 'overlay' + +local gui = require('gui') +local overlay = require('plugins.overlay') + +local stocks = df.global.game.main_interface.stocks + +-- feed a key to the stocks overlay widget's input handler. This is the same +-- code path the viewscreen's interposed feed() takes; we invoke it directly +-- because the focus string cache only refreshes on game frames, which tests +-- cannot wait for. +local function send_key(key) + overlay.get_state().db['stocks.overlay'].widget:onInput({[key]=true}) +end + +local function set_stocks_open(open) + if stocks.open ~= open then + gui.simulateInput(dfhack.gui.getDFViewscreen(true), 'D_STOCKS') + end +end + +local function expanded_count() + local n = 0 + for i=0,#stocks.current_type_a_expanded-1 do + if stocks.current_type_a_expanded[i] then n = n + 1 end + end + return n +end + +local saved_open + +config.wrapper = function(test_fn) + saved_open = stocks.open + -- the widget db can be empty if a rescan is still in progress + overlay.rescan() + return dfhack.with_finalize(function() + -- close and reopen so the game repopulates any lists the test mutated + set_stocks_open(false) + set_stocks_open(saved_open) + end, test_fn) +end + +function test.toggle_collapses_then_expands() + set_stocks_open(true) + local num_sections = #stocks.current_type_a_expanded + expect.true_(num_sections > 0) + for i=0,num_sections-1 do + stocks.current_type_a_expanded[i] = true + end + stocks.scroll_position_item = 5 + send_key('CUSTOM_CTRL_X') + expect.eq(0, expanded_count()) + -- collapsing resets the item scroll position so it stays in bounds + expect.eq(0, stocks.scroll_position_item) + -- with everything collapsed, the same hotkey expands + send_key('CUSTOM_CTRL_X') + expect.eq(num_sections, expanded_count()) +end + +function test.expand_hotkey_expands_all() + set_stocks_open(true) + for i=0,#stocks.current_type_a_expanded-1 do + stocks.current_type_a_expanded[i] = false + end + send_key('CUSTOM_CTRL_Z') + expect.eq(#stocks.current_type_a_expanded, expanded_count()) +end + +function test.remove_empty_keeps_type_scroll_in_bounds() + set_stocks_open(true) + stocks.scroll_position_type = #stocks.filtered_type_list + 10 + send_key('CUSTOM_CTRL_E') + expect.true_(stocks.scroll_position_type >= 0) + expect.true_(stocks.scroll_position_type < #stocks.filtered_type_list) +end + +function test.closed_stocks_ignores_overlay_keys() + set_stocks_open(false) + -- the stocks.open guard must make every hotkey a no-op while the page is + -- closed, even if an activation slips through as the page closes + local n = expanded_count() + send_key('CUSTOM_CTRL_X') + send_key('CUSTOM_CTRL_Z') + send_key('CUSTOM_CTRL_E') + expect.eq(n, expanded_count()) +end diff --git a/test/plugins/strangemood.lua b/test/plugins/strangemood.lua new file mode 100644 index 0000000000..3349826fbb --- /dev/null +++ b/test/plugins/strangemood.lua @@ -0,0 +1,180 @@ +config.mode = 'fortress' +config.target = 'strangemood' + +-- note: the --force/--type/--skill happy path is deliberately not tested +-- here: inducing a real strange mood mutates the fort (dwarf claims a +-- workshop and can go berserk if unmet), which is too disruptive mid-suite. + +function test.help_is_wrong_usage() + local _, status = dfhack.run_command_silent('strangemood', 'help') + expect.eq(CR_WRONG_USAGE, status) +end + +function test.unknown_option_is_wrong_usage() + local output, status = dfhack.run_command_silent('strangemood', '--bogus') + expect.eq(CR_WRONG_USAGE, status) + expect.str_find('Unrecognized parameter', output) +end + +function test.id_missing_value() + local output, status = dfhack.run_command_silent('strangemood', '--id') + expect.eq(CR_WRONG_USAGE, status) + expect.str_find('No unit id specified', output) +end + +function test.id_not_a_number() + -- regression: the raw std::stoi call threw an uncaught exception on + -- non-numeric input, crashing the game + local output, status = dfhack.run_command_silent('strangemood', '--id', 'abc') + expect.eq(CR_WRONG_USAGE, status) + expect.str_find('Invalid unit id', output) +end + +function test.id_nonexistent_unit() + local _, status = dfhack.run_command_silent('strangemood', '--id', '99999999') + expect.eq(CR_FAILURE, status) +end + +function test.type_missing_value() + local _, status = dfhack.run_command_silent('strangemood', '--type') + expect.eq(CR_WRONG_USAGE, status) +end + +function test.type_bad_value() + local output, status = dfhack.run_command_silent('strangemood', '--type', 'bogus') + expect.eq(CR_WRONG_USAGE, status) + expect.str_find('not recognized', output) +end + +-- the following tests trigger real strange moods and validate the generated +-- job_items against the vanilla request tables. they restore all unit and +-- plotinfo state they touch. the mooded dwarf cannot act on the job because +-- the whole test runs atomically on the main thread. + +-- skill name -> expected item_type of the mood's base item +local BASE_ITEMS = { + miner='BOULDER', engraver='BOULDER', mason='BOULDER', + stonecutter='BOULDER', stonecarver='BOULDER', mechanic='BOULDER', + carpenter='WOOD', woodcrafter='WOOD', bowyer='WOOD', + tanner='SKIN_TANNED', leatherworker='SKIN_TANNED', + weaver='CLOTH', clothier='CLOTH', + weaponsmith='BAR', armorsmith='BAR', metalsmith='BAR', + metalcrafter='BAR', + gemcutter='ROUGH', gemsetter='ROUGH', glassmaker='ROUGH', + bonecarver='NONE', +} + +-- every item_type a fey/secretive/possessed mood may ever request +-- (bars/wafers, cloth, and thread are the only dimensional demands) +local ALLOWED_ITEM_TYPES = { + BOULDER=true, WOOD=true, SKIN_TANNED=true, CLOTH=true, BAR=true, + ROUGH=true, SMALLGEM=true, BLOCKS=true, NONE=true, THREAD=true, +} + +-- mirrors isUnitMoodable in strangemood.cpp, plus "not already mid-job" +local function pick_moodable_unit() + for _, u in ipairs(dfhack.units.getCitizens()) do + if not u.flags1.had_mood and not u.flags1.has_mood and + u.mood == df.mood_type.None and not u.job.current_job and + u.status2.limbs_grasp_count > 0 and + df.profession.attrs[u.profession].moodable and + dfhack.units.casteFlagSet(u.race, u.caste, + df.caste_raw_flags.STRANGE_MOODS) then + return u + end + end +end + +local function clear_mood(unit) + local job = unit.job.current_job + unit.job.current_job = nil + if job then + dfhack.job.removeJob(job) + end + unit.mood = df.mood_type.None + unit.mood_copy = df.mood_type.None + unit.flags1.has_mood = false + unit.flags1.had_mood = false + unit.moodstage = df.mood_stage_type.INITIAL + unit.status.artifact_name.type = -1 +end + +local function trigger_mood(unit, ...) + local _, status = dfhack.run_command_silent('strangemood', + '--force', '--id', tostring(unit.id), ...) + return status == CR_OK and unit.job.current_job or nil +end + +local function check_dimension(item, expected_type) + -- strangemood pre-multiplies quantity by the item's dimension and sets + -- min_dimension, working around a vanilla bug + if item.item_type == df.item_type.BAR then + expect.eq(150, item.min_dimension) + expect.eq(0, item.quantity % 150) + elseif item.item_type == df.item_type.CLOTH then + expect.eq(10000, item.min_dimension) + expect.eq(0, item.quantity % 10000) + elseif item.item_type == df.item_type.THREAD then + expect.eq(15000, item.min_dimension) + expect.eq(0, item.quantity % 15000) + else + expect.eq(-1, item.min_dimension) + end +end + +function test.base_items_match_skill() + local unit = pick_moodable_unit() + expect.true_(unit, 'need a moodable citizen without a job') + local saved_cooldown = df.global.plotinfo.mood_cooldown + return dfhack.with_finalize(function() + clear_mood(unit) + df.global.plotinfo.mood_cooldown = saved_cooldown + end, function() + for skill, expected in pairs(BASE_ITEMS) do + local job = trigger_mood(unit, '--skill', skill) + expect.true_(job, 'mood job for ' .. skill) + local base = job.job_items.elements[0] + expect.eq(expected, df.item_type[base.item_type], + 'base item for ' .. skill) + check_dimension(base) + for _, item in ipairs(job.job_items.elements) do + expect.true_(ALLOWED_ITEM_TYPES[df.item_type[item.item_type]], + ('unexpected %s item for %s'):format( + df.item_type[item.item_type], skill)) + if item.item_type == df.item_type.NONE then + expect.true_(item.flags2.body_part) + end + check_dimension(item) + end + clear_mood(unit) + end + end) +end + +function test.fell_and_macabre_base_items() + local unit = pick_moodable_unit() + expect.true_(unit, 'need a moodable citizen without a job') + local saved_cooldown = df.global.plotinfo.mood_cooldown + return dfhack.with_finalize(function() + clear_mood(unit) + df.global.plotinfo.mood_cooldown = saved_cooldown + end, function() + local job = trigger_mood(unit, '--type', 'fell') + expect.true_(job, 'fell mood job') + expect.eq(df.job_type.StrangeMoodFell, job.job_type) + expect.eq(df.item_type.CORPSE, job.job_items.elements[0].item_type) + expect.true_(job.job_items.elements[0].flags1.murdered) + clear_mood(unit) + + job = trigger_mood(unit, '--type', 'macabre') + expect.true_(job, 'macabre mood job') + expect.eq(df.job_type.StrangeMoodBrooding, job.job_type) + local base = job.job_items.elements[0] + if base.item_type == df.item_type.NONE then + expect.true_(base.flags2.body_part) + expect.true_(base.flags2.bone or base.flags2.totemable) + else + expect.eq(df.item_type.REMAINS, base.item_type) + end + end) +end diff --git a/test/plugins/suspendmanager.lua b/test/plugins/suspendmanager.lua new file mode 100644 index 0000000000..911dae4e73 --- /dev/null +++ b/test/plugins/suspendmanager.lua @@ -0,0 +1,419 @@ +config.mode = 'fortress' +config.target = 'suspendmanager' + +local function is_enabled() + local output = dfhack.run_command_silent('suspendmanager') + return output:find('is enabled') ~= nil +end + +local function prevents_blocking() + local output = dfhack.run_command_silent('suspendmanager') + return output:find('but not suspending') == nil +end + +function test.status_reflects_enable_state() + local was_enabled = is_enabled() + + return dfhack.with_finalize(function() + dfhack.run_command_silent('suspendmanager', + was_enabled and 'enable' or 'disable') + end, function() + local _, status = dfhack.run_command_silent('suspendmanager', 'enable') + expect.eq(CR_OK, status) + expect.true_(is_enabled()) + + local _, status2 = dfhack.run_command_silent('suspendmanager', 'disable') + expect.eq(CR_OK, status2) + expect.false_(is_enabled()) + end) +end + +function test.now_runs_cycle() + local _, status = dfhack.run_command_silent('suspendmanager', 'now') + expect.eq(CR_OK, status) +end + +function test.set_preventblocking() + local was_preventing = prevents_blocking() + + return dfhack.with_finalize(function() + dfhack.run_command_silent('suspendmanager', 'set', + 'preventblocking', was_preventing and 'true' or 'false') + end, function() + local _, status = dfhack.run_command_silent('suspendmanager', 'set', + 'preventblocking', 'false') + expect.eq(CR_OK, status) + local _, status2 = dfhack.run_command_silent('suspendmanager', 'set', + 'preventblocking', 'true') + expect.eq(CR_OK, status2) + end) +end + +function test.set_missing_args_is_wrong_usage() + local _, status = dfhack.run_command_silent('suspendmanager', 'set') + expect.eq(CR_WRONG_USAGE, status) +end + +function test.bad_option_is_wrong_usage() + local _, status = dfhack.run_command_silent('suspendmanager', 'bogus') + expect.eq(CR_WRONG_USAGE, status) +end + +function test.unsuspend_runs() + local _, status = dfhack.run_command_silent('unsuspend') + expect.eq(CR_OK, status) +end + +-- machine collapse protection (#5777) + +local function tile_shape(x, y, z) + local block = dfhack.maps.getTileBlock(x, y, z) + if not block then return nil end + local tt = block.tiletype[x % 16][y % 16] + return df.tiletype.attrs[tt].shape +end + +local function free_tile(x, y, z) + local block = dfhack.maps.getTileBlock(x, y, z) + if not block then return false end + local lx, ly = x % 16, y % 16 + local des = block.designation[lx][ly] + if des.dig ~= df.tile_dig_designation.No or des.flow_size ~= 0 then + return false + end + if block.occupancy[lx][ly].building ~= df.tile_building_occ.None then + return false + end + return not dfhack.buildings.findAtTile(xyz2pos(x, y, z)) +end + +-- find a floor tile with two open tiles to its east; if none exists, +-- channel open tiles out of a wall face so the site is floor + two open tiles +local function make_pit_site() + local function clear_run(x, y, z, shape) + for i = 1, 2 do + if tile_shape(x + i, y, z) ~= shape or not free_tile(x + i, y, z) then + return false + end + end + return true + end + -- stay clear of the unbuildable map border + local margin = 20 + local map = df.global.world.map + local wall_site = nil + for _, block in ipairs(df.global.world.map.map_blocks) do + local bx, by, bz = block.map_pos.x, block.map_pos.y, block.map_pos.z + if bx < margin or by < margin or bx > map.x_count - margin - 16 + or by > map.y_count - margin - 16 then + goto next_block + end + for x = 0, 13 do for y = 0, 15 do + local wx, wy = bx + x, by + y + if tile_shape(wx, wy, bz) == df.tiletype_shape.FLOOR + and free_tile(wx, wy, bz) then + if clear_run(wx, wy, bz, df.tiletype_shape.EMPTY) then + return wx, wy, bz + end + if not wall_site + and clear_run(wx, wy, bz, df.tiletype_shape.WALL) then + wall_site = {wx, wy, bz} + end + end + end end + ::next_block:: + end + if not wall_site then return nil end + local x, y, z = wall_site[1], wall_site[2], wall_site[3] + for i = 1, 2 do + local db = dfhack.maps.getTileBlock(x + i, y, z) + db.designation[(x + i) % 16][y % 16].dig = df.tile_dig_designation.Channel + end + dfhack.run_command_silent('dig-now') + if not clear_run(x, y, z, df.tiletype_shape.EMPTY) then return nil end + return x, y, z +end + +-- complete a machine building without dwarf labor: remove its construction +-- job, mark it built, and join it to the machine graph +local function build_machine(btype, x, y, z, width) + local bld = dfhack.buildings.constructBuilding{ + type=btype, pos=xyz2pos(x, y, z), width=width, height=1, + } + if not bld then return nil end + for i = #bld.jobs - 1, 0, -1 do + dfhack.job.removeJob(bld.jobs[i]) + end + bld.flags.exists = true + bld.construction_stage = 1 + df.global.world.machines:add_to_machine(bld) + return bld +end + +local function destroy_job_of(bld) + bld:queueDestroy() + for _, job in ipairs(bld.jobs) do + if job.job_type == df.job_type.DestroyBuilding then + return job + end + end +end + +-- fully remove a completed building: deconstruct() only queues a destroy job +-- for those, so reset the build stage to reach its immediate-deletion path +local function delete_building(bld) + if not bld then return end + local id = bld.id + for i = #bld.jobs - 1, 0, -1 do + dfhack.job.removeJob(bld.jobs[i]) + end + bld.construction_stage = 0 + dfhack.buildings.deconstruct(bld) + -- the object is deleted but its pointer lingers in the global vector + local all = df.global.world.buildings.all + for i = #all - 1, 0, -1 do + if all[i].id == id then + all:erase(i) + end + end +end + +-- drop machine graph nodes left pointing at deleted buildings +local function prune_dead_machines() + local machines = df.global.world.machines.all + for i = #machines - 1, 0, -1 do + local machine = machines[i] + for j = #machine.components - 1, 0, -1 do + if not df.building.find(machine.components[j].building_id) then + machine.components:erase(j) + end + end + if #machine.components == 0 then + machines:erase(i) + end + end +end + +function test.destroying_machine_anchor_suspends_job() + local x, y, z = make_pit_site() + expect.ne(nil, x, 'no diggable site for the machine rig') + if not x then return end + + local was_enabled = is_enabled() + if not was_enabled then + dfhack.run_command_silent('suspendmanager', 'enable') + end + + local anchor = build_machine(df.building_type.GearAssembly, x, y, z, 1) + local axle = build_machine(df.building_type.AxleHorizontal, x + 1, y, z, 1) + local hanging = build_machine(df.building_type.GearAssembly, x + 2, y, z, 1) + + return dfhack.with_finalize(function() + if not was_enabled then + dfhack.run_command_silent('suspendmanager', 'disable') + end + for _, bld in pairs{anchor, axle, hanging} do + delete_building(bld) + end + prune_dead_machines() + end, function() + expect.ne(nil, anchor, 'anchor gear not built') + expect.ne(nil, axle, 'axle not built') + expect.ne(nil, hanging, 'hanging gear not built') + if not anchor or not axle or not hanging then return end + + -- all three components must be in the same machine + local machine_id = anchor.machine.machine_id + expect.ne(-1, machine_id) + expect.eq(machine_id, axle.machine.machine_id) + expect.eq(machine_id, hanging.machine.machine_id) + + -- removing the endpoint leaves an anchored component: safe + local end_job = destroy_job_of(hanging) + expect.ne(nil, end_job, 'no destroy job on endpoint') + -- removing the anchor orphans the axle and the endpoint: unsafe + local anchor_job = destroy_job_of(anchor) + expect.ne(nil, anchor_job, 'no destroy job on anchor') + if not end_job or not anchor_job then return end + + dfhack.run_command_silent('suspendmanager', 'now') + expect.true_(anchor_job.flags.suspend, + 'anchor destroy job was not suspended') + expect.false_(end_job.flags.suspend, + 'endpoint destroy job was suspended') + end) +end + + +local suspendmanager = require('plugins.suspendmanager') + +local function save_suspend_flags() + local flags = {} + suspendmanager.foreach_construction_job(function(job) + flags[job.id] = job.flags.suspend + end) + return flags +end + +local created_buildings = {} + +config.wrapper = function(test_fn) + local flags = save_suspend_flags() + local saved_onstatechange = dfhack.onStateChange.unsuspend + return dfhack.with_finalize(function() + dfhack.onStateChange.unsuspend = saved_onstatechange + for _,bld in ipairs(created_buildings) do + dfhack.buildings.deconstruct(bld) + end + created_buildings = {} + suspendmanager.foreach_construction_job(function(job) + if flags[job.id] ~= nil then + job.flags.suspend = flags[job.id] + end + end) + end, test_fn) +end + +-- scans the map for a tile where a 1x1 building can be placed +local function construct_test_building() + for _,block in ipairs(df.global.world.map.map_blocks) do + for x = 0, 15 do + for y = 0, 15 do + local attrs = df.tiletype.attrs[block.tiletype[x][y]] + if attrs.shape == df.tiletype_shape.FLOOR and + block.occupancy[x][y].building == 0 then + local bld = dfhack.buildings.constructBuilding{ + type=df.building_type.Chair, + pos={x=block.map_pos.x+x, y=block.map_pos.y+y, + z=block.map_pos.z}} + if bld then + table.insert(created_buildings, bld) + return bld + end + end + end + end + end +end + +local function find_job_for(bld) + local found = nil + suspendmanager.foreach_construction_job(function(job) + if dfhack.job.getHolder(job) == bld then found = job end + end) + return found +end + +function test.foreach_construction_job() + local seen = {} + suspendmanager.foreach_construction_job(function(job) + expect.eq(df.job_type.ConstructBuilding, job.job_type) + seen[job.id] = true + end) + local bld = construct_test_building() + if not bld then return end + local job = find_job_for(bld) + expect.ne(nil, job) + if not job then return end + expect.nil_(seen[job.id]) + expect.eq(df.job_type.ConstructBuilding, job.job_type) +end + +function test.run_once_unsuspends_everything() + local bld = construct_test_building() + if not bld then return end + local job = find_job_for(bld) + expect.ne(nil, job) + if not job then return end + job.flags.suspend = true + suspendmanager.runOnce(false, true, true) + expect.false_(job.flags.suspend) +end + +function test.unsuspend_command_args() + local bld = construct_test_building() + if not bld then return end + local job = find_job_for(bld) + expect.ne(nil, job) + if not job then return end + job.flags.suspend = true + -- -q is quiet, -f forces unsuspension of everything + suspendmanager.unsuspend_command('-q', '-f') + expect.false_(job.flags.suspend) +end + +function test.is_buildingplan_job() + local bld = construct_test_building() + if not bld then return end + local job = find_job_for(bld) + if not job then return end + -- buildings created without a material selection have mat_type == -1, + -- which is how suspendmanager identifies buildingplan-placed jobs + expect.true_(suspendmanager.isBuildingPlanJob(job)) +end + +function test.is_kept_suspended() + local bld = construct_test_building() + if not bld then return end + local job = find_job_for(bld) + if not job then return end + -- clearing all suspension reasons must synchronously unmark the job; + -- this is only deterministic within a single frame since an enabled + -- suspendmanager instance re-populates reasons on its own cycle + suspendmanager.runOnce(false, true, true) + expect.false_(suspendmanager.isKeptSuspended(job)) +end + +function test.overlay_update_building() + local overlay = suspendmanager.SuspendOverlay{} + local job = {flags={suspend=true}} + overlay:update_building(42, job) + local data = overlay.in_progress_buildings[42] + expect.true_(data.suspended) + expect.eq(1, data.suspend_count) + -- re-suspending while already suspended doesn't bump the count + overlay:update_building(42, job) + expect.eq(1, overlay.in_progress_buildings[42].suspend_count) + -- unsuspend then suspend again -> repeat offender + job.flags.suspend = false + overlay:update_building(42, job) + job.flags.suspend = true + overlay:update_building(42, job) + expect.eq(2, overlay.in_progress_buildings[42].suspend_count) +end + +function test.overlay_update_viewport() + local overlay = suspendmanager.SuspendOverlay{} + local viewport = {z=1, x1=0, y1=0, x2=10, y2=10} + expect.true_(overlay:update_viewport(viewport)) + expect.false_(overlay:update_viewport(viewport)) + expect.true_(overlay:update_viewport( + {z=2, x1=0, y1=0, x2=10, y2=10})) +end + +function test.overlay_onupdate() + local bld = construct_test_building() + if not bld then return end + local job = find_job_for(bld) + if not job then return end + job.flags.suspend = true + local overlay = suspendmanager.SuspendOverlay{} + overlay:overlay_onupdate() + local data = overlay.in_progress_buildings[bld.id] + expect.ne(nil, data) + expect.true_(data.suspended) + -- a second update clears entries whose version wasn't refreshed + overlay.in_progress_buildings[999999] = {version=0} + overlay:overlay_onupdate() + expect.nil_(overlay.in_progress_buildings[999999]) +end + +function test.status_overlay_no_selection() + local overlay = suspendmanager.StatusOverlay{} + expect.eq('Not suspended.', overlay:get_status_string()) +end + +function test.toggle_overlay_no_selection() + local overlay = suspendmanager.ToggleOverlay{} + expect.false_(overlay:shouldRender()) +end diff --git a/test/plugins/tiletypes.lua b/test/plugins/tiletypes.lua new file mode 100644 index 0000000000..88c8aa6494 --- /dev/null +++ b/test/plugins/tiletypes.lua @@ -0,0 +1,165 @@ +config.mode = 'fortress' +config.target = 'tiletypes' + +local tiletypes = require('plugins.tiletypes') + +local function set_cursor(pos) + df.global.cursor.x = pos.x + df.global.cursor.y = pos.y + df.global.cursor.z = pos.z +end + +local function reset_cursor() + df.global.cursor.x = -30000 + df.global.cursor.y = -30000 + df.global.cursor.z = -30000 +end + +-- options is a list of "