diff --git a/SabreTools.Core/Enums.cs b/SabreTools.Core/Enums.cs
index b6f791e4..5fb75c31 100644
--- a/SabreTools.Core/Enums.cs
+++ b/SabreTools.Core/Enums.cs
@@ -283,6 +283,7 @@ namespace SabreTools.Core
Driver,
Extension,
Feature,
+ File,
Info,
Input,
Instance,
diff --git a/SabreTools.DatItems/Formats/File.cs b/SabreTools.DatItems/Formats/File.cs
new file mode 100644
index 00000000..2eb25c20
--- /dev/null
+++ b/SabreTools.DatItems/Formats/File.cs
@@ -0,0 +1,313 @@
+using System;
+using System.Linq;
+using System.Xml.Serialization;
+using Newtonsoft.Json;
+using SabreTools.Core;
+using SabreTools.Core.Tools;
+
+// TODO: Add item mappings for all fields
+namespace SabreTools.DatItems.Formats
+{
+ ///
+ /// Represents a single file item
+ ///
+ [JsonObject("file"), XmlRoot("file")]
+ public class File : DatItem
+ {
+ #region Private instance variables
+
+ private byte[] _crc; // 8 bytes
+ private byte[] _md5; // 16 bytes
+ private byte[] _sha1; // 20 bytes
+ private byte[] _sha256; // 32 bytes
+
+ #endregion
+
+ #region Fields
+
+ ///
+ /// ID value
+ ///
+ [JsonProperty("id", DefaultValueHandling = DefaultValueHandling.Ignore), XmlElement("id")]
+ public string Id { get; set; }
+
+ ///
+ /// Extension value
+ ///
+ [JsonProperty("extension", DefaultValueHandling = DefaultValueHandling.Ignore), XmlElement("extension")]
+ public string Extension { get; set; }
+
+ ///
+ /// Byte size of the rom
+ ///
+ [JsonProperty("size", DefaultValueHandling = DefaultValueHandling.Ignore), XmlElement("size")]
+ public long? Size { get; set; } = null;
+
+ [JsonIgnore]
+ public bool SizeSpecified { get { return Size != null; } }
+
+ ///
+ /// File CRC32 hash
+ ///
+ [JsonProperty("crc", DefaultValueHandling = DefaultValueHandling.Ignore), XmlElement("crc")]
+ public string CRC
+ {
+ get { return _crc.IsNullOrEmpty() ? null : Utilities.ByteArrayToString(_crc); }
+ set { _crc = (value == "null" ? Constants.CRCZeroBytes : Utilities.StringToByteArray(CleanCRC32(value))); }
+ }
+
+ ///
+ /// File MD5 hash
+ ///
+ [JsonProperty("md5", DefaultValueHandling = DefaultValueHandling.Ignore), XmlElement("md5")]
+ public string MD5
+ {
+ get { return _md5.IsNullOrEmpty() ? null : Utilities.ByteArrayToString(_md5); }
+ set { _md5 = Utilities.StringToByteArray(CleanMD5(value)); }
+ }
+
+ ///
+ /// File SHA-1 hash
+ ///
+ [JsonProperty("sha1", DefaultValueHandling = DefaultValueHandling.Ignore), XmlElement("sha1")]
+ public string SHA1
+ {
+ get { return _sha1.IsNullOrEmpty() ? null : Utilities.ByteArrayToString(_sha1); }
+ set { _sha1 = Utilities.StringToByteArray(CleanSHA1(value)); }
+ }
+
+ ///
+ /// File SHA-256 hash
+ ///
+ [JsonProperty("sha256", DefaultValueHandling = DefaultValueHandling.Ignore), XmlElement("sha256")]
+ public string SHA256
+ {
+ get { return _sha256.IsNullOrEmpty() ? null : Utilities.ByteArrayToString(_sha256); }
+ set { _sha256 = Utilities.StringToByteArray(CleanSHA256(value)); }
+ }
+
+ ///
+ /// Format value
+ ///
+ [JsonProperty("format", DefaultValueHandling = DefaultValueHandling.Ignore), XmlElement("format")]
+ public string Format { get; set; }
+
+ #endregion // Fields
+
+ #region Constructors
+
+ ///
+ /// Create a default, empty File object
+ ///
+ public File()
+ {
+ ItemType = ItemType.File;
+ }
+
+ #endregion
+
+ #region Cloning Methods
+
+ ///
+ public override object Clone()
+ {
+ return new File()
+ {
+ ItemType = this.ItemType,
+ DupeType = this.DupeType,
+
+ Machine = this.Machine.Clone() as Machine,
+ Source = this.Source.Clone() as Source,
+ Remove = this.Remove,
+
+ Id = this.Id,
+ Extension = this.Extension,
+ Size = this.Size,
+ _crc = this._crc,
+ _md5 = this._md5,
+ _sha1 = this._sha1,
+ _sha256 = this._sha256,
+ Format = this.Format,
+ };
+ }
+
+ #endregion
+
+ #region Comparision Methods
+
+ ///
+ public override bool Equals(DatItem other)
+ {
+ bool dupefound = false;
+
+ // If we don't have a file, return false
+ if (ItemType != other.ItemType)
+ return dupefound;
+
+ // Otherwise, treat it as a File
+ File newOther = other as File;
+
+ // If all hashes are empty, then they're dupes
+ if (!HasHashes() && !newOther.HasHashes())
+ {
+ dupefound = true;
+ }
+
+ // If we have a file that has no known size, rely on the hashes only
+ else if (Size == null && HashMatch(newOther))
+ {
+ dupefound = true;
+ }
+
+ // Otherwise if we get a partial match
+ else if (Size == newOther.Size && HashMatch(newOther))
+ {
+ dupefound = true;
+ }
+
+ return dupefound;
+ }
+
+ ///
+ /// Fill any missing size and hash information from another Rom
+ ///
+ /// File to fill information from
+ public void FillMissingInformation(File other)
+ {
+ if (Size == null && other.Size != null)
+ Size = other.Size;
+
+ if (_crc.IsNullOrEmpty() && !other._crc.IsNullOrEmpty())
+ _crc = other._crc;
+
+ if (_md5.IsNullOrEmpty() && !other._md5.IsNullOrEmpty())
+ _md5 = other._md5;
+
+ if (_sha1.IsNullOrEmpty() && !other._sha1.IsNullOrEmpty())
+ _sha1 = other._sha1;
+
+ if (_sha256.IsNullOrEmpty() && !other._sha256.IsNullOrEmpty())
+ _sha256 = other._sha256;
+ }
+
+ ///
+ /// Get unique duplicate suffix on name collision
+ ///
+ /// String representing the suffix
+ public string GetDuplicateSuffix()
+ {
+ if (!_crc.IsNullOrEmpty())
+ return $"_{CRC}";
+ else if (!_md5.IsNullOrEmpty())
+ return $"_{MD5}";
+ else if (!_sha1.IsNullOrEmpty())
+ return $"_{SHA1}";
+ else if (!_sha256.IsNullOrEmpty())
+ return $"_{SHA256}";
+ else
+ return "_1";
+ }
+
+ ///
+ /// Returns if the File contains any hashes
+ ///
+ /// True if any hash exists, false otherwise
+ public bool HasHashes()
+ {
+ return !_crc.IsNullOrEmpty()
+ || !_md5.IsNullOrEmpty()
+ || !_sha1.IsNullOrEmpty()
+ || !_sha256.IsNullOrEmpty();
+ }
+
+ ///
+ /// Returns if all of the hashes are set to their 0-byte values
+ ///
+ /// True if any hash matches the 0-byte value, false otherwise
+ public bool HasZeroHash()
+ {
+ return (_crc != null && _crc.SequenceEqual(Constants.CRCZeroBytes))
+ || (_md5 != null && _md5.SequenceEqual(Constants.MD5ZeroBytes))
+ || (_sha1 != null && _sha1.SequenceEqual(Constants.SHA1ZeroBytes))
+ || (_sha256 != null && _sha256.SequenceEqual(Constants.SHA256ZeroBytes));
+ }
+
+ ///
+ /// Returns if there are no, non-empty hashes in common with another File
+ ///
+ /// File to compare against
+ /// True if at least one hash is not mutually exclusive, false otherwise
+ private bool HasCommonHash(File other)
+ {
+ return !(_crc.IsNullOrEmpty() ^ other._crc.IsNullOrEmpty())
+ || !(_md5.IsNullOrEmpty() ^ other._md5.IsNullOrEmpty())
+ || !(_sha1.IsNullOrEmpty() ^ other._sha1.IsNullOrEmpty())
+ || !(_sha256.IsNullOrEmpty() ^ other._sha256.IsNullOrEmpty());
+ }
+
+ ///
+ /// Returns if any hashes are common with another File
+ ///
+ /// File to compare against
+ /// True if any hashes are in common, false otherwise
+ private bool HashMatch(File other)
+ {
+ // If either have no hashes, we return false, otherwise this would be a false positive
+ if (!HasHashes() || !other.HasHashes())
+ return false;
+
+ // If neither have hashes in common, we return false, otherwise this would be a false positive
+ if (!HasCommonHash(other))
+ return false;
+
+ // Return if all hashes match according to merge rules
+ return ConditionalHashEquals(_crc, other._crc)
+ && ConditionalHashEquals(_md5, other._md5)
+ && ConditionalHashEquals(_sha1, other._sha1)
+ && ConditionalHashEquals(_sha256, other._sha256);
+ }
+
+ #endregion
+
+ #region Sorting and Merging
+
+ ///
+ public override string GetKey(ItemKey bucketedBy, bool lower = true, bool norename = true)
+ {
+ // Set the output key as the default blank string
+ string key;
+
+ // Now determine what the key should be based on the bucketedBy value
+ switch (bucketedBy)
+ {
+ case ItemKey.CRC:
+ key = CRC;
+ break;
+
+ case ItemKey.MD5:
+ key = MD5;
+ break;
+
+ case ItemKey.SHA1:
+ key = SHA1;
+ break;
+
+ case ItemKey.SHA256:
+ key = SHA256;
+ break;
+
+ // Let the base handle generic stuff
+ default:
+ return base.GetKey(bucketedBy, lower, norename);
+ }
+
+ // Double and triple check the key for corner cases
+ if (key == null)
+ key = string.Empty;
+
+ return key;
+ }
+
+ #endregion
+ }
+}