1 module tools.nwn2da;
2 
3 import std;
4 
5 import nwn.twoda;
6 
7 import tools.common.getopt;
8 
9 
10 
11 
12 
13 void usage(in string cmd){
14 	writeln("2DA tool");
15 	writeln();
16 	writeln("Usage: ", cmd.baseName, " command [args]");
17 	writeln();
18 	writeln("Commands");
19 	writeln("  check: Parse the 2da and print found issues");
20 	writeln("  normalize: Fix issues and re-format 2DA files");
21 	writeln("  merge: Merge 2da rows together");
22 }
23 
24 // Hack for having a full stacktrace when unittest fails (otherwise it stops the stacktrace at main())
25 int main(string[] args){return _main(args);}
26 int _main(string[] args)
27 {
28 	if(args.any!(a => a == "--version")){
29 		import nwn.ver: NWN_LIB_D_VERSION;
30 		writeln(NWN_LIB_D_VERSION);
31 		return 0;
32 	}
33 	if(args.length >= 2 && (args[1] == "--help" || args[1] == "-h")){
34 		usage(args[0]);
35 		return 0;
36 	}
37 
38 	enforce(args.length > 1, "No subcommand provided");
39 	immutable command = args[1];
40 	args = args[0] ~ args[2..$];
41 
42 
43 	switch(command){
44 		case "check":
45 			bool noError, noWarning, noNotice;
46 			auto res = getopt(args,
47 				"noerrors", "do not print errors", &noError,
48 				"nowarnings", "do not print warnings", &noWarning,
49 				"nonotice", "do not print notice", &noNotice,
50 			);
51 			if(res.helpWanted){
52 				improvedGetoptPrinter(
53 					multilineStr!`
54 						Parse the 2da and print found issues
55 
56 						Usage: nwn-2da check [options] <2da_file> [<2da_file> ...]
57 						`,
58 					res.options,
59 				);
60 				return 0;
61 			}
62 
63 			bool errored = false;
64 			foreach(file ; args[1 .. $]){
65 				auto parseRes = TwoDA.recover(file);
66 
67 				foreach(err ; parseRes.errors){
68 					if(err.type == "Error" && noError
69 					|| err.type == "Warning" && noWarning
70 					|| err.type == "Notice" && noNotice)
71 						continue;
72 
73 					errored = true;
74 					writefln("%s:%d: %s: %s",
75 						file, err.line, err.type, err.msg
76 					);
77 				}
78 			}
79 
80 			return errored;
81 
82 		case "normalize":
83 			bool noError, noWarning, noNotice;
84 			auto res = getopt(args);
85 			if(res.helpWanted){
86 				improvedGetoptPrinter(
87 					multilineStr!`
88 						Fix issues and re-format 2DA files
89 
90 						Usage: nwn-2da normalize [options] <2da_file> [<2da_file> ...]
91 						`,
92 					res.options,
93 				);
94 				return 0;
95 			}
96 
97 			foreach(file ; args[1 .. $]){
98 				auto twoda = new TwoDA(file);
99 				std.file.write(file, twoda.serialize());
100 			}
101 
102 			return 0;
103 
104 
105 		case "merge":
106 			string[] ranges;
107 			bool nonInteractive = false;
108 			auto res = getopt(args,
109 				"range", "Merge a specific range. Format is: <from>-<to>. Can be provided multiple times.", &ranges,
110 				"y|yes", "Overwrite existing data without asking", &nonInteractive,
111 			);
112 			if(res.helpWanted){
113 				improvedGetoptPrinter(
114 					multilineStr!`
115 						Merge source_2da rows into target_2da
116 
117 						Usage: nwn-2da merge [options] <target_2da> <source_2da>
118 						`,
119 					res.options,
120 					multilineStr!`
121 						===============|  Special 2DA merge file format  |===============
122 
123 						This tool can use the "2DA merge" format for source_2da to specify which rows must be set in target_2da.
124 						The 2da merge file must start with a line '2DAMV1.0', followed by 2DA rows.
125 
126 						Example:
127 						---
128 						2DAMV1.0
129 						10    ****          ****     **** **** ****   **** **** **** **** ****
130 						1000  Aid           16777327 10   2    110533 1    1    1    1    it_s_aid
131 						1001  Bestow_Curse  16777328 20   3    110533 4    0    1    1    it_s_bestowcurse
132 						1002  BlindDeaf     16777329 20   2    110533 8    0    1    1    it_s_blinddeaf
133 						---
134 						`
135 				);
136 				return 0;
137 			}
138 			size_t[2][] parsedRanges = ranges.map!((a){
139 					auto s = a.split('-');
140 					enforce(s.length == 2, "Bad range '" ~ a ~ "', must be <from>-<to>. Ex: 12-32");
141 					auto r = s.map!(to!size_t).array;
142 					enforce(r[0] <= r[1], "Invalid range '" ~ a ~ "'");
143 					return cast(size_t[2])r[0 .. 2];
144 				})
145 				.array;
146 			bool isInRange(size_t targetIndex){
147 				if(parsedRanges.length == 0)
148 					return true;
149 				foreach(r ; parsedRanges){
150 					if(r[0] <= targetIndex && targetIndex <= r[1])
151 						return true;
152 				}
153 				return false;
154 			}
155 
156 			enforce(args.length == 3, "Need a target and source 2da");
157 			auto targetPath = args[1];
158 			auto sourcePath = args[2];
159 
160 			auto targetTwoDA = new TwoDA(targetPath);
161 
162 			size_t maxIndex = 0;
163 			size_t[] sourceRowsIndices;
164 			string[][] sourceRowsData;
165 			auto sourceData = std.file.read(sourcePath);
166 			if(sourceData.length >= 8 && sourceData[0 .. 8] == "2DAMV1.0"){
167 				// 2da merge format
168 				foreach(i, line ; (cast(string)sourceData).splitLines){
169 					if(i == 0)
170 						continue;
171 					auto row = TwoDA.extractRowData(line);
172 					sourceRowsIndices ~= row[0].to!size_t;
173 					sourceRowsData ~= row[1 .. $];
174 					maxIndex = max(maxIndex, sourceRowsIndices[$ - 1]);
175 				}
176 			}
177 			else{
178 				// Standard 2da
179 				auto sourceTwoDA = new TwoDA(cast(ubyte[])sourceData);
180 				foreach(i ; 0 .. sourceTwoDA.rows){
181 					auto row = sourceTwoDA[i];
182 					if(row.any!"a !is null"){
183 						sourceRowsIndices ~= i;
184 						sourceRowsData ~= row.dup;
185 						maxIndex = max(maxIndex, sourceRowsIndices[$ - 1]);
186 					}
187 				}
188 			}
189 
190 			if(maxIndex >= targetTwoDA.rows)
191 				targetTwoDA.rows = maxIndex + 1;
192 
193 			foreach(i, rowIndex ; sourceRowsIndices){
194 				if(!isInRange(rowIndex))
195 					continue;
196 
197 				auto targetRow = targetTwoDA[rowIndex];
198 				auto sourceRow = sourceRowsData[i];
199 				if(targetRow == sourceRow)
200 					continue;
201 
202 				if(targetRow.all!"a is null" || nonInteractive)
203 					targetTwoDA[rowIndex] = sourceRow;
204 				else{
205 					writefln("Conflict on row index %d:", rowIndex);
206 					writefln("Target: ", targetRow);
207 					writefln("Source: ", sourceRow);
208 					while(true){
209 						write("Replace target with source? (y|n|q) ");
210 						stdout.flush();
211 						auto ans = stdin.readln();
212 						switch(ans){
213 							case "y":
214 								targetTwoDA[rowIndex] = sourceRow;
215 								break;
216 							case "n":
217 								break;
218 							case "q":
219 								return 0;
220 							default:
221 								continue;
222 						}
223 						break;
224 					}
225 				}
226 			}
227 
228 			std.file.write(targetPath, targetTwoDA.serialize());
229 
230 
231 			return 0;
232 
233 		default:
234 			writeln("Unknown command ", command);
235 			return 1;
236 	}
237 }
238 
239 
240 
241 unittest {
242 	auto stdout_ = stdout;
243 	auto tmpOut = buildPath(tempDir, "unittest-nwn-lib-d-"~__MODULE__~".out");
244 	stdout = File(tmpOut, "w");
245 	scope(success) std.file.remove(tmpOut);
246 	scope(exit) stdout = stdout_;
247 
248 
249 	assertThrown(_main(["nwn-2da"]));
250 	assert(_main(["nwn-2da","--help"])==0);
251 	assert(_main(["nwn-2da","--version"])==0);
252 	assert(_main(["nwn-2da","yolo"]) != 0);
253 
254 	immutable targetPath = buildPath(tempDir, "unittest-nwn-lib-d-"~__MODULE__~".target.2da");
255 	scope(success) std.file.remove(targetPath);
256 	immutable sourcePath = buildPath(tempDir, "unittest-nwn-lib-d-"~__MODULE__~".source.2dam");
257 	scope(success) std.file.remove(sourcePath);
258 
259 
260 	// Checking (most errors are not checked here)
261 	assert(_main(["nwn-2da","check","--help"])==0);
262 	stdout.reopen(null, "w");
263 	std.file.write(targetPath, import("2da/armorrulestats.2da"));
264 	assert(_main(["nwn-2da","check",targetPath]) == 1);
265 	stdout.flush();
266 	assert(tmpOut.readText.splitLines.length == 2);
267 
268 	// Normalize
269 	assert(_main(["nwn-2da","normalize","--help"])==0);
270 	std.file.write(targetPath, import("2da/armorrulestats.2da"));
271 	assert(_main(["nwn-2da","normalize",targetPath]) == 0);
272 	assert(_main(["nwn-2da","check",targetPath]) == 0);
273 
274 	// Merging
275 	assert(_main(["nwn-2da","merge","--help"])==0);
276 
277 	auto origTwoDA = new TwoDA(cast(ubyte[])import("2da/iprp_ammocost.2da"));
278 
279 	// Row data matches, do not change anything
280 	std.file.write(targetPath, import("2da/iprp_ammocost.2da"));
281 	std.file.write(sourcePath, multilineStr!`
282 		2DAMV1.0
283 		3	1633	1d6Cold	4	NW_WAMMAR005	NW_WAMMBO001	NW_WAMMBU006
284 		`
285 	);
286 	assert(_main(["nwn-2da","merge",targetPath,sourcePath])==0);
287 	auto res = new TwoDA(targetPath);
288 	foreach(i ; 0 .. origTwoDA.rows)
289 		assert(res[i] == origTwoDA[i], i.to!string);
290 
291 	// Overwrite row data
292 	std.file.write(targetPath, import("2da/iprp_ammocost.2da"));
293 	std.file.write(sourcePath, multilineStr!`
294 		2DAMV1.0
295 		7	200888	Modified	7	nx1_arrow03	nx1_bolt03	nx1_bullet03
296 		`
297 	);
298 	assert(_main(["nwn-2da","merge","-y",targetPath,sourcePath])==0);
299 	res = new TwoDA(targetPath);
300 	assert(res.get("Label", 7) == "Modified");
301 
302 	// Insert row data
303 	std.file.write(targetPath, import("2da/iprp_ammocost.2da"));
304 	std.file.write(sourcePath, multilineStr!`
305 		2DAMV1.0
306 		20	1000	NewRow	10	nx1_arrow03	nx1_bolt03	nx1_bullet03
307 		`
308 	);
309 	assert(_main(["nwn-2da","merge",targetPath,sourcePath])==0);
310 	res = new TwoDA(targetPath);
311 	assert(res.get("Label", 20) == "NewRow");
312 
313 	// Insert row data outside of bounds
314 	std.file.write(targetPath, import("2da/iprp_ammocost.2da"));
315 	std.file.write(sourcePath, multilineStr!`
316 		2DAMV1.0
317 		20	1000	NewRow	10	nx1_arrow03	nx1_bolt03	nx1_bullet03
318 		`
319 	);
320 	assert(_main(["nwn-2da","merge",targetPath,sourcePath])==0);
321 	res = new TwoDA(targetPath);
322 	assert(res.get("Label", 20) == "NewRow");
323 
324 
325 	// Insert a range
326 	std.file.write(targetPath, import("2da/armorrulestats.2da"));
327 	std.file.write(sourcePath, multilineStr!`
328 		2DAMV1.0
329 		5  Scale-mod        4 3   -4  25 300 50   179905 111250 5438   Medium
330 		6  Banded-mod       6 1   -6  35 350 250  1733   111251 5439   Heavy
331 		7  Half-Plate-mod   7 0   -7  40 500 600  1734   111252 5440   Heavy
332 		8  Full-Plate-mod   8 1   -6  35 500 1500 1736   111253 5441   Heavy
333 		9  Light_Shield-mod 1 100 -1  5  50  9    2287   179    5443   None
334 		10 Heavy_Shield-mod 2 100 -2  15 100 20   2286   1550   5458   None
335 		11 Tower_Shield-mod 4 2   -10 50 450 30   1717   1551   5459   None
336 		12 Hide-mod         3 4   -3  20 250 15   179882 179878 179886 Medium
337 		`
338 	);
339 	assert(_main(["nwn-2da","merge","-y",targetPath,sourcePath,"--range=6-7","--range=10-11","--range=14-15","--range=100-101"])==0);
340 	res = new TwoDA(targetPath);
341 	assert(res.get("Label", 5) == "Scale");
342 	assert(res.get("Label", 6) == "Banded-mod");
343 	assert(res.get("Label", 7) == "Half-Plate-mod");
344 	assert(res.get("Label", 8) == "Full-Plate");
345 	assert(res.get("Label", 9) == "Light_Shield");
346 	assert(res.get("Label", 10) == "Heavy_Shield-mod");
347 	assert(res.get("Label", 11) == "Tower_Shield-mod");
348 	assert(res.get("Label", 12) == "Hide");
349 	assert(res.get("Label", 13) == "Chainmail");
350 	assert(res.get("Label", 14) == "Breastplate");
351 	assert(res.rows == 47);
352 }