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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
246 changes: 246 additions & 0 deletions PRJ2 Extractor/Core/Prj2Exporter.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
using System.Numerics;
using PRJ2_Extractor.Models;
using TombLib;

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

View workflow job for this annotation

GitHub Actions / build (Release)

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

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

View workflow job for this annotation

GitHub Actions / build (Release)

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

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

View workflow job for this annotation

GitHub Actions / build (Release)

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

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

View workflow job for this annotation

GitHub Actions / build (Release)

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

namespace PRJ2_Extractor.Core;
Expand Down Expand Up @@ -128,6 +128,7 @@
}

room.NormalizeRoomY();
ExportLights(room, trLevel.Rooms[i], pr);
level.Rooms[i] = room;
tombRooms[i] = room;
}
Expand Down Expand Up @@ -220,7 +221,252 @@
}
}

ExportSoundSources(trLevel, tombRooms, warnings);
ExportSinks(trLevel, tombRooms, warnings);
ExportCameras(trLevel, tombRooms, warnings);
ExportFlybyCameras(trLevel, tombRooms, warnings);

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

/// <summary>
/// Converts raw tr4_room_light entries (TR world-coordinate convention) into TombLib
/// LightInstance objects, inverting the exact formulas used by TombLib's own compiler
/// (Compilers/Rooms.cs ConvertLights / BuildRoom) so a round-trip through TombLib reproduces
/// the original TR4 light data.
/// </summary>
private static void ExportLights(Room room, LevelRoom r1, PrjRoom pr)

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

View workflow job for this annotation

GitHub Actions / build (Release)

The type or namespace name 'Room' could not be found (are you missing a using directive or an assembly reference?)
{
foreach (var l in r1.Lights)
{
LightType type = l.LightType switch
{
0 => LightType.Sun,
1 => LightType.Point,
2 => LightType.Spot,
3 => LightType.Shadow,
4 => LightType.FogBulb,
_ => LightType.Point,
};
var light = new LightInstance(type)
{
// Position is room-relative in TombLib; world position (TR convention) is
// RoomInfo.X/Z + Position.X/Z, and -(Position.Y + RoomWorldY) for Y.
Position = new Vector3(l.X - r1.X, -l.Y - room.Position.Y, l.Z - r1.Z),
Color = new Vector3(l.ColourR / 128.0f, l.ColourG / 128.0f, l.ColourB / 128.0f),
};

// Intensity: raw ushort = round(abs(floatIntensity) * 8191), sign restored for Shadow type
// (TombLib negates Intensity for Shadow lights on load/construction).
light.Intensity = l.Intensity / 8191.0f;
if (type == LightType.Shadow) light.Intensity *= -1;

switch (type)
{
case LightType.Point:
case LightType.Shadow:
light.InnerRange = l.In / Level.SectorSizeUnit;
light.OuterRange = l.Out / Level.SectorSizeUnit;
break;
case LightType.Spot:
light.InnerAngle = (float)(Math.Acos(Math.Clamp(l.In, -1.0, 1.0)) * (180.0 / Math.PI));
light.OuterAngle = (float)(Math.Acos(Math.Clamp(l.Out, -1.0, 1.0)) * (180.0 / Math.PI));
light.InnerRange = l.Length / Level.SectorSizeUnit;
light.OuterRange = l.CutOff / Level.SectorSizeUnit;
SetDirection(light, l.DirX, l.DirY, l.DirZ);
break;
case LightType.Sun:
SetDirection(light, l.DirX, l.DirY, l.DirZ);
break;
case LightType.FogBulb:
light.InnerRange = l.In / Level.SectorSizeUnit;
light.OuterRange = l.Out / Level.SectorSizeUnit;
light.Intensity = l.Length; // TR5-native storage; TR4 uses a color hack instead
break;
}

room.AddObject(room.Level, light);
}
}

private static void SetDirection(IRotateableYX light, float dx, float dy, float dz)

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

View workflow job for this annotation

GitHub Actions / build (Release)

The type or namespace name 'IRotateableYX' could not be found (are you missing a using directive or an assembly reference?)
{
// Inverse of GetDirection()/compiler's DirectionX=-dir.X, DirectionY=dir.Y, DirectionZ=-dir.Z
// (light-specific encoding).
ApplyDirection(light, -dx, dy, -dz);
}

private static void ApplyDirection(IRotateableYX obj, float dirX, float dirY, float dirZ)

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

View workflow job for this annotation

GitHub Actions / build (Release)

The type or namespace name 'IRotateableYX' could not be found (are you missing a using directive or an assembly reference?)
{
float rx = (float)Math.Asin(Math.Clamp(dirY, -1.0, 1.0));
float ry = (float)Math.Atan2(dirX, dirZ);
obj.SetArbitaryRotationsYX(ry * (180.0f / (float)Math.PI), rx * (180.0f / (float)Math.PI));
}

