using System.IO.Compression; namespace KeepersCompound.LGS; // TODO: Make this nicer to use! // Rather than navigating through the path manager Context should hold the current campaign resources // Campaign resources should be lazy loaded (only get the paths when we first set it as campaign) // Campaign resources should extend off of the base game resources and just overwrite any resource paths it needs to enum ConfigFile { Cam, CamExt, CamMod, Game, Install, User, ConfigFileCount, } public enum ResourceType { Mission, Object, ObjectTexture, Texture, } public class ResourcePathManager { public record CampaignResources { public bool initialised = false; public string name; private readonly Dictionary _missionPathMap = []; private readonly Dictionary _texturePathMap = []; private readonly Dictionary _objectPathMap = []; private readonly Dictionary _objectTexturePathMap = []; public List GetResourceNames(ResourceType type) { List keys = type switch { ResourceType.Mission => [.. _missionPathMap.Keys], ResourceType.Object => [.. _objectPathMap.Keys], ResourceType.ObjectTexture => [.. _objectTexturePathMap.Keys], ResourceType.Texture => [.. _texturePathMap.Keys], _ => throw new ArgumentOutOfRangeException(nameof(type)), }; keys.Sort(); return keys; } public string GetResourcePath(ResourceType type, string name) { var map = type switch { ResourceType.Mission => _missionPathMap, ResourceType.Object => _objectPathMap, ResourceType.ObjectTexture => _objectTexturePathMap, ResourceType.Texture => _texturePathMap, _ => throw new ArgumentOutOfRangeException(nameof(type)), }; return map.TryGetValue(name.ToLower(), out var resourcePath) ? resourcePath : null; } public void Initialise(string misPath, string resPath) { foreach (var path in Directory.GetFiles(misPath)) { var convertedPath = ConvertSeparator(path); var ext = Path.GetExtension(convertedPath).ToLower(); if (ext == ".mis" || ext == ".cow") { var baseName = Path.GetFileName(convertedPath).ToLower(); _missionPathMap[baseName] = convertedPath; } } foreach (var (name, path) in GetTexturePaths(resPath)) { _texturePathMap[name] = path; } foreach (var (name, path) in GetObjectPaths(resPath)) { _objectPathMap[name] = path; } foreach (var (name, path) in GetObjectTexturePaths(resPath)) { _objectTexturePathMap[name] = path; } } public void Initialise(string misPath, string resPath, CampaignResources parent) { foreach (var (name, path) in parent._texturePathMap) { _texturePathMap[name] = path; } foreach (var (name, path) in parent._objectPathMap) { _objectPathMap[name] = path; } foreach (var (name, path) in parent._objectTexturePathMap) { _objectTexturePathMap[name] = path; } Initialise(misPath, resPath); } } private bool _initialised = false; private readonly string _extractionPath; private string _fmsDir; private CampaignResources _omResources; private Dictionary _fmResources; public ResourcePathManager(string extractionPath) { _extractionPath = extractionPath; } public static string ConvertSeparator(string path) { return path.Replace('\\', '/'); } public void Init(string installPath) { // TODO: // - Determine if folder is a thief install // - Load all the (relevant) config files // - Get base paths from configs // - Build list of FM campaigns // - Initialise OM campaign resource paths // - Lazy load FM campaign resource paths (that inherit OM resources) if (!DirContainsThiefExe(installPath)) { throw new ArgumentException($"No Thief installation found at {installPath}", nameof(installPath)); } // TODO: Should these paths be stored? if (!TryGetConfigPaths(installPath, out var configPaths)) { throw new InvalidOperationException("Failed to find all installation config paths."); } // We need to know where all the texture and object resources are var installCfgLines = File.ReadAllLines(configPaths[(int)ConfigFile.Install]); if (!FindConfigVar(installCfgLines, "resname_base", out var resPaths)) { throw new InvalidOperationException("Failed to find resnames in install config"); } var zipPaths = new List(); foreach (var resPath in resPaths.Split('+')) { var dir = Path.Join(installPath, ConvertSeparator(resPath)); if (!Directory.Exists(dir)) { continue; } foreach (var path in Directory.GetFiles(dir)) { var name = Path.GetFileName(path).ToLower(); if (name is "fam.crf" or "obj.crf") { zipPaths.Add(path); } } } // Do the extraction bro // The path order is a priority order, so we don't want to overwrite any files when extracting // TODO: Check if there's any problems caused by case sensitivity foreach (var zipPath in zipPaths) { var resType = Path.GetFileNameWithoutExtension(zipPath); var extractPath = Path.Join(_extractionPath, resType); ZipFile.OpenRead(zipPath).ExtractToDirectory(extractPath, false); } FindConfigVar(installCfgLines, "load_path", out var omsPath); omsPath = Path.Join(installPath, ConvertSeparator(omsPath)); _omResources = new CampaignResources(); _omResources.name = ""; _omResources.Initialise(omsPath, _extractionPath); var camModLines = File.ReadAllLines(configPaths[(int)ConfigFile.CamMod]); FindConfigVar(camModLines, "fm_path", out var fmsPath, "FMs"); _fmsDir = Path.Join(installPath, fmsPath); // Build up the map of FM campaigns. These are uninitialised, we just want // to have their name _fmResources = new Dictionary(); foreach (var dir in Directory.GetDirectories(_fmsDir)) { var name = Path.GetFileName(dir); var fmResource = new CampaignResources(); fmResource.name = name; _fmResources.Add(name, fmResource); } _initialised = true; } public List GetCampaignNames() { if (!_initialised) return null; var names = new List(_fmResources.Keys); names.Sort(); return names; } public CampaignResources GetCampaign(string campaignName) { if (campaignName == null || campaignName == "") { return _omResources; } else if (_fmResources.TryGetValue(campaignName, out var campaign)) { if (!campaign.initialised) { var fmPath = Path.Join(_fmsDir, campaignName); campaign.Initialise(fmPath, fmPath, _omResources); } return campaign; } throw new ArgumentException("No campaign found with given name", nameof(campaignName)); } private static Dictionary GetObjectTexturePaths(string root) { string[] validExtensions = { ".dds", ".png", ".tga", ".pcx", ".gif", ".bmp", ".cel", }; var dirOptions = new EnumerationOptions { MatchCasing = MatchCasing.CaseInsensitive }; var texOptions = new EnumerationOptions { MatchCasing = MatchCasing.CaseInsensitive, RecurseSubdirectories = true, }; var pathMap = new Dictionary(); foreach (var dir in Directory.EnumerateDirectories(root, "obj", dirOptions)) { foreach (var path in Directory.EnumerateFiles(dir, "*", texOptions)) { var convertedPath = ConvertSeparator(path); var ext = Path.GetExtension(convertedPath); if (validExtensions.Contains(ext.ToLower())) { var key = Path.GetFileNameWithoutExtension(convertedPath).ToLower(); pathMap.TryAdd(key, convertedPath); } } } return pathMap; } private static Dictionary GetTexturePaths(string root) { string[] validExtensions = { ".dds", ".png", ".tga", ".pcx", ".gif", ".bmp", ".cel", }; var famOptions = new EnumerationOptions { MatchCasing = MatchCasing.CaseInsensitive }; var textureOptions = new EnumerationOptions { MatchCasing = MatchCasing.CaseInsensitive, RecurseSubdirectories = true, }; var pathMap = new Dictionary(); foreach (var dir in Directory.EnumerateDirectories(root, "fam", famOptions)) { foreach (var path in Directory.EnumerateFiles(dir, "*", textureOptions)) { var convertedPath = ConvertSeparator(path); var ext = Path.GetExtension(convertedPath); if (validExtensions.Contains(ext.ToLower())) { var key = Path.GetRelativePath(root, convertedPath)[..^ext.Length].ToLower(); pathMap.TryAdd(key, convertedPath); } } } return pathMap; } private static Dictionary GetObjectPaths(string root) { var dirOptions = new EnumerationOptions { MatchCasing = MatchCasing.CaseInsensitive }; var binOptions = new EnumerationOptions { MatchCasing = MatchCasing.CaseInsensitive, RecurseSubdirectories = true, }; var pathMap = new Dictionary(); foreach (var dir in Directory.EnumerateDirectories(root, "obj", dirOptions)) { foreach (var path in Directory.EnumerateFiles(dir, "*.bin", binOptions)) { var convertedPath = ConvertSeparator(path); var key = Path.GetRelativePath(dir, convertedPath).ToLower(); pathMap.TryAdd(key, convertedPath); } } return pathMap; } /// /// Determine if the given directory contains a Thief executable at the top level. /// /// The directory to search /// true if a Thief executable was found, false otherwise. private static bool DirContainsThiefExe(string dir) { var searchOptions = new EnumerationOptions { MatchCasing = MatchCasing.CaseInsensitive, }; foreach (var path in Directory.GetFiles(dir, "*.exe", searchOptions)) { var baseName = Path.GetFileName(path).ToLower(); if (baseName.Contains("thief")) { return true; } } return false; } /// /// Get an array of of all the Dark config file paths. /// /// Root directory of the Thief installation. /// Output array of config file paths /// true if all config files were found, false otherwise. private static bool TryGetConfigPaths(string installPath, out string[] configPaths) { configPaths = new string[(int)ConfigFile.ConfigFileCount]; var searchOptions = new EnumerationOptions { MatchCasing = MatchCasing.CaseInsensitive, }; // `cam.cfg`, `cam_ext.cfg`, and `cam_mod.ini` are always in the root of the install. // The first two configs will tell us if any other configs are in non-default locations. // We can't just do a recursive search for everything else because they can potentially // be *outside* of the Thief installation. foreach (var path in Directory.GetFiles(installPath, "cam*", searchOptions)) { var name = Path.GetFileName(path).ToLower(); if (name == "cam.cfg") { configPaths[(int)ConfigFile.Cam] = path; } else if (name == "cam_ext.cfg") { configPaths[(int)ConfigFile.CamExt] = path; } else if (name == "cam_mod.ini") { configPaths[(int)ConfigFile.CamMod] = path; } } var camExtLines = File.ReadAllLines(configPaths[(int)ConfigFile.CamExt]); var camLines = File.ReadAllLines(configPaths[(int)ConfigFile.Cam]); bool FindCamVar(string varName, out string value, string defaultValue = "") { return FindConfigVar(camExtLines, varName, out value, defaultValue) || FindConfigVar(camLines, varName, out value, defaultValue); } FindCamVar("include_path", out var includePath, "./"); FindCamVar("game", out var gameName); FindCamVar($"{gameName}_include_install_cfg", out var installCfgName); FindCamVar("include_user_cfg", out var userCfgName); // TODO: How to handle case-insensitive absolute paths? // Fixup the include path to "work" cross-platform includePath = ConvertSeparator(includePath); includePath = Path.Join(installPath, includePath); if (!Directory.Exists(includePath)) { return false; } foreach (var path in Directory.GetFiles(includePath, "*.cfg", searchOptions)) { var name = Path.GetFileName(path).ToLower(); if (name == $"{gameName}.cfg") { configPaths[(int)ConfigFile.Game] = path; } else if (name == installCfgName.ToLower()) { configPaths[(int)ConfigFile.Install] = path; } else if (name == userCfgName.ToLower()) { configPaths[(int)ConfigFile.User] = path; } } // Check we found everything var i = 0; foreach (var path in configPaths) { if (path == null || path == "") { return false; } i++; } return true; } private static bool FindConfigVar(string[] lines, string varName, out string value, string defaultValue = "") { value = defaultValue; foreach (var line in lines) { if (line.StartsWith(varName)) { value = line[(line.IndexOf(' ') + 1)..]; return true; } } return false; } }