BizHawk/src/BizHawk.Client.Common/fwmanager/FirmwareManager.cs

227 lines
9.8 KiB
C#

#nullable enable
using System.Collections.Generic;
using System.IO;
using System.Linq;
using BizHawk.Common;
using BizHawk.Common.CollectionExtensions;
using BizHawk.Common.IOExtensions;
using BizHawk.Emulation.Common;
namespace BizHawk.Client.Common
{
public sealed class FirmwareManager
{
private static readonly FirmwareID NDS_FIRMWARE = new("NDS", "firmware");
private const int DSI_NAND_LENGTH = 251658240 + 64;
public static (byte[] Patched, string ActualHash) PerformPatchInMemory(byte[] @base, in FirmwarePatchOption patchOption)
{
var patched = patchOption.Patches.Aggregate(seed: @base, (a, fpd) => fpd.ApplyToMutating(a));
return (patched, SHA1Checksum.ComputeDigestHex(patched));
}
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);
if (actualHash != patchOption.TargetHash) throw new InvalidOperationException("patch produced incorrect output");
var patchedParentDir = Path.Combine(pathEntries[PathEntryCollection.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
}
/// <summary>a list of expected file sizes, used as a simple filter to speed up scanning</summary>
private readonly HashSet<long> _firmwareSizes = new(FirmwareDatabase.FirmwareFiles.Select(ff => ff.Size));
private readonly List<FirmwareEventArgs> _recentlyServed = new();
private readonly Dictionary<FirmwareRecord, ResolutionInfo> _resolutionDictionary = new();
public ICollection<FirmwareEventArgs> RecentlyServed => _recentlyServed;
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>FirmwareConfig.DoScan</c>.
/// In that case, we don't want to call <see cref="DoScanAndResolve"/> repeatedly, so we use <paramref name="forbidScan"/> to skip it.
/// </remarks>
public ResolutionInfo? Resolve(PathEntryCollection pathEntries, IDictionary<string, string> userSpecifications, FirmwareRecord record, bool forbidScan = false)
{
if (_resolutionDictionary.TryGetValue(record, out var resolved)) return resolved;
// else couldn't find it
if (forbidScan) return null;
// try harder by doing a scan and resolve
// NOTE: this could result in bad performance in some cases if the scanning happens repeatedly...
DoScanAndResolve(pathEntries, userSpecifications);
return _resolutionDictionary.TryGetValue(record, out var resolved1) ? resolved1 : null;
}
// 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 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;
}
private sealed class RealFirmwareReader
{
private readonly Dictionary<string, RealFirmwareFile> _dict = new();
public IReadOnlyDictionary<string, RealFirmwareFile> Dict => _dict;
public RealFirmwareFile Read(FileInfo fi)
{
using var fs = fi.OpenRead();
// DSi NAND is huge, and the hash is useless (can't use it to identify a good dump)
// Not sure how well the system can handle a dummy hash, so let's just hash the nocash footer (which should be unique for each NAND)
if (fs.Length == DSI_NAND_LENGTH)
{
fs.Seek(-64, SeekOrigin.End);
// we can let it fall through here, as ReadAllBytes just reads all bytes starting from the stream position :)
}
var hash = SHA1Checksum.ComputeDigestHex(fs.ReadAllBytes());
return _dict![hash] = new RealFirmwareFile(fi, hash);
}
}
/// <summary>
/// Test to determine whether the supplied firmware file matches something in the firmware database
/// </summary>
public bool CanFileBeImported(string f)
{
try
{
var fi = new FileInfo(f);
if (!fi.Exists) return false;
// weed out filesizes first to reduce the unnecessary overhead of a hashing operation
if (!_firmwareSizes.Contains(fi.Length)) return false;
// check the hash
var reader = new RealFirmwareReader();
reader.Read(fi);
var hash = reader.Dict.Values.First().Hash;
return FirmwareDatabase.FirmwareFiles.Any(a => a.Hash == hash);
}
catch
{
return false;
}
}
public void DoScanAndResolve(PathEntryCollection pathEntries, IDictionary<string, string> userSpecifications)
{
var reader = new RealFirmwareReader();
// build a list of files under the global firmware path, and build a hash for each of them (as ResolutionInfo) while we're at it
Queue<DirectoryInfo> todo = [ new(pathEntries.FirmwareAbsolutePath()) ];
while (todo.Count != 0)
{
var di = todo.Dequeue();
if (!di.Exists) continue;
foreach (var subDir in di.GetDirectories()) todo.Enqueue(subDir); // recurse
foreach (var fi in di.GetFiles().Where(fi => _firmwareSizes.Contains(fi.Length))) reader.Read(fi);
}
// now, for each firmware record, try to resolve it
foreach (var fr in FirmwareDatabase.FirmwareRecords)
{
_resolutionDictionary.Remove(fr); // clear previous resolution results
// check each acceptable option for this firmware, looking for the first available sorted by status
var found = FirmwareDatabase.FirmwareOptions
.Where(fo1 => fo1.ID == fr.ID && fo1.IsAcceptableOrIdeal && reader.Dict.ContainsKey(fo1.Hash))
.OrderByDescending(fo => fo.Status)
.Take(1).ToArray(); // 0..1 elements (essentially, first or null)
if (found is [ FirmwareOption fo ]) _resolutionDictionary[fr] = new()
{
FilePath = reader.Dict[fo.Hash].FileInfo.FullName,
KnownFirmwareFile = FirmwareDatabase.FirmwareFilesByHash[fo.Hash],
Hash = fo.Hash,
Size = fo.Size,
};
}
// apply user overrides
foreach (var fr in FirmwareDatabase.FirmwareRecords)
{
// do we have a user specification for this firmware record?
if (!userSpecifications.TryGetValue(fr.ID.ConfigKey, out var userSpec)) continue;
var ri = _resolutionDictionary.GetValueOrPutNew(fr);
// local ri is a reference to a ResolutionInfo which is now definitely in the dict
// flag it as user specified
ri.UserSpecified = true;
ri.KnownFirmwareFile = null;
ri.FilePath = userSpec;
ri.Hash = null;
// check whether it exists
var fi = new FileInfo(userSpec);
if (!fi.Exists)
{
ri.Missing = true;
continue;
}
// compute its hash
// NDS's firmware file contains user settings; these are over-written by sync settings, so we shouldn't allow them to impact the hash
// fixme: there's way more stuff in nds firmware which can affect the hash. rtc fuckery, mac address, wifi settings, etc etc. a proper way to clear these out needs to come
/*var rff = reader.Read(fr.ID == NDS_FIRMWARE
? new FileInfo(Emulation.Cores.Consoles.Nintendo.NDS.NDS.CreateModifiedFirmware(userSpec))
: fi);*/
var rff = reader.Read(fi);
ri.Size = fi.Length;
ri.Hash = rff.Hash;
// check whether it was a known file anyway, and go ahead and bind to the known file, as a perk (the firmware config doesn't really use this information right now)
if (FirmwareDatabase.FirmwareFilesByHash.TryGetValue(rff.Hash, out var ff))
{
ri.KnownFirmwareFile = ff;
// assume the firmware file is for a different firmware, unflag it in case it isn't
// logic here is important, see: https://github.com/TASEmulators/BizHawk/issues/3095
ri.KnownMismatching = true;
if (FirmwareDatabase.FirmwareOptions.Any(fo => fo.Hash == rff.Hash && fo.ID == fr.ID))
{
ri.KnownMismatching = false;
}
}
}
}
}
}