diff --git a/Managed/Decal.DecalDat.Tests/Decal.DecalDat.Tests.csproj b/Managed/Decal.DecalDat.Tests/Decal.DecalDat.Tests.csproj new file mode 100644 index 0000000..1d13e38 --- /dev/null +++ b/Managed/Decal.DecalDat.Tests/Decal.DecalDat.Tests.csproj @@ -0,0 +1,11 @@ + + + Exe + Decal.DecalDat.Tests + Decal.DecalDat.Tests + + + + + + diff --git a/Managed/Decal.DecalDat.Tests/Program.cs b/Managed/Decal.DecalDat.Tests/Program.cs new file mode 100644 index 0000000..cbdd267 --- /dev/null +++ b/Managed/Decal.DecalDat.Tests/Program.cs @@ -0,0 +1,219 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using Decal.Interop.Dat; + +namespace Decal.DecalDat.Tests +{ + class Program + { + // File IDs from FileService.Startup() — decimal values converted to hex + static readonly (uint id, string name)[] PortalFiles = new[] + { + (0x0E00000Eu, "SpellTable"), // 234881038 + (0x0E00000Fu, "ComponentTable"), // 234881039 + (0x0E000003u, "VitalFormulaTable"), // 234881027 + (0x0E000004u, "SkillTable"), // 234881028 + (0x25000006u, "AttributeTable"), // 620756998 + (0x25000007u, "VitalTable"), // 620756999 + }; + + static int _pass, _fail; + + static void Main(string[] args) + { + string acPath = args.Length > 0 + ? args[0] + : @"C:\Turbine\Asheron's Call"; + + string portalPath = Path.Combine(acPath, "client_portal.dat"); + string cellPath = Path.Combine(acPath, "client_cell_1.dat"); + + Console.WriteLine("=== DecalDat Smoke Test ==="); + Console.WriteLine($"Platform: {(IntPtr.Size == 4 ? "x86" : "x64")}"); + Console.WriteLine(); + + TestDatFile("portal", portalPath, 1024); + TestDatFile("cell", cellPath, 256); + TestPortalKnownFiles(portalPath); + TestStreamReadRestart(portalPath); + TestStreamReadBinary(portalPath); + + Console.WriteLine(); + Console.WriteLine($"=== Results: {_pass} passed, {_fail} failed ==="); + } + + static void TestDatFile(string label, string path, int sectorSize) + { + Console.WriteLine($"[TEST] Open {label}.dat ({path})"); + if (!File.Exists(path)) + { + Fail($" File not found: {path}"); + return; + } + + try + { + using (var dat = new DatFile(path, sectorSize)) + { + Pass($" Opened successfully (sector size {sectorSize})"); + } + } + catch (Exception ex) + { + Fail($" Failed to open: {ex.Message}"); + } + } + + static void TestPortalKnownFiles(string portalPath) + { + if (!File.Exists(portalPath)) return; + + Console.WriteLine(); + Console.WriteLine("[TEST] Read known portal.dat file IDs"); + + try + { + using (var dat = new DatFile(portalPath, 1024)) + { + foreach (var (id, name) in PortalFiles) + { + try + { + var entry = dat.GetFile(id); + if (entry.Size > 0) + Pass($" 0x{id:X8} ({name}): {entry.Size:N0} bytes"); + else + Fail($" 0x{id:X8} ({name}): size is 0"); + } + catch (FileNotFoundException) + { + Fail($" 0x{id:X8} ({name}): NOT FOUND"); + } + catch (Exception ex) + { + Fail($" 0x{id:X8} ({name}): ERROR - {ex.GetType().Name}: {ex.Message}"); + } + } + } + } + catch (Exception ex) + { + Fail($" Failed to open DAT: {ex.Message}"); + } + } + + static void TestStreamReadRestart(string portalPath) + { + if (!File.Exists(portalPath)) return; + + Console.WriteLine(); + Console.WriteLine("[TEST] DatStream: Read, Restart, Read again (consistency)"); + + using (var dat = new DatFile(portalPath, 1024)) + { + var entry = dat.GetFile(0x0E00000E); // SpellTable + var stream = new DatStreamImpl(); + stream.Load(entry); + + int size = stream.Size; + if (size <= 0) { Fail("Size is 0"); return; } + + // Read first 64 bytes via ReadBinary + int readSize = Math.Min(64, size); + byte[] first = new byte[readSize]; + stream.ReadBinary(readSize, ref first[0]); + + // Restart and read again + stream.Restart(); + byte[] second = new byte[readSize]; + stream.ReadBinary(readSize, ref second[0]); + + bool match = true; + for (int i = 0; i < readSize; i++) + if (first[i] != second[i]) { match = false; break; } + + if (match) + Pass($"Read {readSize} bytes, restarted, read again — identical"); + else + Fail("Data mismatch after Restart!"); + + stream.Restart(); + if (stream.Tell == 0) + Pass("Tell resets to 0 after Restart"); + else + Fail($"Tell is {stream.Tell} after Restart (expected 0)"); + } + } + + static void TestStreamReadBinary(string portalPath) + { + if (!File.Exists(portalPath)) return; + + Console.WriteLine(); + Console.WriteLine("[TEST] DatStream: ReadBinary consistency + Tell tracking"); + + using (var dat = new DatFile(portalPath, 1024)) + { + var entry = dat.GetFile(0x0E000004); // SkillTable + var stream = new DatStreamImpl(); + stream.Load(entry); + + int size = stream.Size; + int readSize = Math.Min(256, size); + + // Read in two chunks + int chunk1 = readSize / 2; + int chunk2 = readSize - chunk1; + + byte[] viaChunks = new byte[readSize]; + stream.ReadBinary(chunk1, ref viaChunks[0]); + + if (stream.Tell == chunk1) + Pass($"Tell is {chunk1} after reading {chunk1} bytes"); + else + Fail($"Tell is {stream.Tell} (expected {chunk1})"); + + // Read second chunk into offset position + byte[] temp = new byte[chunk2]; + stream.ReadBinary(chunk2, ref temp[0]); + Array.Copy(temp, 0, viaChunks, chunk1, chunk2); + + // Restart and read all at once + stream.Restart(); + byte[] viaFull = new byte[readSize]; + stream.ReadBinary(readSize, ref viaFull[0]); + + bool match = true; + for (int i = 0; i < readSize; i++) + if (viaChunks[i] != viaFull[i]) { match = false; break; } + + if (match) + Pass($"Chunked read ({chunk1}+{chunk2}) matches full read ({readSize})"); + else + Fail("Chunked vs full read mismatch!"); + + if (stream.Tell == readSize) + Pass($"Tell is {readSize} after reading {readSize} bytes"); + else + Fail($"Tell is {stream.Tell} (expected {readSize})"); + } + } + + static void Pass(string msg) + { + _pass++; + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine($" PASS {msg}"); + Console.ResetColor(); + } + + static void Fail(string msg) + { + _fail++; + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($" FAIL {msg}"); + Console.ResetColor(); + } + } +} diff --git a/Managed/Decal.DecalDat/DatFile.cs b/Managed/Decal.DecalDat/DatFile.cs index d7bad36..73de9ba 100644 --- a/Managed/Decal.DecalDat/DatFile.cs +++ b/Managed/Decal.DecalDat/DatFile.cs @@ -1,8 +1,11 @@ using System; using System.IO; using System.IO.MemoryMappedFiles; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +[assembly: InternalsVisibleTo("Decal.DecalDat.Tests")] + namespace Decal.DecalDat { /// @@ -11,17 +14,38 @@ namespace Decal.DecalDat /// internal sealed class DatFile : IDisposable { - private const int FileCount = 62; - private const int FileCountOffset = 0x03E; - private const int RootDirPtrOffset = 0x148; + // DAT header layout (starting at 0x140): + // 0x140: "BT" magic (0x5442) + // 0x144: block/sector size + // 0x148: file size + // 0x14C: data set type (1=portal, 2=cell) + // 0x150: data subset + // 0x154: free head + // 0x158: free tail + // 0x15C: free count + // 0x160: BTree root directory offset + private const int RootDirPtrOffset = 0x160; - // Directory entry layout (packed): - // DWORD subdirs[62] = 248 bytes - // DWORD fileCount = 4 bytes - // FileEntry files[62] = 744 bytes (62 * 12) - // Total = 996 bytes - private const int SubdirsSize = FileCount * 4; - private const int FileEntrySize = 12; // 3 DWORDs: ID, Offset, Size + // BTree node data layout (within sector chain, after skipping sector headers): + // DWORD branches[62] = 248 bytes (child pointers; 0 = leaf) + // DWORD entryCount = 4 bytes + // FileEntry entries[62] = 1488 bytes (62 * 24) + // Total node data = 1740 bytes + // + // Nodes are stored in sector chains: each sector's first 4 bytes = next sector + // offset, with usable data in bytes [4..sectorSize-1]. + // + // Each FileEntry (24 bytes): + // DWORD flags (+0) + // DWORD objectId (+4) — the file ID used for lookup + // DWORD fileOffset (+8) — sector offset to file data + // DWORD fileSize (+12) — size in bytes + // UINT64 timestamp (+16) — Windows FILETIME + private const int MaxEntries = 62; + private const int BranchCount = MaxEntries; // 62 child pointers + private const int BranchesSize = BranchCount * 4; // 248 + private const int FileEntrySize = 24; // flags(4) + id(4) + offset(4) + size(4) + timestamp(8) + private const int MaxNodeDataSize = BranchesSize + 4 + MaxEntries * FileEntrySize; // 1740 private MemoryMappedFile _mmf; private MemoryMappedViewAccessor _accessor; @@ -44,30 +68,76 @@ namespace Decal.DecalDat return FindFile(rootDirOffset, fileId); } - private DatFileEntry FindFile(uint dirOffset, uint fileId) + /// + /// Reads BTree node data from a sector chain into a contiguous byte array. + /// Each sector's first 4 bytes is the next-sector pointer; data follows. + /// + private byte[] ReadNodeData(uint sectorOffset) { - // Read file count from directory - uint fileCount = _accessor.ReadUInt32(dirOffset + SubdirsSize); + var data = new byte[MaxNodeDataSize]; + int dataPerSector = _sectorSize - 4; + int copied = 0; + uint currentSector = sectorOffset; + + while (copied < MaxNodeDataSize && currentSector != 0 && currentSector < (uint)_fileLength) + { + int toCopy = Math.Min(dataPerSector, MaxNodeDataSize - copied); + // Bounds check: ensure we don't read past end of file + long readStart = currentSector + 4; + if (readStart + toCopy > _fileLength) + toCopy = (int)(_fileLength - readStart); + if (toCopy <= 0) break; + + _accessor.ReadArray(readStart, data, copied, toCopy); + copied += toCopy; + + if (copied < MaxNodeDataSize) + currentSector = _accessor.ReadUInt32(currentSector); + else + break; + } + + return data; + } + + private static uint ReadUInt32(byte[] data, int offset) + { + return BitConverter.ToUInt32(data, offset); + } + + private DatFileEntry FindFile(uint sectorOffset, uint fileId) + { + if (sectorOffset >= (uint)_fileLength) + throw new FileNotFoundException($"BTree branch 0x{sectorOffset:X8} is out of bounds (file=0x{_fileLength:X})"); + + byte[] node = ReadNodeData(sectorOffset); + + // Parse node: branches[62] (248 bytes) + entryCount (4 bytes) + entries + uint entryCount = ReadUInt32(node, BranchesSize); + + if (entryCount > MaxEntries) + throw new InvalidDataException($"BTree node at 0x{sectorOffset:X8} has invalid entry count {entryCount}"); // Binary search through sorted file entries - int lo = 0, hi = (int)fileCount - 1; + int lo = 0, hi = (int)entryCount - 1; while (lo <= hi) { int mid = (lo + hi) / 2; - long entryOffset = dirOffset + SubdirsSize + 4 + (mid * FileEntrySize); - uint entryId = _accessor.ReadUInt32(entryOffset); + int entryOffset = BranchesSize + 4 + (mid * FileEntrySize); + // Entry layout: flags(+0), objectId(+4), fileOffset(+8), fileSize(+12), timestamp(+16) + uint entryId = ReadUInt32(node, entryOffset + 4); if (entryId == fileId) { - uint offset = _accessor.ReadUInt32(entryOffset + 4); - uint size = _accessor.ReadUInt32(entryOffset + 8); + uint offset = ReadUInt32(node, entryOffset + 8); + uint size = ReadUInt32(node, entryOffset + 12); return new DatFileEntry(this, offset, size); } else if (fileId < entryId) { - // Recurse into left subdirectory - uint subdir = _accessor.ReadUInt32(dirOffset + (uint)(mid * 4)); + // Recurse into left child branch + uint subdir = ReadUInt32(node, mid * 4); if (subdir != 0) { try @@ -87,8 +157,8 @@ namespace Decal.DecalDat } } - // Check rightmost subdirectory - uint rightSubdir = _accessor.ReadUInt32(dirOffset + (uint)(lo * 4)); + // Check rightmost child branch + uint rightSubdir = ReadUInt32(node, lo * 4); if (rightSubdir != 0) { return FindFile(rightSubdir, fileId); diff --git a/Managed/Decal.DecalDat/DatServiceImpl.cs b/Managed/Decal.DecalDat/DatServiceImpl.cs index 48ed98b..3d9c7e0 100644 --- a/Managed/Decal.DecalDat/DatServiceImpl.cs +++ b/Managed/Decal.DecalDat/DatServiceImpl.cs @@ -55,22 +55,25 @@ namespace Decal.DecalDat private void LoadFilters() { - try - { - var config = _decalCore.Configuration; - if (config == null) return; - - // Look up "FileFilters" collection in Decal configuration - // The C++ code iterates an IDecalEnum to read filter definitions - // Each filter has: Prefix (string), Cache (bool), ComClass (CLSID) - // - // For now, register the known built-in filters that ship with Decal. - // When Decal.Core is implemented, this will read from live config. - } - catch - { - // Config may not be available yet - } + // The C++ original calls: + // m_pDecal->get_Configuration(L"FileFilters", GUID_NULL, &pEnum) + // then iterates the IDecalEnum reading: + // pEnum->get_ComClass(&clsid) + // pEnum->get_Property(L"Prefix", &vPrefix) // protocol name, e.g. "portal" + // pEnum->get_Property(L"Cache", &vCache) // 0 or 1 + // + // However, IDecalCore.Configuration and IDecalEnum.Property are + // parameterized COM properties that the decompiler flattened to + // simple getters. They need to be called via IDispatch to pass + // the category name / property name arguments. + // + // Filters are registered externally (by plugins/installer in the + // registry under HKLM\SOFTWARE\Decal\FileFilters\{CLSID}) and + // will be loaded once Decal.Core exposes the Configuration enum. + // + // DecalDat still works without filters — callers get raw IDatStream + // objects. Filters are an optional layer that parses the stream into + // typed objects (e.g., spell tables, character data). } public void BeforePlugins() diff --git a/Managed/Decal.DecalDat/DatStreamImpl.cs b/Managed/Decal.DecalDat/DatStreamImpl.cs index 042ef91..35adba3 100644 --- a/Managed/Decal.DecalDat/DatStreamImpl.cs +++ b/Managed/Decal.DecalDat/DatStreamImpl.cs @@ -38,8 +38,14 @@ namespace Decal.DecalDat var buf = new byte[Bytes]; _file.Read(buf, 0, Bytes); - // Copy to unmanaged buffer starting at ref Buffer - Marshal.Copy(buf, 0, GetBufferPtr(ref Buffer), Bytes); + // Pin the destination buffer for the duration of the copy + unsafe + { + fixed (byte* p = &Buffer) + { + Marshal.Copy(buf, 0, new IntPtr(p), Bytes); + } + } } public string Read(int Bytes) @@ -49,22 +55,13 @@ namespace Decal.DecalDat var buf = new byte[Bytes]; int read = _file.Read(buf, 0, Bytes); - // Return as binary BSTR (same as SysAllocStringByteLen in C++) - unsafe - { - fixed (byte* p = buf) - { - return Marshal.PtrToStringAnsi(new IntPtr(p), read); - } - } - } - - private static unsafe IntPtr GetBufferPtr(ref byte buffer) - { - fixed (byte* p = &buffer) - { - return new IntPtr(p); - } + // Match C++ SysAllocStringByteLen: pack raw bytes into WCHAR pairs. + // When the CLR marshals this string to BSTR, the raw bytes are preserved + // byte-for-byte (each char = 2 raw bytes, little-endian). + int charCount = (read + 1) / 2; + var chars = new char[charCount]; + Buffer.BlockCopy(buf, 0, chars, 0, read); + return new string(chars, 0, charCount); } } }