/// <summary>
/// Sound sources have no room field in the raw tr_sound_source struct; the containing room is
/// found by testing the raw world position against each room's X/Z/Y bounds (matches the
/// compiler's own room.WorldPos + instance.Position relationship, inverted).
/// </summary>
private static int FindContainingRoom(TrLevel trLevel, int x, int y, int z)
{
for (int i = 0; i < trLevel.Rooms.Length; i++)
{
var r1 = trLevel.Rooms[i];
if (r1.NumX == 0 || r1.NumZ == 0) continue;
if (x < r1.X || x >= r1.X + r1.NumX * 1024) continue;
if (z < r1.Z || z >= r1.Z + r1.NumZ * 1024) continue;
if (y < r1.YTop || y > r1.YBottom) continue;
return i;
}
return -1;
}

private static void ExportSoundSources(TrLevel trLevel, Room?[] tombRooms, List<string> warnings)

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

View workflow job for this annotation

GitHub Actions / build (Release)

The type or namespace name 'Room' could not be found (are you missing a using directive or an assembly reference?)
{
foreach (var s in trLevel.SoundSources)
{
int roomIdx = FindContainingRoom(trLevel, s.X, s.Y, s.Z);
if (roomIdx < 0 || tombRooms[roomIdx] == null)
{
warnings.Add($"Sound source (SoundID={s.SoundId}) at ({s.X},{s.Y},{s.Z}) skipped: no containing room found");
continue;
}
var room = tombRooms[roomIdx]!;
var r1 = trLevel.Rooms[roomIdx];
var sound = new SoundSourceInstance
{
Position = new Vector3(s.X - r1.X, -s.Y - room.Position.Y, s.Z - r1.Z),
SoundId = s.SoundId,
// Flags 0xC0 covers both "Always" and "Automatic in a non-alternated room" (identical
// encoding); 0x40/0x80 mark automatic play tied to a specific alternate-room state.
// Empirically (verified against reference data) 0xC0 is used for Automatic in practice,
// so default there rather than Always.
PlayMode = s.Flags switch
{
0x80 => SoundSourcePlayMode.OnlyInBaseRoom,
0x40 => SoundSourcePlayMode.OnlyInAlternateRoom,
_ => SoundSourcePlayMode.Automatic,
},
};
room.AddObject(room.Level, sound);
}
}

/// <summary>
/// Sinks share the raw tr_camera array with Camera trigger targets; which indices are which is
/// only knowable by scanning trigger ActionLists (done once during TrLevel.Load, see
/// TrLevel.cs's Trigger FloorData handling). For entries classified as sinks, the raw "Room"
/// field is repurposed by the TombLib compiler to hold Strength (not a room index) and "Flags"
/// to hold a pathfinding box index -- see LevelCompilerClassicTR.cs's sink-writing code, which
/// this inverts. The containing room itself must be found by position, same as sound sources.
/// </summary>
private static void ExportSinks(TrLevel trLevel, Room?[] tombRooms, List<string> warnings)

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

View workflow job for this annotation

GitHub Actions / build (Release)

The type or namespace name 'Room' could not be found (are you missing a using directive or an assembly reference?)
{
foreach (int idx in trLevel.SinkFloorDataIndices)
{
if (idx < 0 || idx >= trLevel.Cameras.Count)
{
warnings.Add($"Sink index {idx} out of range of the raw Cameras[] array ({trLevel.Cameras.Count} entries)");
continue;
}
var c = trLevel.Cameras[idx];
int roomIdx = FindContainingRoom(trLevel, c.X, c.Y, c.Z);
if (roomIdx < 0 || tombRooms[roomIdx] == null)
{
warnings.Add($"Sink (index {idx}, strength {c.Room}) at ({c.X},{c.Y},{c.Z}) skipped: no containing room found");
continue;
}
var room = tombRooms[roomIdx]!;
var r1 = trLevel.Rooms[roomIdx];
var sink = new SinkInstance
{
Position = new Vector3(c.X - r1.X, -c.Y - room.Position.Y, c.Z - r1.Z),
// Empirically confirmed (Francy): raw Strength is stored 1-based, TombLib's is 0-based.
Strength = (short)(c.Room - 1),
};
room.AddObject(room.Level, sink);
}
}

/// <summary>
/// Static (non-flyby) cameras, found via the CameraFloorDataIndices set collected while parsing
/// Trigger FloorData (TrigAction 0x01) during TrLevel.Load. Unlike sinks, tr_camera's Room field
/// is a genuine room index for camera entries, so no position-based search is needed.
/// </summary>
private static void ExportCameras(TrLevel trLevel, Room?[] tombRooms, List<string> warnings)

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

