Check hash integrity on sav open

Clean up the Murmur3 implementation to be more C#-like
This commit is contained in:
Kurt 2026-01-14 15:32:43 -06:00
parent f085748530
commit ac79cb06f9
7 changed files with 93 additions and 101 deletions

View File

@ -5,27 +5,8 @@ namespace NHSE.Core;
/// <summary>
/// Contains the <see cref="HashRegions"/> for a <see cref="FileName"/>.
/// </summary>
public sealed class FileHashDetails
{
/// <summary>
/// Name of the File that these <see cref="HashRegions"/> apply to.
/// </summary>
public readonly string FileName;
/// <summary>
/// Expected file size of the <see cref="FileName"/>.
/// </summary>
public readonly uint FileSize;
/// <summary>
/// Hash specs that are done in this file.
/// </summary>
public readonly IReadOnlyList<FileHashRegion> HashRegions;
public FileHashDetails(string fileName, uint fileSize, IReadOnlyList<FileHashRegion> regions)
{
FileName = fileName;
FileSize = fileSize;
HashRegions = regions;
}
}
/// <param name="FileName">Name of the File that these <see cref="HashRegions"/> apply to.</param>
/// <param name="FileSize">Expected file size of the <see cref="FileName"/>.</param>
/// <param name="HashRegions">Hash specs that are done in this file.</param>
/// <remarks>Checking equality is fine to do the regular shallow check; length essentially governs hash regions.</remarks>
public sealed record FileHashDetails(string FileName, uint FileSize, IReadOnlyList<FileHashRegion> HashRegions);

View File

@ -1,11 +1,13 @@
namespace NHSE.Core;
using System;
namespace NHSE.Core;
/// <summary>
/// Specifies the region that a validation hash is calculated over.
/// </summary>
/// <param name="HashOffset">Offset of the calculated hash.</param>
/// <param name="Size">Length of the hashed data.</param>
public readonly record struct FileHashRegion(int HashOffset, int Size)
/// <param name="Length">Length of the hashed data.</param>
public readonly record struct FileHashRegion(int HashOffset, int Length)
{
/// <summary>
/// Offset where the data to be hashed starts at (calculated).
@ -13,9 +15,11 @@
public int BeginOffset => HashOffset + 4;
/// <summary>
/// Offset where the data to be hashed ends at (calculated).
/// Offset where the data to be hashed ends at (calculated), exclusive.
/// </summary>
public int EndOffset => BeginOffset + Size;
public int EndOffset => BeginOffset + Length;
public Range HashedRange => BeginOffset..EndOffset;
public override string ToString() => $"0x{HashOffset:X}: (0x{BeginOffset:X}-0x{EndOffset:X})";
}

View File

