Skip to content

Commit b2cdf44

Browse files
Add integration tests for server archive packaging determinism and round-trip
Tests package the MapleStory2-XML server folder into m2d/m2h archives and verify: - Output hashes are identical across 3 consecutive runs (determinism) - Extracted files byte-match the originals (round-trip integrity) - Archive file count matches source file count Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4fed43b commit b2cdf44

5 files changed

Lines changed: 289 additions & 2 deletions

File tree

CLAUDE.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project Overview
6+
7+
MS2Tools is a set of .NET 8.0 CLI tools for extracting and creating MapleStory 2 game archives (.m2h/.m2d file pairs). Originally by Miyuyami, now maintained as a fork.
8+
9+
## Build & Test Commands
10+
11+
```bash
12+
# Build entire solution
13+
dotnet build MS2Tools.sln
14+
15+
# Build in release mode
16+
dotnet build MS2Tools.sln -c Release
17+
18+
# Run tests (test project is inside the MS2Lib submodule)
19+
dotnet test MS2Lib/MS2Lib.Tests/MS2Lib.Tests.csproj
20+
21+
# Run a specific tool
22+
dotnet run --project MS2Extract -- <source> <destination> [syncMode] [logMode]
23+
dotnet run --project MS2Create -- <source> <destination> <archiveName> <mode> [syncMode] [logMode]
24+
```
25+
26+
## Architecture
27+
28+
### Solution Structure
29+
30+
- **MS2Extract** — CLI tool that extracts .m2h/.m2d archives to disk. Supports batch extraction of entire directories.
31+
- **MS2Create** — CLI tool that packages directories into .m2h/.m2d archives. Auto-detects compression type by file extension (.png, .usm, .zlib).
32+
- **MS2FileHeaderExporter** — Debug utility for dumping archive metadata (file type maps, root folder ID mappings).
33+
- **MS2Lib** — Core library (git submodule from Miyuyami/MS2Lib). Contains all archive parsing, crypto, and compression logic.
34+
- **MiscUtils** — Utility library (nested submodule inside MS2Lib). Provides extensions, endian-aware I/O, and logging.
35+
36+
### Archive Format
37+
38+
Archives consist of paired files: `.m2h` (header with encrypted metadata) and `.m2d` (encrypted/compressed file data). The header begins with a 4-byte crypto mode identifier.
39+
40+
### Crypto System
41+
42+
Four encryption modes exist: **MS2F**, **NS2F**, **OS2F**, **PS2F** (identified by 32-bit magic numbers in `MS2CryptoMode` enum). The crypto layer uses a repository pattern:
43+
44+
- `IMS2ArchiveCryptoRepository` — interface for crypto operations per mode
45+
- `CryptoRepositoryMS2F` / `CryptoRepositoryNS2F` — concrete implementations
46+
- `Repositories.cs` — static registry mapping `MS2CryptoMode` → repository instance
47+
- Separate crypto classes handle archive headers vs file headers vs file info encryption
48+
49+
Dependencies: DotNetZip (compression), BouncyCastle (cryptography).
50+
51+
### Key Types
52+
53+
- `MS2Archive` — main container; implements `IMS2Archive` (Load, Save, SaveConcurrently, Add, Remove)
54+
- `MS2File` — individual file within an archive; async stream access via `GetStreamAsync`
55+
- `MS2FileHeader` / `MS2FileInfo` / `MS2SizeHeader` — metadata value types
56+
57+
### Build Output
58+
59+
Debug and Release builds output to `../Debug/net8.0/` and `../Release/net8.0/` respectively (relative to each project directory, so they land at repo root level).
60+
61+
## Code Conventions
62+
63+
- Private fields: `_camelCase` prefix
64+
- 4-space indentation, CRLF line endings, UTF-8 BOM (see `.editorconfig`)
65+
- Interface-first design: all core types have `IMS2*` interfaces
66+
- Async/await with `Task`-based patterns; concurrent operations use `ConcurrentDictionary`

