diff --git a/EntryPoint.cs b/EntryPoint.cs index 341dba2..fdae71a 100644 --- a/EntryPoint.cs +++ b/EntryPoint.cs @@ -6,6 +6,7 @@ using System.Runtime.ExceptionServices; using System.Runtime.InteropServices; using System.Threading; +using Andraste.Payload.ExtraVFS; using Andraste.Payload.ModManagement; using Andraste.Payload.Native; using Andraste.Shared.Lifecycle; @@ -52,6 +53,7 @@ public abstract class EntryPoint : IEntryPoint protected readonly Dictionary FeatureParser = new Dictionary(); public readonly ManagerContainer Container; private readonly ModLoader _modLoader; + private readonly ExtraVFSLoader _extraVFSLoader; protected EntryPoint(RemoteHooking.IContext context) { @@ -59,6 +61,7 @@ protected EntryPoint(RemoteHooking.IContext context) ModFolder = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); Container = new ManagerContainer(); _modLoader = new ModLoader(this); + _extraVFSLoader = new ExtraVFSLoader(this); } public virtual void Run(RemoteHooking.IContext context) @@ -91,7 +94,9 @@ public virtual void Run(RemoteHooking.IContext context) Logger.Trace("Implementing Mods"); ImplementMods(); - + + ImplementExtraVFS(); + Logger.Trace("Waking up the Application"); RemoteHooking.WakeUpProcess(); Logger.Trace("Calling Post-Wakeup"); @@ -189,7 +194,14 @@ protected virtual void LoadFeatureParsers() FeatureParser.Add("andraste.builtin.plugin", new PluginFeatureParser()); } #endregion - + + #region ExtraVFS + protected virtual void ImplementExtraVFS() + { + _extraVFSLoader.LoadExtraVFSFromJson(ModFolder); + } + #endregion + #region Lifecycle /// /// This is called when Andraste has been loaded so far and the user diff --git a/ExtraVFS/ExtraVFSLoader.cs b/ExtraVFS/ExtraVFSLoader.cs new file mode 100755 index 0000000..6ba1d09 --- /dev/null +++ b/ExtraVFS/ExtraVFSLoader.cs @@ -0,0 +1,98 @@ +using Andraste.Payload.VFS; +using System; +using System.IO; +using System.Text.Json; +using NLog; + +namespace Andraste.Payload.ExtraVFS +{ + public class ExtraVFSPairs + { + public ExtraVFSPair[] PathPairs { get; set; } + } + public class ExtraVFSPair + { + public string Source { get; set; } + public string Dest { get; set; } + public bool DestHasToExist { get; set; } + } + public class ExtraVFSLoader + { + private EntryPoint _entryPoint; + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + public ExtraVFSLoader(EntryPoint entryPoint) + { + _entryPoint = entryPoint; + } + + public void LoadExtraVFSFromJson(string modFolder) + { + var vfs = _entryPoint.Container.GetManager(); + if (vfs != null) + { + var jsonFile = Path.Combine(modFolder, "extra_vfs.json"); + if (!File.Exists(jsonFile)) + { + var empty_template = new ExtraVFSPairs(); + empty_template.PathPairs = new ExtraVFSPair[1]; + empty_template.PathPairs[0] = new ExtraVFSPair(); + empty_template.PathPairs[0].Source = "C:\\Some\\Savegame\\Path"; + empty_template.PathPairs[0].Dest = "C:\\Some\\Savegame\\Path"; + empty_template.PathPairs[0].DestHasToExist = false; + var empty_template_string = JsonSerializer.SerializeToUtf8Bytes(empty_template, new JsonSerializerOptions { WriteIndented = true}); + try + { + File.WriteAllBytes(jsonFile, empty_template_string); + } + catch(Exception ex) + { + Logger.Warn("Couldn't create template extra_vfs.json, " + ex); + } + } + + try + { + var pairs = JsonSerializer.Deserialize(File.ReadAllText(jsonFile)); + if (pairs == null) + { + Logger.Warn("Couldn't properly parse extra_vfs.json"); + return; + } + + if (pairs.PathPairs == null) + { + return; + } + + foreach (var pair in pairs.PathPairs) + { + if (!Directory.Exists(pair.Dest) && pair.DestHasToExist) + { + try + { + Directory.CreateDirectory(pair.Dest); + } + catch (Exception ex) + { + Logger.Error("Destination " + pair.Dest + " does not exist and cannot be created, " + ex); + throw ex; + } + } + Logger.Trace("adding pair " + pair.Source + ", " + pair.Dest); + vfs.AddPrefixMapping(pair.Source.Replace('/', '\\'), pair.Dest.Replace('/', '\\')); + } + } + catch(Exception ex) + { + Logger.Warn("ExtraVFS: Couldn't properly parse extra_vfs.json, " + ex); + } + return; + } + else + { + Logger.Info($"The Framework {_entryPoint.FrameworkName} has not enabled VFS Features"); + } + } + } +} diff --git a/Native/Kernel32.cs b/Native/Kernel32.cs index 82ad27f..88c5058 100644 --- a/Native/Kernel32.cs +++ b/Native/Kernel32.cs @@ -26,7 +26,22 @@ public static extern IntPtr CreateFileA(string lpFileName, uint dwDesiredAccess, public delegate IntPtr Delegate_CreateFileA(string lpFileName, uint dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile); - + + [DllImport("kernel32.dll", CharSet = CharSet.Ansi)] + public static extern bool CreateDirectoryA(string lpFileName, IntPtr lpSecurityAttributes); + + public delegate bool Delegate_CreateDirectoryA(string lpFileName, IntPtr lpSecurityAttribute); + + [DllImport("kernel32.dll", CharSet = CharSet.Ansi)] + public static extern bool DeleteFileA(string lpFileName); + + public delegate bool Delegate_DeleteFileA(string lpFileName); + + [DllImport("kernel32.dll", CharSet = CharSet.Ansi)] + public static extern bool RemoveDirectoryA(string lpFileName); + + public delegate bool Delegate_RemoveDirectoryA(string lpFileName); + [DllImport("kernel32.dll")] public static extern bool VirtualProtect(IntPtr lpAddress, IntPtr dwSize, uint flNewProtect, out uint lpflOldProtect); diff --git a/Native/Shell32.cs b/Native/Shell32.cs new file mode 100644 index 0000000..e8dd4c7 --- /dev/null +++ b/Native/Shell32.cs @@ -0,0 +1,12 @@ +using System; +using System.Runtime.InteropServices; + +namespace Andraste.Payload.Native +{ + public static class Shell32 + { + [DllImport("shell32.dll", CharSet = CharSet.Ansi)] + public static extern int SHFileOperationA(IntPtr lpFileOp); + public delegate int Delegate_SHFileOperationA(IntPtr lpFileOp); + } +} diff --git a/VFS/BasicFileRedirectingManager.cs b/VFS/BasicFileRedirectingManager.cs index 9083317..aa7b1cb 100644 --- a/VFS/BasicFileRedirectingManager.cs +++ b/VFS/BasicFileRedirectingManager.cs @@ -1,6 +1,10 @@ -using System.Collections.Concurrent; +using System; +using System.Collections.Concurrent; using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Runtime.InteropServices; +using System.Threading; using Andraste.Payload.Hooking; using Andraste.Payload.Native; using Andraste.Shared.Lifecycle; @@ -24,9 +28,15 @@ public class BasicFileRedirectingManager : IManager // TODO: What about "OpenFile" for older applications? What about CreateFileW? private Hook _createFileHook; private Hook _findFirstFileHook; + private Hook _createDirectoryHook; + private Hook _deleteFileHook; + private Hook _removeDirectoryHook; + private Hook _shFileOperationHook; private readonly ConcurrentDictionary _fileMap = new ConcurrentDictionary(); private readonly List _hooks = new List(); - + private readonly SortedList _prefixMap = new SortedList(); + private readonly Mutex _prefixMapEnumerationLock = new Mutex(); + public bool Enabled { get => _hooks.All(hook => hook.IsActive); @@ -52,12 +62,14 @@ public void Load() LocalHook.GetProcAddress("kernel32.dll", "CreateFileA"), (name, access, mode, attributes, disposition, andAttributes, file) => { - var queryFile = SanitizePath(name); + name = ApplyPrefixMapping(SanitizePath(name)); + var queryFile = name; // Debug Logging // _logger.Trace($"CreateFileA {name} ({queryFile}) => {_fileMap.ContainsKey(queryFile)}"); //if (_fileMap.ContainsKey(queryFile)) _logger.Trace($"{queryFile} redirected to {_fileMap[queryFile]}"); //if (!_fileMap.ContainsKey(queryFile)) _logger.Trace($"{queryFile} could not be redirected"); var fileName = _fileMap.ContainsKey(queryFile) ? _fileMap[queryFile] : name; + return _createFileHook.Original(fileName, access, mode,attributes, disposition, andAttributes, file); }, this); @@ -67,6 +79,7 @@ public void Load() LocalHook.GetProcAddress("kernel32.dll", "FindFirstFileA"), (name, data) => { + name = ApplyPrefixMapping(SanitizePath(name)); if (name.Contains("*") || name.Contains("?")) { // Wildcards are not supported yet (we'd need to fake all search results and manage the handle) @@ -75,12 +88,96 @@ public void Load() // Games like Test Drive Unlimited (2006) are abusing FindFirstFile with an explicit file name to // get all file attributes, such as the file size. - var queryFile = SanitizePath(name); + var queryFile = name; var fileName = _fileMap.ContainsKey(queryFile) ? _fileMap[queryFile] : name; - + return _findFirstFileHook.Original(fileName, data); }, this); _hooks.Add(_findFirstFileHook); + + _createDirectoryHook = new Hook( + LocalHook.GetProcAddress("kernel32.dll", "CreateDirectoryA"), + (name, attributes) => + { + //_logger.Trace("CreateDirectoryA hook with " + name); + + // TDU uses CreateDirectoryA to: + // check if playersave/playersave2 directories exist + // check if it's data directory in ProgramData exists + var fileName = ApplyPrefixMapping(SanitizePath(name)); + + return _createDirectoryHook.Original(fileName, attributes); + }, this); + _hooks.Add(_createDirectoryHook); + + _deleteFileHook = new Hook( + LocalHook.GetProcAddress("kernel32.dll", "DeleteFileA"), + (name) => + { + _logger.Trace("DeleteFileA with " + name); + name = ApplyPrefixMapping(SanitizePath(name)); + var queryFile = name; + var fileName = _fileMap.ContainsKey(queryFile) ? _fileMap[queryFile] : name; + return _deleteFileHook.Original(fileName); + }, this); + _hooks.Add(_deleteFileHook); + + _removeDirectoryHook = new Hook( + LocalHook.GetProcAddress("kernel32.dll", "RemoveDirectoryA"), + (name) => + { + _logger.Trace("RemoveDirectoryA with " + name); + var fileName = ApplyPrefixMapping(SanitizePath(name)); + return _removeDirectoryHook.Original(fileName); + }, this); + _hooks.Add(_removeDirectoryHook); + + // hmm it seems that shell32.dll can remove files directly without using the above functions + _shFileOperationHook = new Hook( + LocalHook.GetProcAddress("shell32.dll", "SHFileOperationA"), + (lpFileOp) => + { + uint op = Extract32BitUintFromAddress(lpFileOp + 4); + IntPtr from_ptr = Marshal.ReadIntPtr(lpFileOp + 8); + IntPtr to_ptr = Marshal.ReadIntPtr(lpFileOp + 12); + _logger.Trace("SHFileOperationA with op " + op); + // TODO implement more ops maybe, if other games need them down the road + // see https://learn.microsoft.com/en-us/windows/win32/api/shellapi/ns-shellapi-shfileopstructa + // tdu uses this to recursively remove profile + if (op == 3) + { + string from = Marshal.PtrToStringAnsi(from_ptr); + from = ApplyPrefixMapping(SanitizePath(from)); + if (Directory.Exists(from)) + { + _logger.Trace("SHFileOperationA recursively removing " + from); + Directory.Delete(from, true); + } + else + { + from = _fileMap.ContainsKey(from) ? _fileMap[from] : from; + if (File.Exists(from)) + { + _logger.Trace("SHFileOperationA removing single file " + from); + File.Delete(from); + } + else + { + _logger.Trace("SHFileOperationA cowardly not removing supposingly non-existing file " + from); + } + } + return 0; + } + return _shFileOperationHook.Original(lpFileOp); + }, this); + _hooks.Add(_shFileOperationHook); + } + + private uint Extract32BitUintFromAddress(IntPtr address) + { + var buf = new byte[4]; + Marshal.Copy(address, buf, 0, 4); + return BitConverter.ToUInt32(buf, 0); } private string SanitizePath(string fileName) @@ -106,6 +203,23 @@ private string SanitizePath(string fileName) result = result.Replace('/', '\\'); return result; } + private string ApplyPrefixMapping(string sourcePath) + { + //_logger.Trace("processing " + sourcePath); + _prefixMapEnumerationLock.WaitOne(-1); + foreach (var entry in _prefixMap.Reverse()) + { + if (sourcePath.ToLower().StartsWith(entry.Key)) + { + string ret = entry.Value + sourcePath.Substring(entry.Key.Length); + //_logger.Trace("redirecting " + sourcePath + " to " + ret); + _prefixMapEnumerationLock.ReleaseMutex(); + return ret; + } + } + _prefixMapEnumerationLock.ReleaseMutex(); + return sourcePath; + } public void Unload() { @@ -117,6 +231,7 @@ public void Unload() public void ClearMappings() { _fileMap.Clear(); + _prefixMap.Clear(); } /// @@ -139,6 +254,33 @@ public void AddMapping(string sourcePath, string destPath) _fileMap[sourcePath.ToLower()] = destPath; } + /// + /// Adds a custom prefix redirect.
+ /// Note that currently, this functionality is currently limited by the + /// use of the correct path separators (e.g. forward vs. backward slashes)
+ /// All paths are treated as case invariant (windows)/lowercase and need to + /// match the target application (e.g. relative versus absolute path).
+ ///
+ /// This method should NOT be called by Mods, only by the modding framework.
+ /// This is because conflicts cannot be handled and would overwrite each-other.
+ /// Instead the Framework should handle this gracefully and use a priority value + /// or ask the user via the Host Application on a per-file basis. + ///
+ /// The path the target application searches for + /// The path of the file that should be redirected to + [ApiVisibility(Visibility = ApiVisibilityAttribute.EVisibility.ModFrameworkInternalAPI)] + public void AddPrefixMapping(string sourcePath, string destPath) + { + _prefixMap.Add(sourcePath.ToLower(), destPath); + /* + _logger.Trace("printing current _prefixMap"); + foreach(var entry in _prefixMap.Reverse()) + { + _logger.Trace(entry.Key + ", " + entry.Value); + } + */ + } + #nullable enable /// /// Allows other file reading utilities inside Andraste to support VFS redirects by querying them.