-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathModEngine.cs
More file actions
397 lines (371 loc) · 14.8 KB
/
Copy pathModEngine.cs
File metadata and controls
397 lines (371 loc) · 14.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DimensionsModManager;
public class AppliedEntry
{
[JsonPropertyName("relPath")]
public string RelPath { get; set; } = "";
[JsonPropertyName("hadOriginal")]
public bool HadOriginal { get; set; }
[JsonPropertyName("sourceMod")]
public string SourceMod { get; set; } = "";
}
public class DatPatchEntry
{
[JsonPropertyName("archive")]
public string Archive { get; set; } = "";
[JsonPropertyName("internalPath")]
public string InternalPath { get; set; } = "";
[JsonPropertyName("sourceMod")]
public string SourceMod { get; set; } = "";
[JsonPropertyName("entryIndex")]
public int EntryIndex { get; set; } = -1;
}
public class DatOriginalEntry
{
[JsonPropertyName("archive")]
public string Archive { get; set; } = "";
[JsonPropertyName("datLength")]
public long DatLength { get; set; }
/// <summary>
/// Where the archive actually is. Empty in state written before DLC
/// archives outside the game folder could be patched.
/// </summary>
[JsonPropertyName("datPath")]
public string DatPath { get; set; } = "";
}
public class AppliedState
{
[JsonPropertyName("appliedUtc")]
public DateTime AppliedUtc { get; set; }
[JsonPropertyName("entries")]
public List<AppliedEntry> Entries { get; set; } = new();
[JsonPropertyName("datPatches")]
public List<DatPatchEntry> DatPatches { get; set; } = new();
[JsonPropertyName("datOriginals")]
public List<DatOriginalEntry> DatOriginals { get; set; } = new();
}
/// <summary>
/// Applies mod files onto a game folder with one-time backups of the
/// vanilla originals, and can restore everything back. Loose files are
/// plain file layering; DAT-packed files are injected in place into the
/// TT Games .DAT/.HDR archives (see DatArchive).
/// </summary>
public static class ModEngine
{
public const string BackupDirName = ".vanilla_backup";
public const string StateFileName = "modmanager_state.json";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
WriteIndented = true,
};
public static List<ModInfo> DiscoverMods(string modsRoot, string platform)
{
var mods = new List<ModInfo>();
if (!Directory.Exists(modsRoot))
{
return mods;
}
foreach (string folder in Directory.EnumerateDirectories(modsRoot)
.OrderBy(f => f, StringComparer.OrdinalIgnoreCase))
{
ModInfo? mod = ModInfo.Load(folder);
if (mod != null && mod.MatchesPlatform(platform))
{
mods.Add(mod);
}
}
return mods;
}
public static AppliedState? GetAppliedState(string gameDir)
{
string statePath = Path.Combine(gameDir, StateFileName);
if (!File.Exists(statePath))
{
return null;
}
try
{
return JsonSerializer.Deserialize<AppliedState>(
File.ReadAllText(statePath), JsonOptions);
}
catch (JsonException)
{
return null;
}
}
/// <summary>
/// Applies the given mods in list order (later mods win file conflicts).
/// If mods are already applied, restores vanilla first so re-applying
/// with a different selection is always correct.
/// </summary>
/// <summary>
/// Finds the .DAT (or .DAT2) for an archive name. The game folder comes
/// first; then each search folder and its immediate subfolders, which is
/// how the Xbox 360 build keeps its DLC: one package folder per DLC under
/// the content root, holding DLCnn.DAT2/.HDR2.
/// </summary>
public static string? ResolveArchivePath(
string gameDir, string archive, IReadOnlyList<string>? searchDirs)
{
IEnumerable<string> Candidates(string dir)
{
yield return Path.Combine(dir, archive + ".DAT");
yield return Path.Combine(dir, archive + ".DAT2");
}
foreach (string c in Candidates(gameDir))
{
if (File.Exists(c)) return c;
}
foreach (string dir in searchDirs ?? Array.Empty<string>())
{
if (!Directory.Exists(dir)) continue;
foreach (string c in Candidates(dir))
{
if (File.Exists(c)) return c;
}
foreach (string sub in Directory.EnumerateDirectories(dir))
{
foreach (string c in Candidates(sub))
{
if (File.Exists(c)) return c;
}
}
}
return null;
}
public static AppliedState ApplyMods(string gameDir, IReadOnlyList<ModInfo> mods,
IReadOnlyList<string>? archiveSearchDirs = null)
{
if (!Directory.Exists(gameDir))
{
throw new DirectoryNotFoundException($"Game folder not found: {gameDir}");
}
if (GetAppliedState(gameDir) != null)
{
RestoreVanilla(gameDir);
}
// Later mods in the list override earlier ones per file.
var fileMap = new Dictionary<string, (string sourceFile, string modName)>(
StringComparer.OrdinalIgnoreCase);
foreach (ModInfo mod in mods)
{
if (!Directory.Exists(mod.FilesPath))
{
continue;
}
foreach (string file in Directory.EnumerateFiles(
mod.FilesPath, "*", SearchOption.AllDirectories))
{
string relPath = Path.GetRelativePath(mod.FilesPath, file);
fileMap[relPath] = (file, mod.Name);
}
}
string backupDir = Path.Combine(gameDir, BackupDirName);
var state = new AppliedState { AppliedUtc = DateTime.UtcNow };
foreach ((string relPath, (string sourceFile, string modName)) in fileMap)
{
string targetPath = Path.Combine(gameDir, relPath);
bool hadOriginal = File.Exists(targetPath);
if (hadOriginal)
{
string backupPath = Path.Combine(backupDir, relPath);
Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!);
if (!File.Exists(backupPath))
{
File.Copy(targetPath, backupPath);
}
}
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
File.Copy(sourceFile, targetPath, overwrite: true);
state.Entries.Add(new AppliedEntry
{
RelPath = relPath,
HadOriginal = hadOriginal,
SourceMod = modName,
});
}
// DAT-injected files: datfiles\ARCHIVE\internal\path, later mods win.
var datMap = new Dictionary<(string archive, string internalPath),
(string sourceFile, string modName)>();
foreach (ModInfo mod in mods)
{
if (!Directory.Exists(mod.DatFilesPath))
{
continue;
}
foreach (string archiveDir in Directory.EnumerateDirectories(mod.DatFilesPath))
{
string archive = Path.GetFileName(archiveDir).ToUpperInvariant();
foreach (string file in Directory.EnumerateFiles(
archiveDir, "*", SearchOption.AllDirectories))
{
string internalPath = Path.GetRelativePath(archiveDir, file)
.Replace('/', '\\').ToUpperInvariant();
datMap[(archive, internalPath)] = (file, mod.Name);
}
}
}
foreach (var group in datMap.GroupBy(kv => kv.Key.archive))
{
string? datPath = ResolveArchivePath(gameDir, group.Key, archiveSearchDirs);
if (datPath == null)
{
// A DLC the user does not own: the rest of the mod still applies.
// The update archive is different - without it nothing works.
if (group.Key.StartsWith("DLC", StringComparison.OrdinalIgnoreCase))
{
Console.Error.WriteLine(
$"warning: {group.Key} not installed, skipping its {group.Count()} file(s)");
continue;
}
throw new FileNotFoundException(
$"Archive {group.Key}.DAT not found in the game folder.");
}
var archive = new DatArchive(datPath);
// Tiny backup: the original HDR bytes + the original DAT length.
string hdrBackup = Path.Combine(backupDir, group.Key + ".HDR");
Directory.CreateDirectory(backupDir);
if (!File.Exists(hdrBackup))
{
File.Copy(archive.HdrPath, hdrBackup);
}
state.DatOriginals.Add(new DatOriginalEntry
{
Archive = group.Key,
DatLength = new FileInfo(datPath).Length,
DatPath = Path.GetFullPath(datPath),
});
foreach (((_, string internalPath), (string sourceFile, string modName)) in group)
{
int index = archive.FindEntry(internalPath);
if (index < 0)
{
// The archive has no such file, so the mod is bringing a new
// one. Some mods cannot work any other way: a character is
// built from the abilities cached for it, and adding one to
// that cache is the only way to give it something it never
// had. Restore puts the original .HDR back and trims the
// .DAT, which takes the added entry with it.
index = archive.AddEntry(internalPath, File.ReadAllBytes(sourceFile));
state.DatPatches.Add(new DatPatchEntry
{
Archive = group.Key,
InternalPath = internalPath,
SourceMod = modName,
EntryIndex = index,
});
continue;
}
// In-place injection overwrites vanilla bytes, so save the
// entry's original data once (keyed by archive + entry index).
string blobDir = Path.Combine(backupDir, "datbytes");
string blobPath = Path.Combine(blobDir, $"{group.Key}_{index}.bin");
if (!File.Exists(blobPath))
{
Directory.CreateDirectory(blobDir);
File.WriteAllBytes(blobPath, archive.ReadEntryData(index));
}
archive.InjectFile(index, File.ReadAllBytes(sourceFile));
state.DatPatches.Add(new DatPatchEntry
{
Archive = group.Key,
InternalPath = internalPath,
SourceMod = modName,
EntryIndex = index,
});
}
archive.SaveHdr();
}
File.WriteAllText(Path.Combine(gameDir, StateFileName),
JsonSerializer.Serialize(state, JsonOptions));
return state;
}
/// <summary>Puts every vanilla file back and removes added files.</summary>
public static int RestoreVanilla(string gameDir)
{
AppliedState? state = GetAppliedState(gameDir);
if (state == null)
{
return 0;
}
string backupDir = Path.Combine(gameDir, BackupDirName);
int restored = 0;
foreach (AppliedEntry entry in state.Entries)
{
string targetPath = Path.Combine(gameDir, entry.RelPath);
if (entry.HadOriginal)
{
string backupPath = Path.Combine(backupDir, entry.RelPath);
if (File.Exists(backupPath))
{
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
File.Copy(backupPath, targetPath, overwrite: true);
restored++;
}
}
else if (File.Exists(targetPath))
{
File.Delete(targetPath);
restored++;
}
}
// Undo DAT injections: restore the original HDR, truncate the DAT
// back to its pre-mod length, then write the backed-up original
// bytes over any in-place injections (using the restored HDR's
// offsets, which are vanilla again at this point).
foreach (DatOriginalEntry original in state.DatOriginals)
{
string hdrBackup = Path.Combine(backupDir, original.Archive + ".HDR");
// Older state files only name the archive; those always lived in
// the game folder itself.
string datPath = !string.IsNullOrEmpty(original.DatPath)
? original.DatPath
: Path.Combine(gameDir, original.Archive + ".DAT");
string hdrPath = DatArchive.HdrPathFor(datPath);
if (File.Exists(hdrBackup))
{
File.Copy(hdrBackup, hdrPath, overwrite: true);
restored++;
}
if (File.Exists(datPath))
{
using (var dat = new FileStream(datPath, FileMode.Open, FileAccess.Write))
{
if (dat.Length > original.DatLength)
{
dat.SetLength(original.DatLength);
}
}
var archive = new DatArchive(datPath);
foreach (DatPatchEntry patch in state.DatPatches.Where(
p => p.Archive == original.Archive && p.EntryIndex >= 0))
{
string blobPath = Path.Combine(
backupDir, "datbytes", $"{patch.Archive}_{patch.EntryIndex}.bin");
if (!File.Exists(blobPath))
{
continue;
}
byte[] blob = File.ReadAllBytes(blobPath);
var (offset, zsize, _) = archive.GetEntry(patch.EntryIndex);
if (blob.Length == zsize)
{
using var dat = new FileStream(
datPath, FileMode.Open, FileAccess.Write);
dat.Position = offset;
dat.Write(blob, 0, blob.Length);
restored++;
}
}
}
}
File.Delete(Path.Combine(gameDir, StateFileName));
if (Directory.Exists(backupDir))
{
Directory.Delete(backupDir, recursive: true);
}
return restored;
}
}