1 /// Read and write Database Packed File (DBPF) archives. 2 /// 3 /// See_Also: $(UL 4 /// $(LI <a href="https://www.wiki.sc4devotion.com/index.php?title=DBPF">DBPF</a> (SC4D Encyclopedia)) 5 /// $(LI <a href="https://www.wiki.sc4devotion.com/index.php?title=List_of_File_Formats">List of File Formats</a> (SC4D Encyclopedia)) 6 /// ) 7 /// 8 /// Authors: Chance Snow 9 /// Copyright: Copyright © 2024 Chance Snow. All rights reserved. 10 /// License: MIT License 11 module dbpf; 12 13 static import std.stdio; 14 import std.traits : isFloatingPoint; 15 16 /// Determines whether `V` is a valid DBPF version number. 17 /// See_Also: `Version` 18 /// Remarks: $(UL 19 /// $(LI `1.0` seen in SimCity 4, The Sims 2) 20 /// $(LI `1.1` seen in The Sims 2) 21 /// $(LI `2.0` seen in Spore, The Sims 3) 22 /// $(LI `3.0` seen in SimCity (2013)) 23 /// ) 24 enum isValidDbpfVersion(float V) = isFloatingPoint!(typeof(V)) && (V == 1 || V == 1.1 || V == 2 || V == 3); 25 26 /// Params: 27 /// V: DBPF archive version. See `Version`. 28 /// See_Also: <a href="https://www.wiki.sc4devotion.com/index.php?title=DBPF#Header">DBPF Header</a> (SC4D Encyclopedia) 29 struct Header(float V = 1) if (isValidDbpfVersion!V) { 30 import std.string : representation; 31 32 /// Always `DBPF`. 33 static const identifier = "DBPF".representation; 34 align(1): 35 /// Always `DBPF`. 36 ubyte[4] magic; 37 /// 38 Version version_; 39 /// Unused, possibly reserved. 40 uint unknown1; 41 /// Unused, possibly reserved. 42 uint unknown2; 43 /// Should always be zero in DBPF v`2.0`. 44 const uint unknown3 = 0; 45 /// Date created. Unix timestamp. 46 /// Remarks: Unused in DBPF `1.1`. 47 uint dateCreated; 48 /// Date modified. Unix timestamp. 49 /// Remarks: Unused in DBPF v`1.1`. 50 uint dateModified; 51 /// Major version of the Index table. 52 /// Remarks: Always `7` in The Sims 2 and SimCity 4. If this is a DBPF v`2.0` archive, then it is `0` for Spore. 53 /// See_Also: `indexMinorVersion` 54 uint indexMajorVersion = 0; 55 /// Number of entries in the Index Table. 56 uint indexEntryCount; 57 static if (V < 2) { 58 /// Offset to Index table, in bytes. Location of first index entry. 59 uint indexOffset; 60 } else private uint padding; 61 /// Size of the Index table, in bytes. 62 uint indexSize; 63 /// Number of Hole entries in the Hole Record. 64 uint holeEntryCount; 65 /// Location of the hole Record. 66 uint holeOffset; 67 /// Size of the hole Record, in bytes. 68 uint holeSize; 69 /// Minor version of the Index table. 70 /// Remarks: 71 /// $(P In The Sims 2 for DBPF v`1.1+`.) 72 /// $(P In DBPF >= v`2.0`, this is `3`, otherwise:) 73 /// $(UL 74 /// $(LI `1` = v`7.0`) 75 /// $(LI `2` = v`7.1`) 76 /// ) 77 /// See_Also: `indexMajorVersion` 78 uint indexMinorVersion; 79 static if (V >= 2) { 80 /// Offset to Index table, in bytes. Location of first index entry. 81 uint indexOffset; 82 } else private uint padding; 83 /// 84 uint unknown4; 85 /// Reserved for future use. 86 ubyte[24] reserved; 87 88 /// Computed, human-readable Index table version. 89 Version indexVersion() const @property { 90 static if (V >= 2) return Version(this.indexMajorVersion); 91 else { 92 const uint minor = this.indexMinorVersion == 0 ? 0 : this.indexMinorVersion - 1; 93 return Version(this.indexMajorVersion, minor); 94 } 95 } 96 } 97 98 static assert(Header!(Version.sc4).alignof == 1); 99 static assert(Header!(Version.sc4).sizeof == 96); 100 static assert(Header!(Version.sims2).sizeof == 96); 101 static assert(Header!(Version.spore).sizeof == 96); 102 static assert(Header!(Version.simCity).sizeof == 96); 103 104 /// Remarks: For DBPF version specifiers: 105 /// $(UL 106 /// $(LI `1.0` seen in SimCity 4, The Sims 2) 107 /// $(LI `1.1` seen in The Sims 2) 108 /// $(LI `2.0` seen in Spore, The Sims 3) 109 /// $(LI `3.0` seen in SimCity(2013)) 110 /// ) 111 /// For DBPF Index table version specifiers: 112 /// $(UL 113 /// $(LI `7.0` seen in SimCity 4, The Sims 2) 114 /// $(LI `7.1` seen in The Sims 2) 115 /// ) 116 struct Version { 117 /// 118 static const float simCity4 = 1; 119 /// ditto 120 static const float sc4 = 1; 121 /// 122 static const float sims2 = 1.1; 123 /// 124 static const float spore = 2; 125 /// 126 static const float sims3 = 2; 127 /// SimCity (2013) 128 static const float simCity = 3; 129 align(1): 130 /// 131 uint major; 132 /// 133 uint minor; 134 } 135 136 static assert(Version.alignof == 1); 137 static assert(Version.sizeof == 8); 138 139 /// Determines whether `V` is a valid DBPF Index table version number. 140 /// See_Also: `Version` 141 enum isValidIndexVersion(float V) = isFloatingPoint!(typeof(V)) && (V == 0 || V == 7.0 || V == 7.1); 142 143 /// Index Tables list the contents of a DBPF package. 144 /// Remarks: 145 /// The index table is very similar to the directory file 146 /// (<a href="https://www.wiki.sc4devotion.com/index.php?title=DIR">DIR</a>) within a DPBF package. The difference 147 /// being that the Index Table lists every file in the package, whereas the directory file only lists the compressed 148 /// files within the package. <a href="https://www.wiki.sc4devotion.com/index.php?title=Reader">Reader</a> presents a 149 /// directory file that is a mashup of these two entities, listing every file in the package, as well as indicating 150 /// whether or not that particular file is compressed. 151 /// See_Also: <a href="https://www.wiki.sc4devotion.com/index.php?title=DBPF#Index_Table">DBPF Index Table</a> (SC4D Encyclopedia) 152 struct IndexTable(float V = 7.0) if (isValidIndexVersion!V) { 153 align(1): 154 /// Type ID. 155 uint typeId; 156 /// Group ID. 157 uint groupId; 158 /// Instance ID. 159 uint instanceId; 160 /// Resource ID. 161 static if (V >= 7.1) uint resourceId; 162 /// Location offset of a file in the archive, in bytes. 163 uint offset; 164 /// Size of a file, in bytes. 165 uint size; 166 } 167 168 /// 169 alias IndexTableV7 = IndexTable!7; 170 /// 171 alias IndexTableV7_1 = IndexTable!(7.1); 172 173 static assert(IndexTable!7.alignof == 1); 174 static assert(IndexTable!7.sizeof == 20); 175 static assert(IndexTable!(7.1).sizeof == 24); 176 177 /// A Hole Table contains the location and size of all holes in a DBPF file. 178 /// Remarks: 179 /// Holes are created when the game deletes something from a DBPF. The holes themselves are simply junk data of the 180 /// appropriate length to fill the hole. 181 struct HoleTable { 182 /// Location offset, in bytes. 183 uint location; 184 /// Size, in bytes. 185 uint size; 186 } 187 188 /// Occurs before `File.contents` only if the `File` is compressed. 189 struct FileHeader { 190 import dbpf.types : int24; 191 align(1): 192 /// Compressed size of the file, in bytes. 193 uint compressedSize; 194 /// Compression ID, i.e. (`0x10FB`). Always 195 /// <a href="https://www.wiki.sc4devotion.com/index.php?title=DBPF_Compression">QFS Compression</a>. 196 /// See_Also: <a href="https://www.wiki.sc4devotion.com/index.php?title=DBPF_Compression">DBPF Compression</a> (SC4D Encyclopedia) 197 const ushort compressionId = 0x10FB; 198 /// Uncompressed size of the file, in bytes. 199 int24 uncompressedSize; 200 } 201 202 static assert(FileHeader.alignof == 1); 203 static assert(FileHeader.sizeof == 9); 204 205 import std.typecons : Flag; 206 207 /// Files fill the bulk of a DBPF archive. 208 /// 209 /// A file header exists only if this file is compressed. 210 /// Remarks: 211 /// Each file is either uncompressed or compressed. To check if a file is compressed you first need to read the DIR 212 /// file, if it exists. If no <a href="https://www.wiki.sc4devotion.com/index.php?title=DIR">DIR</a> entry exists, 213 /// then no files within the package are compressed. 214 struct File(bool Compressed = Flag!"compressed" = false) { 215 alias contents this; 216 /// Exists only if this file is compressed. 217 static if (Compressed) FileHeader header; 218 219 /// Contents of this file. 220 /// 221 /// See <a href="https://www.wiki.sc4devotion.com/index.php?title=List_of_File_Formats">List of File Formats</a> for 222 /// a list of the file types that may exist within a DBPF archive. 223 ubyte[] contents; 224 225 /// Uncompressed size of this file, in bytes. 226 uint size() const @property { 227 import std.conv : to; 228 static if (Compressed) return this.header.uncompressedSize; 229 else return this.contents.length.to!uint; 230 } 231 } 232 233 /// Determines whether `DBPF and `V` are valid DBPF and Index table versions. 234 /// See_Also: $(UL 235 /// $(LI `isValidDbpfVersion`) 236 /// $(LI `isValidIndexVersion`) 237 /// $(LI `Version`) 238 /// ) 239 enum isValidVersion(float DBPF, float V) = isValidDbpfVersion!DBPF && isValidIndexVersion!V; 240 241 /// 242 struct Archive(float DBPF = 1, float V = 7.0) if (isValidVersion!(DBPF, V)) { 243 alias Head = Header!DBPF; 244 alias Table = IndexTable!V; 245 246 import std.exception : enforce; 247 248 /// 249 const string path; 250 private std.stdio.File file; 251 /// 252 Head metadata; 253 /// 254 Table[] entries; 255 256 /// Open a DBPF archive from the given file `path`. 257 this(string path) { 258 import std.algorithm : equal; 259 import std.conv : to; 260 261 this.path = path; 262 this.file = std.stdio.File(path, "rb"); 263 264 assert(this.file.size >= Head.sizeof); 265 this.file.rawRead!Head((&metadata)[0..1]); 266 enforce(metadata.magic[].equal(Head.identifier), "Input is not a DBPF archive."); 267 auto filesOffset = this.file.tell; 268 269 this.file.seek(metadata.indexOffset); 270 this.entries = new Table[metadata.indexEntryCount]; 271 this.file.rawRead!Table((entries.ptr)[0..entries.length]); 272 273 this.file.seek(filesOffset); 274 } 275 276 /// 277 void close() { 278 file.close(); 279 } 280 }