From 6bc4f846696798d3f07b18d2e45130a58b594129 Mon Sep 17 00:00:00 2001 From: torn1147 Date: Sun, 13 Sep 2026 12:26:55 -0600 Subject: [PATCH 1/5] exportlegends: restore classic companion exports --- changelog.txt | 1 + docs/exportlegends.rst | 15 +++ exportlegends.lua | 247 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 260 insertions(+), 3 deletions(-) diff --git a/changelog.txt b/changelog.txt index b492b6d2ec..e859d8deae 100644 --- a/changelog.txt +++ b/changelog.txt @@ -29,6 +29,7 @@ Template for new versions: ## New Tools ## New Features +- `exportlegends`: recreate the Classic population, history, and world map companion exports ## Fixes - `bodyswap`: fix "invalid argument count" when the target unit has no nemesis record diff --git a/docs/exportlegends.rst b/docs/exportlegends.rst index 5d262a853c..11115d74bb 100644 --- a/docs/exportlegends.rst +++ b/docs/exportlegends.rst @@ -28,6 +28,21 @@ To use: You can also generate just the extended data export by manually running the ``exportlegends`` command while legends mode is open. +In addition to ``legends_plus.xml``, ``exportlegends`` recreates the companion +files that Classic Dwarf Fortress produced with its "Export Map/Gen +Information" action: + +- ``world_sites_and_pops.txt`` contains civilized, site, outdoor animal, and + underground animal population totals. +- ``world_history.txt`` contains civilizations, worship relationships, and + current position holders. +- ``world_map.bmp`` is a geographically aligned terrain map generated from the + current world data. Its terrain colors approximate the Classic map since + Premium does not expose the removed renderer. + +These files use the Classic names and structure expected by external legends +viewers. + Usage ----- diff --git a/exportlegends.lua b/exportlegends.lua index 5488573063..19eb455ca9 100644 --- a/exportlegends.lua +++ b/exportlegends.lua @@ -123,6 +123,243 @@ end local world = df.global.world +local function creature_name(race) + local raw = df.creature_raw.find(race) + return raw and dfhack.df2utf(raw.name[1]) or ('unknown creature ' .. race) +end + +local function add_population(populations, race, count, unnumbered) + if race < 0 then return end + local population = populations[race] or {count=0, unnumbered=false} + population.count = population.count + math.max(0, count or 0) + population.unnumbered = population.unnumbered or unnumbered + populations[race] = population +end + +local function write_populations(file, populations) + local rows = {} + for race, population in pairs(populations) do + if population.unnumbered or population.count > 0 then + table.insert(rows, {race=race, population=population}) + end + end + table.sort(rows, function(a, b) + if a.population.unnumbered ~= b.population.unnumbered then + return a.population.unnumbered + end + if a.population.count ~= b.population.count then + return a.population.count > b.population.count + end + return creature_name(a.race) < creature_name(b.race) + end) + for _, row in ipairs(rows) do + local count = row.population.unnumbered and 'Unnumbered' or row.population.count + file:write(('\t%s %s\n'):format(count, creature_name(row.race))) + end +end + +local function collect_wild_populations(populations, records) + for _, pop in ipairs(records) do + local creature_type = pop.type == df.world_population_type.Animal + or pop.type == df.world_population_type.Vermin + or pop.type == df.world_population_type.VerminInnumerable + or pop.type == df.world_population_type.ColonyInsect + if creature_type then + local unnumbered = pop.type == df.world_population_type.VerminInnumerable + or pop.count_min >= 10000001 + or pop.count_max >= 10000001 + add_population(populations, pop.race, pop.count_min, unnumbered) + end + end +end + +-- Recreates the population companion file that Classic DF generated with +-- "Export Map/Gen Information". Premium removed that export path. +local function export_sites_and_pops() + local filename = world.cur_savegame.save_dir .. '-' .. get_world_date_str() + .. '-world_sites_and_pops.txt' + local file = io.open(filename, 'w') + if not file then qerror('could not open file: ' .. filename) end + + local civilized = {} + for _, entity_population in ipairs(world.entity_populations) do + for i, race in ipairs(entity_population.races) do + add_population(civilized, race, entity_population.counts[i]) + end + end + file:write('Civilized World Population\n\n') + write_populations(file, civilized) + file:write('\nSites\n\n') + + for _, site in ipairs(world.world_data.sites) do + local native_name = dfhack.df2utf(dfhack.translation.translateName(site.name)) + local english_name = dfhack.df2utf(dfhack.translation.translateName(site.name, true)) + file:write(('%d: %s, %s\n'):format(site.id, native_name, english_name)) + local populations = {} + for _, inhabitant in ipairs(site.populace.inhabitants) do + add_population(populations, inhabitant.pop_spec.race, inhabitant.count) + end + write_populations(file, populations) + yield_if_timeout() + end + + local outdoor = {} + for _, region in ipairs(world.world_data.regions) do + collect_wild_populations(outdoor, region.population) + end + file:write('\nOutdoor Animal Populations (Including Undead)\n\n') + write_populations(file, outdoor) + + local underground = {} + for _, region in ipairs(world.world_data.underground_regions) do + local feature = region.feature_init and region.feature_init:getFeature() + if feature then collect_wild_populations(underground, feature.population) end + end + file:write('\nUnderground Animal Populations (Including Undead)\n') + write_populations(file, underground) + file:close() + print('Done exporting population data to: ' .. filename) +end + +local function translated_name(name, english) + return dfhack.df2utf(dfhack.translation.translateName(name, english)) +end + +local function export_world_history() + local filename = world.cur_savegame.save_dir .. '-' .. get_world_date_str() + .. '-world_history.txt' + local file = io.open(filename, 'w') + if not file then qerror('could not open file: ' .. filename) end + + file:write(translated_name(world.world_data.name), '\n') + file:write(translated_name(world.world_data.name, true), '\n\n') + file:write('Civilizations\n\n') + + for _, entity in ipairs(world.entities.all) do + if entity.type == df.historical_entity_type.Civilization and entity.race >= 0 then + file:write(translated_name(entity.name), ', ', creature_name(entity.race), '\n') + + if #entity.relations.deities > 0 then + file:write(' Worship List\n') + for _, hfid in ipairs(entity.relations.deities) do + local deity = df.historical_figure.find(hfid) + if deity then + file:write(' ', translated_name(deity.name), ', deity\n') + end + end + end + + local positions = {} + for _, position in ipairs(entity.positions.own) do + positions[position.id] = position + end + for _, assignment in ipairs(entity.positions.assignments) do + local holder = df.historical_figure.find(assignment.histfig) + local position = positions[assignment.position_id] + if holder and position then + local start_year = 0 + for _, link in ipairs(holder.entity_links) do + if df.histfig_entity_link_positionst:is_instance(link) + and link.entity_id == entity.id + and link.assignment_id == assignment.id then + start_year = link.start_year + break + end + end + local title = position.name[0] or '' + if title == '' then title = position.name_male[0] or '' end + if title == '' then title = position.name_female[0] or '' end + if title ~= '' then + file:write(' ', dfhack.df2utf(title), ' List\n') + file:write((' [*] %s (b. 0), Reign began: %d), current ruler\n') + :format(translated_name(holder.name), start_year)) + end + end + end + end + yield_if_timeout() + end + file:close() + print('Done exporting world history to: ' .. filename) +end + +local function map_color(tile) + if tile.flags.is_lake then return 45, 105, 170 end + if tile.elevation < 100 then + local blue = math.max(90, math.min(190, 190 - (100 - tile.elevation))) + return 30, 75, blue + end + if tile.flags.is_peak or tile.elevation >= 150 then + local shade = math.max(105, math.min(210, tile.elevation + 35)) + return shade, shade, shade + end + if tile.temperature < -500 then return 220, 230, 225 end + if tile.rainfall < 20 or tile.vegetation < 20 then return 190, 165, 95 end + if tile.vegetation > 65 then return 45, 115, 55 end + return 95, 145, 70 +end + +local function export_world_map() + local filename = world.cur_savegame.save_dir .. '-' .. get_world_date_str() + .. '-world_map.bmp' + local file = io.open(filename, 'wb') + if not file then qerror('could not open file: ' .. filename) end + + local scale = 16 + local width = world.world_data.world_width * scale + local height = world.world_data.world_height * scale + local row_size = width * 3 + local padding = (4 - row_size % 4) % 4 + local image_size = (row_size + padding) * height + file:write('BM', string.pack(' Date: Sun, 13 Sep 2026 14:21:36 -0600 Subject: [PATCH 2/5] add more parent info for sites --- exportlegends.lua | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/exportlegends.lua b/exportlegends.lua index 19eb455ca9..6ca1c514a9 100644 --- a/exportlegends.lua +++ b/exportlegends.lua @@ -195,6 +195,25 @@ local function export_sites_and_pops() local native_name = dfhack.df2utf(dfhack.translation.translateName(site.name)) local english_name = dfhack.df2utf(dfhack.translation.translateName(site.name, true)) file:write(('%d: %s, %s\n'):format(site.id, native_name, english_name)) + + local owner = df.historical_entity.find(site.cur_owner_id) + if owner and owner.race >= 0 then + file:write(('Owner: %s, %s\n'):format( + dfhack.df2utf(dfhack.translation.translateName(owner.name, true)), creature_name(owner.race))) + if owner.type ~= df.historical_entity_type.Civilization then + for _, link in ipairs(owner.entity_links) do + if link.type == df.entity_entity_link_type.PARENT then + local parent = df.historical_entity.find(link.target) + if parent and parent.race >= 0 then + file:write(('Parent Civ: %s, %s\n'):format( + dfhack.df2utf(dfhack.translation.translateName(parent.name, true)), creature_name(parent.race))) + break + end + end + end + end + end + local populations = {} for _, inhabitant in ipairs(site.populace.inhabitants) do add_population(populations, inhabitant.pop_spec.race, inhabitant.count) From d2cd3d656127aeecf2447250842b54ffe0c0c99f Mon Sep 17 00:00:00 2001 From: Inder Date: Sun, 13 Sep 2026 14:55:39 -0600 Subject: [PATCH 3/5] add site animal info --- exportlegends.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/exportlegends.lua b/exportlegends.lua index 6ca1c514a9..b666e1231b 100644 --- a/exportlegends.lua +++ b/exportlegends.lua @@ -218,6 +218,7 @@ local function export_sites_and_pops() for _, inhabitant in ipairs(site.populace.inhabitants) do add_population(populations, inhabitant.pop_spec.race, inhabitant.count) end + collect_wild_populations(populations, site.populace.animals) write_populations(file, populations) yield_if_timeout() end From 04ad06a1e6b5a653a81ed875e972410073ef828c Mon Sep 17 00:00:00 2001 From: Inder Date: Sun, 13 Sep 2026 15:10:24 -0600 Subject: [PATCH 4/5] more granular pops labels so undead are differentiated --- exportlegends.lua | 56 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 47 insertions(+), 9 deletions(-) diff --git a/exportlegends.lua b/exportlegends.lua index b666e1231b..9fac32efbc 100644 --- a/exportlegends.lua +++ b/exportlegends.lua @@ -128,19 +128,33 @@ local function creature_name(race) return raw and dfhack.df2utf(raw.name[1]) or ('unknown creature ' .. race) end -local function add_population(populations, race, count, unnumbered) +local function add_population(populations, race, count, unnumbered, label) if race < 0 then return end - local population = populations[race] or {count=0, unnumbered=false} + local key = tostring(race) .. ':' .. (label or '') + local population = populations[key] or {race=race, label=label, count=0, unnumbered=false} population.count = population.count + math.max(0, count or 0) population.unnumbered = population.unnumbered or unnumbered - populations[race] = population + populations[key] = population +end + +local function population_label(pop_spec) + if pop_spec.interaction_index < 0 then return nil end + local interaction = df.interaction.find(pop_spec.interaction_index) + if not interaction then return nil end + local effect = interaction.effects[pop_spec.interaction_effect_index] + local effect_type = effect and effect:getType() + if effect_type == df.interaction_effect_type.ANIMATE + or effect_type == df.interaction_effect_type.RESURRECT then + return 'undead ' .. creature_name(pop_spec.race) + end + return nil end local function write_populations(file, populations) local rows = {} - for race, population in pairs(populations) do + for _, population in pairs(populations) do if population.unnumbered or population.count > 0 then - table.insert(rows, {race=race, population=population}) + table.insert(rows, {race=population.race, population=population}) end end table.sort(rows, function(a, b) @@ -154,7 +168,8 @@ local function write_populations(file, populations) end) for _, row in ipairs(rows) do local count = row.population.unnumbered and 'Unnumbered' or row.population.count - file:write(('\t%s %s\n'):format(count, creature_name(row.race))) + file:write(('\t%s %s\n'):format(count, + row.population.label or creature_name(row.race))) end end @@ -168,7 +183,15 @@ local function collect_wild_populations(populations, records) local unnumbered = pop.type == df.world_population_type.VerminInnumerable or pop.count_min >= 10000001 or pop.count_max >= 10000001 - add_population(populations, pop.race, pop.count_min, unnumbered) + local label + if pop.interaction_idx >= 0 then + label = population_label{ + race=pop.race, + interaction_index=pop.interaction_idx, + interaction_effect_index=pop.interaction_effect, + } + end + add_population(populations, pop.race, pop.count_min, unnumbered, label) end end end @@ -216,9 +239,24 @@ local function export_sites_and_pops() local populations = {} for _, inhabitant in ipairs(site.populace.inhabitants) do - add_population(populations, inhabitant.pop_spec.race, inhabitant.count) + add_population(populations, inhabitant.pop_spec.race, inhabitant.count, false, + population_label(inhabitant.pop_spec)) + end + for _, animal in ipairs(site.populace.animals) do + local unnumbered = animal.type == df.world_population_type.VerminInnumerable + or animal.count_min >= 10000001 or animal.count_max >= 10000001 + local label + if animal.interaction_idx >= 0 then + label = population_label{ + race=animal.race, + interaction_index=animal.interaction_idx, + interaction_effect_index=animal.interaction_effect, + } + end + if not unnumbered then + add_population(populations, animal.race, animal.count_min, false, label) + end end - collect_wild_populations(populations, site.populace.animals) write_populations(file, populations) yield_if_timeout() end From 682648a696b8b6f0055e8aa4145c45b175487e01 Mon Sep 17 00:00:00 2001 From: Inder Date: Sun, 13 Sep 2026 18:11:50 -0600 Subject: [PATCH 5/5] Replace exported map bmp with a csv that contains much more data and allows an approximate reconstruction of the ingame map w/ biomes,savagery,roads,elevation etc. --- changelog.txt | 2 +- docs/exportlegends.rst | 16 +++--- exportlegends.lua | 113 +++++++++++++++++------------------------ 3 files changed, 57 insertions(+), 74 deletions(-) diff --git a/changelog.txt b/changelog.txt index e859d8deae..3eaa1fea3e 100644 --- a/changelog.txt +++ b/changelog.txt @@ -29,7 +29,7 @@ Template for new versions: ## New Tools ## New Features -- `exportlegends`: recreate the Classic population, history, and world map companion exports +- `exportlegends`: recreate the Classic population and history exports, and add a compact world-map metadata companion with river and road connectivity ## Fixes - `bodyswap`: fix "invalid argument count" when the target unit has no nemesis record diff --git a/docs/exportlegends.rst b/docs/exportlegends.rst index 11115d74bb..ae17357dcd 100644 --- a/docs/exportlegends.rst +++ b/docs/exportlegends.rst @@ -36,12 +36,16 @@ Information" action: underground animal population totals. - ``world_history.txt`` contains civilizations, worship relationships, and current position holders. -- ``world_map.bmp`` is a geographically aligned terrain map generated from the - current world data. Its terrain colors approximate the Classic map since - Premium does not expose the removed renderer. - -These files use the Classic names and structure expected by external legends -viewers. +- ``world_map.csv`` is a compact per-world-tile companion containing biome, + alignment, savagery, elevation, volcanism, mountain-peak metadata, and + lake/river/road flags and exact cardinal river connections. Compatible + viewers can combine it with the Premium world-map graphics installed with + the game; no proprietary graphics are copied into the export. Road + connections can be inferred between adjacent road tiles, but the available + data does not identify paving, so compatible viewers render them as dirt. + +The text files use the Classic names and structure expected by external legends +viewers. The CSV companion is a DFHack extension. Usage ----- diff --git a/exportlegends.lua b/exportlegends.lua index 9fac32efbc..08408f4f51 100644 --- a/exportlegends.lua +++ b/exportlegends.lua @@ -341,81 +341,60 @@ local function export_world_history() print('Done exporting world history to: ' .. filename) end -local function map_color(tile) - if tile.flags.is_lake then return 45, 105, 170 end - if tile.elevation < 100 then - local blue = math.max(90, math.min(190, 190 - (100 - tile.elevation))) - return 30, 75, blue - end - if tile.flags.is_peak or tile.elevation >= 150 then - local shade = math.max(105, math.min(210, tile.elevation + 35)) - return shade, shade, shade - end - if tile.temperature < -500 then return 220, 230, 225 end - if tile.rainfall < 20 or tile.vegetation < 20 then return 190, 165, 95 end - if tile.vegetation > 65 then return 45, 115, 55 end - return 95, 145, 70 -end - -local function export_world_map() +-- Compact terrain companion for Premium world-map renderers. It contains no +-- proprietary graphics; viewers load those from the user's DF installation. +local function export_world_map_metadata() local filename = world.cur_savegame.save_dir .. '-' .. get_world_date_str() - .. '-world_map.bmp' - local file = io.open(filename, 'wb') + .. '-world_map.csv' + local file = io.open(filename, 'w') if not file then qerror('could not open file: ' .. filename) end - local scale = 16 - local width = world.world_data.world_width * scale - local height = world.world_data.world_height * scale - local row_size = width * 3 - local padding = (4 - row_size % 4) % 4 - local image_size = (row_size + padding) * height - file:write('BM', string.pack('= 0 and x2 < width and y2 >= 0 and y2 < height then + rivers[key2] = (rivers[key2] or 0) | bit2 + end + end + for _, river in ipairs(world.world_data.rivers) do + for i = 0, #river.path.x - 2 do + connect(river.path.x[i], river.path.y[i], river.path.x[i + 1], river.path.y[i + 1]) + end + local last = #river.path.x - 1 + if last >= 0 then + connect(river.path.x[last], river.path.y[last], river.end_pos.x, river.end_pos.y) + end end - local offsets = { - [1]={-1, 1}, [2]={0, 1}, [3]={1, 1}, - [4]={-1, 0}, [5]={0, 0}, [6]={1, 0}, - [7]={-1, -1}, [8]={0, -1}, [9]={1, -1}, - } - for pixel_y = height - 1, 0, -1 do - local world_y = pixel_y // scale - local detail_y = pixel_y % scale - local row = {} - for pixel_x = 0, width - 1 do - local world_x = pixel_x // scale - local detail_x = pixel_x % scale - local detail = details[world_x .. ',' .. world_y] - local tile = world.world_data.region_map[world_x]:_displace(world_y) - local elevation = tile.elevation - if detail then - local offset = offsets[(detail.biome[detail_x][detail_y] & 15)] or offsets[5] - local biome_x = math.max(0, math.min(world.world_data.world_width - 1, - world_x + offset[1])) - local biome_y = math.max(0, math.min(world.world_data.world_height - 1, - world_y + offset[2])) - tile = world.world_data.region_map[biome_x]:_displace(biome_y) - elevation = detail.elevation[detail_x][detail_y] - end - local r, g, b = map_color(tile) - local shade = math.max(-25, math.min(25, elevation - tile.elevation)) - table.insert(row, string.char( - math.max(0, math.min(255, b + shade)), - math.max(0, math.min(255, g + shade)), - math.max(0, math.min(255, r + shade)))) + file:write(('DFHACK_WORLD_MAP,3,%d,%d\n'):format(width, height)) + file:write('x,y,biome_type,evilness,savagery,elevation,flags,volcanism,peak,river_dirs\n') + for y = 0, height - 1 do + for x = 0, width - 1 do + local tile = world.world_data.region_map[x]:_displace(y) + local flags = (tile.flags.is_lake and 1 or 0) + + (tile.flags.has_river and 2 or 0) + + (tile.flags.has_road and 4 or 0) + file:write(('%d,%d,%d,%d,%d,%d,%d,%d,%d,%d\n'):format(x, y, + dfhack.maps.getBiomeType(x, y), tile.evilness, tile.savagery, + tile.elevation, flags, tile.volcanism, peaks[x .. ',' .. y] or 0, + rivers[x .. ',' .. y] or 0)) end - file:write(table.concat(row), string.rep('\0', padding)) yield_if_timeout() end file:close() - print(('Done exporting world map to: %s (%d/%d detailed tiles available)') - :format(filename, #world.world_data.midmap_data.region_details, - world.world_data.world_width * world.world_data.world_height)) + print('Done exporting world map metadata to: ' .. filename) end -- Export additional legends data, legends_plus.xml @@ -1322,7 +1301,7 @@ local function wrap_export() if not ok then dfhack.printerr(err) end ok, err = pcall(export_world_history) if not ok then dfhack.printerr(err) end - ok, err = pcall(export_world_map) + ok, err = pcall(export_world_map_metadata) if not ok then dfhack.printerr(err) end asyncexport.reset_state() end