diff --git a/Content.Tests/DMProject/Tests/Savefile/BasicReadAndWrite.dm b/Content.Tests/DMProject/Tests/Savefile/BasicReadAndWrite.dm index 12ccc058f1..d1fa86cc75 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,13 +8,27 @@ // 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) + + // 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") diff --git a/Content.Tests/DMProject/Tests/Savefile/ExportAndImportText.dm b/Content.Tests/DMProject/Tests/Savefile/ExportAndImportText.dm new file mode 100644 index 0000000000..c302524f73 --- /dev/null +++ b/Content.Tests/DMProject/Tests/Savefile/ExportAndImportText.dm @@ -0,0 +1,62 @@ +/obj/savetest + var/obj/savetest/recurse = null + 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/obj/savetest/O = new() //create a test object + O.name = "test" + O.recurse = O + + 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() + + ASSERT(F.ExportText("dir6/subdir6") == ". = 321\n") + ASSERT(F.ExportText("dir6/subdir6/") == ". = 321\n") + ASSERT(F.ExportText("dir6") == "\nsubdir6 = 321\n") + + + var/import_test = @{" +dir1 = 1080 +dir2 = "object(\".0\")" +dir4 = "the afternoon of the 3rd" +dir6 + subdir6 = 321 + subsubdir + key = "value" +dir7 = null +"} + + var/savefile/F2 = new() + F2.ImportText("/",import_test) + world.log << F2.ExportText() + ASSERT(F2["dir1"] == 1080) + ASSERT(F2["dir2"] == "object(\".0\")") + ASSERT(F2["dir4"] == "the afternoon of the 3rd") + ASSERT(F2["dir6/subdir6"] == 321) + ASSERT(F2["dir6/subdir6/subsubdir/key"] == "value") + ASSERT(F2["dir7"] == null) \ No newline at end of file diff --git a/Content.Tests/DMProject/Tests/Savefile/datum_saving.dm b/Content.Tests/DMProject/Tests/Savefile/datum_saving.dm new file mode 100644 index 0000000000..d8e099ac9b --- /dev/null +++ b/Content.Tests/DMProject/Tests/Savefile/datum_saving.dm @@ -0,0 +1,43 @@ +/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 + +/datum/foo/Write(savefile/F) + . = ..(F) + ASSERT(F["type"] == /datum/foo) + ASSERT(F["current_map"] == null) + ASSERT(F["default_cube"] == null) + +/proc/RunTest() + var/savefile/S = new("delme.sav") + + 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 = new() + S["mapdata"] >> W + + // load test + ASSERT(istype(W)) + ASSERT(W.best_map == "pl_pier") + ASSERT(W.worst_map == null) + ASSERT(W.null_me == null) + + fdel("delme.sav") + return TRUE + \ No newline at end of file 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/DreamObjectSavefile.cs b/OpenDreamRuntime/Objects/Types/DreamObjectSavefile.cs index 1900d780a0..e73e49498b 100644 --- a/OpenDreamRuntime/Objects/Types/DreamObjectSavefile.cs +++ b/OpenDreamRuntime/Objects/Types/DreamObjectSavefile.cs @@ -1,73 +1,140 @@ -using System.IO; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; using System.Text.Json; +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 { } + public DreamObjectSavefile(DreamObjectDefinition objectDefinition) : base(objectDefinition) { } + + #region JSON Savefile Types + + /// + /// Dumb structs for savefile + /// + [SuppressMessage("Usage", "RA0003:Class must be explicitly marked as [Virtual], abstract, static or sealed")] + public class DreamJsonValue : Dictionary { } + + /// + /// Standard byond types except objects + /// + public sealed class DreamPrimitive : DreamJsonValue { + public DreamValue Value = DreamValue.Null; + } + + /// + /// Unique type for Objects + /// + public sealed class DreamObjectValue : DreamJsonValue { } + + public sealed class DreamListValue : DreamJsonValue { + public List Data; + public Dictionary? AssocData; + } + + /// + /// Dummy type for objects that reference itself (it shows up as `object(..)`) + /// + public sealed class DreamPathValue : DreamJsonValue { + public required string Path; + } + + /// + /// DreamResource holder, encodes said file into base64 + /// + public sealed class DreamFileValue : DreamJsonValue { + public string? Name; + public string? Ext; + public required int Length; + public int Crc32 = 0x00000000; + public string Encoding = "base64"; + public required string Data; + } + + #endregion + + public override bool ShouldCallNew => false; + + /// + /// 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(); - public override bool ShouldCallNew => false; + private static ISawmill? _sawmill; - public DreamResource Resource; - public Dictionary Directories => SavefileDirectories[Resource.ResourcePath ?? ""]; - public SavefileDirectory CurrentDir => Directories[_currentDirPath]; + /// 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; - private string _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(); - //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; + /// + /// Real savefile location on the host OS + /// + public DreamResource Resource = default!; - 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 data holder /// - 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(); - } + public DreamJsonValue Savefile = default!; + + /// + /// The current savefile' working dir. This could be a generic primitive + /// + public DreamJsonValue CurrentDir; - public DreamObjectSavefile(DreamObjectDefinition objectDefinition) : base(objectDefinition) { + private string _currentPath = "/"; + /// + /// The current path, set this to change the Currentdir value + /// + public string CurrentPath { + get => _currentPath; + set { + var tempDir = SeekTo(value); + if (tempDir != CurrentDir) { + CurrentDir = tempDir; + if(value.StartsWith("/")) //absolute path + _currentPath = value; + else //relative path + _currentPath = new DreamPath(_currentPath).AddToPath(value).PathString; + + } + } } 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)); + CurrentDir = Savefile = JsonSerializer.Deserialize(data); + SavefileDirectories.Add(filename, Savefile); } else { - SavefileDirectories.Add(filename, new() { - { "/", new SavefileDirectory() } - }); + CurrentDir = Savefile = new DreamJsonValue(); + SavefileDirectories.Add(filename, Savefile); //create the file immediately Flush(); } @@ -81,34 +148,10 @@ 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? @@ -118,15 +161,14 @@ protected override bool TryGetVar(string varName, out DreamValue value) { return true; case "dir": DreamList dirList = ObjectTree.CreateList(); - - foreach (string dirPath in Directories.Keys) { - if (dirPath.StartsWith(_currentDirPath)) { - dirList.AddValue(new DreamValue(dirPath)); - } - } + // TODO reimplement + // foreach (var 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); return true; default: @@ -140,7 +182,7 @@ 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? break; @@ -150,31 +192,208 @@ 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); + } + + /// + /// 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(); + } + + 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(Savefile))); } - private void ChangeDirectory(string path) { - if (path.StartsWith('/')) { - _currentDirPath = path; - } else { - _currentDirPath += path; + /// + /// Attempts to go to said path relative to CurrentPath (you still have to set CurrentDir) + /// + private DreamJsonValue SeekTo(string to) { + DreamJsonValue tempDir = Savefile; + + var searchPath = new DreamPath(_currentPath).AddToPath(to).PathString; //relative path + if(to.StartsWith("/")) //absolute path + searchPath = to; + + foreach (var path in searchPath.Split("/")) { + if (!tempDir.TryGetValue(path, out var newDir)) { + newDir = tempDir[path] = new DreamJsonValue(); + } + tempDir = newDir; + } + return tempDir; + } + + public DreamValue GetSavefileValue(string? index) { + if (index == null) { + return RealizeJsonValue(CurrentDir); + } + + return RealizeJsonValue(index.Split("/").Length == 1 ? CurrentDir[index] : SeekTo(index)); + } + + public void SetSavefileValue(string? index, DreamValue value) { + // TODO reimplement nulling values when cd + if (index == null) { + CurrentDir[$".{CurrentDir.Count}"] = SerializeDreamValue(value); + _savefilesToFlush.Add(this); + return; } - Directories.TryAdd(_currentDirPath, new SavefileDirectory()); + var pathArray = index.Split("/"); + if (pathArray.Length == 1) { + CurrentDir[index] = SerializeDreamValue(value); + _savefilesToFlush.Add(this); + return; + } + + // go to said dir, seek down and get the last path BEFORE we index the thing + SeekTo(new DreamPath(index).AddToPath("../").PathString)[pathArray[pathArray.Length - 1]] = SerializeDreamValue(value); + _savefilesToFlush.Add(this); } + + /// + /// Turn the json magic value into real byond values + /// + public DreamValue RealizeJsonValue(DreamJsonValue value) { + switch (value) { + case DreamFileValue dreamFileValue: + return new DreamValue(DreamResourceManager.CreateResource(Convert.FromBase64String(dreamFileValue.Data))); + case DreamListValue dreamListValue: + var l = ObjectTree.CreateList(); + if (dreamListValue.AssocData != null) { + foreach (var kv in dreamListValue.AssocData) + { + l.SetValue(kv.Key, kv.Value); + } + } else { + for (var i = 0; i < dreamListValue.Data.Count; i++) { + l.SetValue(new DreamValue(i+1), dreamListValue.Data[i], true); + } + } + return new DreamValue(l); + case DreamObjectValue dreamObjectValue: + // todo DOV should store WHERE is the actual path for data (normaly its ../'.0') + if (!dreamObjectValue.TryGetValue(".0", out var saveData)) + break; + + if (saveData.TryGetValue("type", out var unserialType) && unserialType is DreamPrimitive primtype) { + primtype.Value.MustGetValueAsType(); + var newObj = GetProc("New").Spawn(this, new DreamProcArguments(primtype.Value)); + var dObj = newObj.MustGetValueAsDreamObject()!; + + foreach (var key in dObj.ObjectDefinition.Variables.Keys) { + DreamValue val = DreamValue.Null; + if (saveData.TryGetValue(key, out var dreamObjVal)) { + val = (dreamObjVal is DreamPathValue) ? newObj : RealizeJsonValue(dreamObjVal); + } + dObj.SetVariable(key, val); + } + + return newObj; + } + break; + case DreamPrimitive dreamPrimitive: + return dreamPrimitive.Value; + } + return DreamValue.Null; + } + + /// + /// Serialize byond values/objects into savefile data + /// + public DreamJsonValue SerializeDreamValue(DreamValue val) { + switch (val.Type) { + case DreamValue.DreamValueType.String: + case DreamValue.DreamValueType.Float: + case DreamValue.DreamValueType.DreamType: + return new DreamPrimitive { Value = val }; + case DreamValue.DreamValueType.DreamResource: + var dreamResource = val.MustGetValueAsDreamResource(); + return new DreamFileValue { + Length = dreamResource.ResourceData!.Length, + // Crc32 = new System.IO.Hashing.Crc32(). + Data = Convert.ToBase64String(dreamResource.ResourceData) + }; + case DreamValue.DreamValueType.DreamObject: + if (val.TryGetValueAsDreamList(out var dreamList)) { + return new DreamListValue { + Data = dreamList.GetValues(), + AssocData = dreamList.IsAssociative ? dreamList.GetAssociativeValues() : null + }; + } + + // TODO stub code here, for some reason type isnt saving or something + // TryGetVar("type", out var tgv); + // var tmpSavefile = GetProc("New").Spawn(this, new DreamProcArguments(tgv)); + // tmpSavefile.TryGetValueAsDreamObject(out var tmpSavefileReal); + // Debug.Assert(tmpSavefileReal != null); + // + // var theObj = val.MustGetValueAsDreamObject()!; + // theObj.GetProc("Write").Spawn(theObj, new DreamProcArguments(tmpSavefile)); + // tmpSavefileReal!.SetSavefileValue("type", theObj.GetVariable("type")); + // tmpSavefileReal.Delete(); + + // return new DreamObjectValue { + // [".0"] = new DreamPrimitive() //tmpSavefileReal.Savefile + // }; + break; + // noop + case DreamValue.DreamValueType.DreamProc: + case DreamValue.DreamValueType.Appearance: + break; + } + + return new DreamPrimitive(); + } + } diff --git a/OpenDreamRuntime/Procs/Native/DreamProcNative.cs b/OpenDreamRuntime/Procs/Native/DreamProcNative.cs index d4dabd739c..9800dafbf5 100644 --- a/OpenDreamRuntime/Procs/Native/DreamProcNative.cs +++ b/OpenDreamRuntime/Procs/Native/DreamProcNative.cs @@ -113,6 +113,8 @@ public static void SetupNativeProcs(DreamObjectTree objectTree) { objectTree.SetGlobalNativeProc(DreamProcNativeRoot.NativeProc_winget); objectTree.SetGlobalNativeProc(DreamProcNativeRoot.NativeProc_winset); + objectTree.SetNativeProc(objectTree.Datum, DreamProcNativeDatum.NativeProc_Write); + objectTree.SetNativeProc(objectTree.List, DreamProcNativeList.NativeProc_Add); objectTree.SetNativeProc(objectTree.List, DreamProcNativeList.NativeProc_Copy); objectTree.SetNativeProc(objectTree.List, DreamProcNativeList.NativeProc_Cut); @@ -141,6 +143,7 @@ public static void SetupNativeProcs(DreamObjectTree objectTree) { objectTree.SetNativeProc(objectTree.Icon, DreamProcNativeIcon.NativeProc_Turn); objectTree.SetNativeProc(objectTree.Savefile, DreamProcNativeSavefile.NativeProc_ExportText); + objectTree.SetNativeProc(objectTree.Savefile, DreamProcNativeSavefile.NativeProc_ImportText); objectTree.SetNativeProc(objectTree.Savefile, DreamProcNativeSavefile.NativeProc_Flush); objectTree.SetNativeProc(objectTree.World, DreamProcNativeWorld.NativeProc_Export); diff --git a/OpenDreamRuntime/Procs/Native/DreamProcNativeDatum.cs b/OpenDreamRuntime/Procs/Native/DreamProcNativeDatum.cs new file mode 100644 index 0000000000..9d1f07d04b --- /dev/null +++ b/OpenDreamRuntime/Procs/Native/DreamProcNativeDatum.cs @@ -0,0 +1,24 @@ +using OpenDreamRuntime.Objects; +using OpenDreamRuntime.Objects.Types; + +namespace OpenDreamRuntime.Procs.Native; + +internal static class DreamProcNativeDatum { + [DreamProc("Write")] + [DreamProcParameter("F", Type = DreamValue.DreamValueTypeFlag.DreamObject)] + public static DreamValue NativeProc_Write(NativeProc.Bundle bundle, DreamObject? src, DreamObject? usr) { + var probablySavefile = bundle.GetArgument(0, "F"); + if (!probablySavefile.TryGetValueAsDreamObject(out DreamObjectSavefile? savefile) && savefile == null) + return DreamValue.Null; // error out bad path or something + + foreach (var key in src!.GetVariableNames()) { + if (!src.IsSaved(key)) continue; + var result = src.GetVariable(key); + + // skip if initial var is same + if (src.ObjectDefinition.TryGetVariable(key, out var val) && val == result) continue; + savefile.SetSavefileValue(key, result); + } + return DreamValue.Null; + } +} diff --git a/OpenDreamRuntime/Procs/Native/DreamProcNativeSavefile.cs b/OpenDreamRuntime/Procs/Native/DreamProcNativeSavefile.cs index 8771001570..eaaeea9222 100644 --- a/OpenDreamRuntime/Procs/Native/DreamProcNativeSavefile.cs +++ b/OpenDreamRuntime/Procs/Native/DreamProcNativeSavefile.cs @@ -1,6 +1,10 @@ using OpenDreamRuntime.Objects; using OpenDreamRuntime.Objects.Types; +using OpenDreamRuntime.Resources; using DreamValueTypeFlag = OpenDreamRuntime.DreamValue.DreamValueTypeFlag; +using System.IO; +using System.Linq; +using YamlDotNet.Core.Tokens; namespace OpenDreamRuntime.Procs.Native; @@ -9,29 +13,126 @@ 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"); + 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); + 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); + } + + [DreamProc("ImportText")] + [DreamProcParameter("path", Type = DreamValueTypeFlag.String)] + [DreamProcParameter("source", Type = DreamValueTypeFlag.String | DreamValueTypeFlag.DreamResource)] + public static DreamValue NativeProc_ImportText(NativeProc.Bundle bundle, DreamObject? src, DreamObject? usr) { + + var savefile = (DreamObjectSavefile)src!; + DreamValue path = bundle.GetArgument(0, "path"); + DreamValue source = bundle.GetArgument(1, "source"); + + if(!path.IsNull && path.TryGetValueAsString(out var pathStr)) { //invalid path values are just ignored in BYOND + savefile.CurrentPath = pathStr; + } + //if source is a file, read text from file and parse + //else if source is a string, parse string + //savefile.OperatorOutput(new DreamValue(source)); + string sourceStr = ""; + if (source.TryGetValueAsDreamResource(out var sourceResource)) { + sourceStr = sourceResource.ReadAsString() ?? ""; + } else if (source.TryGetValueAsString(out var sourceString)) { + sourceStr = sourceString; + } else { + throw new ArgumentException($"Invalid source value {source}"); } - if (!bundle.ResourceManager.TryLoadIcon(exportValue, out var icon)) { - throw new NotImplementedException("General support for ExportText() is not implemented"); + var lines = sourceStr.Split('\n'); + var directoryStack = new Stack(); + foreach (var line in lines) { + var indentCount = line.TakeWhile(Char.IsWhiteSpace).Count(); + while (directoryStack.Count > indentCount) { + directoryStack.Pop(); + } + var keyValue = line.Trim().Split(new[] { " = " }, StringSplitOptions.None); + if (keyValue.Length == 2) { + var key = keyValue[0].Trim(); + var value = keyValue[1].Trim(); + if (value.StartsWith("object(")) { + directoryStack.Push(key); + savefile.CurrentPath = string.Join("/", directoryStack.Reverse()); + } else { + savefile.OperatorIndexAssign(new DreamValue(key), new DreamValue(value)); + } + } else { + throw new ArgumentException($"Invalid line {line}"); + } } - var base64 = Convert.ToBase64String(icon.ResourceData); - var exportedText = $"{{\"\n{base64}\n\"}})"; - return new DreamValue(exportedText); + return DreamValue.Null; + } + + + private static string ExportTextInternal(DreamObjectSavefile savefile, int indent = 0) { + string result = ""; + var value = savefile.CurrentDir; + var oldPath = savefile.CurrentPath; + var key = savefile.CurrentPath.Split('/').Last(); + switch(value) { + case DreamObjectSavefile.DreamPrimitive 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.DreamFileValue fileValue: + result += $"{new string('\t', indent)}{key} = \nfiledata(\""; + result += $"name={fileValue.Name};"; + result += $"ext={fileValue.Ext};"; + result += $"length={fileValue.Length};"; + result += $"crc32={fileValue.Crc32};"; //TODO crc32 + result += $"encoding=base64\",{{\"{fileValue.Data}\"}}"; + //result += $"encoding=base64\",{{\"{Convert.ToBase64String(fileValue.Data)}\"}}"; + result += ")\n"; + break; + case DreamObjectSavefile.DreamObjectValue objectValue: + throw new NotImplementedException($"ExportText() can't do objects yet TODO"); + case DreamObjectSavefile.DreamJsonValue jsonValue: + result += $"{new string('\t', indent)}{key}\n"; + break; + default: + throw new NotImplementedException($"Unhandled type {key} = {value} in ExportText()"); + } + + foreach (string subkey in savefile.CurrentDir.Keys) { + if (subkey == "") + continue; + savefile.CurrentPath = subkey; + result += ExportTextInternal(savefile, indent + 1); + } + savefile.CurrentPath = oldPath; + + + return result; } [DreamProc("Flush")]