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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/SharpEmu.GUI/ZarPackDialog.axaml.cs b/src/SharpEmu.GUI/ZarPackDialog.axaml.cs
new file mode 100644
index 0000000..fe15b2d
--- /dev/null
+++ b/src/SharpEmu.GUI/ZarPackDialog.axaml.cs
@@ -0,0 +1,152 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using Avalonia.Controls;
+using SharpEmu.HLE.FileSystem;
+
+namespace SharpEmu.GUI;
+
+// Packs a game folder into a .zar on a worker thread and shows its progress. The button cancels
+// while packing and closes once done; the dialog returns true when the archive was written. When
+// the user opts in, the game folder is deleted afterwards, but only once the finished archive opens
+// and holds every file of the folder at the same size.
+public partial class ZarPackDialog : Window
+{
+ private readonly CancellationTokenSource _cancellation = new();
+ private readonly string _sourceFolder = string.Empty;
+ private readonly string _destination = string.Empty;
+ private bool _finished;
+ private bool _succeeded;
+
+ public ZarPackDialog()
+ {
+ InitializeComponent();
+ }
+
+ public ZarPackDialog(string gameName, string sourceFolder, string destination, bool canDeleteSource = true)
+ : this()
+ {
+ _sourceFolder = sourceFolder;
+ _destination = destination;
+ DeleteSourceCheck.IsEnabled = canDeleteSource;
+ HeadingText.Text = Localization.Instance.Format("Library.Pack.Heading", gameName);
+ DestinationText.Text = destination;
+ StatusText.Text = Localization.Instance.Get("Library.Pack.Preparing");
+ CloseButton.Click += (_, _) => OnCloseClicked();
+ MuteMusicButton.IsCheckedChanged += (_, _) =>
+ {
+ var muted = MuteMusicButton.IsChecked == true;
+ MuteMusicButton.Content = muted ? "🔇" : "🔊";
+ MuteMusicChanged?.Invoke(muted);
+ };
+ Opened += async (_, _) => await PackAsync();
+ Closing += (_, _) =>
+ {
+ if (!_finished)
+ {
+ _cancellation.Cancel();
+ }
+ };
+ }
+
+ // Raised when the mute button changes; the owner silences its title music while it is set.
+ public event Action? MuteMusicChanged;
+
+ private void OnCloseClicked()
+ {
+ if (_finished)
+ {
+ Close(_succeeded);
+ return;
+ }
+
+ CloseButton.IsEnabled = false;
+ StatusText.Text = Localization.Instance.Get("Library.Pack.Cancelling");
+ _cancellation.Cancel();
+ }
+
+ private async Task PackAsync()
+ {
+ // Progress captures the UI context, so reports arrive on the UI thread.
+ var progress = new Progress(report =>
+ {
+ var fraction = report.BytesTotal == 0 ? 1 : (double)report.BytesDone / report.BytesTotal;
+ PackProgress.Value = fraction;
+ StatusText.Text = Localization.Instance.Format(
+ "Library.Pack.Progress",
+ FormatGigabytes(report.BytesDone),
+ FormatGigabytes(report.BytesTotal),
+ (int)(fraction * 100));
+ });
+
+ string? error = null;
+ var cancelled = false;
+ try
+ {
+ await Task.Run(() => ZArchiveWriter.Pack(_sourceFolder, _destination, progress, _cancellation.Token));
+ _succeeded = true;
+ }
+ catch (OperationCanceledException)
+ {
+ cancelled = true;
+ }
+ catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
+ {
+ error = exception.Message;
+ }
+
+ _finished = true;
+ if (cancelled && !IsVisible)
+ {
+ return;
+ }
+
+ if (cancelled)
+ {
+ Close(false);
+ return;
+ }
+
+ PackProgress.Value = _succeeded ? 1 : PackProgress.Value;
+ StatusText.Text = _succeeded
+ ? Localization.Instance.Get("Library.Pack.Done")
+ : Localization.Instance.Format("Library.Pack.Failed", error ?? string.Empty);
+ DeleteSourceCheck.IsEnabled = false;
+ if (_succeeded && DeleteSourceCheck.IsChecked == true)
+ {
+ StatusText.Text = await DeleteSourceAsync();
+ }
+
+ CloseButton.Content = Localization.Instance.Get("Library.Pack.Close");
+ CloseButton.IsEnabled = true;
+ }
+
+ // Deletes the game folder once the archive is confirmed to hold all of it. Returns the status
+ // to show.
+ private async Task DeleteSourceAsync()
+ {
+ var loc = Localization.Instance;
+ CloseButton.IsEnabled = false;
+ StatusText.Text = loc.Get("Library.Pack.Verifying");
+ try
+ {
+ var source = _sourceFolder;
+ var destination = _destination;
+ if (!File.Exists(Path.Combine(source, "eboot.bin")) ||
+ !await Task.Run(() => ZArchiveWriter.MatchesFolder(destination, source)))
+ {
+ return loc.Get("Library.Pack.DeleteSkipped");
+ }
+
+ StatusText.Text = loc.Get("Library.Pack.Deleting");
+ await Task.Run(() => Directory.Delete(source, recursive: true));
+ return loc.Get("Library.Pack.DoneDeleted");
+ }
+ catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
+ {
+ return loc.Format("Library.Pack.DeleteFailed", exception.Message);
+ }
+ }
+
+ private static string FormatGigabytes(long bytes) => (bytes / (1024.0 * 1024 * 1024)).ToString("0.00");
+}
diff --git a/src/SharpEmu.HLE/FileSystem/App0Archive.cs b/src/SharpEmu.HLE/FileSystem/App0Archive.cs
new file mode 100644
index 0000000..7c1ece4
--- /dev/null
+++ b/src/SharpEmu.HLE/FileSystem/App0Archive.cs
@@ -0,0 +1,303 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.IO.Enumeration;
+
+namespace SharpEmu.HLE.FileSystem;
+
+// A game run from a .zar mounts /app0 at a small host directory that holds the files host code reads
+// by path (the executable, its modules, sce_sys); every other /app0 path is served from the archive.
+// A host path under that directory that is not on disk resolves here: stat, listing and read-only
+// streams come from the archive, and a host consumer that needs a real file can extract just it.
+public static class App0Archive
+{
+ private static readonly object Gate = new();
+ private static ZArchive? _archive;
+ private static string? _root;
+
+ public static bool IsMounted => Volatile.Read(ref _archive) is not null;
+
+ public static string? Root => Volatile.Read(ref _root);
+
+ public static void Mount(ZArchive archive, string root)
+ {
+ lock (Gate)
+ {
+ _archive?.Dispose();
+ _root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root));
+ Volatile.Write(ref _archive, archive);
+ }
+ }
+
+ public static void Unmount()
+ {
+ lock (Gate)
+ {
+ _archive?.Dispose();
+ Volatile.Write(ref _archive, null);
+ _root = null;
+ }
+ }
+
+ // The archive and the node a host path names, when it lies under the mount and the archive has it.
+ public static bool TryResolve(string hostPath, out ZArchive archive, out int node)
+ {
+ archive = Volatile.Read(ref _archive)!;
+ node = ZArchive.InvalidNode;
+ var root = Volatile.Read(ref _root);
+ if (archive is null || root is null || string.IsNullOrEmpty(hostPath))
+ {
+ return false;
+ }
+
+ string full;
+ try
+ {
+ full = Path.TrimEndingDirectorySeparator(Path.GetFullPath(hostPath));
+ }
+ catch (Exception exception) when (exception is ArgumentException or NotSupportedException or PathTooLongException)
+ {
+ return false;
+ }
+
+ string relative;
+ if (string.Equals(full, root, StringComparison.OrdinalIgnoreCase))
+ {
+ relative = string.Empty;
+ }
+ else if (full.Length > root.Length && full.StartsWith(root, StringComparison.OrdinalIgnoreCase) &&
+ full[root.Length] is '\\' or '/')
+ {
+ relative = full[(root.Length + 1)..];
+ }
+ else
+ {
+ return false;
+ }
+
+ node = archive.LookUp(relative);
+ return node != ZArchive.InvalidNode;
+ }
+
+ public static bool TryGetFile(string hostPath, out ulong size)
+ {
+ size = 0;
+ if (!TryResolve(hostPath, out var archive, out var node) || !archive.IsFile(node))
+ {
+ return false;
+ }
+
+ size = archive.GetFileSize(node);
+ return true;
+ }
+
+ public static bool IsDirectory(string hostPath) =>
+ TryResolve(hostPath, out var archive, out var node) && archive.IsDirectory(node);
+
+ public static bool Exists(string hostPath) => TryResolve(hostPath, out _, out _);
+
+ // The names in the archive directory a host path names; empty when there is none.
+ public static IReadOnlyList List(string hostPath) =>
+ TryResolve(hostPath, out var archive, out var node) ? archive.GetEntries(node) : [];
+
+ // A read-only, seekable stream over the archive file a host path names.
+ public static ZArchiveFileStream? OpenRead(string hostPath) =>
+ TryResolve(hostPath, out var archive, out var node) && archive.IsFile(node)
+ ? new ZArchiveFileStream(archive, node, hostPath)
+ : null;
+
+ // Opens a host path for reading: from the archive when it is under the mount and not on disk,
+ // otherwise as a host file.
+ public static Stream OpenReadOrFile(string hostPath, FileShare share = FileShare.Read) =>
+ !File.Exists(hostPath) && OpenRead(hostPath) is { } archived
+ ? archived
+ : new FileStream(hostPath, FileMode.Open, FileAccess.Read, share);
+
+ // Writes the archive file a host path names to that path, for host code that opens files itself.
+ // True when the file is on disk afterwards.
+ public static bool Extract(string hostPath)
+ {
+ if (File.Exists(hostPath))
+ {
+ return true;
+ }
+
+ if (OpenRead(hostPath) is not { } source)
+ {
+ return false;
+ }
+
+ using (source)
+ {
+ lock (Gate)
+ {
+ WriteFile(source, hostPath);
+ }
+ }
+
+ return true;
+ }
+
+ // Copies an archive file to a host path through a temporary name, so a partial copy is never seen.
+ internal static void WriteFile(Stream source, string hostPath)
+ {
+ if (File.Exists(hostPath))
+ {
+ return;
+ }
+
+ Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(hostPath))!);
+ var temporary = hostPath + ".partial";
+ using (var target = new FileStream(temporary, FileMode.Create, FileAccess.Write, FileShare.None))
+ {
+ source.CopyTo(target, ZArchive.BlockSize);
+ }
+
+ File.Move(temporary, hostPath, overwrite: true);
+ }
+
+ // Every file in the mounted archive, as paths relative to the mount with '/' separators.
+ public static List ListAllFiles()
+ {
+ var files = new List();
+ if (Volatile.Read(ref _archive) is { } archive)
+ {
+ AddFiles(archive, archive.LookUp(string.Empty), string.Empty, files);
+ }
+
+ return files;
+ }
+
+ private static void AddFiles(ZArchive archive, int directory, string prefix, List files)
+ {
+ foreach (var entry in archive.GetEntries(directory))
+ {
+ var path = prefix + entry.Name;
+ if (entry.IsDirectory)
+ {
+ AddFiles(archive, archive.LookUp(path), path + "/", files);
+ }
+ else
+ {
+ files.Add(path);
+ }
+ }
+ }
+
+ // Files under a host directory and all its subdirectories whose names match a pattern: those on
+ // disk and, when the directory is under the mount, those in the archive.
+ public static IEnumerable EnumerateFilesRecursive(string hostDirectory, string pattern)
+ {
+ var found = Directory.Exists(hostDirectory)
+ ? Directory.EnumerateFiles(hostDirectory, pattern, SearchOption.AllDirectories).ToList()
+ : [];
+ var root = Root;
+ if (root is null || !TryResolve(hostDirectory, out var archive, out var node) || !archive.IsDirectory(node))
+ {
+ return found;
+ }
+
+ var seen = new HashSet(found, StringComparer.OrdinalIgnoreCase);
+ var start = Path.GetRelativePath(root, Path.GetFullPath(hostDirectory));
+ var prefix = start == "." ? string.Empty : start.Replace('\\', '/') + "/";
+ var archived = new List();
+ AddFiles(archive, node, prefix, archived);
+ foreach (var relative in archived)
+ {
+ var hostPath = Path.Combine(root, relative.Replace('/', Path.DirectorySeparatorChar));
+ if (FileSystemName.MatchesSimpleExpression(pattern, Path.GetFileName(hostPath)) && seen.Add(hostPath))
+ {
+ found.Add(hostPath);
+ }
+ }
+
+ return found;
+ }
+
+ // Extracts every file under the archive directory into the mount, for the files host code reads by path.
+ public static void ExtractDirectory(string relative)
+ {
+ var root = Root ?? throw new InvalidOperationException("No archive is mounted.");
+ var directory = Path.Combine(root, relative);
+ foreach (var entry in List(directory))
+ {
+ var path = Path.Combine(relative, entry.Name);
+ if (entry.IsDirectory)
+ {
+ ExtractDirectory(path);
+ }
+ else
+ {
+ Extract(Path.Combine(root, path));
+ }
+ }
+ }
+}
+
+// One archive file as a read-only, seekable stream; reads at any position go through the archive's
+// block cache. Name is the host path it was opened as.
+public sealed class ZArchiveFileStream : Stream
+{
+ private readonly ZArchive _archive;
+ private readonly int _node;
+ private readonly long _length;
+ private long _position;
+
+ public ZArchiveFileStream(ZArchive archive, int node, string name)
+ {
+ _archive = archive;
+ _node = node;
+ _length = (long)archive.GetFileSize(node);
+ Name = name;
+ }
+
+ public string Name { get; }
+
+ public override bool CanRead => true;
+
+ public override bool CanSeek => true;
+
+ public override bool CanWrite => false;
+
+ public override long Length => _length;
+
+ public override long Position
+ {
+ get => _position;
+ set => _position = value >= 0 ? value : throw new ArgumentOutOfRangeException(nameof(value));
+ }
+
+ // Reads at an offset without moving the position, like a positional read on a file handle.
+ public int ReadAt(Span buffer, long offset) =>
+ offset < 0 ? throw new ArgumentOutOfRangeException(nameof(offset)) : _archive.Read(_node, (ulong)offset, buffer);
+
+ public override int Read(Span buffer)
+ {
+ var read = ReadAt(buffer, _position);
+ _position += read;
+ return read;
+ }
+
+ public override int Read(byte[] buffer, int offset, int count) => Read(buffer.AsSpan(offset, count));
+
+ public override long Seek(long offset, SeekOrigin origin)
+ {
+ var target = origin switch
+ {
+ SeekOrigin.Begin => offset,
+ SeekOrigin.Current => _position + offset,
+ SeekOrigin.End => _length + offset,
+ _ => throw new ArgumentOutOfRangeException(nameof(origin)),
+ };
+ Position = target;
+ return target;
+ }
+
+ public override void Flush()
+ {
+ }
+
+ public override void SetLength(long value) => throw new NotSupportedException("An archive file is read-only.");
+
+ public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException("An archive file is read-only.");
+}
diff --git a/src/SharpEmu.HLE/FileSystem/ZArchive.cs b/src/SharpEmu.HLE/FileSystem/ZArchive.cs
new file mode 100644
index 0000000..4ebc77a
--- /dev/null
+++ b/src/SharpEmu.HLE/FileSystem/ZArchive.cs
@@ -0,0 +1,390 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Buffers.Binary;
+using System.Text;
+using ZstdSharp;
+
+namespace SharpEmu.HLE.FileSystem;
+
+// A read-only ZArchive (.zar): a game folder packed into zstd-compressed 64 KiB blocks with an index,
+// so any byte range reads without unpacking the archive. Format by Exzap
+// (https://github.com/Exzap/ZArchive, MIT No Attribution); every multi-byte field is big-endian.
+//
+// Layout, read from the footer at the end of the file:
+// compressed data blocks of 64 KiB once decompressed; a block stored at exactly 64 KiB is raw
+// offset records per 16 blocks: the first block's offset (u64) and each block's size - 1 (u16)
+// names per node: a 1-byte length (or 2 bytes when the top bit is set) and the bytes
+// file tree 16-byte nodes; node 0 is the unnamed root directory
+public sealed class ZArchive : IDisposable
+{
+ public const int BlockSize = 64 * 1024;
+ private const int BlocksPerOffsetRecord = 16;
+ private const int OffsetRecordBytes = 8 + 2 * BlocksPerOffsetRecord;
+ private const int NodeBytes = 16;
+ private const int FooterBytes = 16 * 6 + 32 + 8 + 4 + 4;
+ private const uint FooterMagic = 0x169F52D6;
+ private const uint FooterVersion1 = 0x61BF3A01;
+ private const uint FileFlag = 0x80000000;
+ private const uint NameOffsetMask = 0x7FFFFFFF;
+ private const int CacheBlocks = 64;
+
+ public const int InvalidNode = -1;
+
+ private readonly FileStream _stream;
+ private readonly OffsetRecord[] _offsetRecords;
+ private readonly byte[] _names;
+ private readonly Node[] _nodes;
+ private readonly ulong _dataOffset;
+ private readonly ulong _dataSize;
+ private readonly object _gate = new();
+ private readonly Decompressor _decompressor = new();
+ private readonly byte[] _compressed = new byte[BlockSize];
+ // Decompressed blocks, least recently used first.
+ private readonly LinkedList<(ulong Block, byte[] Data)> _cache = new();
+ private readonly Dictionary> _cached = new();
+
+ private readonly record struct OffsetRecord(ulong BaseOffset, ushort[] Sizes);
+
+ private readonly record struct Node(uint NameAndType, uint Word0, uint Word1, uint Word2)
+ {
+ public bool IsFile => (NameAndType & FileFlag) != 0;
+
+ public uint NameOffset => NameAndType & NameOffsetMask;
+
+ public ulong FileOffset => Word0 | ((ulong)(Word2 & 0xFFFF) << 32);
+
+ public ulong FileSize => Word1 | ((ulong)(Word2 & 0xFFFF0000) << 16);
+
+ public uint FirstChild => Word0;
+
+ public uint ChildCount => Word1;
+ }
+
+ public readonly record struct Entry(string Name, bool IsDirectory, ulong Size);
+
+ public string Path { get; }
+
+ private ZArchive(string path, FileStream stream, OffsetRecord[] offsetRecords, byte[] names, Node[] nodes, ulong dataOffset, ulong dataSize)
+ {
+ Path = path;
+ _stream = stream;
+ _offsetRecords = offsetRecords;
+ _names = names;
+ _nodes = nodes;
+ _dataOffset = dataOffset;
+ _dataSize = dataSize;
+ }
+
+ public static bool IsArchivePath(string? path) =>
+ path is not null && path.EndsWith(".zar", StringComparison.OrdinalIgnoreCase) && File.Exists(path);
+
+ // Null when the file is not a well-formed version-1 archive.
+ public static ZArchive? Open(string path)
+ {
+ var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 1, FileOptions.RandomAccess);
+ try
+ {
+ if (TryOpen(path, stream) is { } archive)
+ {
+ return archive;
+ }
+ }
+ catch (Exception exception) when (exception is IOException or ArgumentException or OverflowException)
+ {
+ }
+
+ stream.Dispose();
+ return null;
+ }
+
+ private static ZArchive? TryOpen(string path, FileStream stream)
+ {
+ var length = (ulong)stream.Length;
+ if (length <= FooterBytes)
+ {
+ return null;
+ }
+
+ Span footer = stackalloc byte[FooterBytes];
+ ReadExactly(stream, length - FooterBytes, footer);
+ var magic = BinaryPrimitives.ReadUInt32BigEndian(footer[(FooterBytes - 4)..]);
+ var version = BinaryPrimitives.ReadUInt32BigEndian(footer[(FooterBytes - 8)..]);
+ var totalSize = BinaryPrimitives.ReadUInt64BigEndian(footer[(FooterBytes - 16)..]);
+ if (magic != FooterMagic || version != FooterVersion1 || totalSize != length)
+ {
+ return null;
+ }
+
+ // Six (offset, size) pairs: compressed data, offset records, names, file tree, metadata
+ // directory and metadata; the last two are unused.
+ var sections = new (ulong Offset, ulong Size)[6];
+ for (var index = 0; index < sections.Length; index++)
+ {
+ var offset = BinaryPrimitives.ReadUInt64BigEndian(footer[(index * 16)..]);
+ var size = BinaryPrimitives.ReadUInt64BigEndian(footer[(index * 16 + 8)..]);
+ if (offset > length || size > length - offset)
+ {
+ return null;
+ }
+
+ sections[index] = (offset, size);
+ }
+
+ var (dataOffset, dataSize) = sections[0];
+ var (recordsOffset, recordsSize) = sections[1];
+ var (namesOffset, namesSize) = sections[2];
+ var (treeOffset, treeSize) = sections[3];
+ if (recordsSize == 0 || recordsSize % OffsetRecordBytes != 0 || recordsSize > uint.MaxValue ||
+ namesSize > int.MaxValue || treeSize == 0 || treeSize % NodeBytes != 0 || treeSize > int.MaxValue)
+ {
+ return null;
+ }
+
+ var recordBytes = new byte[recordsSize];
+ ReadExactly(stream, recordsOffset, recordBytes);
+ var records = new OffsetRecord[recordsSize / OffsetRecordBytes];
+ for (var index = 0; index < records.Length; index++)
+ {
+ var record = recordBytes.AsSpan(index * OffsetRecordBytes, OffsetRecordBytes);
+ var sizes = new ushort[BlocksPerOffsetRecord];
+ for (var block = 0; block < sizes.Length; block++)
+ {
+ sizes[block] = BinaryPrimitives.ReadUInt16BigEndian(record[(8 + block * 2)..]);
+ }
+
+ records[index] = new OffsetRecord(BinaryPrimitives.ReadUInt64BigEndian(record), sizes);
+ }
+
+ var names = new byte[namesSize];
+ ReadExactly(stream, namesOffset, names);
+ var treeBytes = new byte[treeSize];
+ ReadExactly(stream, treeOffset, treeBytes);
+ var nodes = new Node[treeSize / NodeBytes];
+ for (var index = 0; index < nodes.Length; index++)
+ {
+ var node = treeBytes.AsSpan(index * NodeBytes, NodeBytes);
+ nodes[index] = new Node(
+ BinaryPrimitives.ReadUInt32BigEndian(node),
+ BinaryPrimitives.ReadUInt32BigEndian(node[4..]),
+ BinaryPrimitives.ReadUInt32BigEndian(node[8..]),
+ BinaryPrimitives.ReadUInt32BigEndian(node[12..]));
+ }
+
+ if (nodes[0].IsFile || NameOf(names, nodes[0].NameOffset) is not { Length: 0 })
+ {
+ return null;
+ }
+
+ foreach (var node in nodes)
+ {
+ if (!node.IsFile && ((ulong)node.FirstChild + node.ChildCount > (ulong)nodes.Length))
+ {
+ return null;
+ }
+ }
+
+ return new ZArchive(path, stream, records, names, nodes, dataOffset, dataSize);
+ }
+
+ // The node at the path, with '/' or '\' separators and names compared without case;
+ // the root for an empty path; InvalidNode when it is missing.
+ public int LookUp(string path)
+ {
+ var current = 0;
+ foreach (var part in path.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries))
+ {
+ var directory = _nodes[current];
+ if (directory.IsFile)
+ {
+ return InvalidNode;
+ }
+
+ var match = InvalidNode;
+ for (var child = directory.FirstChild; child < directory.FirstChild + directory.ChildCount; child++)
+ {
+ if (string.Equals(NameOf(_names, _nodes[child].NameOffset), part, StringComparison.OrdinalIgnoreCase))
+ {
+ match = (int)child;
+ break;
+ }
+ }
+
+ if (match == InvalidNode)
+ {
+ return InvalidNode;
+ }
+
+ current = match;
+ }
+
+ return current;
+ }
+
+ public bool IsFile(int node) => (uint)node < (uint)_nodes.Length && _nodes[node].IsFile;
+
+ public bool IsDirectory(int node) => (uint)node < (uint)_nodes.Length && !_nodes[node].IsFile;
+
+ public ulong GetFileSize(int node) => IsFile(node) ? _nodes[node].FileSize : 0;
+
+ public IReadOnlyList GetEntries(int directory)
+ {
+ if (!IsDirectory(directory))
+ {
+ return [];
+ }
+
+ var node = _nodes[directory];
+ var entries = new List((int)node.ChildCount);
+ for (var child = node.FirstChild; child < node.FirstChild + node.ChildCount; child++)
+ {
+ var entry = _nodes[child];
+ if (NameOf(_names, entry.NameOffset) is { Length: > 0 } name)
+ {
+ entries.Add(new Entry(name, !entry.IsFile, entry.IsFile ? entry.FileSize : 0));
+ }
+ }
+
+ return entries;
+ }
+
+ // Copies file bytes from offset into destination; returns the count read, short at the end of
+ // the file. Throws InvalidDataException for a damaged block.
+ public int Read(int file, ulong offset, Span destination)
+ {
+ if (!IsFile(file))
+ {
+ return 0;
+ }
+
+ var node = _nodes[file];
+ if (offset >= node.FileSize)
+ {
+ return 0;
+ }
+
+ var count = (int)Math.Min((ulong)destination.Length, node.FileSize - offset);
+ var position = node.FileOffset + offset;
+ var written = 0;
+ lock (_gate)
+ {
+ while (written < count)
+ {
+ var block = position / BlockSize;
+ var within = (int)(position % BlockSize);
+ var step = Math.Min(count - written, BlockSize - within);
+ LoadBlock(block).AsSpan(within, step).CopyTo(destination[written..]);
+ written += step;
+ position += (ulong)step;
+ }
+ }
+
+ return written;
+ }
+
+ private byte[] LoadBlock(ulong block)
+ {
+ if (_cached.TryGetValue(block, out var cached))
+ {
+ _cache.Remove(cached);
+ _cache.AddLast(cached);
+ return cached.Value.Data;
+ }
+
+ var recordIndex = block / BlocksPerOffsetRecord;
+ if (recordIndex >= (ulong)_offsetRecords.Length)
+ {
+ throw new InvalidDataException($"The archive block {block} is past its offset records.");
+ }
+
+ var record = _offsetRecords[recordIndex];
+ var sub = (int)(block % BlocksPerOffsetRecord);
+ var offset = record.BaseOffset;
+ for (var index = 0; index < sub; index++)
+ {
+ offset += (ulong)record.Sizes[index] + 1;
+ }
+
+ var size = record.Sizes[sub] + 1;
+ if (offset + (ulong)size > _dataSize)
+ {
+ throw new InvalidDataException($"The archive block {block} lies outside the compressed data.");
+ }
+
+ byte[] data;
+ LinkedListNode<(ulong Block, byte[] Data)> slot;
+ if (_cache.Count >= CacheBlocks)
+ {
+ slot = _cache.First!;
+ _cache.RemoveFirst();
+ _cached.Remove(slot.Value.Block);
+ data = slot.Value.Data;
+ }
+ else
+ {
+ data = new byte[BlockSize];
+ slot = new LinkedListNode<(ulong Block, byte[] Data)>((block, data));
+ }
+
+ if (size == BlockSize)
+ {
+ ReadExactly(_stream, _dataOffset + offset, data);
+ }
+ else
+ {
+ ReadExactly(_stream, _dataOffset + offset, _compressed.AsSpan(0, size));
+ if (!_decompressor.TryUnwrap(_compressed.AsSpan(0, size), data, out var produced) || produced != BlockSize)
+ {
+ throw new InvalidDataException($"The archive block {block} does not decompress to {BlockSize} bytes.");
+ }
+ }
+
+ slot.Value = (block, data);
+ _cache.AddLast(slot);
+ _cached[block] = slot;
+ return data;
+ }
+
+ // The name at the offset; empty for a missing or malformed one. A length of 128 or more takes a
+ // second byte: (first & 0x7F) | (second << 7), as the archive writer stores it.
+ private static string NameOf(byte[] names, uint offset)
+ {
+ if (offset == NameOffsetMask || offset >= (uint)names.Length)
+ {
+ return string.Empty;
+ }
+
+ var position = (int)offset;
+ var length = names[position] & 0x7F;
+ if ((names[position] & 0x80) != 0)
+ {
+ if (position + 1 >= names.Length)
+ {
+ return string.Empty;
+ }
+
+ length |= names[position + 1] << 7;
+ position += 2;
+ }
+ else
+ {
+ position++;
+ }
+
+ return position + length > names.Length ? string.Empty : Encoding.UTF8.GetString(names, position, length);
+ }
+
+ private static void ReadExactly(FileStream stream, ulong offset, Span destination)
+ {
+ if (RandomAccess.Read(stream.SafeFileHandle, destination, (long)offset) != destination.Length)
+ {
+ throw new IOException("The archive ended before a section it lists.");
+ }
+ }
+
+ public void Dispose()
+ {
+ _stream.Dispose();
+ _decompressor.Dispose();
+ }
+}
diff --git a/src/SharpEmu.HLE/FileSystem/ZArchiveCache.cs b/src/SharpEmu.HLE/FileSystem/ZArchiveCache.cs
new file mode 100644
index 0000000..63d968b
--- /dev/null
+++ b/src/SharpEmu.HLE/FileSystem/ZArchiveCache.cs
@@ -0,0 +1,79 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+namespace SharpEmu.HLE.FileSystem;
+
+// Each .zar gets one host folder under user/zar-cache, named after the archive. /app0 is mounted
+// there while the game runs and the library reads the game's metadata from it. A stamp of the
+// archive's path, size and write time tells whether the files in it came from this archive; when
+// it differs, the folder starts over.
+public static class ZArchiveCache
+{
+ private const string StampName = ".sharpemu-archive";
+
+ // The files the library shows for a game: its parameters, icon and key art.
+ private static readonly string[] MetadataFiles =
+ [
+ "sce_sys/param.json",
+ "sce_sys/icon0.png",
+ "sce_sys/pic0.png",
+ "sce_sys/pic1.png",
+ "sce_sys/snd0.at9",
+ ];
+
+ private static string DefaultCacheParent => Path.Combine(AppContext.BaseDirectory, "user", "zar-cache");
+
+ // The cache folder an archive uses, without creating or checking it.
+ public static string GetCacheRoot(string archivePath, string? cacheParent = null) =>
+ Path.Combine(cacheParent ?? DefaultCacheParent, Path.GetFileNameWithoutExtension(archivePath));
+
+ public static string Prepare(string archivePath) => Prepare(archivePath, DefaultCacheParent);
+
+ public static string Prepare(string archivePath, string cacheParent)
+ {
+ archivePath = Path.GetFullPath(archivePath);
+ var info = new FileInfo(archivePath);
+ var cacheRoot = GetCacheRoot(archivePath, cacheParent);
+ var stampPath = Path.Combine(cacheRoot, StampName);
+ var stamp = $"{archivePath}|{info.Length}|{info.LastWriteTimeUtc.Ticks}";
+ if (!File.Exists(stampPath) || File.ReadAllText(stampPath) != stamp)
+ {
+ if (Directory.Exists(cacheRoot))
+ {
+ Directory.Delete(cacheRoot, recursive: true);
+ }
+
+ Directory.CreateDirectory(cacheRoot);
+ File.WriteAllText(stampPath, stamp);
+ }
+
+ return cacheRoot;
+ }
+
+ // Extracts an archive's sce_sys metadata into its cache folder and returns the folder, or null
+ // when the file is not a readable archive. The folder stands in for the game folder.
+ public static string? ExtractMetadata(string archivePath, string? cacheParent = null)
+ {
+ using var archive = ZArchive.Open(archivePath);
+ if (archive is null)
+ {
+ return null;
+ }
+
+ var cacheRoot = cacheParent is null ? Prepare(archivePath) : Prepare(archivePath, cacheParent);
+ foreach (var relative in MetadataFiles)
+ {
+ var node = archive.LookUp(relative);
+ if (!archive.IsFile(node))
+ {
+ continue;
+ }
+
+ var hostPath = Path.Combine(cacheRoot, relative.Replace('/', Path.DirectorySeparatorChar));
+ using var source = new ZArchiveFileStream(archive, node, hostPath);
+ App0Archive.WriteFile(source, hostPath);
+ }
+
+ return cacheRoot;
+ }
+}
diff --git a/src/SharpEmu.HLE/FileSystem/ZArchiveWriter.cs b/src/SharpEmu.HLE/FileSystem/ZArchiveWriter.cs
new file mode 100644
index 0000000..148ed8c
--- /dev/null
+++ b/src/SharpEmu.HLE/FileSystem/ZArchiveWriter.cs
@@ -0,0 +1,312 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Buffers.Binary;
+using System.Text;
+using ZstdSharp;
+
+namespace SharpEmu.HLE.FileSystem;
+
+// Packs a folder into a .zar the way the ZArchive writer lays one out: file data appended in order
+// and cut into 64 KiB zstd blocks (the last padded with zeros, a block that does not shrink stored
+// raw), the data section padded to 8 bytes, then offset records, names, a breadth-first file tree
+// with each directory's children sorted without case, empty metadata and the footer. Data streams
+// through one block at a time, so a large game needs little memory.
+public static class ZArchiveWriter
+{
+ private const int CompressionLevel = 6;
+ private const uint Version = 0x61BF3A01;
+ private const uint Magic = 0x169F52D6;
+
+ public readonly record struct Progress(long BytesDone, long BytesTotal, int FilesDone, int FilesTotal);
+
+ private sealed class PathNode(string name, bool isFile)
+ {
+ public string Name { get; } = name;
+ public bool IsFile { get; } = isFile;
+ public List Children { get; } = [];
+ public ulong FileOffset { get; set; }
+ public ulong FileSize { get; set; }
+ public uint NameOffset { get; set; }
+ public uint FirstChild { get; set; }
+ }
+
+ // Writes the archive through a temporary name and moves it into place only when complete, so a
+ // cancelled or failed pack leaves no partial .zar behind.
+ public static void Pack(
+ string sourceDirectory,
+ string destinationPath,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default)
+ {
+ var source = Path.GetFullPath(sourceDirectory);
+ var destination = Path.GetFullPath(destinationPath);
+ if (destination.StartsWith(Path.TrimEndingDirectorySeparator(source) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new IOException("The archive cannot be written inside the folder being packed.");
+ }
+
+ var files = Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories)
+ .Select(path => new FileInfo(path))
+ .OrderBy(info => info.FullName, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ var bytesTotal = files.Sum(info => info.Length);
+ var temporary = destination + ".partial";
+ try
+ {
+ using (var output = new FileStream(temporary, FileMode.Create, FileAccess.Write, FileShare.None, 1 << 20))
+ {
+ Write(source, files, bytesTotal, output, progress, cancellationToken);
+ }
+
+ File.Move(temporary, destination, overwrite: true);
+ }
+ catch
+ {
+ try
+ {
+ File.Delete(temporary);
+ }
+ catch (IOException)
+ {
+ }
+
+ throw;
+ }
+ }
+
+ // Whether the archive opens and holds every file of the folder at the same size: the check
+ // that must pass before the folder may be deleted.
+ public static bool MatchesFolder(string archivePath, string sourceDirectory)
+ {
+ using var archive = ZArchive.Open(archivePath);
+ if (archive is null)
+ {
+ return false;
+ }
+
+ var source = Path.GetFullPath(sourceDirectory);
+ foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories))
+ {
+ var node = archive.LookUp(Path.GetRelativePath(source, file));
+ if (!archive.IsFile(node) || archive.GetFileSize(node) != (ulong)new FileInfo(file).Length)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static void Write(
+ string source,
+ List files,
+ long bytesTotal,
+ FileStream output,
+ IProgress? progress,
+ CancellationToken cancellationToken)
+ {
+ var root = new PathNode(string.Empty, isFile: false);
+ var block = new byte[ZArchive.BlockSize];
+ var filled = 0;
+ var blockIndex = 0;
+ var records = new List<(ulong BaseOffset, ushort[] Sizes)>();
+ using var compressor = new Compressor(CompressionLevel);
+ ulong dataOffset = 0;
+ var lastReport = 0L;
+
+ void FlushBlock()
+ {
+ var compressed = compressor.Wrap(block);
+ var offset = (ulong)output.Position;
+ int size;
+ if (compressed.Length >= ZArchive.BlockSize)
+ {
+ output.Write(block);
+ size = ZArchive.BlockSize;
+ }
+ else
+ {
+ output.Write(compressed);
+ size = compressed.Length;
+ }
+
+ if (blockIndex % 16 == 0)
+ {
+ records.Add((offset, new ushort[16]));
+ }
+
+ records[^1].Sizes[blockIndex % 16] = (ushort)(size - 1);
+ blockIndex++;
+ filled = 0;
+ }
+
+ for (var index = 0; index < files.Count; index++)
+ {
+ var parts = Path.GetRelativePath(source, files[index].FullName).Split(Path.DirectorySeparatorChar);
+ var directory = root;
+ foreach (var part in parts[..^1])
+ {
+ var next = directory.Children.Find(child => string.Equals(child.Name, part, StringComparison.OrdinalIgnoreCase));
+ if (next is null)
+ {
+ next = new PathNode(part, isFile: false);
+ directory.Children.Add(next);
+ }
+
+ directory = next;
+ }
+
+ var file = new PathNode(parts[^1], isFile: true) { FileOffset = dataOffset };
+ using (var input = new FileStream(files[index].FullName, FileMode.Open, FileAccess.Read, FileShare.Read, 1 << 20))
+ {
+ int read;
+ while ((read = input.Read(block, filled, block.Length - filled)) > 0)
+ {
+ filled += read;
+ dataOffset += (ulong)read;
+ file.FileSize += (ulong)read;
+ if (filled == block.Length)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ FlushBlock();
+ if ((long)dataOffset - lastReport >= 16 << 20)
+ {
+ lastReport = (long)dataOffset;
+ progress?.Report(new Progress(lastReport, bytesTotal, index, files.Count));
+ }
+ }
+ }
+ }
+
+ directory.Children.Add(file);
+ }
+
+ if (filled > 0)
+ {
+ Array.Clear(block, filled, block.Length - filled);
+ FlushBlock();
+ }
+
+ var dataSize = (ulong)output.Position;
+ while (output.Position % 8 != 0)
+ {
+ output.WriteByte(0);
+ }
+
+ var sections = new (ulong Offset, ulong Size)[6];
+ sections[0] = (0, dataSize);
+ var start = (ulong)output.Position;
+ foreach (var (baseOffset, sizes) in records)
+ {
+ WriteU64(output, baseOffset);
+ foreach (var size in sizes)
+ {
+ WriteU16(output, size);
+ }
+ }
+
+ sections[1] = (start, (ulong)output.Position - start);
+
+ // Names in breadth-first order, as the reference writer interns them.
+ var ordered = new List();
+ var queue = new Queue();
+ queue.Enqueue(root);
+ var nextIndex = 1u;
+ while (queue.TryDequeue(out var node))
+ {
+ ordered.Add(node);
+ if (node.IsFile)
+ {
+ continue;
+ }
+
+ node.Children.Sort((left, right) => string.Compare(left.Name, right.Name, StringComparison.OrdinalIgnoreCase));
+ node.FirstChild = nextIndex;
+ nextIndex += (uint)node.Children.Count;
+ foreach (var child in node.Children)
+ {
+ queue.Enqueue(child);
+ }
+ }
+
+ start = (ulong)output.Position;
+ foreach (var node in ordered.Skip(1))
+ {
+ node.NameOffset = (uint)((ulong)output.Position - start);
+ var name = Encoding.UTF8.GetBytes(node.Name);
+ if (name.Length >= 0x8000)
+ {
+ throw new IOException($"The name '{node.Name}' is too long for an archive.");
+ }
+
+ if (name.Length >= 0x80)
+ {
+ output.WriteByte((byte)((name.Length & 0x7F) | 0x80));
+ output.WriteByte((byte)(name.Length >> 7));
+ }
+ else
+ {
+ output.WriteByte((byte)name.Length);
+ }
+
+ output.Write(name);
+ }
+
+ sections[2] = (start, (ulong)output.Position - start);
+ start = (ulong)output.Position;
+ foreach (var node in ordered)
+ {
+ var nameOffset = ReferenceEquals(node, root) ? 0x7FFFFFFFu : node.NameOffset;
+ WriteU32(output, nameOffset | (node.IsFile ? 0x80000000u : 0));
+ if (node.IsFile)
+ {
+ WriteU32(output, (uint)node.FileOffset);
+ WriteU32(output, (uint)node.FileSize);
+ WriteU32(output, (uint)((node.FileOffset >> 32) & 0xFFFF) | (uint)((node.FileSize >> 16) & 0xFFFF0000));
+ }
+ else
+ {
+ WriteU32(output, node.FirstChild);
+ WriteU32(output, (uint)node.Children.Count);
+ WriteU32(output, 0);
+ }
+ }
+
+ sections[3] = (start, (ulong)output.Position - start);
+ sections[4] = ((ulong)output.Position, 0);
+ sections[5] = ((ulong)output.Position, 0);
+ foreach (var (offset, size) in sections)
+ {
+ WriteU64(output, offset);
+ WriteU64(output, size);
+ }
+
+ output.Write(new byte[32]);
+ WriteU64(output, (ulong)output.Position + 8 + 4 + 4);
+ WriteU32(output, Version);
+ WriteU32(output, Magic);
+ progress?.Report(new Progress(bytesTotal, bytesTotal, files.Count, files.Count));
+ }
+
+ private static void WriteU16(Stream stream, ushort value)
+ {
+ Span bytes = stackalloc byte[2];
+ BinaryPrimitives.WriteUInt16BigEndian(bytes, value);
+ stream.Write(bytes);
+ }
+
+ private static void WriteU32(Stream stream, uint value)
+ {
+ Span bytes = stackalloc byte[4];
+ BinaryPrimitives.WriteUInt32BigEndian(bytes, value);
+ stream.Write(bytes);
+ }
+
+ private static void WriteU64(Stream stream, ulong value)
+ {
+ Span bytes = stackalloc byte[8];
+ BinaryPrimitives.WriteUInt64BigEndian(bytes, value);
+ stream.Write(bytes);
+ }
+}
diff --git a/src/SharpEmu.HLE/SharpEmu.HLE.csproj b/src/SharpEmu.HLE/SharpEmu.HLE.csproj
index 8932bea..105bf10 100644
--- a/src/SharpEmu.HLE/SharpEmu.HLE.csproj
+++ b/src/SharpEmu.HLE/SharpEmu.HLE.csproj
@@ -23,6 +23,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
+
diff --git a/src/SharpEmu.Libs/Ampr/AmprExports.cs b/src/SharpEmu.Libs/Ampr/AmprExports.cs
index 355762f..f04ae98 100644
--- a/src/SharpEmu.Libs/Ampr/AmprExports.cs
+++ b/src/SharpEmu.Libs/Ampr/AmprExports.cs
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
+using SharpEmu.HLE.FileSystem;
using SharpEmu.Libs.Agc;
using SharpEmu.Libs.Kernel;
using System.Buffers;
@@ -100,23 +101,40 @@ public static class AmprExports
public uint Flush;
}
+ // A host file, or a file of an archive-mounted app0 that is not on disk.
private sealed class CachedHostFile : IDisposable
{
+ private readonly SafeFileHandle? _handle;
+ private readonly ZArchiveFileStream? _archived;
+
public CachedHostFile(string path)
{
- Handle = File.OpenHandle(
+ if (!File.Exists(path) && App0Archive.OpenRead(path) is { } archived)
+ {
+ _archived = archived;
+ Length = archived.Length;
+ return;
+ }
+
+ _handle = File.OpenHandle(
path,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite | FileShare.Delete,
FileOptions.RandomAccess);
- Length = RandomAccess.GetLength(Handle);
+ Length = RandomAccess.GetLength(_handle);
}
- public SafeFileHandle Handle { get; }
public long Length { get; }
- public void Dispose() => Handle.Dispose();
+ public int Read(Span buffer, long offset) =>
+ _archived?.ReadAt(buffer, offset) ?? RandomAccess.Read(_handle!, buffer, offset);
+
+ public void Dispose()
+ {
+ _handle?.Dispose();
+ _archived?.Dispose();
+ }
}
private sealed class CachedHostFileEntry
@@ -1252,8 +1270,7 @@ public static class AmprExports
}
var request = (int)Math.Min((ulong)buffer.Length, size - bytesRead);
- var read = RandomAccess.Read(
- cachedFile.Handle,
+ var read = cachedFile.Read(
buffer.AsSpan(0, request),
unchecked((long)absoluteOffset));
diff --git a/src/SharpEmu.Libs/Ampr/AmprFileRegistry.cs b/src/SharpEmu.Libs/Ampr/AmprFileRegistry.cs
index 3e77db0..2e3fd51 100644
--- a/src/SharpEmu.Libs/Ampr/AmprFileRegistry.cs
+++ b/src/SharpEmu.Libs/Ampr/AmprFileRegistry.cs
@@ -1,6 +1,7 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
+using SharpEmu.HLE.FileSystem;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Runtime.CompilerServices;
@@ -269,20 +270,29 @@ internal static class AmprFileRegistry
var relatives = new List(256 * 1024);
try
{
- foreach (var hostPath in Directory.EnumerateFiles(
- normalizedRoot,
- "*",
- SearchOption.AllDirectories))
+ // An app0 mounted from an archive holds only a few files on disk; the archive lists them all.
+ if (App0Archive.Root is { } archiveRoot &&
+ string.Equals(archiveRoot, Path.TrimEndingDirectorySeparator(normalizedRoot), HostFsPath.Comparison))
{
- var relative = Path.GetRelativePath(normalizedRoot, hostPath)
- .Replace('\\', '/');
- if (string.IsNullOrEmpty(relative) ||
- relative.StartsWith("..", StringComparison.Ordinal))
+ relatives.AddRange(App0Archive.ListAllFiles());
+ }
+ else
+ {
+ foreach (var hostPath in Directory.EnumerateFiles(
+ normalizedRoot,
+ "*",
+ SearchOption.AllDirectories))
{
- continue;
- }
+ var relative = Path.GetRelativePath(normalizedRoot, hostPath)
+ .Replace('\\', '/');
+ if (string.IsNullOrEmpty(relative) ||
+ relative.StartsWith("..", StringComparison.Ordinal))
+ {
+ continue;
+ }
- relatives.Add(relative);
+ relatives.Add(relative);
+ }
}
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
diff --git a/src/SharpEmu.Libs/AvPlayer/AvPlayerExports.cs b/src/SharpEmu.Libs/AvPlayer/AvPlayerExports.cs
index dfb5d3d..4a331e7 100644
--- a/src/SharpEmu.Libs/AvPlayer/AvPlayerExports.cs
+++ b/src/SharpEmu.Libs/AvPlayer/AvPlayerExports.cs
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
+using SharpEmu.HLE.FileSystem;
using SharpEmu.Libs.Kernel;
using SharpEmu.Libs.Media;
using SharpEmu.Libs.VideoOut;
@@ -2592,6 +2593,8 @@ public static class AvPlayerExports
out string resolved)
{
resolved = string.Empty;
+ // A media file only in an archive-mounted app0 is extracted so the host decoder can open it.
+ App0Archive.Extract(Path.Combine(root, relativePath));
var current = root;
var segments = relativePath.Split(
Path.DirectorySeparatorChar,
diff --git a/src/SharpEmu.Libs/Kernel/KernelFileExtendedExports.cs b/src/SharpEmu.Libs/Kernel/KernelFileExtendedExports.cs
index 456df86..4fa781b 100644
--- a/src/SharpEmu.Libs/Kernel/KernelFileExtendedExports.cs
+++ b/src/SharpEmu.Libs/Kernel/KernelFileExtendedExports.cs
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
+using SharpEmu.HLE.FileSystem;
using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Threading;
@@ -34,7 +35,7 @@ public static partial class KernelMemoryCompatExports
private static long _nextAioSubmitId = 1;
private static readonly ConcurrentDictionary _aioResults = new();
- private static FileStream? GetOpenFile(int fd)
+ private static Stream? GetOpenFile(int fd)
{
lock (_fdGate)
{
@@ -42,6 +43,32 @@ public static partial class KernelMemoryCompatExports
}
}
+ // A descriptor is a host file or a read-only file served from a mounted archive.
+ private static int ReadOpenFileAt(Stream stream, Span buffer, long offset) => stream switch
+ {
+ FileStream file => RandomAccess.Read(file.SafeFileHandle, buffer, offset),
+ ZArchiveFileStream archived => archived.ReadAt(buffer, offset),
+ _ => throw new IOException("The descriptor does not support positional reads."),
+ };
+
+ private static void WriteOpenFileAt(Stream stream, ReadOnlySpan payload, long offset)
+ {
+ if (stream is not FileStream file)
+ {
+ throw new IOException("The descriptor is read-only.");
+ }
+
+ RandomAccess.Write(file.SafeFileHandle, payload, offset);
+ }
+
+ private static void FlushOpenFileToDisk(Stream stream)
+ {
+ if (stream is FileStream file)
+ {
+ file.Flush(flushToDisk: true);
+ }
+ }
+
// ---- Positional read/write (do not move the file offset) ----
[SysAbiExport(Nid = "ezv-RSBNKqI", ExportName = "pread",
@@ -79,7 +106,7 @@ public static partial class KernelMemoryCompatExports
int read;
try
{
- read = RandomAccess.Read(stream.SafeFileHandle, buffer, offset);
+ read = ReadOpenFileAt(stream, buffer, offset);
}
catch (IOException)
{
@@ -134,7 +161,7 @@ public static partial class KernelMemoryCompatExports
try
{
- RandomAccess.Write(stream.SafeFileHandle, payload, offset);
+ WriteOpenFileAt(stream, payload, offset);
}
catch (IOException)
{
@@ -180,7 +207,7 @@ public static partial class KernelMemoryCompatExports
try
{
- stream.Flush(flushToDisk: true);
+ FlushOpenFileToDisk(stream);
}
catch (IOException)
{
@@ -198,7 +225,7 @@ public static partial class KernelMemoryCompatExports
{
foreach (var stream in _openFiles.Values)
{
- try { stream.Flush(flushToDisk: true); } catch (IOException) { }
+ try { FlushOpenFileToDisk(stream); } catch (IOException) { }
}
}
@@ -597,11 +624,11 @@ public static partial class KernelMemoryCompatExports
return -1;
}
- RandomAccess.Write(stream.SafeFileHandle, scratch, offset);
+ WriteOpenFileAt(stream, scratch, offset);
return nbyte;
}
- var read = RandomAccess.Read(stream.SafeFileHandle, scratch, offset);
+ var read = ReadOpenFileAt(stream, scratch, offset);
if (read > 0 && !ctx.Memory.TryWrite(buf, scratch.AsSpan(0, read)))
{
return -1;
diff --git a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs
index c815660..02431b2 100644
--- a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs
+++ b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
+using SharpEmu.HLE.FileSystem;
using SharpEmu.HLE.GpuMemory;
using SharpEmu.Libs.Agc;
using SharpEmu.Libs.Ampr;
@@ -97,7 +98,7 @@ public static partial class KernelMemoryCompatExports
private const int KernelStatStBirthtimOffset = 104;
private static readonly object _fdGate = new();
- private static readonly Dictionary _openFiles = new();
+ private static readonly Dictionary _openFiles = new();
private static readonly HashSet _entropyDevices = new();
private static readonly Dictionary
_binkGuestCompletionShims = new();
@@ -1483,6 +1484,11 @@ public static partial class KernelMemoryCompatExports
}
try
{
+ if (access == FileAccess.Read && HostMovieBridge.IsSelfDecodedMovie(hostPath))
+ {
+ App0Archive.Extract(hostPath);
+ }
+
if (HostMovieBridge.ShouldSkipGuestMovie(hostPath))
{
LogOpenTrace(
@@ -1509,9 +1515,9 @@ public static partial class KernelMemoryCompatExports
}
var wantsDirectory = (flags & O_DIRECTORY) != 0;
- if (wantsDirectory || Directory.Exists(hostPath))
+ if (wantsDirectory || HostDirectoryExists(hostPath))
{
- if (!Directory.Exists(hostPath))
+ if (!HostDirectoryExists(hostPath))
{
LogOpenTrace($"_open miss path='{guestPath}' host='{hostPath}' flags=0x{flags:X8} directory=1");
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
@@ -1539,8 +1545,18 @@ public static partial class KernelMemoryCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
- EnsureOpenParentDirectoryExists(guestPath, hostPath, flags);
- var stream = new FileStream(hostPath, mode, access, GuestFileShare);
+ Stream stream;
+ if (access == FileAccess.Read && !IsMutatingOpen(flags) && !File.Exists(hostPath) &&
+ App0Archive.OpenRead(hostPath) is { } archived)
+ {
+ stream = archived;
+ }
+ else
+ {
+ EnsureOpenParentDirectoryExists(guestPath, hostPath, flags);
+ stream = new FileStream(hostPath, mode, access, GuestFileShare);
+ }
+
if ((flags & O_APPEND) != 0)
{
stream.Seek(0, SeekOrigin.End);
@@ -2271,7 +2287,7 @@ public static partial class KernelMemoryCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
- FileStream? stream;
+ Stream? stream;
var notifyBinkClose = false;
string? observedBinkPath = null;
lock (_fdGate)
@@ -2364,7 +2380,7 @@ public static partial class KernelMemoryCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
- FileStream? stream;
+ Stream? stream;
HostMovieBridge.BinkGuestCompletionShim completionShim = default;
var useBinkCompletionShim = false;
lock (_fdGate)
@@ -2382,7 +2398,7 @@ public static partial class KernelMemoryCompatExports
// still owns its Bink file descriptor. Return EOF so the guest closes
// that descriptor and proceeds to the next movie instead of waiting
// forever on a stale native decoder.
- if (HostMovieBridge.ShouldForceGuestMovieEof(stream.Name))
+ if (HostMovieBridge.ShouldForceGuestMovieEof(OpenFileName(stream)))
{
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
@@ -2412,7 +2428,7 @@ public static partial class KernelMemoryCompatExports
// logic can't race ahead of what's still on screen.
if (completionShim.Patch(positionBefore, buffer.AsSpan(0, read)))
{
- HostMovieBridge.WaitForHostPlaybackToFinish(stream.Name);
+ HostMovieBridge.WaitForHostPlaybackToFinish(OpenFileName(stream));
}
}
if (read > 0 && !ctx.Memory.TryWrite(bufferAddress, buffer.AsSpan(0, read)))
@@ -2432,7 +2448,7 @@ public static partial class KernelMemoryCompatExports
LogIoTrace(
"read",
- stream.Name,
+ OpenFileName(stream),
$"fd={fd} req={requested} read={read} pos={positionBefore}->{positionAfter} preview='{PreviewIoBytes(buffer, read, 64)}' hex={PreviewIoHex(buffer, read, 32)} guest_tail={PreviewGuestHex(ctx, bufferAddress + (ulong)Math.Max(read, 0), 32)}");
ctx[CpuRegister.Rax] = unchecked((ulong)read);
@@ -2548,7 +2564,7 @@ public static partial class KernelMemoryCompatExports
{
position = -1;
- FileStream? stream;
+ Stream? stream;
lock (_fdGate)
{
_openFiles.TryGetValue(fd, out stream);
@@ -2573,7 +2589,7 @@ public static partial class KernelMemoryCompatExports
origin = SeekOrigin.End;
break;
default:
- LogIoTrace("lseek", stream.Name, $"fd={fd} offset={offset} whence={whence} result=invalid_whence");
+ LogIoTrace("lseek", OpenFileName(stream), $"fd={fd} offset={offset} whence={whence} result=invalid_whence");
return OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
@@ -2583,16 +2599,16 @@ public static partial class KernelMemoryCompatExports
}
catch (IOException ex)
{
- LogIoTrace("lseek", stream.Name, $"fd={fd} offset={offset} whence={whence} result=io_error ex={ex.Message}");
+ LogIoTrace("lseek", OpenFileName(stream), $"fd={fd} offset={offset} whence={whence} result=io_error ex={ex.Message}");
return OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
catch (ArgumentException ex)
{
- LogIoTrace("lseek", stream.Name, $"fd={fd} offset={offset} whence={whence} result=invalid ex={ex.Message}");
+ LogIoTrace("lseek", OpenFileName(stream), $"fd={fd} offset={offset} whence={whence} result=invalid ex={ex.Message}");
return OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
- LogIoTrace("lseek", stream.Name, $"fd={fd} offset={offset} whence={whence} pos={position}");
+ LogIoTrace("lseek", OpenFileName(stream), $"fd={fd} offset={offset} whence={whence} pos={position}");
return OrbisGen2Result.ORBIS_GEN2_OK;
}
@@ -2652,7 +2668,7 @@ public static partial class KernelMemoryCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
- FileStream? stream;
+ Stream? stream;
lock (_fdGate)
{
_openFiles.TryGetValue(fd, out stream);
@@ -6996,7 +7012,7 @@ public static partial class KernelMemoryCompatExports
}
else if (_openFiles.TryGetValue(fd, out var stream))
{
- hostPath = stream.Name;
+ hostPath = OpenFileName(stream);
}
}
@@ -7007,7 +7023,9 @@ public static partial class KernelMemoryCompatExports
{
try
{
- size = new FileInfo(hostPath!).Length;
+ size = !File.Exists(hostPath) && App0Archive.TryGetFile(hostPath!, out var archivedSize)
+ ? (long)archivedSize
+ : new FileInfo(hostPath!).Length;
}
catch (IOException)
{
@@ -7023,8 +7041,8 @@ public static partial class KernelMemoryCompatExports
private static bool TryWriteHostPathStat(CpuContext ctx, ulong statAddress, string hostPath)
{
- var isDirectory = Directory.Exists(hostPath);
- if (!isDirectory && !File.Exists(hostPath))
+ var isDirectory = HostDirectoryExists(hostPath);
+ if (!isDirectory && !HostFileExists(hostPath))
{
return false;
}
@@ -7080,7 +7098,13 @@ public static partial class KernelMemoryCompatExports
return true;
}
- if (!new DirectoryInfo(cachePath).Exists)
+ if (App0Archive.TryGetFile(cachePath, out size))
+ {
+ _aprFileSizeCache.TryAdd(cachePath, size);
+ return true;
+ }
+
+ if (!new DirectoryInfo(cachePath).Exists && !App0Archive.IsDirectory(cachePath))
{
if (IsUnderApp0(cachePath))
{
@@ -7102,16 +7126,9 @@ public static partial class KernelMemoryCompatExports
private static bool TryWriteHostPathStat(CpuContext ctx, ulong statAddress, string hostPath, bool isDirectory)
{
- if (isDirectory)
+ if (isDirectory ? !Directory.Exists(hostPath) : !File.Exists(hostPath))
{
- if (!Directory.Exists(hostPath))
- {
- return false;
- }
- }
- else if (!File.Exists(hostPath))
- {
- return false;
+ return TryWriteArchivePathStat(ctx, statAddress, hostPath, isDirectory);
}
try
@@ -7128,6 +7145,33 @@ public static partial class KernelMemoryCompatExports
}
}
+ // A path under an app0 mounted from an archive that is not on disk takes its times from the archive.
+ private static bool TryWriteArchivePathStat(CpuContext ctx, ulong statAddress, string hostPath, bool isDirectory)
+ {
+ if (!App0Archive.TryResolve(hostPath, out var archive, out var node) ||
+ (isDirectory ? !archive.IsDirectory(node) : !archive.IsFile(node)))
+ {
+ return false;
+ }
+
+ var size = isDirectory ? 65536L : (long)archive.GetFileSize(node);
+ var archiveTime = File.GetLastWriteTimeUtc(archive.Path);
+ return TryWriteKernelStat(ctx, statAddress, isDirectory, size, archiveTime, archiveTime, archiveTime, hostPath);
+ }
+
+ private static bool HostFileExists(string hostPath) =>
+ File.Exists(hostPath) || App0Archive.TryGetFile(hostPath, out _);
+
+ private static bool HostDirectoryExists(string hostPath) =>
+ Directory.Exists(hostPath) || App0Archive.IsDirectory(hostPath);
+
+ private static string OpenFileName(Stream stream) => stream switch
+ {
+ FileStream file => file.Name,
+ ZArchiveFileStream archived => archived.Name,
+ _ => string.Empty,
+ };
+
private static bool TryWriteKernelStat(
CpuContext ctx,
ulong statAddress,
@@ -7236,7 +7280,7 @@ public static partial class KernelMemoryCompatExports
var record = payload.AsSpan(written, recordLength);
BinaryPrimitives.WriteUInt32LittleEndian(record, ComputeDirectoryEntryHash(entryBytes.AsSpan(0, nameLength)));
BinaryPrimitives.WriteUInt16LittleEndian(record[4..], unchecked((ushort)recordLength));
- record[6] = Directory.Exists(Path.Combine(directory.Path, entryName)) ? (byte)4 : (byte)8;
+ record[6] = HostDirectoryExists(Path.Combine(directory.Path, entryName)) ? (byte)4 : (byte)8;
record[7] = unchecked((byte)nameLength);
entryBytes.AsSpan(0, nameLength).CopyTo(record[8..]);
written += recordLength;
@@ -7259,9 +7303,14 @@ public static partial class KernelMemoryCompatExports
// host dir previously returned EOF on the first call (rax=0), which
// sent GTA's fiWriteAsyncDataWorker down a path that treated the fd as
// a pointer (AV at 0xB1 on /download0/cloudcache/).
- var children = Directory.EnumerateFileSystemEntries(hostPath)
- .Select(Path.GetFileName)
+ // Under an app0 mounted from an archive the listing is the union of the cache folder and the archive.
+ var onDisk = Directory.Exists(hostPath)
+ ? Directory.EnumerateFileSystemEntries(hostPath).Select(Path.GetFileName)
+ : [];
+ var children = onDisk
+ .Concat(App0Archive.List(hostPath).Select(static entry => entry.Name))
.Where(static name => !string.IsNullOrEmpty(name))
+ .Distinct(HostFsPath.Comparer)
.OrderBy(static name => name, StringComparer.OrdinalIgnoreCase);
return new[] { ".", ".." }.Concat(children).ToArray()!;
@@ -7948,7 +7997,7 @@ public static partial class KernelMemoryCompatExports
}
var hostPath = ResolveGuestPath(guestPath);
- if (File.Exists(hostPath) || Directory.Exists(hostPath))
+ if (HostFileExists(hostPath) || HostDirectoryExists(hostPath))
{
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
diff --git a/src/SharpEmu.Libs/LibcStdioExports.cs b/src/SharpEmu.Libs/LibcStdioExports.cs
index 31f979c..fd13b5d 100644
--- a/src/SharpEmu.Libs/LibcStdioExports.cs
+++ b/src/SharpEmu.Libs/LibcStdioExports.cs
@@ -6,6 +6,7 @@ using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
using SharpEmu.HLE;
+using SharpEmu.HLE.FileSystem;
using SharpEmu.Libs.Kernel;
namespace SharpEmu.Libs.LibcStdio;
@@ -30,10 +31,10 @@ public static class LibcStdioExports
private const int Einval = 22;
private const int Erofs = 30;
- private sealed class StdioFile(FileStream stream)
+ private sealed class StdioFile(Stream stream)
{
public object Gate { get; } = new();
- public FileStream Stream { get; set; } = stream;
+ public Stream Stream { get; set; } = stream;
public bool IsClosed { get; set; }
}
@@ -147,7 +148,7 @@ public static class LibcStdioExports
}
}
- var stream = new FileStream(hostPath, fileMode, fileAccess, KernelMemoryCompatExports.GuestFileShare);
+ var stream = OpenStdioStream(hostPath, fileMode, fileAccess);
if (mode.StartsWith('a') && fileAccess == FileAccess.ReadWrite)
{
stream.Seek(0, SeekOrigin.End);
@@ -1063,7 +1064,7 @@ public static class LibcStdioExports
}
}
- var replacement = new FileStream(hostPath, fileMode, fileAccess, KernelMemoryCompatExports.GuestFileShare);
+ var replacement = OpenStdioStream(hostPath, fileMode, fileAccess);
lock (file.Gate)
{
if (file.IsClosed)
@@ -1227,9 +1228,15 @@ public static class LibcStdioExports
return (int)diagnosticResult;
}
+ // A read-only open of a file that exists only in an archive-mounted app0 reads from the archive.
+ private static Stream OpenStdioStream(string hostPath, FileMode fileMode, FileAccess fileAccess) =>
+ fileAccess == FileAccess.Read && fileMode == FileMode.Open
+ ? App0Archive.OpenReadOrFile(hostPath, KernelMemoryCompatExports.GuestFileShare)
+ : new FileStream(hostPath, fileMode, fileAccess, KernelMemoryCompatExports.GuestFileShare);
+
private static void FlushForBufferingMode(
ulong handle,
- FileStream stream,
+ Stream stream,
ReadOnlySpan payload)
{
if (!_fileBufferingModes.TryGetValue(handle, out var mode))
diff --git a/src/SharpEmu.Libs/Media/HostMovieBridge.cs b/src/SharpEmu.Libs/Media/HostMovieBridge.cs
index 9873ee7..77355e8 100644
--- a/src/SharpEmu.Libs/Media/HostMovieBridge.cs
+++ b/src/SharpEmu.Libs/Media/HostMovieBridge.cs
@@ -24,7 +24,7 @@ internal static class HostMovieBridge
private static readonly string[] SelfDecodedMovieExtensions = [".bk2"];
- private static bool IsSelfDecodedMovie(string hostPath)
+ internal static bool IsSelfDecodedMovie(string hostPath)
{
foreach (var extension in SelfDecodedMovieExtensions)
{
diff --git a/src/SharpEmu.Libs/Ngs2/Ngs2Exports.cs b/src/SharpEmu.Libs/Ngs2/Ngs2Exports.cs
index 22b4cbb..43d17b2 100644
--- a/src/SharpEmu.Libs/Ngs2/Ngs2Exports.cs
+++ b/src/SharpEmu.Libs/Ngs2/Ngs2Exports.cs
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
+using SharpEmu.HLE.FileSystem;
using SharpEmu.Libs.Kernel;
using System.Buffers;
using System.Buffers.Binary;
@@ -979,7 +980,7 @@ public static class Ngs2Exports
long fileLength;
try
{
- using var stream = File.OpenRead(hostPath);
+ using var stream = App0Archive.OpenReadOrFile(hostPath);
fileLength = stream.Length;
if (offset >= (ulong)fileLength)
{
diff --git a/src/SharpEmu.Libs/PlayGo/PlayGoExports.cs b/src/SharpEmu.Libs/PlayGo/PlayGoExports.cs
index 0b16673..16f9291 100644
--- a/src/SharpEmu.Libs/PlayGo/PlayGoExports.cs
+++ b/src/SharpEmu.Libs/PlayGo/PlayGoExports.cs
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
+using SharpEmu.HLE.FileSystem;
using System.Buffers.Binary;
using System.Text.RegularExpressions;
using System.Xml.Linq;
@@ -808,6 +809,7 @@ public static class PlayGoExports
var playGoDat = Path.Combine(app0Root, "sce_sys", "playgo-chunk.dat");
var scenarioJson = Path.Combine(app0Root, "sce_sys", "playgo-scenario.json");
var chunkDefsXml = Path.Combine(app0Root, "playgo-chunkdefs.xml");
+ App0Archive.Extract(chunkDefsXml);
var pgmChunkIds = LoadPlayGoPgmChunkIds(playGoPgm);
if (pgmChunkIds.Length > 0)
@@ -913,7 +915,7 @@ public static class PlayGoExports
var ids = new SortedSet { 0 };
try
{
- foreach (var pakFile in Directory.EnumerateFiles(app0Root, "pakchunk*.pak", SearchOption.AllDirectories))
+ foreach (var pakFile in App0Archive.EnumerateFilesRecursive(app0Root, "pakchunk*.pak"))
{
var name = Path.GetFileNameWithoutExtension(pakFile);
var digits = name.AsSpan("pakchunk".Length);
diff --git a/tests/SharpEmu.Libs.Tests/FileSystem/App0ArchiveTests.cs b/tests/SharpEmu.Libs.Tests/FileSystem/App0ArchiveTests.cs
new file mode 100644
index 0000000..8798bef
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/FileSystem/App0ArchiveTests.cs
@@ -0,0 +1,151 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using SharpEmu.HLE.FileSystem;
+using Xunit;
+
+namespace SharpEmu.Libs.Tests.FileSystem;
+
+public sealed class App0ArchiveTests : IDisposable
+{
+ private readonly string _directory = Directory.CreateTempSubdirectory("sharpemu-app0-").FullName;
+ private readonly string _root;
+
+ public App0ArchiveTests()
+ {
+ var archivePath = Path.Combine(_directory, "game.zar");
+ File.WriteAllBytes(archivePath, ZArchiveTestWriter.Build(
+ [
+ ("eboot.bin", Bytes(100_000, 1)),
+ ("sce_sys/param.json", Bytes(500, 2)),
+ ("data/movies/intro.bk2", Bytes(80_000, 3)),
+ ("data/level.bin", Bytes(3_000, 4)),
+ ]));
+ _root = Path.Combine(_directory, "cache");
+ Directory.CreateDirectory(_root);
+ App0Archive.Mount(ZArchive.Open(archivePath)!, _root);
+ }
+
+ public void Dispose()
+ {
+ App0Archive.Unmount();
+ Directory.Delete(_directory, recursive: true);
+ }
+
+ private static byte[] Bytes(int length, int seed)
+ {
+ var bytes = new byte[length];
+ for (var index = 0; index < length; index++)
+ {
+ bytes[index] = (byte)(index * 13 + seed);
+ }
+
+ return bytes;
+ }
+
+ [Fact]
+ public void HostPathsUnderTheMountResolveInTheArchive()
+ {
+ Assert.True(App0Archive.TryGetFile(Path.Combine(_root, "DATA", "level.bin"), out var size));
+ Assert.Equal(3_000UL, size);
+ Assert.True(App0Archive.IsDirectory(Path.Combine(_root, "data", "movies")));
+ Assert.True(App0Archive.IsDirectory(_root));
+ Assert.False(App0Archive.Exists(Path.Combine(_root, "data", "missing.bin")));
+ Assert.False(App0Archive.Exists(Path.Combine(_directory, "data", "level.bin")));
+ Assert.False(App0Archive.Exists(_root + "-other" + Path.DirectorySeparatorChar + "eboot.bin"));
+ }
+
+ [Fact]
+ public void AStreamSeeksAndReadsLikeAFile()
+ {
+ using var stream = App0Archive.OpenRead(Path.Combine(_root, "data", "movies", "intro.bk2"))!;
+ var expected = Bytes(80_000, 3);
+
+ Assert.Equal(expected.Length, stream.Length);
+ stream.Seek(-1_000, SeekOrigin.End);
+ var tail = new byte[4_000];
+ Assert.Equal(1_000, stream.Read(tail));
+ Assert.Equal(expected.AsSpan(79_000).ToArray(), tail.AsSpan(0, 1_000).ToArray());
+ Assert.Equal(0, stream.Read(tail));
+
+ var window = new byte[10_000];
+ Assert.Equal(window.Length, stream.ReadAt(window, 60_000));
+ Assert.Equal(expected.AsSpan(60_000, window.Length).ToArray(), window);
+ Assert.Equal(expected.Length, stream.Position);
+ Assert.Throws(() => stream.Write(window, 0, 1));
+ }
+
+ [Fact]
+ public void ExtractionWritesOneFileAndListingsShowTheArchive()
+ {
+ var movie = Path.Combine(_root, "data", "movies", "intro.bk2");
+ Assert.True(App0Archive.Extract(movie));
+ Assert.Equal(Bytes(80_000, 3), File.ReadAllBytes(movie));
+ Assert.False(File.Exists(Path.Combine(_root, "data", "level.bin")));
+ Assert.False(App0Archive.Extract(Path.Combine(_root, "data", "missing.bin")));
+
+ App0Archive.ExtractDirectory("sce_sys");
+ Assert.Equal(Bytes(500, 2), File.ReadAllBytes(Path.Combine(_root, "sce_sys", "param.json")));
+
+ var names = App0Archive.List(_root).Select(entry => entry.Name).Order(StringComparer.OrdinalIgnoreCase);
+ Assert.Equal(["data", "eboot.bin", "sce_sys"], names);
+ }
+
+ [Fact]
+ public void RecursiveListingsJoinTheDiskAndTheArchive()
+ {
+ File.WriteAllBytes(Path.Combine(_root, "local.bk2"), [1]);
+
+ var movies = App0Archive.EnumerateFilesRecursive(_root, "*.bk2")
+ .Select(path => Path.GetRelativePath(_root, path).Replace('\\', '/'))
+ .Order(StringComparer.OrdinalIgnoreCase);
+
+ Assert.Equal(["data/movies/intro.bk2", "local.bk2"], movies);
+ Assert.Equal(
+ ["data/level.bin", "data/movies/intro.bk2", "eboot.bin", "sce_sys/param.json"],
+ App0Archive.ListAllFiles().Order(StringComparer.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void AReadOpenPrefersTheDiskAndFallsBackToTheArchive()
+ {
+ var level = Path.Combine(_root, "data", "level.bin");
+ using (var archived = App0Archive.OpenReadOrFile(level))
+ {
+ Assert.IsType(archived);
+ Assert.Equal(3_000, archived.Length);
+ }
+
+ Directory.CreateDirectory(Path.GetDirectoryName(level)!);
+ File.WriteAllBytes(level, [9, 9]);
+ using (var onDisk = App0Archive.OpenReadOrFile(level))
+ {
+ Assert.IsType(onDisk);
+ Assert.Equal(2, onDisk.Length);
+ }
+ }
+
+ [Fact]
+ public void TheCacheKeepsItsFilesUntilTheArchiveChanges()
+ {
+ var cacheParent = Path.Combine(_directory, "caches");
+ var archivePath = Path.Combine(_directory, "game.zar");
+
+ var cacheRoot = ZArchiveCache.ExtractMetadata(archivePath, cacheParent)!;
+ Assert.Equal(Path.Combine(cacheParent, "game"), cacheRoot);
+ Assert.Equal(Bytes(500, 2), File.ReadAllBytes(Path.Combine(cacheRoot, "sce_sys", "param.json")));
+
+ var kept = Path.Combine(cacheRoot, "kept.bin");
+ File.WriteAllBytes(kept, [1]);
+ ZArchiveCache.Prepare(archivePath, cacheParent);
+ Assert.True(File.Exists(kept));
+
+ File.SetLastWriteTimeUtc(archivePath, DateTime.UtcNow.AddMinutes(5));
+ ZArchiveCache.Prepare(archivePath, cacheParent);
+ Assert.False(File.Exists(kept));
+
+ var plain = Path.Combine(_directory, "plain.zar");
+ File.WriteAllBytes(plain, Bytes(4096, 5));
+ Assert.Null(ZArchiveCache.ExtractMetadata(plain, cacheParent));
+ }
+}
diff --git a/tests/SharpEmu.Libs.Tests/FileSystem/ZArchiveTestWriter.cs b/tests/SharpEmu.Libs.Tests/FileSystem/ZArchiveTestWriter.cs
new file mode 100644
index 0000000..e2a53ed
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/FileSystem/ZArchiveTestWriter.cs
@@ -0,0 +1,203 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Buffers.Binary;
+using System.Text;
+using ZstdSharp;
+
+namespace SharpEmu.Libs.Tests.FileSystem;
+
+// Builds .zar archives the way the ZArchive writer lays them out: file data appended in order and
+// cut into 64 KiB blocks (the last padded with zeros, a block that does not shrink stored raw), the
+// data section padded to 8 bytes, then offset records, names, a breadth-first file tree with each
+// directory's children sorted without case, empty metadata and the footer.
+internal static class ZArchiveTestWriter
+{
+ private const int BlockSize = 64 * 1024;
+
+ private sealed class PathNode(string name, bool isFile)
+ {
+ public string Name { get; } = name;
+ public bool IsFile { get; } = isFile;
+ public List Children { get; } = [];
+ public ulong FileOffset { get; set; }
+ public ulong FileSize { get; set; }
+ public uint NameOffset { get; set; }
+ public uint FirstChild { get; set; }
+ }
+
+ public static byte[] Build(IEnumerable<(string Path, byte[] Data)> files)
+ {
+ var root = new PathNode(string.Empty, isFile: false);
+ var data = new MemoryStream();
+ foreach (var (path, bytes) in files)
+ {
+ var parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
+ var directory = root;
+ foreach (var part in parts[..^1])
+ {
+ var next = directory.Children.Find(child => string.Equals(child.Name, part, StringComparison.OrdinalIgnoreCase));
+ if (next is null)
+ {
+ next = new PathNode(part, isFile: false);
+ directory.Children.Add(next);
+ }
+
+ directory = next;
+ }
+
+ directory.Children.Add(new PathNode(parts[^1], isFile: true) { FileOffset = (ulong)data.Length, FileSize = (ulong)bytes.Length });
+ data.Write(bytes);
+ }
+
+ if (data.Length % BlockSize != 0)
+ {
+ data.Write(new byte[BlockSize - data.Length % BlockSize]);
+ }
+
+ var output = new MemoryStream();
+ var records = new List<(ulong BaseOffset, ushort[] Sizes)>();
+ using var compressor = new Compressor(6);
+ var raw = data.ToArray();
+ for (var block = 0; block < raw.Length / BlockSize; block++)
+ {
+ var input = raw.AsSpan(block * BlockSize, BlockSize);
+ var compressed = compressor.Wrap(input).ToArray();
+ var offset = (ulong)output.Length;
+ int size;
+ if (compressed.Length >= BlockSize)
+ {
+ output.Write(input);
+ size = BlockSize;
+ }
+ else
+ {
+ output.Write(compressed);
+ size = compressed.Length;
+ }
+
+ if (block % 16 == 0)
+ {
+ records.Add((offset, new ushort[16]));
+ }
+
+ records[^1].Sizes[block % 16] = (ushort)(size - 1);
+ }
+
+ var dataSize = (ulong)output.Length;
+ while (output.Length % 8 != 0)
+ {
+ output.WriteByte(0);
+ }
+
+ var sections = new (ulong Offset, ulong Size)[6];
+ sections[0] = (0, dataSize);
+ var start = (ulong)output.Length;
+ foreach (var (baseOffset, sizes) in records)
+ {
+ WriteU64(output, baseOffset);
+ foreach (var size in sizes)
+ {
+ WriteU16(output, size);
+ }
+ }
+
+ sections[1] = (start, (ulong)output.Length - start);
+
+ // Names in breadth-first order, as the writer interns them.
+ var ordered = new List();
+ var queue = new Queue();
+ queue.Enqueue(root);
+ var nextIndex = 1u;
+ while (queue.TryDequeue(out var node))
+ {
+ ordered.Add(node);
+ if (node.IsFile)
+ {
+ continue;
+ }
+
+ node.Children.Sort((left, right) => string.Compare(left.Name, right.Name, StringComparison.OrdinalIgnoreCase));
+ node.FirstChild = nextIndex;
+ nextIndex += (uint)node.Children.Count;
+ foreach (var child in node.Children)
+ {
+ queue.Enqueue(child);
+ }
+ }
+
+ start = (ulong)output.Length;
+ foreach (var node in ordered.Skip(1))
+ {
+ node.NameOffset = (uint)((ulong)output.Length - start);
+ var name = Encoding.UTF8.GetBytes(node.Name);
+ if (name.Length >= 0x80)
+ {
+ output.WriteByte((byte)((name.Length & 0x7F) | 0x80));
+ output.WriteByte((byte)(name.Length >> 7));
+ }
+ else
+ {
+ output.WriteByte((byte)name.Length);
+ }
+
+ output.Write(name);
+ }
+
+ sections[2] = (start, (ulong)output.Length - start);
+ start = (ulong)output.Length;
+ foreach (var node in ordered)
+ {
+ var nameOffset = ReferenceEquals(node, root) ? 0x7FFFFFFFu : node.NameOffset;
+ WriteU32(output, nameOffset | (node.IsFile ? 0x80000000u : 0));
+ if (node.IsFile)
+ {
+ WriteU32(output, (uint)node.FileOffset);
+ WriteU32(output, (uint)node.FileSize);
+ WriteU32(output, (uint)((node.FileOffset >> 32) & 0xFFFF) | (uint)((node.FileSize >> 16) & 0xFFFF0000));
+ }
+ else
+ {
+ WriteU32(output, node.FirstChild);
+ WriteU32(output, (uint)node.Children.Count);
+ WriteU32(output, 0);
+ }
+ }
+
+ sections[3] = (start, (ulong)output.Length - start);
+ sections[4] = ((ulong)output.Length, 0);
+ sections[5] = ((ulong)output.Length, 0);
+ foreach (var (offset, size) in sections)
+ {
+ WriteU64(output, offset);
+ WriteU64(output, size);
+ }
+
+ output.Write(new byte[32]);
+ WriteU64(output, (ulong)output.Length + 8 + 4 + 4);
+ WriteU32(output, 0x61BF3A01);
+ WriteU32(output, 0x169F52D6);
+ return output.ToArray();
+ }
+
+ private static void WriteU16(Stream stream, ushort value)
+ {
+ Span bytes = stackalloc byte[2];
+ BinaryPrimitives.WriteUInt16BigEndian(bytes, value);
+ stream.Write(bytes);
+ }
+
+ private static void WriteU32(Stream stream, uint value)
+ {
+ Span bytes = stackalloc byte[4];
+ BinaryPrimitives.WriteUInt32BigEndian(bytes, value);
+ stream.Write(bytes);
+ }
+
+ private static void WriteU64(Stream stream, ulong value)
+ {
+ Span bytes = stackalloc byte[8];
+ BinaryPrimitives.WriteUInt64BigEndian(bytes, value);
+ stream.Write(bytes);
+ }
+}
diff --git a/tests/SharpEmu.Libs.Tests/FileSystem/ZArchiveTests.cs b/tests/SharpEmu.Libs.Tests/FileSystem/ZArchiveTests.cs
new file mode 100644
index 0000000..c09feff
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/FileSystem/ZArchiveTests.cs
@@ -0,0 +1,116 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using SharpEmu.HLE.FileSystem;
+using Xunit;
+
+namespace SharpEmu.Libs.Tests.FileSystem;
+
+public sealed class ZArchiveTests : IDisposable
+{
+ private readonly string _directory = Directory.CreateTempSubdirectory("sharpemu-zar-").FullName;
+
+ public void Dispose() => Directory.Delete(_directory, recursive: true);
+
+ private string Write(params (string Path, byte[] Data)[] files)
+ {
+ var path = Path.Combine(_directory, $"{Guid.NewGuid():N}.zar");
+ File.WriteAllBytes(path, ZArchiveTestWriter.Build(files));
+ return path;
+ }
+
+ private static byte[] Pattern(int length, int seed)
+ {
+ var bytes = new byte[length];
+ for (var index = 0; index < length; index++)
+ {
+ bytes[index] = (byte)((index * 31 + seed) ^ (index >> 9));
+ }
+
+ return bytes;
+ }
+
+ [Fact]
+ public void FilesReadBackAcrossBlocksAndDirectories()
+ {
+ var eboot = Pattern(200_000, 1);
+ var param = Pattern(1_234, 2);
+ var data = Pattern(70_000, 3);
+ using var archive = ZArchive.Open(Write(("eboot.bin", eboot), ("sce_sys/param.json", param), ("data/level/a.bin", data)))!;
+
+ Assert.NotNull(archive);
+ foreach (var (path, expected) in new[] { ("eboot.bin", eboot), ("sce_sys/param.json", param), ("data/level/a.bin", data) })
+ {
+ var node = archive.LookUp(path);
+ Assert.True(archive.IsFile(node));
+ Assert.Equal((ulong)expected.Length, archive.GetFileSize(node));
+ var actual = new byte[expected.Length];
+ Assert.Equal(expected.Length, archive.Read(node, 0, actual));
+ Assert.Equal(expected, actual);
+ }
+ }
+
+ [Fact]
+ public void AReadAtAnOffsetSpansBlocksAndStopsAtTheEnd()
+ {
+ var bytes = Pattern(150_000, 7);
+ using var archive = ZArchive.Open(Write(("big.bin", bytes)))!;
+ var node = archive.LookUp("big.bin");
+
+ var window = new byte[20_000];
+ Assert.Equal(window.Length, archive.Read(node, 60_000, window));
+ Assert.Equal(bytes.AsSpan(60_000, window.Length).ToArray(), window);
+
+ var tail = new byte[1000];
+ Assert.Equal(100, archive.Read(node, 149_900, tail));
+ Assert.Equal(bytes.AsSpan(149_900, 100).ToArray(), tail.AsSpan(0, 100).ToArray());
+ Assert.Equal(0, archive.Read(node, 150_000, tail));
+ }
+
+ [Fact]
+ public void LookupIgnoresCaseAndAcceptsEitherSeparator()
+ {
+ using var archive = ZArchive.Open(Write(("sce_sys/Param.JSON", [1, 2, 3])))!;
+
+ Assert.True(archive.IsFile(archive.LookUp("SCE_SYS\\param.json")));
+ Assert.True(archive.IsDirectory(archive.LookUp("/sce_sys/")));
+ Assert.True(archive.IsDirectory(archive.LookUp(string.Empty)));
+ Assert.Equal(ZArchive.InvalidNode, archive.LookUp("sce_sys/missing.json"));
+ Assert.Equal(ZArchive.InvalidNode, archive.LookUp("sce_sys/param.json/inner"));
+ }
+
+ [Fact]
+ public void DirectoriesListTheirEntries()
+ {
+ using var archive = ZArchive.Open(Write(("dir/b.bin", [1]), ("dir/a.bin", [1, 2]), ("dir/sub/c.bin", [3])))!;
+
+ var entries = archive.GetEntries(archive.LookUp("dir")).OrderBy(entry => entry.Name).ToArray();
+
+ Assert.Equal(["a.bin", "b.bin", "sub"], entries.Select(entry => entry.Name));
+ Assert.Equal(2UL, entries[0].Size);
+ Assert.True(entries[2].IsDirectory);
+ }
+
+ [Fact]
+ public void LongNamesAndIncompressibleBlocksReadBack()
+ {
+ var name = new string('n', 300) + ".bin";
+ var random = new byte[100_000];
+ new System.Random(5).NextBytes(random);
+ using var archive = ZArchive.Open(Write((name, random)))!;
+
+ var node = archive.LookUp(name);
+ var actual = new byte[random.Length];
+ Assert.Equal(random.Length, archive.Read(node, 0, actual));
+ Assert.Equal(random, actual);
+ }
+
+ [Fact]
+ public void AFileThatIsNotAnArchiveDoesNotOpen()
+ {
+ var path = Path.Combine(_directory, "plain.zar");
+ File.WriteAllBytes(path, Pattern(4096, 9));
+
+ Assert.Null(ZArchive.Open(path));
+ }
+}
diff --git a/tests/SharpEmu.Libs.Tests/FileSystem/ZArchiveWriterTests.cs b/tests/SharpEmu.Libs.Tests/FileSystem/ZArchiveWriterTests.cs
new file mode 100644
index 0000000..e79781a
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/FileSystem/ZArchiveWriterTests.cs
@@ -0,0 +1,108 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using SharpEmu.HLE.FileSystem;
+using Xunit;
+
+namespace SharpEmu.Libs.Tests.FileSystem;
+
+public sealed class ZArchiveWriterTests : IDisposable
+{
+ private readonly string _directory = Directory.CreateTempSubdirectory("sharpemu-zarpack-").FullName;
+ private readonly string _game;
+
+ public ZArchiveWriterTests()
+ {
+ _game = Path.Combine(_directory, "game");
+ Put("eboot.bin", Bytes(150_000, 1));
+ Put(Path.Combine("sce_sys", "param.json"), Bytes(700, 2));
+ Put(Path.Combine("data", "Level", "a.bin"), Bytes(0, 3));
+ Put(Path.Combine("data", "Level", "b.bin"), Bytes(200_000, 4));
+ Put(Path.Combine("data", new string('n', 200) + ".bin"), Bytes(10, 5));
+ }
+
+ public void Dispose() => Directory.Delete(_directory, recursive: true);
+
+ private void Put(string relative, byte[] data)
+ {
+ var path = Path.Combine(_game, relative);
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ File.WriteAllBytes(path, data);
+ }
+
+ private static byte[] Bytes(int length, int seed)
+ {
+ var bytes = new byte[length];
+ new System.Random(seed).NextBytes(bytes.AsSpan(0, length / 2));
+ for (var index = length / 2; index < length; index++)
+ {
+ bytes[index] = (byte)(index % 7);
+ }
+
+ return bytes;
+ }
+
+ [Fact]
+ public void AFolderReadsBackFromItsArchive()
+ {
+ var archivePath = Path.Combine(_directory, "game.zar");
+ var reports = new List();
+ ZArchiveWriter.Pack(_game, archivePath, new SynchronousProgress(reports.Add));
+
+ using var archive = ZArchive.Open(archivePath)!;
+ Assert.NotNull(archive);
+ foreach (var path in Directory.EnumerateFiles(_game, "*", SearchOption.AllDirectories))
+ {
+ var expected = File.ReadAllBytes(path);
+ var node = archive.LookUp(Path.GetRelativePath(_game, path));
+ Assert.True(archive.IsFile(node));
+ var actual = new byte[expected.Length];
+ Assert.Equal(expected.Length, archive.Read(node, 0, actual));
+ Assert.Equal(expected, actual);
+ }
+
+ Assert.Equal(["data", "eboot.bin", "sce_sys"], archive.GetEntries(archive.LookUp(string.Empty)).Select(e => e.Name).Order());
+ Assert.Equal(reports[^1].BytesTotal, reports[^1].BytesDone);
+ Assert.Equal(5, reports[^1].FilesTotal);
+ }
+
+ [Fact]
+ public void ACancelledPackLeavesNoArchive()
+ {
+ var archivePath = Path.Combine(_directory, "game.zar");
+ using var cancelled = new CancellationTokenSource();
+ cancelled.Cancel();
+
+ Assert.Throws(() => ZArchiveWriter.Pack(_game, archivePath, cancellationToken: cancelled.Token));
+ Assert.Empty(Directory.EnumerateFiles(_directory));
+ }
+
+ [Fact]
+ public void AnArchiveMatchesItsFolderUntilTheFolderChanges()
+ {
+ var archivePath = Path.Combine(_directory, "game.zar");
+ ZArchiveWriter.Pack(_game, archivePath);
+ Assert.True(ZArchiveWriter.MatchesFolder(archivePath, _game));
+
+ Put("added.bin", Bytes(5, 6));
+ Assert.False(ZArchiveWriter.MatchesFolder(archivePath, _game));
+
+ File.Delete(Path.Combine(_game, "added.bin"));
+ Put("eboot.bin", Bytes(150_001, 1));
+ Assert.False(ZArchiveWriter.MatchesFolder(archivePath, _game));
+
+ File.WriteAllBytes(archivePath, Bytes(4096, 7));
+ Assert.False(ZArchiveWriter.MatchesFolder(archivePath, _game));
+ }
+
+ [Fact]
+ public void AnArchiveCannotBeWrittenIntoTheFolderItPacks()
+ {
+ Assert.Throws(() => ZArchiveWriter.Pack(_game, Path.Combine(_game, "game.zar")));
+ }
+
+ private sealed class SynchronousProgress(Action report) : IProgress
+ {
+ public void Report(ZArchiveWriter.Progress value) => report(value);
+ }
+}
diff --git a/tests/SharpEmu.Libs.Tests/GUI/LibrarySelectionTests.cs b/tests/SharpEmu.Libs.Tests/GUI/LibrarySelectionTests.cs
index 1e28d01..3896d07 100644
--- a/tests/SharpEmu.Libs.Tests/GUI/LibrarySelectionTests.cs
+++ b/tests/SharpEmu.Libs.Tests/GUI/LibrarySelectionTests.cs
@@ -40,6 +40,19 @@ public sealed class LibrarySelectionTests
Assert.Same(first, selected);
}
+ [Theory]
+ [InlineData("Hades", "PPSA03355", "01.003.000", "PPSA03355-1.003")]
+ [InlineData("Hades", "PPSA03355", "01.004.002", "PPSA03355-1.004.002")]
+ [InlineData("Hades", "PPSA03355", "10.000.000", "PPSA03355-10.000")]
+ [InlineData("Hades", "PPSA03355", null, "PPSA03355")]
+ [InlineData("Hades", null, "01.003.000", "Hades-1.003")]
+ [InlineData("Hades", " ", " ", "Hades")]
+ [InlineData("Game", "PPSA00001", "1.0/beta", "PPSA00001-1.0_beta")]
+ public void SuggestedArchiveName_IsTitleIdAndVersion(string folder, string? titleId, string? version, string expected)
+ {
+ Assert.Equal(expected, MainWindow.SuggestedArchiveName(folder, titleId, version));
+ }
+
private static GameEntry CreateGame(string name) =>
new(name, null, null, $"/games/{name}/eboot.bin", 0, null, null);
}