2020-01-21 14:23:11 -08:00
using LibHac ;
2020-05-14 23:16:46 -07:00
using LibHac.Common ;
2021-07-13 01:19:28 -07:00
using LibHac.Common.Keys ;
2020-01-21 14:23:11 -08:00
using LibHac.Fs ;
2020-09-01 13:08:59 -07:00
using LibHac.Fs.Fsa ;
2021-07-13 01:19:28 -07:00
using LibHac.Fs.Shim ;
2020-09-01 13:08:59 -07:00
using LibHac.FsSrv ;
2020-01-21 14:23:11 -08:00
using LibHac.FsSystem ;
2021-07-13 01:19:28 -07:00
using LibHac.Ncm ;
2020-05-14 23:16:46 -07:00
using LibHac.Spl ;
2020-08-30 09:51:53 -07:00
using Ryujinx.Common.Configuration ;
2021-07-13 01:19:28 -07:00
using Ryujinx.Common.Logging ;
2018-11-18 11:37:41 -08:00
using Ryujinx.HLE.FileSystem.Content ;
2018-09-08 15:04:26 -07:00
using Ryujinx.HLE.HOS ;
2018-02-04 15:08:20 -08:00
using System ;
2021-07-13 01:19:28 -07:00
using System.Buffers.Text ;
using System.Collections.Generic ;
2018-02-04 15:08:20 -08:00
using System.IO ;
2021-07-13 01:19:28 -07:00
using System.Runtime.CompilerServices ;
using RightsId = LibHac . Fs . RightsId ;
2018-02-04 15:08:20 -08:00
2018-09-08 15:04:26 -07:00
namespace Ryujinx.HLE.FileSystem
2018-02-04 15:08:20 -08:00
{
2019-09-02 09:03:57 -07:00
public class VirtualFileSystem : IDisposable
2018-02-04 15:08:20 -08:00
{
2020-08-30 09:51:53 -07:00
public const string NandPath = AppDataManager . DefaultNandDir ;
public const string SdCardPath = AppDataManager . DefaultSdcardDir ;
2018-09-08 15:04:26 -07:00
2018-11-18 11:37:41 -08:00
public static string SafeNandPath = Path . Combine ( NandPath , "safe" ) ;
2018-09-08 15:04:26 -07:00
public static string SystemNandPath = Path . Combine ( NandPath , "system" ) ;
public static string UserNandPath = Path . Combine ( NandPath , "user" ) ;
2020-07-08 21:31:15 -07:00
2020-01-24 08:01:21 -08:00
private static bool _isInitialized = false ;
2021-07-13 01:19:28 -07:00
public KeySet KeySet { get ; private set ; }
2020-01-21 14:23:11 -08:00
public EmulatedGameCard GameCard { get ; private set ; }
2020-03-03 06:07:06 -08:00
public EmulatedSdCard SdCard { get ; private set ; }
2020-01-21 14:23:11 -08:00
2021-07-13 01:19:28 -07:00
public ModLoader ModLoader { get ; private set ; }
2020-07-08 21:31:15 -07:00
2020-01-24 08:01:21 -08:00
private VirtualFileSystem ( )
2020-01-21 14:23:11 -08:00
{
2021-07-13 01:19:28 -07:00
ReloadKeySet ( ) ;
2020-07-08 21:31:15 -07:00
ModLoader = new ModLoader ( ) ; // Should only be created once
2020-01-21 14:23:11 -08:00
}
2018-02-04 15:08:20 -08:00
public Stream RomFs { get ; private set ; }
2018-12-06 03:16:24 -08:00
public void LoadRomFs ( string fileName )
2018-02-04 15:08:20 -08:00
{
2018-12-06 03:16:24 -08:00
RomFs = new FileStream ( fileName , FileMode . Open , FileAccess . Read ) ;
2018-02-04 15:08:20 -08:00
}
2018-12-06 03:16:24 -08:00
public void SetRomFs ( Stream romfsStream )
2018-09-08 11:33:27 -07:00
{
RomFs ? . Close ( ) ;
2018-12-06 03:16:24 -08:00
RomFs = romfsStream ;
2018-09-08 11:33:27 -07:00
}
2018-12-06 03:16:24 -08:00
public string GetFullPath ( string basePath , string fileName )
2018-02-04 15:08:20 -08:00
{
2018-12-06 03:16:24 -08:00
if ( fileName . StartsWith ( "//" ) )
2018-03-06 12:27:50 -08:00
{
2018-12-06 03:16:24 -08:00
fileName = fileName . Substring ( 2 ) ;
2018-03-06 12:27:50 -08:00
}
2018-12-06 03:16:24 -08:00
else if ( fileName . StartsWith ( '/' ) )
2018-02-04 15:08:20 -08:00
{
2018-12-06 03:16:24 -08:00
fileName = fileName . Substring ( 1 ) ;
2018-02-04 15:08:20 -08:00
}
2018-03-06 12:27:50 -08:00
else
{
return null ;
}
2018-02-04 15:08:20 -08:00
2018-12-06 03:16:24 -08:00
string fullPath = Path . GetFullPath ( Path . Combine ( basePath , fileName ) ) ;
2018-02-04 15:08:20 -08:00
2018-12-06 03:16:24 -08:00
if ( ! fullPath . StartsWith ( GetBasePath ( ) ) )
2018-02-04 15:08:20 -08:00
{
return null ;
}
2018-12-06 03:16:24 -08:00
return fullPath ;
2018-02-04 15:08:20 -08:00
}
2020-08-30 09:51:53 -07:00
internal string GetBasePath ( ) = > AppDataManager . BaseDirPath ;
internal string GetSdCardPath ( ) = > MakeFullPath ( SdCardPath ) ;
2019-09-08 14:33:40 -07:00
public string GetNandPath ( ) = > MakeFullPath ( NandPath ) ;
2018-02-06 15:28:32 -08:00
2018-12-06 03:16:24 -08:00
public string GetFullPartitionPath ( string partitionPath )
2018-11-18 11:37:41 -08:00
{
2019-09-08 14:33:40 -07:00
return MakeFullPath ( partitionPath ) ;
2018-11-18 11:37:41 -08:00
}
2018-12-06 03:16:24 -08:00
public string SwitchPathToSystemPath ( string switchPath )
2018-07-17 12:14:27 -07:00
{
2018-12-06 03:16:24 -08:00
string [ ] parts = switchPath . Split ( ":" ) ;
2018-11-18 11:37:41 -08:00
2018-12-06 03:16:24 -08:00
if ( parts . Length ! = 2 )
2018-07-17 12:14:27 -07:00
{
return null ;
}
2018-11-18 16:20:17 -08:00
2019-09-08 14:33:40 -07:00
return GetFullPath ( MakeFullPath ( parts [ 0 ] ) , parts [ 1 ] ) ;
2018-07-17 12:14:27 -07:00
}
2018-12-06 03:16:24 -08:00
public string SystemPathToSwitchPath ( string systemPath )
2018-07-17 12:14:27 -07:00
{
2018-12-06 03:16:24 -08:00
string baseSystemPath = GetBasePath ( ) + Path . DirectorySeparatorChar ;
2018-11-18 11:37:41 -08:00
2018-12-06 03:16:24 -08:00
if ( systemPath . StartsWith ( baseSystemPath ) )
2018-07-17 12:14:27 -07:00
{
2021-07-13 01:19:28 -07:00
string rawPath = systemPath . Replace ( baseSystemPath , "" ) ;
int firstSeparatorOffset = rawPath . IndexOf ( Path . DirectorySeparatorChar ) ;
2018-11-18 11:37:41 -08:00
2018-12-06 03:16:24 -08:00
if ( firstSeparatorOffset = = - 1 )
2018-07-17 12:14:27 -07:00
{
2018-12-06 03:16:24 -08:00
return $"{rawPath}:/" ;
2018-07-17 12:14:27 -07:00
}
2018-12-06 03:16:24 -08:00
string basePath = rawPath . Substring ( 0 , firstSeparatorOffset ) ;
string fileName = rawPath . Substring ( firstSeparatorOffset + 1 ) ;
2018-11-18 11:37:41 -08:00
2018-12-06 03:16:24 -08:00
return $"{basePath}:/{fileName}" ;
2018-07-17 12:14:27 -07:00
}
return null ;
}
2019-09-08 14:33:40 -07:00
private string MakeFullPath ( string path , bool isDirectory = true )
2018-02-04 15:08:20 -08:00
{
2018-11-18 11:37:41 -08:00
// Handles Common Switch Content Paths
2019-09-08 14:33:40 -07:00
switch ( path )
2018-11-18 11:37:41 -08:00
{
case ContentPath . SdCard :
case "@Sdcard" :
2019-09-08 14:33:40 -07:00
path = SdCardPath ;
2018-11-18 11:37:41 -08:00
break ;
case ContentPath . User :
2019-09-08 14:33:40 -07:00
path = UserNandPath ;
2018-11-18 11:37:41 -08:00
break ;
case ContentPath . System :
2019-09-08 14:33:40 -07:00
path = SystemNandPath ;
2018-11-18 11:37:41 -08:00
break ;
case ContentPath . SdCardContent :
2019-09-08 14:33:40 -07:00
path = Path . Combine ( SdCardPath , "Nintendo" , "Contents" ) ;
2018-11-18 11:37:41 -08:00
break ;
case ContentPath . UserContent :
2019-09-08 14:33:40 -07:00
path = Path . Combine ( UserNandPath , "Contents" ) ;
2018-11-18 11:37:41 -08:00
break ;
case ContentPath . SystemContent :
2019-09-08 14:33:40 -07:00
path = Path . Combine ( SystemNandPath , "Contents" ) ;
2018-11-18 11:37:41 -08:00
break ;
}
2019-09-08 14:33:40 -07:00
string fullPath = Path . Combine ( GetBasePath ( ) , path ) ;
2018-02-04 15:08:20 -08:00
2019-09-08 14:33:40 -07:00
if ( isDirectory )
2018-02-04 15:08:20 -08:00
{
2019-09-08 14:33:40 -07:00
if ( ! Directory . Exists ( fullPath ) )
{
Directory . CreateDirectory ( fullPath ) ;
}
2018-02-04 15:08:20 -08:00
}
2018-12-06 03:16:24 -08:00
return fullPath ;
2018-02-04 15:08:20 -08:00
}
2018-02-21 13:56:52 -08:00
public DriveInfo GetDrive ( )
{
return new DriveInfo ( Path . GetPathRoot ( GetBasePath ( ) ) ) ;
}
2021-07-13 01:19:28 -07:00
public void InitializeFsServer ( LibHac . Horizon horizon , out HorizonClient fsServerClient )
2020-01-21 14:23:11 -08:00
{
LocalFileSystem serverBaseFs = new LocalFileSystem ( GetBasePath ( ) ) ;
2021-07-13 01:19:28 -07:00
fsServerClient = horizon . CreatePrivilegedHorizonClient ( ) ;
var fsServer = new FileSystemServer ( fsServerClient ) ;
DefaultFsServerObjects fsServerObjects = DefaultFsServerObjects . GetDefaultEmulatedCreators ( serverBaseFs , KeySet , fsServer ) ;
2020-01-21 14:23:11 -08:00
GameCard = fsServerObjects . GameCard ;
2021-07-13 01:19:28 -07:00
SdCard = fsServerObjects . SdCard ;
2020-01-21 14:23:11 -08:00
2020-03-09 15:34:35 -07:00
SdCard . SetSdCardInsertionStatus ( true ) ;
2021-07-13 01:19:28 -07:00
var fsServerConfig = new FileSystemServerConfig
2020-01-21 14:23:11 -08:00
{
DeviceOperator = fsServerObjects . DeviceOperator ,
2021-07-13 01:19:28 -07:00
ExternalKeySet = KeySet . ExternalKeySet ,
FsCreators = fsServerObjects . FsCreators
2020-01-21 14:23:11 -08:00
} ;
2021-07-13 01:19:28 -07:00
FileSystemServerInitializer . InitializeWithConfig ( fsServerClient , fsServer , fsServerConfig ) ;
2020-01-21 14:23:11 -08:00
}
2021-07-13 01:19:28 -07:00
public void ReloadKeySet ( )
2020-01-21 14:23:11 -08:00
{
2021-07-13 01:19:28 -07:00
KeySet ? ? = KeySet . CreateDefaultKeySet ( ) ;
2020-01-21 14:23:11 -08:00
string keyFile = null ;
string titleKeyFile = null ;
string consoleKeyFile = null ;
2021-03-15 14:10:36 -07:00
if ( AppDataManager . Mode = = AppDataManager . LaunchMode . UserProfile )
2020-08-30 09:51:53 -07:00
{
2021-03-15 14:10:36 -07:00
LoadSetAtPath ( AppDataManager . KeysDirPathUser ) ;
2020-08-30 09:51:53 -07:00
}
2020-01-21 14:23:11 -08:00
2020-08-30 09:51:53 -07:00
LoadSetAtPath ( AppDataManager . KeysDirPath ) ;
2020-01-21 14:23:11 -08:00
void LoadSetAtPath ( string basePath )
{
string localKeyFile = Path . Combine ( basePath , "prod.keys" ) ;
string localTitleKeyFile = Path . Combine ( basePath , "title.keys" ) ;
string localConsoleKeyFile = Path . Combine ( basePath , "console.keys" ) ;
if ( File . Exists ( localKeyFile ) )
{
keyFile = localKeyFile ;
}
if ( File . Exists ( localTitleKeyFile ) )
{
titleKeyFile = localTitleKeyFile ;
}
if ( File . Exists ( localConsoleKeyFile ) )
{
consoleKeyFile = localConsoleKeyFile ;
}
}
2021-07-13 01:19:28 -07:00
ExternalKeyReader . ReadKeyFile ( KeySet , keyFile , titleKeyFile , consoleKeyFile , null ) ;
2020-01-21 14:23:11 -08:00
}
2020-05-14 23:16:46 -07:00
public void ImportTickets ( IFileSystem fs )
{
foreach ( DirectoryEntryEx ticketEntry in fs . EnumerateEntries ( "/" , "*.tik" ) )
{
Result result = fs . OpenFile ( out IFile ticketFile , ticketEntry . FullPath . ToU8Span ( ) , OpenMode . Read ) ;
if ( result . IsSuccess ( ) )
{
Ticket ticket = new Ticket ( ticketFile . AsStream ( ) ) ;
2021-01-10 19:47:13 -08:00
if ( ticket . TitleKeyType = = TitleKeyType . Common )
{
KeySet . ExternalKeySet . Add ( new RightsId ( ticket . RightsId ) , new AccessKey ( ticket . GetTitleKey ( KeySet ) ) ) ;
}
2020-05-14 23:16:46 -07:00
}
}
}
2021-07-13 01:19:28 -07:00
// Save data created before we supported extra data in directory save data will not work properly if
// given empty extra data. Luckily some of that extra data can be created using the data from the
// save data indexer, which should be enough to check access permissions for user saves.
// Every single save data's extra data will be checked and fixed if needed each time the emulator is opened.
// Consider removing this at some point in the future when we don't need to worry about old saves.
public static Result FixExtraData ( HorizonClient hos )
{
Result rc = GetSystemSaveList ( hos , out List < ulong > systemSaveIds ) ;
if ( rc . IsFailure ( ) ) return rc ;
rc = FixUnindexedSystemSaves ( hos , systemSaveIds ) ;
if ( rc . IsFailure ( ) ) return rc ;
rc = FixExtraDataInSpaceId ( hos , SaveDataSpaceId . System ) ;
if ( rc . IsFailure ( ) ) return rc ;
rc = FixExtraDataInSpaceId ( hos , SaveDataSpaceId . User ) ;
if ( rc . IsFailure ( ) ) return rc ;
rc = FixExtraDataInSpaceId ( hos , SaveDataSpaceId . SdCache ) ;
if ( rc . IsFailure ( ) ) return rc ;
return Result . Success ;
}
private static Result FixExtraDataInSpaceId ( HorizonClient hos , SaveDataSpaceId spaceId )
{
Span < SaveDataInfo > info = stackalloc SaveDataInfo [ 8 ] ;
Result rc = hos . Fs . OpenSaveDataIterator ( out var iterator , spaceId ) ;
if ( rc . IsFailure ( ) ) return rc ;
while ( true )
{
rc = iterator . ReadSaveDataInfo ( out long count , info ) ;
if ( rc . IsFailure ( ) ) return rc ;
if ( count = = 0 )
return Result . Success ;
for ( int i = 0 ; i < count ; i + + )
{
rc = FixExtraData ( out bool wasFixNeeded , hos , in info [ i ] ) ;
if ( rc . IsFailure ( ) )
{
Logger . Warning ? . Print ( LogClass . Application , $"Error {rc.ToStringWithName()} when fixing extra data for save data 0x{info[i].SaveDataId:x} in the {spaceId} save data space" ) ;
}
else if ( wasFixNeeded )
{
Logger . Info ? . Print ( LogClass . Application , $"Tried to rebuild extra data for save data 0x{info[i].SaveDataId:x} in the {spaceId} save data space" ) ;
}
}
}
}
// Gets a list of all the save data files or directories in the system partition.
private static Result GetSystemSaveList ( HorizonClient hos , out List < ulong > list )
{
list = null ;
var mountName = "system" . ToU8Span ( ) ;
DirectoryHandle handle = default ;
List < ulong > localList = new List < ulong > ( ) ;
try
{
Result rc = hos . Fs . MountBis ( mountName , BisPartitionId . System ) ;
if ( rc . IsFailure ( ) ) return rc ;
rc = hos . Fs . OpenDirectory ( out handle , "system:/save" . ToU8Span ( ) , OpenDirectoryMode . All ) ;
if ( rc . IsFailure ( ) ) return rc ;
DirectoryEntry entry = new DirectoryEntry ( ) ;
while ( true )
{
rc = hos . Fs . ReadDirectory ( out long readCount , SpanHelpers . AsSpan ( ref entry ) , handle ) ;
if ( rc . IsFailure ( ) ) return rc ;
if ( readCount = = 0 )
break ;
if ( Utf8Parser . TryParse ( entry . Name , out ulong saveDataId , out int bytesRead , 'x' ) & &
bytesRead = = 16 & & ( long ) saveDataId < 0 )
{
localList . Add ( saveDataId ) ;
}
}
list = localList ;
return Result . Success ;
}
finally
{
if ( handle . IsValid )
{
hos . Fs . CloseDirectory ( handle ) ;
}
if ( hos . Fs . IsMounted ( mountName ) )
{
hos . Fs . Unmount ( mountName ) ;
}
}
}
// Adds system save data that isn't in the save data indexer to the indexer and creates extra data for it.
// Only save data IDs added to SystemExtraDataFixInfo will be fixed.
private static Result FixUnindexedSystemSaves ( HorizonClient hos , List < ulong > existingSaveIds )
{
foreach ( var fixInfo in SystemExtraDataFixInfo )
{
if ( ! existingSaveIds . Contains ( fixInfo . StaticSaveDataId ) )
{
continue ;
}
Result rc = FixSystemExtraData ( out bool wasFixNeeded , hos , in fixInfo ) ;
if ( rc . IsFailure ( ) )
{
Logger . Warning ? . Print ( LogClass . Application ,
$"Error {rc.ToStringWithName()} when fixing extra data for system save data 0x{fixInfo.StaticSaveDataId:x}" ) ;
}
else if ( wasFixNeeded )
{
Logger . Info ? . Print ( LogClass . Application ,
$"Tried to rebuild extra data for system save data 0x{fixInfo.StaticSaveDataId:x}" ) ;
}
}
return Result . Success ;
}
private static Result FixSystemExtraData ( out bool wasFixNeeded , HorizonClient hos , in ExtraDataFixInfo info )
{
wasFixNeeded = true ;
Result rc = hos . Fs . Impl . ReadSaveDataFileSystemExtraData ( out SaveDataExtraData extraData , info . StaticSaveDataId ) ;
if ( ! rc . IsSuccess ( ) )
{
if ( ! ResultFs . TargetNotFound . Includes ( rc ) )
return rc ;
// We'll reach this point only if the save data directory exists but it's not in the save data indexer.
// Creating the save will add it to the indexer while leaving its existing contents intact.
return hos . Fs . CreateSystemSaveData ( info . StaticSaveDataId , UserId . InvalidId , info . OwnerId , info . DataSize ,
info . JournalSize , info . Flags ) ;
}
if ( extraData . Attribute . StaticSaveDataId ! = 0 & & extraData . OwnerId ! = 0 )
{
wasFixNeeded = false ;
return Result . Success ;
}
extraData = new SaveDataExtraData
{
Attribute = { StaticSaveDataId = info . StaticSaveDataId } ,
OwnerId = info . OwnerId ,
Flags = info . Flags ,
DataSize = info . DataSize ,
JournalSize = info . JournalSize
} ;
// Make a mask for writing the entire extra data
Unsafe . SkipInit ( out SaveDataExtraData extraDataMask ) ;
SpanHelpers . AsByteSpan ( ref extraDataMask ) . Fill ( 0xFF ) ;
return hos . Fs . Impl . WriteSaveDataFileSystemExtraData ( SaveDataSpaceId . System , info . StaticSaveDataId ,
in extraData , in extraDataMask ) ;
}
private static Result FixExtraData ( out bool wasFixNeeded , HorizonClient hos , in SaveDataInfo info )
{
wasFixNeeded = true ;
Result rc = hos . Fs . Impl . ReadSaveDataFileSystemExtraData ( out SaveDataExtraData extraData , info . SpaceId ,
info . SaveDataId ) ;
if ( rc . IsFailure ( ) ) return rc ;
// The extra data should have program ID or static save data ID set if it's valid.
// We only try to fix the extra data if the info from the save data indexer has a program ID or static save data ID.
bool canFixByProgramId = extraData . Attribute . ProgramId = = ProgramId . InvalidId & &
info . ProgramId ! = ProgramId . InvalidId ;
bool canFixBySaveDataId = extraData . Attribute . StaticSaveDataId = = 0 & & info . StaticSaveDataId ! = 0 ;
if ( ! canFixByProgramId & & ! canFixBySaveDataId )
{
wasFixNeeded = false ;
return Result . Success ;
}
// The save data attribute struct can be completely created from the save data info.
extraData . Attribute . ProgramId = info . ProgramId ;
extraData . Attribute . UserId = info . UserId ;
extraData . Attribute . StaticSaveDataId = info . StaticSaveDataId ;
extraData . Attribute . Type = info . Type ;
extraData . Attribute . Rank = info . Rank ;
extraData . Attribute . Index = info . Index ;
// The rest of the extra data can't be created from the save data info.
// On user saves the owner ID will almost certainly be the same as the program ID.
if ( info . Type ! = LibHac . Fs . SaveDataType . System )
{
extraData . OwnerId = info . ProgramId . Value ;
}
else
{
// Try to match the system save with one of the known saves
foreach ( ExtraDataFixInfo fixInfo in SystemExtraDataFixInfo )
{
if ( extraData . Attribute . StaticSaveDataId = = fixInfo . StaticSaveDataId )
{
extraData . OwnerId = fixInfo . OwnerId ;
extraData . Flags = fixInfo . Flags ;
extraData . DataSize = fixInfo . DataSize ;
extraData . JournalSize = fixInfo . JournalSize ;
break ;
}
}
}
// Make a mask for writing the entire extra data
Unsafe . SkipInit ( out SaveDataExtraData extraDataMask ) ;
SpanHelpers . AsByteSpan ( ref extraDataMask ) . Fill ( 0xFF ) ;
return hos . Fs . Impl . WriteSaveDataFileSystemExtraData ( info . SpaceId , info . SaveDataId , in extraData , in extraDataMask ) ;
}
struct ExtraDataFixInfo
{
public ulong StaticSaveDataId ;
public ulong OwnerId ;
public SaveDataFlags Flags ;
public long DataSize ;
public long JournalSize ;
}
private static readonly ExtraDataFixInfo [ ] SystemExtraDataFixInfo =
{
new ExtraDataFixInfo ( )
{
StaticSaveDataId = 0x8000000000000030 ,
OwnerId = 0x010000000000001F ,
Flags = SaveDataFlags . KeepAfterResettingSystemSaveDataWithoutUserSaveData ,
DataSize = 0x10000 ,
JournalSize = 0x10000
} ,
new ExtraDataFixInfo ( )
{
StaticSaveDataId = 0x8000000000001040 ,
OwnerId = 0x0100000000001009 ,
Flags = SaveDataFlags . None ,
DataSize = 0xC000 ,
JournalSize = 0xC000
}
} ;
2020-01-21 14:23:11 -08:00
public void Unload ( )
{
RomFs ? . Dispose ( ) ;
}
2018-02-04 15:08:20 -08:00
public void Dispose ( )
{
Dispose ( true ) ;
}
protected virtual void Dispose ( bool disposing )
{
2018-02-20 02:54:00 -08:00
if ( disposing )
2018-02-04 15:08:20 -08:00
{
2020-01-21 14:23:11 -08:00
Unload ( ) ;
2018-02-04 15:08:20 -08:00
}
}
2020-01-24 08:01:21 -08:00
public static VirtualFileSystem CreateInstance ( )
{
if ( _isInitialized )
{
2021-07-13 01:19:28 -07:00
throw new InvalidOperationException ( "VirtualFileSystem can only be instantiated once!" ) ;
2020-01-24 08:01:21 -08:00
}
_isInitialized = true ;
return new VirtualFileSystem ( ) ;
}
2018-02-04 15:08:20 -08:00
}
}