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
11 changes: 11 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"permissions": {
"allow": [
"Bash(PrjDiag/bin/Debug/net10.0-windows/PrjDiag.exe)",
"Bash('Lets fix the path. *)",
"Bash(start PrjDiag/bin/Debug/net10.0-windows/PrjDiag.exe)",
"Bash(ls -la \"/c/Users/Checkm8ra1n/Documents/alexhub2.prj2\")",
"Read(//c/Users/Checkm8ra1n/Documents/**)"
]
}
}
87 changes: 87 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Development Commands

### Building the Solution
The solution uses the .NET SDK format and can be built with:
- `dotnet build` - builds both PRJ2 Extractor (WPF) and PrjDiag (console) projects
- `msbuild PRJ2 Extractor.slnx` - alternative using MSBuild

The GitHub Actions workflow (`.github/workflows/dotnet-desktop.yml`) builds the solution in both Debug and Release configurations on Windows.

### Running the Applications
- **PRJ2 Extractor (GUI)**: Run `PRJ2 Extractor\bin\Debug\net10.0-windows\PRJ2 Extractor.exe` (or the Release equivalent)
- **PrjDiag (diagnostic console)**: Run `PrjDiag\bin\Debug\net10.0-windows\PrjDiag.exe` - this tool exports a TR4 file to PRJ2 and validates the output using TombLib's loader

### Testing
There are no test projects in the repository. The GitHub workflow runs `dotnet test` but it will not execute any tests.

## Project Structure

### PRJ2 Extractor (Main WPF Application)
- `App.xaml/App.xaml.cs` - WPF application entry point
- `MainWindow.xaml/MainWindow.xaml.cs` - Main GUI for loading TR4 files, converting to PRJ, and exporting to PRJ2
- `Core/` - Core logic for TR4 parsing and conversion
- `TrLevel.cs` - Parses Tomb Raider 4 (.tr4) level files and provides conversion methods
- `TrProject.cs` - Intermediate representation of a classic Tomb Raider level project (PRJ format)
- `Prj2Exporter.cs` - Exports TrLevel to Tomb Editor PRJ2 format using TombLib.dll
- `TgaWriter.cs` - Utility for saving textures as TGA files
- `Models/` - Data structures representing TR4 file format structures
- `TrLevelModels.cs` - Enums and classes for sectors, rooms, portals, textures, etc.
- `TrProjectModels.cs` - Classic PRJ format structures (blocks, doors, rooms)

### PrjDiag (Diagnostic Tool)
- `Program.cs` - Console application that tests the PRJ2 export functionality by:
1. Loading a TR4 file
2. Exporting to PRJ2 using Prj2Exporter
3. Reloading the PRJ2 through TombLib's loader
4. Checking for invalid slopes using TombLib's validation

