From 3baa3387b886961c5abd6410afadb00794a6ef70 Mon Sep 17 00:00:00 2001 From: Kelly Kinkade Date: Mon, 21 Sep 2026 00:00:01 -0500 Subject: [PATCH 1/3] implement `Maps::forEachTile` API for bulk operations over portions of the map --- docs/changelog.txt | 2 + docs/dev/Lua API.rst | 73 +++++++++++ library/LuaApi.cpp | 230 +++++++++++++++++++++++++++++++++ library/include/DataDefs.h | 2 +- library/include/modules/Maps.h | 105 +++++++++++++++ library/modules/Maps.cpp | 126 ++++++++++++++++++ test/library/maps.lua | 162 +++++++++++++++++++++++ 7 files changed, 699 insertions(+), 1 deletion(-) create mode 100644 test/library/maps.lua diff --git a/docs/changelog.txt b/docs/changelog.txt index 595907c5b5..e6e6f6aa1a 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -106,8 +106,10 @@ Template for new versions: - Added ``Maps::getCurrentWeather`` to determine the current weather on the map. - Added ``Units::breathes`` and ``Units::getBreathingState`` - Deprecated ``World::ReadCurrentWeather`` in favor of ``Maps::getCurrentWeather`` +- 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 ## Lua +- Added ``dfhack.maps.forEachTile`` to scan a cuboid of tiles with a declarative filter and apply actions (count, set tiletype, set designation/occupancy fields, spawn constructions, Lua callback) in a single native call ## Removed diff --git a/docs/dev/Lua API.rst b/docs/dev/Lua API.rst index 8ddf1f60b3..e253098b86 100644 --- a/docs/dev/Lua API.rst +++ b/docs/dev/Lua API.rst @@ -2532,6 +2532,79 @@ Maps module 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. + + 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, tiletype)`` evaluated last + for each candidate tile; the tile matches only if it returns a truthy + value. + + 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. + - ``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, 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 -------------- diff --git a/library/LuaApi.cpp b/library/LuaApi.cpp index 161e6fb425..acfd85925d 100644 --- a/library/LuaApi.cpp +++ b/library/LuaApi.cpp @@ -76,6 +76,7 @@ distribution. #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" @@ -119,6 +120,7 @@ distribution. #include #include #include +#include #include #include #include @@ -2857,6 +2859,233 @@ static int maps_addItemSpatter(lua_State *L) 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, tiletype). 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, (int)block->tiletype[local.x][local.y]); + lua_call(L, 5, 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_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 }, @@ -2875,6 +3104,7 @@ static const luaL_Reg dfhack_maps_funcs[] = { { "removeTileAquifer", maps_removeTileAquifer }, { "addMaterialSpatter", maps_addMaterialSpatter }, { "addItemSpatter", maps_addItemSpatter }, + { "forEachTile", maps_forEachTile }, { NULL, NULL } }; diff --git a/library/include/DataDefs.h b/library/include/DataDefs.h index 467816e69c..dd910b3a8e 100644 --- a/library/include/DataDefs.h +++ b/library/include/DataDefs.h @@ -853,7 +853,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); } /** diff --git a/library/include/modules/Maps.h b/library/include/modules/Maps.h index 93d506e273..24a8af8f43 100644 --- a/library/include/modules/Maps.h +++ b/library/include/modules/Maps.h @@ -34,6 +34,9 @@ distribution. #include "modules/Materials.h" +#include +#include + #include "df/biome_type.h" #include "df/block_flags.h" #include "df/coord.h" @@ -41,6 +44,7 @@ distribution. #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" @@ -435,6 +439,107 @@ DFHACK_EXPORT const char* describeSurroundings(int savagery, int evilness); // 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. + std::optional set_tiletype; + /// 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/modules/Maps.cpp b/library/modules/Maps.cpp index bec23c8c20..2c17a2bb5f 100644 --- a/library/modules/Maps.cpp +++ b/library/modules/Maps.cpp @@ -31,9 +31,11 @@ distribution. #include "Error.h" #include "MemAccess.h" #include "MiscUtils.h" +#include "TileTypes.h" #include "VersionInfo.h" #include "modules/Buildings.h" +#include "modules/Constructions.h" #include "modules/MapCache.h" #include "modules/Maps.h" @@ -49,6 +51,7 @@ distribution. #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" @@ -1792,3 +1795,126 @@ const char* Maps::getSiteTypeName(df::world_site *site) { 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) { + block->tiletype[lx][ly] = *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/test/library/maps.lua b/test/library/maps.lua new file mode 100644 index 0000000000..7252d24a5f --- /dev/null +++ b/test/library/maps.lua @@ -0,0 +1,162 @@ +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.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_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, tt) + seen = seen + 1 + expect.eq(block.map_pos.x, block_arg.map_pos.x) + expect.eq(block.tiletype[x % 16][y % 16], 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 From f2f8f74e8b1957787a9e5974a26fe00dca2b608d Mon Sep 17 00:00:00 2001 From: Kelly Kinkade Date: Mon, 21 Sep 2026 01:50:53 -0500 Subject: [PATCH 2/3] implement some suggestions added an optional tiletype replacement map added passing block-local coordinates to callbacks --- docs/changelog.txt | 2 +- docs/dev/Lua API.rst | 18 +++++++++++------- library/LuaApi.cpp | 29 +++++++++++++++++++++++------ library/include/modules/Maps.h | 9 ++++++++- library/modules/Maps.cpp | 14 +++++++++++--- test/library/maps.lua | 31 +++++++++++++++++++++++++++++-- 6 files changed, 83 insertions(+), 20 deletions(-) diff --git a/docs/changelog.txt b/docs/changelog.txt index e6e6f6aa1a..0eb32cff63 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -109,7 +109,7 @@ Template for new versions: - 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 ## Lua -- Added ``dfhack.maps.forEachTile`` to scan a cuboid of tiles with a declarative filter and apply actions (count, set tiletype, set designation/occupancy fields, spawn constructions, Lua callback) in a single native call +- 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 ## Removed diff --git a/docs/dev/Lua API.rst b/docs/dev/Lua API.rst index e253098b86..1b1aae9263 100644 --- a/docs/dev/Lua API.rst +++ b/docs/dev/Lua API.rst @@ -2561,16 +2561,20 @@ Maps module 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, tiletype)`` evaluated last - for each candidate tile; the tile matches only if it returns a truthy - value. + - ``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. + 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). @@ -2585,9 +2589,9 @@ Maps module 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, tiletype)`` invoked for - each matching tile after the other actions are applied. Returning - exactly ``false`` aborts the scan. + - ``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 diff --git a/library/LuaApi.cpp b/library/LuaApi.cpp index acfd85925d..7dd9d4a763 100644 --- a/library/LuaApi.cpp +++ b/library/LuaApi.cpp @@ -2937,10 +2937,11 @@ static void parse_bitfield_spec(lua_State *L, int tbl, const char *key, } // Wrap the Lua function at stack index fn as a per-tile predicate/callback. -// The function is invoked as fn(x, y, z, block, tiletype). 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). +// 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) { @@ -2950,8 +2951,10 @@ wrap_lua_tile_fn(lua_State *L, int fn, bool truthy = false) 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, 5, 1); + 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); @@ -3032,7 +3035,21 @@ static int maps_forEachTile(lua_State *L) actions.callback = wrap_lua_tile_fn(L, aidx); else if (lua_istable(L, aidx)) { lua_getfield(L, aidx, "set_tiletype"); - if (!lua_isnil(L, -1)) + 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); diff --git a/library/include/modules/Maps.h b/library/include/modules/Maps.h index 24a8af8f43..c40001d993 100644 --- a/library/include/modules/Maps.h +++ b/library/include/modules/Maps.h @@ -35,6 +35,7 @@ distribution. #include "modules/Materials.h" #include +#include #include #include "df/biome_type.h" @@ -484,8 +485,14 @@ struct DFHACK_EXPORT TileFilter { * \ingroup grp_maps */ struct DFHACK_EXPORT TileActions { - /// If set, matching tiles are changed to this tiletype. + /// 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; diff --git a/library/modules/Maps.cpp b/library/modules/Maps.cpp index 2c17a2bb5f..cd6b6fa46c 100644 --- a/library/modules/Maps.cpp +++ b/library/modules/Maps.cpp @@ -1864,9 +1864,17 @@ Maps::TileScanResult Maps::forEachTile( continue; result.tiles_matched++; - if (actions.set_tiletype) { - block->tiletype[lx][ly] = *actions.set_tiletype; - result.tiletypes_changed++; + 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) { diff --git a/test/library/maps.lua b/test/library/maps.lua index 7252d24a5f..b7a06c8ab3 100644 --- a/test/library/maps.lua +++ b/test/library/maps.lua @@ -64,6 +64,31 @@ function test.forEachTile_set_tiletype() 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 @@ -90,10 +115,12 @@ 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, tt) + 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(block.tiletype[x % 16][y % 16], tt) + 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) From 13a73c33e7f38801722840ac75cceb39987e5f74 Mon Sep 17 00:00:00 2001 From: Kelly Kinkade Date: Mon, 21 Sep 2026 18:51:21 -0500 Subject: [PATCH 3/3] add gentle warning that the API is not yet stable --- docs/dev/Lua API.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/dev/Lua API.rst b/docs/dev/Lua API.rst index 1b1aae9263..be94fa3253 100644 --- a/docs/dev/Lua API.rst +++ b/docs/dev/Lua API.rst @@ -2539,6 +2539,9 @@ Maps module 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