From 3a7158006b79aacdde37b562c18719af5244b242 Mon Sep 17 00:00:00 2001 From: Alistair-Afton Date: Thu, 17 Sep 2026 22:27:56 +0200 Subject: [PATCH 1/2] suspendmanager: suspend unsafe machine deconstruction --- docs/changelog.txt | 1 + docs/plugins/suspendmanager.rst | 3 + plugins/lua/suspendmanager.lua | 4 +- plugins/suspendmanager.cpp | 89 +++++++++++- test/plugins/suspendmanager.lua | 245 ++++++++++++++++++++++++++++++++ 5 files changed, 340 insertions(+), 2 deletions(-) create mode 100644 test/plugins/suspendmanager.lua diff --git a/docs/changelog.txt b/docs/changelog.txt index da5a2d4d7c..c0977179c1 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -61,6 +61,7 @@ Template for new versions: ## New Features - `autodump`: new ``undestroy`` option reverts pending item destruction while the game is still paused - `stocks`: the overlay's ``collapse all`` hotkey now toggles, expanding all categories again when everything is collapsed +- `suspendmanager`: suspend deconstruction jobs that would collapse connected machinery, such as waterwheels supporting other components ## Fixes - Fix broken weather lookup in ``World::ReadCurrentWeather`` 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/plugins/lua/suspendmanager.lua b/plugins/lua/suspendmanager.lua index 86f0504ed3..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 diff --git a/plugins/suspendmanager.cpp b/plugins/suspendmanager.cpp index 13a37d1901..1c64535764 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)) diff --git a/test/plugins/suspendmanager.lua b/test/plugins/suspendmanager.lua new file mode 100644 index 0000000000..a23a504176 --- /dev/null +++ b/test/plugins/suspendmanager.lua @@ -0,0 +1,245 @@ +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 From 12eccb7ba05781fecf5c080536a45031b9449248 Mon Sep 17 00:00:00 2001 From: Alistair-Afton Date: Thu, 17 Sep 2026 23:49:07 +0200 Subject: [PATCH 2/2] suspendmanager: bounds-check 'set' command arguments Reading parameters[1]/[2] without a size check was out-of-bounds UB when 'suspendmanager set' was invoked with fewer than two arguments. It happened to return CR_WRONG_USAGE on most toolchains, but the garbage read could match 'preventblocking' and break argument validation entirely (seen on the gcc-11 CI leg). --- plugins/suspendmanager.cpp | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/plugins/suspendmanager.cpp b/plugins/suspendmanager.cpp index 1c64535764..81fe48c2e3 100644 --- a/plugins/suspendmanager.cpp +++ b/plugins/suspendmanager.cpp @@ -977,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[0] == "set" && parameters.size() == 3 && 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; }