diff --git a/Directory.Packages.props b/Directory.Packages.props
index a087e29..6057237 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -4,13 +4,12 @@
+
-
-
-
+
-
\ No newline at end of file
+
diff --git a/ParkanPlayground/ParkanPlayground.csproj b/ParkanPlayground/ParkanPlayground.csproj
index bf89f86..ae934d4 100644
--- a/ParkanPlayground/ParkanPlayground.csproj
+++ b/ParkanPlayground/ParkanPlayground.csproj
@@ -1,4 +1,4 @@
-
+
Exe
@@ -15,4 +15,8 @@
+
+
+
+
diff --git a/ParkanPlayground/Program.cs b/ParkanPlayground/Program.cs
index 8254e96..fddfe86 100644
--- a/ParkanPlayground/Program.cs
+++ b/ParkanPlayground/Program.cs
@@ -1,16 +1,63 @@
-using NResLib;
using ResTreeLib;
System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance);
+var trfPath = Directory.EnumerateFiles(@"C:\Program Files (x86)\Nikita\Iron Strategy\MISSIONS\SCRIPTS", "*.trf").First();
+var nodes = ResTreeParser.Parse(trfPath);
-foreach (var trfFile in Directory.EnumerateFiles("C:\\Program Files (x86)\\Nikita\\Iron Strategy\\MISSIONS\\SCRIPTS", "*.trf"))
+var active = nodes.Where(n => !string.IsNullOrWhiteSpace(n.ShortName) && (n.Node.ResearchCost > 0 || n.Node.ResearchTime > 0)).ToList();
+Console.WriteLine($"active={active.Count}");
+
+Analyze("X from Cost", active.Select(n => (A:n.Node.ResearchCost, B:n.Node.ViewPosX, Name:n.ShortName, Idx:n.Index)).ToList());
+Analyze("Y from Time", active.Select(n => (A:n.Node.ResearchTime, B:n.Node.ViewPosY, Name:n.ShortName, Idx:n.Index)).ToList());
+
+Console.WriteLine();
+Console.WriteLine("Largest |Y-Time| (active):");
+foreach (var n in active.OrderByDescending(n => Math.Abs(n.Node.ViewPosY - n.Node.ResearchTime)).Take(20))
{
-
- using var fs = new FileStream(trfFile, FileMode.Open, FileAccess.Read, FileShare.Read);
-
- var nres = NResParser.ReadFile(trfFile);
-
- var resTree = ResTreeParser.Parse(nres.Archive!, fs);
- Console.WriteLine(trfFile);
- _ = 5;
-}
\ No newline at end of file
+ Console.WriteLine($"idx={n.Index,3} {n.ShortName,-12} T={n.Node.ResearchTime,6:0.##} Y={n.Node.ViewPosY,6:0.##} d={n.Node.ViewPosY-n.Node.ResearchTime,6:0.##}");
+}
+
+Console.WriteLine();
+Console.WriteLine("Largest |X-Cost| (active):");
+foreach (var n in active.OrderByDescending(n => Math.Abs(n.Node.ViewPosX - n.Node.ResearchCost)).Take(20))
+{
+ Console.WriteLine($"idx={n.Index,3} {n.ShortName,-12} C={n.Node.ResearchCost,6:0.##} X={n.Node.ViewPosX,6:0.##} d={n.Node.ViewPosX-n.Node.ResearchCost,6:0.##}");
+}
+
+static void Analyze(string title, List<(float A, float B, string Name, int Idx)> data)
+{
+ float meanA = data.Average(x => x.A);
+ float meanB = data.Average(x => x.B);
+ float cov=0, varA=0;
+ foreach (var d in data)
+ {
+ var da = d.A - meanA;
+ var db = d.B - meanB;
+ cov += da*db;
+ varA += da*da;
+ }
+ float slope = varA == 0 ? 0 : cov / varA;
+ float intercept = meanB - slope*meanA;
+
+ float mae = data.Average(d => Math.Abs((slope*d.A + intercept) - d.B));
+ float rmse = MathF.Sqrt(data.Average(d => MathF.Pow((slope*d.A + intercept) - d.B,2)));
+
+ float corr = Corr(data.Select(x=>x.A).ToArray(), data.Select(x=>x.B).ToArray());
+
+ Console.WriteLine($"{title}: corr={corr:0.###}, slope={slope:0.###}, intercept={intercept:0.###}, MAE={mae:0.###}, RMSE={rmse:0.###}");
+}
+
+static float Corr(float[] a, float[] b)
+{
+ if (a.Length != b.Length || a.Length == 0) return 0;
+ var ma = a.Average();
+ var mb = b.Average();
+ float top=0, aa=0, bb=0;
+ for(int i=0;i Parse(string filePath)
@@ -16,25 +16,23 @@ public class ResTreeParser
var nres = NResParser.ReadFile(fs);
fs.Seek(0, SeekOrigin.Begin);
-
+
return Parse(nres.Archive!, fs);
}
-
+
public static List Parse(NResArchive archive, Stream stream)
{
- // Register provider for Windows-1251 if not already done globally
+ // Register provider for Windows-1251 if not already done globally.
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
-
- if (archive.Files.Any(x => x.FileType == "TRFB"))
+
+ // 1. Locate the master nodes (TRF0).
+ var trf0 = archive.Files.FirstOrDefault(f => f.FileType == "TRF0");
+ if (trf0 == null)
{
- _ = 5;
+ return new List();
}
- // 1. Locate the Master Nodes (TRF0)
- var trf0 = archive.Files.FirstOrDefault(f => f.FileType == "TRF0");
- if (trf0 == null) return new List();
-
- // 2. Load the Node States (TRF1) - 1 byte per node
+ // 2. Load node states (TRF1): one byte per node.
byte[] initialStates = LoadRawBuffer(stream, archive, "TRF1");
var nodes = new List();
@@ -44,21 +42,21 @@ public class ResTreeParser
nodes.Add(ReadTrf0Element(stream));
}
- // 3. Load String Pools
- byte[] shortNamesPool_trf7 = LoadRawBuffer(stream, archive, "TRF7");
- byte[] longNamesPool_trf8 = LoadRawBuffer(stream, archive, "TRF8");
- byte[] helpTextPool_trf9 = LoadRawBuffer(stream, archive, "TRF9");
- byte[] descriptionsPool_trfa = LoadRawBuffer(stream, archive, "TRFA");
+ // 3. Load string pools.
+ byte[] shortNamesPoolTrf7 = LoadRawBuffer(stream, archive, "TRF7");
+ byte[] longNamesPoolTrf8 = LoadRawBuffer(stream, archive, "TRF8");
+ byte[] helpTextPoolTrf9 = LoadRawBuffer(stream, archive, "TRF9");
+ byte[] descriptionsPoolTrfa = LoadRawBuffer(stream, archive, "TRFA");
- // 4. Load Relationship Maps
- // TRF2/3 = Prerequisites
- var prereqMap = LoadRelationMap(stream, archive, "TRF2", "TRF3");
- // TRF4/5 = Unlocks/Effects
- var unlockMap = LoadRelationMap(stream, archive, "TRF4", "TRF5");
- // TRFB/6 = unknown
- var aux_trf6 = LoadRelationMap(stream, archive, "TRFB", "TRF6");
+ // 4. Load relationship maps.
+ var prereqMap = LoadRelationMap(stream, archive, "TRF2", "TRF3", strict: true);
+ var unlockMap = LoadRelationMap(stream, archive, "TRF4", "TRF5", strict: true);
- // 5. Assemble
+ // TRFB/TRF6 is auxiliary and currently unknown.
+ // Real game files may have inconsistent counts here, so keep it non-strict.
+ _ = LoadRelationMap(stream, archive, "TRFB", "TRF6", strict: false);
+
+ // 5. Assemble DTOs.
var result = new List();
for (int i = 0; i < nodes.Count; i++)
{
@@ -67,12 +65,11 @@ public class ResTreeParser
{
Index = i,
Node = element,
- // Cast the TRF1 byte to our NodeState Flags
- State = (initialStates.Length > i) ? (NodeState)initialStates[i] : NodeState.Hidden,
- ShortName = GetStringFromPool(shortNamesPool_trf7, element.OffsetShortName_TRF7),
- LongName = GetStringFromPool(longNamesPool_trf8, element.OffsetLongName_TRF8),
- HelpText = GetStringFromPool(helpTextPool_trf9, element.OffsetHelpText_TRF9),
- Description = GetStringFromPool(descriptionsPool_trfa, element.OffsetDescription_TRFA),
+ State = initialStates.Length > i ? (NodeState)initialStates[i] : NodeState.Hidden,
+ ShortName = GetStringFromPool(shortNamesPoolTrf7, element.OffsetShortName_TRF7),
+ LongName = GetStringFromPool(longNamesPoolTrf8, element.OffsetLongName_TRF8),
+ HelpText = GetStringFromPool(helpTextPoolTrf9, element.OffsetHelpText_TRF9),
+ Description = GetStringFromPool(descriptionsPoolTrfa, element.OffsetDescription_TRFA),
PrerequisiteIds = prereqMap.TryGetValue(i, out var pValue) ? pValue : [],
UnlockIds = unlockMap.TryGetValue(i, out var uValue) ? uValue : []
});
@@ -83,7 +80,7 @@ public class ResTreeParser
private static Trf0Element ReadTrf0Element(Stream s)
{
- // Total size must be 40 bytes (0x28)
+ // TRF0 element size is 40 bytes (0x28).
return new Trf0Element
{
ResearchCost = s.ReadFloatLittleEndian(),
@@ -104,14 +101,21 @@ public class ResTreeParser
};
}
- // LoadRelationMap and LoadRawBuffer remain the same as your provided code
- private static Dictionary LoadRelationMap(Stream s, NResArchive archive, string headerType, string dataType)
+ private static Dictionary LoadRelationMap(
+ Stream s,
+ NResArchive archive,
+ string headerType,
+ string dataType,
+ bool strict)
{
var header = archive.Files.FirstOrDefault(f => f.FileType == headerType);
var data = archive.Files.FirstOrDefault(f => f.FileType == dataType);
var map = new Dictionary();
- if (header == null || data == null) return map;
+ if (header == null || data == null)
+ {
+ return map;
+ }
uint[] counts = new uint[header.ElementCount];
s.Position = header.OffsetInFile;
@@ -120,15 +124,46 @@ public class ResTreeParser
counts[i] = s.ReadUInt32LittleEndian();
}
+ long dataWordsRequired = 0;
+ for (int i = 0; i < counts.Length; i++)
+ {
+ dataWordsRequired += counts[i];
+ }
+
+ if (dataWordsRequired * sizeof(uint) > data.FileLength)
+ {
+ if (strict)
+ {
+ throw new InvalidDataException(
+ $"Relation map {headerType}/{dataType} expects {dataWordsRequired * sizeof(uint)} bytes but section has {data.FileLength} bytes.");
+ }
+
+ return map;
+ }
+
+ long dataEnd = data.OffsetInFile + data.FileLength;
s.Position = data.OffsetInFile;
+
for (int i = 0; i < counts.Length; i++)
{
uint count = counts[i];
+ if (s.Position + count * sizeof(uint) > dataEnd)
+ {
+ if (strict)
+ {
+ throw new EndOfStreamException(
+ $"Relation map {headerType}/{dataType} overreads section at node {i} (count={count}).");
+ }
+
+ break;
+ }
+
uint[] ids = new uint[count];
for (int j = 0; j < count; j++)
{
ids[j] = s.ReadUInt32LittleEndian();
}
+
map[i] = ids;
}
@@ -138,7 +173,10 @@ public class ResTreeParser
private static byte[] LoadRawBuffer(Stream s, NResArchive archive, string type)
{
var item = archive.Files.FirstOrDefault(f => f.FileType == type);
- if (item == null) return [];
+ if (item == null)
+ {
+ return [];
+ }
byte[] buffer = new byte[item.FileLength];
s.Position = item.OffsetInFile;
@@ -148,11 +186,14 @@ public class ResTreeParser
private static string GetStringFromPool(byte[] pool, uint offset)
{
- if (pool.Length == 0 || offset >= pool.Length) return string.Empty;
-
+ if (pool.Length == 0 || offset >= pool.Length)
+ {
+ return string.Empty;
+ }
+
int end = Array.IndexOf(pool, (byte)0, (int)offset);
- int length = (end == -1) ? pool.Length - (int)offset : end - (int)offset;
-
+ int length = end == -1 ? pool.Length - (int)offset : end - (int)offset;
+
return CyrillicEncoding.GetString(pool, (int)offset, length);
}
-}
\ No newline at end of file
+}
diff --git a/ResTreeLib/Trf0Element.cs b/ResTreeLib/Trf0Element.cs
index 163ce3a..315deb5 100644
--- a/ResTreeLib/Trf0Element.cs
+++ b/ResTreeLib/Trf0Element.cs
@@ -1,18 +1,22 @@
-namespace ResTreeLib;
-
-// Result DTO for easier use
+namespace ResTreeLib;
+// TRF0 node payload (40 bytes).
+// Field naming for the first 16 bytes is based on empirical checks over the mission .trf set.
+// ResearchCost/ResearchTime look gameplay-related (strong mutual correlation and upgrade-level scaling).
+// ViewPosX/ViewPosY behave like 2D layout coordinates; very large Y outliers are mostly unnamed/off-screen nodes.
public record Trf0Element
{
- public float ResearchCost; // field_0
- public float ResearchTime; // field_4
- public float ViewPosX; // field_8
- public float ViewPosY; // field_12
- public uint OffsetShortName_TRF7; // offset_into_TRF7
- public uint OffsetLongName_TRF8; // offset_into_TRF8
- public uint OffsetHelpText_TRF9; // offset_into_TRF9
- public uint OffsetDescription_TRFA; // offset_into_TRFA
- public ushort OffsetAux_TRFB; // offset_into_TRFB
+ public float ResearchCost; // 0..45 in sample files
+ public float ResearchTime; // 0..150 in sample files
+ public float ViewPosX; // 0..80 in sample files
+ public float ViewPosY; // 0..1500 in sample files
+
+ public uint OffsetShortName_TRF7;
+ public uint OffsetLongName_TRF8;
+ public uint OffsetHelpText_TRF9;
+ public uint OffsetDescription_TRFA;
+
+ public ushort OffsetAux_TRFB;
public byte TurretType;
public byte MainType;
public byte SubType;
@@ -24,8 +28,8 @@ public record Trf0Element
[Flags]
public enum NodeState : byte
{
- Hidden = 0,
- Available = 1 << 0, // 0x01
+ Hidden = 0,
+ Available = 1 << 0, // 0x01
Researched = 1 << 1, // 0x02
- Active = 1 << 2 // 0x04 - If not set, node is disabled
-}
\ No newline at end of file
+ Active = 1 << 2 // 0x04: if not set, node is disabled
+}