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
This commit is contained in:
YoshiRulz 2023-03-16 03:23:05 +10:00
parent eb13e46f45
commit a8f26ccf08
No known key found for this signature in database
GPG Key ID: C4DE31C245353FB7
3 changed files with 157 additions and 0 deletions

View File

@ -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");
}
}
/// <remarks>https://snes.nesdev.org/wiki/ROM_file_formats#Detecting_Headered_ROM</remarks>
private static ReadOnlySpan<byte> StripSNESDumpHeader(ReadOnlySpan<byte> 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

View File

@ -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
{
/// <remarks>
/// constructor assumes valid header/footer<br/>
/// https://github.com/Alcaro/Flips/blob/master/bps_spec.md
/// </remarks>
/// <seealso cref="IsBPSFile"/>
public readonly ref struct BPSPayload
{
internal static readonly byte[] HEADER = Encoding.ASCII.GetBytes("BPS1");
public static int DecodeVarInt(ReadOnlySpan<byte> 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<byte> _data;
private readonly bool _isValid;
private readonly ReadOnlySpan<byte> _sourceChecksum;
private readonly int _sourceSize;
private readonly ReadOnlySpan<byte> _targetChecksum;
public readonly int TargetSize;
internal readonly ReadOnlySpan<byte> PatchChecksum;
/// <remarks>assumes valid header/footer</remarks>
/// <seealso cref="IsBPSFile"/>
public BPSPayload(ReadOnlySpan<byte> 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);
}
/// <returns><see langword="true"/> iff checksums of base rom and result both matched</returns>
public bool DoPatch(ReadOnlySpan<byte> baseRom, Span<byte> 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);
}
}
/// <remarks>
/// constructor assumes valid header/footer<br/>
/// 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<byte> data, ReadOnlySpan<byte> reversedChecksum)
=> ((ReadOnlySpan<byte>) CRC32Checksum.Compute(data)).ReversedSequenceEqual(reversedChecksum);
public static bool IsBPSFile(ReadOnlySpan<byte> 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<byte> 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);
}
/// <remarks>always allocates a new array</remarks>
public static byte[] Patch(ReadOnlySpan<byte> baseRom, BPSPayload patch, out bool checksumsMatch)
{
var target = new byte[patch.TargetSize];
checksumsMatch = patch.DoPatch(baseRom: baseRom, target: target);
return target;
}
/// <remarks>may patch in place, returning <paramref name="baseRom"/>, or allocate a new array</remarks>
public static byte[] Patch(byte[] baseRom, IPSPayload patch)
{

View File

@ -223,6 +223,21 @@ namespace BizHawk.Common.CollectionExtensions
private static T ReturnSelf<T>(this T self)
=> self;
public static bool ReversedSequenceEqual<T>(this ReadOnlySpan<T> a, ReadOnlySpan<T> b)
where T : IEquatable<T>
{
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<T>(this IReadOnlyList<T> list)
where T : IComparable<T>
{