From 5152881568a376f74d6dc4fd0e015a31710fab2d Mon Sep 17 00:00:00 2001 From: Kurt Date: Fri, 16 Jan 2026 16:37:37 -0600 Subject: [PATCH] Add support for opening .zip save files ty claude u a real g heavy legwork by claude, refinements/finish by me --- NHSE.Core/Save/Files/MainSave.cs | 2 +- NHSE.Core/Save/Files/Personal.cs | 2 +- NHSE.Core/Save/Files/PhotoStudioIsland.cs | 5 +- NHSE.Core/Save/Files/PostBox.cs | 5 +- NHSE.Core/Save/Files/Profile.cs | 10 +- NHSE.Core/Save/Files/WhereAreN.cs | 3 +- NHSE.Core/Save/Meta/EncryptedFilePair.cs | 35 ++-- NHSE.Core/Save/Meta/FolderSaveFileProvider.cs | 52 ++++++ NHSE.Core/Save/Meta/HorizonSave.cs | 35 +++- NHSE.Core/Save/Meta/ISaveFileProvider.cs | 50 ++++++ NHSE.Core/Save/Meta/Player.cs | 32 ++-- NHSE.Core/Save/Meta/ZipSaveFileProvider.cs | 151 ++++++++++++++++++ NHSE.WinForms/Editor.cs | 9 +- NHSE.WinForms/Main.cs | 11 +- NHSE.WinForms/Util/DevUtil.cs | 2 +- 15 files changed, 342 insertions(+), 62 deletions(-) create mode 100644 NHSE.Core/Save/Meta/FolderSaveFileProvider.cs create mode 100644 NHSE.Core/Save/Meta/ISaveFileProvider.cs create mode 100644 NHSE.Core/Save/Meta/ZipSaveFileProvider.cs 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(new object(), PropertySort.NoSort, false); }