Merge branch 'master' into tileshift

This commit is contained in:
Kurt 2026-01-16 21:36:29 -06:00
commit f443add432
24 changed files with 1147 additions and 828 deletions

View File

@ -1,32 +1,24 @@
using System;
using System.Diagnostics;
using System.Numerics;
using static System.Buffers.Binary.BinaryPrimitives;
namespace NHSE.Core;
public sealed class EncryptedInt32
/// <summary>
/// Represents an encrypted 32-bit integer with associated encryption parameters.
/// </summary>
[DebuggerDisplay("{Value}")]
public sealed record EncryptedInt32(uint OriginalEncrypted, ushort Adjust = 0, byte Shift = 0, byte Checksum = 0)
{
// Encryption constant used to encrypt the int.
private const uint ENCRYPTION_CONSTANT = 0x80E32B11;
// Base shift count used in the encryption.
private const byte SHIFT_BASE = 3;
public readonly uint OriginalEncrypted;
public readonly ushort Adjust;
public readonly byte Shift;
public readonly byte Checksum;
public uint Value { get; set; } = Decrypt(OriginalEncrypted, Shift, Adjust);
public uint Value;
public override string ToString() => Value.ToString();
public EncryptedInt32(uint encryptedValue, ushort adjust = 0, byte shift = 0, byte checksum = 0)
{
OriginalEncrypted = encryptedValue;
Adjust = adjust;
Shift = shift;
Checksum = checksum;
Value = Decrypt(encryptedValue, shift, adjust);
}
public static implicit operator uint(EncryptedInt32 encryptedInt32) => encryptedInt32.Value;
public void Write(Span<byte> data) => Write(this, data);
public void Write(byte[] data, int offset) => Write(data.AsSpan(offset));
@ -41,16 +33,14 @@ public static byte CalculateChecksum(uint value)
public static uint Decrypt(uint encrypted, byte shift, ushort adjust)
{
// Decrypt the encrypted int using the given params.
ulong val = ((ulong) encrypted) << ((32 - SHIFT_BASE - shift) & 0x3F);
val += val >> 32;
return ENCRYPTION_CONSTANT - adjust + (uint)val;
var rotated = BitOperations.RotateRight(encrypted, shift + SHIFT_BASE);
return rotated + ENCRYPTION_CONSTANT - adjust;
}
public static uint Encrypt(uint value, byte shift, ushort adjust)
{
ulong val = (ulong) (value + unchecked(adjust - ENCRYPTION_CONSTANT)) << (shift + SHIFT_BASE);
return (uint) ((val >> 32) + val);
var adjusted = value + adjust - ENCRYPTION_CONSTANT;
return BitOperations.RotateLeft(adjusted, shift + SHIFT_BASE);
}
public static EncryptedInt32 ReadVerify(ReadOnlySpan<byte> data, int offset)

View File

@ -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; }

View File

@ -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
@ -173,5 +173,10 @@ public bool ProfileIsMakeVillage
set => Data[Offsets.ProfileIsMakeVillage] = (byte)(value ? 1 : 0);
}
/// <summary>
/// Appended structure added in 3.0.0 for Hotel data.
/// </summary>
public Personal30? Data30 => (Offsets as IPersonal30)?.Get30s_064c1881(Raw);
#endregion
}

View File

@ -3,7 +3,4 @@
/// <summary>
/// photo_studio_island.dat
/// </summary>
public sealed class PhotoStudioIsland : EncryptedFilePair
{
public PhotoStudioIsland(string folder) : base(folder, "photo_studio_island") { }
}
public sealed class PhotoStudioIsland(ISaveFileProvider provider) : EncryptedFilePair(provider, "photo_studio_island");

View File

@ -3,7 +3,4 @@
/// <summary>
/// postbox.dat
/// </summary>
public sealed class PostBox : EncryptedFilePair
{
public PostBox(string folder) : base(folder, "postbox") { }
}
public sealed class PostBox(ISaveFileProvider provider) : EncryptedFilePair(provider, "postbox");

View File

@ -3,9 +3,7 @@
/// <summary>
/// profile.dat
/// </summary>
public sealed class Profile : EncryptedFilePair
{
public Profile(string folder) : base(folder, "profile") { }
// pretty much just a jpeg -- which is also stored in Personal.
}
/// <remarks>
/// pretty much just a jpeg -- which is also stored in Personal.
/// </remarks>
public sealed class Profile(ISaveFileProvider provider) : EncryptedFilePair(provider, "profile");

View File