@ -1,4 +1,6 @@
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using static System.Buffers.Binary.BinaryPrimitives;
namespace NHSE.Core;
@ -6,54 +8,29 @@ namespace NHSE.Core;
/// <summary>
/// MurmurHash implementation used by Animal Crossing New Horizons
/// </summary>
/// <remarks>
/// Never is any remainder in inputs, so we don't need to handle that case.
/// </remarks>
public static class Murmur3
{
private static uint Murmur32_Scramble(uint k)
private static uint Scramble(uint value)
{
k = (k * 0x16A88000) | ((k * 0xCC9E2D51) >> 17);
k *= 0x1B873593;
return k;
value = (value * 0x16A88000) | ((value * 0xCC9E2D51) >> 17);
value *= 0x1B873593;
return value;
}
/// <summary>
/// Updates the hash at the specified offset, using the input parameters.
/// </summary>
/// <param name="data">Data to hash</param>
/// <param name="seed">Initial Murmur seed (optional)</param>
/// <returns>Calculated hash.</returns>
public static uint GetMurmur3Hash(ReadOnlySpan<byte> data, uint seed = 0)
private static uint Advance(uint checksum, uint value)
{
var checksum = seed;
var remaining = data;
while (remaining.Length >= sizeof(uint))
{
var val = ReadUInt32LittleEndian(remaining);
checksum ^= Murmur32_Scramble(val);
checksum = (checksum >> 19) | (checksum << 13);
checksum = (checksum * 5) + 0xE6546B64;
remaining = remaining[sizeof(uint)..];
}
checksum ^= Scramble(value);
checksum = (checksum >> 19) | (checksum << 13);
checksum = (checksum * 5) + 0xE6546B64;
return checksum;
}
if (!remaining.IsEmpty)
{
uint val = 0;
switch (remaining.Length)
{
case 3:
val |= (uint)remaining[2] << 16;
goto case 2;
case 2:
val |= (uint)remaining[1] << 8;
goto case 1;
case 1:
val |= remaining[0];
break;
}
checksum ^= Murmur32_Scramble(val);
}
checksum ^= (uint)data.Length;
private static uint Finalize(uint checksum, uint length)
{
checksum ^= length;
checksum ^= checksum >> 16;
checksum *= 0x85EBCA6B;
checksum ^= checksum >> 13;
@ -66,12 +43,47 @@ public static uint GetMurmur3Hash(ReadOnlySpan<byte> data, uint seed = 0)
/// Updates the hash at the specified offset, using the input parameters.
/// </summary>
/// <param name="data">Data to hash</param>
/// <param name="hashDestination">Location two write the hash</param>
/// <returns>Calculated hash that was written back to the data.</returns>
public static uint UpdateMurmur32(ReadOnlySpan<byte> data, Span<byte> hashDestination)
/// <param name="seed">Initial Murmur seed (optional)</param>
/// <returns>Calculated hash.</returns>
public static uint Hash(ReadOnlySpan<byte> data, uint seed = 0)
{
var newHash = GetMurmur3Hash(data);
WriteUInt32LittleEndian(hashDestination, newHash);
return newHash;
Debug.Assert(data.Length % 4 == 0); // no irregular sizes (no remainder if processing as u32*)
var checksum = seed;
var u32 = MemoryMarshal.Cast<byte, uint>(data);
foreach (var x in u32)
{
var value = x;
if (!BitConverter.IsLittleEndian)
value = ReverseEndianness(value);
checksum = Advance(checksum, value);
}
return Finalize(checksum, (uint)data.Length);
}
/// <summary>
/// Attempts to determine the length of data that produces the expected hash.
/// </summary>
/// <param name="data">Data to hash</param>
/// <param name="expect">Expected checksum</param>
/// <param name="seed">Initial Murmur seed (optional)</param>
/// <returns>Length of data (in bytes) that produces the expected hash, or -1 if not found.</returns>
public static int GetLength(ReadOnlySpan<byte> data, uint expect, uint seed = 0)
{
var checksum = seed;
var u32 = MemoryMarshal.Cast<byte, uint>(data);
for (int i = 0; i < u32.Length; i++)
{
var value = u32[i];
if (!BitConverter.IsLittleEndian)
value = ReverseEndianness(value);
checksum = Advance(checksum, value);
var length = ((i + 1) * sizeof(uint));
var actual = Finalize(checksum, (uint)length);
if (actual == expect)
return length;
}
return -1; // not found
}
}

View File

@ -67,10 +67,9 @@ public void Hash()
var ver = Info.GetKnownRevisionIndex();
var hash = RevisionChecker.HashInfo[ver];
var details = hash.GetFile(NameData);
if (details == null)
throw new ArgumentNullException(nameof(NameData));
ArgumentNullException.ThrowIfNull(details, nameof(NameData));
foreach (var h in details.HashRegions)
Murmur3.UpdateMurmur32(Data.Slice(h.BeginOffset, h.Size), Data[h.HashOffset..]);
WriteUInt32LittleEndian(Data[h.HashOffset..], Murmur3.Hash(Data[h.HashedRange]));
}
public IEnumerable<FileHashRegion> InvalidHashes()
@ -78,11 +77,10 @@ public IEnumerable<FileHashRegion> InvalidHashes()
var ver = Info.GetKnownRevisionIndex();
var hash = RevisionChecker.HashInfo[ver];
var details = hash.GetFile(NameData);
if (details == null)
throw new ArgumentNullException(nameof(NameData));
ArgumentNullException.ThrowIfNull(details, nameof(NameData));
foreach (var h in details.HashRegions)
{
var current = Murmur3.GetMurmur3Hash(Data.Slice(h.BeginOffset, h.Size));
var current = Murmur3.Hash(Data[h.HashedRange]);
var saved = ReadUInt32LittleEndian(Data[h.HashOffset..]);
if (current != saved)
yield return h;

View File

@ -3,22 +3,10 @@
/// <summary>
/// Stores file sizes for various savedata files at different patch revisions.
/// </summary>
public class SaveFileSizes
{
public readonly uint Main;
public readonly uint Personal;
public readonly uint PhotoStudioIsland;
public readonly uint PostBox;
public readonly uint Profile;
public readonly uint WhereAreN;
public SaveFileSizes(uint main, uint personal, uint photo, uint postbox, uint profile, uint wherearen = 0)
{
Main = main;
Personal = personal;
PhotoStudioIsland = photo;
PostBox = postbox;
Profile = profile;
WhereAreN = wherearen;
}
}
public sealed record SaveFileSizes(
uint Main,
uint Personal,
uint PhotoStudioIsland,
uint PostBox,
uint Profile,
uint WhereAreN = 0);

View File

@ -1,5 +1,6 @@
using System;
using System.IO;
using System.Linq;
using System.Windows.Forms;
using NHSE.Core;
using NHSE.Injection;
@ -46,6 +47,13 @@ private static void Open(HorizonSave file)
if (prompt != DialogResult.Yes)
return;
}
var isAnyHashBad = file.GetInvalidHashes().Any();
if (isAnyHashBad)
{
var prompt = WinFormsUtil.Prompt(MessageBoxButtons.YesNo, MessageStrings.MsgSaveDataHashMismatch, MessageStrings.MsgAskContinue);
if (prompt != DialogResult.Yes)
return;
}
new Editor(file).Show();
}

View File

@ -26,6 +26,7 @@ public static class MessageStrings
public static string MsgSaveDataExportFail { get; set; } = "Unable to save files to their original location.";
public static string MsgSaveDataHashesValid { get; set; } = "Hashes are valid.";
public static string MsgSaveDataSizeMismatch { get; set; } = "Save file sizes appear to be incorrect.";
public static string MsgSaveDataHashMismatch { get; set; } = "Save file hashes appear to be incorrect.";
public static string MsgMoveOut { get; set; } = "Are you trying to make the Villager move out?";
public static string MsgMoveOutSuggest { get; set; } = "If so, set the Event Flag (024 - ForceMoveOut) to 1 so that the Villager is removed by the game.";