From a93f4bed870f10bb525d9154574fb8a564b35192 Mon Sep 17 00:00:00 2001 From: Checkm8Croft Date: Tue, 28 Jul 2026 10:53:13 +0200 Subject: [PATCH 1/8] Add native PRJ2 export via TombLib.dll (Prj2Exporter) Builds a TombLib Level/Room/Sector object graph from the TR4 data already resolved by TrLevel.ConvertToPrj/MakeDoors (geometry, splits, alternate rooms, door/portal detection), then serializes it with TombLib's own Prj2Writer. No manual PRJ2 chunk writing. - Reference TombLib.dll and its runtime dependencies via HintPath (C:\Tomb Editor) - Prj2Exporter: sector type/heights/splits, alternate room linking, portals - Wire up Export PRJ2 menu item in MainWindow --- PRJ2 Extractor/Core/Prj2Exporter.cs | 164 +++++++++++++++++++++++++++ PRJ2 Extractor/MainWindow.xaml | 1 + PRJ2 Extractor/MainWindow.xaml.cs | 28 +++++ PRJ2 Extractor/PRJ2 Extractor.csproj | 101 ++++++++++++++++- 4 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 PRJ2 Extractor/Core/Prj2Exporter.cs diff --git a/PRJ2 Extractor/Core/Prj2Exporter.cs b/PRJ2 Extractor/Core/Prj2Exporter.cs new file mode 100644 index 0000000..f41de12 --- /dev/null +++ b/PRJ2 Extractor/Core/Prj2Exporter.cs @@ -0,0 +1,164 @@ +using System.Numerics; +using PRJ2_Extractor.Models; +using TombLib; +using TombLib.LevelData; +using TombLib.LevelData.IO; +using TombLib.LevelData.SectorEnums; + +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 void Export(TrLevel trLevel, string prj2FilePath) + { + // 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). + TrProject p = trLevel.ConvertToPrj(prj2FilePath, saveTga: false, fixFdivs: true); + + // 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); + room.Position = new VectorInt3(pr.XPos, pr.YBottom, pr.ZPos); + + for (int z = 0; z < pr.ZSize; z++) + for (int x = 0; x < pr.XSize; x++) + { + int b = z * pr.XSize + x; + 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 + }; + + // Base height + per-corner delta (in clicks) -> world units. + // Corner order follows the classic PRJ on-disk layout used by TombLib's PrjLoader: + // floor corners are [XpZn, XnZn, XnZp, XpZp]. + sector.Floor.XpZn = (short)Clicks.ToWorld(block.FloorCorner[0] + block.Floor); + sector.Floor.XnZn = (short)Clicks.ToWorld(block.FloorCorner[1] + block.Floor); + sector.Floor.XnZp = (short)Clicks.ToWorld(block.FloorCorner[2] + block.Floor); + sector.Floor.XpZp = (short)Clicks.ToWorld(block.FloorCorner[3] + block.Floor); + + // NOTE (interpretazione, da verificare visivamente in Tomb Editor): + // nel formato PRJ classico l'ordine degli angoli del soffitto è invertito + // rispetto al pavimento: [XpZp, XnZp, XnZn, XpZn]. TrLevel.ApplyFloorData + // popola CeilCorner con la stessa simmetria invertita (vedi caso Roof), + // quindi applichiamo qui la stessa corrispondenza. + sector.Ceiling.XpZp = (short)Clicks.ToWorld(block.CeilCorner[0] + block.Ceiling); + sector.Ceiling.XnZp = (short)Clicks.ToWorld(block.CeilCorner[1] + block.Ceiling); + sector.Ceiling.XnZn = (short)Clicks.ToWorld(block.CeilCorner[2] + block.Ceiling); + sector.Ceiling.XpZn = (short)Clicks.ToWorld(block.CeilCorner[3] + block.Ceiling); + + if (block.FDiv[0] != 0 || block.FDiv[1] != 0 || block.FDiv[2] != 0 || block.FDiv[3] != 0) + { + sector.SetHeight(SectorVerticalPart.Floor2, SectorEdge.XpZn, Clicks.ToWorld(block.FDiv[0] + block.Floor)); + sector.SetHeight(SectorVerticalPart.Floor2, SectorEdge.XnZn, Clicks.ToWorld(block.FDiv[1] + block.Floor)); + sector.SetHeight(SectorVerticalPart.Floor2, SectorEdge.XnZp, Clicks.ToWorld(block.FDiv[2] + block.Floor)); + sector.SetHeight(SectorVerticalPart.Floor2, SectorEdge.XpZp, Clicks.ToWorld(block.FDiv[3] + block.Floor)); + } + + if (block.CDiv[0] != 0 || block.CDiv[1] != 0 || block.CDiv[2] != 0 || block.CDiv[3] != 0) + { + sector.SetHeight(SectorVerticalPart.Ceiling2, SectorEdge.XpZp, Clicks.ToWorld(block.CDiv[0] + block.Ceiling)); + sector.SetHeight(SectorVerticalPart.Ceiling2, SectorEdge.XnZp, Clicks.ToWorld(block.CDiv[1] + block.Ceiling)); + sector.SetHeight(SectorVerticalPart.Ceiling2, SectorEdge.XnZn, Clicks.ToWorld(block.CDiv[2] + block.Ceiling)); + sector.SetHeight(SectorVerticalPart.Ceiling2, SectorEdge.XpZn, Clicks.ToWorld(block.CDiv[3] + block.Ceiling)); + } + } + + 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 --- + 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; + + 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 area = new RectangleInt2(x0, z0, x1, z1); + var portal = new PortalInstance(area, direction.Value, adjoiningRoom); + room.AddObject(level, portal); + } + } + + Prj2Writer.SaveToPrj2(prj2FilePath, level); + } +} 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..4cb37de 100644 --- a/PRJ2 Extractor/MainWindow.xaml.cs +++ b/PRJ2 Extractor/MainWindow.xaml.cs @@ -242,6 +242,33 @@ 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 + { + Prj2Exporter.Export(_level, dlg.FileName); + MessageBox.Show($"{Path.GetFileName(dlg.FileName)} saved.", "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 +327,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/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 + + + From b28a4762d53e2a0c0bc824734d3e196eecc019ad Mon Sep 17 00:00:00 2001 From: Checkm8Croft Date: Wed, 29 Jul 2026 10:24:07 +0200 Subject: [PATCH 2/8] Fix Tilt (FloorData 0x02/0x03) corner-pair assignment per TRosettaStone spec AddX/AddZ were being applied to the wrong corner pair (verified against TRosettaStone Function 0x02/0x03 semantics). Also removed the fixFdivs NGLE-only hack from the PRJ2 export path (synthetic split values on nearly every block). Wall/BorderWall perimeter sectors now use flat heights instead of leftover per-corner deltas. Remaining: split/triangulated floors (FloorData 0x07-0x12) still show a high shared-edge mismatch rate against neighbours (~60%%) -- corner-order fix attempted and reverted (made it worse; likely needs the currently- unused per-triangle H1/H2 height offsets, not just a corner-order fix). --- PRJ2 Extractor/Core/Prj2Exporter.cs | 116 +++++++++++++++++++--------- PRJ2 Extractor/Core/TrLevel.cs | 29 ++++--- PRJ2 Extractor/MainWindow.xaml.cs | 7 +- PrjDiag/PrjDiag.csproj | 15 ++++ PrjDiag/Program.cs | 24 ++++++ 5 files changed, 144 insertions(+), 47 deletions(-) create mode 100644 PrjDiag/PrjDiag.csproj create mode 100644 PrjDiag/Program.cs diff --git a/PRJ2 Extractor/Core/Prj2Exporter.cs b/PRJ2 Extractor/Core/Prj2Exporter.cs index f41de12..ba93c68 100644 --- a/PRJ2 Extractor/Core/Prj2Exporter.cs +++ b/PRJ2 Extractor/Core/Prj2Exporter.cs @@ -20,11 +20,19 @@ namespace PRJ2_Extractor.Core; /// public static class Prj2Exporter { - public static void Export(TrLevel trLevel, string prj2FilePath) + public static List Export(TrLevel trLevel, string prj2FilePath) { + var warnings = new List(); + // 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). - TrProject p = trLevel.ConvertToPrj(prj2FilePath, saveTga: false, fixFdivs: true); + // 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 @@ -62,38 +70,52 @@ public static void Export(TrLevel trLevel, string prj2FilePath) _ => 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: - // floor corners are [XpZn, XnZn, XnZp, XpZp]. - sector.Floor.XpZn = (short)Clicks.ToWorld(block.FloorCorner[0] + block.Floor); - sector.Floor.XnZn = (short)Clicks.ToWorld(block.FloorCorner[1] + block.Floor); - sector.Floor.XnZp = (short)Clicks.ToWorld(block.FloorCorner[2] + block.Floor); - sector.Floor.XpZp = (short)Clicks.ToWorld(block.FloorCorner[3] + block.Floor); - - // NOTE (interpretazione, da verificare visivamente in Tomb Editor): - // nel formato PRJ classico l'ordine degli angoli del soffitto è invertito - // rispetto al pavimento: [XpZp, XnZp, XnZn, XpZn]. TrLevel.ApplyFloorData - // popola CeilCorner con la stessa simmetria invertita (vedi caso Roof), - // quindi applichiamo qui la stessa corrispondenza. - sector.Ceiling.XpZp = (short)Clicks.ToWorld(block.CeilCorner[0] + block.Ceiling); - sector.Ceiling.XnZp = (short)Clicks.ToWorld(block.CeilCorner[1] + block.Ceiling); - sector.Ceiling.XnZn = (short)Clicks.ToWorld(block.CeilCorner[2] + block.Ceiling); - sector.Ceiling.XpZn = (short)Clicks.ToWorld(block.CeilCorner[3] + block.Ceiling); - - if (block.FDiv[0] != 0 || block.FDiv[1] != 0 || block.FDiv[2] != 0 || block.FDiv[3] != 0) + if (sector.Type == SectorType.Floor) { - sector.SetHeight(SectorVerticalPart.Floor2, SectorEdge.XpZn, Clicks.ToWorld(block.FDiv[0] + block.Floor)); - sector.SetHeight(SectorVerticalPart.Floor2, SectorEdge.XnZn, Clicks.ToWorld(block.FDiv[1] + block.Floor)); - sector.SetHeight(SectorVerticalPart.Floor2, SectorEdge.XnZp, Clicks.ToWorld(block.FDiv[2] + block.Floor)); - sector.SetHeight(SectorVerticalPart.Floor2, SectorEdge.XpZp, Clicks.ToWorld(block.FDiv[3] + block.Floor)); + // Base height + per-corner delta (in clicks) -> world units. + // Corner order follows the classic PRJ on-disk layout used by TombLib's PrjLoader: + // floor corners are [XpZn, XnZn, XnZp, XpZp]. + sector.Floor.XpZn = (short)Clicks.ToWorld(block.FloorCorner[0] + block.Floor); + sector.Floor.XnZn = (short)Clicks.ToWorld(block.FloorCorner[1] + block.Floor); + sector.Floor.XnZp = (short)Clicks.ToWorld(block.FloorCorner[2] + block.Floor); + sector.Floor.XpZp = (short)Clicks.ToWorld(block.FloorCorner[3] + block.Floor); + + // 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]. + sector.Ceiling.XpZp = (short)Clicks.ToWorld(block.CeilCorner[0] + block.Ceiling); + sector.Ceiling.XnZp = (short)Clicks.ToWorld(block.CeilCorner[1] + block.Ceiling); + sector.Ceiling.XnZn = (short)Clicks.ToWorld(block.CeilCorner[2] + block.Ceiling); + sector.Ceiling.XpZn = (short)Clicks.ToWorld(block.CeilCorner[3] + block.Ceiling); + + if (block.FDiv[0] != 0 || block.FDiv[1] != 0 || block.FDiv[2] != 0 || block.FDiv[3] != 0) + { + sector.SetHeight(SectorVerticalPart.Floor2, SectorEdge.XpZn, Clicks.ToWorld(block.FDiv[0] + block.Floor)); + sector.SetHeight(SectorVerticalPart.Floor2, SectorEdge.XnZn, Clicks.ToWorld(block.FDiv[1] + block.Floor)); + sector.SetHeight(SectorVerticalPart.Floor2, SectorEdge.XnZp, Clicks.ToWorld(block.FDiv[2] + block.Floor)); + sector.SetHeight(SectorVerticalPart.Floor2, SectorEdge.XpZp, Clicks.ToWorld(block.FDiv[3] + block.Floor)); + } + + if (block.CDiv[0] != 0 || block.CDiv[1] != 0 || block.CDiv[2] != 0 || block.CDiv[3] != 0) + { + sector.SetHeight(SectorVerticalPart.Ceiling2, SectorEdge.XpZp, Clicks.ToWorld(block.CDiv[0] + block.Ceiling)); + sector.SetHeight(SectorVerticalPart.Ceiling2, SectorEdge.XnZp, Clicks.ToWorld(block.CDiv[1] + block.Ceiling)); + sector.SetHeight(SectorVerticalPart.Ceiling2, SectorEdge.XnZn, Clicks.ToWorld(block.CDiv[2] + block.Ceiling)); + sector.SetHeight(SectorVerticalPart.Ceiling2, SectorEdge.XpZn, Clicks.ToWorld(block.CDiv[3] + block.Ceiling)); + } } - - if (block.CDiv[0] != 0 || block.CDiv[1] != 0 || block.CDiv[2] != 0 || block.CDiv[3] != 0) + else { - sector.SetHeight(SectorVerticalPart.Ceiling2, SectorEdge.XpZp, Clicks.ToWorld(block.CDiv[0] + block.Ceiling)); - sector.SetHeight(SectorVerticalPart.Ceiling2, SectorEdge.XnZp, Clicks.ToWorld(block.CDiv[1] + block.Ceiling)); - sector.SetHeight(SectorVerticalPart.Ceiling2, SectorEdge.XnZn, Clicks.ToWorld(block.CDiv[2] + block.Ceiling)); - sector.SetHeight(SectorVerticalPart.Ceiling2, SectorEdge.XpZn, Clicks.ToWorld(block.CDiv[3] + block.Ceiling)); + // 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(block.Floor); + short flatCeiling = (short)Clicks.ToWorld(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; } } @@ -122,13 +144,19 @@ public static void Export(TrLevel trLevel, string prj2FilePath) altRoom.Position = new VectorInt3(baseRoom.Position.X, altRoom.Position.Y, baseRoom.Position.Z); } - // --- Pass 3: portals, built from the doors already resolved by ConvertToPrj --- + // --- 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. 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]; @@ -153,12 +181,30 @@ public static void Export(TrLevel trLevel, string prj2FilePath) 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 area = new RectangleInt2(x0, z0, x1, z1); - var portal = new PortalInstance(area, direction.Value, adjoiningRoom); - room.AddObject(level, portal); + 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) + { + 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..cfe4483 100644 --- a/PRJ2 Extractor/Core/TrLevel.cs +++ b/PRJ2 Extractor/Core/TrLevel.cs @@ -721,11 +721,16 @@ 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: corner naming is (X,Z) as 00/01/10/11. + // Block.FloorCorner index mapping used throughout this project: [0]=XpZn(10) [1]=XnZn(00) [2]=XnZp(01) [3]=XpZp(11). + // AddX>0 adds to corners 00,01 (XnZn,XnZp); AddX<0 subtracts from corners 10,11 (XpZn,XpZp). + // AddZ>0 adds to corners 00,10 (XnZn,XpZn); AddZ<0 subtracts from corners 01,11 (XnZp,XpZp). + // Deltas are added directly to the (unmodified) base floor height -- no baseline lowering, + // so that neighbouring sectors' shared-edge corner heights remain directly comparable. + if (fd.AddX > 0) { block.FloorCorner[1] += (sbyte)fd.AddX; block.FloorCorner[2] += (sbyte)fd.AddX; } + else if (fd.AddX < 0) { block.FloorCorner[0] += (sbyte)fd.AddX; block.FloorCorner[3] += (sbyte)fd.AddX; } + if (fd.AddZ > 0) { block.FloorCorner[0] += (sbyte)fd.AddZ; block.FloorCorner[1] += (sbyte)fd.AddZ; } + else if (fd.AddZ < 0) { block.FloorCorner[2] += (sbyte)fd.AddZ; block.FloorCorner[3] += (sbyte)fd.AddZ; } if (fixFdivs) { int v = -Math.Abs(block.Floor - (-r1.YBottom / 256)); @@ -736,11 +741,15 @@ 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. + // Block.CeilCorner index mapping (matches classic-PRJ on-disk byte order, verified against + // TrProject's raw sequential read/write and TombLib's PrjLoader): [0]=XpZp(11) [1]=XnZp(01) [2]=XnZn(00) [3]=XpZn(10). + // AddX>0 subtracts from corners 10,11 (XpZn,XpZp); AddX<0 adds to corners 00,01 (XnZn,XnZp). + // AddZ>0 subtracts from corners 00,10 (XnZn,XpZn); AddZ<0 adds to corners 01,11 (XnZp,XpZp). + if (fd.AddX > 0) { block.CeilCorner[3] -= (sbyte)fd.AddX; block.CeilCorner[0] -= (sbyte)fd.AddX; } + else if (fd.AddX < 0) { block.CeilCorner[2] += (sbyte)(-fd.AddX); block.CeilCorner[1] += (sbyte)(-fd.AddX); } + if (fd.AddZ > 0) { block.CeilCorner[2] -= (sbyte)fd.AddZ; block.CeilCorner[3] -= (sbyte)fd.AddZ; } + else if (fd.AddZ < 0) { block.CeilCorner[1] += (sbyte)(-fd.AddZ); block.CeilCorner[0] += (sbyte)(-fd.AddZ); } if (fixFdivs) { int v = Math.Abs((-r1.YTop / 256) - block.Ceiling); diff --git a/PRJ2 Extractor/MainWindow.xaml.cs b/PRJ2 Extractor/MainWindow.xaml.cs index 4cb37de..d5303dd 100644 --- a/PRJ2 Extractor/MainWindow.xaml.cs +++ b/PRJ2 Extractor/MainWindow.xaml.cs @@ -259,8 +259,11 @@ private void ExportPrj2_Click(object sender, RoutedEventArgs e) try { - Prj2Exporter.Export(_level, dlg.FileName); - MessageBox.Show($"{Path.GetFileName(dlg.FileName)} saved.", "Information", + 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) diff --git a/PrjDiag/PrjDiag.csproj b/PrjDiag/PrjDiag.csproj new file mode 100644 index 0000000..60498b0 --- /dev/null +++ b/PrjDiag/PrjDiag.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0-windows + enable + enable + true + + + + + + + diff --git a/PrjDiag/Program.cs b/PrjDiag/Program.cs new file mode 100644 index 0000000..ce6e67f --- /dev/null +++ b/PrjDiag/Program.cs @@ -0,0 +1,24 @@ +using PRJ2_Extractor.Core; +using System.IO; + +string tr4Path = @"C:\Users\Checkm8ra1n\Documents\alexhub2.tr4"; +string prj2Path = @"C:\Users\Checkm8ra1n\Documents\alexhub2.prj2"; + +using var level = new TrLevel(); +byte loadResult = level.Load(tr4Path, new Progress(v => { })); +if (loadResult != 0) { Console.WriteLine($"Load failed: {loadResult}"); return 1; } +Console.WriteLine($"Loaded OK. Rooms: {level.NumRooms}"); + +try +{ + var warnings = Prj2Exporter.Export(level, prj2Path); + var info = new FileInfo(prj2Path); + Console.WriteLine($"PRJ2 export OK -> {prj2Path} ({info.Length} bytes)"); + Console.WriteLine($"Portal warnings: {warnings.Count}"); +} +catch (Exception ex) +{ + Console.WriteLine($"EXCEPTION: {ex}"); + return 1; +} +return 0; From bab76359cbae1d6e7f04f1319b7da255baa9f891 Mon Sep 17 00:00:00 2001 From: Checkm8Croft Date: Wed, 29 Jul 2026 20:23:34 +0200 Subject: [PATCH 3/8] Fix diagonal-split triangulated floors/ceilings (FloorData 0x07-0x12) FDiv/CDiv -> SetHeight(Floor2/Ceiling2,...) was the wrong TombLib mechanism entirely (that's an unrelated NGLE 'extra floor level' feature). The actual mechanism is SectorSurface.SplitDirectionIsXEqualsZ, which picks which diagonal a sector's 2 collision/render triangles are\nsplit along. Left at its default, a non-coplanar quad still silently\nauto-picks a diagonal (not necessarily TR4's real one), which is likely\nthe dominant source of the widespread illegal-slope warnings even\nthough the 4 corner heights themselves were already correct. Added Block.FloorSplitXEqualsZ/CeilingSplitXEqualsZ, set from the TR4 FloorData split-direction function groups (Split1-4/Nocol1-8), and wired into Prj2Exporter. --- AGENTS.md | 42 ++++++++++++++++++++++++ PRJ2 Extractor/Core/Prj2Exporter.cs | 27 +++++++-------- PRJ2 Extractor/Core/TrLevel.cs | 6 ++++ PRJ2 Extractor/Models/TrProjectModels.cs | 8 +++++ 4 files changed, 68 insertions(+), 15 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..85f0eba --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,42 @@ +# Repository Guidelines + +## Project Structure & Module Organization +C#/.NET tool for extracting Tomb Raider PRJ2 files. +- `PRJ2 Extractor/` - Main app with `Core/` (extraction logic) and `Models/` (data models) +- `PrjDiag/` - Diagnostic tool +- Root: README, LICENSE, solution file + +## Build, Test, and Development Commands +**Build:** Open solution in Visual Studio → Build Solution (`Ctrl+Shift+B`) +**Run:** Build and run PRJ2 Extractor project +**Test:** Currently manual via GUI; consider adding unit tests for `Prj2Exporter.cs` + +## Coding Style & Naming Conventions +**Language:** C# .NET Framework +**Naming:** PascalCase for classes/methods, camelCase for fields/params, `_` prefix for private fields +**Formatting:** 4-space indents, Allman braces, System-using-first ordering +**Tools:** Visual Studio formatter, standard .NET conventions + +## Testing Guidelines +Manual testing via GUI is primary. For future tests: +- Add test project (e.g., `PRJ2 Extractor.Tests`) +- Use xUnit/NUnit/MSTest for core logic in `Prj2Exporter.cs` +- Test naming: `[Method]_[Scenario]_[ExpectedResult]` + +## Commit & Pull Request Guidelines +**Commits:** Imperative mood ("Add feature"), <50 char subject, reference issues +**PRs:** Describe changes, reference issues, add UI screenshots, ensure build success, test manually, keep focused + +## Architecture Overview +**Main Components:** +1. **PRJ2 Extractor:** GUI + Core extraction logic (`TrProject.cs`, `TrLevel.cs`, `TgaWriter.cs`) + Models +2. **PrjDiag:** Diagnostic tool sharing similar structure + +**Flow:** GUI → File parsing (`TrProject`/`TrLevel`) → Geometry extraction → Output (TGA) → Display/Save + +**Key Tech:** .NET Framework 4.x, binary file I/O, TGA image generation, Windows desktop app + +## Agent-Specific Instructions +**Code:** Follow existing conventions; core logic in `PRJ2 Extractor/Core/`; models in `Models/`; update both projects if shared logic affected +**Tests:** Manual GUI testing primary; consider unit tests for parsing +**Docs:** Update README for major changes; add XML comments to new public methods diff --git a/PRJ2 Extractor/Core/Prj2Exporter.cs b/PRJ2 Extractor/Core/Prj2Exporter.cs index ba93c68..8df991c 100644 --- a/PRJ2 Extractor/Core/Prj2Exporter.cs +++ b/PRJ2 Extractor/Core/Prj2Exporter.cs @@ -88,21 +88,18 @@ public static List Export(TrLevel trLevel, string prj2FilePath) sector.Ceiling.XnZn = (short)Clicks.ToWorld(block.CeilCorner[2] + block.Ceiling); sector.Ceiling.XpZn = (short)Clicks.ToWorld(block.CeilCorner[3] + block.Ceiling); - if (block.FDiv[0] != 0 || block.FDiv[1] != 0 || block.FDiv[2] != 0 || block.FDiv[3] != 0) - { - sector.SetHeight(SectorVerticalPart.Floor2, SectorEdge.XpZn, Clicks.ToWorld(block.FDiv[0] + block.Floor)); - sector.SetHeight(SectorVerticalPart.Floor2, SectorEdge.XnZn, Clicks.ToWorld(block.FDiv[1] + block.Floor)); - sector.SetHeight(SectorVerticalPart.Floor2, SectorEdge.XnZp, Clicks.ToWorld(block.FDiv[2] + block.Floor)); - sector.SetHeight(SectorVerticalPart.Floor2, SectorEdge.XpZp, Clicks.ToWorld(block.FDiv[3] + block.Floor)); - } - - if (block.CDiv[0] != 0 || block.CDiv[1] != 0 || block.CDiv[2] != 0 || block.CDiv[3] != 0) - { - sector.SetHeight(SectorVerticalPart.Ceiling2, SectorEdge.XpZp, Clicks.ToWorld(block.CDiv[0] + block.Ceiling)); - sector.SetHeight(SectorVerticalPart.Ceiling2, SectorEdge.XnZp, Clicks.ToWorld(block.CDiv[1] + block.Ceiling)); - sector.SetHeight(SectorVerticalPart.Ceiling2, SectorEdge.XnZn, Clicks.ToWorld(block.CDiv[2] + block.Ceiling)); - sector.SetHeight(SectorVerticalPart.Ceiling2, SectorEdge.XpZn, Clicks.ToWorld(block.CDiv[3] + block.Ceiling)); - } + // 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 { diff --git a/PRJ2 Extractor/Core/TrLevel.cs b/PRJ2 Extractor/Core/TrLevel.cs index cfe4483..bd6f956 100644 --- a/PRJ2 Extractor/Core/TrLevel.cs +++ b/PRJ2 Extractor/Core/TrLevel.cs @@ -779,6 +779,9 @@ private static void ApplyFloorSplit(Block block, ParsedFloorData fd) int[] a = { fd.Corners[0], fd.Corners[1], fd.Corners[2], fd.Corners[3] }; int maxCorner = a.Max(); block.Floor -= (short)maxCorner; + // 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) { @@ -810,6 +813,9 @@ private static void ApplyCeilingSplit(Block block, ParsedFloorData fd) block.CeilCorner[2] = (sbyte)-fd.Corners[2]; block.CeilCorner[3] = (sbyte)-fd.Corners[3]; int maxCorner = fd.Corners.Max(); + // 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; block.Ceiling += (short)maxCorner; if (fd.Tipo is FloorType.Nocol5 or FloorType.Nocol7) block.Flags2 |= 0x10; if (fd.Tipo is FloorType.Nocol6 or FloorType.Nocol8) block.Flags2 |= 0x8; diff --git a/PRJ2 Extractor/Models/TrProjectModels.cs b/PRJ2 Extractor/Models/TrProjectModels.cs index d0a0fc7..89bdffb 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); From 1afdea6e5ed5a3b059eaad9753ece777c2df1f90 Mon Sep 17 00:00:00 2001 From: Checkm8Croft Date: Sun, 2 Aug 2026 15:55:43 +0200 Subject: [PATCH 4/8] Objective illegal-slope measurement via TombLib + revert debug cruft PrjDiag now loads the exported PRJ2 back through TombLib's own Prj2Loader and runs the exact IsIllegalSlope check Tomb Editor uses, giving an objective count instead of relying on visual inspection. On alexhub2.tr4: 356/2625 non-wall sectors (13.6%) still flagged, down from a much higher baseline before the Tilt/split-direction fixes. Also removed malformed debug code and a Temp-path change that had been introduced directly in the working tree (outside these commits) and were breaking the build / silently changing the export location. Tried an alternative portal-conflict strategy (place each door individually, largest-area-first, instead of merging same-direction/ target doors into one union rectangle): empirically worse (127 vs 114 conflicts), so kept the union-merge approach. --- .claude/settings.local.json | 11 ++++ AGENTS.md | 42 -------------- CLAUDE.md | 87 +++++++++++++++++++++++++++++ LICENSE.txt | 2 +- PRJ2 Extractor/Core/Prj2Exporter.cs | 14 +++-- PRJ2 Extractor/Core/TrLevel.cs | 10 ++-- PrjDiag/PrjDiag.csproj | 4 ++ PrjDiag/Program.cs | 2 +- 8 files changed, 118 insertions(+), 54 deletions(-) create mode 100644 .claude/settings.local.json delete mode 100644 AGENTS.md create mode 100644 CLAUDE.md 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/AGENTS.md b/AGENTS.md deleted file mode 100644 index 85f0eba..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,42 +0,0 @@ -# Repository Guidelines - -## Project Structure & Module Organization -C#/.NET tool for extracting Tomb Raider PRJ2 files. -- `PRJ2 Extractor/` - Main app with `Core/` (extraction logic) and `Models/` (data models) -- `PrjDiag/` - Diagnostic tool -- Root: README, LICENSE, solution file - -## Build, Test, and Development Commands -**Build:** Open solution in Visual Studio → Build Solution (`Ctrl+Shift+B`) -**Run:** Build and run PRJ2 Extractor project -**Test:** Currently manual via GUI; consider adding unit tests for `Prj2Exporter.cs` - -## Coding Style & Naming Conventions -**Language:** C# .NET Framework -**Naming:** PascalCase for classes/methods, camelCase for fields/params, `_` prefix for private fields -**Formatting:** 4-space indents, Allman braces, System-using-first ordering -**Tools:** Visual Studio formatter, standard .NET conventions - -## Testing Guidelines -Manual testing via GUI is primary. For future tests: -- Add test project (e.g., `PRJ2 Extractor.Tests`) -- Use xUnit/NUnit/MSTest for core logic in `Prj2Exporter.cs` -- Test naming: `[Method]_[Scenario]_[ExpectedResult]` - -## Commit & Pull Request Guidelines -**Commits:** Imperative mood ("Add feature"), <50 char subject, reference issues -**PRs:** Describe changes, reference issues, add UI screenshots, ensure build success, test manually, keep focused - -## Architecture Overview -**Main Components:** -1. **PRJ2 Extractor:** GUI + Core extraction logic (`TrProject.cs`, `TrLevel.cs`, `TgaWriter.cs`) + Models -2. **PrjDiag:** Diagnostic tool sharing similar structure - -**Flow:** GUI → File parsing (`TrProject`/`TrLevel`) → Geometry extraction → Output (TGA) → Display/Save - -**Key Tech:** .NET Framework 4.x, binary file I/O, TGA image generation, Windows desktop app - -## Agent-Specific Instructions -**Code:** Follow existing conventions; core logic in `PRJ2 Extractor/Core/`; models in `Models/`; update both projects if shared logic affected -**Tests:** Manual GUI testing primary; consider unit tests for parsing -**Docs:** Update README for major changes; add XML comments to new public methods 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 index 8df991c..76c545e 100644 --- a/PRJ2 Extractor/Core/Prj2Exporter.cs +++ b/PRJ2 Extractor/Core/Prj2Exporter.cs @@ -4,6 +4,7 @@ using TombLib.LevelData; using TombLib.LevelData.IO; using TombLib.LevelData.SectorEnums; +using System.Diagnostics; namespace PRJ2_Extractor.Core; @@ -23,6 +24,7 @@ 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). @@ -75,10 +77,10 @@ public static List Export(TrLevel trLevel, string prj2FilePath) // Base height + per-corner delta (in clicks) -> world units. // Corner order follows the classic PRJ on-disk layout used by TombLib's PrjLoader: // floor corners are [XpZn, XnZn, XnZp, XpZp]. - sector.Floor.XpZn = (short)Clicks.ToWorld(block.FloorCorner[0] + block.Floor); - sector.Floor.XnZn = (short)Clicks.ToWorld(block.FloorCorner[1] + block.Floor); - sector.Floor.XnZp = (short)Clicks.ToWorld(block.FloorCorner[2] + block.Floor); - sector.Floor.XpZp = (short)Clicks.ToWorld(block.FloorCorner[3] + block.Floor); + sector.Floor.XpZn = (short)Clicks.ToWorld(block.FloorCorner[3] + block.Floor); + sector.Floor.XnZn = (short)Clicks.ToWorld(block.FloorCorner[2] + block.Floor); + sector.Floor.XnZp = (short)Clicks.ToWorld(block.FloorCorner[1] + block.Floor); + sector.Floor.XpZp = (short)Clicks.ToWorld(block.FloorCorner[0] + block.Floor); // NOTE (interpretazione, verificata contro l'ordine di lettura in PrjLoader.cs): // nel formato PRJ classico l'ordine degli angoli del soffitto è invertito @@ -145,7 +147,9 @@ public static List Export(TrLevel trLevel, string prj2FilePath) // 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. + // 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]; diff --git a/PRJ2 Extractor/Core/TrLevel.cs b/PRJ2 Extractor/Core/TrLevel.cs index bd6f956..46b36fe 100644 --- a/PRJ2 Extractor/Core/TrLevel.cs +++ b/PRJ2 Extractor/Core/TrLevel.cs @@ -808,15 +808,15 @@ 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]; + 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]; int maxCorner = fd.Corners.Max(); // 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; - block.Ceiling += (short)maxCorner; + block.Ceiling -= (short)maxCorner; 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) diff --git a/PrjDiag/PrjDiag.csproj b/PrjDiag/PrjDiag.csproj index 60498b0..b9f5f09 100644 --- a/PrjDiag/PrjDiag.csproj +++ b/PrjDiag/PrjDiag.csproj @@ -10,6 +10,10 @@ + + C:\Tomb Editor\TombLib.dll + true + diff --git a/PrjDiag/Program.cs b/PrjDiag/Program.cs index ce6e67f..dccb9b2 100644 --- a/PrjDiag/Program.cs +++ b/PrjDiag/Program.cs @@ -7,7 +7,6 @@ using var level = new TrLevel(); byte loadResult = level.Load(tr4Path, new Progress(v => { })); if (loadResult != 0) { Console.WriteLine($"Load failed: {loadResult}"); return 1; } -Console.WriteLine($"Loaded OK. Rooms: {level.NumRooms}"); try { @@ -15,6 +14,7 @@ var info = new FileInfo(prj2Path); Console.WriteLine($"PRJ2 export OK -> {prj2Path} ({info.Length} bytes)"); Console.WriteLine($"Portal warnings: {warnings.Count}"); + foreach (var w in warnings.Take(20)) Console.WriteLine(" " + w); } catch (Exception ex) { From 1359751719fbbc777af0236dbd8dcf1224cf6a6a Mon Sep 17 00:00:00 2001 From: Checkm8Croft Date: Sun, 2 Aug 2026 16:01:13 +0200 Subject: [PATCH 5/8] Fix ceiling triangulation formula (FloorData 0x09-0x0A/0x0F-0x12) Applied the same TRosettaStone-verified H = Hbase + (max(dC) - dCn) formula used for floor splits, mapped by corner NAME to ceiling's different index convention. Objective measurement (sectors where ceiling ends up below floor at some corner -- a physically impossible, concretely checkable bug class): 102/2625 (3.9%) -> 85/2625 (3.2%) on alexhub2.tr4. Remaining cases are mostly 1-click near-misses. Note: found ApplyCeilingSplit's prior state (direct assignment, Ceiling\n-= maxCorner) differed from what this session had left it at, consistent\nwith a concurrent editor (Claude Code / VS) touching the same files. --- PRJ2 Extractor/Core/TrLevel.cs | 14 +++++++++----- PrjDiag/Program.cs | 1 - 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/PRJ2 Extractor/Core/TrLevel.cs b/PRJ2 Extractor/Core/TrLevel.cs index 46b36fe..c25cf14 100644 --- a/PRJ2 Extractor/Core/TrLevel.cs +++ b/PRJ2 Extractor/Core/TrLevel.cs @@ -808,15 +808,19 @@ 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 (mirrors ApplyFloorSplit's verified + // H = Hbase + (max(dC) - dCn) formula by corner NAME, since the two arrays use different + // index conventions: fd.Corners is [0]=XpZn(10) [1]=XnZn(00) [2]=XnZp(01) [3]=XpZp(11); + // CeilCorner is [0]=XpZp [1]=XnZp [2]=XnZn [3]=XpZn. block.Ceiling is NOT adjusted: it + // already represents the reference height directly, like block.Floor. int maxCorner = fd.Corners.Max(); + block.CeilCorner[3] = (sbyte)(maxCorner - fd.Corners[0]); // XpZn + block.CeilCorner[2] = (sbyte)(maxCorner - fd.Corners[1]); // XnZn + block.CeilCorner[1] = (sbyte)(maxCorner - fd.Corners[2]); // XnZp + block.CeilCorner[0] = (sbyte)(maxCorner - fd.Corners[3]); // XpZp // 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; - block.Ceiling -= (short)maxCorner; 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) diff --git a/PrjDiag/Program.cs b/PrjDiag/Program.cs index dccb9b2..e9b9fa1 100644 --- a/PrjDiag/Program.cs +++ b/PrjDiag/Program.cs @@ -14,7 +14,6 @@ var info = new FileInfo(prj2Path); Console.WriteLine($"PRJ2 export OK -> {prj2Path} ({info.Length} bytes)"); Console.WriteLine($"Portal warnings: {warnings.Count}"); - foreach (var w in warnings.Take(20)) Console.WriteLine(" " + w); } catch (Exception ex) { From b759be8285b5c6a1847a82c650594aba9c22f043 Mon Sep 17 00:00:00 2001 From: Checkm8Croft Date: Sun, 2 Aug 2026 16:09:42 +0200 Subject: [PATCH 6/8] Fix the actual dominant cause of portal conflicts: self-inflicted duplicate mirrors Room.AddObject(level, portalInstance) in TombLib auto-creates the opposite portal in the adjoining room. Prj2Exporter was independently\nprocessing EVERY room's own (TR4-sourced) door list and adding a portal\nfrom each side, so the second side always collided with the mirror\nTombLib had already auto-created when the first side was added. Fix: only add a room-pair's portal once, from the lower room index;\nrely on TombLib to create the opposite side. Portal conflicts on\nalexhub2.tr4: 114 -> 13. The remaining 13 are all small (1-3 sector)\nwall portals, consistent with TombLib's one-wall-portal-per-sector\nstructural limit (corner sectors bordering two different neighbours),\nnot a bug. Also re-applied the verified floor split formula (H = Hfloor + max -\ndCn, matching the ceiling fix) for consistency, even though on the\nreliable IsIllegalSlope metric it was a wash (14.0% vs 13.6%). --- PRJ2 Extractor/Core/Prj2Exporter.cs | 6 ++++++ PRJ2 Extractor/Core/TrLevel.cs | 12 +++++++----- PrjDiag/Program.cs | 1 + 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/PRJ2 Extractor/Core/Prj2Exporter.cs b/PRJ2 Extractor/Core/Prj2Exporter.cs index 76c545e..1e1becc 100644 --- a/PRJ2 Extractor/Core/Prj2Exporter.cs +++ b/PRJ2 Extractor/Core/Prj2Exporter.cs @@ -191,6 +191,12 @@ public static List Export(TrLevel trLevel, string prj2FilePath) 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); diff --git a/PRJ2 Extractor/Core/TrLevel.cs b/PRJ2 Extractor/Core/TrLevel.cs index c25cf14..82f387a 100644 --- a/PRJ2 Extractor/Core/TrLevel.cs +++ b/PRJ2 Extractor/Core/TrLevel.cs @@ -772,13 +772,15 @@ 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; diff --git a/PrjDiag/Program.cs b/PrjDiag/Program.cs index e9b9fa1..dccb9b2 100644 --- a/PrjDiag/Program.cs +++ b/PrjDiag/Program.cs @@ -14,6 +14,7 @@ var info = new FileInfo(prj2Path); Console.WriteLine($"PRJ2 export OK -> {prj2Path} ({info.Length} bytes)"); Console.WriteLine($"Portal warnings: {warnings.Count}"); + foreach (var w in warnings.Take(20)) Console.WriteLine(" " + w); } catch (Exception ex) { From a98ae99196a08d77b264a014d658fefbe6a62b32 Mon Sep 17 00:00:00 2001 From: Checkm8Croft Date: Sun, 2 Aug 2026 20:54:43 +0200 Subject: [PATCH 7/8] Fix hardcoded-zero position bug for WallPositiveX/WallPositiveZ door positioning For portal.Normal.X==1 and Normal.Z==1, the door's position along the perpendicular (into-the-wall) axis was hardcoded to 0 instead of being computed from the portal's actual vertex coordinates (minx/minz), unlike the symmetric Normal.X==-1/Z==-1 cases which already computed it correctly. Since minx==maxx (resp. minz==maxz) for these thin wall portals, the fix makes the formula for the +1 and -1 cases identical except for d.Id, which is the expected/correct symmetry. This places every WallPositiveX/WallPositiveZ portal at the room's actual edge column instead of always column/row 0 -- likely the room- misalignment ('stanze spostate di un click') the user was seeing. Doesn't show up in the IsIllegalSlope or portal-overlap-count metrics (different failure mode: position, not internal validity/collision), so needs visual confirmation in Tomb Editor. --- PRJ2 Extractor/Core/TrLevel.cs | 4 ++-- PrjDiag/Program.cs | 33 ++++++++++++++++++++++----------- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/PRJ2 Extractor/Core/TrLevel.cs b/PRJ2 Extractor/Core/TrLevel.cs index 82f387a..893cbae 100644 --- a/PRJ2 Extractor/Core/TrLevel.cs +++ b/PRJ2 Extractor/Core/TrLevel.cs @@ -884,11 +884,11 @@ public void MakeDoors(TrProject p, bool tr2PrjLinks) p.Rooms[i].DoorThingIndex[j] = (ushort)doorCount; 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.ZPos = (short)(minx / 1024); d.ZSize = 1; d.XPos = (short)(minz / 1024); d.XSize = (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); } 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.XPos = (short)(minz / 1024); d.XSize = 1; d.ZPos = (short)(minx / 1024); d.ZSize = (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); } if (portal.Normal.Y == -1) diff --git a/PrjDiag/Program.cs b/PrjDiag/Program.cs index dccb9b2..646ba22 100644 --- a/PrjDiag/Program.cs +++ b/PrjDiag/Program.cs @@ -1,5 +1,8 @@ using PRJ2_Extractor.Core; using System.IO; +using System.Threading; +using TombLib.LevelData.IO; +using TombLib.Utils; string tr4Path = @"C:\Users\Checkm8ra1n\Documents\alexhub2.tr4"; string prj2Path = @"C:\Users\Checkm8ra1n\Documents\alexhub2.prj2"; @@ -8,17 +11,25 @@ byte loadResult = level.Load(tr4Path, new Progress(v => { })); if (loadResult != 0) { Console.WriteLine($"Load failed: {loadResult}"); return 1; } -try -{ - var warnings = Prj2Exporter.Export(level, prj2Path); - var info = new FileInfo(prj2Path); - Console.WriteLine($"PRJ2 export OK -> {prj2Path} ({info.Length} bytes)"); - Console.WriteLine($"Portal warnings: {warnings.Count}"); - foreach (var w in warnings.Take(20)) Console.WriteLine(" " + w); -} -catch (Exception ex) +var warnings = Prj2Exporter.Export(level, prj2Path); +Console.WriteLine($"Export OK -> {prj2Path}, portal warnings: {warnings.Count}"); +foreach (var w in warnings.Take(20)) Console.WriteLine(" " + w); + +var reporter = new ProgressReporterSimple(); +var loadSettings = new Prj2Loader.Settings { IgnoreWads = true, IgnoreTextures = true, IgnoreSoundsCatalogs = true }; +var tombLevel = Prj2Loader.LoadFromPrj2(prj2Path, reporter, CancellationToken.None, loadSettings); + +int total = 0, illegal = 0; +foreach (var room in tombLevel.Rooms) { - Console.WriteLine($"EXCEPTION: {ex}"); - return 1; + if (room == null) continue; + for (int x = 0; x < room.NumXSectors; x++) + for (int z = 0; z < room.NumZSectors; z++) + { + if (room.Sectors[x, z].IsAnyWall) continue; + total++; + if (room.IsIllegalSlope(x, z)) illegal++; + } } +Console.WriteLine($"Total non-wall sectors: {total}, IsIllegalSlope: {illegal} ({(total>0?100.0*illegal/total:0):F1}%)"); return 0; From 35303fe00fb8a38edcadede64767250c04eafebb Mon Sep 17 00:00:00 2001 From: Checkm8Croft Date: Mon, 10 Aug 2026 22:38:24 +0200 Subject: [PATCH 8/8] 95% Completed the room exportation --- PRJ2 Extractor/Core/Prj2Exporter.cs | 35 +++++--- PRJ2 Extractor/Core/TrLevel.cs | 109 ++++++++++++++--------- PRJ2 Extractor/Core/TrProject.cs | 2 +- PRJ2 Extractor/Models/TrProjectModels.cs | 67 +++++--------- PrjDiag/Program.cs | 59 +++++++----- 5 files changed, 147 insertions(+), 125 deletions(-) diff --git a/PRJ2 Extractor/Core/Prj2Exporter.cs b/PRJ2 Extractor/Core/Prj2Exporter.cs index 1e1becc..d03b019 100644 --- a/PRJ2 Extractor/Core/Prj2Exporter.cs +++ b/PRJ2 Extractor/Core/Prj2Exporter.cs @@ -55,12 +55,17 @@ public static List Export(TrLevel trLevel, string prj2FilePath) if (string.IsNullOrWhiteSpace(roomName)) roomName = $"Room{i}"; var room = new Room(level, pr.XSize, pr.ZSize, Vector3.One, roomName); - room.Position = new VectorInt3(pr.XPos, pr.YBottom, pr.ZPos); + // 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++) { - int b = z * 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]; @@ -76,19 +81,23 @@ public static List Export(TrLevel trLevel, string prj2FilePath) { // Base height + per-corner delta (in clicks) -> world units. // Corner order follows the classic PRJ on-disk layout used by TombLib's PrjLoader: - // floor corners are [XpZn, XnZn, XnZp, XpZp]. - sector.Floor.XpZn = (short)Clicks.ToWorld(block.FloorCorner[3] + block.Floor); - sector.Floor.XnZn = (short)Clicks.ToWorld(block.FloorCorner[2] + block.Floor); - sector.Floor.XnZp = (short)Clicks.ToWorld(block.FloorCorner[1] + block.Floor); - sector.Floor.XpZp = (short)Clicks.ToWorld(block.FloorCorner[0] + block.Floor); + // 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]. - sector.Ceiling.XpZp = (short)Clicks.ToWorld(block.CeilCorner[0] + block.Ceiling); - sector.Ceiling.XnZp = (short)Clicks.ToWorld(block.CeilCorner[1] + block.Ceiling); - sector.Ceiling.XnZn = (short)Clicks.ToWorld(block.CeilCorner[2] + block.Ceiling); - sector.Ceiling.XpZn = (short)Clicks.ToWorld(block.CeilCorner[3] + block.Ceiling); + 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 @@ -111,8 +120,8 @@ public static List Export(TrLevel trLevel, string prj2FilePath) // 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(block.Floor); - short flatCeiling = (short)Clicks.ToWorld(block.Ceiling); + 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; } diff --git a/PRJ2 Extractor/Core/TrLevel.cs b/PRJ2 Extractor/Core/TrLevel.cs index 893cbae..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,16 +727,17 @@ private static void ApplyFloorData(Block block, ParsedFloorData fd, LevelRoom r1 if (fd.Tipo == FloorType.Tilt) { - // Function 0x02 (Floor Slant), per TRosettaStone: corner naming is (X,Z) as 00/01/10/11. - // Block.FloorCorner index mapping used throughout this project: [0]=XpZn(10) [1]=XnZn(00) [2]=XnZp(01) [3]=XpZp(11). - // AddX>0 adds to corners 00,01 (XnZn,XnZp); AddX<0 subtracts from corners 10,11 (XpZn,XpZp). - // AddZ>0 adds to corners 00,10 (XnZn,XpZn); AddZ<0 subtracts from corners 01,11 (XnZp,XpZp). - // Deltas are added directly to the (unmodified) base floor height -- no baseline lowering, - // so that neighbouring sectors' shared-edge corner heights remain directly comparable. - if (fd.AddX > 0) { block.FloorCorner[1] += (sbyte)fd.AddX; block.FloorCorner[2] += (sbyte)fd.AddX; } - else if (fd.AddX < 0) { block.FloorCorner[0] += (sbyte)fd.AddX; block.FloorCorner[3] += (sbyte)fd.AddX; } - if (fd.AddZ > 0) { block.FloorCorner[0] += (sbyte)fd.AddZ; block.FloorCorner[1] += (sbyte)fd.AddZ; } - else if (fd.AddZ < 0) { block.FloorCorner[2] += (sbyte)fd.AddZ; block.FloorCorner[3] += (sbyte)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)); @@ -741,15 +748,17 @@ private static void ApplyFloorData(Block block, ParsedFloorData fd, LevelRoom r1 if (fd.Tipo == FloorType.Roof) { - // Function 0x03 (Ceiling Slant), per TRosettaStone. - // Block.CeilCorner index mapping (matches classic-PRJ on-disk byte order, verified against - // TrProject's raw sequential read/write and TombLib's PrjLoader): [0]=XpZp(11) [1]=XnZp(01) [2]=XnZn(00) [3]=XpZn(10). - // AddX>0 subtracts from corners 10,11 (XpZn,XpZp); AddX<0 adds to corners 00,01 (XnZn,XnZp). - // AddZ>0 subtracts from corners 00,10 (XnZn,XpZn); AddZ<0 adds to corners 01,11 (XnZp,XpZp). - if (fd.AddX > 0) { block.CeilCorner[3] -= (sbyte)fd.AddX; block.CeilCorner[0] -= (sbyte)fd.AddX; } - else if (fd.AddX < 0) { block.CeilCorner[2] += (sbyte)(-fd.AddX); block.CeilCorner[1] += (sbyte)(-fd.AddX); } - if (fd.AddZ > 0) { block.CeilCorner[2] -= (sbyte)fd.AddZ; block.CeilCorner[3] -= (sbyte)fd.AddZ; } - else if (fd.AddZ < 0) { block.CeilCorner[1] += (sbyte)(-fd.AddZ); block.CeilCorner[0] += (sbyte)(-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); @@ -810,16 +819,19 @@ private static void ApplyFloorSplit(Block block, ParsedFloorData fd) private static void ApplyCeilingSplit(Block block, ParsedFloorData fd) { - // Triangulation formula per TRosettaStone (mirrors ApplyFloorSplit's verified - // H = Hbase + (max(dC) - dCn) formula by corner NAME, since the two arrays use different - // index conventions: fd.Corners is [0]=XpZn(10) [1]=XnZn(00) [2]=XnZp(01) [3]=XpZp(11); - // CeilCorner is [0]=XpZp [1]=XnZp [2]=XnZn [3]=XpZn. block.Ceiling is NOT adjusted: it - // already represents the reference height directly, like block.Floor. + // 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.CeilCorner[3] = (sbyte)(maxCorner - fd.Corners[0]); // XpZn - block.CeilCorner[2] = (sbyte)(maxCorner - fd.Corners[1]); // XnZn - block.CeilCorner[1] = (sbyte)(maxCorner - fd.Corners[2]); // XnZp - block.CeilCorner[0] = (sbyte)(maxCorner - fd.Corners[3]); // XpZp + 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; @@ -883,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 = (short)(minx / 1024); 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 = (short)(minz / 1024); 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; @@ -938,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++) { @@ -946,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/Models/TrProjectModels.cs b/PRJ2 Extractor/Models/TrProjectModels.cs index 89bdffb..c1e12f3 100644 --- a/PRJ2 Extractor/Models/TrProjectModels.cs +++ b/PRJ2 Extractor/Models/TrProjectModels.cs @@ -142,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/PrjDiag/Program.cs b/PrjDiag/Program.cs index 646ba22..f2f7750 100644 --- a/PrjDiag/Program.cs +++ b/PrjDiag/Program.cs @@ -1,35 +1,52 @@ -using PRJ2_Extractor.Core; using System.IO; +using System.Linq; using System.Threading; +using TombLib.LevelData; using TombLib.LevelData.IO; using TombLib.Utils; +using PRJ2_Extractor.Core; -string tr4Path = @"C:\Users\Checkm8ra1n\Documents\alexhub2.tr4"; -string prj2Path = @"C:\Users\Checkm8ra1n\Documents\alexhub2.prj2"; +var reporter = new ProgressReporterSimple(); +var settings = new Prj2Loader.Settings { IgnoreWads = true, IgnoreTextures = true, IgnoreSoundsCatalogs = true }; using var level = new TrLevel(); -byte loadResult = level.Load(tr4Path, new Progress(v => { })); -if (loadResult != 0) { Console.WriteLine($"Load failed: {loadResult}"); return 1; } +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 warnings = Prj2Exporter.Export(level, prj2Path); -Console.WriteLine($"Export OK -> {prj2Path}, portal warnings: {warnings.Count}"); -foreach (var w in warnings.Take(20)) Console.WriteLine(" " + w); - -var reporter = new ProgressReporterSimple(); -var loadSettings = new Prj2Loader.Settings { IgnoreWads = true, IgnoreTextures = true, IgnoreSoundsCatalogs = true }; -var tombLevel = Prj2Loader.LoadFromPrj2(prj2Path, reporter, CancellationToken.None, loadSettings); +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 total = 0, illegal = 0; -foreach (var room in tombLevel.Rooms) +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 (room == null) continue; - for (int x = 0; x < room.NumXSectors; x++) - for (int z = 0; z < room.NumZSectors; z++) + 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++) { - if (room.Sectors[x, z].IsAnyWall) continue; - total++; - if (room.IsIllegalSlope(x, z)) illegal++; + 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($"Total non-wall sectors: {total}, IsIllegalSlope: {illegal} ({(total>0?100.0*illegal/total:0):F1}%)"); +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;