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);