@ -1,13 +1,14 @@
namespace NHSE.Core;
/// <summary>
/// Data structures stored for the HappyHomeDesigner DLC.
/// </summary>
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
{

View File

@ -1,53 +1,50 @@
using System;
using System.Collections.Generic;
using System.IO;
using static System.Buffers.Binary.BinaryPrimitives;
namespace NHSE.Core;
/// <summary>
/// Represents two files -- <see cref="DataPath"/> and <see cref="HeaderPath"/> and their decrypted data.
/// Represents two files -- <see cref="NameData"/> and <see cref="NameHeader"/> and their decrypted data.
/// </summary>
public abstract class EncryptedFilePair
{
private readonly byte[] RawData;
private readonly byte[] RawHeader;
private readonly ISaveFileProvider Provider;
protected Memory<byte> Raw => RawData;
public Span<byte> Data => RawData;
public Span<byte> Header => RawHeader;
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)
/// <summary>
/// Checks if the file pair exists in the specified provider.
/// </summary>
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<FileHeaderInfo>();
}
@ -55,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);
}
/// <summary>
/// Updates all hashes of <see cref="Data"/>.
/// </summary>

View File

@ -0,0 +1,52 @@
using System;
using System.IO;
namespace NHSE.Core;
/// <summary>
/// Provides file access from a filesystem folder.
/// </summary>
/// <remarks>
/// Creates a provider rooted at the specified folder path.
/// </remarks>
/// <param name="rootPath">Absolute path to the save folder.</param>
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<byte> 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.
}
}

View File

@ -7,16 +7,38 @@ namespace NHSE.Core;
/// <summary>
/// Represents all saved data that is stored on the device for the New Horizon's game.
/// </summary>
public class HorizonSave
/// <remarks>
/// Creates a HorizonSave from a file provider.
/// </remarks>
/// <param name="provider">Provider for reading/writing save files.</param>
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)
/// <summary>
/// Creates a HorizonSave from an unpacked file path.
/// </summary>
/// <param name="folder">Path to the folder containing save files.</param>
/// <returns>HorizonSave loaded from the folder.</returns>
public static HorizonSave FromFolder(string folder)
{
Main = new MainSave(folder);
Players = Player.ReadMany(folder);
var provider = new FolderSaveFileProvider(folder);
return new HorizonSave(provider);
}
/// <summary>
/// Creates a HorizonSave from a ZIP file path.
/// </summary>
/// <param name="zipPath">Path to the ZIP archive containing save files.</param>
/// <returns>HorizonSave loaded from the ZIP archive.</returns>
public static HorizonSave FromZip(string zipPath)
{
var provider = new ZipSaveFileProvider(zipPath);
return new HorizonSave(provider);
}
/// <summary>
@ -35,6 +57,7 @@ public void Save(uint seed)
pair.Save(seed);
}
}
Provider.Flush();
}
/// <summary>

View File

@ -0,0 +1,50 @@
using System;
namespace NHSE.Core;
/// <summary>
/// Abstraction for reading and writing save files from different storage backends (folder, ZIP archive, etc.).
/// </summary>
public interface ISaveFileProvider
{
/// <summary>
/// Reads all bytes from the specified file path relative to the root.
/// </summary>
/// <param name="relativePath">Relative path to the file (e.g., "main.dat" or "Villager0/personal.dat").</param>
/// <returns>Byte array containing the file contents.</returns>
byte[] ReadFile(string relativePath);
/// <summary>
/// Writes all bytes to the specified file path relative to the root.
/// </summary>
/// <param name="relativePath">Relative path to the file.</param>
/// <param name="data">Byte data to write.</param>
void WriteFile(string relativePath, ReadOnlySpan<byte> data);
/// <summary>
/// Checks if a file exists at the specified relative path.
/// </summary>
/// <param name="relativePath">Relative path to the file.</param>
/// <returns>True if the file exists; otherwise false.</returns>
bool FileExists(string relativePath);
/// <summary>
/// Gets all subdirectory names matching the specified pattern relative to the root.
/// </summary>
/// <param name="searchPattern">Search pattern (e.g., "Villager*").</param>
/// <returns>Array of matching directory names (not full paths).</returns>
string[] GetDirectories(string searchPattern);
/// <summary>
/// Creates a child provider scoped to the specified subdirectory.
/// </summary>
/// <param name="subdirectory">Subdirectory name to scope to.</param>
/// <returns>A new provider rooted at the subdirectory.</returns>
ISaveFileProvider GetSubdirectoryProvider(string subdirectory);
/// <summary>
/// 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.
/// </summary>
void Flush();
}

View File

