Add NSO save handler for Gen1/2 saves

Closes #3921
Not sure if the RTC handling is correct (always 0x20 length?) but at least we have a non-fixed-sized header handled with some leniency for different builds.
This commit is contained in:
Kurt 2024-02-24 21:53:21 -06:00
parent 88569d1107
commit 8b5e4e798b
9 changed files with 192 additions and 11 deletions

View file

@ -28,6 +28,7 @@ public sealed record SaveFileMetadata(SaveFile SAV)
private byte[] Footer = []; // .dsv
private byte[] Header = []; // .gci
private ISaveHandler? Handler;
private string BAKSuffix => $" [{SAV.ShortSummary}].bak";
@ -53,19 +54,22 @@ public sealed record SaveFileMetadata(SaveFile SAV)
public byte[] Finalize(byte[] data, BinaryExportSetting setting)
{
if (Footer.Length > 0 && setting.HasFlag(BinaryExportSetting.IncludeFooter))
return [..data, ..Footer];
data = [..data, ..Footer];
if (Header.Length > 0 && setting.HasFlag(BinaryExportSetting.IncludeHeader))
return [..Header, ..data];
data = [..Header, ..data];
if (setting != BinaryExportSetting.None)
Handler?.Finalize(data);
return data;
}
/// <summary>
/// Sets the details of any trimmed header and footer arrays to a <see cref="SaveFile"/> object.
/// </summary>
public void SetExtraInfo(byte[] header, byte[] footer)
public void SetExtraInfo(byte[] header, byte[] footer, ISaveHandler handler)
{
Header = header;
Footer = footer;
Handler = handler;
}
/// <summary>
@ -128,7 +132,7 @@ public sealed record SaveFileMetadata(SaveFile SAV)
var flags = BinaryExportSetting.None;
if (ext == ".dsv")
flags |= BinaryExportSetting.IncludeFooter;
if (ext == ".gci" || SAV is IGCSaveFile {MemoryCard: null})
if (ext == ".gci" || SAV is IGCSaveFile {MemoryCard: null} || ext == ".sram")
flags |= BinaryExportSetting.IncludeHeader;
return flags;
}

View file

@ -20,6 +20,12 @@ public interface ISaveHandler
/// <param name="input">Combined data</param>
/// <returns>Null if not a valid save file for this handler's format. Returns an object containing header, footer, and inner data references.</returns>
SaveHandlerSplitResult? TrySplit(ReadOnlySpan<byte> input);
/// <summary>
/// When exporting a save file, the handler might want to update the header/footer.
/// </summary>
/// <param name="input">Combined data</param>
void Finalize(Span<byte> input);
}
#endif

View file

@ -17,6 +17,8 @@ public sealed class SaveHandlerARDS : ISaveHandler
// No authentication to see if it actually is a header; no size collisions expected.
var header = input[..sizeHeader].ToArray();
var data = input[sizeHeader..].ToArray();
return new SaveHandlerSplitResult(data, header, []);
return new SaveHandlerSplitResult(data, header, [], this);
}
public void Finalize(Span<byte> data) { }
}

View file

@ -30,6 +30,8 @@ public sealed class SaveHandlerBizHawk : ISaveHandler
var footer = input[realSize..].ToArray();
var data = input[..realSize].ToArray();
return new SaveHandlerSplitResult(data, [], footer);
return new SaveHandlerSplitResult(data, [], footer, this);
}
public void Finalize(Span<byte> data) { }
}

View file

@ -23,6 +23,8 @@ public sealed class SaveHandlerDeSmuME : ISaveHandler
var footer = input[RealSize..].ToArray();
var data = input[..RealSize].ToArray();
return new SaveHandlerSplitResult(data, [], footer);
return new SaveHandlerSplitResult(data, [], footer, this);
}
public void Finalize(Span<byte> data) { }
}

View file

@ -55,9 +55,11 @@ public sealed class SaveHandlerGCI : ISaveHandler
var header = input[..headerSize].ToArray();
var data = input[headerSize..].ToArray();
return new SaveHandlerSplitResult(data, header, []);
return new SaveHandlerSplitResult(data, header, [], this);
}
public void Finalize(Span<byte> data) { }
/// <summary>
/// Checks if the game code is one of the recognizable versions.
/// </summary>

View file

