From 7a32000ab1c9e45b758750b6f4aef7e7e12edb18 Mon Sep 17 00:00:00 2001 From: Alistair-Afton Date: Tue, 15 Sep 2026 03:10:25 +0200 Subject: [PATCH 1/2] combine: differentiate dyes by dye_profile instead of excluding them Blanket-excluding dyes prevented combining them at all. Mixed dyes share mat_type/mat_index with their components, so keying POWDER_MISC items on the dye_profile fingerprint keeps mixes distinct while allowing identical dyes to stack. fixes DFHack/dfhack#5849 --- changelog.txt | 1 + combine.lua | 48 ++++++++++++++++++++++--------- test/combine.lua | 75 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 14 deletions(-) create mode 100644 test/combine.lua diff --git a/changelog.txt b/changelog.txt index b492b6d2ec..5acfd2c12e 100644 --- a/changelog.txt +++ b/changelog.txt @@ -31,6 +31,7 @@ Template for new versions: ## New Features ## Fixes +- `combine`: dyes are combined again, but only when their ``dye_profile`` matches, so mixed dyes no longer revert to a component dye - `bodyswap`: fix "invalid argument count" when the target unit has no nemesis record - `fix/loyaltycascade`: guard against citizens that are not historical figures and emit a warning. - `gui/siegemanager`: fix nil index if there are no siege engines on the map diff --git a/combine.lua b/combine.lua index cd9e2522f0..b0ce608fcd 100644 --- a/combine.lua +++ b/combine.lua @@ -1,3 +1,6 @@ +-- Combine items in stockpiles into stacks. +--@module = true + local argparse = require('argparse') local utils = require('utils') @@ -182,21 +185,22 @@ local function stack_type_new(type_vals) return stack_type end -local function isDye(item) - -- Dyes should not be combined as this will cause bugs when mixing them together - if item:getType() ~= df.item_type.POWDER_MISC then return false end - -- pcall guards items/materials that can't be decoded or lack the flag - local ok, is_dye = pcall(function() - local mat = dfhack.matinfo.decode(item.mat_type, item.mat_index) - return mat and mat.material.flags.IS_DYE or false - end) - return ok and is_dye or false +-- produce a fingerprint of an item's dye_profile, which distinguishes mixed +-- dyes (e.g. a blend of two dyes) from their components; they all share the +-- same mat_type/mat_index +local function dye_profile_key(item) + local profile = item.dye_profile + if not profile then return '' end + local parts = {profile.color_index} + for _,v in ipairs(profile.dye_material) do parts[#parts+1] = v end + for _,v in ipairs(profile.dye_matg) do parts[#parts+1] = v end + for _,v in ipairs(profile.degree) do parts[#parts+1] = v end + for _,v in ipairs(profile.target_index) do parts[#parts+1] = v end + return table.concat(parts, '+') end -local function stacks_add_item(stockpile, stacks, stack_type, item, container) - -- add an item to the matching comp_items table; based on comp_key. - local comp_key = '' - +local function make_comp_key(stack_type, item) + local comp_key if typesThatUseCreatures[df.item_type[stack_type.type_id]] then if not typesThatUseMaterial[df.item_type[stack_type.type_id]] then comp_key = ('%s+%s+%s'):format(stack_type.type_id, item.race, item.caste) @@ -212,6 +216,15 @@ local function stacks_add_item(stockpile, stacks, stack_type, item, container) else comp_key = ('%s+%s+%s'):format(stack_type.type_id, item.mat_type, item.mat_index) end + if stack_type.type_id == df.item_type.POWDER_MISC then + comp_key = ('%s+%s'):format(comp_key, dye_profile_key(item)) + end + return comp_key +end + +local function stacks_add_item(stockpile, stacks, stack_type, item, container) + -- add an item to the matching comp_items table; based on comp_key. + local comp_key = make_comp_key(stack_type, item) if not stack_type.comp_items[comp_key] then stack_type.comp_items[comp_key] = comp_item_new(comp_key, stack_type) @@ -447,7 +460,7 @@ local function stacks_add_items(stockpile, stacks, items, container, ind) local stack_type = stacks.stack_types[type_id] -- item type in list of included types? - if stack_type and not item:isSand() and not item:isPlaster() and not isDye(item) and isValidPart(item) then + if stack_type and not item:isSand() and not item:isPlaster() and isValidPart(item) then if not isRestrictedItem(item) and item.stack_size <= stack_type.max_stack_qty then stacks_add_item(stockpile, stacks, stack_type, item, container) @@ -862,6 +875,13 @@ local function main() end +if dfhack.internal.IN_TEST then + unit_test_hooks = { + make_comp_key=make_comp_key, + dye_profile_key=dye_profile_key, + } +end + if not dfhack_flags.module then main() end diff --git a/test/combine.lua b/test/combine.lua new file mode 100644 index 0000000000..2d1a551b25 --- /dev/null +++ b/test/combine.lua @@ -0,0 +1,75 @@ +config.target = 'combine' + +local combine = reqscript('combine') +local p = combine.unit_test_hooks + +local POWDER_MISC = df.item_type.POWDER_MISC +local DRINK = df.item_type.DRINK + +local function mock_item(item_type, fields) + local item = { + getType=function() return item_type end, + isCrafted=function() return false end, + } + for k,v in pairs(fields) do item[k] = v end + return item +end + +local function mock_dye(color_index, materials) + return mock_item(POWDER_MISC, { + mat_type=0, mat_index=0, + dye_profile={ + color_index=color_index, + dye_material=materials, + dye_matg={}, + degree={}, + target_index={}, + }, + }) +end + +function test.dye_same_profile_same_key() + local stack_type = {type_id=POWDER_MISC} + local dye_a = mock_dye(5, {10}) + local dye_b = mock_dye(5, {10}) + expect.eq(p.make_comp_key(stack_type, dye_a), + p.make_comp_key(stack_type, dye_b)) +end + +function test.dye_different_color_different_key() + local stack_type = {type_id=POWDER_MISC} + local dye_a = mock_dye(5, {10}) + local dye_b = mock_dye(7, {10}) + expect.ne(p.make_comp_key(stack_type, dye_a), + p.make_comp_key(stack_type, dye_b)) +end + +function test.dye_mix_different_key_than_component() + -- a mixed dye shares mat_type/mat_index with its components but has a + -- different dye_profile; it must not be merged into a component stack + local stack_type = {type_id=POWDER_MISC} + local dye_a = mock_dye(5, {10}) + local dye_mix = mock_dye(5, {10, 20}) + expect.ne(p.make_comp_key(stack_type, dye_a), + p.make_comp_key(stack_type, dye_mix)) +end + +function test.powder_without_profile() + -- non-dye powders have an empty/unset profile and still merge as before + local stack_type = {type_id=POWDER_MISC} + local p1 = mock_item(POWDER_MISC, {mat_type=1, mat_index=2}) + local p2 = mock_item(POWDER_MISC, {mat_type=1, mat_index=2}) + expect.eq(p.make_comp_key(stack_type, p1), + p.make_comp_key(stack_type, p2)) +end + +function test.dye_key_ignores_other_types() + local stack_type = {type_id=DRINK} + local d1 = mock_item(DRINK, {mat_type=1, mat_index=2}) + local d2 = mock_item(DRINK, {mat_type=1, mat_index=2, + dye_profile={color_index=9, dye_material={1}, + dye_matg={}, degree={}, + target_index={}}}) + expect.eq(p.make_comp_key(stack_type, d1), + p.make_comp_key(stack_type, d2)) +end From af1d3d369e24fc8ca9b05926286a909333191eaf Mon Sep 17 00:00:00 2001 From: Alistair-Afton Date: Tue, 15 Sep 2026 18:32:10 +0200 Subject: [PATCH 2/2] combine: never merge dyes that are missing their dye_profile Merchant dyes can end up with missing or incorrect dye info due to unfixed vanilla bugs. Give such items a unique comparison key so they stay uncombined rather than being merged into an unrelated stack. --- combine.lua | 18 +++++++++++++++++- test/combine.lua | 30 ++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/combine.lua b/combine.lua index b0ce608fcd..d7116db10c 100644 --- a/combine.lua +++ b/combine.lua @@ -185,12 +185,28 @@ local function stack_type_new(type_vals) return stack_type end +local function isDye(item) + -- pcall guards items/materials that can't be decoded or lack the flag + local ok, is_dye = pcall(function() + local mat = dfhack.matinfo.decode(item.mat_type, item.mat_index) + return mat and mat.material.flags.IS_DYE or false + end) + return ok and is_dye or false +end + -- produce a fingerprint of an item's dye_profile, which distinguishes mixed -- dyes (e.g. a blend of two dyes) from their components; they all share the -- same mat_type/mat_index local function dye_profile_key(item) local profile = item.dye_profile - if not profile then return '' end + if not profile then + if isDye(item) then + -- merchant dyes can be missing their profile due to a vanilla + -- bug; give each such item its own key so they never combine + return 'unprofiled+' .. item.id + end + return '' + end local parts = {profile.color_index} for _,v in ipairs(profile.dye_material) do parts[#parts+1] = v end for _,v in ipairs(profile.dye_matg) do parts[#parts+1] = v end diff --git a/test/combine.lua b/test/combine.lua index 2d1a551b25..ca2f4066aa 100644 --- a/test/combine.lua +++ b/test/combine.lua @@ -63,6 +63,36 @@ function test.powder_without_profile() p.make_comp_key(stack_type, p2)) end +local function find_dye_mat() + for _, plant in ipairs(df.global.world.raws.plants.all) do + local mtype = plant.material_defs.type.mill + local midx = plant.material_defs.idx.mill + if mtype ~= -1 then + local matinfo = dfhack.matinfo.decode(mtype, midx) + if matinfo and matinfo.material.flags.IS_DYE then + return mtype, midx + end + end + end +end + +function test.unprofiled_dye_never_combines() + -- merchant dyes can lose their dye_profile to a vanilla bug; such items + -- must not be merged, not even with each other + local mtype, midx = find_dye_mat() + if not mtype then return end + local stack_type = {type_id=POWDER_MISC} + local d1 = mock_item(POWDER_MISC, {mat_type=mtype, mat_index=midx, id=101}) + local d2 = mock_item(POWDER_MISC, {mat_type=mtype, mat_index=midx, id=102}) + local d3 = mock_item(POWDER_MISC, {mat_type=mtype, mat_index=midx, id=103, + dye_profile={color_index=1, dye_material={1}, dye_matg={}, degree={}, + target_index={}}}) + expect.ne(p.make_comp_key(stack_type, d1), + p.make_comp_key(stack_type, d2)) + expect.ne(p.make_comp_key(stack_type, d1), + p.make_comp_key(stack_type, d3)) +end + function test.dye_key_ignores_other_types() local stack_type = {type_id=DRINK} local d1 = mock_item(DRINK, {mat_type=1, mat_index=2})