From 52014c15555f0a9641ce972151281715f5741aa6 Mon Sep 17 00:00:00 2001 From: Kelly Kinkade Date: Tue, 22 Sep 2026 14:50:41 -0500 Subject: [PATCH 1/2] Add MapCache::propagateVerticalFlags Propagate light, outside, and subterranean flags to tiles below a tile that changed between an open and a solid shape, matching how the game maintains sky exposure. Handles both directions: exposing tiles copies the changed tile's flags downward, while covering tiles clears the light/outside flags of the column below. --- docs/changelog.txt | 1 + library/include/modules/MapCache.h | 5 ++++ library/modules/MapCache.cpp | 44 ++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/docs/changelog.txt b/docs/changelog.txt index 4dae12dbfc..7a1b7075a8 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -115,6 +115,7 @@ Template for new versions: - Added ``Maps::describeSurroundings`` to get surroundings classification from savagery and evilness - Added ``Maps::getCurrentWeather`` to determine the current weather on the map. - 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 ``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 ``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`` diff --git a/library/include/modules/MapCache.h b/library/include/modules/MapCache.h index c4502cc4b9..7428536afc 100644 --- a/library/include/modules/MapCache.h +++ b/library/include/modules/MapCache.h @@ -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); diff --git a/library/modules/MapCache.cpp b/library/modules/MapCache.cpp index a14d81288e..8f8f2eb4c7 100644 --- a/library/modules/MapCache.cpp +++ b/library/modules/MapCache.cpp @@ -1412,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) From 0200db89f4bd125fda64d3a3ddfad57307879904 Mon Sep 17 00:00:00 2001 From: Kelly Kinkade Date: Tue, 22 Sep 2026 14:50:54 -0500 Subject: [PATCH 2/2] liquids: support painting into sky and fix covered tile flags Implements the liquids improvements requested in #80: support painting into sky and fix covered tile flags - Allocate target map blocks when they don't exist, filling them with open space tiles like natural sky, so liquids and obsidian can be painted into unallocated sky blocks (point, range, block, and column brushes). - Update the light/outside flags of tiles covered by newly painted obsidian walls, obsidian floors, and river sources via the new ` MapCache::propagateVerticalFlags`, so stacked obsidian structures no longer leave tiles below reading "outside light". Tests cover the new behaviors: - painting water or obsidian into unallocated sky blocks, including a range brush that spans a map block boundary - obsidian walls, obsidian floors, and river sources clearing light/outside on the open tiles they cover The tests synthesize unallocated blocks by detaching a block's block_index slot (generated forts typically have no unallocated blocks to find), and the cursor test waits for transient screens above the map to go away so cursor_hotkey-guarded commands can run. Closes #80 (we are not going to implement undo). --- docs/changelog.txt | 2 + plugins/liquids.cpp | 66 ++++++++++ test/plugins/liquids.lua | 261 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 329 insertions(+) diff --git a/docs/changelog.txt b/docs/changelog.txt index 7a1b7075a8..9ac57f6fce 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -91,6 +91,8 @@ Template for new versions: - `burrow`: fix ``tiles clear`` and other tile operations silently failing because burrow block z-coordinates were stored incorrectly in the ``Burrows`` module - `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 - `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 diff --git a/plugins/liquids.cpp b/plugins/liquids.cpp index 0b5526d996..c104102aee 100644 --- a/plugins/liquids.cpp +++ b/plugins/liquids.cpp @@ -150,6 +150,64 @@ struct OperationMode { command_result df_liquids_execute(color_ostream &out); command_result df_liquids_execute(color_ostream &out, OperationMode &mode, df::coord pos); +// allocate the map block containing pos if it doesn't exist yet; newly +// allocated sky blocks are filled with open space tiles like natural sky +static void ensure_block(MapExtras::MapCache &mcache, const DFCoord &pos) +{ + auto block = mcache.BlockAtTile(pos); + if (!block || block->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 is already in map + // block units so only x and y get scaled down + DFCoord lo = cursor / 16; + DFCoord hi = (cursor + mode.size - DFCoord(1,1,1)) / 16; + 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, DFCoord(x*16, y*16, 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]; @@ -446,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 @@ -465,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; @@ -475,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; @@ -497,6 +562,7 @@ command_result df_liquids_execute(color_ostream &out, OperationMode &cur_mode, d Block * b = mcache.BlockAt((*iter)/16); b->enableBlockUpdates(true); + mcache.propagateVerticalFlags(*iter); iter++; } break; diff --git a/test/plugins/liquids.lua b/test/plugins/liquids.lua index d7213ae47a..3b121a06c2 100644 --- a/test/plugins/liquids.lua +++ b/test/plugins/liquids.lua @@ -2,6 +2,7 @@ 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 @@ -29,6 +30,20 @@ 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 @@ -46,6 +61,7 @@ function test.liquids_here_spawns_magma_at_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') @@ -57,6 +73,251 @@ function test.liquids_here_spawns_magma_at_cursor() 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