1 /// Two Dimentional Array (2da) 2 module nwn.twoda; 3 4 import std.string; 5 import std.conv: to, ConvException; 6 import std.typecons: Nullable; 7 import std.exception: enforce; 8 import std.algorithm; 9 import std.uni; 10 debug import std.stdio: writeln; 11 version(unittest) import std.exception: assertThrown, assertNotThrown; 12 13 14 /// 15 class TwoDAParseException : Exception{ 16 @safe pure nothrow this(string msg, string fileName, size_t fileLine, string f=__FILE__, size_t l=__LINE__, Throwable t=null){ 17 super((fileName !is null? fileName : "twoda")~"("~fileLine.to!string~")"~msg, f, l, t); 18 } 19 } 20 /// 21 class TwoDAValueException : Exception{ 22 @safe pure nothrow this(string msg, string f=__FILE__, size_t l=__LINE__, Throwable t=null){ 23 super(msg, f, l, t); 24 } 25 } 26 /// 27 class TwoDAColumnNotFoundException : Exception{ 28 @safe pure nothrow this(string msg, string f=__FILE__, size_t l=__LINE__, Throwable t=null){ 29 super(msg, f, l, t); 30 } 31 } 32 /// 33 class TwoDAOutOfBoundsException : 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 /// 2da file 40 class TwoDA{ 41 42 /// Read a 2da file 43 this(string filepath){ 44 import std.file: readFile=read; 45 import std.path: baseName; 46 this(cast(ubyte[])filepath.readFile, filepath.baseName); 47 } 48 49 /// Parse raw data 50 this(in ubyte[] rawData, in string name=null){ 51 fileName = name; 52 53 enum State{ 54 header, defaults, columns, data 55 } 56 auto state = State.header; 57 58 foreach(lineIndex, line ; (cast(string)rawData).splitLines){ 59 if(state != State.header && line.all!isWhite) 60 continue;// Skip empty lines 61 62 final switch(state){ 63 case State.header: 64 //Header 65 enforce!TwoDAParseException(line.length >= 8, "First line is too short"); 66 fileType = line[0..4].stripRight; 67 fileVersion = line[4..8].stripRight; 68 state = State.defaults; 69 break; 70 71 case State.defaults: 72 if(line.length >= 8 && line[0 .. 8].toUpper == "DEFAULT:"){ 73 //TODO: handle default definition? 74 // line is: "DEFAULT: somevalue" 75 // somevalue is returned if the row does not exist 76 break; 77 } 78 79 state = State.columns; 80 goto case;//fallthrough 81 82 case State.columns: 83 //Column name definition 84 columnList = extractRowData(line); 85 foreach(index, colName ; columnList){ 86 columnLookup[colName.toLower] = index; 87 } 88 columnLookup.rehash(); 89 state = State.data; 90 break; 91 92 case State.data: 93 //Data 94 auto data = extractRowData(line); 95 if(data.length < columnList.length + 1){ 96 auto oldLength = data.length; 97 data.length = columnList.length + 1; 98 data[oldLength .. $] = null; 99 } 100 101 valueList ~= data[1 .. 1 + columnList.length]; 102 break; 103 } 104 } 105 } 106 107 private this(){} 108 109 /// Recover a damaged 2DA file 110 static auto recover(string filepath){ 111 import std.file: readFile=read; 112 import std.path: baseName; 113 return recover(cast(ubyte[])filepath.readFile, filepath.baseName); 114 } 115 /// ditto 116 static auto recover(in ubyte[] rawData, in string name=null){ 117 118 static struct Ret { 119 TwoDA twoDA; 120 static struct Error { 121 string type; 122 size_t line; 123 string msg; 124 } 125 Error[] errors; 126 } 127 auto ret = Ret(new TwoDA); 128 with(ret.twoDA){ 129 fileName = name; 130 131 string[] columns; 132 133 enum State{ 134 header, defaults, columns, data 135 } 136 auto state = State.header; 137 138 size_t currentIndex = 0; 139 size_t prevLineIndex = size_t.max; 140 foreach(iLine, line ; (cast(string)rawData).splitLines){ 141 if(state != State.header && line.all!isWhite) 142 continue;// Skip empty lines 143 144 final switch(state){ 145 case State.header: 146 //Header 147 if(line.length >= 8){ 148 fileType = line[0..4].stripRight; 149 fileVersion = line[4..8].stripRight; 150 } 151 else{ 152 ret.errors ~= Ret.Error( 153 "Error", iLine + 1, 154 "Bad first line: Should be 8 characters with file type and file version (e.g. '2DA V2.0')" 155 ); 156 fileType = "2DA "; 157 fileVersion = "V2.0"; 158 } 159 state = State.defaults; 160 break; 161 162 case State.defaults: 163 if(line.length >= 8 && line[0 .. 8].toUpper == "DEFAULT:"){ 164 //TODO: handle default definition? 165 // line is: "DEFAULT: somevalue" 166 // somevalue must be returned if the row does not exist 167 // However it doesn't appear to be used in NWN2 168 break; 169 } 170 171 state = State.columns; 172 goto case;//fallthrough 173 174 case State.columns: 175 //Column name definition 176 columnList = extractRowData(line); 177 foreach(index, colName ; columnList){ 178 columnLookup[colName.toLower] = index; 179 } 180 columnLookup.rehash(); 181 if(columnList.length == 0){ 182 ret.errors ~= Ret.Error( 183 "Error", iLine + 1, 184 "No columns" 185 ); 186 return ret; 187 } 188 state = State.data; 189 break; 190 191 case State.data: 192 //Data 193 auto data = extractRowData(line); 194 195 if(data.length == 0){ 196 ret.errors ~= Ret.Error( 197 "Notice", iLine + 1, 198 "Empty line" 199 ); 200 continue; 201 } 202 203 size_t writtenIndex; 204 try writtenIndex = data[0].to!size_t; 205 catch(ConvException e){ 206 ret.errors ~= Ret.Error( 207 "Error", iLine + 1, 208 format!"Invalid line index: '%s' is not a positive integer"(data[0]) 209 ); 210 } 211 212 if(writtenIndex != currentIndex){ 213 ret.errors ~= Ret.Error( 214 "Warning", iLine + 1, 215 prevLineIndex != size_t.max ? 216 format!"Line index mismatch: Written line index is %d, while previous index was %d. If kept as is, the line effective index will be %d."(writtenIndex, currentIndex - 1, rows) 217 : format!"Line index mismatch: First written line index is %d instead of 0. If kept as is, the line effective index will be %d."(writtenIndex, rows) 218 ); 219 currentIndex = writtenIndex; 220 } 221 prevLineIndex = currentIndex; 222 currentIndex++; 223 224 if(data.length != columnList.length + 1){ 225 ret.errors ~= Ret.Error( 226 "Error", iLine + 1, 227 format!"Bad number of columns: Line has %d columns instead of %d"(data.length, columnList.length + 1) 228 ); 229 } 230 231 foreach(i, field ; data){ 232 if(field.length > 0 && field != "****" && field.all!"a == '*'"){ 233 ret.errors ~= Ret.Error( 234 "Notice", iLine + 1, 235 i < columns.length ? 236 format!"Bad null field: Column '%s' has %d stars instead of 4"(columns[i], field.length) 237 : format!"Bad null field: Column number %d has %d stars instead of 4"(i, field.length) 238 ); 239 } 240 } 241 242 if(data.length < columnList.length + 1){ 243 auto oldLength = data.length; 244 data.length = columnList.length + 1; 245 data[oldLength .. $] = null; 246 } 247 248 valueList ~= data[1 .. 1 + columnList.length]; 249 break; 250 } 251 } 252 253 } 254 return ret; 255 } 256 257 /// Get a value in the 2da, converted to T. 258 /// Returns: if T is string returns the string value, else returns a `Nullable!T` that is null if the value is empty 259 /// Throws: `std.conv.ConvException` if the conversion into T fails 260 auto ref get(T = string)(in size_t colIndex, in size_t line) const { 261 assert(line < rows, "Line is out of bounds"); 262 assert(colIndex < columnList.length, "Column is out of bounds"); 263 264 static if(is(T == string)){ 265 return this[colIndex, line]; 266 } 267 else { 268 if(this[colIndex, line] is null){ 269 return Nullable!T(); 270 } 271 try return Nullable!T(this[colIndex, line].to!T); 272 catch(ConvException e){ 273 //Annotate conv exception 274 string colName; 275 276 if(colIndex < columnList.length) 277 colName = columnList[colIndex]; 278 279 e.msg ~= " ("~fileName~": column: "~(colName !is null ? colName : colIndex.to!string)~", line: "~line.to!string~")"; 280 throw e; 281 } 282 } 283 } 284 285 /// Get a value in the 2da, converted to T. 286 /// Returns: value if found, otherwise defaultValue 287 T get(T = string)(in string colName, in size_t line, T defaultValue) const { 288 if(line >= rows) 289 return defaultValue; 290 291 if(auto colIndex = colName.toLower in columnLookup){ 292 if(this[*colIndex, line] !is null){ 293 try return this[*colIndex, line].to!T; 294 catch(ConvException){} 295 } 296 } 297 return defaultValue; 298 } 299 300 /// ditto 301 /// Throws: `TwoDAColumnNotFoundException` if the column does not exist 302 auto ref get(T = string)(in string colName, in size_t line) const { 303 return get!T(columnIndex(colName), line); 304 } 305 306 307 /// Get the index of a column by its name, for faster access 308 size_t columnIndex(in string colName) const { 309 if(auto colIndex = colName.toLower in columnLookup){ 310 return *colIndex; 311 } 312 throw new TwoDAColumnNotFoundException("Column '"~colName~"' not found"); 313 } 314 315 /// Check if a column exists in the 2da, and returns a pointer to its index 316 const(size_t*) opBinaryRight(string op: "in")(in string colName) const { 317 return colName.toLower in columnLookup; 318 } 319 320 /// Get a specific cell value 321 /// Note: column 0 is the first named column (not the index column) 322 ref inout(string) opIndex(size_t column, size_t row) inout nothrow { 323 assert(column < columns, "column out of bounds"); 324 assert(row < rows, "row out of bounds"); 325 return valueList[row * columnList.length + column]; 326 } 327 /// ditto 328 ref inout(string) opIndex(string column, size_t row) inout { 329 assert(column.toLower in columnLookup, "Column not found"); 330 return this[columnLookup[column.toLower], row]; 331 } 332 333 // Get row 334 const(string[]) opIndex(size_t i) const { 335 return valueList[i * columnList.length .. (i + 1) * columnList.length]; 336 } 337 // Set row 338 void opIndexAssign(in string[] value, size_t i){ 339 assert(value.length == columnList.length, format!"value has %d columns instead of %d"(value.length, columnList.length)); 340 valueList[i * columnList.length .. (i + 1) * columnList.length] = value; 341 } 342 343 344 @property{ 345 /// File type (should always be "2DA") 346 /// Max width: 4 chars 347 string fileType()const{return m_fileType;} 348 /// ditto 349 void fileType(string value){ 350 if(value.length>4) 351 throw new TwoDAValueException("fileType cannot be longer than 4 characters"); 352 m_fileType = value; 353 } 354 } 355 private string m_fileType; 356 @property{ 357 /// File version (should always be "V2.0") 358 /// Max width: 4 chars 359 string fileVersion()const{return m_fileVersion;} 360 /// ditto 361 void fileVersion(string value){ 362 if(value.length>4) 363 throw new TwoDAValueException("fileVersion cannot be longer than 4 characters"); 364 m_fileVersion = value; 365 } 366 } 367 private string m_fileVersion; 368 369 370 371 @property{ 372 /// Number of rows in the 2da 373 size_t rows() const nothrow { 374 if(columnList.length == 0) 375 return 0; 376 return valueList.length / columnList.length; 377 } 378 /// Resize the 2da table 379 void rows(size_t rowsCount) nothrow { 380 valueList.length = columnList.length * rowsCount; 381 } 382 383 /// Number of named columns in the 2da (i.e. without the index column) 384 size_t columns() const nothrow { 385 return columnList.length; 386 } 387 } 388 389 /// Outputs 2da text content 390 ubyte[] serialize() const { 391 import std.algorithm: map, sort; 392 import std.array: array; 393 import std.string: leftJustify; 394 char[] ret; 395 396 //Header 397 ret ~=" \n"; 398 ret[0..fileType.length] = fileType; 399 ret[4..4+fileVersion.length] = fileVersion; 400 401 //Default 402 ret ~= "\n"; 403 404 //column width calculation 405 import std.math: log10, floor; 406 size_t[] columnsWidth = 407 (cast(int)log10(rows)+2) 408 ~(columnList 409 .map!(a => (a.length < 4 ? 4 : a.length) + 1) 410 .array); 411 412 foreach(row ; 0 .. rows){ 413 foreach(col ; 0 .. columnList.length){ 414 auto value = this[col, row]; 415 if(value.length + 1 > columnsWidth[col + 1]) 416 columnsWidth[col + 1] = value.length + 1; 417 } 418 } 419 420 //Column names 421 ret ~= "".leftJustify(columnsWidth[0]); 422 foreach(i, ref colName ; columnList){ 423 ret ~= colName.leftJustify(columnsWidth[i + 1]); 424 } 425 ret ~= "\n"; 426 427 //Data 428 foreach(rowIndex ; 0 .. rows){ 429 ret ~= rowIndex.to!string.leftJustify(columnsWidth[0]); 430 foreach(colIndex ; 0 .. columnList.length){ 431 auto value = this[colIndex, rowIndex]; 432 string serializedValue; 433 if(value is null || value.length == 0) 434 serializedValue = "****"; 435 else{ 436 if(value.indexOf('"') >= 0) 437 throw new TwoDAValueException("A 2da field cannot contain double quotes"); 438 439 if(value.indexOf(' ') >= 0) 440 serializedValue = '"'~value~'"'; 441 else 442 serializedValue = value; 443 } 444 445 if(colIndex == columnList.length - 1) 446 ret ~= serializedValue; 447 else 448 ret ~= serializedValue.leftJustify(columnsWidth[colIndex + 1]); 449 450 451 } 452 ret ~= "\n"; 453 } 454 455 return cast(ubyte[])ret; 456 } 457 458 /// Parse a 2DA row 459 static string[] extractRowData(in string line){ 460 string[] ret; 461 462 enum State{ 463 Whitespace, 464 Field, 465 QuotedField, 466 } 467 string fieldBuf; 468 auto state = State.Whitespace; 469 foreach(c ; line~" "){ 470 final switch(state){ 471 case State.Whitespace: 472 if(c.isWhite) 473 continue; 474 else{ 475 fieldBuf = ""; 476 if(c=='"') 477 state = State.QuotedField; 478 else{ 479 fieldBuf ~= c; 480 state = State.Field; 481 } 482 } 483 break; 484 485 case State.Field: 486 if(c.isWhite){ 487 if(fieldBuf.length > 0 && fieldBuf.all!"a == '*'") 488 ret ~= null; 489 else 490 ret ~= fieldBuf; 491 state = State.Whitespace; 492 } 493 else 494 fieldBuf ~= c; 495 break; 496 497 case State.QuotedField: 498 if(c=='"'){ 499 ret ~= fieldBuf; 500 state = State.Whitespace; 501 } 502 else 503 fieldBuf ~= c; 504 break; 505 } 506 } 507 return ret; 508 } 509 510 /// Optional 2DA file name set during construction 511 string fileName = null; 512 private: 513 string[] columnList; 514 size_t[string] columnLookup; 515 string[] valueList; 516 } 517 unittest{ 518 immutable polymorphTwoDA = cast(immutable ubyte[])import("polymorph.2da"); 519 auto twoda = new TwoDA(polymorphTwoDA); 520 521 assert(twoda.fileType == "2DA"); 522 assertThrown!TwoDAValueException(twoda.fileType = "12345"); 523 assert(twoda.fileVersion == "V2.0"); 524 assertThrown!TwoDAValueException(twoda.fileVersion = "12345"); 525 526 assert(twoda["Name", 0] == "POLYMORPH_TYPE_WEREWOLF"); 527 assert(twoda.get("Name", 0) == "POLYMORPH_TYPE_WEREWOLF"); 528 assert(twoda.get("name", 0) == "POLYMORPH_TYPE_WEREWOLF"); 529 530 assert(twoda.get!int("RacialType", 0) == 23); 531 assert(twoda.get("EQUIPPED", 0) == null); 532 assert(twoda.get!int("MergeA", 13) == 1); 533 assert(twoda.get("Name", 20) == "MULTI WORD VALUE"); 534 535 assert(twoda.get("Name", 1) == "POLYMORPH_TYPE_WERERAT"); 536 assert(twoda.get("Name", 8) == "POLYMORPH_TYPE_FIRE_GIANT");//deleted line 537 assert(twoda.get("Name", 10) == "POLYMORPH_TYPE_ELDER_FIRE_ELEMENTAL");//misordered line 538 assert(twoda.get("Name", 25) == null);//empty value 539 assert(twoda.get("Name", 206) == "POLYMORPH_TYPE_LESS_EMBER_GUARD");//last line 540 541 assertThrown!TwoDAColumnNotFoundException(twoda.get("Yolooo", 1)); 542 assertThrown!Error(twoda.get("Name", 207)); 543 544 twoda = new TwoDA(polymorphTwoDA); 545 auto twodaSerialized = twoda.serialize(); 546 auto twodaReparsed = new TwoDA(twodaSerialized); 547 548 assert(twoda.columnList == twodaReparsed.columnList); 549 assert(twoda.valueList == twodaReparsed.valueList); 550 551 552 twoda = new TwoDA(cast(immutable ubyte[])import("terrainmaterials.2da")); 553 assert(twoda["Material", 52] == "Stone"); 554 assert(twoda.get("STR_REF", 56, 42) == 42); 555 556 twoda = new TwoDA(cast(immutable ubyte[])import("exptable.2da")); 557 assert(twoda.get!ulong("XP", 0).get == 0); 558 assert(twoda.get!ulong("XP", 1).get == 1000); 559 assert(twoda.get!ulong("XP", 10).get == 55000); 560 assert(twoda.get!ulong("XP", 100, 42) == 42); 561 }