From 75c0d6717cf3d6ff59a5529d9013991cb9901e43 Mon Sep 17 00:00:00 2001 From: erwan-joly Date: Thu, 23 Apr 2026 23:28:18 +1200 Subject: [PATCH] Add NosArchive read/write for legacy .NOS containers Exposes a public static NosArchive type with Read(byte[]) / Write(IReadOnlyList) handling the legacy file-list + XOR-obfuscated payload layout (NScliData_*.NOS, NSlangData_*.NOS, etc). Preserves the per-entry Id and the unknown 4-byte field verbatim so round-trips are byte-faithful after decryption. Useful for tooling that needs to rewrite strings inside conststring.dat (e.g. the NosMall URL) and repack without shelling out to external tools. Bump version to 4.1.0 (new public API). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Extractor/NosArchive.cs | 152 ++++++++++++++++++ .../NosCore.ParserInputGenerator.csproj | 2 +- 2 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 src/NosCore.ParserInputGenerator/Extractor/NosArchive.cs diff --git a/src/NosCore.ParserInputGenerator/Extractor/NosArchive.cs b/src/NosCore.ParserInputGenerator/Extractor/NosArchive.cs new file mode 100644 index 0000000..0ed48b7 --- /dev/null +++ b/src/NosCore.ParserInputGenerator/Extractor/NosArchive.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +namespace NosCore.ParserInputGenerator.Extractor; + +/// +/// Read/write support for the legacy NosTale .NOS archive format used +/// by NScliData_*.NOS and friends. The newer "NT Data" DEFLATE variant +/// is not supported — not needed for the NosMall URL patch since conststring +/// archives use the legacy layout. +/// +/// Layout: int32 fileCount, then per entry +/// int32 id; int32 nameLen; byte[nameLen] name; int32 unknown; int32 encLen; byte[encLen] enc. +/// The inner content is XOR-obfuscated — see and +/// (our encrypter uses only the simple 0x33-XOR mode, +/// which the decrypter handles along with the packed-nibble mode from the +/// real client). +/// +public static class NosArchive +{ + /// + /// A single file entry inside a legacy .NOS archive. + /// + /// Per-entry id stored in the archive header (usually the sequential index). + /// Inner file name (ASCII), e.g. conststring.dat. + /// The 4-byte header field between the name and the payload size whose purpose we don't yet characterise — preserve verbatim on round-trip. + /// Decrypted payload bytes. + public sealed record Entry(int Id, string Name, int Unknown, byte[] Content); + + private static readonly byte[] CryptoArray = + { + 0x00, 0x20, 0x2D, 0x2E, 0x30, 0x31, 0x32, 0x33, 0x34, + 0x35, 0x36, 0x37, 0x38, 0x39, 0x0A, 0x00, + }; + + /// + /// Parse a legacy .NOS archive and return its entries with + /// decrypted . + /// + public static List Read(byte[] bytes) + { + var result = new List(); + var i = 0; + var fileCount = BitConverter.ToInt32(bytes, i); i += 4; + for (var f = 0; f < fileCount; f++) + { + var id = BitConverter.ToInt32(bytes, i); i += 4; + var nameLen = BitConverter.ToInt32(bytes, i); i += 4; + var name = Encoding.ASCII.GetString(bytes, i, nameLen); + i += nameLen; + var unknown = BitConverter.ToInt32(bytes, i); i += 4; + var encLen = BitConverter.ToInt32(bytes, i); i += 4; + var enc = new byte[encLen]; + Buffer.BlockCopy(bytes, i, enc, 0, encLen); + i += encLen; + var content = Decrypt(enc); + result.Add(new Entry(id, name, unknown, content)); + } + return result; + } + + /// + /// Serialise a list of entries back to the legacy .NOS byte layout, + /// re-applying the simple 0x33-XOR encryption. The decrypter in the real + /// client accepts this output. + /// + public static byte[] Write(IReadOnlyList entries) + { + using var ms = new MemoryStream(); + using var w = new BinaryWriter(ms); + w.Write(entries.Count); + foreach (var e in entries) + { + var nameBytes = Encoding.ASCII.GetBytes(e.Name); + var enc = Encrypt(e.Content); + w.Write(e.Id); + w.Write(nameBytes.Length); + w.Write(nameBytes); + w.Write(e.Unknown); + w.Write(enc.Length); + w.Write(enc); + } + return ms.ToArray(); + } + + private static byte[] Decrypt(byte[] enc) + { + var output = new List(enc.Length * 2); + var i = 0; + while (i < enc.Length) + { + var b = enc[i++]; + if (b == 0xFF) + { + output.Add(0x0D); + continue; + } + var len = b & 0x7F; + if ((b & 0x80) != 0) + { + for (; len > 0; len -= 2) + { + if (i >= enc.Length) break; + var c = enc[i++]; + output.Add(CryptoArray[(c & 0xF0) >> 4]); + if (len <= 1) break; + var lo = CryptoArray[c & 0x0F]; + if (lo == 0) break; + output.Add(lo); + } + } + else + { + for (; len > 0; len--) + { + if (i >= enc.Length) break; + output.Add((byte)(enc[i++] ^ 0x33)); + } + } + } + return output.ToArray(); + } + + private static byte[] Encrypt(byte[] plain) + { + using var ms = new MemoryStream(plain.Length * 2); + var i = 0; + while (i < plain.Length) + { + if (plain[i] == 0x0D) + { + ms.WriteByte(0xFF); + i++; + continue; + } + var start = i; + while (i < plain.Length && plain[i] != 0x0D && (i - start) < 0x7F) + { + i++; + } + var chunkLen = i - start; + ms.WriteByte((byte)chunkLen); + for (var j = start; j < i; j++) + { + ms.WriteByte((byte)(plain[j] ^ 0x33)); + } + } + return ms.ToArray(); + } +} diff --git a/src/NosCore.ParserInputGenerator/NosCore.ParserInputGenerator.csproj b/src/NosCore.ParserInputGenerator/NosCore.ParserInputGenerator.csproj index 0314e49..6125ad7 100644 --- a/src/NosCore.ParserInputGenerator/NosCore.ParserInputGenerator.csproj +++ b/src/NosCore.ParserInputGenerator/NosCore.ParserInputGenerator.csproj @@ -12,7 +12,7 @@ https://github.com/NosCoreIO/NosCore.ParserInputGenerator.git nostale, noscore, nostale private server source, nostale emulator - 4.0.0 + 4.1.0 false NosCore's Parser InputGenerator