Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 28 additions & 6 deletions CODE_OF_CONDUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
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
7 changes: 7 additions & 0 deletions Lara Extractor/AssetProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ public void UnpackAll()
}
Directory.CreateDirectory(_outputDir);

if (PSXAssetExtractor.IsPsxContainerName(_filePath))
{
_logger("[Engine] PlayStation GAMEWAD container detected.");
PSXAssetExtractor.Extract(_filePath, _outputDir, _extractTextures, _extractAudio, _logger, _progressReporter);
_progressReporter(100);
}

if (Path.GetExtension(_filePath).Equals(".ten", StringComparison.OrdinalIgnoreCase))
{
new TENAssetProcessor(_filePath, _logger, _progressReporter, _outputDir,
Expand Down
2 changes: 1 addition & 1 deletion Lara Extractor/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ private void BtnBrowse_Click(object sender, RoutedEventArgs e)
{
OpenFileDialog openFileDialog = new OpenFileDialog
{
Filter = "Tomb Raider Levels (*.phd;*.tr2;*.tr4;*.trc;*.ten)|*.phd;*.tr2;*.tr4;*.trc;*.ten|All files (*.*)|*.*",
Filter = "Tomb Raider Levels (*.phd;*.tr2;*.tr4;*.trc;*.ten;*.obj)|*.phd;*.tr2;*.tr4;*.trc;*.ten;*.obj|All files (*.*)|*.*",
Title = "Select a Tomb Raider Level"
};

Expand Down
84 changes: 84 additions & 0 deletions Lara Extractor/PSXAdpcm.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
using System;
using System.Collections.Generic;
using System.IO;

namespace Lara_Extractor
{
/// <summary>
/// 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).
/// </summary>
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 };

/// <summary>Length in bytes of the sample starting at <paramref name="start"/>, up to and including the block with the end flag.</summary>
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);
}
}
}
143 changes: 143 additions & 0 deletions Lara Extractor/PSXAssetExtractor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
using System;
using System.IO;
using System.Collections.Generic;
using System.Security.Cryptography;

namespace Lara_Extractor
{
/// <summary>
/// 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).
/// </summary>
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, bool extractAudio, Action<string> log, Action<double> 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 && !extractAudio)
{
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);

var seen = new HashSet<string>();
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.");
}

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<string> 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)
{
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);
}
}
}
67 changes: 67 additions & 0 deletions Lara Extractor/PSXGamewad.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
using System;
using System.Collections.Generic;
using System.IO;

namespace Lara_Extractor
{
/// <summary>
/// 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.
/// </summary>
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<Entry> Entries { get; }

private PSXGamewad(byte[] data, List<Entry> entries)
{
_data = data;
Entries = entries;
}

/// <summary>Returns null if the file does not look like a GAMEWAD.OBJ.</summary>
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<Entry>();
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;
}
}
}
Loading
Loading