@ -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<EncryptedFilePair> GetEnumerator()
#endregion
/// <summary>
/// Imports Player data from the requested <see cref="folder"/>.
/// Imports Player data from the requested provider.
/// </summary>
/// <param name="folder">Folder that contains the Player Villager sub-folders.</param>
/// <returns>Player object array loaded from the <see cref="folder"/>.</returns>
public static Player[] ReadMany(string folder)
/// <param name="provider">Provider that contains the Player Villager sub-folders.</param>
/// <returns>Player object array loaded from the provider.</returns>
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);
}
}

View File

@ -0,0 +1,151 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
namespace NHSE.Core;
/// <summary>
/// Provides file access from a ZIP archive. Loads contents into memory and rebuilds the archive on Flush.
/// </summary>
public sealed class ZipSaveFileProvider : ISaveFileProvider
{
private string ZipPath { get; }
private string BasePath { get; } = string.Empty;
private Dictionary<string, byte[]> Files { get; } = [];
private ZipSaveFileProvider? Root { get; }
/// <summary>
/// Creates a provider from a ZIP file path. The ZIP contents are loaded into memory.
/// </summary>
/// <param name="zipPath">Absolute path to the ZIP file.</param>
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<byte> 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<string>(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;
}
}
}

View File

@ -0,0 +1,31 @@
using System;
namespace NHSE.Core;
public sealed class Personal30(Memory<byte> raw)
{
public Span<byte> Data => raw.Span;
public bool IsInitialized30 => System.Buffers.Binary.BinaryPrimitives.ReadInt64LittleEndian(raw.Span) != 0;
public EncryptedInt32 HotelTickets // @0x0 size 0x8, align 4
{
get => EncryptedInt32.ReadVerify(Data, 0);
set => value.Write(Data);
}
}
public interface IPersonal30
{
public int Offset30s_064c1881 { get; }
public int Length30s_064c1881 { get; }
}
public static class Personal30Extensions
{
extension(IPersonal30 personal)
{
public Personal30 Get30s_064c1881(Memory<byte> data)
=> new(data.Slice(personal.Offset30s_064c1881, personal.Length30s_064c1881));
}
}

View File

@ -5,7 +5,7 @@ namespace NHSE.Core;
/// <summary>
/// <inheritdoc cref="PersonalOffsets"/>
/// </summary>
public sealed class PersonalOffsets30 : PersonalOffsets
public sealed class PersonalOffsets30 : PersonalOffsets, IPersonal30
{
// GSavePlayer
private const int Player = 0x110;
@ -50,6 +50,10 @@ public sealed class PersonalOffsets30 : PersonalOffsets
public override int MaxRecipeID => 0x430; // unchanged
public override int MaxRemakeBitFlag => 0x7D0 * 32;
// Additional struct added in 3.0.0 for Hotel; fetch via Offset's interface extension method.
public int Offset30s_064c1881 => PlayerOther + 0x3C6D0;
public int Length30s_064c1881 => 0x790;
public override IReactionStore ReadReactions(ReadOnlySpan<byte> data) => data.Slice(Manpu, GSavePlayerManpu15.SIZE).ToStructure<GSavePlayerManpu15>();
public override void SetReactions(Span<byte> data, IReactionStore value) => ((GSavePlayerManpu15)value).ToBytes().CopyTo(data[Manpu..]);
}

View File

@ -1,5 +1,4 @@
using System;
using System.Runtime.InteropServices;
using static System.Buffers.Binary.BinaryPrimitives;
namespace NHSE.Core;

View File