View workflow job for this annotation

GitHub Actions / build (Release)

The type or namespace name 'Room' could not be found (are you missing a using directive or an assembly reference?)
{
foreach (int idx in trLevel.CameraFloorDataIndices)
{
if (idx < 0 || idx >= trLevel.Cameras.Count)
{
warnings.Add($"Camera index {idx} out of range of the raw Cameras[] array ({trLevel.Cameras.Count} entries)");
continue;
}
var c = trLevel.Cameras[idx];
if (c.Room < 0 || c.Room >= tombRooms.Length || tombRooms[c.Room] == null)
{
warnings.Add($"Camera (index {idx}) at ({c.X},{c.Y},{c.Z}) skipped: invalid room {c.Room}");
continue;
}
var room = tombRooms[c.Room]!;
var r1 = trLevel.Rooms[c.Room];
var camera = new CameraInstance
{
Position = new Vector3(c.X - r1.X, -c.Y - room.Position.Y, c.Z - r1.Z),
// Flags: 0x3 (bits 0+1 both set) = Sniper; bit0 alone = Locked; bit2 = GlideOut.
CameraMode = (c.Flags & 0x3) == 0x3 ? CameraInstanceMode.Sniper
: (c.Flags & 0x1) != 0 ? CameraInstanceMode.Locked
: CameraInstanceMode.Default,
GlideOut = (c.Flags & 0x4) != 0,
};
room.AddObject(room.Level, camera);
}
}

