The DAT file reader had several bugs inherited from the old C++ reference code, which targeted an older format version. Verified and fixed against real client_portal.dat and client_cell_1.dat files: - Fix header offset: BTree root is at 0x160, not 0x148 (file size field) - Fix BTree entry size: 24 bytes (flags+id+offset+size+timestamp), not 12 - Fix sector-chain node reading: BTree nodes span multiple sectors via linked-list headers; must assemble node data across sector boundaries - Fix DatStreamImpl.Read() BSTR handling: use Buffer.BlockCopy to match C++ SysAllocStringByteLen instead of Marshal.PtrToStringAnsi - Fix DatStreamImpl.ReadBinary() pointer lifetime: inline fixed block to keep destination buffer pinned during Marshal.Copy - Document LoadFilters() dependency on parameterized COM properties in IDecalCore.Configuration that need IDispatch to call correctly Add smoke test project (13/13 tests pass against real DAT files). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
283 lines
10 KiB
C#
283 lines
10 KiB
C#
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
|
|
{
|
|
/// <summary>
|
|
/// Low-level reader for Asheron's Call DAT archive files.
|
|
/// DAT files use a sector-based linked-list format with a hierarchical directory.
|
|
/// </summary>
|
|
internal sealed class DatFile : IDisposable
|
|
{
|
|
// 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;
|
|
|
|
// 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;
|
|
private readonly int _sectorSize;
|
|
private readonly long _fileLength;
|
|
|
|
public DatFile(string filename, int sectorSize = 256)
|
|
{
|
|
_sectorSize = sectorSize;
|
|
|
|
var fs = new FileStream(filename, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
|
_fileLength = fs.Length;
|
|
_mmf = MemoryMappedFile.CreateFromFile(fs, null, 0, MemoryMappedFileAccess.Read, HandleInheritability.None, false);
|
|
_accessor = _mmf.CreateViewAccessor(0, _fileLength, MemoryMappedFileAccess.Read);
|
|
}
|
|
|
|
public DatFileEntry GetFile(uint fileId)
|
|
{
|
|
uint rootDirOffset = _accessor.ReadUInt32(RootDirPtrOffset);
|
|
return FindFile(rootDirOffset, fileId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private byte[] ReadNodeData(uint sectorOffset)
|
|
{
|
|
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)entryCount - 1;
|
|
|
|
while (lo <= hi)
|
|
{
|
|
int mid = (lo + hi) / 2;
|
|
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 = ReadUInt32(node, entryOffset + 8);
|
|
uint size = ReadUInt32(node, entryOffset + 12);
|
|
return new DatFileEntry(this, offset, size);
|
|
}
|
|
else if (fileId < entryId)
|
|
{
|
|
// Recurse into left child branch
|
|
uint subdir = ReadUInt32(node, mid * 4);
|
|
if (subdir != 0)
|
|
{
|
|
try
|
|
{
|
|
return FindFile(subdir, fileId);
|
|
}
|
|
catch (FileNotFoundException)
|
|
{
|
|
// Not in this subtree
|
|
}
|
|
}
|
|
hi = mid - 1;
|
|
}
|
|
else
|
|
{
|
|
lo = mid + 1;
|
|
}
|
|
}
|
|
|
|
// Check rightmost child branch
|
|
uint rightSubdir = ReadUInt32(node, lo * 4);
|
|
if (rightSubdir != 0)
|
|
{
|
|
return FindFile(rightSubdir, fileId);
|
|
}
|
|
|
|
throw new FileNotFoundException($"File 0x{fileId:X8} not found in DAT");
|
|
}
|
|
|
|
internal int SectorSize => _sectorSize;
|
|
internal int DataPerSector => _sectorSize - 4; // First 4 bytes = next sector pointer
|
|
|
|
internal void ReadBytes(long offset, byte[] buffer, int bufferOffset, int count)
|
|
{
|
|
_accessor.ReadArray(offset, buffer, bufferOffset, count);
|
|
}
|
|
|
|
internal uint ReadUInt32(long offset)
|
|
{
|
|
return _accessor.ReadUInt32(offset);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_accessor?.Dispose();
|
|
_accessor = null;
|
|
_mmf?.Dispose();
|
|
_mmf = null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Represents a single file within a DAT archive.
|
|
/// Reads through a linked list of sectors.
|
|
/// </summary>
|
|
internal sealed class DatFileEntry
|
|
{
|
|
private readonly DatFile _source;
|
|
private readonly uint _firstSectorOffset;
|
|
private readonly uint _size;
|
|
|
|
private uint _currentSectorOffset;
|
|
private int _posInSector; // position within current sector's data area
|
|
private int _totalRead; // total bytes read so far
|
|
|
|
public DatFileEntry(DatFile source, uint sectorOffset, uint size)
|
|
{
|
|
_source = source;
|
|
_firstSectorOffset = sectorOffset;
|
|
_size = size;
|
|
Reset();
|
|
}
|
|
|
|
public int Size => (int)_size;
|
|
public int Tell => _totalRead;
|
|
|
|
public void Reset()
|
|
{
|
|
_currentSectorOffset = _firstSectorOffset;
|
|
_posInSector = 0;
|
|
_totalRead = 0;
|
|
}
|
|
|
|
public void Skip(int bytes)
|
|
{
|
|
int remaining = bytes;
|
|
while (remaining > 0)
|
|
{
|
|
int dataPerSector = _source.DataPerSector;
|
|
int availableInSector = dataPerSector - _posInSector;
|
|
|
|
if (remaining < availableInSector)
|
|
{
|
|
_posInSector += remaining;
|
|
_totalRead += remaining;
|
|
return;
|
|
}
|
|
|
|
// Skip rest of this sector and move to next
|
|
_totalRead += availableInSector;
|
|
remaining -= availableInSector;
|
|
MoveToNextSector();
|
|
}
|
|
}
|
|
|
|
public int Read(byte[] buffer, int offset, int count)
|
|
{
|
|
int remaining = Math.Min(count, (int)_size - _totalRead);
|
|
int totalCopied = 0;
|
|
|
|
while (remaining > 0)
|
|
{
|
|
int dataPerSector = _source.DataPerSector;
|
|
int availableInSector = dataPerSector - _posInSector;
|
|
int toCopy = Math.Min(remaining, availableInSector);
|
|
|
|
// Data starts at sector offset + 4 (skip next-sector pointer)
|
|
long readOffset = _currentSectorOffset + 4 + _posInSector;
|
|
_source.ReadBytes(readOffset, buffer, offset + totalCopied, toCopy);
|
|
|
|
_posInSector += toCopy;
|
|
_totalRead += toCopy;
|
|
totalCopied += toCopy;
|
|
remaining -= toCopy;
|
|
|
|
if (_posInSector >= dataPerSector && remaining > 0)
|
|
{
|
|
MoveToNextSector();
|
|
}
|
|
}
|
|
|
|
return totalCopied;
|
|
}
|
|
|
|
private void MoveToNextSector()
|
|
{
|
|
// Next sector pointer is stored in first 4 bytes of current sector
|
|
uint nextSector = _source.ReadUInt32(_currentSectorOffset);
|
|
_currentSectorOffset = nextSector;
|
|
_posInSector = 0;
|
|
}
|
|
}
|
|
}
|