diff --git a/src/SharpEmu.Libs/Ampr/PakDirectoryTracker.cs b/src/SharpEmu.Libs/Ampr/PakDirectoryTracker.cs index e12ea164..6b82a42d 100644 --- a/src/SharpEmu.Libs/Ampr/PakDirectoryTracker.cs +++ b/src/SharpEmu.Libs/Ampr/PakDirectoryTracker.cs @@ -54,20 +54,41 @@ internal static class PakDirectoryTracker if (state.Directory is { } entries) { + // Several unrelated archived files can share a byte count (e.g. a head model and a bot + // navigation file both 0x3A34 bytes). Directory order alone then picks whichever comes + // first, which is wrong when the game reads them out of order. id archives cluster + // related assets, and the guest streams them with locality, so disambiguate collisions + // by choosing the unconsumed match nearest the running read cursor. + DirectoryEntry? best = null; + var bestDistance = ulong.MaxValue; foreach (var entry in entries) { - if (!entry.Consumed && entry.FileLen == requestedSize) + if (entry.Consumed || entry.FileLen != requestedSize) { - entry.Consumed = true; - if (_trace) - { - Console.Error.WriteLine( - $"[LOADER][TRACE] pak.directory_match: id=0x{fileId:X8} name='{entry.Name}' " + - $"filepos=0x{entry.FilePos:X8} filelen=0x{entry.FileLen:X8}"); - } - - return entry.FilePos; + continue; } + + var distance = entry.FilePos >= state.NextOffset + ? entry.FilePos - state.NextOffset + : state.NextOffset - entry.FilePos; + if (distance < bestDistance) + { + bestDistance = distance; + best = entry; + } + } + + if (best is not null) + { + best.Consumed = true; + if (_trace) + { + Console.Error.WriteLine( + $"[LOADER][TRACE] pak.directory_match: id=0x{fileId:X8} name='{best.Name}' " + + $"filepos=0x{best.FilePos:X8} filelen=0x{best.FileLen:X8}"); + } + + return best.FilePos; } } diff --git a/tests/SharpEmu.Libs.Tests/Ampr/PakDirectoryTrackerTests.cs b/tests/SharpEmu.Libs.Tests/Ampr/PakDirectoryTrackerTests.cs new file mode 100644 index 00000000..d07f42a7 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Ampr/PakDirectoryTrackerTests.cs @@ -0,0 +1,108 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Buffers.Binary; +using System.Text; +using SharpEmu.HLE; +using SharpEmu.Libs.Ampr; +using Xunit; + +namespace SharpEmu.Libs.Tests.Ampr; + +// PakDirectoryTracker reconstructs the absolute pak offset for a "next sequential chunk" read +// (offset -1) from the requested byte count. When several archived files share that byte count, +// picking by directory order alone mis-resolves out-of-order reads: Quake requested progs/h_ogre.mdl +// (0x3A34 bytes) but the tracker returned bots/navigation/death32c.nav, which shares the size and +// sits earlier in the directory, so the guest parsed NAV2 data as a brush model and aborted. +public sealed class PakDirectoryTrackerTests +{ + private const int EntrySize = 64; + private const int NameLength = 56; + + private readonly record struct PakEntry(string Name, uint FilePos, uint FileLen); + + [Fact] + public void ResolveSequentialOffset_SizeCollision_PicksEntryNearestReadCursor() + { + const uint fileId = 0x5AA5_0001; + const uint collidingLen = 0x3A34; + var memory = new FakeCpuMemory(0x1_0000_0000, 0x1000); + var ctx = new CpuContext(memory, Generation.Gen5); + + // "far" collides in size but lives early in the pak; "near" is what the guest actually wants. + LoadDirectory(ctx, fileId, memory, 0x1_0000_0000, dirFileOffset: 0x400, new[] + { + new PakEntry("far.dat", FilePos: 0x1000, FileLen: collidingLen), + new PakEntry("near.dat", FilePos: 0x9000, FileLen: collidingLen), + }); + + // Advance the read cursor next to the "near" entry, mimicking a burst of nearby asset reads. + PakDirectoryTracker.OnReadCompleted(ctx, fileId, destination: 0x1_0000_0000, fileOffset: 0x8800, bytesRead: 0x400); + + Assert.Equal(0x9000UL, PakDirectoryTracker.ResolveSequentialOffset(fileId, collidingLen)); + // Once the near entry is consumed, the same size resolves to the remaining match. + Assert.Equal(0x1000UL, PakDirectoryTracker.ResolveSequentialOffset(fileId, collidingLen)); + } + + [Fact] + public void ResolveSequentialOffset_ContiguousSameSizeRun_StaysInOrder() + { + const uint fileId = 0x5AA5_0002; + const uint runLen = 0x608; + var memory = new FakeCpuMemory(0x1_0000_0000, 0x1000); + var ctx = new CpuContext(memory, Generation.Gen5); + + // A contiguous run of equal-size lumps (like Quake's gfx/weapons/ww_*.lmp) must still resolve + // in packed order when the guest streams them sequentially. + LoadDirectory(ctx, fileId, memory, 0x1_0000_0000, dirFileOffset: 0x400, new[] + { + new PakEntry("lump_a", FilePos: 0x1000, FileLen: runLen), + new PakEntry("lump_b", FilePos: 0x1000 + runLen, FileLen: runLen), + new PakEntry("lump_c", FilePos: 0x1000 + (runLen * 2), FileLen: runLen), + }); + + var first = PakDirectoryTracker.ResolveSequentialOffset(fileId, runLen); + PakDirectoryTracker.OnReadCompleted(ctx, fileId, destination: 0x1_0000_0000, fileOffset: first, bytesRead: runLen); + var second = PakDirectoryTracker.ResolveSequentialOffset(fileId, runLen); + PakDirectoryTracker.OnReadCompleted(ctx, fileId, destination: 0x1_0000_0000, fileOffset: second, bytesRead: runLen); + var third = PakDirectoryTracker.ResolveSequentialOffset(fileId, runLen); + + Assert.Equal(0x1000UL, first); + Assert.Equal(0x1000UL + runLen, second); + Assert.Equal(0x1000UL + (runLen * 2), third); + } + + // Feeds the tracker a synthetic PACK header + directory table exactly as the AMPR read path does: + // first the 12-byte header (which arms directory parsing), then the directory records themselves. + private static void LoadDirectory( + CpuContext ctx, + uint fileId, + FakeCpuMemory memory, + ulong destination, + ulong dirFileOffset, + PakEntry[] entries) + { + Span header = stackalloc byte[12]; + header[0] = (byte)'P'; + header[1] = (byte)'A'; + header[2] = (byte)'C'; + header[3] = (byte)'K'; + BinaryPrimitives.WriteUInt32LittleEndian(header[4..8], (uint)dirFileOffset); + BinaryPrimitives.WriteUInt32LittleEndian(header[8..12], (uint)(entries.Length * EntrySize)); + memory.TryWrite(destination, header); + PakDirectoryTracker.OnReadCompleted(ctx, fileId, destination, fileOffset: 0, bytesRead: 12); + + var table = new byte[entries.Length * EntrySize]; + for (var i = 0; i < entries.Length; i++) + { + var record = table.AsSpan(i * EntrySize, EntrySize); + var name = Encoding.ASCII.GetBytes(entries[i].Name); + name.AsSpan(0, Math.Min(name.Length, NameLength)).CopyTo(record); + BinaryPrimitives.WriteUInt32LittleEndian(record.Slice(56, 4), entries[i].FilePos); + BinaryPrimitives.WriteUInt32LittleEndian(record.Slice(60, 4), entries[i].FileLen); + } + + memory.TryWrite(destination, table); + PakDirectoryTracker.OnReadCompleted(ctx, fileId, destination, dirFileOffset, (ulong)table.Length); + } +}