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 +}