From f9f63dadc59f9a6c1daad6adfdddc71f7843f59e Mon Sep 17 00:00:00 2001 From: Sound Date: Wed, 24 May 2023 16:45:48 -0500 Subject: [PATCH 1/6] Added diagnostic header to the log that displays version information. --- BepInEx.Hacknet/Entrypoint.cs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/BepInEx.Hacknet/Entrypoint.cs b/BepInEx.Hacknet/Entrypoint.cs index 2abe792d..61b17305 100644 --- a/BepInEx.Hacknet/Entrypoint.cs +++ b/BepInEx.Hacknet/Entrypoint.cs @@ -10,6 +10,8 @@ public static class Entrypoint { public static void Bootstrap() { + WriteDiagnosticHeader(); + AppDomain.CurrentDomain.AssemblyResolve += ResolveBepAssembly; if (Type.GetType("Mono.Runtime") != null) AppDomain.CurrentDomain.AssemblyResolve += ResolveGACAssembly; @@ -20,6 +22,33 @@ public static void Bootstrap() LoadBepInEx.Load(); } + private static void WriteDiagnosticHeader() + { + string Center(string s, int r) + { + int x = r - s.Length; + int l = x/2 + s.Length; + return s.PadLeft(l).PadRight(r); + } + void WriteInBox(params string[] lines) + { + int l = lines.Max(s => s.Length); + + Console.WriteLine("#" + new string('=', l+2) + "#"); + foreach (string line in lines) + Console.WriteLine("| " + Center(line,l) + " |"); + Console.WriteLine("#" + new string('=', l+2) + "#"); + } + bool hasDLC = File.Exists("Content/DLC/DLCFaction.xml"); + bool isSteam = typeof(HN.PlatformAPI.Storage.SteamCloudStorageMethod).GetField("deserialized") != null; + WriteInBox + ( + $"Hacknet {(hasDLC ? "+Labyrinths " : "")}{(isSteam ? "Steam" : "Non-Steam")} {HN.MainMenu.OSVersion}", + $"{Environment.OSVersion.Platform} ({SDL2.SDL.SDL_GetPlatform()})", + $"Chainloader {HacknetChainloader.VERSION}" + ); + } + public static Assembly ResolveBepAssembly(object sender, ResolveEventArgs args) { var asmName = new AssemblyName(args.Name); From d30b305da68fa4b6d86b502f706b96b270689937 Mon Sep 17 00:00:00 2001 From: Fayti1703 Date: Wed, 28 Jun 2023 23:23:41 +0200 Subject: [PATCH 2/6] Prevent additional arg splitting in launch script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is an issue with the base-game launch script as well. Expanding `$@` in an unquoted context means the already-split arguments undergo another round of field splitting, which we don't want, since that prevents passing an argv like « "foo bar", "baz" » (it'd get passed as « "foo", "bar", "baz" ») --- Linux/StartPathfinder.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Linux/StartPathfinder.sh b/Linux/StartPathfinder.sh index a9b5ab7a..39991bb9 100644 --- a/Linux/StartPathfinder.sh +++ b/Linux/StartPathfinder.sh @@ -1,7 +1,7 @@ cd "`dirname "$0"`" if [ "$(uname -m)" == "x86_64" ]; then - LD_PRELOAD="$(pwd)/lib64/libcef.so $(pwd)/intercept.so /usr/lib/libmono-2.0.so" MONO_DEBUG=explicit-null-checks ./HacknetPathfinder.bin.x86_64 $@ + LD_PRELOAD="$(pwd)/lib64/libcef.so $(pwd)/intercept.so /usr/lib/libmono-2.0.so" MONO_DEBUG=explicit-null-checks ./HacknetPathfinder.bin.x86_64 "$@" else - LD_PRELOAD="$(pwd)/lib/libcef.so $(pwd)/intercept.so /usr/lib/libmono-2.0.so" MONO_DEBUG=explicit-null-checks ./HacknetPathfinder.bin.x86 $@ + LD_PRELOAD="$(pwd)/lib/libcef.so $(pwd)/intercept.so /usr/lib/libmono-2.0.so" MONO_DEBUG=explicit-null-checks ./HacknetPathfinder.bin.x86 "$@" fi From bd1da8ba78c62bc8580b49364a96fb446f19a429 Mon Sep 17 00:00:00 2001 From: Fayti1703 Date: Wed, 28 Jun 2023 23:30:29 +0200 Subject: [PATCH 3/6] Use $() instead of `` in launch script --- Linux/StartPathfinder.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Linux/StartPathfinder.sh b/Linux/StartPathfinder.sh index 39991bb9..906ccec9 100644 --- a/Linux/StartPathfinder.sh +++ b/Linux/StartPathfinder.sh @@ -1,4 +1,4 @@ -cd "`dirname "$0"`" +cd "$(dirname "$0")" if [ "$(uname -m)" == "x86_64" ]; then LD_PRELOAD="$(pwd)/lib64/libcef.so $(pwd)/intercept.so /usr/lib/libmono-2.0.so" MONO_DEBUG=explicit-null-checks ./HacknetPathfinder.bin.x86_64 "$@" From 569723cf1a40499cb4ea0e99db4c124109f2f4e9 Mon Sep 17 00:00:00 2001 From: Aaron Robinson Date: Sun, 20 Aug 2023 03:29:09 -0500 Subject: [PATCH 4/6] Make file encryption work cross platform --- BepInEx.Hacknet/BepInEx.Hacknet.csproj | 1 + Configurations.props | 1 + PathfinderAPI/PathfinderAPI.csproj | 4 + .../Replacements/FileEncrypterReplacement.cs | 238 ++++++++++++++++++ PathfinderAPI/Util/XML/EventReader.cs | 2 +- PathfinderUpdater/Updater.cs | 1 + 6 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 PathfinderAPI/Replacements/FileEncrypterReplacement.cs diff --git a/BepInEx.Hacknet/BepInEx.Hacknet.csproj b/BepInEx.Hacknet/BepInEx.Hacknet.csproj index 93e14800..98add7bd 100644 --- a/BepInEx.Hacknet/BepInEx.Hacknet.csproj +++ b/BepInEx.Hacknet/BepInEx.Hacknet.csproj @@ -19,6 +19,7 @@ + 10 enable + true false $(MSBuildThisFileDirectory) $(PathfinderSolutionDir)libs/ diff --git a/PathfinderAPI/PathfinderAPI.csproj b/PathfinderAPI/PathfinderAPI.csproj index 853862da..14de8338 100644 --- a/PathfinderAPI/PathfinderAPI.csproj +++ b/PathfinderAPI/PathfinderAPI.csproj @@ -17,6 +17,10 @@ + + + + diff --git a/PathfinderAPI/Replacements/FileEncrypterReplacement.cs b/PathfinderAPI/Replacements/FileEncrypterReplacement.cs new file mode 100644 index 00000000..8bb119be --- /dev/null +++ b/PathfinderAPI/Replacements/FileEncrypterReplacement.cs @@ -0,0 +1,238 @@ +using System.Runtime.InteropServices; +using Hacknet; +using HarmonyLib; +using Pathfinder.Util; + +namespace Pathfinder.Replacements; + +[HarmonyPatch] +public static class FileEncrypterReplacement +{ + // Two things need to be replaced here + // First, decryption needs to be attempted at max 3 times for all three hash types when it wasn't created by Pathfinder. + // 1. Windows .NET Framework 64 bit's hash + // 2. Windows .NET Framework 32 bit's hash (yes, they're different) + // 3. Mono's older hash (the one shipped with Hacknet on Linux, newer Mono uses the NetFX hash (i think)) + // Second, encryption should *always* use Pathfinder's stable hash. + // Files encrypted by Pathfinder will get an extra header section just with PATHFINDER, so we can tell which are stable. + // Stable hash I'm using here is FNV1a xor-folded to 16 bits, for its simplicity. https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function + // We don't need anything good (we only have 16 bits in the first place), I don't care that its not for cryptographic use + // ALL I care about is that the hash is *stable*. + // - Aaron + + // Just keep all the legacy hash functions in an array to make this easy to loop over + private static readonly Func[] _hashFunctions = { Fx64Hash, MonoHash, Fx32Hash, GetHashCodeHashBad }; + + private static readonly ushort Fnv1aEmptyHash = Fnv1aHash(string.Empty); + + // *technically* an extension could have this as a file extension, but... i dont think thats going to happen + private static readonly string PathfinderNeedle = FileEncrypter.Encrypt("PATHFINDER", Fnv1aHash("PATHFINDER")); + + [HarmonyPrefix] + [HarmonyPatch(typeof(FileEncrypter), nameof(FileEncrypter.DecryptString))] + private static bool DecryptStringPrefix(string data, string pass, out string[] __result) + { + if (string.IsNullOrEmpty(data)) + { + throw new NullReferenceException("String to decrypt cannot be null or empty"); + } + var lines = data.Split(Utils.robustNewlineDelim, StringSplitOptions.RemoveEmptyEntries); + if (lines.Length < 2) + { + throw new FormatException("Tried to decrypt an invalid valid DEC ENC file \"" + data + "\" - not enough elements. Need 2 lines, had " + lines.Length); + } + var headerParts = lines[0].Split(FileEncrypter.HeaderSplitDelimiters, StringSplitOptions.None); + if (headerParts.Length < 4) + { + throw new FormatException("Tried to decrypt an invalid valid DEC ENC file \"" + data + "\" - not enough headers"); + } + + if (headerParts[headerParts.Length - 1] == PathfinderNeedle) + { + // we know this is FNV1a + var hash = Fnv1aHash(pass); + var passcodeCheck = FileEncrypter.Decrypt(headerParts[3], hash); + var correctPass = passcodeCheck == "ENCODED"; + __result = new[] + { + // header text + FileEncrypter.Decrypt(headerParts[1], Fnv1aEmptyHash), + // IP address + FileEncrypter.Decrypt(headerParts[2], Fnv1aEmptyHash), + // actual content + correctPass ? FileEncrypter.Decrypt(lines[1], hash) : null, + // extension + headerParts.Length > 5 ? FileEncrypter.Decrypt(headerParts[4], Fnv1aEmptyHash) : null, + // success marker + correctPass ? "1" : "0", + // whatever the decryption attempt for ENCODED came back as (even if it failed) + passcodeCheck + }; + return false; + } + + foreach (var hashFunction in _hashFunctions) + { + ushort attempedHash = hashFunction(pass); + // this can still hypothetically be wrong if there is a collision between the hash functions + // but i really just do not care at that point, sorry for the people playing on vanilla saves with pathfinder who hit those odds + var decodedMarker = FileEncrypter.Decrypt(headerParts[3], attempedHash); + var correct = decodedMarker == "ENCODED"; + if (correct) + { + var emptyHash = hashFunction(string.Empty); + __result = new[] + { + FileEncrypter.Decrypt(headerParts[1], emptyHash), + FileEncrypter.Decrypt(headerParts[2], emptyHash), + FileEncrypter.Decrypt(lines[1], attempedHash), + headerParts.Length > 4 ? FileEncrypter.Decrypt(headerParts[4], emptyHash) : null, + "1", + decodedMarker + }; + return false; + } + } + + // password wasn't correct, time to guess at header decoding! :) + // the best way i can think of to do this is to see if the IP is just "ERROR" (default value), exists on the netmap, or just contains three dots (xxx.xxx.xxx.xxx) + // also checks if the header text is ERROR for good measure + // should cover most cases + foreach (var hashFunction in _hashFunctions) + { + var emptyHash = hashFunction(string.Empty); + var decodedIp = FileEncrypter.Decrypt(headerParts[2], emptyHash); + var headerText = FileEncrypter.Decrypt(headerParts[1], emptyHash); + if (decodedIp == "ERROR" || ComputerLookup.FindByIp(decodedIp, false) != null || decodedIp.Count(c => c == '.') == 3 || headerText == "ERROR") + { + __result = new[] + { + headerText, + decodedIp, + null, + headerParts.Length > 4 ? FileEncrypter.Decrypt(headerParts[4], emptyHash) : null, + "0", + FileEncrypter.Decrypt(headerParts[3], hashFunction(pass)) + }; + return false; + } + } + + // give up + __result = new[] { "", "", "", "", "", "" }; + return false; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(FileEncrypter), nameof(FileEncrypter.DecryptHeaders))] + private static bool DecryptHeadersPrefix(string data, string pass, out string[] __result) + { + // i'm too lazy to actually implement this. + var fullDecrypt = FileEncrypter.DecryptString(data, pass); + __result = new[] + { + fullDecrypt[0], + fullDecrypt[1], + fullDecrypt[3] + }; + return false; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(FileEncrypter), nameof(FileEncrypter.GetPassCodeFromString))] + private static bool GetPassCodeFromStringReplacement(string code, out ushort __result) + { + __result = Fnv1aHash(code); + return false; + } + + private static ushort Fnv1aHash(string str) + { + const uint OFFSET_BASIS = 2166136261; + const uint FNV_PRIME = 16777619; + + uint hash = OFFSET_BASIS; + + foreach (var b in MemoryMarshal.AsBytes(str.AsSpan())) + { + hash ^= b; + hash *= FNV_PRIME; + } + + return (ushort)((hash >> 16) ^ (hash & ushort.MaxValue)); + } + + // stolen straight from a decompile of the mscorlib.dll shipped with Linux Hacknet + private static unsafe ushort MonoHash(string str) + { + fixed (char* ptr = str) + { + char* ptr2 = ptr; + char* ptr3 = (char*)((byte*)ptr2 + str.Length * 2 - 2); + int num = 0; + for (; ptr2 < ptr3; ptr2 += 2) + { + num = (num<<5) - num + *ptr2; + num = (num<<5) - num + ptr2[1]; + } + ptr3++; + if (ptr2 < ptr3) + { + num = (num<<5) - num + *ptr2; + } + return (ushort)num; + } + } + + // Both the 32 and 64 bit hashes are taken from this source https://referencesource.microsoft.com/#mscorlib/system/string.cs,898 + + private static unsafe ushort Fx32Hash(string str) + { + fixed (char* src = str) + { + int hash1 = (5381<<16) + 5381; + int hash2 = hash1; + + int* pint = (int *)src; + int len = str.Length; + while (len > 2) + { + hash1 = ((hash1 << 5) + hash1 + (hash1 >> 27)) ^ pint[0]; + hash2 = ((hash2 << 5) + hash2 + (hash2 >> 27)) ^ pint[1]; + pint += 2; + len -= 4; + } + + if (len > 0) + { + hash1 = ((hash1 << 5) + hash1 + (hash1 >> 27)) ^ pint[0]; + } + + return (ushort)(hash1 + (hash2 * 1566083941)); + } + } + + private static unsafe ushort Fx64Hash(string str) + { + fixed (char* src = str) + { + int hash1 = 5381; + int hash2 = hash1; + + int c; + char* s = src; + while ((c = s[0]) != 0) { + hash1 = ((hash1 << 5) + hash1) ^ c; + c = s[1]; + if (c == 0) + break; + hash2 = ((hash2 << 5) + hash2) ^ c; + s += 2; + } + + return (ushort)(hash1 + (hash2 * 1566083941)); + } + } + + private static ushort GetHashCodeHashBad(string str) => (ushort)str.GetHashCode(); +} diff --git a/PathfinderAPI/Util/XML/EventReader.cs b/PathfinderAPI/Util/XML/EventReader.cs index 7aff3794..c6412a92 100644 --- a/PathfinderAPI/Util/XML/EventReader.cs +++ b/PathfinderAPI/Util/XML/EventReader.cs @@ -42,7 +42,7 @@ public void Parse() if (Reader == null) using (XmlReader reader = XmlReader.Create(new StringReader(Text))) while (reader.Read()); - using (Reader = Reader ?? XmlReader.Create(new StringReader(Text))) + using (Reader ??= XmlReader.Create(new StringReader(Text))) { while (Reader.Read()) { diff --git a/PathfinderUpdater/Updater.cs b/PathfinderUpdater/Updater.cs index 40abbe2c..43903332 100644 --- a/PathfinderUpdater/Updater.cs +++ b/PathfinderUpdater/Updater.cs @@ -1,5 +1,6 @@ using System.Reflection; using System.IO.Compression; +using System.Net.Http; using System.Net.Http.Headers; using BepInEx.Hacknet; using BepInEx.Logging; From 5b49804634b20d5fd24f5d02a61d71f2fd4919c2 Mon Sep 17 00:00:00 2001 From: Aaron Robinson Date: Sun, 20 Aug 2023 04:02:57 -0500 Subject: [PATCH 5/6] Add GetHashCode fallback --- .../Replacements/FileEncrypterReplacement.cs | 51 ++++++++++++++++--- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/PathfinderAPI/Replacements/FileEncrypterReplacement.cs b/PathfinderAPI/Replacements/FileEncrypterReplacement.cs index 8bb119be..decbd0dc 100644 --- a/PathfinderAPI/Replacements/FileEncrypterReplacement.cs +++ b/PathfinderAPI/Replacements/FileEncrypterReplacement.cs @@ -1,18 +1,22 @@ using System.Runtime.InteropServices; +using System.Text; using Hacknet; using HarmonyLib; +using Mono.Cecil.Cil; +using MonoMod.Cil; using Pathfinder.Util; namespace Pathfinder.Replacements; [HarmonyPatch] -public static class FileEncrypterReplacement +internal static class FileEncrypterReplacement { // Two things need to be replaced here - // First, decryption needs to be attempted at max 3 times for all three hash types when it wasn't created by Pathfinder. + // First, decryption needs to be attempted at max 4 times for all three hash types when it wasn't created by Pathfinder. // 1. Windows .NET Framework 64 bit's hash // 2. Windows .NET Framework 32 bit's hash (yes, they're different) // 3. Mono's older hash (the one shipped with Hacknet on Linux, newer Mono uses the NetFX hash (i think)) + // 4. as a last resort, try GetHashCode (bleh) // Second, encryption should *always* use Pathfinder's stable hash. // Files encrypted by Pathfinder will get an extra header section just with PATHFINDER, so we can tell which are stable. // Stable hash I'm using here is FNV1a xor-folded to 16 bits, for its simplicity. https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function @@ -95,8 +99,12 @@ private static bool DecryptStringPrefix(string data, string pass, out string[] _ } // password wasn't correct, time to guess at header decoding! :) - // the best way i can think of to do this is to see if the IP is just "ERROR" (default value), exists on the netmap, or just contains three dots (xxx.xxx.xxx.xxx) - // also checks if the header text is ERROR for good measure + // the best way i can think of to do this is to: + // 1. IP is + // a. "ERROR" (default value) + // b. exists on the netmap + // c. just contains three dots (xxx.xxx.xxx.xxx) + // 2. header text is "ERROR" for good measure // should cover most cases foreach (var hashFunction in _hashFunctions) { @@ -118,8 +126,18 @@ private static bool DecryptStringPrefix(string data, string pass, out string[] _ } } - // give up - __result = new[] { "", "", "", "", "", "" }; + // give up, use GetHashCode to give back something for headers :( + var passHashBad = GetHashCodeHashBad(pass); + var emptyHashBad = GetHashCodeHashBad(string.Empty); + __result = new[] + { + FileEncrypter.Decrypt(headerParts[1], emptyHashBad), + FileEncrypter.Decrypt(headerParts[2], emptyHashBad), + null, + headerParts.Length > 4 ? FileEncrypter.Decrypt(headerParts[4], emptyHashBad) : null, + "0", + FileEncrypter.Decrypt(headerParts[3], passHashBad) + }; return false; } @@ -146,6 +164,27 @@ private static bool GetPassCodeFromStringReplacement(string code, out ushort __r return false; } + [HarmonyILManipulator] + [HarmonyPatch(typeof(FileEncrypter), nameof(FileEncrypter.EncryptString))] + private static void EncryptStringIL(ILContext il) + { + var c = new ILCursor(il); + + var appendMethod = AccessTools.DeclaredMethod(typeof(StringBuilder), nameof(StringBuilder.Append), new[] { typeof(string) }); + + c.GotoNext(MoveType.After, + x => x.MatchCallvirt(appendMethod), + x => x.MatchPop() + ); + + c.Emit(OpCodes.Ldloc_2); + c.Emit(OpCodes.Ldstr, "::"); + c.Emit(OpCodes.Callvirt, appendMethod); + c.Emit(OpCodes.Ldsfld, AccessTools.DeclaredField(typeof(FileEncrypterReplacement), nameof(PathfinderNeedle))); + c.Emit(OpCodes.Callvirt, appendMethod); + c.Emit(OpCodes.Pop); + } + private static ushort Fnv1aHash(string str) { const uint OFFSET_BASIS = 2166136261; From ed19e4a8aacae274b10dbd402a5f0fc5218e9b94 Mon Sep 17 00:00:00 2001 From: Aaron Robinson Date: Sun, 15 Oct 2023 16:51:24 -0500 Subject: [PATCH 6/6] Fix random stuff --- Linux/StartPathfinder.sh | 4 ++-- PathfinderAPI/PathfinderAPIPlugin.cs | 2 ++ PathfinderAPI/Replacements/ActionsLoader.cs | 2 +- README.md | 2 ++ 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Linux/StartPathfinder.sh b/Linux/StartPathfinder.sh index 906ccec9..75750888 100644 --- a/Linux/StartPathfinder.sh +++ b/Linux/StartPathfinder.sh @@ -1,7 +1,7 @@ cd "$(dirname "$0")" if [ "$(uname -m)" == "x86_64" ]; then - LD_PRELOAD="$(pwd)/lib64/libcef.so $(pwd)/intercept.so /usr/lib/libmono-2.0.so" MONO_DEBUG=explicit-null-checks ./HacknetPathfinder.bin.x86_64 "$@" + LD_PRELOAD="$(pwd)/intercept.so /usr/lib/libmono-2.0.so" MONO_DEBUG=explicit-null-checks ./HacknetPathfinder.bin.x86_64 "$@" else - LD_PRELOAD="$(pwd)/lib/libcef.so $(pwd)/intercept.so /usr/lib/libmono-2.0.so" MONO_DEBUG=explicit-null-checks ./HacknetPathfinder.bin.x86 "$@" + LD_PRELOAD="$(pwd)/intercept.so /usr/lib/libmono-2.0.so" MONO_DEBUG=explicit-null-checks ./HacknetPathfinder.bin.x86 "$@" fi diff --git a/PathfinderAPI/PathfinderAPIPlugin.cs b/PathfinderAPI/PathfinderAPIPlugin.cs index 1d28642a..269407f5 100644 --- a/PathfinderAPI/PathfinderAPIPlugin.cs +++ b/PathfinderAPI/PathfinderAPIPlugin.cs @@ -30,6 +30,8 @@ public override bool Load() PathfinderAPIPlugin.HarmonyInstance = base.HarmonyInstance; Logger.LogSource = base.Log; PathfinderAPIPlugin.Config = base.Config; + + Environment.SetEnvironmentVariable("LD_PRELOAD", $"./lib{(Environment.Is64BitProcess ? "64" : "")}/libcef.so"); foreach (var initMethod in typeof(PathfinderAPIPlugin).Assembly.GetTypes().SelectMany(AccessTools.GetDeclaredMethods)) { diff --git a/PathfinderAPI/Replacements/ActionsLoader.cs b/PathfinderAPI/Replacements/ActionsLoader.cs index 56543af0..d9e4e03f 100644 --- a/PathfinderAPI/Replacements/ActionsLoader.cs +++ b/PathfinderAPI/Replacements/ActionsLoader.cs @@ -196,7 +196,7 @@ public static SerializableAction ReadAction(ElementInfo actionInfo) case "AddIRCMessage": return new SAAddIRCMessage() { - Author = ComputerLoader.filter(actionInfo.Attributes.GetOrThrow("Author", "Invalid author for AddIRCMessage", StringExtensions.HasContent)), + Author = ComputerLoader.filter(actionInfo.Attributes.GetString("Author")), Message = ComputerLoader.filter(string.IsNullOrEmpty(actionInfo.Content) ? throw new FormatException("Invalid message for AddIRCMessage") : actionInfo.Content), Delay = actionInfo.Attributes.GetFloat("Delay"), TargetComp = actionInfo.Attributes.GetOrThrow("TargetComp", "Invalid target computer for AddIRCMessage", StringExtensions.HasContent) diff --git a/README.md b/README.md index 9dcd2849..9ed4543c 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ An extensive modding API and loader for Hacknet that enables practically limitless programable extensions to the game. +Docs: https://arkhist.github.io/Hacknet-Pathfinder/ + ## Installation There are several options available to choose to install Pathfinder, the installer .exe, the installer .py, or the manually with the .zip.