diff --git a/Source/ACE.DatLoader.Tests/DatTests.cs b/Source/ACE.DatLoader.Tests/DatTests.cs index b1bd7fdd80..9d681fa119 100644 --- a/Source/ACE.DatLoader.Tests/DatTests.cs +++ b/Source/ACE.DatLoader.Tests/DatTests.cs @@ -142,7 +142,7 @@ public void UnpackPortalDatFiles_NoExceptions() Assert.IsNotNull(fileType, $"Key: 0x{kvp.Key:X8}, ObjectID: 0x{kvp.Value.ObjectId:X8}, FileSize: {kvp.Value.FileSize}"); // These file types aren't converted yet - if (fileType == DatFileType.KeyMap) continue; // 0x14, 2 files + // 0x14, 2 files - KeyMap is now supported by KeyMap.cs and validated against client_portal.dat if (fileType == DatFileType.RenderMaterial) continue; // 0x16, 1 file if (fileType == DatFileType.MaterialModifier) continue; // 0x17, 1 file if (fileType == DatFileType.MaterialInstance) continue; // 0x18, 1 file diff --git a/Source/ACE.DatLoader.Tests/KeyMapTests.cs b/Source/ACE.DatLoader.Tests/KeyMapTests.cs new file mode 100644 index 0000000000..04ba7ba2dd --- /dev/null +++ b/Source/ACE.DatLoader.Tests/KeyMapTests.cs @@ -0,0 +1,211 @@ + +using System; +using System.IO; +using System.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using KeyMap = ACE.DatLoader.FileTypes.KeyMap; + +namespace ACE.DatLoader.Tests +{ + [TestClass] + public class KeyMapTests + { + private static readonly Guid TestGuid = + new Guid("451f8ccc-a7e9-4df4-9a6b-f4a7c706f300"); + + // TEST 1: Decode a complete valid KeyMap. + [TestMethod] + public void KeyMap_ValidRecord_ParsesCompletely() + { + byte[] data = CreateValidKeyMap(); + + using var stream = new MemoryStream(data); + using var reader = new BinaryReader(stream); + + var keyMap = new KeyMap(); + keyMap.Unpack(reader); + + Assert.AreEqual(0x14000000u, keyMap.Id); + Assert.AreEqual("TestMap", keyMap.Name); + Assert.AreEqual(TestGuid, keyMap.MapGuid); + + Assert.AreEqual(2, keyMap.Devices.Count); + Assert.AreEqual((byte)0, keyMap.Devices[0].Type); + Assert.AreEqual((byte)2, keyMap.Devices[1].Type); + + Assert.AreEqual(1, keyMap.MetaKeys.Count); + Assert.AreEqual(0x002A0000u, keyMap.MetaKeys[0].Key); + Assert.AreEqual(0x80000000u, + keyMap.MetaKeys[0].Modifier); + + Assert.AreEqual(2, keyMap.InputMaps.Count); + Assert.AreEqual(2, keyMap.InputMaps[10].Count); + Assert.AreEqual(1, keyMap.InputMaps[20].Count); + + Assert.AreEqual(3, + keyMap.InputMaps.Values.Sum(x => x.Count)); + + var firstBinding = keyMap.InputMaps[10][0]; + + Assert.AreEqual(0x001E0000u, + firstBinding.Control.Key); + Assert.AreEqual(0u, + firstBinding.Control.Modifier); + Assert.AreEqual(3u, firstBinding.Activation); + Assert.AreEqual(0u, firstBinding.Unknown); + + Assert.AreEqual(stream.Length, stream.Position); + } + + // TEST 2: Reject truncated binary records. + [TestMethod] + public void KeyMap_TruncatedRecord_Throws() + { + byte[] data = CreateValidKeyMap(); + + // Remove the final byte to simulate corruption. + Array.Resize(ref data, data.Length - 1); + + using var stream = new MemoryStream(data); + using var reader = new BinaryReader(stream); + + var keyMap = new KeyMap(); + + // ValidateCount detects insufficient bytes before + // BinaryReader attempts to read the incomplete record. + Assert.ThrowsExactly( + () => keyMap.Unpack(reader)); + } + + // TEST 3: Reject impossible collection counts. + [TestMethod] + public void KeyMap_InvalidDeviceCount_Throws() + { + using var stream = new MemoryStream(); + + using (var writer = new BinaryWriter( + stream, System.Text.Encoding.UTF8, true)) + { + writer.Write(0x14000000u); + writer.Write("InvalidMap"); + writer.Write(TestGuid.ToByteArray()); + + // This count cannot fit in the remaining data. + writer.Write(uint.MaxValue); + } + + stream.Position = 0; + + using var reader = new BinaryReader(stream); + var keyMap = new KeyMap(); + + Assert.ThrowsExactly( + () => keyMap.Unpack(reader)); + } + + // TEST 4: Reject duplicate input map identifiers. + [TestMethod] + public void KeyMap_DuplicateInputMap_Throws() + { + byte[] data = CreateValidKeyMap( + duplicateMapIds: true); + + using var stream = new MemoryStream(data); + using var reader = new BinaryReader(stream); + + var keyMap = new KeyMap(); + + Assert.ThrowsExactly( + () => keyMap.Unpack(reader)); + } + + // TEST 5: Reusing the parser replaces previous data. + [TestMethod] + public void KeyMap_RepeatedUnpack_ReplacesCollections() + { + byte[] data = CreateValidKeyMap(); + var keyMap = new KeyMap(); + + for (int i = 0; i < 2; i++) + { + using var stream = new MemoryStream(data); + using var reader = new BinaryReader(stream); + + keyMap.Unpack(reader); + + Assert.AreEqual(2, keyMap.Devices.Count); + Assert.AreEqual(1, keyMap.MetaKeys.Count); + Assert.AreEqual(2, keyMap.InputMaps.Count); + Assert.AreEqual(stream.Length, stream.Position); + } + } + + // Generate a valid KeyMap record in memory. + // No client DAT files are required for these tests. + private static byte[] CreateValidKeyMap( + bool duplicateMapIds = false) + { + using var stream = new MemoryStream(); + + using (var writer = new BinaryWriter( + stream, System.Text.Encoding.UTF8, true)) + { + // Header + writer.Write(0x14000000u); + writer.Write("TestMap"); + writer.Write(TestGuid.ToByteArray()); + + // Two devices + writer.Write(2u); + + writer.Write((byte)0); + writer.Write(TestGuid.ToByteArray()); + + writer.Write((byte)2); + writer.Write(TestGuid.ToByteArray()); + + // One modifier key definition + writer.Write(1u); + writer.Write(0x002A0000u); + writer.Write(0x80000000u); + + // Two input map groups + writer.Write(2u); + + // Map 10: two bindings + writer.Write(10u); + writer.Write(2u); + + WriteBinding(writer, + 0x001E0000u, 0u, 3u, 0u); + + WriteBinding(writer, + 0x00300000u, 0u, 3u, 0u); + + // Map 20, or duplicate map 10 + writer.Write(duplicateMapIds ? 10u : 20u); + writer.Write(1u); + + WriteBinding(writer, + 0x002E0000u, 0x20000000u, 3u, 0u); + } + + return stream.ToArray(); + } + + // Each binding contains four unsigned 32-bit values. + private static void WriteBinding( + BinaryWriter writer, + uint key, + uint modifier, + uint activation, + uint unknown) + { + writer.Write(key); + writer.Write(modifier); + writer.Write(activation); + writer.Write(unknown); + } + } +} diff --git a/Source/ACE.DatLoader/FileTypes/KeyMap.cs b/Source/ACE.DatLoader/FileTypes/KeyMap.cs new file mode 100644 index 0000000000..a56b69ec7b --- /dev/null +++ b/Source/ACE.DatLoader/FileTypes/KeyMap.cs @@ -0,0 +1,179 @@ + +using System; +using System.Collections.Generic; +using System.IO; + +namespace ACE.DatLoader.FileTypes +{ + [DatFileType(DatFileType.KeyMap)] + public class KeyMap : FileType + { + public string Name { get; private set; } + public Guid MapGuid { get; private set; } + + public List Devices { get; } = + new List(); + + public List MetaKeys { get; } = + new List(); + + public Dictionary> InputMaps { get; } = + new Dictionary>(); + + public override void Unpack(BinaryReader reader) + { + if (reader == null) + throw new ArgumentNullException(nameof(reader)); + + // Build the record in temporary collections so a failed + // unpack never leaves a partially populated KeyMap. + uint id = reader.ReadUInt32(); + string name = reader.ReadString(); + Guid mapGuid = ReadGuid(reader); + + var devices = new List(); + var metaKeys = new List(); + var inputMaps = + new Dictionary>(); + + // Each device consists of a byte and a GUID. + uint deviceCount = reader.ReadUInt32(); + ValidateCount(reader, deviceCount, 17, "Devices"); + + for (uint i = 0; i < deviceCount; i++) + { + devices.Add(new KeyMapDevice + { + Type = reader.ReadByte(), + Id = ReadGuid(reader) + }); + } + + // Each modifier definition has two UInt32 values. + uint metaKeyCount = reader.ReadUInt32(); + ValidateCount(reader, metaKeyCount, 8, "MetaKeys"); + + for (uint i = 0; i < metaKeyCount; i++) + { + metaKeys.Add(ReadControl(reader)); + } + + // Each input map has an ID and a binding count, + // followed by its individual binding records. + uint inputMapCount = reader.ReadUInt32(); + ValidateCount(reader, inputMapCount, 8, "InputMaps"); + + for (uint i = 0; i < inputMapCount; i++) + { + uint mapId = reader.ReadUInt32(); + uint bindingCount = reader.ReadUInt32(); + + // A qualified control contains four UInt32 values. + ValidateCount( + reader, bindingCount, 16, "Bindings"); + + var bindings = new List(); + + for (uint j = 0; j < bindingCount; j++) + { + bindings.Add(new KeyMapBinding + { + Control = ReadControl(reader), + Activation = reader.ReadUInt32(), + Unknown = reader.ReadUInt32() + }); + } + + if (inputMaps.ContainsKey(mapId)) + { + throw new InvalidDataException( + $"Duplicate KeyMap input map ID: 0x{mapId:X8}"); + } + + inputMaps.Add(mapId, bindings); + } + + // Commit the successfully decoded record. + Id = id; + Name = name; + MapGuid = mapGuid; + + Devices.Clear(); + Devices.AddRange(devices); + + MetaKeys.Clear(); + MetaKeys.AddRange(metaKeys); + + InputMaps.Clear(); + foreach (var entry in inputMaps) + InputMaps.Add(entry.Key, entry.Value); + } + + private static Guid ReadGuid(BinaryReader reader) + { + byte[] bytes = reader.ReadBytes(16); + + if (bytes.Length != 16) + { + throw new EndOfStreamException( + "Incomplete KeyMap GUID."); + } + + return new Guid(bytes); + } + + private static KeyMapControl ReadControl( + BinaryReader reader) + { + return new KeyMapControl + { + Key = reader.ReadUInt32(), + Modifier = reader.ReadUInt32() + }; + } + + private static void ValidateCount( + BinaryReader reader, + uint count, + int minimumBytesPerEntry, + string sectionName) + { + Stream stream = reader.BaseStream; + + if (!stream.CanSeek) + { + throw new NotSupportedException( + "KeyMap unpacking requires a seekable stream."); + } + + long remaining = stream.Length - stream.Position; + + if (remaining < 0 || + (ulong)count * (ulong)minimumBytesPerEntry > + (ulong)remaining) + { + throw new InvalidDataException( + $"Invalid KeyMap {sectionName} count: {count}."); + } + } + } + + public class KeyMapDevice + { + public byte Type { get; set; } + public Guid Id { get; set; } + } + + public class KeyMapControl + { + public uint Key { get; set; } + public uint Modifier { get; set; } + } + + public class KeyMapBinding + { + public KeyMapControl Control { get; set; } + public uint Activation { get; set; } + public uint Unknown { get; set; } + } +}