diff --git a/NHSE.Core/Save/Files/MainSave.cs b/NHSE.Core/Save/Files/MainSave.cs
index 05919e6..8ff08f3 100644
--- a/NHSE.Core/Save/Files/MainSave.cs
+++ b/NHSE.Core/Save/Files/MainSave.cs
@@ -11,7 +11,7 @@ namespace NHSE.Core;
public sealed class MainSave : EncryptedFilePair
{
public readonly MainSaveOffsets Offsets;
- public MainSave(string folder) : base(folder, "main") => Offsets = MainSaveOffsets.GetOffsets(Info);
+ public MainSave(ISaveFileProvider provider) : base(provider, "main") => Offsets = MainSaveOffsets.GetOffsets(Info);
public Hemisphere Hemisphere { get => (Hemisphere)Data[Offsets.WeatherArea]; set => Data[Offsets.WeatherArea] = (byte)value; }
public AirportColor AirportThemeColor { get => (AirportColor)Data[Offsets.AirportThemeColor]; set => Data[Offsets.AirportThemeColor] = (byte)value; }
diff --git a/NHSE.Core/Save/Files/Personal.cs b/NHSE.Core/Save/Files/Personal.cs
index 6026cca..2017c4d 100644
--- a/NHSE.Core/Save/Files/Personal.cs
+++ b/NHSE.Core/Save/Files/Personal.cs
@@ -11,7 +11,7 @@ namespace NHSE.Core;
public sealed class Personal : EncryptedFilePair, IVillagerOrigin
{
public readonly PersonalOffsets Offsets;
- public Personal(string folder) : base(folder, "personal") => Offsets = PersonalOffsets.GetOffsets(Info);
+ public Personal(ISaveFileProvider provider) : base(provider, "personal") => Offsets = PersonalOffsets.GetOffsets(Info);
public override string ToString() => PlayerName;
public uint TownID
diff --git a/NHSE.Core/Save/Files/PhotoStudioIsland.cs b/NHSE.Core/Save/Files/PhotoStudioIsland.cs
index 988b7bb..7bd0f8e 100644
--- a/NHSE.Core/Save/Files/PhotoStudioIsland.cs
+++ b/NHSE.Core/Save/Files/PhotoStudioIsland.cs
@@ -3,7 +3,4 @@
///
/// photo_studio_island.dat
///
-public sealed class PhotoStudioIsland : EncryptedFilePair
-{
- public PhotoStudioIsland(string folder) : base(folder, "photo_studio_island") { }
-}
\ No newline at end of file
+public sealed class PhotoStudioIsland(ISaveFileProvider provider) : EncryptedFilePair(provider, "photo_studio_island");
\ No newline at end of file
diff --git a/NHSE.Core/Save/Files/PostBox.cs b/NHSE.Core/Save/Files/PostBox.cs
index 7dc5f74..96ef7ee 100644
--- a/NHSE.Core/Save/Files/PostBox.cs
+++ b/NHSE.Core/Save/Files/PostBox.cs
@@ -3,7 +3,4 @@
///
/// postbox.dat
///
-public sealed class PostBox : EncryptedFilePair
-{
- public PostBox(string folder) : base(folder, "postbox") { }
-}
\ No newline at end of file
+public sealed class PostBox(ISaveFileProvider provider) : EncryptedFilePair(provider, "postbox");
\ No newline at end of file
diff --git a/NHSE.Core/Save/Files/Profile.cs b/NHSE.Core/Save/Files/Profile.cs
index 027ca32..7e92cd1 100644
--- a/NHSE.Core/Save/Files/Profile.cs
+++ b/NHSE.Core/Save/Files/Profile.cs
@@ -3,9 +3,7 @@
///
/// profile.dat
///
-public sealed class Profile : EncryptedFilePair
-{
- public Profile(string folder) : base(folder, "profile") { }
-
- // pretty much just a jpeg -- which is also stored in Personal.
-}
\ No newline at end of file
+///
+/// pretty much just a jpeg -- which is also stored in Personal.
+///
+public sealed class Profile(ISaveFileProvider provider) : EncryptedFilePair(provider, "profile");
\ No newline at end of file
diff --git a/NHSE.Core/Save/Files/WhereAreN.cs b/NHSE.Core/Save/Files/WhereAreN.cs
index 1542111..085daeb 100644
--- a/NHSE.Core/Save/Files/WhereAreN.cs
+++ b/NHSE.Core/Save/Files/WhereAreN.cs
@@ -1,13 +1,14 @@
namespace NHSE.Core;
///
+/// Data structures stored for the HappyHomeDesigner DLC.
///
public sealed class WhereAreN : EncryptedFilePair
{
public const string FileName = "wherearen";
public readonly WhereAreNOffsets Offsets;
- public WhereAreN(string folder) : base(folder, FileName) => Offsets = WhereAreNOffsets.GetOffsets(Info);
+ public WhereAreN(ISaveFileProvider provider) : base(provider, FileName) => Offsets = WhereAreNOffsets.GetOffsets(Info);
public EncryptedInt32 Poki
{
diff --git a/NHSE.Core/Save/Meta/EncryptedFilePair.cs b/NHSE.Core/Save/Meta/EncryptedFilePair.cs
index 2ab3876..14f875a 100644
--- a/NHSE.Core/Save/Meta/EncryptedFilePair.cs
+++ b/NHSE.Core/Save/Meta/EncryptedFilePair.cs
@@ -1,17 +1,17 @@
using System;
using System.Collections.Generic;
-using System.IO;
using static System.Buffers.Binary.BinaryPrimitives;
namespace NHSE.Core;
///
-/// Represents two files -- and and their decrypted data.
+/// Represents two files -- and and their decrypted data.
///
public abstract class EncryptedFilePair
{
private readonly byte[] RawData;
private readonly byte[] RawHeader;
+ private readonly ISaveFileProvider Provider;
protected Memory Raw => RawData;
public Span Data => RawData;
@@ -19,36 +19,32 @@ public abstract class EncryptedFilePair
public readonly FileHeaderInfo Info;
- public readonly string DataPath;
- public readonly string HeaderPath;
public readonly string NameData;
public readonly string NameHeader;
- public static bool Exists(string folder, string name)
+ ///
+ /// Checks if the file pair exists in the specified provider.
+ ///
+ public static bool Exists(ISaveFileProvider provider, string name)
{
- var NameData = $"{name}.dat";
- var NameHeader = $"{name}Header.dat";
- var hdr = Path.Combine(folder, NameHeader);
- var dat = Path.Combine(folder, NameData);
- return File.Exists(hdr) && File.Exists(dat);
+ var nameData = $"{name}.dat";
+ var nameHeader = $"{name}Header.dat";
+ return provider.FileExists(nameHeader) && provider.FileExists(nameData);
}
- protected EncryptedFilePair(string folder, string name)
+ protected EncryptedFilePair(ISaveFileProvider provider, string name)
{
+ Provider = provider;
NameData = $"{name}.dat";
NameHeader = $"{name}Header.dat";
- var hdr = Path.Combine(folder, NameHeader);
- var dat = Path.Combine(folder, NameData);
- var hd = File.ReadAllBytes(hdr);
- var md = File.ReadAllBytes(dat);
+ var hd = provider.ReadFile(NameHeader);
+ var md = provider.ReadFile(NameData);
Encryption.Decrypt(hd, md);
RawHeader = hd;
RawData = md;
- DataPath = dat;
- HeaderPath = hdr;
Info = RawHeader[..FileHeaderInfo.SIZE].ToClass();
}
@@ -56,10 +52,11 @@ protected EncryptedFilePair(string folder, string name)
public void Save(uint seed)
{
var encrypt = Encryption.Encrypt(RawData, seed, RawHeader);
- File.WriteAllBytes(DataPath, encrypt.Data.Span);
- File.WriteAllBytes(HeaderPath, encrypt.Header.Span);
+ Provider.WriteFile(NameData, encrypt.Data.Span);
+ Provider.WriteFile(NameHeader, encrypt.Header.Span);
}
+
///
/// Updates all hashes of .
///
diff --git a/NHSE.Core/Save/Meta/FolderSaveFileProvider.cs b/NHSE.Core/Save/Meta/FolderSaveFileProvider.cs
new file mode 100644
index 0000000..91b3893
--- /dev/null
+++ b/NHSE.Core/Save/Meta/FolderSaveFileProvider.cs
@@ -0,0 +1,52 @@
+using System;
+using System.IO;
+
+namespace NHSE.Core;
+
+///
+/// Provides file access from a filesystem folder.
+///
+///
+/// Creates a provider rooted at the specified folder path.
+///
+/// Absolute path to the save folder.
+public sealed class FolderSaveFileProvider(string rootPath) : ISaveFileProvider
+{
+ public byte[] ReadFile(string relativePath)
+ {
+ var fullPath = Path.Combine(rootPath, relativePath);
+ return File.ReadAllBytes(fullPath);
+ }
+
+ public void WriteFile(string relativePath, ReadOnlySpan data)
+ {
+ var fullPath = Path.Combine(rootPath, relativePath);
+ File.WriteAllBytes(fullPath, data);
+ }
+
+ public bool FileExists(string relativePath)
+ {
+ var fullPath = Path.Combine(rootPath, relativePath);
+ return File.Exists(fullPath);
+ }
+
+ public string[] GetDirectories(string searchPattern)
+ {
+ var dirs = Directory.GetDirectories(rootPath, searchPattern, SearchOption.TopDirectoryOnly);
+ var result = new string[dirs.Length];
+ for (int i = 0; i < dirs.Length; i++)
+ result[i] = new DirectoryInfo(dirs[i]).Name;
+ return result;
+ }
+
+ public ISaveFileProvider GetSubdirectoryProvider(string subdirectory)
+ {
+ var subPath = Path.Combine(rootPath, subdirectory);
+ return new FolderSaveFileProvider(subPath);
+ }
+
+ public void Flush()
+ {
+ // No-op for folder provider; writes are immediate.
+ }
+}
diff --git a/NHSE.Core/Save/Meta/HorizonSave.cs b/NHSE.Core/Save/Meta/HorizonSave.cs
index 5cbae60..526d488 100644
--- a/NHSE.Core/Save/Meta/HorizonSave.cs
+++ b/NHSE.Core/Save/Meta/HorizonSave.cs
@@ -7,16 +7,38 @@ namespace NHSE.Core;
///
/// Represents all saved data that is stored on the device for the New Horizon's game.
///
-public class HorizonSave
+///
+/// Creates a HorizonSave from a file provider.
+///
+/// Provider for reading/writing save files.
+public class HorizonSave(ISaveFileProvider provider)
{
- public readonly MainSave Main;
- public readonly Player[] Players;
+ public readonly MainSave Main = new(provider);
+ public readonly Player[] Players = Player.ReadMany(provider);
+ private readonly ISaveFileProvider Provider = provider;
+
public override string ToString() => $"{Players[0].Personal.TownName} - {Players[0]}";
- public HorizonSave(string folder)
+ ///
+ /// Creates a HorizonSave from an unpacked file path.
+ ///
+ /// Path to the folder containing save files.
+ /// HorizonSave loaded from the folder.
+ public static HorizonSave FromFolder(string folder)
{
- Main = new MainSave(folder);
- Players = Player.ReadMany(folder);
+ var provider = new FolderSaveFileProvider(folder);
+ return new HorizonSave(provider);
+ }
+
+ ///
+ /// Creates a HorizonSave from a ZIP file path.
+ ///
+ /// Path to the ZIP archive containing save files.
+ /// HorizonSave loaded from the ZIP archive.
+ public static HorizonSave FromZip(string zipPath)
+ {
+ var provider = new ZipSaveFileProvider(zipPath);
+ return new HorizonSave(provider);
}
///
@@ -35,6 +57,7 @@ public void Save(uint seed)
pair.Save(seed);
}
}
+ Provider.Flush();
}
///
diff --git a/NHSE.Core/Save/Meta/ISaveFileProvider.cs b/NHSE.Core/Save/Meta/ISaveFileProvider.cs
new file mode 100644
index 0000000..1d515ca
--- /dev/null
+++ b/NHSE.Core/Save/Meta/ISaveFileProvider.cs
@@ -0,0 +1,50 @@
+using System;
+
+namespace NHSE.Core;
+
+///
+/// Abstraction for reading and writing save files from different storage backends (folder, ZIP archive, etc.).
+///
+public interface ISaveFileProvider
+{
+ ///
+ /// Reads all bytes from the specified file path relative to the root.
+ ///
+ /// Relative path to the file (e.g., "main.dat" or "Villager0/personal.dat").
+ /// Byte array containing the file contents.
+ byte[] ReadFile(string relativePath);
+
+ ///
+ /// Writes all bytes to the specified file path relative to the root.
+ ///
+ /// Relative path to the file.
+ /// Byte data to write.
+ void WriteFile(string relativePath, ReadOnlySpan data);
+
+ ///
+ /// Checks if a file exists at the specified relative path.
+ ///
+ /// Relative path to the file.
+ /// True if the file exists; otherwise false.
+ bool FileExists(string relativePath);
+
+ ///
+ /// Gets all subdirectory names matching the specified pattern relative to the root.
+ ///
+ /// Search pattern (e.g., "Villager*").
+ /// Array of matching directory names (not full paths).
+ string[] GetDirectories(string searchPattern);
+
+ ///
+ /// Creates a child provider scoped to the specified subdirectory.
+ ///
+ /// Subdirectory name to scope to.
+ /// A new provider rooted at the subdirectory.
+ ISaveFileProvider GetSubdirectoryProvider(string subdirectory);
+
+ ///
+ /// Flushes any pending writes to the underlying storage.
+ /// For folder providers, this may be a no-op. For ZIP providers, this rebuilds and saves the archive.
+ ///
+ void Flush();
+}
diff --git a/NHSE.Core/Save/Meta/Player.cs b/NHSE.Core/Save/Meta/Player.cs
index 42e5c8f..7e8cf4e 100644
--- a/NHSE.Core/Save/Meta/Player.cs
+++ b/NHSE.Core/Save/Meta/Player.cs
@@ -1,6 +1,5 @@
using System.Collections;
using System.Collections.Generic;
-using System.IO;
using System.Linq;
namespace NHSE.Core;
@@ -36,29 +35,32 @@ public IEnumerator GetEnumerator()
#endregion
///
- /// Imports Player data from the requested .
+ /// Imports Player data from the requested provider.
///
- /// Folder that contains the Player Villager sub-folders.
- /// Player object array loaded from the .
- public static Player[] ReadMany(string folder)
+ /// Provider that contains the Player Villager sub-folders.
+ /// Player object array loaded from the provider.
+ public static Player[] ReadMany(ISaveFileProvider provider)
{
- var dirs = Directory.GetDirectories(folder, "Villager*", SearchOption.TopDirectoryOnly);
+ var dirs = provider.GetDirectories("Villager*");
var result = new Player[dirs.Length];
for (int i = 0; i < result.Length; i++)
- result[i] = new Player(dirs[i]);
+ {
+ var subProvider = provider.GetSubdirectoryProvider(dirs[i]);
+ result[i] = new Player(subProvider, dirs[i]);
+ }
return result;
}
- private Player(string folder)
+ private Player(ISaveFileProvider provider, string directoryName)
{
- DirectoryName = new DirectoryInfo(folder).Name;
+ DirectoryName = directoryName;
- Personal = new Personal(folder);
- Photo = new PhotoStudioIsland(folder);
- PostBox = new PostBox(folder);
- Profile = new Profile(folder);
+ Personal = new Personal(provider);
+ Photo = new PhotoStudioIsland(provider);
+ PostBox = new PostBox(provider);
+ Profile = new Profile(provider);
- if (EncryptedFilePair.Exists(folder, WhereAreN.FileName))
- WhereAreN = new WhereAreN(folder);
+ if (EncryptedFilePair.Exists(provider, WhereAreN.FileName))
+ WhereAreN = new WhereAreN(provider);
}
}
\ No newline at end of file
diff --git a/NHSE.Core/Save/Meta/ZipSaveFileProvider.cs b/NHSE.Core/Save/Meta/ZipSaveFileProvider.cs
new file mode 100644
index 0000000..9f4afc9
--- /dev/null
+++ b/NHSE.Core/Save/Meta/ZipSaveFileProvider.cs
@@ -0,0 +1,151 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+
+namespace NHSE.Core;
+
+///
+/// Provides file access from a ZIP archive. Loads contents into memory and rebuilds the archive on Flush.
+///
+public sealed class ZipSaveFileProvider : ISaveFileProvider
+{
+ private string ZipPath { get; }
+ private string BasePath { get; } = string.Empty;
+ private Dictionary Files { get; } = [];
+ private ZipSaveFileProvider? Root { get; }
+
+ ///
+ /// Creates a provider from a ZIP file path. The ZIP contents are loaded into memory.
+ ///
+ /// Absolute path to the ZIP file.
+ public ZipSaveFileProvider(string zipPath)
+ {
+ ZipPath = zipPath;
+ using var archive = ZipFile.OpenRead(ZipPath);
+ LoadFromZip(archive);
+ }
+
+ private ZipSaveFileProvider(ZipSaveFileProvider root, string basePath)
+ {
+ ZipPath = root.ZipPath;
+ BasePath = basePath;
+ Files = root.Files;
+ Root = root;
+ }
+
+ private void LoadFromZip(ZipArchive archive)
+ {
+ foreach (var entry in archive.Entries)
+ {
+ if (string.IsNullOrEmpty(entry.Name))
+ continue; // Skip directory entries
+
+ using var stream = entry.Open();
+ using var ms = new MemoryStream((int)entry.Length);
+ stream.CopyTo(ms);
+ var key = NormalizePath(entry.FullName);
+ Files[key] = ms.ToArray();
+ }
+ }
+
+ private string GetFullKey(string relativePath)
+ {
+ var combined = string.IsNullOrEmpty(BasePath) ? relativePath : $"{BasePath}/{relativePath}";
+ return NormalizePath(combined);
+ }
+
+ private static string NormalizePath(string path)
+ {
+ return path.Replace('\\', '/').TrimStart('/');
+ }
+
+ public byte[] ReadFile(string relativePath)
+ {
+ var key = GetFullKey(relativePath);
+ if (!Files.TryGetValue(key, out var data))
+ throw new FileNotFoundException($"File not found in ZIP: {key}");
+ return data;
+ }
+
+ public void WriteFile(string relativePath, ReadOnlySpan data)
+ {
+ var key = GetFullKey(relativePath);
+ Files[key] = data.ToArray();
+ }
+
+ public bool FileExists(string relativePath)
+ {
+ var key = GetFullKey(relativePath);
+ return Files.ContainsKey(key);
+ }
+
+ public string[] GetDirectories(string searchPattern)
+ {
+ var prefix = string.IsNullOrEmpty(BasePath) ? string.Empty : BasePath + "/";
+ var pattern = searchPattern.Replace("*", string.Empty);
+
+ var directories = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var key in Files.Keys)
+ {
+ if (!key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ var remainder = key[prefix.Length..];
+ var slashIndex = remainder.IndexOf('/');
+ if (slashIndex <= 0)
+ continue;
+
+ var dirName = remainder[..slashIndex];
+ if (dirName.Contains(pattern, StringComparison.OrdinalIgnoreCase))
+ directories.Add(dirName);
+ }
+
+ return [.. directories.Order()];
+ }
+
+ public ISaveFileProvider GetSubdirectoryProvider(string subdirectory)
+ {
+ var newBase = string.IsNullOrEmpty(BasePath) ? subdirectory : $"{BasePath}/{subdirectory}";
+ var rootProvider = Root ?? this;
+ return new ZipSaveFileProvider(rootProvider, NormalizePath(newBase));
+ }
+
+ public void Flush()
+ {
+ // Only the root provider can flush
+ if (Root is not null)
+ {
+ Root.Flush();
+ return;
+ }
+
+ // Write to a temporary file first, then replace the original
+ var tempPath = ZipPath + ".tmp";
+ try
+ {
+ using (var stream = new FileStream(tempPath, FileMode.Create, FileAccess.Write))
+ using (var archive = new ZipArchive(stream, ZipArchiveMode.Create))
+ {
+ foreach (var (key, data) in Files.OrderBy(x => x.Key))
+ {
+ var entry = archive.CreateEntry(key, CompressionLevel.Optimal);
+ using var entryStream = entry.Open();
+ entryStream.Write(data);
+ }
+ }
+
+ // Replace original with temp
+ File.Delete(ZipPath);
+ File.Move(tempPath, ZipPath);
+ }
+ catch
+ {
+ // Clean up temp file on failure
+ if (File.Exists(tempPath))
+ File.Delete(tempPath);
+ throw;
+ }
+ }
+}
diff --git a/NHSE.WinForms/Editor.cs b/NHSE.WinForms/Editor.cs
index 18e4092..54a8574 100644
--- a/NHSE.WinForms/Editor.cs
+++ b/NHSE.WinForms/Editor.cs
@@ -17,6 +17,11 @@ public sealed partial class Editor : Form
private readonly HorizonSave SAV;
private readonly VillagerEditor Villagers;
+ ///
+ /// Currently loaded player index.
+ ///
+ private int PlayerIndex = -1;
+
public Editor(HorizonSave file)
{
InitializeComponent();
@@ -212,8 +217,6 @@ private void LoadPlayers()
PlayerIndex = -1;
CB_Players.SelectedIndex = 0;
}
-
- private int PlayerIndex = -1;
private void LoadPlayer(object sender, EventArgs e) => LoadPlayer(CB_Players.SelectedIndex);
private void B_EditPlayerItems_Click(object sender, EventArgs e)
@@ -313,7 +316,7 @@ private void LoadPlayer(int index)
if (player.WhereAreN is not null)
{
- NUD_Poki.Value = Math.Min(int.MaxValue, player.WhereAreN.Poki.Value);
+ NUD_Poki.Value = Math.Min(int.MaxValue, player.WhereAreN.Poki);
}
else
{
diff --git a/NHSE.WinForms/Main.cs b/NHSE.WinForms/Main.cs
index 313b17e..bdfa58e 100644
--- a/NHSE.WinForms/Main.cs
+++ b/NHSE.WinForms/Main.cs
@@ -123,6 +123,15 @@ private static void OpenFileOrPath(string path)
return;
}
+ // Load zip files differently
+ var ext = Path.GetExtension(path);
+ if (ext.Equals(".zip", StringComparison.OrdinalIgnoreCase) && new FileInfo(path).Length < 20 * 1024 * 1024) // less than 20MB
+ {
+ var file = HorizonSave.FromZip(path);
+ Open(file);
+ return;
+ }
+
var dir = Path.GetDirectoryName(path);
if (dir is null || !Directory.Exists(dir)) // ya never know
{
@@ -135,7 +144,7 @@ private static void OpenFileOrPath(string path)
private static void OpenSaveFile(string path)
{
- var file = new HorizonSave(path);
+ var file = HorizonSave.FromFolder(path);
Open(file);
var settings = Settings.Default;
diff --git a/NHSE.WinForms/Util/DevUtil.cs b/NHSE.WinForms/Util/DevUtil.cs
index 2b39990..9911303 100644
--- a/NHSE.WinForms/Util/DevUtil.cs
+++ b/NHSE.WinForms/Util/DevUtil.cs
@@ -67,7 +67,7 @@ private static void LoadSpecialForms()
{
// For forms that require more complete initialization (dynamically added user controls)
var path = Settings.Default.LastFilePath;
- var sav = new HorizonSave(path);
+ var sav = HorizonSave.FromFolder(path);
using var editor = new Editor(sav);
using var so = new SingleObjectEditor