### Dependencies
The project relies on several native DLLs located at `C:\Tomb Editor\` (reference paths are hardcoded in the project files). These include:
- TombLib.dll (core library for PRJ2 reading/writing)
- AssimpNet.dll (3D model import)
- NAudio.dll (audio processing)
- Various image and compression libraries (DevIL, FreeImage, NLog, etc.)

These dependencies must be present at the specified path for the project to build and run correctly.

## Architecture Overview

1. **TR4 Parsing** (`TrLevel.cs`):
- Reads and decompresses TR4 level files
- Extracts geometry, textures, rooms, portals, and sector floor/ceiling data
- Provides `ConvertToPrj()` method to convert to classic PRJ format

2. **Intermediate Representation** (`TrProject.cs`):
- Models the classic Tomb Raider level project (PRJ) format
- Contains rooms, blocks, doors, and texture information
- Used as a stepping stone between TR4 and PRJ2 formats

3. **PRJ2 Export** (`Prj2Exporter.cs`):
- Takes a TrLevel and converts it to a TrProject (with fixFdivs=false to avoid invalid geometry)
- Applies door processing via `MakeDoors()`
- Uses TombLib to create an in-memory Level/Room object graph from the TrProject data
- Writes the PRJ2 file using TombLib's `Prj2Writer`

4. **GUI Interaction** (`MainWindow.xaml.cs`):
- Allows users to load TR4 files and optionally load a PRJ for reference
- Provides controls to copy doors/textures/lights from a reference PRJ
- Exports to PRJ2 format with progress reporting and error handling

## Key Implementation Details

- The TR4 parser handles both compressed and uncompressed sections of the file format
- Floor data includes slope information, triggers, doors, and special floor/ceiling types
- Door detection in TR4 portals is complex - multiple adjacent portals may represent a single door
- The PRJ2 export process involves two passes:
1. Creating room geometry from block data (including slope information)
2. Adding alternate (flip) room links and portals derived from door information
- TombLib handles the actual PRJ2 file writing, ensuring compliance with the Tomb Editor format

## Important Notes
- The hardcoded dependency path `C:\Tomb Editor\` must be configured for successful builds
- The application is specifically designed for Tomb Raider 4 (TR4) level files
- PRJ2 output is compatible with Tomb Editor (a modern Tomb Raider level editor)
2 changes: 1 addition & 1 deletion LICENSE.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) [year] [fullname]
Copyright (c) 2026 Checkm8ra1n

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
226 changes: 226 additions & 0 deletions PRJ2 Extractor/Core/Prj2Exporter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
using System.Numerics;
using PRJ2_Extractor.Models;
using TombLib;

Check failure on line 3 in PRJ2 Extractor/Core/Prj2Exporter.cs

View workflow job for this annotation

GitHub Actions / build (Debug)

The type or namespace name 'TombLib' could not be found (are you missing a using directive or an assembly reference?)
using TombLib.LevelData;

Check failure on line 4 in PRJ2 Extractor/Core/Prj2Exporter.cs

View workflow job for this annotation

GitHub Actions / build (Debug)

The type or namespace name 'TombLib' could not be found (are you missing a using directive or an assembly reference?)
using TombLib.LevelData.IO;

Check failure on line 5 in PRJ2 Extractor/Core/Prj2Exporter.cs

View workflow job for this annotation

GitHub Actions / build (Debug)

The type or namespace name 'TombLib' could not be found (are you missing a using directive or an assembly reference?)
using TombLib.LevelData.SectorEnums;

Check failure on line 6 in PRJ2 Extractor/Core/Prj2Exporter.cs

View workflow job for this annotation

GitHub Actions / build (Debug)

The type or namespace name 'TombLib' could not be found (are you missing a using directive or an assembly reference?)
using System.Diagnostics;

namespace PRJ2_Extractor.Core;

/// <summary>
/// Converts a parsed TR4 level (<see cref="TrLevel"/>) into a native Tomb Editor PRJ2 file,
/// by building an in-memory TombLib.dll <see cref="Level"/>/<see cref="Room"/> object graph
/// and delegating serialization entirely to TombLib's own <see cref="Prj2Writer"/>.
///
/// Room/sector geometry, alternate (flip) room linking and portal detection are reused from
/// the already-working <see cref="TrLevel.ConvertToPrj"/> classic-PRJ conversion pipeline,
/// since that model (<see cref="Block"/> with per-corner byte deltas and split arrays) mirrors
/// the on-disk classic PRJ sector layout that TombLib's own PrjLoader reads. We are simply
/// re-targeting the *output* side from classic PRJ bytes to the TombLib object model.
/// </summary>
public static class Prj2Exporter
{
public static List<string> Export(TrLevel trLevel, string prj2FilePath)
{
var warnings = new List<string>();
var problematicRows = new HashSet<int> { 104, 103, 60, 61, 48, 49, 52, 24, 17 };

// Reuse the existing, proven TR4 -> classic-PRJ-model conversion for all the hard geometry work
// (floor data / tilts / splits / door-portal detection / alternate room bookkeeping).
// fixFdivs=false: that flag is an NGLE/classic-PRJ-only workaround that stamps a synthetic
// non-zero FDiv/CDiv value onto almost every block (floor/ceiling distance to room bounds),
// not just genuinely split ones. In TombLib any non-zero SetHeight(Floor2/Ceiling2, ...) call
// creates real diagonal-split geometry, so leaving it on turns flat/tilted terrain into spiky,
// invalid sectors. Real splits from TR4 FloorData (Split1-4) are still applied unconditionally
// by ApplyFloorSplit/ApplyCeilingSplit regardless of this flag.
TrProject p = trLevel.ConvertToPrj(prj2FilePath, saveTga: false, fixFdivs: false);

// Resolves portals into p.Rooms[i].Doors AND corrects floor/ceiling heights and sector
// Ids of border-wall blocks adjacent to a door (MarkDoorBlocks). Must run before we read
// sector geometry below. The tr2PrjLinks flag only affects the legacy classic-PRJ room
// chain-link field (Room.Link), irrelevant to TombLib/PRJ2, so we pass false.
trLevel.MakeDoors(p, tr2PrjLinks: false);

var level = new Level();
var tombRooms = new Room?[p.Rooms.Length];

// --- Pass 1: create rooms and sector geometry ---
for (int i = 0; i < p.Rooms.Length; i++)
{
var pr = p.Rooms[i];
if (pr.Id == 1) continue; // undefined room slot

string roomName = new string(pr.Name).TrimEnd('\0', ' ');
if (string.IsNullOrWhiteSpace(roomName)) roomName = $"Room{i}";

var room = new Room(level, pr.XSize, pr.ZSize, Vector3.One, roomName);
// Position.X/Z are sector-grid units (matching XSize/ZSize), but Position.Y must be
// world units, not clicks (verified: TombLib's Room.Position.Y is compared directly
// against Prj2Loader-reconstructed sector heights, which are in world units).
room.Position = new VectorInt3(pr.XPos, Clicks.ToWorld(pr.YBottom), pr.ZPos);

for (int z = 0; z < pr.ZSize; z++)
for (int x = 0; x < pr.XSize; x++)
{
// pr.Blocks[] is X-major (TrLevel.cs: b = X_idx*ZSize + Z_idx), matching the
// TRosettaStone file order. Index accordingly (not z*XSize+x).
int b = x * pr.ZSize + z;
var block = pr.Blocks[b];
var sector = room.Sectors[x, z];

sector.Type = block.Id switch
{
0x01 or 0x05 or 0x07 or 0x03 => SectorType.Floor,
0x1E or 0x06 => SectorType.BorderWall,
0x0E => SectorType.Wall,
_ => SectorType.Floor
};

if (sector.Type == SectorType.Floor)
{
// Base height + per-corner delta (in clicks) -> world units.
// Corner order follows the classic PRJ on-disk layout used by TombLib's PrjLoader:
// Absolute world height = -RoomYBottom + base + corner delta (verified against
// TombLib's own compiler, Compilers/Rooms.cs: compiledSector.Floor/Ceiling formula).
// floor corners are [XpZn, XnZn, XnZp, XpZp]; ceiling delta is ADDED, floor delta SUBTRACTED.
int floorBase = -pr.YBottom + block.Floor;
sector.Floor.XpZn = (short)Clicks.ToWorld(floorBase - block.FloorCorner[0]);
sector.Floor.XnZn = (short)Clicks.ToWorld(floorBase - block.FloorCorner[1]);
sector.Floor.XnZp = (short)Clicks.ToWorld(floorBase - block.FloorCorner[2]);
sector.Floor.XpZp = (short)Clicks.ToWorld(floorBase - block.FloorCorner[3]);

// NOTE (interpretazione, verificata contro l'ordine di lettura in PrjLoader.cs):
// nel formato PRJ classico l'ordine degli angoli del soffitto è invertito
// rispetto al pavimento: [XpZp, XnZp, XnZn, XpZn].
int ceilBase = -pr.YBottom + block.Ceiling;
sector.Ceiling.XpZp = (short)Clicks.ToWorld(ceilBase + block.CeilCorner[0]);
sector.Ceiling.XnZp = (short)Clicks.ToWorld(ceilBase + block.CeilCorner[1]);
sector.Ceiling.XnZn = (short)Clicks.ToWorld(ceilBase + block.CeilCorner[2]);
sector.Ceiling.XpZn = (short)Clicks.ToWorld(ceilBase + block.CeilCorner[3]);

// Diagonal-split triangulation (TR FloorData functions 0x07-0x12). TombLib's
// SectorSurface.SplitDirectionIsXEqualsZ (NOT the DiagonalSplit enum, which is
// only for portal sub-triangles we don't yet model) picks which diagonal the
// sector's 2 collision/render triangles are split along. When left at its
// default (auto-detected from the 4 corners), a non-coplanar quad still renders
// as 2 triangles, but along whichever diagonal happens to look flattest -- not
// necessarily the one TR4 actually intended, which is what was producing
// "illegal slope" sectors even though the 4 corner heights were individually correct.
if (block.FloorSplitXEqualsZ.HasValue)
sector.Floor.SplitDirectionIsXEqualsZ = block.FloorSplitXEqualsZ.Value;
if (block.CeilingSplitXEqualsZ.HasValue)
sector.Ceiling.SplitDirectionIsXEqualsZ = block.CeilingSplitXEqualsZ.Value;
}
else
{
// Wall / BorderWall sectors: classic-PRJ perimeter placeholder blocks. They still
// carry leftover per-corner deltas from whatever real floor data was computed before
// being downgraded to a wall/border sentinel (their Ceiling is forced to a fixed
// value but Floor/FloorCorner are not), which can encode near-vertical fake slopes.
// Since these sectors have no floor/ceiling collision semantics, use flat heights
// instead of the per-corner deltas to avoid feeding TombLib invalid steep geometry.
short flatFloor = (short)Clicks.ToWorld(-pr.YBottom + block.Floor);
short flatCeiling = (short)Clicks.ToWorld(-pr.YBottom + block.Ceiling);
sector.Floor.XpZn = sector.Floor.XnZn = sector.Floor.XnZp = sector.Floor.XpZp = flatFloor;
sector.Ceiling.XpZp = sector.Ceiling.XnZp = sector.Ceiling.XnZn = sector.Ceiling.XpZn = flatCeiling;
}
}

room.NormalizeRoomY();
level.Rooms[i] = room;
tombRooms[i] = room;
}

// --- Pass 2: alternate (flip) room linking ---
// Uses the raw parsed TR4 room data directly (r1.AltRoom / r1.AltGroup / r1.IsFlipRoom)
// rather than the classic-PRJ intermediate model, to avoid relying on TrProject.Flags2
// (which mixes flag bits and the alternate group id via bitwise OR).
for (int i = 0; i < trLevel.Rooms.Length; i++)
{
var r1 = trLevel.Rooms[i];
if (r1.IsFlipRoom || r1.AltRoom < 0 || r1.AltRoom >= tombRooms.Length) continue;

var baseRoom = tombRooms[i];
var altRoom = tombRooms[r1.AltRoom];
if (baseRoom == null || altRoom == null) continue;

baseRoom.AlternateRoom = altRoom;
baseRoom.AlternateGroup = r1.AltGroup;
altRoom.AlternateBaseRoom = baseRoom;
altRoom.AlternateGroup = r1.AltGroup;
altRoom.Position = new VectorInt3(baseRoom.Position.X, altRoom.Position.Y, baseRoom.Position.Z);
}

// --- Pass 3: portals, built from the doors already resolved by ConvertToPrj/MakeDoors ---
// TR4 sometimes encodes a single opening as several adjacent/overlapping quads (e.g. large
// or non-trivially shaped portals get split during level compilation). TombLib only allows
// one portal per sector face, so we group raw doors by (room, direction, adjoining room) and
// add a single portal covering the union of their sector areas. (Tried adding each door
// individually largest-first instead: empirically worse -- 127 vs 114 conflicts on alexhub2 --
// so the union grouping stays.)
for (int i = 0; i < p.Rooms.Length; i++)
{
var pr = p.Rooms[i];
var room = tombRooms[i];
if (pr.Id == 1 || room == null) continue;

var groups = new Dictionary<(PortalDirection Direction, int Target), (int X0, int Z0, int X1, int Z1)>();

foreach (var door in pr.Doors)
{
int targetIndex = door.Filler[0];
if (targetIndex < 0 || targetIndex >= tombRooms.Length) continue;
var adjoiningRoom = tombRooms[targetIndex];
if (adjoiningRoom == null || adjoiningRoom == room) continue;

PortalDirection? direction = door.Id switch
{
1 => PortalDirection.WallNegativeZ,
2 => PortalDirection.WallNegativeX,
4 => PortalDirection.Floor,
0xFFFE => PortalDirection.WallPositiveZ,
0xFFFD => PortalDirection.WallPositiveX,
0xFFFB => PortalDirection.Ceiling,
_ => null
};
if (direction == null) continue; // unknown/unsupported door type, skip rather than fail the whole export

int x0 = Math.Clamp((int)door.XPos, 0, room.NumXSectors - 1);
int z0 = Math.Clamp((int)door.ZPos, 0, room.NumZSectors - 1);
int x1 = Math.Clamp(door.XPos + door.XSize - 1, x0, room.NumXSectors - 1);
int z1 = Math.Clamp(door.ZPos + door.ZSize - 1, z0, room.NumZSectors - 1);

var key = (direction.Value, targetIndex);
if (groups.TryGetValue(key, out var acc))
groups[key] = (Math.Min(acc.X0, x0), Math.Min(acc.Z0, z0), Math.Max(acc.X1, x1), Math.Max(acc.Z1, z1));
else
groups[key] = (x0, z0, x1, z1);
}

foreach (var (key, rect) in groups)
{
// TombLib's Room.AddObject auto-creates the mirrored portal in the adjoining room, so
// adding it again from that room's own (independent, TR4-sourced) door list would
// always conflict with the auto-created copy. Process each room pair once, from the
// lower-indexed room only; this was the dominant cause of "Portal overlaps another".
if (key.Target < i) continue;

var adjoiningRoom = tombRooms[key.Target]!;
var area = new RectangleInt2(rect.X0, rect.Z0, rect.X1, rect.Z1);
var portal = new PortalInstance(area, key.Direction, adjoiningRoom);
try
{
room.AddObject(level, portal);
}
catch (Exception ex)
{
warnings.Add($"Room {i} ({room.Name}): portal to room {key.Target} [{key.Direction}] area ({rect.X0},{rect.Z0})-({rect.X1},{rect.Z1}) skipped: {ex.Message}");
}
}
}

Prj2Writer.SaveToPrj2(prj2FilePath, level);
return warnings;
}
}
Loading
Loading