diff --git a/Content.Tests/DMProject/Tests/Savefile/BasicReadAndWrite.dm b/Content.Tests/DMProject/Tests/Savefile/BasicReadAndWrite.dm index 12ccc058f1..143a84a3c6 100644 --- a/Content.Tests/DMProject/Tests/Savefile/BasicReadAndWrite.dm +++ b/Content.Tests/DMProject/Tests/Savefile/BasicReadAndWrite.dm @@ -1,3 +1,4 @@ +/datum/foobar /proc/RunTest() var/savefile/S = new("savefile.sav") @@ -7,15 +8,74 @@ // Indexing the object to write/read the savefile S["ABC"] = 5 ASSERT(V == null) + ASSERT(S["ABC"] == 5) V = S["ABC"] ASSERT(V == 5) // << and >> can do the same thing S["DEF"] << 10 S["DEF"] >> V + ASSERT(S["DEF"] == 10) ASSERT(V == 10) + S["notakey"] >> V + ASSERT(V == null) + + // test path + S["pathymcpathface"] << /datum/foobar + ASSERT(S["pathymcpathface"] == /datum/foobar) + + // test list() + var/list/array = list("3.14159", "pizza") + S["pie"] << array + ASSERT(S["pie"] ~= array) + var/list/assoc = list("6.28" = "pizza", "aaaaa" = "bbbbbbb") + S["pie2"] << assoc + ASSERT(S["pie2"] ~= assoc) // Shouldn't evaluate CRASH S2?["ABC"] << CRASH("rhs should not evaluate due to null-conditional") + // Test EOF + S.cd = "DEF" + var/out + ASSERT(S.eof == 0) + S >> out + ASSERT(out == 10) + ASSERT(S.eof == 1) + S.eof = -1 + S.cd = "/" + ASSERT(S["DEF"] == null) + + //Test dir + S.cd = "/" + var/dir = S.dir + ASSERT(dir ~= list("ABC", "DEF", "notakey", "pathymcpathface", "pie", "pie2")) + + //test add + dir += "test/beep" + ASSERT(dir ~= list("ABC", "DEF", "notakey", "pathymcpathface", "pie", "pie2", "test")) + ASSERT(S["test"] == null) + S.cd = "test" + ASSERT(dir ~= list("beep")) + + //test del + S.cd = "/" + dir -= "test" + ASSERT(dir ~= list("ABC", "DEF", "notakey", "pathymcpathface", "pie", "pie2")) + + //test rename and null + dir[1] = "CBA" + ASSERT(dir ~= list("CBA", "DEF", "notakey", "pathymcpathface", "pie", "pie2")) + ASSERT(S["CBA"] == null) + fdel("savefile.sav") + + file("badsavefile.sav") << "hey wait, this isn't json! oh well, better fail miserably and die" + + var/savefile/fail + try + fail = new("badsavefile.sav") + catch(var/exception/e) + ASSERT(isnull(fail)) + ASSERT(fail == null) + fdel("badsavefile.sav") diff --git a/Content.Tests/DMProject/Tests/Savefile/DatumSaving.dm b/Content.Tests/DMProject/Tests/Savefile/DatumSaving.dm new file mode 100644 index 0000000000..6b4ad68a3c --- /dev/null +++ b/Content.Tests/DMProject/Tests/Savefile/DatumSaving.dm @@ -0,0 +1,52 @@ +/datum/foo + var/best_map = "pl_upward" // mutated by RunTest(), should save + var/worst_map = "pl_badwater" // same as, should not be saved + var/null_me = "ok" // should save as null + + var/tmp/current_map = "yeah" // tmp, should not save + var/const/default_cube = "delete it" // const, should not save + + New(args) + proc_call_order_check += list("New") + ..() + + Read(savefile/F) + proc_call_order_check += list("Read") + ..() + + Write(savefile/F) + proc_call_order_check += list("Write") + ..() + +/var/static/proc_call_order_check = list() + + +/proc/RunTest() + var/savefile/S = new() + + var/datum/foo/F = new() + F.best_map = "pl_pier" + F.null_me = null + + S["mapdata"] << F + + // test the savefile's contents + ASSERT(S["mapdata/.0/type"] == /datum/foo) + ASSERT(S["mapdata/.0/best_map"] == "pl_pier") + ASSERT(S["mapdata/.0/null_me"] == null) + ASSERT(S["mapdata/.0/worst_map"] == null) + ASSERT(S["mapdata/.0/current_map"] == null) + ASSERT(S["mapdata/.0/default_cube"] == null) + + var/datum/foo/W + S["mapdata"] >> W + + // load test + ASSERT(istype(W)) + ASSERT(W != F) //they are equivalent, but not the same datum + ASSERT(W.best_map == "pl_pier") + ASSERT(W.worst_map == "pl_badwater") + ASSERT(W.null_me == null) + + ASSERT(proc_call_order_check ~= list("New","Write","New","Read")) + \ No newline at end of file diff --git a/Content.Tests/DMProject/Tests/Savefile/ExportText.dm b/Content.Tests/DMProject/Tests/Savefile/ExportText.dm new file mode 100644 index 0000000000..b99c840a7c --- /dev/null +++ b/Content.Tests/DMProject/Tests/Savefile/ExportText.dm @@ -0,0 +1,37 @@ +/obj/savetest + var/obj/savetest/recurse = null + +/proc/RunTest() + var/obj/savetest/O = new() //create a test object + O.name = "test" + //O.recurse = O //TODO + + var/savefile/F = new() //create a temporary savefile + + F["dir"] = O + F["dir2"] = "object(\".0\")" + F["dir3"] = 1080 + F["dir4"] = "the afternoon of the 3rd" + var/savefile/P = new() //nested savefile + P["subsavedir1"] = O + P["subsavedir2"] = "butts" + P["subsavedir3"] = 123 + + F["dir5"] = P + F["dir6/subdir6"] = 321 + F["dir7"] = null + F["dirIcon"] = new /icon() + F["list"] << list("1",2,"three"=3,4, new /datum(), new /datum(), list(1,2,3, new /datum())) + + ASSERT(F.dir ~= list("dir","dir2","dir3","dir4","dir5","dir6","dir7","dirIcon","list")) + ASSERT(F.ExportText("dir6/subdir6") == ". = 321\n") + ASSERT(F.ExportText("dir6/subdir6/") == ". = 321\n") + ASSERT(F.ExportText("dir6") == "\nsubdir6 = 321\n") + var/list_match = {". = list("1",2,"three" = 3,4,object(".0"),object(".1"),list(1,2,3,object(".2")))\n.0\n\ttype = /datum\n.1\n\ttype = /datum\n.2\n\ttype = /datum\n"} + ASSERT(F.ExportText("list") == list_match) + + F.cd = "dir6" + F << "test" + F.cd = "/" + ASSERT(F.ExportText("dir6") == ". = \"test\"\nsubdir6 = 321\n") + ASSERT(F.ExportText("dir6/subdir6/") == ". = 321\n") \ No newline at end of file diff --git a/Content.Tests/DMProject/Tests/Savefile/SaveAndLoad.dm b/Content.Tests/DMProject/Tests/Savefile/SaveAndLoad.dm new file mode 100644 index 0000000000..604dcfe431 --- /dev/null +++ b/Content.Tests/DMProject/Tests/Savefile/SaveAndLoad.dm @@ -0,0 +1,44 @@ +/datum/foobar + +/proc/RunTest() + var/savefile/S = new("savefile.sav") + var/savefile/S2 = null + + + // Indexing the object to write/read the savefile + S["ABC"] = 5 + ASSERT(S["ABC"] == 5) + + S["DEF"] = 10 + ASSERT(S["DEF"] == 10) + + // test path + S["pathymcpathface"] << /datum/foobar + ASSERT(S["pathymcpathface"] == /datum/foobar) + + // test list() + var/list/array = list("3.14159", "pizza") + S["pie"] << array + ASSERT(S["pie"] ~= array) + + // test assoc list() + var/list/assoc = list("6.28" = "pizza", "aaaaa" = "bbbbbbb") + S["pie2"] << assoc + ASSERT(S["pie2"] ~= assoc) + + S.Flush() + + // test loading + //gotta copy it because otherwise we're accessing the cache + fcopy("savefile.sav", "savefile2.sav") + ASSERT(fexists("savefile2.sav")) + S2 = new("savefile2.sav") + ASSERT(S2["ABC"] == 5) + ASSERT(S2["DEF"] == 10) + ASSERT(S2["pathymcpathface"] == /datum/foobar) + ASSERT(S2["pie"] ~= array) + ASSERT(S2["pie2"] ~= assoc) + + + fdel("savefile.sav") + fdel("savefile2.sav") \ No newline at end of file diff --git a/Content.Tests/DMProject/Tests/Savefile/SequenceRead.dm b/Content.Tests/DMProject/Tests/Savefile/SequenceRead.dm new file mode 100644 index 0000000000..7f8c001a36 --- /dev/null +++ b/Content.Tests/DMProject/Tests/Savefile/SequenceRead.dm @@ -0,0 +1,30 @@ +/datum/test_holder + var/test1 + var/test2 + var/test3 + var/test4 + +/proc/RunTest() + var/savefile/F = new("savtest.sav") + F["savtest1"] = "beep" + F["savtest2"] = "boop" + F["savtest3"] = "berp" + F["savtest4"] = "borp" + del(F) + fcopy("savtest.sav", "savtest2.sav") + var/savefile/F2 = new("savtest2.sav") + var/datum/test_holder/T = new() + T.test1 = F2["savtest1"] + T.test2 = F2["savtest2"] + + F2["savtest1"] >> T.test1 + F2["savtest2"] >> T.test2 + F2["savtest3"] >> T.test3 + F2["savtest4"] >> T.test4 + + ASSERT(T.test1 == "beep") + ASSERT(T.test2 == "boop") + ASSERT(T.test3 == "berp") + ASSERT(T.test4 == "borp") + fdel("savtest.sav") + fdel("savtest2.sav") \ No newline at end of file diff --git a/Content.Tests/DMProject/Tests/Savefile/override_datum.dm b/Content.Tests/DMProject/Tests/Savefile/override_datum.dm deleted file mode 100644 index 38eee024a6..0000000000 --- a/Content.Tests/DMProject/Tests/Savefile/override_datum.dm +++ /dev/null @@ -1,10 +0,0 @@ -// RETURN TRUE - -/datum/foo - -/datum/foo/Read(savefile/S) - -/datum/foo/Write(savefile/S) - -/proc/RunTest() - return TRUE diff --git a/DMCompiler/DM/Builders/DMProcBuilder.cs b/DMCompiler/DM/Builders/DMProcBuilder.cs index e22b3db87f..1877025be7 100644 --- a/DMCompiler/DM/Builders/DMProcBuilder.cs +++ b/DMCompiler/DM/Builders/DMProcBuilder.cs @@ -943,7 +943,6 @@ public void ProcessStatementInput(DMASTProcStatementInput statementInput) { _proc.Input(leftRef, rightRef); _proc.AddLabel(leftEndLabel); - _proc.PopReference(rightRef); _proc.AddLabel(rightEndLabel); } diff --git a/DMCompiler/DMStandard/Types/Atoms/Mob.dm b/DMCompiler/DMStandard/Types/Atoms/Mob.dm index ebcfcbea70..521aecfb16 100644 --- a/DMCompiler/DMStandard/Types/Atoms/Mob.dm +++ b/DMCompiler/DMStandard/Types/Atoms/Mob.dm @@ -3,9 +3,9 @@ var/client/client var/key - var/ckey + var/tmp/ckey - var/list/group as opendream_unimplemented + var/tmp/list/group as opendream_unimplemented var/see_invisible = 0 var/see_infrared = 0 as opendream_unimplemented diff --git a/DMCompiler/DMStandard/Types/Atoms/Movable.dm b/DMCompiler/DMStandard/Types/Atoms/Movable.dm index f5e9c86eb3..429a483d67 100644 --- a/DMCompiler/DMStandard/Types/Atoms/Movable.dm +++ b/DMCompiler/DMStandard/Types/Atoms/Movable.dm @@ -3,12 +3,12 @@ var/animate_movement = FORWARD_STEPS as opendream_unimplemented var/list/locs = null as opendream_unimplemented - var/glide_size + var/glide_size = 0 var/step_size as opendream_unimplemented - var/bound_x as opendream_unimplemented - var/bound_y as opendream_unimplemented - var/bound_width as opendream_unimplemented - var/bound_height as opendream_unimplemented + var/tmp/bound_x as opendream_unimplemented + var/tmp/bound_y as opendream_unimplemented + var/tmp/bound_width as opendream_unimplemented + var/tmp/bound_height as opendream_unimplemented //Undocumented var. "[x],[y]" or "[x],[y] to [x2],[y2]" based on bound_* vars var/bounds as opendream_unimplemented diff --git a/DMCompiler/DMStandard/Types/Atoms/_Atom.dm b/DMCompiler/DMStandard/Types/Atoms/_Atom.dm index 6e9b9b5cda..3e0cf12813 100644 --- a/DMCompiler/DMStandard/Types/Atoms/_Atom.dm +++ b/DMCompiler/DMStandard/Types/Atoms/_Atom.dm @@ -7,18 +7,18 @@ var/suffix = null as opendream_unimplemented // The initialization/usage of these lists is handled internally by the runtime - var/list/verbs = null + var/tmp/list/verbs = null var/list/contents = null var/list/overlays = null var/list/underlays = null - var/list/vis_locs = null as opendream_unimplemented + var/tmp/list/vis_locs = null as opendream_unimplemented var/list/vis_contents = null - var/atom/loc + var/tmp/atom/loc var/dir = SOUTH - var/x = 0 - var/y = 0 - var/z = 0 + var/tmp/x = 0 + var/tmp/y = 0 + var/tmp/z = 0 var/pixel_x = 0 var/pixel_y = 0 var/pixel_z = 0 as opendream_unimplemented @@ -53,9 +53,9 @@ var/step_x as opendream_unimplemented var/step_y as opendream_unimplemented var/render_source - var/mouse_drag_pointer as opendream_unimplemented - var/mouse_drop_pointer as opendream_unimplemented - var/mouse_over_pointer as opendream_unimplemented + var/tmp/mouse_drag_pointer as opendream_unimplemented + var/tmp/mouse_drop_pointer as opendream_unimplemented + var/tmp/mouse_over_pointer as opendream_unimplemented var/render_target var/vis_flags as opendream_unimplemented diff --git a/DMCompiler/DMStandard/Types/Datum.dm b/DMCompiler/DMStandard/Types/Datum.dm index ce2d1c7b01..1c47a39134 100644 --- a/DMCompiler/DMStandard/Types/Datum.dm +++ b/DMCompiler/DMStandard/Types/Datum.dm @@ -13,7 +13,5 @@ proc/Topic(href, href_list) proc/Read(savefile/F) - set opendream_unimplemented = TRUE proc/Write(savefile/F) - set opendream_unimplemented = TRUE diff --git a/OpenDreamRuntime/Objects/Types/DreamList.cs b/OpenDreamRuntime/Objects/Types/DreamList.cs index 2e82b15c04..f5527a253e 100644 --- a/OpenDreamRuntime/Objects/Types/DreamList.cs +++ b/OpenDreamRuntime/Objects/Types/DreamList.cs @@ -1292,3 +1292,60 @@ public override int GetLength() { return _state.ArgumentCount; } } + +// Savefile Dir List - always sync'd with Savefiles currentDir. Only stores keys. +internal sealed class SavefileDirList : DreamList { + private readonly DreamObjectSavefile _save; + + public SavefileDirList(DreamObjectDefinition listDef, DreamObjectSavefile backedSaveFile) : base(listDef, 0) { + _save = backedSaveFile; + } + + public override DreamValue GetValue(DreamValue key) { + if (!key.TryGetValueAsInteger(out var index)) + throw new Exception($"Invalid index on savefile dir list: {key}"); + if (index < 1 || index > _save.CurrentDir.Count) + throw new Exception($"Out of bounds index on savefile dir list: {index}"); + return new DreamValue(_save.CurrentDir.Keys.ElementAt(index - 1)); + } + + public override List GetValues() { + List values = new(_save.CurrentDir.Count); + + foreach (string value in _save.CurrentDir.Keys.OrderBy(x => x)) + values.Add(new DreamValue(value)); + return values; + } + + public override void SetValue(DreamValue key, DreamValue value, bool allowGrowth = false) { + if (!key.TryGetValueAsInteger(out var index)) + throw new Exception($"Invalid index on savefile dir list: {key}"); + if (!value.TryGetValueAsString(out var valueStr)) + throw new Exception($"Invalid value on savefile dir name: {value}"); + if (index < 1 || index > _save.CurrentDir.Count) + throw new Exception($"Out of bounds index on savefile dir list: {index}"); + + _save.RenameAndNullSavefileValue(_save.CurrentDir.Keys.ElementAt(index - 1), valueStr); + } + + public override void AddValue(DreamValue value) { + if (!value.TryGetValueAsString(out var valueStr)) + throw new Exception($"Invalid value on savefile dir name: {value}"); + _save.AddSavefileDir(valueStr); + + } + + public override void RemoveValue(DreamValue value) { + if (!value.TryGetValueAsString(out var valueStr)) + throw new Exception($"Invalid value on savefile dir name: {value}"); + _save.RemoveSavefileValue(valueStr); + } + + public override void Cut(int start = 1, int end = 0) { + throw new Exception("Cannot cut savefile dir list"); //BYOND actually throws undefined proc for this + } + + public override int GetLength() { + return _save.CurrentDir.Count; + } +} diff --git a/OpenDreamRuntime/Objects/Types/DreamObjectSavefile.cs b/OpenDreamRuntime/Objects/Types/DreamObjectSavefile.cs index 1900d780a0..95c9b4c8d0 100644 --- a/OpenDreamRuntime/Objects/Types/DreamObjectSavefile.cs +++ b/OpenDreamRuntime/Objects/Types/DreamObjectSavefile.cs @@ -1,76 +1,124 @@ -using System.IO; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; using System.Text.Json; +using System.Text.Json.Serialization; +using DMCompiler; using OpenDreamRuntime.Procs; using OpenDreamRuntime.Resources; -using OpenDreamShared.Dream; namespace OpenDreamRuntime.Objects.Types; + public sealed class DreamObjectSavefile : DreamObject { - public sealed class SavefileDirectory : Dictionary { } + private readonly DreamObjectTree _objectTree; + /// + /// Cache list for all savefiles, used to keep track for datums using it + /// public static readonly List Savefiles = new(); - //basically a global database of savefile contents, which each savefile datum points to - this preserves state between savefiles and reduces memory usage - private static readonly Dictionary> SavefileDirectories = new(); - private static readonly HashSet _savefilesToFlush = new(); + + /// + /// Savefiles that have been modified since the last flush, processed at the end of the tick + /// + private static readonly HashSet SavefilesToFlush = new(); + + private static ISawmill? _sawmill; + public override bool ShouldCallNew => false; + /// + /// Temporary savefiles should be deleted when the DreamObjectSavefile is deleted. Temporary savefiles can be created by creating a new savefile datum with a null filename or an entry in the world's resource cache + /// + private bool _isTemporary; - public DreamResource Resource; - public Dictionary Directories => SavefileDirectories[Resource.ResourcePath ?? ""]; - public SavefileDirectory CurrentDir => Directories[_currentDirPath]; + /// + /// basically a global database of savefile contents, which each savefile datum points to - this preserves state between savefiles and reduces memory usage + /// + private static readonly Dictionary SavefileDirectories = new(); - private string _currentDirPath = "/"; + /// + /// Real savefile location on the host OS + /// + public DreamResource Resource = default!; - //Temporary savefiles should be deleted when the DreamObjectSavefile is deleted. Temporary savefiles can be created by creating a new savefile datum with a null filename or an entry in the world's resource cache - private bool _isTemporary = false; + /// + /// The current savefile data holder - the root of the savefile tree + /// + private SFDreamJsonValue _rootNode; - private static ISawmill? _sawmill = null; /// - /// Flushes all savefiles that have been marked as needing flushing. Basically just used to call Flush() between ticks instead of on every write. + /// The current savefile' working dir. This could be a generic primitive /// - public static void FlushAllUpdates() { - _sawmill ??= Logger.GetSawmill("opendream.res"); - foreach (DreamObjectSavefile savefile in _savefilesToFlush) { - try { - savefile.Flush(); - } catch (Exception e) { - _sawmill.Error($"Error flushing savefile {savefile.Resource.ResourcePath}: {e}"); + public SFDreamJsonValue CurrentDir; + + /// + /// It's not *super* clear what this is supposed to indicate other than "this directory has been read with the >> operator" + /// It is reset when cd is set. When savefile.eof is set to -1, it deletes the current dir and sets eof to 0 + /// + private bool _eof = false; + + private string _currentPath = "/"; + + /// + /// The current path, set this to change the Currentdir value + /// + public string CurrentPath { + get => _currentPath; + set { + var tempDir = SeekTo(value, true); + if (tempDir != CurrentDir) { + CurrentDir = tempDir; + _eof = false; + if(value.StartsWith("/")) //absolute path + _currentPath = value; + else //relative path + _currentPath = new DreamPath(_currentPath).AddToPath(value).PathString; } } - _savefilesToFlush.Clear(); } - public DreamObjectSavefile(DreamObjectDefinition objectDefinition) : base(objectDefinition) { + public DreamObjectSavefile(DreamObjectDefinition objectDefinition) : base(objectDefinition) { + CurrentDir = _rootNode = new SFDreamDir(); + _objectTree ??= objectDefinition.ObjectTree; } public override void Initialize(DreamProcArguments args) { base.Initialize(args); - args.GetArgument(0).TryGetValueAsString(out string? filename); + args.GetArgument(0).TryGetValueAsString(out var filename); DreamValue timeout = args.GetArgument(1); //TODO: timeout if (string.IsNullOrEmpty(filename)) { _isTemporary = true; - filename = Path.GetTempPath() + "tmp_opendream_savefile_" + System.DateTime.Now.Ticks.ToString(); + filename = Path.GetTempPath() + "tmp_opendream_savefile_" + DateTime.Now.Ticks; } Resource = DreamResourceManager.LoadResource(filename); if(!SavefileDirectories.ContainsKey(filename)) { //if the savefile hasn't already been loaded, load it or create it - string? data = Resource.ReadAsString(); + var data = Resource.ReadAsString(); if (!string.IsNullOrEmpty(data)) { - SavefileDirectories.Add(filename, JsonSerializer.Deserialize>(data)); + try{ + CurrentDir = _rootNode = JsonSerializer.Deserialize(data)!; + } catch (JsonException e) { //only catch JSON exceptions, other exceptions probably mean something else happened + //fail safe, make this null if something goes super fucky. Prevents accidentally overwrite of non-savefile files. + Resource = null; + throw new InvalidDataException($"Error parsing savefile {filename}: Is the savefile corrupted or using a BYOND version? BYOND savefiles are not compatible with OpenDream. Details: {e}"); + } + SavefileDirectories.Add(filename, _rootNode); } else { - SavefileDirectories.Add(filename, new() { - { "/", new SavefileDirectory() } - }); + //_rootNode is created in constructor + SavefileDirectories.Add(filename, _rootNode); //create the file immediately Flush(); } + } else { + //if the savefile has already been loaded, just point to it + CurrentDir = _rootNode = SavefileDirectories[filename]; } Savefiles.Add(this); @@ -81,53 +129,19 @@ protected override void HandleDeletion() { base.HandleDeletion(); } - public void Flush() { - Resource.Clear(); - Resource.Output(new DreamValue(JsonSerializer.Serialize(Directories))); - } - - public void Close() { - Flush(); - if (_isTemporary && Resource.ResourcePath != null) { - File.Delete(Resource.ResourcePath); - } - //check to see if the file is still in use by another savefile datum - if(Resource.ResourcePath != null) { - bool fineToDelete = true; - foreach (var savefile in Savefiles) - if (savefile != this && savefile.Resource.ResourcePath == Resource.ResourcePath) { - fineToDelete = false; - break; - } - if (fineToDelete) - SavefileDirectories.Remove(Resource.ResourcePath); - } - Savefiles.Remove(this); - } - protected override bool TryGetVar(string varName, out DreamValue value) { switch (varName) { case "cd": - value = new DreamValue(_currentDirPath); + value = new DreamValue(CurrentPath); return true; case "eof": - value = new DreamValue(0); //TODO: What's a savefile buffer? + value = _eof ? DreamValue.True : DreamValue.False; return true; case "name": value = new DreamValue(Resource.ResourcePath ?? "[no path]"); return true; case "dir": - DreamList dirList = ObjectTree.CreateList(); - - foreach (string dirPath in Directories.Keys) { - if (dirPath.StartsWith(_currentDirPath)) { - dirList.AddValue(new DreamValue(dirPath)); - } - } - - //TODO: dirList.Add(), dirList.Remove() should affect the directories in a savefile - - value = new DreamValue(dirList); + value = new DreamValue(new SavefileDirList(ObjectTree.List.ObjectDefinition, this)); return true; default: return base.TryGetVar(varName, out value); @@ -140,9 +154,28 @@ protected override void SetVar(string varName, DreamValue value) { if (!value.TryGetValueAsString(out var cdTo)) throw new Exception($"Cannot change directory to {value}"); - ChangeDirectory(cdTo); + CurrentPath = cdTo; break; - case "eof": // TODO: What's a savefile buffer? + case "eof": + if(value.TryGetValueAsInteger(out int intValue) && intValue != 0){ + if(intValue == -1) { + if(CurrentDir != _rootNode) { + SFDreamJsonValue parentDir = SeekTo(".."); + //wipe the value of the current dir but keep any subdirs + SFDreamDir newCurrentDir = new SFDreamDir(); + foreach(var key in CurrentDir.Keys) { + newCurrentDir[key] = CurrentDir[key]; + } + CurrentDir.Clear(); + parentDir[CurrentPath.Split("/").Last()] = newCurrentDir; + } else { + CurrentDir.Clear(); + } + } + _eof = true; + } else { + _eof = false; + } break; default: throw new Exception($"Cannot set var \"{varName}\" on savefiles"); @@ -150,31 +183,433 @@ protected override void SetVar(string varName, DreamValue value) { } public override DreamValue OperatorIndex(DreamValue index) { - if (!index.TryGetValueAsString(out string? entryName)) + if (!index.TryGetValueAsString(out var entryName)) throw new Exception($"Invalid savefile index {index}"); - if (CurrentDir.TryGetValue(entryName, out DreamValue entry)) { - return entry; //TODO: This should be something like value.DMProc("Read", new DreamProcArguments(this)) for DreamObjects and a copy for everything else - } else { - return DreamValue.Null; - } + return GetSavefileValue(entryName); } public override void OperatorIndexAssign(DreamValue index, DreamValue value) { - if (!index.TryGetValueAsString(out string? entryName)) + if (!index.TryGetValueAsString(out var entryName)) throw new Exception($"Invalid savefile index {index}"); - CurrentDir[entryName] = value; //TODO: This should be something like value.DMProc("Write", new DreamProcArguments(this)) for DreamObjects and a copy for everything else - _savefilesToFlush.Add(this); //mark this as needing flushing + if (entryName == ".") { + SetSavefileValue(null, value); + return; + } + + SetSavefileValue(entryName, value); + } + + public override void OperatorOutput(DreamValue value) { + SetSavefileValue(null, value); + } + + public DreamValue OperatorInput() { + _eof = true; + return GetSavefileValue(null); + } + + /// + /// Flushes all savefiles that have been marked as needing flushing. Basically just used to call Flush() between ticks instead of on every write. + /// + public static void FlushAllUpdates() { + _sawmill ??= Logger.GetSawmill("opendream.res"); + foreach (DreamObjectSavefile savefile in SavefilesToFlush) { + try { + savefile.Flush(); + } catch (Exception e) { + _sawmill.Error($"Error flushing savefile {savefile.Resource.ResourcePath}: {e}"); + } + } + SavefilesToFlush.Clear(); } - private void ChangeDirectory(string path) { - if (path.StartsWith('/')) { - _currentDirPath = path; + public void Close() { + Flush(); + if (_isTemporary && Resource.ResourcePath != null) { + File.Delete(Resource.ResourcePath); + } + //check to see if the file is still in use by another savefile datum + if(Resource.ResourcePath != null) { + var fineToDelete = true; + foreach (var savefile in Savefiles) { + if (savefile == this || savefile.Resource.ResourcePath != Resource.ResourcePath) continue; + fineToDelete = false; + break; + } + + if (fineToDelete) + SavefileDirectories.Remove(Resource.ResourcePath); + } + Savefiles.Remove(this); + } + + public void Flush() { + Resource.Clear(); + Resource.Output(new DreamValue(JsonSerializer.Serialize(_rootNode))); + } + + /// + /// Attempts to go to said path relative to CurrentPath (you still have to set CurrentDir) + /// + private SFDreamJsonValue SeekTo(string to, bool createPath=false) { + SFDreamJsonValue tempDir = _rootNode; + + var searchPath = new DreamPath(_currentPath).AddToPath(to).PathString; //relative path + if(to.StartsWith("/")) //absolute path + searchPath = to; + + foreach (var path in searchPath.Split("/")) { + if(path == string.Empty) + continue; + if (!tempDir.TryGetValue(path, out var newDir)) { + if(createPath) + newDir = tempDir[path] = new SFDreamDir(); + else + return tempDir; + } + tempDir = newDir; + } + return tempDir; + } + + public DreamValue GetSavefileValue(string? index) { + if (index == null) { + return DeserializeJsonValue(CurrentDir); + } + + return DeserializeJsonValue(SeekTo(index, true)); //should create the path if it doesn't exist + } + + public void RemoveSavefileValue(string index){ + if (CurrentDir.Remove(index)) { + SavefilesToFlush.Add(this); + } + } + + public void RenameAndNullSavefileValue(string index, string newIndex){ + if(CurrentDir.TryGetValue(index, out var value)) { + CurrentDir.Remove(index); + SFDreamDir newDir = new SFDreamDir(); + foreach(var key in value.Keys) { + newDir[key] = value[key]; + } + CurrentDir[newIndex] = newDir; + SavefilesToFlush.Add(this); + } + } + + public void AddSavefileDir(string index){ + SeekTo(index, true); + } + + public void SetSavefileValue(string? index, DreamValue value) { + if (index == null) { + SFDreamJsonValue newCurrentDir = SerializeDreamValue(value); + foreach(var key in CurrentDir.Keys) { + if(newCurrentDir.ContainsKey(key)) //if the new dir has a key that overwrites the old one, skip it + continue; + newCurrentDir[key] = CurrentDir[key]; + } + + if(CurrentDir != _rootNode) { + SFDreamJsonValue parentDir = SeekTo(".."); + parentDir[CurrentPath.Split("/").Last()] = newCurrentDir; + } else { + CurrentDir = _rootNode = newCurrentDir; + } + SavefilesToFlush.Add(this); + return; + } + + var pathArray = index.Split("/"); + if (pathArray.Length == 1) { + var newValue = SerializeDreamValue(value); + if(CurrentDir.TryGetValue(index, out var oldValue)) { + foreach(var key in oldValue.Keys) { + if(newValue.ContainsKey(key)) //if the new dir has a key that overwrites the old one, skip it + continue; + newValue[key] = oldValue[key]; + } + } + CurrentDir[index] = newValue; } else { - _currentDirPath += path; + string oldPath = CurrentPath; + CurrentPath = new DreamPath(index).AddToPath("../").PathString; //get the parent of the target path + SetSavefileValue(pathArray[pathArray.Length - 1], value); + CurrentPath = oldPath; + } + SavefilesToFlush.Add(this); + } + + /// + /// Turn the json magic value into real DM values + /// + public DreamValue DeserializeJsonValue(SFDreamJsonValue value) { + switch (value) { + case SFDreamFileValue sfDreamFileValue: + return new DreamValue(DreamResourceManager.CreateResource(Convert.FromBase64String(sfDreamFileValue.Data))); + case SFDreamListValue sfDreamListValue: + var l = ObjectTree.CreateList(); + for(int i=0; i < sfDreamListValue.AssocKeys.Count; i++) { + if(sfDreamListValue.AssocData?[i] != null) //note that null != DreamValue.Null + l.SetValue(DeserializeJsonValue(sfDreamListValue.AssocKeys[i]), DeserializeJsonValue(sfDreamListValue.AssocData[i]!)); + else + l.AddValue(DeserializeJsonValue(sfDreamListValue.AssocKeys[i])); + } + return new DreamValue(l); + case SFDreamObjectPathValue sfDreamObjectPath: + SFDreamJsonValue storedObjectVars = sfDreamObjectPath; + SFDreamJsonValue searchDir = sfDreamObjectPath; + while(searchDir != _rootNode) + if(!searchDir.TryGetValue(sfDreamObjectPath.Path, out storedObjectVars!)) + searchDir = SeekTo(".."); + else + break; + + if(storedObjectVars!.TryGetValue("type", out SFDreamJsonValue? storedObjectTypeJson) && DeserializeJsonValue(storedObjectTypeJson).TryGetValueAsType(out TreeEntry? objectTypeActual)) { + DreamObject resultObj = _objectTree.CreateObject(objectTypeActual); + foreach(string key in storedObjectVars.Keys){ + if(key == "type" || storedObjectVars[key] is SFDreamDir) //is type or a non-valued dir + continue; + resultObj.SetVariable(key, DeserializeJsonValue(storedObjectVars[key])); + } + resultObj.InitSpawn(new DreamProcArguments()); + resultObj.SpawnProc("Read", null, [new DreamValue(this)]); + return new DreamValue(resultObj); + } else + throw new InvalidDataException("Unable to deserialize object in savefile: " + ((storedObjectTypeJson as SFDreamType) is null ? "no type specified (corrupted savefile?)" : "invalid type "+((SFDreamType)storedObjectTypeJson!).TypePath)); + case SFDreamType sfDreamTypeValue: + if(_objectTree.TryGetTreeEntry(sfDreamTypeValue.TypePath, out var type)) { + return new DreamValue(type); + } else { + return DreamValue.Null; + } + case SFDreamPrimitive sfDreamPrimitive: + return sfDreamPrimitive.Value; + } + return DreamValue.Null; + } + + /// + /// Serialize DM values/objects into savefile data + /// + public SFDreamJsonValue SerializeDreamValue(DreamValue val, int objectCount = 0) { + switch (val.Type) { + case DreamValue.DreamValueType.String: + case DreamValue.DreamValueType.Float: + return new SFDreamPrimitive { Value = val }; + case DreamValue.DreamValueType.DreamType: + return new SFDreamType { TypePath = val.MustGetValueAsType().Path }; + case DreamValue.DreamValueType.DreamResource: + var dreamResource = val.MustGetValueAsDreamResource(); + return new SFDreamFileValue { + Name = dreamResource.ResourcePath, + Ext = dreamResource.ResourcePath!.Split('.').Last(), + Length = dreamResource.ResourceData!.Length, + Crc32 = CalculateCrc32(dreamResource.ResourceData), + Data = Convert.ToBase64String(dreamResource.ResourceData) + }; + case DreamValue.DreamValueType.DreamObject: + if (val.TryGetValueAsDreamList(out var dreamList)) { + SFDreamListValue jsonEncodedList = new SFDreamListValue(); + int thisObjectCount = objectCount; + if(dreamList.IsAssociative) + jsonEncodedList.AssocData = new List(dreamList.GetLength()); //only init the list if it's needed + + foreach (var keyValue in dreamList.GetValues()) { //get all normal values and keys + if(keyValue.TryGetValueAsDreamObject(out var _) && !keyValue.IsNull) { + SFDreamJsonValue jsonEncodedObject = SerializeDreamValue(keyValue, thisObjectCount); + //merge the object subdirectories into the list parent directory + foreach(var key in jsonEncodedObject.Keys) { + jsonEncodedList[key] = jsonEncodedObject[key]; + } + //we already merged the nodes into the parent, so clear them from the child + jsonEncodedObject.Clear(); + //add the object path to the list + jsonEncodedList.AssocKeys.Add(jsonEncodedObject); + thisObjectCount++; + } else { + jsonEncodedList.AssocKeys.Add(SerializeDreamValue(keyValue)); + } + if(dreamList.IsAssociative) { //if it's an assoc list, check if this value is a key + if(!dreamList.ContainsKey(keyValue)) { + jsonEncodedList.AssocData!.Add(null); //store an actual null if this key does not have an associated value - this is distinct from storing DreamValue.Null + } else { + var assocValue = dreamList.GetValue(keyValue); + if(assocValue.TryGetValueAsDreamObject(out var _) && !assocValue.IsNull) { + SFDreamJsonValue jsonEncodedObject = SerializeDreamValue(assocValue, thisObjectCount); + //merge the object subdirectories into the list parent directory + foreach(var key in jsonEncodedObject.Keys) { + jsonEncodedList[key] = jsonEncodedObject[key]; + } + //we already merged the nodes into the parent, so clear them from the child + jsonEncodedObject.Clear(); + //add the object path to the list + jsonEncodedList.AssocData!.Add(jsonEncodedObject); + thisObjectCount++; + } else { + jsonEncodedList.AssocData!.Add(SerializeDreamValue(assocValue)); + } + } + } + } + + return jsonEncodedList; + } else if( val.TryGetValueAsDreamObject(out var dreamObject) && !(dreamObject is null)) { //dreamobject can be null if it's disposed + if(val.TryGetValueAsDreamObject(out var savefile)) { + //if this is a savefile, just return a filedata object with it encoded + savefile.Flush(); //flush the savefile to make sure the backing resource is up to date + return new SFDreamFileValue(){ + Name = savefile.Resource.ResourcePath, + Ext = ".sav", + Length = savefile.Resource.ResourceData!.Length, + Crc32 = CalculateCrc32(savefile.Resource.ResourceData), + Data = Convert.ToBase64String(savefile.Resource.ResourceData) + }; + } + + SFDreamObjectPathValue jsonEncodedObject = new SFDreamObjectPathValue(){Path = $".{objectCount}"}; + SFDreamDir objectVars = new SFDreamDir(); + //special handling for type, because it's const but we need to save it anyway + objectVars["type"] = SerializeDreamValue(dreamObject.GetVariable("type")); + foreach (var key in dreamObject.ObjectDefinition.Variables.Keys) { + if((dreamObject.ObjectDefinition.ConstVariables is not null && dreamObject.ObjectDefinition.ConstVariables.Contains(key)) || (dreamObject.ObjectDefinition.TmpVariables is not null && dreamObject.ObjectDefinition.TmpVariables.Contains(key))) + continue; //skip const & tmp variables (they're not saved) + DreamValue objectVarVal = dreamObject.GetVariable(key); + if(dreamObject.ObjectDefinition.Variables[key] == objectVarVal || (objectVarVal.TryGetValueAsDreamObject(out DreamObject? equivTestObject) && equivTestObject != null && equivTestObject.OperatorEquivalent(dreamObject.ObjectDefinition.Variables[key]).IsTruthy())) + continue; //skip default values - equivalence check used for lists and objects + objectVars[key] = SerializeDreamValue(objectVarVal); + } + + //special handling for /icon since the icon var doesn't actually contain the icon data + if(DreamResourceManager.TryLoadIcon(val, out var iconResource)) { + objectVars["icon"] = new SFDreamFileValue(){ + Ext=".dmi", + Length = iconResource.ResourceData!.Length, + Crc32 = CalculateCrc32(iconResource.ResourceData), + Data = Convert.ToBase64String(iconResource.ResourceData)}; + } + //Call the Write proc on the object - note that this is a weird one, it does not need to call parent to the native function to save the object + dreamObject.SpawnProc("Write", null, [new DreamValue(this)]); + jsonEncodedObject[jsonEncodedObject.Path] = objectVars; + return jsonEncodedObject; + } + break; + // noop + case DreamValue.DreamValueType.DreamProc: + case DreamValue.DreamValueType.Appearance: + break; + } + + return new SFDreamPrimitive(); + } + + private uint CalculateCrc32(byte[] data){ + const uint polynomial = 0xEDB88320; + uint crc = 0xFFFFFFFF; + + for (int i = 0; i < data.Length; i++){ + crc ^= data[i]; + for (int j = 0; j < 8; j++){ + if ((crc & 1) == 1) + crc = (crc >> 1) ^ polynomial; + else + crc >>= 1; + } + } + return ~crc; + } + + #region JSON Savefile Types + + /// + /// Dumb structs for savefile + /// + [JsonPolymorphic] + [JsonDerivedType(typeof(SFDreamDir), typeDiscriminator: "dir")] + [JsonDerivedType(typeof(SFDreamPrimitive), typeDiscriminator: "primitive")] + [JsonDerivedType(typeof(SFDreamType), typeDiscriminator: "typepath")] + [JsonDerivedType(typeof(SFDreamListValue), typeDiscriminator: "list")] + [JsonDerivedType(typeof(SFDreamObjectPathValue), typeDiscriminator: "objectpath")] + [JsonDerivedType(typeof(SFDreamFileValue), typeDiscriminator: "file")] + public abstract class SFDreamJsonValue { + //because dictionary implements its own serialization, we basically just store a dict internally and wrap the functions we need instead of inheriting from it + [JsonInclude] + private Dictionary _nodes = new(); + + [JsonIgnore] + public SFDreamJsonValue this[string key] { + get => _nodes[key]; + set => _nodes[key] = value; } + public bool TryGetValue(string key, [MaybeNullWhen(false)] out SFDreamJsonValue value) => _nodes.TryGetValue(key, out value); + [JsonIgnore] + public Dictionary.KeyCollection Keys => _nodes.Keys; + [JsonIgnore] + public int Count => _nodes.Count; + public void Clear() => _nodes.Clear(); + public bool Remove(string key) => _nodes.Remove(key); + public bool ContainsKey(string key) => _nodes.ContainsKey(key); - Directories.TryAdd(_currentDirPath, new SavefileDirectory()); } + + /// + /// Dummy type for directories + /// + public sealed class SFDreamDir : SFDreamJsonValue; + /// + /// Standard DM types except objects and type paths + /// + public sealed class SFDreamPrimitive : SFDreamJsonValue { + [JsonInclude] + public DreamValue Value = DreamValue.Null; + } + /// + /// Standard DM type paths + /// + public sealed class SFDreamType : SFDreamJsonValue { + [JsonInclude] + public string TypePath = ""; + } + + /// + /// List type, with support for associative lists + /// + public sealed class SFDreamListValue : SFDreamJsonValue { + [JsonInclude] + public List AssocKeys = new(); + [JsonInclude] + public List? AssocData; + } + + /// + /// Dummy type for objects (it shows up as `object(relative-path-to-vars-dir)`) + /// + public sealed class SFDreamObjectPathValue : SFDreamJsonValue { + [JsonInclude] + public required string Path; + } + + /// + /// DreamResource holder, encodes said file into base64 + /// + public sealed class SFDreamFileValue : SFDreamJsonValue { + [JsonInclude] + public string? Name; + [JsonInclude] + public string? Ext; + [JsonInclude] + public required int Length; + [JsonInclude] + public uint Crc32 = 0x00000000; + [JsonInclude] + public string Encoding = "base64"; + [JsonInclude] + public required string Data; + } + + #endregion + } diff --git a/OpenDreamRuntime/Procs/DMOpcodeHandlers.cs b/OpenDreamRuntime/Procs/DMOpcodeHandlers.cs index 5148001246..dab9d81c23 100644 --- a/OpenDreamRuntime/Procs/DMOpcodeHandlers.cs +++ b/OpenDreamRuntime/Procs/DMOpcodeHandlers.cs @@ -2104,6 +2104,11 @@ public static ProcStatus Input(DMProcState state) { state.GetReferenceValue(leftRef); state.GetReferenceValue(rightRef); } + } else if (state.GetReferenceValue(leftRef).TryGetValueAsDreamObject(out var savefile)) { + // Savefiles get some special treatment. + // "savefile >> B" is the same as "B = savefile[current_dir]" + state.AssignReference(rightRef, savefile.OperatorInput()); + return ProcStatus.Continue; } throw new NotImplementedException($"Input operation is unimplemented for {leftRef} and {rightRef}"); diff --git a/OpenDreamRuntime/Procs/Native/DreamProcNativeSavefile.cs b/OpenDreamRuntime/Procs/Native/DreamProcNativeSavefile.cs index 8771001570..6513591624 100644 --- a/OpenDreamRuntime/Procs/Native/DreamProcNativeSavefile.cs +++ b/OpenDreamRuntime/Procs/Native/DreamProcNativeSavefile.cs @@ -1,6 +1,8 @@ using OpenDreamRuntime.Objects; using OpenDreamRuntime.Objects.Types; using DreamValueTypeFlag = OpenDreamRuntime.DreamValue.DreamValueTypeFlag; +using System.IO; +using System.Linq; namespace OpenDreamRuntime.Procs.Native; @@ -9,29 +11,124 @@ internal static class DreamProcNativeSavefile { [DreamProcParameter("path", Type = DreamValueTypeFlag.String)] [DreamProcParameter("file", Type = DreamValueTypeFlag.String | DreamValueTypeFlag.DreamResource)] public static DreamValue NativeProc_ExportText(NativeProc.Bundle bundle, DreamObject? src, DreamObject? usr) { - // Implementing this correctly is a fair amount of effort, and the only use of it I'm aware of is icon2base64() - // So this implements it just enough to get that working var savefile = (DreamObjectSavefile)src!; DreamValue path = bundle.GetArgument(0, "path"); DreamValue file = bundle.GetArgument(1, "file"); - if (!path.TryGetValueAsString(out var pathStr) || !file.IsNull) { - throw new NotImplementedException("General support for ExportText() is not implemented"); + string oldPath = savefile.CurrentPath; + if(!path.IsNull && path.TryGetValueAsString(out var pathStr)) { //invalid path values are just ignored in BYOND + savefile.CurrentPath = pathStr; } - // Treat pathStr as the name of a value in the current dir, as that's how icon2base64() uses it - if (!savefile.CurrentDir.TryGetValue(pathStr, out var exportValue)) { - throw new NotImplementedException("General support for ExportText() is not implemented"); + string result = ExportTextInternal(savefile); + + savefile.CurrentPath = oldPath; //restore current directory after a query + if(!file.IsNull){ + if(file.TryGetValueAsString(out var fileStr)) { + File.WriteAllText(fileStr, result); + } else if(file.TryGetValueAsDreamResource(out var fileResource)) { + fileResource.Output(new DreamValue(result)); + } else { + throw new ArgumentException($"Invalid file value {file}"); + } + } + return new DreamValue(result); + } + + private static string ExportTextInternal(DreamObjectSavefile savefile, int indent = int.MinValue) { + string result = ""; + var value = savefile.CurrentDir; + var key = savefile.CurrentPath.Split('/').Last(); + if(indent == int.MinValue){ + key = "."; + indent = 0; //either way, set indent to 0 so we know we're not at the start anymore + } + switch(value) { + case DreamObjectSavefile.SFDreamPrimitive primitiveValue: + if(primitiveValue.Value.IsNull) + result += $"{new string('\t', indent)}{key} = null\n"; + else switch(primitiveValue.Value.Type){ + case DreamValue.DreamValueType.String: + result += $"{new string('\t', indent)}{key} = \"{primitiveValue.Value.MustGetValueAsString()}\"\n"; + break; + case DreamValue.DreamValueType.Float: + result += $"{new string('\t', indent)}{key} = {primitiveValue.Value.MustGetValueAsFloat()}\n"; + break; + } + break; + case DreamObjectSavefile.SFDreamFileValue fileValue: + result += $"{new string('\t', indent)}{key} = \nfiledata(\""; + result += $"name={fileValue.Name};"; + result += $"ext={fileValue.Ext};"; + result += $"length={fileValue.Length};"; + result += $"crc32=0x{fileValue.Crc32:x8};"; + result += $"encoding=base64\",{{\"\n{fileValue.Data}\n\"}}"; + result += ")\n"; + break; + case DreamObjectSavefile.SFDreamObjectPathValue objectValue: + result += $"{new string('\t', indent)}{key} = object(\"{objectValue.Path}\")\n"; + break; + case DreamObjectSavefile.SFDreamType typeValue: + result += $"{new string('\t', indent)}{key} = {typeValue.TypePath}\n"; + break; + case DreamObjectSavefile.SFDreamListValue listValue: + result += $"{new string('\t', indent)}{key} = {ExportTextInternalListFormat(listValue)}\n"; + break; + case DreamObjectSavefile.SFDreamDir: + if(key==".") + result += "\n"; + else + result += $"{new string('\t', indent)}{key}\n"; + break; + default: + throw new NotImplementedException($"Unhandled type {key} = {value} in ExportText()"); } - if (!bundle.ResourceManager.TryLoadIcon(exportValue, out var icon)) { - throw new NotImplementedException("General support for ExportText() is not implemented"); + if(string.IsNullOrEmpty(key) || key==".") + indent = -1; //don't indent the subdirs of directly accessed keys or root dir + + foreach (string subKey in savefile.CurrentDir.Keys) { + savefile.CurrentPath = subKey; + result += ExportTextInternal(savefile, indent + 1); + savefile.CurrentPath = "../"; + } + return result; + } + + private static string ExportTextInternalListFormat(DreamObjectSavefile.SFDreamJsonValue listEntry){ + switch(listEntry) { + case DreamObjectSavefile.SFDreamPrimitive primitiveValue: + if(primitiveValue.Value.IsNull) + return "null"; + else switch(primitiveValue.Value.Type){ + case DreamValue.DreamValueType.String: + return $"\"{primitiveValue.Value.MustGetValueAsString()}\""; + case DreamValue.DreamValueType.Float: + return $"{primitiveValue.Value.MustGetValueAsFloat()}"; + } + throw new NotImplementedException($"Unhandled list entry type {listEntry} in ExportTextInternalListFormat()"); + case DreamObjectSavefile.SFDreamObjectPathValue objectValue: + return $"object(\"{objectValue.Path}\")"; + case DreamObjectSavefile.SFDreamType typeValue: + return $"{typeValue.TypePath}"; + case DreamObjectSavefile.SFDreamListValue listValue: + string result = "list("; + + for(int i=0; i