From ae406fc04909beaa0816ac8cb9f0192b2e460ce1 Mon Sep 17 00:00:00 2001
From: Mathias <06mathias6@gmail.com>
Date: Mon, 12 Oct 2026 18:26:51 +0200
Subject: [PATCH] Run and pack ZArchive (.zar) game archives (#1085)
* feat: run and pack ZArchive (.zar) game archives
* feat(gui): optionally delete the game folder after packing a .zar
* feat(gui): name a packed .zar after the game version
* feat(gui): explain packing in the .zar dialog
* feat(gui): name a packed .zar by title id and version
---
Directory.Packages.props | 1 +
src/SharpEmu.CLI/Program.cs | 7 +-
src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs | 69 ++++
src/SharpEmu.GUI/Languages/en.json | 22 +
src/SharpEmu.GUI/MainWindow.axaml | 7 +
src/SharpEmu.GUI/MainWindow.axaml.cs | 143 ++++++-
src/SharpEmu.GUI/SndPreviewPlayer.cs | 43 +-
src/SharpEmu.GUI/ZarPackDialog.axaml | 61 +++
src/SharpEmu.GUI/ZarPackDialog.axaml.cs | 152 +++++++
src/SharpEmu.HLE/FileSystem/App0Archive.cs | 303 ++++++++++++++
src/SharpEmu.HLE/FileSystem/ZArchive.cs | 390 ++++++++++++++++++
src/SharpEmu.HLE/FileSystem/ZArchiveCache.cs | 79 ++++
src/SharpEmu.HLE/FileSystem/ZArchiveWriter.cs | 312 ++++++++++++++
src/SharpEmu.HLE/SharpEmu.HLE.csproj | 1 +
src/SharpEmu.Libs/Ampr/AmprExports.cs | 29 +-
src/SharpEmu.Libs/Ampr/AmprFileRegistry.cs | 32 +-
src/SharpEmu.Libs/AvPlayer/AvPlayerExports.cs | 3 +
.../Kernel/KernelFileExtendedExports.cs | 41 +-
.../Kernel/KernelMemoryCompatExports.cs | 117 ++++--
src/SharpEmu.Libs/LibcStdioExports.cs | 17 +-
src/SharpEmu.Libs/Media/HostMovieBridge.cs | 2 +-
src/SharpEmu.Libs/Ngs2/Ngs2Exports.cs | 3 +-
src/SharpEmu.Libs/PlayGo/PlayGoExports.cs | 4 +-
.../FileSystem/App0ArchiveTests.cs | 151 +++++++
.../FileSystem/ZArchiveTestWriter.cs | 203 +++++++++
.../FileSystem/ZArchiveTests.cs | 116 ++++++
.../FileSystem/ZArchiveWriterTests.cs | 108 +++++
.../GUI/LibrarySelectionTests.cs | 13 +
28 files changed, 2354 insertions(+), 75 deletions(-)
create mode 100644 src/SharpEmu.GUI/ZarPackDialog.axaml
create mode 100644 src/SharpEmu.GUI/ZarPackDialog.axaml.cs
create mode 100644 src/SharpEmu.HLE/FileSystem/App0Archive.cs
create mode 100644 src/SharpEmu.HLE/FileSystem/ZArchive.cs
create mode 100644 src/SharpEmu.HLE/FileSystem/ZArchiveCache.cs
create mode 100644 src/SharpEmu.HLE/FileSystem/ZArchiveWriter.cs
create mode 100644 tests/SharpEmu.Libs.Tests/FileSystem/App0ArchiveTests.cs
create mode 100644 tests/SharpEmu.Libs.Tests/FileSystem/ZArchiveTestWriter.cs
create mode 100644 tests/SharpEmu.Libs.Tests/FileSystem/ZArchiveTests.cs
create mode 100644 tests/SharpEmu.Libs.Tests/FileSystem/ZArchiveWriterTests.cs
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" />
+