diff --git a/Penumbra.String b/Penumbra.String
index 2f396444..3276f379 160000
--- a/Penumbra.String
+++ b/Penumbra.String
@@ -1 +1 @@
-Subproject commit 2f396444c80ef2d2dca30b32c8f0ef787a928534
+Subproject commit 3276f37974e668b1e4769c176c114da17611b734
diff --git a/Penumbra/Interop/Loader/CreateFileWHook.cs b/Penumbra/Interop/Loader/CreateFileWHook.cs
new file mode 100644
index 00000000..21f40e6b
--- /dev/null
+++ b/Penumbra/Interop/Loader/CreateFileWHook.cs
@@ -0,0 +1,172 @@
+using System;
+using System.Runtime.InteropServices;
+using System.Text;
+using System.Threading;
+using Dalamud.Hooking;
+using Penumbra.String;
+using Penumbra.String.Classes;
+using Penumbra.String.Functions;
+
+namespace Penumbra.Interop.Loader;
+
+///
+/// To allow XIV to load files of arbitrary path length,
+/// we use the fixed size buffers of their formats to only store pointers to the actual path instead.
+/// Then we translate the stored pointer to the path in CreateFileW, if the prefix matches.
+///
+public unsafe class CreateFileWHook : IDisposable
+{
+ public const int RequiredSize = 28;
+
+ // The prefix is not valid for any actual path, so should never run into false-positives.
+ private const char Prefix = ( char )( ( byte )'P' | ( ( '?' & 0x00FF ) << 8 ) );
+ private const int BufferSize = Utf8GamePath.MaxGamePathLength;
+
+ [DllImport( "kernel32.dll" )]
+ private static extern nint LoadLibrary( string dllName );
+
+ [DllImport( "kernel32.dll" )]
+ private static extern nint GetProcAddress( nint hModule, string procName );
+
+ private delegate nint CreateFileWDelegate( char* fileName, uint access, uint shareMode, nint security, uint creation, uint flags, nint template );
+
+ private readonly Hook< CreateFileWDelegate > _createFileWHook;
+
+ /// Some storage to skip repeated allocations.
+ private readonly ThreadLocal< nint > _fileNameStorage = new(SetupStorage, true);
+
+ public CreateFileWHook()
+ {
+ var userApi = LoadLibrary( "kernel32.dll" );
+ var createFileAddress = GetProcAddress( userApi, "CreateFileW" );
+ _createFileWHook = Hook< CreateFileWDelegate >.FromAddress( createFileAddress, CreateFileWDetour );
+ }
+
+ /// Long paths in windows need to start with "\\?\", so we keep this static in the pointers.
+ private static nint SetupStorage()
+ {
+ var ptr = ( char* )Marshal.AllocHGlobal( 2 * BufferSize );
+ ptr[ 0 ] = '\\';
+ ptr[ 1 ] = '\\';
+ ptr[ 2 ] = '?';
+ ptr[ 3 ] = '\\';
+ ptr[ 4 ] = '\0';
+ return ( nint )ptr;
+ }
+
+ public void Enable()
+ => _createFileWHook.Enable();
+
+ public void Disable()
+ => _createFileWHook.Disable();
+
+ public void Dispose()
+ {
+ _createFileWHook.Dispose();
+ foreach( var ptr in _fileNameStorage.Values )
+ {
+ Marshal.FreeHGlobal( ptr );
+ }
+ }
+
+ private nint CreateFileWDetour( char* fileName, uint access, uint shareMode, nint security, uint creation, uint flags, nint template )
+ {
+ // Translate data if prefix fits.
+ if( CheckPtr( fileName, out var name ) )
+ {
+ // Use static storage.
+ var ptr = WriteFileName( name );
+ Penumbra.Log.Verbose( $"Calling CreateFileWDetour with {ByteString.FromSpanUnsafe( name, false )}." );
+ return _createFileWHook.Original( ptr, access, shareMode, security, creation, flags, template );
+ }
+
+ return _createFileWHook.Original( fileName, access, shareMode, security, creation, flags, template );
+ }
+
+
+ /// Write the UTF8-encoded byte string as UTF16 into the static buffers,
+ /// replacing any forward-slashes with back-slashes and adding a terminating null-wchar_t.
+ private char* WriteFileName( ReadOnlySpan< byte > actualName )
+ {
+ var span = new Span< char >( ( char* )_fileNameStorage.Value + 4, BufferSize - 4 );
+ var written = Encoding.UTF8.GetChars( actualName, span );
+ for( var i = 0; i < written; ++i )
+ {
+ if( span[ i ] == '/' )
+ {
+ span[ i ] = '\\';
+ }
+ }
+
+ span[ written ] = '\0';
+
+ return ( char* )_fileNameStorage.Value;
+ }
+
+
+ public static void WritePtr( char* buffer, byte* address, int length )
+ {
+ // Set the prefix, which is not valid for any actual path.
+ buffer[ 0 ] = Prefix;
+
+ var ptr = ( byte* )buffer;
+ var v = ( ulong )address;
+ var l = ( uint )length;
+
+ // Since the game calls wstrcpy without a length, we need to ensure
+ // that there is no wchar_t (i.e. 2 bytes) of 0-values before the end.
+ // Fill everything with 0xFF and use every second byte.
+ MemoryUtility.MemSet( ptr + 2, 0xFF, 23 );
+
+ // Write the byte pointer.
+ ptr[ 2 ] = ( byte )( v >> 0 );
+ ptr[ 4 ] = ( byte )( v >> 8 );
+ ptr[ 6 ] = ( byte )( v >> 16 );
+ ptr[ 8 ] = ( byte )( v >> 24 );
+ ptr[ 10 ] = ( byte )( v >> 32 );
+ ptr[ 12 ] = ( byte )( v >> 40 );
+ ptr[ 14 ] = ( byte )( v >> 48 );
+ ptr[ 16 ] = ( byte )( v >> 56 );
+
+ // Write the length.
+ ptr[ 18 ] = ( byte )( l >> 0 );
+ ptr[ 20 ] = ( byte )( l >> 8 );
+ ptr[ 22 ] = ( byte )( l >> 16 );
+ ptr[ 24 ] = ( byte )( l >> 24 );
+
+ ptr[ RequiredSize - 2 ] = 0;
+ ptr[ RequiredSize - 1 ] = 0;
+ }
+
+ private static bool CheckPtr( char* buffer, out ReadOnlySpan< byte > fileName )
+ {
+ if( buffer[ 0 ] is not Prefix )
+ {
+ fileName = ReadOnlySpan< byte >.Empty;
+ return false;
+ }
+
+ var ptr = ( byte* )buffer;
+
+ // Read the byte pointer.
+ var address = 0ul;
+ address |= ( ulong )ptr[ 2 ] << 0;
+ address |= ( ulong )ptr[ 4 ] << 8;
+ address |= ( ulong )ptr[ 6 ] << 16;
+ address |= ( ulong )ptr[ 8 ] << 24;
+ address |= ( ulong )ptr[ 10 ] << 32;
+ address |= ( ulong )ptr[ 12 ] << 40;
+ address |= ( ulong )ptr[ 14 ] << 48;
+ address |= ( ulong )ptr[ 16 ] << 56;
+
+ // Read the length.
+ var length = 0u;
+ length |= ( uint )ptr[ 18 ] << 0;
+ length |= ( uint )ptr[ 20 ] << 8;
+ length |= ( uint )ptr[ 22 ] << 16;
+ length |= ( uint )ptr[ 24 ] << 24;
+
+ fileName = new ReadOnlySpan< byte >( ( void* )address, ( int )length );
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/Penumbra/Interop/Loader/ResourceLoader.Debug.cs b/Penumbra/Interop/Loader/ResourceLoader.Debug.cs
index 438e6fa5..d921e7f3 100644
--- a/Penumbra/Interop/Loader/ResourceLoader.Debug.cs
+++ b/Penumbra/Interop/Loader/ResourceLoader.Debug.cs
@@ -209,6 +209,7 @@ public unsafe partial class ResourceLoader
for( var i = 0; i < _debugList.Count; ++i )
{
var data = _debugList.Values[ i ];
+ // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
if( data.OriginalPath.Path == null )
{
_debugList.RemoveAt( i-- );
diff --git a/Penumbra/Interop/Loader/ResourceLoader.Replacement.cs b/Penumbra/Interop/Loader/ResourceLoader.Replacement.cs
index 7b19c62a..27dac9b3 100644
--- a/Penumbra/Interop/Loader/ResourceLoader.Replacement.cs
+++ b/Penumbra/Interop/Loader/ResourceLoader.Replacement.cs
@@ -1,8 +1,3 @@
-using System;
-using System.Diagnostics;
-using System.Linq;
-using System.Runtime.InteropServices;
-using System.Threading;
using Dalamud.Hooking;
using Dalamud.Utility.Signatures;
using FFXIVClientStructs.FFXIV.Client.System.Resource;
@@ -13,6 +8,11 @@ using Penumbra.Interop.Structs;
using Penumbra.String;
using Penumbra.String.Classes;
using Penumbra.Util;
+using System;
+using System.Diagnostics;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Threading;
using FileMode = Penumbra.Interop.Structs.FileMode;
using ResourceHandle = FFXIVClientStructs.FFXIV.Client.System.Resource.Handle.ResourceHandle;
@@ -20,6 +20,8 @@ namespace Penumbra.Interop.Loader;
public unsafe partial class ResourceLoader
{
+ private readonly CreateFileWHook _createFileWHook = new();
+
// Resources can be obtained synchronously and asynchronously. We need to change behaviour in both cases.
// Both work basically the same, so we can reduce the main work to one function used by both hooks.
@@ -173,6 +175,7 @@ public unsafe partial class ResourceLoader
default:
break;
}
+
return DefaultResolver( path );
}
@@ -249,27 +252,20 @@ public unsafe partial class ResourceLoader
return ret;
}
- // Load the resource from a path on the users hard drives.
+ /// Load the resource from a path on the users hard drives.
+ ///
private byte DefaultRootedResourceLoad( ByteString gamePath, ResourceManager* resourceManager,
SeFileDescriptor* fileDescriptor, int priority, bool isSync )
{
// Specify that we are loading unpacked files from the drive.
- // We need to copy the actual file path in UTF16 (Windows-Unicode) on two locations,
- // but since we only allow ASCII in the game paths, this is just a matter of upcasting.
+ // We need to copy the actual file path in UTF16 (Windows-Unicode) on two locations.
fileDescriptor->FileMode = FileMode.LoadUnpackedResource;
- var fd = stackalloc byte[0x20 + 2 * gamePath.Length + 0x16];
- fileDescriptor->FileDescriptor = fd;
- var fdPtr = ( char* )( fd + 0x21 );
- for( var i = 0; i < gamePath.Length; ++i )
- {
- var c = ( char )gamePath.Path[ i ];
- ( &fileDescriptor->Utf16FileName )[ i ] = c;
- fdPtr[ i ] = c;
- }
-
- ( &fileDescriptor->Utf16FileName )[ gamePath.Length ] = '\0';
- fdPtr[ gamePath.Length ] = '\0';
+ // Ensure that the file descriptor has its wchar_t array on aligned boundary even if it has to be odd.
+ var fd = stackalloc char[0x11 + 0x0B + 14];
+ fileDescriptor->FileDescriptor = (byte*) fd + 1;
+ CreateFileWHook.WritePtr( fd + 0x11, gamePath.Path, gamePath.Length );
+ CreateFileWHook.WritePtr( &fileDescriptor->Utf16FileName, gamePath.Path, gamePath.Length );
// Use the SE ReadFile function.
var ret = ReadFile( resourceManager, fileDescriptor, priority, isSync );
@@ -287,6 +283,7 @@ public unsafe partial class ResourceLoader
private void DisposeHooks()
{
DisableHooks();
+ _createFileWHook.Dispose();
ReadSqPackHook.Dispose();
GetResourceSyncHook.Dispose();
GetResourceAsyncHook.Dispose();
diff --git a/Penumbra/Interop/Loader/ResourceLoader.cs b/Penumbra/Interop/Loader/ResourceLoader.cs
index 9a929eaf..2eb0e010 100644
--- a/Penumbra/Interop/Loader/ResourceLoader.cs
+++ b/Penumbra/Interop/Loader/ResourceLoader.cs
@@ -82,6 +82,7 @@ public unsafe partial class ResourceLoader : IDisposable
}
HooksEnabled = true;
+ _createFileWHook.Enable();
ReadSqPackHook.Enable();
GetResourceSyncHook.Enable();
GetResourceAsyncHook.Enable();
@@ -96,6 +97,7 @@ public unsafe partial class ResourceLoader : IDisposable
}
HooksEnabled = false;
+ _createFileWHook.Disable();
ReadSqPackHook.Disable();
GetResourceSyncHook.Disable();
GetResourceAsyncHook.Disable();
diff --git a/Penumbra/Mods/Mod.Creation.cs b/Penumbra/Mods/Mod.Creation.cs
index 69adbeef..13cd4ab7 100644
--- a/Penumbra/Mods/Mod.Creation.cs
+++ b/Penumbra/Mods/Mod.Creation.cs
@@ -156,9 +156,7 @@ public partial class Mod
private static DirectoryInfo NewOptionDirectory( DirectoryInfo baseDir, string optionName )
=> new(Path.Combine( baseDir.FullName, ReplaceBadXivSymbols( optionName ) ));
-
- // XIV can not deal with non-ascii symbols in a path,
- // and the path must obviously be valid itself.
+ // Normalize for nicer names, and remove invalid symbols or invalid paths.
public static string ReplaceBadXivSymbols( string s, string replacement = "_" )
{
if( s == "." )
@@ -174,7 +172,7 @@ public partial class Mod
StringBuilder sb = new(s.Length);
foreach( var c in s.Normalize( NormalizationForm.FormKC ) )
{
- if( c.IsInvalidAscii() || c.IsInvalidInPath() )
+ if( c.IsInvalidInPath() )
{
sb.Append( replacement );
}
diff --git a/Penumbra/UI/ConfigWindow.SettingsTab.cs b/Penumbra/UI/ConfigWindow.SettingsTab.cs
index 3a251e23..8857a21b 100644
--- a/Penumbra/UI/ConfigWindow.SettingsTab.cs
+++ b/Penumbra/UI/ConfigWindow.SettingsTab.cs
@@ -104,12 +104,6 @@ public partial class ConfigWindow
return ( "Path is not allowed to be a drive root. Please add a directory.", false );
}
- var symbol = '\0';
- if( newName.Any( c => ( symbol = c ) > ( char )0x7F ) )
- {
- return ( $"Path contains invalid symbol {symbol}. Only ASCII is allowed.", false );
- }
-
var desktop = Environment.GetFolderPath( Environment.SpecialFolder.Desktop );
if( IsSubPathOf( desktop, newName ) )
{