/// <summary>
/// Flyby cameras, from the raw tr4_flyby_camera array. Unlike static cameras, Room is a genuine
/// direct room index here too, so no position search is needed.
/// </summary>
private static void ExportFlybyCameras(TrLevel trLevel, Room?[] tombRooms, List<string> warnings)
{
foreach (var c in trLevel.FlybyCameras)
{
int roomIdx = (int)c.RoomId;
if (roomIdx < 0 || roomIdx >= tombRooms.Length || tombRooms[roomIdx] == null)
{
warnings.Add($"Flyby camera (seq {c.Sequence}, idx {c.Index}) at ({c.X},{c.Y},{c.Z}) skipped: invalid room {roomIdx}");
continue;
}
var room = tombRooms[roomIdx]!;
var r1 = trLevel.Rooms[roomIdx];

var position = new Vector3(c.X - r1.X, -c.Y - room.Position.Y, c.Z - r1.Z);
// DirX/Y/Z encode a world-space "look at" point: position + SectorSizeUnit*direction (with
// the same Y sign convention as the position fields). Invert to recover the direction.
float dirX = (c.DirX - c.X) / (float)Level.SectorSizeUnit;
float dirY = (c.Y - c.DirY) / (float)Level.SectorSizeUnit;
float dirZ = (c.DirZ - c.Z) / (float)Level.SectorSizeUnit;

var flyby = new FlybyCameraInstance
{
Position = position,
Sequence = c.Sequence,
Number = c.Index,
Timer = (short)c.Timer,
Flags = c.Flags,
Fov = c.Fov * (360.0f / 65536.0f),
Speed = c.Speed / 655.0f,
};
// Roll: encoded as rollTo65536 = (65536 - round(Roll*65536/360)) mod 65536, stored as a
// reinterpreted (unchecked) int16. Invert by reading it back as unsigned first.
ushort rollRaw = unchecked((ushort)c.Roll);
int rollX = (65536 - rollRaw) % 65536;
flyby.Roll = rollX * (360.0f / 65536.0f);
ApplyDirection(flyby, dirX, dirY, dirZ);

room.AddObject(room.Level, flyby);
}
}
}
94 changes: 90 additions & 4 deletions PRJ2 Extractor/Core/TrLevel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ public class TrLevel : IDisposable
public uint NumMeshtrees, SizeKeyframes, NumMoveables, NumStatics;
public uint NumFloorData;
public uint NumBoxes;
public List<LevelSoundSource> SoundSources = [];
public List<LevelCamera> Cameras = [];
public List<LevelFlybyCamera> FlybyCameras = [];
public HashSet<int> CameraFloorDataIndices = [];
public HashSet<int> SinkFloorDataIndices = [];
public ushort[] FloorData = [];
public LevelRoom[] Rooms = [];
public LevelBox[] Boxes = [];
Expand Down Expand Up @@ -69,10 +74,30 @@ static void ParseFloorType(ushort arg, out int f, out int sub, out int e)
}
else if (fd.Tipo == FloorType.Trigger)
{
bool isFirst = true;
do
{
data = FloorData[fdIndex + k];
k++;
if (isFirst) { isFirst = false; continue; } // TriggerSetup word, not an ActionList entry

int trigAction = (data & 0x7C00) >> 10;
int parameter = data & 0x03FF;
if (trigAction == 0x01) // Camera: uses Parameter as Cameras[] index, plus one extra word
{
CameraFloorDataIndices.Add(parameter & 0x7F);
data = FloorData[fdIndex + k]; // the end/cont bit for 2-word actions lives here, not on the entry word above
k++;
}
else if (trigAction == 0x02) // Underwater Current (Sink): Parameter is Cameras[] index
{
SinkFloorDataIndices.Add(parameter);
}
else if (trigAction == 0x0C) // Flyby: also has one extra word
{
data = FloorData[fdIndex + k];
k++;
}
} while ((data & 0x8000) != 0x8000);
}
else if (fd.Tipo == FloorType.Climb)
Expand Down Expand Up @@ -251,7 +276,8 @@ public byte Load(string filename, IProgress<int>? progress = null)
r.Colour.R = br2.ReadByte();
r.Colour.A = br2.ReadByte();
ushort lightCount = br2.ReadUInt16();
geometry.Seek(lightCount * 46, SeekOrigin.Current);
for (int li = 0; li < lightCount; li++)
r.Lights.Add(ReadLight(br2));
ushort staticCount = br2.ReadUInt16();
geometry.Seek(staticCount * 20, SeekOrigin.Current);
r.AltRoom = br2.ReadInt16();
Expand Down Expand Up @@ -305,11 +331,47 @@ public byte Load(string filename, IProgress<int>? progress = null)
size = br2.ReadUInt32();
geometry.Seek(size * 8, SeekOrigin.Current);
size = br2.ReadUInt32();
geometry.Seek(size * 16, SeekOrigin.Current);
Cameras = new List<LevelCamera>((int)size);
for (int i = 0; i < size; i++)
Cameras.Add(new LevelCamera
{
X = br2.ReadInt32(),
Y = br2.ReadInt32(),
Z = br2.ReadInt32(),
Room = br2.ReadInt16(),
Flags = br2.ReadUInt16(),
});
size = br2.ReadUInt32();
geometry.Seek(size * 40, SeekOrigin.Current);
FlybyCameras = new List<LevelFlybyCamera>((int)size);
for (int i = 0; i < size; i++)
FlybyCameras.Add(new LevelFlybyCamera
{
X = br2.ReadInt32(),
Y = br2.ReadInt32(),
Z = br2.ReadInt32(),
DirX = br2.ReadInt32(),
DirY = br2.ReadInt32(),
DirZ = br2.ReadInt32(),
Sequence = br2.ReadByte(),
Index = br2.ReadByte(),
Fov = br2.ReadUInt16(),
Roll = br2.ReadInt16(),
Timer = br2.ReadUInt16(),
Speed = br2.ReadUInt16(),
Flags = br2.ReadUInt16(),
RoomId = br2.ReadUInt32(),
});
size = br2.ReadUInt32();
geometry.Seek(size * 16, SeekOrigin.Current);
SoundSources = new List<LevelSoundSource>((int)size);
for (int i = 0; i < size; i++)
SoundSources.Add(new LevelSoundSource
{
X = br2.ReadInt32(),
Y = br2.ReadInt32(),
Z = br2.ReadInt32(),
SoundId = br2.ReadUInt16(),
Flags = br2.ReadUInt16(),
});
NumBoxes = br2.ReadUInt32();
Boxes = new LevelBox[NumBoxes];
for (int i = 0; i < NumBoxes; i++)
Expand Down Expand Up @@ -425,6 +487,30 @@ private static ObjectTexture ReadObjectTexture(BinaryReader br)
return texture;
}

private static LevelLight ReadLight(BinaryReader br)
{
// tr4_room_light, 46 bytes, per TRosettaStone.
var l = new LevelLight
{
X = br.ReadInt32(),
Y = br.ReadInt32(),
Z = br.ReadInt32(),
ColourR = br.ReadByte(),
ColourG = br.ReadByte(),
ColourB = br.ReadByte(),
LightType = br.ReadByte(),
};
l.Intensity = br.ReadUInt16();
l.In = br.ReadSingle();
l.Out = br.ReadSingle();
l.Length = br.ReadSingle();
l.CutOff = br.ReadSingle();
l.DirX = br.ReadSingle();
l.DirY = br.ReadSingle();
l.DirZ = br.ReadSingle();
return l;
}

private static Portal ReadPortal(BinaryReader br)
{
var p = new Portal { ToRoom = br.ReadUInt16() };
Expand Down
Loading
Loading