1 /// Authors: Thibaut CHARLES (Crom) cromfr@gmail.com
2 /// License: GPL-3.0
3 /// Copyright: Copyright Thibaut CHARLES 2016
4 
5 module tools.nwntrn;
6 
7 import std.stdio;
8 import std.conv: to, ConvException;
9 import std.traits;
10 import std.string;
11 import std.file;
12 import std.file: readFile = read, writeFile = write;
13 import std.path;
14 import std.stdint;
15 import std.typecons: Tuple, Nullable;
16 import std.algorithm;
17 import std.array;
18 import std.exception;
19 import std.random: uniform;
20 import std.format;
21 import std.math;
22 import std.parallelism;
23 
24 import nwnlibd.path;
25 import nwnlibd.parseutils;
26 import nwnlibd.geometry;
27 import tools.common.getopt;
28 import nwn.trn;
29 import nwn.dds;
30 import gfm.math.vector;
31 import gfm.math.box;
32 
33 class ArgException : Exception{
34 	@safe pure nothrow this(string msg, string f=__FILE__, size_t l=__LINE__, Throwable t=null){
35 		super(msg, f, l, t);
36 	}
37 }
38 
39 void usage(in string cmd){
40 	writeln("TRN / TRX tool");
41 	writeln("Usage: ", cmd.baseName, " command [args]");
42 	writeln();
43 	writeln("Commands");
44 	writeln("  info: Print TRN and packets header information");
45 	writeln("  bake: Bake an area (replacement for builtin nwn2toolset bake tool)");
46 	writeln("  check: Performs several checks on the TRN packets data");
47 	writeln("  optimize: Reduce the TRX file size for client or server usage");
48 	writeln("  trrn-export: Export the terrain mesh, textures and grass");
49 	writeln("  trrn-import: Import a terrain mesh, textures and grass into an existing TRN/TRX file");
50 	writeln("  trrn-uv-remap: Recalculate terrain UV coordinates (using different algorithms)");
51 	writeln("  watr-export: Export water mesh");
52 	writeln("  watr-import: Import a water mesh into an existing TRN/TRX file");
53 	writeln("  aswm-strip: Optimize TRX file size");
54 	writeln("  aswm-export: Export walkable walkmesh into a wavefront obj");
55 	writeln("  aswm-export-fancy: Export custom walkmesh data into a colored wavefront obj");
56 	writeln("  aswm-import: Import a wavefront obj as the walkmesh of an existing TRX file");
57 	writeln();
58 	writeln("Advanced commands:");
59 	writeln("  aswm-check: Checks if a TRX file contains valid data");
60 	writeln("  aswm-dump: Print walkmesh data using a (barely) human-readable format");
61 	writeln("  aswm-bake: Re-bake all tiles of an already baked walkmesh");
62 }
63 
64 int main(string[] args){
65 	if(args.any!(a => a == "--version")){
66 		import nwn.ver: NWN_LIB_D_VERSION;
67 		writeln(NWN_LIB_D_VERSION);
68 		return 0;
69 	}
70 	if(args.length >= 2 && (args[1] == "--help" || args[1] == "-h")){
71 		usage(args[0]);
72 		return 0;
73 	}
74 
75 	enforce(args.length > 1, "No subcommand provided");
76 	immutable command = args[1];
77 	args = args[0] ~ args[2..$];
78 
79 	switch(command){
80 		default:
81 			usage(args[0]);
82 			return 1;
83 
84 		case "info":
85 			auto res = getopt(args);
86 			if(res.helpWanted){
87 				improvedGetoptPrinter(
88 					"Print TRN file information\n"
89 					~"Usage: "~args[0].baseName~" "~command~" file.trn",
90 					res.options);
91 				return 0;
92 			}
93 			enforce(args.length > 1, "No input file provided");
94 			enforce(args.length <=2, "Too many input files provided");
95 
96 			auto data = cast(ubyte[])args[1].read();
97 			auto trn = new Trn(data);
98 			writeln("nwnVersion: ", trn.nwnVersion);
99 			writeln("versionMajor: ", trn.versionMajor);
100 			writeln("versionMinor: ", trn.versionMinor);
101 			writeln("packetsCount: ", trn.packets.length);
102 			foreach(i, ref packet ; trn.packets){
103 				writeln("# Packet ", i);
104 				writeln("packet[", i, "].type: ", packet.type);
105 				final switch(packet.type) with(TrnPacketType){
106 					case NWN2_TRWH:
107 						auto p = packet.as!TrnNWN2TerrainDimPayload;
108 						writeln("packet[", i, "].width: ", p.width);
109 						writeln("packet[", i, "].height: ", p.height);
110 						writeln("packet[", i, "].id: ", p.id);
111 						break;
112 					case NWN2_TRRN:
113 						auto p = packet.as!TrnNWN2MegatilePayload;
114 						writeln("packet[", i, "].name: ", p.name.charArrayToString.toSafeString);
115 						foreach(j, ref t ; p.textures){
116 							writeln("packet[", i, "].textures[", j, "].name: ", t.name.charArrayToString.toSafeString);
117 							writeln("packet[", i, "].textures[", j, "].color: ", t.color);
118 						}
119 						break;
120 					case NWN2_WATR:
121 						auto p = packet.as!TrnNWN2WaterPayload;
122 						writeln("packet[", i, "].name: ", p.name.charArrayToString.toSafeString);
123 						writeln("packet[", i, "].color: ", p.color);
124 						writeln("packet[", i, "].ripple: ", p.ripple);
125 						writeln("packet[", i, "].smoothness: ", p.smoothness);
126 						writeln("packet[", i, "].reflect_bias: ", p.reflect_bias);
127 						writeln("packet[", i, "].reflect_power: ", p.reflect_power);
128 						writeln("packet[", i, "].specular_power: ", p.specular_power);
129 						writeln("packet[", i, "].specular_cofficient: ", p.specular_cofficient);
130 						foreach(j, ref t ; p.textures){
131 							writeln("packet[", i, "].textures[", j, "].name: ", t.name.charArrayToString.toSafeString);
132 							writeln("packet[", i, "].textures[", j, "].direction: ", t.direction);
133 							writeln("packet[", i, "].textures[", j, "].rate: ", t.rate);
134 							writeln("packet[", i, "].textures[", j, "].angle: ", t.angle);
135 						}
136 						writeln("packet[", i, "].uv_offset: ", p.uv_offset);
137 						break;
138 					case NWN2_ASWM:
139 						auto p = packet.as!TrnNWN2WalkmeshPayload;
140 						writeln("packet[", i, "].aswm_version: ", p.header.aswm_version.format!"0x%02x");
141 						writeln("packet[", i, "].name: ", p.header.name.charArrayToString.toSafeString);
142 						writeln("packet[", i, "].owns_data: ", p.header.owns_data);
143 						writeln("packet[", i, "].vertices_count: ", p.vertices.length);
144 						writeln("packet[", i, "].edges_count: ", p.edges.length);
145 						writeln("packet[", i, "].triangles_count: ", p.triangles.length);
146 						break;
147 				}
148 			}
149 			break;
150 
151 		case "check":
152 			bool strict = false;
153 			auto res = getopt(args,
154 				"strict", "Check some inconsistencies that does not cause issues with nwn2\nDefault: false", &strict);
155 			if(res.helpWanted){
156 				improvedGetoptPrinter(
157 					"Check if TRN packets contains valid data\n"
158 					~"Usage: "~args[0].baseName~" "~command~" file1.trx file2.trn ...",
159 					res.options);
160 				return 0;
161 			}
162 			enforce(args.length > 1, "No input file provided");
163 
164 			foreach(file ; args[1 .. $]){
165 				Trn trn;
166 				try trn = new Trn(file);
167 				catch(Exception e){
168 					writeln("Error while parsing ", file, ": ", e);
169 				}
170 
171 				if(trn !is null){
172 					foreach(i, ref packet ; trn.packets){
173 						try{
174 							final switch(packet.type){
175 								case TrnPacketType.NWN2_TRWH:
176 									break;
177 								case TrnPacketType.NWN2_TRRN:
178 									packet.as!(TrnPacketType.NWN2_TRRN).validate();
179 									break;
180 								case TrnPacketType.NWN2_WATR:
181 									packet.as!(TrnPacketType.NWN2_WATR).validate();
182 									break;
183 								case TrnPacketType.NWN2_ASWM:
184 									packet.as!(TrnPacketType.NWN2_ASWM).validate(strict);
185 									break;
186 							}
187 						}
188 						catch(TrnInvalidValueException e){
189 							writefln!"Error in %s on packet[%d] of type %s: %s"(file, i, packet.type, e.msg);
190 						}
191 					}
192 				}
193 			}
194 			break;
195 
196 		case "optimize":{
197 			bool inPlace = false;
198 			bool quiet = false;
199 			bool server = false;
200 			string targetPath = null;
201 			uint threads = 0;
202 			float roundBound = float.nan;
203 
204 			auto res = getopt(args,
205 				"in-place|i", "Provide this flag to overwrite the provided TRX file", &inPlace,
206 				"output|o", "Output file or directory. Mandatory if --in-place is not provided.", &targetPath,
207 				"server", "Optimize for server usage (Optimize for client if not provided).", &server,
208 				"quiet|q", "Do not display statistics", &quiet,
209 				"round", "Round floating point values, to enhance compressibility. Set to 0.02 to round to the nearest multiple of 0.02.", &roundBound,
210 				"j", "Threads to use. Default to the number of available threads on this machine.", &threads,
211 				);
212 			if(res.helpWanted){
213 				improvedGetoptPrinter(
214 					"Optimize TRN/TRX files to improve file size, compressibility and game/server memory usage.\n"
215 					~"\n"
216 					~"Usage: "~args[0].baseName~" "~command~" map.trx -o optimized_map.trx\n"
217 					~"       "~args[0].baseName~" "~command~" -i map.trx",
218 					res.options,
219 					multilineStr!"
220 						Optimization details:
221 						- TRWH (area size):
222 						  + Zero-out ID field
223 						- TRRN (terrain mesh & grass):
224 						  + Zero-out megatile name and grass patch names
225 						  + Clean trailing garbage in texture names and grass patch names
226 						- WATR (water mesh):
227 						  + Zero-out megatile name and padding
228 						  + Reduce the triangles count to 2 per megatile (only if the water is planar)
229 						- ASWM (walk mesh):
230 						  + Zero-out megatile name, tile names and island padding
231 						  + Remove triangles that are not walkable, and their associated data (using aswm-strip)
232 
233 						Server optimization details:
234 						- All the above optimizations
235 						- Remove TRRN and WATR data (keeping only TRWH and ASWM)
236 
237 						Notes:
238 						You shouldn't optimize TRN files, as these files are only used by the toolset
239 						and it may not work very well with certain optimizations (for example, you
240 						won't be able to extend water planes after the optimization).
241 					");
242 				return 0;
243 			}
244 			enforce(args.length > 1, "No input file provided");
245 
246 			if(inPlace){
247 				enforce(targetPath is null, "You cannot use --in-place with --output");
248 				enforce(args.length >= 2, "No input file");
249 			}
250 			else{
251 				enforce(args.length <=2, "Too many input files provided");
252 				if(targetPath is null)
253 					targetPath = ".";
254 			}
255 
256 			if(threads > 0)
257 				defaultPoolThreads = threads;
258 
259 			float roundFloat(in float f){
260 				return round(f / roundBound) * roundBound;
261 			}
262 
263 			foreach(file ; args[1 .. $].parallel){
264 				auto data = cast(ubyte[])file.read();
265 				auto trn = new Trn(data);
266 				size_t initLen = data.length;
267 
268 				// Remove unneeded packets
269 				if(server){
270 					typeof(trn.packets) newPackets;
271 					foreach(i, ref packet ; trn.packets){
272 						final switch(packet.type){
273 							case TrnPacketType.NWN2_TRRN:
274 							case TrnPacketType.NWN2_WATR:
275 								break;
276 							case TrnPacketType.NWN2_TRWH:
277 							case TrnPacketType.NWN2_ASWM:
278 								newPackets ~= packet;
279 								break;
280 						}
281 					}
282 					trn.packets = newPackets;
283 				}
284 
285 				// Size of a megatile
286 				// 9.0 for interior areas, 40.0 for exterior areas
287 				float megatileSize = 9.0;
288 				foreach(ref TrnNWN2MegatilePayload packet ; trn){
289 					megatileSize = 40.0;
290 					break;
291 				}
292 
293 
294 				// Clean garbage in fields (uninitialized memory written that was written to the file)
295 				foreach(ref packet ; trn.packets){
296 					final switch(packet.type){
297 						case TrnPacketType.NWN2_TRWH:
298 							packet.as!TrnNWN2TerrainDimPayload.id = 0;
299 							break;
300 						case TrnPacketType.NWN2_TRRN:
301 							with(packet.as!TrnNWN2MegatilePayload){
302 								// Zero out trailing / useless data
303 								name[] = 0;
304 
305 								foreach(ref t ; textures)
306 									t.name = t.name.charArrayToString.stringToCharArray!(typeof(t.name));
307 
308 								foreach(ref g ; grass){
309 									g.name[] = 0;
310 									g.texture = g.texture.charArrayToString.stringToCharArray!(typeof(g.texture));
311 								}
312 
313 
314 								if(!roundBound.isNaN){
315 									foreach(ref v ; vertices){
316 										v.position.each!((ref f){f = roundFloat(f);});
317 										v.normal.each!((ref f){f = roundFloat(f);});
318 										v.weights.each!((ref f){f = roundFloat(f);});
319 									}
320 								}
321 
322 								validate();
323 							}
324 							break;
325 
326 						case TrnPacketType.NWN2_WATR:
327 							with(packet.as!TrnNWN2WaterPayload){
328 								// Zero out trailing / useless data
329 								name[] = 0;
330 								unknown[] = 0;
331 
332 								// If planar water mesh, simplify the mesh
333 								float altitude = vertices[0].position[2];
334 								bool isPlanar = vertices.all!(v => v.position[2] == altitude);
335 
336 								if(isPlanar){
337 									auto waterMask = Dds(dds).toBitmap!(ubyte);
338 
339 									// Build bounding box of water pixels
340 									box2f aabb;
341 									foreach(y ; 0 .. waterMask.height){
342 										foreach(x ; 0 .. waterMask.width){
343 											if(waterMask[x, y] == ubyte.max){
344 												box2f pixBox = box2f(vec2f(x, y), vec2f(x + 1, y + 1));
345 
346 												if(aabb.min.x.isNaN)
347 													aabb = pixBox;
348 												else if(!aabb.contains(pixBox))
349 													aabb = aabb.expand(pixBox);
350 											}
351 										}
352 									}
353 
354 									if(aabb.min.x.isNaN){
355 										vertices.length = 0;
356 										triangles.length = 0;
357 										triangles_flags[] = 0;
358 									}
359 									else{
360 										// Move / resize bounding box to match megatile size & position
361 										const offset = vec2f(megatile_position[0], megatile_position[1]) * megatileSize;
362 										aabb.min = aabb.min * megatileSize / 128f + offset;
363 										aabb.max = aabb.max * megatileSize / 128f + offset;
364 
365 										// 4 corners of the aabb
366 										float[3] a = [aabb.min.x, aabb.min.y, altitude];
367 										float[3] b = [aabb.max.x, aabb.min.y, altitude];
368 										float[3] c = [aabb.max.x, aabb.max.y, altitude];
369 										float[3] d = [aabb.min.x, aabb.max.y, altitude];
370 
371 										vertices = [
372 											TrnNWN2WaterPayload.Vertex(a),
373 											TrnNWN2WaterPayload.Vertex(b),
374 											TrnNWN2WaterPayload.Vertex(c),
375 											TrnNWN2WaterPayload.Vertex(d),
376 										];
377 										// calculate texture coordinates
378 										vertices.each!((ref v){
379 											v.uv = [(v.position[0] - offset.x) / megatileSize, (v.position[1] - offset.y) / megatileSize];
380 											v.uvx5 = v.uv[] * 5.0;
381 										});
382 										triangles = [
383 											TrnNWN2WaterPayload.Triangle([1, 3, 0]),
384 											TrnNWN2WaterPayload.Triangle([2, 3, 1]),
385 										];
386 										triangles_flags = [0, 0];
387 									}
388 								}
389 
390 
391 								if(!roundBound.isNaN){
392 									foreach(ref v ; vertices){
393 										v.position.each!((ref f){f = roundFloat(f);});
394 										v.uvx5.each!((ref f){f = roundFloat(f);});
395 										v.uv.each!((ref f){f = roundFloat(f);});
396 									}
397 								}
398 
399 								validate();
400 							}
401 							break;
402 						case TrnPacketType.NWN2_ASWM:
403 							import aswmstrip: stripASWM;
404 							stripASWM(packet.as!TrnNWN2WalkmeshPayload, true);
405 
406 							with(packet.as!TrnNWN2WalkmeshPayload){
407 								// Zero out trailing / useless data
408 								header.name[] = 0;
409 
410 								header.unknownB = 0;
411 
412 								foreach(ref t ; tiles)
413 									t.header.name[] = 0;
414 
415 								foreach(ref ipn ; islands_path_nodes)
416 									ipn._padding = 0;
417 
418 								if(!roundBound.isNaN){
419 									foreach(ref v ; vertices){
420 										v.position.each!((ref f){f = roundFloat(f);});
421 									}
422 									foreach(ref t ; triangles){
423 										t.center.each!((ref f){f = roundFloat(f);});
424 										t.normal.each!((ref f){f = roundFloat(f);});
425 										t.dot_product = roundFloat(t.dot_product);
426 									}
427 									foreach(ref tile ; tiles){
428 										foreach(ref v ; tile.vertices){
429 											v.position.each!((ref f){f = roundFloat(f);});
430 										}
431 									}
432 									foreach(ref i ; islands){
433 										i.header.center.position.each!((ref f){f = roundFloat(f);});
434 										i.adjacent_islands_dist.each!((ref f){f = roundFloat(f);});
435 									}
436 									islands_path_nodes.each!((ref ipn){ipn.weight = roundFloat(ipn.weight);});
437 								}
438 
439 								validate();
440 							}
441 							break;
442 					}
443 				}
444 
445 				auto finalData = trn.serialize();
446 				if(!quiet){
447 					writefln("%-32s File size: %9dB => %9dB (stripped %.2f%%)",
448 						file.baseName.stripExtension, initLen, finalData.length, 100 - finalData.length * 100.0 / initLen
449 					);
450 				}
451 
452 				string outPath;
453 				if(inPlace)
454 					outPath = file;
455 				else{
456 					if(targetPath.exists && targetPath.isDir)
457 						outPath = buildPath(targetPath, file.baseName);
458 					else
459 						outPath = targetPath;
460 				}
461 
462 				std.file.write(outPath, finalData);
463 			}
464 
465 
466 
467 		}
468 		break;
469 
470 		case "aswm-strip":{
471 			bool inPlace = false;
472 			bool quiet = false;
473 			string targetPath = null;
474 
475 			auto res = getopt(args,
476 				"in-place|i", "Provide this flag to overwrite the provided TRX file", &inPlace,
477 				"output|o", "Output file or directory. Mandatory if --in-place is not provided.", &targetPath,
478 				"quiet|q", "Do not display statistics", &quiet,
479 				);
480 			if(res.helpWanted){
481 				improvedGetoptPrinter(
482 					"Reduce TRX file size by removing non walkable triangles from walkmesh and path tables\n"
483 					~"Usage: "~args[0].baseName~" "~command~" map.trx -o stripped_map.trx\n"
484 					~"       "~args[0].baseName~" "~command~" -i map.trx",
485 					res.options);
486 				return 0;
487 			}
488 			enforce(args.length > 1, "No input file provided");
489 
490 			if(inPlace){
491 				enforce(targetPath is null, "You cannot use --in-place with --output");
492 				enforce(args.length >= 2, "No input file");
493 			}
494 			else{
495 				enforce(args.length <=2, "Too many input files provided");
496 				if(targetPath is null)
497 					targetPath = ".";
498 			}
499 
500 			foreach(file ; args[1 .. $]){
501 
502 				auto data = cast(ubyte[])file.read();
503 				auto trn = new Trn(data);
504 				size_t initLen = data.length;
505 
506 				bool found = false;
507 				foreach(ref TrnNWN2WalkmeshPayload aswm ; trn){
508 					found = true;
509 
510 					import aswmstrip: stripASWM;
511 					stripASWM(aswm, quiet);
512 					aswm.validate();
513 				}
514 
515 				enforce(found, "No ASWM packet found. Make sure you are targeting a TRX file.");
516 
517 				auto finalData = trn.serialize();
518 				if(!quiet)
519 					writeln("File size: ", initLen, "B => ", finalData.length, "B (stripped ", 100 - finalData.length * 100.0 / initLen, "%)");
520 
521 				string outPath;
522 				if(inPlace)
523 					outPath = file;
524 				else{
525 					if(targetPath.exists && targetPath.isDir)
526 						outPath = buildPath(targetPath, file.baseName);
527 					else
528 						outPath = targetPath;
529 				}
530 
531 				std.file.write(outPath, finalData);
532 			}
533 
534 
535 
536 		}
537 		break;
538 
539 		case "aswm-export-fancy":{
540 			string targetDir = null;
541 			string[] features = [];
542 			auto res = getopt(args,
543 				"output-dir|o", "Output directory where to write converted files", &targetDir,
544 				"feature|f", "Features to render. Can be provided multiple times. Default: [\"walkmesh\"]", &features,
545 				);
546 
547 			if(res.helpWanted){
548 				improvedGetoptPrinter(
549 					"Convert NWN2 walkmeshes into TRX / OBJ (only TRX => OBJ supported for now)\n"
550 					~"Usage: "~args[0].baseName~" "~command~" map.trx\n"
551 					~"\n"
552 					~"Available features to render:\n"
553 					~"- walkmesh: All triangles including non-walkable.\n"
554 					~"- edges: Edges between two triangles.\n"
555 					~"- tiles: Each tile using random colors.\n"
556 					~"- pathtables-los: Line of sight pathtable property between two triangles.\n"
557 					~"- randomtilepaths: Calculate random paths between tile triangles.\n"
558 					~"- randomislandspaths: Calculate random paths between islands.\n"
559 					~"- islands: Each island using random colors.\n",
560 					res.options);
561 				return 0;
562 			}
563 			enforce(args.length > 1, "No input file provided");
564 			enforce(args.length <=2, "Too many input files provided");
565 
566 			if(targetDir == null && targetDir != "-"){
567 				targetDir = args[1].dirName;
568 			}
569 
570 			auto outfile = targetDir == "-"? stdout : File(buildPath(targetDir, baseName(args[1])~".obj"), "w");
571 
572 			if(features.length == 0)
573 				features = [ "walkmesh" ];
574 
575 
576 			auto trn = new Trn(args[1]);
577 
578 			bool found = false;
579 			foreach(ref TrnNWN2WalkmeshPayload aswm ; trn){
580 				found = true;
581 
582 				import aswmtoobj: writeWalkmeshObj;
583 				writeWalkmeshObj(
584 					aswm,
585 					args[1].baseName.stripExtension,
586 					outfile,
587 					features);
588 			}
589 			enforce(found, "No ASWM packet found. Make sure you are targeting a TRX file.");
590 
591 			if(targetDir != "-"){
592 				import aswmtoobj: colors;
593 
594 				auto colPath = buildPath(targetDir, "nwnlibd-colors.mtl");
595 				if(!colPath.exists)
596 					std.file.write(colPath, colors);
597 			}
598 		}
599 		break;
600 
601 		case "aswm-export": {
602 			string outFile = ".";
603 			auto res = getopt(args,
604 				"output|o", "Output file or directory where to write the obj file. Default: '.'", &outFile,
605 			);
606 
607 			if(res.helpWanted){
608 				improvedGetoptPrinter(
609 					"Export all walkable triangles into a Wavefront OBJ file.\n"
610 					~"Usage: "~args[0].baseName~" "~command~" map.trx\n"
611 					~"       "~args[0].baseName~" "~command~" map.trx -o outputFile.obj\n",
612 					res.options);
613 				return 0;
614 			}
615 			enforce(args.length > 1, "No input file provided");
616 			enforce(args.length <=2, "Too many input files provided");
617 
618 			auto inputFile = args[1];
619 
620 			if(outFile.exists && outFile.isDir)
621 				outFile = buildPath(outFile, inputFile.baseName ~ ".aswm.obj");
622 
623 			foreach(ref TrnNWN2WalkmeshPayload aswm ; new Trn(inputFile)){
624 				aswm.toGenericMesh.toObj(outFile);
625 			}
626 		}
627 		break;
628 
629 		case "aswm-import": {
630 			string trnFile;
631 			string objFile;
632 			string objName;
633 			string outFile;
634 			string terrain2daPath;
635 			bool keepBorders;
636 			auto res = getopt(args,
637 				config.required, "trn", "TRN file to set the walkmesh of", &trnFile,
638 				config.required, "obj", "Wavefront OBJ file to import", &objFile,
639 				"terrain2da", "Path to terrainmaterials.2da, to generate footstep sounds", &terrain2daPath,
640 				"obj-name", "Object name to import. Default: the first object declared.", &objName,
641 				"keep-borders", "Keep the ASWM triangles in the exterior area borders.", &keepBorders,
642 				"output|o", "Output file or directory where to write the obj file. Default: the file provided by --trn", &outFile,
643 				);
644 
645 			if(res.helpWanted){
646 				improvedGetoptPrinter(
647 					"Import a Wavefront OBJ file and use it as the area walkmesh. All triangles will be walkable.\n"
648 					~"Usage: "~args[0].baseName~" "~command~" --trn map.trx --obj walkmesh.obj --terrain2da ./terrainmaterials.2da -o newmap.trx\n"
649 					~"       "~args[0].baseName~" "~command~" --trn map.trx --obj walkmesh.obj --terrain2da ./terrainmaterials.2da\n",
650 					res.options);
651 				return 0;
652 			}
653 			enforce(args.length == 1, "Wrong number of arguments");
654 
655 			if(outFile is null)
656 				outFile = trnFile;
657 			else if(outFile.exists && outFile.isDir)
658 				outFile = buildPath(outFile, trnFile.baseName);
659 
660 			auto mesh = GenericMesh.fromObj(File(objFile), objName);
661 
662 			TwoDA terrainmaterials;
663 			if(terrain2daPath !is null)
664 				terrainmaterials = new TwoDA(terrain2daPath);
665 			else
666 				writeln("Warning: No triangle soundstep flags will be set. Please provide --terrain2da");
667 
668 
669 			auto trn = new Trn(trnFile);
670 			foreach(ref TrnNWN2WalkmeshPayload aswm ; trn){
671 				aswm.setGenericMesh(mesh);
672 
673 				aswm.bake(!keepBorders);
674 
675 				if(terrainmaterials !is null)
676 					aswm.setFootstepSounds(trn.packets, terrainmaterials);
677 
678 				aswm.validate();
679 			}
680 			std.file.write(outFile, trn.serialize);
681 		}
682 		break;
683 
684 		case "aswm-dump":{
685 			if(args.any!(a => a == "-h" || a == "--help")){
686 				writeln("Dump walkmesh data");
687 				writeln("Usage: "~args[0].baseName~" "~command~" file.trx");
688 				return 0;
689 			}
690 			enforce(args.length > 1, "No input file provided");
691 			enforce(args.length <=2, "Too many input files provided");
692 
693 			auto trn = new Trn(args[1]);
694 
695 			bool found = false;
696 			foreach(ref TrnNWN2WalkmeshPayload aswm ; trn){
697 				found = true;
698 				writeln(aswm.dump);
699 			}
700 			enforce(found, "No ASWM packet found. Make sure you are targeting a TRX file.");
701 		}
702 		break;
703 
704 		case "aswm-bake":{
705 			bool inPlace = false;
706 			string targetPath = null;
707 
708 			auto res = getopt(args,
709 				"in-place|i", "Provide this flag to overwrite the provided TRX file", &inPlace,
710 				"output|o", "Output file or directory. Mandatory if --in-place is not provided.", &targetPath,
711 				);
712 			if(res.helpWanted){
713 				improvedGetoptPrinter(
714 					"Re-bake all tile / islands path tables of a baked TRX file.\n"
715 					~"Usage: "~args[0].baseName~" "~command~" map.trx -o baked_map.trx\n"
716 					~"       "~args[0].baseName~" "~command~" -i map.trx",
717 					res.options);
718 				return 0;
719 			}
720 			enforce(args.length > 1, "No input file provided");
721 
722 			if(inPlace){
723 				enforce(targetPath is null, "You cannot use --in-place with --output");
724 				enforce(args.length == 2, "You can only provide one TRX file with --in-place");
725 				targetPath = args[1];
726 			}
727 			else{
728 				enforce(targetPath !is null, "No output file / directory. See --help");
729 				if(targetPath.exists && targetPath.isDir)
730 					targetPath = buildPath(targetPath, args[1].baseName);
731 			}
732 
733 			auto trn = new Trn(args[1]);
734 
735 			bool found = false;
736 			foreach(ref TrnNWN2WalkmeshPayload aswm ; trn){
737 				found = true;
738 				aswm.bake();
739 			}
740 			enforce(found, "No ASWM packet found. Make sure you are targeting a TRX file.");
741 
742 			std.file.write(targetPath, trn.serialize());
743 		}
744 		break;
745 
746 
747 		case "aswm-check":{
748 			bool strict = false;
749 			auto res = getopt(args,
750 				"strict", "Check some inconsistencies that does not cause issues with nwn2\nDefault: false", &strict);
751 			if(res.helpWanted){
752 				improvedGetoptPrinter(
753 					"Check if ASWM packets are valid.\n"
754 					~"Usage: "~args[0].baseName~" "~command~" file1.trx file2.trx ...",
755 					res.options);
756 				return 0;
757 			}
758 			enforce(args.length > 1, "No input file provided");
759 
760 			foreach(file ; args[1 .. $]){
761 				auto trn = new Trn(file);
762 				foreach(ref TrnNWN2WalkmeshPayload aswm ; trn){
763 					aswm.validate(strict);
764 				}
765 			}
766 		}
767 		break;
768 
769 
770 		case "bake":{
771 			// import nwn.gff;
772 
773 			string targetPath = null;
774 			bool inPlace = false;
775 			bool reuseTrx = false;
776 			bool forceWalkable = false;
777 			bool keepBorders = false;
778 			bool unsafe = false;
779 			bool noWmCutters = false;
780 			string terrain2daPath = null;
781 			string trnPath = null;
782 			string gitPath = null;
783 			uint threads = 0;
784 
785 			auto res = getopt(args,
786 				"output|o", "Output trx file or directory. Default: './'", &targetPath,
787 				"in-place|i", "Provide this flag to write TRX files next to the TRN files", &inPlace,
788 				"terrain2da", "Path to terrainmaterials.2da, to generate footstep sounds. By default the official NWN2 2da will be used.", &terrain2daPath,
789 				"trn", "TRN file path. Default to $map_name_without_extension.trn", &trnPath,
790 				"reuse-trx|r", "Reuse walkmesh from an already existing TRX file", &reuseTrx,
791 				"force-walkable", "Make all triangles walkable. Triangles removed with walkmesh cutters won't be walkable.", &forceWalkable,
792 				"no-wmcutter", "Do not remove triangles inside walkmesh cutters", &noWmCutters,
793 				"keep-borders", "Do not remove exterior area borders from baked mesh. (can be used with --force-walkable to make borders walkable).", &keepBorders,
794 				// "git", "GIT file path. Default to $map_name_without_extension.git", &gitPath,
795 				"j", "Parallel threads for baking multiple maps at the same time", &threads,
796 				"unsafe", "Skip TRX validation checks, ie for dumping content & debugging", &unsafe,
797 			);
798 			if(res.helpWanted){
799 				improvedGetoptPrinter(
800 					"Generate baked TRX file.\n"
801 					~"Usage: "~args[0].baseName~" "~command~" map_name -o baked.trx\n"
802 					~"       "~args[0].baseName~" "~command~" --terrain2da ./terrainmaterials.2da map_name map_name_2 ...\n"
803 					~" `map_name` can be any map file with or without its extension (.are, .git, .gic, .trn, .trx)",
804 					res.options);
805 				return 0;
806 			}
807 
808 			enforce(args.length > 1 || trnPath !is null, "No input file provided");
809 			enforce(args.length <= 2 || trnPath !is null, "Too many input files provided");
810 
811 			if(inPlace)
812 				enforce(targetPath is null, "You cannot use --in-place with --output");
813 			if(targetPath is null)
814 				targetPath = ".";
815 
816 			enforce(args.length >= 2 || trnPath !is null, "No input map name given");
817 			if(args.length > 2)
818 				enforce(trnPath is null && gitPath is null && targetPath.exists && targetPath.isDir,
819 					"Cannot use --trn, --git or --output=file with multiple input files");
820 
821 
822 			if(threads > 0)
823 				defaultPoolThreads = threads;
824 
825 			if(trnPath !is null)
826 				args ~= trnPath;
827 
828 			TwoDA terrainmaterials;
829 			if(terrain2daPath !is null)
830 				terrainmaterials = new TwoDA(terrain2daPath);
831 			else
832 				terrainmaterials = new TwoDA(cast(ubyte[])import("terrainmaterials.2da"));
833 
834 			foreach(resname ; args[1 .. $].parallel){
835 				if(trnPath !is null){
836 					switch(resname.extension.toLower){
837 						case null:
838 							break;
839 						case ".are", ".git", ".gic", ".trn", ".trx":
840 							resname = resname.stripExtension;
841 							break;
842 						default: enforce(0, "Unknown file extension "~resname.extension.toLower);
843 					}
844 				}
845 				else
846 					resname = resname.stripExtension;
847 
848 				immutable dir = resname.dirName;
849 				string trnFilePath = trnPath is null? buildPathCI(dir, resname.baseName~(reuseTrx? ".trx" : ".trn")) : trnPath;
850 				string gitFilePath = gitPath is null? buildPathCI(dir, resname.baseName~".git") : gitPath;
851 				string trxFilePath;
852 				if(inPlace)
853 					trxFilePath = resname ~ ".trx";
854 				else{
855 					if(targetPath.exists && targetPath.isDir)
856 						trxFilePath = buildPathCI(targetPath, resname.baseName~".trx");
857 					else
858 						trxFilePath = targetPath;
859 				}
860 
861 
862 				auto trn = new Trn(trnFilePath);
863 				import nwn.fastgff;
864 				auto git = new FastGff(gitFilePath);
865 
866 				// Extract all walkmesh cutters data
867 				alias WMCutter = vec2f[];
868 				WMCutter[] wmCutters;
869 				foreach(trigger ; git["TriggerList"].get!GffList){
870 					if(trigger["Type"].get!GffInt == 3){
871 						// Walkmesh cutter
872 						auto start = [trigger["XPosition"].get!GffFloat, trigger["YPosition"].get!GffFloat];
873 
874 						// what about: XOrientation YOrientation ZOrientation ?
875 						WMCutter cutter;
876 						foreach(point ; trigger["Geometry"].get!GffList){
877 							cutter ~= vec2f(
878 								start[0] + point["PointX"].get!GffFloat,
879 								start[1] + point["PointY"].get!GffFloat,
880 							);
881 						}
882 
883 						wmCutters ~= cutter;
884 					}
885 				}
886 
887 				import std.datetime.stopwatch: StopWatch;
888 				auto sw = new StopWatch;
889 				sw.start();
890 
891 				foreach(ref TrnNWN2WalkmeshPayload aswm ; trn){
892 
893 					stderr.writeln("Cutting mesh");
894 					auto mesh = aswm.toGenericMesh();
895 					foreach(i, ref wmCutter ; wmCutters){
896 						stderr.writefln("  Walkmesh cutter %d / %d", i + 1, wmCutters.length);
897 						if(wmCutter.length < 3){
898 							stderr.writeln("    Warning: Invalid walkmesh cutter geometry (cutter n°%d has only %d vertices)", i + 1, wmCutter.length);
899 							continue;
900 						}
901 						if(isPolygonComplex(wmCutter)){
902 							stderr.writeln("    Warning: Complex / self intersecting walkmesh cutters are not supported yet: ", wmCutter);
903 							continue;
904 						}
905 						mesh.polygonCut(wmCutter);
906 					}
907 					aswm.setGenericMesh(mesh);
908 
909 					stderr.writeln("Calculating path tables");
910 					aswm.tiles_flags = 31;
911 					if(forceWalkable){
912 						foreach(ref t ; aswm.triangles)
913 							t.flags |= t.Flags.walkable;
914 					}
915 					aswm.bake(!keepBorders);
916 
917 					if(terrainmaterials !is null){
918 						stderr.writeln("Setting footstep sounds");
919 						aswm.setFootstepSounds(trn.packets, terrainmaterials);
920 					}
921 
922 					if(!unsafe){
923 						stderr.writeln("Verifying walkmesh");
924 						aswm.validate();
925 					}
926 				}
927 				sw.stop();
928 				writeln(resname.baseName.leftJustify(32), " ", sw.peek.total!"msecs"/1000.0, " seconds");
929 
930 				stderr.writeln("Writing file");
931 				std.file.write(trxFilePath, trn.serialize());
932 
933 			}
934 
935 		}
936 		break;
937 
938 
939 		case "trrn-export":{
940 			string outFolder = ".";
941 			bool noTextures = false;
942 			bool noGrass = false;
943 			auto res = getopt(args,
944 				"output|o", "Output directory where to write the OBJ and DDS file. Default: '.'", &outFolder,
945 				"no-textures", "Do not output texture data (DDS alpha maps & config)", &noTextures,
946 				"no-grass", "Do not output grass data (3D lines & config)", &noGrass,
947 			);
948 
949 			if(res.helpWanted){
950 				improvedGetoptPrinter(
951 					"Export terrain mesh, textures and grass into wavefront obj, json and DDS files.\n"
952 					~"Note: works for both TRN and TRX files, though TRN files are only used by the toolset.\n"
953 					~"Usage: "~args[0].baseName~" "~command~" map.trx\n"
954 					~"       "~args[0].baseName~" "~command~" map.trx -o converted/\n"
955 					~"\n"
956 					~"Wavefront format notes:\n"
957 					~"- Each megatile is stored in a different object named with its megatile coordinates: 'megatile-x6y9' or 'megatile-x6y9-MTName' if the megatile has a name.\n"
958 					~"  This naming scheme is mandatory.\n"
959 					~"- There can be only one megatile at a given megatile coordinate.\n"
960 					~"- Vertex colors are exported, but many 3d tools don't handle it.\n"
961 					~"- Grass is exported as lines using an arbitrary format:\n"
962 					~"    + first point: grass blade position\n"
963 					~"    + second point: grass blade normal + position\n"
964 					~"    + third point: grass blade dimension + normal + position\n",
965 					res.options);
966 				return 0;
967 			}
968 			enforce(args.length > 1, "No input file provided");
969 			enforce(args.length <=2, "Too many input files provided");
970 
971 
972 			auto trnFile = args[1];
973 			auto trnFileName = trnFile.baseName;
974 			auto trn = new Trn(trnFile);
975 
976 			TrnNWN2TerrainDimPayload* trwh = null;
977 			foreach(ref TrnNWN2TerrainDimPayload _trwh ; trn){
978 				trwh = &_trwh;
979 			}
980 			enforce(trwh !is null, "No TRWH packet found");
981 
982 
983 			import nwnlibd.wavefrontobj: WavefrontObj;
984 			auto wfobj = new WavefrontObj();
985 			import std.json: JSONValue;
986 			JSONValue trrnConfig;
987 
988 
989 			size_t trrnCounter = 0;
990 			foreach(ref TrnNWN2MegatilePayload trrn ; trn){
991 				size_t x = trrnCounter % trwh.width;
992 				size_t y = trrnCounter / trwh.width;
993 				auto id = format!"x%dy%d"(x, y);
994 
995 				// Json
996 				trrnConfig[id] = JSONValue([
997 						"name": JSONValue(trrn.name[0] == 0? null : trrn.name.ptr.fromStringz)
998 					]);
999 				if(!noTextures){
1000 					trrnConfig[id]["textures"] = JSONValue(trrn.textures[].map!(a => JSONValue([
1001 							"name":  JSONValue(a.name.ptr.fromStringz),
1002 							"color": JSONValue(a.color),
1003 						])).array);
1004 				}
1005 				if(!noGrass){
1006 					trrnConfig[id]["grass"] = JSONValue(trrn.grass[].map!(a => JSONValue([
1007 							"name":  JSONValue(a.name.ptr.fromStringz),
1008 							"texture": JSONValue(a.texture.ptr.fromStringz),
1009 						])).array);
1010 				}
1011 
1012 				// DDS
1013 				if(!noTextures){
1014 
1015 					buildPath(outFolder, trnFileName ~ ".trrn." ~ id ~ ".a.dds")
1016 						.writeFile(trrn.dds_a);
1017 					buildPath(outFolder, trnFileName ~ ".trrn." ~ id ~ ".b.dds")
1018 						.writeFile(trrn.dds_b);
1019 				}
1020 
1021 				// Vertices
1022 				size_t vi = wfobj.vertices.length + 1;
1023 				size_t vti = wfobj.textCoords.length + 1;
1024 				size_t vni = wfobj.normals.length + 1;
1025 
1026 				foreach(ref v ; trrn.vertices){
1027 					auto tint = vec3f(v.tinting[0 .. 3].to!(float[])) / 255.0;
1028 
1029 					wfobj.vertices ~= WavefrontObj.WFVertex(vec3f(v.position), Nullable!vec3f(tint));
1030 					wfobj.textCoords ~= vec2f(v.uv);
1031 					wfobj.normals ~= vec3f(v.normal);
1032 				}
1033 
1034 				// Triangles
1035 				auto grp = WavefrontObj.WFGroup();
1036 				foreach(ref triangle ; trrn.triangles){
1037 					auto v = triangle.vertices.to!(size_t[]);
1038 					v[] += vi;
1039 					auto vt = triangle.vertices.to!(size_t[]);
1040 					vt[] += vti;
1041 					auto vn = triangle.vertices.to!(size_t[]);
1042 					vn[] += vni;
1043 
1044 					grp.faces ~= WavefrontObj.WFFace(
1045 						v,
1046 						Nullable!(size_t[])(vt),
1047 						Nullable!(size_t[])(vn));
1048 				}
1049 				wfobj.objects[format!"megatile-%s"(id)] = WavefrontObj.WFObject([
1050 					null: grp,
1051 				]);
1052 
1053 				// Grass
1054 				if(!noGrass && trrn.grass.length > 0){
1055 					// TODO: need to understand how grass works in order to
1056 					// display relevant data
1057 					foreach(gi, ref g ; trrn.grass){
1058 						auto grassGrp = WavefrontObj.WFGroup();
1059 						foreach(ref b ; g.blades){
1060 							vi = wfobj.vertices.length + 1;
1061 
1062 							auto pos = vec3f(b.position);
1063 							auto dir = vec3f(b.direction);
1064 							auto dim = vec3f(b.dimension);
1065 
1066 							wfobj.vertices ~= WavefrontObj.WFVertex(pos);
1067 							wfobj.vertices ~= WavefrontObj.WFVertex(pos + dir);
1068 							wfobj.vertices ~= WavefrontObj.WFVertex(pos + dir + dim);
1069 
1070 							grassGrp.lines ~= WavefrontObj.WFLine([
1071 								vi,
1072 								vi + 1,
1073 								vi + 2,
1074 								vi]);
1075 						}
1076 						wfobj.objects[format!"grass-%s-%d"(id, gi)] = WavefrontObj.WFObject([
1077 							null: grassGrp,
1078 						]);
1079 					}
1080 				}
1081 
1082 				trrnCounter++;
1083 			}
1084 
1085 			enforce(trrnCounter > 0, "No TRRN data found. Note: interior areas have no TRRN data.");
1086 
1087 			wfobj.validate();
1088 			buildPath(outFolder, trnFileName ~ ".trrn.obj").writeFile(wfobj.serialize());
1089 			buildPath(outFolder, trnFileName ~ ".trrn.json").writeFile(trrnConfig.toPrettyString);
1090 		}
1091 		break;
1092 
1093 
1094 		case "trrn-import":{
1095 			string trnFile;
1096 			bool noTextures = false;
1097 			bool noGrass = false;
1098 			string outputFile = null;
1099 			bool emptyMegatiles = false;
1100 			auto res = getopt(args,
1101 				config.required, "trn", "Existing TRN or TRX file to store the terrain mesh", &trnFile,
1102 				"no-textures", "Do not import texture data (DDS alpha maps & config)", &noTextures,
1103 				"no-grass", "Do not import grass data (3D lines & config)", &noGrass,
1104 				"rm", "Empty all megatiles before importing new mesh.\nUse with --no-textures to obtain harmless but glitchy textures.", &emptyMegatiles,
1105 				"output|o", "TRN/TRX file to write.\nDefault: overwrite the file provided by --trn", &outputFile,
1106 			);
1107 
1108 			if(res.helpWanted){
1109 				improvedGetoptPrinter(
1110 					"Import terrain mesh, textures and grass into an existing TRN or TRX file\n"
1111 					~"All needed files (json, dds) must be located in the same directory as the obj file.\n"
1112 					~"Usage: "~args[0].baseName~" "~command~" map.obj --trn map.trx\n"
1113 					~"\n"
1114 					~"Wavefront format notes:\n"
1115 					~"- Each megatile must be stored in a different object named with its megatile coordinates: 'megatile-x6y9'.\n"
1116 					~"- If a megatile is not in the obj file, the TRN/TRX megatile won't be modified\n",
1117 					res.options);
1118 				return 0;
1119 			}
1120 			enforce(args.length > 1, "No input file provided");
1121 			enforce(args.length <=2, "Too many input files provided");
1122 
1123 			auto objFilePath = args[1];
1124 			auto objFileDir = objFilePath.dirName;
1125 			auto objFileBaseName = objFilePath.baseName(".trrn.obj");
1126 
1127 			if(outputFile is null)
1128 				outputFile = trnFile;
1129 
1130 			auto trn = new Trn(trnFile);
1131 
1132 			import nwnlibd.wavefrontobj;
1133 			auto wfobj = new WavefrontObj(objFilePath.readText);
1134 			wfobj.validate();
1135 
1136 			import std.json;
1137 			auto trrnConfig = buildPath(objFileDir, objFileBaseName ~ ".trrn.json").readText.parseJSON;
1138 
1139 
1140 			TrnNWN2TerrainDimPayload* trwh = null;
1141 			foreach(ref TrnNWN2TerrainDimPayload _trwh ; trn){
1142 				trwh = &_trwh;
1143 			}
1144 			enforce(trwh !is null, "No TRWH packet found");
1145 
1146 
1147 			size_t trrnCounter;
1148 			foreach(ref TrnNWN2MegatilePayload trrn ; trn){
1149 				size_t x = trrnCounter % trwh.width;
1150 				size_t y = trrnCounter / trwh.width;
1151 				string id = format!"x%dy%d"(x, y);
1152 
1153 				if(emptyMegatiles){
1154 					trrn.name[] = 0;
1155 					foreach(ref t ; trrn.textures){
1156 						t.name[] = 0;
1157 						t.color[] = 1.0;
1158 					}
1159 					trrn.vertices.length = 0;
1160 					trrn.triangles.length = 0;
1161 					// TODO: empty DDS
1162 					trrn.grass.length = 0;
1163 				}
1164 
1165 				// Megatile name
1166 				trrn.name = trrnConfig[id]["name"].str.stringToCharArray!(char[128]);
1167 
1168 				// Mesh
1169 				if(auto o = ("megatile-"~id) in wfobj.objects){
1170 					trrn.vertices.length = 0;
1171 					trrn.triangles.length = 0;
1172 
1173 					uint16_t[size_t] vtxTransTable;
1174 					auto triangles = o.groups
1175 						.values
1176 						.map!(g => g.faces)
1177 						.join
1178 						.filter!(t => t.vertices.length == 3);// Ignore non triangles
1179 					foreach(ref t ; triangles){
1180 						TrnNWN2MegatilePayload.Triangle trrnTri;
1181 						foreach(i, v ; t.vertices){
1182 							if(v !in vtxTransTable){
1183 								// Add vertices as needed
1184 								vtxTransTable[v] = trrn.vertices.length.to!uint16_t;
1185 
1186 								ubyte[4] color;
1187 								if(wfobj.vertices[v - 1].color.isNull)
1188 									color = [255, 255, 255, 255];
1189 								else
1190 									color = (wfobj.vertices[v - 1].color.get()[] ~ 1.0)
1191 										.map!(a => (a * 255).to!ubyte)
1192 										.array[0 .. 4];
1193 
1194 								enforce(!t.normals.isNull, "No normal vector for vertex " ~ i.to!string);
1195 								enforce(!t.textCoords.isNull, "No texture coordinate for vertex " ~ i.to!string);
1196 
1197 								trrn.vertices ~= TrnNWN2MegatilePayload.Vertex(
1198 									wfobj.vertices[v - 1].position.v[0 .. 3],
1199 									wfobj.normals[t.normals.get()[i] - 1].v[0 .. 3],
1200 									color,
1201 									wfobj.textCoords[t.textCoords.get()[i] - 1].v[0 .. 2],
1202 									wfobj.textCoords[t.textCoords.get()[i] - 1][].map!(a => cast(float)(fabs(a) / 10.0)).array[0 .. 2],
1203 								);
1204 							}
1205 
1206 							trrnTri.vertices[i] = vtxTransTable[v];
1207 						}
1208 						trrn.triangles ~= trrnTri;
1209 					}
1210 
1211 				}
1212 
1213 				// DDS & textures
1214 				if(!noTextures && id in trrnConfig){
1215 					// Textures
1216 					foreach(i, ref t ; trrn.textures){
1217 						t.name = trrnConfig[id]["textures"][i]["name"]
1218 							.str
1219 							.stringToCharArray!(char[32]);
1220 						t.color = trrnConfig[id]["textures"][i]["color"]
1221 							.array
1222 							.map!(a => a.toString.to!float)
1223 								.array;
1224 					}
1225 
1226 					// DDS
1227 					trrn.dds_a = cast(ubyte[])buildPath(objFileDir, objFileBaseName ~ ".trrn." ~ id ~ ".a.dds").readFile();
1228 					trrn.dds_b = cast(ubyte[])buildPath(objFileDir, objFileBaseName ~ ".trrn." ~ id ~ ".b.dds").readFile();
1229 				}
1230 
1231 				// Grass
1232 				if(!noGrass && id in trrnConfig){
1233 					trrn.grass.length = 0;
1234 
1235 					size_t i;
1236 					WavefrontObj.WFObject* o;
1237 					for(i = 0, o = format!"grass-%s-%d"(id, i) in wfobj.objects
1238 						; o !is null
1239 						; i++, o = format!"grass-%s-%d"(id, i) in wfobj.objects){
1240 
1241 						TrnNWN2MegatilePayload.Grass grass;
1242 
1243 						// Textures
1244 						grass.name = trrnConfig[id]["grass"][i]["name"].str.stringToCharArray!(char[32]);
1245 						grass.texture = trrnConfig[id]["grass"][i]["texture"].str.stringToCharArray!(char[32]);
1246 
1247 						// Data
1248 						auto lines = o.groups
1249 							.values
1250 							.map!(g => g.lines)
1251 							.join
1252 							.filter!(t => t.vertices.length == 4);
1253 						foreach(ref l ; lines){
1254 							auto position  = wfobj.vertices[l.vertices[0] - 1].position;
1255 							auto direction = wfobj.vertices[l.vertices[1] - 1].position - position;
1256 							auto dimension = wfobj.vertices[l.vertices[2] - 1].position - direction - position;
1257 
1258 							grass.blades ~= TrnNWN2MegatilePayload.Grass.Blade(
1259 								position.v[0..3],
1260 								direction.v[0..3],
1261 								dimension.v[0..3]);
1262 						}
1263 
1264 						trrn.grass ~= grass;
1265 					}
1266 				}
1267 
1268 				trrnCounter++;
1269 			}
1270 
1271 			enforce(trrnCounter > 0, "No TRRN data found. Note: interior areas have no TRRN data.");
1272 			outputFile.writeFile(trn.serialize());
1273 		}
1274 		break;
1275 
1276 		case "trrn-uv-remap":{
1277 			bool inPlace = false;
1278 			string targetPath = null;
1279 			enum UVMappingAlgo { planar, stretch }
1280 			UVMappingAlgo uvMapAlgo = UVMappingAlgo.planar;
1281 			float scale = 1.0;
1282 
1283 
1284 			auto res = getopt(args,
1285 				"in-place|i", "Provide this flag to overwrite the provided TRX file", &inPlace,
1286 				"output|o", "Output file or directory. Mandatory if --in-place is not provided.", &targetPath,
1287 				"type|t", "UV mapping algorithm. Default: 'planar'. Possible values are: " ~ EnumMembers!UVMappingAlgo.stringof[6..$-1], &uvMapAlgo,
1288 				"scale", "UV scaling. 1 will reproduce NWN2 scaling, values > 1 will produce bigger textures.", &scale,
1289 				);
1290 			if(res.helpWanted){
1291 				improvedGetoptPrinter(
1292 					"Change terrain texture mapping.\n"
1293 					~"Note: The TRN files are used by the toolset but not by the game. Remap the TRX file to see the changes in-game.\n"
1294 					~"If optimized for server, it will also remove water, terrain textures & mesh.\n"
1295 					~"Usage: "~args[0].baseName~" "~command~" map.trn -o map.trn\n"
1296 					~"       "~args[0].baseName~" "~command~" -i map.trn",
1297 					res.options);
1298 				return 0;
1299 			}
1300 			enforce(args.length > 1, "No input file provided");
1301 
1302 			if(inPlace){
1303 				enforce(targetPath is null, "You cannot use --in-place with --output");
1304 				enforce(args.length >= 2, "No input file");
1305 			}
1306 			else{
1307 				enforce(args.length <=2, "Too many input files provided");
1308 				if(targetPath is null)
1309 					targetPath = ".";
1310 			}
1311 
1312 			foreach(file ; args[1 .. $]){
1313 				auto trn = new Trn(file);
1314 
1315 				foreach(ref TrnNWN2MegatilePayload trrn ; trn) with(trrn) {
1316 					final switch(uvMapAlgo){
1317 						case UVMappingAlgo.planar:
1318 							foreach(ref v ; vertices){
1319 								v.uv[0] = v.position[0] * scale / (-4.0);
1320 								v.uv[1] = v.position[1] * scale / 4.0;
1321 							}
1322 							break;
1323 						case UVMappingAlgo.stretch:
1324 							// Megatile bounds
1325 							auto aabb = box2f(vec2f(vertices[0].position[0 .. 2]), vec2f(vertices[0].position[0 .. 2]));
1326 							vertices.each!((ref v) => aabb = aabb.expand(vec2f(v.position[0 .. 2])));
1327 
1328 							if(vertices.length != 25 * 25 || triangles.length != 24 * 24 * 2){
1329 								stderr.writefln("Stretch algorithm only works on standard megatiles. Skipping megatile located at %s", (aabb.min + aabb.max) / 2);
1330 								continue;
1331 							}
1332 
1333 							// Split vertices into lines/cols
1334 							size_t[][25] verticesLines;
1335 							size_t[][25] verticesColumns;
1336 							foreach(i, ref v ; vertices){
1337 
1338 								auto relPos = vec2f(v.position[0 .. 2]);
1339 								relPos -= aabb.min;
1340 
1341 								auto gridPos = vec2i(cast(int)round(relPos.x * 24f / 40f), cast(int)round(relPos.y * 24f / 40f));
1342 
1343 								verticesLines[gridPos.y] ~= i;
1344 								verticesColumns[gridPos.x] ~= i;
1345 							}
1346 
1347 							// Sort lines/cols by X/Y value
1348 							verticesLines[].each!((ref list) => list = list.sort!((a, b) => vertices[a].position[0] < vertices[b].position[0]).array);
1349 							verticesColumns[].each!((ref list) => list = list.sort!((a, b) => vertices[a].position[1] < vertices[b].position[1]).array);
1350 
1351 
1352 							// Calculate UV X coordinates for each line
1353 							foreach(iline, ref line ; verticesLines){
1354 								vertices[line[0]].uv[0] = 0;
1355 
1356 								float len = 0;
1357 								float midLen = 0;
1358 								foreach(i ; 1 .. line.length){
1359 									len += (vec3f(vertices[line[i]].position) - vec3f(vertices[line[i - 1]].position)).magnitude;
1360 
1361 									if(i == 12){
1362 										midLen = len;
1363 										len = 0;
1364 									}
1365 
1366 									vertices[line[i]].uv[0] = len;
1367 								}
1368 
1369 								line[0 .. 12].each!(vid => vertices[vid].uv[0] /= midLen);
1370 								line[12 .. $].each!(vid => vertices[vid].uv[0] = 1 + vertices[vid].uv[0] / len);
1371 
1372 								//writefln("Line %s: %s", iline, line[].map!(vid => [vertices[vid].position[0]: vertices[vid].uv[0]]));
1373 							}
1374 							// Calculate UV Y coordinates for each column
1375 							foreach(icol, ref col ; verticesColumns){
1376 								vertices[col[0]].uv[1] = 0;
1377 
1378 								float len = 0;
1379 								float midLen = 0;
1380 								foreach(i ; 1 .. col.length){
1381 									len += (vec3f(vertices[col[i]].position) - vec3f(vertices[col[i - 1]].position)).magnitude;
1382 
1383 									if(i == 12){
1384 										midLen = len;
1385 										len = 0;
1386 									}
1387 
1388 									vertices[col[i]].uv[1] = len;
1389 								}
1390 
1391 								col[0 .. 12].each!(vid => vertices[vid].uv[1] /= midLen);
1392 								col[12 .. $].each!(vid => vertices[vid].uv[1] = 1 + vertices[vid].uv[1] / len);
1393 
1394 								//writefln("Col %s: %s", icol, col[].map!(vid => [vertices[vid].position[1]: vertices[vid].uv[1]]));
1395 							}
1396 
1397 							// Fix texture scaling & repeating across adjacent megatiles
1398 							vertices.each!((ref a){ a.uv[0] += aabb.min.x; a.uv[1] += aabb.min.y; a.uv[] *= 2; });
1399 							break;
1400 					}
1401 				}
1402 
1403 				string outPath;
1404 				if(inPlace)
1405 					outPath = file;
1406 				else{
1407 					if(targetPath.exists && targetPath.isDir)
1408 						outPath = buildPath(targetPath, file.baseName);
1409 					else
1410 						outPath = targetPath;
1411 				}
1412 
1413 				std.file.write(outPath, trn.serialize());
1414 			}
1415 
1416 
1417 
1418 		}
1419 		break;
1420 
1421 
1422 		case "watr-export":{
1423 
1424 			string outFolder = ".";
1425 			bool noTextures = false;
1426 			bool exportAll = false;
1427 			auto res = getopt(args,
1428 				"output|o", "Output directory where to write the OBJ, JSON and DDS files. Default: '.'", &outFolder,
1429 				"no-textures", "Do not output texture data (DDS alpha maps & config)", &noTextures,
1430 				"all", "Export all triangles, including those without water", &exportAll,
1431 				);
1432 
1433 			if(res.helpWanted){
1434 				improvedGetoptPrinter(
1435 					"Export water mesh and properties into a wavefront obj, json and dds files.\n"
1436 					~"Note: works on both TRN and TRX files, though TRN files are only used by the toolset.\n"
1437 					~"Usage: "~args[0].baseName~" "~command~" map.trx\n"
1438 					~"       "~args[0].baseName~" "~command~" map.trx -o converted/\n",
1439 					res.options);
1440 				return 0;
1441 			}
1442 			enforce(args.length > 1, "No input file provided");
1443 			enforce(args.length <=2, "Too many input files provided");
1444 
1445 
1446 			auto trnFile = args[1];
1447 			auto trnFileName = trnFile.baseName;
1448 			auto trn = new Trn(trnFile);
1449 
1450 			import nwnlibd.wavefrontobj: WavefrontObj;
1451 			auto wfobj = new WavefrontObj();
1452 			wfobj.mtllibs ~= trnFileName ~ ".watr.mtl";
1453 			string wfmtl = "# This file is not used during WATR importation\n";
1454 			import std.json: JSONValue;
1455 			JSONValue watrConfig;
1456 
1457 			size_t watrIdx;
1458 			foreach(ref TrnNWN2WaterPayload watr ; trn){
1459 				immutable name = format!"water-%d"(watrIdx);
1460 
1461 				// Config
1462 				watrConfig[watrIdx.to!string] = JSONValue([
1463 					"name":                JSONValue(watr.name.ptr.fromStringz),
1464 					"megatile_position":   JSONValue(watr.megatile_position),
1465 					"color":               JSONValue(watr.color),
1466 					"ripple":              JSONValue(watr.ripple),
1467 					"smoothness":          JSONValue(watr.smoothness),
1468 					"reflect_bias":        JSONValue(watr.reflect_bias),
1469 					"reflect_power":       JSONValue(watr.reflect_power),
1470 					"specular_power":      JSONValue(watr.specular_power),
1471 					"specular_cofficient": JSONValue(watr.specular_cofficient),
1472 					"textures":            JSONValue(watr.textures[].map!(a => JSONValue([
1473 							"name":        JSONValue(a.name.ptr.fromStringz),
1474 							"direction":   JSONValue(a.direction),
1475 							"rate":        JSONValue(a.rate),
1476 							"angle":       JSONValue(a.angle),
1477 						])).array),
1478 					"uv_offset":           JSONValue(watr.uv_offset),
1479 				]);
1480 				// TODO: unknown not handled
1481 
1482 				// Vertices & faces
1483 				size_t vi = wfobj.vertices.length + 1;
1484 				size_t vti = wfobj.textCoords.length + 1;
1485 
1486 				foreach(ref v ; watr.vertices){
1487 					wfobj.vertices ~= WavefrontObj.WFVertex(vec3f(v.position));
1488 					wfobj.textCoords ~= vec2f(v.uv);
1489 				}
1490 
1491 				auto grpWater = WavefrontObj.WFGroup();
1492 				auto grpNoWater = WavefrontObj.WFGroup();
1493 				foreach(ti, ref triangle ; watr.triangles){
1494 					if(!exportAll && watr.triangles_flags[ti] == 1)
1495 						continue;// don't export triangles without water
1496 
1497 					WavefrontObj.WFFace face;
1498 					face.vertices = triangle.vertices.to!(size_t[]);
1499 					face.vertices[] += vi;
1500 					face.textCoords = triangle.vertices.to!(size_t[]);
1501 					face.textCoords.get[] += vti;
1502 
1503 					if(watr.triangles_flags[ti] == 0)
1504 						grpWater.faces ~= face;
1505 					else
1506 						grpNoWater.faces ~= face;
1507 				}
1508 				wfobj.objects[name] = WavefrontObj.WFObject([null: grpWater]);
1509 				if(exportAll)
1510 					wfobj.objects[name ~ "-nowater"] = WavefrontObj.WFObject([null: grpNoWater]);
1511 
1512 
1513 				// Alpha bitmap
1514 				immutable ddsName = format!"%s.watr.%d.dds"(trnFileName, watrIdx);
1515 				buildPath(outFolder, ddsName).writeFile(watr.dds);
1516 
1517 				// Material
1518 				wfmtl ~= format!"newmtl %s\n"(name);
1519 				wfmtl ~= format!"map_d %s\n"(ddsName);
1520 				wfmtl ~= "\n";
1521 
1522 				watrIdx++;
1523 			}
1524 
1525 			writeFile(buildPath(outFolder, trnFileName ~ ".watr.obj"), wfobj.serialize());
1526 			writeFile(buildPath(outFolder, trnFileName ~ ".watr.mtl"), wfmtl);
1527 			writeFile(buildPath(outFolder, trnFileName ~ ".watr.json"), watrConfig.toPrettyString());
1528 		}
1529 		break;
1530 
1531 		case "watr-import":{
1532 			string trnFile;
1533 			string outputFile = null;
1534 			bool emptyWatr = false;
1535 			auto res = getopt(args,
1536 				config.required, "trn", "Existing TRN or TRX file to store the water mesh", &trnFile,
1537 				"output|o", "TRN/TRX file to write.\nDefault: the file provided by --trn", &outputFile,
1538 			);
1539 
1540 			if(res.helpWanted){
1541 				improvedGetoptPrinter(
1542 					"Import mater mesh properties into an existing TRN or TRX file\n"
1543 					~"Usage: "~args[0].baseName~" "~command~" map.watr.obj --trn map.trx\n"
1544 					~"\n"
1545 					~"Wavefront format notes:\n"
1546 					~"- Water data is always cleared before importing\n",
1547 					res.options);
1548 				return 0;
1549 			}
1550 			enforce(args.length > 1, "No input file provided");
1551 			enforce(args.length <=2, "Too many input files provided");
1552 
1553 			auto objFilePath = args[1];
1554 			auto objFileDir = objFilePath.dirName;
1555 			auto objFileBaseName = objFilePath.baseName(".watr.obj");
1556 
1557 			if(outputFile is null)
1558 				outputFile = trnFile;
1559 
1560 			auto trn = new Trn(trnFile);
1561 
1562 
1563 			import nwnlibd.wavefrontobj: WavefrontObj;
1564 			auto wfobj = new WavefrontObj(buildPath(objFileDir, objFileBaseName ~ ".watr.obj").readText);
1565 			import std.json;
1566 			auto watrConfig = buildPath(objFileDir, objFileBaseName ~ ".watr.json").readText.parseJSON;
1567 
1568 			// Remove previous packets
1569 			trn.packets = trn.packets
1570 				.filter!(a => a.type != TrnPacketType.NWN2_WATR)
1571 				.array;
1572 
1573 			foreach(oName, ref o ; wfobj.objects){
1574 				if(oName.length < 6 || oName[0 .. 6] != "water-")
1575 					continue;
1576 
1577 				trn.packets ~= TrnPacket(TrnPacketType.NWN2_WATR);
1578 				auto watr = &trn.packets[$ - 1].as!TrnNWN2WaterPayload();
1579 
1580 				size_t id;
1581 				oName.dup.formattedRead!"water-%d"(id);
1582 				auto watrIdx = id.to!string;
1583 
1584 				// Set properties
1585 				watr.name                = watrConfig[watrIdx]["name"].str.stringToCharArray!(char[32]);
1586 				watr.unknown[]           = 0;//TODO: reverse & save unknown block
1587 				watr.megatile_position   = watrConfig[watrIdx]["megatile_position"].array.map!(a => a.toString.to!uint32_t).array[0 .. 2];
1588 				watr.color               = watrConfig[watrIdx]["color"].array.map!(a => a.toString.to!float).array[0 .. 3];
1589 				watr.ripple              = watrConfig[watrIdx]["ripple"].array.map!(a => a.toString.to!float).array[0 .. 2];
1590 				watr.smoothness          = watrConfig[watrIdx]["smoothness"].toString.to!float;
1591 				watr.reflect_bias        = watrConfig[watrIdx]["reflect_bias"].toString.to!float;
1592 				watr.reflect_power       = watrConfig[watrIdx]["reflect_power"].toString.to!float;
1593 				watr.specular_power      = watrConfig[watrIdx]["specular_power"].toString.to!float;
1594 				watr.specular_cofficient = watrConfig[watrIdx]["specular_cofficient"].toString.to!float;
1595 				foreach(i, ref t ; watr.textures){
1596 					t.name      = watrConfig[watrIdx]["textures"][i]["name"].str.stringToCharArray!(char[32]);
1597 					t.direction = watrConfig[watrIdx]["textures"][i]["direction"].array.map!(a => a.toString.to!float).array[0 .. 2];
1598 					t.rate      = watrConfig[watrIdx]["textures"][i]["rate"].toString.to!float;
1599 					t.angle     = watrConfig[watrIdx]["textures"][i]["angle"].toString.to!float;
1600 				}
1601 				watr.uv_offset = watrConfig[watrIdx]["uv_offset"].array.map!(a => a.toString.to!float).array[0 .. 2];
1602 
1603 
1604 				// Vertices & triangles
1605 				watr.vertices.length = 0;
1606 				watr.triangles.length = 0;
1607 				watr.triangles_flags.length = 0;
1608 
1609 				uint16_t[size_t] vtxTransTable;
1610 				auto triangles = o.groups
1611 					.values
1612 					.map!(g => g.faces)
1613 					.join
1614 					.filter!(t => t.vertices.length == 3);// Ignore non triangles
1615 				foreach(ref t ; triangles){
1616 
1617 					foreach(i, v ; t.vertices){
1618 						if(v !in vtxTransTable){
1619 							// Add vertices as needed
1620 							vtxTransTable[v] = watr.vertices.length.to!uint16_t;
1621 
1622 							Vector!(float, 2) uv_1;
1623 							if(!t.textCoords.isNull)
1624 								uv_1 = wfobj.textCoords[t.textCoords.get()[i] - 1];
1625 							else
1626 								uv_1 = wfobj.vertices[v - 1].position.v[0 .. 2];
1627 							auto uv_0 = uv_1 * 5.0;
1628 
1629 							watr.vertices ~= TrnNWN2WaterPayload.Vertex(
1630 								wfobj.vertices[v - 1].position.v[0 .. 3],
1631 								uv_0.v,
1632 								uv_1.v,
1633 							);
1634 						}
1635 					}
1636 
1637 					watr.triangles ~= TrnNWN2WaterPayload.Triangle(
1638 						t.vertices
1639 							.map!(a => vtxTransTable[a])
1640 							.array[0 .. 3]
1641 					);
1642 					watr.triangles_flags ~= 0;
1643 				}
1644 
1645 				// DDS
1646 				watr.dds = cast(ubyte[])buildPath(objFileDir, format!"%s.watr.%d.dds"(objFileBaseName, id)).readFile();
1647 
1648 				// check
1649 				watr.validate();
1650 			}
1651 
1652 			outputFile.writeFile(trn.serialize());
1653 		}
1654 		break;
1655 	}
1656 	return 0;
1657 }
1658 
1659 
1660 
1661 
1662 unittest{
1663 	version(Windows)
1664 		auto nullFile = "nul";
1665 	else
1666 		auto nullFile = "/dev/null";
1667 
1668 	auto stdout_ = stdout;
1669 	auto tmpOut = buildPath(tempDir, "unittest-nwn-lib-d-"~__MODULE__);
1670 	stdout = File(tmpOut, "w");
1671 	scope(success) tmpOut.remove();
1672 	scope(exit) stdout = stdout_;
1673 
1674 
1675 	assertThrown(main(["nwn-trn"]));
1676 	assert(main(["nwn-trn","--help"]) == 0);
1677 	assert(main(["nwn-trn","--version"]) == 0);
1678 
1679 	auto filePath = buildPath(tempDir, "unittest-nwn-lib-d-"~__MODULE__);
1680 
1681 	assert(main(["nwn-trn", "check", "--help"]) == 0);
1682 
1683 	assert(main(["nwn-trn", "bake", "--help"]) == 0);
1684 	assert(main([
1685 			"nwn-trn", "bake",
1686 			"--terrain2da=../../unittest/terrainmaterials.2da",
1687 			"../../unittest/WalkmeshObjects",
1688 			"-o", nullFile,
1689 		]) == 0);
1690 
1691 	assert(main(["nwn-trn", "aswm-check", "--help"]) == 0);
1692 	assert(main([
1693 			"nwn-trn", "aswm-check",
1694 			"../../unittest/WalkmeshObjects.trn", "../../unittest/WalkmeshObjects.trx", "../../unittest/TestImportExportTRN.trx",
1695 		]) == 0);
1696 
1697 	assert(main(["nwn-trn", "aswm-strip", "--help"]) == 0);
1698 	assert(main([
1699 			"nwn-trn", "aswm-strip",
1700 			"../../unittest/TestImportExportTRN.trx",
1701 			"-o", filePath,
1702 		]) == 0);
1703 	auto trn = new Trn(filePath);
1704 	foreach(ref TrnNWN2WalkmeshPayload aswm ; trn){
1705 		assert(aswm.vertices.length == 2141);
1706 		assert(aswm.edges.length == 5864);
1707 		assert(aswm.triangles.length == 3703);
1708 	}
1709 
1710 	assert(main(["nwn-trn", "aswm-export-fancy", "--help"]) == 0);
1711 	assert(main([
1712 			"nwn-trn", "aswm-export-fancy",
1713 			"-f", "walkmesh",
1714 			"-f", "edges",
1715 			"-f", "tiles",
1716 			"-f", "pathtables-los",
1717 			"-f", "randomtilepaths",
1718 			"-f", "randomislandspaths",
1719 			"-f", "islands",
1720 			"../../unittest/TestImportExportTRN.trx",
1721 			"-o", tempDir,
1722 		]) == 0);
1723 
1724 
1725 	// Import/export functions
1726 
1727 	// ASWM
1728 	assert(main(["nwn-trn", "aswm-export", "--help"]) == 0);
1729 	assert(main([
1730 			"nwn-trn", "aswm-export",
1731 			"../../unittest/TestImportExportTRN.trn",
1732 			"-o", filePath,
1733 		]) == 0);
1734 
1735 	assert(main(["nwn-trn", "aswm-import", "--help"]) == 0);
1736 	assert(main([
1737 			"nwn-trn", "aswm-import",
1738 			"--obj", filePath,
1739 			"--trn", "../../unittest/TestImportExportTRN.trx",
1740 			"--terrain2da=../../unittest/terrainmaterials.2da",
1741 			"-o", buildPath(tempDir, "TestImportExportTRN.new.trx"),
1742 		]) == 0);
1743 
1744 	assert(main(["nwn-trn", "check", buildPath(tempDir, "TestImportExportTRN.new.trx")]) == 0);
1745 
1746 	// TRRN
1747 	assert(main(["nwn-trn", "trrn-export", "--help"]) == 0);
1748 	assert(main([
1749 			"nwn-trn", "trrn-export",
1750 			"../../unittest/TestImportExportTRN.trx",
1751 			"-o", tempDir,
1752 		]) == 0);
1753 
1754 	assert(main(["nwn-trn", "trrn-import", "--help"]) == 0);
1755 	assert(main([
1756 			"nwn-trn", "trrn-import",
1757 			buildPath(tempDir, "TestImportExportTRN.trx.trrn.obj"),
1758 			"--trn", "../../unittest/TestImportExportTRN.trx",
1759 			"-o", buildPath(tempDir, "TestImportExportTRN.new.trx"),
1760 		]) == 0);
1761 
1762 	assert(main(["nwn-trn", "check", buildPath(tempDir, "TestImportExportTRN.new.trx")]) == 0);
1763 
1764 	// WATR
1765 	assert(main(["nwn-trn", "watr-export", "--help"]) == 0);
1766 	assert(main([
1767 			"nwn-trn", "watr-export",
1768 			"../../unittest/TestImportExportTRN.trx",
1769 			"-o", tempDir,
1770 		]) == 0);
1771 
1772 	assert(main(["nwn-trn", "watr-import", "--help"]) == 0);
1773 	assert(main([
1774 			"nwn-trn", "watr-import",
1775 			buildPath(tempDir, "TestImportExportTRN.trx.watr.obj"),
1776 			"--trn", "../../unittest/TestImportExportTRN.trx",
1777 			"-o", buildPath(tempDir, "TestImportExportTRN.new.trx"),
1778 		]) == 0);
1779 
1780 	assert(main(["nwn-trn", "check", buildPath(tempDir, "TestImportExportTRN.new.trx")]) == 0);
1781 
1782 
1783 	// Advanced commands
1784 	assert(main(["nwn-trn", "aswm-dump", "../../unittest/WalkmeshObjects.trx"]) == 0);
1785 
1786 	assert(main(["nwn-trn", "aswm-bake", "--help"]) == 0);
1787 	assert(main([
1788 			"nwn-trn", "aswm-bake",
1789 			"../../unittest/WalkmeshObjects.trx",
1790 			"-o", nullFile,
1791 		]) == 0);
1792 
1793 
1794 	stdout = stdout_;
1795 }