From bf10f42389de5040e6d52ffdd308b8a7307e7aa9 Mon Sep 17 00:00:00 2001 From: Checkm8Croft Date: Wed, 21 Oct 2026 09:46:14 +0200 Subject: [PATCH 1/5] assets: added PSX GAMEWAD support with room texture extraction --- Lara Extractor/AssetProcessor.cs | 8 + Lara Extractor/MainWindow.xaml.cs | 2 +- Lara Extractor/PSXAssetExtractor.cs | 103 ++++++++++ Lara Extractor/PSXGamewad.cs | 67 +++++++ Lara Extractor/PSXLevel.cs | 264 ++++++++++++++++++++++++++ Lara Extractor/TR4PSXWad2Processor.cs | 53 ++++++ Lara Extractor/Wad2Processor.cs | 7 + 7 files changed, 503 insertions(+), 1 deletion(-) create mode 100644 Lara Extractor/PSXAssetExtractor.cs create mode 100644 Lara Extractor/PSXGamewad.cs create mode 100644 Lara Extractor/PSXLevel.cs create mode 100644 Lara Extractor/TR4PSXWad2Processor.cs diff --git a/Lara Extractor/AssetProcessor.cs b/Lara Extractor/AssetProcessor.cs index 3831c06..f4643c8 100644 --- a/Lara Extractor/AssetProcessor.cs +++ b/Lara Extractor/AssetProcessor.cs @@ -36,6 +36,14 @@ public void UnpackAll() } Directory.CreateDirectory(_outputDir); + if (PSXAssetExtractor.IsPsxContainerName(_filePath)) + { + _logger("[Engine] PlayStation GAMEWAD container detected."); + PSXAssetExtractor.Extract(_filePath, _outputDir, _extractTextures, _logger, _progressReporter); + _progressReporter(100); + return; + } + using var fs = new FileStream(_filePath, FileMode.Open, FileAccess.Read); using var br = new BinaryReader(fs); diff --git a/Lara Extractor/MainWindow.xaml.cs b/Lara Extractor/MainWindow.xaml.cs index c36f934..517b500 100644 --- a/Lara Extractor/MainWindow.xaml.cs +++ b/Lara Extractor/MainWindow.xaml.cs @@ -20,7 +20,7 @@ private void BtnBrowse_Click(object sender, RoutedEventArgs e) { OpenFileDialog openFileDialog = new OpenFileDialog { - Filter = "Tomb Raider Levels (*.phd;*.tr2;*.tr4;*.trc)|*.phd;*.tr2;*.tr4;*.trc|All files (*.*)|*.*", + Filter = "Tomb Raider Levels (*.phd;*.tr2;*.tr4;*.trc;*.obj)|*.phd;*.tr2;*.tr4;*.trc;*.obj|All files (*.*)|*.*", Title = "Select a Tomb Raider Level" }; diff --git a/Lara Extractor/PSXAssetExtractor.cs b/Lara Extractor/PSXAssetExtractor.cs new file mode 100644 index 0000000..0e7d876 --- /dev/null +++ b/Lara Extractor/PSXAssetExtractor.cs @@ -0,0 +1,103 @@ +using System; +using System.IO; +using System.Collections.Generic; +using System.Security.Cryptography; + +namespace Lara_Extractor +{ + /// + /// Asset extraction for PlayStation TR4/TR5 GAMEWAD.OBJ containers: one folder per level + /// with the textures used by the level rooms (not the ones of moveables and statics). + /// + internal static class PSXAssetExtractor + { + public static bool IsPsxContainerName(string path) + { + string name = Path.GetFileName(path); + string ext = Path.GetExtension(path).ToLowerInvariant(); + return name.StartsWith("GAMEWAD", StringComparison.OrdinalIgnoreCase) || ext == ".obj"; + } + + public static void Extract(string path, string outputDir, bool extractTextures, Action log, Action progress) + { + var gamewad = PSXGamewad.TryOpen(path); + if (gamewad == null) + { + log("[PSX Error] The file is not a valid GAMEWAD.OBJ (invalid entry table)."); + return; + } + + log($"[PSX] GAMEWAD.OBJ: {gamewad.Entries.Count} entries."); + + var levels = new List<(PSXGamewad.Entry Entry, PSXLevel Level)>(); + foreach (var entry in gamewad.Entries) + { + if (entry.Size < 0x10000) continue; + var level = PSXLevel.TryParse(gamewad.Read(entry), out _); + if (level != null) + levels.Add((entry, level)); + } + + log($"[PSX] {levels.Count} level(s) found."); + if (!extractTextures) + { + log("[PSX] Texture extraction disabled; nothing to do."); + return; + } + + int done = 0; + int totalFiles = 0; + foreach (var (entry, level) in levels) + { + string levelDir = Path.Combine(outputDir, $"Level_{entry.Index:D2}", "Textures"); + Directory.CreateDirectory(levelDir); + + var seen = new HashSet(); + int saved = 0; + + for (int i = 0; i < level.RoomTextures.Count; i++) + { + if (!level.TryRender(level.RoomTextures[i], out int w, out int h, out byte[] bgra)) + continue; + + if (!seen.Add(Hash(w, h, bgra))) + continue; + + // Each room texture set holds three versions (full size and smaller ones). + string name = $"Room_{i / 3:D4}_{i % 3}.tga"; + WriteTga(Path.Combine(levelDir, name), bgra, w, h); + saved++; + } + + totalFiles += saved; + done++; + progress(done * 100.0 / levels.Count); + log($"[PSX Tex] Level {entry.Index:D2}: {saved} unique room texture(s) from {level.RoomTextures.Count} entries, {level.NumRooms} rooms."); + } + + log($"[PSX Tex] {totalFiles} texture(s) saved in {levels.Count} level folder(s)."); + } + + private static string Hash(int w, int h, byte[] bgra) + { + using var sha = SHA1.Create(); + sha.TransformBlock(BitConverter.GetBytes(w), 0, 4, null, 0); + sha.TransformBlock(BitConverter.GetBytes(h), 0, 4, null, 0); + sha.TransformFinalBlock(bgra, 0, bgra.Length); + return Convert.ToHexString(sha.Hash!); + } + + internal static void WriteTga(string path, byte[] bgra, int width, int height) + { + using var fs = new FileStream(path, FileMode.Create, FileAccess.Write); + var header = new byte[18]; + header[2] = 2; // uncompressed true-color + header[12] = (byte)(width & 255); header[13] = (byte)(width >> 8); + header[14] = (byte)(height & 255); header[15] = (byte)(height >> 8); + header[16] = 32; // bits per pixel + header[17] = 0x28; // 8 alpha bits, top-left origin + fs.Write(header, 0, header.Length); + fs.Write(bgra, 0, width * height * 4); + } + } +} diff --git a/Lara Extractor/PSXGamewad.cs b/Lara Extractor/PSXGamewad.cs new file mode 100644 index 0000000..82cbd59 --- /dev/null +++ b/Lara Extractor/PSXGamewad.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace Lara_Extractor +{ + /// + /// PlayStation GAMEWAD.OBJ container (Tomb Raider: The Last Revelation / Chronicles). + /// + /// Layout (see scripts/gamewad.py and SPEC_PSX/CD.H in the TOMB5 project, confirmed on a TR4 GAMEWAD): + /// 51 entries of { int32 offset, int32 size } at the start of the file, offsets are multiples of 2048. + /// Entry 0 = SETUP.MOD, entry 1 = cutscenes (CUTSEQ), entry 2 = title, the following ones are levels, + /// then loading screens (512x256, 16bpp) and a few small blobs. + /// + public sealed class PSXGamewad + { + public const int EntryCount = 51; + public const int SectorSize = 2048; + + public readonly record struct Entry(int Index, long Offset, int Size); + + private readonly byte[] _data; + public IReadOnlyList Entries { get; } + + private PSXGamewad(byte[] data, List entries) + { + _data = data; + Entries = entries; + } + + /// Returns null if the file does not look like a GAMEWAD.OBJ. + public static PSXGamewad? TryOpen(string path) + { + var info = new FileInfo(path); + if (!info.Exists || info.Length < SectorSize || info.Length > int.MaxValue) + return null; + + byte[] data = File.ReadAllBytes(path); + var entries = new List(); + int validNonEmpty = 0; + + for (int i = 0; i < EntryCount; i++) + { + int offset = BitConverter.ToInt32(data, i * 8); + int size = BitConverter.ToInt32(data, i * 8 + 4); + + if (offset < 0 || size < 0 || (offset % SectorSize) != 0 || (long)offset + size > data.Length) + { + // Entries past the table must be sane; a single bad one means this is not a GAMEWAD. + return null; + } + + if (size > 0) validNonEmpty++; + entries.Add(new Entry(i, offset, size)); + } + + return validNonEmpty >= 3 ? new PSXGamewad(data, entries) : null; + } + + public byte[] Read(Entry entry) + { + var result = new byte[entry.Size]; + Buffer.BlockCopy(_data, (int)entry.Offset, result, 0, entry.Size); + return result; + } + } +} diff --git a/Lara Extractor/PSXLevel.cs b/Lara Extractor/PSXLevel.cs new file mode 100644 index 0000000..38936c9 --- /dev/null +++ b/Lara Extractor/PSXLevel.cs @@ -0,0 +1,264 @@ +using System; +using System.Collections.Generic; + +namespace Lara_Extractor +{ + /// + /// A level of the PlayStation versions of Tomb Raider: The Last Revelation / Chronicles, as stored in a GAMEWAD.OBJ entry. + /// + /// The header is TOMB5's `struct Level` (GAME/SETUP.H). The level data does not start at the beginning of the + /// GAMEWAD entry (a relocatable code module comes first), so the header is located by its signature: + /// offsetSoundPointers = 0x800 and offsetSoundData = 0x1000, all offsets being relative to the header. + /// + /// Textures live in VRAM: two 512x256 16-bit blocks at offsetTextiles are uploaded to VRAM (512,0) and (512,256). + /// Every texture is a quad of UVs plus a tpage (page, depth) and a CLUT (palette position), see PSXTEXTSTRUCT. + /// + public sealed class PSXLevel + { + public const int VramWidth = 1024; + public const int VramHeight = 512; + private const int TextileSize = 0x40000; + private const int RoomInfoSize = 80; + private const int OutsideRoomOffsetsSize = 1460; + private const int TextureSize = 16; + private const int MMTextureSize = 3 * TextureSize; + + /// One entry of psxtextinfo, or one of the three textures of an MMTEXTURE (room textures). + public readonly record struct Texture(byte[] U, byte[] V, ushort Clut, ushort Tpage) + { + /// 0 = 4-bit, 1 = 8-bit, 2 = 15-bit. + public int ColorMode => (Tpage >> 7) & 3; + } + + private readonly byte[] _data; + private readonly ushort[] _vram = new ushort[VramWidth * VramHeight]; + + public int Base { get; private set; } + public int ObjectVersion { get; private set; } + public int NumRooms { get; private set; } + public int ClutStartY { get; private set; } + public int NumLevelItems { get; private set; } + + public List ObjectTextures { get; } = new(); + /// Room textures: three textures per MMTEXTURE, in file order (t[0], t[1], t[2] of entry 0, then entry 1, ...). + public List RoomTextures { get; } = new(); + public int RoomTextureSets => RoomTextures.Count / 3; + + private PSXLevel(byte[] data) => _data = data; + + private int I32(int offset) => BitConverter.ToInt32(_data, Base + offset); + private ushort U16(int offset) => BitConverter.ToUInt16(_data, Base + offset); + + /// Returns null when the data does not contain a recognizable level header. + public static PSXLevel? TryParse(byte[] data, out string error) + { + error = ""; + var level = new PSXLevel(data); + if (!level.FindHeader()) + { + error = "level header not found"; + return null; + } + + try + { + level.ReadHeaderAndTables(); + level.BuildVram(); + return level; + } + catch (Exception ex) when (ex is InvalidOperationException || ex is IndexOutOfRangeException || ex is ArgumentException) + { + error = ex.Message; + return null; + } + } + + private bool FindHeader() + { + int limit = Math.Min(0x60000, _data.Length - 0xE4); + for (int b = 0; b <= limit; b += 0x800) + { + int soundPointers = BitConverter.ToInt32(_data, b + 4); + int soundData = BitConverter.ToInt32(_data, b + 8); + int textiles = BitConverter.ToInt32(_data, b + 12); + int animFrames = BitConverter.ToInt32(_data, b + 16); + + if (soundPointers == 0x800 && soundData == 0x1000 && + textiles > soundData && (textiles % 0x800) == 0 && b + (long)animFrames <= _data.Length && + animFrames - textiles == 2 * TextileSize) + { + Base = b; + return true; + } + } + return false; + } + + private void ReadHeaderAndTables() + { + ObjectVersion = I32(0x00); + int offsetTextiles = I32(0x0C); + int offsetRoomInfo = I32(0x14); + ClutStartY = U16(0x2C); + NumRooms = U16(0x2E); + NumLevelItems = U16(0x32); + + int roomInfoLength = I32(0x38); + int floorDataLength = I32(0x3C); + int outsideRoomTableLength = I32(0x40); + int roomBoundingBoxesLength = I32(0x44); + int meshBaseLength = I32(0x4C); + int meshesLength = I32(0x50); + int animsLength = I32(0x54); + int changesLength = I32(0x58); + int rangesLength = I32(0x5C); + int commandsLength = I32(0x60); + int bonesLength = I32(0x64); + int textureInfoLength = I32(0x6C); + int mmTextureInfoLength = I32(0x74); + int animTextureRangesLength = I32(0x78); + + if (NumRooms <= 0 || NumRooms > 1000 || textureInfoLength % TextureSize != 0 || mmTextureInfoLength % MMTextureSize != 0) + throw new InvalidOperationException("unexpected level header values"); + + // Everything from the room table to the sample infos is a single blob (roomInfoLength bytes). + // Walk it in the same order as LoadLevel() in TOMB5's GAME/SETUP.C. + long p = (long)Base + offsetRoomInfo; + long blobEnd = p + roomInfoLength; + long cursor = p + (long)RoomInfoSize * NumRooms; + + for (int r = 0; r < NumRooms; r++) + { + long ri = p + (long)RoomInfoSize * r; + // data, door, floor, light, mesh: sizes (they become pointers once loaded) + for (int k = 0; k < 5; k++) + cursor += BitConverter.ToInt32(_data, (int)ri + k * 4); + } + + cursor += floorDataLength; + cursor += OutsideRoomOffsetsSize + outsideRoomTableLength; + cursor += roomBoundingBoxesLength; + cursor += meshBaseLength + meshesLength + animsLength + changesLength + rangesLength + commandsLength + bonesLength; + cursor += animTextureRangesLength; + + long textureInfoOffset = cursor; + long mmTextureOffset = textureInfoOffset + textureInfoLength + I32(0x70) /* spriteInfoLength */; + + if (textureInfoOffset + textureInfoLength > blobEnd || mmTextureOffset + mmTextureInfoLength > blobEnd || blobEnd > _data.Length) + throw new InvalidOperationException("texture tables fall outside the level data"); + + for (int i = 0; i < textureInfoLength / TextureSize; i++) + ObjectTextures.Add(ReadTexture((int)textureInfoOffset + i * TextureSize)); + + for (int i = 0; i < mmTextureInfoLength / TextureSize; i++) + RoomTextures.Add(ReadTexture((int)mmTextureOffset + i * TextureSize)); + } + + private Texture ReadTexture(int offset) + { + // u0 v0 clut(2) | u1 v1 tpage(2) | u2 v2 pad(2) | u3 v3 pad(2) + var u = new[] { _data[offset], _data[offset + 4], _data[offset + 8], _data[offset + 12] }; + var v = new[] { _data[offset + 1], _data[offset + 5], _data[offset + 9], _data[offset + 13] }; + ushort clut = BitConverter.ToUInt16(_data, offset + 2); + ushort tpage = BitConverter.ToUInt16(_data, offset + 6); + return new Texture(u, v, clut, tpage); + } + + private void BuildVram() + { + int textileOffset = Base + I32(0x0C); + if (textileOffset + 2 * TextileSize > _data.Length) + throw new InvalidOperationException("textile data runs past the end of the level"); + + // Two 512x256 blocks, uploaded at (512, 0) and (512, 256). + for (int y = 0; y < 512; y++) + for (int x = 0; x < 512; x++) + _vram[y * VramWidth + 512 + x] = BitConverter.ToUInt16(_data, textileOffset + (y * 512 + x) * 2); + } + + private ushort Vram(int x, int y) + => (x < 0 || y < 0 || x >= VramWidth || y >= VramHeight) ? (ushort)0 : _vram[y * VramWidth + x]; + + private static void WriteColor(byte[] bgra, int index, ushort value) + { + if (value == 0) + { + bgra[index] = bgra[index + 1] = bgra[index + 2] = bgra[index + 3] = 0; // 0x0000 is transparent on PSX + return; + } + bgra[index] = (byte)(((value >> 10) & 31) * 255 / 31); // B + bgra[index + 1] = (byte)(((value >> 5) & 31) * 255 / 31); // G + bgra[index + 2] = (byte)((value & 31) * 255 / 31); // R + bgra[index + 3] = 255; + } + + /// Renders a texture to BGRA32 using its tpage/CLUT. Returns false if the rectangle is degenerate. + public bool TryRender(Texture t, out int width, out int height, out byte[] bgra) + { + width = height = 0; + bgra = Array.Empty(); + + // A triangle leaves the fourth corner at (0,0); ignore it unless it is a real corner. + bool ignoreFourth = t.U[3] == 0 && t.V[3] == 0 && (t.U[0] | t.V[0] | t.U[1] | t.V[1] | t.U[2] | t.V[2]) != 0 + && Math.Min(Math.Min(t.U[0], t.U[1]), t.U[2]) > 0; + int count = ignoreFourth ? 3 : 4; + + int u0 = 255, u1 = 0, v0 = 255, v1 = 0; + for (int i = 0; i < count; i++) + { + u0 = Math.Min(u0, t.U[i]); u1 = Math.Max(u1, t.U[i]); + v0 = Math.Min(v0, t.V[i]); v1 = Math.Max(v1, t.V[i]); + } + + width = u1 - u0 + 1; + height = v1 - v0 + 1; + if (width < 1 || height < 1) + return false; + + int pageX = (t.Tpage & 15) * 64; + int pageY = ((t.Tpage >> 4) & 1) * 256; + int clutX = (t.Clut & 63) * 16; + int clutY = t.Clut >> 6; + int mode = t.ColorMode; + + bgra = new byte[width * height * 4]; + + ushort[]? palette = null; + if (mode == 0) + { + palette = new ushort[16]; + for (int i = 0; i < 16; i++) palette[i] = Vram(clutX + i, clutY); + } + else if (mode == 1) + { + palette = new ushort[256]; + for (int i = 0; i < 256; i++) palette[i] = Vram(clutX + i, clutY); + } + + for (int y = 0; y < height; y++) + { + int v = v0 + y; + for (int x = 0; x < width; x++) + { + int u = u0 + x; + ushort color; + switch (mode) + { + case 0: // 4-bit: 4 pixels per VRAM word + color = palette![(Vram(pageX + u / 4, pageY + v) >> ((u % 4) * 4)) & 15]; + break; + case 1: // 8-bit: 2 pixels per VRAM word + color = palette![(Vram(pageX + u / 2, pageY + v) >> ((u % 2) * 8)) & 255]; + break; + default: // 15-bit direct + color = Vram(pageX + u, pageY + v); + break; + } + WriteColor(bgra, (y * width + x) * 4, color); + } + } + + return true; + } + } +} diff --git a/Lara Extractor/TR4PSXWad2Processor.cs b/Lara Extractor/TR4PSXWad2Processor.cs new file mode 100644 index 0000000..9f02968 --- /dev/null +++ b/Lara Extractor/TR4PSXWad2Processor.cs @@ -0,0 +1,53 @@ +using System; +using System.IO; + +namespace Lara_Extractor +{ + /// + /// Wad2 export for PlayStation TR4/TR5 levels stored in GAMEWAD.OBJ. + /// + /// Done so far: container and level header parsing (PSXGamewad, PSXLevel) and texture decoding + /// (VRAM, tpage, CLUT). Not done yet: meshes, bones, animations and statics, which in the level file are + /// the sections between the room data and the texture tables (see LoadLevel() in TOMB5's GAME/SETUP.C). + /// + public sealed class TR4PSXWad2Processor + { + private readonly string _inputPath; + private readonly string _outputWad2Path; + private readonly Action _logger; + + public TR4PSXWad2Processor(string inputPath, string outputWad2Path, Action logger) + { + _inputPath = inputPath; + _outputWad2Path = outputWad2Path; + _logger = logger; + } + + public void ExtractWad2() + { + _logger("[PSX Wad2] ========== STARTING PSX WAD2 PIPELINE =========="); + + var gamewad = PSXGamewad.TryOpen(_inputPath); + if (gamewad == null) + { + _logger("[PSX Wad2 Error] The file is not a valid GAMEWAD.OBJ."); + return; + } + + int levels = 0; + foreach (var entry in gamewad.Entries) + { + if (entry.Size < 0x10000) continue; + var level = PSXLevel.TryParse(gamewad.Read(entry), out _); + if (level == null) continue; + + levels++; + _logger($"[PSX Wad2] Level {entry.Index:D2}: {level.NumRooms} rooms, {level.NumLevelItems} items, " + + $"{level.ObjectTextures.Count} object textures, {level.RoomTextureSets} room texture sets."); + } + + _logger($"[PSX Wad2] {levels} level(s) recognized."); + _logger("[PSX Wad2] Wad2 export is not implemented yet (meshes, bones and animations are still to be decoded); no file written."); + } + } +} diff --git a/Lara Extractor/Wad2Processor.cs b/Lara Extractor/Wad2Processor.cs index aa9eb09..3a63d98 100644 --- a/Lara Extractor/Wad2Processor.cs +++ b/Lara Extractor/Wad2Processor.cs @@ -35,6 +35,13 @@ public void ExtractWad2() return; } + if (PSXAssetExtractor.IsPsxContainerName(_inputLevelPath)) + { + _logger("[Wad2] PlayStation GAMEWAD container detected. Delegating to TR4PSXWad2Processor..."); + new TR4PSXWad2Processor(_inputLevelPath, _outputWad2Path, _logger).ExtractWad2(); + return; + } + var fi = new FileInfo(_inputLevelPath); byte[] header = new byte[4]; using (var fs = File.OpenRead(_inputLevelPath)) From 2435c6adfbf0c4e0756e5d958121a94d85d287a5 Mon Sep 17 00:00:00 2001 From: Checkm8Croft Date: Thu, 22 Oct 2026 08:08:09 +0200 Subject: [PATCH 2/5] assets: added PSX sound extraction --- Lara Extractor/AssetProcessor.cs | 2 +- Lara Extractor/PSXAdpcm.cs | 84 +++++++++++++++++++++++++++++ Lara Extractor/PSXAssetExtractor.cs | 48 +++++++++++++++-- Lara Extractor/PSXLevel.cs | 39 ++++++++++++++ 4 files changed, 168 insertions(+), 5 deletions(-) create mode 100644 Lara Extractor/PSXAdpcm.cs diff --git a/Lara Extractor/AssetProcessor.cs b/Lara Extractor/AssetProcessor.cs index f4643c8..fb59527 100644 --- a/Lara Extractor/AssetProcessor.cs +++ b/Lara Extractor/AssetProcessor.cs @@ -39,7 +39,7 @@ public void UnpackAll() if (PSXAssetExtractor.IsPsxContainerName(_filePath)) { _logger("[Engine] PlayStation GAMEWAD container detected."); - PSXAssetExtractor.Extract(_filePath, _outputDir, _extractTextures, _logger, _progressReporter); + PSXAssetExtractor.Extract(_filePath, _outputDir, _extractTextures, _extractAudio, _logger, _progressReporter); _progressReporter(100); return; } diff --git a/Lara Extractor/PSXAdpcm.cs b/Lara Extractor/PSXAdpcm.cs new file mode 100644 index 0000000..5cdc5d7 --- /dev/null +++ b/Lara Extractor/PSXAdpcm.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace Lara_Extractor +{ + /// + /// PlayStation SPU-ADPCM decoder. Samples are stored as 16-byte blocks: + /// byte 0: low nibble = shift, high nibble = filter; byte 1: flags (bit 0 = end of sample, bit 1 = loop, bit 2 = loop start); + /// bytes 2-15: 28 4-bit samples, low nibble first. + /// Tomb Raider PlayStation samples have no VAG header; level sounds play at 11025 Hz when the per-sound pitch + /// modifier is 0 (the game computes an SPU pitch of 0x400, where 0x1000 is 44100 Hz). + /// + internal static class PSXAdpcm + { + public const int BlockSize = 16; + public const int SamplesPerBlock = 28; + public const int DefaultSampleRate = 11025; + + private static readonly int[] F0 = { 0, 60, 115, 98, 122 }; + private static readonly int[] F1 = { 0, 0, -52, -55, -60 }; + + /// Length in bytes of the sample starting at , up to and including the block with the end flag. + public static int FindSampleLength(byte[] data, int start, int limit) + { + int p = start; + while (p + BlockSize <= limit) + { + bool end = (data[p + 1] & 1) != 0; + p += BlockSize; + if (end) break; + } + return p - start; + } + + public static short[] Decode(byte[] data, int start, int length) + { + int blocks = length / BlockSize; + var pcm = new short[blocks * SamplesPerBlock]; + int old = 0, older = 0, o = 0; + + for (int b = 0; b < blocks; b++) + { + int p = start + b * BlockSize; + int shift = data[p] & 15; + int filter = Math.Min(data[p] >> 4, 4); + if (shift > 12) shift = 9; + + for (int i = 0; i < SamplesPerBlock; i++) + { + int nibble = (data[p + 2 + i / 2] >> ((i & 1) * 4)) & 15; + int s = (short)(nibble << 12) >> shift; + s += (old * F0[filter] + older * F1[filter] + 32) >> 6; + s = Math.Clamp(s, short.MinValue, short.MaxValue); + pcm[o++] = (short)s; + older = old; + old = s; + } + } + + return pcm; + } + + public static void WriteWav(string path, short[] pcm, int sampleRate) + { + using var fs = new FileStream(path, FileMode.Create, FileAccess.Write); + using var w = new BinaryWriter(fs); + int dataSize = pcm.Length * 2; + w.Write(new[] { (byte)'R', (byte)'I', (byte)'F', (byte)'F' }); + w.Write(36 + dataSize); + w.Write(new[] { (byte)'W', (byte)'A', (byte)'V', (byte)'E', (byte)'f', (byte)'m', (byte)'t', (byte)' ' }); + w.Write(16); + w.Write((short)1); // PCM + w.Write((short)1); // mono + w.Write(sampleRate); + w.Write(sampleRate * 2); + w.Write((short)2); + w.Write((short)16); + w.Write(new[] { (byte)'d', (byte)'a', (byte)'t', (byte)'a' }); + w.Write(dataSize); + foreach (short s in pcm) w.Write(s); + } + } +} diff --git a/Lara Extractor/PSXAssetExtractor.cs b/Lara Extractor/PSXAssetExtractor.cs index 0e7d876..3341ef5 100644 --- a/Lara Extractor/PSXAssetExtractor.cs +++ b/Lara Extractor/PSXAssetExtractor.cs @@ -18,7 +18,7 @@ public static bool IsPsxContainerName(string path) return name.StartsWith("GAMEWAD", StringComparison.OrdinalIgnoreCase) || ext == ".obj"; } - public static void Extract(string path, string outputDir, bool extractTextures, Action log, Action progress) + public static void Extract(string path, string outputDir, bool extractTextures, bool extractAudio, Action log, Action progress) { var gamewad = PSXGamewad.TryOpen(path); if (gamewad == null) @@ -39,16 +39,27 @@ public static void Extract(string path, string outputDir, bool extractTextures, } log($"[PSX] {levels.Count} level(s) found."); - if (!extractTextures) + if (!extractTextures && !extractAudio) { - log("[PSX] Texture extraction disabled; nothing to do."); + log("[PSX] Texture and audio extraction disabled; nothing to do."); return; } int done = 0; int totalFiles = 0; + int totalSounds = 0; foreach (var (entry, level) in levels) { + if (extractAudio) + totalSounds += ExtractSounds(level, entry.Index, outputDir, log); + + if (!extractTextures) + { + done++; + progress(done * 100.0 / levels.Count); + continue; + } + string levelDir = Path.Combine(outputDir, $"Level_{entry.Index:D2}", "Textures"); Directory.CreateDirectory(levelDir); @@ -75,7 +86,36 @@ public static void Extract(string path, string outputDir, bool extractTextures, log($"[PSX Tex] Level {entry.Index:D2}: {saved} unique room texture(s) from {level.RoomTextures.Count} entries, {level.NumRooms} rooms."); } - log($"[PSX Tex] {totalFiles} texture(s) saved in {levels.Count} level folder(s)."); + if (extractTextures) + log($"[PSX Tex] {totalFiles} texture(s) saved in {levels.Count} level folder(s)."); + if (extractAudio) + log($"[PSX Audio] {totalSounds} sound(s) saved as 11025 Hz mono WAV."); + } + + private static int ExtractSounds(PSXLevel level, int levelIndex, string outputDir, Action log) + { + if (!level.TryReadSounds(out byte[] bank, out var samples)) + { + log($"[PSX Audio Warning] Level {levelIndex:D2}: no valid sound bank."); + return 0; + } + + string dir = Path.Combine(outputDir, $"Level_{levelIndex:D2}", "Sounds"); + Directory.CreateDirectory(dir); + + int saved = 0; + for (int i = 0; i < samples.Count; i++) + { + var (start, length) = samples[i]; + if (length < PSXAdpcm.BlockSize) continue; + + short[] pcm = PSXAdpcm.Decode(bank, start, length); + PSXAdpcm.WriteWav(Path.Combine(dir, $"Sound_{i:D3}.wav"), pcm, PSXAdpcm.DefaultSampleRate); + saved++; + } + + log($"[PSX Audio] Level {levelIndex:D2}: {saved} sound(s)."); + return saved; } private static string Hash(int w, int h, byte[] bgra) diff --git a/Lara Extractor/PSXLevel.cs b/Lara Extractor/PSXLevel.cs index 38936c9..bec8887 100644 --- a/Lara Extractor/PSXLevel.cs +++ b/Lara Extractor/PSXLevel.cs @@ -154,6 +154,45 @@ private void ReadHeaderAndTables() RoomTextures.Add(ReadTexture((int)mmTextureOffset + i * TextureSize)); } + /// + /// Sound effects: at offsetSoundPointers there are numSoundEffects int32 offsets into the sound bank, which is + /// soundWadLength bytes of raw SPU-ADPCM at offsetSoundData (see LoadLevel() and LoadSoundEffects() in TOMB5). + /// Returns the bank plus the (start, length) of every sample. + /// + public bool TryReadSounds(out byte[] bank, out List<(int Start, int Length)> samples) + { + bank = Array.Empty(); + samples = new List<(int, int)>(); + + int pointers = I32(0x04); + int soundData = I32(0x08); + int count = I32(0x24); + int length = I32(0x28); + + if (count <= 0 || count > 4096 || length <= 0 || length > 0x80000 || + (long)Base + pointers + count * 4L > _data.Length || (long)Base + soundData + length > _data.Length) + return false; + + bank = new byte[length]; + Buffer.BlockCopy(_data, Base + soundData, bank, 0, length); + + var starts = new int[count]; + for (int i = 0; i < count; i++) + { + starts[i] = BitConverter.ToInt32(_data, Base + pointers + i * 4); + if (starts[i] < 0 || starts[i] % PSXAdpcm.BlockSize != 0 || starts[i] >= length) + return false; + } + + for (int i = 0; i < count; i++) + { + int limit = (i + 1 < count && starts[i + 1] > starts[i]) ? starts[i + 1] : length; + samples.Add((starts[i], PSXAdpcm.FindSampleLength(bank, starts[i], limit))); + } + + return true; + } + private Texture ReadTexture(int offset) { // u0 v0 clut(2) | u1 v1 tpage(2) | u2 v2 pad(2) | u3 v3 pad(2) From f639f937e91d6bc6f9e6d7ced3893e507586e8de Mon Sep 17 00:00:00 2001 From: Checkm8Croft Date: Fri, 23 Oct 2026 22:50:19 +0200 Subject: [PATCH 3/5] wad2: added TR4 PSX support --- Lara Extractor/PSXLevel.cs | 67 +++- Lara Extractor/PSXModels.cs | 254 +++++++++++++++ Lara Extractor/TR4PSXWad2Processor.cs | 447 +++++++++++++++++++++++++- 3 files changed, 750 insertions(+), 18 deletions(-) create mode 100644 Lara Extractor/PSXModels.cs diff --git a/Lara Extractor/PSXLevel.cs b/Lara Extractor/PSXLevel.cs index bec8887..8ff8469 100644 --- a/Lara Extractor/PSXLevel.cs +++ b/Lara Extractor/PSXLevel.cs @@ -22,6 +22,33 @@ public sealed class PSXLevel private const int OutsideRoomOffsetsSize = 1460; private const int TextureSize = 16; private const int MMTextureSize = 3 * TextureSize; + private const int AnimSize = 40; + public const int ObjectInfoSize = 64; + public const int StaticInfoSize = 28; + public const int NumObjects = 465; // TR4 PlayStation object table size (verified on the TR4 GAMEWAD) + public const int NumStaticObjects = 60; // static ids 10-55 hold data in the TR4 GAMEWAD; entries from 60 on are not static data + + // Absolute offsets inside the entry data (filled by ReadHeaderAndTables). + public int MeshBaseOffset { get; private set; } + public int MeshPointerOffset { get; private set; } + public int MeshPointerCount { get; private set; } + public int AnimOffset { get; private set; } + public int AnimCount { get; private set; } + public int ChangeOffset { get; private set; } + public int ChangeCount { get; private set; } + public int DispatchOffset { get; private set; } + public int DispatchCount { get; private set; } + public int CommandOffset { get; private set; } + public int CommandCount { get; private set; } + public int BoneOffset { get; private set; } + public int BoneIntCount { get; private set; } + public int FrameDataOffset { get; private set; } + public int FrameDataLength { get; private set; } + public int ObjectTableOffset { get; private set; } + public int SpriteInfoOffset { get; private set; } + public int SpriteInfoCount { get; private set; } + + public byte[] Data => _data; /// One entry of psxtextinfo, or one of the three textures of an MMTEXTURE (room textures). public readonly record struct Texture(byte[] U, byte[] V, ushort Clut, ushort Tpage) @@ -38,6 +65,7 @@ public readonly record struct Texture(byte[] U, byte[] V, ushort Clut, ushort Tp public int NumRooms { get; private set; } public int ClutStartY { get; private set; } public int NumLevelItems { get; private set; } + public int MeshBaseLength { get; private set; } public List ObjectTextures { get; } = new(); /// Room textures: three textures per MMTEXTURE, in file order (t[0], t[1], t[2] of entry 0, then entry 1, ...). @@ -138,9 +166,24 @@ private void ReadHeaderAndTables() cursor += floorDataLength; cursor += OutsideRoomOffsetsSize + outsideRoomTableLength; cursor += roomBoundingBoxesLength; - cursor += meshBaseLength + meshesLength + animsLength + changesLength + rangesLength + commandsLength + bonesLength; + + // Model data sections, in file order (see LoadLevel() in TOMB5): mesh data, mesh pointer table, anims, + // state changes, dispatches, anim commands, bones. Offsets here are absolute positions in the entry. + MeshBaseOffset = (int)cursor; MeshBaseLength = meshBaseLength; cursor += meshBaseLength; + MeshPointerOffset = (int)cursor; MeshPointerCount = meshesLength / 4; cursor += meshesLength; + AnimOffset = (int)cursor; AnimCount = animsLength / AnimSize; cursor += animsLength; + ChangeOffset = (int)cursor; ChangeCount = changesLength / 6; cursor += changesLength; + DispatchOffset = (int)cursor; DispatchCount = rangesLength / 8; cursor += rangesLength; + CommandOffset = (int)cursor; CommandCount = commandsLength / 2; cursor += commandsLength; + BoneOffset = (int)cursor; BoneIntCount = bonesLength / 4; cursor += bonesLength; cursor += animTextureRangesLength; + FrameDataOffset = Base + I32(0x10); + FrameDataLength = I32(0x68); + ObjectTableOffset = Base + I32(0x18); + SpriteInfoOffset = (int)(cursor + textureInfoLength); + SpriteInfoCount = I32(0x70) / 16; + long textureInfoOffset = cursor; long mmTextureOffset = textureInfoOffset + textureInfoLength + I32(0x70) /* spriteInfoLength */; @@ -232,14 +275,13 @@ private static void WriteColor(byte[] bgra, int index, ushort value) } /// Renders a texture to BGRA32 using its tpage/CLUT. Returns false if the rectangle is degenerate. - public bool TryRender(Texture t, out int width, out int height, out byte[] bgra) + /// Bounding rectangle (inclusive pixel coordinates inside the tpage) covered by the texture corners. + public static (int U0, int V0, int U1, int V1) GetRect(Texture t, bool? triangle = null) { - width = height = 0; - bgra = Array.Empty(); - - // A triangle leaves the fourth corner at (0,0); ignore it unless it is a real corner. - bool ignoreFourth = t.U[3] == 0 && t.V[3] == 0 && (t.U[0] | t.V[0] | t.U[1] | t.V[1] | t.U[2] | t.V[2]) != 0 - && Math.Min(Math.Min(t.U[0], t.U[1]), t.U[2]) > 0; + // A triangle leaves the fourth corner at (0,0); when the caller does not know the shape, ignore it + // unless it is a real corner. + bool ignoreFourth = triangle ?? (t.U[3] == 0 && t.V[3] == 0 && (t.U[0] | t.V[0] | t.U[1] | t.V[1] | t.U[2] | t.V[2]) != 0 + && Math.Min(Math.Min(t.U[0], t.U[1]), t.U[2]) > 0); int count = ignoreFourth ? 3 : 4; int u0 = 255, u1 = 0, v0 = 255, v1 = 0; @@ -248,6 +290,15 @@ public bool TryRender(Texture t, out int width, out int height, out byte[] bgra) u0 = Math.Min(u0, t.U[i]); u1 = Math.Max(u1, t.U[i]); v0 = Math.Min(v0, t.V[i]); v1 = Math.Max(v1, t.V[i]); } + return (u0, v0, u1, v1); + } + + public bool TryRender(Texture t, out int width, out int height, out byte[] bgra, bool? triangle = null) + { + width = height = 0; + bgra = Array.Empty(); + + var (u0, v0, u1, v1) = GetRect(t, triangle); width = u1 - u0 + 1; height = v1 - v0 + 1; diff --git a/Lara Extractor/PSXModels.cs b/Lara Extractor/PSXModels.cs new file mode 100644 index 0000000..0402106 --- /dev/null +++ b/Lara Extractor/PSXModels.cs @@ -0,0 +1,254 @@ +using System; +using System.Collections.Generic; + +namespace Lara_Extractor +{ + /// One entry of the object table (struct object_info, 64 bytes). Negative NumMeshes = sprite sequence. + internal readonly record struct PsxObject(int Id, int NumMeshes, int MeshIndex, int BoneIndex, int AnimIndex); + + /// One entry of the static table (struct static_info, 28 bytes). Boxes are X1 X2 Y1 Y2 Z1 Z2. + internal readonly record struct PsxStatic(int Id, int MeshNumber, short Flags, short[] Visibility, short[] Collision); + + /// One entry of psxspriteinfo (16 bytes). + internal readonly record struct PsxSprite(ushort Clut, ushort Tpage, int U1, int V1, int U2, int V2); + + internal sealed class PsxMesh + { + public short CenterX, CenterY, CenterZ, Radius; + public int Flags; + public bool PreLit; // flag 0x80: 4th short of each vertex is a 5-5-5 color + public (short X, short Y, short Z, short W)[] Vertices = Array.Empty<(short, short, short, short)>(); + public (short X, short Y, short Z)[]? Normals; // 4096 = 1.0, only when not pre-lit + public List<(int A, int B, int C, int Tex)> Triangles = new(); + /// Quads store their vertices in GPU "Z" order (A B C D): the outline is A B D C. + public List<(int A, int B, int C, int D, int Tex)> Quads = new(); + } + + internal sealed class PsxAnim + { + public uint FrameOffset; + public int FrameSizeWords, Rate; + public int State, Velocity, Acceleration, XVelocity, XAcceleration; + public int FrameStart, FrameEnd, JumpAnim, JumpFrame; + public int NumChanges, ChangeIndex, NumCommands, CommandIndex; + } + + /// + /// Reads the model-related tables of a PSXLevel: objects, statics, meshes, animations (layouts from TOMB5's + /// GAME/SETUP.C, SPEC_PSX/STYPES.H and SPEC_PSXPC_N/DRAWOBJ.C, checked on a TR4 GAMEWAD). + /// + internal static class PSXModelReader + { + public static List ReadObjects(PSXLevel level) + { + var list = new List(); + byte[] d = level.Data; + for (int i = 0; i < PSXLevel.NumObjects; i++) + { + int p = level.ObjectTableOffset + i * PSXLevel.ObjectInfoSize; + if (p + PSXLevel.ObjectInfoSize > d.Length) break; + short nm = BitConverter.ToInt16(d, p); + if (nm == 0) continue; + list.Add(new PsxObject(i, nm, BitConverter.ToInt16(d, p + 2), BitConverter.ToInt32(d, p + 4), BitConverter.ToInt16(d, p + 38))); + } + return list; + } + + public static List ReadStatics(PSXLevel level) + { + var list = new List(); + byte[] d = level.Data; + int start = level.ObjectTableOffset + PSXLevel.NumObjects * PSXLevel.ObjectInfoSize; + for (int i = 0; i < PSXLevel.NumStaticObjects; i++) + { + int p = start + i * PSXLevel.StaticInfoSize; + if (p + PSXLevel.StaticInfoSize > d.Length) break; + short meshNumber = BitConverter.ToInt16(d, p); + if (meshNumber == 0) continue; + + var vis = new short[6]; + var col = new short[6]; + for (int k = 0; k < 6; k++) + { + vis[k] = BitConverter.ToInt16(d, p + 4 + k * 2); + col[k] = BitConverter.ToInt16(d, p + 16 + k * 2); + } + list.Add(new PsxStatic(i, meshNumber, BitConverter.ToInt16(d, p + 2), vis, col)); + } + return list; + } + + public static PsxSprite[] ReadSprites(PSXLevel level) + { + var result = new PsxSprite[level.SpriteInfoCount]; + byte[] d = level.Data; + for (int i = 0; i < result.Length; i++) + { + int p = level.SpriteInfoOffset + i * 16; + // x1 y1 x2 y2 (screen offsets) | clut | tpage | u1 v1 u2 v2 + result[i] = new PsxSprite(BitConverter.ToUInt16(d, p + 8), BitConverter.ToUInt16(d, p + 10), d[p + 12], d[p + 13], d[p + 14], d[p + 15]); + } + return result; + } + + /// + /// Reads the mesh at the given pointer-table index. Returns null if the pointer is 0 (the mesh is not in the + /// level file: Lara and weapon meshes are resident) or if the data fails validation. + /// + public static PsxMesh? ReadMesh(PSXLevel level, int pointerIndex) + { + if (pointerIndex < 0 || pointerIndex >= level.MeshPointerCount) return null; + + byte[] d = level.Data; + uint ptr = BitConverter.ToUInt32(d, level.MeshPointerOffset + pointerIndex * 4); + if (ptr == 0 || ptr >= level.MeshBaseLength) return null; + + try + { + int m = level.MeshBaseOffset + (int)ptr; + int limit = level.MeshBaseOffset + level.MeshBaseLength; + if (m + 12 > limit) return null; + + var mesh = new PsxMesh + { + CenterX = BitConverter.ToInt16(d, m), + CenterY = BitConverter.ToInt16(d, m + 2), + CenterZ = BitConverter.ToInt16(d, m + 4), + Radius = BitConverter.ToInt16(d, m + 6) + }; + + uint header = BitConverter.ToUInt32(d, m + 8); + int vertexCount = (int)(header & 0xFF); + mesh.Flags = (int)((header >> 8) & 0xFF); + mesh.PreLit = (mesh.Flags & 0x80) != 0; + int polyOffset = (int)(header >> 16); + if (vertexCount == 0) return null; + + int vertexStart = m + 12; + if (vertexStart + vertexCount * 8 > limit) return null; + + mesh.Vertices = new (short, short, short, short)[vertexCount]; + for (int i = 0; i < vertexCount; i++) + { + int p = vertexStart + i * 8; + mesh.Vertices[i] = (BitConverter.ToInt16(d, p), BitConverter.ToInt16(d, p + 2), BitConverter.ToInt16(d, p + 4), BitConverter.ToInt16(d, p + 6)); + } + + if (!mesh.PreLit && vertexStart + vertexCount * 16 <= limit) + { + mesh.Normals = new (short, short, short)[vertexCount]; + for (int i = 0; i < vertexCount; i++) + { + int p = vertexStart + vertexCount * 8 + i * 8; + mesh.Normals[i] = (BitConverter.ToInt16(d, p), BitConverter.ToInt16(d, p + 2), BitConverter.ToInt16(d, p + 4)); + } + } + + int ps = vertexStart + polyOffset; + if (ps + 4 > limit) return null; + int triCount = BitConverter.ToUInt16(d, ps); + int quadCount = BitConverter.ToUInt16(d, ps + 2); + int q = ps + 4; + int textures = level.ObjectTextures.Count; + + // Triangles: a word with 4 texture low bytes, then up to 4 words of vertex indices (3 bytes) + texture high nibble. + for (int i = 0; i < triCount;) + { + if (q + 4 > limit) return null; + uint texWord = BitConverter.ToUInt32(d, q); q += 4; + for (int k = 0; k < 4 && i < triCount; k++, i++) + { + if (q + 4 > limit) return null; + uint vw = BitConverter.ToUInt32(d, q); q += 4; + int tex = (int)(((texWord >> (8 * k)) & 0xFF) | (((vw >> 24) & 0xF) << 8)); + int a = (int)(vw & 0xFF), b = (int)((vw >> 8) & 0xFF), c = (int)((vw >> 16) & 0xFF); + if (a >= vertexCount || b >= vertexCount || c >= vertexCount || tex >= textures) return null; + mesh.Triangles.Add((a, b, c, tex)); + } + } + + // Quads: a word with 2 x 16 bits (12-bit texture + 4 flag bits), then up to 2 words of 4 vertex indices. + for (int i = 0; i < quadCount;) + { + if (q + 4 > limit) return null; + uint texWord = BitConverter.ToUInt32(d, q); q += 4; + for (int k = 0; k < 2 && i < quadCount; k++, i++) + { + if (q + 4 > limit) return null; + uint vw = BitConverter.ToUInt32(d, q); q += 4; + int tex = (int)((texWord >> (16 * k)) & 0xFFF); + int a = (int)(vw & 0xFF), b = (int)((vw >> 8) & 0xFF), c = (int)((vw >> 16) & 0xFF), dd = (int)(vw >> 24); + if (a >= vertexCount || b >= vertexCount || c >= vertexCount || dd >= vertexCount || tex >= textures) return null; + mesh.Quads.Add((a, b, c, dd, tex)); + } + } + + return mesh; + } + catch (ArgumentException) + { + return null; + } + } + + public static PsxAnim? ReadAnim(PSXLevel level, int index) + { + if (index < 0 || index >= level.AnimCount) return null; + byte[] d = level.Data; + int p = level.AnimOffset + index * 40; + int interpolation = BitConverter.ToUInt16(d, p + 4); + + return new PsxAnim + { + FrameOffset = BitConverter.ToUInt32(d, p), + FrameSizeWords = interpolation >> 8, + Rate = interpolation & 0xFF, + State = BitConverter.ToInt16(d, p + 6), + Velocity = BitConverter.ToInt32(d, p + 8), + Acceleration = BitConverter.ToInt32(d, p + 12), + XVelocity = BitConverter.ToInt32(d, p + 16), + XAcceleration = BitConverter.ToInt32(d, p + 20), + FrameStart = BitConverter.ToInt16(d, p + 24), + FrameEnd = BitConverter.ToInt16(d, p + 26), + JumpAnim = BitConverter.ToInt16(d, p + 28), + JumpFrame = BitConverter.ToInt16(d, p + 30), + NumChanges = BitConverter.ToInt16(d, p + 32), + ChangeIndex = BitConverter.ToInt16(d, p + 34), + NumCommands = BitConverter.ToInt16(d, p + 36), + CommandIndex = BitConverter.ToUInt16(d, p + 38) + }; + } + + /// Reads 16-bit words of frame data at a byte offset, or null if out of range. + public static short[]? ReadFrameWords(PSXLevel level, long byteOffset, int words) + { + if (byteOffset < 0 || words <= 0 || byteOffset + words * 2L > level.FrameDataLength) return null; + var result = new short[words]; + Buffer.BlockCopy(level.Data, level.FrameDataOffset + (int)byteOffset, result, 0, words * 2); + return result; + } + + public static int ReadBoneInt(PSXLevel level, int intIndex) + => (intIndex < 0 || intIndex >= level.BoneIntCount) ? 0 : BitConverter.ToInt32(level.Data, level.BoneOffset + intIndex * 4); + + public static short ReadCommandWord(PSXLevel level, int index) + => (index < 0 || index >= level.CommandCount) ? (short)0 : BitConverter.ToInt16(level.Data, level.CommandOffset + index * 2); + + /// State change record: state id, number of dispatches, index of the first dispatch. + public static (int State, int Count, int First) ReadChange(PSXLevel level, int index) + { + if (index < 0 || index >= level.ChangeCount) return (0, 0, 0); + int p = level.ChangeOffset + index * 6; + return (BitConverter.ToInt16(level.Data, p), BitConverter.ToInt16(level.Data, p + 2), BitConverter.ToInt16(level.Data, p + 4)); + } + + /// Dispatch record: low frame, high frame, next anim, next frame. + public static (int Low, int High, int NextAnim, int NextFrame) ReadDispatch(PSXLevel level, int index) + { + if (index < 0 || index >= level.DispatchCount) return (0, 0, 0, 0); + int p = level.DispatchOffset + index * 8; + byte[] d = level.Data; + return (BitConverter.ToInt16(d, p), BitConverter.ToInt16(d, p + 2), BitConverter.ToInt16(d, p + 4), BitConverter.ToInt16(d, p + 6)); + } + } +} diff --git a/Lara Extractor/TR4PSXWad2Processor.cs b/Lara Extractor/TR4PSXWad2Processor.cs index 9f02968..a71c2cb 100644 --- a/Lara Extractor/TR4PSXWad2Processor.cs +++ b/Lara Extractor/TR4PSXWad2Processor.cs @@ -1,14 +1,27 @@ using System; +using System.Collections.Generic; using System.IO; +using System.Linq; +using System.Numerics; +using TombLib; +using TombLib.LevelData; +using TombLib.Utils; +using TombLib.Wad; namespace Lara_Extractor { /// - /// Wad2 export for PlayStation TR4/TR5 levels stored in GAMEWAD.OBJ. + /// Wad2 export for PlayStation TR4 levels stored in GAMEWAD.OBJ (one Wad2 per level). /// - /// Done so far: container and level header parsing (PSXGamewad, PSXLevel) and texture decoding - /// (VRAM, tpage, CLUT). Not done yet: meshes, bones, animations and statics, which in the level file are - /// the sections between the room data and the texture tables (see LoadLevel() in TOMB5's GAME/SETUP.C). + /// Formats (TOMB5 GAME/SETUP.C + SPEC_PSXPC_N/DRAWOBJ.C, validated on a TR4 GAMEWAD): + /// - Meshes: 8-byte vertices, optional normals, triangles/quads packed with 8-bit vertex indices and a texture index. + /// Quads are in GPU "Z" order and are re-ordered to an outline (A B D C). + /// - Textures: PSXTEXTSTRUCT (UVs inside a 4/8/15-bit tpage + CLUT), rendered from the level VRAM. + /// - Animations: same tables as TR4 PC (anims, state changes, dispatches, commands) plus fixed-size frames made of + /// a 9-word header and "super-packed" rotations. + /// + /// Not available in the level file: Lara, her weapons and other resident meshes (their mesh pointers are 0). + /// Moveables with no mesh at all are skipped; the others get empty meshes for the missing bones. /// public sealed class TR4PSXWad2Processor { @@ -16,6 +29,10 @@ public sealed class TR4PSXWad2Processor private readonly string _outputWad2Path; private readonly Action _logger; + private PSXLevel _level = null!; + private readonly Dictionary<(int Tex, bool Tri), (TextureArea Area, bool Ok)> _areaCache = new(); + private int _missingTextures; + public TR4PSXWad2Processor(string inputPath, string outputWad2Path, Action logger) { _inputPath = inputPath; @@ -34,20 +51,430 @@ public void ExtractWad2() return; } - int levels = 0; + string dir = Path.GetDirectoryName(_outputWad2Path) ?? "."; + string baseName = Path.GetFileNameWithoutExtension(_outputWad2Path); + Directory.CreateDirectory(dir); + + int saved = 0; foreach (var entry in gamewad.Entries) { if (entry.Size < 0x10000) continue; var level = PSXLevel.TryParse(gamewad.Read(entry), out _); if (level == null) continue; - levels++; - _logger($"[PSX Wad2] Level {entry.Index:D2}: {level.NumRooms} rooms, {level.NumLevelItems} items, " + - $"{level.ObjectTextures.Count} object textures, {level.RoomTextureSets} room texture sets."); + try + { + _level = level; + _areaCache.Clear(); + _missingTextures = 0; + + Wad2 wad = BuildWad2(entry.Index); + string path = Path.Combine(dir, $"{baseName}_Level_{entry.Index:D2}.wad2"); + Wad2Writer.SaveToFile(wad, path); + saved++; + _logger($"[PSX Wad2] Level {entry.Index:D2}: saved {Path.GetFileName(path)} ({wad.Moveables.Count} moveables, {wad.Statics.Count} statics, {wad.SpriteSequences.Count} sprite sequences)."); + } + catch (Exception ex) + { + _logger($"[PSX Wad2 Error] Level {entry.Index:D2}: {ex.GetType().Name}: {ex.Message}"); + } + } + + _logger($"[PSX Wad2] ========== {saved} WAD2 FILE(S) SAVED IN: {dir} =========="); + } + + // ---------------------------------------------------------------- Wad2 + + private Wad2 BuildWad2(int levelIndex) + { + var wad = new Wad2 { GameVersion = TRVersion.Game.TR4 }; + var objects = PSXModelReader.ReadObjects(_level); + + // Animations of an object run from its anim index to the next object's anim index. + var animStarts = objects.Where(o => o.NumMeshes > 0 && (o.AnimIndex > 0 || o.Id == 0)) + .Select(o => o.AnimIndex).Distinct().OrderBy(x => x).ToList(); + + int moveables = 0, skipped = 0, failed = 0, partial = 0; + foreach (var obj in objects.Where(o => o.NumMeshes > 0)) + { + try + { + var mov = BuildMoveable(obj, animStarts, out int missingMeshes); + if (mov == null) { skipped++; continue; } + if (missingMeshes > 0) partial++; + wad.Moveables[new WadMoveableId((uint)obj.Id)] = mov; + moveables++; + } + catch (Exception ex) + { + failed++; + _logger($"[PSX Wad2] Level {levelIndex:D2} moveable {obj.Id}: {ex.GetType().Name}: {ex.Message}"); + } + } + + int statics = 0, staticsSkipped = 0; + foreach (var st in PSXModelReader.ReadStatics(_level)) + { + var mesh = PSXModelReader.ReadMesh(_level, st.MeshNumber); + if (mesh == null) { staticsSkipped++; continue; } + + var stat = new WadStatic(new WadStaticId((uint)st.Id)) + { + Mesh = BuildMesh(mesh, $"static_{st.Id}"), + Flags = st.Flags + }; + stat.VisibilityBox = new BoundingBox( + new Vector3(st.Visibility[0], -st.Visibility[3], st.Visibility[4]), + new Vector3(st.Visibility[1], -st.Visibility[2], st.Visibility[5])); + stat.CollisionBox = new BoundingBox( + new Vector3(st.Collision[0], -st.Collision[3], st.Collision[4]), + new Vector3(st.Collision[1], -st.Collision[2], st.Collision[5])); + wad.Statics[new WadStaticId((uint)st.Id)] = stat; + statics++; + } + + int sprites = 0; + var spriteTable = PSXModelReader.ReadSprites(_level); + foreach (var obj in objects.Where(o => o.NumMeshes < 0)) + { + var seq = BuildSpriteSequence(obj, spriteTable); + if (seq.Sprites.Count == 0) continue; + wad.SpriteSequences[new WadSpriteSequenceId((uint)obj.Id)] = seq; + sprites++; + } + + _logger($"[PSX Wad2] Level {levelIndex:D2}: moveables {moveables} ({partial} with missing meshes, {skipped} skipped: meshes not in the level file, {failed} failed), statics {statics} ({staticsSkipped} skipped), sprite sequences {sprites}."); + if (_missingTextures > 0) + _logger($"[PSX Wad2 Warning] Level {levelIndex:D2}: {_missingTextures} polygon(s) used a texture that could not be rendered."); + return wad; + } + + // ---------------------------------------------------------------- moveables + + private WadMoveable? BuildMoveable(PsxObject obj, List animStarts, out int missingMeshes) + { + missingMeshes = 0; + var meshes = new PsxMesh?[obj.NumMeshes]; + for (int i = 0; i < obj.NumMeshes; i++) + { + meshes[i] = PSXModelReader.ReadMesh(_level, obj.MeshIndex + i * 2); // the pointer table holds 2 entries per mesh + if (meshes[i] == null) missingMeshes++; + } + + if (missingMeshes == obj.NumMeshes) + return null; + + var mov = new WadMoveable(new WadMoveableId((uint)obj.Id)); + for (int i = 0; i < obj.NumMeshes; i++) + { + var bone = new WadBone + { + Name = $"bone_{i}", + Mesh = meshes[i] != null ? BuildMesh(meshes[i]!, $"moveable_{obj.Id}_mesh_{i}") : new WadMesh { Name = "mesh_missing" } + }; + + if (i == 0) + { + bone.Translation = Vector3.Zero; + bone.OpCode = WadLinkOpcode.NotUseStack; + } + else + { + int b = obj.BoneIndex + (i - 1) * 4; + int flags = PSXModelReader.ReadBoneInt(_level, b); + bone.Translation = new Vector3(PSXModelReader.ReadBoneInt(_level, b + 1), -PSXModelReader.ReadBoneInt(_level, b + 2), PSXModelReader.ReadBoneInt(_level, b + 3)); + bone.OpCode = flags switch + { + 1 => WadLinkOpcode.Pop, + 2 => WadLinkOpcode.Push, + 3 => WadLinkOpcode.Read, + _ => WadLinkOpcode.NotUseStack + }; + } + mov.Bones.Add(bone); + } + + if (obj.AnimIndex > 0 || obj.Id == 0) + { + int first = obj.AnimIndex; + int end = animStarts.FirstOrDefault(s => s > first); + if (end == 0) end = _level.AnimCount; + for (int a = first; a < end && a < _level.AnimCount; a++) + { + var anim = BuildAnimation(a, first, end - first, obj.NumMeshes); + if (anim != null) mov.Animations.Add(anim); + } + } + + return mov; + } + + private WadAnimation? BuildAnimation(int index, int firstAnim, int animCount, int numBones) + { + var src = PSXModelReader.ReadAnim(_level, index); + if (src == null) return null; + + int rate = Math.Max(1, src.Rate); + int length = Math.Max(0, src.FrameEnd - src.FrameStart); + + var anim = new WadAnimation + { + Name = $"Animation {index - firstAnim}", + StateId = (ushort)src.State, + FrameRate = (byte)rate, + EndFrame = (ushort)length, + NextAnimation = (ushort)Math.Clamp(src.JumpAnim - firstAnim, 0, Math.Max(0, animCount - 1)), + // TR4 velocities are 16.16 fixed point and change by the acceleration every frame. + StartVelocity = src.Velocity / 65536.0f, + EndVelocity = (src.Velocity + (long)src.Acceleration * length) / 65536.0f, + StartLateralVelocity = src.XVelocity / 65536.0f, + EndLateralVelocity = (src.XVelocity + (long)src.XAcceleration * length) / 65536.0f + }; + + var target = PSXModelReader.ReadAnim(_level, src.JumpAnim); + anim.NextFrame = (ushort)Math.Max(0, src.JumpFrame - (target?.FrameStart ?? 0)); + + // Stored key frames: one every frames, plus one more to interpolate up to the last frame. + int keyFrames = length / rate + 1 + (length % rate != 0 ? 1 : 0); + int frameWords = src.FrameSizeWords; + if (frameWords >= 9) + { + for (int f = 0; f < keyFrames; f++) + { + // Read the frame plus a few extra words: bones with 2-word rotations can run past a too-small frame size. + long offset = src.FrameOffset + (long)f * frameWords * 2; + int want = frameWords + numBones * 2; + short[]? words = null; + while (want >= frameWords && (words = PSXModelReader.ReadFrameWords(_level, offset, want)) == null) + want -= 2; + if (words == null) break; + anim.KeyFrames.Add(BuildKeyFrame(words, numBones)); + } + } + if (anim.KeyFrames.Count == 0) + anim.KeyFrames.Add(new WadKeyFrame()); + + // State changes and dispatches (TR4 PC layout). + for (int c = 0; c < src.NumChanges; c++) + { + var (state, count, firstDispatch) = PSXModelReader.ReadChange(_level, src.ChangeIndex + c); + var change = new WadStateChange { StateId = (ushort)state }; + for (int d = 0; d < count; d++) + { + var (low, high, nextAnim, nextFrame) = PSXModelReader.ReadDispatch(_level, firstDispatch + d); + var next = PSXModelReader.ReadAnim(_level, nextAnim); + change.Dispatches.Add(new WadAnimDispatch + { + InFrame = (ushort)Math.Max(0, low - src.FrameStart), + OutFrame = (ushort)Math.Max(0, high - src.FrameStart), + NextAnimation = (ushort)Math.Clamp(nextAnim - firstAnim, 0, Math.Max(0, animCount - 1)), + // TR4 dispatches have a single target frame: use it for both ends of the range. + NextLowFrame = (ushort)Math.Max(0, nextFrame - (next?.FrameStart ?? 0)), + NextHighFrame = (ushort)Math.Max(0, nextFrame - (next?.FrameStart ?? 0)) + }); + } + anim.StateChanges.Add(change); + } + + // Animation commands. + int ptr = src.CommandIndex; + for (int c = 0; c < src.NumCommands && ptr < _level.CommandCount; c++) + { + short opcode = PSXModelReader.ReadCommandWord(_level, ptr++); + var cmd = new WadAnimCommand { Type = (WadAnimCommandType)opcode }; + switch (opcode) + { + case 1: + cmd.Parameter1 = PSXModelReader.ReadCommandWord(_level, ptr++); + cmd.Parameter2 = PSXModelReader.ReadCommandWord(_level, ptr++); + cmd.Parameter3 = PSXModelReader.ReadCommandWord(_level, ptr++); + break; + case 2: + cmd.Parameter1 = PSXModelReader.ReadCommandWord(_level, ptr++); + cmd.Parameter2 = PSXModelReader.ReadCommandWord(_level, ptr++); + break; + case 5: + case 6: + cmd.Parameter1 = PSXModelReader.ReadCommandWord(_level, ptr++); + cmd.Parameter2 = PSXModelReader.ReadCommandWord(_level, ptr++); + cmd.ConvertLegacyConditions(); + break; + } + anim.AnimCommands.Add(cmd); + } + + return anim; + } + + private static WadKeyFrame BuildKeyFrame(short[] words, int numBones) + { + // Header: min X, max X, min Y, max Y, min Z, max Z, root X, root Y, root Z. + var kf = new WadKeyFrame + { + BoundingBox = new BoundingBox( + new Vector3(words[0], -words[3], words[4]), + new Vector3(words[1], -words[2], words[5])), + Offset = new Vector3(words[6], -words[7], words[8]) + }; + + var data = new List(words.Length - 9); + for (int i = 9; i < words.Length; i++) data.Add(words[i]); + while (data.Count < numBones * 2) data.Add(0); + + int index = 0; + for (int b = 0; b < numBones; b++) + { + int before = index; + kf.Angles.Add(WadKeyFrameRotation.FromTrAngle(ref index, data, false, true)); + if (index <= before) index = before + 1; + } + + return kf; + } + + // ---------------------------------------------------------------- meshes + + private WadMesh BuildMesh(PsxMesh src, string name) + { + var mesh = new WadMesh { Name = name, LightingType = src.PreLit ? WadMeshLightingType.VertexColors : WadMeshLightingType.Normals }; + + int count = src.Vertices.Length; + for (int i = 0; i < count; i++) + { + var v = src.Vertices[i]; + mesh.VertexPositions.Add(new Vector3(v.X, -v.Y, v.Z)); + + if (src.PreLit) + { + int w = (ushort)v.W; + mesh.VertexColors.Add(new Vector3((w & 31) / 31.0f, ((w >> 5) & 31) / 31.0f, ((w >> 10) & 31) / 31.0f)); + } + else + { + mesh.VertexColors.Add(Vector3.One); + } + } + + // Polygons (outline order; quads go A B D C). Winding checked against stored normals: it matches TombLib's. + foreach (var t in src.Triangles) + { + var area = GetTextureArea(t.Tex, true); + mesh.Polys.Add(new WadPolygon + { + Shape = WadPolygonShape.Triangle, + Index0 = t.A, Index1 = t.B, Index2 = t.C, + Texture = area.Area + }); + } + foreach (var q in src.Quads) + { + var area = GetTextureArea(q.Tex, false); + var a = area.Area; + // Texture corners come in Z order as well: swap the last two so they follow the outline. + (a.TexCoord2, a.TexCoord3) = (a.TexCoord3, a.TexCoord2); + mesh.Polys.Add(new WadPolygon + { + Shape = WadPolygonShape.Quad, + Index0 = q.A, Index1 = q.B, Index2 = q.D, Index3 = q.C, + Texture = a + }); + } + + // Normals: stored ones when available (4096 = 1.0), otherwise averaged face normals. + var normals = new Vector3[count]; + if (src.Normals != null) + { + for (int i = 0; i < count; i++) + { + var n = src.Normals[i]; + var v = new Vector3(n.X, -n.Y, n.Z); + normals[i] = v.LengthSquared() > 1e-6f ? Vector3.Normalize(v) : Vector3.UnitY; + } + } + else + { + foreach (var p in mesh.Polys) + { + var p0 = mesh.VertexPositions[p.Index0]; + var n = Vector3.Cross(mesh.VertexPositions[p.Index1] - p0, mesh.VertexPositions[p.Index2] - p0); + normals[p.Index0] += n; normals[p.Index1] += n; normals[p.Index2] += n; + if (p.Shape == WadPolygonShape.Quad) normals[p.Index3] += n; + } + for (int i = 0; i < count; i++) + normals[i] = normals[i].LengthSquared() > 1e-6f ? Vector3.Normalize(normals[i]) : Vector3.UnitY; + } + foreach (var n in normals) mesh.VertexNormals.Add(n); + + mesh.BoundingSphere = new BoundingSphere(new Vector3(src.CenterX, -src.CenterY, src.CenterZ), src.Radius); + mesh.BoundingBox = mesh.CalculateBoundingBox(); + return mesh; + } + + // ---------------------------------------------------------------- textures + + private (TextureArea Area, bool Ok) GetTextureArea(int texIndex, bool isTriangle) + { + if (_areaCache.TryGetValue((texIndex, isTriangle), out var cached)) + return cached; + + var t = _level.ObjectTextures[texIndex]; + var area = new TextureArea { DoubleSided = false }; + bool ok = _level.TryRender(t, out int w, out int h, out byte[] bgra, isTriangle); + + if (!ok) + { + _missingTextures++; + var img = ImageC.CreateNew(2, 2); + img.Fill(new ColorC(255, 0, 255, 255)); + area.Texture = new WadTexture(img); + area.BlendMode = BlendMode.Normal; + area.TexCoord0 = new Vector2(0, 0); area.TexCoord1 = new Vector2(2, 0); + area.TexCoord2 = new Vector2(2, 2); area.TexCoord3 = new Vector2(0, 2); + } + else + { + bool transparent = false; + for (int i = 3; i < bgra.Length; i += 4) + if (bgra[i] == 0) { transparent = true; break; } + + area.Texture = new WadTexture(ImageC.FromByteArray(bgra, w, h)); + area.BlendMode = transparent ? BlendMode.AlphaTest : BlendMode.Normal; // PSX color 0x0000 is transparent + + var (u0, v0, u1, v1) = PSXLevel.GetRect(t, isTriangle); + // Corner coordinates are pixel edges: the far corners sit one pixel further. + Vector2 Corner(int i) => new(t.U[i] - u0 + (t.U[i] == u1 ? 1 : 0), t.V[i] - v0 + (t.V[i] == v1 ? 1 : 0)); + area.TexCoord0 = Corner(0); + area.TexCoord1 = Corner(1); + area.TexCoord2 = Corner(2); + area.TexCoord3 = isTriangle ? area.TexCoord2 : Corner(3); + } + + var result = (area, ok); + _areaCache[(texIndex, isTriangle)] = result; + return result; + } + + // ---------------------------------------------------------------- sprites + + private WadSpriteSequence BuildSpriteSequence(PsxObject obj, PsxSprite[] table) + { + var seq = new WadSpriteSequence(new WadSpriteSequenceId((uint)obj.Id)); + int count = -obj.NumMeshes; + + for (int i = 0; i < count; i++) + { + int idx = obj.MeshIndex + i; + if (idx < 0 || idx >= table.Length) continue; + var s = table[idx]; + + var tex = new PSXLevel.Texture(new[] { (byte)s.U1, (byte)s.U2, (byte)s.U1, (byte)s.U2 }, new[] { (byte)s.V1, (byte)s.V1, (byte)s.V2, (byte)s.V2 }, s.Clut, s.Tpage); + if (!_level.TryRender(tex, out int w, out int h, out byte[] bgra, false)) + continue; + + seq.Sprites.Add(new WadSprite { Texture = new WadTexture(ImageC.FromByteArray(bgra, w, h)) }); } - _logger($"[PSX Wad2] {levels} level(s) recognized."); - _logger("[PSX Wad2] Wad2 export is not implemented yet (meshes, bones and animations are still to be decoded); no file written."); + return seq; } } } From e7ca4207c25d1cb451c20d59726a740c6f9cc78f Mon Sep 17 00:00:00 2001 From: Checkm8Croft Date: Sat, 24 Oct 2026 09:51:32 +0200 Subject: [PATCH 4/5] wad2: fixed PSX meshes with implicit vertex count --- Lara Extractor/PSXModels.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Lara Extractor/PSXModels.cs b/Lara Extractor/PSXModels.cs index 0402106..4262661 100644 --- a/Lara Extractor/PSXModels.cs +++ b/Lara Extractor/PSXModels.cs @@ -121,7 +121,12 @@ public static PsxSprite[] ReadSprites(PSXLevel level) int vertexCount = (int)(header & 0xFF); mesh.Flags = (int)((header >> 8) & 0xFF); mesh.PreLit = (mesh.Flags & 0x80) != 0; - int polyOffset = (int)(header >> 16); + int polyOffset = (int)(header >> 16); + + // Some meshes have 0 in the vertex-count byte: the count is then implied by the polygon offset + // (vertices take 8 bytes when pre-lit, 16 bytes with normals). Verified on 468/468 such meshes of a TR4 GAMEWAD. + if (vertexCount == 0) + vertexCount = mesh.PreLit ? polyOffset / 8 : polyOffset / 16; if (vertexCount == 0) return null; int vertexStart = m + 12; From 00924a2cd0aa05579cf47f231bff462611f47599 Mon Sep 17 00:00:00 2001 From: Checkm8Croft Date: Sat, 24 Oct 2026 15:44:30 +0200 Subject: [PATCH 5/5] docs: updated with PSX notes --- CODE_OF_CONDUCT.md | 34 ++++++++++++++++++++++++++++------ README.md | 28 +++++++++++++++++++++++----- 2 files changed, 51 insertions(+), 11 deletions(-) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 3243985..2243204 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -3,34 +3,36 @@ Lara-Extractor is a vibe-coded program, but if you'd like to improve something, feel free to do a PR! AI generated code is ok +This project is born for helping TRLE builders for building their levels, where they can have the possibility of using assets directly from a specific Tomb Raider level. + ## Issues Have you found a problem with the program? -Make an issue! Describe clearly your problem, send screenshots if you can +Make an issue! Describe clearly your problem, send screenshots if you can. Also set the label on your Issue ## Feature Requests Do you have an idea for this program? -We'd happy to hear it! On the issues, you can also create a Feature Request. Describe clearly what you'd like to see on the program +We'd happy to hear it! On the issues, you can also create a Feature Request. Describe clearly what you'd like to see on the program. Also set the label on your Feature Request ## Pull Requests Would you like to implement a features you did on the repo? -Feel free to do it! Describe clearly what your PR does, check if you you used AI code, and send screenshots if you can. +Feel free to do it! Describe clearly what your PR does, check if you you used AI code, and send screenshots if you can. Also set the label on your PR Follow the examples below for a good PR content: ### PR Content Bad PR Content: -``` +```txt Implemented TR1 support ``` Good PR Content: -``` +```txt This PR adds the support for TR1 Levels (Eventaully Screenshots) @@ -49,4 +51,24 @@ Good: ## Where to discuss -You can discuss of the project on the Issues and Discussions tab or also my [Discord Server](https://discord.gg/zmtkMK98hZ) \ No newline at end of file +You can discuss of the project on the Issues and Discussions tab or also my [Discord Server](https://discord.gg/zmtkMK98hZ) + +## What is not allowed + +- Off-Topic Discussions: If you create an Issue/FR/PR, the content must be related to the content only. If you'd like to talk of other topics, join the discord server +- Respect: Unrespectful behaviours will immediately mean Issue/FR/PR closed +- Privacy: Beware of sharing your personal infomation on the code (Windows paths, your real name, etc...) +- Duplicated or repetitive content: Pay attention of not duplicate an Issue/FR of another one, and your Issue/FR/PR would be rejected, please don't insist for it + + +## AI-generated code + +AI-generated code is allowed. However, contributors should make a reasonable effort to understand, test, and describe the changes they submit. Using AI is not, by itself, a reason to reject a contribution. + +## Enforcement + +If these rules are violated, project maintainers may remove content, request changes, close issues or pull requests, or restrict access to project spaces when necessary. The response will depend on the severity and context of the behavior. + +## Collaborators + +Would you like to collaborate with me with this project? I'd be very happy! For join as Collaborator, write me on the Discord Server \ No newline at end of file diff --git a/README.md b/README.md index 299364a..1e19111 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,14 @@ ## Lara Extractor -Lara Extractor is a tool for extract resources from Tomb Raider 1-5 (PC Only) and TRNG +Lara Extractor is a tool for extract resources from Tomb Raider 1-5 (PC, plus PlayStation for TR4 and TR5) and TRNG -- .phd -- .tr2 -- .tr4 -- .trc +The program is Vibe-Coded, so sorry for bad quality code + +- .phd (TR1) +- .tr2 (TR2/3) +- .tr4 (TR4 and TRNG) +- .trc (TR5) +- GAMEWAD.OBJ (TR4 and TR5 for PlayStation) ## File Extraction @@ -15,6 +18,21 @@ These are the files that the program will extract: - Textures in .tga format - Sounds in .wav format (On TR2/3 you'll have to have the main.sfx file on the same directory) +## PlayStation (PSX) support + +TR4 and TR5 (Chronicles) PlayStation levels are read from `GAMEWAD.OBJ`, the file that contains all the levels of the disc. +For every level in it, the program extracts: + +- Textures of the level rooms in .tga format (one folder per level) +- Sounds in .wav format (11025 Hz, mono) +- .wad2 files, one per level (Moveables with bones and animations, Statics and Sprites) + +### Known limitations of the PSX .wad2 + +- **Lara, her weapons and the other resident meshes are missing.** In the level files their mesh pointers are empty and I could not find the meshes in any file of the disc. The Moveables that use them are skipped (Lara) or have empty meshes on the missing bones (weapons and similar objects); animations are there +- Texture animations, polygon blend flags and the sound pitch are not converted +- The names of the levels are not known, so files are named by their position inside `GAMEWAD.OBJ` (for example `Level_03`) + ### Why no .prj2? The .prj2 files are used for the level geometry, but they are not in a format that can be easily extracted. The program had to be rewritten to support the .prj2 files, and it was not worth the effort for a tool that is only used for extracting resources.