From c4e3ec209fd550687ae7f77467e06900c6c619c0 Mon Sep 17 00:00:00 2001 From: Matt Nadareski Date: Thu, 29 Feb 2024 16:01:06 -0500 Subject: [PATCH] Port code from SabreTools --- .vscode/tasks.json | 24 ++ Detector.cs | 99 ++++++ Detectors/Atari7800.cs | 33 ++ Detectors/AtariLynx.cs | 33 ++ Detectors/CommodorePSID.cs | 42 +++ Detectors/NECPCEngine.cs | 30 ++ Detectors/Nintendo64.cs | 36 +++ Detectors/NintendoEntertainmentSystem.cs | 30 ++ Detectors/NintendoFamicomDiskSystem.cs | 39 +++ Detectors/SuperFamicomSPC.cs | 30 ++ Detectors/SuperNintendoEntertainmentSystem.cs | 36 +++ Enums.cs | 40 +++ LICENSE | 21 -- README.MD | 5 + Rule.cs | 286 ++++++++++++++++++ SabreTools.Skippers.csproj | 30 ++ SabreTools.Skippers.sln | 8 + SkipperMatch.cs | 121 ++++++++ Test.cs | 73 +++++ Tests/AndTest.cs | 141 +++++++++ Tests/DataTest.cs | 111 +++++++ Tests/FileTest.cs | 97 ++++++ Tests/OrTest.cs | 141 +++++++++ Tests/XorTest.cs | 141 +++++++++ 24 files changed, 1626 insertions(+), 21 deletions(-) create mode 100644 .vscode/tasks.json create mode 100644 Detector.cs create mode 100644 Detectors/Atari7800.cs create mode 100644 Detectors/AtariLynx.cs create mode 100644 Detectors/CommodorePSID.cs create mode 100644 Detectors/NECPCEngine.cs create mode 100644 Detectors/Nintendo64.cs create mode 100644 Detectors/NintendoEntertainmentSystem.cs create mode 100644 Detectors/NintendoFamicomDiskSystem.cs create mode 100644 Detectors/SuperFamicomSPC.cs create mode 100644 Detectors/SuperNintendoEntertainmentSystem.cs create mode 100644 Enums.cs delete mode 100644 LICENSE create mode 100644 README.MD create mode 100644 Rule.cs create mode 100644 SabreTools.Skippers.csproj create mode 100644 SkipperMatch.cs create mode 100644 Test.cs create mode 100644 Tests/AndTest.cs create mode 100644 Tests/DataTest.cs create mode 100644 Tests/FileTest.cs create mode 100644 Tests/OrTest.cs create mode 100644 Tests/XorTest.cs diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..31c32bd --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,24 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "shell", + "args": [ + "build", + // Ask dotnet build to generate full paths for file names. + "/property:GenerateFullPaths=true", + // Do not generate summary otherwise it leads to duplicate errors in Problems panel + "/consoleloggerparameters:NoSummary" + ], + "group": "build", + "presentation": { + "reveal": "silent" + }, + "problemMatcher": "$msCompile" + } + ] +} \ No newline at end of file diff --git a/Detector.cs b/Detector.cs new file mode 100644 index 0000000..105fdf4 --- /dev/null +++ b/Detector.cs @@ -0,0 +1,99 @@ +using System; +using System.IO; +using System.Xml; +using System.Xml.Serialization; + +namespace SabreTools.Skippers +{ + [XmlRoot("detector")] + public abstract class Detector + { + #region Fields + + /// + /// Detector name + /// + [XmlElement("name")] + public string? Name { get; protected set; } + + /// + /// Author names + /// + [XmlElement("author")] + public string? Author { get; protected set; } + + /// + /// File version + /// + [XmlElement("version")] + public string? Version { get; protected set; } + + /// + /// Set of all rules in the skipper + /// + [XmlElement("rule")] + public Rule[]? Rules { get; protected set; } + + /// + /// Filename the skipper lives in + /// + [XmlIgnore] + public string? SourceFile { get; protected set; } + + #endregion + + #region Matching + + /// + /// Get the Rule associated with a given stream + /// + /// Stream to be checked + /// Name of the skipper to be used, blank to find a matching skipper + /// The Rule that matched the stream, null otherwise + public Rule? GetMatchingRule(Stream input, string skipperName) + { + // If we have no name supplied, try to blindly match + if (string.IsNullOrEmpty(skipperName)) + return GetMatchingRule(input); + + // If the name matches the internal name of the skipper + else if (string.Equals(skipperName, Name, StringComparison.OrdinalIgnoreCase)) + return GetMatchingRule(input); + + // If the name matches the source file name of the skipper + else if (string.Equals(skipperName, SourceFile, StringComparison.OrdinalIgnoreCase)) + return GetMatchingRule(input); + + // Otherwise, nothing matches by default + return null; + } + + /// + /// Get the matching Rule from all Rules, if possible + /// + /// Stream to be checked + /// The Rule that matched the stream, null otherwise + private Rule? GetMatchingRule(Stream input) + { + // If we have no rules + if (Rules == null) + return null; + + // Loop through the rules until one is found that works + foreach (Rule rule in Rules) + { + // Always reset the stream back to the original place + input.Seek(0, SeekOrigin.Begin); + + // If all tests in the rule pass + if (rule.PassesAllTests(input)) + return rule; + } + + // If nothing passed + return null; + } + + #endregion + } +} diff --git a/Detectors/Atari7800.cs b/Detectors/Atari7800.cs new file mode 100644 index 0000000..fcae87c --- /dev/null +++ b/Detectors/Atari7800.cs @@ -0,0 +1,33 @@ +using SabreTools.Skippers.Tests; + +namespace SabreTools.Skippers.Detectors +{ + /// + /// Detector for Atari 7800 headers + /// + /// Originally from a7800.xml + internal class Atari7800 : Detector + { + public Atari7800() + { + // Create tests + var rule1Test1 = new DataTest("1", "415441524937383030", true); + var rule2Test1 = new DataTest("64", "41435455414C20434152542044415441205354415254532048455245", true); + + // Create rules + var rule1 = new Rule("80", "EOF", HeaderSkipOperation.None, [rule1Test1], "a7800"); + var rule2 = new Rule("80", "EOF", HeaderSkipOperation.None, [rule2Test1], "a7800"); + + // Create file + Name = "Atari 7800"; + Author = "Roman Scherzer"; + Version = "1.0"; + SourceFile = "a7800"; + Rules = + [ + rule1, + rule2, + ]; + } + } +} diff --git a/Detectors/AtariLynx.cs b/Detectors/AtariLynx.cs new file mode 100644 index 0000000..e7d581b --- /dev/null +++ b/Detectors/AtariLynx.cs @@ -0,0 +1,33 @@ +using SabreTools.Skippers.Tests; + +namespace SabreTools.Skippers.Detectors +{ + /// + /// Detector for Atari Lynx headers + /// + /// Originally from lynx.xml + internal class AtariLynx : Detector + { + public AtariLynx() + { + // Create tests + var rule1Test1 = new DataTest("0", "4C594E58", true); + var rule2Test1 = new DataTest("6", "425339", true); + + // Create rules + var rule1 = new Rule("40", "EOF", HeaderSkipOperation.None, [rule1Test1], "lynx"); + var rule2 = new Rule("40", "EOF", HeaderSkipOperation.None, [rule2Test1], "lynx"); + + // Create file + Name = "Atari Lynx"; + Author = "Roman Scherzer"; + Version = "1.0"; + SourceFile = "lynx"; + Rules = + [ + rule1, + rule2, + ]; + } + } +} diff --git a/Detectors/CommodorePSID.cs b/Detectors/CommodorePSID.cs new file mode 100644 index 0000000..9475971 --- /dev/null +++ b/Detectors/CommodorePSID.cs @@ -0,0 +1,42 @@ +using SabreTools.Skippers.Tests; + +namespace SabreTools.Skippers.Detectors +{ + /// + /// Detector for Commodore PSID headers + /// + /// Originally from psid.xml + internal class CommodorePSID : Detector + { + public CommodorePSID() + { + // Create tests + var rule1Test1 = new DataTest("0", "5053494400010076", true); + var rule2Test1 = new DataTest("0", "505349440003007c", true); + var rule3Test1 = new DataTest("0", "505349440002007c", true); + var rule4Test1 = new DataTest("0", "505349440001007c", true); + var rule5Test1 = new DataTest("0", "525349440002007c", true); + + // Create rules + var rule1 = new Rule("76", "EOF", HeaderSkipOperation.None, [rule1Test1], "psid"); + var rule2 = new Rule("76", "EOF", HeaderSkipOperation.None, [rule2Test1], "psid"); + var rule3 = new Rule("7c", "EOF", HeaderSkipOperation.None, [rule3Test1], "psid"); + var rule4 = new Rule("7c", "EOF", HeaderSkipOperation.None, [rule4Test1], "psid"); + var rule5 = new Rule("7c", "EOF", HeaderSkipOperation.None, [rule5Test1], "psid"); + + // Create file + Name = "psid"; + Author = "Yori Yoshizuki"; + Version = "1.2"; + SourceFile = "psid"; + Rules = + [ + rule1, + rule2, + rule3, + rule4, + rule5, + ]; + } + } +} diff --git a/Detectors/NECPCEngine.cs b/Detectors/NECPCEngine.cs new file mode 100644 index 0000000..a07ef10 --- /dev/null +++ b/Detectors/NECPCEngine.cs @@ -0,0 +1,30 @@ +using SabreTools.Skippers.Tests; + +namespace SabreTools.Skippers.Detectors +{ + /// + /// Detector for NEC PC-Engine / TurboGrafx 16 headers + /// + /// Originally from pce.xml + internal class NECPCEngine : Detector + { + public NECPCEngine() + { + // Create tests + var rule1Test1 = new DataTest("0", "4000000000000000AABB02", true); + + // Create rules + var rule1 = new Rule("200", null, HeaderSkipOperation.None, [rule1Test1], "pce"); + + // Create file + Name = "NEC TurboGrafx-16/PC-Engine"; + Author = "Matt Nadareski (darksabre76)"; + Version = "1.0"; + SourceFile = "pce"; + Rules = + [ + rule1, + ]; + } + } +} diff --git a/Detectors/Nintendo64.cs b/Detectors/Nintendo64.cs new file mode 100644 index 0000000..af6d90f --- /dev/null +++ b/Detectors/Nintendo64.cs @@ -0,0 +1,36 @@ +using SabreTools.Skippers.Tests; + +namespace SabreTools.Skippers.Detectors +{ + /// + /// Detector for Nintendo 64 headers + /// + /// Originally from n64.xml + internal class Nintendo64 : Detector + { + public Nintendo64() + { + // Create tests + var v64Test = new DataTest("0", "80371240", true); + var z64Test = new DataTest("0", "37804012", true); + var n64Test = new DataTest("0", "40123780", true); + + // Create rules + var v64Rule = new Rule("0", "EOF", HeaderSkipOperation.None, [v64Test], "n64"); + var z64Rule = new Rule("0", "EOF", HeaderSkipOperation.Byteswap, [z64Test], "n64"); + var n64Rule = new Rule("0", "EOF", HeaderSkipOperation.Wordswap, [n64Test], "n64"); + + // Create file + Name = "Nintendo 64 - ABCD"; + Author = "CUE"; + Version = "1.1"; + SourceFile = "n64"; + Rules = + [ + v64Rule, + z64Rule, + n64Rule, + ]; + } + } +} diff --git a/Detectors/NintendoEntertainmentSystem.cs b/Detectors/NintendoEntertainmentSystem.cs new file mode 100644 index 0000000..cd15018 --- /dev/null +++ b/Detectors/NintendoEntertainmentSystem.cs @@ -0,0 +1,30 @@ +using SabreTools.Skippers.Tests; + +namespace SabreTools.Skippers.Detectors +{ + /// + /// Detector for Nintendo Entertainment System headers + /// + /// Originally from nes.xml + internal class NintendoEntertainmentSystem : Detector + { + public NintendoEntertainmentSystem() + { + // Create tests + var inesTest = new DataTest("0", "4E45531A", true); + + // Create rules + var inesRule = new Rule("10", "EOF", HeaderSkipOperation.None, [inesTest], "nes"); + + // Create file + Name = "Nintendo Famicon/NES"; + Author = "Roman Scherzer"; + Version = "1.1"; + SourceFile = "nes"; + Rules = + [ + inesRule, + ]; + } + } +} diff --git a/Detectors/NintendoFamicomDiskSystem.cs b/Detectors/NintendoFamicomDiskSystem.cs new file mode 100644 index 0000000..6489d83 --- /dev/null +++ b/Detectors/NintendoFamicomDiskSystem.cs @@ -0,0 +1,39 @@ +using SabreTools.Skippers.Tests; + +namespace SabreTools.Skippers.Detectors +{ + /// + /// Detector for Nintendo Famicom Disk System headers + /// + /// Originally from fds.xml + internal class NintendoFamicomDiskSystem : Detector + { + public NintendoFamicomDiskSystem() + { + // Create tests + var rule1Test1 = new DataTest("0", "4644531A010000000000000000000000", true); + var rule2Test1 = new DataTest("0", "4644531A020000000000000000000000", true); + var rule3Test1 = new DataTest("0", "4644531A030000000000000000000000", true); + var rule4Test1 = new DataTest("0", "4644531A040000000000000000000000", true); + + // Create rules + var rule1 = new Rule("10", null, HeaderSkipOperation.None, [rule1Test1], "fds"); + var rule2 = new Rule("10", null, HeaderSkipOperation.None, [rule2Test1], "fds"); + var rule3 = new Rule("10", null, HeaderSkipOperation.None, [rule3Test1], "fds"); + var rule4 = new Rule("10", null, HeaderSkipOperation.None, [rule4Test1], "fds"); + + // Create file + Name = "fds"; + Author = "Yori Yoshizuki"; + Version = "1.0"; + SourceFile = "fds"; + Rules = + [ + rule1, + rule2, + rule3, + rule4, + ]; + } + } +} diff --git a/Detectors/SuperFamicomSPC.cs b/Detectors/SuperFamicomSPC.cs new file mode 100644 index 0000000..a33a438 --- /dev/null +++ b/Detectors/SuperFamicomSPC.cs @@ -0,0 +1,30 @@ +using SabreTools.Skippers.Tests; + +namespace SabreTools.Skippers.Detectors +{ + /// + /// Detector for Super Famicom SPC headers + /// + /// Originally from spc.xml + internal class SuperFamicomSPC : Detector + { + public SuperFamicomSPC() + { + // Create tests + var rule1Test1 = new DataTest("0", "534E45532D535043", true); + + // Create rules + var rule1 = new Rule("00100", "EOF", HeaderSkipOperation.None, [rule1Test1], "spc"); + + // Create file + Name = "Nintendo Super Famicon SPC"; + Author = "Yori Yoshizuki"; + Version = "1.0"; + SourceFile = "spc"; + Rules = + [ + rule1, + ]; + } + } +} diff --git a/Detectors/SuperNintendoEntertainmentSystem.cs b/Detectors/SuperNintendoEntertainmentSystem.cs new file mode 100644 index 0000000..fa9d387 --- /dev/null +++ b/Detectors/SuperNintendoEntertainmentSystem.cs @@ -0,0 +1,36 @@ +using SabreTools.Skippers.Tests; + +namespace SabreTools.Skippers.Detectors +{ + /// + /// Detector for Super Nintendo Entertainment System headers + /// + /// Originally from snes.xml + internal class SuperNintendoEntertainmentSystem : Detector + { + public SuperNintendoEntertainmentSystem() + { + // Create tests + var figTest = new DataTest("16", "0000000000000000", true); + var smcTest = new DataTest("16", "AABB040000000000", true); + var ufoTest = new DataTest("16", "535550455255464F", true); + + // Create rules + var figRule = new Rule("200", null, HeaderSkipOperation.None, [figTest], "snes"); + var smcRule = new Rule("200", null, HeaderSkipOperation.None, [smcTest], "snes"); + var ufoRule = new Rule("200", null, HeaderSkipOperation.None, [ufoTest], "snes"); + + // Create file + Name = "Nintendo Super Famicom/SNES"; + Author = "Matt Nadareski (darksabre76)"; + Version = "1.0"; + SourceFile = "snes"; + Rules = + [ + figRule, + smcRule, + ufoRule, + ]; + } + } +} diff --git a/Enums.cs b/Enums.cs new file mode 100644 index 0000000..da60297 --- /dev/null +++ b/Enums.cs @@ -0,0 +1,40 @@ +using System.Xml.Serialization; + +namespace SabreTools.Skippers +{ + /// + /// Determines the header skip operation + /// + public enum HeaderSkipOperation + { + [XmlEnum("none")] + None = 0, + + [XmlEnum("bitswap")] + Bitswap, + + [XmlEnum("byteswap")] + Byteswap, + + [XmlEnum("wordswap")] + Wordswap, + + [XmlEnum("wordbyteswap")] + WordByteswap, + } + + /// + /// Determines the operator to be used in a file test + /// + public enum HeaderSkipTestFileOperator + { + [XmlEnum("equal")] + Equal = 0, + + [XmlEnum("less")] + Less, + + [XmlEnum("greater")] + Greater, + } +} diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 3ae8619..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2024 SabreTools - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/README.MD b/README.MD new file mode 100644 index 0000000..b51cfb7 --- /dev/null +++ b/README.MD @@ -0,0 +1,5 @@ +# SabreTools.Skippers + +This library comprises of code to perform copier header operations such as matching, manipulation, and removal. These are used for many older console-based systems and helps define known header skippers for immediate use. + +Find the link to the Nuget package [here](https://www.nuget.org/packages/SabreTools.Skippers). diff --git a/Rule.cs b/Rule.cs new file mode 100644 index 0000000..f8e3caa --- /dev/null +++ b/Rule.cs @@ -0,0 +1,286 @@ +using System; +using System.IO; +using System.Xml.Serialization; +using SabreTools.Skippers.Tests; + +namespace SabreTools.Skippers +{ + [XmlType("rule")] + public class Rule + { + #region Fields + + /// + /// Starting offset for applying rule + /// + /// Either numeric or the literal "EOF" + [XmlAttribute("start_offset")] + public string? StartOffset + { + get => _startOffset == null ? "EOF" : _startOffset.Value.ToString(); + set + { + if (value == null || value.Equals("eof", StringComparison.InvariantCultureIgnoreCase)) + _startOffset = null; + else + _startOffset = Convert.ToInt64(value, fromBase: 16); + } + } + + /// + /// Ending offset for applying rule + /// + /// Either numeric or the literal "EOF" + [XmlAttribute("end_offset")] + public string? EndOffset + { + get => _endOffset == null ? "EOF" : _endOffset.Value.ToString(); + set + { + if (value == null || value.Equals("eof", StringComparison.InvariantCultureIgnoreCase)) + _endOffset = null; + else + _endOffset = Convert.ToInt64(value, fromBase: 16); + } + } + + /// + /// Byte manipulation operation + /// + [XmlAttribute("operation")] + public HeaderSkipOperation Operation { get; set; } + + /// + /// List of matching tests in a rule + /// + [XmlElement("and", typeof(AndTest))] + [XmlElement("data", typeof(DataTest))] + [XmlElement("file", typeof(FileTest))] + [XmlElement("or", typeof(OrTest))] + [XmlElement("xor", typeof(XorTest))] + public Test[]? Tests { get; set; } + + /// + /// Filename the skipper rule lives in + /// + [XmlIgnore] + public string? SourceFile { get; set; } + + #endregion + + #region Private instance variables + + /// + /// Starting offset for applying rule + /// + /// null is EOF + private long? _startOffset = null; + + /// + /// Ending offset for applying rule + /// + /// null is EOF + private long? _endOffset = null; + + #endregion + + /// + /// Constructor + /// + public Rule(string? startOffset, string? endOffset, HeaderSkipOperation operation, Test[]? tests, string? sourceFile) + { + StartOffset = startOffset; + EndOffset = endOffset; + Operation = operation; + Tests = tests; + SourceFile = sourceFile; + } + + /// + /// Check if a Stream passes all tests in the Rule + /// + /// Stream to check + /// True if all tests passed, false otherwise + public bool PassesAllTests(Stream input) + { + bool success = true; + + // If there are no tests + if (Tests == null || Tests.Length == 0) + return success; + + foreach (Test test in Tests) + { + bool result = test.Passes(input); + success &= result; + } + + return success; + } + + /// + /// Transform an input file using the given rule + /// + /// Input file name + /// Output file name + /// True if the file was transformed properly, false otherwise + public bool TransformFile(string input, string output) + { + // If the input file doesn't exist + if (string.IsNullOrEmpty(input) || !File.Exists(input)) + return false; + + // If we have an invalid output directory name + if (string.IsNullOrEmpty(output)) + return false; + + // Create the output directory if it doesn't already + string parentDirectory = Path.GetDirectoryName(output) ?? string.Empty; + Directory.CreateDirectory(parentDirectory); + + //logger.User($"Attempting to apply rule to '{input}'"); + bool success = TransformStream(File.OpenRead(input), File.Create(output)); + + // If the output file has size 0, delete it + if (new FileInfo(output).Length == 0) + { + File.Delete(output); + success = false; + } + + return success; + } + + /// + /// Transform an input stream using the given rule + /// + /// Input stream + /// Output stream + /// True if the underlying read stream should be kept open, false otherwise + /// True if the underlying write stream should be kept open, false otherwise + /// True if the file was transformed properly, false otherwise + public bool TransformStream(Stream? input, Stream output, bool keepReadOpen = false, bool keepWriteOpen = false) + { + bool success = true; + + // If the input stream isn't valid + if (input == null || !input.CanRead) + return false; + + // If the sizes are wrong for the values, fail + long extsize = input.Length; + if ((Operation > HeaderSkipOperation.Bitswap && (extsize % 2) != 0) + || (Operation > HeaderSkipOperation.Byteswap && (extsize % 4) != 0) + || (Operation > HeaderSkipOperation.Bitswap && (_startOffset == null || _startOffset % 2 != 0))) + { + return false; + } + + // Now read the proper part of the file and apply the rule + BinaryWriter? bw = null; + BinaryReader? br = null; + try + { + bw = new BinaryWriter(output); + br = new BinaryReader(input); + + // Seek to the beginning offset + if (_startOffset == null) + success = false; + + else if (Math.Abs((long)_startOffset) > input.Length) + success = false; + + else if (_startOffset > 0) + input.Seek((long)_startOffset, SeekOrigin.Begin); + + else if (_startOffset < 0) + input.Seek((long)_startOffset, SeekOrigin.End); + + // Then read and apply the operation as you go + if (success) + { + byte[] buffer = new byte[4]; + int pos = 0; + while (input.Position < (_endOffset ?? input.Length) + && input.Position < input.Length) + { + byte b = br.ReadByte(); + switch (Operation) + { + case HeaderSkipOperation.Bitswap: + // http://stackoverflow.com/questions/3587826/is-there-a-built-in-function-to-reverse-bit-order + uint r = b; + int s = 7; + for (b >>= 1; b != 0; b >>= 1) + { + r <<= 1; + r |= (byte)(b & 1); + s--; + } + r <<= s; + buffer[pos] = (byte)r; + break; + + case HeaderSkipOperation.Byteswap: + if (pos % 2 == 1) + buffer[pos - 1] = b; + else + buffer[pos + 1] = b; + + break; + + case HeaderSkipOperation.Wordswap: + buffer[3 - pos] = b; + break; + + case HeaderSkipOperation.WordByteswap: + buffer[(pos + 2) % 4] = b; + break; + + case HeaderSkipOperation.None: + default: + buffer[pos] = b; + break; + } + + // Set the buffer position to default write to + pos = (pos + 1) % 4; + + // If we filled a buffer, flush to the stream + if (pos == 0) + { + bw.Write(buffer); + bw.Flush(); + buffer = new byte[4]; + } + } + + // If there's anything more in the buffer, write only the left bits + for (int i = 0; i < pos; i++) + { + bw.Write(buffer[i]); + } + } + } + catch + { + return false; + } + finally + { +#if NET40_OR_GREATER + // If we're not keeping the read stream open, dispose of the binary reader + if (!keepReadOpen) + br?.Dispose(); + + // If we're not keeping the write stream open, dispose of the binary reader + if (!keepWriteOpen) + bw?.Dispose(); +#endif + } + + return success; + } + } +} diff --git a/SabreTools.Skippers.csproj b/SabreTools.Skippers.csproj new file mode 100644 index 0000000..ef4fa18 --- /dev/null +++ b/SabreTools.Skippers.csproj @@ -0,0 +1,30 @@ + + + + + net20;net35;net40;net452;net462;net472;net48;netcoreapp3.1;net5.0;net6.0;net7.0;net8.0 + win-x86;win-x64;win-arm64;linux-x64;linux-arm64;osx-x64 + false + false + latest + enable + true + true + 1.1.2 + + + Matt Nadareski + Copyright (c)2016-2024 Matt Nadareski + https://github.com/SabreTools/ + README.md + https://github.com/SabreTools/SabreTools.Skippers + git + skippers headers game + MIT + + + + + + + diff --git a/SabreTools.Skippers.sln b/SabreTools.Skippers.sln index 58ea566..64a6106 100644 --- a/SabreTools.Skippers.sln +++ b/SabreTools.Skippers.sln @@ -3,6 +3,8 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SabreTools.Skippers", "SabreTools.Skippers.csproj", "{D71725DB-0DA3-4D92-9ADC-9601D136126C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -11,4 +13,10 @@ Global GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {D71725DB-0DA3-4D92-9ADC-9601D136126C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D71725DB-0DA3-4D92-9ADC-9601D136126C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D71725DB-0DA3-4D92-9ADC-9601D136126C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D71725DB-0DA3-4D92-9ADC-9601D136126C}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection EndGlobal diff --git a/SkipperMatch.cs b/SkipperMatch.cs new file mode 100644 index 0000000..8a24204 --- /dev/null +++ b/SkipperMatch.cs @@ -0,0 +1,121 @@ +using System.Collections.Generic; +using System.IO; + +namespace SabreTools.Skippers +{ + /// + /// Class for matching existing Skippers + /// + /// + /// Skippers, in general, are distributed as XML files by some projects + /// in order to denote a way of transforming a file so that it will match + /// the hashes included in their DATs. Each skipper file can contain multiple + /// skipper rules, each of which denote a type of header/transformation. In + /// turn, each of those rules can contain multiple tests that denote that + /// a file should be processed using that rule. Transformations can include + /// simply skipping over a portion of the file all the way to byteswapping + /// the entire file. For the purposes of this library, Skippers also denote + /// a way of changing files directly in order to produce a file whose external + /// hash would match those same DATs. + /// + public static class SkipperMatch + { + /// + /// Header detectors represented by a list of detector objects + /// + private static List? Skippers = null; + + /// + /// Initialize static fields + /// + public static void Init() + { + // If the list is populated, don't add to it + if (Skippers != null) + return; + + // Generate header skippers internally + PopulateSkippers(); + } + + /// + /// Populate the entire list of header skippers from generated objects + /// + /// + /// http://mamedev.emulab.it/clrmamepro/docs/xmlheaders.txt + /// http://www.emulab.it/forum/index.php?topic=127.0 + /// + private static void PopulateSkippers() + { + // Ensure the list exists + Skippers ??= []; + + // Get skippers for each known header type + Skippers.Add(new Detectors.Atari7800()); + Skippers.Add(new Detectors.AtariLynx()); + Skippers.Add(new Detectors.CommodorePSID()); + Skippers.Add(new Detectors.NECPCEngine()); + Skippers.Add(new Detectors.Nintendo64()); + Skippers.Add(new Detectors.NintendoEntertainmentSystem()); + Skippers.Add(new Detectors.NintendoFamicomDiskSystem()); + Skippers.Add(new Detectors.SuperNintendoEntertainmentSystem()); + Skippers.Add(new Detectors.SuperFamicomSPC()); + } + + /// + /// Get the Rule associated with a given file + /// + /// Name of the file to be checked + /// Name of the skipper to be used, blank to find a matching skipper + /// Logger object for file and console output + /// The Rule that matched the file + public static Rule GetMatchingRule(string input, string skipperName) + { + // If the file doesn't exist, return a blank skipper rule + if (!File.Exists(input)) + return new Rule(null, null, HeaderSkipOperation.None, null, null); + + return GetMatchingRule(File.OpenRead(input), skipperName); + } + + /// + /// Get the Rule associated with a given stream + /// + /// Name of the file to be checked + /// Name of the skipper to be used, blank to find a matching skipper + /// True if the underlying stream should be kept open, false otherwise + /// The Rule that matched the file + public static Rule GetMatchingRule(Stream? input, string skipperName, bool keepOpen = false) + { + var skipperRule = new Rule(null, null, HeaderSkipOperation.None, null, null); + + // If we have an invalid input + if (input == null || !input.CanRead) + return skipperRule; + + // If we have an invalid set of skippers or skipper name + if (Skippers == null || skipperName == null) + return skipperRule; + + // Loop through all known Detectors + foreach (Detector? skipper in Skippers) + { + // This should not happen + if (skipper == null) + continue; + + skipperRule = skipper.GetMatchingRule(input, skipperName); + if (skipperRule != null) + break; + } + + // If we're not keeping the stream open, dispose of the binary reader + if (!keepOpen) + input?.Dispose(); + + // If the Rule is null, make it empty + skipperRule ??= new Rule(null, null, HeaderSkipOperation.None, null, null); + return skipperRule; + } + } +} \ No newline at end of file diff --git a/Test.cs b/Test.cs new file mode 100644 index 0000000..0b68b69 --- /dev/null +++ b/Test.cs @@ -0,0 +1,73 @@ +using System; +using System.Globalization; +using System.IO; + +namespace SabreTools.Skippers +{ + /// + /// Individual test that applies to a Rule + /// + public abstract class Test + { + /// + /// Check if a stream passes the test + /// + /// Stream to check rule against + /// The Stream is assumed to be in the proper position for a given test + public abstract bool Passes(Stream input); + + #region Helpers + + /// + /// Prase a hex string into a byte array + /// + /// + protected static byte[]? ParseByteArrayFromHex(string? hex) + { + // If we have an invalid string + if (string.IsNullOrEmpty(hex)) + return null; + + var ret = new byte[hex!.Length / 2]; + for (int index = 0; index < ret.Length; index++) + { + string byteValue = hex.Substring(index * 2, 2); + ret[index] = byte.Parse(byteValue, NumberStyles.HexNumber, CultureInfo.InvariantCulture); + } + + return ret; + } + + /// + /// Seek an input stream based on the test value + /// + /// Stream to seek + /// Offset to seek to + /// True if the stream could seek, false on error + protected static bool Seek(Stream input, long? offset) + { + try + { + // Null offset means EOF + if (offset == null) + input.Seek(0, SeekOrigin.End); + + // Positive offset means from beginning + else if (offset >= 0 && offset <= input.Length) + input.Seek(offset.Value, SeekOrigin.Begin); + + // Negative offset means from end + else if (offset < 0 && Math.Abs(offset.Value) <= input.Length) + input.Seek(offset.Value, SeekOrigin.End); + + return true; + } + catch + { + return false; + } + } + + #endregion + } +} \ No newline at end of file diff --git a/Tests/AndTest.cs b/Tests/AndTest.cs new file mode 100644 index 0000000..575cd98 --- /dev/null +++ b/Tests/AndTest.cs @@ -0,0 +1,141 @@ +using System; +using System.IO; +using System.Xml; +using System.Xml.Serialization; + +namespace SabreTools.Skippers.Tests +{ + /// + /// Test that uses a byte mask AND against data + /// + [XmlType("and")] + public class AndTest : Test + { + #region Fields + + /// + /// File offset to run the test + /// + /// Either numeric or the literal "EOF" + [XmlAttribute("offset")] + public string? Offset + { + get => _offset == null ? "EOF" : _offset.Value.ToString(); + set + { + if (value == null || value.ToLowerInvariant() == "eof") + _offset = null; + else + _offset = Convert.ToInt64(value, fromBase: 16); + } + } + + /// + /// Static value to be checked at the offset + /// + /// Hex string representation of a byte array + [XmlAttribute("value")] + public string? Value + { + get => _value == null ? string.Empty : BitConverter.ToString(_value).Replace("-", string.Empty); + set => _value = ParseByteArrayFromHex(value); + } + + /// + /// Determines whether a pass or failure is expected + /// + [XmlAttribute("result")] + public bool Result { get; set; } = true; + + /// + /// Byte mask to be applied to the tested bytes + /// + /// Hex string representation of a byte array + [XmlAttribute("mask")] + public string? Mask + { + get => _mask == null ? string.Empty : BitConverter.ToString(_mask).Replace("-", string.Empty); + set => _mask = ParseByteArrayFromHex(value); + } + + #endregion + + #region Private instance variables + + /// + /// File offset to run the test + /// + /// null is EOF + private long? _offset; + + /// + /// Static value to be checked at the offset + /// + private byte[]? _value; + + /// + /// Byte mask to be applied to the tested bytes + /// + private byte[]? _mask; + + #endregion + + /// + /// Constructor + /// + public AndTest(string? offset, string? value, bool result, string? mask) + { + Offset = offset; + Value = value; + Result = result; + Mask = mask; + } + + /// + public override bool Passes(Stream input) + { + // If we have an invalid mask + if (_mask == null || _mask.Length == 0) + return false; + + // If we have an invalid value + if (_value == null || _value.Length == 0) + return false; + + // Seek to the correct position, if possible + if (!Seek(input, _offset)) + return false; + + bool result = true; + try + { + // Then apply the mask if it exists + byte[] read = new byte[_mask.Length]; + input.Read(read, 0, _mask.Length); + + byte[] masked = new byte[_mask.Length]; + for (int i = 0; i < read.Length; i++) + { + masked[i] = (byte)(read[i] & _mask[i]); + } + + // Finally, compare it against the value + for (int i = 0; i < _value.Length; i++) + { + if (masked[i] != _value[i]) + { + result = false; + break; + } + } + } + catch + { + result = false; + } + + // Return if the expected and actual results match + return result == Result; + } + } +} \ No newline at end of file diff --git a/Tests/DataTest.cs b/Tests/DataTest.cs new file mode 100644 index 0000000..513b7cf --- /dev/null +++ b/Tests/DataTest.cs @@ -0,0 +1,111 @@ +using System; +using System.IO; +using System.Xml; +using System.Xml.Serialization; + +namespace SabreTools.Skippers.Tests +{ + /// + /// Test that checks data matches + /// + [XmlType("data")] + public class DataTest : Test + { + #region Fields + + /// + /// File offset to run the test + /// + /// Either numeric or the literal "EOF" + [XmlAttribute("offset")] + public string? Offset + { + get => _offset == null ? "EOF" : _offset.Value.ToString(); + set + { + if (value == null || value.ToLowerInvariant() == "eof") + _offset = null; + else + _offset = Convert.ToInt64(value, fromBase: 16); + } + } + + /// + /// Static value to be checked at the offset + /// + /// Hex string representation of a byte array + [XmlAttribute("value")] + public string? Value + { + get => _value == null ? string.Empty : BitConverter.ToString(_value).Replace("-", string.Empty); + set => _value = ParseByteArrayFromHex(value); + } + + /// + /// Determines whether a pass or failure is expected + /// + [XmlAttribute("result")] + public bool Result { get; set; } = true; + + #endregion + + #region Private instance variables + + /// + /// File offset to run the test + /// + /// null is EOF + private long? _offset; + + /// + /// Static value to be checked at the offset + /// + private byte[]? _value; + + #endregion + + /// + /// Constructor + /// + public DataTest(string? offset, string? value, bool result) + { + Offset = offset; + Value = value; + Result = result; + } + + /// + public override bool Passes(Stream input) + { + // If we have an invalid value + if (_value == null || _value.Length == 0) + return false; + + // Seek to the correct position, if possible + if (!Seek(input, _offset)) + return false; + + // Then read and compare bytewise + bool result = true; + for (int i = 0; i < _value.Length; i++) + { + try + { + if (input.ReadByte() != _value[i]) + { + result = false; + break; + } + } + catch + { + result = false; + break; + } + } + + // Return if the expected and actual results match + return result == Result; + } + } +} \ No newline at end of file diff --git a/Tests/FileTest.cs b/Tests/FileTest.cs new file mode 100644 index 0000000..8c1436e --- /dev/null +++ b/Tests/FileTest.cs @@ -0,0 +1,97 @@ +using System; +using System.IO; +using System.Xml; +using System.Xml.Serialization; + +namespace SabreTools.Skippers.Tests +{ + /// + /// Test that tests file size + /// + [XmlType("file")] + public class FileTest : Test + { + #region Fields + + /// + /// Determines whether a pass or failure is expected + /// + [XmlAttribute("result")] + public bool Result { get; set; } = true; + + /// + /// Expected size of the input byte array, used with the Operator + /// + /// Either numeric or the literal "po2" + [XmlAttribute("size")] + public string? Size + { + get => _size == null ? "po2" : _size.Value.ToString(); + set + { + if (value == null || value.ToLowerInvariant() == "po2") + _size = null; + else + _size = Convert.ToInt64(value, fromBase: 16); + } + } + + /// + /// Expected range value for the input byte array size, used with Size + /// + [XmlAttribute("operator")] + public HeaderSkipTestFileOperator Operator { get; set; } + + #endregion + + #region Private instance variables + + /// + /// File offset to run the test + /// + /// null is PO2 ("power of 2" filesize) + private long? _size; + + #endregion + + /// + /// Constructor + /// + public FileTest(bool result, string? size, HeaderSkipTestFileOperator opr) + { + Result = result; + Size = size; + Operator = opr; + } + + /// + public override bool Passes(Stream input) + { + // First get the file size from stream + long size = input.Length; + + // If we have a null size, check that the size is a power of 2 + bool result = true; + if (_size == null) + { + // http://stackoverflow.com/questions/600293/how-to-check-if-a-number-is-a-power-of-2 + result = (((ulong)size & ((ulong)size - 1)) == 0); + } + else if (Operator == HeaderSkipTestFileOperator.Less) + { + result = (size < _size); + } + else if (Operator == HeaderSkipTestFileOperator.Greater) + { + result = (size > _size); + } + else if (Operator == HeaderSkipTestFileOperator.Equal) + { + result = (size == _size); + } + + // Return if the expected and actual results match + return result == Result; + } + } +} \ No newline at end of file diff --git a/Tests/OrTest.cs b/Tests/OrTest.cs new file mode 100644 index 0000000..f077168 --- /dev/null +++ b/Tests/OrTest.cs @@ -0,0 +1,141 @@ +using System; +using System.IO; +using System.Xml; +using System.Xml.Serialization; + +namespace SabreTools.Skippers.Tests +{ + /// + /// Test that uses a byte mask OR against data + /// + [XmlType("or")] + public class OrTest : Test + { + #region Fields + + /// + /// File offset to run the test + /// + /// Either numeric or the literal "EOF" + [XmlAttribute("offset")] + public string? Offset + { + get => _offset == null ? "EOF" : _offset.Value.ToString(); + set + { + if (value == null || value.ToLowerInvariant() == "eof") + _offset = null; + else + _offset = Convert.ToInt64(value, fromBase: 16); + } + } + + /// + /// Static value to be checked at the offset + /// + /// Hex string representation of a byte array + [XmlAttribute("value")] + public string? Value + { + get => _value == null ? string.Empty : BitConverter.ToString(_value).Replace("-", string.Empty); + set => _value = ParseByteArrayFromHex(value); + } + + /// + /// Determines whether a pass or failure is expected + /// + [XmlAttribute("result")] + public bool Result { get; set; } = true; + + /// + /// Byte mask to be applied to the tested bytes + /// + /// Hex string representation of a byte array + [XmlAttribute("mask")] + public string? Mask + { + get => _mask == null ? string.Empty : BitConverter.ToString(_mask).Replace("-", string.Empty); + set => _mask = ParseByteArrayFromHex(value); + } + + #endregion + + #region Private instance variables + + /// + /// File offset to run the test + /// + /// null is EOF + private long? _offset; + + /// + /// Static value to be checked at the offset + /// + private byte[]? _value; + + /// + /// Byte mask to be applied to the tested bytes + /// + private byte[]? _mask; + + #endregion + + /// + /// Constructor + /// + public OrTest(string? offset, string? value, bool result, string? mask) + { + Offset = offset; + Value = value; + Result = result; + Mask = mask; + } + + /// + public override bool Passes(Stream input) + { + // If we have an invalid mask + if (_mask == null || _mask.Length == 0) + return false; + + // If we have an invalid value + if (_value == null || _value.Length == 0) + return false; + + // Seek to the correct position, if possible + if (!Seek(input, _offset)) + return false; + + bool result = true; + try + { + // Then apply the mask if it exists + byte[] read = new byte[_mask.Length]; + input.Read(read, 0, _mask.Length); + + byte[] masked = new byte[_mask.Length]; + for (int i = 0; i < read.Length; i++) + { + masked[i] = (byte)(read[i] | _mask[i]); + } + + // Finally, compare it against the value + for (int i = 0; i < _value.Length; i++) + { + if (masked[i] != _value[i]) + { + result = false; + break; + } + } + } + catch + { + result = false; + } + + // Return if the expected and actual results match + return result == Result; + } + } +} \ No newline at end of file diff --git a/Tests/XorTest.cs b/Tests/XorTest.cs new file mode 100644 index 0000000..1c3d5ca --- /dev/null +++ b/Tests/XorTest.cs @@ -0,0 +1,141 @@ +using System; +using System.IO; +using System.Xml; +using System.Xml.Serialization; + +namespace SabreTools.Skippers.Tests +{ + /// + /// Test that uses a byte mask XOR against data + /// + [XmlType("xor")] + public class XorTest : Test + { + #region Fields + + /// + /// File offset to run the test + /// + /// Either numeric or the literal "EOF" + [XmlAttribute("offset")] + public string? Offset + { + get => _offset == null ? "EOF" : _offset.Value.ToString(); + set + { + if (value == null || value.ToLowerInvariant() == "eof") + _offset = null; + else + _offset = Convert.ToInt64(value, fromBase: 16); + } + } + + /// + /// Static value to be checked at the offset + /// + /// Hex string representation of a byte array + [XmlAttribute("value")] + public string? Value + { + get => _value == null ? string.Empty : BitConverter.ToString(_value).Replace("-", string.Empty); + set => _value = ParseByteArrayFromHex(value); + } + + /// + /// Determines whether a pass or failure is expected + /// + [XmlAttribute("result")] + public bool Result { get; set; } = true; + + /// + /// Byte mask to be applied to the tested bytes + /// + /// Hex string representation of a byte array + [XmlAttribute("mask")] + public string? Mask + { + get => _mask == null ? string.Empty : BitConverter.ToString(_mask).Replace("-", string.Empty); + set => _mask = ParseByteArrayFromHex(value); + } + + #endregion + + #region Private instance variables + + /// + /// File offset to run the test + /// + /// null is EOF + private long? _offset; + + /// + /// Static value to be checked at the offset + /// + private byte[]? _value; + + /// + /// Byte mask to be applied to the tested bytes + /// + private byte[]? _mask; + + #endregion + + /// + /// Constructor + /// + public XorTest(string? offset, string? value, bool result, string? mask) + { + Offset = offset; + Value = value; + Result = result; + Mask = mask; + } + + /// + public override bool Passes(Stream input) + { + // If we have an invalid mask + if (_mask == null || _mask.Length == 0) + return false; + + // If we have an invalid value + if (_value == null || _value.Length == 0) + return false; + + // Seek to the correct position, if possible + if (!Seek(input, _offset)) + return false; + + bool result = true; + try + { + // Then apply the mask if it exists + byte[] read = new byte[_mask.Length]; + input.Read(read, 0, _mask.Length); + + byte[] masked = new byte[_mask.Length]; + for (int i = 0; i < read.Length; i++) + { + masked[i] = (byte)(read[i] ^ _mask[i]); + } + + // Finally, compare it against the value + for (int i = 0; i < _value.Length; i++) + { + if (masked[i] != _value[i]) + { + result = false; + break; + } + } + } + catch + { + result = false; + } + + // Return if the expected and actual results match + return result == Result; + } + } +} \ No newline at end of file