diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..547a964
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+bin/
+obj/
+.vs/
+*.user
diff --git a/GetModMemoryData.lua b/GetModMemoryData.lua
new file mode 100644
index 0000000..4427409
--- /dev/null
+++ b/GetModMemoryData.lua
@@ -0,0 +1,422 @@
+-- DFHack's gui/mod-manager exports have changed between releases. This script probes the old
+-- and current function names, then returns explicit diagnostics instead of malformed mod data.
+local function append_line(lines, value)
+ table.insert(lines, tostring(value))
+end
+
+local function get_exported_keys(module)
+ local keys = {}
+ if type(module) ~= 'table' then
+ return keys
+ end
+
+ for key, _ in pairs(module) do
+ table.insert(keys, tostring(key))
+ end
+
+ table.sort(keys)
+ return keys
+end
+
+local function append_exported_keys(lines, manager)
+ append_line(lines, 'Exported keys from reqscript("gui/mod-manager"):')
+
+ local keys = get_exported_keys(manager)
+ if #keys == 0 then
+ append_line(lines, '(none)')
+ return
+ end
+
+ for _, key in ipairs(keys) do
+ append_line(lines, ' - ' .. key)
+ end
+end
+
+local function describe_current_viewscreen()
+ local lines = {}
+
+ if not dfhack or not dfhack.gui or type(dfhack.gui.getCurViewscreen) ~= 'function' then
+ append_line(lines, 'dfhack.gui.getCurViewscreen is unavailable')
+ return table.concat(lines, '\n')
+ end
+
+ local ok, viewscreen = pcall(dfhack.gui.getCurViewscreen)
+ if not ok then
+ append_line(lines, 'dfhack.gui.getCurViewscreen failed: ' .. tostring(viewscreen))
+ return table.concat(lines, '\n')
+ end
+
+ append_line(lines, 'Current viewscreen: ' .. tostring(viewscreen))
+ if viewscreen then
+ local ok_type, type_name = pcall(function() return tostring(viewscreen._type) end)
+ if ok_type then
+ append_line(lines, 'Current viewscreen type/name: ' .. tostring(type_name))
+ end
+
+ if type(dfhack.gui.getFocusString) == 'function' then
+ local ok_focus, focus = pcall(dfhack.gui.getFocusString, viewscreen)
+ if ok_focus then
+ append_line(lines, 'Current viewscreen focus: ' .. tostring(focus))
+ end
+ end
+ end
+
+ return table.concat(lines, '\n')
+end
+
+local function build_error(manager, message)
+ local lines = { 'ERROR: ' .. tostring(message) }
+ append_line(lines, describe_current_viewscreen())
+ append_exported_keys(lines, manager)
+ return table.concat(lines, '\n')
+end
+
+local function resolve_mod_manager_api(manager)
+ local api = {}
+ local manager_table = type(manager) == 'table' and manager or {}
+
+ if type(manager_table.get_newregion_viewscreen) == 'function' then
+ api.get_viewscreen = manager_table.get_newregion_viewscreen
+ api.get_viewscreen_name = 'manager.get_newregion_viewscreen'
+ elseif type(manager_table.get_moddable_viewscreen) == 'function' then
+ api.get_viewscreen = function() return manager_table.get_moddable_viewscreen('region') end
+ api.get_viewscreen_name = 'manager.get_moddable_viewscreen("region")'
+ elseif type(manager_table.get_any_moddable_viewscreen) == 'function' then
+ api.get_viewscreen = manager_table.get_any_moddable_viewscreen
+ api.get_viewscreen_name = 'manager.get_any_moddable_viewscreen'
+ elseif type(get_newregion_viewscreen) == 'function' then
+ api.get_viewscreen = get_newregion_viewscreen
+ api.get_viewscreen_name = 'get_newregion_viewscreen'
+ elseif type(get_moddable_viewscreen) == 'function' then
+ api.get_viewscreen = function() return get_moddable_viewscreen('region') end
+ api.get_viewscreen_name = 'get_moddable_viewscreen("region")'
+ elseif type(get_any_moddable_viewscreen) == 'function' then
+ api.get_viewscreen = get_any_moddable_viewscreen
+ api.get_viewscreen_name = 'get_any_moddable_viewscreen'
+ end
+
+ if type(manager_table.get_modlist_fields) == 'function' then
+ api.get_modlist_fields = manager_table.get_modlist_fields
+ api.get_modlist_fields_name = 'manager.get_modlist_fields'
+ elseif type(get_modlist_fields) == 'function' then
+ api.get_modlist_fields = get_modlist_fields
+ api.get_modlist_fields_name = 'get_modlist_fields'
+ end
+
+ return api
+end
+
+local function json_escape(value)
+ local text = tostring(value or '')
+ local replacements = {
+ ['\\'] = '\\\\',
+ ['"'] = '\\"',
+ ['\n'] = '\\n',
+ ['\r'] = '\\r',
+ ['\t'] = '\\t',
+ ['\b'] = '\\b',
+ ['\f'] = '\\f',
+ }
+
+ return (text:gsub('[%z\1-\31\\"]', function(char)
+ return replacements[char] or string.format('\\u%04x', string.byte(char))
+ end))
+end
+
+local function safe_value(value)
+ if value == nil then
+ return ''
+ end
+
+ if type(value) == 'table' or type(value) == 'userdata' then
+ local ok, table_value = pcall(function() return value.value end)
+ if ok and table_value ~= nil then
+ return tostring(table_value)
+ end
+ end
+
+ return tostring(value)
+end
+
+local function safe_field(fields, field_name, index)
+ if type(fields) ~= 'table' then
+ return ''
+ end
+
+ local field = fields[field_name]
+ if type(field) ~= 'table' and type(field) ~= 'userdata' then
+ return ''
+ end
+
+ local ok, value = pcall(function() return field[index] end)
+ if not ok then
+ return ''
+ end
+
+ return safe_value(value)
+end
+
+local function safe_header(fields, index)
+ if type(fields) ~= 'table' then
+ return {}
+ end
+
+ local headers = fields.mod_header
+ if type(headers) ~= 'table' and type(headers) ~= 'userdata' then
+ return {}
+ end
+
+ local ok, header = pcall(function() return headers[index] end)
+ if not ok or (type(header) ~= 'table' and type(header) ~= 'userdata') then
+ return {}
+ end
+
+ return header
+end
+
+local function safe_member(value, member_name)
+ if type(value) ~= 'table' and type(value) ~= 'userdata' then
+ return nil
+ end
+
+ local ok, member = pcall(function() return value[member_name] end)
+ if ok then
+ return member
+ end
+
+ return nil
+end
+
+local function safe_header_field(mod_header, field_name)
+ return safe_value(safe_member(mod_header, field_name))
+end
+
+local function collect_vector_values(vector)
+ local values = {}
+ if type(vector) ~= 'table' and type(vector) ~= 'userdata' then
+ return values
+ end
+
+ for _, value in ipairs(vector) do
+ local text = safe_value(value)
+ if text ~= '' then
+ table.insert(values, text)
+ end
+ end
+
+ return values
+end
+
+local function collect_header_vector(mod_header, field_name)
+ return collect_vector_values(safe_member(mod_header, field_name))
+end
+
+local function get_enum_constant(enum_table, names)
+ if type(enum_table) ~= 'table' and type(enum_table) ~= 'userdata' then
+ return nil
+ end
+
+ for _, name in ipairs(names) do
+ local ok, value = pcall(function() return enum_table[name] end)
+ if ok and value ~= nil then
+ return tonumber(value)
+ end
+ end
+
+ return nil
+end
+
+local function classify_dependency_type(raw_type)
+ local text = safe_value(raw_type)
+ local lower_text = string.lower(text)
+
+ if string.find(lower_text, 'before') ~= nil then
+ return 'requires_before_me'
+ end
+ if string.find(lower_text, 'after') ~= nil then
+ return 'requires_after_me'
+ end
+
+ local numeric_type = tonumber(text)
+ local enum_table = df and df.mod_header_dependency_type or nil
+ local before_value = get_enum_constant(enum_table, {
+ 'REQUIRES_ID_BEFORE_ME',
+ 'RequiresIdBeforeMe',
+ 'requires_id_before_me',
+ })
+ local after_value = get_enum_constant(enum_table, {
+ 'REQUIRES_ID_AFTER_ME',
+ 'RequiresIdAfterMe',
+ 'requires_id_after_me',
+ })
+
+ if numeric_type ~= nil and before_value ~= nil and numeric_type == before_value then
+ return 'requires_before_me'
+ end
+ if numeric_type ~= nil and after_value ~= nil and numeric_type == after_value then
+ return 'requires_after_me'
+ end
+
+ return text
+end
+
+local function collect_dependency_metadata(mod_header)
+ local dependencies = collect_header_vector(mod_header, 'dependencies')
+ local dependency_types = collect_header_vector(mod_header, 'dependency_type')
+ local must_be_after = {}
+ local must_be_before = {}
+ local raw_dependencies = {}
+
+ for i, dependency in ipairs(dependencies) do
+ local raw_type = dependency_types[i] or ''
+ local kind = classify_dependency_type(raw_type)
+ table.insert(raw_dependencies, kind .. ':' .. dependency)
+
+ -- DF token semantics: REQUIRES_ID_BEFORE_ME means that dependency must load before this mod.
+ -- Some current DFHack builds expose dependency_type as opaque userdata, so keep the
+ -- raw value for diagnostics and default unclassified dependencies to "Must be after".
+ if kind == 'requires_before_me' then
+ table.insert(must_be_after, dependency)
+ elseif kind == 'requires_after_me' then
+ table.insert(must_be_before, dependency)
+ elseif dependency ~= '' then
+ table.insert(must_be_after, dependency)
+ end
+ end
+
+ return must_be_after, must_be_before, raw_dependencies, dependency_types
+end
+
+local function format_numeric_version(value)
+ local text = tostring(value or '')
+ if text == '' then
+ return ''
+ end
+
+ if string.find(text, '%.') ~= nil or #text <= 2 then
+ return text
+ end
+
+ return string.sub(text, 1, #text - 2) .. '.' .. string.sub(text, #text - 1)
+end
+
+local function add_json_field(parts, key, value)
+ local text = safe_value(value)
+ if text ~= '' then
+ table.insert(parts, '"' .. json_escape(key) .. '": "' .. json_escape(text) .. '"')
+ end
+end
+
+local function add_json_list(parts, key, values)
+ if #values > 0 then
+ add_json_field(parts, key, table.concat(values, '\n'))
+ end
+end
+
+local function build_header_json(fields, index, mod_header)
+ local parts = {}
+
+ local name = safe_field(fields, 'name', index)
+ local displayed_version = safe_field(fields, 'displayed_version', index)
+ local id = safe_field(fields, 'id', index)
+ local earliest_compat_numeric_version = safe_field(fields, 'earliest_compat_numeric_version', index)
+ local numeric_version = safe_field(fields, 'numeric_version', index)
+ local src_dir = safe_field(fields, 'src_dir', index)
+
+ add_json_field(parts, 'name', name)
+ add_json_field(parts, 'displayed_name', name)
+ add_json_field(parts, 'id', id)
+ add_json_field(parts, 'numeric_version', numeric_version)
+ add_json_field(parts, 'displayed_version', displayed_version)
+ add_json_field(parts, 'earliest_compat_numeric_version', earliest_compat_numeric_version)
+ add_json_field(parts, 'earliest_compatible_numeric_version', earliest_compat_numeric_version)
+ add_json_field(parts, 'earliest_compatible_displayed_version', format_numeric_version(earliest_compat_numeric_version))
+ add_json_field(parts, 'src_dir', src_dir)
+ add_json_field(parts, 'source_dir', src_dir)
+
+ for _, field_name in ipairs({
+ 'author',
+ 'description',
+ 'steam_file_id',
+ 'steam_title',
+ 'steam_description',
+ }) do
+ add_json_field(parts, field_name, safe_header_field(mod_header, field_name))
+ end
+
+ local header_name = safe_header_field(mod_header, 'name')
+ if header_name ~= '' then
+ add_json_field(parts, 'header_name', header_name)
+ end
+
+ local must_be_after, must_be_before, raw_dependencies, dependency_types = collect_dependency_metadata(mod_header)
+ add_json_list(parts, 'must_be_after', must_be_after)
+ add_json_list(parts, 'requires_id_before_me', must_be_after)
+ add_json_list(parts, 'must_be_before', must_be_before)
+ add_json_list(parts, 'requires_id_after_me', must_be_before)
+ add_json_list(parts, 'dependencies', raw_dependencies)
+ add_json_list(parts, 'dependency_type', dependency_types)
+ add_json_list(parts, 'conflicts', collect_header_vector(mod_header, 'conflicts'))
+ add_json_list(parts, 'steam_tags', collect_header_vector(mod_header, 'steam_tag'))
+ add_json_list(parts, 'steam_key_tags', collect_header_vector(mod_header, 'steam_key_tag'))
+ add_json_list(parts, 'steam_value_tags', collect_header_vector(mod_header, 'steam_value_tag'))
+
+ return '{' .. table.concat(parts, ',') .. '}'
+end
+
+function GetAllMods()
+ local manager_ok, manager = pcall(reqscript, 'gui/mod-manager')
+ if not manager_ok then
+ return build_error(nil, 'reqscript("gui/mod-manager") failed: ' .. tostring(manager))
+ end
+
+ local api = resolve_mod_manager_api(manager)
+ local missing = {}
+
+ if not api.get_viewscreen then
+ table.insert(missing, 'get_newregion_viewscreen/get_moddable_viewscreen/get_any_moddable_viewscreen')
+ end
+ if not api.get_modlist_fields then
+ table.insert(missing, 'get_modlist_fields')
+ end
+ if #missing > 0 then
+ return build_error(manager, 'DFHack gui/mod-manager compatibility failure. Missing required function(s): ' .. table.concat(missing, ', '))
+ end
+
+ local vs_ok, viewScreen = pcall(api.get_viewscreen)
+ if not vs_ok then
+ return build_error(manager, api.get_viewscreen_name .. ' failed: ' .. tostring(viewScreen))
+ end
+ if viewScreen == nil then
+ return build_error(manager, api.get_viewscreen_name .. ' returned nil. Navigate to the Dwarf Fortress world creation mod selection screen and try again.')
+ end
+
+ local fields_ok, allAvailableMods = pcall(api.get_modlist_fields, 'base_available', viewScreen)
+ if not fields_ok then
+ return build_error(manager, api.get_modlist_fields_name .. '("base_available", viewscreen) failed: ' .. tostring(allAvailableMods))
+ end
+ if type(allAvailableMods) ~= 'table' then
+ return build_error(manager, api.get_modlist_fields_name .. '("base_available", viewscreen) returned ' .. type(allAvailableMods) .. ', expected table.')
+ end
+ if type(allAvailableMods.id) ~= 'table' and type(allAvailableMods.id) ~= 'userdata' then
+ return build_error(manager, 'base_available mod fields did not include an id array.')
+ end
+
+ local output = {}
+ for i, id_entry in ipairs(allAvailableMods.id) do
+ local name = safe_field(allAvailableMods, 'name', i)
+ local displayed_version = safe_field(allAvailableMods, 'displayed_version', i)
+ local id = safe_value(id_entry)
+ local earliest_compat_numeric_version = safe_field(allAvailableMods, 'earliest_compat_numeric_version', i)
+ local numeric_version = safe_field(allAvailableMods, 'numeric_version', i)
+ local src_dir = safe_field(allAvailableMods, 'src_dir', i)
+ local mod_header = safe_header(allAvailableMods, i)
+ local mod_header_json = build_header_json(allAvailableMods, i, mod_header)
+
+ table.insert(output, name .. '|' .. displayed_version .. '|' .. id .. '|' .. earliest_compat_numeric_version .. '|' .. numeric_version .. '|' .. src_dir .. '===' .. mod_header_json)
+ end
+
+ return table.concat(output, '___')
+end
+
+print(GetAllMods())
diff --git a/ModHearth.csproj b/ModHearth.csproj
index 4a9e9ee..d3b1101 100644
--- a/ModHearth.csproj
+++ b/ModHearth.csproj
@@ -14,6 +14,12 @@
+
+
+ PreserveNewest
+
+
+
Form
@@ -37,4 +43,4 @@
Never
-
\ No newline at end of file
+
diff --git a/ModHearthManager.cs b/ModHearthManager.cs
index 09bfdf6..7d34b68 100644
--- a/ModHearthManager.cs
+++ b/ModHearthManager.cs
@@ -14,6 +14,7 @@
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Windows.Forms;
+using Microsoft.Win32;
using static System.Windows.Forms.VisualStyles.VisualStyleElement;
namespace ModHearth
@@ -25,8 +26,8 @@ namespace ModHearth
public class ModHearthConfig
{
// Path to DF.exe
- public string DFEXEPath { get; set; }
- public string DFFolderPath => Path.GetDirectoryName(DFEXEPath);
+ public string DFEXEPath { get; set; } = "";
+ public string DFFolderPath => Path.GetDirectoryName(DFEXEPath) ?? "";
public string ModsPath => Path.Combine(DFFolderPath, "Mods");
// Should this be in lightmode?
@@ -106,6 +107,25 @@ public class ModHearthManager
// The file config for this class.
private ModHearthConfig config;
+ private sealed class DwarfFortressInstallCandidate
+ {
+ public string ExePath { get; }
+ public string FolderPath { get; }
+ public bool HasDfHack { get; }
+
+ public DwarfFortressInstallCandidate(string exePath)
+ {
+ ExePath = exePath;
+ FolderPath = Path.GetDirectoryName(exePath) ?? "";
+ HasDfHack = HasDfHackLauncher(FolderPath);
+ }
+
+ public override string ToString()
+ {
+ return HasDfHack ? $"{FolderPath} (DFHack detected)" : FolderPath;
+ }
+ }
+
// Paths.
private static readonly string configPath = "config.json";
private static readonly string stylePath = "style.json";
@@ -208,6 +228,17 @@ private HashSet> GetModMemoryData()
// Load raw memory data string, and parse it with regex
string RawModData = LoadModMemoryData();
+ string rawOutputPath = Path.Combine(AppContext.BaseDirectory, "dfhack_raw_mod_output.txt");
+ File.WriteAllText(rawOutputPath, RawModData);
+ Console.WriteLine("--- RAW DFHACK MOD OUTPUT START ---");
+ Console.WriteLine(RawModData);
+ Console.WriteLine("--- RAW DFHACK MOD OUTPUT END ---");
+
+ if (RawModData.StartsWith("ERROR:"))
+ {
+ Console.WriteLine("DFHack mod data load failed. No mods were loaded.");
+ return modData;
+ }
if (RawModData.StartsWith('0'))
{
@@ -232,11 +263,52 @@ private HashSet> GetModMemoryData()
foreach (string simpleModDataPair in singleModDataPairs)
{
// Split into headers and non headers. Deserialize headers into dict.
- string[] pairArr = simpleModDataPair.Split("===");
+ string[] pairArr = simpleModDataPair.Split(new[] { "===" }, 2, StringSplitOptions.None);
+ if (pairArr.Length < 2)
+ {
+ Console.WriteLine("Warning: malformed DFHack mod data chunk is missing the expected '===' separator.");
+ Console.WriteLine("Malformed chunk:");
+ Console.WriteLine(simpleModDataPair);
+ continue;
+ }
+
string[] nonHeaders = pairArr[0].Split('|');
- Dictionary headers = JsonSerializer.Deserialize>(pairArr[1]);
+ Dictionary headers;
+ try
+ {
+ headers = JsonSerializer.Deserialize>(pairArr[1]);
+ }
+ catch (JsonException ex)
+ {
+ Console.WriteLine("Warning: failed to deserialize DFHack mod header JSON.");
+ Console.WriteLine("JSON:");
+ Console.WriteLine(pairArr[1]);
+ Console.WriteLine("Chunk:");
+ Console.WriteLine(simpleModDataPair);
+ Console.WriteLine("Error: " + ex.Message);
+ continue;
+ }
+
+ if (headers == null)
+ {
+ Console.WriteLine("Warning: DFHack mod header JSON deserialized to null.");
+ Console.WriteLine("Chunk:");
+ Console.WriteLine(simpleModDataPair);
+ continue;
+ }
+
+ ApplyNonHeaderFallbackFields(headers, nonHeaders);
+
modData.Add(headers);
- Console.WriteLine(" Mod Found: " + headers["name"]);
+ Console.WriteLine(" Mod Found: " + headers.GetValueOrDefault("name", "(unknown)"));
+ if (IsDebugTargetMod(headers))
+ {
+ Console.WriteLine(" Metadata debug for " + headers.GetValueOrDefault("id", "(unknown)") + ":");
+ foreach (KeyValuePair entry in headers.OrderBy(entry => entry.Key))
+ {
+ Console.WriteLine($" {entry.Key}: {entry.Value}");
+ }
+ }
// To see which headers there are to choose from.
//foreach (string k in headers.Keys)
@@ -244,25 +316,99 @@ private HashSet> GetModMemoryData()
}
+ WriteModMetadataDebugFile(modData);
+
return modData;
}
+ private bool IsDebugTargetMod(Dictionary headers)
+ {
+ string id = headers.GetValueOrDefault("id", "");
+ string name = headers.GetValueOrDefault("name", "");
+ return id.Contains("advanced_botanical_orchards", StringComparison.OrdinalIgnoreCase)
+ || name.Contains("advanced botanical orchards", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private void WriteModMetadataDebugFile(HashSet> modData)
+ {
+ try
+ {
+ StringBuilder debugText = new StringBuilder();
+ foreach (Dictionary mod in modData.OrderBy(mod => mod.GetValueOrDefault("id", ""), StringComparer.OrdinalIgnoreCase))
+ {
+ debugText.AppendLine("[" + mod.GetValueOrDefault("id", "(unknown)") + "] " + mod.GetValueOrDefault("name", "(unknown)"));
+ foreach (KeyValuePair entry in mod.OrderBy(entry => entry.Key))
+ {
+ string value = entry.Value ?? "";
+ if (value.Length > 2000)
+ {
+ value = value.Substring(0, 2000) + "...";
+ }
+
+ debugText.AppendLine(entry.Key + ": " + value);
+ }
+
+ debugText.AppendLine();
+ }
+
+ File.WriteAllText(Path.Combine(AppContext.BaseDirectory, "dfhack_mod_metadata_debug.txt"), debugText.ToString());
+ }
+ catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException || ex is DirectoryNotFoundException)
+ {
+ Console.WriteLine("Warning: unable to write DFHack metadata debug file: " + ex.Message);
+ }
+ }
+
+ private void ApplyNonHeaderFallbackFields(Dictionary headers, string[] nonHeaders)
+ {
+ if (nonHeaders.Length > 0)
+ headers.TryAdd("name", nonHeaders[0]);
+ if (nonHeaders.Length > 1)
+ headers.TryAdd("displayed_version", nonHeaders[1]);
+ if (nonHeaders.Length > 2)
+ headers.TryAdd("id", nonHeaders[2]);
+ if (nonHeaders.Length > 3)
+ {
+ headers.TryAdd("earliest_compatible_numeric_version", nonHeaders[3]);
+ headers.TryAdd("earliest_compat_numeric_version", nonHeaders[3]);
+ }
+ if (nonHeaders.Length > 4)
+ headers.TryAdd("numeric_version", nonHeaders[4]);
+ if (nonHeaders.Length > 5)
+ headers.TryAdd("src_dir", nonHeaders[5]);
+
+ headers.TryAdd("numeric_version", "1");
+ headers.TryAdd("displayed_version", headers.GetValueOrDefault("numeric_version", "1"));
+ headers.TryAdd("earliest_compatible_numeric_version", headers.GetValueOrDefault("numeric_version", "1"));
+ headers.TryAdd("earliest_compatible_displayed_version", headers.GetValueOrDefault("displayed_version", "1"));
+ headers.TryAdd("id", "(unknown)");
+ headers.TryAdd("name", headers.GetValueOrDefault("id", "(unknown)"));
+ headers.TryAdd("description", "");
+ headers.TryAdd("author", "");
+ headers.TryAdd("src_dir", "");
+ headers.TryAdd("steam_file_id", "0");
+ headers.TryAdd("steam_title", "");
+ headers.TryAdd("steam_description", "");
+ }
+
// Use dfhack-run.exe and lua to get raw mod data.
private string LoadModMemoryData()
{
// Get path to lua script.
- string luaPath = Path.Combine(Environment.CurrentDirectory, "GetModMemoryData.lua");
+ string luaPath = Path.Combine(AppContext.BaseDirectory, "GetModMemoryData.lua");
+ Console.WriteLine("Using GetModMemoryData.lua: " + luaPath);
+ if (!File.Exists(luaPath))
+ {
+ throw new FileNotFoundException(
+ "Could not find GetModMemoryData.lua beside the ModHearth executable. " +
+ "The Lua script is intentionally loaded from the app base directory to avoid stale scripts from old release folders.\n" +
+ "Expected path:\n" +
+ luaPath,
+ luaPath);
+ }
// Set up dfhack process.
- ProcessStartInfo processStartInfo = new ProcessStartInfo
- {
- FileName = Path.Combine(config.DFFolderPath, "dfhack-run.exe"),
- WorkingDirectory = config.DFFolderPath,
- Arguments = $"lua -f \"{luaPath}\"",
- RedirectStandardOutput = true,
- UseShellExecute = false,
- CreateNoWindow = true,
- };
+ ProcessStartInfo processStartInfo = CreateDfHackProcessStartInfo(luaPath);
// Start dfhack process.
Process process = new Process
@@ -282,6 +428,46 @@ private string LoadModMemoryData()
return output;
}
+ private ProcessStartInfo CreateDfHackProcessStartInfo(string luaPath)
+ {
+ // Older DFHack installs place dfhack-run.exe in the DF root; current installs can put it under hack.
+ // Check both layouts and use hack as the working directory when running the nested executable.
+ string rootDfHackRunPath = Path.Combine(config.DFFolderPath, "dfhack-run.exe");
+ if (File.Exists(rootDfHackRunPath))
+ {
+ return new ProcessStartInfo
+ {
+ FileName = rootDfHackRunPath,
+ WorkingDirectory = config.DFFolderPath,
+ Arguments = $"lua -f \"{luaPath}\"",
+ RedirectStandardOutput = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+ }
+
+ string hackFolderPath = Path.Combine(config.DFFolderPath, "hack");
+ string hackDfHackRunPath = Path.Combine(hackFolderPath, "dfhack-run.exe");
+ if (File.Exists(hackDfHackRunPath))
+ {
+ return new ProcessStartInfo
+ {
+ FileName = hackDfHackRunPath,
+ WorkingDirectory = hackFolderPath,
+ Arguments = $"lua -f \"{luaPath}\"",
+ RedirectStandardOutput = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+ }
+
+ throw new FileNotFoundException(
+ "Could not find DFHack launcher. Checked these locations:\n" +
+ $"{rootDfHackRunPath}\n" +
+ $"{hackDfHackRunPath}\n\n" +
+ "Please verify your Dwarf Fortress folder and DFHack installation.");
+ }
+
// Check if DF is running.
public bool DwarfFortressRunning()
{
@@ -528,54 +714,89 @@ private void FindModpacks()
}
}
- // Generated a vanilla modlist using manually generated mod ID list.
- public List GenerateVanillaModlist()
+ private static readonly List vanillaModIDList = new List()
+ {
+ "vanilla_text",
+ "vanilla_languages",
+ "vanilla_descriptors",
+ "vanilla_materials",
+ "vanilla_environment",
+ "vanilla_plants",
+ "vanilla_items",
+ "vanilla_buildings",
+ "vanilla_bodies",
+ "vanilla_creatures",
+ "vanilla_entities",
+ "vanilla_reactions",
+ "vanilla_interactions",
+ "vanilla_descriptors_graphics",
+ "vanilla_plants_graphics",
+ "vanilla_items_graphics",
+ "vanilla_buildings_graphics",
+ "vanilla_creatures_graphics",
+ "vanilla_world_map",
+ "vanilla_interface",
+ "vanilla_music",
+ };
+
+ public bool TryGenerateVanillaModlist(out List vanillaList, out List missingIds)
{
- // Manually made vanilla modlist.
- List vanillaModIDList = new List()
- {
- "vanilla_text",
- "vanilla_languages",
- "vanilla_descriptors",
- "vanilla_materials",
- "vanilla_environment",
- "vanilla_plants",
- "vanilla_items",
- "vanilla_buildings",
- "vanilla_bodies",
- "vanilla_creatures",
- "vanilla_entities",
- "vanilla_reactions",
- "vanilla_interactions",
- "vanilla_descriptors_graphics",
- "vanilla_plants_graphics",
- "vanilla_items_graphics",
- "vanilla_buildings_graphics",
- "vanilla_creatures_graphics",
- "vanilla_world_map",
- "vanilla_interface",
- "vanilla_music",
- };
+ vanillaList = new List();
+ missingIds = new List();
+
+ if (modPool == null || modPool.Count == 0)
+ {
+ missingIds.AddRange(vanillaModIDList);
+ Console.WriteLine("Warning: cannot generate the default vanilla modlist because mod data was not loaded.");
+ Console.WriteLine("Missing vanilla mod IDs: " + string.Join(", ", missingIds));
+ return false;
+ }
// Get all mods that are probably vanilla.
- Dictionary vanillaPool = new Dictionary();
+ Dictionary vanillaPool = new Dictionary(StringComparer.OrdinalIgnoreCase);
foreach (DFHMod dfm in modPool)
{
if (dfm.id.ToLower().Contains("vanilla"))
{
- vanillaPool.Add(dfm.id, dfm);
+ vanillaPool.TryAdd(dfm.id, dfm);
Console.WriteLine("added mod with id " + dfm.id);
}
}
// Load vanilla mods into pack.
- List vanillaList = new List();
for (int i = 0; i < vanillaModIDList.Count; i++)
{
Console.WriteLine("looking for mod with id " + vanillaModIDList[i]);
- vanillaList.Add(vanillaPool[vanillaModIDList[i]]);
+ if (vanillaPool.TryGetValue(vanillaModIDList[i], out DFHMod vanillaMod))
+ {
+ vanillaList.Add(vanillaMod);
+ }
+ else
+ {
+ missingIds.Add(vanillaModIDList[i]);
+ }
+ }
+
+ if (missingIds.Count > 0)
+ {
+ vanillaList.Clear();
+ Console.WriteLine("Warning: cannot generate the default vanilla modlist because required vanilla mods are missing.");
+ Console.WriteLine("Missing vanilla mod IDs: " + string.Join(", ", missingIds));
+ return false;
+ }
+
+ return true;
+ }
+
+ // Generated a vanilla modlist using manually generated mod ID list.
+ public List GenerateVanillaModlist()
+ {
+ if (TryGenerateVanillaModlist(out List vanillaList, out _))
+ {
+ return vanillaList;
}
+ // Return a safe empty list instead of throwing when DFHack/Lua failed to load mod data.
return vanillaList;
}
@@ -600,8 +821,22 @@ public void FixConfig()
if (config == null)
config = new ModHearthConfig();
- // If it's missing the path to dwarf fortress executable, get the path.
- if (String.IsNullOrEmpty(config.DFEXEPath))
+ if (IsValidDwarfFortressExecutable(config.DFEXEPath))
+ {
+ Console.WriteLine("Using configured DF path: " + config.DFEXEPath);
+ return;
+ }
+
+ if (!string.IsNullOrWhiteSpace(config.DFEXEPath))
+ {
+ Console.WriteLine("Configured DF path is invalid: " + config.DFEXEPath);
+ }
+
+ if (TryAutoDetectDwarfFortress(out string detectedPath))
+ {
+ config.DFEXEPath = detectedPath;
+ }
+ else
{
Console.WriteLine("Config file missing DF path.");
string newPath = "";
@@ -616,6 +851,275 @@ public void FixConfig()
SaveConfigFile();
}
+ private static bool IsValidDwarfFortressExecutable(string? exePath)
+ {
+ if (string.IsNullOrWhiteSpace(exePath))
+ {
+ return false;
+ }
+
+ string folderPath = Path.GetDirectoryName(exePath) ?? "";
+ return File.Exists(exePath)
+ && Path.GetFileName(exePath).Equals("Dwarf Fortress.exe", StringComparison.OrdinalIgnoreCase)
+ && Directory.Exists(folderPath)
+ && Directory.Exists(Path.Combine(folderPath, "data"));
+ }
+
+ private bool TryAutoDetectDwarfFortress(out string detectedPath)
+ {
+ detectedPath = "";
+ List candidates = FindSteamDwarfFortressCandidates();
+ if (candidates.Count == 0)
+ {
+ Console.WriteLine("No Steam Dwarf Fortress install found automatically.");
+ return false;
+ }
+
+ if (candidates.Count == 1)
+ {
+ detectedPath = candidates[0].ExePath;
+ Console.WriteLine("Automatically detected Dwarf Fortress: " + detectedPath);
+ return true;
+ }
+
+ detectedPath = ChooseDwarfFortressCandidate(candidates);
+ return !string.IsNullOrEmpty(detectedPath);
+ }
+
+ private static List FindSteamDwarfFortressCandidates()
+ {
+ List candidates = new List();
+ foreach (string libraryPath in GetSteamLibraryPaths())
+ {
+ string exePath = Path.Combine(libraryPath, "steamapps", "common", "Dwarf Fortress", "Dwarf Fortress.exe");
+ if (!IsValidDwarfFortressExecutable(exePath))
+ {
+ continue;
+ }
+
+ if (!candidates.Any(candidate => candidate.ExePath.Equals(exePath, StringComparison.OrdinalIgnoreCase)))
+ {
+ candidates.Add(new DwarfFortressInstallCandidate(exePath));
+ }
+ }
+
+ return candidates
+ .OrderByDescending(candidate => candidate.HasDfHack)
+ .ThenBy(candidate => candidate.FolderPath, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+
+ private static List GetSteamLibraryPaths()
+ {
+ HashSet steamRoots = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (string steamRoot in GetSteamInstallPaths())
+ {
+ AddExistingDirectory(steamRoots, steamRoot);
+ }
+
+ HashSet libraryPaths = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (string steamRoot in steamRoots)
+ {
+ AddExistingDirectory(libraryPaths, steamRoot);
+ foreach (string libraryPath in ReadSteamLibraryFolders(steamRoot))
+ {
+ AddExistingDirectory(libraryPaths, libraryPath);
+ }
+ }
+
+ return libraryPaths.ToList();
+ }
+
+ private static IEnumerable GetSteamInstallPaths()
+ {
+ foreach (string registryPath in ReadSteamInstallPathsFromRegistry())
+ {
+ yield return registryPath;
+ }
+
+ string programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
+ if (!string.IsNullOrWhiteSpace(programFilesX86))
+ {
+ yield return Path.Combine(programFilesX86, "Steam");
+ }
+
+ string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
+ if (!string.IsNullOrWhiteSpace(programFiles))
+ {
+ yield return Path.Combine(programFiles, "Steam");
+ }
+ }
+
+ private static IEnumerable ReadSteamInstallPathsFromRegistry()
+ {
+ string[] subKeyNames =
+ {
+ @"Software\Valve\Steam",
+ @"Software\WOW6432Node\Valve\Steam",
+ };
+
+ foreach (string subKeyName in subKeyNames)
+ {
+ foreach (RegistryKey baseKey in new[] { Registry.CurrentUser, Registry.LocalMachine })
+ {
+ using RegistryKey? steamKey = OpenRegistryKeySafely(baseKey, subKeyName);
+ if (steamKey == null)
+ {
+ continue;
+ }
+
+ foreach (string valueName in new[] { "SteamPath", "InstallPath" })
+ {
+ if (steamKey.GetValue(valueName) is string steamPath && !string.IsNullOrWhiteSpace(steamPath))
+ {
+ yield return NormalizeDirectoryPath(steamPath);
+ }
+ }
+ }
+ }
+ }
+
+ private static RegistryKey? OpenRegistryKeySafely(RegistryKey baseKey, string subKeyName)
+ {
+ try
+ {
+ return baseKey.OpenSubKey(subKeyName);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Warning: unable to read Steam registry key {subKeyName}: {ex.Message}");
+ return null;
+ }
+ }
+
+ private static IEnumerable ReadSteamLibraryFolders(string steamRoot)
+ {
+ string libraryFoldersPath = Path.Combine(steamRoot, "steamapps", "libraryfolders.vdf");
+ if (!File.Exists(libraryFoldersPath))
+ {
+ yield break;
+ }
+
+ string vdfContent;
+ try
+ {
+ vdfContent = File.ReadAllText(libraryFoldersPath);
+ }
+ catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)
+ {
+ Console.WriteLine($"Warning: unable to read Steam library list {libraryFoldersPath}: {ex.Message}");
+ yield break;
+ }
+
+ foreach (Match match in Regex.Matches(vdfContent, @"""path""\s+""(?[^""]+)"""))
+ {
+ yield return NormalizeDirectoryPath(match.Groups["path"].Value);
+ }
+
+ foreach (Match match in Regex.Matches(vdfContent, @"^\s*""\d+""\s+""(?(?:[A-Za-z]:|\\\\)[^""]+)""", RegexOptions.Multiline))
+ {
+ yield return NormalizeDirectoryPath(match.Groups["path"].Value);
+ }
+ }
+
+ private static void AddExistingDirectory(HashSet paths, string path)
+ {
+ string normalizedPath = NormalizeDirectoryPath(path);
+ if (!string.IsNullOrWhiteSpace(normalizedPath) && Directory.Exists(normalizedPath))
+ {
+ paths.Add(normalizedPath);
+ }
+ }
+
+ private static string NormalizeDirectoryPath(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ return "";
+ }
+
+ string normalized = path.Trim().Replace("\\\\", "\\").Replace('/', Path.DirectorySeparatorChar);
+ normalized = normalized.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+
+ try
+ {
+ return Path.GetFullPath(normalized);
+ }
+ catch
+ {
+ return normalized;
+ }
+ }
+
+ private static bool HasDfHackLauncher(string folderPath)
+ {
+ return File.Exists(Path.Combine(folderPath, "dfhack-run.exe"))
+ || File.Exists(Path.Combine(folderPath, "hack", "dfhack-run.exe"));
+ }
+
+ private string ChooseDwarfFortressCandidate(List candidates)
+ {
+ using Form chooser = new Form();
+ chooser.Text = "Dwarf Fortress install";
+ chooser.StartPosition = FormStartPosition.CenterParent;
+ chooser.FormBorderStyle = FormBorderStyle.FixedDialog;
+ chooser.MinimizeBox = false;
+ chooser.MaximizeBox = false;
+ chooser.ClientSize = new Size(720, 260);
+
+ Label label = new Label
+ {
+ AutoSize = false,
+ Text = "Multiple Steam Dwarf Fortress installs were found. Choose one, or cancel to select manually.",
+ Location = new Point(12, 12),
+ Size = new Size(696, 36),
+ };
+
+ ListBox listBox = new ListBox
+ {
+ Location = new Point(12, 54),
+ Size = new Size(696, 150),
+ HorizontalScrollbar = true,
+ };
+ foreach (DwarfFortressInstallCandidate candidate in candidates)
+ {
+ listBox.Items.Add(candidate);
+ }
+ listBox.SelectedIndex = 0;
+
+ System.Windows.Forms.Button okButton = new System.Windows.Forms.Button
+ {
+ Text = "Use Selected",
+ DialogResult = DialogResult.OK,
+ Location = new Point(480, 218),
+ Size = new Size(105, 28),
+ };
+
+ System.Windows.Forms.Button cancelButton = new System.Windows.Forms.Button
+ {
+ Text = "Manual...",
+ DialogResult = DialogResult.Cancel,
+ Location = new Point(603, 218),
+ Size = new Size(105, 28),
+ };
+
+ chooser.Controls.Add(label);
+ chooser.Controls.Add(listBox);
+ chooser.Controls.Add(okButton);
+ chooser.Controls.Add(cancelButton);
+ chooser.AcceptButton = okButton;
+ chooser.CancelButton = cancelButton;
+
+ if (chooser.ShowDialog(MainForm.instance) == DialogResult.OK && listBox.SelectedItem is DwarfFortressInstallCandidate selected)
+ {
+ Console.WriteLine("Selected detected Dwarf Fortress: " + selected.ExePath);
+ return selected.ExePath;
+ }
+
+ Console.WriteLine("Automatic detection skipped; falling back to manual DF path selection.");
+ return "";
+ }
+
// Destroy the config file, to be remade.
public void DestroyConfig()
{
diff --git a/ModReference.cs b/ModReference.cs
index 9535f2f..0dd0a87 100644
--- a/ModReference.cs
+++ b/ModReference.cs
@@ -35,6 +35,29 @@ public class ModReference
// Path of mod folder, not path to info.
public string path;
+ // True when optional info.txt metadata could not be read.
+ public bool MissingInfoFile = false;
+
+ public bool InfoFileFound => !MissingInfoFile;
+ public string InfoFilePath { get; private set; } = "";
+ public string SourcePath => path;
+ public string SourceDirectory => path;
+ public string RawInfoText { get; private set; } = "";
+ public string ModId => ID;
+ public string DisplayName => name;
+ public string Name => name;
+ public string DisplayedVersion => displayedVersion;
+ public string NumericVersion => numericVersion;
+ public string Author => author;
+ public string Description => description;
+ public List Tags { get; private set; } = new List();
+ public string MetadataWarning { get; private set; } = "";
+ public string MissingInfoWarning => MetadataWarning;
+ public bool MetadataLoadedFromDfHackMemory { get; private set; }
+ public List MustBeAfter => require_before_me;
+ public List MustBeBefore => require_after_me;
+ public List Conflicts => conflicts_with;
+
// Is this modref missing a version (one mod did this, dfhack set version to 1 so this matches it).
public bool MissingVersion = false;
@@ -57,6 +80,11 @@ public ModReference(string ID, string numericVersion, string displayedVersion, s
this.steamID = steamID;
this.path = path;
problematic = false;
+ InfoFilePath = "";
+ RawInfoText = "";
+ Tags = new List();
+ MetadataWarning = "";
+ MetadataLoadedFromDfHackMemory = false;
require_before_me = new List();
require_after_me = new List();
@@ -66,50 +94,433 @@ public ModReference(string ID, string numericVersion, string displayedVersion, s
public ModReference(Dictionary modMemoryData)
{
Dictionary mmd = modMemoryData;
- ID = mmd["id"];
- numericVersion = mmd["numeric_version"];
- displayedVersion = mmd["displayed_version"];
- earliestCompatibleNumericVersion = mmd["earliest_compatible_numeric_version"];
- earliestCompatibleDisplayedVersion = mmd["earliest_compatible_displayed_version"];
- author = mmd["author"];
- name = mmd["name"];
- description = mmd["description"];
- path = Path.Combine(mmd["src_dir"]);
- steamID = mmd["steam_file_id"]; // FIXME: dubious
- steamName = mmd["steam_title"];
- steamDescription = mmd["steam_description"];
-
- // In theory info file is always present
- string modInfo = File.ReadAllText(Path.Combine(path, "info.txt"));
-
- // FIXME: this should really pull from memory using lua, but dealing with the tables sucks.
- // FIXME: this may not match the game internals.
- MatchCollection requireBeforeMatches = Regex.Matches(modInfo, @"\[REQUIRES_ID_BEFORE_ME\]:*(.*?)\n|\[REQUIRES_ID_BEFORE_ME:*(.*?)\]", RegexOptions.IgnoreCase);
- MatchCollection requireAfterMatches = Regex.Matches(modInfo, @"\[REQUIRES_ID_AFTER_ME\]:*(.*?)\n|\[REQUIRES_ID_AFTER_ME:*(.*?)\]", RegexOptions.IgnoreCase);
- MatchCollection conflictsMatches = Regex.Matches(modInfo, @"\[CONFLICTS_WITH_ID\]:*(.*?)\n|\[CONFLICTS_WITH_ID:*(.*?)\]", RegexOptions.IgnoreCase);
-
- // See if this mod has any extra needs. The groups are added, since one is empty.
- require_before_me = new List();
- foreach (Match match in requireBeforeMatches)
+ ID = GetFirstModValue(mmd, "(unknown)", "id", "header_id");
+ numericVersion = GetFirstModValue(mmd, "1", "numeric_version");
+ displayedVersion = GetFirstModValue(mmd, numericVersion, "displayed_version");
+ earliestCompatibleNumericVersion = GetFirstModValue(mmd, numericVersion, "earliest_compatible_numeric_version", "earliest_compat_numeric_version");
+ earliestCompatibleDisplayedVersion = GetFirstModValue(mmd, FormatNumericVersionForDisplay(earliestCompatibleNumericVersion), "earliest_compatible_displayed_version");
+ author = GetFirstModValue(mmd, "", "author");
+ name = GetFirstModValue(mmd, ID, "name", "displayed_name", "header_name");
+ description = GetFirstModValue(mmd, "", "description");
+ path = NormalizePath(GetFirstModValue(mmd, "", "src_dir", "source_dir"));
+ steamID = GetModValue(mmd, "steam_file_id", "0"); // FIXME: dubious
+ steamName = GetModValue(mmd, "steam_title", "");
+ steamDescription = GetModValue(mmd, "steam_description", "");
+ Tags = SplitMetadataList(GetFirstModValue(mmd, "", "steam_tags", "steam_tag")).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
+ require_before_me = SplitMetadataList(GetFirstModValue(mmd, "", "must_be_after", "requires_id_before_me"));
+ require_after_me = SplitMetadataList(GetFirstModValue(mmd, "", "must_be_before", "requires_id_after_me"));
+ conflicts_with = SplitMetadataList(GetFirstModValue(mmd, "", "conflicts", "conflicts_with_id"));
+ ApplyRawDependencyFallbacks(GetModValue(mmd, "dependencies", ""));
+ MetadataLoadedFromDfHackMemory = HasDfHackDetailMetadata(mmd);
+
+ // Current DF/Steam layouts do not always expose info.txt at the old installed_mods path.
+ // Treat it as optional metadata so a missing local file does not prevent the mod from loading.
+ string modInfo = TryReadModInfo(mmd, out string foundInfoPath);
+ MissingInfoFile = string.IsNullOrEmpty(foundInfoPath);
+ InfoFilePath = foundInfoPath;
+ RawInfoText = modInfo;
+
+ if (!MissingInfoFile)
{
- require_before_me.Add(match.Groups[1].Value + match.Groups[2].Value);
+ ApplyInfoTokenFallbacks(ParseInfoTokens(modInfo));
+ }
+ else
+ {
+ MetadataWarning = MetadataLoadedFromDfHackMemory
+ ? "Local info.txt not found; metadata loaded from DFHack memory."
+ : $"Warning: info.txt not found for {name} ({ID}).";
}
- require_after_me = new List();
- foreach (Match match in requireAfterMatches)
+ // Set problematic based on if this mod has extra needs.
+ problematic = require_before_me.Count != 0 || require_after_me.Count != 0 || conflicts_with.Count != 0;
+
+ }
+
+ private void ApplyInfoTokenFallbacks(Dictionary> infoTokens)
+ {
+ ID = FirstInfoTokenIfMissing(infoTokens, "ID", ID, "(unknown)");
+ numericVersion = FirstInfoTokenIfMissing(infoTokens, "NUMERIC_VERSION", numericVersion, "1");
+ displayedVersion = FirstInfoTokenIfMissing(infoTokens, "DISPLAYED_VERSION", displayedVersion, numericVersion);
+ earliestCompatibleNumericVersion = FirstInfoTokenIfMissing(infoTokens, "EARLIEST_COMPATIBLE_NUMERIC_VERSION", earliestCompatibleNumericVersion, numericVersion);
+ earliestCompatibleDisplayedVersion = FirstInfoTokenIfMissing(infoTokens, "EARLIEST_COMPATIBLE_DISPLAYED_VERSION", earliestCompatibleDisplayedVersion, FormatNumericVersionForDisplay(earliestCompatibleNumericVersion));
+ author = FirstInfoTokenIfMissing(infoTokens, "AUTHOR", author, "");
+ name = FirstInfoTokenIfMissing(infoTokens, "NAME", name, ID);
+ description = FirstInfoTokenIfMissing(infoTokens, "DESCRIPTION", description, "");
+
+ if (Tags.Count == 0 && infoTokens.TryGetValue("STEAM_TAG", out List? tags))
{
- require_after_me.Add(match.Groups[1].Value + match.Groups[2].Value);
+ Tags = tags.Where(tag => !string.IsNullOrWhiteSpace(tag)).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
}
- conflicts_with = new List();
- foreach (Match match in conflictsMatches)
+ if (require_before_me.Count == 0)
+ require_before_me = GetInfoTokenList(infoTokens, "REQUIRES_ID_BEFORE_ME");
+ if (require_after_me.Count == 0)
+ require_after_me = GetInfoTokenList(infoTokens, "REQUIRES_ID_AFTER_ME");
+ if (conflicts_with.Count == 0)
+ conflicts_with = GetInfoTokenList(infoTokens, "CONFLICTS_WITH_ID");
+ }
+
+ private static string FirstInfoTokenIfMissing(Dictionary> infoTokens, string key, string current, string missingFallback)
+ {
+ if (!IsMissingMetadata(current, missingFallback))
{
- conflicts_with.Add(match.Groups[1].Value + match.Groups[2].Value);
+ return current;
}
- // Set problematic based on if this mod has extra needs.
- problematic = require_before_me.Count != 0 || require_after_me.Count != 0 || conflicts_with.Count != 0;
+ return FirstInfoToken(infoTokens, key, current);
+ }
+
+ private static string FirstInfoToken(Dictionary> infoTokens, string key, string fallback)
+ {
+ if (infoTokens.TryGetValue(key, out List? values))
+ {
+ string? value = values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
+ if (!string.IsNullOrWhiteSpace(value))
+ {
+ return value;
+ }
+ }
+
+ return fallback;
+ }
+
+ private static List GetInfoTokenList(Dictionary> infoTokens, string key)
+ {
+ if (!infoTokens.TryGetValue(key, out List? values))
+ {
+ return new List();
+ }
+
+ return values.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
+ }
+
+ private static Dictionary> ParseInfoTokens(string modInfo)
+ {
+ Dictionary> infoTokens = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ if (string.IsNullOrEmpty(modInfo))
+ {
+ return infoTokens;
+ }
+
+ MatchCollection tokenMatches = Regex.Matches(modInfo, @"\[(?[A-Z0-9_]+):(?[^\]]*)\]", RegexOptions.IgnoreCase);
+ foreach (Match match in tokenMatches)
+ {
+ if (!match.Success)
+ {
+ continue;
+ }
+
+ string token = match.Groups["token"].Value.Trim().ToUpperInvariant();
+ string value = match.Groups["value"].Value.Trim();
+ if (string.IsNullOrEmpty(token))
+ {
+ continue;
+ }
+
+ if (!infoTokens.TryGetValue(token, out List? values))
+ {
+ values = new List();
+ infoTokens[token] = values;
+ }
+
+ values.Add(value);
+ }
+
+ return infoTokens;
+ }
+
+ private static string GetModValue(Dictionary data, string key, string fallback)
+ {
+ if (data.TryGetValue(key, out string? value) && value != null)
+ {
+ return value;
+ }
+
+ return fallback;
+ }
+
+ private static string GetFirstModValue(Dictionary data, string fallback, params string[] keys)
+ {
+ foreach (string key in keys)
+ {
+ if (data.TryGetValue(key, out string? value) && !string.IsNullOrWhiteSpace(value))
+ {
+ return value;
+ }
+ }
+
+ return fallback;
+ }
+
+ private static bool HasDfHackDetailMetadata(Dictionary data)
+ {
+ return new[]
+ {
+ "author",
+ "description",
+ "steam_tags",
+ "must_be_after",
+ "must_be_before",
+ "conflicts",
+ "dependencies",
+ }.Any(key => data.TryGetValue(key, out string? value) && !string.IsNullOrWhiteSpace(value));
+ }
+
+ private static bool IsMissingMetadata(string value, string missingFallback)
+ {
+ return string.IsNullOrWhiteSpace(value)
+ || value == "(unknown)"
+ || (!string.IsNullOrWhiteSpace(missingFallback) && value == missingFallback);
+ }
+
+ private static List SplitMetadataList(string value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return new List();
+ }
+
+ return value
+ .Split(new[] { '\n', '\r', '|', ';' }, StringSplitOptions.RemoveEmptyEntries)
+ .Select(part => part.Trim())
+ .Where(part => !string.IsNullOrWhiteSpace(part))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+
+ private void ApplyRawDependencyFallbacks(string rawDependencies)
+ {
+ if (string.IsNullOrWhiteSpace(rawDependencies))
+ {
+ return;
+ }
+
+ foreach (string rawDependency in SplitMetadataList(rawDependencies))
+ {
+ int separatorIndex = rawDependency.LastIndexOf(':');
+ if (separatorIndex <= 0 || separatorIndex == rawDependency.Length - 1)
+ {
+ continue;
+ }
+
+ string kind = rawDependency.Substring(0, separatorIndex).Trim().ToLowerInvariant();
+ string dependency = rawDependency.Substring(separatorIndex + 1).Trim();
+ if (string.IsNullOrWhiteSpace(dependency))
+ {
+ continue;
+ }
+
+ if (kind.Contains("before") && !require_before_me.Contains(dependency, StringComparer.OrdinalIgnoreCase))
+ {
+ require_before_me.Add(dependency);
+ }
+ else if (kind.Contains("after") && !require_after_me.Contains(dependency, StringComparer.OrdinalIgnoreCase))
+ {
+ require_after_me.Add(dependency);
+ }
+ else if (!require_before_me.Contains(dependency, StringComparer.OrdinalIgnoreCase)
+ && !require_after_me.Contains(dependency, StringComparer.OrdinalIgnoreCase))
+ {
+ // DFHack can expose dependency kinds as opaque values; default to the
+ // in-game "Must be after" interpretation rather than hiding the hint.
+ require_before_me.Add(dependency);
+ }
+ }
+ }
+
+ private static string FormatNumericVersionForDisplay(string numericVersion)
+ {
+ if (string.IsNullOrWhiteSpace(numericVersion) || numericVersion.Contains('.'))
+ {
+ return numericVersion;
+ }
+
+ string trimmed = numericVersion.Trim();
+ if (trimmed.Length <= 2)
+ {
+ return trimmed;
+ }
+
+ return trimmed.Substring(0, trimmed.Length - 2) + "." + trimmed.Substring(trimmed.Length - 2);
+ }
+
+ private static string NormalizePath(string value)
+ {
+ return string.IsNullOrWhiteSpace(value) ? "" : value.Replace('/', Path.DirectorySeparatorChar).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+ }
+
+ private static string TryReadModInfo(Dictionary data, out string foundInfoPath)
+ {
+ foundInfoPath = "";
+ List candidates = BuildInfoPathCandidates(data);
+
+ foreach (string candidate in candidates)
+ {
+ try
+ {
+ if (!File.Exists(candidate))
+ {
+ continue;
+ }
+
+ foundInfoPath = candidate;
+ return File.ReadAllText(candidate);
+ }
+ catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException || ex is DirectoryNotFoundException)
+ {
+ Console.WriteLine($"Warning: unable to read info.txt at {candidate}: {ex.Message}");
+ }
+ }
+
+ string modName = GetModValue(data, "name", "(unknown name)");
+ string modId = GetModValue(data, "id", "(unknown id)");
+ string srcDir = GetModValue(data, "src_dir", "(unknown src_dir)");
+ Console.WriteLine($"Warning: info.txt not found for {modName} ({modId}) at {srcDir}.");
+ Console.WriteLine("Checked candidate paths:");
+ foreach (string candidate in candidates)
+ {
+ Console.WriteLine(" " + candidate);
+ }
+
+ return "";
+ }
+
+ private static List BuildInfoPathCandidates(Dictionary data)
+ {
+ List candidates = new List();
+ string srcDir = NormalizePath(GetModValue(data, "src_dir", ""));
+ string absolutePath = NormalizePath(GetModValue(data, "absolute_path", ""));
+ string dfRoot = FindDwarfFortressRoot(srcDir);
+ if (string.IsNullOrEmpty(dfRoot))
+ {
+ dfRoot = FindDwarfFortressRoot(absolutePath);
+ }
+
+ AddInfoCandidate(candidates, srcDir);
+ AddInfoCandidate(candidates, absolutePath);
+ AddInfoCandidate(candidates, RemoveTrailingNumericSuffix(srcDir));
+ AddInfoCandidate(candidates, RemoveTrailingNumericSuffix(absolutePath));
+
+ string folderName = GetLastPathPart(srcDir);
+ if (string.IsNullOrEmpty(folderName))
+ {
+ folderName = GetLastPathPart(absolutePath);
+ }
+
+ string normalizedFolderName = RemoveTrailingNumericSuffix(folderName);
+ if (!string.IsNullOrEmpty(dfRoot))
+ {
+ foreach (string root in new[]
+ {
+ Path.Combine(dfRoot, "data", "installed_mods"),
+ Path.Combine(dfRoot, "mods"),
+ Path.Combine(dfRoot, "Mods"),
+ })
+ {
+ AddInfoCandidate(candidates, Path.Combine(root, folderName));
+ AddInfoCandidate(candidates, Path.Combine(root, normalizedFolderName));
+ }
+
+ AddWorkshopInfoCandidates(candidates, dfRoot, GetModValue(data, "steam_file_id", ""));
+ }
+
+ return candidates;
+ }
+
+ private static void AddInfoCandidate(List candidates, string modPath)
+ {
+ if (string.IsNullOrWhiteSpace(modPath))
+ {
+ return;
+ }
+
+ string infoPath = Path.Combine(NormalizePath(modPath), "info.txt");
+ if (!candidates.Contains(infoPath, StringComparer.OrdinalIgnoreCase))
+ {
+ candidates.Add(infoPath);
+ }
+ }
+
+ private static void AddWorkshopInfoCandidates(List candidates, string dfRoot, string steamId)
+ {
+ if (string.IsNullOrWhiteSpace(steamId) || steamId == "0")
+ {
+ return;
+ }
+
+ string normalizedRoot = NormalizePath(dfRoot);
+ string marker = $"{Path.DirectorySeparatorChar}steamapps{Path.DirectorySeparatorChar}common{Path.DirectorySeparatorChar}";
+ int markerIndex = normalizedRoot.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
+ if (markerIndex < 0)
+ {
+ return;
+ }
+
+ string steamAppsRoot = normalizedRoot.Substring(0, markerIndex + $"{Path.DirectorySeparatorChar}steamapps".Length);
+ string workshopModRoot = Path.Combine(steamAppsRoot, "workshop", "content", "975370", steamId);
+ AddInfoCandidate(candidates, workshopModRoot);
+
+ try
+ {
+ if (!Directory.Exists(workshopModRoot))
+ {
+ return;
+ }
+
+ foreach (string childDirectory in Directory.GetDirectories(workshopModRoot))
+ {
+ AddInfoCandidate(candidates, childDirectory);
+ }
+ }
+ catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException || ex is DirectoryNotFoundException)
+ {
+ Console.WriteLine($"Warning: unable to inspect Steam Workshop mod folder {workshopModRoot}: {ex.Message}");
+ }
+ }
+
+ private static string FindDwarfFortressRoot(string path)
+ {
+ string normalized = NormalizePath(path);
+ if (string.IsNullOrWhiteSpace(normalized))
+ {
+ return "";
+ }
+
+ foreach (string marker in new[]
+ {
+ $"{Path.DirectorySeparatorChar}data{Path.DirectorySeparatorChar}",
+ $"{Path.DirectorySeparatorChar}mods{Path.DirectorySeparatorChar}",
+ })
+ {
+ int markerIndex = normalized.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
+ if (markerIndex > 0)
+ {
+ return normalized.Substring(0, markerIndex);
+ }
+ }
+
+ return "";
+ }
+
+ private static string GetLastPathPart(string path)
+ {
+ string normalized = NormalizePath(path);
+ if (string.IsNullOrEmpty(normalized))
+ {
+ return "";
+ }
+
+ return Path.GetFileName(normalized);
+ }
+
+ private static string RemoveTrailingNumericSuffix(string value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return "";
+ }
+ return Regex.Replace(NormalizePath(value), @"\s\(\d+\)$", "");
}
// Use this mods ID and numvericVersion to create the DFHMod.
diff --git a/UI/MainForm.Designer.cs b/UI/MainForm.Designer.cs
index a878aa2..2924bdc 100644
--- a/UI/MainForm.Designer.cs
+++ b/UI/MainForm.Designer.cs
@@ -56,7 +56,7 @@ private void InitializeComponent()
saveButton = new Button();
modInfoPanel = new TableLayoutPanel();
modTitleLabel = new Label();
- modDescriptionLabel = new Label();
+ modDetailsTextBox = new TextBox();
modPictureBox = new PictureBox();
modlistColumnTableLayout.SuspendLayout();
rightSearchPanel.SuspendLayout();
@@ -332,7 +332,7 @@ private void InitializeComponent()
modInfoPanel.ColumnCount = 1;
modInfoPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F));
modInfoPanel.Controls.Add(modTitleLabel, 0, 0);
- modInfoPanel.Controls.Add(modDescriptionLabel, 0, 2);
+ modInfoPanel.Controls.Add(modDetailsTextBox, 0, 2);
modInfoPanel.Controls.Add(modPictureBox, 0, 1);
modInfoPanel.Location = new Point(3, 3);
modInfoPanel.Name = "modInfoPanel";
@@ -352,14 +352,20 @@ private void InitializeComponent()
modTitleLabel.Size = new Size(500, 42);
modTitleLabel.TabIndex = 0;
//
- // modDescriptionLabel
- //
- modDescriptionLabel.Dock = DockStyle.Fill;
- modDescriptionLabel.Font = new Font("Segoe UI", 12F, FontStyle.Regular, GraphicsUnit.Point);
- modDescriptionLabel.Location = new Point(3, 678);
- modDescriptionLabel.Name = "modDescriptionLabel";
- modDescriptionLabel.Size = new Size(500, 120);
- modDescriptionLabel.TabIndex = 1;
+ // modDetailsTextBox
+ //
+ modDetailsTextBox.BorderStyle = BorderStyle.FixedSingle;
+ modDetailsTextBox.Dock = DockStyle.Fill;
+ modDetailsTextBox.Font = new Font("Segoe UI", 9.75F, FontStyle.Regular, GraphicsUnit.Point);
+ modDetailsTextBox.Location = new Point(3, 681);
+ modDetailsTextBox.Multiline = true;
+ modDetailsTextBox.Name = "modDetailsTextBox";
+ modDetailsTextBox.ReadOnly = true;
+ modDetailsTextBox.ScrollBars = ScrollBars.Vertical;
+ modDetailsTextBox.Size = new Size(500, 114);
+ modDetailsTextBox.TabIndex = 1;
+ modDetailsTextBox.TabStop = false;
+ modDetailsTextBox.Text = "No mod selected.";
//
// modPictureBox
//
@@ -388,6 +394,7 @@ private void InitializeComponent()
outerTableLayout.ResumeLayout(false);
rightPanel.ResumeLayout(false);
modInfoPanel.ResumeLayout(false);
+ modInfoPanel.PerformLayout();
((System.ComponentModel.ISupportInitialize)modPictureBox).EndInit();
ResumeLayout(false);
}
@@ -405,7 +412,7 @@ private void InitializeComponent()
private Button saveButton;
private TableLayoutPanel modInfoPanel;
private Label modTitleLabel;
- private Label modDescriptionLabel;
+ private TextBox modDetailsTextBox;
private PictureBox modPictureBox;
private Button newListButton;
private Button deleteListButton;
@@ -421,4 +428,4 @@ private void InitializeComponent()
private Button redoConfigButton;
private ComboBox themeComboBox;
}
-}
\ No newline at end of file
+}
diff --git a/UI/MainForm.cs b/UI/MainForm.cs
index b9028d4..064d248 100644
--- a/UI/MainForm.cs
+++ b/UI/MainForm.cs
@@ -144,7 +144,8 @@ private void FixStyle()
this.BackColor = style.formColor;
modTitleLabel.ForeColor = style.textColor;
- modDescriptionLabel.ForeColor = style.textColor;
+ modDetailsTextBox.ForeColor = style.textColor;
+ modDetailsTextBox.BackColor = style.formColor;
leftModlistPanel.BackColor = style.modRefPanelColor;
rightModlistPanel.BackColor = style.modRefPanelColor;
@@ -167,6 +168,15 @@ private void PostLoadFix(object sender, EventArgs e)
manager.FindModlistProblems();
RefreshModlistPanels();
+ // DFHack/Lua compatibility failures can legitimately leave both mod panels empty.
+ // Keep the UI alive so the console/raw-output diagnostics remain visible.
+ if (manager.modPool.Count == 0)
+ {
+ Console.WriteLine("No mods loaded. Skipping initial mod selection; check DFHack raw output diagnostics for the underlying error.");
+ modDetailsTextBox.Text = "No mod selected.";
+ return;
+ }
+
// Select a random mod to be the shown one.
long selectedModIndex = DateTime.Now.Ticks % manager.modPool.Count;
ChangeModInfoDisplay(manager.GetRefFromDFHMod(manager.modPool.ToList()[(int)selectedModIndex]));
@@ -307,8 +317,16 @@ public void ModrefMouseUp(Point position, ModRefPanel modrefPanel)
// Given a modreference, set the image and description to show it's info.
public void ChangeModInfoDisplay(ModReference modref)
{
+ if (modref == null)
+ {
+ modTitleLabel.Text = "";
+ modDetailsTextBox.Text = BuildModDetailsText(modref);
+ SetModPictureBoxImage(Resource1.DFIcon);
+ return;
+ }
+
modTitleLabel.Text = modref.name;
- modDescriptionLabel.Text = modref.description;
+ modDetailsTextBox.Text = BuildModDetailsText(modref);
string previewPath = Path.Combine(modref.path, "preview.png");
if (File.Exists(previewPath))
@@ -328,6 +346,51 @@ public void ChangeModInfoDisplay(ModReference modref)
}
+ private string BuildModDetailsText(ModReference? modref)
+ {
+ if (modref == null)
+ return "No mod selected.";
+
+ string version = string.IsNullOrWhiteSpace(modref.DisplayedVersion) ? modref.NumericVersion : modref.DisplayedVersion;
+ string author = string.IsNullOrWhiteSpace(modref.Author) ? "(unknown)" : modref.Author;
+ string earliestCompatibleVersion = string.IsNullOrWhiteSpace(modref.earliestCompatibleDisplayedVersion)
+ ? modref.earliestCompatibleNumericVersion
+ : modref.earliestCompatibleDisplayedVersion;
+ string sourcePath = string.IsNullOrWhiteSpace(modref.SourcePath) ? "(unknown)" : modref.SourcePath;
+ string infoFile = modref.InfoFileFound ? modref.InfoFilePath : "not found";
+ string description = string.IsNullOrWhiteSpace(modref.Description) ? "(none)" : modref.Description;
+ string tags = modref.Tags.Count == 0 ? "(none)" : string.Join(", ", modref.Tags);
+ string warning = string.IsNullOrWhiteSpace(modref.MetadataWarning) ? "(none)" : modref.MetadataWarning;
+ List loadOrderLines = new List();
+ loadOrderLines.AddRange(modref.MustBeAfter.Select(modId => $"Must be after {modId}"));
+ loadOrderLines.AddRange(modref.MustBeBefore.Select(modId => $"Must be before {modId}"));
+ loadOrderLines.AddRange(modref.Conflicts.Select(modId => $"Conflicts with {modId}"));
+ string loadOrderText = loadOrderLines.Count == 0 ? "(none)" : string.Join(Environment.NewLine, loadOrderLines);
+
+ StringBuilder details = new StringBuilder();
+ details.AppendLine($"Name: {modref.DisplayName}");
+ details.AppendLine($"ID: {modref.ModId}");
+ details.AppendLine($"Version: {version}");
+ details.AppendLine($"Author: {author}");
+ details.AppendLine($"Earliest compatible version: {earliestCompatibleVersion}");
+ details.AppendLine($"Source: {sourcePath}");
+ details.AppendLine($"Info file: {infoFile}");
+ details.AppendLine();
+ details.AppendLine("Description:");
+ details.AppendLine(description);
+ details.AppendLine();
+ details.AppendLine("Load order / requirements:");
+ details.AppendLine(loadOrderText);
+ details.AppendLine();
+ details.AppendLine("Tags:");
+ details.AppendLine(tags);
+ details.AppendLine();
+ details.AppendLine("Warnings:");
+ details.AppendLine(warning);
+
+ return details.ToString();
+ }
+
// Given a double clicked modref, do a transfer to the last index.
public void ModRefDoubleClicked(ModRefPanel modrefPanel)
{
@@ -397,7 +460,7 @@ private void SetChangesMade(bool changesMade)
renameListButton.Enabled = !changesMade;
importButton.Enabled = !changesMade;
exportButton.Enabled = !changesMade;
- newListButton.Enabled = !changesMade;
+ newListButton.Enabled = !changesMade && manager.modPool.Count > 0;
}
// Tell both modlistPanels to update which modreferencePanels are visible based on lists from manager.
@@ -581,13 +644,27 @@ private void modlistComboBox_SelectedIndexChanged(object sender, EventArgs e)
// Creates a new modpack. This can only be pressed when no unsaved changes.
private void newPackButton_Click(object sender, EventArgs e)
{
+ if (!manager.TryGenerateVanillaModlist(out List vanillaModlist, out List missingVanillaIds))
+ {
+ string message = "Cannot create the default vanilla modlist because mod data was not loaded. Check DFHack/Lua diagnostics.";
+ if (manager.modPool.Count > 0 && missingVanillaIds.Count > 0)
+ {
+ message = "Cannot create the default vanilla modlist because required vanilla mods are missing.\n\nMissing IDs:\n" +
+ string.Join("\n", missingVanillaIds);
+ }
+
+ Console.WriteLine(message);
+ MessageBox.Show(message, "Mod data unavailable", MessageBoxButtons.OK);
+ return;
+ }
+
// Ask the user for a name.
string newName = Interaction.InputBox("Please enter a name for the new modpack", "New Modpack Name", "");
if (string.IsNullOrEmpty(newName))
return;
// Create a new modpack.
- DFHModpack newPack = new DFHModpack(false, manager.GenerateVanillaModlist(), newName);
+ DFHModpack newPack = new DFHModpack(false, vanillaModlist, newName);
// Register the modpack.
RegisterNewModpack(newPack);