MS2Lib

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net8.0</TargetFramework>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<IsPackable>false</IsPackable>
7+
</PropertyGroup>
8+
9+
<ItemGroup>
10+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
11+
<PackageReference Include="MSTest.TestAdapter" Version="3.2.0" />
12+
<PackageReference Include="MSTest.TestFramework" Version="3.2.0" />
13+
</ItemGroup>
14+
15+
<ItemGroup>
16+
<ProjectReference Include="..\MS2Lib\MS2Lib\MS2Lib.csproj" />
17+
</ItemGroup>
18+
19+
</Project>
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
using System.Security.Cryptography;
2+
using Microsoft.VisualStudio.TestTools.UnitTesting;
3+
using MS2Lib;
4+
using MiscUtils;
5+
6+
namespace MS2Tools.IntegrationTests;
7+
8+
[TestClass]
9+
public class ServerArchiveTests
10+
{
11+
private const string ServerSourcePath = @"D:\Projetos\GitHub\MapleStory2-XML\server";
12+
private const string TestOutputDir = "ServerArchiveTestOutput";
13+
private const string ArchiveName = "server";
14+
private const string HeaderFileExtension = ".m2h";
15+
private const string DataFileExtension = ".m2d";
16+
17+
[ClassInitialize]
18+
public static void ClassInit(TestContext context)
19+
{
20+
if (!Directory.Exists(ServerSourcePath))
21+
{
22+
Assert.Inconclusive($"Server source folder not found: {ServerSourcePath}");
23+
}
24+
25+
if (Directory.Exists(TestOutputDir))
26+
{
27+
Directory.Delete(TestOutputDir, true);
28+
}
29+
30+
Directory.CreateDirectory(TestOutputDir);
31+
}
32+
33+
[ClassCleanup]
34+
public static void ClassCleanup()
35+
{
36+
if (Directory.Exists(TestOutputDir))
37+
{
38+
Directory.Delete(TestOutputDir, true);
39+
}
40+
}
41+
42+
private static (string FullPath, string RelativePath)[] GetFilesRelative(string path)
43+
{
44+
if (!path.EndsWith(Path.DirectorySeparatorChar))
45+
{
46+
path += Path.DirectorySeparatorChar;
47+
}
48+
49+
string[] files = Directory.GetFiles(path, "*.*", SearchOption.AllDirectories);
50+
Array.Sort(files, StringComparer.Ordinal);
51+
52+
var result = new (string FullPath, string RelativePath)[files.Length];
53+
for (int i = 0; i < files.Length; i++)
54+
{
55+
result[i] = (files[i], files[i].Substring(path.Length));
56+
}
57+
58+
return result;
59+
}
60+
61+
private static CompressionType GetCompressionTypeFromFileExtension(string filePath) =>
62+
Path.GetExtension(filePath) switch
63+
{
64+
".png" => CompressionType.Png,
65+
".usm" => CompressionType.Usm,
66+
".zlib" => CompressionType.Zlib,
67+
_ => CompressionType.Zlib,
68+
};
69+
70+
private static async Task<(string headerPath, string dataPath)> PackageServerFolder(string outputSubDir)
71+
{
72+
string outputPath = Path.Combine(TestOutputDir, outputSubDir);
73+
Directory.CreateDirectory(outputPath);
74+
75+
string headerPath = Path.Combine(outputPath, ArchiveName + HeaderFileExtension);
76+
string dataPath = Path.Combine(outputPath, ArchiveName + DataFileExtension);
77+
78+
var archive = new MS2Archive(Repositories.Repos[MS2CryptoMode.MS2F]);
79+
var filePaths = GetFilesRelative(ServerSourcePath);
80+
81+
for (uint i = 0; i < filePaths.Length; i++)
82+
{
83+
var (fullPath, relativePath) = filePaths[i];
84+
uint id = i + 1;
85+
FileStream fs = File.OpenRead(fullPath);
86+
IMS2FileInfo info = new MS2FileInfo(id.ToString(), relativePath);
87+
IMS2FileHeader header = new MS2FileHeader(fs.Length, id, 0, GetCompressionTypeFromFileExtension(fullPath));
88+
IMS2File file = new MS2File(archive, fs, info, header, false);
89+
archive.Add(file);
90+
}
91+
92+
await archive.SaveConcurrentlyAsync(headerPath, dataPath);
93+
94+
return (headerPath, dataPath);
95+
}
96+
97+
private static string ComputeFileHash(string filePath)
98+
{
99+
using var stream = File.OpenRead(filePath);
100+
byte[] hash = SHA256.HashData(stream);
101+
return Convert.ToHexString(hash);
102+
}
103+
104+
[TestMethod]
105+
[Timeout(300000)] // 5 min timeout for large archive
106+
public async Task Package_ServerFolder_ProducesDeterministicOutput()
107+
{
108+
// First run
109+
var (headerPath1, dataPath1) = await PackageServerFolder("run1");
110+
string headerHash1 = ComputeFileHash(headerPath1);
111+
string dataHash1 = ComputeFileHash(dataPath1);
112+
113+
Assert.IsTrue(File.Exists(headerPath1), "Header file should exist after first run");
114+
Assert.IsTrue(File.Exists(dataPath1), "Data file should exist after first run");
115+
Assert.IsTrue(new FileInfo(headerPath1).Length > 0, "Header file should not be empty");
116+
Assert.IsTrue(new FileInfo(dataPath1).Length > 0, "Data file should not be empty");
117+
118+
// Second run
119+
var (headerPath2, dataPath2) = await PackageServerFolder("run2");
120+
string headerHash2 = ComputeFileHash(headerPath2);
121+
string dataHash2 = ComputeFileHash(dataPath2);
122+
123+
Assert.AreEqual(headerHash1, headerHash2,
124+
$"Header hash mismatch between runs.\nRun1: {headerHash1}\nRun2: {headerHash2}");
125+
Assert.AreEqual(dataHash1, dataHash2,
126+
$"Data hash mismatch between runs.\nRun1: {dataHash1}\nRun2: {dataHash2}");
127+
128+
// Third run for extra confidence
129+
var (headerPath3, dataPath3) = await PackageServerFolder("run3");
130+
string headerHash3 = ComputeFileHash(headerPath3);
131+
string dataHash3 = ComputeFileHash(dataPath3);
132+
133+
Assert.AreEqual(headerHash1, headerHash3,
134+
$"Header hash mismatch on third run.\nRun1: {headerHash1}\nRun3: {headerHash3}");
135+
Assert.AreEqual(dataHash1, dataHash3,
136+
$"Data hash mismatch on third run.\nRun1: {dataHash1}\nRun3: {dataHash3}");
137+
}
138+
139+
[TestMethod]
140+
[Timeout(300000)]
141+
public async Task Package_ThenExtract_ServerFolder_FilesMatchOriginals()
142+
{
143+
var (headerPath, dataPath) = await PackageServerFolder("extract_test");
144+
145+
string extractPath = Path.Combine(TestOutputDir, "extracted");
146+
Directory.CreateDirectory(extractPath);
147+
148+
// Extract
149+
using (IMS2Archive archive = await MS2Archive.GetAndLoadArchiveAsync(headerPath, dataPath))
150+
{
151+
Assert.IsTrue(archive.Count > 0, "Archive should contain files");
152+
153+
foreach (var file in archive)
154+
{
155+
string destPath = Path.Combine(extractPath, file.Name);
156+
157+
using Stream stream = await file.GetStreamAsync();
158+
await stream.CopyToAsync(destPath);
159+
}
160+
}
161+
162+
// Verify extracted files match originals
163+
var originalFiles = GetFilesRelative(ServerSourcePath);
164+
int verifiedCount = 0;
165+
166+
foreach (var (fullPath, relativePath) in originalFiles)
167+
{
168+
string extractedPath = Path.Combine(extractPath, relativePath);
169+
Assert.IsTrue(File.Exists(extractedPath),
170+
$"Extracted file missing: {relativePath}");
171+
172+
byte[] originalBytes = await File.ReadAllBytesAsync(fullPath);
173+
byte[] extractedBytes = await File.ReadAllBytesAsync(extractedPath);
174+
175+
CollectionAssert.AreEqual(originalBytes, extractedBytes,
176+
$"Content mismatch for: {relativePath}");
177+
verifiedCount++;
178+
}
179+
180+
Assert.AreEqual(originalFiles.Length, verifiedCount,
181+
"Number of verified files should match number of original files");
182+
}
183+
184+
[TestMethod]
185+
[Timeout(300000)]
186+
public async Task Package_ServerFolder_ArchiveFileCountMatchesSourceFileCount()
187+
{
188+
var (headerPath, dataPath) = await PackageServerFolder("count_test");
189+
190+
using IMS2Archive archive = await MS2Archive.GetAndLoadArchiveAsync(headerPath, dataPath);
191+
192+
var sourceFiles = GetFilesRelative(ServerSourcePath);
193+
Assert.AreEqual(sourceFiles.Length, (int)archive.Count,
194+
$"Archive should contain {sourceFiles.Length} files, but has {archive.Count}");
195+
}
196+
}

