diff --git a/ControlLib/ControlLib.csproj b/ControlLib/ControlLib.csproj
new file mode 100644
index 0000000..c632161
--- /dev/null
+++ b/ControlLib/ControlLib.csproj
@@ -0,0 +1,3 @@
+
+
+
diff --git a/ControlLib/ControlResourceDump.cs b/ControlLib/ControlResourceDump.cs
new file mode 100644
index 0000000..d538a56
--- /dev/null
+++ b/ControlLib/ControlResourceDump.cs
@@ -0,0 +1,561 @@
+using System.Globalization;
+
+namespace ControlLib;
+
+public static class ControlResourceDump
+{
+ public static void DumpFiles(string ctlPath, string? cptPath = null, string? ndpPath = null, TextWriter? writer = null)
+ {
+ var ctl = ControlResourceReader.ReadCtlFile(ctlPath);
+ var cpt = cptPath is null ? null : ControlResourceReader.ReadCptFile(cptPath);
+ var ndp = ndpPath is null ? null : ControlResourceReader.ReadNdpFile(ndpPath);
+
+ DumpAll(ctl, cpt, ndp, writer);
+ }
+
+ public static void DumpAll(CtlFile ctl, CptFile? cpt = null, NdpFile? ndp = null, TextWriter? writer = null)
+ {
+ var w = writer ?? Console.Out;
+
+ w.WriteLine("================================================================================");
+ w.WriteLine($"CTL: {ctl.SourceName}");
+ w.WriteLine("================================================================================");
+ DumpCtl(ctl, cpt, w);
+
+ if (cpt is not null)
+ {
+ w.WriteLine();
+ w.WriteLine("================================================================================");
+ w.WriteLine($"CPT: {cpt.SourceName}");
+ w.WriteLine("================================================================================");
+ DumpCpt(cpt, w);
+ }
+
+ if (ndp is not null)
+ {
+ w.WriteLine();
+ w.WriteLine("================================================================================");
+ w.WriteLine($"NDP: {ndp.SourceName}");
+ w.WriteLine("================================================================================");
+ DumpNdp(ndp, w);
+ }
+ }
+
+ private static void DumpCtl(CtlFile ctl, CptFile? cpt, TextWriter w)
+ {
+ var h = ctl.Header;
+
+ w.WriteLine("Header");
+ w.WriteLine($" states : {h.ControlStateCount}");
+ w.WriteLine($" link slots : {h.ControlStateLinkSlotCount}");
+ w.WriteLine($" bindings : {h.PieceBindingCount}");
+ w.WriteLine($" items : {h.ItemRecordCount}");
+ w.WriteLine($" command lists: {h.CommandListCount}");
+
+ DumpRootParams(ctl.RootParams, w);
+ DumpStates(ctl, w);
+ DumpPieceBindings(ctl, cpt, w);
+ DumpItems(ctl, cpt, w);
+ DumpCommandTable(ctl, w);
+ DumpCommandLists(ctl, cpt, w);
+ }
+
+ private static void DumpRootParams(object root, TextWriter w)
+ {
+ w.WriteLine();
+ w.WriteLine($"Root params @ {Off(GetLong(root, "Offset"))}");
+ w.WriteLine($" max tang accel : {Vec(Get(root, "MaxTangentialAccel"))}");
+ w.WriteLine($" max tang speed : {Vec(Get(root, "MaxTangentialSpeed"))}");
+ w.WriteLine($" max angular speed : {Vec(Get(root, "MaxAngularSpeed"))}");
+ w.WriteLine($" torque response scale : {Vec(Get(root, "ExternalTorqueResponseScale"))}");
+ w.WriteLine($" visual euler limit : {Vec(Get(root, "VisualEulerAngleLimit"))}");
+ w.WriteLine($" movement physics mode : {GetInt(root, "MovementPhysicsMode")}");
+ w.WriteLine($" slope limit angle : {F(GetFloat(root, "SlopeLimitAngle"))}");
+ w.WriteLine($" mobility integrity : {F(GetFloat(root, "MobilityIntegrityAmount"))}");
+ w.WriteLine($" detach cleanup ms : {GetInt(root, "DetachCleanupDelayMs", "DetachCleanupDelay")}");
+ w.WriteLine($" unknown 0x4C / 0x50 : {F(GetFloat(root, "Unknown4C"))}, {F(GetFloat(root, "Unknown50"))}");
+ w.WriteLine($" unknown 0x58 / 0x60/64: {F(GetFloat(root, "Unknown58"))}, {GetInt(root, "Unknown60")}, {F(GetFloat(root, "Unknown64"))}");
+ }
+
+ private static void DumpStates(CtlFile ctl, TextWriter w)
+ {
+ w.WriteLine();
+ w.WriteLine("Control states");
+
+ if (ctl.StateRecords.Count == 0)
+ {
+ w.WriteLine(" ");
+ return;
+ }
+
+ for (int i = 0; i < ctl.StateRecords.Count; i++)
+ {
+ var state = ctl.StateRecords[i];
+ w.WriteLine($" [{i}] @ {Off(state.Offset)} links={state.Links.Count}");
+
+ for (int j = 0; j < state.Links.Count; j++)
+ {
+ var link = state.Links[j];
+ w.WriteLine(
+ $" link[{j}] " +
+ $"w0={Hex(link.Word0)} ({UncheckedInt(link.Word0)}) " +
+ $"w1={Hex(link.Word1)} ({UncheckedInt(link.Word1)}) " +
+ $"w2={Hex(link.Word2)} ({UncheckedInt(link.Word2)}) " +
+ $"w3={Hex(link.Word3)} ({UncheckedInt(link.Word3)})");
+ }
+ }
+
+ if (ctl.EdgeWeightMatrix.Count != 0)
+ w.WriteLine($" edge weights: {string.Join(", ", ctl.EdgeWeightMatrix.Select(F))}");
+ }
+
+ private static void DumpPieceBindings(CtlFile ctl, CptFile? cpt, TextWriter w)
+ {
+ w.WriteLine();
+ w.WriteLine("Piece bindings");
+
+ for (int i = 0; i < ctl.PieceBindings.Count; i++)
+ {
+ var b = ctl.PieceBindings[i];
+
+ int primary = GetInt(b, "PrimaryCptRefIndex");
+ int secondary = GetInt(b, "SecondaryCptRefIndex");
+ uint flags = GetUInt(b, "Flags");
+
+ w.WriteLine(
+ $" [{i}] @ {Off(GetLong(b, "Offset"))} " +
+ $"piece={GetInt(b, "LocalPieceIndex"),3} " +
+ $"cptA={CptRef(cpt, primary),-24} " +
+ $"cptB={CptRef(cpt, secondary),-24} " +
+ $"default={F(GetFloat(b, "DefaultValue")),8} " +
+ $"flags={Hex(flags)} {PieceBindingFlags(flags)}");
+
+ w.WriteLine(
+ $" anim1=({F(GetFloat(b, "Anim1Start"))}, {F(GetFloat(b, "Anim1EndOrDuration"))}) " +
+ $"anim2=({F(GetFloat(b, "Anim2Start"))}, {F(GetFloat(b, "Anim2EndOrDuration"))}) " +
+ $"weight={F(GetFloat(b, "AnimBlendWeight"))} " +
+ $"p24={F(GetFloat(b, "BindingParam24", "Unknown24"))} " +
+ $"p28={F(GetFloat(b, "BindingParam28", "Unknown28"))}");
+ }
+ }
+ private static string ItemFlags(uint flags)
+ {
+ var names = new List();
+
+ if ((flags & 0x01000000) != 0) names.Add("left_drive_group");
+ if ((flags & 0x02000000) != 0) names.Add("right_drive_group");
+ if ((flags & 0x04000000) != 0) names.Add("invert_motion_or_orientation");
+
+ return names.Count == 0 ? "" : $"[{string.Join("|", names)}]";
+ }
+ private static void DumpItems(CtlFile ctl, CptFile? cpt, TextWriter w)
+ {
+ w.WriteLine();
+ w.WriteLine("Items");
+
+ for (int i = 0; i < ctl.ItemRecords.Count; i++)
+ {
+ var item = ctl.ItemRecords[i];
+
+ int type = GetInt(item, "ItemType");
+ uint flags = GetUInt(item, "Flags");
+ var bindingIndices = ToIntList(Get(item, "PieceBindingIndices"));
+
+ string name = GetString(item, "ItemName");
+ string namePart = string.IsNullOrEmpty(name) ? "" : $" name='{name}'";
+
+ w.WriteLine(
+ $" [{i}] @ {Off(GetLong(item, "Offset"))}-{Off(GetLong(item, "EndOffset"))} " +
+ $"type=0x{type:X2} {ItemTypeName(type)} " +
+ $"piece={GetInt(item, "LocalPieceIndex"),3} " +
+ $"flags={Hex(flags)} {ItemFlags(flags)}{namePart}");
+
+ w.WriteLine(
+ $" idx0C/10/14={GetInt(item, "OptionalIndex0C", "Unknown0C")}, " +
+ $"{GetInt(item, "OptionalIndex10", "Unknown10")}, " +
+ $"{GetInt(item, "OptionalIndex14", "Unknown14")} " +
+ $"link={GetInt(item, "OptionalLinkIndex")} " +
+ $"mobility={GetInt(item, "MobilityContribution")}");
+
+ w.WriteLine(
+ $" p20/24/28/2C={F(GetFloat(item, "Param20", "Unknown20"))}, " +
+ $"{F(GetFloat(item, "Param24", "Unknown24"))}, " +
+ $"{F(GetFloat(item, "Param28", "Unknown28"))}, " +
+ $"{F(GetFloat(item, "Param2C", "Unknown2C"))}");
+
+ var resource = Get(item, "Resource");
+ if (resource is not null && !ResourceIsEmpty(resource))
+ w.WriteLine($" resource={ResourceToString(resource)}");
+
+ var commonParams = ToFloatList(Get(item, "CommonParams"));
+ var nonZeroParams = commonParams
+ .Select((v, idx) => (idx, v))
+ .Where(x => Math.Abs(x.v) > 0.00001f)
+ .Select(x => $"{x.idx}:{F(x.v)}")
+ .ToArray();
+
+ if (nonZeroParams.Length != 0)
+ w.WriteLine($" common params: {string.Join(", ", nonZeroParams)}");
+
+ if (bindingIndices.Count != 0)
+ {
+ w.WriteLine($" binding refs: {string.Join(", ", bindingIndices.Select(x => BindingRef(ctl, cpt, x)))}");
+ }
+ }
+ }
+
+ private static void DumpCommandTable(CtlFile ctl, TextWriter w)
+ {
+ w.WriteLine();
+ w.WriteLine($"Command hook table @ {Off(ctl.CommandTable.Offset)}");
+ w.WriteLine($" on_load : {ctl.CommandTable.OnLoad}");
+ w.WriteLine($" on_enter_critical_decay: {ctl.CommandTable.OnEnterCriticalDecay}");
+ w.WriteLine($" on_exit_critical_decay : {ctl.CommandTable.OnExitCriticalDecay}");
+
+ for (int i = 0; i < ctl.CommandTable.Indices.Count; i++)
+ {
+ int index = ctl.CommandTable.Indices[i];
+ if (index != -1)
+ w.WriteLine($" slot[{i,2}] -> list {index}");
+ }
+ }
+
+ private static void DumpCommandLists(CtlFile ctl, CptFile? cpt, TextWriter w)
+ {
+ w.WriteLine();
+ w.WriteLine("Command lists");
+
+ for (int listIndex = 0; listIndex < ctl.CommandLists.Count; listIndex++)
+ {
+ var list = ctl.CommandLists[listIndex];
+ w.WriteLine($" list[{listIndex}] @ {Off(list.Offset)} count={list.Count}");
+
+ for (int commandIndex = 0; commandIndex < list.Records.Count; commandIndex++)
+ {
+ var cmd = list.Records[commandIndex];
+ uint opcode = GetUInt(cmd, "Opcode");
+ var args = GetCommandArgs(cmd);
+
+ w.WriteLine(
+ $" [{commandIndex}] @ {Off(GetLong(cmd, "Offset"))} " +
+ $"op=0x{opcode:X2} {OpcodeName(opcode)}");
+
+ var condition = Get(cmd, "Condition");
+ if (condition is not null)
+ {
+ uint cf = GetUInt(condition, "ConditionFlags");
+ uint cm = GetUInt(condition, "ConditionMask");
+ uint ci = GetUInt(condition, "ConditionInvertMask");
+ if (cf != 0 || cm != 0 || ci != 0)
+ w.WriteLine($" cond flags={Hex(cf)} mask={Hex(cm)} invert={Hex(ci)}");
+ }
+
+ w.WriteLine(
+ $" args int : {string.Join(", ", args.Select(x => UncheckedInt(x).ToString(CultureInfo.InvariantCulture)))}");
+ w.WriteLine(
+ $" args float: {string.Join(", ", args.Select(x => F(U32AsFloat(x))))}");
+
+ var resource = Get(cmd, "Resource");
+ if (resource is not null && !ResourceIsEmpty(resource))
+ w.WriteLine($" resource : {ResourceToString(resource)}");
+
+ PrintCommandHints(cpt, opcode, args, w);
+ }
+ }
+ }
+
+ private static void DumpCpt(CptFile cpt, TextWriter w)
+ {
+ for (int i = 0; i < cpt.Records.Count; i++)
+ {
+ var r = cpt.Records[i];
+ string name = i < cpt.Names.Count ? cpt.Names[i] : "";
+ int piece = GetInt(r, "OwnerPieceIndex");
+
+ w.WriteLine(
+ $" [{i,2}] @ {Off(GetLong(r, "Offset"))} " +
+ $"name='{name,-16}' OwnerPieceIndex={piece,3} " +
+ $"TargetPieceIndex={GetInt(r, "TargetPieceIndex"),3} " +
+ $"pos={Vec(Get(r, "Position"))} dir={Vec(Get(r, "Direction"))}");
+ }
+ }
+
+ private static void DumpNdp(NdpFile ndp, TextWriter w)
+ {
+ float totalMax = 0;
+
+ for (int i = 0; i < ndp.Records.Count; i++)
+ {
+ var r = ndp.Records[i];
+ float max = GetFloat(r, "MaxVolume");
+ float mobility = GetFloat(r, "MobilityContributionPerVolume");
+ totalMax += max;
+
+ w.WriteLine(
+ $" [{i,2}] @ {Off(GetLong(r, "Offset"))} " +
+ $"max={F(max),8} mobility/vol={F(mobility),10} " +
+ $"unknown0={Hex(GetUInt(r, "Unknown0"))} " +
+ $"resource={ResourceToString(Get(r, "Resource")!)}");
+ }
+
+ w.WriteLine($" total max volume: {F(totalMax)}");
+ }
+
+ private static void PrintCommandHints(CptFile? cpt, uint opcode, IReadOnlyList args, TextWriter w)
+ {
+ if (cpt is null || args.Count < 5)
+ return;
+
+ switch (opcode)
+ {
+ case 0x03:
+ case 0x04:
+ case 0x05:
+ case 0x16:
+ w.WriteLine(
+ $" cpt hint : " +
+ $"a0={CptRef(cpt, UncheckedInt(args[0]))}, " +
+ $"a1={CptRef(cpt, UncheckedInt(args[1]))}, " +
+ $"a2={CptRef(cpt, UncheckedInt(args[2]))}, " +
+ $"id/local={UncheckedInt(args[3])}, " +
+ $"scale/range={F(U32AsFloat(args[4]))}");
+ break;
+ }
+ }
+
+ private static string BindingRef(CtlFile ctl, CptFile? cpt, int bindingIndex)
+ {
+ if (bindingIndex < 0 || bindingIndex >= ctl.PieceBindings.Count)
+ return $"{bindingIndex}:";
+
+ var b = ctl.PieceBindings[bindingIndex];
+ return $"{bindingIndex}:piece {GetInt(b, "LocalPieceIndex")} cptA={CptRef(cpt, GetInt(b, "PrimaryCptRefIndex"))}";
+ }
+
+ private static string CptRef(CptFile? cpt, int index)
+ {
+ if (index < 0)
+ return "-";
+
+ if (cpt is null)
+ return index.ToString(CultureInfo.InvariantCulture);
+
+ if (index >= cpt.Records.Count)
+ return $"{index}:";
+
+ string name = index < cpt.Names.Count ? cpt.Names[index] : "";
+ var r = cpt.Records[index];
+
+ string label = string.IsNullOrEmpty(name) ? $"cpt{index}" : name;
+ return $"{index}:{label}/owner {r.OwnerPieceIndex}/target {r.TargetPieceIndex}";
+ }
+
+ private static string PieceBindingFlags(uint flags)
+ {
+ var names = new List();
+
+ if ((flags & 0x01) != 0) names.Add("wrap");
+ if ((flags & 0x02) != 0) names.Add("invert");
+ if ((flags & 0x04) != 0) names.Add("skip_anim_init");
+ if ((flags & 0x08) != 0) names.Add("auto_item_ref");
+ if ((flags & 0x100) != 0) names.Add("conditional_link_target");
+
+ return names.Count == 0 ? "" : $"[{string.Join("|", names)}]";
+ }
+
+ private static string ItemTypeName(int type)
+ {
+ return type switch
+ {
+ 0x01 => "turret drive",
+ 0x02 => "projectile weapon",
+ 0x03 => "mobility support",
+ 0x04 => "targeting system",
+ 0x05 => "engine",
+ 0x08 => "sensor-radar",
+ 0x09 => "force shield",
+ 0x0A => "detection shield",
+ 0x0F => "repair system",
+ 0x11 => "BULL guidance",
+ 0x13 => "battery",
+ 0x14 => "wheel mobility unit",
+ 0x15 => "deflector",
+ 0x16 => "weapon/actuator? type16",
+ 0x1A => "engine alt",
+ 0x1B => "armour",
+ 0x1E => "projectile weapon alt",
+ _ => $"unknown item type 0x{type:X2}",
+ };
+ }
+
+ private static string OpcodeName(uint op)
+ {
+ return Enum.IsDefined(typeof(EControlCommandOpcode), op) ? ((EControlCommandOpcode)op).ToString() : $"UNKNOWN_0x{op:X2}";
+ }
+
+ private static IReadOnlyList GetCommandArgs(ControlCommandRecordRaw cmd)
+ {
+ return
+ [
+ GetUInt(cmd, "Arg0"),
+ GetUInt(cmd, "Arg1"),
+ GetUInt(cmd, "Arg2"),
+ GetUInt(cmd, "Arg3"),
+ GetUInt(cmd, "Arg4"),
+ ];
+ }
+
+ private static object? Get(object? obj, params string[] names)
+ {
+ if (obj is null)
+ return null;
+
+ var t = obj.GetType();
+
+ foreach (string name in names)
+ {
+ var p = t.GetProperty(name);
+ if (p is not null)
+ return p.GetValue(obj);
+ }
+
+ return null;
+ }
+
+ private static int GetInt(object? obj, params string[] names)
+ {
+ var v = Get(obj, names);
+ return v switch
+ {
+ Enum e => Convert.ToInt32(e, CultureInfo.InvariantCulture),
+ int x => x,
+ uint x => unchecked((int)x),
+ long x => checked((int)x),
+ ulong x => checked((int)x),
+ short x => x,
+ ushort x => x,
+ byte x => x,
+ sbyte x => x,
+ float x => checked((int)x),
+ double x => checked((int)x),
+ _ => 0
+ };
+ }
+
+ private static uint GetUInt(object? obj, params string[] names)
+ {
+ var v = Get(obj, names);
+ return v switch
+ {
+ Enum e => Convert.ToUInt32(e, CultureInfo.InvariantCulture),
+ uint x => x,
+ int x => unchecked((uint)x),
+ long x => checked((uint)x),
+ ulong x => checked((uint)x),
+ short x => unchecked((uint)x),
+ ushort x => x,
+ byte x => x,
+ sbyte x => unchecked((uint)x),
+ _ => 0
+ };
+ }
+
+ private static long GetLong(object? obj, params string[] names)
+ {
+ var v = Get(obj, names);
+ return v switch
+ {
+ long x => x,
+ int x => x,
+ uint x => x,
+ ulong x => checked((long)x),
+ _ => 0
+ };
+ }
+
+ private static float GetFloat(object? obj, params string[] names)
+ {
+ var v = Get(obj, names);
+
+ return v switch
+ {
+ float x => x,
+ double x => (float)x,
+ int x when Math.Abs(x) > 1_000_000 => BitConverter.Int32BitsToSingle(x),
+ uint x when x > 1_000_000 => BitConverter.UInt32BitsToSingle(x),
+ int x => x,
+ uint x => x,
+ long x => x,
+ ulong x => x,
+ _ => 0
+ };
+ }
+
+ private static string GetString(object? obj, params string[] names)
+ {
+ return Get(obj, names) as string ?? "";
+ }
+
+ private static List ToIntList(object? value)
+ {
+ if (value is IEnumerable ints)
+ return ints.ToList();
+
+ if (value is IEnumerable uints)
+ return uints.Select(uncheckedInt => unchecked((int)uncheckedInt)).ToList();
+
+ return [];
+ }
+
+ private static List ToFloatList(object? value)
+ {
+ if (value is IEnumerable floats)
+ return floats.ToList();
+
+ if (value is IEnumerable doubles)
+ return doubles.Select(x => (float)x).ToList();
+
+ return [];
+ }
+
+ private static string Vec(object? v)
+ {
+ if (v is null)
+ return "(0, 0, 0)";
+
+ return $"({F(GetFloat(v, "X"))}, {F(GetFloat(v, "Y"))}, {F(GetFloat(v, "Z"))})";
+ }
+
+ private static string ResourceToString(object resource)
+ {
+ var archive = GetString(resource, "ArchiveName");
+ var name = GetString(resource, "ResourceName");
+ return string.IsNullOrEmpty(archive) || string.IsNullOrEmpty(name)
+ ? ""
+ : $"{archive}/{name}";
+ }
+
+ private static bool ResourceIsEmpty(object resource)
+ {
+ var archive = GetString(resource, "ArchiveName");
+ var name = GetString(resource, "ResourceName");
+ return string.IsNullOrEmpty(archive) || string.IsNullOrEmpty(name);
+ }
+
+ private static string Off(long offset) => $"0x{offset:X4}";
+ private static string Hex(uint value) => $"0x{value:X8}";
+ private static int UncheckedInt(uint value) => unchecked((int)value);
+ private static float U32AsFloat(uint value) => BitConverter.UInt32BitsToSingle(value);
+
+ private static string F(float value)
+ {
+ if (float.IsNaN(value))
+ return "NaN";
+ if (float.IsPositiveInfinity(value))
+ return "+Inf";
+ if (float.IsNegativeInfinity(value))
+ return "-Inf";
+
+ return value.ToString("0.######", CultureInfo.InvariantCulture);
+ }
+}
\ No newline at end of file
diff --git a/ControlLib/ControlResourceReaders.cs b/ControlLib/ControlResourceReaders.cs
new file mode 100644
index 0000000..d746025
--- /dev/null
+++ b/ControlLib/ControlResourceReaders.cs
@@ -0,0 +1,544 @@
+using System.Text;
+
+namespace ControlLib;
+
+public sealed class ControlReadException : Exception
+{
+ public ControlReadException(string message) : base(message) { }
+}
+
+public readonly record struct Vec3(float X, float Y, float Z);
+
+public readonly record struct ResourceNamedRef(string ArchiveName, string ResourceName)
+{
+ public bool IsEmpty => string.IsNullOrEmpty(ArchiveName) || string.IsNullOrEmpty(ResourceName);
+ public override string ToString() => IsEmpty ? "" : $"{ArchiveName} / {ResourceName}";
+}
+
+public static class ControlResourceReader
+{
+ public static CptFile ReadCptFile(string path)
+ {
+ using var fs = File.OpenRead(path);
+ return ReadCptFile(fs, path);
+ }
+
+ public static CptFile ReadCptFile(Stream stream, string? sourceName = null)
+ {
+ var r = new LeReader(stream);
+ long start = stream.Position;
+ uint count = r.ReadUInt32();
+ var records = new List(checked((int)count));
+
+ for (int i = 0; i < count; i++)
+ {
+ long off = stream.Position - start;
+ records.Add(new CptRecordRaw(
+ Offset: off,
+ Unknown0: r.ReadInt32(),
+ OwnerPieceIndex: r.ReadInt32(),
+ TargetPieceIndex: r.ReadInt32(),
+ Position: r.ReadVec3(),
+ Direction: r.ReadVec3()
+ ));
+ }
+
+ var names = new List(checked((int)count));
+ for (int i = 0; i < count; i++)
+ {
+ names.Add(r.ReadFixedString(32));
+ }
+
+ r.ExpectEofOrThrow(sourceName ?? "CPT");
+ return new CptFile(sourceName, count, records, names);
+ }
+
+ public static NdpFile ReadNdpFile(string path)
+ {
+ using var fs = File.OpenRead(path);
+ return ReadNdpFile(fs, path);
+ }
+
+ public static NdpFile ReadNdpFile(Stream stream, string? sourceName = null)
+ {
+ var r = new LeReader(stream);
+ long start = stream.Position;
+ uint count = r.ReadUInt32();
+ var records = new List(checked((int)count));
+
+ for (int i = 0; i < count; i++)
+ {
+ long off = stream.Position - start;
+ records.Add(new NdpRecordRaw(
+ Offset: off,
+ Unknown0: r.ReadUInt32(),
+ MaxVolume: r.ReadSingle(),
+ MobilityContributionPerVolume: r.ReadSingle(),
+ Resource: r.ReadResourceNamedRef()
+ ));
+ }
+
+ r.ExpectEofOrThrow(sourceName ?? "NDP");
+ return new NdpFile(sourceName, count, records);
+ }
+
+ public static CtlFile ReadCtlFile(string path)
+ {
+ using var fs = File.OpenRead(path);
+ return ReadCtlFile(fs, path);
+ }
+
+ public static CtlFile ReadCtlFile(Stream stream, string? sourceName = null)
+ {
+ var r = new LeReader(stream);
+ long start = stream.Position;
+
+ var header = new CtlHeader(
+ ControlStateCount: r.ReadUInt32(),
+ ControlStateLinkSlotCount: r.ReadUInt32(),
+ PieceBindingCount: r.ReadUInt32(),
+ ItemRecordCount: r.ReadUInt32(),
+ CommandListCount: r.ReadUInt32()
+ );
+
+ var rootParams = ReadCtlRootControlParams(r, stream.Position - start);
+
+ var states = new List(checked((int)header.ControlStateCount));
+ for (int i = 0; i < header.ControlStateCount; i++)
+ {
+ long off = stream.Position - start;
+ byte[] fixedBody = r.ReadBytesExact(0x9C);
+ var links = new List(checked((int)header.ControlStateLinkSlotCount));
+ for (int j = 0; j < header.ControlStateLinkSlotCount; j++)
+ {
+ links.Add(new NavNodeLinkSlotRaw(
+ Word0: r.ReadUInt32(),
+ Word1: r.ReadUInt32(),
+ Word2: r.ReadUInt32(),
+ Word3: r.ReadUInt32()
+ ));
+ }
+ states.Add(new CtlStateRecordRaw(off, fixedBody, links));
+ }
+
+ int edgeCount = checked((int)(header.ControlStateCount * header.ControlStateCount));
+ var edgeWeightMatrix = new float[edgeCount];
+ for (int i = 0; i < edgeWeightMatrix.Length; i++)
+ edgeWeightMatrix[i] = r.ReadSingle();
+
+ var pieceBindings = new List(checked((int)header.PieceBindingCount));
+ for (int i = 0; i < header.PieceBindingCount; i++)
+ {
+ long off = stream.Position - start;
+ pieceBindings.Add(ReadPieceBinding(r, off));
+ }
+
+ var items = new List(checked((int)header.ItemRecordCount));
+ for (int i = 0; i < header.ItemRecordCount; i++)
+ {
+ long off = stream.Position - start;
+ items.Add(ReadCtlItemRecord(r, start, off));
+ }
+
+ long commandTableOffset = stream.Position - start;
+ var commandTable = new ControlCommandListIndexTable(commandTableOffset, r.ReadInt32Array(21));
+
+ var commandLists = new List(checked((int)header.CommandListCount));
+ for (int i = 0; i < header.CommandListCount; i++)
+ {
+ long listOffset = stream.Position - start;
+ uint count = r.ReadUInt32();
+ var records = new List(checked((int)count));
+ for (int j = 0; j < count; j++)
+ {
+ long recordOffset = stream.Position - start;
+ records.Add(ReadCommandRecord(r, recordOffset));
+ }
+ commandLists.Add(new ControlCommandListRaw(listOffset, count, records));
+ }
+
+ r.ExpectEofOrThrow(sourceName ?? "CTL");
+
+ return new CtlFile(
+ SourceName: sourceName,
+ Header: header,
+ RootParams: rootParams,
+ StateRecords: states,
+ EdgeWeightMatrix: edgeWeightMatrix,
+ PieceBindings: pieceBindings,
+ ItemRecords: items,
+ CommandTable: commandTable,
+ CommandLists: commandLists
+ );
+ }
+
+ private static CtlRootControlParams ReadCtlRootControlParams(LeReader r, long offset)
+ {
+ return new CtlRootControlParams(
+ Offset: offset,
+ MaxTangentialAccel: r.ReadVec3(),
+ Unknown0C: r.ReadUInt32(),
+ Unknown10: r.ReadUInt32(),
+ Unknown14: r.ReadUInt32(),
+ MaxTangentialSpeed: r.ReadVec3(),
+ MaxAngularSpeed: r.ReadVec3(),
+ ExternalTorqueResponseScale: r.ReadVec3(),
+ VisualEulerAngleLimit: r.ReadVec3(),
+ DetachCleanupDelayMs: r.ReadInt32(),
+ Unknown4C: r.ReadSingle(),
+ Unknown50: r.ReadSingle(),
+ MovementPhysicsMode: r.ReadInt32(),
+ Unknown58: r.ReadSingle(),
+ SlopeLimitAngle: r.ReadSingle(),
+ Unknown60: r.ReadUInt32(),
+ Unknown64: r.ReadSingle(),
+ MobilityIntegrityAmount: r.ReadSingle()
+ );
+ }
+
+ private static CtlPieceBindingRecordRaw ReadPieceBinding(LeReader r, long offset)
+ {
+ return new CtlPieceBindingRecordRaw(
+ Offset: offset,
+ LocalPieceIndex: r.ReadInt32(),
+ Anim1Start: r.ReadSingle(),
+ Anim1EndOrDuration: r.ReadSingle(),
+ Anim2Start: r.ReadSingle(),
+ Anim2EndOrDuration: r.ReadSingle(),
+ AnimBlendWeight: r.ReadSingle(),
+ DefaultValue: r.ReadSingle(),
+ PrimaryCptRefIndex: r.ReadInt32(),
+ SecondaryCptRefIndex: r.ReadInt32(),
+ Param24: r.ReadSingle(),
+ Param28: r.ReadSingle(),
+ Flags: r.ReadUInt32()
+ );
+ }
+
+ private static CtlItemRecordRaw ReadCtlItemRecord(LeReader r, long fileStart, long offset)
+ {
+ int itemType = r.ReadInt32();
+ int localPieceIndex = r.ReadInt32();
+ uint flags = r.ReadUInt32();
+ int unknown0C = r.ReadInt32();
+ int unknown10 = r.ReadInt32();
+ int unknown14 = r.ReadInt32();
+ int optionalLinkIndex = r.ReadInt32();
+ uint mobilityContribution = r.ReadUInt32();
+ float unknown20 = r.ReadSingle();
+ float unknown24 = r.ReadSingle();
+ float unknown28 = r.ReadSingle();
+ float unknown2C = r.ReadSingle();
+
+ var commonParams = new float[16];
+ for (int i = 0; i < commonParams.Length; i++)
+ commonParams[i] = r.ReadSingle();
+
+ string resourceArchiveName = r.ReadFixedString(32);
+ string resourceResourceName = r.ReadFixedString(32);
+ uint bindingCount = r.ReadUInt32();
+
+ var bindingIndices = new int[checked((int)bindingCount)];
+ for (int i = 0; i < bindingIndices.Length; i++)
+ bindingIndices[i] = r.ReadInt32();
+
+ int itemNameLength = r.ReadInt32();
+ string itemName = string.Empty;
+ if (itemNameLength < 0)
+ throw new ControlReadException($"Negative CTL item name length {itemNameLength} at 0x{offset:X}");
+
+ if (itemNameLength != 0)
+ {
+ // The engine allocates length+1 and copies length+1 bytes from the file.
+ byte[] raw = r.ReadBytesExact(itemNameLength + 1);
+ int nul = Array.IndexOf(raw, (byte)0);
+ int len = nul >= 0 ? nul : raw.Length;
+ itemName = Encoding.ASCII.GetString(raw, 0, len);
+ }
+
+ long endOffset = r.BaseStream.Position - fileStart;
+
+ return new CtlItemRecordRaw(
+ Offset: offset,
+ EndOffset: endOffset,
+ ItemType: itemType,
+ LocalPieceIndex: localPieceIndex,
+ Flags: flags,
+ Unknown0C: unknown0C,
+ Unknown10: unknown10,
+ Unknown14: unknown14,
+ OptionalLinkIndex: optionalLinkIndex,
+ MobilityContribution: mobilityContribution,
+ Param20: unknown20,
+ Param24: unknown24,
+ Param28: unknown28,
+ Param2C: unknown2C,
+ CommonParams: commonParams,
+ Resource: new ResourceNamedRef(resourceArchiveName, resourceResourceName),
+ PieceBindingIndices: bindingIndices,
+ ItemNameLength: itemNameLength,
+ ItemName: itemName
+ );
+ }
+
+ private static ControlCommandRecordRaw ReadCommandRecord(LeReader r, long offset)
+ {
+ var condition = new ControlCommandConditionRaw(
+ ConditionFlags: r.ReadUInt32(),
+ ConditionMask: r.ReadUInt32(),
+ ConditionInvertMask: r.ReadUInt32()
+ );
+
+ return new ControlCommandRecordRaw(
+ Offset: offset,
+ Condition: condition,
+ Opcode: (EControlCommandOpcode)r.ReadUInt32(),
+ Arg0: r.ReadUInt32(),
+ Arg1: r.ReadUInt32(),
+ Arg2: r.ReadUInt32(),
+ Arg3: r.ReadUInt32(),
+ Arg4: r.ReadUInt32(),
+ Resource: r.ReadResourceNamedRef()
+ );
+ }
+}
+
+public sealed record CptFile(
+ string? SourceName,
+ uint Count,
+ IReadOnlyList Records,
+ IReadOnlyList Names);
+
+public sealed record CptRecordRaw(
+ long Offset,
+ int Unknown0,
+ int OwnerPieceIndex,
+ int TargetPieceIndex,
+ Vec3 Position,
+ Vec3 Direction);
+
+public sealed record NdpFile(
+ string? SourceName,
+ uint Count,
+ IReadOnlyList Records);
+
+public sealed record NdpRecordRaw(
+ long Offset,
+ uint Unknown0,
+ float MaxVolume,
+ float MobilityContributionPerVolume,
+ ResourceNamedRef Resource);
+
+public sealed record CtlFile(
+ string? SourceName,
+ CtlHeader Header,
+ CtlRootControlParams RootParams,
+ IReadOnlyList StateRecords,
+ IReadOnlyList EdgeWeightMatrix,
+ IReadOnlyList PieceBindings,
+ IReadOnlyList ItemRecords,
+ ControlCommandListIndexTable CommandTable,
+ IReadOnlyList CommandLists);
+
+public readonly record struct CtlHeader(
+ uint ControlStateCount,
+ uint ControlStateLinkSlotCount,
+ uint PieceBindingCount,
+ uint ItemRecordCount,
+ uint CommandListCount);
+
+public sealed record CtlRootControlParams(
+ long Offset,
+ Vec3 MaxTangentialAccel,
+ uint Unknown0C,
+ uint Unknown10,
+ uint Unknown14,
+ Vec3 MaxTangentialSpeed,
+ Vec3 MaxAngularSpeed,
+ Vec3 ExternalTorqueResponseScale,
+ Vec3 VisualEulerAngleLimit,
+ int DetachCleanupDelayMs,
+ float Unknown4C,
+ float Unknown50,
+ int MovementPhysicsMode,
+ float Unknown58,
+ float SlopeLimitAngle,
+ uint Unknown60,
+ float Unknown64,
+ float MobilityIntegrityAmount);
+
+public sealed record CtlStateRecordRaw(
+ long Offset,
+ byte[] FixedBody,
+ IReadOnlyList Links);
+
+public readonly record struct NavNodeLinkSlotRaw(uint Word0, uint Word1, uint Word2, uint Word3);
+
+public sealed record CtlPieceBindingRecordRaw(
+ long Offset,
+ int LocalPieceIndex,
+ float Anim1Start,
+ float Anim1EndOrDuration,
+ float Anim2Start,
+ float Anim2EndOrDuration,
+ float AnimBlendWeight,
+ float DefaultValue,
+ int PrimaryCptRefIndex,
+ int SecondaryCptRefIndex,
+ float Param24,
+ float Param28,
+ uint Flags);
+
+public sealed record CtlItemRecordRaw(
+ long Offset,
+ long EndOffset,
+ int ItemType,
+ int LocalPieceIndex,
+ uint Flags,
+ int Unknown0C,
+ int Unknown10,
+ int Unknown14,
+ int OptionalLinkIndex,
+ uint MobilityContribution,
+ float Param20,
+ float Param24,
+ float Param28,
+ float Param2C,
+ IReadOnlyList CommonParams,
+ ResourceNamedRef Resource,
+ IReadOnlyList PieceBindingIndices,
+ int ItemNameLength,
+ string ItemName);
+
+public sealed record ControlCommandListIndexTable(long Offset, IReadOnlyList Indices)
+{
+ public int OnLoad => Indices.Count > 0 ? Indices[0] : -1;
+ public int OnEnterCriticalDecay => Indices.Count > 6 ? Indices[6] : -1;
+ public int OnExitCriticalDecay => Indices.Count > 7 ? Indices[7] : -1;
+}
+
+public sealed record ControlCommandListRaw(
+ long Offset,
+ uint Count,
+ IReadOnlyList Records);
+
+public sealed record ControlCommandRecordRaw(
+ long Offset,
+ ControlCommandConditionRaw Condition,
+ EControlCommandOpcode Opcode,
+ uint Arg0,
+ uint Arg1,
+ uint Arg2,
+ uint Arg3,
+ uint Arg4,
+ ResourceNamedRef Resource);
+
+public enum EControlCommandOpcode : uint {
+ CONTROL_CMD_RESET_MOTION_COMMANDS = 0,
+ CONTROL_CMD_MARK_ROOT_CENTRAL = 1,
+ CONTROL_CMD_UNMARK_ROOT_CENTRAL = 2,
+ CONTROL_CMD_SPAWN_EFFECT_ON_JOINT_BOUNDS = 3,
+ CONTROL_CMD_SPAWN_ORIENTED_EFFECT_FROM_CPTS = 4,
+ CONTROL_CMD_SPAWN_EFFECT_ON_OBJECT_BOUNDS = 5,
+ CONTROL_CMD_KILL_PIECE = 7,
+ CONTROL_CMD_REMOVE_EFFECT = 8,
+ CONTROL_CMD_ITEM_LOAD_RESOURCE = 9,
+ CONTROL_CMD_START_EFFECT = 0xA,
+ CONTROL_CMD_REQUEST_STOP_EFFECT = 0xB,
+ CONTROL_CMD_SET_TANG_ACCEL = 0xC,
+ CONTROL_CMD_SET_NORM_SPEED = 0xD,
+ CONTROL_CMD_ATTACH_EFFECT_TO_PIECE = 0xE,
+ CONTROL_CMD_SCHEDULE_DETACH_OR_KILL = 0xF,
+ CONTROL_CMD_FORCE_KILL_IGNORE_INVULNERABILITY = 0x11,
+ CONTROL_CMD_ACTIVATE_EFFECT = 0x12,
+ CONTROL_CMD_DEACTIVATE_EFFECT = 0x13,
+ CONTROL_CMD_PLACE_BUILDING_ON_TERRAIN = 0x14,
+ CONTROL_CMD_DAMAGE_OBJECTS_IN_BOUNDS = 0x15,
+ CONTROL_CMD_SPAWN_OBJECT_FROM_3_CPTS = 0x16,
+ CONTROL_CMD_EXECUTE_ITEM_ACTION_FALSE = 0x17,
+ CONTROL_CMD_SET_ITEM_FLAG_0x100 = 0x18,
+ CONTROL_CMD_CLEAR_ITEM_FLAG_0x100 = 0x19,
+ CONTROL_CMD_SWAP_NDP_RESOURCE = 0x1B,
+ CONTROL_CMD_QUERY_PARENT_CEILING = 0x1D,
+ CONTROL_CMD_EXECUTE_ITEM_ACTION_TRUE = 0x1E,
+ CONTROL_CMD_SET_OBJECT_TRANSFORMATION = 0x1F,
+ CONTROL_CMD_DISTRIBUTE_DAMAGE = 0x23,
+ CONTROL_CMD_QUEUE_PATH_TO_CONTROL_STATE = 0x28,
+ CONTROL_CMD_QUEUE_PATH_TO_RANDOM_CONTROL_STATE = 0x29,
+ CONTROL_CMD_SET_NET_SYNC_FLAG_0x10000000 = 0x2A,
+ CONTROL_CMD_CLEAR_NET_SYNC_FLAG_0x10000000 = 0x2B,
+ CONTROL_CMD_UNKNOWN_0x2c = 0x2C,
+ CONTROL_CMD_UNKNOWN_0x2d = 0x2D,
+ CONTROL_CMD_UNKNOWN_0x2e = 0x2E,
+ CONTROL_CMD_SET_NET_SYNCHRO_MODE = 0x2F,
+}
+
+public readonly record struct ControlCommandConditionRaw(
+ uint ConditionFlags,
+ uint ConditionMask,
+ uint ConditionInvertMask);
+
+internal sealed class LeReader
+{
+ private readonly Stream _s;
+ private readonly byte[] _buf = new byte[8];
+
+ public LeReader(Stream stream) => _s = stream;
+ public Stream BaseStream => _s;
+
+ public byte[] ReadBytesExact(int count)
+ {
+ byte[] bytes = new byte[count];
+ _s.ReadExactly(bytes, 0, count);
+ return bytes;
+ }
+
+ public int ReadInt32()
+ {
+ _s.ReadExactly(_buf, 0, 4);
+ return BitConverter.ToInt32(_buf, 0);
+ }
+
+ public uint ReadUInt32()
+ {
+ _s.ReadExactly(_buf, 0, 4);
+ return BitConverter.ToUInt32(_buf, 0);
+ }
+
+ public float ReadSingle()
+ {
+ _s.ReadExactly(_buf, 0, 4);
+ return BitConverter.ToSingle(_buf, 0);
+ }
+
+ public Vec3 ReadVec3() => new(ReadSingle(), ReadSingle(), ReadSingle());
+
+ public string ReadFixedString(int byteCount)
+ {
+ byte[] raw = ReadBytesExact(byteCount);
+ int nul = Array.IndexOf(raw, (byte)0);
+ int len = nul >= 0 ? nul : raw.Length;
+ return Encoding.ASCII.GetString(raw, 0, len);
+ }
+
+ public ResourceNamedRef ReadResourceNamedRef()
+ {
+ return new ResourceNamedRef(
+ ArchiveName: ReadFixedString(32),
+ ResourceName: ReadFixedString(32));
+ }
+
+ public int[] ReadInt32Array(int count)
+ {
+ var arr = new int[count];
+ for (int i = 0; i < count; i++)
+ arr[i] = ReadInt32();
+ return arr;
+ }
+
+ public void ExpectEofOrThrow(string sourceName)
+ {
+ if (_s.CanSeek && _s.Position != _s.Length)
+ throw new ControlReadException($"{sourceName}: parser stopped at 0x{_s.Position:X}, file length is 0x{_s.Length:X}");
+ }
+}
diff --git a/ParkanPlayground.slnx b/ParkanPlayground.slnx
index 449019b..76167bb 100644
--- a/ParkanPlayground.slnx
+++ b/ParkanPlayground.slnx
@@ -6,6 +6,7 @@
+
diff --git a/ParkanPlayground/Program.cs b/ParkanPlayground/Program.cs
index d8d9dcb..d1eba72 100644
--- a/ParkanPlayground/Program.cs
+++ b/ParkanPlayground/Program.cs
@@ -1,8 +1,28 @@
+using System.Text.Json;
using Common;
+using ControlLib;
using MshLib;
System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance);
+//
+var ctl = ControlResourceReader.ReadCtlFile("E:\\ParkanUnpacked\\bases.rlb\\2_CTLD_r_l_03.ctl");
+var cpt = ControlResourceReader.ReadCptFile("E:\\ParkanUnpacked\\bases.rlb\\18_CTPT_R_L_03.CPT");
+var ndp = ControlResourceReader.ReadNdpFile("E:\\ParkanUnpacked\\bases.rlb\\14_NDPR_r_l_03.ndp");
-var converter = new MshConverter();
+// var ctl = ControlResourceReader.ReadCtlFile("E:\\ParkanUnpacked\\turrets.rlb\\30_CTLD_o_tur_lt_02.ctl");
+// var cpt = ControlResourceReader.ReadCptFile("E:\\ParkanUnpacked\\turrets.rlb\\162_CTPT_o_tur_lt_02.cpt");
+// var ndp = ControlResourceReader.ReadNdpFile("E:\\ParkanUnpacked\\turrets.rlb\\119_NDPR_o_tur_la_02.ndp");
-converter.Convert("E:\\ParkanUnpacked\\fortif.rlb\\130_fr_b_plant.msh");
\ No newline at end of file
+// var ctl = ControlResourceReader.ReadCtlFile("E:\\ParkanUnpacked\\guns.rlb\\86_CTLD_o_gun_la_01.ctl");
+// var cpt = ControlResourceReader.ReadCptFile("E:\\ParkanUnpacked\\guns.rlb\\364_CTPT_o_gun_la_01.cpt");
+// var ndp = ControlResourceReader.ReadNdpFile("E:\\ParkanUnpacked\\guns.rlb\\309_NDPR_o_gun_la_01.ndp");
+
+ControlResourceDump.DumpAll(ctl, cpt, ndp, Console.Out);
+
+// foreach (var item in ctl.ItemRecords)
+// {
+// Console.WriteLine(
+// $"0x{item.Offset:X4}-0x{item.EndOffset:X4} " +
+// $"type={item.ItemType:X} piece={item.LocalPieceIndex} " +
+// $"name='{item.ItemName}' bindings=[{string.Join(", ", item.PieceBindingIndices)}]");
+// }
\ No newline at end of file