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