From 2e58a0b8014d1611c79ed372774033aa916e2166 Mon Sep 17 00:00:00 2001 From: Alistair-Afton Date: Wed, 16 Sep 2026 17:04:31 +0200 Subject: [PATCH] buildings: make windows masterable like other demandable furnishings Gem and glass windows are the only buildings nobles can demand in their rooms whose masterablebuilding vmethod returns false. The game is itself inconsistent: placing a building inside an existing room links it unconditionally, while designating a room over an existing building is gated by the vmethod, so window-in-room linking was order-dependent and DFHack-placed windows were never linked at all. Interpose canMakeRoom on the two concrete window subclasses to return true, fixing all call paths (vanilla and DFHack) uniformly. Covers both link directions with fortress tests. Fixes #5693. --- docs/changelog.txt | 1 + library/modules/Buildings.cpp | 33 +++++++++++ test/modules/buildings_fortress.lua | 92 +++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+) create mode 100644 test/modules/buildings_fortress.lua diff --git a/docs/changelog.txt b/docs/changelog.txt index be0b51c69e..331604e716 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -74,6 +74,7 @@ Template for new versions: - `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 - `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 +- Fixed gem and glass windows being unable to be made part of a room; they are the only buildings nobles can demand that could not be, whether the window or the room is placed first - `timestream`: deal properly with units who have breathing difficulties - `stocks`: overlay now resets scroll position when collapsing categories so the item list is no longer left blank and unscrollable - Fixed persistent site data API (``dfhack.persistent.saveSiteData``/``getSiteData``) failing on newly reclaimed fortresses until the first save diff --git a/library/modules/Buildings.cpp b/library/modules/Buildings.cpp index ddedbbc1b6..357c744358 100644 --- a/library/modules/Buildings.cpp +++ b/library/modules/Buildings.cpp @@ -33,6 +33,7 @@ distribution. #include "TileTypes.h" #include "MiscUtils.h" #include "DataDefs.h" +#include "VTableInterpose.h" #include "modules/Buildings.h" #include "modules/Maps.h" @@ -61,6 +62,8 @@ distribution. #include "df/building_water_wheelst.h" #include "df/building_weaponst.h" #include "df/building_wellst.h" +#include "df/building_window_gemst.h" +#include "df/building_window_glassst.h" #include "df/building_workshopst.h" #include "df/buildingitemst.h" #include "df/buildings_other_id.h" @@ -134,13 +137,43 @@ static df::building_extents_type *getExtentTile(const df::building::T_room &room */ bool buildings_do_onupdate = false; +/* + * Mitigation for a DF bug: gem and glass windows are the only buildings that + * nobles can demand in their rooms whose masterablebuilding (canMakeRoom) + * vmethod returns false, so a window built inside an existing room is linked + * to it while a room designated over an existing window is not. Interpose the + * vmethod on the two concrete subclasses; the abstract building_windowst + * vtable cannot be hooked. + */ +struct window_gem_masterable_hook : df::building_window_gemst { + typedef df::building_window_gemst interpose_base; + DEFINE_VMETHOD_INTERPOSE(bool, canMakeRoom, ()) + { + return true; + } +}; +IMPLEMENT_VMETHOD_INTERPOSE(window_gem_masterable_hook, canMakeRoom); + +struct window_glass_masterable_hook : df::building_window_glassst { + typedef df::building_window_glassst interpose_base; + DEFINE_VMETHOD_INTERPOSE(bool, canMakeRoom, ()) + { + return true; + } +}; +IMPLEMENT_VMETHOD_INTERPOSE(window_glass_masterable_hook, canMakeRoom); + void buildings_onStateChange(color_ostream &out, state_change_event event) { switch (event) { case SC_MAP_LOADED: + INTERPOSE_HOOK(window_gem_masterable_hook, canMakeRoom).apply(); + INTERPOSE_HOOK(window_glass_masterable_hook, canMakeRoom).apply(); buildings_do_onupdate = true; break; case SC_MAP_UNLOADED: + INTERPOSE_HOOK(window_gem_masterable_hook, canMakeRoom).remove(); + INTERPOSE_HOOK(window_glass_masterable_hook, canMakeRoom).remove(); buildings_do_onupdate = false; break; default: diff --git a/test/modules/buildings_fortress.lua b/test/modules/buildings_fortress.lua new file mode 100644 index 0000000000..53b9026648 --- /dev/null +++ b/test/modules/buildings_fortress.lua @@ -0,0 +1,92 @@ +config.target = 'core' +config.mode = 'fortress' + +local function contains(vec, value) + for _,entry in ipairs(vec) do + if entry == value then return true end + end + return false +end + +local function construct_window_in_zone(window_type) + for _,zone in ipairs(df.global.world.buildings.other.ANY_ZONE) do + if zone.room.extents then + for y = zone.y1, zone.y2 do + for x = zone.x1, zone.x2 do + local pos = xyz2pos(x, y, zone.z) + if not dfhack.buildings.findAtTile(pos) then + local bld = dfhack.buildings.constructBuilding{ + pos=pos, + type=window_type, + filters=dfhack.buildings.getFiltersByType( + {}, window_type, -1, -1), + } + if bld then return bld, zone end + end + end + end + end + end +end + +local function expect_window_linked(window_type) + local bld, zone = construct_window_in_zone(window_type) + expect.ne(nil, bld, 'could not find a zone tile suitable for a window') + if not bld then return end + dfhack.with_finalize( + function() dfhack.buildings.deconstruct(bld) end, + function() + expect.true_(contains(bld.relations, zone)) + expect.true_(contains(zone.contained_buildings, bld)) + end) +end + +function test.constructed_gem_window_is_linked_to_zone() + expect_window_linked(df.building_type.WindowGem) +end + +function test.constructed_glass_window_is_linked_to_zone() + expect_window_linked(df.building_type.WindowGlass) +end + +local function expect_zoned_window_linked(window_type) + local bld, _ = construct_window_in_zone(window_type) + expect.ne(nil, bld, 'could not find a tile suitable for a window') + if not bld then return end + dfhack.with_finalize( + function() dfhack.buildings.deconstruct(bld) end, + function() + local pos = xyz2pos(bld.centerx, bld.centery, bld.z) + local extents = df.reinterpret_cast(df.building_extents_type, + df.new('uint8_t', 1)) + extents[0] = 1 + local zone, err = dfhack.buildings.constructBuilding{ + type=df.building_type.Civzone, + subtype=df.civzone_type.Bedroom, + abstract=true, + pos=pos, width=1, height=1, + fields={ + assigned_unit_id=-1, + room={x=pos.x, y=pos.y, width=1, height=1, + extents=extents}, + }, + } + expect.ne(nil, zone, 'could not place zone over window: ' .. + tostring(err)) + if not zone then return end + dfhack.with_finalize( + function() dfhack.buildings.deconstruct(zone) end, + function() + expect.true_(contains(bld.relations, zone)) + expect.true_(contains(zone.contained_buildings, bld)) + end) + end) +end + +function test.zoned_gem_window_is_linked_to_zone() + expect_zoned_window_linked(df.building_type.WindowGem) +end + +function test.zoned_glass_window_is_linked_to_zone() + expect_zoned_window_linked(df.building_type.WindowGlass) +end