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;