diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 0328378..01303d9 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -1007,6 +1007,53 @@ Exported as `TriggerInstance` (TR4, TRNG and TR5), inverse of the trigger part o - The camera / sink sets are unchanged and PrjDiag is unchanged (97.01%). **Not recovered.** Action types 27 and 31 (zoo 15, Venice 1, Burial Chambers 1) are skipped with a warning (see 2.38). A Moveable-target action whose number is a static's script ID (NG) is indistinguishable from an item index; numbers beyond the item count are skipped with a warning. When one CameraInstance is used by several triggers with different move timers the last one wins. In non-NG levels an action 11 (clear bodies) is mapped to ActionNg, which has no counterpart there; no sample level contains one. +### 2.41 -- TombEngine levels (.ten): container and data layout + +Reader: `Core/TenLevel.cs` (not yet wired into the exporter). Source of truth: TombEngine `Specific/level.cpp` (`LoadLevel` and the `Load*` functions); the writer side is TombLib `Compilers/TombEngine` (`Structs.cs`, `FloorData.cs`). + +**Container.** 16-byte header (`TEN`, compiler version[4], system hash, level hash), then three blocks: media (texture atlases, samples), geometry, dynamic data. Each block is `[int64 uncompressed][int64 compressed]` followed by `[uint32 chunks]{[uint32 uncompressed][uint32 compressed][LZ4 block]}` (K4os.Compression.LZ4). Primitive types are little-endian; strings are LEB128-prefixed UTF-8 (same as `BinaryReader.ReadString`); bools are one byte. + +**Geometry block.** Rooms (position x/z, bottom/top height, vertex positions / colours / effects, texture buckets of quads and triangles with UVs, portals, size, sectors, lights), then the floordata words, then moveable models, sprites, boxes, mirrors, animated textures and material definitions (the last group is not read). Sectors carry trigger index, pathfinding box, material, stopper, and for floor and ceiling: split angle, two portal room numbers and two "fake planes" (x, y = tilt per block, z = height at the sector centre), plus the side portal and nine flags (death, monkey, four climbs, mark triggerer, mark beetle). + +**Dynamic block.** Per room: name, tags, ambient, flipped room, flags, mesh effect, reverb, flip number, statics (position, rotation, scale, flags, colour, slot, hit points, name) and trigger volumes; then items, AI objects, cameras, flyby cameras (with DOF), sinks, sound sources, materials, event sets (global, volume) and the property blob. + +**Verification.** palace.ten and venice.ten (The Emerald Vault, compiler 2.0.0) load in about 0.3 s each; the dynamic block is consumed to the last byte, which validates every field read there (palace: 13 rooms, 18 items, 49 statics; venice: 68 rooms, 147 items, 13 flybys, 231 statics). The geometry block cannot be checked to its end because only the room part is read. Both levels use floordata triggers (`TriggerIndex` + floordata) and no volumes. +### 2.42 -- TombEngine levels to PRJ2: rooms, sectors, portals, objects, triggers + +Code: `Core/TenExporter.cs` (rooms, portals, flip), `Core/TenSectors.cs` (sector surfaces), `Core/TenObjects.cs` (objects), triggers through `TriggerExporter.ExportCore` (shared with the TR path, `tenFormat` switch). Not wired into the UI yet and no textures yet (see below). Ground truth: the Tomb Editor sources of The Emerald Vault levels (`palace.prj2`, `venice.prj2`) loaded with `Prj2Loader`, compared with what is rebuilt from `palace.ten` / `venice.ten`. + +**Rooms.** Position X/Z = file X/Z / 1024. `Position.Y` is NOT `-BottomHeight` (that is only the lowest corner): every complete wall has all four planes at Z = -Position.Y, so the exact value comes from any wall sector (the border always has some). The file stores half of the editor's colour values (ambient, statics, moveables, lights): multiply by 2. Room flags: 0x1 water, 0x4 quicksand, 0x8 horizon, 0x20 outside, 0x80 no lensflare, 0x800 damage, 0x1000 cold (0x40 is automatic). Flip: `FlippedRoom` names the alternate room, `FlipNumber` is the alternate group. + +**Sectors.** The file keeps two "fake planes" per surface (x, y = tilt over one sector, z = height at the centre: engine Y = z + x * xl + y * zl, xl / zl in -512..512 from the centre) instead of corner heights; TombLib height = -Y - Position.Y. A complete wall is flat floor = ceiling = -Position.Y with no split (wall heights are lost); a wall triangle is marked by Z = -float.MaxValue. Diagonal splits are inverted from the compiler's `RoomSectorShape` rules: split angle pi/4 allows XnZp / XpZn, 3pi/4 allows XnZn / XpZp, a step of 0 means no split; a wall triangle gives the diagonal wall type (pi/4: first = XnZp, second = XpZn; 3pi/4: first = XpZp, second = XnZn) with the unknown corner copied. Flags: death, monkey, four climbs, beetle, triggerer. + +**Portals.** The room's portal polygons give direction (normal (0,0,1) = WallNegativeZ, (0,0,-1) = WallPositiveZ, (1,0,0) = WallNegativeX, (-1,0,0) = WallPositiveX, (0,1,0) = Ceiling, (0,-1,0) = Floor) and area (wall portals sit one unit inside their sector: index = v / 1024; the other axis spans [min, max) in sectors). Only the lower-indexed room adds the portal, AddObject creates the mirror. + +**Objects.** Local position = (x - roomX, -y - Position.Y, z - roomZ). Statics: slot, rotation Y, scale, colour, name. Moveables: slot, rotations X/Y and roll = -Z, OCB = trigger flags, flags = code bits << 9 | 0x80 clear body | 0x100 invisible, colour, name. AI objects are moveables (code bits = flags >> 1). Lights: type 0 sun, 1 point, 2 spot, 3 shadow, 4 fog bulb; ranges / 1024, spot direction y negated. Cameras: flags 1 locked, 2 glide out, speed = move timer. Flybys: the file stores a point one sector ahead instead of the rotation; speed / 655, roll = 65536 - roll. Sinks and sound sources have no room index: the room whose box contains them is taken. Sound source flags: 0x8000 always, 0x4000 base only, 0x2000 alternate only; the combinations Automatic writes for the room's flip state are read back as Automatic. + +**Triggers.** Floordata at the sector's TriggerIndex: `[type << 8][setup][actions]`, no function word; an action is `target type << 10 | parameter`; camera, flyby and event actions (target types 16 / 17) have an extra word; bit 15 of the last word ends the list. Type 12 = Monkey, 13 = TightRope, 14 = Crawl, 15 = Climb. Targets: item index, camera / sink index in the file's own lists, flyby sequence. In editor-made levels only the first (setup) action carries the sector's type, timer, one-shot and code bits; the other actions are plain Triggers with defaults. TR levels keep the legacy convention (all actions share the setup), which matches TombLib's own PRJ import and the reference prj2 of alexhub2 (51 of 51). + +**Results.** +- Sectors: palace 1151, venice 5681. Type, flags, floor and ceiling heights, diagonal split and (when the surface is not planar) split direction are identical on all of them; the only differences are 5 palace sectors that carry a ghost block in the source (the file holds the total height). Room properties, flip links and room geometry (name, position, size) are identical. +- Portals: palace 20 of 24, venice 160 of 172 identical; the others differ in area (the compiler leaves out sectors whose neighbour is a wall) and a few are not in the file. +- Objects: venice 231 / 231 statics, 149 / 149 moveables, 37 / 37 lights, 8 / 8 cameras, 13 / 13 flybys, 1 / 1 sound source identical; palace 49 / 49 statics, 4 of 5 lights (one shadow light with negative intensity is not in the file), 16 of 18 moveables (two doors are placed differently in the source than in the released level). +- Triggers: venice 108 of 109 (one auxiliary PlayAudio keeps a timer in the source that the file does not store), palace 10 of 14 (the same two doors, swapped between source and release; the indices are consistent inside the .ten). + +**Not done.** Face textures (atlases of the media block + polygon UVs), trigger volumes and event sets (neither level has any), UI wiring (`LevelSession` is built around `TrLevel`). Lost by design of the format: wall heights, ghost blocks, per-action timers of non-setup triggers, the exact area of portals next to walls, static OCB / flags, shadow lights with no intensity. A trigger Object action whose number is a static's script ID is indistinguishable from an item index. +### 2.43 -- TombEngine levels: face textures + +Code: `Core/TenTextures.cs`, `Core/TenWalls.cs`, atlases read by `TenLevel.ReadAtlases`. Verified against the Tomb Editor sources of palace and venice, comparing pixels. + +**Atlases.** The media block holds five texture groups (room, moveables, statics, animated, sprites), each `[count]{width, height, size, colour map, 3 x (flag, blob)}`; the colour map is PNG or DDS (BC1 / BC3). Room and animated atlases are kept; PNG ones are written as they are to `_textures\room00.png` ... and registered as level textures (with 512-wide pages not converted). DDS atlases are not decoded yet (warning, faces stay untextured). Animated buckets are used as a still image (first frame), the animation sequences are not exported. + +**Polygons to faces.** A room polygon stores its vertices and one UV per vertex (normalised, atlas relative). TombLib's own `RoomGeometry` of the rebuilt room says which sector face owns which vertices (`VertexRangeLookup`, vertex positions = file position with Y = -(y + Position.Y)), so a polygon is matched to its face by the set of its vertex positions and its UVs are written back as the face's `TextureArea` (UV x atlas size, pixel coordinates). Corner order, inverse of the compiler (`Rooms.cs`) and `UpdateFaceTexture`: quad TexCoord0..3 = vertices 3, 2, 0, 1 (ceiling: 5, 0, 2, 1); triangle = vertices 0, 1, 2 (ceiling: 2, 1, 0 with TexCoord3 = TexCoord2). Blend mode is the bucket's; a second polygon on the same vertices means double sided. Two faces back to back (both sides of a thin wall) have the same vertices; the polygon whose normal agrees with the face's winding belongs to it. A face the compiler cut into several polygons (a wall face with a sloped top becomes a quad plus a triangle) is rebuilt from the polygons lying inside it, taking each corner's UV from whichever polygon has a vertex there. + +**Wall heights.** The file keeps no heights for wall sectors, but TombLib builds the wall faces (QA / middle / WS) from them, so without them most wall polygons match nothing. At each end of a wall edge the polygon vertex heights are the open neighbour's floor / ceiling at that corner plus the wall's own floor / ceiling; whatever does not equal the neighbour's corner heights is the wall's. Corners are inferred one by one (sloped walls work), voted by the edges touching them, and a corner with no information takes its wall's most common value. With only one extra boundary the ceiling is assumed to coincide with the neighbour's. + +**Portals.** A portal whose plane carries textured polygons gets opacity TraversableFaces (also on the mirrored portal in the other room), because TombLib builds no faces under a portal otherwise. + +**Results (venice, palace).** Polygons that match no face: 13 of 8300 and 9 of 1931. Pixel comparison against the sources (six sample points per face): palace 1864 of 1890 faces textured on both sides are close; venice 7513 of 7925 close, 64 near, 348 different (ceiling quads 225 of 1218, floors 20 of 2918, walls 103 of 3525). Faces textured in the sources but with no polygon in the file are not an error (faces TombLib does not compile). + +**Open.** The different ceiling quads share geometry and vertex order with the good ones, and their atlas content is the transposed or flipped version of the source tile, so it looks like an orientation difference between the editor and the compiled level rather than a mapping bug (not proven). A wall face higher than one tile is stored as several polygons that each carry their own atlas region; the rebuilt face takes the corner UVs from different polygons and looks wrong in about 3% of walls. DDS atlases, animated texture sequences and materials (the bucket's material index) are not handled. ## Key structural lessons (apply to future work on this codebase) - **TombLib local source is authoritative.** For any format question, find what TombLib's *compiler* writes (PRJ2→TR4) and invert it, rather than guessing from TRosettaStone/trview alone (though both are good secondary checks, and TRosettaStone caught the real bump-mapping mechanism in 2.5). diff --git a/PRJ2 Extractor/Core/TenExporter.cs b/PRJ2 Extractor/Core/TenExporter.cs new file mode 100644 index 0000000..0ce5159 --- /dev/null +++ b/PRJ2 Extractor/Core/TenExporter.cs @@ -0,0 +1,228 @@ +using System.IO; +using System.Numerics; +using TombLib; +using TombLib.LevelData; +using TombLib.LevelData.IO; +using TombLib.LevelData.SectorEnums; +using TombLib.LevelData.SectorStructs; + +namespace PRJ2_Extractor.Core; + +/// Converts a compiled TombEngine level () into a PRJ2 project: rooms, sectors, portals and room flags. +public static class TenExporter +{ + public static Level BuildLevel(TenLevel ten, string prj2FilePath, List warnings, bool textures = true) + { + var level = new Level(); + level.Settings.GameVersion = TRVersion.Game.TombEngine; + level.Settings.LevelFilePath = prj2FilePath; + + var rooms = new Room[ten.Rooms.Count]; + for (int i = 0; i < ten.Rooms.Count; i++) + rooms[i] = BuildRoom(level, ten.Rooms[i], i, warnings); + // Rooms are placed in their compiled slot, which is also what flip rooms and portals refer to. + for (int i = 0; i < rooms.Length; i++) level.Rooms[i] = rooms[i]; + + // Flip rooms: a base room names its flipped room; the flip number is the alternate group. + for (int i = 0; i < ten.Rooms.Count; i++) + { + var t = ten.Rooms[i]; + if (t.FlippedRoom >= 0 && t.FlippedRoom < rooms.Length) + { + rooms[i].AlternateRoom = rooms[t.FlippedRoom]; + rooms[i].AlternateGroup = (short)t.FlipNumber; + rooms[t.FlippedRoom].AlternateGroup = (short)t.FlipNumber; + } + } + + AddPortals(level, ten, rooms, warnings); + var registry = new ObjectRegistry(); + TenObjects.Add(ten, rooms, registry, warnings); + // Triggers last: their targets are the objects added above. + TriggerExporter.ExportCore(rooms, ten.Rooms.Count, + i => (ten.Rooms[i].XSize, ten.Rooms[i].ZSize), + (i, x, z) => ten.ReadTrigger(ten.Rooms[i].Sectors[x * ten.Rooms[i].ZSize + z].TriggerIndex), + false, true, registry, warnings); + + if (textures) + { + string dir = Path.Combine(Path.GetDirectoryName(Path.GetFullPath(prj2FilePath)) ?? ".", Path.GetFileNameWithoutExtension(prj2FilePath) + "_textures"); + var roomTextures = TenTextures.RegisterAtlases(level, ten.RoomAtlases, dir, "room", warnings); + var animatedTextures = TenTextures.RegisterAtlases(level, ten.AnimatedAtlases, dir, "animated", warnings); + var stats = TenTextures.Apply(ten, rooms, roomTextures, animatedTextures, warnings); + warnings.Add($"Info: {stats.FacesTextured} of {stats.Faces} room faces textured ({stats.FacesWithoutPolygon} faces have no polygon, {stats.PolygonsUnmatched} of {stats.Polygons} polygons matched no face, {stats.MissingAtlas} faces lack an atlas)."); + } + return level; + } + + private static Room BuildRoom(Level level, TenRoom t, int index, List warnings) + { + string name = string.IsNullOrWhiteSpace(t.Name) ? $"Room{index}" : t.Name; + // The file stores half of the editor's ambient value. + var room = new Room(level, t.XSize, t.ZSize, t.Ambient * 2, name); + // World X/Z are in sector units, Y in world units. BottomHeight is only the lowest corner, not Position.Y; the exact + // Position.Y is the negated plane height of any complete wall (every room has some: the border). + int posY = -t.BottomHeight; + var wall = t.Sectors.FirstOrDefault(s => TenSectors.IsFullWall(s)); + if (wall != null) posY = -(int)Math.Round(wall.FloorPlane[0].Z); + room.Position = new VectorInt3(t.X / 1024, posY, t.Z / 1024); + foreach (var tag in t.Tags) room.Properties.Tags.Add(tag); + + // Room flags as written by TombLib's TombEngine compiler (Compilers/TombEngine/Rooms.cs). + var props = room.Properties; + if ((t.Flags & 0x0001) != 0) props.Type = RoomType.Water; + else if ((t.Flags & 0x0004) != 0) props.Type = RoomType.Quicksand; + props.FlagHorizon = (t.Flags & 0x0008) != 0; + props.FlagOutside = (t.Flags & 0x0020) != 0; + props.FlagNoLensflare = (t.Flags & 0x0080) != 0; + props.FlagDamage = (t.Flags & 0x0800) != 0; + props.FlagCold = (t.Flags & 0x1000) != 0; + + for (int x = 0; x < t.XSize; x++) + for (int z = 0; z < t.ZSize; z++) + ApplySector(room, t, x, z, warnings, index); + return room; + } + + private static void ApplySector(Room room, TenRoom t, int x, int z, List warnings, int roomIndex) + { + var ts = t.Sectors[x * t.ZSize + z]; + var sector = room.Sectors[x, z]; + int roomY = room.Position.Y; + bool border = x == 0 || z == 0 || x == t.XSize - 1 || z == t.ZSize - 1; + + if (ts.Death) sector.Flags |= SectorFlags.DeathFire; + if (ts.Monkeyswing) sector.Flags |= SectorFlags.Monkey; + if (ts.ClimbNorth) sector.Flags |= SectorFlags.ClimbPositiveZ; + if (ts.ClimbSouth) sector.Flags |= SectorFlags.ClimbNegativeZ; + if (ts.ClimbEast) sector.Flags |= SectorFlags.ClimbPositiveX; + if (ts.ClimbWest) sector.Flags |= SectorFlags.ClimbNegativeX; + if (ts.MarkBeetle) sector.Flags |= SectorFlags.Beetle; + if (ts.MarkTriggerer) sector.Flags |= SectorFlags.TriggerTriggerer; + + if (TenSectors.IsFullWall(ts)) + { + sector.Type = border ? SectorType.BorderWall : SectorType.Wall; + return; + } + + var floor = TenSectors.Decode(ts.FloorSplitAngle, ts.FloorPlane, roomY); + var ceiling = TenSectors.Decode(ts.CeilingSplitAngle, ts.CeilingPlane, roomY); + if (!floor.Exact || !ceiling.Exact) + warnings.Add($"Room {roomIndex} sector ({x},{z}): the {(floor.Exact ? "ceiling" : "floor")} shape is not reproducible with a TombLib surface"); + + bool wallTriangle = floor.WallFirst || floor.WallSecond || ceiling.WallFirst || ceiling.WallSecond; + sector.Type = wallTriangle ? SectorType.Wall : SectorType.Floor; + + sector.Floor.XnZn = (short)floor.XnZn; sector.Floor.XpZn = (short)floor.XpZn; + sector.Floor.XnZp = (short)floor.XnZp; sector.Floor.XpZp = (short)floor.XpZp; + sector.Ceiling.XnZn = (short)ceiling.XnZn; sector.Ceiling.XpZn = (short)ceiling.XpZn; + sector.Ceiling.XnZp = (short)ceiling.XnZp; sector.Ceiling.XpZp = (short)ceiling.XpZp; + sector.Floor.DiagonalSplit = wallTriangle && !floor.WallFirst && !floor.WallSecond ? ceiling.Split : floor.Split; + sector.Ceiling.DiagonalSplit = wallTriangle && !ceiling.WallFirst && !ceiling.WallSecond ? floor.Split : ceiling.Split; + sector.Floor.SplitDirectionIsXEqualsZ = floor.SplitDirectionIsXEqualsZ; + sector.Ceiling.SplitDirectionIsXEqualsZ = ceiling.SplitDirectionIsXEqualsZ; + } + + private static void AddPortals(Level level, TenLevel ten, Room[] rooms, List warnings) + { + for (int i = 0; i < ten.Rooms.Count; i++) + foreach (var portal in ten.Rooms[i].Portals) + { + int target = portal.RoomNumber; + // AddObject also creates the mirrored portal in the adjoining room, so only the lower-indexed room adds it. + if (target < 0 || target >= rooms.Length || target < i) continue; + + PortalDirection direction; + if (portal.NormalY == 1) direction = PortalDirection.Ceiling; + else if (portal.NormalY == -1) direction = PortalDirection.Floor; + else if (portal.NormalZ == 1) direction = PortalDirection.WallNegativeZ; + else if (portal.NormalZ == -1) direction = PortalDirection.WallPositiveZ; + else if (portal.NormalX == 1) direction = PortalDirection.WallNegativeX; + else if (portal.NormalX == -1) direction = PortalDirection.WallPositiveX; + else { warnings.Add($"Room {i}: portal to room {target} has an unsupported normal"); continue; } + + int minX = int.MaxValue, maxX = int.MinValue, minZ = int.MaxValue, maxZ = int.MinValue; + for (int v = 0; v < 4; v++) + { + int vx = portal.Vertices[v * 3], vz = portal.Vertices[v * 3 + 2]; + minX = Math.Min(minX, vx); maxX = Math.Max(maxX, vx); + minZ = Math.Min(minZ, vz); maxZ = Math.Max(maxZ, vz); + } + + // Wall portals sit one unit inside the sector that holds them (x or z = 1023 / 13312 ...); the other axis spans [min, max). + int x0, x1, z0, z1; + switch (direction) + { + case PortalDirection.WallNegativeX: case PortalDirection.WallPositiveX: + x0 = x1 = minX / 1024; z0 = minZ / 1024; z1 = maxZ / 1024 - 1; break; + case PortalDirection.WallNegativeZ: case PortalDirection.WallPositiveZ: + z0 = z1 = minZ / 1024; x0 = minX / 1024; x1 = maxX / 1024 - 1; break; + default: + x0 = minX / 1024; x1 = maxX / 1024 - 1; z0 = minZ / 1024; z1 = maxZ / 1024 - 1; break; + } + + // A portal whose plane carries textured polygons is a portal with visible faces (traversable); the file has no flag for it. + var instance = new PortalInstance(new RectangleInt2(x0, z0, x1, z1), direction, rooms[target]); + if (HasPolygonsOn(ten.Rooms[i], portal)) instance.Opacity = PortalOpacity.TraversableFaces; + try + { + rooms[i].AddObject(level, instance); + // AddObject also made the mirrored portal in the adjoining room; its own side may carry textured faces too. + var opposite = Opposite(direction); + var mirror = rooms[target].Portals.FirstOrDefault(m => m.AdjoiningRoom == rooms[i] && m.Direction == opposite && m.Opacity == PortalOpacity.None); + var mirrorSource = ten.Rooms[target].Portals.FirstOrDefault(m => m.RoomNumber == i && Normal(m) == opposite); + if (mirror != null && mirrorSource != null && HasPolygonsOn(ten.Rooms[target], mirrorSource)) mirror.Opacity = PortalOpacity.TraversableFaces; + } + catch (Exception ex) { warnings.Add($"Room {i}: portal to room {target} [{direction}] ({x0},{z0})-({x1},{z1}) skipped: {ex.Message}"); } + } + } + + private static PortalDirection Opposite(PortalDirection d) => d switch + { + PortalDirection.Floor => PortalDirection.Ceiling, + PortalDirection.Ceiling => PortalDirection.Floor, + PortalDirection.WallNegativeX => PortalDirection.WallPositiveX, + PortalDirection.WallPositiveX => PortalDirection.WallNegativeX, + PortalDirection.WallNegativeZ => PortalDirection.WallPositiveZ, + _ => PortalDirection.WallNegativeZ, + }; + + private static PortalDirection? Normal(TenPortal p) + { + if (p.NormalY == 1) return PortalDirection.Ceiling; + if (p.NormalY == -1) return PortalDirection.Floor; + if (p.NormalZ == 1) return PortalDirection.WallNegativeZ; + if (p.NormalZ == -1) return PortalDirection.WallPositiveZ; + if (p.NormalX == 1) return PortalDirection.WallNegativeX; + if (p.NormalX == -1) return PortalDirection.WallPositiveX; + return null; + } + /// True when the room has a polygon lying on the portal's plane inside its extent (portal vertices sit one unit off the face). + private static bool HasPolygonsOn(TenRoom room, TenPortal portal) + { + var verts = Enumerable.Range(0, 4).Select(v => new Vector3(portal.Vertices[v * 3], portal.Vertices[v * 3 + 1], portal.Vertices[v * 3 + 2])).ToArray(); + var normal = Vector3.Normalize(new Vector3(portal.NormalX, portal.NormalY, portal.NormalZ)); + var min = new Vector3(verts.Min(v => v.X), verts.Min(v => v.Y), verts.Min(v => v.Z)) - Vector3.One * 3; + var max = new Vector3(verts.Max(v => v.X), verts.Max(v => v.Y), verts.Max(v => v.Z)) + Vector3.One * 3; + foreach (var bucket in room.Buckets) + foreach (var poly in bucket.Polygons) + { + bool onPlane = true; + foreach (int idx in poly.Indices) + { + var p = room.Positions[idx]; + if (Math.Abs(Vector3.Dot(normal, p - verts[0])) > 3 || p.X < min.X || p.X > max.X || p.Y < min.Y || p.Y > max.Y || p.Z < min.Z || p.Z > max.Z) { onPlane = false; break; } + } + if (onPlane) return true; + } + return false; + } + public static List Export(TenLevel ten, string prj2FilePath) + { + var warnings = new List(); + var level = BuildLevel(ten, prj2FilePath, warnings); + Prj2Writer.SaveToPrj2(prj2FilePath, level); + return warnings; + } +} diff --git a/PRJ2 Extractor/Core/TenLevel.cs b/PRJ2 Extractor/Core/TenLevel.cs new file mode 100644 index 0000000..87fd2be --- /dev/null +++ b/PRJ2 Extractor/Core/TenLevel.cs @@ -0,0 +1,560 @@ +using System.IO; +using System.Numerics; +using K4os.Compression.LZ4; + +namespace PRJ2_Extractor.Core; + +// ------------------------------------------------------------------------------------------ +// Reader for compiled TombEngine levels (.ten). Layout follows TombEngine's Specific/level.cpp +// (LoadLevel): 16-byte header ("TEN", version[4], system hash, level hash) and three blocks +// (media, geometry, dynamic data), each [int64 uncompressed][int64 compressed][LZ4 chunks]. +// Strings are LEB128-prefixed (BinaryReader.ReadString compatible). +// ------------------------------------------------------------------------------------------ + +public sealed class TenPolygon +{ + public int Shape; // 0 = quad, otherwise triangle + public int AnimatedSequence, AnimatedFrame; + public Vector3 Normal; + public int[] Indices = []; + public Vector2[] Uv = []; +} + +public sealed class TenBucket +{ + public int Texture; + public byte BlendMode; + public int Material; + public bool Animated; + public List Polygons = []; +} + +public sealed class TenPortal +{ + public short RoomNumber; + public int NormalX, NormalY, NormalZ; + public int[] Vertices = new int[12]; // 4 corners x (x, y, z) +} + +/// Sector data as stored by the compiler. Planes are "fake planes": x, y = X / Z tilt per block, z = height at the sector centre. +public sealed class TenSector +{ + public int TriggerIndex, PathfindingBoxId, Material; + public bool Stopper; + public float FloorSplitAngle, CeilingSplitAngle; + public int[] FloorPortal = new int[2], CeilingPortal = new int[2]; + public Vector3[] FloorPlane = new Vector3[2], CeilingPlane = new Vector3[2]; + public int SidePortal; + public bool Death, Monkeyswing, ClimbNorth, ClimbSouth, ClimbEast, ClimbWest, MarkTriggerer, MarkBeetle; +} + +public sealed class TenLight +{ + public int X, Y, Z; + public float Dx, Dy, Dz, R, G, B, Intensity, In, Out, Length, Cutoff; + public byte Type; + public bool CastShadows; +} + +public sealed class TenStatic +{ + public int X, Y, Z; + public ushort RotY, RotX, RotZ; + public float Scale; + public ushort Flags; + public Vector4 Color; + public ushort Slot; + public short HitPoints; + public string Name = ""; +} + +public sealed class TenVolume +{ + public int Type; + public Vector3 Position, Scale; + public Vector4 Orientation; + public bool Enabled, DetectInAdjacentRooms; + public string Name = ""; + public int EventSetIndex; +} + +public sealed class TenRoom +{ + // geometry block + public int X, Z, BottomHeight, TopHeight; + public Vector3[] Positions = [], Colors = [], Effects = []; + public List Buckets = []; + public List Portals = []; + public int XSize, ZSize; + public TenSector[] Sectors = []; // index = x * ZSize + z + public List Lights = []; + // dynamic block + public string Name = ""; + public List Tags = []; + public Vector3 Ambient; + public int FlippedRoom = -1, Flags, MeshEffect, ReverbType, FlipNumber; + public List Statics = []; + public List Volumes = []; +} + +public sealed class TenItem +{ + public short ObjectNumber, Room; + public int X, Y, Z; + public short RotY, RotX, RotZ; + public Vector4 Color; + public short TriggerFlags, Flags; + public string Name = ""; +} + +public sealed class TenAiObject +{ + public short ObjectNumber, Room; + public int X, Y, Z; + public short RotY, RotX, RotZ, TriggerFlags, Flags; + public int BoxNumber; + public string Name = ""; +} + +public sealed class TenCamera +{ + public int X, Y, Z, Room, Flags, Speed; + public string Name = ""; +} + +public sealed class TenFlybyCamera +{ + public int X, Y, Z, TargetX, TargetY, TargetZ, Sequence, Camera; + public short Fov, Roll, Timer, Speed, Flags; + public int Room, DofMode; + public float DofDistance, DofRange, DofStrength; +} + +public sealed class TenSink +{ + public int X, Y, Z, Strength, BoxIndex; + public string Name = ""; +} + +public sealed class TenSoundSource +{ + public int X, Y, Z, SoundId, Flags; + public string Name = ""; +} + +public sealed class TenEvent +{ + public int Type, Mode, CallCounter; + public string Function = "", Data = ""; + public bool Enabled; +} + +public sealed class TenEventSet +{ + public string Name = ""; + public int Activators; // volume event sets only + public List Events = []; +} + +/// A room texture atlas as stored in the media block: PNG or DDS (BC1 / BC3) bytes of the colour map. +public sealed class TenAtlas +{ + public int Width, Height; + public byte[] ColorMap = []; +} + +public sealed class TenLevel +{ + public byte[] Version = new byte[4]; + public int SystemHash, LevelHash; + + public List Rooms = []; + public short[] FloorData = []; + public List Items = []; + public List AiObjects = []; + public List Cameras = []; + public List FlybyCameras = []; + public List Sinks = []; + public List SoundSources = []; + public List GlobalEventSets = []; + public List VolumeEventSets = []; + public string Properties = ""; + + /// Raw (decompressed) media block: texture atlases and samples. Not parsed yet. + public byte[] MediaBlock = []; + + /// Room texture atlases (the first group of the media block); polygon UVs of rooms are relative to these. + public List RoomAtlases = []; + /// Animated texture atlases; buckets flagged as animated refer to these. + public List AnimatedAtlases = []; + + public string VersionText => $"{Version[0]}.{Version[1]}.{Version[2]}"; + + public static bool IsTenFile(string path) + { + try + { + using var fs = File.OpenRead(path); + var h = new byte[4]; + return fs.Read(h, 0, 4) == 4 && h[0] == (byte)'T' && h[1] == (byte)'E' && h[2] == (byte)'N'; + } + catch (IOException) { return false; } + } + + /// Loads a .ten level. Returns null on success or an error message. + public string? Load(string path, IProgress? progress = null) + { + try + { + using var fs = File.OpenRead(path); + using var br = new BinaryReader(fs); + var header = br.ReadBytes(4); + if (header.Length < 4 || header[0] != 'T' || header[1] != 'E' || header[2] != 'N') + return "Not a TombEngine level (header is not TEN)."; + Version = br.ReadBytes(4); + SystemHash = br.ReadInt32(); + LevelHash = br.ReadInt32(); + + progress?.Report(5); + MediaBlock = ReadBlock(br); + using (var media = new BinaryReader(new MemoryStream(MediaBlock))) + ReadAtlases(media); + progress?.Report(30); + + using (var geometry = new BinaryReader(new MemoryStream(ReadBlock(br)))) + ReadGeometry(geometry); + progress?.Report(70); + + using (var dynamic = new BinaryReader(new MemoryStream(ReadBlock(br)))) + ReadDynamic(dynamic); + progress?.Report(100); + return null; + } + catch (Exception ex) when (ex is IOException or EndOfStreamException or InvalidDataException or ArgumentException) + { + return ex.Message; + } + } + + // [int64 uncompressed][int64 compressed][numChunks u32 { [u32 uncompressed][u32 compressed][LZ4 block] }] + private static byte[] ReadBlock(BinaryReader br) + { + long uncompressed = br.ReadInt64(), compressed = br.ReadInt64(); + if (uncompressed <= 0 || compressed <= 0 || compressed > br.BaseStream.Length - br.BaseStream.Position) + throw new InvalidDataException("Data block size is incorrect (older level version?)."); + if (uncompressed > int.MaxValue) throw new InvalidDataException("Level data block exceeds 2 GB."); + + var region = br.ReadBytes((int)compressed); + var result = new byte[uncompressed]; + int pos = 0, dest = 0; + uint chunks = BitConverter.ToUInt32(region, pos); pos += 4; + for (uint i = 0; i < chunks; i++) + { + int chunkUncompressed = (int)BitConverter.ToUInt32(region, pos); pos += 4; + int chunkCompressed = (int)BitConverter.ToUInt32(region, pos); pos += 4; + int n = LZ4Codec.Decode(region, pos, chunkCompressed, result, dest, chunkUncompressed); + if (n != chunkUncompressed) throw new InvalidDataException("LZ4 decompression failed."); + pos += chunkCompressed; + dest += chunkUncompressed; + } + if (dest != uncompressed) throw new InvalidDataException("Decompressed size mismatch."); + return result; + } + + private static int Count(BinaryReader r, int max = 1024 * 1024) + { + int n = r.ReadInt32(); + if (n < 0 || n > max) throw new InvalidDataException("Level data block has incorrect size (older level version?)."); + return n; + } + + private static Vector2 V2(BinaryReader r) => new(r.ReadSingle(), r.ReadSingle()); + private static Vector3 V3(BinaryReader r) => new(r.ReadSingle(), r.ReadSingle(), r.ReadSingle()); + private static Vector4 V4(BinaryReader r) => new(r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), r.ReadSingle()); + + /// Mirrors LoadRooms(): static room data (LoadStaticRoomData) followed by the floordata. The rest of the block (moveable models, sprites, boxes, ...) is not needed. + private void ReadGeometry(BinaryReader r) + { + int roomCount = Count(r); + Rooms = new List(roomCount); + for (int i = 0; i < roomCount; i++) + { + var room = new TenRoom { X = r.ReadInt32() }; + room.Z = r.ReadInt32(); + room.BottomHeight = r.ReadInt32(); + room.TopHeight = r.ReadInt32(); + + int vertexCount = Count(r, 1 << 30); + room.Positions = new Vector3[vertexCount]; + room.Colors = new Vector3[vertexCount]; + room.Effects = new Vector3[vertexCount]; + for (int j = 0; j < vertexCount; j++) room.Positions[j] = V3(r); + for (int j = 0; j < vertexCount; j++) room.Colors[j] = V3(r); + for (int j = 0; j < vertexCount; j++) room.Effects[j] = V3(r); + + int bucketCount = Count(r); + for (int b = 0; b < bucketCount; b++) + { + var bucket = new TenBucket { Texture = r.ReadInt32(), BlendMode = r.ReadByte(), Material = r.ReadInt32(), Animated = r.ReadBoolean() }; + int polyCount = Count(r, 1 << 30); + bucket.Polygons.Capacity = polyCount; + for (int k = 0; k < polyCount; k++) + { + var poly = new TenPolygon { Shape = r.ReadInt32(), AnimatedSequence = r.ReadInt32(), AnimatedFrame = r.ReadInt32(), Normal = V3(r) }; + int n = poly.Shape == 0 ? 4 : 3; + poly.Indices = new int[n]; + poly.Uv = new Vector2[n]; + for (int l = 0; l < n; l++) poly.Indices[l] = r.ReadInt32(); + for (int l = 0; l < n; l++) poly.Uv[l] = V2(r); + r.BaseStream.Seek(3L * n * 12, SeekOrigin.Current); // normals, tangents, binormals + bucket.Polygons.Add(poly); + } + room.Buckets.Add(bucket); + } + + int portalCount = Count(r); + for (int j = 0; j < portalCount; j++) + { + var portal = new TenPortal { RoomNumber = r.ReadInt16(), NormalX = r.ReadInt32(), NormalY = r.ReadInt32(), NormalZ = r.ReadInt32() }; + for (int v = 0; v < 12; v++) portal.Vertices[v] = r.ReadInt32(); + room.Portals.Add(portal); + } + + room.ZSize = r.ReadInt32(); + room.XSize = r.ReadInt32(); + if (room.XSize < 0 || room.ZSize < 0 || (long)room.XSize * room.ZSize > 1 << 20) + throw new InvalidDataException("Room size is incorrect."); + room.Sectors = new TenSector[room.XSize * room.ZSize]; + for (int s = 0; s < room.Sectors.Length; s++) + { + var sec = new TenSector { TriggerIndex = r.ReadInt32(), PathfindingBoxId = r.ReadInt32(), Material = r.ReadInt32(), Stopper = r.ReadInt32() != 0 }; + sec.FloorSplitAngle = r.ReadSingle(); + sec.FloorPortal[0] = r.ReadInt32(); sec.FloorPortal[1] = r.ReadInt32(); + sec.FloorPlane[0] = V3(r); sec.FloorPlane[1] = V3(r); + sec.CeilingSplitAngle = r.ReadSingle(); + sec.CeilingPortal[0] = r.ReadInt32(); sec.CeilingPortal[1] = r.ReadInt32(); + sec.CeilingPlane[0] = V3(r); sec.CeilingPlane[1] = V3(r); + sec.SidePortal = r.ReadInt32(); + sec.Death = r.ReadBoolean(); sec.Monkeyswing = r.ReadBoolean(); + sec.ClimbNorth = r.ReadBoolean(); sec.ClimbSouth = r.ReadBoolean(); sec.ClimbEast = r.ReadBoolean(); sec.ClimbWest = r.ReadBoolean(); + sec.MarkTriggerer = r.ReadBoolean(); sec.MarkBeetle = r.ReadBoolean(); + room.Sectors[s] = sec; + } + + int lightCount = Count(r); + for (int j = 0; j < lightCount; j++) + { + var l = new TenLight { X = r.ReadInt32(), Y = r.ReadInt32(), Z = r.ReadInt32() }; + l.Dx = r.ReadSingle(); l.Dy = r.ReadSingle(); l.Dz = r.ReadSingle(); + l.R = r.ReadSingle(); l.G = r.ReadSingle(); l.B = r.ReadSingle(); + l.Intensity = r.ReadSingle(); l.In = r.ReadSingle(); l.Out = r.ReadSingle(); l.Length = r.ReadSingle(); l.Cutoff = r.ReadSingle(); + l.Type = r.ReadByte(); l.CastShadows = r.ReadBoolean(); + room.Lights.Add(l); + } + Rooms.Add(room); + } + + int floorDataCount = r.ReadInt32(); + if (floorDataCount < 0 || floorDataCount * 2L > r.BaseStream.Length - r.BaseStream.Position) + throw new InvalidDataException("Floordata size is incorrect."); + FloorData = new short[floorDataCount]; + for (int i = 0; i < floorDataCount; i++) FloorData[i] = r.ReadInt16(); + } + + /// + /// Reads the trigger a sector points to (TriggerIndex into the floordata). Unlike TR floordata there is no function word: + /// [trigger type << 8][setup: timer, 0x100 one-shot, code bits << 9][actions...]. An action is [target type << 10 | parameter]; + /// camera, flyby and event actions are followed by an extra word (timer / one-shot); bit 15 of the last word ends the list. + /// + public Models.TrTrigger? ReadTrigger(int index) + { + if (index < 0 || index + 2 >= FloorData.Length) return null; + var trigger = new Models.TrTrigger { SubFunction = (ushort)FloorData[index] >> 8, Setup = (ushort)FloorData[index + 1] }; + int i = index + 2; + ushort last; + do + { + if (i >= FloorData.Length) break; + last = (ushort)FloorData[i++]; + int type = (last >> 10) & 0x1F; + var action = new Models.TrTriggerAction { Type = type, Param = (ushort)(last & 0x03FF) }; + if (type is 1 or 12 or 16 or 17) + { + if (i >= FloorData.Length) break; + last = (ushort)FloorData[i++]; + action.HasExtra = true; + action.Extra = last; + } + trigger.Actions.Add(action); + } while ((last & 0x8000) == 0); + if (trigger.Actions.Count > 0 && trigger.Actions[^1].HasExtra) trigger.Actions[^1].Extra &= 0x7FFF; + return trigger.Actions.Count > 0 ? trigger : null; + } + /// + /// Mirrors LoadTextures(): five groups (room, moveables, statics, animated, sprites) of [count]{ width, height, size, colour map, then + /// flag + blob for normal, ORSH and emissive maps }, followed by the sky. Room and animated atlases are kept; polygon UVs of rooms are + /// relative to them. + /// + private void ReadAtlases(BinaryReader r) + { + RoomAtlases = ReadAtlasGroup(r, true); + ReadAtlasGroup(r, false); // moveables + ReadAtlasGroup(r, false); // statics + AnimatedAtlases = ReadAtlasGroup(r, true); + } + + private static List ReadAtlasGroup(BinaryReader r, bool keep) + { + int count = Count(r, 4096); + var list = new List(count); + for (int i = 0; i < count; i++) + { + var atlas = new TenAtlas { Width = r.ReadInt32(), Height = r.ReadInt32() }; + int size = r.ReadInt32(); + if (size < 0 || size > r.BaseStream.Length - r.BaseStream.Position) throw new InvalidDataException("Texture size is incorrect."); + if (keep) atlas.ColorMap = r.ReadBytes(size); else r.BaseStream.Seek(size, SeekOrigin.Current); + for (int map = 0; map < 3; map++) // normal, ORSH, emissive + { + if (!r.ReadBoolean()) continue; + int mapSize = r.ReadInt32(); + if (mapSize < 0 || mapSize > r.BaseStream.Length - r.BaseStream.Position) throw new InvalidDataException("Texture map size is incorrect."); + r.BaseStream.Seek(mapSize, SeekOrigin.Current); + } + if (keep) list.Add(atlas); + } + return list; + } + private static TenEvent ReadEvent(BinaryReader r) => new() + { + Type = r.ReadInt32(), + Mode = r.ReadInt32(), + Function = r.ReadString(), + Data = r.ReadString(), + CallCounter = r.ReadInt32(), + Enabled = r.ReadBoolean(), + }; + + /// Mirrors LoadDynamicRoomData, LoadItems, LoadAIObjects, LoadCameras, LoadSoundSources, LoadMaterials, LoadEventSets and LoadProperties. + private void ReadDynamic(BinaryReader r) + { + int roomCount = Count(r); + if (roomCount != Rooms.Count) throw new InvalidDataException("Dynamic room data count is inconsistent with room count."); + foreach (var room in Rooms) + { + room.Name = r.ReadString(); + int tags = Count(r); + for (int j = 0; j < tags; j++) room.Tags.Add(r.ReadString()); + room.Ambient = V3(r); + room.FlippedRoom = r.ReadInt32(); + room.Flags = r.ReadInt32(); + room.MeshEffect = r.ReadInt32(); + room.ReverbType = r.ReadInt32(); + room.FlipNumber = r.ReadInt32(); + + int statics = Count(r); + for (int j = 0; j < statics; j++) + { + var s = new TenStatic { X = r.ReadInt32(), Y = r.ReadInt32(), Z = r.ReadInt32() }; + s.RotY = r.ReadUInt16(); s.RotX = r.ReadUInt16(); s.RotZ = r.ReadUInt16(); + s.Scale = r.ReadSingle(); + s.Flags = r.ReadUInt16(); + s.Color = V4(r); + s.Slot = r.ReadUInt16(); + s.HitPoints = r.ReadInt16(); + s.Name = r.ReadString(); + room.Statics.Add(s); + } + + int volumes = Count(r); + for (int j = 0; j < volumes; j++) + { + var v = new TenVolume { Type = r.ReadInt32() }; + v.Position = V3(r); v.Orientation = V4(r); v.Scale = V3(r); + v.Enabled = r.ReadBoolean(); v.DetectInAdjacentRooms = r.ReadBoolean(); + v.Name = r.ReadString(); + v.EventSetIndex = r.ReadInt32(); + room.Volumes.Add(v); + } + } + + int items = Count(r); + for (int i = 0; i < items; i++) + { + var it = new TenItem { ObjectNumber = r.ReadInt16(), Room = r.ReadInt16(), X = r.ReadInt32(), Y = r.ReadInt32(), Z = r.ReadInt32() }; + it.RotY = r.ReadInt16(); it.RotX = r.ReadInt16(); it.RotZ = r.ReadInt16(); + it.Color = V4(r); + it.TriggerFlags = r.ReadInt16(); it.Flags = r.ReadInt16(); + it.Name = r.ReadString(); + Items.Add(it); + } + + int ai = Count(r); + for (int i = 0; i < ai; i++) + { + var o = new TenAiObject { ObjectNumber = r.ReadInt16(), Room = r.ReadInt16(), X = r.ReadInt32(), Y = r.ReadInt32(), Z = r.ReadInt32() }; + o.RotY = r.ReadInt16(); o.RotX = r.ReadInt16(); o.RotZ = r.ReadInt16(); + o.TriggerFlags = r.ReadInt16(); o.Flags = r.ReadInt16(); + o.BoxNumber = r.ReadInt32(); + o.Name = r.ReadString(); + AiObjects.Add(o); + } + + int cameras = Count(r); + for (int i = 0; i < cameras; i++) + Cameras.Add(new TenCamera { X = r.ReadInt32(), Y = r.ReadInt32(), Z = r.ReadInt32(), Room = r.ReadInt32(), Flags = r.ReadInt32(), Speed = r.ReadInt32(), Name = r.ReadString() }); + + int flybys = Count(r); + for (int i = 0; i < flybys; i++) + { + var f = new TenFlybyCamera { X = r.ReadInt32(), Y = r.ReadInt32(), Z = r.ReadInt32(), TargetX = r.ReadInt32(), TargetY = r.ReadInt32(), TargetZ = r.ReadInt32() }; + f.Sequence = r.ReadInt32(); f.Camera = r.ReadInt32(); + f.Fov = r.ReadInt16(); f.Roll = r.ReadInt16(); f.Timer = r.ReadInt16(); f.Speed = r.ReadInt16(); f.Flags = r.ReadInt16(); + f.Room = r.ReadInt32(); + f.DofMode = r.ReadInt32(); f.DofDistance = r.ReadSingle(); f.DofRange = r.ReadSingle(); f.DofStrength = r.ReadSingle(); + FlybyCameras.Add(f); + } + + int sinks = Count(r); + for (int i = 0; i < sinks; i++) + Sinks.Add(new TenSink { X = r.ReadInt32(), Y = r.ReadInt32(), Z = r.ReadInt32(), Strength = r.ReadInt32(), BoxIndex = r.ReadInt32(), Name = r.ReadString() }); + + int sounds = Count(r); + for (int i = 0; i < sounds; i++) + SoundSources.Add(new TenSoundSource { X = r.ReadInt32(), Y = r.ReadInt32(), Z = r.ReadInt32(), SoundId = r.ReadInt32(), Flags = r.ReadInt32(), Name = r.ReadString() }); + + int materials = Count(r); + for (int i = 0; i < materials; i++) + { + r.ReadString(); // name + r.ReadInt32(); // type + r.BaseStream.Seek(6, SeekOrigin.Current); // normal, height, AO, roughness, specular, emissive map flags + } + + int eventSets = Count(r); + if (eventSets != 0) + { + int globals = Count(r); + for (int i = 0; i < globals; i++) + { + var set = new TenEventSet { Name = r.ReadString() }; + int n = Count(r); + for (int j = 0; j < n; j++) set.Events.Add(ReadEvent(r)); + GlobalEventSets.Add(set); + } + int volumeSets = Count(r); + for (int i = 0; i < volumeSets; i++) + { + var set = new TenEventSet { Name = r.ReadString(), Activators = r.ReadInt32() }; + int n = Count(r); + for (int j = 0; j < n; j++) set.Events.Add(ReadEvent(r)); + VolumeEventSets.Add(set); + } + } + + int props = Count(r); + Properties = props > 0 ? r.ReadString() : ""; + + if (r.BaseStream.Position != r.BaseStream.Length) + throw new InvalidDataException($"Dynamic data block was not fully consumed ({r.BaseStream.Length - r.BaseStream.Position} bytes left)."); + } +} diff --git a/PRJ2 Extractor/Core/TenObjects.cs b/PRJ2 Extractor/Core/TenObjects.cs new file mode 100644 index 0000000..3d75639 --- /dev/null +++ b/PRJ2 Extractor/Core/TenObjects.cs @@ -0,0 +1,195 @@ +using System.Numerics; +using TombLib.LevelData; +using TombLib.Wad; + +namespace PRJ2_Extractor.Core; + +/// +/// Moveables, AI objects, statics, lights, cameras, flybys, sinks and sound sources of a .ten level as TombLib instances (inverse of +/// TombLib's Compilers/TombEngine/LevelCompilerTombEngine.cs and Rooms.cs). The WAD is not extracted: instances only carry their slot ID. +/// +internal static class TenObjects +{ + // The compiled file stores half of the editor's colour values (ambient, statics, moveables, lights). + private static Vector3 Color(Vector4 c) => new(c.X * 2, c.Y * 2, c.Z * 2); + private static float Degrees(ushort angle) => angle * (360.0f / 65536.0f); + private static float Degrees(short angle) => Degrees(unchecked((ushort)angle)); + + private static Room? RoomOf(Room[] rooms, int index, string what, List warnings) + { + if (index < 0 || index >= rooms.Length) { warnings.Add($"{what} skipped: invalid room {index}"); return null; } + return rooms[index]; + } + + /// Local position inside the room: world X/Z minus the room origin, engine Y (down) negated minus the room's Position.Y. + private static Vector3 Local(Room room, int x, int y, int z) + => new(x - room.Position.X * 1024, -y - room.Position.Y, z - room.Position.Z * 1024); + + public static void Add(TenLevel ten, Room[] rooms, ObjectRegistry registry, List warnings) + { + for (int i = 0; i < ten.Rooms.Count; i++) + { + var t = ten.Rooms[i]; + var room = rooms[i]; + + foreach (var s in t.Statics) + { + var instance = new StaticInstance + { + WadObjectId = new WadStaticId(s.Slot), + Position = Local(room, s.X, s.Y, s.Z), + RotationY = Degrees(s.RotY), + Scale = s.Scale, + Color = Color(s.Color), + LuaName = s.Name, + }; + room.AddObject(room.Level, instance); + } + + foreach (var l in t.Lights) + { + var light = new LightInstance(l.Type switch { 0 => LightType.Sun, 2 => LightType.Spot, 3 => LightType.Shadow, 4 => LightType.FogBulb, _ => LightType.Point }) + { + Position = Local(room, l.X, l.Y, l.Z), + Color = new Vector3(l.R, l.G, l.B) * 2, + Intensity = l.Intensity, + CastDynamicShadows = l.CastShadows, + }; + switch (light.Type) + { + case LightType.Point: case LightType.Shadow: case LightType.FogBulb: + light.InnerRange = l.In / 1024.0f; light.OuterRange = l.Out / 1024.0f; break; + case LightType.Spot: + light.InnerAngle = l.In; light.OuterAngle = l.Out; + light.InnerRange = l.Length / 1024.0f; light.OuterRange = l.Cutoff / 1024.0f; + SetDirection(light, new Vector3(l.Dx, -l.Dy, l.Dz)); break; + case LightType.Sun: + SetDirection(light, new Vector3(l.Dx, -l.Dy, l.Dz)); break; + } + room.AddObject(room.Level, light); + } + } + + foreach (var it in ten.Items) + { + var room = RoomOf(rooms, it.Room, $"Moveable (ID {it.ObjectNumber}) '{it.Name}'", warnings); + if (room == null) continue; + var instance = new MoveableInstance + { + WadObjectId = new WadMoveableId((uint)(ushort)it.ObjectNumber), + Position = Local(room, it.X, it.Y, it.Z), + RotationY = Degrees(it.RotY), + RotationX = Degrees(it.RotX), + Roll = -Degrees(it.RotZ), + Color = Color(it.Color), + Ocb = it.TriggerFlags, + // Flags layout is the compiler's: code bits << 9, 0x80 clear body, 0x100 invisible. + CodeBits = (byte)((it.Flags >> 9) & 0x1F), + ClearBody = (it.Flags & 0x80) != 0, + Invisible = (it.Flags & 0x100) != 0, + LuaName = it.Name, + }; + room.AddObject(room.Level, instance); + registry.Items[ten.Items.IndexOf(it)] = instance; + } + + // AI objects are stored apart in the file but are ordinary moveables in TombLib. + foreach (var ai in ten.AiObjects) + { + var room = RoomOf(rooms, ai.Room, $"AI object (ID {ai.ObjectNumber}) '{ai.Name}'", warnings); + if (room == null) continue; + var instance = new MoveableInstance + { + WadObjectId = new WadMoveableId((uint)(ushort)ai.ObjectNumber), + Position = Local(room, ai.X, ai.Y, ai.Z), + RotationY = Degrees(ai.RotY), + RotationX = Degrees(ai.RotX), + Roll = -Degrees(ai.RotZ), + Ocb = ai.TriggerFlags, + CodeBits = (byte)((ai.Flags >> 1) & 0x1F), + LuaName = ai.Name, + }; + room.AddObject(room.Level, instance); + } + + foreach (var c in ten.Cameras) + { + var room = RoomOf(rooms, c.Room, $"Camera '{c.Name}'", warnings); + if (room == null) continue; + var camera = new CameraInstance() + { + Position = Local(room, c.X, c.Y, c.Z), + MoveTimer = (byte)c.Speed, + GlideOut = (c.Flags & 0x0002) != 0, + LuaName = c.Name, + }; + if ((c.Flags & 0x0001) != 0) camera.CameraMode = CameraInstanceMode.Locked; + room.AddObject(room.Level, camera); + registry.Cameras[ten.Cameras.IndexOf(c)] = camera; + } + + foreach (var f in ten.FlybyCameras) + { + var room = RoomOf(rooms, f.Room, $"Flyby camera (sequence {f.Sequence}, number {f.Camera})", warnings); + if (room == null) continue; + var flyby = new FlybyCameraInstance + { + Position = Local(room, f.X, f.Y, f.Z), + Sequence = (ushort)f.Sequence, + Number = (ushort)f.Camera, + Timer = f.Timer, + Flags = (ushort)f.Flags, + Speed = (ushort)f.Speed / 655.0f, + Fov = (ushort)f.Fov * (360.0f / 65536.0f), + Roll = (float)((65536 - (ushort)f.Roll) * (360.0 / 65536.0) % 360.0), + }; + // The file stores a point one sector ahead of the camera instead of its rotation. + SetDirection(flyby, new Vector3(f.TargetX - f.X, -(f.TargetY - f.Y), f.TargetZ - f.Z) / 1024.0f); + room.AddObject(room.Level, flyby); + registry.FlybyBySequence.TryAdd(f.Sequence, flyby); + } + + foreach (var s in ten.Sinks) + { + var room = RoomAt(ten, rooms, s.X, s.Y, s.Z); + if (room == null) { warnings.Add($"Sink '{s.Name}' skipped: no room contains it"); continue; } + var sink = new SinkInstance { Position = Local(room, s.X, s.Y, s.Z), Strength = (short)s.Strength, LuaName = s.Name }; + room.AddObject(room.Level, sink); + registry.Sinks[ten.Sinks.IndexOf(s)] = sink; + } + + foreach (var s in ten.SoundSources) + { + var room = RoomAt(ten, rooms, s.X, s.Y, s.Z); + if (room == null) { warnings.Add($"Sound source '{s.Name}' skipped: no room contains it"); continue; } + // Flags: low bits = flip group, 0x8000 always, 0x4000 base room only, 0x2000 alternate room only. "Automatic" writes the + // flag that fits the room's flip state, so those combinations are read back as Automatic. + var mode = SoundSourcePlayMode.Automatic; + if ((s.Flags & 0x8000) != 0) mode = room.Alternated ? SoundSourcePlayMode.Always : SoundSourcePlayMode.Automatic; + else if ((s.Flags & 0x4000) != 0) mode = room.Alternated && !room.IsAlternate ? SoundSourcePlayMode.Automatic : SoundSourcePlayMode.OnlyInBaseRoom; + else if ((s.Flags & 0x2000) != 0) mode = room.IsAlternate ? SoundSourcePlayMode.Automatic : SoundSourcePlayMode.OnlyInAlternateRoom; + room.AddObject(room.Level, new SoundSourceInstance { Position = Local(room, s.X, s.Y, s.Z), SoundId = s.SoundId, PlayMode = mode, LuaName = s.Name }); + } + } + + /// Sinks and sound sources carry no room index: take the room whose box contains the point (the base room first). + private static Room? RoomAt(TenLevel ten, Room[] rooms, int x, int y, int z) + { + for (int i = 0; i < ten.Rooms.Count; i++) + { + var t = ten.Rooms[i]; + if (x < t.X || z < t.Z || x >= t.X + t.XSize * 1024 || z >= t.Z + t.ZSize * 1024) continue; + if (-y < rooms[i].Position.Y + 0 && y > t.BottomHeight + 1) { } + if (y <= t.BottomHeight && y >= t.TopHeight) return rooms[i]; + } + return null; + } + + private static void SetDirection(IRotateableYX obj, Vector3 direction) + { + var d = Vector3.Normalize(direction); + float rotationX = (float)(Math.Asin(Math.Clamp(d.Y, -1f, 1f)) * 180.0 / Math.PI); + float rotationY = (float)(Math.Atan2(d.X, d.Z) * 180.0 / Math.PI); + obj.SetArbitaryRotationsYX(rotationY, rotationX); + } +} diff --git a/PRJ2 Extractor/Core/TenSectors.cs b/PRJ2 Extractor/Core/TenSectors.cs new file mode 100644 index 0000000..4455d60 --- /dev/null +++ b/PRJ2 Extractor/Core/TenSectors.cs @@ -0,0 +1,120 @@ +using System.Numerics; +using TombLib.LevelData; +using TombLib.LevelData.SectorEnums; + +namespace PRJ2_Extractor.Core; + +/// +/// Inverse of TombLib's TombEngine collision compiler (Compilers/TombEngine/FloorData.cs, RoomSectorShape and +/// BuildFloorDataCollision): rebuilds a sector's floor or ceiling surface (four corner heights, diagonal split +/// type and split direction) from the two "fake planes" stored in the .ten file. Heights are TombLib world units +/// relative to the room's Position.Y. +/// +internal static class TenSectors +{ + internal struct SurfaceInfo + { + public int XnZn, XpZn, XnZp, XpZp; + public DiagonalSplit Split; + public bool SplitDirectionIsXEqualsZ; + public bool WallFirst, WallSecond; // a triangle that is a wall (plane marked -float.MaxValue) + public bool Exact; // false when no TombLib surface reproduces the planes + } + + private const float HalfSector = 512f; + + private static bool IsWallMarker(Vector3 plane) => plane.Z < -1e30f; + + // y = c + a * xl + b * zl (engine Y, down is positive); H = -y - roomY + private static int Height(Vector3 plane, float xl, float zl, int roomY) + => (int)Math.Round(-(plane.Z + plane.X * xl + plane.Y * zl) - roomY); + + /// + /// The compiler writes Planes[*].Z = -room.Position.Y for all four planes of a complete wall, so floor and ceiling are flat, equal + /// and unsplit (an open sector never has its floor at its ceiling). That also gives the room's real Position.Y. + /// + public static bool IsFullWall(TenSector s) + { + return Math.Abs(s.FloorPlane[0].X) < 1e-6f && Math.Abs(s.FloorPlane[0].Y) < 1e-6f + && Math.Abs(s.CeilingPlane[0].X) < 1e-6f && Math.Abs(s.CeilingPlane[0].Y) < 1e-6f + && s.FloorPlane[0] == s.FloorPlane[1] && s.CeilingPlane[0] == s.CeilingPlane[1] + && s.FloorPlane[0].Z == s.CeilingPlane[0].Z + && s.FloorSplitAngle == 0 && s.CeilingSplitAngle == 0; + } + + public static SurfaceInfo Decode(float splitAngle, Vector3[] planes, int roomY) + { + var res = new SurfaceInfo { Exact = true }; + bool wall0 = IsWallMarker(planes[0]), wall1 = IsWallMarker(planes[1]); + bool split = splitAngle > 0.1f; + bool xEqualsZ = split && Math.Abs(splitAngle - (float)(Math.PI / 4)) < 0.01f; + + if (!split) + { + var p = planes[0]; + res.XnZn = Height(p, -HalfSector, -HalfSector, roomY); + res.XpZn = Height(p, HalfSector, -HalfSector, roomY); + res.XnZp = Height(p, -HalfSector, HalfSector, roomY); + res.XpZp = Height(p, HalfSector, HalfSector, roomY); + res.Split = DiagonalSplit.None; + // A planar sector has no split in the file; the editor's default direction is X = Z, which also decides how TombLib lays out + // the quad's vertices (and with it how a texture is attached to the corners). + res.SplitDirectionIsXEqualsZ = true; + return res; + } + + res.SplitDirectionIsXEqualsZ = xEqualsZ; + + if (wall0 || wall1) + { + // Diagonal wall: only one triangle has a plane. With a step of 0 the surface heights equal the shape heights, and the + // unknown corner copies a neighbour (the editor never stores heights for the wall corner). + var p = wall0 ? planes[1] : planes[0]; + int xnzn = Height(p, -HalfSector, -HalfSector, roomY), xpzn = Height(p, HalfSector, -HalfSector, roomY); + int xnzp = Height(p, -HalfSector, HalfSector, roomY), xpzp = Height(p, HalfSector, HalfSector, roomY); + res.XnZn = xnzn; res.XpZn = xpzn; res.XnZp = xnzp; res.XpZp = xpzp; + if (xEqualsZ) res.Split = wall0 ? DiagonalSplit.XnZp : DiagonalSplit.XpZn; + else res.Split = wall0 ? DiagonalSplit.XpZp : DiagonalSplit.XnZn; + res.WallFirst = wall0; res.WallSecond = wall1; + return res; + } + + int sXnZn, sXpZn, sXnZp, sXpZp, step; + if (xEqualsZ) + { + // T0 through (XnZp, XpZp, XnZn); T1 through (XpZn, XnZn + step, XpZp + step) + sXnZp = Height(planes[0], -HalfSector, HalfSector, roomY); + sXpZp = Height(planes[0], HalfSector, HalfSector, roomY); + sXnZn = Height(planes[0], -HalfSector, -HalfSector, roomY); + sXpZn = Height(planes[1], HalfSector, -HalfSector, roomY); + step = Height(planes[1], -HalfSector, -HalfSector, roomY) - sXnZn; + } + else + { + // T0 through (XpZp, XpZn + step, XnZp + step); T1 through (XnZn, XnZp, XpZn) + sXpZp = Height(planes[0], HalfSector, HalfSector, roomY); + sXnZn = Height(planes[1], -HalfSector, -HalfSector, roomY); + sXnZp = Height(planes[1], -HalfSector, HalfSector, roomY); + sXpZn = Height(planes[1], HalfSector, -HalfSector, roomY); + step = Height(planes[0], HalfSector, -HalfSector, roomY) - sXpZn; + } + + res.XnZn = sXnZn; res.XpZn = sXpZn; res.XnZp = sXnZp; res.XpZp = sXpZp; + if (step == 0) { res.Split = DiagonalSplit.None; return res; } + + // Try the diagonal split types that match the split direction (XnZp / XpZn use XEqualsZ, XnZn / XpZp do not). + if (xEqualsZ) + { + if (step == sXpZn - sXpZp) { res.Split = DiagonalSplit.XnZp; return res; } + if (sXnZp == sXnZn) { res.Split = DiagonalSplit.XpZn; res.XnZn = sXnZn + step; res.XpZp = sXpZp + step; return res; } + } + else + { + if (step == sXpZp - sXnZp) { res.Split = DiagonalSplit.XnZn; return res; } + if (sXnZn == sXpZn) { res.Split = DiagonalSplit.XpZp; res.XpZn = sXpZn + step; res.XnZp = sXnZp + step; return res; } + } + res.Exact = false; + res.Split = DiagonalSplit.None; + return res; + } +} diff --git a/PRJ2 Extractor/Core/TenTextures.cs b/PRJ2 Extractor/Core/TenTextures.cs new file mode 100644 index 0000000..ad71ff2 --- /dev/null +++ b/PRJ2 Extractor/Core/TenTextures.cs @@ -0,0 +1,208 @@ +using System.IO; +using System.Numerics; +using TombLib; +using TombLib.LevelData; +using TombLib.LevelData.SectorEnums; +using TombLib.LevelData.SectorStructs; +using TombLib.Utils; + +namespace PRJ2_Extractor.Core; + +/// +/// Room face textures of a .ten level. The compiler packs every texture into atlases and writes, for each room polygon, its vertices and the +/// UV of every vertex; TombLib's own room geometry knows which sector face covers which vertices, so each polygon is matched to its face by +/// vertex positions and the UVs are turned back into the face's TextureArea (pixel coordinates inside the atlas page). +/// +internal static class TenTextures +{ + internal sealed class Stats + { + public int Faces, FacesTextured, FacesFromParts, FacesWithoutPolygon, Polygons, PolygonsUnmatched, MissingAtlas; + } + + /// Writes the PNG atlases next to the project and registers them as level textures. DDS (BC1 / BC3) atlases are not supported yet. + public static LevelTexture?[] RegisterAtlases(Level level, List atlases, string directory, string prefix, List warnings) + { + var result = new LevelTexture?[atlases.Count]; + Directory.CreateDirectory(directory); + for (int i = 0; i < atlases.Count; i++) + { + var blob = atlases[i].ColorMap; + bool png = blob.Length > 8 && blob[0] == 0x89 && blob[1] == 0x50 && blob[2] == 0x4E && blob[3] == 0x47; + if (!png) + { + warnings.Add($"Texture atlas {prefix}{i} is not a PNG (DDS atlases are not supported yet); its faces stay untextured."); + continue; + } + string path = Path.Combine(directory, $"{prefix}{i:D2}.png"); + File.WriteAllBytes(path, blob); + var texture = new LevelTexture(level.Settings, level.Settings.MakeRelative(path, VariableType.LevelDirectory), false); + level.Settings.Textures.Add(texture); + if (texture.LoadException != null) + warnings.Add($"Texture atlas '{path}' failed to load: {texture.LoadException.Message}"); + result[i] = texture; + } + return result; + } + + private static string Key(float x, float y, float z) => $"{(int)Math.Round(x)},{(int)Math.Round(y)},{(int)Math.Round(z)}"; + + private static Vector3 ParseKey(string key) + { + var s = key.Split(','); + return new Vector3(int.Parse(s[0]), int.Parse(s[1]), int.Parse(s[2])); + } + + /// Dot product of the polygon normal with the normal of the face's first triangle (vertices in the file's coordinate system). + private static float Facing(string[] corner, Vector3 polygonNormal) + { + var p0 = ParseKey(corner[0]); var p1 = ParseKey(corner[1]); var p2 = ParseKey(corner[2]); + var n = Vector3.Cross(p1 - p0, p2 - p0); + return Vector3.Dot(n, polygonNormal); + } + private sealed record Poly(TenBucket Bucket, TenPolygon Polygon, string[] Keys); + + public static Stats Apply(TenLevel ten, Room[] rooms, LevelTexture?[] roomTextures, LevelTexture?[] animatedTextures, List warnings) + { + var stats = new Stats(); + for (int ri = 0; ri < ten.Rooms.Count; ri++) + { + var t = ten.Rooms[ri]; + var room = rooms[ri]; + TenWalls.Infer(t, room); + room.BuildGeometry(); + var geometry = room.RoomGeometry; + int roomY = room.Position.Y; + + // Polygons by the set of their vertex positions. + var polygons = new Dictionary>(); + foreach (var bucket in t.Buckets) + foreach (var poly in bucket.Polygons) + { + var keys = poly.Indices.Select(i => Key(t.Positions[i].X, t.Positions[i].Y, t.Positions[i].Z)).ToArray(); + string setKey = string.Join("|", keys.OrderBy(k => k, StringComparer.Ordinal).Distinct()); + if (!polygons.TryGetValue(setKey, out var list)) polygons[setKey] = list = []; + list.Add(new Poly(bucket, poly, keys)); + stats.Polygons++; + } + var used = new HashSet(); + var pending = new List<(SectorFaceIdentity Id, VertexRange Range, string[] Corner)>(); + + // Two faces back to back (the two sides of a thin wall) have the same vertices; the polygon whose normal agrees with the face's own + // winding belongs to it. The agreement sign is the one that most single-owner faces show. + var faceCorners = new List<(SectorFaceIdentity Id, VertexRange Range, string[] Corner, string SetKey)>(); + var facesPerKey = new Dictionary(); + foreach (var entry in geometry.VertexRangeLookup) + { + var range = entry.Value; + if (range.Count != 3 && range.Count != 6) continue; + var corner = new string[range.Count]; + for (int k = 0; k < range.Count; k++) + { + var v = geometry.VertexPositions[range.Start + k]; + corner[k] = Key(v.X, -(v.Y + roomY), v.Z); + } + string setKey = string.Join("|", corner.OrderBy(k => k, StringComparer.Ordinal).Distinct()); + faceCorners.Add((entry.Key, range, corner, setKey)); + facesPerKey[setKey] = facesPerKey.GetValueOrDefault(setKey) + 1; + } + int agree = 0, disagree = 0; + foreach (var f in faceCorners) + if (facesPerKey[f.SetKey] == 1 && polygons.TryGetValue(f.SetKey, out var single) && single.Count == 1) + { + if (Facing(f.Corner, single[0].Polygon.Normal) > 0) agree++; else disagree++; + } + bool positive = agree >= disagree; + + foreach (var (id, range, corner, setKey) in faceCorners) + { + stats.Faces++; + if (!polygons.TryGetValue(setKey, out var found)) { pending.Add((id, range, corner)); continue; } + used.Add(setKey); + + // Several polygons on the same vertices: the face's own one by orientation; a second, mirrored copy of the same face means double sided. + var own = found.Where(f => (Facing(corner, f.Polygon.Normal) > 0) == positive).ToList(); + var first = own.Count > 0 ? own[0] : found[0]; + bool doubleSided = facesPerKey[setKey] == 1 && found.Count > 1; + if (Assign(ten, room, roomTextures, animatedTextures, id, range, corner, first.Bucket, doubleSided, + c => { int j = Array.IndexOf(first.Keys, c); return j < 0 ? null : first.Polygon.Uv[j]; }, stats)) + stats.FacesTextured++; + } + // The compiler may cut a face into several polygons (a wall face with a sloped top becomes a quad plus a triangle): take the + // polygons that lie inside the face and read every corner's UV from whichever polygon has a vertex there. + var unused = polygons.Where(kv => !used.Contains(kv.Key)).SelectMany(kv => kv.Value).ToList(); + foreach (var (id, range, corner) in pending) + { + var corners = corner.Distinct().Select(ParseKey).ToArray(); + if (corners.Length < 3) { stats.FacesWithoutPolygon++; continue; } + var normal = Vector3.Cross(corners[1] - corners[0], corners[2] - corners[0]); + if (normal.LengthSquared() < 1e-3f) { stats.FacesWithoutPolygon++; continue; } + normal = Vector3.Normalize(normal); + var min = new Vector3(corners.Min(c => c.X), corners.Min(c => c.Y), corners.Min(c => c.Z)) - Vector3.One * 2; + var max = new Vector3(corners.Max(c => c.X), corners.Max(c => c.Y), corners.Max(c => c.Z)) + Vector3.One * 2; + bool Inside(string key) + { + var p = ParseKey(key); + return Math.Abs(Vector3.Dot(normal, p - corners[0])) < 2 && p.X >= min.X && p.X <= max.X && p.Y >= min.Y && p.Y <= max.Y && p.Z >= min.Z && p.Z <= max.Z; + } + var inside = unused.Where(u => u.Keys.All(Inside)).ToList(); + // A double-sided face has a second, mirrored polygon on the same vertices: only the first one carries the face's own UVs. + var parts = inside.GroupBy(u => string.Join("|", u.Keys.OrderBy(k => k, StringComparer.Ordinal))).Select(g => g.First()).ToList(); + if (parts.Count == 0) { stats.FacesWithoutPolygon++; continue; } + + Vector2? Uv(string c) + { + foreach (var part in parts) + { + int j = Array.IndexOf(part.Keys, c); + if (j >= 0) return part.Polygon.Uv[j]; + } + return null; + } + if (corner.Distinct().Any(c => Uv(c) == null)) { stats.FacesWithoutPolygon++; continue; } + if (Assign(ten, room, roomTextures, animatedTextures, id, range, corner, parts[0].Bucket, false, Uv, stats)) + { + stats.FacesTextured++; + stats.FacesFromParts++; + foreach (var part in inside) unused.Remove(part); + } + } + stats.PolygonsUnmatched += unused.Count; + } + return stats; + } + + private static bool Assign(TenLevel ten, Room room, LevelTexture?[] roomTextures, LevelTexture?[] animatedTextures, SectorFaceIdentity id, + VertexRange range, string[] corner, TenBucket bucket, bool doubleSided, Func uvOf, Stats stats) + { + var atlas = bucket.Animated ? (bucket.Texture < ten.AnimatedAtlases.Count ? ten.AnimatedAtlases[bucket.Texture] : null) + : (bucket.Texture < ten.RoomAtlases.Count ? ten.RoomAtlases[bucket.Texture] : null); + var levelTexture = bucket.Animated ? (bucket.Texture < animatedTextures.Length ? animatedTextures[bucket.Texture] : null) + : (bucket.Texture < roomTextures.Length ? roomTextures[bucket.Texture] : null); + if (atlas == null || levelTexture == null) { stats.MissingAtlas++; return false; } + + Vector2 Uv(string cornerKey) + { + var uv = uvOf(cornerKey) ?? Vector2.Zero; + return new Vector2(uv.X * atlas.Width, uv.Y * atlas.Height); + } + + bool ceiling = id.Face is SectorFace.Ceiling or SectorFace.Ceiling_Triangle2; + var area = new TextureArea { Texture = levelTexture, BlendMode = (BlendMode)bucket.BlendMode, DoubleSided = doubleSided }; + if (range.Count == 6) + { + // Vertex of each TexCoord, from UpdateFaceTexture / the compiler's polygon vertex order. + int v0 = ceiling ? 5 : 3, v1 = ceiling ? 0 : 2, v2 = ceiling ? 2 : 0, v3 = 1; + area.TexCoord0 = Uv(corner[v0]); area.TexCoord1 = Uv(corner[v1]); + area.TexCoord2 = Uv(corner[v2]); area.TexCoord3 = Uv(corner[v3]); + } + else + { + int v0 = ceiling ? 2 : 0, v2 = ceiling ? 0 : 2; + area.TexCoord0 = Uv(corner[v0]); area.TexCoord1 = Uv(corner[1]); + area.TexCoord2 = Uv(corner[v2]); area.TexCoord3 = area.TexCoord2; + } + room.Sectors[id.Position.X, id.Position.Y].SetFaceTexture(id.Face, area); + return true; + } +} \ No newline at end of file diff --git a/PRJ2 Extractor/Core/TenWalls.cs b/PRJ2 Extractor/Core/TenWalls.cs new file mode 100644 index 0000000..dc29838 --- /dev/null +++ b/PRJ2 Extractor/Core/TenWalls.cs @@ -0,0 +1,126 @@ +using System.Numerics; +using TombLib.LevelData; +using TombLib.LevelData.SectorEnums; +using TombLib.LevelData.SectorStructs; + +namespace PRJ2_Extractor.Core; + +/// +/// The compiled file does not keep the heights of wall sectors, but TombLib builds the wall faces (QA / middle / WS ...) from them: the +/// polygons on a wall edge are stacked bands whose boundaries, at each end of the edge, are the open neighbour's floor / ceiling and the +/// wall's own floor / ceiling at that corner. The wall's corner heights are read back from those boundaries so that TombLib's face +/// geometry lines up with the polygons again (sloped walls included, since every corner is handled on its own). +/// +public static class TenWalls +{ + private enum Corner { XnZn, XpZn, XnZp, XpZp } + + /// Returns the number of wall sectors with at least one inferred corner. + public static int Infer(TenRoom t, Room room) + { + int roomY = room.Position.Y; + int Height(float engineY) => (int)Math.Round(-engineY - roomY); + + // Vertex heights of vertical polygons, by the plane they lie on ('x' or 'z'), the sector they start in and the end of the edge. + var ends = new Dictionary<(char Axis, int Plane, int Start, int End), HashSet>(); + foreach (var bucket in t.Buckets) + foreach (var poly in bucket.Polygons) + { + var v = poly.Indices.Select(i => t.Positions[i]).ToArray(); + var xs = v.Select(p => (int)Math.Round(p.X)).Distinct().ToList(); + var zs = v.Select(p => (int)Math.Round(p.Z)).Distinct().ToList(); + char axis; int plane; List along; bool alongX; + if (zs.Count == 1 && xs.Count == 2) { axis = 'z'; plane = zs[0]; along = xs; alongX = true; } + else if (xs.Count == 1 && zs.Count == 2) { axis = 'x'; plane = xs[0]; along = zs; alongX = false; } + else continue; + int start = along.Min(); + if (along.Max() - start != 1024) continue; // only polygons spanning exactly one sector edge + foreach (var p in v) + { + int a = (int)Math.Round(alongX ? p.X : p.Z); + var key = (axis, plane, start, a == start ? 0 : 1); + if (!ends.TryGetValue(key, out var set)) ends[key] = set = []; + set.Add(Height(p.Y)); + } + } + + var floorVotes = new Dictionary<(int, int, Corner), Dictionary>(); + var ceilingVotes = new Dictionary<(int, int, Corner), Dictionary>(); + static void Vote(Dictionary<(int, int, Corner), Dictionary> votes, int x, int z, Corner corner, int height) + { + var key = (x, z, corner); + if (!votes.TryGetValue(key, out var d)) votes[key] = d = []; + d[height] = d.GetValueOrDefault(height) + 1; + } + + for (int x = 0; x < t.XSize; x++) + for (int z = 0; z < t.ZSize; z++) + { + if (room.Sectors[x, z].Type is not (SectorType.Wall or SectorType.BorderWall)) continue; + + // (neighbour, plane axis, plane coordinate, start along the edge, wall corners at end 0 / 1, neighbour corners at end 0 / 1) + var edges = new (int nx, int nz, char axis, int plane, int start, Corner w0, Corner w1, Corner n0, Corner n1)[] + { + (x, z - 1, 'z', z * 1024, x * 1024, Corner.XnZn, Corner.XpZn, Corner.XnZp, Corner.XpZp), + (x, z + 1, 'z', (z + 1) * 1024, x * 1024, Corner.XnZp, Corner.XpZp, Corner.XnZn, Corner.XpZn), + (x - 1, z, 'x', x * 1024, z * 1024, Corner.XnZn, Corner.XnZp, Corner.XpZn, Corner.XpZp), + (x + 1, z, 'x', (x + 1) * 1024, z * 1024, Corner.XpZn, Corner.XpZp, Corner.XnZn, Corner.XnZp), + }; + foreach (var e in edges) + { + if (e.nx < 0 || e.nz < 0 || e.nx >= t.XSize || e.nz >= t.ZSize) continue; + var n = room.Sectors[e.nx, e.nz]; + if (n.Type != SectorType.Floor) continue; + for (int end = 0; end < 2; end++) + { + if (!ends.TryGetValue((e.axis, e.plane, e.start, end), out var heights)) continue; + var nCorner = end == 0 ? e.n0 : e.n1; var wCorner = end == 0 ? e.w0 : e.w1; + int nf = Get(n.Floor, nCorner), nc = Get(n.Ceiling, nCorner); + var interior = heights.Where(h => Math.Abs(h - nf) > 1 && Math.Abs(h - nc) > 1).OrderBy(h => h).ToList(); + int wf, wc; + if (interior.Count >= 2) { wf = interior.First(); wc = interior.Last(); } + else if (interior.Count == 1) { wf = interior[0]; wc = nc; } + else { wf = nf; wc = nc; } + Vote(floorVotes, x, z, wCorner, wf); + Vote(ceilingVotes, x, z, wCorner, wc); + } + } + } + + var touched = new HashSet<(int, int)>(); + foreach (var kv in floorVotes) { Put(ref room.Sectors[kv.Key.Item1, kv.Key.Item2].Floor, kv.Key.Item3, Mode(kv.Value)); touched.Add((kv.Key.Item1, kv.Key.Item2)); } + foreach (var kv in ceilingVotes) Put(ref room.Sectors[kv.Key.Item1, kv.Key.Item2].Ceiling, kv.Key.Item3, Mode(kv.Value)); + + // A wall corner without any information takes the most common value of the wall's other corners. + foreach (var (x, z) in touched) + { + var s = room.Sectors[x, z]; + foreach (Corner c in Enum.GetValues()) + { + if (!floorVotes.ContainsKey((x, z, c))) + Put(ref s.Floor, c, Mode(Enum.GetValues().Where(o => floorVotes.ContainsKey((x, z, o))).GroupBy(o => Mode(floorVotes[(x, z, o)])).ToDictionary(g => g.Key, g => g.Count()))); + if (!ceilingVotes.ContainsKey((x, z, c))) + Put(ref s.Ceiling, c, Mode(Enum.GetValues().Where(o => ceilingVotes.ContainsKey((x, z, o))).GroupBy(o => Mode(ceilingVotes[(x, z, o)])).ToDictionary(g => g.Key, g => g.Count()))); + } + } + return touched.Count; + } + + private static int Mode(Dictionary votes) => votes.OrderByDescending(kv => kv.Value).ThenBy(kv => kv.Key).First().Key; + + private static int Get(SectorSurface surface, Corner c) => c switch + { + Corner.XnZn => surface.XnZn, Corner.XpZn => surface.XpZn, Corner.XnZp => surface.XnZp, _ => surface.XpZp, + }; + + private static void Put(ref SectorSurface surface, Corner c, int height) + { + switch (c) + { + case Corner.XnZn: surface.XnZn = (short)height; break; + case Corner.XpZn: surface.XpZn = (short)height; break; + case Corner.XnZp: surface.XnZp = (short)height; break; + default: surface.XpZp = (short)height; break; + } + } +} diff --git a/PRJ2 Extractor/Core/TriggerExporter.cs b/PRJ2 Extractor/Core/TriggerExporter.cs index 6775f45..d2dfb72 100644 --- a/PRJ2 Extractor/Core/TriggerExporter.cs +++ b/PRJ2 Extractor/Core/TriggerExporter.cs @@ -63,40 +63,53 @@ private static bool EnsureNgCatalog() public static void Export(TrLevel trLevel, Room?[] tombRooms, ObjectRegistry registry, List warnings) { bool ng = trLevel.IsNg; - bool catalog = ng && EnsureNgCatalog(); + ExportCore(tombRooms, trLevel.Rooms.Length, + roomIdx => (trLevel.Rooms[roomIdx].NumX, trLevel.Rooms[roomIdx].NumZ), + (roomIdx, x, z) => trLevel.Rooms[roomIdx].Sectors[x * trLevel.Rooms[roomIdx].NumZ + z].FloorInfo?.FirstOrDefault(f => f.Trigger != null)?.Trigger, + ng, false, registry, warnings); + } + + /// + /// Shared by the TR and TEN exporters. selects TombEngine's variant of the action list: trigger types + /// 13-15 are TightRope / Crawl / Climb, camera indices are not masked and there are no NG extra words. + /// + internal static void ExportCore(Room?[] tombRooms, int roomCount, Func size, + Func getTrigger, bool ng, bool tenFormat, ObjectRegistry registry, List warnings) + { + bool catalog = ng && !tenFormat && EnsureNgCatalog(); bool warnedCatalog = false, warnedUnknown = false; var ids = new Dictionary(ReferenceEqualityComparer.Instance); - for (int roomIdx = 0; roomIdx < trLevel.Rooms.Length && roomIdx < tombRooms.Length; roomIdx++) + for (int roomIdx = 0; roomIdx < roomCount && roomIdx < tombRooms.Length; roomIdx++) { var room = tombRooms[roomIdx]; - var r1 = trLevel.Rooms[roomIdx]; - if (room == null || r1.NumX == 0 || r1.NumZ == 0) continue; + var (numX, numZ) = size(roomIdx); + if (room == null || numX == 0 || numZ == 0) continue; - var specs = new List?[r1.NumX, r1.NumZ]; - var signatures = new string?[r1.NumX, r1.NumZ]; - for (int x = 0; x < r1.NumX; x++) - for (int z = 0; z < r1.NumZ; z++) + var specs = new List?[numX, numZ]; + var signatures = new string?[numX, numZ]; + for (int x = 0; x < numX; x++) + for (int z = 0; z < numZ; z++) { - var trigger = r1.Sectors[x * r1.NumZ + z].FloorInfo?.FirstOrDefault(f => f.Trigger != null)?.Trigger; + var trigger = getTrigger(roomIdx, x, z); if (trigger == null) continue; - var list = Decode(trigger, ng, catalog, registry, ids, warnings, roomIdx, x, z, ref warnedCatalog, ref warnedUnknown); + var list = Decode(trigger, ng, catalog, registry, ids, warnings, roomIdx, x, z, ref warnedCatalog, ref warnedUnknown, tenFormat); if (list.Count == 0) continue; specs[x, z] = list; signatures[x, z] = string.Join(";", list.Select(s => s.Signature)); } // Greedy rectangle cover over sectors with identical trigger lists. - var done = new bool[r1.NumX, r1.NumZ]; - for (int z = 0; z < r1.NumZ; z++) - for (int x = 0; x < r1.NumX; x++) + var done = new bool[numX, numZ]; + for (int z = 0; z < numZ; z++) + for (int x = 0; x < numX; x++) { string? sig = signatures[x, z]; if (sig == null || done[x, z]) continue; int x1 = x; - while (x1 + 1 < r1.NumX && !done[x1 + 1, z] && signatures[x1 + 1, z] == sig) x1++; + while (x1 + 1 < numX && !done[x1 + 1, z] && signatures[x1 + 1, z] == sig) x1++; int z1 = z; - while (z1 + 1 < r1.NumZ) + while (z1 + 1 < numZ) { bool rowOk = true; for (int xx = x; xx <= x1 && rowOk; xx++) @@ -126,9 +139,8 @@ public static void Export(TrLevel trLevel, Room?[] tombRooms, ObjectRegistry reg } } } - private static List Decode(TrTrigger t, bool ng, bool catalog, ObjectRegistry reg, Dictionary ids, - List warnings, int roomIdx, int x, int z, ref bool warnedCatalog, ref bool warnedUnknown) + List warnings, int roomIdx, int x, int z, ref bool warnedCatalog, ref bool warnedUnknown, bool ten = false) { var result = new List(); string where = $"room {roomIdx} sector ({x},{z})"; @@ -137,11 +149,11 @@ private static List Decode(TrTrigger t, bool ng, bool catalog, ObjectRegis switch (t.SubFunction) { case >= 0 and <= 11: type = (TriggerType)t.SubFunction; break; - case 12: type = ng ? TriggerType.ConditionNg : TriggerType.Monkey; break; - case 13: type = TriggerType.Skeleton; break; - case 14: type = TriggerType.TightRope; break; - case 15: type = TriggerType.Crawl; break; - case 16: type = TriggerType.Climb; break; + case 12: type = ng && !ten ? TriggerType.ConditionNg : TriggerType.Monkey; break; + case 13: type = ten ? TriggerType.TightRope : TriggerType.Skeleton; break; + case 14: type = ten ? TriggerType.Crawl : TriggerType.TightRope; break; + case 15: type = ten ? TriggerType.Climb : TriggerType.Crawl; break; + case 16 when !ten: type = TriggerType.Climb; break; default: warnings.Add($"Trigger in {where} skipped: unknown trigger type {t.SubFunction}"); return result; @@ -159,7 +171,15 @@ private static List Decode(TrTrigger t, bool ng, bool catalog, ObjectRegis foreach (var a in t.Actions) { - var s = new Spec { Type = type, OneShot = oneShot, CodeBits = codeBits, Timer = setupTimer, Extra = setupExtra }; + // The compiler takes type, timer, one-shot and code bits from the first (setup) trigger alone; camera and flyby actions also have an + // extra word of their own. + // TR levels keep the legacy convention (every action carries the sector's type and setup, as TombLib's own PRJ import does), which also + // guarantees an identical setup word whichever trigger a recompile picks; TEN levels are made in the editor, where the other actions + // keep their own defaults. + bool setup = !(ten && result.Count > 0); + var s = setup + ? new Spec { Type = type, OneShot = oneShot, CodeBits = codeBits, Timer = setupTimer, Extra = setupExtra } + : new Spec { Type = TriggerType.Trigger }; switch (a.Type) { case 0: // Object @@ -170,11 +190,12 @@ private static List Decode(TrTrigger t, bool ng, bool catalog, ObjectRegis break; case 1: // Camera s.TargetType = TriggerTargetType.Camera; - if (!reg.Cameras.TryGetValue(a.Param & 0x7F, out var camera)) { warnings.Add($"Trigger in {where}: no camera with index {a.Param}, action skipped"); continue; } + if (!reg.Cameras.TryGetValue(ten ? a.Param : a.Param & 0x7F, out var camera)) { warnings.Add($"Trigger in {where}: no camera with index {a.Param}, action skipped"); continue; } s.TargetObject = camera; if (a.HasExtra) { s.Timer = (ushort)(a.Extra & 0xFF); + if (!setup) s.OneShot = (a.Extra & 0x100) != 0; if (camera.CameraMode != CameraInstanceMode.Sniper) camera.MoveTimer = (byte)((a.Extra >> 9) & 0x3F); } @@ -210,7 +231,12 @@ private static List Decode(TrTrigger t, bool ng, bool catalog, ObjectRegis s.TargetType = TriggerTargetType.FlyByCamera; if (!reg.FlybyBySequence.TryGetValue(a.Param, out var flyby)) { warnings.Add($"Trigger in {where}: no flyby camera with sequence {a.Param}, action skipped"); continue; } s.TargetObject = flyby; + if (!setup && a.HasExtra) s.OneShot = (a.Extra & 0x100) != 0; break; + case 16 when ten: // volume event set + case 17 when ten: // global event set + warnings.Add($"Trigger in {where}: event set action skipped (event sets are not exported yet)"); + continue; case 13: s.TargetType = TriggerTargetType.ParameterNg; s.TargetKey = a.Param; break; case 14: s.TargetType = TriggerTargetType.FmvNg; s.TargetKey = a.Param; break; case 15: s.TargetType = TriggerTargetType.TimerfieldNg; s.TargetKey = a.Param; break; diff --git a/PRJ2 Extractor/MainWindow.xaml b/PRJ2 Extractor/MainWindow.xaml index 4040616..51797f9 100644 --- a/PRJ2 Extractor/MainWindow.xaml +++ b/PRJ2 Extractor/MainWindow.xaml @@ -26,7 +26,7 @@ - + @@ -70,7 +70,7 @@ - +