-
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Plugin.cs
573 lines (488 loc) · 24.6 KB
/
Plugin.cs
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
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
using BepInEx;
using UnityEngine;
using System;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Diagnostics;
using System.Collections.Generic;
using System.Threading;
using Sunless.Game.ApplicationProviders;
namespace SDLS
{
[BepInPlugin(PluginInfo.PLUGIN_GUID, PluginInfo.PLUGIN_NAME, PluginInfo.PLUGIN_VERSION)]
internal sealed class Plugin : BaseUnityPlugin
{
// Config options
private const string CONFIG_FILENAME = "SDLS_Config.ini";
private Dictionary<string, bool> ConfigOptions = new(); // Gets filled in by LoadConfig
// Config options end
public static string PersistentDataPath { get; } = Application.persistentDataPath;
private static Assembly Assembly { get; } = Assembly.GetExecutingAssembly();
private HashSet<string> componentNames; // Contains the names of all the JSON defaults
private Dictionary<string, Dictionary<string, object>> componentCache = new(); // Cache for loaded components
private bool TilesSpecialCase = false; // Variable for the special case of Tiles.json. Check GetAComponent for more info
private string currentModName; // Variable for tracking which mod is currently being merged. Used for logging conflicts
private List<string> conflictLog = new(); // List of conflicts
private Dictionary<string, Stopwatch> DebugTimers = new(); // List of Debug timers, used by DebugTimer()
private Dictionary<string, Dictionary<int, Dictionary<string, object>>> mergedModsDict = new();
// Dictionary structure breakdown:
// - string: Represents the filename or category (eg events.json).
// - Dictionary<int, Dictionary<string, object>>:
// - int: Id of an entry, either Id or Name or AssociatedQualityId.
// - Dictionary<string, object>: The actual data from a JSON object.
// - string: The key from the JSON object.
// - object: The value, which can be a primitive value or a nested object.
// Tracks every file SDLS creates. Used during cleanup
private List<string> createdFiles = new();
public static Plugin I { get; private set; }
public static bool jsonInitializationComplete = false;
private FastLoad fastLoader;
private LoadIntoSave saveLoader;
private void Awake( /* Run by Unity on game start */ )
{
Log($"Plugin {PluginInfo.PLUGIN_GUID} is loaded!");
I = this;
LoadConfig();
InitializationLine();
PatchMethodsForPerformance.DoPerformancePatches();
var jsonCompletedEvent = new ManualResetEvent(false); // Track whether JsonInitialization is complete
ThreadPool.QueueUserWorkItem(state => JsonInitialization(jsonCompletedEvent)); // Start JSON Initialization Async
}
private void Start()
{
_ = GameProvider.Instance; // Prevent Resources.Load from being called async, by caching GameProvider
if (ConfigOptions["fastLoad"]) FastLoadInitialization();
if (ConfigOptions["loadIntoSave"]) LoadIntoSaveInitialization();
}
private void JsonInitialization(ManualResetEvent jsonCompletedEvent)
{
try
{
DebugTimer("TrashAllJSON");
TrashAllJSON();
}
catch (Exception ex)
{
Error($"Exception in TrashAllJSON: {ex.Message}");
Error($"Stack trace: {ex.StackTrace}");
}
finally
{
DebugTimer("TrashAllJSON");
// Signal that initialization is complete
jsonInitializationComplete = true;
jsonCompletedEvent.Set();
}
}
private void FastLoadInitialization()
{
// Create a new GameObject and attach FastLoad to it
var fastLoadObject = new GameObject("FastLoad");
DontDestroyOnLoad(fastLoadObject);
fastLoader = fastLoadObject.AddComponent<FastLoad>();
StartCoroutine(fastLoader.WaitForInitAndStartRepositoryManager());
}
private void LoadIntoSaveInitialization()
{
var loadIntoSaveObject = new GameObject("loadIntoSave");
DontDestroyOnLoad(loadIntoSaveObject);
saveLoader = loadIntoSaveObject.AddComponent<LoadIntoSave>();
StartCoroutine(saveLoader.LoadIntoSaveCoroutine(fastLoader));
}
void OnApplicationQuit( /* Run by Unity on game exit */ )
{
if (ConfigOptions["doCleanup"])
{
// Removes all files created by SDLS before closing, because otherwise they would cause problems with Vortex
// So basically, if a user had SDLS installed, and used Vortex to manage their mods, if they wanted to remove
// a mod, Vortex would only remove the .sdls files, and not the .json files, which would cause the game to
// still run them, and the user would have no clue why.
DebugTimer("Cleanup");
RemoveDirectory("SDLS_MERGED");
foreach (string file in createdFiles) JSON.RemoveJSON(file);
DebugTimer("Cleanup");
}
}
private void TrashAllJSON()
{
string[] filePaths = JSON.GetFilePaths(); // List of all possible moddable files
componentNames = FindComponents(); // list of each default component
foreach (string modFolder in Directory.GetDirectories(Path.Combine(PersistentDataPath, "addon")))
{
foreach (string filePath in filePaths)
{
{
try
{
string modFolderInAddon = Path.Combine("addon", modFolder);
string fullRelativePath = Path.Combine(modFolderInAddon, filePath);
string fileContent = JSON.ReadGameJson(fullRelativePath + ".sdls"); // Attempt to read the file with ".sdls" extension
if (fileContent == null) fileContent = JSON.ReadGameJson(fullRelativePath + "SDLS.json"); // Attempt to read the file with "SDLS.json" extension only if .sdls file is not found
if (fileContent != null)
{
string fileName = GetLastWord(fullRelativePath);
DebugTimer("Trash " + fullRelativePath);
currentModName = fullRelativePath; // Track current mod to log conflicts
string trashedJSON = TrashJSON(fileContent, fileName);
DebugTimer("Trash " + fullRelativePath);
DebugTimer("Create " + fullRelativePath);
JSON.CreateJSON(trashedJSON, fullRelativePath);
createdFiles.Add(fullRelativePath);
DebugTimer("Create " + fullRelativePath);
}
}
catch (Exception ex)
{
Error($"Error processing file {filePath}: {ex.Message}");
}
};
}
}
}
private string TrashJSON(string strObjJoined, string name)
{
try
{
var strObjList = new List<string>(); // List to store all the JSON strings
var embeddedData = GetAComponent(name); // Deserialize the default mold data
foreach (string splitString in JSON.SplitJSON(strObjJoined))
{
var deserializedJSON = JSON.Deserialize(splitString);
// Apply each field found in the deserialized JSON to the mold data recursively
var returnData = ApplyFieldsToMold(deserializedJSON, embeddedData);
// Serialize the updated mold data back to JSON string
strObjList.Add(JSON.Serialize(returnData));
}
TilesSpecialCase = false; // Stupid special case for Tiles, check GetAComponent for details
// Join the processed JSON strings together
string result = JSON.JoinJSON(strObjList);
return result;
}
catch (Exception ex)
{
Error($"Error occurred while processing JSON for '{name}': {ex.Message}");
return strObjJoined; // Return providedJSON as fallback
}
}
private Dictionary<string, object> ApplyFieldsToMold(
Dictionary<string, object> tracedJSONObj, // This data will get copied over
Dictionary<string, object> mergeJSONObj // Data will get merged INTO this object
)
{
// Copy the input dictionary to a new dictionary as a preventitive measure to not repeat the events of 16/05/2023
var mergeJSONObjCopy = new Dictionary<string, object>(mergeJSONObj);
foreach (var kvp in tracedJSONObj)
{
string tracedKey = kvp.Key; // Name of key, e.g., UseEvent
var tracedValue = kvp.Value; // Value of key, e.g., 500500
mergeJSONObjCopy[tracedKey] = /* If */ componentNames.Contains(/* the currently handled */ tracedKey)
// Is there a default component for this field?
? HandleComponent(tracedValue, tracedKey) // Yes
: HandleDefault(tracedValue); // No
}
return mergeJSONObjCopy;
}
private object HandleComponent(object tracedValue, string tracedKey)
{
if (tracedValue is IEnumerable<object> array)
{
var castedArray = array.Cast<Dictionary<string, object>>().ToList();
return HandleArray(castedArray, tracedKey);
}
else
{
return HandleObject(tracedValue, tracedKey);
}
}
private object HandleObject(object fieldValue, string fieldName)
{
var newMoldItem = GetAComponent(fieldName);
if (fieldValue is Dictionary<string, object> fieldValueDict)
{
return ApplyFieldsToMold(fieldValueDict, newMoldItem);
}
else return fieldValue;
}
private object HandleDefault(object fieldValue)
{
if (fieldValue is Dictionary<string, object> nestedJSON)
{
return ApplyFieldsToMold(nestedJSON, new Dictionary<string, object>());
}
else if (fieldValue is IEnumerable<object> array)
{
return array.Select(item => HandleDefault(item)).ToList();
}
else
{
return fieldValue;
}
}
private object HandleArray(
List<Dictionary<string, object>> array, // Provided array
string fieldName, // Name of the current field (eg. Enhancements) used for GetAComponent
List<Dictionary<string, object>> arrayToMergeInto = null)
{
arrayToMergeInto ??= new List<Dictionary<string, object>>(array.Count); // Set the arrayToMergeInto to an empty array (list) if none are provided.
foreach (var item in array)
{
if (item is Dictionary<string, object> itemDict) // If the item is a dictionary (eg, storylet, quality etc)
{
arrayToMergeInto.Add((Dictionary<string, object>)HandleObject(itemDict, fieldName));
}
else arrayToMergeInto.Add(item); // Else, if the item is a value (for example, "SubsurfaceWeather", results in ["value"])
}
return arrayToMergeInto;
}
private int NameOrId(Dictionary<string, object> JSONObj)
{
string primaryKey = FindPrimaryKey(JSONObj);
return primaryKey == "Name" ?
JSONObj[primaryKey].GetHashCode() :
(int)JSONObj[primaryKey];
}
private string FindPrimaryKey(Dictionary<string, object> JSONObj)
{
string[] keys = { "AssociatedQualityId", "Id", "Name" };
foreach (string key in keys)
{
if (JSONObj.ContainsKey(key))
{
DLog("Chose " + key + " as a primary key");
return key;
}
}
throw new ArgumentException($"The provided JSON object does not contain an 'Id', 'AssociatedQualityId', or 'Name' field. Object: {JSON.Serialize(JSONObj)}");
}
public static string GetLastWord(string str)
{
if (str.IndexOfAny(new char[] { '/', '\\' }) == -1) return str; // No separators found, return the original string
string result = str.Split(new char[] { '/', '\\' }).Last();
return result;
}
public static string GetParentPath(string filePath)
{
int lastIndex = filePath.LastIndexOfAny(new char[] { '/', '\\' });
// Return the substring from the start of the string up to the last directory separator
return filePath.Substring(0, lastIndex + 1);
}
private void RemoveDirectory(string relativePath) // Removes any directory in addon
{
string relativePathDirectory = Path.Combine("addon", relativePath);
string path = Path.Combine(PersistentDataPath, relativePathDirectory);
try
{
Directory.Delete(path, true);
Warn("Removing " + relativePathDirectory);
}
catch (DirectoryNotFoundException) { }
catch (Exception ex)
{
Error($"Error deleting directory: {ex.Message}");
}
}
private HashSet<string> FindComponents() // Fetches a list of all files (names) in the defaultComponents folder
{
string embeddedPath = GetEmbeddedPath("default");
string[] resourceNames = Assembly.GetExecutingAssembly().GetManifestResourceNames();
var components = new HashSet<string>();
for (int i = 0; i < resourceNames.Length; i++)
{
string name = resourceNames[i];
if (name.StartsWith(embeddedPath))
{
int startIndex = embeddedPath.Length + 1; // +1 to skip the dot
int endIndex = name.Length - 5; // -5 to remove ".json"
string componentName = name.Substring(startIndex, endIndex - startIndex);
components.Add(componentName);
}
}
return components;
}
private Dictionary<string, object> GetAComponent(string name)
{
string componentName = name;
if (!componentCache.ContainsKey(componentName))
{
string asText = JSON.ReadInternalJson(componentName);
string referenceString = "REFERENCE=";
string strippedAsText = asText.Replace(" ", "");
if (strippedAsText.Contains(referenceString))
{
componentName = strippedAsText.Replace(referenceString, "");
var value = GetAComponent(componentName);
componentCache[componentName] = value;
}
else
{
componentCache[componentName] = JSON.Deserialize(asText);
}
}
else
{
// So the deal is, Tiles.json contains a field called Tiles, spelled exactly the same.
// So if we request the component, there is no way to know which one it requests.
// We get around this by checking whether we are currently handling the Tiles.json object,
// And if we are, return TilesTiles instead, since TilesTiles is only used inside of Tiles.json
// We set TilesSpecialCase to false after we handle the Tiles object.
if (componentName == "Tiles" && TilesSpecialCase)
{
componentName = "TilesTiles"; // Set the name to TilesTiles to return the correct component
if (!componentCache.ContainsKey("TilesTiles")) // If the TilesTiles component hasn't been added, add it.
{
componentCache[componentName] = JSON.Deserialize(JSON.ReadInternalJson(componentName));
}
}
}
if (componentName == "Tiles") TilesSpecialCase = true; // Set special case to true after the first time Tiles has been requested and returned
return componentCache[componentName];
}
public static string GetEmbeddedPath(string folderName = "") // Get the path of embedded resources
{
string projectName = Assembly.GetExecutingAssembly().GetName().Name;
string fullPath = $"{projectName}.{folderName}";
return fullPath;
}
private void LoadConfig(bool loadDefault = false)
{
string[] lines;
if (File.Exists(CONFIG_FILENAME) && !loadDefault)
{
lines = File.ReadAllLines(CONFIG_FILENAME);
}
else
{
Warn("Config not found or corrupt, using default values.");
string file = ReadTextResource(GetEmbeddedPath() + CONFIG_FILENAME); // Get the default config from the embedded resources
lines = file.Split(new[] { Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries); // Split the file into lines
}
var optionsDict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
try
{
foreach (var line in lines)
{
if (line.Contains('=')) // Check if the line contains an '=' character so it's a valid config line
{
// Remove all spaces from the line and split it at the first occurrence of '=' into two parts
string[] keyValue = line.Replace(" ", "").Split(new[] { '=' }, 2);
optionsDict[keyValue[0]] = keyValue[1]; // Add the key and value to the dictionary
}
}
ConfigOptions["doMerge"] = bool.Parse(optionsDict["domerge"]);
ConfigOptions["logConflicts"] = ConfigOptions["doMerge"] ? bool.Parse(optionsDict["logmergeconflicts"]) : false;
ConfigOptions["basegamemerge"] = ConfigOptions["doMerge"] ? bool.Parse(optionsDict["basegamemerge"]) : false;
// Prevents issues with mod managers by removing all SDLS-created files on exit
ConfigOptions["doCleanup"] = bool.Parse(optionsDict["docleanup"]);
// Preloads game data asynchronously, significantly reducing save loading times.
// Seemingly causes crashes in the Steam version
ConfigOptions["fastLoad"] = bool.Parse(optionsDict["fastload"]);
// Whether to load into a save immediately when launching the game
ConfigOptions["loadIntoSave"] = bool.Parse(optionsDict["loadintosave"]);
// Log the time it takes to complete certain actions in the log
ConfigOptions["logDebugTimers"] = bool.Parse(optionsDict["logdebugtimers"]);
}
catch (Exception)
{
LoadConfig( /*loadDefault =*/ true); // Load config with default values
}
}
public static string ReadTextResource(string fullResourceName)
{
using (Stream stream = Assembly.GetManifestResourceStream(fullResourceName))
{
if (stream == null)
{
I.Warn("Tried to get resource that doesn't exist: " + fullResourceName);
return null; // Return null if the embedded resource doesn't exist
}
using var reader = new StreamReader(stream);
return reader.ReadToEnd(); // Read and return the embedded resource
}
}
public void DebugTimer(string name)
{
// if (!ConfigOptions["logDebugTimers"]) return;
if (!DebugTimers.TryGetValue(name, out Stopwatch stopwatch))
{ // Start a new timer
Log(string.Format("Starting process {0}", name));
stopwatch = new Stopwatch();
stopwatch.Start();
DebugTimers[name] = stopwatch;
}
else if (stopwatch.IsRunning)
{ // Stop the timer and log the result
stopwatch.Stop();
Log(string.Format("Finished process {0}. Took {1:F3} seconds.", name, stopwatch.Elapsed.TotalSeconds));
}
else
{ // Removes the timer and starts it again
DebugTimers.Remove(name);
DebugTimer(name);
}
}
// private void LogValueOverwritten(string key, object NameOrId, object oldValue, object newValue)
// {
// if (logConflicts)
// {
// string modToBlame = $"{currentModName} overwrote a value:";
// Warn(modToBlame);
// conflictLog.Add(modToBlame);
// string overwrittenValues = $"Key '{key}' overwritten in Id '{NameOrId}'.\nOld value: {oldValue}\nNew value: {newValue}";
// Warn(overwrittenValues);
// conflictLog.Add(overwrittenValues);
// }
// }
// private void LogConflictsToFile()
// {
// if (conflictLog.Count > 0)
// {
// string fileName = "SDLS_Merge_Conflicts.log";
// string writePath = Path.Combine(Directory.GetCurrentDirectory(), fileName);
// using StreamWriter writer = new StreamWriter(writePath, false);
// foreach (string str in conflictLog) writer.WriteLine(str);
// }
// }
private void InitializationLine()
{
string[] lines = {
"Querrying ChatGPT for better JSON.",
"Help! If you're seeing this, I'm the guy he trapped within this program to rewrite your JSON!.",
"This is an alternate line 3.",
"I'm sorry, but as an AI language model.",
"Error 404: Humor not found... in your JSON.",
"Adding a lot of useless stuff. Like, a LOT of useless stuff.",
"Adding a mascot that's more powerful than all the others, found in London, as is tradition.",
"Compiling JSON files into JSON files into JSON files into JSON files into JSON files into JSON files into JSON files...",
"You better be using .sdls files.",
"Adding gluten to your JSON.",
"Jason? JASON!",
"Press X to JSON",
"Adding exponentially more data.",
"JSON is honestly just a Trojan Horse to smuggle Javascript into other languages.",
"\n\nIn Xanadu did Kubla Khan\nA stately pleasure-dome decree:\nWhere Alph, the sacred river, ran\nThrough caverns measureless to man\nDown to a sunless sea.",
"She Simplifying my Data Loading till I Sunless",
"Screw it. Grok, give me some more jokes for the JSON.",
"\nCan you guess where the JSON goes?\nThat's right!\nIt goes in the square hole!",
"You merely adopted the JSON. I was born in it, molded by it.",
};
string line = lines[new System.Random().Next(0, lines.Length)];
Log(line + "\n");
}
// Simplified log functions
public void Log(object message) { Logger.LogInfo(message); }
public void Warn(object message) { Logger.LogWarning(message); }
public void Error(object message) { Logger.LogError(message); }
#if DEBUG
// Log functions that don't run when built in Release mode
public void DLog(object message) { Log(message); }
public void DWarn(object message) { Warn(message); }
public void DError(object message) { Error(message); }
#else
// Empty overload methods to make sure the plugin doesn't crash when built in release mode
private void DLog(object message) { }
private void DWarn(object message) { }
private void DError(object message) { }
#endif
}
}