diff --git a/PDTools.Files/Courses/PS2/GT3CourseParameters.cs b/PDTools.Files/Courses/PS2/GT3CourseParameters.cs
new file mode 100644
index 00000000..07cc89ab
--- /dev/null
+++ b/PDTools.Files/Courses/PS2/GT3CourseParameters.cs
@@ -0,0 +1,220 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+
+namespace PDTools.Files.Courses.PS2;
+
+///
+/// GT3 "22_CourseParameters" — AI driving-line data (racing/AI lines, the pit-lane
+/// line, pit boxes). It is a sibling course file, NOT part of the runway file.
+///
+/// Layout (little-endian):
+/// 0x00 u32 totalSize (offset of the end of the last section)
+/// 0x04 u32 count (section-table entry count; observed 8 for 7 sections)
+/// 0x08 u32 0
+/// 0x0C u32[] section offsets (until the build string)
+/// 0x28 ASCII "build MM/DD/YY HH:MM:SS\n" + padding up to the first section
+/// <section>* each: u32 nodeCount, then nodeCount × 40-byte node
+/// node = 10 × int32: [0]flag [1]X [2]Z [3]Y [4..9] partly decoded
+///
+/// The pit-lane line is the section whose first node lies on the runway's
+/// pitlane-flagged surface (tri+12 bit0). Coordinates are integers ≈ ×4000 the
+/// runway's float metres (transform not yet calibrated).
+///
+/// Read()/Write() are lossless: the header (TOC + build string + padding) and the
+/// trailing alignment bytes are preserved verbatim, and the section offsets +
+/// totalSize are recomputed on write so edited line sizes stay consistent.
+///
+public class GT3CourseParameters
+{
+ public const int NodeSize = 40; // bytes per node (10 × int32)
+ public const int NodeInts = 10;
+
+ /// Header field at 0x04 — a constant 8 on every observed file (NOT the
+ /// section count, which is always 7). Written verbatim into synthetic headers.
+ public const uint HeaderCountConst = 8;
+
+ /// Offset where the first section begins (0x48 on every file): the TOC
+ /// (0x00..0x27) + the ASCII build string (0x28) + zero-pad up to here.
+ public const int FirstSectionOffset = 0x48;
+
+ /// Every official file's total length is a multiple of 0x40 (the data ends
+ /// at totalSize, then zero-pad up to the next 0x40 boundary).
+ public const int FileAlignment = 0x40;
+
+ ///
+ /// If > 1, zero-pads the output up to this byte alignment
+ /// AFTER (totalSize still marks the data end). 0 = write
+ /// exactly (preserves byte-identical round-trips of files read from disk, whose
+ /// already carries the original padding). Synthetic files
+ /// () set this to so the
+ /// engine/volume sees a correctly-aligned file — without it the file is short and
+ /// downstream data shifts (crash).
+ ///
+ public int PadToAlignment { get; set; } = 0;
+
+ ///
+ /// Fixed-point scale between integer course coords and runway float metres
+ /// (12 fractional bits). The X axis is flipped vs the runway mesh.
+ /// Calibrated against apricot: racing line lands within ~6 m of the centerline.
+ ///
+ public const float WorldScale = 4096f;
+
+ /// Convert a node's integer (X,Z) to runway-mesh world metres (X flipped).
+ public static (float x, float z) ToWorld(int courseX, int courseZ)
+ => (-courseX / WorldScale, courseZ / WorldScale);
+
+ /// Raw header bytes [0, FirstSectionOffset): TOC + build string + padding.
+ public byte[] HeaderBlob { get; set; } = [];
+
+ /// Raw bytes after the last section (alignment padding), preserved verbatim.
+ public byte[] TailBlob { get; set; } = [];
+
+ public List Lines { get; set; } = [];
+
+ /// One AI driving line / pit line / pit-box set: a list of 10-int32 nodes.
+ public class Line
+ {
+ public List Nodes { get; set; } = [];
+
+ public int Count => Nodes.Count;
+ public int X(int i) => Nodes[i][1];
+ public int Z(int i) => Nodes[i][2];
+ public int Y(int i) => Nodes[i][3];
+ }
+
+ private static uint U32(byte[] d, int o) => BitConverter.ToUInt32(d, o);
+
+ ///
+ /// Builds a from-scratch instance with a synthetic header (no donor file). The
+ /// caller fills (always 7 sections; empties are allowed) and
+ /// calls , which patches the section offsets + totalSize. The
+ /// header is the verified-regular layout: count=8 at 0x04, 7 offset slots
+ /// (0x0C..0x27, patched on write), an ASCII build MM/DD/YY HH:MM:SS\n string
+ /// at 0x28, zero-padded to the first section at 0x48.
+ ///
+ public static GT3CourseParameters CreateForSynthesis(DateTime buildTime)
+ {
+ var h = new byte[FirstSectionOffset];
+ BitConverter.GetBytes(HeaderCountConst).CopyTo(h, 0x04);
+
+ string build = "build " + buildTime.ToString("MM/dd/yy HH:mm:ss",
+ System.Globalization.CultureInfo.InvariantCulture) + "\n";
+ byte[] bs = System.Text.Encoding.ASCII.GetBytes(build);
+ Array.Copy(bs, 0, h, 0x28, Math.Min(bs.Length, FirstSectionOffset - 0x28));
+
+ return new GT3CourseParameters { HeaderBlob = h, PadToAlignment = FileAlignment };
+ }
+
+ public static GT3CourseParameters Read(byte[] d)
+ {
+ var cp = new GT3CourseParameters();
+ int totalSize = (int)U32(d, 0x00);
+
+ // Section offsets run from 0x0C until a u32 stops looking like an ascending,
+ // in-range offset (the next field is the ASCII build string).
+ var offs = new List();
+ for (int p = 0x0C; p + 4 <= d.Length; p += 4)
+ {
+ uint v = U32(d, p);
+ if (v < 0x0C || v > (uint)d.Length) break;
+ if (offs.Count > 0 && v <= (uint)offs[^1]) break;
+ offs.Add((int)v);
+ }
+ if (offs.Count == 0)
+ throw new InvalidDataException("CourseParameters: no section offsets found.");
+
+ cp.HeaderBlob = d[..offs[0]];
+
+ foreach (int o in offs)
+ {
+ int nc = (int)U32(d, o);
+ var line = new Line();
+ for (int k = 0; k < nc; k++)
+ {
+ int b = o + 4 + k * NodeSize;
+ var node = new int[NodeInts];
+ for (int j = 0; j < NodeInts; j++)
+ node[j] = BitConverter.ToInt32(d, b + j * 4);
+ line.Nodes.Add(node);
+ }
+ cp.Lines.Add(line);
+ }
+
+ cp.TailBlob = totalSize <= d.Length ? d[totalSize..] : [];
+ return cp;
+ }
+
+ public byte[] Write()
+ {
+ var outp = new List(HeaderBlob); // offsets/totalSize patched below
+ var sectionOffsets = new List(Lines.Count);
+
+ foreach (var line in Lines)
+ {
+ sectionOffsets.Add(outp.Count);
+ Append32(outp, (uint)line.Nodes.Count);
+ foreach (var node in line.Nodes)
+ for (int j = 0; j < NodeInts; j++)
+ Append32(outp, unchecked((uint)node[j]));
+ }
+ int totalSize = outp.Count; // data end (excludes tail/alignment padding)
+ outp.AddRange(TailBlob);
+
+ // Zero-pad the file up to the alignment boundary (synthetic files only; read
+ // files keep PadToAlignment = 0 so their original TailBlob padding is exact).
+ if (PadToAlignment > 1)
+ {
+ int aligned = (outp.Count + PadToAlignment - 1) / PadToAlignment * PadToAlignment;
+ while (outp.Count < aligned) outp.Add(0);
+ }
+
+ var arr = outp.ToArray();
+ Patch32(arr, 0x00, (uint)totalSize); // totalSize (data end)
+ for (int i = 0; i < sectionOffsets.Count; i++)
+ Patch32(arr, 0x0C + i * 4, (uint)sectionOffsets[i]); // section offset table
+ return arr;
+ }
+
+ /// Section index of the direction-independent reference line (S4).
+ public const int ReferenceSectionIndex = 4;
+
+ ///
+ /// Reverse the driving direction of the AI lines, to match a reversed runway.
+ /// reverse-gtrw keeps the collision mesh byte-identical, so node positions (f1/f2)
+ /// and the f4 collision-node link stay valid — they simply travel with the reversed
+ /// nodes (f4 then runs the opposite way, e.g. 11..93 → 93..11).
+ ///
+ /// Per line the node ORDER is reversed and each node's f6 turn-direction term has its
+ /// SIGN flipped (a left bend becomes a right bend when you drive the other way; f6=0
+ /// on synthesized/plain lines, so this is a no-op there). The reference line
+ /// () is left untouched — in PD's own forward/
+ /// reverse pairs it is direction-independent (≈byte-identical fwd vs rev).
+ ///
+ /// Validated against apricot_f vs apricot_r: reversing the forward racing line lands
+ /// its new first node where the reverse file's first node is. The transform is its
+ /// own inverse (reversing twice restores the original).
+ ///
+ public void ReverseDirection()
+ {
+ for (int s = 0; s < Lines.Count; s++)
+ {
+ if (s == ReferenceSectionIndex) continue; // direction-independent reference
+
+ var nodes = Lines[s].Nodes;
+ nodes.Reverse();
+ foreach (var node in nodes)
+ node[6] = unchecked(-node[6]); // turn-direction sign flips
+ }
+ }
+
+ private static void Append32(List l, uint v)
+ {
+ l.Add((byte)v); l.Add((byte)(v >> 8)); l.Add((byte)(v >> 16)); l.Add((byte)(v >> 24));
+ }
+
+ private static void Patch32(byte[] b, int o, uint v)
+ {
+ b[o] = (byte)v; b[o + 1] = (byte)(v >> 8); b[o + 2] = (byte)(v >> 16); b[o + 3] = (byte)(v >> 24);
+ }
+}
diff --git a/PDTools.Files/Courses/PS2/Runway/GT3GateRecord.cs b/PDTools.Files/Courses/PS2/Runway/GT3GateRecord.cs
new file mode 100644
index 00000000..d87e6573
--- /dev/null
+++ b/PDTools.Files/Courses/PS2/Runway/GT3GateRecord.cs
@@ -0,0 +1,40 @@
+using Syroot.BinaryData;
+
+namespace PDTools.Files.Courses.PS2.Runway;
+
+///
+/// One 8-byte entry in the GTRW gate/sector table.
+/// Fields:
+/// SectorId – track zone: 0 = global origin, 1 = start/finish gate, 2 = main track
+/// GateFlag – record type: -1 = sentinel (b/e of list), 0 = timing gate, 1 = normal
+/// TrackV – V-coordinate (metres) of this record along the track
+///
+public class GT3GateRecord
+{
+ public short SectorId { get; set; }
+ public short GateFlag { get; set; }
+ public float TrackV { get; set; }
+
+ /// True when this record marks a timing-sector boundary (T1/T2/T3).
+ public bool IsTimingGate => GateFlag == 0;
+
+ /// True when this is a sentinel entry (start/finish markers).
+ public bool IsSentinel => GateFlag == -1;
+
+ public static GT3GateRecord FromStream(BinaryStream bs)
+ => new GT3GateRecord
+ {
+ SectorId = bs.ReadInt16(),
+ GateFlag = bs.ReadInt16(),
+ TrackV = bs.ReadSingle(),
+ };
+
+ public void ToStream(BinaryStream bs)
+ {
+ bs.WriteInt16(SectorId);
+ bs.WriteInt16(GateFlag);
+ bs.WriteSingle(TrackV);
+ }
+
+ public static int GetSize() => 8;
+}
diff --git a/PDTools.Files/Courses/PS2/Runway/GT3RunwayData.cs b/PDTools.Files/Courses/PS2/Runway/GT3RunwayData.cs
new file mode 100644
index 00000000..c62d551b
--- /dev/null
+++ b/PDTools.Files/Courses/PS2/Runway/GT3RunwayData.cs
@@ -0,0 +1,601 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+
+using Syroot.BinaryData;
+
+namespace PDTools.Files.Courses.PS2.Runway;
+
+///
+/// GT3 (PS2) runway file — magic "GTRW", little-endian.
+///
+/// Binary layout (all offsets relative to file start):
+/// 0x00 uint32 Magic = 0x57525447 ("GTRW")
+/// 0x04 uint32 Runtime ptr (always 0 on disk)
+/// 0x08 uint32 Runtime ptr (always 0 on disk)
+/// 0x0C uint32 RelocSize (file length − 16)
+/// 0x10 uint32 Version/flags field A
+/// 0x14 uint32 Version/flags field B
+/// 0x18 float TrackV – total track length in metres
+/// 0x1C float A1VCoord – V of the start/finish gate record (mirrors GateRecords a=1 entry)
+/// 0x20 uint16 HeaderField20 (observed = 1, meaning unclear)
+/// 0x22 uint16 GateRecordCount
+/// 0x24 uint32 GateSectionOffset (always 0x60)
+/// 0x28 uint32 SpawnCount
+/// 0x2C uint32 SpawnSectionOffset
+/// 0x30–0x5F 48 raw bytes (section counts, offsets, and flags whose meaning is partially known)
+/// 0x60+ Gate records (GateRecordCount × 8 bytes)
+/// SpawnOff+ Spawn positions (SpawnCount × 16 bytes)
+/// … All remaining sections (checkpoint pairs, clusters, road mesh, BSP…) — opaque blob
+///
+public class GT3RunwayData
+{
+ // ── Magic ────────────────────────────────────────────────────────────────
+ public const uint MAGIC = 0x57525447u; // "GTRW"
+
+ // ── Parsed header fields ─────────────────────────────────────────────────
+ public uint Field04 { get; set; }
+ public uint Field08 { get; set; }
+
+ /// File length − 16. Patched to the actual output length on Write().
+ public uint RelocSize { get; set; }
+
+ public uint Field10 { get; set; }
+ public uint Field14 { get; set; }
+
+ /// Total track length in metres.
+ public float TrackV { get; set; }
+
+ ///
+ /// V-coordinate of the start/finish gate (the SectorId=1 sentinel record).
+ /// Near 0 for a forward file; near TrackV for a reversed file.
+ /// Updated automatically by ReverseDirection().
+ ///
+ public float A1VCoord { get; set; }
+
+ public ushort HeaderField20 { get; set; }
+
+ /// Raw 48-byte blob covering header offsets 0x30–0x5F.
+ public byte[] OpaqueHeaderTail { get; set; }
+
+ // ── Data lists ───────────────────────────────────────────────────────────
+
+ ///
+ /// Sector/timing-gate table. Each record carries a V-coordinate, a sector ID,
+ /// and a gate flag (−1 = sentinel, 0 = timing gate, 1 = normal checkpoint).
+ /// In the forward file the records are in ascending V order:
+ /// [0] SectorId=0, GateFlag=−1, V=0 — global origin sentinel
+ /// [1] SectorId=1, GateFlag=−1, V≈0 — start/finish gate sentinel
+ /// [2…N-1] SectorId=2, GateFlag=0 or 1 — main track (gates and normals)
+ ///
+ public List GateRecords { get; set; } = [];
+
+ ///
+ /// Starting-grid positions. Same 16-byte {X,Y,Z,Rotation} layout as RNW4.
+ /// GTRW does not use packed config slots — gate V-coords live in GateRecords.
+ ///
+ public List SpawnPositions { get; set; } = [];
+
+ ///
+ /// Raw bytes covering everything from the end of the spawn block to EOF.
+ /// Includes checkpoint pairs, cluster table, road mesh, BSP tree, etc.
+ /// Preserved verbatim on Write().
+ ///
+ public byte[] TrailingData { get; set; }
+
+ // ── Reading ──────────────────────────────────────────────────────────────
+
+ public GT3RunwayData FromStream(Stream stream)
+ {
+ BinaryStream bs = new BinaryStream(stream);
+ long basePos = bs.Position;
+
+ // ── Header ──────────────────────────────────────────────────────────
+ uint magic = bs.ReadUInt32(); // 0x00
+ if (magic != MAGIC)
+ throw new InvalidDataException(
+ $"Not a GTRW file (magic 0x{magic:X8}; expected 0x{MAGIC:X8}).");
+
+ Field04 = bs.ReadUInt32(); // 0x04
+ Field08 = bs.ReadUInt32(); // 0x08
+ RelocSize = bs.ReadUInt32(); // 0x0C
+ Field10 = bs.ReadUInt32(); // 0x10
+ Field14 = bs.ReadUInt32(); // 0x14
+ TrackV = bs.ReadSingle(); // 0x18
+ A1VCoord = bs.ReadSingle(); // 0x1C
+ HeaderField20 = bs.ReadUInt16(); // 0x20
+ int gateCount = bs.ReadUInt16(); // 0x22
+ uint gateOff = bs.ReadUInt32(); // 0x24 (= 0x60)
+ uint spawnCount = bs.ReadUInt32(); // 0x28
+ uint spawnOff = bs.ReadUInt32(); // 0x2C
+
+ OpaqueHeaderTail = bs.ReadBytes(0x30); // 0x30–0x5F (48 bytes)
+
+ // ── Gate records ─────────────────────────────────────────────────────
+ bs.Position = basePos + gateOff;
+ for (int i = 0; i < gateCount; i++)
+ GateRecords.Add(GT3GateRecord.FromStream(bs));
+
+ // ── Spawn positions ──────────────────────────────────────────────────
+ bs.Position = basePos + spawnOff;
+ for (int i = 0; i < (int)spawnCount; i++)
+ SpawnPositions.Add(RunwayStartingPosition.FromStream(bs));
+
+ // ── Trailing opaque data ─────────────────────────────────────────────
+ long trailingStart = basePos + spawnOff + spawnCount * RunwayStartingPosition.GetSize();
+ bs.Position = trailingStart;
+ TrailingData = bs.ReadBytes((int)(stream.Length - bs.Position));
+
+ return this;
+ }
+
+ // ── Gadget transplant ────────────────────────────────────────────────────
+
+ ///
+ /// Replaces this file's gadget section with the gadget section from a
+ /// reference reversed GTRW file. Gadgets define the physical checkpoint
+ /// trigger volumes (T1/T2/T3 gate beams and start-line stanchions) that
+ /// the game uses for timing detection. The reference file must be a
+ /// reversed variant of the same course so that world coordinates are valid.
+ ///
+ /// OpaqueHeaderTail byte indices (relative to file header offset 0x30):
+ /// [0x20] = header 0x50 = gadget_count (updated to reference count)
+ /// [0x24] = header 0x54 = gadget_offset (unchanged — same absolute start)
+ /// [0x2C] = header 0x5C = light_vfx_ptr (shifted by gadget size delta)
+ ///
+ ///
+ /// Replaces the gate/sector records from a reference reversed GTRW file.
+ /// The reference sectordata encodes the correct reversed-track V positions for the
+ /// a=1 finish-line sentinel (in the footer, not the header) and the T1/T2/T3
+ /// checkpoint thresholds that match the reversed pitbox V-coordinate system.
+ ///
+ public void ApplyReferenceSectorData(Stream referenceStream)
+ {
+ referenceStream.Position = 0;
+ var refBytes = new byte[referenceStream.Length];
+ referenceStream.ReadExactly(refBytes, 0, refBytes.Length);
+
+ // Gate records start at 0x60; count is at header 0x22; each record is 8 bytes.
+ int refGateCount = BitConverter.ToUInt16(refBytes, 0x22);
+ float refTrackV = BitConverter.ToSingle(refBytes, 0x18);
+ const int kGateOff = 0x60;
+ const int kGateStride = 8;
+
+ if (refGateCount <= 0 || kGateOff + refGateCount * kGateStride > refBytes.Length) return;
+
+ GateRecords.Clear();
+ for (int i = 0; i < refGateCount; i++)
+ {
+ int off = kGateOff + i * kGateStride;
+ var rec = new GT3GateRecord
+ {
+ SectorId = BitConverter.ToInt16(refBytes, off),
+ GateFlag = BitConverter.ToInt16(refBytes, off + 2),
+ TrackV = BitConverter.ToSingle(refBytes, off + 4),
+ };
+ GateRecords.Add(rec);
+ }
+
+ // Sync A1VCoord header field to the reference file's a=1 sentinel value.
+ foreach (var rec in GateRecords)
+ if (rec.IsSentinel && rec.SectorId == 1) { A1VCoord = rec.TrackV; break; }
+ }
+
+ ///
+ /// Copies spawn grid positions (X/Z/Rotation) from a reference reversed GTRW file.
+ /// The spawn grid must be on the correct side of the finish line gate; using forward
+ /// spawn positions places cars on the wrong side, causing wrong-way detection and a
+ /// misplaced start-line trigger.
+ ///
+ public void ApplyReferenceSpawns(Stream referenceStream)
+ {
+ referenceStream.Position = 0;
+ var refBytes = new byte[referenceStream.Length];
+ referenceStream.ReadExactly(refBytes, 0, refBytes.Length);
+
+ uint refSpawnCount = BitConverter.ToUInt32(refBytes, 0x28);
+ uint refSpawnOffset = BitConverter.ToUInt32(refBytes, 0x2C);
+ int stride = RunwayStartingPosition.GetSize(); // 16
+
+ if (refSpawnOffset == 0 || refSpawnCount == 0) return;
+ if ((int)(refSpawnOffset + refSpawnCount * stride) > refBytes.Length) return;
+
+ // Replace our SpawnPositions with those from the reference file.
+ SpawnPositions.Clear();
+ for (int i = 0; i < (int)refSpawnCount; i++)
+ {
+ int off = (int)(refSpawnOffset + i * stride);
+ var sp = new RunwayStartingPosition
+ {
+ X = BitConverter.ToSingle(refBytes, off),
+ Y = BitConverter.ToSingle(refBytes, off + 4),
+ Z = BitConverter.ToSingle(refBytes, off + 8),
+ Rotation = BitConverter.ToSingle(refBytes, off + 12),
+ };
+ SpawnPositions.Add(sp);
+ }
+ }
+
+ public void ApplyReferenceGadgets(Stream referenceStream)
+ {
+ var refBytes = new byte[referenceStream.Length];
+ referenceStream.ReadExactly(refBytes, 0, refBytes.Length);
+
+ // ── Read gadget bounds from the reference file ────────────────────
+ uint refGadCount = BitConverter.ToUInt32(refBytes, 0x50);
+ uint refGadOffset = BitConverter.ToUInt32(refBytes, 0x54);
+ uint refLvxOffset = BitConverter.ToUInt32(refBytes, 0x5C);
+ if (refGadOffset == 0 || refLvxOffset <= refGadOffset) return; // nothing to copy
+ int refGadSize = (int)(refLvxOffset - refGadOffset);
+
+ // ── Our current gadget bounds (from OpaqueHeaderTail) ─────────────
+ uint ourGadOffset = BitConverter.ToUInt32(OpaqueHeaderTail, 0x24); // header 0x54
+ uint ourLvxOffset = BitConverter.ToUInt32(OpaqueHeaderTail, 0x2C); // header 0x5C
+ if (ourGadOffset == 0 || ourLvxOffset <= ourGadOffset) return;
+ int ourGadSize = (int)(ourLvxOffset - ourGadOffset);
+
+ // ── Locate sections within TrailingData ───────────────────────────
+ uint spawnSectOff = (uint)(0x60u + GateRecords.Count * GT3GateRecord.GetSize());
+ uint spawnEnd = (uint)(spawnSectOff + SpawnPositions.Count * RunwayStartingPosition.GetSize());
+
+ int tdGadStart = (int)(ourGadOffset - spawnEnd); // gadget start in TrailingData
+ int tdAfterGads = (int)(ourLvxOffset - spawnEnd); // light_vfx start in TrailingData
+ if (tdGadStart < 0 || tdAfterGads > TrailingData.Length) return;
+
+ // ── Extract new gadget bytes from reference file ───────────────────
+ var newGadBytes = new byte[refGadSize];
+ Array.Copy(refBytes, (int)refGadOffset, newGadBytes, 0, refGadSize);
+
+ // ── Rebuild TrailingData with new gadgets ─────────────────────────
+ int sizeDelta = refGadSize - ourGadSize;
+ var newTrailing = new byte[TrailingData.Length + sizeDelta];
+
+ Array.Copy(TrailingData, 0, newTrailing, 0, tdGadStart); // before gadgets
+ Array.Copy(newGadBytes, 0, newTrailing, tdGadStart, refGadSize); // new gadgets
+ Array.Copy(TrailingData, tdAfterGads, newTrailing, // light_vfx+
+ tdGadStart + refGadSize, TrailingData.Length - tdAfterGads);
+
+ TrailingData = newTrailing;
+
+ // ── Patch OpaqueHeaderTail fields ─────────────────────────────────
+ // gadget_count (OpaqueHeaderTail[0x20] = header 0x50)
+ PatchU32(OpaqueHeaderTail, 0x20, refGadCount);
+
+ // light_vfx_ptr (OpaqueHeaderTail[0x2C] = header 0x5C)
+ PatchU32(OpaqueHeaderTail, 0x2C, (uint)(ourGadOffset + refGadSize));
+
+ // RelocSize (header 0x0C) is patched by Write() from actual output length.
+ }
+
+ private static void PatchU32(byte[] buf, int off, uint val)
+ {
+ buf[off] = (byte)(val & 0xFF);
+ buf[off+1] = (byte)((val >> 8) & 0xFF);
+ buf[off+2] = (byte)((val >> 16) & 0xFF);
+ buf[off+3] = (byte)((val >> 24) & 0xFF);
+ }
+
+ // ── Writing ──────────────────────────────────────────────────────────────
+
+ public void Write(Stream stream)
+ {
+ BinaryStream bs = new BinaryStream(stream);
+ long basePos = bs.Position;
+
+ // Gate and spawn section offsets are determined by layout:
+ // Gate section always starts at 0x60 (immediately after the 96-byte header).
+ // Spawn section follows immediately after the gate records.
+ const uint gateOff = 0x60u;
+ uint spawnOff = (uint)(gateOff + GateRecords.Count * GT3GateRecord.GetSize());
+
+ // ── Header ──────────────────────────────────────────────────────────
+ bs.WriteUInt32(MAGIC); // 0x00
+ bs.WriteUInt32(Field04); // 0x04
+ bs.WriteUInt32(Field08); // 0x08
+ long relocPos = bs.Position;
+ bs.WriteUInt32(0); // 0x0C placeholder — patched below
+ bs.WriteUInt32(Field10); // 0x10
+ bs.WriteUInt32(Field14); // 0x14
+ bs.WriteSingle(TrackV); // 0x18
+ bs.WriteSingle(A1VCoord); // 0x1C
+ bs.WriteUInt16(HeaderField20); // 0x20
+ bs.WriteUInt16((ushort)GateRecords.Count); // 0x22
+ bs.WriteUInt32(gateOff); // 0x24
+ bs.WriteUInt32((uint)SpawnPositions.Count); // 0x28
+ bs.WriteUInt32(spawnOff); // 0x2C
+ bs.WriteBytes(OpaqueHeaderTail); // 0x30–0x5F
+
+ // ── Gate records ─────────────────────────────────────────────────────
+ // Position should already be at basePos + gateOff = basePos + 0x60.
+ foreach (var rec in GateRecords)
+ rec.ToStream(bs);
+
+ // ── Spawn positions ──────────────────────────────────────────────────
+ foreach (var sp in SpawnPositions)
+ sp.ToStream(bs);
+
+ // ── Trailing opaque data ─────────────────────────────────────────────
+ bs.WriteBytes(TrailingData);
+
+ // ── Patch RelocSize ──────────────────────────────────────────────────
+ long endPos = bs.Position;
+ bs.Position = relocPos;
+ bs.WriteUInt32((uint)(endPos - basePos - 16));
+ bs.Position = endPos;
+ }
+
+ // ── Reversal ─────────────────────────────────────────────────────────────
+
+ public void ReverseDirection()
+ {
+ float totalTrackV = TrackV;
+ int N = GateRecords.Count;
+
+ uint spawnSectOff = (uint)(0x60u + N * GT3GateRecord.GetSize());
+ uint spawnEnd = (uint)(spawnSectOff + SpawnPositions.Count * RunwayStartingPosition.GetSize());
+
+ uint pfFileOff = BitConverter.ToUInt32(OpaqueHeaderTail, 0x14);
+ int pfCount = (int)BitConverter.ToUInt32(OpaqueHeaderTail, 0x10);
+ int tdPfStart = (int)(pfFileOff - spawnEnd);
+ const int kPfStride = 32;
+
+ // ── Step 1: Read start/finish line geometry from the forward path spline ─
+ // The start/finish gate is at the max-V → min-V wrap point in the pitbox.
+ // The entry with the MAXIMUM V is physically just before the finish line —
+ // verified against a known-good same-size fwd/rev pair: spawn midpoints
+ // cluster near max-V.M, not min-V.M (which is ~27 m off on the far side).
+ int sfIdx = 0; float sfMaxV = float.MinValue;
+ if (tdPfStart >= 0 && tdPfStart + pfCount * kPfStride <= TrailingData.Length)
+ {
+ for (int k = 0; k < pfCount; k++)
+ {
+ float v = BitConverter.ToSingle(TrailingData, tdPfStart + k * kPfStride + 16);
+ if (v > sfMaxV) { sfMaxV = v; sfIdx = k; }
+ }
+ }
+ int sfBase = tdPfStart + sfIdx * kPfStride;
+ float sfLx = BitConverter.ToSingle(TrailingData, sfBase + 0);
+ float sfLz = BitConverter.ToSingle(TrailingData, sfBase + 4);
+ float sfRx = BitConverter.ToSingle(TrailingData, sfBase + 8);
+ float sfRz = BitConverter.ToSingle(TrailingData, sfBase + 12);
+ float sfNx = BitConverter.ToSingle(TrailingData, sfBase + 20); // road tangent = line normal
+ float sfNz = BitConverter.ToSingle(TrailingData, sfBase + 24);
+ float sfMx = (sfLx + sfRx) * 0.5f;
+ float sfMz = (sfLz + sfRz) * 0.5f;
+
+ // ── Step 2: V-mirror sector records, then re-sort ascending ──────────
+ // Verified against known-good fwd/rev pair: mirror dist (L−d), re-sort
+ // ascending, sentinels kept unchanged (gate[0].V=0, gate[1].V=0.025).
+ // Re-sorting puts timing gates (gateFlag=0) in the order they are crossed
+ // going CCW, so the game's sequential scan correctly assigns T1/T2/T3.
+ for (int g = 0; g < GateRecords.Count; g++)
+ {
+ var rec = GateRecords[g];
+ if (rec.SectorId == 0 && rec.GateFlag == -1) continue; // a=0 only: keep V=0
+ rec.TrackV = totalTrackV - rec.TrackV; // a=1 mirrors to ≈3921 → sorts to end
+ }
+ GateRecords.Sort((a, b) => a.TrackV.CompareTo(b.TrackV));
+ A1VCoord = GateRecords.FirstOrDefault(r => r.IsSentinel && r.SectorId == 1)?.TrackV ?? A1VCoord;
+
+ // ── Step 3: Reflect start grid across the start/finish line ──────────
+ // Verified against known-good pair: spawns are REFLECTED across the gate
+ // plane (midpoint sfM, unit normal sfN = road tangent), not just rotated.
+ // Reflecting position and heading: heading component along sfN negated.
+ // Convention: dir = (sin rot, cos rot) in (X, Z) — verified by reflection
+ // formula giving +π/2 → −π/2 for an X-axis-normal start line.
+ foreach (var sp in SpawnPositions)
+ {
+ float d = (sp.X - sfMx) * sfNx + (sp.Z - sfMz) * sfNz;
+ sp.X -= 2f * d * sfNx;
+ sp.Z -= 2f * d * sfNz;
+ float sinR = MathF.Sin(sp.Rotation), cosR = MathF.Cos(sp.Rotation);
+ float proj = sinR * sfNx + cosR * sfNz;
+ sp.Rotation = MathF.Atan2(sinR - 2f * proj * sfNx, cosR - 2f * proj * sfNz);
+ }
+
+ // ── Step 4: Reverse path spline — 5-part transform ───────────────────
+ // Verified 116/116 against known-good fwd/rev pair:
+ // 1. Reverse station order: new[i] ← fwd[(N−2−i) mod N]
+ // 2. Swap L/R edge points: (Lx,Lz) ↔ (Rx,Rz)
+ // 3. Mirror lap distance: dist → TrackV − dist
+ // 4. Negate tangent: (tanX,tanZ) → (−tanX,−tanZ)
+ // 5. Negate aux (f7): aux → −aux ← the field the old code missed
+ // Collision mesh, vertex pool, spatial grids, tail: byte-identical — no transform.
+ if (tdPfStart >= 0 && tdPfStart + pfCount * kPfStride <= TrailingData.Length)
+ {
+ var pfCopy = new byte[pfCount * kPfStride];
+ Array.Copy(TrailingData, tdPfStart, pfCopy, 0, pfCopy.Length);
+
+ for (int i = 0; i < pfCount; i++)
+ {
+ int srcIdx = ((pfCount - 2 - i) % pfCount + pfCount) % pfCount;
+ int src = srcIdx * kPfStride;
+ int dst = tdPfStart + i * kPfStride;
+
+ float f0 = BitConverter.ToSingle(pfCopy, src);
+ float f1 = BitConverter.ToSingle(pfCopy, src + 4);
+ float f2 = BitConverter.ToSingle(pfCopy, src + 8);
+ float f3 = BitConverter.ToSingle(pfCopy, src + 12);
+ float f4 = BitConverter.ToSingle(pfCopy, src + 16);
+ float f5 = BitConverter.ToSingle(pfCopy, src + 20);
+ float f6 = BitConverter.ToSingle(pfCopy, src + 24);
+ float f7 = BitConverter.ToSingle(pfCopy, src + 28);
+
+ void WriteF(int off, float val)
+ => Array.Copy(BitConverter.GetBytes(val), 0, TrailingData, dst + off, 4);
+
+ WriteF( 0, f2); WriteF( 4, f3); // Left ← old Right
+ WriteF( 8, f0); WriteF(12, f1); // Right ← old Left
+ WriteF(16, totalTrackV - f4); // mirror dist
+ WriteF(20, -f5); WriteF(24, -f6); // negate tangent
+ WriteF(28, -f7); // negate aux
+ }
+
+ // Rotate so the entry with minimum V (= physical start/finish) is at index 0.
+ // The game renders the start-line marker at entry[0]; without rotation
+ // entry[0] sits at V≈1957 (the forward mid-track), placing the visual
+ // start line halfway around the circuit.
+ int minVIdx2 = 0; float minV2 = float.MaxValue;
+ for (int k = 0; k < pfCount; k++)
+ {
+ float v = BitConverter.ToSingle(TrailingData, tdPfStart + k * kPfStride + 16);
+ if (v < minV2) { minV2 = v; minVIdx2 = k; }
+ }
+ if (minVIdx2 != 0)
+ {
+ var tmp = new byte[pfCount * kPfStride];
+ Array.Copy(TrailingData, tdPfStart, tmp, 0, tmp.Length);
+ for (int i = 0; i < pfCount; i++)
+ {
+ int srcIdx = (i + minVIdx2) % pfCount;
+ Array.Copy(tmp, srcIdx * kPfStride,
+ TrailingData, tdPfStart + i * kPfStride, kPfStride);
+ }
+ }
+ }
+ // ── Step 6: Reverse groundcolldata spatial grid (TODO — structure TBD) ─
+ // The spatial grid (cell→node-index tables) encodes traversal direction.
+ // Correct transformation requires understanding the exact cell-list format
+ // to avoid over-modifying non-node values. Currently disabled.
+ if (false) // placeholder — do not execute
+ // ── Step 6 body ──────────────────────────────────────────────────────
+ // The spatial grid maps world-space cells to mesh node indices.
+ // For the reversed track, each node index k must become (S_fwd − k + N) % N,
+ // where S_fwd is the forward node closest to the start/finish gate (found
+ // via vertex centroid distance) and N is the total node count.
+ // This encodes the CCW traversal order without re-tessellating the mesh.
+ //
+ // Layout within groundcolldata (starting at magic 0x780053):
+ // [0 .. N×64−1] node descriptors (64 B each)
+ // [N×64 .. minOffA−1] spatial grid (the section we remap)
+ // [minOffA ..] vertex / triangle blocks (untouched)
+ {
+ // Locate groundcolldata: first try the magic 0x780053 (present in some tracks),
+ // otherwise fall back to scanning from the start of TrailingData.
+ // Some tracks (e.g. smtsouth) have no magic — node descriptors begin immediately.
+ const uint kGcMag = 0x00780053u;
+ int gcS = -1;
+ for (int p = 0; p + 4 <= TrailingData.Length; p++)
+ if (BitConverter.ToUInt32(TrailingData, p) == kGcMag) { gcS = p; break; }
+ if (gcS < 0) gcS = 0; // no magic — scan from beginning of TrailingData
+
+ {
+ // Find first valid 64-byte node descriptor: n1>5, n2>5, deg 1-20, zeros at +28..+63
+ int dBase = gcS;
+ for (int p = gcS; p + 64 <= TrailingData.Length; p++)
+ {
+ int dn1 = BitConverter.ToUInt16(TrailingData, p);
+ int dn2 = BitConverter.ToUInt16(TrailingData, p + 2);
+ int ddeg = BitConverter.ToUInt16(TrailingData, p + 22);
+ if (dn1 > 5 && dn2 > 5 && ddeg >= 1 && ddeg <= 20)
+ { dBase = p; break; }
+ }
+
+ // Count consecutive valid descriptors → N
+ // Only require n1>0, n2>0, deg in [1,20] — not all-zeros at +28..+63,
+ // because detail/junction nodes may have neighbour data there.
+ int gcN = 0;
+ while (true)
+ {
+ int bp = dBase + gcN * 64;
+ if (bp + 64 > TrailingData.Length) break;
+ int dn1 = BitConverter.ToUInt16(TrailingData, bp);
+ int dn2 = BitConverter.ToUInt16(TrailingData, bp + 2);
+ int ddeg = BitConverter.ToUInt16(TrailingData, bp + 22);
+ if (dn1 < 1 || dn2 < 1 || ddeg < 1 || ddeg > 20) break;
+ gcN++;
+ }
+
+ if (gcN >= 2)
+ {
+ // Full node count for mod-N arithmetic: prefer the 4-byte count stored
+ // just before the descriptor array (or at the start of TrailingData).
+ // This includes detail/junction nodes that may not follow the sequential
+ // 64-byte layout. Fall back to gcN if no valid count is found.
+ int gcNFull = gcN;
+ {
+ // Try: 4 bytes just before dBase
+ if (dBase >= 4)
+ {
+ int tryN = (int)BitConverter.ToUInt32(TrailingData, dBase - 4);
+ if (tryN >= gcN && tryN < 4096) gcNFull = tryN;
+ }
+ // Try: first 4 bytes of TrailingData
+ if (gcNFull == gcN && TrailingData.Length >= 4)
+ {
+ int tryN = (int)BitConverter.ToUInt32(TrailingData, 0);
+ if (tryN >= gcN && tryN < 4096) gcNFull = tryN;
+ }
+ }
+
+ // offA in the descriptor may be:
+ // ABSOLUTE (file offset) — tracks with magic header, offA > spawnEnd + nodeDataBase
+ // RELATIVE (to node data area = dBase + gcN×64) — tracks without magic
+ // Auto-detect by checking whether offA[0] exceeds the node-data-area file offset.
+ int nodeDataBase = dBase + gcN * 64;
+ uint offA0 = BitConverter.ToUInt32(TrailingData, dBase + 4);
+ bool absoluteOffA = (offA0 > (uint)(spawnEnd + nodeDataBase));
+
+ int sFwd = 0;
+ float minNodeDist = float.MaxValue;
+ int minVtxTd = TrailingData.Length;
+
+ for (int n = 0; n < gcN; n++)
+ {
+ int bp = dBase + n * 64;
+ int dn1 = BitConverter.ToUInt16(TrailingData, bp);
+ uint offA = BitConverter.ToUInt32(TrailingData, bp + 4);
+
+ int vtxTd = absoluteOffA
+ ? (int)(offA - spawnEnd) // file-absolute → TrailingData offset
+ : nodeDataBase + (int)offA; // section-relative → TrailingData offset
+
+ if (vtxTd < minVtxTd) minVtxTd = vtxTd;
+ if (vtxTd < 0 || vtxTd + dn1 * 16 > TrailingData.Length) continue;
+
+ float cx = 0f, cz = 0f;
+ for (int v = 0; v < dn1; v++)
+ {
+ cx += BitConverter.ToSingle(TrailingData, vtxTd + v * 16 + 0);
+ cz += BitConverter.ToSingle(TrailingData, vtxTd + v * 16 + 8);
+ }
+ cx /= dn1; cz /= dn1;
+ float d = (cx - sfMx) * (cx - sfMx) + (cz - sfMz) * (cz - sfMz);
+ if (d < minNodeDist) { minNodeDist = d; sFwd = n; }
+ }
+
+ // Spatial grid: from end of descriptor array to start of first vertex block
+ int gridStart2 = nodeDataBase;
+ int gridEnd2 = (minVtxTd < TrailingData.Length) ? minVtxTd : TrailingData.Length;
+ if (gridEnd2 > TrailingData.Length) gridEnd2 = TrailingData.Length;
+
+ // Remap every uint16 in [0, gcNFull) within the spatial grid.
+ // Use gcNFull (full node count) for the modular formula so that
+ // detail/junction nodes (indices >= gcN) are also correctly remapped.
+ for (int off = gridStart2; off + 2 <= gridEnd2; off += 2)
+ {
+ int k = BitConverter.ToUInt16(TrailingData, off);
+ if (k < gcNFull)
+ {
+ int newK = ((sFwd - k) % gcNFull + gcNFull) % gcNFull;
+ TrailingData[off] = (byte)(newK & 0xFF);
+ TrailingData[off + 1] = (byte)(newK >> 8);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // ── Private helpers ──────────────────────────────────────────────────────
+
+ private static float NormaliseAngle(float rad)
+ {
+ const float TwoPi = MathF.PI * 2f;
+ rad %= TwoPi;
+ if (rad > MathF.PI) rad -= TwoPi;
+ if (rad <= -MathF.PI) rad += TwoPi;
+ return rad;
+ }
+}
diff --git a/PDTools.Files/Courses/PS2/Runway/GT3RunwayReverser.cs b/PDTools.Files/Courses/PS2/Runway/GT3RunwayReverser.cs
new file mode 100644
index 00000000..705473f6
--- /dev/null
+++ b/PDTools.Files/Courses/PS2/Runway/GT3RunwayReverser.cs
@@ -0,0 +1,301 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+
+namespace PDTools.Files.Courses.PS2.Runway;
+
+///
+/// Reverses the driving direction of a GTRW "CourseRunwayData" runway file
+/// (full single-file, little-endian format). Validated byte-for-byte against
+/// known-good forward/reverse pairs (smtsouth: 0 bytes off).
+///
+/// Four transforms flip the lap — all other sections are left unchanged:
+/// 1. Path spline — new[k] = T(fwd[(PC-2-k)%PC]); swap L/R, mirror dist,
+/// negate tangent and aux.
+/// 2. Start grid — rotated 180° about the start/finish point.
+/// 3. Sectors — dist mirrored (L-d), re-sorted ascending; start/finish
+/// markers (type≠2) stay at distance 0.
+/// 4. Mesh — each node's g path-segment refs reflected by S=(PC-3)%PC
+/// and re-sorted ascending. Geometry and adjacency unchanged.
+///
+/// File size is preserved — header offsets and EOF need no changes.
+///
+public static class GT3RunwayReverser
+{
+ // ── Helpers ──────────────────────────────────────────────────────────────
+
+ private static ushort RU16(byte[] b, int o) => BitConverter.ToUInt16(b, o);
+ private static uint RU32(byte[] b, int o) => BitConverter.ToUInt32(b, o);
+ private static float RF32(byte[] b, int o) => BitConverter.ToSingle(b, o);
+
+ private static void WU16(byte[] b, int o, int v)
+ {
+ b[o] = (byte)(v & 0xFF);
+ b[o + 1] = (byte)((v >> 8) & 0xFF);
+ }
+ private static void WF32(byte[] b, int o, float v)
+ => Array.Copy(BitConverter.GetBytes(v), 0, b, o, 4);
+
+ // Floating-point modulo, always non-negative.
+ private static float FMod(float x, float m)
+ {
+ float r = x % m;
+ return r < 0f ? r + m : r;
+ }
+
+ // ── Section descriptor ───────────────────────────────────────────────────
+
+ private readonly struct Info
+ {
+ public readonly int Size;
+ public readonly float L; // lap length
+ public readonly int SectorCount;
+ public readonly int GridCount, GridOff; // start grid (spawn slots)
+ public readonly int MeshCount, MeshOff; // collision mesh nodes
+ public readonly int PoolOff; // surface vertex pool
+ public readonly int PathCount, PathOff; // centreline path spline
+ public readonly int Grid1Off, Grid2Off; // broadphase grids
+ public readonly int TailOff; // staging / tail block
+
+ public Info(byte[] d)
+ {
+ Size = d.Length;
+ L = RF32(d, 0x18);
+ SectorCount = RU16(d, 0x22);
+ GridCount = (int)RU32(d, 0x28);
+ GridOff = (int)RU32(d, 0x2C);
+ MeshCount = (int)RU32(d, 0x30);
+ MeshOff = (int)RU32(d, 0x34);
+ PoolOff = (int)RU32(d, 0x3C);
+ PathCount = (int)RU32(d, 0x40);
+ PathOff = (int)RU32(d, 0x44);
+ Grid1Off = (int)RU32(d, 0x48);
+ Grid2Off = (int)RU32(d, 0x4C);
+ TailOff = (int)RU32(d, 0x54);
+ }
+ }
+
+ // ── Validation ───────────────────────────────────────────────────────────
+
+ private static Info Validate(byte[] d)
+ {
+ if (d.Length < 0x60
+ || d[0] != (byte)'G' || d[1] != (byte)'T'
+ || d[2] != (byte)'R' || d[3] != (byte)'W')
+ throw new InvalidDataException("Not a GTRW runway file (missing GTRW magic).");
+
+ var info = new Info(d);
+
+ if (RU32(d, 0x24) != 0x60)
+ throw new InvalidDataException("Unexpected sectors offset — layout not supported.");
+ if (info.L <= 10f || info.L >= 1e6f)
+ throw new InvalidDataException("Lap length out of range — aborting to avoid corruption.");
+
+ int[] order = { info.MeshOff, info.PoolOff, info.PathOff,
+ info.Grid1Off, info.Grid2Off, info.TailOff };
+ for (int i = 0; i < order.Length; i++)
+ if (order[i] < 0x60 || order[i] > info.Size)
+ throw new InvalidDataException("Section offsets inconsistent — file not supported.");
+ for (int i = 1; i < order.Length; i++)
+ if (order[i] < order[i - 1])
+ throw new InvalidDataException("Section offsets not monotonically increasing — not supported.");
+
+ if (info.MeshCount < 2 || info.MeshCount > 100_000)
+ throw new InvalidDataException("Mesh node count out of range — aborting.");
+
+ return info;
+ }
+
+ // ── Transform 1: reverse the centreline path spline ─────────────────────
+ // new[k] = T(fwd[(PC-2-k) % PC]) — swap L/R, mirror dist, negate tan+aux.
+
+ private static void ReversePath(byte[] d, in Info i)
+ {
+ int po = i.PathOff, pc = i.PathCount;
+ float L = i.L;
+
+ var snap = new float[pc, 8];
+ for (int s = 0; s < pc; s++)
+ for (int f = 0; f < 8; f++)
+ snap[s, f] = RF32(d, po + s * 32 + f * 4);
+
+ for (int k = 0; k < pc; k++)
+ {
+ int src = ((pc - 2 - k) % pc + pc) % pc;
+ float Lx = snap[src, 0], Lz = snap[src, 1];
+ float Rx = snap[src, 2], Rz = snap[src, 3];
+ float dist = snap[src, 4];
+ float tx = snap[src, 5], tz = snap[src, 6], aux = snap[src, 7];
+ int dst = po + k * 32;
+ WF32(d, dst + 0, Rx);
+ WF32(d, dst + 4, Rz);
+ WF32(d, dst + 8, Lx);
+ WF32(d, dst + 12, Lz);
+ WF32(d, dst + 16, FMod(L - dist, L));
+ WF32(d, dst + 20, -tx);
+ WF32(d, dst + 24, -tz);
+ WF32(d, dst + 28, -aux);
+ }
+ }
+
+ // ── Transform 2: rotate start grid 180° about the start/finish point ────
+ // Must be called BEFORE ReversePath so it reads the forward centreline.
+
+ private static void ReverseStartGrid(byte[] d, in Info i)
+ {
+ int go = i.GridOff, gc = i.GridCount;
+ int po = i.PathOff, pc = i.PathCount;
+ float L = i.L;
+
+ // Read forward path stations (8 floats: Lx,Lz,Rx,Rz,dist,tx,tz,aux)
+ var midX = new float[pc]; var midZ = new float[pc];
+ var dist = new float[pc];
+ var tanX = new float[pc]; var tanZ = new float[pc];
+ for (int s = 0; s < pc; s++)
+ {
+ int o = po + s * 32;
+ float lx = RF32(d, o), lz = RF32(d, o + 4), rx = RF32(d, o + 8), rz = RF32(d, o + 12);
+ midX[s] = (lx + rx) * 0.5f;
+ midZ[s] = (lz + rz) * 0.5f;
+ dist[s] = RF32(d, o + 16);
+ tanX[s] = RF32(d, o + 20);
+ tanZ[s] = RF32(d, o + 24);
+ }
+
+ // Find the dist→0 wrap (largest drop between consecutive distances)
+ int w = 0; float maxGap = float.MinValue;
+ for (int s = 0; s < pc; s++)
+ {
+ float gap = dist[s] - dist[(s + 1) % pc];
+ if (gap > maxGap) { maxGap = gap; w = s; }
+ }
+ int w2 = (w + 1) % pc;
+ float den = (L - dist[w]) + dist[w2];
+ float f = den > 1e-6f ? (L - dist[w]) / den : 0f;
+
+ float p0x = midX[w] + f * (midX[w2] - midX[w]);
+ float p0z = midZ[w] + f * (midZ[w2] - midZ[w]);
+ float tx0 = tanX[w] + f * (tanX[w2] - tanX[w]);
+ float tz0 = tanZ[w] + f * (tanZ[w2] - tanZ[w]);
+ float tl = MathF.Sqrt(tx0 * tx0 + tz0 * tz0);
+ if (tl < 1e-10f) tl = 1f;
+ float px = -tz0 / tl; // perpendicular to tangent = start-line direction
+ float pz = tx0 / tl;
+
+ // Project grid centroid onto the start line to get the reflection centre
+ float cx = 0f, cz = 0f;
+ for (int s = 0; s < gc; s++) { cx += RF32(d, go + s * 16); cz += RF32(d, go + s * 16 + 8); }
+ cx /= gc; cz /= gc;
+ float dot = (cx - p0x) * px + (cz - p0z) * pz;
+ float rx2 = p0x + dot * px, rz2 = p0z + dot * pz;
+
+ // Reflect each slot: x → 2rx-x, z → 2rz-z, heading → atan2(-sin,-cos)
+ for (int s = 0; s < gc; s++)
+ {
+ int o = go + s * 16;
+ float x = RF32(d, o), y = RF32(d, o + 4), z = RF32(d, o + 8), h = RF32(d, o + 12);
+ WF32(d, o + 0, 2f * rx2 - x);
+ WF32(d, o + 4, y);
+ WF32(d, o + 8, 2f * rz2 - z);
+ WF32(d, o + 12, MathF.Atan2(-MathF.Sin(h), -MathF.Cos(h)));
+ }
+ }
+
+ // ── Transform 3: mirror sector / checkpoint distances ────────────────────
+ // type≠2 markers stay at dist=0; type-2 records mirrored (L-d), re-sorted.
+
+ private static void ReverseSectors(byte[] d, in Info i)
+ {
+ int sc = i.SectorCount;
+ float L = i.L;
+
+ var markers = new List<(int t, int fl, float dist)>();
+ var feats = new List<(int t, int fl, float dist)>();
+ for (int s = 0; s < sc; s++)
+ {
+ int o = 0x60 + s * 8;
+ int type = RU16(d, o), flag = RU16(d, o + 2);
+ float dt = RF32(d, o + 4);
+ (type != 2 ? markers : feats).Add((type, flag, dt));
+ }
+
+ for (int s = 0; s < feats.Count; s++)
+ {
+ var (t, fl, dt) = feats[s];
+ feats[s] = (t, fl, FMod(L - dt, L));
+ }
+ feats.Sort((a, b) => a.dist.CompareTo(b.dist));
+
+ int idx = 0;
+ foreach (var (t, fl, dt) in markers) { int o = 0x60 + idx++ * 8; WU16(d, o, t); WU16(d, o + 2, fl); WF32(d, o + 4, dt); }
+ foreach (var (t, fl, dt) in feats) { int o = 0x60 + idx++ * 8; WU16(d, o, t); WU16(d, o + 2, fl); WF32(d, o + 4, dt); }
+ }
+
+ // ── Transform 4: reflect ground-collision path-segment references ─────────
+ // Each node carries g segment indices (at its offC record); S = (PC-3)%PC
+ // derived from the path reversal formula. Re-sorted ascending per node.
+ // Geometry, adjacency and all other node data are left unchanged.
+
+ private static void ReverseMesh(byte[] d, in Info i)
+ {
+ int mo = i.MeshOff, N = i.MeshCount, PC = i.PathCount;
+ int S = (PC - 3 + PC) % PC;
+
+ for (int n = 0; n < N; n++)
+ {
+ int o = mo + n * 64;
+ int g = RU16(d, o + 20); // geom-count = path-segment ref count
+ int offC = (int)RU32(d, o + 16); // absolute offset of node's 16-byte record
+
+ var refs = new int[g];
+ for (int j = 0; j < g; j++)
+ refs[j] = ((S - RU16(d, offC + j * 2)) % PC + PC) % PC;
+ Array.Sort(refs);
+
+ for (int j = 0; j < g; j++)
+ WU16(d, offC + j * 2, refs[j]);
+ }
+ }
+
+ // ── Public API ───────────────────────────────────────────────────────────
+
+ ///
+ /// Returns a new byte array containing the reversed GTRW runway.
+ /// Throws if the file format is not supported.
+ ///
+ public static byte[] Reverse(byte[] data)
+ {
+ var d = (byte[])data.Clone();
+ var info = Validate(d);
+
+ ReverseStartGrid(d, info); // reads forward path — must precede ReversePath
+ ReversePath(d, info);
+ ReverseSectors(d, info);
+ ReverseMesh(d, info);
+
+ return d;
+ }
+
+ ///
+ /// Read , reverse it, write to .
+ /// Returns a summary line suitable for logging.
+ ///
+ public static string ReverseFile(string inputPath, string outputPath)
+ {
+ if (Path.GetFullPath(inputPath).Equals(
+ Path.GetFullPath(outputPath), StringComparison.OrdinalIgnoreCase))
+ throw new InvalidOperationException("Output path must differ from the input file.");
+
+ byte[] data = File.ReadAllBytes(inputPath);
+ var info = Validate(data); // validate before cloning
+ byte[] result = Reverse(data);
+
+ string dir = Path.GetDirectoryName(outputPath);
+ if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
+ File.WriteAllBytes(outputPath, result);
+
+ return $"{result.Length} bytes | lap {info.L:F2} m | " +
+ $"{info.MeshCount} mesh nodes | {info.SectorCount} sectors | " +
+ $"{info.PathCount} path stations";
+ }
+}
diff --git a/PDTools.Files/Courses/PS2/Runway/RunwayCluster.cs b/PDTools.Files/Courses/PS2/Runway/RunwayCluster.cs
index f47e461c..19daff66 100644
--- a/PDTools.Files/Courses/PS2/Runway/RunwayCluster.cs
+++ b/PDTools.Files/Courses/PS2/Runway/RunwayCluster.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
@@ -12,26 +12,48 @@ namespace PDTools.Files.Courses.PS2.Runway;
public class RunwayCluster
{
public ushort CheckpointLookupIndexStart { get; set; }
- public ushort CheckpointLookupLength { get; set; }
- public short[] TriIndices { get; set; }
+ public ushort CheckpointLookupLength { get; set; }
+ public short[] TriIndices { get; set; }
public static RunwayCluster FromStream(BinaryStream bs)
{
RunwayCluster cluster = new RunwayCluster();
- ushort triIndexCount = bs.ReadUInt16();
- cluster.CheckpointLookupLength = bs.ReadUInt16();
+ ushort triIndexCount = bs.ReadUInt16();
+ cluster.CheckpointLookupLength = bs.ReadUInt16();
cluster.CheckpointLookupIndexStart = bs.ReadUInt16();
- bs.ReadUInt16();
- int triIndicesOffset = bs.ReadInt32();
- bs.ReadInt32();
+ bs.ReadUInt16(); // padding
+ int triIndicesOffset = bs.ReadInt32();
+ bs.ReadInt32(); // padding
+ long savedPos = bs.Position;
bs.Position = triIndicesOffset;
cluster.TriIndices = bs.ReadInt16s((int)triIndexCount);
+ bs.Position = savedPos;
return cluster;
}
- public static int GetSize()
+ ///
+ /// Writes the 0x10-byte cluster header.
+ /// is the file-absolute byte offset of the
+ /// first tri-index short for this cluster (written into the header's triIndicesOffset field).
+ /// The actual tri-index data is written separately by .
+ ///
+ public void WriteHeader(BinaryStream bs, int absoluteTriDataOffset)
{
- return 0x10;
+ bs.WriteUInt16((ushort)TriIndices.Length);
+ bs.WriteUInt16(CheckpointLookupLength);
+ bs.WriteUInt16(CheckpointLookupIndexStart);
+ bs.WriteUInt16(0); // padding
+ bs.WriteInt32(absoluteTriDataOffset);
+ bs.WriteInt32(0); // padding
}
+
+ /// Writes the tri-index array (sequentially; call after all cluster headers).
+ public void WriteTriData(BinaryStream bs)
+ {
+ foreach (short idx in TriIndices)
+ bs.WriteInt16(idx);
+ }
+
+ public static int GetSize() => 0x10;
}
diff --git a/PDTools.Files/Courses/PS2/Runway/RunwayData.cs b/PDTools.Files/Courses/PS2/Runway/RunwayData.cs
index 7aed74e7..0ccf3762 100644
--- a/PDTools.Files/Courses/PS2/Runway/RunwayData.cs
+++ b/PDTools.Files/Courses/PS2/Runway/RunwayData.cs
@@ -1,12 +1,9 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
using System.Numerics;
using System.IO;
-using PDTools.Files;
using PDTools.Utils;
using Syroot.BinaryData;
@@ -15,225 +12,915 @@ namespace PDTools.Files.Courses.PS2.Runway;
public class RunwayData
{
- ///
- /// Version major of the runway.
- ///
- public ushort VersionMajor { get; set; }
+ // ── File magic ───────────────────────────────────────────────────────────
- ///
- /// Version minor of the runway.
- ///
- public ushort VersionMinor { get; set; }
+ /// '4WNR' big-endian magic.
+ public const uint MAGIC_BE = 0x524E5734u;
- ///
- /// Unknown
- ///
- public uint Flags { get; set; }
+ /// 'RNW4' little-endian magic.
+ public const uint MAGIC_LE = 0x34574E52u;
- ///
- /// Min model bound of the runway.
- ///
- public Vector3[] Bounds = default;
+ // ── Header fields ────────────────────────────────────────────────────────
- ///
- /// Increases depending on the track
- ///
- public uint UnkVal1 { get; set; }
+ public uint Magic { get; set; }
///
- /// Unknown
+ /// Value stored at header offset 0x08 (RelocSize / relocation data size).
+ /// Preserved here so the builder can write back the original value rather than
+ /// the actual computed file length. 0 = use computed size (safe default).
///
- public uint UnkVal2 { get; set; }
+ public uint OriginalRelocSize { get; set; }
- ///
- /// Unknown (GT6 Apricot Hill has it set)
- ///
- public uint UnkVal3 { get; set; }
+ /// Version word from offset 0x10 (high 16 = major, low 16 = minor).
+ public uint Version { get; set; }
+ public ushort VersionMajor => (ushort)(Version >> 16);
+ public ushort VersionMinor => (ushort)(Version & 0xFFFF);
- ///
- /// Track length in meters.
- ///
+ public uint Flags { get; set; }
+
+ /// Total track length in metres (the VCoord at wrap).
public float TrackV { get; set; }
public float StartVCoord { get; set; }
- public float GoalVCoord { get; set; }
+ public float GoalVCoord { get; set; }
- public List Checkpoints { get; set; } = [];
- public List CheckpointLookupIndices { get; set; } = [];
- public List Vertices { get; set; } = [];
- public List Tris { get; set; } = [];
- public List Clusters { get; set; } = [];
+ /// World-space AABB: [0] = min, [1] = max.
+ public Vector3[] Bounds { get; set; } = new Vector3[2];
+ // ── Counts stored verbatim from the header ───────────────────────────────
+ public short CheckpointListCount { get; set; } // 0x38
+ public short UnkCount { get; set; } // 0x3A
+ public int Unk0x40 { get; set; } // 0x40 (int32 after the first four counts)
+ public short GadgetsCount { get; set; } // 0x44
+
+ /// BSP tree depth (number of levels including root).
public byte TreeMaxDepth { get; set; }
- public Node Root { get; set; }
+
+ // ── Opaque header blobs ──────────────────────────────────────────────────
+
+ /// 19 raw bytes from header offset 0x4D to 0x5F (unknown count / flag fields).
+ public byte[] UnknownHeader0x4D { get; set; }
///
- /// '4WNR'
+ /// 60 raw bytes from header offset 0x84 to 0xBF.
+ /// The first four int32 words are absolute file offsets into the trailing-data region.
+ /// These are patched on write so they reflect the new layout; the stored copy always
+ /// contains the values as read from the source file.
///
- public const int MAGIC_BE = 0x524E5734;
+ public byte[] ExtraHeaderBytes { get; set; }
+
+ // ── Spawn positions ──────────────────────────────────────────────────────
///
- /// 'RNW4'
+ /// Six starting-grid positions at file offset 0xC0 (6 × 0x10 bytes).
+ /// Rotation is in radians; to reverse the course, add π (≈ 3.14159) to each angle.
///
- public const int MAGIC_LE = 0x34574E52;
+ public List SpawnPositions { get; set; } = [];
- public uint Magic { get; set; }
+ // ── Main data lists ──────────────────────────────────────────────────────
+
+ public List Checkpoints { get; set; } = [];
+ public List CheckpointLookupIndices { get; set; } = [];
+ public List Vertices { get; set; } = [];
+ public List Tris { get; set; } = [];
+ public List Clusters { get; set; } = [];
+
+ // ── Opaque section blobs (raw bytes including any trailing padding) ───────
+
+ public byte[] LightSetsRawBytes { get; set; }
+ public byte[] UnkOffset3RawBytes { get; set; }
+ public byte[] GadgetsRawBytes { get; set; }
+
+ ///
+ /// Raw bytes that follow the cluster tri-index arrays, referenced by the offset
+ /// words in . Contains physics/gadget/lighting data
+ /// whose internal structure is unknown; it is preserved verbatim on write.
+ /// This section is the cause of wall-collision failure when absent.
+ ///
+ public byte[] TrailingRawBytes { get; set; }
+
+ // ── BSP tree ─────────────────────────────────────────────────────────────
+
+ public Node Root { get; set; }
+
+ // ── Original-offset tracking (set by FromStream, used by Write) ──────────
+ // These let Write() detect which blob sections shared the same file position
+ // in the source file (so they're written only once) and correctly relocate the
+ // trailing data referenced by ExtraHeaderBytes.
+
+ private int _originalLightSetsOffset;
+ private int _originalUnkOffset3;
+ private int _originalGadgetsOffset;
+ private int _originalTrailingDataOffset; // absolute from file base; 0 = absent
+
+ // When a blob section's offset equals roadVertsOffset in the source file the
+ // blob is absent (the game just finds road verts there). Track these flags so
+ // Write() can reproduce the same header layout without mis-reading road-vert
+ // bytes as blob data.
+ private bool _lightSetsSharesWithRoadVerts;
+ private bool _unk3SharesWithRoadVerts;
+ private bool _gadgetsSharesWithRoadVerts;
+
+ // ── Reading ──────────────────────────────────────────────────────────────
public RunwayData FromStream(Stream stream)
{
BinaryStream bs = new BinaryStream(stream);
long basePos = bs.Position;
- RunwayData rwy = new RunwayData();
- rwy.Magic = bs.ReadUInt32();
+ Magic = bs.ReadUInt32(); // 0x00
- if (rwy.Magic == MAGIC_BE)
+ if (Magic == MAGIC_BE)
bs.ByteConverter = ByteConverter.Big;
- else if (rwy.Magic == MAGIC_LE)
+ else if (Magic == MAGIC_LE)
bs.ByteConverter = ByteConverter.Little;
else
- throw new InvalidDataException("Unsupported runway format.");
-
- bs.ReadInt32(); // Reloc Pointer
- bs.ReadUInt32(); // Reloc Size
- rwy.Flags = bs.ReadUInt32();
- uint version = bs.ReadUInt32(); // version
- rwy.TrackV = bs.ReadSingle();
- rwy.StartVCoord = bs.ReadSingle();
- rwy.GoalVCoord = bs.ReadSingle();
-
- rwy.Bounds =
- [
- new Vector3(bs.ReadSingle(), bs.ReadSingle(), bs.ReadSingle()),
- new Vector3(bs.ReadSingle(), bs.ReadSingle(), bs.ReadSingle())
- ];
-
- short checkpointListCount = bs.ReadInt16();
- short unkCount = bs.ReadInt16();
- short checkpointCount = bs.ReadInt16();
- short checkpointLookupIndicesCount = bs.ReadInt16();
- bs.ReadInt32();
- short gadgetsCount = bs.ReadInt16();
- short roadVerticesCount = bs.ReadInt16();
- short roadTriCount = bs.ReadInt16();
- short clusterCount = bs.ReadInt16();
- rwy.TreeMaxDepth = bs.Read1Byte();
-
- bs.Position = 0x60;
- int checkpointsOffset = bs.ReadInt32();
- int checkpointLookupIndicesOffset = bs.ReadInt32();
- int lightSetsOffset = bs.ReadInt32();
- int unkOffset3 = bs.ReadInt32();
- int gadgetsOffset = bs.ReadInt32();
- int roadVertsOffset = bs.ReadInt32();
- int roadTrisOffset = bs.ReadInt32();
- int clustersOffset = bs.ReadInt32();
- int traversalDataOffset = bs.ReadInt32();
+ throw new InvalidDataException($"Not a RNW4 file (magic 0x{Magic:X8}).");
+
+ bs.ReadInt32(); // 0x04 RelocPtr (runtime)
+ OriginalRelocSize = bs.ReadUInt32(); // 0x08 RelocSize (preserve for exact round-trip)
+ Flags = bs.ReadUInt32(); // 0x0C
+ Version = bs.ReadUInt32(); // 0x10
+ TrackV = bs.ReadSingle(); // 0x14
+ StartVCoord = bs.ReadSingle(); // 0x18
+ GoalVCoord = bs.ReadSingle(); // 0x1C
+ Bounds = new Vector3[]
+ {
+ new Vector3(bs.ReadSingle(), bs.ReadSingle(), bs.ReadSingle()), // 0x20
+ new Vector3(bs.ReadSingle(), bs.ReadSingle(), bs.ReadSingle()), // 0x2C
+ };
+
+ // ── Counts @ 0x38 ─────────────────────────────────────────────────
+ CheckpointListCount = bs.ReadInt16(); // 0x38
+ UnkCount = bs.ReadInt16(); // 0x3A
+ short checkpointCount = bs.ReadInt16(); // 0x3C
+ short checkpointLookupIndicesCount = bs.ReadInt16(); // 0x3E
+ Unk0x40 = bs.ReadInt32(); // 0x40
+ GadgetsCount = bs.ReadInt16(); // 0x44
+ ushort roadVerticesCount = bs.ReadUInt16(); // 0x46 — ushort: Suzuka has >32767 verts
+ ushort roadTriCount = bs.ReadUInt16(); // 0x48 — ushort: Suzuka has >32767 tris
+ ushort clusterCount = bs.ReadUInt16(); // 0x4A
+ TreeMaxDepth = bs.Read1Byte(); // 0x4C
+
+ // 0x4D – 0x5F: 19 unknown bytes
+ UnknownHeader0x4D = bs.ReadBytes(0x13);
+
+ // ── Offset table @ 0x60 ───────────────────────────────────────────
+ bs.Position = basePos + 0x60;
+ int checkpointsOffset = bs.ReadInt32(); // 0x60
+ int checkpointLookupOffset = bs.ReadInt32(); // 0x64
+ int lightSetsOffset = bs.ReadInt32(); // 0x68
+ int unkOffset3 = bs.ReadInt32(); // 0x6C
+ int gadgetsOffset = bs.ReadInt32(); // 0x70
+ int roadVertsOffset = bs.ReadInt32(); // 0x74
+ int roadTrisOffset = bs.ReadInt32(); // 0x78
+ int clustersOffset = bs.ReadInt32(); // 0x7C
+ int traversalDataOffset = bs.ReadInt32(); // 0x80
+
+ // Store for use in Write() (duplicate-section detection)
+ _originalLightSetsOffset = lightSetsOffset;
+ _originalUnkOffset3 = unkOffset3;
+ _originalGadgetsOffset = gadgetsOffset;
+
+ // 0x84 – 0xBF: 60 bytes of extra header (4 offset words + constant words)
+ ExtraHeaderBytes = bs.ReadBytes(0x3C);
+
+ // Extract the first non-zero trailing-data pointer from ExtraHeaderBytes
+ // (bytes 0–3 of the array = file offset 0x84).
+ _originalTrailingDataOffset = 0;
+ if (ExtraHeaderBytes.Length >= 4)
+ {
+ _originalTrailingDataOffset = Magic == MAGIC_BE
+ ? (ExtraHeaderBytes[0] << 24) | (ExtraHeaderBytes[1] << 16) |
+ (ExtraHeaderBytes[2] << 8) | ExtraHeaderBytes[3]
+ : ExtraHeaderBytes[0] | (ExtraHeaderBytes[1] << 8) |
+ (ExtraHeaderBytes[2] << 16) | (ExtraHeaderBytes[3] << 24);
+ }
+
+ // ── Spawn positions @ 0xC0 (count derived from distance to checkpoints) ──
+ // The spawn block always starts at 0xC0 and ends where checkpoints begin.
+ // Most tracks have 6 entries; some have more (e.g. 10).
+ bs.Position = basePos + 0xC0;
+ int spawnCount = (checkpointsOffset - 0xC0) / RunwayStartingPosition.GetSize();
+ for (int i = 0; i < spawnCount; i++)
+ SpawnPositions.Add(RunwayStartingPosition.FromStream(bs));
+
+ // ── Section helpers ────────────────────────────────────────────────
+ int fileSize = (int)(stream.Length - basePos);
+
+ // Use Distinct so that sections sharing the same offset only appear once;
+ // this ensures SectionEnd() returns the correct next boundary.
+ int[] allOffsets = new[]
+ {
+ checkpointsOffset, checkpointLookupOffset,
+ lightSetsOffset, unkOffset3, gadgetsOffset,
+ roadVertsOffset, roadTrisOffset,
+ clustersOffset, traversalDataOffset
+ }.Where(o => o > 0).Distinct().OrderBy(o => o).ToArray();
+
+ int SectionEnd(int off)
+ {
+ foreach (int o in allOffsets)
+ if (o > off) return o;
+ return fileSize;
+ }
+
+ // ── Checkpoints ───────────────────────────────────────────────────
for (int i = 0; i < checkpointCount; i++)
{
- bs.Position = basePos + checkpointsOffset + (i * RunwayCheckpoint.GetSize());
- var vert = RunwayCheckpoint.FromStream(bs);
- rwy.Checkpoints.Add(vert);
+ bs.Position = basePos + checkpointsOffset + i * RunwayCheckpoint.GetSize();
+ Checkpoints.Add(RunwayCheckpoint.FromStream(bs));
}
+ // ── Checkpoint lookup indices ─────────────────────────────────────
for (int i = 0; i < checkpointLookupIndicesCount; i++)
{
- bs.Position = basePos + checkpointLookupIndicesOffset + (i * sizeof(short));
- rwy.CheckpointLookupIndices.Add(bs.ReadInt16());
+ bs.Position = basePos + checkpointLookupOffset + i * sizeof(short);
+ CheckpointLookupIndices.Add(bs.ReadInt16());
+ }
+
+ // ── Opaque blobs (light sets, unk3, gadgets) ──────────────────────
+ // Several of these pointers may share the same file offset (common in GT4
+ // tracks that have no light-set or gadget data at that position).
+ // Each unique offset is read exactly once to avoid duplicating data on write.
+ //
+ // When a blob offset equals roadVertsOffset the blob is absent: the header
+ // simply points to the start of the road-vert data as a sentinel. Guard
+ // against this to avoid reading road-vert bytes as blob data (which would
+ // then be written twice in Write(), doubling the file size).
+
+ _lightSetsSharesWithRoadVerts = lightSetsOffset > 0 && lightSetsOffset == roadVertsOffset;
+ _unk3SharesWithRoadVerts = unkOffset3 > 0 && unkOffset3 == roadVertsOffset;
+ _gadgetsSharesWithRoadVerts = gadgetsOffset > 0 && gadgetsOffset == roadVertsOffset;
+
+ if (lightSetsOffset > 0 && !_lightSetsSharesWithRoadVerts)
+ {
+ bs.Position = basePos + lightSetsOffset;
+ LightSetsRawBytes = bs.ReadBytes(SectionEnd(lightSetsOffset) - lightSetsOffset);
}
+ if (unkOffset3 > 0 && unkOffset3 != lightSetsOffset && !_unk3SharesWithRoadVerts)
+ {
+ bs.Position = basePos + unkOffset3;
+ UnkOffset3RawBytes = bs.ReadBytes(SectionEnd(unkOffset3) - unkOffset3);
+ }
+
+ if (gadgetsOffset > 0 && gadgetsOffset != lightSetsOffset && gadgetsOffset != unkOffset3 && !_gadgetsSharesWithRoadVerts)
+ {
+ bs.Position = basePos + gadgetsOffset;
+ GadgetsRawBytes = bs.ReadBytes(SectionEnd(gadgetsOffset) - gadgetsOffset);
+ }
+
+ // ── Road vertices ─────────────────────────────────────────────────
for (int i = 0; i < roadVerticesCount; i++)
{
- bs.Position = basePos + roadVertsOffset + (i * RunwayRoadVert.GetSize());
- var vert = RunwayRoadVert.FromStream(bs);
- rwy.Vertices.Add(vert);
+ bs.Position = basePos + roadVertsOffset + i * RunwayRoadVert.GetSize();
+ Vertices.Add(RunwayRoadVert.FromStream(bs));
}
+ // ── Road tris ─────────────────────────────────────────────────────
for (int i = 0; i < roadTriCount; i++)
{
- bs.Position = basePos + roadTrisOffset + (i * RunwayRoadTri.GetSize());
- var tri = RunwayRoadTri.FromStream(bs);
- rwy.Tris.Add(tri);
+ bs.Position = basePos + roadTrisOffset + i * RunwayRoadTri.GetSize();
+ Tris.Add(RunwayRoadTri.FromStream(bs));
}
+ // ── BSP traversal tree ────────────────────────────────────────────
+ bs.Position = basePos + traversalDataOffset;
+ Root = TraverseRead(bs, TreeMaxDepth - 1);
+
+ // ── Clusters (header + per-cluster tri-index list) ────────────────
for (int i = 0; i < clusterCount; i++)
{
- bs.Position = basePos + clustersOffset + (i * RunwayCluster.GetSize());
- var cluster = RunwayCluster.FromStream(bs);
- rwy.Clusters.Add(cluster);
+ bs.Position = basePos + clustersOffset + i * RunwayCluster.GetSize();
+ Clusters.Add(RunwayCluster.FromStream(bs));
}
- bs.Position = traversalDataOffset;
- rwy.Root = TraverseRead(bs, rwy.TreeMaxDepth - 1);
+ // ── Trailing data (after cluster tri-index arrays) ────────────────
+ // The four offset words in ExtraHeaderBytes (0x84–0x93) point into this
+ // region, which holds physics/gadget/lighting data used for wall collision.
+ // It must be read and preserved verbatim; omitting it breaks wall collision.
+ if (_originalTrailingDataOffset > 0 && _originalTrailingDataOffset < fileSize)
+ {
+ bs.Position = basePos + _originalTrailingDataOffset;
+ TrailingRawBytes = bs.ReadBytes(fileSize - _originalTrailingDataOffset);
+ }
- return rwy;
+ return this;
}
- private static Node TraverseRead(BinaryStream stream, int depthLeft)
+ // ── BSP tree helpers ─────────────────────────────────────────────────────
+
+ private static Node TraverseRead(BinaryStream bs, int depthLeft)
{
if (depthLeft < 0)
return null;
var node = new Node();
- byte data = stream.Read1Byte();
- node.Axis = (byte)(data >> 6);
+ byte data = bs.Read1Byte();
+ node.Axis = (byte)(data >> 6);
node.Value = ((float)(data & 0b111111) + 0.5f) * 0.015625f;
- node.Left = TraverseRead(stream, depthLeft - 1);
- node.Right = TraverseRead(stream, depthLeft - 1);
-
+ node.Left = TraverseRead(bs, depthLeft - 1);
+ node.Right = TraverseRead(bs, depthLeft - 1);
return node;
}
+ private static void TraverseWrite(BinaryStream bs, Node node, int depthLeft)
+ {
+ if (depthLeft < 0)
+ return;
+
+ if (node == null)
+ {
+ // Write zeroed bytes for entire missing subtree (pre-order: root+left+right)
+ int nodeCount = (1 << (depthLeft + 1)) - 1;
+ for (int i = 0; i < nodeCount; i++)
+ bs.WriteByte(0);
+ return;
+ }
+
+ // Reverse of read: valueBits = round(value / 0.015625) - 0.5 clamped to [0,63]
+ byte valueBits = (byte)Math.Clamp(
+ (int)MathF.Round(node.Value / 0.015625f - 0.5f), 0, 63);
+ bs.WriteByte((byte)((node.Axis << 6) | valueBits));
+
+ TraverseWrite(bs, node.Left, depthLeft - 1);
+ TraverseWrite(bs, node.Right, depthLeft - 1);
+ }
+
+ // ── Writing ──────────────────────────────────────────────────────────────
+
///
- /// Searches the runway for a possible collision at the provided coordinates (Y will be within bounds)
+ /// Serialises the runway to starting at its current position.
+ /// All section offsets are recomputed from scratch so the file is self-consistent
+ /// after changes the checkpoint-lookup table size.
+ ///
+ /// Alignment:
+ /// The PS2 DMA engine requires 16-byte (quadword) alignment for the sections it
+ /// fetches directly. The writer inserts alignment padding before:
+ /// • the opaque-blob region (light-sets / unk3 / gadgets),
+ /// • road vertices,
+ /// • the BSP traversal tree,
+ /// • the trailing-data region.
+ /// Sections that follow naturally-aligned predecessors (road tris after road verts,
+ /// clusters after BSP) need no explicit padding.
+ ///
+ public void Write(Stream stream)
+ {
+ long basePos = stream.Position;
+ var bs = new BinaryStream(stream,
+ Magic == MAGIC_BE ? ByteConverter.Big : ByteConverter.Little);
+
+ // Local helper: write a raw blob and return its offset from basePos,
+ // or 0 if the blob is absent.
+ int WriteBlob(byte[] data)
+ {
+ if (data == null || data.Length == 0) return 0;
+ int off = (int)(stream.Position - basePos);
+ stream.Write(data);
+ return off;
+ }
+
+ // ── Spawn positions @ 0xC0 ───────────────────────────────────────
+ stream.Position = basePos + 0xC0;
+ foreach (var sp in SpawnPositions)
+ sp.ToStream(bs);
+ // 6 × 0x10 = 0x60 bytes → stream now at basePos + 0x120
+
+ // ── Checkpoints ──────────────────────────────────────────────────
+ int checkpointsOffset = (int)(stream.Position - basePos); // = 0x120
+ foreach (var cp in Checkpoints)
+ cp.ToStream(bs);
+
+ // ── Checkpoint lookup indices ─────────────────────────────────────
+ int checkpointLookupOffset = (int)(stream.Position - basePos);
+ foreach (short idx in CheckpointLookupIndices)
+ bs.WriteInt16(idx);
+
+ // ── 16-byte alignment before opaque blobs ─────────────────────────
+ // The checkpoint-lookup table's byte count may not be a multiple of 16
+ // (e.g. after ReverseDirection adds entries). Pad to a quadword boundary
+ // so that the blob section and all subsequent sections remain PS2-aligned.
+ AlignStream(stream, 16);
+
+ // ── Opaque blobs (light-sets, unk3, gadgets) ─────────────────────
+ // If two or three of these pointers shared the same file offset in the
+ // source file, write the blob only once and reuse the offset for the
+ // others. Writing duplicates shifts every section that follows.
+ //
+ // Blobs that originally pointed at roadVertsOffset (absent/sentinel) are
+ // not written here; their header offset is patched to roadVertsOffset after
+ // the road-vert section is placed. Use -1 as a sentinel meaning "copy from
+ // roadVertsOffset once that is known".
+
+ const int kSharesWithRoadVerts = -1;
+
+ int lightSetsWriteOff = _lightSetsSharesWithRoadVerts ? kSharesWithRoadVerts : WriteBlob(LightSetsRawBytes);
+
+ int unkOffset3WriteOff;
+ if (_unk3SharesWithRoadVerts)
+ unkOffset3WriteOff = kSharesWithRoadVerts;
+ else if (_originalUnkOffset3 > 0 && _originalUnkOffset3 == _originalLightSetsOffset)
+ unkOffset3WriteOff = lightSetsWriteOff; // shared — reuse
+ else
+ unkOffset3WriteOff = WriteBlob(UnkOffset3RawBytes);
+
+ int gadgetsWriteOff;
+ if (_gadgetsSharesWithRoadVerts)
+ gadgetsWriteOff = kSharesWithRoadVerts;
+ else if (_originalGadgetsOffset > 0 && _originalGadgetsOffset == _originalLightSetsOffset)
+ gadgetsWriteOff = lightSetsWriteOff;
+ else if (_originalGadgetsOffset > 0 && _originalGadgetsOffset == _originalUnkOffset3)
+ gadgetsWriteOff = unkOffset3WriteOff;
+ else
+ gadgetsWriteOff = WriteBlob(GadgetsRawBytes);
+
+ // ── Road vertices (16-byte aligned) ──────────────────────────────
+ AlignStream(stream, 16);
+ int roadVertsOffset = (int)(stream.Position - basePos);
+ foreach (var v in Vertices)
+ v.ToStream(bs);
+
+ // Patch any blob offsets that were deferred because they share roadVertsOffset.
+ if (lightSetsWriteOff == kSharesWithRoadVerts) lightSetsWriteOff = roadVertsOffset;
+ if (unkOffset3WriteOff == kSharesWithRoadVerts) unkOffset3WriteOff = roadVertsOffset;
+ if (gadgetsWriteOff == kSharesWithRoadVerts) gadgetsWriteOff = roadVertsOffset;
+
+ // ── Road tris ─────────────────────────────────────────────────────
+ // Each RunwayRoadVert is 0x10 bytes, so tris always follow at a
+ // 16-byte-aligned address — no explicit padding needed here.
+ int roadTrisOffset = (int)(stream.Position - basePos);
+ foreach (var t in Tris)
+ t.ToStream(bs);
+
+ // ── BSP traversal tree (16-byte aligned, 1< 0)
+ {
+ AlignStream(stream, 16);
+ int newTrailingOffset = (int)(stream.Position - basePos);
+ trailingDelta = newTrailingOffset - _originalTrailingDataOffset;
+ stream.Write(TrailingRawBytes);
+ }
+
+ long endPos = stream.Position;
+
+ // ── Header @ basePos ──────────────────────────────────────────────
+ stream.Position = basePos;
+ bs.WriteUInt32(Magic); // 0x00
+ bs.WriteInt32(0); // 0x04 RelocPtr (runtime; always 0 on disk)
+ // Always use the actual file size so the game loads the complete file including
+ // trailing physics/collision data. If the reversed file is larger than the
+ // source (due to extra non-contiguous lookup entries), preserving the original
+ // RelocSize would cause the game to stop reading before the trailing data.
+ bs.WriteUInt32((uint)(endPos - basePos)); // 0x08 RelocSize
+ bs.WriteUInt32(Flags); // 0x0C
+ bs.WriteUInt32(Version); // 0x10
+ bs.WriteSingle(TrackV); // 0x14
+ bs.WriteSingle(StartVCoord); // 0x18
+ bs.WriteSingle(GoalVCoord); // 0x1C
+ bs.WriteSingle(Bounds[0].X); // 0x20
+ bs.WriteSingle(Bounds[0].Y);
+ bs.WriteSingle(Bounds[0].Z);
+ bs.WriteSingle(Bounds[1].X); // 0x2C
+ bs.WriteSingle(Bounds[1].Y);
+ bs.WriteSingle(Bounds[1].Z);
+
+ // Counts @ 0x38
+ bs.WriteInt16(CheckpointListCount); // 0x38
+ bs.WriteInt16(UnkCount); // 0x3A
+ bs.WriteInt16((short)Checkpoints.Count); // 0x3C
+ bs.WriteInt16((short)CheckpointLookupIndices.Count); // 0x3E
+ bs.WriteInt32(Unk0x40); // 0x40
+ bs.WriteInt16(GadgetsCount); // 0x44
+ bs.WriteUInt16((ushort)Vertices.Count); // 0x46
+ bs.WriteUInt16((ushort)Tris.Count); // 0x48
+ bs.WriteUInt16((ushort)Clusters.Count); // 0x4A
+ bs.WriteByte(TreeMaxDepth); // 0x4C
+
+ // 0x4D–0x5F unknown
+ if (UnknownHeader0x4D != null)
+ stream.Write(UnknownHeader0x4D, 0, Math.Min(UnknownHeader0x4D.Length, 0x13));
+ else
+ stream.Write(new byte[0x13]);
+
+ // Offset table @ 0x60
+ stream.Position = basePos + 0x60;
+ bs.WriteInt32(checkpointsOffset); // 0x60
+ bs.WriteInt32(checkpointLookupOffset); // 0x64
+ bs.WriteInt32(lightSetsWriteOff); // 0x68
+ bs.WriteInt32(unkOffset3WriteOff); // 0x6C
+ bs.WriteInt32(gadgetsWriteOff); // 0x70
+ bs.WriteInt32(roadVertsOffset); // 0x74
+ bs.WriteInt32(roadTrisOffset); // 0x78
+ bs.WriteInt32(clustersOffset); // 0x7C
+ bs.WriteInt32(traversalDataOffset); // 0x80
+
+ // Extra header @ 0x84–0xBF
+ // Clone the stored bytes so we can patch without mutating the source data.
+ // The four pointer words (bytes 0–15 of the array = file offsets 0x84–0x93)
+ // are adjusted by the net delta between where the trailing data now lands
+ // versus where it was in the original file.
+ byte[] patchedExtra = ExtraHeaderBytes != null
+ ? (byte[])ExtraHeaderBytes.Clone()
+ : new byte[0x3C];
+
+ if (trailingDelta != 0)
+ PatchExtraHeaderOffsets(patchedExtra, trailingDelta);
+
+ stream.Write(patchedExtra, 0, Math.Min(patchedExtra.Length, 0x3C));
+
+ stream.Position = endPos;
+ }
+
+ // ── Direction reversal ───────────────────────────────────────────────────
+
+ ///
+ /// Transforms this runway so that it runs in the opposite direction:
+ ///
+ /// • Checkpoint order reversed (last→first); Left↔Right positions swapped on each gate;
+ /// V = (TrackV − oldV) mod TrackV.
+ /// • Checkpoint-pair indices in each cluster's lookup entry are remapped:
+ /// pair j → (N−2−j) for j < N−1; pair (N−1) stays as (N−1) (wrap pair).
+ /// • Non-contiguous transformed pair sets are pushed into new lookup entries.
+ /// • StartVCoord/GoalVCoord are transformed by the same V formula.
+ /// • Spawn-position rotations are flipped by π radians so cars face the new direction.
+ ///
+ /// The Left↔Right swap is essential: QuadSTCompute performs signed-area (cross-product)
+ /// tests that depend on correct winding order. Reversing traversal direction flips the
+ /// winding; swapping Left↔Right restores it, exactly equivalent to rotating each gate
+ /// 180° about its Middle point.
+ ///
+ /// Triangle UnkBits (SectorId / CpSubIndex) are NOT modified. Comparison against GT4's
+ /// own reverse-variant files confirms Polyphony leaves them unchanged: SectorId encodes
+ /// the physical mesh region, not the traversal order.
+ ///
+ /// Geometry (vertices, tris, clusters, BSP tree, bounds) and the collision/physics
+ /// trailing data are otherwise unchanged.
+ /// The ExtraHeaderBytes offset words are patched by Write() when it knows the
+ /// final layout, so no adjustment is needed here.
+ ///
+ public void ReverseDirection()
+ {
+ int N = Checkpoints.Count;
+ float totalTrackV = TrackV;
+
+ // ── Step 1: Reverse checkpoint order, mirror V, swap Left↔Right ─────
+ // Left↔Right swap is required: QuadSTCompute uses signed cross-products
+ // whose winding depends on which side is "left" vs "right" relative to the
+ // travel direction. Reversing the traversal order flips the winding;
+ // swapping Left↔Right corrects it. Geometrically this is identical to
+ // rotating each gate 180° about its Middle point.
+ var reversed = new List(N);
+ for (int i = 0; i < N; i++)
+ {
+ var src = Checkpoints[N - 1 - i];
+ reversed.Add(new RunwayCheckpoint
+ {
+ Left = src.Right, // swap: corrects QuadSTCompute winding
+ Middle = src.Middle,
+ Right = src.Left, // swap: corrects QuadSTCompute winding
+ TrackV = (totalTrackV - src.TrackV) % totalTrackV,
+ });
+ }
+ Checkpoints = reversed;
+
+ // ── Step 2: Transform StartVCoord / GoalVCoord ───────────────────
+ StartVCoord = (totalTrackV - StartVCoord) % totalTrackV;
+ GoalVCoord = (totalTrackV - GoalVCoord) % totalTrackV;
+
+ // ── Step 3: Flip spawn rotations by π; transform config gate records ─
+ // The spawn block begins with zero or more configuration records followed by
+ // real starting-grid slots. A slot is a config record if it appears before
+ // the first slot with a genuine heading angle (0 < |Rot| ≤ 2π).
+ //
+ // Config records pack checkpoint-gate V-coordinates into their struct fields:
+ // |Rot| > 2π : Z and Rotation both hold gate V-coords (two gates per slot).
+ // Rot == 0 : X (and Z if nonzero) hold additional gate V-coords.
+ //
+ // The forward file stores gate V-coords in ascending order (traversal order)
+ // across the config slots, in field sequence: slot0.Z, slot0.Rot, slot1.X, …
+ // Reversal must:
+ // 1. Collect all gate values from all config slots in field order.
+ // 2. V-mirror each: new_v = TrackV - old_v
+ // 3. Reverse the list — so the smallest reversed-V (first gate reached in
+ // the reversed lap) goes into the first field position, preserving the
+ // ascending-V invariant that the game requires.
+ // 4. Write the reordered, mirrored values back into the same field slots.
+ //
+ // Real spawn slots: flip heading by π.
+ const float TwoPiF = MathF.PI * 2f;
+
+ // Find the index of the first real spawn slot.
+ int firstRealSpawn = SpawnPositions.Count;
+ for (int i = 0; i < SpawnPositions.Count; i++)
+ {
+ float absRot = MathF.Abs(SpawnPositions[i].Rotation);
+ if (absRot > 0f && absRot <= TwoPiF)
+ {
+ firstRealSpawn = i;
+ break;
+ }
+ }
+
+ // ── Collect all gate V-coords from config slots in field order ──────
+ // Each entry: (slot index, field name, forward V-value)
+ var gateFields = new List<(int slot, string field)>();
+ var gateValues = new List();
+
+ for (int i = 0; i < firstRealSpawn; i++)
+ {
+ var sp = SpawnPositions[i];
+ if (MathF.Abs(sp.Rotation) > TwoPiF)
+ {
+ gateFields.Add((i, "Z")); gateValues.Add(sp.Z);
+ gateFields.Add((i, "Rot")); gateValues.Add(sp.Rotation);
+ }
+ else // Rot == 0
+ {
+ if (sp.X != 0f) { gateFields.Add((i, "X")); gateValues.Add(sp.X); }
+ if (sp.Z != 0f) { gateFields.Add((i, "Z")); gateValues.Add(sp.Z); }
+ }
+ }
+
+ // ── V-mirror all values then reverse the list ─────────────────────
+ var newGateValues = new float[gateValues.Count];
+ for (int g = 0; g < gateValues.Count; g++)
+ newGateValues[gateValues.Count - 1 - g] = totalTrackV - gateValues[g];
+
+ // ── Write back in the same field positions ─────────────────────────
+ for (int g = 0; g < gateFields.Count; g++)
+ {
+ var (idx, field) = gateFields[g];
+ var sp = SpawnPositions[idx];
+ switch (field)
+ {
+ case "Z": sp.Z = newGateValues[g]; break;
+ case "Rot": sp.Rotation = newGateValues[g]; break;
+ case "X": sp.X = newGateValues[g]; break;
+ }
+ }
+
+ // ── Flip real spawn headings by π ─────────────────────────────────
+ for (int i = firstRealSpawn; i < SpawnPositions.Count; i++)
+ SpawnPositions[i].Rotation = NormaliseAngle(SpawnPositions[i].Rotation + MathF.PI);
+
+ // ── Step 4: Remap checkpoint-pair indices in-place ───────────────────
+ // Transform each lookup entry j: j < N-1 → N-2-j, j = N-1 → N-1.
+ // Cluster CheckpointLookupIndexStart / CheckpointLookupLength are left
+ // unchanged — the same windows into the table cover the correct reversed
+ // pairs. No entries are added, so the file size stays identical to the
+ // source. The source file's per-cluster ordering (including N-1 first
+ // in custom entries that span the start/finish boundary) is preserved,
+ // which is what getVCoord requires for correct V-coordinate tie-breaking.
+ for (int i = 0; i < CheckpointLookupIndices.Count; i++)
+ {
+ short j = CheckpointLookupIndices[i];
+ CheckpointLookupIndices[i] = j < N - 1 ? (short)(N - 2 - j) : (short)(N - 1);
+ }
+
+ // Triangle UnkBits (SectorId / CpSubIndex) are intentionally NOT modified.
+ // Empirical comparison against GT4's own reverse-variant files confirms that
+ // Polyphony leaves the triangle sector IDs unchanged when reversing a track:
+ // the SectorId encodes the physical timing-sector region on the mesh, not
+ // the traversal order. Inverting them shifts the T1/T2/T3 boundary by one
+ // checkpoint in the blueprint sector-map and does not match the originals.
+ }
+
+ // ── Private helpers ──────────────────────────────────────────────────────
+
+ private static float NormaliseAngle(float rad)
+ {
+ const float TwoPi = MathF.PI * 2f;
+ rad %= TwoPi;
+ if (rad > MathF.PI) rad -= TwoPi;
+ if (rad <= -MathF.PI) rad += TwoPi;
+ return rad;
+ }
+
+ ///
+ /// Adjusts offset words in a clone of by
+ /// bytes. Only words whose stored value is
+ /// ≥ are touched — those are
+ /// confirmed pointers into the trailing-data region. Words that are zero
+ /// (absent) or smaller than that threshold are counts/constants (e.g. the
+ /// 3751-entry count at ExtraHdr[1] in 20r60r) and must not be modified.
+ /// Works in-place on ; call with a clone to avoid
+ /// mutating the stored source data.
+ ///
+ private void PatchExtraHeaderOffsets(byte[] bytes, int delta)
+ {
+ bool bigEndian = (Magic == MAGIC_BE);
+ for (int i = 0; i < 4; i++)
+ {
+ int bytePos = i * 4;
+ if (bytePos + 4 > bytes.Length) break;
+
+ int val = bigEndian
+ ? (bytes[bytePos ] << 24) | (bytes[bytePos + 1] << 16) |
+ (bytes[bytePos + 2] << 8) | bytes[bytePos + 3]
+ : bytes[bytePos ] | (bytes[bytePos + 1] << 8) |
+ (bytes[bytePos + 2] << 16) | (bytes[bytePos + 3] << 24);
+
+ // Skip zero (absent) and any word smaller than the start of the
+ // trailing-data region — those are counts/constants, not pointers.
+ if (val == 0 || val < _originalTrailingDataOffset) continue;
+
+ val += delta;
+
+ if (bigEndian)
+ {
+ bytes[bytePos ] = (byte)(val >> 24);
+ bytes[bytePos + 1] = (byte)(val >> 16);
+ bytes[bytePos + 2] = (byte)(val >> 8);
+ bytes[bytePos + 3] = (byte) val;
+ }
+ else
+ {
+ bytes[bytePos ] = (byte) val;
+ bytes[bytePos + 1] = (byte)(val >> 8);
+ bytes[bytePos + 2] = (byte)(val >> 16);
+ bytes[bytePos + 3] = (byte)(val >> 24);
+ }
+ }
+ }
+
+ private static void AlignStream(Stream stream, int alignment)
+ {
+ long pos = stream.Position;
+ long aligned = (pos + alignment - 1) & ~(long)(alignment - 1);
+ long pad = aligned - pos;
+ if (pad > 0)
+ {
+ Span zeros = stackalloc byte[(int)pad];
+ zeros.Clear();
+ stream.Write(zeros);
+ }
+ }
+
+ // ── Blueprint helpers ────────────────────────────────────────────────────
+
+ ///
+ /// Whether shares the same file offset as
+ /// in the source file. Used by the blueprint
+ /// exporter to generate the correct sharing flags in blueprint.yaml.
+ ///
+ public bool Unk3SharesOffsetWithLightsets =>
+ _originalUnkOffset3 > 0 && _originalUnkOffset3 == _originalLightSetsOffset;
+
+ ///
+ /// Whether shares the same file offset as
+ /// in the source file.
+ ///
+ public bool GadgetsSharesOffsetWithLightsets =>
+ _originalGadgetsOffset > 0 && _originalGadgetsOffset == _originalLightSetsOffset;
+
+ ///
+ /// Whether shares the same file offset as
+ /// (and not with lightsets) in the source file.
+ ///
+ public bool GadgetsSharesOffsetWithUnk3 =>
+ _originalGadgetsOffset > 0 &&
+ _originalGadgetsOffset == _originalUnkOffset3 &&
+ _originalGadgetsOffset != _originalLightSetsOffset;
+
+ ///
+ /// Configures original-offset tracking for blueprint round-trips.
+ /// Must be called after building a from a blueprint
+ /// (instead of ) so that can
+ /// correctly deduplicate blob sections and patch ExtraHeaderBytes offset words.
+ ///
+ /// Fake original offset for lightsets (nonzero = present).
+ /// Same value as lightSetsOffset if shared, otherwise distinct nonzero.
+ /// Same as lightsets/unk3 if shared, otherwise distinct nonzero.
+ ///
+ /// Value of the trailing-data pointer as it appears in ExtraHeaderBytes[0:4].
+ /// Used by to distinguish pointer words
+ /// from count words (any word < this threshold is treated as a count).
+ ///
+ public void SetOriginalOffsets(
+ int lightSetsOffset, int unkOffset3, int gadgetsOffset, int trailingDataOffset)
+ {
+ _originalLightSetsOffset = lightSetsOffset;
+ _originalUnkOffset3 = unkOffset3;
+ _originalGadgetsOffset = gadgetsOffset;
+ _originalTrailingDataOffset = trailingDataOffset;
+ }
+
+ ///
+ /// Returns raw bytes of the BSP traversal tree written in pre-order.
+ /// The length equals 2^TreeMaxDepth − 1 bytes (one byte per node).
+ /// Used by the blueprint exporter to produce bsp_tree.bin.
+ ///
+ public byte[] SaveBspToBytes()
+ {
+ using var ms = new MemoryStream();
+ var bs = new BinaryStream(ms,
+ Magic == MAGIC_BE ? ByteConverter.Big : ByteConverter.Little);
+ TraverseWrite(bs, Root, TreeMaxDepth - 1);
+ return ms.ToArray();
+ }
+
+ ///
+ /// Loads the BSP traversal tree from raw bytes produced by .
+ /// must be set before calling.
+ /// Used by the blueprint builder to restore bsp_tree.bin.
+ ///
+ public void LoadBspFromBytes(byte[] bspBytes)
+ {
+ using var ms = new MemoryStream(bspBytes);
+ var bs = new BinaryStream(ms,
+ Magic == MAGIC_BE ? ByteConverter.Big : ByteConverter.Little);
+ Root = TraverseRead(bs, TreeMaxDepth - 1);
+ }
+
+ // ── Collision / track-position query methods ─────────────────────────────
+
+ ///
+ /// Searches the runway for a possible collision at the provided coordinates (Y will be within bounds).
///
- ///
- ///
// GT4O US 0x293F90
public bool search(out RunwayResult result, Vector3 pos)
{
if (pos.Y >= Bounds[0].Y)
{
- Vector3 startPoint;
- startPoint.X = pos.X;
- startPoint.Y = Bounds[1].Y >= pos.Y ? pos.Y : Bounds[1].Y;
- startPoint.Z = pos.Z;
+ Vector3 startPoint = new Vector3(
+ pos.X,
+ Bounds[1].Y >= pos.Y ? pos.Y : Bounds[1].Y,
+ pos.Z);
- Vector3 endPoint;
- endPoint.X = pos.X;
- endPoint.Y = Bounds[0].Y;
- endPoint.Z = pos.Z;
+ Vector3 endPoint = new Vector3(pos.X, Bounds[0].Y, pos.Z);
int depth = TreeMaxDepth - 1;
return traverse(out result, Bounds, startPoint, endPoint, Root, depth, 0);
}
- else
- result = null;
+ result = null;
return false;
}
// GT4O US 0x294128
- // Finds the cluster linked to provided positions?
- public bool traverse(out RunwayResult result, Span bounds, Vector3 startPoint, Vector3 endPoint, Node node, int depth, short clusterIndex)
+ public bool traverse(out RunwayResult result, Span bounds,
+ Vector3 startPoint, Vector3 endPoint,
+ Node node, int depth, short clusterIndex)
{
if (depth < 0)
return checkHit(out result, startPoint, endPoint, clusterIndex);
- int axis = node.Axis;
+ int axis = node.Axis;
float axisBoundsMin = bounds[0].GetAxis(axis);
float axisBoundsMax = bounds[1].GetAxis(axis);
float axisV1 = startPoint.GetAxis(axis);
float axisV2 = endPoint.GetAxis(axis);
- float pos = MathUtils.Lerp(axisBoundsMin, axisBoundsMax, node.Value); // (axisBoundsMin * (1.0f - node.Value)) + (axisBoundsMax * node.Value);
- float v20 = axisV1 - pos;
+ float pos = MathUtils.Lerp(axisBoundsMin, axisBoundsMax, node.Value);
+ float v20 = axisV1 - pos;
Node nextNode;
-
clusterIndex *= 2;
Span nextBounds = stackalloc Vector3[2];
@@ -246,14 +933,12 @@ public bool traverse(out RunwayResult result, Span bounds, Vector3 star
{
nextNode = node.Left;
depth--;
-
nextBounds[1].SetAxis(axis, pos);
}
else
{
nextNode = node.Right;
depth--;
-
nextBounds[0].SetAxis(axis, pos);
clusterIndex++;
}
@@ -266,52 +951,35 @@ public bool traverse(out RunwayResult result, Span bounds, Vector3 star
return traverse(out result, nextBounds, startPoint, endPoint, nextNode, depth, clusterIndex);
}
- ///
- /// Returns whether a vector hits collision within a specific cluster, and returns additional information about the point that was hit
- ///
- ///
- ///
- ///
- ///
- ///
- ///
// GT4O US 0x294508
public bool checkHit(out RunwayResult result, Vector3 startPoint, Vector3 endPoint, short clusterIndex)
{
RunwayCluster cluster = Clusters[clusterIndex];
-
Vector3 vecDiff = endPoint - startPoint;
- // Results
Vector3 closest = Vector3.Zero;
- short resTri = -1;
- float v20 = float.NaN;
-
- float val1 = 0.0f, val2 = 0.0f, val3 = 0.0f;
+ short resTri = -1;
+ float v20 = float.NaN;
+ float val1 = 0, val2 = 0, val3 = 0;
- // Iterate through all tris/faces for this cluster, check if we are colliding against any of them
for (int i = 0; i < cluster.TriIndices.Length; i++)
{
- short currentTriIndex = cluster.TriIndices[i];
- RunwayRoadTri tri = Tris[currentTriIndex];
-
+ short currentTriIndex = cluster.TriIndices[i];
+ RunwayRoadTri tri = Tris[currentTriIndex];
RunwayRoadVert p1 = Vertices[tri.Vert1];
RunwayRoadVert p2 = Vertices[tri.Vert2];
RunwayRoadVert p3 = Vertices[tri.Vert3];
- // Check 1 to 2
Vector3 a = p1.Vertex - startPoint;
Vector3 b = p2.Vertex - startPoint;
Vector3 c = p3.Vertex - startPoint;
- Vector3 crossed = Vector3.Cross(vecDiff, a); // GT4Course::'anonymous_namespace'::outerProduct
- float dot1 = Vector3.Dot(b, crossed); // GT4Course::'anonymous_namespace'::innerProduct
+ Vector3 crossed = Vector3.Cross(vecDiff, a);
+ float dot1 = Vector3.Dot(b, crossed);
if (dot1 <= 0.0f)
{
- // Check 2 to 3
float dot2 = Vector3.Dot(c, crossed);
-
if (0.0f <= dot2)
{
crossed = Vector3.Cross(b, c);
@@ -328,21 +996,18 @@ public bool checkHit(out RunwayResult result, Vector3 startPoint, Vector3 endPoi
if (dot4 != 0.0f)
{
var last = Vector3.Dot(crossed, a);
- if (0.0f < last || last < dot4)
- continue;
-
+ if (0.0f < last || last < dot4) continue;
val = last / dot4;
}
if (float.IsNaN(v20) || val <= v20)
{
closest = crossed;
- resTri = currentTriIndex;
- v20 = val;
-
- val1 = dot1;
- val2 = -dot2;
- val3 = dot3;
+ resTri = currentTriIndex;
+ v20 = val;
+ val1 = dot1;
+ val2 = -dot2;
+ val3 = dot3;
}
}
}
@@ -352,19 +1017,17 @@ public bool checkHit(out RunwayResult result, Vector3 startPoint, Vector3 endPoi
if (resTri >= 0)
{
result = new RunwayResult();
-
result.HitPoint = new Vector3(
- ((endPoint.X - startPoint.X) * v20) + startPoint.X,
- ((endPoint.Y - startPoint.Y) * v20) + startPoint.Y,
- ((endPoint.Z - startPoint.Z) * v20) + startPoint.Z
- );
- result.Cluster = clusterIndex;
+ (endPoint.X - startPoint.X) * v20 + startPoint.X,
+ (endPoint.Y - startPoint.Y) * v20 + startPoint.Y,
+ (endPoint.Z - startPoint.Z) * v20 + startPoint.Z);
+ result.Cluster = clusterIndex;
result.TriIndex = resTri;
- var tri = Tris[resTri];
- RunwayRoadVert p1 = Vertices[tri.Vert1];
- RunwayRoadVert p2 = Vertices[tri.Vert2];
- RunwayRoadVert p3 = Vertices[tri.Vert3];
+ var tri = Tris[resTri];
+ RunwayRoadVert p1 = Vertices[tri.Vert1];
+ RunwayRoadVert p2 = Vertices[tri.Vert2];
+ RunwayRoadVert p3 = Vertices[tri.Vert3];
Vector3 adjusted = result.HitPoint;
for (int axis = 0; axis < 3; axis++)
@@ -373,36 +1036,25 @@ public bool checkHit(out RunwayResult result, Vector3 startPoint, Vector3 endPoi
float f2 = p2.Vertex.GetAxis(axis);
float f3 = p3.Vertex.GetAxis(axis);
float res = result.HitPoint.GetAxis(axis);
-
float min, max;
- if (f1 >= f2)
- {
- max = MathF.Max(f1, f3);
- min = MathF.Min(f2, f3);
- }
- else
- {
- max = MathF.Max(f2, f3);
- min = MathF.Min(f1, f3);
- }
-
- float axisVal = MathF.Max(min, Math.Min(max, res));
- adjusted.SetAxis(axis, axisVal);
+ if (f1 >= f2) { max = MathF.Max(f1, f3); min = MathF.Min(f2, f3); }
+ else { max = MathF.Max(f2, f3); min = MathF.Min(f1, f3); }
+ adjusted.SetAxis(axis, MathF.Max(min, Math.Min(max, res)));
}
result.HitPoint = adjusted;
float combined = val1 + val2 + val3;
- float rsqrt = 1.0f / closest.Length(); // SQRT(closest.X * closest.X + closest.Y * closest.Y + closest.Z * closest.Z);
+ float rsqrt = 1.0f / closest.Length();
- float val = (float)p1.Unk2 / 255.0f;
- val = val + (((float)p2.Unk2 / 255.0f - val) * val2 + ((float)p3.Unk2 / 255.0f - val) * val1) / combined;
- result.Unk = Math.Clamp(val, 0.0f, 1.0f); // min.s + max.s
+ float v = (float)p1.Unk2 / 255.0f;
+ v += (((float)p2.Unk2 / 255.0f - v) * val2 + ((float)p3.Unk2 / 255.0f - v) * val1) / combined;
+ result.Unk = Math.Clamp(v, 0.0f, 1.0f);
result.UnkVec = closest * rsqrt;
- val = (float)(p1.Unk3 & 0x7F) / 127.0f;
- val = val + (((float)(p2.Unk3 & 0x7F) / 127.0f - val) * val2 + ((float)(p3.Unk3 & 0x7F) / 127.0f - val) * val1) / combined;
- result.Unk2 = Math.Clamp(val, 0.0f, 1.0f); // min.s + max.s
+ v = (float)(p1.Unk3 & 0x7F) / 127.0f;
+ v += (((float)(p2.Unk3 & 0x7F) / 127.0f - v) * val2 + ((float)(p3.Unk3 & 0x7F) / 127.0f - v) * val1) / combined;
+ result.Unk2 = Math.Clamp(v, 0.0f, 1.0f);
result.Unk3 = v20;
result.Unk4 = -Vector3.Dot(result.UnkVec, p1.Vertex);
return true;
@@ -412,55 +1064,41 @@ public bool checkHit(out RunwayResult result, Vector3 startPoint, Vector3 endPoi
return false;
}
- ///
- /// Gets the vcoord (meter) given a position.
- ///
- /// Position on the track.
- /// "Hint" for searching with the cluster and tri index. If default, will search for that first.
- ///
+ /// Gets the VCoord (metres from start) for a world position.
public float getVCoord(Vector3 position, RunwayHint hint)
{
if ((hint.Cluster & 0x8000) == -1 || hint.TriIndex == -1)
{
search(out RunwayResult result, position);
- if (result.Cluster == -1 || result.TriIndex == -1)
- return 0.0f;
-
+ if (result.Cluster == -1 || result.TriIndex == -1) return 0.0f;
hint.TriIndex = result.TriIndex;
- hint.Cluster = result.Cluster;
+ hint.Cluster = result.Cluster;
}
float lowest = float.NaN;
float vcoord = 0.0f;
- // We have a cluster, loop through all the checkpoints linked to it
- // We'll use them to calculate the vcoord
RunwayCluster cluster = Clusters[hint.Cluster];
- for (int i = cluster.CheckpointLookupIndexStart; i < cluster.CheckpointLookupIndexStart + cluster.CheckpointLookupLength; i++)
+ for (int i = cluster.CheckpointLookupIndexStart;
+ i < cluster.CheckpointLookupIndexStart + cluster.CheckpointLookupLength; i++)
{
- var index = CheckpointLookupIndices[i];
- RunwayCheckpoint cp = Checkpoints[index];
- RunwayCheckpoint nextcp = index + 1 != Checkpoints.Count ? Checkpoints[index + 1] : Checkpoints[0];
+ var index = CheckpointLookupIndices[i];
+ RunwayCheckpoint cp = Checkpoints[index];
+ RunwayCheckpoint nextcp = index + 1 != Checkpoints.Count
+ ? Checkpoints[index + 1] : Checkpoints[0];
- // Check each side (aka each rectangle) for a possible hit
- if (QuadSTCompute(out Vector3 result, position, cp.Left, nextcp.Left, cp.Middle, nextcp.Middle) ||
- QuadSTCompute(out result, position, cp.Middle, nextcp.Middle, cp.Right, nextcp.Right))
+ if (QuadSTCompute(out Vector3 result, position, cp.Left, nextcp.Left, cp.Middle, nextcp.Middle) ||
+ QuadSTCompute(out result, position, cp.Middle, nextcp.Middle, cp.Right, nextcp.Right))
{
float current = MathF.Abs(result.Z);
if (float.IsNaN(lowest) || current < lowest)
{
vcoord = cp.TrackV;
float nextV = nextcp.TrackV;
-
- if (nextcp.TrackV < cp.TrackV)
- nextV = this.TrackV;
-
+ if (nextcp.TrackV < cp.TrackV) nextV = this.TrackV;
vcoord += (nextV - vcoord) * result.X;
- lowest = current;
-
- // Loop back
- if (vcoord > this.TrackV)
- vcoord -= this.TrackV;
+ lowest = current;
+ if (vcoord > this.TrackV) vcoord -= this.TrackV;
}
}
}
@@ -468,17 +1106,8 @@ public float getVCoord(Vector3 position, RunwayHint hint)
return vcoord;
}
- ///
- ///
- ///
- ///
- /// Position
- /// Rect P1
- /// Rect P2
- /// Rect P3
- /// Rect P4
- ///
- public static bool QuadSTCompute(out Vector3 result, Vector3 pos, Vector3 p1, Vector3 p2, Vector3 p3, Vector3 p4)
+ public static bool QuadSTCompute(out Vector3 result, Vector3 pos,
+ Vector3 p1, Vector3 p2, Vector3 p3, Vector3 p4)
{
float p3p1XDiff = p3.X - p1.X;
float p3p1ZDiff = p3.Z - p1.Z;
@@ -486,7 +1115,6 @@ public static bool QuadSTCompute(out Vector3 result, Vector3 pos, Vector3 p1, Ve
float p2p4ZDiff = p2.Z - p4.Z;
result = default;
-
if (p3p1XDiff == 0.0 && p3p1ZDiff == 0.0 && p2p4XDiff == 0.0 && p2p4ZDiff == 0.0f)
return false;
@@ -529,15 +1157,14 @@ public static bool QuadSTCompute(out Vector3 result, Vector3 pos, Vector3 p1, Ve
float unk5 = p2p1ZDiff * p3p1XDiff - p2p1XDiff * p3p1ZDiff;
float unk6 = aa + unk5;
float unk7 = MathF.Sqrt(unk6 * unk6 + unk2 * 4.0f * (p3p1ZDiff * v1 - p3p1XDiff - v2));
- result.X = (unk + unk) / ((unk5 - aa) - unk7); // Progress between previous and next cp (0.0 to 1.0)
+ result.X = (unk + unk) / ((unk5 - aa) - unk7);
result.Y = (unk2 * -2.0f) / (unk6 - unk7);
}
- result.Z = pos.Y - ((1.0f - result.X) * (1.0f - result.Y) * p1.Y
- + result.X * (1.0f - result.Y) * p2.Y
- + (1.0f - result.X) * result.Y * p3.Y
- + result.X * result.Y * p4.Y);
-
+ result.Z = pos.Y - ((1.0f - result.X) * (1.0f - result.Y) * p1.Y
+ + result.X * (1.0f - result.Y) * p2.Y
+ + (1.0f - result.X) * result.Y * p3.Y
+ + result.X * result.Y * p4.Y);
return true;
}
}
@@ -547,27 +1174,28 @@ public static bool QuadSTCompute(out Vector3 result, Vector3 pos, Vector3 p1, Ve
return false;
}
+ // ── Nested types ─────────────────────────────────────────────────────────
+
public class Node
{
- public byte Axis;
+ public byte Axis;
public float Value;
- public Node Left;
- public Node Right;
+ public Node Left;
+ public Node Right;
}
public class RunwayResult
{
- public Vector3 HitPoint { get; set; } // 0x00
- public byte TriUnk { get; set; } // 0x0C
- public byte TriUnk2 { get; set; } // 0x10
- public float Unk { get; set; } // 0x14
- public float Unk2 { get; set; } // 0x18
- public Vector3 UnkVec { get; set; } // 0x1C
- public float Unk4 { get; set; } // 0x28
- public float Unk3 { get; set; } // 0x2C
-
- public short TriIndex { get; set; } = -1; // 0x30
- public short Cluster { get; set; } = -1; // 0x32
+ public Vector3 HitPoint { get; set; } // 0x00
+ public byte TriUnk { get; set; } // 0x0C
+ public byte TriUnk2 { get; set; } // 0x10
+ public float Unk { get; set; } // 0x14
+ public float Unk2 { get; set; } // 0x18
+ public Vector3 UnkVec { get; set; } // 0x1C
+ public float Unk4 { get; set; } // 0x28
+ public float Unk3 { get; set; } // 0x2C
+ public short TriIndex { get; set; } = -1; // 0x30
+ public short Cluster { get; set; } = -1; // 0x32
}
public struct RunwayHint
@@ -578,14 +1206,13 @@ public struct RunwayHint
public RunwayHint(short triIndex, short cluster)
{
TriIndex = triIndex;
- Cluster = cluster;
+ Cluster = cluster;
}
public RunwayHint()
{
TriIndex = -1;
- Cluster = -1;
+ Cluster = -1;
}
}
}
-
diff --git a/PDTools.Files/Courses/PS2/Runway/RunwayRoadTri.cs b/PDTools.Files/Courses/PS2/Runway/RunwayRoadTri.cs
index 934d3135..0e100899 100644
--- a/PDTools.Files/Courses/PS2/Runway/RunwayRoadTri.cs
+++ b/PDTools.Files/Courses/PS2/Runway/RunwayRoadTri.cs
@@ -14,7 +14,20 @@ public class RunwayRoadTri
public ushort Vert1 { get; set; }
public ushort Vert2 { get; set; }
public ushort Vert3 { get; set; }
- public byte UnkBits { get; set; } // Returned from runway search - 5 bits and 3 bits
+ ///
+ /// Byte packed as [7:5] SectorId (3 bits, 0–7) | [4:0] CpSubIndex (5 bits, 0–31).
+ /// Returned in the runway-search result as TriUnk (sector) / TriUnk2 (sub-index).
+ /// SectorId identifies the in-game timing sector (0 = T1, 1 = T2, …) for this triangle.
+ /// CpSubIndex is the local checkpoint sub-index used for V-coord interpolation.
+ ///
+ public byte UnkBits { get; set; }
+
+ /// High 3 bits of : in-game timing sector ID (0–7).
+ public byte SectorId => (byte)(UnkBits >> 5);
+
+ /// Low 5 bits of : checkpoint sub-index within the sector (0–31).
+ public byte CpSubIndex => (byte)(UnkBits & 0x1F);
+
public byte Unk { get; set; } // Returned from runway search
public uint Flags { get; set; }
@@ -30,6 +43,16 @@ public static RunwayRoadTri FromStream(BinaryStream bs)
return tri;
}
+ public void ToStream(BinaryStream bs)
+ {
+ bs.WriteUInt16(Vert1);
+ bs.WriteUInt16(Vert2);
+ bs.WriteUInt16(Vert3);
+ bs.WriteByte(UnkBits);
+ bs.WriteByte(Unk);
+ bs.WriteUInt32(Flags);
+ }
+
public static int GetSize()
{
return 0x0C;
diff --git a/PDTools.Files/Courses/PS2/Runway/RunwayRoadVert.cs b/PDTools.Files/Courses/PS2/Runway/RunwayRoadVert.cs
index 93d000ab..0c4e2391 100644
--- a/PDTools.Files/Courses/PS2/Runway/RunwayRoadVert.cs
+++ b/PDTools.Files/Courses/PS2/Runway/RunwayRoadVert.cs
@@ -26,6 +26,16 @@ public static RunwayRoadVert FromStream(BinaryStream bs)
return vert;
}
+ public void ToStream(BinaryStream bs)
+ {
+ bs.WriteSingle(Vertex.X);
+ bs.WriteSingle(Vertex.Y);
+ bs.WriteSingle(Vertex.Z);
+ bs.WriteInt16(Unk);
+ bs.WriteByte(Unk2);
+ bs.WriteByte(Unk3);
+ }
+
public static int GetSize()
{
return 0x10;
diff --git a/PDTools.Files/Courses/PS2/Runway/RunwayStartingPosition.cs b/PDTools.Files/Courses/PS2/Runway/RunwayStartingPosition.cs
new file mode 100644
index 00000000..ba843547
--- /dev/null
+++ b/PDTools.Files/Courses/PS2/Runway/RunwayStartingPosition.cs
@@ -0,0 +1,41 @@
+using Syroot.BinaryData;
+
+namespace PDTools.Files.Courses.PS2.Runway;
+
+///
+/// One spawn / starting-grid position inside a RNW4 file.
+/// Six of these are stored contiguously at file offset 0xC0 (each 16 bytes = 4 floats).
+///
+public class RunwayStartingPosition
+{
+ public float X { get; set; }
+ public float Y { get; set; }
+ public float Z { get; set; }
+
+ ///
+ /// Heading angle of the spawned car, in radians (positive = counter-clockwise from +Z).
+ ///
+ public float Rotation { get; set; }
+
+ public static RunwayStartingPosition FromStream(BinaryStream bs)
+ {
+ return new RunwayStartingPosition
+ {
+ X = bs.ReadSingle(),
+ Y = bs.ReadSingle(),
+ Z = bs.ReadSingle(),
+ Rotation = bs.ReadSingle(),
+ };
+ }
+
+ public void ToStream(BinaryStream bs)
+ {
+ bs.WriteSingle(X);
+ bs.WriteSingle(Y);
+ bs.WriteSingle(Z);
+ bs.WriteSingle(Rotation);
+ }
+
+ /// Size of one record in bytes (always 0x10).
+ public static int GetSize() => 0x10;
+}
diff --git a/PDTools.Files/Courses/PS2/console-example-internal.log b/PDTools.Files/Courses/PS2/console-example-internal.log
new file mode 100644
index 00000000..e35a7092
--- /dev/null
+++ b/PDTools.Files/Courses/PS2/console-example-internal.log
@@ -0,0 +1,14 @@
+2026-06-06 17:47:42.6214 Info Registered target NLog.Targets.ColoredConsoleTarget(Name=logconsole)
+2026-06-06 17:47:42.6214 Warn Error has been raised. Exception: NLog.NLogConfigurationException: Target 'logfile' not found for logging rule: *.
+2026-06-06 17:47:42.6343 Info NLog, Version=5.0.0.0, Culture=neutral, PublicKeyToken=5120e14c03d0593c. File version: 5.2.5.2160. Product version: 5.2.5+7c014325443d65541200b698a50b9dd4ec30c7f0. GlobalAssemblyCache: False
+2026-06-06 17:47:42.6343 Info Validating config: TargetNames=logconsole, ConfigItems=8, FilePath=C:\Users\User\Desktop\gtps2modeltool2\GTPS2ModelTool\bin\Debug\net9.0\NLog.config
+2026-06-06 17:47:42.6508 Info Configuration initialized.
+2026-06-06 17:47:43.2609 Info AppDomain Shutting down. LogFactory closing...
+2026-06-06 17:47:43.2609 Info LogFactory has been closed.
+2026-06-06 17:47:43.3892 Info Registered target NLog.Targets.ColoredConsoleTarget(Name=logconsole)
+2026-06-06 17:47:43.3999 Warn Error has been raised. Exception: NLog.NLogConfigurationException: Target 'logfile' not found for logging rule: *.
+2026-06-06 17:47:43.3999 Info NLog, Version=5.0.0.0, Culture=neutral, PublicKeyToken=5120e14c03d0593c. File version: 5.2.5.2160. Product version: 5.2.5+7c014325443d65541200b698a50b9dd4ec30c7f0. GlobalAssemblyCache: False
+2026-06-06 17:47:43.3999 Info Validating config: TargetNames=logconsole, ConfigItems=8, FilePath=C:\Users\User\Desktop\gtps2modeltool2\GTPS2ModelTool\bin\Debug\net9.0\NLog.config
+2026-06-06 17:47:43.4190 Info Configuration initialized.
+2026-06-06 17:47:43.4691 Info AppDomain Shutting down. LogFactory closing...
+2026-06-06 17:47:43.4691 Info LogFactory has been closed.
diff --git a/PDTools.Files/Models/PS2/CarModel1/CarModel4.cs b/PDTools.Files/Models/PS2/CarModel1/CarModel4.cs
new file mode 100644
index 00000000..a94c5314
--- /dev/null
+++ b/PDTools.Files/Models/PS2/CarModel1/CarModel4.cs
@@ -0,0 +1,269 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+
+using PDTools.Files.Models.PS2.ModelSet;
+
+namespace PDTools.Files.Models.PS2.CarModel1;
+
+///
+/// GT4 Car Model container (CAR4 / "CAR4" magic).
+///
+/// The file is a 0x40-byte header followed by a variable number of sub-sections.
+/// Each field in the header is an absolute file offset; zero means the section is absent.
+///
+/// Sub-sections are stored here as raw byte arrays so the container can be round-tripped
+/// losslessly even when a particular sub-format is not yet fully understood.
+/// Helper Get*() methods parse the byte arrays on demand for callers that need the
+/// object-model view (e.g. the model dumper).
+///
+public class CarModel4
+{
+ /// "CAR4" little-endian magic.
+ public const uint MAGIC = 0x34524143u;
+
+ /// Size of the CAR4 file header.
+ public const uint HeaderSize = 0x40u;
+
+ // ── Sub-section raw bytes (null = section absent) ───────────────────────
+
+ /// 0x10 — GT4 car-info block (contains lights, cameras, exhaust positions, etc.).
+ public byte[] CarInfoBytes { get; set; }
+
+ /// 0x14 — Collision boundary data.
+ public byte[] CollisionModelBytes { get; set; }
+
+ /// 0x18 — Main body ModelSet2 (MDLS).
+ public byte[] MainModelSetBytes { get; set; }
+
+ /// 0x1C — Color-patch table for the main model set (internal format unknown).
+ public byte[] MainModelColorPatchBytes { get; set; }
+
+ /// 0x20 — Wheel ModelSet2 (MDLS).
+ public byte[] WheelModelSetBytes { get; set; }
+
+ /// 0x24 — Color-patch table for the wheel model set.
+ public byte[] WheelColorPatchBytes { get; set; }
+
+ /// 0x28 — Wing / spoiler ModelSet2 (MDLS, optional).
+ public byte[] WingModelSetBytes { get; set; }
+
+ /// 0x2C — Tire ModelSet2 slot 0 (optional).
+ public byte[] TireModelSet0Bytes { get; set; }
+
+ /// 0x30 — Tire ModelSet2 slot 1 (optional).
+ public byte[] TireModelSet1Bytes { get; set; }
+
+ /// 0x34 — Built-in driver model (optional).
+ public byte[] BuiltinDriverModelBytes { get; set; }
+
+ /// 0x38 — Extra texture set (optional).
+ public byte[] TexSetBytes { get; set; }
+
+ // ── Reading ──────────────────────────────────────────────────────────────
+
+ ///
+ /// Parses a CAR4 from starting at its current position.
+ /// Each sub-section is stored as a raw byte array (offset → next_offset).
+ ///
+ public void FromStream(Stream stream)
+ {
+ long basePos = stream.Position;
+
+ using var br = new BinaryReader(stream, System.Text.Encoding.UTF8, leaveOpen: true);
+
+ uint magic = br.ReadUInt32();
+ if (magic != MAGIC)
+ throw new InvalidDataException($"Not a CAR4 file (magic 0x{magic:X8}, expected 0x{MAGIC:X8}).");
+
+ br.ReadUInt32(); // 0x04 RelocatePtr (runtime)
+ br.ReadUInt32(); // 0x08 FileSizeForReloc (runtime)
+ br.ReadUInt32(); // 0x0C Empty
+
+ uint carInfoOff = br.ReadUInt32(); // 0x10
+ uint collisionModelOff = br.ReadUInt32(); // 0x14
+ uint mainModelSetOff = br.ReadUInt32(); // 0x18
+ uint mainColorPatchOff = br.ReadUInt32(); // 0x1C
+ uint wheelModelSetOff = br.ReadUInt32(); // 0x20
+ uint wheelColorPatchOff = br.ReadUInt32(); // 0x24
+ uint wingModelSetOff = br.ReadUInt32(); // 0x28
+ uint tire0Off = br.ReadUInt32(); // 0x2C
+ uint tire1Off = br.ReadUInt32(); // 0x30
+ uint builtinDriverOff = br.ReadUInt32(); // 0x34
+ uint texSetOff = br.ReadUInt32(); // 0x38
+ // 0x3C: padding — header ends at 0x40
+
+ uint fileSize = (uint)(stream.Length - basePos);
+
+ // Build the sorted set of all present section start offsets so we can
+ // derive each section's byte range as [start, nextStart).
+ uint[] allOffsets =
+ [
+ carInfoOff, collisionModelOff, mainModelSetOff, mainColorPatchOff,
+ wheelModelSetOff, wheelColorPatchOff, wingModelSetOff,
+ tire0Off, tire1Off, builtinDriverOff, texSetOff
+ ];
+
+ CarInfoBytes = ReadSection(stream, basePos, carInfoOff, allOffsets, fileSize);
+ CollisionModelBytes = ReadSection(stream, basePos, collisionModelOff, allOffsets, fileSize);
+ MainModelSetBytes = ReadSection(stream, basePos, mainModelSetOff, allOffsets, fileSize);
+ MainModelColorPatchBytes = ReadSection(stream, basePos, mainColorPatchOff, allOffsets, fileSize);
+ WheelModelSetBytes = ReadSection(stream, basePos, wheelModelSetOff, allOffsets, fileSize);
+ WheelColorPatchBytes = ReadSection(stream, basePos, wheelColorPatchOff, allOffsets, fileSize);
+ WingModelSetBytes = ReadSection(stream, basePos, wingModelSetOff, allOffsets, fileSize);
+ TireModelSet0Bytes = ReadSection(stream, basePos, tire0Off, allOffsets, fileSize);
+ TireModelSet1Bytes = ReadSection(stream, basePos, tire1Off, allOffsets, fileSize);
+ BuiltinDriverModelBytes = ReadSection(stream, basePos, builtinDriverOff, allOffsets, fileSize);
+ TexSetBytes = ReadSection(stream, basePos, texSetOff, allOffsets, fileSize);
+ }
+
+ ///
+ /// Reads the bytes for one section. The range is [sectionOffset, nextLargerOffset).
+ /// Trailing alignment padding between sections is included in the extracted bytes,
+ /// ensuring that writing the bytes back produces an identical file layout.
+ ///
+ private static byte[] ReadSection(Stream stream, long basePos,
+ uint sectionOffset, uint[] allOffsets, uint fileSize)
+ {
+ if (sectionOffset == 0)
+ return null;
+
+ // Next section start = smallest offset that is strictly greater than this one
+ uint nextOffset = allOffsets
+ .Where(o => o > sectionOffset)
+ .DefaultIfEmpty(fileSize) // fall back to end-of-file
+ .Min();
+
+ int size = (int)(nextOffset - sectionOffset);
+ if (size <= 0)
+ return null;
+
+ stream.Position = basePos + sectionOffset;
+ byte[] data = new byte[size];
+ int read = 0;
+ while (read < size)
+ {
+ int n = stream.Read(data, read, size - read);
+ if (n == 0)
+ break;
+ read += n;
+ }
+ return data;
+ }
+
+ // ── Writing ──────────────────────────────────────────────────────────────
+
+ ///
+ /// Serialises the CAR4 to .
+ /// Sub-sections are written sequentially starting at offset 0x40; the 0x40-byte
+ /// header is written last (so offsets can be determined during the write pass).
+ ///
+ /// Sections are aligned to 0x40 bytes. When sub-sections were extracted from an
+ /// original file (and therefore already contain trailing alignment padding), the
+ /// alignment step is a no-op and the output is bit-identical to the source.
+ /// When sections are freshly built (no trailing padding), alignment bytes are
+ /// inserted automatically.
+ ///
+ public void Write(Stream stream)
+ {
+ long basePos = stream.Position;
+ stream.Position = basePos + HeaderSize; // leave header space
+
+ uint carInfoOff = WriteSectionAligned(stream, CarInfoBytes, basePos, 0x80);
+ uint collisionModelOff = WriteSectionAligned(stream, CollisionModelBytes, basePos, 0x40);
+ uint mainModelSetOff = WriteSectionAligned(stream, MainModelSetBytes, basePos, 0x40);
+ uint mainColorPatchOff = WriteSectionAligned(stream, MainModelColorPatchBytes, basePos, 0x40);
+ uint wheelModelSetOff = WriteSectionAligned(stream, WheelModelSetBytes, basePos, 0x40);
+ uint wheelColorPatchOff = WriteSectionAligned(stream, WheelColorPatchBytes, basePos, 0x40);
+ uint wingModelSetOff = WriteSectionAligned(stream, WingModelSetBytes, basePos, 0x40);
+ uint tire0Off = WriteSectionAligned(stream, TireModelSet0Bytes, basePos, 0x40);
+ uint tire1Off = WriteSectionAligned(stream, TireModelSet1Bytes, basePos, 0x40);
+ uint builtinDriverOff = WriteSectionAligned(stream, BuiltinDriverModelBytes, basePos, 0x40);
+ uint texSetOff = WriteSectionAligned(stream, TexSetBytes, basePos, 0x40);
+
+ long endPos = stream.Position;
+
+ // Write the 0x40-byte header
+ stream.Position = basePos;
+ using var bw = new BinaryWriter(stream, System.Text.Encoding.UTF8, leaveOpen: true);
+ bw.Write(MAGIC); // 0x00 "CAR4"
+ bw.Write(0u); // 0x04 RelocatePtr (runtime fills)
+ bw.Write((uint)endPos); // 0x08 FileSizeForReloc
+ bw.Write(0u); // 0x0C Empty
+ bw.Write(carInfoOff); // 0x10
+ bw.Write(collisionModelOff); // 0x14
+ bw.Write(mainModelSetOff); // 0x18
+ bw.Write(mainColorPatchOff); // 0x1C
+ bw.Write(wheelModelSetOff); // 0x20
+ bw.Write(wheelColorPatchOff); // 0x24
+ bw.Write(wingModelSetOff); // 0x28
+ bw.Write(tire0Off); // 0x2C
+ bw.Write(tire1Off); // 0x30
+ bw.Write(builtinDriverOff); // 0x34
+ bw.Write(texSetOff); // 0x38
+ bw.Write(0u); // 0x3C padding → header ends at 0x40
+
+ stream.Position = endPos;
+ }
+
+ ///
+ /// Aligns the stream position to , then writes
+ /// and returns the section's absolute offset
+ /// (relative to ), or 0 if data is null/empty.
+ ///
+ private static uint WriteSectionAligned(Stream stream, byte[] data, long basePos, int alignment)
+ {
+ if (data == null || data.Length == 0)
+ return 0;
+
+ AlignStream(stream, alignment);
+ uint offset = (uint)(stream.Position - basePos);
+ stream.Write(data);
+ return offset;
+ }
+
+ private static void AlignStream(Stream stream, int alignment)
+ {
+ long pos = stream.Position;
+ long aligned = (pos + alignment - 1) & ~(long)(alignment - 1);
+ long padding = aligned - pos;
+ if (padding > 0)
+ stream.Write(new byte[padding]);
+ }
+
+ // ── On-demand parsed helpers ─────────────────────────────────────────────
+
+ /// Parses and returns the main body ModelSet2, or null if absent.
+ public ModelSet2 GetMainModelSet() => ParseModelSet2(MainModelSetBytes);
+
+ /// Parses and returns the wheel ModelSet2, or null if absent.
+ public ModelSet2 GetWheelModelSet() => ParseModelSet2(WheelModelSetBytes);
+
+ /// Parses and returns the wing ModelSet2, or null if absent.
+ public ModelSet2 GetWingModelSet() => ParseModelSet2(WingModelSetBytes);
+
+ /// Parses and returns tire ModelSet2 slot 0, or null if absent.
+ public ModelSet2 GetTireModelSet0() => ParseModelSet2(TireModelSet0Bytes);
+
+ /// Parses and returns tire ModelSet2 slot 1, or null if absent.
+ public ModelSet2 GetTireModelSet1() => ParseModelSet2(TireModelSet1Bytes);
+
+ private static ModelSet2 ParseModelSet2(byte[] data)
+ {
+ if (data == null || data.Length < 8)
+ return null;
+
+ try
+ {
+ using var ms = new MemoryStream(data);
+ var modelSet = new ModelSet2();
+ modelSet.FromStream(ms);
+ return modelSet;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+}
diff --git a/PDTools.Files/Models/PS2/ModelSet/ModelSet1.cs b/PDTools.Files/Models/PS2/ModelSet/ModelSet1.cs
index bfea110a..b46a3f84 100644
--- a/PDTools.Files/Models/PS2/ModelSet/ModelSet1.cs
+++ b/PDTools.Files/Models/PS2/ModelSet/ModelSet1.cs
@@ -212,6 +212,10 @@ public override int GetMaterialCount()
public override List GetVariationMaterials(int varIndex)
{
+ // Track models have no variation materials (VariationMaterialsTable is empty).
+ // Fall back to the base Materials list so the dumper doesn't crash.
+ if (varIndex >= VariationMaterialsTable.Count)
+ return Materials;
return VariationMaterialsTable[varIndex];
}
diff --git a/PDTools.Files/Models/PS2/ModelSet/ModelSet2Model.cs b/PDTools.Files/Models/PS2/ModelSet/ModelSet2Model.cs
index 8f4cd4c2..9e0542b9 100644
--- a/PDTools.Files/Models/PS2/ModelSet/ModelSet2Model.cs
+++ b/PDTools.Files/Models/PS2/ModelSet/ModelSet2Model.cs
@@ -165,14 +165,13 @@ private void setBound(float[] min, float[] max)
}
float[] dist = new float[3];
- float total_UNUSED = 0.0f;
float final = 0.0f;
for (i = 0; i < 3; i++)
{
float center = (min[i] + max[i]) * 0.5f;
dist[i] = center;
- final = (max[i] - center) * (max[i] - center);
- total_UNUSED += final;
+ float half = max[i] - center;
+ final += half * half; // Fixed: accumulate all three axes (was replacing each iteration)
}
// Not original, added for convenience
@@ -181,6 +180,23 @@ private void setBound(float[] min, float[] max)
Unk = MathF.Sqrt(final);
}
+ ///
+ /// Sets the model origin and bounding sphere radius from an axis-aligned bounding box.
+ /// Skips populating the 8 corner vectors (Bounds list stays empty).
+ ///
+ public void SetOriginAndRadius(Vector3 min, Vector3 max)
+ {
+ float cx = (min.X + max.X) * 0.5f;
+ float cy = (min.Y + max.Y) * 0.5f;
+ float cz = (min.Z + max.Z) * 0.5f;
+ Origin = new Vector3(cx, cy, cz);
+
+ float dx = max.X - cx;
+ float dy = max.Y - cy;
+ float dz = max.Z - cz;
+ Unk = MathF.Sqrt(dx * dx + dy * dy + dz * dz);
+ }
+
public static uint GetSize()
{
return 0x28;
diff --git a/PDTools.Files/Models/PS2/ModelSet/ModelSet2Serializer.cs b/PDTools.Files/Models/PS2/ModelSet/ModelSet2Serializer.cs
index 746755b3..3370e240 100644
--- a/PDTools.Files/Models/PS2/ModelSet/ModelSet2Serializer.cs
+++ b/PDTools.Files/Models/PS2/ModelSet/ModelSet2Serializer.cs
@@ -63,8 +63,10 @@ public void Write(Stream stream)
bs.WriteUInt32(ModelSet2.MAGIC);
bs.WriteUInt32((uint)(relocationInfoOffset - _baseMdlPos));
bs.WriteUInt32(relocationDataSize);
- bs.WriteUInt32(0); // Relocatrion base
+ bs.WriteUInt32(0); // Relocation base
bs.WriteUInt32((uint)lastPos); // File size
+ bs.WriteByte(0); // 0x14: unk
+ bs.WriteByte(_modelSet.InstanceFlags); // 0x15: InstanceFlags (preserves original or 0 for new models)
bs.Position = lastPos;
}
@@ -74,28 +76,54 @@ private void WriteInstance(BinaryStream bs)
long instanceOffset = bs.Position;
int size = _modelSet.GetInstanceSize();
- bs.Position = instanceOffset + 0x20;
+ // Instance header: 0x20 bytes (explicit zero-init; runtime fills +0x00 and +0x10)
+ bs.WriteInt32(0); // +0x00: parentModelSetPtr (runtime fills this)
+ long outRegsPtrPos = bs.Position;
+ bs.WriteInt32(0); // +0x04: outRegistersPtr (patched below if registers exist)
+ long unkRegsPtrPos = bs.Position;
+ bs.WriteInt32(0); // +0x08: unkRegistersPtr (patched below if registers exist)
+ long hostMethodRegsPtrPos = bs.Position;
+ bs.WriteInt32(0); // +0x0C: hostMethodRegistersPtr (patched below if registers exist)
+ bs.WriteInt32(0); // +0x10: hostMethodInfosFuncs (runtime fills this)
+ bs.WriteInt32(0); // +0x14
+ bs.WriteInt32(0); // +0x18
+ bs.WriteInt32(0); // +0x1C
+
+ // Register arrays follow the 0x20-byte header
long outRegistersOffset = bs.Position;
for (int i = 0; i < _modelSet.OutRegisterInfos.Count; i++)
bs.WriteUInt32(0);
+ long unkRegistersOffset = bs.Position;
+ for (int i = 0; i < _modelSet.InstanceUnkRegisterCount; i++) // Fixed: was missing entirely
+ bs.WriteUInt32(0);
+
long hostMethodRegistersOffset = bs.Position;
for (int i = 0; i < _modelSet.HostMethodInfos.Count; i++)
bs.WriteUInt32(0);
+
long lastPos = bs.Position;
+ // Patch register pointers in instance header
if (_modelSet.OutRegisterInfos.Count > 0)
{
- bs.Position = instanceOffset + 0x04;
+ bs.Position = outRegsPtrPos;
WriteOffset32(bs, (uint)(outRegistersOffset - _baseMdlPos));
}
+ if (_modelSet.InstanceUnkRegisterCount > 0) // Fixed: was missing entirely
+ {
+ bs.Position = unkRegsPtrPos;
+ WriteOffset32(bs, (uint)(unkRegistersOffset - _baseMdlPos));
+ }
+
if (_modelSet.HostMethodInfos.Count > 0)
{
- bs.Position = instanceOffset + 0x0C;
- WriteOffset32(bs, ((uint)(hostMethodRegistersOffset - _baseMdlPos)));
+ bs.Position = hostMethodRegsPtrPos;
+ WriteOffset32(bs, (uint)(hostMethodRegistersOffset - _baseMdlPos));
}
+ // Write instance offset and size to MDLS header
bs.Position = _baseMdlPos + 0x7C;
WriteOffset32(bs, (uint)(instanceOffset - _baseMdlPos));
@@ -137,12 +165,12 @@ private void WriteVariationMaterials(BinaryStream bs)
for (int i = 0; i < _modelSet.VariationMaterials.Count; i++)
{
bs.Position = variationMaterialsOffset + (i * 0x04);
- WriteOffset32(bs, (uint)(_baseMdlPos - dataOffset));
+ WriteOffset32(bs, (uint)(dataOffset - _baseMdlPos)); // Fixed: was _baseMdlPos - dataOffset
bs.Position = dataOffset;
- for (int j = 0; i < _modelSet.VariationMaterials[j].Count; j++)
+ for (int j = 0; j < _modelSet.VariationMaterials[i].Count; j++) // Fixed: j < count, [i] not [j]
{
- PGLUmaterial material = _modelSet.VariationMaterials[j][i];
+ PGLUmaterial material = _modelSet.VariationMaterials[i][j]; // Fixed: [i][j] not [j][i]
material.Write(bs);
}
dataOffset = bs.Position;
@@ -303,6 +331,7 @@ private void WriteVariationTextureSets(BinaryStream bs)
}
}
+ TexSetListHashToOffset.Add(listHash, (uint)texOffsetTable); // Fixed: was never populated
}
}
diff --git a/PDTools.Files/Models/PS2/RenderCommands/Cmd_BBoxRender.cs b/PDTools.Files/Models/PS2/RenderCommands/Cmd_BBoxRender.cs
index ddb2a0d5..74bdbdae 100644
--- a/PDTools.Files/Models/PS2/RenderCommands/Cmd_BBoxRender.cs
+++ b/PDTools.Files/Models/PS2/RenderCommands/Cmd_BBoxRender.cs
@@ -19,9 +19,14 @@ public class Cmd_BBoxRender : ModelSetupPS2Command
public Vector3[] BBox { get; set; }
public List CommandsOnRender { get; set; } = [];
+ /// Absolute stream byte offset where the BBox Vector3 points begin (recorded
+ /// on read so callers can scale the cull box in place without re-serialising).
+ public long BBoxStreamOffset { get; set; }
+
public override void Read(BinaryStream bs, int commandsBaseOffset)
{
byte count = bs.Read1Byte();
+ BBoxStreamOffset = bs.Position;
BBox = new Vector3[count];
for (int i = 0; i < count; i++)
BBox[i] = new Vector3(bs.ReadSingle(), bs.ReadSingle(), bs.ReadSingle());
diff --git a/PDTools.Files/Models/PS2/RenderCommands/Cmd_ModelSet_setShapeTweenRatio.cs b/PDTools.Files/Models/PS2/RenderCommands/Cmd_ModelSet_setShapeTweenRatio.cs
new file mode 100644
index 00000000..da41282d
--- /dev/null
+++ b/PDTools.Files/Models/PS2/RenderCommands/Cmd_ModelSet_setShapeTweenRatio.cs
@@ -0,0 +1,27 @@
+using Syroot.BinaryData;
+
+namespace PDTools.Files.Models.PS2.RenderCommands;
+
+///
+/// GT4 and above. Calls ModelSet2::setShapeTweenRatio with a direct float argument.
+/// Opcode 52 (0x34).
+///
+public class Cmd_ModelSet_setShapeTweenRatio : ModelSetupPS2Command
+{
+ public override ModelSetupPS2Opcode Opcode => ModelSetupPS2Opcode.ModelSet_setShapeTweenRatio;
+
+ public float Ratio { get; set; }
+
+ public override void Read(BinaryStream bs, int commandsBaseOffset)
+ {
+ Ratio = bs.ReadSingle();
+ }
+
+ public override void Write(BinaryStream bs)
+ {
+ bs.WriteSingle(Ratio);
+ }
+
+ public override string ToString()
+ => $"{nameof(Cmd_ModelSet_setShapeTweenRatio)}({Ratio:G})";
+}
diff --git a/PDTools.Files/Models/PS2/RenderCommands/Cmd_pglCylinderMapHint.cs b/PDTools.Files/Models/PS2/RenderCommands/Cmd_pglCylinderMapHint.cs
new file mode 100644
index 00000000..812973f7
--- /dev/null
+++ b/PDTools.Files/Models/PS2/RenderCommands/Cmd_pglCylinderMapHint.cs
@@ -0,0 +1,29 @@
+using System.Numerics;
+using Syroot.BinaryData;
+
+namespace PDTools.Files.Models.PS2.RenderCommands;
+
+///
+/// Calls pglCylinderMapHint with 3 floats. Opcode 49 (0x31).
+///
+public class Cmd_pglCylinderMapHint : ModelSetupPS2Command
+{
+ public override ModelSetupPS2Opcode Opcode => ModelSetupPS2Opcode.pglCylinderMapHint;
+
+ public Vector3 Hint { get; set; }
+
+ public override void Read(BinaryStream bs, int commandsBaseOffset)
+ {
+ Hint = new Vector3(bs.ReadSingle(), bs.ReadSingle(), bs.ReadSingle());
+ }
+
+ public override void Write(BinaryStream bs)
+ {
+ bs.WriteSingle(Hint.X);
+ bs.WriteSingle(Hint.Y);
+ bs.WriteSingle(Hint.Z);
+ }
+
+ public override string ToString()
+ => $"{nameof(Cmd_pglCylinderMapHint)}({Hint.X:G}, {Hint.Y:G}, {Hint.Z:G})";
+}
diff --git a/PDTools.Files/Models/PS2/RenderCommands/Cmd_pglLoadMatrix.cs b/PDTools.Files/Models/PS2/RenderCommands/Cmd_pglLoadMatrix.cs
index 7f31d364..8878ec91 100644
--- a/PDTools.Files/Models/PS2/RenderCommands/Cmd_pglLoadMatrix.cs
+++ b/PDTools.Files/Models/PS2/RenderCommands/Cmd_pglLoadMatrix.cs
@@ -18,8 +18,13 @@ public class Cmd_pglLoadMatrix : ModelSetupPS2Command
public Matrix4x4 Matrix { get; set; }
+ /// Absolute stream byte offset of the 16 matrix floats (recorded on read so
+ /// callers can scale the translation column in place).
+ public long MatrixStreamOffset { get; set; }
+
public override void Read(BinaryStream bs, int commandsBaseOffset)
{
+ MatrixStreamOffset = bs.Position;
Matrix = new Matrix4x4(
bs.ReadSingle(), bs.ReadSingle(), bs.ReadSingle(), bs.ReadSingle(),
bs.ReadSingle(), bs.ReadSingle(), bs.ReadSingle(), bs.ReadSingle(),
diff --git a/PDTools.Files/Models/PS2/RenderCommands/Cmd_pglMultMatrix.cs b/PDTools.Files/Models/PS2/RenderCommands/Cmd_pglMultMatrix.cs
index 110e2bec..d3e4ee01 100644
--- a/PDTools.Files/Models/PS2/RenderCommands/Cmd_pglMultMatrix.cs
+++ b/PDTools.Files/Models/PS2/RenderCommands/Cmd_pglMultMatrix.cs
@@ -15,8 +15,13 @@ public class Cmd_pglMultMatrix : ModelSetupPS2Command
public Matrix4x4 Matrix { get; set; }
+ /// Absolute stream byte offset of the 16 matrix floats (recorded on read so
+ /// callers can scale the translation column in place).
+ public long MatrixStreamOffset { get; set; }
+
public override void Read(BinaryStream bs, int commandsBaseOffset)
{
+ MatrixStreamOffset = bs.Position;
Matrix = new Matrix4x4(
bs.ReadSingle(), bs.ReadSingle(), bs.ReadSingle(), bs.ReadSingle(),
bs.ReadSingle(), bs.ReadSingle(), bs.ReadSingle(), bs.ReadSingle(),
diff --git a/PDTools.Files/Models/PS2/RenderCommands/Cmd_pglTranslate.cs b/PDTools.Files/Models/PS2/RenderCommands/Cmd_pglTranslate.cs
index ef189075..92e19581 100644
--- a/PDTools.Files/Models/PS2/RenderCommands/Cmd_pglTranslate.cs
+++ b/PDTools.Files/Models/PS2/RenderCommands/Cmd_pglTranslate.cs
@@ -21,6 +21,10 @@ public class Cmd_pglTranslate : ModelSetupPS2Command
///
public Vector3 Vector { get; set; }
+ /// Absolute stream byte offset of the translation floats (recorded on read so
+ /// callers can scale the placement in place without re-serialising).
+ public long VectorStreamOffset { get; set; }
+
public Cmd_pglTranslate()
{
@@ -33,6 +37,7 @@ public Cmd_pglTranslate(float x, float y, float z)
public override void Read(BinaryStream bs, int commandsBaseOffset)
{
+ VectorStreamOffset = bs.Position;
Vector = new Vector3(bs.ReadSingle(), bs.ReadSingle(), bs.ReadSingle());
}
diff --git a/PDTools.Files/Models/PS2/RenderCommands/ModelSetupCommand.cs b/PDTools.Files/Models/PS2/RenderCommands/ModelSetupCommand.cs
index a850728e..dfe89418 100644
--- a/PDTools.Files/Models/PS2/RenderCommands/ModelSetupCommand.cs
+++ b/PDTools.Files/Models/PS2/RenderCommands/ModelSetupCommand.cs
@@ -78,6 +78,8 @@ public static ModelSetupPS2Command GetByOpcode(ModelSetupPS2Opcode opcode)
ModelSetupPS2Opcode.pglAlphaFail => new Cmd_pglAlphaFail(),
ModelSetupPS2Opcode.pglGT3_2_1ui => new Cmd_GT3_2_1ui(),
ModelSetupPS2Opcode.pglGT3_2_4f => new Cmd_GT3_2_4f(),
+ ModelSetupPS2Opcode.pglCylinderMapHint => new Cmd_pglCylinderMapHint(),
+ ModelSetupPS2Opcode.ModelSet_setShapeTweenRatio => new Cmd_ModelSet_setShapeTweenRatio(),
ModelSetupPS2Opcode.pgl_53 => new Cmd_Unk53(),
ModelSetupPS2Opcode.CallVM => new Cmd_CallVM(),
ModelSetupPS2Opcode.VM_pglRotate => new Cmd_VM_pglRotate(),
diff --git a/PDTools.Files/Models/PS2/VIFCommand.cs b/PDTools.Files/Models/PS2/VIFCommand.cs
index ae878466..47ef82bb 100644
--- a/PDTools.Files/Models/PS2/VIFCommand.cs
+++ b/PDTools.Files/Models/PS2/VIFCommand.cs
@@ -18,6 +18,11 @@ public class VIFCommand
public GIFTag GIFTag { get; set; }
public List