MS2Tools.sln

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
1+
22
Microsoft Visual Studio Solution File, Format Version 12.00
33
# Visual Studio Version 16
44
VisualStudioVersion = 16.0.30309.148
@@ -15,6 +15,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MS2Extract", "MS2Extract\MS
1515
EndProject
1616
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MS2FileHeaderExporter", "MS2FileHeaderExporter\MS2FileHeaderExporter.csproj", "{5377D52A-5633-47EF-A0C4-2CB7595B0C4F}"
1717
EndProject
18+
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MS2Tools.IntegrationTests", "MS2Tools.IntegrationTests\MS2Tools.IntegrationTests.csproj", "{118293F4-D99F-45F5-9435-FA83E93C190C}"
19+
EndProject
1820
Global
1921
GlobalSection(SolutionConfigurationPlatforms) = preSolution
2022
Debug|Any CPU = Debug|Any CPU
@@ -41,6 +43,10 @@ Global
4143
{5377D52A-5633-47EF-A0C4-2CB7595B0C4F}.Debug|Any CPU.Build.0 = Debug|Any CPU
4244
{5377D52A-5633-47EF-A0C4-2CB7595B0C4F}.Release|Any CPU.ActiveCfg = Release|Any CPU
4345
{5377D52A-5633-47EF-A0C4-2CB7595B0C4F}.Release|Any CPU.Build.0 = Release|Any CPU
46+
{118293F4-D99F-45F5-9435-FA83E93C190C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
47+
{118293F4-D99F-45F5-9435-FA83E93C190C}.Debug|Any CPU.Build.0 = Debug|Any CPU
48+
{118293F4-D99F-45F5-9435-FA83E93C190C}.Release|Any CPU.ActiveCfg = Release|Any CPU
49+
{118293F4-D99F-45F5-9435-FA83E93C190C}.Release|Any CPU.Build.0 = Release|Any CPU
4450
EndGlobalSection
4551
GlobalSection(SolutionProperties) = preSolution
4652
HideSolutionNode = FALSE

0 commit comments

Comments
 (0)