1 /// ERF file format (erf, hak, mod, ...) 2 module nwn.erf; 3 4 import std.stdio: File; 5 import std.stdint; 6 import std.string; 7 import std.conv: to; 8 import std.datetime; 9 import std.typecons: Nullable; 10 import std.exception: enforce; 11 import nwnlibd.parseutils; 12 import nwn.constants; 13 public import nwn.constants: NwnVersion, ResourceType, Language; 14 15 debug import std.stdio: writeln; 16 version(unittest) import std.exception: assertThrown, assertNotThrown; 17 18 /// Parsing exception 19 class ErfParseException : Exception{ 20 @safe pure nothrow this(string msg, string f=__FILE__, size_t l=__LINE__, Throwable t=null){ 21 super(msg, f, l, t); 22 } 23 } 24 /// Value doesn't match constraints (ex: label too long) 25 class ErfValueSetException : Exception{ 26 @safe pure nothrow this(string msg, string f=__FILE__, size_t l=__LINE__, Throwable t=null){ 27 super(msg, f, l, t); 28 } 29 } 30 31 /// 32 alias NWN1ErfFile = ErfFile!(NwnVersion.NWN1); 33 /// 34 alias NWN2ErfFile = ErfFile!(NwnVersion.NWN2); 35 36 /// File stored in Erf class 37 struct ErfFile(NwnVersion NV){ 38 /// Construct a Erf file 39 this(in string name, ResourceType type, ubyte[] data){ 40 this.name = name; 41 this.type = type; 42 this.data = data; 43 } 44 45 this(string filePath){ 46 import std.file: read; 47 import std.path: baseName, stripExtension, extension; 48 49 auto ext = ResourceType.invalid; 50 immutable extStr = filePath.extension; 51 if(extStr.length>1) 52 ext = extStr[1..$].fileExtensionToResourceType; 53 54 this( 55 filePath.stripExtension.baseName.toLower, 56 ext, 57 cast(ubyte[])filePath.read); 58 } 59 unittest{ 60 import std.file: tempDir, writeFile=write; 61 62 auto filePath = tempDir~"/unittest-nwn-lib-d-"~__MODULE__~".test.txt"; 63 filePath.writeFile("YOLO"); 64 65 auto erfFile = NWN2ErfFile(filePath); 66 assert(erfFile.name == "unittest-nwn-lib-d-nwn.erf.test"); 67 assert(erfFile.type == ResourceType.txt); 68 assert(erfFile.data == "YOLO"); 69 } 70 71 72 @property{ 73 /// File name (without file extension) 74 string name()const{return m_name;} 75 /// ditto 76 void name(string value){ 77 if(value.length>(NV==NwnVersion.NWN1? 16 : 32)) 78 throw new ErfValueSetException( 79 "file name cannot be longer than "~(NV==NwnVersion.NWN1? 16 : 32).to!string~" characters"); 80 m_name = value; 81 } 82 } 83 private string m_name; 84 85 /// File type (related with its extension) 86 ResourceType type; 87 88 /// File raw data 89 ubyte[] data; 90 91 /// Expected file size. If not null, can be used to detect truncated files 92 Nullable!size_t expectedLength; 93 } 94 95 /// 96 alias NWN1Erf = Erf!(NwnVersion.NWN1); 97 /// 98 alias NWN2Erf = Erf!(NwnVersion.NWN2); 99 100 /// ERF file parsing (.erf, .hak, .mod files) 101 class Erf(NwnVersion NV){ 102 103 /// 104 this(){} 105 106 /// Parse raw binary data 107 /// Params: 108 /// data = ERF File raw data 109 /// recover = Set to true to skip incomplete files and finish parsing a truncated ERF 110 this(in ubyte[] data, bool recover = false){ 111 enforce!ErfParseException(data.length >= ErfHeader.sizeof, "ERF file is shorter than its header size. Cannot read"); 112 113 const header = cast(ErfHeader*)data.ptr; 114 fileType = header.file_type.idup.stripRight; 115 fileVersion = header.file_version.idup.stripRight; 116 auto date = Date(header.build_year+1900, 1, 1); 117 date.dayOfYear = header.build_day + 1; 118 buildDate = date; 119 120 enforce!ErfParseException(data.length > (header.localizedstrings_offset + header.localizedstrings_size), 121 "Cannot read localized strings"); 122 123 auto locStrings = ChunkReader( 124 data[header.localizedstrings_offset 125 .. header.localizedstrings_offset + header.localizedstrings_size]); 126 127 foreach(i ; 0..header.localizedstrings_count){ 128 immutable langage = cast(Language)locStrings.read!uint32_t; 129 immutable length = locStrings.read!uint32_t; 130 131 description[langage] = locStrings.readArray!char(length).idup; 132 } 133 134 const keys = cast(ErfKey*)(data.ptr + header.keys_offset); 135 size_t keysLength = header.keys_count; 136 const resources = cast(ErfResource*)(data.ptr + header.resources_offset); 137 138 enforce!ErfParseException(data.length > header.keys_offset, "Cannot read keys table"); 139 if(recover){ 140 if(header.keys_offset + ErfKey.sizeof * keysLength >= data.length){ 141 // Set lower keysLength 142 const availableSize = data.length - header.keys_offset; 143 keysLength = availableSize / ErfKey.sizeof; 144 } 145 } 146 147 // Read key table and allocate files 148 files.length = keysLength; 149 foreach(i, ref key ; keys[0 .. keysLength]){ 150 files[i].name = key.file_name.charArrayToString; 151 files[i].type = cast(ResourceType)key.resource_type; 152 153 if(recover && header.resources_offset + ErfResource.sizeof * key.resource_id >= data.length){ 154 continue; 155 } 156 const res = &resources[key.resource_id]; 157 size_t startOffset = res.resource_offset; 158 size_t endOffset = res.resource_offset + res.resource_size; 159 files[i].expectedLength = res.resource_size; 160 161 if(recover){ 162 if(startOffset > data.length){ 163 // Null-sized file 164 startOffset = 0; 165 endOffset = 0; 166 } 167 else{ 168 // Truncate file 169 endOffset = endOffset > data.length? data.length : endOffset; 170 } 171 } 172 173 files[i].data = data[startOffset .. endOffset].dup; 174 175 } 176 177 } 178 179 /// Localized module description 180 string[Language] description; 181 182 /// Files contained in the erf file 183 ErfFile!NV[] files; 184 185 186 @property{ 187 /// File type (ERF, HAK, MOD, ...) 188 /// Max width: 4 chars 189 string fileType()const{return m_fileType;} 190 /// ditto 191 void fileType(string value){ 192 if(value.length>4) 193 throw new ErfValueSetException("fileType cannot be longer than 4 characters"); 194 m_fileType = value; 195 } 196 } 197 private string m_fileType; 198 199 @property{ 200 /// File version ("V1.0" for NWN1, "V1.1" for NWN2) 201 /// Max width: 4 chars 202 string fileVersion()const{return m_fileVersion;} 203 /// ditto 204 void fileVersion(string value){ 205 if(value.length>4) 206 throw new ErfValueSetException("fileVersion cannot be longer than 4 characters"); 207 m_fileVersion = value; 208 } 209 } 210 private string m_fileVersion; 211 212 @property{ 213 /// Date when the erf file has been created 214 Date buildDate()const{return m_buildDate;} 215 /// ditto 216 void buildDate(Date value){ 217 if(value.year<1900) 218 throw new ErfValueSetException("buildDate year must be >= 1900"); 219 m_buildDate = value; 220 } 221 } 222 private Date m_buildDate = Date(1900, 1, 1); 223 224 225 /// Serialize erf data except file content 226 ubyte[] serializeHead(){ 227 ubyte[] ret; 228 ret.length = ErfHeader.sizeof; 229 230 with(cast(ErfHeader*)ret.ptr){ 231 file_type = " "; 232 file_type[0..fileType.length] = fileType; 233 file_version = " "; 234 file_version[0..fileVersion.length] = fileVersion; 235 236 localizedstrings_count = cast(uint32_t)description.length; 237 keys_count = cast(uint32_t)files.length; 238 239 build_year = m_buildDate.year - 1900; 240 build_day = m_buildDate.dayOfYear - 1; 241 242 file_description_strref = 0;//TODO: seems to be always 0 243 //reserved: keep null values 244 245 localizedstrings_offset = ErfHeader.sizeof; 246 } 247 248 size_t locstringLength = 0; 249 250 import std.algorithm: sort; 251 import std.array: array; 252 foreach(ref kv ; description.byKeyValue.array.sort!((a,b)=>a.key<b.key)){ 253 immutable langID = cast(uint32_t)kv.key; 254 immutable length = cast(uint32_t)kv.value.length; 255 256 locstringLength += 4+4+length; 257 258 ret ~= cast(ubyte[])(&langID)[0..1].dup; 259 ret ~= cast(ubyte[])(&length)[0..1].dup; 260 ret ~= kv.value.dup; 261 } 262 263 immutable keysOffset = ret.length; 264 immutable resourcesOffset = keysOffset + files.length*ErfKey.sizeof; 265 266 with(cast(ErfHeader*)ret.ptr){ 267 localizedstrings_size = cast(uint32_t)locstringLength; 268 keys_offset = cast(uint32_t)keysOffset; 269 resources_offset = cast(uint32_t)resourcesOffset; 270 } 271 272 ret.length += files.length*(ErfKey.sizeof + ErfResource.sizeof); 273 274 275 size_t fileDataOffset = ret.length; 276 277 foreach(index, ref file ; files){ 278 with((cast(ErfKey*)(ret.ptr+keysOffset))[index]){ 279 file_name[0..file.name.length] = file.name.dup[0..$]; 280 resource_id = cast(uint32_t)index; 281 resource_type = file.type; 282 reserved = 0; 283 } 284 285 with((cast(ErfResource*)(ret.ptr+resourcesOffset))[index]){ 286 resource_offset = cast(uint32_t)fileDataOffset; 287 resource_size = cast(uint32_t)file.data.length; 288 289 fileDataOffset += resource_size; 290 } 291 } 292 293 return ret; 294 } 295 296 /// Serializes the erf data. 297 /// Warning: can allocate a huge chunk of RAM 298 ubyte[] serialize(){ 299 ubyte[] ret = serializeHead(); 300 301 foreach(ref f ; files){ 302 ret ~= f.data; 303 } 304 return ret; 305 } 306 307 /// Efficient writing to file, with less RAM consumption 308 void writeToFile(File file){ 309 auto wr = file.lockingBinaryWriter; 310 wr.rawWrite(serializeHead()); 311 312 foreach(ref f ; files){ 313 wr.rawWrite(f.data); 314 } 315 } 316 317 318 private: 319 align(1) static struct ErfHeader{ 320 char[4] file_type; 321 char[4] file_version; 322 323 uint32_t localizedstrings_count; 324 uint32_t localizedstrings_size; 325 uint32_t keys_count; 326 327 uint32_t localizedstrings_offset; 328 uint32_t keys_offset; 329 uint32_t resources_offset; 330 331 uint32_t build_year; 332 uint32_t build_day; 333 uint32_t file_description_strref; 334 ubyte[116] reserved; 335 } 336 align(1) static struct ErfKey{ 337 char[NV == NwnVersion.NWN1? 16 : 32] file_name; 338 uint32_t resource_id; 339 uint16_t resource_type; 340 uint16_t reserved; 341 } 342 align(1) static struct ErfResource{ 343 uint32_t resource_offset; 344 uint32_t resource_size; 345 } 346 347 } 348 349 unittest{ 350 auto hak = new NWN2Erf(cast(ubyte[])import("test.hak")); 351 352 assert(hak.files[0].name == "eye"); 353 assert(hak.files[0].type == ResourceType.tga); 354 assert(hak.files[1].name == "test"); 355 assert(hak.files[1].type == ResourceType.txt); 356 assert(cast(string)hak.files[1].data == "Hello world\n"); 357 358 auto modData = cast(ubyte[])import("module.mod"); 359 auto mod = new NWN2Erf(modData); 360 assert(mod.description[Language.English]=="module description"); 361 assert(mod.buildDate == Date(2016, 6, 9)); 362 363 assert(mod.serialize() == modData); 364 }