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 }