mirror of
https://github.com/kwsch/PKHeX.git
synced 2026-03-21 17:48:28 -05:00
BlankSaveFile -> creation of blank save files SaveFileType -> listing of all savefile types Blank save file arg passing is now clearer Instead of SaveFile? return, use TryGet pattern with nullable annotations to indicate success
752 lines
32 KiB
C#
752 lines
32 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics.CodeAnalysis;
|
|
using System.IO;
|
|
using System.Threading;
|
|
using static System.Buffers.Binary.BinaryPrimitives;
|
|
using static PKHeX.Core.MessageStrings;
|
|
using static PKHeX.Core.SaveFileType;
|
|
|
|
namespace PKHeX.Core;
|
|
|
|
/// <summary>
|
|
/// Logic for <see cref="SaveFile"/> data loading and manipulation.
|
|
/// </summary>
|
|
public static class SaveUtil
|
|
{
|
|
private const int SIZE_G9_0 = 0x31626F; // 1.0.0 fresh
|
|
private const int SIZE_G9_0a = 0x31627C; // 1.0.0 after multiplayer
|
|
private const int SIZE_G9_1 = 0x319DB3; // 1.0.1 fresh
|
|
private const int SIZE_G9_1a = 0x319DC0; // 1.0.1 after multiplayer
|
|
private const int SIZE_G9_3 = 0x319DC3; // 1.1.0 fresh
|
|
private const int SIZE_G9_1Ba = 0x319DD0; // 1.0.1 -> 1.1.0
|
|
private const int SIZE_G9_1A = 0x31A2C0; // 1.0.0 -> 1.0.1
|
|
private const int SIZE_G9_1Aa = 0x31A2CD; // 1.0.0 -> 1.0.1 -> 1.0.1 after multiplayer
|
|
private const int SIZE_G9_1Ab = 0x31A2DD; // 1.0.0 -> 1.0.1 -> 1.0.1 after multiplayer -> 1.1.0
|
|
private const int SIZE_G9_2 = 0x31A2D0; // 1.0.0 -> 1.1.0
|
|
|
|
// 1.2.0: add 0x2C9F; clean upgrade (1.1.0->1.2.0 is same as *1.2.0)
|
|
private const int SIZE_G9_3B1 = SIZE_G9_3A1 - 0xD; // BM
|
|
private const int SIZE_G9_3P1 = SIZE_G9_3B1 + 0x5; // GO (before Multiplayer)
|
|
private const int SIZE_G9_3A1 = 0x31CA6F; // 1.0.1 -> 1.1.0 -> 1.2.0 AM
|
|
private const int SIZE_G9_3G1 = SIZE_G9_3A1 + 0x5; // GO
|
|
|
|
private const int SIZE_G9_3B0 = SIZE_G9_3A0 - 0xD; // BM
|
|
private const int SIZE_G9_3P0 = SIZE_G9_3B0 + 0x5; // GO (before Multiplayer)
|
|
private const int SIZE_G9_3A0 = 0x31CF7C; // 1.0.0 -> 1.0.1 -> 1.1.0 -> 1.2.0 AM
|
|
private const int SIZE_G9_3G0 = SIZE_G9_3A0 + 0x5; // GO
|
|
|
|
// 2.0.1 (2.0.0 skipped): Teal Mask
|
|
// 3.0.0: The Indigo Disk
|
|
// Both save file sizes have a ton of optional blocks, so just accept a range (min/max) and rely on the hash validity.
|
|
private const int SIZE_G9_DLC1_MIN1 = 0x4329A0;
|
|
private const int SIZE_G9_DLC1_MAX1 = 0x4329C9;
|
|
private const int SIZE_G9_DLC1_MIN2 = 0x432EB6;
|
|
private const int SIZE_G9_DLC1_MAX2 = 0x432ED6;
|
|
private const int SIZE_G9_202 = 0xC8E; // Add 2 blocks (1 obj 0xC80, 1 bool) = 4{key}1{obj}4{len} + 4{key}1{boolT/boolF}
|
|
private const int SIZE_G9_300 = 0x83AD;
|
|
|
|
private const int SIZE_G8LA = 0x136DDE;
|
|
private const int SIZE_G8LA_1 = 0x13AD06;
|
|
|
|
public const int SIZE_G8BDSP_0 = 0xE9828;
|
|
public const int SIZE_G8BDSP_1 = 0xEDC20;
|
|
public const int SIZE_G8BDSP_2 = 0xEED8C;
|
|
public const int SIZE_G8BDSP_3 = 0xEF0A4;
|
|
|
|
private const int SIZE_G8SWSH = 0x1716B3; // 1.0
|
|
private const int SIZE_G8SWSH_1 = 0x17195E; // 1.0 -> 1.1
|
|
private const int SIZE_G8SWSH_2 = 0x180B19; // 1.0 -> 1.1 -> 1.2
|
|
private const int SIZE_G8SWSH_2B = 0x180AD0; // 1.0 -> 1.2
|
|
private const int SIZE_G8SWSH_3 = 0x1876B1; // 1.0 -> 1.1 -> 1.2 -> 1.3
|
|
private const int SIZE_G8SWSH_3A = 0x187693; // 1.0 -> 1.1 -> 1.3
|
|
private const int SIZE_G8SWSH_3B = 0x187668; // 1.0 -> 1.2 -> 1.3
|
|
private const int SIZE_G8SWSH_3C = 0x18764A; // 1.0 -> 1.3
|
|
|
|
public const int SIZE_G7GG = 0x100000;
|
|
public const int SIZE_G7USUM = 0x6CC00;
|
|
public const int SIZE_G7SM = 0x6BE00;
|
|
public const int SIZE_G6XY = 0x65600;
|
|
public const int SIZE_G6ORAS = 0x76000;
|
|
public const int SIZE_G6ORASDEMO = 0x5A00;
|
|
public const int SIZE_G5RAW = 0x80000;
|
|
public const int SIZE_G5BW = 0x24000;
|
|
public const int SIZE_G5B2W2 = 0x26000;
|
|
public const int SIZE_G4BR = 0x380000;
|
|
public const int SIZE_G4RAW = 0x80000;
|
|
public const int SIZE_G3BOX = 0x76000;
|
|
public const int SIZE_G3COLO = 0x60000;
|
|
public const int SIZE_G3XD = 0x56000;
|
|
public const int SIZE_G3RAW = 0x20000;
|
|
public const int SIZE_G3RAWHALF = 0x10000;
|
|
public const int SIZE_G2STAD = 0x20000; // same as G3RAW
|
|
public const int SIZE_G2STADF = 0x1FF00;
|
|
public const int SIZE_G2RAW_U = 0x8000;
|
|
public const int SIZE_G2RAW_J = 0x10000;
|
|
public const int SIZE_G1STAD = 0x20000; // same as G3RAW
|
|
public const int SIZE_G1STADF = 0x1FF00;
|
|
public const int SIZE_G1STADJ = 0x8000; // same as G1RAW
|
|
public const int SIZE_G1RAW = 0x8000;
|
|
|
|
// Bank Binaries
|
|
public const int SIZE_G7BANK = 0xACA48;
|
|
public const int SIZE_G4BANK = 0x405C4;
|
|
public const int SIZE_G4RANCH = 0x54000;
|
|
public const int SIZE_G4RANCH_PLAT = 0x7C000;
|
|
|
|
private static readonly SaveHandlerGCI DolphinHandler = new();
|
|
|
|
#if !EXCLUDE_HACKS
|
|
/// <summary>
|
|
/// Specialized readers for loading save files from non-standard games (e.g. hacks).
|
|
/// </summary>
|
|
// ReSharper disable once CollectionNeverUpdated.Global
|
|
public static readonly List<ISaveReader> CustomSaveReaders = [];
|
|
#endif
|
|
|
|
#if !EXCLUDE_EMULATOR_FORMATS
|
|
/// <summary>
|
|
/// Pre-formatters for loading save files from non-standard formats (e.g. emulators).
|
|
/// </summary>
|
|
public static readonly List<ISaveHandler> Handlers =
|
|
[
|
|
new SaveHandlerFooterRTC(),
|
|
DolphinHandler,
|
|
new SaveHandlerDeSmuME(),
|
|
new SaveHandlerARDS(),
|
|
new SaveHandlerNSO(),
|
|
];
|
|
#endif
|
|
|
|
private static bool IsSizeGen9SV(int length) => length is
|
|
SIZE_G9_0 or SIZE_G9_0a or
|
|
SIZE_G9_1 or SIZE_G9_1a or SIZE_G9_1A or SIZE_G9_1Aa or SIZE_G9_1Ba or SIZE_G9_1Ab or
|
|
SIZE_G9_2 or
|
|
SIZE_G9_3 or
|
|
SIZE_G9_3A0 or SIZE_G9_3A1 or SIZE_G9_3B0 or SIZE_G9_3B1 or
|
|
SIZE_G9_3G0 or SIZE_G9_3G1 or SIZE_G9_3P0 or SIZE_G9_3P1 or
|
|
// tons of optional blocks, just rely on range(+) and hash validity
|
|
(>= SIZE_G9_DLC1_MIN1 and <= SIZE_G9_DLC1_MAX1 + 100) or
|
|
(>= SIZE_G9_DLC1_MIN2 and <= SIZE_G9_DLC1_MAX2 + 100) or
|
|
(>= SIZE_G9_DLC1_MIN1 + SIZE_G9_202 and <= SIZE_G9_DLC1_MAX1 + SIZE_G9_202 + 100) or
|
|
(>= SIZE_G9_DLC1_MIN2 + SIZE_G9_202 and <= SIZE_G9_DLC1_MAX2 + SIZE_G9_202 + 100) or
|
|
(>= SIZE_G9_DLC1_MIN1 + SIZE_G9_300 and <= SIZE_G9_DLC1_MAX1 + SIZE_G9_300 + 100) or
|
|
(>= SIZE_G9_DLC1_MIN2 + SIZE_G9_300 and <= SIZE_G9_DLC1_MAX2 + SIZE_G9_300 + 100)
|
|
;
|
|
|
|
private static bool IsSizeGen8SWSH(int length) => length is SIZE_G8SWSH
|
|
or SIZE_G8SWSH_1
|
|
or SIZE_G8SWSH_2 or SIZE_G8SWSH_2B
|
|
or SIZE_G8SWSH_3 or SIZE_G8SWSH_3A or SIZE_G8SWSH_3B or SIZE_G8SWSH_3C;
|
|
|
|
private static bool IsSizeGen2(int length) => length is SIZE_G2RAW_U or SIZE_G2RAW_J;
|
|
|
|
private static bool IsSizeCommonFixed(int length) => length is
|
|
SIZE_G8LA or SIZE_G8LA_1
|
|
or SIZE_G8BDSP_0 or SIZE_G8BDSP_1 or SIZE_G8BDSP_2 or SIZE_G8BDSP_3
|
|
or SIZE_G7SM or SIZE_G7USUM or SIZE_G7GG
|
|
or SIZE_G6XY or SIZE_G6ORAS or SIZE_G6ORASDEMO
|
|
or SIZE_G5RAW or SIZE_G5BW or SIZE_G5B2W2
|
|
or SIZE_G4BR
|
|
or SIZE_G3COLO or SIZE_G3XD or SIZE_G3RAW or SIZE_G3RAWHALF
|
|
or SIZE_G2RAW_U
|
|
or SIZE_G7BANK or SIZE_G4BANK or SIZE_G4RANCH or SIZE_G4RANCH_PLAT;
|
|
|
|
/// <summary>Determines the type of the provided save data.</summary>
|
|
/// <param name="data">Save data of which to determine the origins of</param>
|
|
/// <returns>Save file type information including sub-version details, or Invalid if type cannot be determined.</returns>
|
|
private static SaveTypeInfo GetTypeInfo(ReadOnlySpan<byte> data)
|
|
{
|
|
// Mainline
|
|
if (IsG1(data, out var info)) return info;
|
|
if (IsG2(data, out info)) return info;
|
|
if (IsG3(data, out var smallOffset)) return GetVersionG3SAV(data[smallOffset..]);
|
|
if (IsG4DP(data)) return DP;
|
|
if (IsG4Pt(data)) return Pt;
|
|
if (IsG4HGSS(data)) return HGSS;
|
|
if (IsG5BW(data)) return BW;
|
|
if (IsG5B2W2(data)) return B2W2;
|
|
if (IsG6XY(data)) return XY;
|
|
if (IsG6AO(data)) return AO;
|
|
if (IsG6AODemo(data)) return AODemo;
|
|
if (IsG7SM(data)) return SM;
|
|
if (IsG7USUM(data)) return USUM;
|
|
if (IsG7LGPE(data)) return LGPE;
|
|
if (IsG8SWSH(data)) return SWSH;
|
|
if (IsG8BDSP(data)) return BDSP;
|
|
if (IsG8LA(data)) return LA;
|
|
if (IsG9SV(data)) return SV;
|
|
|
|
// Side-game
|
|
if (IsG3Colosseum(data)) return Colosseum;
|
|
if (IsG3XD(data)) return XD;
|
|
if (IsG3RSBox(data)) return RSBox;
|
|
if (IsG4BR(data)) return BattleRevolution;
|
|
|
|
// Adjacent/misc.
|
|
if (IsBank7(data)) return Bulk7; // pokebank
|
|
if (IsBank4(data)) return Bulk4; // pokestock
|
|
if (IsBank3(data)) return Bulk3; // pokestock
|
|
if (IsRanch4(data)) return Ranch;
|
|
if (SAV2Stadium.IsStadium(data)) return Stadium2;
|
|
if (SAV1Stadium.IsStadium(data)) return Stadium1;
|
|
if (SAV1StadiumJ.IsStadium(data)) return Stadium1J;
|
|
|
|
return SaveTypeInfo.Invalid;
|
|
}
|
|
|
|
private static bool IsG1INT(ReadOnlySpan<byte> data) => HasListAt(data, 0x2F2C, 0x30C0, 20);
|
|
private static bool IsG1JPN(ReadOnlySpan<byte> data) => HasListAt(data, 0x2ED5, 0x302D, 30);
|
|
private static bool IsG2GSINT(ReadOnlySpan<byte> data) => HasListAt(data, 0x288A, 0x2D6C, 20);
|
|
private static bool IsG2GSJPN(ReadOnlySpan<byte> data) => HasListAt(data, 0x2D10, 0x283E, 30);
|
|
private static bool IsG2GSKOR(ReadOnlySpan<byte> data) => HasListAt(data, 0x2DAE, 0x28CC, 20);
|
|
private static bool IsG2CrystalINT(ReadOnlySpan<byte> data) => HasListAt(data, 0x2865, 0x2D10, 20);
|
|
private static bool IsG2CrystalJPN(ReadOnlySpan<byte> data) => HasListAt(data, 0x283E, 0x281A, 30);
|
|
private static bool HasListAt(ReadOnlySpan<byte> data, [ConstantExpected] int offset1, [ConstantExpected] int offset2, [ConstantExpected] byte maxCount) =>
|
|
IsListValidG12(data, offset1, maxCount) && IsListValidG12(data, offset2, maxCount);
|
|
|
|
/// <summary>
|
|
/// Determines if a Gen1/2 Pokémon List is Invalid
|
|
/// </summary>
|
|
/// <param name="data">Save data</param>
|
|
/// <param name="offset">Offset the list starts at</param>
|
|
/// <param name="maxCount">Max count of Pokémon in the list</param>
|
|
/// <returns>True if a valid list, False otherwise</returns>
|
|
private static bool IsListValidG12(ReadOnlySpan<byte> data, int offset, [ConstantExpected] byte maxCount)
|
|
{
|
|
byte count = data[offset];
|
|
return count <= maxCount && data[offset + 1 + count] == 0xFF;
|
|
}
|
|
|
|
/// <summary>Checks to see if the data belongs to a Gen1 save</summary>
|
|
/// <param name="data">Save data of which to determine the type</param>
|
|
/// <param name="info">Recognized save file type, if any.</param>
|
|
/// <returns>Version Identifier or Invalid if type cannot be determined.</returns>
|
|
private static bool IsG1(ReadOnlySpan<byte> data, out SaveTypeInfo info)
|
|
{
|
|
info = default;
|
|
if (data.Length is not SIZE_G1RAW)
|
|
return false;
|
|
|
|
// Check if it's not an international save or a Japanese save
|
|
if (IsG1JPN(data))
|
|
{ info = new SaveTypeInfo(RBY, SAV1.IsYellowJPN(data) ? GameVersion.YW : default, LanguageID.Japanese); return true; }
|
|
if (IsG1INT(data))
|
|
{ info = new SaveTypeInfo(RBY, SAV1.IsYellowINT(data) ? GameVersion.YW : default); return true; }
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>Checks to see if the data belongs to a Gen2 save</summary>
|
|
/// <param name="data">Save data of which to determine the type</param>
|
|
/// <param name="info">Recognized save file type, if any.</param>
|
|
/// <returns>Version Identifier or Invalid if type cannot be determined.</returns>
|
|
private static bool IsG2(ReadOnlySpan<byte> data, out SaveTypeInfo info)
|
|
{
|
|
info = default;
|
|
if (!IsSizeGen2(data.Length))
|
|
return false;
|
|
|
|
// Check if it's not an International, Japanese, or Korean save file
|
|
// International
|
|
if (IsG2GSINT(data))
|
|
{ info = new SaveTypeInfo(GSC, GameVersion.GS); return true; }
|
|
if (IsG2CrystalINT(data))
|
|
{ info = new SaveTypeInfo(GSC, GameVersion.C); return true; }
|
|
|
|
// Japanese
|
|
if (IsG2GSJPN(data))
|
|
{ info = new SaveTypeInfo(GSC, GameVersion.GS, LanguageID.Japanese); return true; }
|
|
if (IsG2CrystalJPN(data))
|
|
{ info = new SaveTypeInfo(GSC, GameVersion.C, LanguageID.Japanese); return true; }
|
|
|
|
// Korean
|
|
if (IsG2GSKOR(data))
|
|
{ info = new SaveTypeInfo(GSC, GameVersion.GS, LanguageID.Korean); return true; }
|
|
return false;
|
|
}
|
|
|
|
/// <summary>Checks to see if the data belongs to a Gen3 save</summary>
|
|
/// <param name="data">Save data of which to determine the type</param>
|
|
/// <param name="smallOffset">Offset to the small sector of the save file, if applicable.</param>
|
|
/// <returns>Version Identifier or Invalid if type cannot be determined.</returns>
|
|
private static bool IsG3(ReadOnlySpan<byte> data, out int smallOffset)
|
|
{
|
|
smallOffset = 0;
|
|
if (data.Length is not SIZE_G3RAW)
|
|
return false;
|
|
|
|
// check the save file(s)
|
|
int count = data.Length/SIZE_G3RAWHALF;
|
|
for (int slot = 0; slot < count; slot++)
|
|
{
|
|
if (SAV3.IsAllMainSectorsPresent(data, slot, out smallOffset))
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks the input <see cref="data"/> to see which game is for this file.
|
|
/// </summary>
|
|
/// <param name="data">Data to check</param>
|
|
/// <returns>RS, E, or FR/LG.</returns>
|
|
private static SaveFileType GetVersionG3SAV(ReadOnlySpan<byte> data)
|
|
{
|
|
// 0xAC
|
|
// RS: Battle Tower Data, which will never match the FR/LG fixed value.
|
|
// E: Encryption Key
|
|
// FR/LG @ 0xAC has a fixed value (01 00 00 00)
|
|
// RS has battle tower data (variable)
|
|
uint _0xAC = ReadUInt32LittleEndian(data[0xAC..]);
|
|
switch (_0xAC)
|
|
{
|
|
case 1: return FRLG; // fixed value
|
|
case 0: return RS; // save has no battle tower record data
|
|
default:
|
|
// RS data structure only extends 0x890 bytes; check if any data is present afterward.
|
|
var remainder = data[0x890..0xF2C];
|
|
if (remainder.ContainsAnyExcept<byte>(0))
|
|
return Emerald;
|
|
return RS;
|
|
}
|
|
}
|
|
|
|
/// <summary>Checks to see if the data belongs to a Gen3 Box RS save</summary>
|
|
/// <param name="data">Save data of which to determine the type</param>
|
|
/// <returns>Version Identifier or Invalid if type cannot be determined.</returns>
|
|
private static bool IsG3RSBox(ReadOnlySpan<byte> data)
|
|
{
|
|
if (data.Length is not SIZE_G3BOX)
|
|
return false;
|
|
|
|
// Verify first checksum
|
|
const int offset = 0x2000;
|
|
var span = data.Slice(offset, 0x1FFC);
|
|
var actual = ReadUInt32BigEndian(span);
|
|
var chk = Checksums.CheckSum16BigInvert(span[4..]);
|
|
return chk == actual;
|
|
}
|
|
|
|
/// <summary>Checks to see if the data belongs to a Colosseum save</summary>
|
|
/// <param name="data">Save data of which to determine the type</param>
|
|
/// <returns>Version Identifier or Invalid if type cannot be determined.</returns>
|
|
private static bool IsG3Colosseum(ReadOnlySpan<byte> data)
|
|
{
|
|
if (data.Length is not SIZE_G3COLO)
|
|
return false;
|
|
|
|
// Check the intro bytes for each save slot
|
|
const int offset = 0x6000;
|
|
for (int i = 0; i < 3; i++)
|
|
{
|
|
var ofs = offset + (0x1E000 * i);
|
|
if (ReadUInt32LittleEndian(data[ofs..]) != 0x00000101)
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/// <summary>Checks to see if the data belongs to a Gen3 XD save</summary>
|
|
/// <param name="data">Save data of which to determine the type</param>
|
|
/// <returns>Version Identifier or Invalid if type cannot be determined.</returns>
|
|
private static bool IsG3XD(ReadOnlySpan<byte> data)
|
|
{
|
|
if (data.Length is not SIZE_G3XD)
|
|
return false;
|
|
|
|
// Check the intro bytes for each save slot
|
|
const int offset = 0x6000;
|
|
for (int i = 0; i < 2; i++)
|
|
{
|
|
var ofs = offset + (0x28000 * i);
|
|
if ((ReadUInt32LittleEndian(data[ofs..]) & 0xFFFE_FFFF) != 0x00000101)
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private static bool IsG4DP(ReadOnlySpan<byte> data) => data.Length == SIZE_G4RAW && IsValidGeneralFooter2(data, SAV4DP.GeneralSize);
|
|
private static bool IsG4Pt(ReadOnlySpan<byte> data) => data.Length == SIZE_G4RAW && IsValidGeneralFooter2(data, SAV4Pt.GeneralSize);
|
|
private static bool IsG4HGSS(ReadOnlySpan<byte> data) => data.Length == SIZE_G4RAW && IsValidGeneralFooter2(data, SAV4HGSS.GeneralSize);
|
|
private static bool IsG4BR(ReadOnlySpan<byte> data) => data.Length == SIZE_G4BR && SAV4BR.IsValidSaveFile(data);
|
|
private static bool IsG5BW(ReadOnlySpan<byte> data) => data.Length == SIZE_G5BW && IsValidFooter5(data, SIZE_G5BW, 0x8C);
|
|
private static bool IsG5B2W2(ReadOnlySpan<byte> data) => data.Length == SIZE_G5B2W2 && IsValidFooter5(data, SIZE_G5B2W2, 0x94);
|
|
private static bool IsG6XY(ReadOnlySpan<byte> data) => data.Length == SIZE_G6XY && HasSaveFooterBEEF(data);
|
|
private static bool IsG6AO(ReadOnlySpan<byte> data) => data.Length == SIZE_G6ORAS && HasSaveFooterBEEF(data);
|
|
private static bool IsG6AODemo(ReadOnlySpan<byte> data) => data.Length == SIZE_G6ORASDEMO && HasSaveFooterBEEF(data);
|
|
private static bool IsG7SM(ReadOnlySpan<byte> data) => data.Length is (SIZE_G7SM) && HasSaveFooterBEEF(data);
|
|
private static bool IsG7USUM(ReadOnlySpan<byte> data) => data.Length is (SIZE_G7USUM) && HasSaveFooterBEEF(data);
|
|
|
|
private static bool IsValidGeneralFooter2(ReadOnlySpan<byte> data, [ConstantExpected] int length)
|
|
{
|
|
// Check the other save -- first save is done to the latter half of the binary.
|
|
// The second save should be all that is needed to check.
|
|
const int generalOffset = 0x40000;
|
|
var general = data.Slice(generalOffset, length);
|
|
|
|
// The block footers contain a 32-bit 'size' followed by a 32-bit binary-coded-decimal timestamp
|
|
// Korean saves have a different timestamp from other localizations.
|
|
var size = ReadUInt32LittleEndian(general[^0xC..]);
|
|
if (size != general.Length)
|
|
return false;
|
|
var sdk = ReadUInt32LittleEndian(general[^0x8..]);
|
|
return sdk is SAV4.MAGIC_JAPAN_INTL or SAV4.MAGIC_KOREAN;
|
|
}
|
|
|
|
private static bool IsValidFooter5(ReadOnlySpan<byte> data, int mainSize, int infoLength)
|
|
{
|
|
var footer = data.Slice(mainSize - 0x100, infoLength + 0x10);
|
|
var stored = ReadUInt16LittleEndian(footer[^2..]);
|
|
var actual = Checksums.CRC16_CCITT(footer[..infoLength]);
|
|
return stored == actual;
|
|
}
|
|
|
|
private static bool HasSaveFooterBEEF(ReadOnlySpan<byte> data) => ReadUInt32LittleEndian(data[^0x1F0..]) == 0x42454546; // BEEF
|
|
|
|
/// <summary>Determines if the input data belongs to a <see cref="SAV7b"/> save</summary>
|
|
/// <param name="data">Save data of which to determine the type</param>
|
|
/// <returns>Version Identifier or Invalid if type cannot be determined.</returns>
|
|
private static bool IsG7LGPE(ReadOnlySpan<byte> data)
|
|
{
|
|
if (data.Length != SIZE_G7GG)
|
|
return false;
|
|
|
|
data = data[..0xB8800];
|
|
const int actualLength = 0xB8800;
|
|
if (!HasSaveFooterBEEF(data))
|
|
return false;
|
|
if (ReadUInt16LittleEndian(data[(actualLength - 0x200 + 0xB0)..]) != 0x13) // check a block number to double-check
|
|
return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
private static bool IsG8BDSP(ReadOnlySpan<byte> data) => data.Length switch
|
|
{
|
|
SIZE_G8BDSP_0 => (Gem8Version)ReadUInt32LittleEndian(data) == Gem8Version.V1_0,
|
|
SIZE_G8BDSP_1 => (Gem8Version)ReadUInt32LittleEndian(data) == Gem8Version.V1_1,
|
|
SIZE_G8BDSP_2 => (Gem8Version)ReadUInt32LittleEndian(data) == Gem8Version.V1_2,
|
|
SIZE_G8BDSP_3 => (Gem8Version)ReadUInt32LittleEndian(data) == Gem8Version.V1_3,
|
|
_ => false
|
|
};
|
|
|
|
private static bool IsG8LA(ReadOnlySpan<byte> data) => data.Length is SIZE_G8LA or SIZE_G8LA_1 && SwishCrypto.GetIsHashValid(data);
|
|
private static bool IsG8SWSH(ReadOnlySpan<byte> data) => IsSizeGen8SWSH(data.Length) && SwishCrypto.GetIsHashValid(data);
|
|
private static bool IsG9SV(ReadOnlySpan<byte> data) => IsSizeGen9SV(data.Length) && SwishCrypto.GetIsHashValid(data);
|
|
|
|
private static bool IsBank7(ReadOnlySpan<byte> data) => data.Length == SIZE_G7BANK && data[0] != 0;
|
|
private static bool IsBank4(ReadOnlySpan<byte> data) => data.Length == SIZE_G4BANK && ReadUInt32LittleEndian(data[0x3FC00..]) != 0; // box name present
|
|
private static bool IsBank3(ReadOnlySpan<byte> data) => data.Length == SIZE_G4BANK && ReadUInt32LittleEndian(data[0x3FC00..]) == 0; // size collision with ^
|
|
private static bool IsRanchDP(ReadOnlySpan<byte> data) => data.Length == SIZE_G4RANCH && ReadUInt32BigEndian(data[0x22AC..]) != 0;
|
|
private static bool IsRanchPlat(ReadOnlySpan<byte> data) => data.Length == SIZE_G4RANCH_PLAT && ReadUInt32BigEndian(data[0x268C..]) != 0;
|
|
private static bool IsRanch4(ReadOnlySpan<byte> data) => IsRanchDP(data) || IsRanchPlat(data);
|
|
|
|
/// <summary>Creates an instance of a SaveFile using the given save data.</summary>
|
|
/// <param name="path">File location from which to create a SaveFile.</param>
|
|
/// <param name="result">Save file loaded from the given path, or null if loading failed.</param>
|
|
public static bool TryGetSaveFile(string path, [NotNullWhen(true)] out SaveFile? result)
|
|
{
|
|
// Many things can go wrong with loading save data (file no longer present toc-tou, or bad save layout).
|
|
try
|
|
{
|
|
var data = File.ReadAllBytes(path);
|
|
if (!TryGetSaveFile(data, out result, path))
|
|
return false;
|
|
|
|
result.Metadata.SetExtraInfo(path);
|
|
if (result.Generation <= 3)
|
|
SaveLanguage.TryRevise(result);
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
System.Diagnostics.Debug.WriteLine(ex);
|
|
result = null;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>Creates an instance of a SaveFile using the given save data.</summary>
|
|
/// <param name="path">File location from which to create a SaveFile.</param>
|
|
/// <returns>An appropriate type of save file for the given data, or null if the save data is invalid.</returns>
|
|
public static SaveFile? GetSaveFile(string path) => TryGetSaveFile(path, out var result) ? result : null;
|
|
|
|
/// <summary>Creates an instance of a SaveFile using the given save data.</summary>
|
|
/// <param name="data">Save data from which to create a SaveFile.</param>
|
|
/// <param name="path">File path, may help initialize a non-standard save file format.</param>
|
|
/// <returns>An appropriate type of save file for the given data, or null if the save data is invalid.</returns>
|
|
public static SaveFile? GetSaveFile(Memory<byte> data, string? path = null) => TryGetSaveFile(data, out var result, path) ? result : null;
|
|
|
|
/// <summary>Creates an instance of a SaveFile using the given save data.</summary>
|
|
/// <param name="data">Save data from which to create a SaveFile.</param>
|
|
/// <param name="result">Save file loaded from the given data, or null if loading failed.</param>
|
|
/// <param name="path">Optional save file path, may help initialize a non-standard save file format.</param>
|
|
/// <returns>An appropriate type of save file for the given data, or null if the save data is invalid.</returns>
|
|
public static bool TryGetSaveFile(Memory<byte> data, [NotNullWhen(true)] out SaveFile? result, string? path = null)
|
|
{
|
|
#if !EXCLUDE_HACKS
|
|
if (TryGetSaveFileCustom(data, out result, path))
|
|
return true;
|
|
#endif
|
|
|
|
result = GetSaveFileInternal(data);
|
|
if (result is not null)
|
|
return true;
|
|
|
|
#if !EXCLUDE_EMULATOR_FORMATS
|
|
if (TryGetSaveFileHandler(data, out result, path))
|
|
return true;
|
|
#endif
|
|
|
|
// unrecognized.
|
|
return false;
|
|
}
|
|
|
|
private static bool TryGetSaveFileCustom(Memory<byte> data, [NotNullWhen(true)] out SaveFile? result, string? path)
|
|
{
|
|
foreach (var h in CustomSaveReaders)
|
|
{
|
|
if (!h.IsRecognized(data.Length))
|
|
continue;
|
|
|
|
result = h.ReadSaveFile(data, path);
|
|
if (result is not null)
|
|
return true;
|
|
}
|
|
result = null;
|
|
return false;
|
|
}
|
|
|
|
private static bool TryGetSaveFileHandler(Memory<byte> data, [NotNullWhen(true)] out SaveFile? result, string? path)
|
|
{
|
|
foreach (var h in Handlers)
|
|
{
|
|
if (!h.IsRecognized(data.Length))
|
|
continue;
|
|
|
|
var split = h.TrySplit(data);
|
|
if (split is null)
|
|
continue;
|
|
|
|
result = GetSaveFileInternal(split.Data);
|
|
if (result is null)
|
|
continue;
|
|
|
|
var meta = result.Metadata;
|
|
meta.SetExtraInfo(split.Header, split.Footer, split.Handler);
|
|
if (path is not null)
|
|
meta.SetExtraInfo(path);
|
|
return true;
|
|
}
|
|
result = null;
|
|
return false;
|
|
}
|
|
|
|
private static SaveFile? GetSaveFileInternal(Memory<byte> data)
|
|
{
|
|
var typeInfo = GetTypeInfo(data.Span);
|
|
return GetSaveFileInternal(data, typeInfo);
|
|
}
|
|
|
|
private static SaveFile? GetSaveFileInternal(Memory<byte> data, SaveTypeInfo typeInfo) => typeInfo.Type switch
|
|
{
|
|
// Main Games
|
|
RBY => new SAV1(data, typeInfo.Language, typeInfo.SubVersion),
|
|
GSC => new SAV2(data, typeInfo.Language, typeInfo.SubVersion),
|
|
|
|
RS => new SAV3RS(data),
|
|
Emerald => new SAV3E(data),
|
|
FRLG => new SAV3FRLG(data),
|
|
|
|
DP => new SAV4DP(data),
|
|
Pt => new SAV4Pt(data),
|
|
HGSS => new SAV4HGSS(data),
|
|
|
|
BW => new SAV5BW(data),
|
|
B2W2 => new SAV5B2W2(data),
|
|
|
|
XY => new SAV6XY(data),
|
|
AO => new SAV6AO(data),
|
|
AODemo => new SAV6AODemo(data),
|
|
|
|
SM => new SAV7SM(data),
|
|
USUM => new SAV7USUM(data),
|
|
LGPE => new SAV7b(data),
|
|
|
|
SWSH => new SAV8SWSH(data),
|
|
BDSP => new SAV8BS(data),
|
|
LA => new SAV8LA(data),
|
|
|
|
SV => new SAV9SV(data),
|
|
|
|
// Side Games
|
|
Colosseum => new SAV3Colosseum(data),
|
|
XD => new SAV3XD(data),
|
|
RSBox => new SAV3RSBox(data),
|
|
BattleRevolution => new SAV4BR(data),
|
|
Stadium2 => new SAV2Stadium(data),
|
|
Stadium1 => new SAV1Stadium(data),
|
|
Stadium1J => new SAV1StadiumJ(data),
|
|
|
|
// Bulk Storage
|
|
Ranch => new SAV4Ranch(data),
|
|
Bulk3 => new Bank3(data),
|
|
Bulk4 => new Bank4(data),
|
|
Bulk7 => Bank7.GetBank7(data),
|
|
|
|
// No pattern matched
|
|
_ => null,
|
|
};
|
|
|
|
public static bool TryGetSaveFile(SAV3GCMemoryCard memCard, [NotNullWhen(true)] out SaveFile? result)
|
|
{
|
|
// Pre-check for header/footer signatures
|
|
if (memCard.IsNoGameSelected)
|
|
memCard.GetMemoryCardState();
|
|
|
|
result = null;
|
|
var peek = memCard.ReadSaveGameData();
|
|
if (peek.Length == 0)
|
|
return false;
|
|
|
|
var memory = peek.ToArray();
|
|
var split = DolphinHandler.TrySplit(memory);
|
|
var data = split?.Data ?? memory;
|
|
|
|
SaveFile sav;
|
|
switch (memCard.SelectedGameVersion)
|
|
{
|
|
// Side Games
|
|
case Colosseum: sav = new SAV3Colosseum(data) { MemoryCard = memCard }; break;
|
|
case XD: sav = new SAV3XD(data) { MemoryCard = memCard }; break;
|
|
case RSBox: sav = new SAV3RSBox(data) { MemoryCard = memCard }; break;
|
|
|
|
// No pattern matched
|
|
default: return false;
|
|
}
|
|
|
|
if (split is not null)
|
|
sav.Metadata.SetExtraInfo(split.Header, split.Footer, split.Handler);
|
|
result = sav;
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retrieves possible save file paths from the provided <see cref="folderPath"/>.
|
|
/// </summary>
|
|
/// <param name="folderPath">Folder to look within</param>
|
|
/// <param name="deep">Search all subfolders</param>
|
|
/// <param name="token">Cancellation token to cancel the operation.</param>
|
|
/// <param name="result">If this function returns true, full path of all <see cref="SaveFile"/> that match criteria. If this function returns false, the error message, or null if the directory could not be found</param>
|
|
/// <param name="ignoreBackups">Option to ignore files with backup names and extensions</param>
|
|
/// <returns>Boolean indicating if the operation was successful.</returns>
|
|
public static bool GetSavesFromFolder(string folderPath, bool deep, CancellationToken token, out IEnumerable<string> result, bool ignoreBackups = true)
|
|
{
|
|
if (!Directory.Exists(folderPath))
|
|
{
|
|
result = [];
|
|
return false;
|
|
}
|
|
try
|
|
{
|
|
var searchOption = deep ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
|
|
var files = Directory.EnumerateFiles(folderPath, "*", searchOption)
|
|
.IterateSafe(log: z => System.Diagnostics.Debug.WriteLine(z));
|
|
result = FilterSaveFiles(ignoreBackups, files, token);
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
result =
|
|
[
|
|
MsgFileLoadFailAuto + Environment.NewLine + folderPath,
|
|
MsgFileLoadFailAutoAdvise + Environment.NewLine + MsgFileLoadFailAutoCause,
|
|
ex.Message,
|
|
];
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static IEnumerable<string> FilterSaveFiles(bool ignoreBackups, IEnumerable<string> files, CancellationToken token)
|
|
{
|
|
foreach (var file in files)
|
|
{
|
|
if (token.IsCancellationRequested)
|
|
yield break;
|
|
|
|
if (ignoreBackups && IsBackupFilePath(file))
|
|
continue;
|
|
|
|
var size = FileUtil.GetFileSize(file);
|
|
if (!IsSizeValid(size))
|
|
continue;
|
|
|
|
yield return file;
|
|
}
|
|
}
|
|
|
|
public static bool IsBackupFilePath(ReadOnlySpan<char> path)
|
|
{
|
|
// Gen8+ store main,backup,poke_trade
|
|
var fn = Path.GetFileNameWithoutExtension(path);
|
|
if (fn is "backup")
|
|
return true;
|
|
|
|
// Programs storing backups via .bak extension
|
|
var ext = Path.GetExtension(path);
|
|
return ext is ".bak";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Determines whether the save data size is valid for automatically detecting saves.
|
|
/// </summary>
|
|
/// <param name="size">Size in bytes of the save data</param>
|
|
/// <returns>A boolean indicating if the save data size is valid.</returns>
|
|
public static bool IsSizeValid(long size) => IsSizeValidNoHandler(size) || IsSizeValidHandler(size) || SAV3GCMemoryCard.IsMemoryCardSize(size);
|
|
|
|
/// <summary>
|
|
/// Determines whether the save data size is valid for automatically detecting saves.
|
|
/// </summary>
|
|
/// <remarks>Only checks the <see cref="Handlers"/> list.</remarks>
|
|
public static bool IsSizeValidHandler(long size) => Handlers.Exists(z => z.IsRecognized(size));
|
|
|
|
/// <summary>
|
|
/// Determines whether the save data size is valid for automatically detecting saves.
|
|
/// </summary>
|
|
/// <remarks>Does not check the <see cref="Handlers"/> list.</remarks>
|
|
public static bool IsSizeValidNoHandler(long size)
|
|
{
|
|
if ((uint)size > int.MaxValue)
|
|
return false;
|
|
int length = (int)size;
|
|
if (IsSizeGen9SV(length))
|
|
return true;
|
|
if (IsSizeGen8SWSH(length))
|
|
return true;
|
|
if (IsSizeCommonFixed(length))
|
|
return true;
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stores the result from a save detection.
|
|
/// </summary>
|
|
/// <param name="Type">The save file type detected</param>
|
|
/// <param name="SubVersion">Specific game version within the type, or Any if not distinguished</param>
|
|
private readonly record struct SaveTypeInfo(SaveFileType Type, GameVersion SubVersion = default, LanguageID Language = default)
|
|
{
|
|
/// <summary>
|
|
/// Implicit conversion from SaveTypeInfo to SaveFileType for convenience.
|
|
/// </summary>
|
|
public static implicit operator SaveFileType(SaveTypeInfo info) => info.Type;
|
|
|
|
public static implicit operator SaveTypeInfo(SaveFileType type) => new(type);
|
|
|
|
/// <summary>
|
|
/// Returns Invalid save type info.
|
|
/// </summary>
|
|
public static SaveTypeInfo Invalid => default;
|
|
}
|
|
}
|