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 }