@ -0,0 +1,161 @@
using System;
using System.Runtime.CompilerServices;
using static PKHeX.Core.HeaderInfoNSO;
namespace PKHeX.Core;
public sealed class SaveHandlerNSO : ISaveHandler
{
private static ReadOnlySpan<byte> Magic => "SRAM"u8;
public bool IsRecognized(long size) => IsPlausible(size);
public static ReadOnlySpan<byte> GetHashROM(ReadOnlySpan<byte> header) => new UnpackedHeaderNSOReadOnly(header).HashROM;
public SaveHandlerSplitResult? TrySplit(ReadOnlySpan<byte> input)
{
if (input.Length < SmallestSize)
return null;
if (!input.StartsWith(Magic))
return null;
var obj = new UnpackedHeaderNSOReadOnly(input);
var header = input[..obj.Length];
var data = input[obj.Length..];
return new SaveHandlerSplitResult([..data], [..header], [], this);
}
public void Finalize(Span<byte> input)
{
// Need to re-sign the save file.
var obj = new UnpackedHeaderNSO(input);
var hashSave = obj.SaveHash;
var data = input[obj.Length..];
WriteHash(data, hashSave);
}
public static void WriteHash(ReadOnlySpan<byte> input, Span<byte> result)
{
// Compute hash into the result span, then inflate in place.
System.Security.Cryptography.SHA1.HashData(input, result);
InflateAscii(result);
}
public static void InflateAscii(Span<byte> data)
{
// Since we're expanding in-place, our current hash is 20 bytes.
// We can expand backwards; no byte will be overwritten until it is no longer needed.
for (int i = 19, j = data.Length - 2; i >= 0; i--, j -= 2)
{
byte b = data[i];
data[j + 1] = GetHexCharLower(b & 0x0F);
data[j] = GetHexCharLower(b >> 4);
}
}
private static byte GetHexCharLower(int value) => (byte)(value < 10 ? value + '0' : value - 10 + 'a');
}
public static class HeaderInfoNSO
{
public const int SIZE_HASH = 40; // Inflated from bytes to ASCII chars
public const int OffsetROM = 0x08;
public const int SIZE_RTC = 0x20;
// Anatomy:
// 0x00 - 0x03: Magic
// 0x04 - 0x07: (u32 ???) 3?
// 0x08 - 0x2F: SHA1 hash of the ROM
// 0x30 - 0x33: Length of following Ascii string
// 0x34: u8[length] Ascii Build
// .... bool8 HasRTC
// .... {if true, 0x20 bytes of RTC}
// SHA1 hash of the save data
// remainder: save data
public const int SmallestSize = SaveUtil.SIZE_G1RAW;
public static ReadOnlySpan<int> AllowedBaseSizes =>
[
SaveUtil.SIZE_G1RAW, // smallest
SaveUtil.SIZE_G2RAW_J,
];
public static bool IsPlausible(long size)
{
foreach (var inner in AllowedBaseSizes)
{
if (IsPlausible(size, inner))
return true;
}
return false;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsPlausible(long size, int expected)
{
// allow different builds and RTC presence
var delta = (uint)(size - expected);
return delta is >= 0x68 and <= 0xA0;
}
}
/// <summary>
/// Readonly version of <see cref="UnpackedHeaderNSO"/>
/// </summary>
public readonly ref struct UnpackedHeaderNSOReadOnly
{
public readonly ReadOnlySpan<byte> HashROM;
public readonly ReadOnlySpan<byte> Build;
public readonly bool HasRTC;
public readonly ReadOnlySpan<byte> RTC;
public readonly ReadOnlySpan<byte> HashSaveFile;
public readonly int Length; // of this header
public UnpackedHeaderNSOReadOnly(ReadOnlySpan<byte> input)
{
HashROM = input.Slice(OffsetROM, SIZE_HASH);
var length = input[0x30]; // really is a 32-bit number, but is never bigger than 255.
Build = input.Slice(0x34, length);
var ofs = 0x34 + length;
HasRTC = input[ofs++] != 0;
if (HasRTC)
{
RTC = input.Slice(ofs, SIZE_RTC);
ofs += SIZE_RTC;
}
HashSaveFile = input.Slice(ofs, SIZE_HASH); ofs += SIZE_HASH;
Length = ofs;
}
}
/// <summary>
/// Unpacked Header of an NSO save file's variable-sized header.
/// </summary>
public readonly ref struct UnpackedHeaderNSO
{
public readonly Span<byte> ROMHash;
public readonly Span<byte> Build;
public readonly bool HasRTC;
public readonly Span<byte> RTC;
public readonly Span<byte> SaveHash;
public readonly int Length;
public UnpackedHeaderNSO(Span<byte> input)
{
ROMHash = input.Slice(OffsetROM, SIZE_HASH);
var length = input[0x30]; // really is a 32-bit number, but is never bigger than 255.
Build = input.Slice(0x34, length);
var ofs = 0x34 + length;
HasRTC = input[ofs++] != 0;
if (HasRTC)
{
RTC = input.Slice(ofs, SIZE_RTC);
ofs += SIZE_RTC;
}
SaveHash = input.Slice(ofs, SIZE_HASH); ofs += SIZE_HASH;
Length = ofs;
}
}

View file

@ -1,8 +1,9 @@
namespace PKHeX.Core;
public sealed class SaveHandlerSplitResult(byte[] Data, byte[] Header, byte[] Footer)
public sealed class SaveHandlerSplitResult(byte[] Data, byte[] Header, byte[] Footer, ISaveHandler Handler)
{
public readonly byte[] Header = Header;
public readonly byte[] Footer = Footer;
public readonly byte[] Data = Data;
public readonly ISaveHandler Handler = Handler;
}

View file

@ -141,6 +141,7 @@ public static class SaveUtil
new SaveHandlerDeSmuME(),
new SaveHandlerBizHawk(),
new SaveHandlerARDS(),
new SaveHandlerNSO(),
];
#endif
@ -699,7 +700,7 @@ public static class SaveUtil
continue;
var meta = sav.Metadata;
meta.SetExtraInfo(split.Header, split.Footer);
meta.SetExtraInfo(split.Header, split.Footer, split.Handler);
if (path is not null)
meta.SetExtraInfo(path);
return sav;
@ -787,7 +788,7 @@ public static class SaveUtil
}
if (split != null)
sav.Metadata.SetExtraInfo(split.Header, split.Footer);
sav.Metadata.SetExtraInfo(split.Header, split.Footer, split.Handler);
return sav;
}