diff --git a/Directory.Packages.props b/Directory.Packages.props index 106aae7..a63b0ca 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,6 +13,7 @@ SPDX-License-Identifier: GPL-2.0-or-later + diff --git a/src/SharpEmu.CLI/Program.cs b/src/SharpEmu.CLI/Program.cs index a2daec2..e2de817 100644 --- a/src/SharpEmu.CLI/Program.cs +++ b/src/SharpEmu.CLI/Program.cs @@ -5,6 +5,7 @@ using SharpEmu.Core.Runtime; using SharpEmu.Core.Cpu; using SharpEmu.GUI; using SharpEmu.HLE; +using SharpEmu.HLE.FileSystem; using SharpEmu.Libs.VideoOut; using SharpEmu.Logging; using System.Runtime.InteropServices; @@ -734,7 +735,9 @@ internal static partial class Program try { - var directory = Path.GetDirectoryName(Path.GetFullPath(ebootPath)); + var directory = ZArchive.IsArchivePath(ebootPath) + ? ZArchiveCache.ExtractMetadata(ebootPath) + : Path.GetDirectoryName(Path.GetFullPath(ebootPath)); if (string.IsNullOrEmpty(directory)) { return null; @@ -1012,7 +1015,7 @@ internal static partial class Program private static void PrintUsage() { - Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=] [--console=] [--console-language=] [--log-level=] [--log-file[=]] [--window-mode=] [--resolution=] [--guest-resolution=<1920x1080|3840x2160>] [--render-scale=<0.5|0.75|1|1.5|2>] [--display=] [--refresh-rate=] [--scaling=] [--vsync=] [--hdr=] [--overlay=] [--overlay-mode=] [--overlay-corner=] [--debug-server[=host:port]] "); + Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=] [--console=] [--console-language=] [--log-level=] [--log-file[=]] [--window-mode=] [--resolution=] [--guest-resolution=<1920x1080|3840x2160>] [--render-scale=<0.5|0.75|1|1.5|2>] [--display=] [--refresh-rate=] [--scaling=] [--vsync=] [--hdr=] [--overlay=] [--overlay-mode=] [--overlay-corner=] [--debug-server[=host:port]] "); Log.Info(@"Example: SharpEmu.CLI --cpu-engine=native --trace-imports=64 --log-level=debug --log-file ""E:\Games\...\eboot.bin"""); Log.Info("Debug server: --debug-server starts a live debug listener (default 127.0.0.1:5714); connect with SharpEmu.DebugClient."); } diff --git a/src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs b/src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs index fddbf84..2981613 100644 --- a/src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs +++ b/src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs @@ -7,6 +7,7 @@ using SharpEmu.Core.Cpu.Disasm; using SharpEmu.Core.Loader; using SharpEmu.Core.Memory; using SharpEmu.HLE; +using SharpEmu.HLE.FileSystem; using SharpEmu.HLE.Host; using SharpEmu.HLE.GpuMemory; using SharpEmu.Libs.VideoOut; @@ -148,6 +149,7 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime public OrbisGen2Result Run(string ebootPath) { + using var archiveMount = MountArchive(ref ebootPath); var normalizedEbootPath = Path.GetFullPath(ebootPath); using var app0Binding = BindApp0Root(normalizedEbootPath); Console.Error.WriteLine($"[RUNTIME] Loading: {ebootPath}"); @@ -400,6 +402,72 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime return result; } + // A .zar holds the whole application folder. Mount it at a cache folder that gets the files host + // code reads by path (the executable, its modules and sce_sys); everything else is read from the + // archive when the guest asks for it. The path becomes the cached executable. + private static ArchiveMountScope? MountArchive(ref string ebootPath) + { + if (!ZArchive.IsArchivePath(ebootPath)) + { + return null; + } + + var archivePath = Path.GetFullPath(ebootPath); + var archive = ZArchive.Open(archivePath) ?? + throw new InvalidDataException($"'{archivePath}' is not a valid .zar archive."); + if (!archive.IsFile(archive.LookUp("eboot.bin"))) + { + archive.Dispose(); + throw new FileNotFoundException("The archive has no eboot.bin at its root.", archivePath); + } + + var cacheRoot = ZArchiveCache.Prepare(archivePath); + App0Archive.Mount(archive, cacheRoot); + try + { + foreach (var entry in archive.GetEntries(archive.LookUp(string.Empty))) + { + if (entry.IsDirectory) + { + if (entry.Name.Equals("sce_sys", StringComparison.OrdinalIgnoreCase) || + entry.Name.Equals("sce_module", StringComparison.OrdinalIgnoreCase) || + entry.Name.Equals("sce_modules", StringComparison.OrdinalIgnoreCase)) + { + App0Archive.ExtractDirectory(entry.Name); + } + } + else if (entry.Name.Equals("eboot.bin", StringComparison.OrdinalIgnoreCase) || + Path.GetExtension(entry.Name).Equals(".prx", StringComparison.OrdinalIgnoreCase) || + Path.GetExtension(entry.Name).Equals(".sprx", StringComparison.OrdinalIgnoreCase)) + { + App0Archive.Extract(Path.Combine(cacheRoot, entry.Name)); + } + } + + foreach (var modules in new[] { Path.Combine("Media", "Modules"), Path.Combine("Media", "Plugins") }) + { + if (App0Archive.IsDirectory(Path.Combine(cacheRoot, modules))) + { + App0Archive.ExtractDirectory(modules); + } + } + } + catch + { + App0Archive.Unmount(); + throw; + } + + Console.Error.WriteLine($"[RUNTIME] Mounted archive '{archivePath}' at '{cacheRoot}'"); + ebootPath = Path.Combine(cacheRoot, "eboot.bin"); + return new ArchiveMountScope(); + } + + private sealed class ArchiveMountScope : IDisposable + { + public void Dispose() => App0Archive.Unmount(); + } + private static App0BindingScope? BindApp0Root(string normalizedEbootPath) { const string app0VariableName = "SHARPEMU_APP0_DIR"; @@ -806,6 +874,7 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime try { + App0Archive.Extract(modulePath); var fileInfo = new FileInfo(modulePath); if (!fileInfo.Exists || fileInfo.Length <= 0 || fileInfo.Length > int.MaxValue) { diff --git a/src/SharpEmu.GUI/Languages/en.json b/src/SharpEmu.GUI/Languages/en.json index 4740c3a..025c73c 100644 --- a/src/SharpEmu.GUI/Languages/en.json +++ b/src/SharpEmu.GUI/Languages/en.json @@ -34,6 +34,25 @@ "Library.Context.CopyTitleId": "Copy title ID", "Library.Context.GameSettings": "Game settings…", "Library.Context.Remove": "Remove from library", + "Library.Context.PackZar": "Pack as .zar…", + + "Library.Pack.Title": "Pack as .zar", + "Library.Pack.Heading": "Packing {0}", + "Library.Pack.Explanation": "This stores the whole game folder in a single compressed .zar file. SharpEmu runs the game straight from it without unpacking, so it usually takes less disk space and plays the same. The game folder is kept unless you choose to delete it below.", + "Library.Pack.Preparing": "Reading the game folder…", + "Library.Pack.Progress": "{0} / {1} GB ({2}%)", + "Library.Pack.Cancelling": "Cancelling…", + "Library.Pack.Done": "Done. The archive is ready to launch.", + "Library.Pack.Failed": "Packing failed: {0}", + "Library.Pack.Close": "Close", + "Library.Pack.MuteMusic": "Mute the title music while packing", + "Library.Pack.DeleteSource": "Delete the game folder after packing", + "Library.Pack.DeleteSourceWarning": "The folder is permanently deleted once the archive is written and checked. Keep a backup until you know the archive works.", + "Library.Pack.Verifying": "Checking the archive…", + "Library.Pack.Deleting": "Deleting the game folder…", + "Library.Pack.DoneDeleted": "Done. The archive is ready and the game folder was deleted.", + "Library.Pack.DeleteSkipped": "Done. The game folder was kept because the archive could not be confirmed to hold all of it.", + "Library.Pack.DeleteFailed": "The archive is ready, but deleting the game folder failed: {0}", "Library.Empty.Title": "Your library is empty", "Library.Empty.Hint": "Add a folder containing your games to get started.", @@ -205,6 +224,7 @@ "Status.AddFolderPrompt": "Add a game folder to populate the library.", "Status.LibraryScanned": "Library scanned: {0} game(s) in {1} folder(s).", "Status.CouldNotOpenFolder": "Could not open folder: {0}", + "Status.PackedZar": "Packed game archive: {0}", "Status.CopiedToClipboard": "{0} copied to clipboard.", "Status.RemovedFromLibrary": "Removed “{0}” from the library. Re-add its folder to restore it.", "Status.Running": "Running {0}", @@ -223,6 +243,8 @@ "Dialog.SaveLogFile": "Select where to save the Log file", "Dialog.PlainTextFiles": "Plain Text Files", "Dialog.LogFiles": "Log Files", + "Dialog.SaveZar": "Save the game archive", + "Dialog.ZarFiles": "Game archives", "Options.About" : "About", "About.Github.Label": "GitHub", diff --git a/src/SharpEmu.GUI/MainWindow.axaml b/src/SharpEmu.GUI/MainWindow.axaml index 4d04979..59655c8 100644 --- a/src/SharpEmu.GUI/MainWindow.axaml +++ b/src/SharpEmu.GUI/MainWindow.axaml @@ -216,6 +216,13 @@ SPDX-License-Identifier: GPL-2.0-or-later HorizontalAlignment="Center" VerticalAlignment="Center" /> + + + + + diff --git a/src/SharpEmu.GUI/MainWindow.axaml.cs b/src/SharpEmu.GUI/MainWindow.axaml.cs index 94b90f7..4ab3914 100644 --- a/src/SharpEmu.GUI/MainWindow.axaml.cs +++ b/src/SharpEmu.GUI/MainWindow.axaml.cs @@ -18,6 +18,7 @@ using Avalonia.Threading; using Avalonia.VisualTree; using SharpEmu.Core.Cpu; using SharpEmu.Core.Runtime; +using SharpEmu.HLE.FileSystem; using SharpEmu.HLE.Host; using SharpEmu.Libs.Pad; using SharpEmu.Libs.VideoOut; @@ -408,6 +409,7 @@ public partial class MainWindow : Window await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.TitleId); CtxGameSettings.Click += (_, _) => OpenSelectedGameSettings(); CtxRemove.Click += (_, _) => RemoveSelectedFromLibrary(); + CtxPackZar.Click += async (_, _) => await PackSelectedAsZarAsync(); Opened += async (_, _) => await OnOpenedAsync(); Closing += (_, _) => BeginWindowClosing(); @@ -1959,6 +1961,19 @@ public partial class MainWindow : Window /// private static long ComputeInstallSize(string ebootPath) { + // An archive is the whole game; the folder it sits in may hold other games. + if (ZArchive.IsArchivePath(ebootPath)) + { + try + { + return new FileInfo(ebootPath).Length; + } + catch (IOException) + { + return 0; + } + } + var directory = Path.GetDirectoryName(ebootPath); if (directory is null) { @@ -2007,7 +2022,9 @@ public partial class MainWindow : Window try { - foreach (var file in Directory.EnumerateFiles(folder, "eboot.bin", enumeration)) + // A game is a folder with eboot.bin or a .zar archive of one. + foreach (var file in Directory.EnumerateFiles(folder, "eboot.bin", enumeration) + .Concat(Directory.EnumerateFiles(folder, "*.zar", enumeration))) { var fullPath = Path.GetFullPath(file); if (!seen.Add(fullPath) || excludedPaths.Contains(fullPath)) @@ -2055,10 +2072,28 @@ public partial class MainWindow : Window $"[GUI][WARN] Could not inspect executable '{fullPath}': {exception.Message}"); } - var (title, titleId, version) = TryReadParamJson(fullPath); + // An archive's metadata is read from its cache folder, which stands in for the game folder. + var metadataPath = fullPath; + if (ZArchive.IsArchivePath(fullPath)) + { + try + { + if (ZArchiveCache.ExtractMetadata(fullPath) is { } cacheRoot) + { + metadataPath = Path.Combine(cacheRoot, "eboot.bin"); + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException) + { + Console.Error.WriteLine( + $"[GUI][WARN] Could not read archive '{fullPath}': {exception.Message}"); + } + } + + var (title, titleId, version) = TryReadParamJson(metadataPath); return new GameEntry( - title ?? GameNameFor(fullPath), titleId, version, fullPath, size, - FindCoverFor(fullPath), FindBackgroundFor(fullPath)); + title ?? GameNameFor(metadataPath), titleId, version, fullPath, size, + FindCoverFor(metadataPath), FindBackgroundFor(metadataPath)); } /// @@ -2475,6 +2510,100 @@ public partial class MainWindow : Window CtxLaunch.IsEnabled = !_isRunning; CtxCopyTitleId.IsEnabled = game.TitleId is not null; CtxGameSettings.IsEnabled = !string.IsNullOrWhiteSpace(game.TitleId); + CtxPackZar.IsEnabled = !ZArchive.IsArchivePath(game.Path); + } + + // Packs the selected game's folder into a .zar chosen by the user, beside the folder by default. + private async Task PackSelectedAsZarAsync() + { + if (GameList.SelectedItem is not GameEntry game || ZArchive.IsArchivePath(game.Path) || + Path.GetDirectoryName(game.Path) is not { } gameFolder) + { + return; + } + + var loc = Localization.Instance; + var options = new FilePickerSaveOptions + { + Title = loc.Get("Dialog.SaveZar"), + SuggestedFileName = SuggestedArchiveName(Path.GetFileName(gameFolder), game.TitleId, game.Version), + DefaultExtension = "zar", + FileTypeChoices = [new FilePickerFileType(loc.Get("Dialog.ZarFiles")) { Patterns = ["*.zar"] }], + }; + if (Path.GetDirectoryName(gameFolder) is { } parent && + await StorageProvider.TryGetFolderFromPathAsync(parent) is { } startFolder) + { + options.SuggestedStartLocation = startFolder; + } + + var file = await StorageProvider.SaveFilePickerAsync(options); + var destination = file?.TryGetLocalPath(); + if (string.IsNullOrEmpty(destination)) + { + return; + } + + // A running game may hold files in the folder open, so it is never deleted then. + var dialog = new ZarPackDialog(game.Name, gameFolder, destination, canDeleteSource: !_isRunning); + dialog.MuteMusicChanged += SetTitleMusicMuted; + try + { + if (await dialog.ShowDialog(this)) + { + AppendConsoleLine(loc.Format("Status.PackedZar", destination), SuccessLineBrush); + } + } + finally + { + SetTitleMusicMuted(false); + } + } + + // TITLEID-version (PPSA01234-1.004): the title id differs between regions of the same game and + // the version between updates, so each archive gets its own name and cache folder. Without a + // title id the game folder's name stands in. + internal static string SuggestedArchiveName(string folderName, string? titleId, string? version) + { + var name = string.IsNullOrWhiteSpace(titleId) ? folderName : titleId.Trim(); + var shortVersion = ShortVersion(version); + if (shortVersion.Length > 0) + { + name = $"{name}-{shortVersion}"; + } + + foreach (var invalid in Path.GetInvalidFileNameChars()) + { + name = name.Replace(invalid, '_'); + } + + return name; + } + + // "01.004.000" reads as "1.004": the leading zeros of the major number go, and the last part + // stays only when it is not zero. A version in another form is kept as it is. + private static string ShortVersion(string? version) + { + var text = version?.Trim() ?? string.Empty; + var parts = text.Split('.'); + if (parts.Length < 2 || !parts.All(part => part.Length > 0 && part.All(char.IsAsciiDigit))) + { + return text; + } + + var major = parts[0].TrimStart('0'); + var shortened = $"{(major.Length == 0 ? "0" : major)}.{parts[1]}"; + return parts.Length > 2 && parts[2].Any(digit => digit != '0') + ? $"{shortened}.{string.Join('.', parts[2..])}" + : shortened; + } + + private void SetTitleMusicMuted(bool muted) + { + _sndPreview.Muted = muted; + if (!muted && WindowState != WindowState.Minimized) + { + _sndPreview.Resume(); + } } private void OpenSelectedGameFolder() @@ -2650,7 +2779,9 @@ public partial class MainWindow : Window return; } - var directory = Path.GetDirectoryName(game.Path); + var directory = ZArchive.IsArchivePath(game.Path) + ? ZArchiveCache.GetCacheRoot(game.Path) + : Path.GetDirectoryName(game.Path); var sndPath = directory is null ? null : Path.Combine(directory, "sce_sys", "snd0.at9"); if (sndPath is not null && File.Exists(sndPath)) { @@ -2758,7 +2889,7 @@ public partial class MainWindow : Window FileTypeFilter = new[] { new FilePickerFileType(Localization.Instance.Get("Dialog.PsExecutables")) - { Patterns = new[] { "eboot.bin", "*.bin", "*.self", "*.elf" } }, + { Patterns = new[] { "eboot.bin", "*.bin", "*.self", "*.elf", "*.zar" } }, FilePickerFileTypes.All, }, }); diff --git a/src/SharpEmu.GUI/SndPreviewPlayer.cs b/src/SharpEmu.GUI/SndPreviewPlayer.cs index 31f4660..ca7e830 100644 --- a/src/SharpEmu.GUI/SndPreviewPlayer.cs +++ b/src/SharpEmu.GUI/SndPreviewPlayer.cs @@ -26,9 +26,44 @@ internal sealed class SndPreviewPlayer private GCHandle _pinnedWav; private bool _playing; private bool _paused; + private bool _muted; private string? _cachedPath; private byte[]? _cachedWav; + /// + /// Keeps the music silent while set, even across and + /// ; the track stays ready so clearing it and + /// resuming restarts the music. + /// + public bool Muted + { + get + { + lock (_sync) + { + return _muted; + } + } + set + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + lock (_sync) + { + _muted = value; + if (value && _playing) + { + _ = PlaySound(0, 0, 0); + _playing = false; + _paused = true; + } + } + } + } + /// Starts looping the given snd0.at9 after a short debounce. public void Play(string at9Path) { @@ -87,6 +122,12 @@ internal sealed class SndPreviewPlayer // The WAV image must stay pinned while winmm plays from it. _pinnedWav = GCHandle.Alloc(wav, GCHandleType.Pinned); + if (_muted) + { + _paused = true; + return; + } + _playing = PlaySound(_pinnedWav.AddrOfPinnedObject(), 0, SND_MEMORY | SND_ASYNC | SND_LOOP | SND_NODEFAULT); if (!_playing) { @@ -144,7 +185,7 @@ internal sealed class SndPreviewPlayer lock (_sync) { - if (!_paused || !_pinnedWav.IsAllocated) + if (!_paused || _muted || !_pinnedWav.IsAllocated) { return; } diff --git a/src/SharpEmu.GUI/ZarPackDialog.axaml b/src/SharpEmu.GUI/ZarPackDialog.axaml new file mode 100644 index 0000000..3dc1fff --- /dev/null +++ b/src/SharpEmu.GUI/ZarPackDialog.axaml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + +