Add automatic patching capability to FirmwareManager

This commit is contained in:
YoshiRulz 2021-09-22 06:21:13 +10:00 committed by James Groom
parent 190e121a90
commit dbc36fa420
5 changed files with 233 additions and 4 deletions

View File

@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Security.Cryptography;
using System.IO;
using System.Linq;
@ -16,6 +17,27 @@ namespace BizHawk.Client.Common
{
private static readonly FirmwareID NDS_FIRMWARE = new("NDS", "firmware");
public static (byte[] Patched, string ActualHash) PerformPatchInMemory(byte[] @base, in FirmwarePatchOption patchOption)
{
var patched = patchOption.Patches.Aggregate(seed: @base, (a, fpd) => fpd.ApplyToMutating(a));
using var sha1 = SHA1.Create();
sha1.ComputeHash(patched);
return (patched, sha1.Hash.BytesToHexString());
}
public static (string FilePath, int FileSize, FirmwareFile FF) PerformPatchOnDisk(string baseFilename, in FirmwarePatchOption patchOption, PathEntryCollection pathEntries)
{
var @base = File.ReadAllBytes(baseFilename);
var (patched, actualHash) = PerformPatchInMemory(@base, in patchOption);
Trace.Assert(actualHash == patchOption.TargetHash);
var patchedParentDir = Path.Combine(pathEntries["Global", "Temp Files"].Path, "AutopatchedFirmware");
Directory.CreateDirectory(patchedParentDir);
var ff = FirmwareDatabase.FirmwareFilesByHash[patchOption.TargetHash];
var patchedFilePath = Path.Combine(patchedParentDir, ff.RecommendedName);
File.WriteAllBytes(patchedFilePath, patched);
return (patchedFilePath, @base.Length, ff); // patches can't change length with the current implementation
}
private readonly IReadOnlyCollection<long> _firmwareSizes;
private readonly List<FirmwareEventArgs> _recentlyServed = new();
@ -29,6 +51,27 @@ namespace BizHawk.Client.Common
_firmwareSizes = new HashSet<long>(FirmwareDatabase.FirmwareFiles.Select(ff => ff.Size)); // build a list of expected file sizes, used as a simple filter to speed up scanning
}
private ResolutionInfo? AttemptPatch(FirmwareRecord requested, PathEntryCollection pathEntries, IDictionary<string, string> userSpecifications)
{
// look for patchsets where 1. they produce a file that fulfils the request, and 2. a matching input file is present in the firmware dir
var targetOptionHashes = FirmwareDatabase.FirmwareOptions.Where(fo => fo.ID == requested.ID).Select(fo => fo.Hash).ToList();
var presentBaseFiles = _resolutionDictionary.Values.Select(ri => ri.Hash).ToList(); //TODO might want to use files which are known (in the database), but not assigned to any record
var patchOption = FirmwareDatabase.AllPatches.FirstOrNull(fpo => targetOptionHashes.Contains(fpo.TargetHash) && presentBaseFiles.Contains(fpo.BaseHash));
if (patchOption is null) return null;
// found one, proceed with patching the base file
var baseFilename = _resolutionDictionary.Values.First(ri => ri.Hash == patchOption.Value.BaseHash).FilePath!;
var (patchedFilePath, patchedFileLength, ff) = PerformPatchOnDisk(baseFilename, patchOption.Value, pathEntries);
// cache and return this new file's metadata
userSpecifications[requested.ID.ConfigKey] = patchedFilePath;
return _resolutionDictionary[requested] = new()
{
FilePath = patchedFilePath,
KnownFirmwareFile = ff,
Hash = patchOption.Value.TargetHash,
Size = patchedFileLength
};
}
/// <remarks>
/// Sometimes this is called from a loop in <c>FirmwaresConfig.DoScan</c>.
/// In that case, we don't want to call <see cref="DoScanAndResolve"/> repeatedly, so we use <paramref name="forbidScan"/> to skip it.
@ -48,10 +91,9 @@ namespace BizHawk.Client.Common
// Requests the specified firmware. tries really hard to scan and resolve as necessary
public string? Request(PathEntryCollection pathEntries, IDictionary<string, string> userSpecifications, FirmwareID id)
{
var resolved = Resolve(
pathEntries,
userSpecifications,
FirmwareDatabase.FirmwareRecords.First(fr => fr.ID == id));
var requestedRecord = FirmwareDatabase.FirmwareRecords.First(fr => fr.ID == id);
var resolved = Resolve(pathEntries, userSpecifications, requestedRecord)
?? AttemptPatch(requestedRecord, pathEntries, userSpecifications);
if (resolved == null) return null;
RecentlyServed.Add(new(id, resolved.Hash, resolved.Size));
return resolved.FilePath;

View File

@ -2,12 +2,15 @@
#if DEBUG
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Security.Cryptography;
using System.Windows.Forms;
using BizHawk.Client.Common;
using BizHawk.Client.EmuHawk.Properties;
using BizHawk.Common.BufferExtensions;
using BizHawk.Emulation.Common;
using BizHawk.Emulation.Cores;
using BizHawk.Emulation.Cores.Nintendo.GBA;
@ -42,6 +45,80 @@ namespace BizHawk.Client.EmuHawk
}
}
private sealed class FirmwareAutopatchDebugToolForm : ToolFormBase
{
public const string TOOL_NAME = "Manual Firmware Autopatching Tool";
protected override string WindowTitleStatic { get; } = TOOL_NAME;
public FirmwareAutopatchDebugToolForm()
{
static string LabelFragment(string hash) => $"{hash.Substring(0, 8)}... {FirmwareDatabase.FirmwareFilesByHash[hash].RecommendedName}";
List<(string Label, FirmwarePatchOption PatchOption)> patches = FirmwareDatabase.AllPatches.Select(static fpo => ($"{LabelFragment(fpo.BaseHash)} --> {LabelFragment(fpo.TargetHash)}", fpo)).ToList();
patches.Sort(static (a, b) => a.Label.CompareTo(b.Label));
ComboBox comboPatchsets = new() { Size = new(300, 23) };
foreach (var tuple in patches) comboPatchsets.Items.Add(tuple.Label);
SzTextBoxEx txtBaseFile = new() { Size = new(224, 23) };
SzButtonEx btnBaseFilePicker = new() { Size = new(75, 23), Text = "(browse)" };
btnBaseFilePicker.Click += (_, _) =>
{
using OpenFileDialog ofd = new() { InitialDirectory = Config!.PathEntries.FirmwareAbsolutePath() };
this.ShowDialogAsChild(ofd);
txtBaseFile.Text = ofd.FileName;
};
CheckBoxEx cbDryRun = new() { Checked = true, Text = "dry run (skip writing to disk)" };
SzButtonEx btnPatch = new() { Size = new(75, 23), Text = "--> patch" };
btnPatch.Click += (_, _) =>
{
var fpo = patches[comboPatchsets.SelectedIndex].PatchOption;
try
{
if (!cbDryRun.Checked)
{
var (filePath, _, _) = FirmwareManager.PerformPatchOnDisk(txtBaseFile.Text, in fpo, Config!.PathEntries);
// if the base file (or patchset) is wrong, too bad
this.ModalMessageBox($"wrote {filePath}");
return;
}
var @base = File.ReadAllBytes(txtBaseFile.Text);
var (_, actualHash) = FirmwareManager.PerformPatchInMemory(@base, in fpo);
if (actualHash == fpo.TargetHash)
{
this.ModalMessageBox("success");
return;
}
// else something happened, figure out what it was
using var sha1 = SHA1.Create();
sha1.ComputeHash(@base);
var baseHash = sha1.Hash.BytesToHexString();
this.ModalMessageBox(baseHash == fpo.BaseHash
? $"patchset declared with target\nSHA1:{fpo.TargetHash}\nbut produced\nSHA1:{actualHash}\n(is the patch wrong, or the hash?)"
: $"patchset declared for base\nSHA1:{fpo.BaseHash}\nbut\nSHA1:{baseHash}\nwas provided");
}
catch (Exception e)
{
this.ModalMessageBox($"caught {e.GetType().Name}:\n{e}");
}
};
ClientSize = new(320, 200);
SuspendLayout();
Controls.Add(new SingleColumnFLP
{
Controls =
{
new LabelEx { Text = "apply" },
comboPatchsets,
new LabelEx { Text = "to file" },
new SingleRowFLP { Controls = { txtBaseFile, btnBaseFilePicker } },
cbDryRun,
new LabelEx { Text = "patched files are saved in dir set as \"Temp Files\"" },
btnPatch,
}
});
ResumeLayout();
}
}
private sealed class N64VideoSettingsFuzzToolForm : ToolFormBase
{
public const string TOOL_NAME = "N64 Video Settings Fuzzer";
@ -97,10 +174,21 @@ namespace BizHawk.Client.EmuHawk
// this.ShowDialogAsChild(form);
// }
void OpenTool<T>() where T : class, IToolForm => Tools.Load<T>();
ToolStripMenuItemEx firmwareAutopatchDebugItem = new() { Text = FirmwareAutopatchDebugToolForm.TOOL_NAME };
firmwareAutopatchDebugItem.Click += (_, _) => OpenTool<FirmwareAutopatchDebugToolForm>();
ToolStripMenuItemEx debugMenu = new()
{
DropDownItems =
{
new ToolStripMenuItemEx
{
DropDownItems =
{
firmwareAutopatchDebugItem,
},
Text = "Firmware",
},
new ToolStripSeparatorEx(),
new DebugVSystemMenuItem("GBA")
{
DropDownItems =

View File

@ -1,6 +1,7 @@
#nullable disable
using System.Collections.Generic;
using System.Linq;
// ReSharper disable IdentifierTypo
// ReSharper disable InconsistentNaming
@ -17,8 +18,11 @@ namespace BizHawk.Emulation.Common
public static readonly IReadOnlyCollection<FirmwareRecord> FirmwareRecords;
public static readonly IReadOnlyList<FirmwarePatchOption> AllPatches;
static FirmwareDatabase()
{
List<FirmwarePatchOption> allPatches = new();
Dictionary<string, FirmwareFile> filesByHash = new();
List<FirmwareOption> options = new();
List<FirmwareRecord> records = new();
@ -50,6 +54,13 @@ namespace BizHawk.Emulation.Common
Option(systemId, id, File(hash, size, name, desc), FirmwareOptionStatus.Ideal);
}
void AddPatchAndMaybeReverse(FirmwarePatchOption fpo)
{
allPatches.Add(fpo);
if (fpo.Patches.Any(fpd => fpd.Overwrite)) return;
allPatches.Add(new(fpo.TargetHash, fpo.Patches.Reverse().ToArray(), fpo.BaseHash));
}
// FDS has two OK variants (http://tcrf.net/Family_Computer_Disk_System)
var fdsNintendo = File("57FE1BDEE955BB48D357E463CCBF129496930B62", 8192, "FDS_disksys-nintendo.rom", "Bios (Nintendo)");
var fdsTwinFc = File("E4E41472C454F928E53EB10E0509BF7D1146ECC1", 8192, "FDS_disksys-nintendo.rom", "Bios (TwinFC)");
@ -303,6 +314,11 @@ namespace BizHawk.Emulation.Common
// Early revisions of GB/C boot ROMs are not well-supported because the corresponding CPU differences are not emulated.
Option("GB", "World", File("8BD501E31921E9601788316DBD3CE9833A97BCBC", 256, "dmg0.bin", "Game Boy Boot Rom (Early J Revision)"), FirmwareOptionStatus.Unacceptable);
Option("GB", "World", File("4E68F9DA03C310E84C523654B9026E51F26CE7F0", 256, "mgb.bin", "Game Boy Boot Rom (Pocket)"), FirmwareOptionStatus.Acceptable);
FirmwarePatchData gbCommonPatchAt0xFD = new(0xFD, new byte[] { 0xFE }); // 2 pairs, all have either 0x01 or 0xFF at this octet
AddPatchAndMaybeReverse(new(
"4ED31EC6B0B175BB109C0EB5FD3D193DA823339F",
gbCommonPatchAt0xFD,
"4E68F9DA03C310E84C523654B9026E51F26CE7F0"));
// these are only used for supported SGB cores
// placed in GB as these are within the Game Boy side rather than the SNES side
@ -310,6 +326,10 @@ namespace BizHawk.Emulation.Common
Option("GB", "SGB", File("AA2F50A77DFB4823DA96BA99309085A3C6278515", 256, "sgb.bin", "Super Game Boy Boot Rom"), FirmwareOptionStatus.Ideal);
Firmware("GB", "SGB2", "Super Game Boy 2 Boot Rom");
Option("GB", "SGB2", File("93407EA10D2F30AB96A314D8ECA44FE160AEA734", 256, "sgb2.bin", "Super Game Boy 2 Boot Rom"), FirmwareOptionStatus.Ideal);
AddPatchAndMaybeReverse(new(
"AA2F50A77DFB4823DA96BA99309085A3C6278515",
gbCommonPatchAt0xFD,
"93407EA10D2F30AB96A314D8ECA44FE160AEA734"));
Firmware("GBC", "World", "Game Boy Color Boot Rom");
Option("GBC", "World", File("1293D68BF9643BC4F36954C1E80E38F39864528D", 2304, "cgb.bin", "Game Boy Color Boot Rom"), FirmwareOptionStatus.Ideal);
@ -317,6 +337,10 @@ namespace BizHawk.Emulation.Common
Firmware("GBC", "AGB", "Game Boy Color Boot Rom (GBA)");
Option("GBC", "AGB", File("FA5287E24B0FA533B3B5EF2B28A81245346C1A0F", 2304, "agb.bin", "Game Boy Color Boot Rom (GBA)"), FirmwareOptionStatus.Ideal);
Option("GBC", "AGB", File("1ECAFA77AB3172193F3305486A857F443E28EBD9", 2304, "agb_gambatte.bin", "Game Boy Color Boot Rom (GBA, Gambatte RE)"), FirmwareOptionStatus.Bad);
AddPatchAndMaybeReverse(new(
"1293D68BF9643BC4F36954C1E80E38F39864528D",
new FirmwarePatchData(0xF3, new byte[] { 0x03, 0x00, 0xCD, 0x1D, 0xD5, 0xAA, 0x4F, 0x90, 0x74 }),
"1ECAFA77AB3172193F3305486A857F443E28EBD9"));
Firmware("PCFX", "BIOS", "PCFX bios");
var pcfxbios = File("1A77FD83E337F906AECAB27A1604DB064CF10074", 1024 * 1024, "PCFX_bios.bin", "PCFX BIOS 1.00");
@ -335,6 +359,7 @@ namespace BizHawk.Emulation.Common
Option("PS2", "BIOS", File("F9229FE159D0353B9F0632F3FDC66819C9030458", 4 * 1024 * 1024, "ps2-0230a-20080220.bin", "PS2 Bios"), FirmwareOptionStatus.Ideal);
Option("PS2", "BIOS", File("9915B5BA56798F4027AC1BD8D10ABE0C1C9C326A", 4 * 1024 * 1024, "ps2-0230e-20080220.bin", "PS2 Bios"));
AllPatches = allPatches;
FirmwareFilesByHash = filesByHash;
FirmwareOptions = options;
FirmwareRecords = records;

View File

@ -0,0 +1,47 @@
#nullable enable
using System;
namespace BizHawk.Emulation.Common
{
/// <summary>
/// Represents a binary patch, to be applied to a byte array. Patches must be contiguous; multiple instances can be used to for non-contiguous patches.
/// Patches usually contain data which needs to be XOR'd with a base file, but with <see cref="Overwrite"/> set to <see langword="true"/>, this struct can represent data which should replace part of a base file.
/// </summary>
/// <remarks>TODO no mechanism to change length, would that be useful? --yoshi</remarks>
public readonly struct FirmwarePatchData
{
public readonly byte[] Contents;
/// <summary>position in base file where patch should start</summary>
/// <remarks>in bytes (octets)</remarks>
public readonly int Offset;
/// <summary>base file should be overwritten with patch iff <see langword="true"/>, XOR'd otherwise</summary>
public readonly bool Overwrite;
public FirmwarePatchData(int offset, byte[] contents, bool overwrite = false)
{
Contents = contents;
Offset = offset;
Overwrite = overwrite;
}
/// <summary>applies this patch to <paramref name="base"/> in-place, and returns the same reference</summary>
public readonly byte[] ApplyToMutating(byte[] @base)
{
if (Overwrite)
{
Array.Copy(Contents, 0, @base, Offset, Contents.Length);
}
else
{
var iBase = Offset;
var iPatch = 0;
var l = Contents.Length;
while (iPatch < l) @base[iBase++] ^= Contents[iPatch++];
}
return @base;
}
}
}

View File

@ -0,0 +1,27 @@
#nullable enable
using System.Collections.Generic;
namespace BizHawk.Emulation.Common
{
public readonly struct FirmwarePatchOption
{
/// <summary>hash of base file patch should be applied to</summary>
public readonly string BaseHash;
public readonly IReadOnlyList<FirmwarePatchData> Patches;
/// <summary>hash of file produced by patching</summary>
public readonly string TargetHash;
public FirmwarePatchOption(string baseHash, IReadOnlyList<FirmwarePatchData> patches, string targetHash)
{
BaseHash = baseHash;
Patches = patches;
TargetHash = targetHash;
}
public FirmwarePatchOption(string baseHash, FirmwarePatchData patch, string targetHash)
: this(baseHash, new[] { patch }, targetHash) {}
}
}