@ -1,5 +1,4 @@
using System;
using System.Runtime.InteropServices;
using static System.Buffers.Binary.BinaryPrimitives;
namespace NHSE.Core;

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,14 @@
using System;
using NHSE.Core;
using NHSE.Injection;
using NHSE.Sprites;
using NHSE.WinForms.Properties;
using System;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Forms;
using NHSE.Core;
using NHSE.Injection;
using NHSE.Sprites;
using NHSE.WinForms.Properties;
namespace NHSE.WinForms;
@ -17,6 +17,11 @@ public sealed partial class Editor : Form
private readonly HorizonSave SAV;
private readonly VillagerEditor Villagers;
/// <summary>
/// Currently loaded player index.
/// </summary>
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)
@ -292,19 +295,28 @@ private void LoadPlayer(int index)
var pers = player.Personal;
TB_Name.Text = pers.PlayerName;
TB_TownName.Text = pers.TownName;
NUD_BankBells.Value = Math.Min(int.MaxValue, pers.Bank.Value);
NUD_NookMiles.Value = Math.Min(int.MaxValue, pers.NookMiles.Value);
NUD_TotalNookMiles.Value = Math.Min(int.MaxValue, pers.TotalNookMiles.Value);
NUD_Wallet.Value = Math.Min(int.MaxValue, pers.Wallet.Value);
NUD_BankBells.Value = Math.Min(int.MaxValue, pers.Bank);
NUD_NookMiles.Value = Math.Min(int.MaxValue, pers.NookMiles);
NUD_TotalNookMiles.Value = Math.Min(int.MaxValue, pers.TotalNookMiles);
NUD_Wallet.Value = Math.Min(int.MaxValue, pers.Wallet);
// swapped on purpose -- first count is the first two rows of items
NUD_PocketCount1.Value = Math.Min(int.MaxValue, pers.PocketCount);
NUD_PocketCount2.Value = Math.Min(int.MaxValue, pers.BagCount);
NUD_StorageCount.Value = Math.Min(int.MaxValue, pers.ItemChestCount);
if (pers.Data30 is { IsInitialized30: true } addition)
{
NUD_HotelTickets.Value = Math.Min(int.MaxValue, addition.HotelTickets);
}
else
{
L_HotelTickets.Visible = NUD_HotelTickets.Visible = false;
}
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
{
@ -347,21 +359,10 @@ private void SavePlayer(int index)
SAV.ChangeIdentity(orig, updated);
}
var bank = pers.Bank;
bank.Value = (uint)NUD_BankBells.Value;
pers.Bank = bank;
var nook = pers.NookMiles;
nook.Value = (uint)NUD_NookMiles.Value;
pers.NookMiles = nook;
var tnook = pers.TotalNookMiles;
tnook.Value = (uint)NUD_TotalNookMiles.Value;
pers.TotalNookMiles = tnook;
var wallet = pers.Wallet;
wallet.Value = (uint)NUD_Wallet.Value;
pers.Wallet = wallet;
pers.Bank = pers.Bank with { Value = (uint)NUD_BankBells.Value };
pers.NookMiles = pers.NookMiles with { Value = (uint)NUD_NookMiles.Value };
pers.TotalNookMiles = pers.TotalNookMiles with { Value = (uint)NUD_TotalNookMiles.Value };
pers.Wallet = pers.Wallet with { Value = (uint)NUD_Wallet.Value };
// swapped on purpose -- first count is the first two rows of items
pers.PocketCount = (uint)NUD_PocketCount1.Value;
@ -369,11 +370,14 @@ private void SavePlayer(int index)
pers.ItemChestCount = (uint)NUD_StorageCount.Value;
if (player.Personal.Data30 is { IsInitialized30: true } addition)
{
addition.HotelTickets = addition.HotelTickets with { Value = (uint)NUD_HotelTickets.Value };
}
if (player.WhereAreN is { } x)
{
var poki = x.Poki;
poki.Value = (uint)NUD_Poki.Value;
x.Poki = poki;
x.Poki = x.Poki with { Value = (uint)NUD_Poki.Value };
}
}

View File

@ -1,17 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
@ -26,36 +26,36 @@
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->

View File

@ -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;

View File

