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 }