diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..1be6288
--- /dev/null
+++ b/.claude/settings.local.json
@@ -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/**)"
+ ]
+ }
+}
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..300e90d
--- /dev/null
+++ b/CLAUDE.md
@@ -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)
\ No newline at end of file
diff --git a/LICENSE.txt b/LICENSE.txt
index 8aa2645..5ed6341 100644
--- a/LICENSE.txt
+++ b/LICENSE.txt
@@ -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
diff --git a/PRJ2 Extractor/Core/Prj2Exporter.cs b/PRJ2 Extractor/Core/Prj2Exporter.cs
new file mode 100644
index 0000000..d03b019
--- /dev/null
+++ b/PRJ2 Extractor/Core/Prj2Exporter.cs
@@ -0,0 +1,226 @@
+using System.Numerics;
+using PRJ2_Extractor.Models;
+using TombLib;
+using TombLib.LevelData;
+using TombLib.LevelData.IO;
+using TombLib.LevelData.SectorEnums;
+using System.Diagnostics;
+
+namespace PRJ2_Extractor.Core;
+
+///
+/// Converts a parsed TR4 level () into a native Tomb Editor PRJ2 file,
+/// by building an in-memory TombLib.dll / object graph
+/// and delegating serialization entirely to TombLib's own .
+///
+/// Room/sector geometry, alternate (flip) room linking and portal detection are reused from
+/// the already-working classic-PRJ conversion pipeline,
+/// since that model ( 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.
+///
+public static class Prj2Exporter
+{
+ public static List Export(TrLevel trLevel, string prj2FilePath)
+ {
+ var warnings = new List();
+ var problematicRows = new HashSet { 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;
+ }
+}
diff --git a/PRJ2 Extractor/Core/TrLevel.cs b/PRJ2 Extractor/Core/TrLevel.cs
index fb48f3e..2adcaf4 100644
--- a/PRJ2 Extractor/Core/TrLevel.cs
+++ b/PRJ2 Extractor/Core/TrLevel.cs
@@ -221,8 +221,9 @@ public byte Load(string filename, IProgress? progress = null)
progress?.Report(Math.Min(99, (int)(memfile.Position * 100 / fileSize) + 1));
var r = new LevelRoom
{
- Z = br2.ReadInt32(),
+ // TRosettaStone tr4_room_info: file order is X, Z, YBottom, YTop.
X = br2.ReadInt32(),
+ Z = br2.ReadInt32(),
YBottom = br2.ReadInt32(),
YTop = br2.ReadInt32()
};
@@ -234,8 +235,11 @@ public byte Load(string filename, IProgress? progress = null)
r.Portals = new Portal[r.NumPortals];
for (int j = 0; j < r.NumPortals; j++)
r.Portals[j] = ReadPortal(br2);
- r.NumX = br2.ReadUInt16();
+ // Per TRosettaStone (tr_room struct): the file stores NumZsectors FIRST,
+ // then NumXsectors SECOND. Read order matches that here; the sector-copy
+ // loop below is X-major (idx = X_idx*NumZ + Z_idx) to match spec ordering.
r.NumZ = br2.ReadUInt16();
+ r.NumX = br2.ReadUInt16();
r.Sectors = new LevelSector[r.NumX * r.NumZ];
for (int j = 0; j < r.NumX * r.NumZ; j++)
{
@@ -494,10 +498,12 @@ public TrProject ConvertToPrj(string filename, bool saveTga = true, bool fixFdiv
p.Rooms[i].YTop = -r1.YTop / 256;
p.Rooms[i].Blocks = new Block[r1.NumZ * r1.NumX];
- for (int j = 0; j < r1.NumZ; j++)
- for (int k = 0; k < r1.NumX; k++)
+ // Sectors[] are stored in the file in X-major order (idx = X_idx*NumZ + Z_idx,
+ // per TRosettaStone). j = X_idx, k = Z_idx here to read them back correctly.
+ for (int j = 0; j < r1.NumX; j++)
+ for (int k = 0; k < r1.NumZ; k++)
{
- int b = j * r1.NumX + k;
+ int b = j * r1.NumZ + k;
var sector = r1.Sectors[b];
p.Rooms[i].Blocks[b] = new Block();
var block = p.Rooms[i].Blocks[b];
@@ -516,12 +522,12 @@ public TrProject ConvertToPrj(string filename, bool saveTga = true, bool fixFdiv
for (int ii = 0; ii < 4; ii++) block.CDiv[ii] = (sbyte)Math.Abs(temp);
}
- if ((k == 0 && j == 0) || (k == r1.NumX - 1 && j == 0) ||
- (k == 0 && j == r1.NumZ - 1) || (k == r1.NumX - 1 && j == r1.NumZ - 1))
+ if ((k == 0 && j == 0) || (k == r1.NumZ - 1 && j == 0) ||
+ (k == 0 && j == r1.NumX - 1) || (k == r1.NumZ - 1 && j == r1.NumX - 1))
{
block.Id = 0x1E; block.Floor = 0; block.Ceiling = 20;
}
- else if (j == 0 || j == r1.NumZ - 1 || k == 0 || k == r1.NumX - 1)
+ else if (j == 0 || j == r1.NumX - 1 || k == 0 || k == r1.NumZ - 1)
{
block.Id = 0x1E;
block.Floor = (short)(-r1.YBottom / 256);
@@ -721,11 +727,17 @@ private static void ApplyFloorData(Block block, ParsedFloorData fd, LevelRoom r1
if (fd.Tipo == FloorType.Tilt)
{
- if (fd.AddX >= 0) { block.FloorCorner[2] = fd.AddX; block.FloorCorner[3] = fd.AddX; }
- else { block.FloorCorner[0] = (sbyte)-fd.AddX; block.FloorCorner[1] = (sbyte)-fd.AddX; }
- if (fd.AddZ >= 0) { block.FloorCorner[0] += fd.AddZ; block.FloorCorner[3] += fd.AddZ; }
- else { block.FloorCorner[2] += (sbyte)Math.Abs(fd.AddZ); block.FloorCorner[1] += (sbyte)Math.Abs(fd.AddZ); }
- block.Floor -= (short)(Math.Abs(fd.AddX) + Math.Abs(fd.AddZ));
+ // Function 0x02 (Floor Slant), per TRosettaStone, verified against TombLib's compiler
+ // (Compilers/FloorData.cs quad-slope branch): relative to XnZn=0, XpZn=AddZ, XnZp=AddX,
+ // XpZp=AddX+AddZ. FloorCorner convention (verified against the triangulation path) is
+ // max-relative: FloorCorner[i] = max(rel) - rel[i], always >= 0.
+ // Block.FloorCorner index mapping: [0]=XpZn [1]=XnZn [2]=XnZp [3]=XpZp.
+ int relXnZn = 0, relXpZn = fd.AddZ, relXnZp = fd.AddX, relXpZp = fd.AddX + fd.AddZ;
+ int maxRel = Math.Max(Math.Max(relXnZn, relXpZn), Math.Max(relXnZp, relXpZp));
+ block.FloorCorner[0] = (sbyte)(maxRel - relXpZn);
+ block.FloorCorner[1] = (sbyte)(maxRel - relXnZn);
+ block.FloorCorner[2] = (sbyte)(maxRel - relXnZp);
+ block.FloorCorner[3] = (sbyte)(maxRel - relXpZp);
if (fixFdivs)
{
int v = -Math.Abs(block.Floor - (-r1.YBottom / 256));
@@ -736,11 +748,17 @@ private static void ApplyFloorData(Block block, ParsedFloorData fd, LevelRoom r1
if (fd.Tipo == FloorType.Roof)
{
- if (fd.AddX >= 0) { block.CeilCorner[0] = (sbyte)-fd.AddX; block.CeilCorner[1] = (sbyte)-fd.AddX; }
- else { block.CeilCorner[2] = fd.AddX; block.CeilCorner[3] = fd.AddX; }
- if (fd.AddZ >= 0) { block.CeilCorner[1] -= fd.AddZ; block.CeilCorner[2] -= fd.AddZ; }
- else { block.CeilCorner[0] += fd.AddZ; block.CeilCorner[3] += fd.AddZ; }
- block.Ceiling += (short)(Math.Abs(fd.AddX) + Math.Abs(fd.AddZ));
+ // Function 0x03 (Ceiling Slant), per TRosettaStone, verified against TombLib's compiler:
+ // relative to XnZn=0, XpZn=AddZ, XnZp=-AddX, XpZp=AddZ-AddX (X-difference sign flips for
+ // ceiling vs floor). CeilCorner convention (verified against the triangulation path) is
+ // min-relative: CeilCorner[i] = rel[i] - min(rel), always >= 0.
+ // Block.CeilCorner index mapping: [0]=XpZp [1]=XnZp [2]=XnZn [3]=XpZn.
+ int relXnZn = 0, relXpZn = fd.AddZ, relXnZp = -fd.AddX, relXpZp = fd.AddZ - fd.AddX;
+ int minRel = Math.Min(Math.Min(relXnZn, relXpZn), Math.Min(relXnZp, relXpZp));
+ block.CeilCorner[0] = (sbyte)(relXpZp - minRel);
+ block.CeilCorner[1] = (sbyte)(relXnZp - minRel);
+ block.CeilCorner[2] = (sbyte)(relXnZn - minRel);
+ block.CeilCorner[3] = (sbyte)(relXpZn - minRel);
if (fixFdivs)
{
int v = Math.Abs((-r1.YTop / 256) - block.Ceiling);
@@ -763,13 +781,18 @@ private static void ApplyFloorData(Block block, ParsedFloorData fd, LevelRoom r1
private static void ApplyFloorSplit(Block block, ParsedFloorData fd)
{
- block.FloorCorner[0] = (sbyte)fd.Corners[0];
- block.FloorCorner[1] = (sbyte)fd.Corners[1];
- block.FloorCorner[2] = (sbyte)fd.Corners[2];
- block.FloorCorner[3] = (sbyte)fd.Corners[3];
+ // Triangulation formula per TRosettaStone: H = Hfloor + (max(dC) - dCn). fd.Corners parsing
+ // order already matches FloorCorner's index convention 1:1 ([0]=XpZn,[1]=XnZn,[2]=XnZp,[3]=XpZp).
+ // block.Floor is NOT lowered: it already represents Hfloor directly, like the fixed ceiling case.
int[] a = { fd.Corners[0], fd.Corners[1], fd.Corners[2], fd.Corners[3] };
int maxCorner = a.Max();
- block.Floor -= (short)maxCorner;
+ block.FloorCorner[0] = (sbyte)(maxCorner - a[0]);
+ block.FloorCorner[1] = (sbyte)(maxCorner - a[1]);
+ block.FloorCorner[2] = (sbyte)(maxCorner - a[2]);
+ block.FloorCorner[3] = (sbyte)(maxCorner - a[3]);
+ // Split1(0x07)/Nocol1(0x0B)/Nocol2(0x0C): NW-SE diagonal (XnZp-XpZn) -> SplitDirectionIsXEqualsZ=false.
+ // Split2(0x08)/Nocol3(0x0D)/Nocol4(0x0E): NE-SW diagonal (XnZn-XpZp) -> SplitDirectionIsXEqualsZ=true.
+ block.FloorSplitXEqualsZ = fd.Tipo is FloorType.Split2 or FloorType.Nocol3 or FloorType.Nocol4;
if (fd.Tipo is FloorType.Split2 or FloorType.Nocol3 or FloorType.Nocol4)
{
@@ -796,12 +819,22 @@ private static void ApplyFloorSplit(Block block, ParsedFloorData fd)
private static void ApplyCeilingSplit(Block block, ParsedFloorData fd)
{
- block.CeilCorner[0] = (sbyte)-fd.Corners[0];
- block.CeilCorner[1] = (sbyte)-fd.Corners[1];
- block.CeilCorner[2] = (sbyte)-fd.Corners[2];
- block.CeilCorner[3] = (sbyte)-fd.Corners[3];
+ // Triangulation formula per TRosettaStone: H = Hbase + (max(dC) - dCn). Verified against
+ // trview's parse_triangulation/Sector.cpp: for the ceiling, the SAME raw c00/c01/c10/c11
+ // bit fields (same fd.Corners[] parse as floor) map to corners with Z MIRRORED but X
+ // unchanged relative to the floor interpretation -- i.e. fd.Corners[i] keeps the same
+ // index but means a different corner: [0]=XpZp(NE) [1]=XnZp(NW) [2]=XnZn(SW) [3]=XpZn(SE).
+ // CeilCorner's own target order is [0]=XpZp [1]=XnZp [2]=XnZn [3]=XpZn -- so, unlike a
+ // naive full reversal, this is actually a direct 1:1 index mapping, not reversed.
+ // block.Ceiling is NOT adjusted: it already represents the reference height directly.
int maxCorner = fd.Corners.Max();
- block.Ceiling += (short)maxCorner;
+ block.CeilCorner[0] = (sbyte)(maxCorner - fd.Corners[0]); // XpZp
+ block.CeilCorner[1] = (sbyte)(maxCorner - fd.Corners[1]); // XnZp
+ block.CeilCorner[2] = (sbyte)(maxCorner - fd.Corners[2]); // XnZn
+ block.CeilCorner[3] = (sbyte)(maxCorner - fd.Corners[3]); // XpZn
+ // Split3(0x09)/Nocol5(0x0F)/Nocol6(0x10): "NW" ceiling diagonal -> SplitDirectionIsXEqualsZ=false.
+ // Split4(0x0A)/Nocol7(0x11)/Nocol8(0x12): "NE" ceiling diagonal -> SplitDirectionIsXEqualsZ=true.
+ block.CeilingSplitXEqualsZ = fd.Tipo is FloorType.Split4 or FloorType.Nocol7 or FloorType.Nocol8;
if (fd.Tipo is FloorType.Nocol5 or FloorType.Nocol7) block.Flags2 |= 0x10;
if (fd.Tipo is FloorType.Nocol6 or FloorType.Nocol8) block.Flags2 |= 0x8;
if (fd.Tipo is >= FloorType.Nocol5 and <= FloorType.Nocol8)
@@ -862,18 +895,25 @@ public void MakeDoors(TrProject p, bool tr2PrjLinks)
d.Filler[0] = portal.ToRoom;
p.Rooms[i].DoorThingIndex[j] = (ushort)doorCount;
+ // NOTE: door.XPos/XSize always come from the portal's X-vertex extent (minx/maxx),
+ // and door.ZPos/ZSize always come from its Z-vertex extent (minz/maxz), for every
+ // direction including walls. Prj2Exporter builds a RectangleInt2(x0,z0,x1,z1) directly
+ // from these fields with no per-direction rotation, matching TombLib's own PrjLoader
+ // (GetArea is called identically regardless of portal direction). The previous code
+ // crossed the axes for all 6 directions (X-derived data stored in the Z field and vice
+ // versa), which would rotate every portal's rectangle 90 degrees from where it belongs.
if (portal.Normal.X == 1)
- { d.Id = 2; d.ZPos = 0; d.ZSize = 1; d.XPos = (short)(minz / 1024); d.XSize = (short)((maxz - minz) / 1024); }
+ { d.Id = 2; d.XPos = (short)(minx / 1024); d.XSize = 1; d.ZPos = (short)(minz / 1024); d.ZSize = (short)((maxz - minz) / 1024); }
if (portal.Normal.X == -1)
- { d.Id = 0xFFFD; d.ZPos = (short)(minx / 1024); d.ZSize = 1; d.XPos = (short)(minz / 1024); d.XSize = (short)((maxz - minz) / 1024); }
+ { d.Id = 0xFFFD; d.XPos = (short)(minx / 1024); d.XSize = 1; d.ZPos = (short)(minz / 1024); d.ZSize = (short)((maxz - minz) / 1024); }
if (portal.Normal.Z == 1)
- { d.Id = 1; d.XPos = 0; d.XSize = 1; d.ZPos = (short)(minx / 1024); d.ZSize = (short)((maxx - minx) / 1024); }
+ { d.Id = 1; d.ZPos = (short)(minz / 1024); d.ZSize = 1; d.XPos = (short)(minx / 1024); d.XSize = (short)((maxx - minx) / 1024); }
if (portal.Normal.Z == -1)
- { d.Id = 0xFFFE; d.XPos = (short)(minz / 1024); d.XSize = 1; d.ZPos = (short)(minx / 1024); d.ZSize = (short)((maxx - minx) / 1024); }
+ { d.Id = 0xFFFE; d.ZPos = (short)(minz / 1024); d.ZSize = 1; d.XPos = (short)(minx / 1024); d.XSize = (short)((maxx - minx) / 1024); }
if (portal.Normal.Y == -1)
- { d.Id = 4; d.XPos = (short)(minz / 1024); d.XSize = (short)((maxz - minz) / 1024); d.ZPos = (short)(minx / 1024); d.ZSize = (short)((maxx - minx) / 1024); }
+ { d.Id = 4; d.XPos = (short)(minx / 1024); d.XSize = (short)((maxx - minx) / 1024); d.ZPos = (short)(minz / 1024); d.ZSize = (short)((maxz - minz) / 1024); }
if (portal.Normal.Y == 1)
- { d.Id = 0xFFFB; d.XPos = (short)(minz / 1024); d.XSize = (short)((maxz - minz) / 1024); d.ZPos = (short)(minx / 1024); d.ZSize = (short)((maxx - minx) / 1024); }
+ { d.Id = 0xFFFB; d.XPos = (short)(minx / 1024); d.XSize = (short)((maxx - minx) / 1024); d.ZPos = (short)(minz / 1024); d.ZSize = (short)((maxz - minz) / 1024); }
if (!r.IsFlipRoom) doorCount++;
p.Rooms[i].Doors[j] = d;
@@ -917,7 +957,7 @@ public void MakeDoors(TrProject p, bool tr2PrjLinks)
{
var d = p.Rooms[i].Doors[j];
if (d.Id is 4 or 0xFFFB) continue;
- var bloks = d.GetBlockIndices(p.Rooms[i].XSize);
+ var bloks = d.GetBlockIndices(p.Rooms[i].ZSize);
Door? dd = null;
for (int k = 0; k < p.Rooms[d.Filler[0]].Doors.Length; k++)
{
@@ -925,12 +965,14 @@ public void MakeDoors(TrProject p, bool tr2PrjLinks)
{ dd = p.Rooms[d.Filler[0]].Doors[k]; break; }
}
if (dd == null) continue;
- var bloks2 = dd.GetAdjacentBlockIndices(p.Rooms[dd.Room].XSize);
+ var bloks2 = dd.GetAdjacentBlockIndices(p.Rooms[dd.Room].ZSize);
if (bloks.Length != bloks2.Length) continue;
int rm = p.Rooms[i].IsFlipRoom && p.Rooms[dd.Room].FlipRoom > -1
? p.Rooms[dd.Room].FlipRoom : dd.Room;
for (int k = 0; k < bloks.Length; k++)
{
+ if (bloks[k] >= p.Rooms[i].Blocks.Length || bloks2[k] >= p.Rooms[rm].Blocks.Length)
+ continue; // out-of-range door geometry (bad/edge-case source data); skip rather than crash
p.Rooms[i].Blocks[bloks[k]].Floor = p.Rooms[rm].Blocks[bloks2[k]].Floor;
p.Rooms[i].Blocks[bloks[k]].Ceiling = p.Rooms[rm].Blocks[bloks2[k]].Ceiling;
}
diff --git a/PRJ2 Extractor/Core/TrProject.cs b/PRJ2 Extractor/Core/TrProject.cs
index 711307f..946b765 100644
--- a/PRJ2 Extractor/Core/TrProject.cs
+++ b/PRJ2 Extractor/Core/TrProject.cs
@@ -578,7 +578,7 @@ public bool InvalidBlockHeights
for (int z = 0; z < rm.ZSize; z++)
for (int x = 0; x < rm.XSize; x++)
{
- int b = (z * rm.XSize) + x;
+ int b = (x * rm.ZSize) + z;
if (rm.Blocks[b].Floor >= rm.Blocks[b].Ceiling)
{
var s = $"Room {r,3} Block {x + 1,3}, {z + 1,3} :: f {rm.Blocks[b].Floor,3} c {rm.Blocks[b].Ceiling,3}";
diff --git a/PRJ2 Extractor/MainWindow.xaml b/PRJ2 Extractor/MainWindow.xaml
index 5bd1e44..07fc416 100644
--- a/PRJ2 Extractor/MainWindow.xaml
+++ b/PRJ2 Extractor/MainWindow.xaml
@@ -15,6 +15,7 @@
diff --git a/PRJ2 Extractor/MainWindow.xaml.cs b/PRJ2 Extractor/MainWindow.xaml.cs
index 9e27569..d5303dd 100644
--- a/PRJ2 Extractor/MainWindow.xaml.cs
+++ b/PRJ2 Extractor/MainWindow.xaml.cs
@@ -242,6 +242,36 @@ private void SaveAs_Click(object sender, RoutedEventArgs e)
MessageBoxButton.OK, MessageBoxImage.Information);
}
+ private void ExportPrj2_Click(object sender, RoutedEventArgs e)
+ {
+ if (_level == null) return;
+
+ var dlg = new SaveFileDialog
+ {
+ DefaultExt = "prj2",
+ Filter = "Tomb Editor Project Files (*.prj2)|*.prj2",
+ FileName = string.IsNullOrEmpty(_lastTr4Path)
+ ? "output"
+ : Path.GetFileNameWithoutExtension(_lastTr4Path)
+ };
+
+ if (dlg.ShowDialog() != true) return;
+
+ try
+ {
+ var warnings = Prj2Exporter.Export(_level, dlg.FileName);
+ var extra = warnings.Count > 0
+ ? Environment.NewLine + $"{warnings.Count} portal(s) skipped, see log."
+ : "";
+ MessageBox.Show($"{Path.GetFileName(dlg.FileName)} saved.{extra}", "Information",
+ MessageBoxButton.OK, MessageBoxImage.Information);
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show($"Error exporting PRJ2: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error);
+ }
+ }
+
private void SaveTga_Click(object sender, RoutedEventArgs e)
{
if (_level?.TextureBitmap == null) return;
@@ -300,6 +330,7 @@ private void UpdateUiState()
{
var hasLevel = _level != null;
SaveAsMenuItem.IsEnabled = hasLevel;
+ ExportPrj2MenuItem.IsEnabled = hasLevel;
LoadPrjButton.IsEnabled = hasLevel;
UnloadPrjButton.IsEnabled = _aktrekker != null;
CopyDoorsCheckBox.IsEnabled = _aktrekker != null;
diff --git a/PRJ2 Extractor/Models/TrProjectModels.cs b/PRJ2 Extractor/Models/TrProjectModels.cs
index d0a0fc7..c1e12f3 100644
--- a/PRJ2 Extractor/Models/TrProjectModels.cs
+++ b/PRJ2 Extractor/Models/TrProjectModels.cs
@@ -66,6 +66,14 @@ public class Block
public BlockTex[] Textures = Enumerable.Range(0, 14).Select(_ => new BlockTex()).ToArray();
public ushort Flags2, Flags3;
+ // Diagonal-split triangulation (TR3+ FloorData functions 0x07-0x12), used to build
+ // TombLib's Sector.Floor/Ceiling.DiagonalSplit -- NOT related to FDiv/CDiv above, which
+ // encode an unrelated classic-PRJ/NGLE "extra floor level" feature.
+ // FloorSplit/CeilingSplit: null = no triangulation (single plane / Tilt / flat).
+ // true = diagonal runs XnZn-XpZp ("NE-SW"), false = diagonal runs XnZp-XpZn ("NW-SE").
+ public bool? FloorSplitXEqualsZ;
+ public bool? CeilingSplitXEqualsZ;
+
public bool HasCornerDataFloor =>
FloorCorner.Any(c => c != 0);
@@ -134,67 +142,42 @@ public static bool SameDoor(this Door self, Door other) =>
self.ZSize == other.ZSize &&
self.Room == other.Filler[0];
- public static ushort[] GetBlockIndices(this Door self, int roomX)
+ public static ushort[] GetBlockIndices(this Door self, int roomZ)
{
- if (self.Id == 1)
- {
- var result = new ushort[self.ZSize];
- for (int i = 0; i < result.Length; i++)
- result[i] = (ushort)((self.ZPos * roomX) + (i * roomX));
- return result;
- }
- if (self.Id == 0xFFFE)
- {
- var result = new ushort[self.ZSize];
- for (int i = 0; i < result.Length; i++)
- result[i] = (ushort)(((self.ZPos + 1) * roomX - 1) + (i * roomX));
- return result;
- }
- if (self.Id == 2)
- {
- var result = new ushort[self.XSize];
- for (int i = 0; i < result.Length; i++)
- result[i] = (ushort)(self.XPos + i);
- return result;
- }
- if (self.Id == 0xFFFD)
- {
- var result = new ushort[self.XSize];
- for (int i = 0; i < result.Length; i++)
- result[i] = (ushort)((roomX * self.ZPos) + self.XPos + i);
- return result;
- }
- if (self.Id == 4 || self.Id == 0xFFFB)
- {
- var result = new ushort[self.XSize * self.ZSize];
- for (int y = 0; y < self.ZSize; y++)
- for (int x = 0; x < self.XSize; x++)
- {
- int i = x + self.XSize * y;
- result[i] = (ushort)((roomX * self.ZPos) + self.XPos + (roomX * y + x));
- }
- return result;
- }
- return [];
+ // Blocks[] is populated X-major (TrLevel.cs): b = X_idx*NumZ + Z_idx, i.e. Z is the
+ // fast/inner axis, and moving one step along X jumps by NumZ (=room.ZSize, passed here
+ // as roomZ). XPos/XSize is the X-column range, ZPos/ZSize is the Z-row range.
+ // For wall doors either XSize or ZSize is 1, so this degenerates correctly to a single
+ // line of blocks along the wall.
+ var result = new ushort[self.XSize * self.ZSize];
+ for (int y = 0; y < self.ZSize; y++)
+ for (int x = 0; x < self.XSize; x++)
+ result[x + self.XSize * y] = (ushort)((self.XPos + x) * roomZ + (self.ZPos + y));
+ return result;
}
- public static ushort[] GetAdjacentBlockIndices(this Door self, int roomX)
+ public static ushort[] GetAdjacentBlockIndices(this Door self, int roomZ)
{
- var result = self.GetBlockIndices(roomX);
+ // The "adjacent" block is one step further in the direction the portal's normal points.
+ // In the X-major Blocks[] layout (b = X_idx*NumZ + Z_idx), Z is the fast axis (step=1)
+ // and X is the slow axis (step=roomZ):
+ // Id 1 (Normal.Z==1) -> +Z (+1); Id 0xFFFE (Normal.Z==-1) -> -Z (-1);
+ // Id 2 (Normal.X==1) -> +X (+roomZ); Id 0xFFFD (Normal.X==-1) -> -X (-roomZ).
+ var result = self.GetBlockIndices(roomZ);
if (self.Id == 1)
for (int i = 0; i < result.Length; i++) result[i]++;
if (self.Id == 0xFFFE)
for (int i = 0; i < result.Length; i++) result[i]--;
if (self.Id == 2)
- for (int i = 0; i < result.Length; i++) result[i] = (ushort)(result[i] + roomX);
+ for (int i = 0; i < result.Length; i++) result[i] = (ushort)(result[i] + roomZ);
if (self.Id == 0xFFFD)
- for (int i = 0; i < result.Length; i++) result[i] = (ushort)(result[i] - roomX);
+ for (int i = 0; i < result.Length; i++) result[i] = (ushort)(result[i] - roomZ);
return result;
}
public static void MarkDoorBlocks(this Door self, PrjRoom room)
{
- var bloks = self.GetBlockIndices(room.XSize);
+ var bloks = self.GetBlockIndices(room.ZSize).Where(b => b < room.Blocks.Length).ToArray();
if (self.Id == 4 || self.Id == 0xFFFB)
{
foreach (var b in bloks)
diff --git a/PRJ2 Extractor/PRJ2 Extractor.csproj b/PRJ2 Extractor/PRJ2 Extractor.csproj
index 9bb9ab9..561aebe 100644
--- a/PRJ2 Extractor/PRJ2 Extractor.csproj
+++ b/PRJ2 Extractor/PRJ2 Extractor.csproj
@@ -1,4 +1,4 @@
-
+
WinExe
@@ -9,4 +9,103 @@
true
+
+
+ C:\Tomb Editor\TombLib.dll
+ true
+
+
+ C:\Tomb Editor\AssimpNet.dll
+ true
+
+
+ C:\Tomb Editor\Blake3.dll
+ true
+
+
+ C:\Tomb Editor\bzPSD.dll
+ true
+
+
+ C:\Tomb Editor\ColorThief.Netstandard.v20.dll
+ true
+
+
+ C:\Tomb Editor\DirectXTexNet.dll
+ true
+
+
+ C:\Tomb Editor\FreeImage.Standard.dll
+ true
+
+
+ C:\Tomb Editor\K4os.Compression.LZ4.dll
+ true
+
+
+ C:\Tomb Editor\K4os.Compression.LZ4.Streams.dll
+ true
+
+
+ C:\Tomb Editor\K4os.Hash.xxHash.dll
+ true
+
+
+ C:\Tomb Editor\NAudio.dll
+ true
+
+
+ C:\Tomb Editor\NAudio.Core.dll
+ true
+
+
+ C:\Tomb Editor\NAudio.Flac.dll
+ true
+
+
+ C:\Tomb Editor\NAudio.Vorbis.dll
+ true
+
+
+ C:\Tomb Editor\NAudio.WinMM.dll
+ true
+
+
+ C:\Tomb Editor\NAudio.Wasapi.dll
+ true
+
+
+ C:\Tomb Editor\NAudio.Asio.dll
+ true
+
+
+ C:\Tomb Editor\NAudio.Midi.dll
+ true
+
+
+ C:\Tomb Editor\NVorbis.dll
+ true
+
+
+ C:\Tomb Editor\NLog.dll
+ true
+
+
+ C:\Tomb Editor\Pfim.dll
+ true
+
+
+ C:\Tomb Editor\Microsoft.IO.RecyclableMemoryStream.dll
+ true
+
+
+ C:\Tomb Editor\System.IO.Hashing.dll
+ true
+
+
+ C:\Tomb Editor\Newtonsoft.Json.dll
+ true
+
+
+
diff --git a/PrjDiag/PrjDiag.csproj b/PrjDiag/PrjDiag.csproj
new file mode 100644
index 0000000..b9f5f09
--- /dev/null
+++ b/PrjDiag/PrjDiag.csproj
@@ -0,0 +1,19 @@
+
+
+
+ Exe
+ net10.0-windows
+ enable
+ enable
+ true
+
+
+
+
+
+ C:\Tomb Editor\TombLib.dll
+ true
+
+
+
+
diff --git a/PrjDiag/Program.cs b/PrjDiag/Program.cs
new file mode 100644
index 0000000..f2f7750
--- /dev/null
+++ b/PrjDiag/Program.cs
@@ -0,0 +1,52 @@
+using System.IO;
+using System.Linq;
+using System.Threading;
+using TombLib.LevelData;
+using TombLib.LevelData.IO;
+using TombLib.Utils;
+using PRJ2_Extractor.Core;
+
+var reporter = new ProgressReporterSimple();
+var settings = new Prj2Loader.Settings { IgnoreWads = true, IgnoreTextures = true, IgnoreSoundsCatalogs = true };
+
+using var level = new TrLevel();
+level.Load(@"C:\Users\Checkm8ra1n\Documents\alexhub2.tr4", new Progress(v => { }));
+var warnings = Prj2Exporter.Export(level, @"C:\Users\Checkm8ra1n\Documents\alexhub2.prj2");
+Console.WriteLine($"Export warnings: {warnings.Count}");
+
+var ours = Prj2Loader.LoadFromPrj2(@"C:\Users\Checkm8ra1n\Documents\alexhub2.prj2", reporter, CancellationToken.None, settings);
+var orig = Prj2Loader.LoadFromPrj2(@"C:\Users\Checkm8ra1n\Documents\alexhub2_orig.prj2", reporter, CancellationToken.None, settings);
+
+int totalRooms = 0, matchingRooms = 0;
+int mismatchSectors = 0, comparedSectors = 0;
+var worstRooms = new List<(string name, int mismatches, int total)>();
+foreach (var rOrig in orig.Rooms)
+{
+ if (rOrig == null) continue;
+ var rOurs = ours.Rooms.FirstOrDefault(r => r != null && r.Name == rOrig.Name);
+ if (rOurs == null) continue;
+ totalRooms++;
+ if (rOurs.NumXSectors != rOrig.NumXSectors || rOurs.NumZSectors != rOrig.NumZSectors) continue;
+ int roomMismatch = 0, roomTotal = 0;
+ for (int x = 0; x < rOrig.NumXSectors; x++)
+ for (int z = 0; z < rOrig.NumZSectors; z++)
+ {
+ var so = rOurs.Sectors[x, z];
+ var sr = rOrig.Sectors[x, z];
+ if (so.IsAnyWall && sr.IsAnyWall) continue;
+ roomTotal++; comparedSectors++;
+ bool mismatch = Math.Abs(so.Floor.XpZn - sr.Floor.XpZn) > 4 || Math.Abs(so.Floor.XnZn - sr.Floor.XnZn) > 4 ||
+ Math.Abs(so.Floor.XnZp - sr.Floor.XnZp) > 4 || Math.Abs(so.Floor.XpZp - sr.Floor.XpZp) > 4 ||
+ Math.Abs(so.Ceiling.XpZn - sr.Ceiling.XpZn) > 4 || Math.Abs(so.Ceiling.XnZn - sr.Ceiling.XnZn) > 4 ||
+ Math.Abs(so.Ceiling.XnZp - sr.Ceiling.XnZp) > 4 || Math.Abs(so.Ceiling.XpZp - sr.Ceiling.XpZp) > 4;
+ if (mismatch) { mismatchSectors++; roomMismatch++; }
+ }
+ if (roomMismatch == 0) matchingRooms++;
+ worstRooms.Add((rOrig.Name, roomMismatch, roomTotal));
+}
+Console.WriteLine($"Rooms compared: {totalRooms}, fully matching (non-wall): {matchingRooms}");
+Console.WriteLine($"Non-wall sectors compared: {comparedSectors}, mismatched: {mismatchSectors} ({(comparedSectors>0?100.0*mismatchSectors/comparedSectors:0):F1}%)");
+Console.WriteLine("Worst rooms:");
+foreach (var w in worstRooms.OrderByDescending(w => w.mismatches).Take(10))
+ Console.WriteLine($" {w.name}: {w.mismatches}/{w.total}");
+return 0;