From a8f26ccf08c8c6e9ef1a8bdbef828d6afef66d83 Mon Sep 17 00:00:00 2001 From: YoshiRulz Date: Thu, 16 Mar 2023 03:23:05 +1000 Subject: [PATCH] Add `.bps` patch applier currently unused, like `.ips` patch applier; will return to hook up to loading (currently flagged as good dump if base was), write unit tests, and add a way to save patched rom to disk --- src/BizHawk.Client.Common/RomGame.cs | 11 ++ src/BizHawk.Common/BPSPatcher.cs | 131 ++++++++++++++++++ .../Extensions/CollectionExtensions.cs | 15 ++ 3 files changed, 157 insertions(+) diff --git a/src/BizHawk.Client.Common/RomGame.cs b/src/BizHawk.Client.Common/RomGame.cs index 48b4dbee24..c436e99a43 100644 --- a/src/BizHawk.Client.Common/RomGame.cs +++ b/src/BizHawk.Client.Common/RomGame.cs @@ -114,18 +114,29 @@ namespace BizHawk.Client.Common if (patch is null) return; using var patchFile = new HawkFile(patch); patchFile.BindFirstOf(".ips"); + if (!patchFile.IsBound) patchFile.BindFirstOf(".bps"); if (!patchFile.IsBound) return; var patchBytes = patchFile.GetStream().ReadAllBytes(); if (BPSPatcher.IsIPSFile(patchBytes)) { RomData = BPSPatcher.Patch(RomData, new BPSPatcher.IPSPayload(patchBytes)); } + else if (BPSPatcher.IsBPSFile(patchBytes, out var patchStruct)) + { + var ignoreBaseChecksum = true; //TODO check base checksum and ask user before continuing + RomData = BPSPatcher.Patch(StripSNESDumpHeader(RomData), patchStruct, out var checksumsMatch); + if (!checksumsMatch && !ignoreBaseChecksum) throw new Exception("BPS patch didn't produce the expected output"); + } else { throw new Exception("doesn't appear to be a BPS or IPS patch"); } } + /// https://snes.nesdev.org/wiki/ROM_file_formats#Detecting_Headered_ROM + private static ReadOnlySpan StripSNESDumpHeader(ReadOnlySpan rom) + => rom.Length % 512 is 0 ? rom : rom.Slice(start: 512); + private static byte[] DeInterleaveSMD(byte[] source) { // SMD files are interleaved in pages of 16k, with the first 8k containing all diff --git a/src/BizHawk.Common/BPSPatcher.cs b/src/BizHawk.Common/BPSPatcher.cs index 5486029abc..556f9a2a9f 100644 --- a/src/BizHawk.Common/BPSPatcher.cs +++ b/src/BizHawk.Common/BPSPatcher.cs @@ -3,10 +3,117 @@ using System.Collections.Generic; using System.Diagnostics; using System.Text; +using BizHawk.Common.BufferExtensions; +using BizHawk.Common.CollectionExtensions; + namespace BizHawk.Common { public static class BPSPatcher { + /// + /// constructor assumes valid header/footer
+ /// https://github.com/Alcaro/Flips/blob/master/bps_spec.md + ///
+ /// + public readonly ref struct BPSPayload + { + internal static readonly byte[] HEADER = Encoding.ASCII.GetBytes("BPS1"); + + public static int DecodeVarInt(ReadOnlySpan data, ref int i) + { + // no idea how this works or what it will do if a varint is too large, I just copied from the spec --yoshi + // also casting to int (for use with Span) so the max is even smaller + ulong num = 0, mul = 1; + while (true) + { + var x = data[i++]; + num += (x & 0b0111_1111UL) * mul; + if ((x & 0b1000_0000UL) is not 0UL) break; + mul <<= 7; + num += mul; + } + return (int) num; + } + + private readonly ReadOnlySpan _data; + + private readonly bool _isValid; + + private readonly ReadOnlySpan _sourceChecksum; + + private readonly int _sourceSize; + + private readonly ReadOnlySpan _targetChecksum; + + public readonly int TargetSize; + + internal readonly ReadOnlySpan PatchChecksum; + + /// assumes valid header/footer + /// + public BPSPayload(ReadOnlySpan dataWithHeader) + { + _isValid = true; + var i = 4; + _sourceSize = DecodeVarInt(dataWithHeader, ref i); + TargetSize = DecodeVarInt(dataWithHeader, ref i); + var metadataSize = DecodeVarInt(dataWithHeader, ref i); + if (metadataSize is not 0) + { + Console.WriteLine($"ignoring {metadataSize} bytes of .bps metadata"); + i += metadataSize; + } + _data = dataWithHeader.Slice(start: i, length: dataWithHeader.Length - 12 - i); + _sourceChecksum = dataWithHeader.Slice(start: dataWithHeader.Length - 12, length: 4); + _targetChecksum = dataWithHeader.Slice(start: dataWithHeader.Length - 8, length: 4); + PatchChecksum = dataWithHeader.Slice(start: dataWithHeader.Length - 4, length: 4); + } + + /// iff checksums of base rom and result both matched + public bool DoPatch(ReadOnlySpan baseRom, Span target) + { + if (!_isValid) throw new InvalidOperationException(ERR_MSG_UNINIT); + if (target.Length != TargetSize) throw new ArgumentException(message: $"target buffer too {(target.Length < TargetSize ? "small" : "large")}", paramName: nameof(target)); + if (baseRom.Length != _sourceSize) throw new ArgumentException(message: $"target buffer too {(baseRom.Length < _sourceSize ? "small" : "large")}", paramName: nameof(baseRom)); + var checksumsMatch = CheckCRC(data: baseRom, reversedChecksum: _sourceChecksum); + var outputOffset = 0; + var sourceRelOffset = 0; + var targetRelOffset = 0; + var i = 0; + while (i < _data.Length) + { + var actionAndLength = DecodeVarInt(_data, ref i); + var length = (actionAndLength >> 2) + 1; + switch (actionAndLength & 0b11) + { + case 0b00: // SourceRead + while (length-- is not 0) + { + target[outputOffset] = baseRom[outputOffset]; + outputOffset++; + } + break; + case 0b01: // TargetRead + while (length-- is not 0) target[outputOffset++] = _data[i++]; + break; + case 0b10: // SourceCopy + var offset = DecodeVarInt(_data, ref i); + if ((offset & 1) is 0) sourceRelOffset += offset >> 1; + else sourceRelOffset -= offset >> 1; + while (length-- is not 0) target[outputOffset++] = baseRom[sourceRelOffset++]; + break; + case 0b11: // TargetCopy + var offset1 = DecodeVarInt(_data, ref i); + if ((offset1 & 1) is 0) targetRelOffset += offset1 >> 1; + else targetRelOffset -= offset1 >> 1; + while (length-- is not 0) target[outputOffset++] = target[targetRelOffset++]; + break; + } + } + return checksumsMatch && CheckCRC(data: target, reversedChecksum: _targetChecksum); + } + } + /// /// constructor assumes valid header/footer
/// https://zerosoft.zophar.net/ips.php @@ -100,6 +207,22 @@ namespace BizHawk.Common private const string ERR_MSG_UNINIT = "uninitialised struct"; + private static bool CheckCRC(ReadOnlySpan data, ReadOnlySpan reversedChecksum) + => ((ReadOnlySpan) CRC32Checksum.Compute(data)).ReversedSequenceEqual(reversedChecksum); + + public static bool IsBPSFile(ReadOnlySpan dataWithHeader, out BPSPayload patchStruct) + { + patchStruct = default; + const int MIN_VALID_BPS_SIZE = 20; + if (dataWithHeader.Length < MIN_VALID_BPS_SIZE + || !dataWithHeader.Slice(start: 0, length: 4).SequenceEqual(BPSPayload.HEADER)) + { + return false; + } + patchStruct = new(dataWithHeader); + return CheckCRC(data: dataWithHeader.Slice(start: 0, length: dataWithHeader.Length - 4), reversedChecksum: patchStruct.PatchChecksum); + } + public static bool IsIPSFile(ReadOnlySpan dataWithHeader) { const int MIN_VALID_IPS_SIZE = 8; @@ -108,6 +231,14 @@ namespace BizHawk.Common && dataWithHeader.Slice(start: dataWithHeader.Length - 3, length: 3).SequenceEqual(IPSPayload.FOOTER); } + /// always allocates a new array + public static byte[] Patch(ReadOnlySpan baseRom, BPSPayload patch, out bool checksumsMatch) + { + var target = new byte[patch.TargetSize]; + checksumsMatch = patch.DoPatch(baseRom: baseRom, target: target); + return target; + } + /// may patch in place, returning , or allocate a new array public static byte[] Patch(byte[] baseRom, IPSPayload patch) { diff --git a/src/BizHawk.Common/Extensions/CollectionExtensions.cs b/src/BizHawk.Common/Extensions/CollectionExtensions.cs index 4c03641450..7016bb666a 100644 --- a/src/BizHawk.Common/Extensions/CollectionExtensions.cs +++ b/src/BizHawk.Common/Extensions/CollectionExtensions.cs @@ -223,6 +223,21 @@ namespace BizHawk.Common.CollectionExtensions private static T ReturnSelf(this T self) => self; + public static bool ReversedSequenceEqual(this ReadOnlySpan a, ReadOnlySpan b) + where T : IEquatable + { + var len = a.Length; + if (len != b.Length) return false; + if (len is 0) return true; + var i = 0; + while (i < len) + { + if (!a[i].Equals(b[len - 1 - i])) return false; + i++; + } + return true; + } + public static bool IsSortedAsc(this IReadOnlyList list) where T : IComparable {