@ -1,17 +1,16 @@
using System;
using System.ComponentModel;
using NHSE.Sprites;
using System;
using System.IO;
using System.IO.Compression;
using System.Net;
using System.Threading.Tasks;
using System.Windows.Forms;
using NHSE.Sprites;
namespace NHSE.WinForms;
public sealed partial class ImageFetcher : Form
{
private const string Filename = "image.zip";
private const int MaxBufferSize = 8192;
private static string ZipFilePath => Path.Combine(ItemSprite.PlatformAppDataPath, Filename);
private readonly string[] AllHosts;
@ -49,14 +48,39 @@ private async void B_Download_Click(object sender, EventArgs e)
SetUIDownloadState(false);
L_Status.Text = "Downloading...";
L_Status.PerformSafely(() => L_Status.Text = "Downloading...");
if (!Directory.Exists(path))
Directory.CreateDirectory(path);
using var httpClient = new System.Net.Http.HttpClient();
await using var stream = await httpClient.GetStreamAsync(hostSelected).ConfigureAwait(false);
await using var fileStream = new FileStream(ZipFilePath, FileMode.CreateNew);
await stream.CopyToAsync(fileStream).ConfigureAwait(false);
using var response = await httpClient.GetAsync(hostSelected, System.Net.Http.HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var totalBytes = response.Content.Headers.ContentLength ?? -1L;
var canReportProgress = totalBytes != -1 && PBar_MultiUse != null;
using (var contentStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false))
using (var fileStream = new FileStream(ZipFilePath, FileMode.Create, FileAccess.Write, FileShare.None, MaxBufferSize, true))
{
var buffer = new byte[MaxBufferSize];
long totalRead = 0;
int read;
while ((read = await contentStream.ReadAsync(buffer).ConfigureAwait(false)) > 0)
{
await fileStream.WriteAsync(buffer.AsMemory(0, read)).ConfigureAwait(false);
totalRead += read;
if (canReportProgress && PBar_MultiUse != null)
{
int progress = (int)((totalRead * 100) / totalBytes);
PBar_MultiUse.PerformSafely(() => PBar_MultiUse.Value = Math.Min(progress, 100));
}
}
}
PBar_MultiUse?.PerformSafely(() => PBar_MultiUse.Value = 100);
L_Status.Invoke((Action)(() => L_Status.Text = "Unzipping..."));
UnzipFile();
}
catch (Exception ex)
{
@ -65,22 +89,6 @@ private async void B_Download_Click(object sender, EventArgs e)
}
}
private void ProgressChanged(object sender, DownloadProgressChangedEventArgs e) => PBar_MultiUse.Value = e.ProgressPercentage;
private void Completed(object? sender, AsyncCompletedEventArgs e)
{
if (e.Error != null)
{
WinFormsUtil.Error(e.Error.Message, e.Error.InnerException == null ? string.Empty : e.Error.InnerException.Message);
SetUIDownloadState(true);
return;
}
PBar_MultiUse.Value = 100;
L_Status.Text = "Unzipping...";
UnzipFile();
}
private async void UnzipFile()
{
try
@ -105,11 +113,11 @@ private async void UnzipFile()
private void SetUIDownloadState(bool val, bool success = false)
{
ControlBox = val;
B_Download.Enabled = val;
PBar_MultiUse.Value = 0;
this.PerformSafely(() => ControlBox = val);
B_Download.PerformSafely(() => B_Download.Enabled = val);
PBar_MultiUse.PerformSafely(() => PBar_MultiUse.Value = 0);
L_Status.Text = success ? "Images installed successfully." : string.Empty;
L_Status.PerformSafely(() => L_Status.Text = success ? "Images installed successfully." : string.Empty);
CheckFileStatusLabel();
@ -129,30 +137,24 @@ private async void CheckNetworkFileSizeAsync()
{
try
{
L_FileSize.Text = string.Empty;
L_FileSize.PerformSafely(() => L_FileSize.Text = string.Empty);
var host = AllHosts[CB_HostSelect.SelectedIndex];
#if NETFRAMEWORK
using var webClient = new WebClient();
await webClient.OpenReadTaskAsync(new Uri(host, UriKind.Absolute)).ConfigureAwait(false);
var hdr = webClient.ResponseHeaders?["Content-Length"];
#elif NETCOREAPP
using var httpClient = new System.Net.Http.HttpClient();
var httpInitialResponse = await httpClient.GetAsync(host).ConfigureAwait(false);
var hdr = httpInitialResponse.Content.Headers.ContentLength;
#endif
if (hdr == null)
{
L_FileSize.Text = "Failed.";
L_FileSize.PerformSafely(() => L_FileSize.Text = "Failed.");
return;
}
var totalSizeBytes = Convert.ToInt64(hdr);
var totalSizeMb = totalSizeBytes / 1e+6;
L_FileSize.Text = $"{totalSizeMb:0.##}MB";
L_FileSize.PerformSafely(() => L_FileSize.Text = $"{totalSizeMb:0.##}MB");
}
catch (Exception ex)
{
L_FileSize.Text = ex.Message;
L_FileSize.PerformSafely(() => L_FileSize.Text = ex.Message);
}
}
@ -162,5 +164,5 @@ private static string CleanUrl(string url)
return uri.Segments.Length < 2 ? url : $"{uri.Host}/{uri.Segments[1]}";
}
private bool CheckFileStatusLabel() => L_ImgStatus.Visible = ItemSprite.SingleSpriteExists;
private void CheckFileStatusLabel() => L_ImgStatus.PerformSafely(() => L_ImgStatus.Visible = ItemSprite.SingleSpriteExists);
}

View File

@ -0,0 +1,22 @@
using System;
using System.Windows.Forms;
namespace NHSE.WinForms;
public static class CrossThreadExtensions
{
/// <summary>
/// Helper function to perform an action on a Control's thread safely.
/// </summary>
public static void PerformSafely(this Control target, Action action)
{
if (target.InvokeRequired)
{
target.Invoke(action);
}
else
{
action();
}
}
}

View File

@ -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<object>(new object(), PropertySort.NoSort, false);
}