From 1cd91c735f1810ea7fac613f227b8df6fa8d985c Mon Sep 17 00:00:00 2001
From: jneb802 <73610029+jneb802@users.noreply.github.com>
Date: Sun, 18 Oct 2026 14:54:45 -0700
Subject: [PATCH 1/7] Add temporary Endgame tablet compatibility for Epic Loot
---
PraetorisClient.csproj | 1 +
Source/Core/Plugin.cs | 3 +
Source/EpicLoot/EndgameCompatibility.cs | 252 ++++++++++++++++++
.../EndgameCompatibility.csproj | 9 +
tests/EndgameCompatibility/Program.cs | 104 ++++++++
tests/EndgameCompatibility/README.md | 100 +++++++
6 files changed, 469 insertions(+)
create mode 100644 Source/EpicLoot/EndgameCompatibility.cs
create mode 100644 tests/EndgameCompatibility/EndgameCompatibility.csproj
create mode 100644 tests/EndgameCompatibility/Program.cs
create mode 100644 tests/EndgameCompatibility/README.md
diff --git a/PraetorisClient.csproj b/PraetorisClient.csproj
index 82643f9..5b6fa25 100644
--- a/PraetorisClient.csproj
+++ b/PraetorisClient.csproj
@@ -49,6 +49,7 @@
+
PraetorisClient.GuideImages.%(Filename)%(Extension)
diff --git a/Source/Core/Plugin.cs b/Source/Core/Plugin.cs
index e2378a1..a61e65e 100644
--- a/Source/Core/Plugin.cs
+++ b/Source/Core/Plugin.cs
@@ -22,6 +22,7 @@
namespace PraetorisClient
{
[BepInPlugin(ModGUID, ModName, ModVersion)]
+ [BepInDependency(EndgameCompatibility.EndgameGuid, BepInDependency.DependencyFlags.SoftDependency)]
[BepInDependency(Jotunn.Main.ModGuid)]
[BepInDependency(EpicLootApiBridge.PluginGuid, BepInDependency.DependencyFlags.SoftDependency)]
[BepInDependency("org.tristan.rcon", BepInDependency.DependencyFlags.SoftDependency)]
@@ -166,6 +167,7 @@ public void Awake()
FrameTimeMonitor.Initialize();
RpcTraceTelemetry.Initialize();
ApplyHarmonyPatches(epicLootLoaded);
+ EndgameCompatibility.Initialize();
CraftyBoxesWardGuard.TryApply(_harmony);
PublicBasicPickables.TryApply(_harmony);
ShippingPortDepartures.Initialize(_harmony);
@@ -226,6 +228,7 @@ private void Update()
private void OnDestroy()
{
+ EndgameCompatibility.Shutdown();
GuardStoneFeature.GuardStoneMapPins.Clear();
GuardStoneFeature.SpawnIslandWards.ClearSurvey();
PrefabManager.OnPrefabsRegistered -= BossSpawnMessageSuppression.Refresh;
diff --git a/Source/EpicLoot/EndgameCompatibility.cs b/Source/EpicLoot/EndgameCompatibility.cs
new file mode 100644
index 0000000..12ff842
--- /dev/null
+++ b/Source/EpicLoot/EndgameCompatibility.cs
@@ -0,0 +1,252 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Reflection;
+using System.Reflection.Emit;
+using System.Security.Cryptography;
+using BepInEx.Bootstrap;
+using HarmonyLib;
+using UnityEngine;
+
+namespace PraetorisClient;
+
+// Temporary bridge for Epic Loot PRs #45 and #47. No replacement EpicLoot.dll or new save format.
+internal static class EndgameCompatibility
+{
+ internal const string EndgameGuid = "warpalicious.ValheimEndgame";
+ private const string Owner = "warpalicious.PraetorisClient.EndgameCompatibility";
+ // Production Season 8 8.0.46. Epic Loot forks can share a version; match the actual binary.
+ private const string LegacyHash = "802790e0cbd8a6f951627619a488b84e6a406e8770e2687b2fff8b63d2adf9ea";
+ private static readonly Harmony Patches = new(Owner);
+ private static FieldInfo socketCount = null!;
+ private static FieldInfo craftedItem = null!;
+ private static MethodInfo socketWeights = null!;
+ private static Type rarityType = null!;
+
+ internal static bool IsTabletName(string? name)
+ {
+ if (name == "ve_tablet") return true;
+ const string prefix = "ve_tablet_tier";
+ return name != null && name.Length == prefix.Length + 2 &&
+ name.StartsWith(prefix, StringComparison.Ordinal) &&
+ int.TryParse(name.Substring(prefix.Length), NumberStyles.None, CultureInfo.InvariantCulture, out int tier) &&
+ tier >= 1 && tier <= 15;
+ }
+
+ private static bool IsTablet(ItemDrop.ItemData? item) => IsTabletName(item?.m_dropPrefab?.name);
+
+ internal static void Initialize()
+ {
+ if (!Chainloader.PluginInfos.ContainsKey(EndgameGuid) ||
+ !Chainloader.PluginInfos.TryGetValue(EpicLootApiBridge.PluginGuid, out BepInEx.PluginInfo info)) return;
+
+ Assembly assembly = info.Instance.GetType().Assembly;
+ try
+ {
+ if (HasNativeSupport(assembly))
+ {
+ ApplyCraftResultFix(assembly);
+ PraetorisClientPlugin.Log.LogInfo("Endgame compatibility: native Epic Loot enchanting/socket support detected; temporary item patches skipped.");
+ return;
+ }
+
+ using SHA256 sha = SHA256.Create();
+ using System.IO.FileStream stream = System.IO.File.OpenRead(assembly.Location);
+ string hash = string.Concat(sha.ComputeHash(stream).Select(b => b.ToString("x2")));
+ if (hash != LegacyHash)
+ {
+ PraetorisClientPlugin.Log.LogWarning("Endgame compatibility: unsupported Epic Loot binary. No temporary patches applied; tablet enchanting/socket rules are NOT provided.");
+ return;
+ }
+
+ Type item = typeof(ItemDrop.ItemData);
+ rarityType = RequiredType(assembly, "EpicLoot.ItemRarity");
+ socketCount = AccessTools.Field(RequiredType(assembly, "EpicLoot.MagicItem"), "SocketCount")
+ ?? throw new MissingFieldException("MagicItem.SocketCount");
+ craftedItem = AccessTools.Field(RequiredType(assembly, "EpicLoot.Crafting.TransferMagicalEffects"), "CraftedItem")
+ ?? throw new MissingFieldException("TransferMagicalEffects.CraftedItem");
+ socketWeights = RequiredMethod(assembly, "EpicLoot.LootRoller", "GetSocketCountsPerRarity", rarityType);
+
+ // Manual registration: these methods must not join Praetoris Client's assembly-wide patch scan.
+ Patch(RequiredMethod(assembly, "EpicLoot.EpicLoot", "IsAllowedMagicItemType", item), postfix: nameof(AllowTablet));
+ Patch(RequiredMethod(assembly, "EpicLoot.GatedItemType.ItemTypeClassifier", "ClassifyFromFields", item), postfix: nameof(ClassifyTablet));
+ Patch(RequiredMethod(assembly, "EpicLoot.LootRoller", "RollMagicItem", rarityType, item, typeof(float), typeof(float)), transpiler: nameof(PreventRolledSockets));
+ Patch(RequiredMethod(assembly, "EpicLoot.CraftingV2.EnchantingUIController", "GetEnchantInfo", item, rarityType), transpiler: nameof(HideSocketPreview));
+ Patch(RequiredMethod(assembly, "EpicLoot.Crafting.TransferMagicalEffects", "TransferMagicToCraftedItem", typeof(Player)), transpiler: nameof(PreventTransferredSockets));
+ Patch(RequiredMethod(assembly, "EpicLoot.ShardStones.ShardSlotChisel", "CanApply", item, item, typeof(int).MakeByRefType(), typeof(string).MakeByRefType()), prefix: nameof(PreventChisel));
+ Patch(RequiredMethod(assembly, "EpicLoot.ShardStones.ShardSocketManager", "CanSocket", item, item,
+ typeof(string).MakeByRefType()), prefix: nameof(PreventPlacement));
+ Patch(RequiredMethod(assembly, "EpicLoot.ShardStones.ShardSocketManager", "CanCoexist", item, item,
+ typeof(IEnumerable), typeof(string).MakeByRefType()), prefix: nameof(PreventPlacement));
+ ApplyCraftResultFix(assembly);
+ PraetorisClientPlugin.Log.LogInfo("Endgame compatibility: applied tablet enchanting, socket restrictions, and craft result fix to verified Epic Loot 0.14.13.");
+ }
+ catch (Exception exception)
+ {
+ Shutdown();
+ PraetorisClientPlugin.Log.LogError("Endgame compatibility could not initialize; all temporary patches removed: " + exception.GetBaseException().Message);
+ }
+ }
+
+ internal static void Shutdown() => Patches.UnpatchSelf();
+
+ private static Type RequiredType(Assembly assembly, string name) => assembly.GetType(name, true)!;
+ private static MethodInfo RequiredMethod(Assembly assembly, string type, string name, params Type[] parameters) =>
+ AccessTools.Method(RequiredType(assembly, type), name, parameters) ?? throw new MissingMethodException(type, name);
+
+ private static void Patch(MethodInfo target, string? prefix = null, string? postfix = null, string? transpiler = null) =>
+ Patches.Patch(target, Hook(prefix), Hook(postfix), Hook(transpiler));
+
+ private static HarmonyMethod? Hook(string? name) => name == null ? null : new HarmonyMethod(typeof(EndgameCompatibility), name);
+
+ private static bool HasNativeSupport(Assembly assembly)
+ {
+ Type? config = assembly.GetType("EpicLoot.GatedItemType.ItemTypeInfo");
+ Type? details = assembly.GetType("EpicLoot.GatedItemType.GatedItemDetails");
+ Type? helper = assembly.GetType("EpicLoot.GatedItemType.GatedItemTypeHelper");
+ MethodInfo? native = helper == null ? null : AccessTools.Method(helper, "IsSocketable", new[] { typeof(ItemDrop.ItemData) });
+ if (config?.GetField("Enchantable")?.FieldType != typeof(bool) || config.GetField("Socketable")?.FieldType != typeof(bool) ||
+ details?.GetProperty("Enchantable")?.PropertyType != typeof(bool) || details.GetProperty("Socketable")?.PropertyType != typeof(bool) || native == null) return false;
+
+ // Fields alone can be present in an incomplete upstream build. Check each socket enforcement path.
+ (string Type, string Method)[] paths =
+ {
+ ("EpicLoot.LootRoller", "RollMagicItem"),
+ ("EpicLoot.CraftingV2.EnchantingUIController", "GetEnchantInfo"),
+ ("EpicLoot.Crafting.TransferMagicalEffects", "TransferMagicToCraftedItem"),
+ ("EpicLoot.ShardStones.ShardSlotChisel", "CanApply"),
+ ("EpicLoot.ShardStones.ShardSocketManager", "ResolvePlacementEffect")
+ };
+ foreach ((string typeName, string methodName) in paths)
+ {
+ Type? type = assembly.GetType(typeName);
+ if (type == null || !AccessTools.GetDeclaredMethods(type).Where(m => m.Name == methodName)
+ .Any(m => PatchProcessor.GetOriginalInstructions(m).Any(i => i.Calls(native)))) return false;
+ }
+ MethodInfo allowed = RequiredMethod(assembly, "EpicLoot.EpicLoot", "IsAllowedMagicItemType", typeof(ItemDrop.ItemData));
+ MethodInfo? enchantable = details.GetProperty("Enchantable")?.GetGetMethod();
+ return enchantable != null && PatchProcessor.GetOriginalInstructions(allowed).Any(i => i.Calls(enchantable));
+ }
+
+ private static void AllowTablet(ItemDrop.ItemData __0, ref bool __result)
+ {
+ if (IsTablet(__0) && __0.m_shared.m_itemType != ItemDrop.ItemData.ItemType.Ammo &&
+ __0.m_shared.m_itemType != ItemDrop.ItemData.ItemType.AmmoNonEquipable) __result = true;
+ }
+
+ private static void ClassifyTablet(ItemDrop.ItemData __0, ref string __result)
+ {
+ if (IsTablet(__0)) __result = "Tablet";
+ }
+
+ private static int FilterSocketCount(int count, ItemDrop.ItemData item) => IsTablet(item) ? 0 : count;
+
+ private static int TransferSocketCount(object magicItem) =>
+ IsTablet(craftedItem.GetValue(null) as ItemDrop.ItemData) ? 0 : (int)socketCount.GetValue(magicItem);
+
+ private static List> PreviewSocketWeights(int rarity, ItemDrop.ItemData item) =>
+ IsTablet(item) ? new List>() :
+ (List>)socketWeights.Invoke(null, new[] { Enum.ToObject(rarityType, rarity) });
+
+ private static IEnumerable PreventRolledSockets(IEnumerable instructions)
+ {
+ int matches = 0;
+ foreach (CodeInstruction instruction in instructions)
+ {
+ if (instruction.opcode == OpCodes.Stfld && Equals(instruction.operand, socketCount))
+ {
+ matches++;
+ CodeInstruction load = new(OpCodes.Ldarg_1);
+ load.labels.AddRange(instruction.labels);
+ instruction.labels.Clear();
+ load.blocks.AddRange(instruction.blocks);
+ instruction.blocks.Clear();
+ yield return load;
+ yield return new CodeInstruction(OpCodes.Call, AccessTools.Method(typeof(EndgameCompatibility), nameof(FilterSocketCount)));
+ }
+ yield return instruction;
+ }
+ if (matches != 1) throw new InvalidOperationException("Unexpected Epic Loot socket roll layout.");
+ }
+
+ private static IEnumerable HideSocketPreview(IEnumerable instructions)
+ {
+ int matches = 0;
+ foreach (CodeInstruction instruction in instructions)
+ {
+ if (instruction.Calls(socketWeights))
+ {
+ matches++;
+ CodeInstruction load = new(OpCodes.Ldarg_0);
+ load.labels.AddRange(instruction.labels);
+ instruction.labels.Clear();
+ load.blocks.AddRange(instruction.blocks);
+ instruction.blocks.Clear();
+ yield return load;
+ instruction.opcode = OpCodes.Call;
+ instruction.operand = AccessTools.Method(typeof(EndgameCompatibility), nameof(PreviewSocketWeights));
+ }
+ yield return instruction;
+ }
+ if (matches != 1) throw new InvalidOperationException("Unexpected Epic Loot socket preview layout.");
+ }
+
+ private static IEnumerable PreventTransferredSockets(IEnumerable instructions)
+ {
+ int matches = 0;
+ foreach (CodeInstruction instruction in instructions)
+ {
+ if (instruction.opcode == OpCodes.Ldfld && Equals(instruction.operand, socketCount))
+ {
+ matches++;
+ instruction.opcode = OpCodes.Call;
+ instruction.operand = AccessTools.Method(typeof(EndgameCompatibility), nameof(TransferSocketCount));
+ }
+ yield return instruction;
+ }
+ if (matches != 3) throw new InvalidOperationException("Unexpected Epic Loot socket transfer layout.");
+ }
+
+ private static bool PreventChisel(ItemDrop.ItemData __1, ref int __2, ref string __3, ref bool __result)
+ {
+ if (!IsTablet(__1)) return true;
+ __2 = 0;
+ __3 = "$mod_epicloot_socket_unavailable";
+ __result = false;
+ return false;
+ }
+
+ private static bool PreventPlacement(ItemDrop.ItemData __0, ref string reason, ref bool __result)
+ {
+ if (!IsTablet(__0)) return true;
+ reason = "$mod_epicloot_socket_unavailable";
+ __result = false;
+ return false;
+ }
+
+ private static void ShowCraftResult(object __instance)
+ {
+ foreach (string field in new[] { "NameText", "Description", "Icon" })
+ {
+ if (AccessTools.Field(__instance.GetType(), field)?.GetValue(__instance) is Behaviour component)
+ component.enabled = true;
+ }
+ }
+
+ private static void ApplyCraftResultFix(Assembly assembly)
+ {
+ Type dialog = RequiredType(assembly, "EpicLoot.Crafting.CraftSuccessDialog");
+ MethodInfo show = RequiredMethod(assembly, dialog.FullName!, "Show", typeof(ItemDrop.ItemData));
+ MethodInfo enabled = typeof(Behaviour).GetProperty(nameof(Behaviour.enabled))!.GetSetMethod()!;
+ if (PatchProcessor.GetOriginalInstructions(show).Count(i => i.Calls(enabled)) >= 3) return;
+ foreach (string field in new[] { "NameText", "Description", "Icon" })
+ {
+ FieldInfo member = AccessTools.Field(dialog, field) ?? throw new MissingFieldException(dialog.FullName, field);
+ if (!typeof(Behaviour).IsAssignableFrom(member.FieldType))
+ throw new InvalidOperationException("Unexpected Epic Loot craft result component: " + field);
+ }
+ Patch(show, postfix: nameof(ShowCraftResult));
+ PraetorisClientPlugin.Log.LogInfo("Endgame compatibility: applied temporary craft result visibility fix.");
+ }
+}
diff --git a/tests/EndgameCompatibility/EndgameCompatibility.csproj b/tests/EndgameCompatibility/EndgameCompatibility.csproj
new file mode 100644
index 0000000..ef8a995
--- /dev/null
+++ b/tests/EndgameCompatibility/EndgameCompatibility.csproj
@@ -0,0 +1,9 @@
+
+
+ Exe
+ net481
+ 10
+ enable
+ enable
+
+
diff --git a/tests/EndgameCompatibility/Program.cs b/tests/EndgameCompatibility/Program.cs
new file mode 100644
index 0000000..def3104
--- /dev/null
+++ b/tests/EndgameCompatibility/Program.cs
@@ -0,0 +1,104 @@
+using System.Collections;
+using System.Reflection;
+
+// Exercise the actual candidate and Epic Loot IL without starting Unity. No copied compatibility implementation.
+if (args.Length < 3 || args.Length > 4)
+ throw new ArgumentException("Usage: [native]");
+string game = Path.GetFullPath(args[2]);
+string[] search =
+{
+ Path.Combine(game, "BepInEx/core"),
+ Path.Combine(game, "Valheim.app/Contents/Resources/Data/Managed"),
+ Path.Combine(game, "valheim_Data/Managed"),
+ Path.GetDirectoryName(Path.GetFullPath(args[0]))!,
+ Path.GetDirectoryName(Path.GetFullPath(args[1]))!
+};
+AppDomain.CurrentDomain.AssemblyResolve += (_, request) =>
+{
+ AssemblyName name = new(request.Name);
+ foreach (string directory in search)
+ {
+ string path = Path.Combine(directory, name.Name + ".dll");
+ if (File.Exists(path)) return Assembly.LoadFrom(path);
+ }
+ string? plugin = Directory.EnumerateFiles(Path.Combine(game, "BepInEx/plugins"), name.Name + ".dll", SearchOption.AllDirectories).FirstOrDefault();
+ return plugin == null ? null : Assembly.LoadFrom(plugin);
+};
+Assembly epic = Assembly.LoadFrom(Path.GetFullPath(args[1]));
+Assembly client = Assembly.LoadFrom(Path.GetFullPath(args[0]));
+Type compatibility = client.GetType("PraetorisClient.EndgameCompatibility", true)!;
+const BindingFlags flags = BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
+object? Call(string name, params object?[] values) => compatibility.GetMethod(name, flags)!.Invoke(null, values);
+void Require(bool condition, string name)
+{
+ if (!condition) throw new Exception("FAIL: " + name);
+ System.Console.WriteLine("PASS: " + name);
+}
+Require((bool)Call("IsTabletName", "ve_tablet")!, "legacy tablet");
+for (int tier = 1; tier <= 15; tier++)
+ Require((bool)Call("IsTabletName", "ve_tablet_tier" + tier.ToString("00"))!, "tablet tier " + tier);
+foreach (string? name in new[] { null, "", "SwordIron", "ve_tablet_tier00", "ve_tablet_tier16", "ve_tablet_tier1", "ve_tablet_tier01(Clone)", "ve_tablet_tier+1" })
+ Require(!(bool)Call("IsTabletName", new object?[] { name })!, "reject unrelated/invalid name " + (name ?? "null"));
+bool native = args.Length == 4 && args[3] == "native";
+Require((bool)Call("HasNativeSupport", epic)! == native, native ? "upstream build recognized without item patches" : "legacy build does not claim native flags");
+if (native) return;
+
+Assembly harmony = Assembly.LoadFrom(Path.Combine(search[0], "0Harmony.dll"));
+Type processor = harmony.GetType("HarmonyLib.PatchProcessor", true)!;
+MethodInfo read = processor.GetMethods().Single(m => m.Name == "GetOriginalInstructions" && m.GetParameters().Length == 2 && !m.GetParameters()[1].ParameterType.IsByRef);
+Type magic = epic.GetType("EpicLoot.MagicItem", true)!;
+FieldInfo socketCount = magic.GetField("SocketCount")!;
+Type rarity = epic.GetType("EpicLoot.ItemRarity", true)!;
+Type item = epic.GetType("EpicLoot.EpicLoot", true)!.GetMethod("IsAllowedMagicItemType")!.GetParameters()[0].ParameterType;
+Type roller = epic.GetType("EpicLoot.LootRoller", true)!;
+MethodInfo weights = roller.GetMethod("GetSocketCountsPerRarity")!;
+compatibility.GetField("socketCount", flags)!.SetValue(null, socketCount);
+compatibility.GetField("socketWeights", flags)!.SetValue(null, weights);
+
+(MethodInfo Method, string Transform, string Helper, int Count)[] cases =
+{
+ (roller.GetMethod("RollMagicItem", new[] { rarity, item, typeof(float), typeof(float) })!, "PreventRolledSockets", "FilterSocketCount", 1),
+ (epic.GetType("EpicLoot.CraftingV2.EnchantingUIController", true)!.GetMethod("GetEnchantInfo", flags)!, "HideSocketPreview", "PreviewSocketWeights", 1),
+ (epic.GetType("EpicLoot.Crafting.TransferMagicalEffects", true)!.GetMethod("TransferMagicToCraftedItem", flags)!, "PreventTransferredSockets", "TransferSocketCount", 3)
+};
+foreach ((MethodInfo method, string transform, string helper, int count) in cases)
+{
+ object instructions = read.Invoke(null, new object?[] { method, null })!;
+ object[] rewritten = ((IEnumerable)Call(transform, instructions)!).Cast
-
PraetorisClient.GuideImages.%(Filename)%(Extension)
diff --git a/tests/EndgameCompatibility/EndgameCompatibility.csproj b/tests/EndgameCompatibility/EndgameCompatibility.csproj
deleted file mode 100644
index ef8a995..0000000
--- a/tests/EndgameCompatibility/EndgameCompatibility.csproj
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
- Exe
- net481
- 10
- enable
- enable
-
-
diff --git a/tests/EndgameCompatibility/Program.cs b/tests/EndgameCompatibility/Program.cs
deleted file mode 100644
index def3104..0000000
--- a/tests/EndgameCompatibility/Program.cs
+++ /dev/null
@@ -1,104 +0,0 @@
-using System.Collections;
-using System.Reflection;
-
-// Exercise the actual candidate and Epic Loot IL without starting Unity. No copied compatibility implementation.
-if (args.Length < 3 || args.Length > 4)
- throw new ArgumentException("Usage: [native]");
-string game = Path.GetFullPath(args[2]);
-string[] search =
-{
- Path.Combine(game, "BepInEx/core"),
- Path.Combine(game, "Valheim.app/Contents/Resources/Data/Managed"),
- Path.Combine(game, "valheim_Data/Managed"),
- Path.GetDirectoryName(Path.GetFullPath(args[0]))!,
- Path.GetDirectoryName(Path.GetFullPath(args[1]))!
-};
-AppDomain.CurrentDomain.AssemblyResolve += (_, request) =>
-{
- AssemblyName name = new(request.Name);
- foreach (string directory in search)
- {
- string path = Path.Combine(directory, name.Name + ".dll");
- if (File.Exists(path)) return Assembly.LoadFrom(path);
- }
- string? plugin = Directory.EnumerateFiles(Path.Combine(game, "BepInEx/plugins"), name.Name + ".dll", SearchOption.AllDirectories).FirstOrDefault();
- return plugin == null ? null : Assembly.LoadFrom(plugin);
-};
-Assembly epic = Assembly.LoadFrom(Path.GetFullPath(args[1]));
-Assembly client = Assembly.LoadFrom(Path.GetFullPath(args[0]));
-Type compatibility = client.GetType("PraetorisClient.EndgameCompatibility", true)!;
-const BindingFlags flags = BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
-object? Call(string name, params object?[] values) => compatibility.GetMethod(name, flags)!.Invoke(null, values);
-void Require(bool condition, string name)
-{
- if (!condition) throw new Exception("FAIL: " + name);
- System.Console.WriteLine("PASS: " + name);
-}
-Require((bool)Call("IsTabletName", "ve_tablet")!, "legacy tablet");
-for (int tier = 1; tier <= 15; tier++)
- Require((bool)Call("IsTabletName", "ve_tablet_tier" + tier.ToString("00"))!, "tablet tier " + tier);
-foreach (string? name in new[] { null, "", "SwordIron", "ve_tablet_tier00", "ve_tablet_tier16", "ve_tablet_tier1", "ve_tablet_tier01(Clone)", "ve_tablet_tier+1" })
- Require(!(bool)Call("IsTabletName", new object?[] { name })!, "reject unrelated/invalid name " + (name ?? "null"));
-bool native = args.Length == 4 && args[3] == "native";
-Require((bool)Call("HasNativeSupport", epic)! == native, native ? "upstream build recognized without item patches" : "legacy build does not claim native flags");
-if (native) return;
-
-Assembly harmony = Assembly.LoadFrom(Path.Combine(search[0], "0Harmony.dll"));
-Type processor = harmony.GetType("HarmonyLib.PatchProcessor", true)!;
-MethodInfo read = processor.GetMethods().Single(m => m.Name == "GetOriginalInstructions" && m.GetParameters().Length == 2 && !m.GetParameters()[1].ParameterType.IsByRef);
-Type magic = epic.GetType("EpicLoot.MagicItem", true)!;
-FieldInfo socketCount = magic.GetField("SocketCount")!;
-Type rarity = epic.GetType("EpicLoot.ItemRarity", true)!;
-Type item = epic.GetType("EpicLoot.EpicLoot", true)!.GetMethod("IsAllowedMagicItemType")!.GetParameters()[0].ParameterType;
-Type roller = epic.GetType("EpicLoot.LootRoller", true)!;
-MethodInfo weights = roller.GetMethod("GetSocketCountsPerRarity")!;
-compatibility.GetField("socketCount", flags)!.SetValue(null, socketCount);
-compatibility.GetField("socketWeights", flags)!.SetValue(null, weights);
-
-(MethodInfo Method, string Transform, string Helper, int Count)[] cases =
-{
- (roller.GetMethod("RollMagicItem", new[] { rarity, item, typeof(float), typeof(float) })!, "PreventRolledSockets", "FilterSocketCount", 1),
- (epic.GetType("EpicLoot.CraftingV2.EnchantingUIController", true)!.GetMethod("GetEnchantInfo", flags)!, "HideSocketPreview", "PreviewSocketWeights", 1),
- (epic.GetType("EpicLoot.Crafting.TransferMagicalEffects", true)!.GetMethod("TransferMagicToCraftedItem", flags)!, "PreventTransferredSockets", "TransferSocketCount", 3)
-};
-foreach ((MethodInfo method, string transform, string helper, int count) in cases)
-{
- object instructions = read.Invoke(null, new object?[] { method, null })!;
- object[] rewritten = ((IEnumerable)Call(transform, instructions)!).Cast().ToArray();
- int calls = rewritten.Count(i => (i.GetType().GetField("operand")!.GetValue(i) as MethodInfo)?.Name == helper);
- Require(calls == count, method.Name + " rewrites actual production IL");
- // The version guard must not quietly accept a changed method body.
- object empty = Activator.CreateInstance(instructions.GetType())!;
- bool rejected = false;
- try { _ = ((IEnumerable)Call(transform, empty)!).Cast().ToArray(); }
- catch (InvalidOperationException) { rejected = true; }
- Require(rejected, transform + " rejects changed IL");
-}
-
-compatibility.GetField("rarityType", flags)!.SetValue(null, rarity);
-compatibility.GetField("craftedItem", flags)!.SetValue(null,
- epic.GetType("EpicLoot.Crafting.TransferMagicalEffects", true)!.GetField("CraftedItem", flags));
-MethodInfo Required(string type, string method) => epic.GetType(type, true)!.GetMethod(method, flags)!;
-(MethodInfo Method, string? Prefix, string? Postfix, string? Transpiler)[] patches =
-{
- (Required("EpicLoot.EpicLoot", "IsAllowedMagicItemType"), null, "AllowTablet", null),
- (Required("EpicLoot.GatedItemType.ItemTypeClassifier", "ClassifyFromFields"), null, "ClassifyTablet", null),
- (cases[0].Method, null, null, cases[0].Transform),
- (cases[1].Method, null, null, cases[1].Transform),
- (cases[2].Method, null, null, cases[2].Transform),
- (Required("EpicLoot.ShardStones.ShardSlotChisel", "CanApply"), "PreventChisel", null, null),
- (Required("EpicLoot.ShardStones.ShardSocketManager", "CanSocket"), "PreventPlacement", null, null),
- (Required("EpicLoot.ShardStones.ShardSocketManager", "CanCoexist"), "PreventPlacement", null, null),
- (epic.GetType("EpicLoot.Crafting.CraftSuccessDialog", true)!.GetMethod("Show")!, null, "ShowCraftResult", null)
-};
-foreach ((MethodInfo method, string? prefix, string? postfix, string? transpiler) in patches)
-{
- Require(method != null, "patch target exists");
- foreach (string? callback in new[] { prefix, postfix, transpiler }.Where(name => name != null))
- Require(compatibility.GetMethod(callback!, flags) != null, "callback exists: " + callback);
-}
-foreach (string name in new[] { "CanSocket", "CanCoexist" })
- Require(Required("EpicLoot.ShardStones.ShardSocketManager", name).GetParameters()
- .Any(p => p.Name == "reason" && p.ParameterType == typeof(string).MakeByRefType()),
- name + " callback binds the actual rejection reason");
-System.Console.WriteLine("Runtime attachment and gameplay require a Unity client; this harness checks policy, targets, and IL transformations only.");
diff --git a/tests/EndgameCompatibility/README.md b/tests/EndgameCompatibility/README.md
deleted file mode 100644
index c925486..0000000
--- a/tests/EndgameCompatibility/README.md
+++ /dev/null
@@ -1,100 +0,0 @@
-# Temporary Endgame compatibility
-
-This bridges Epic Loot PRs [45](https://github.com/MidnightsFX/Randy_Vapok_ValheimMods/pull/45)
-and [47](https://github.com/MidnightsFX/Randy_Vapok_ValheimMods/pull/47) for Endgame testing.
-Keep the separately built Endgame DLL and its Epic Loot JSON patches in the client
-package. This change does not publish or bundle an Endgame build.
-
-## Scope
-
-- Runs only when BepInEx loads both Epic Loot and Endgame. The optional dependency
- makes Endgame load first; no source or binary reference to Endgame is needed.
-- Supports the exact production Epic Loot 0.14.13 binary with SHA-256
- `802790e0cbd8a6f951627619a488b84e6a406e8770e2687b2fff8b63d2adf9ea`.
- An unrecognized older binary gets a warning and no compatibility patches.
-- Allows enchanting `ve_tablet` and `ve_tablet_tier01` through `ve_tablet_tier15`.
- Other eligibility checks still apply. Keeps these items in the Tablet category
- when Epic Loot rebuilds item configuration.
-- Prevents socket rolls, insertion, swaps, chisel use, and crafting socket
- transfers for those items. Omits socket probabilities from their preview.
- Existing socket data is not erased on load; removal of old socket contents
- remains available.
-- Restores the crafting result's text and icon when the native fix is absent.
-- Does not add general-purpose JSON fields to older Epic Loot. The temporary
- tablet policy is compiled into this bridge, so clients and servers running it
- use the same policy. This is not a backport for arbitrary item categories.
-- Uses a separate Harmony owner. Removing this code does not require changing
- Epic Loot's saved-item format.
-
-## Native support and removal
-
-Item patches are skipped when the loaded assembly has both config fields, both
-runtime properties, and the native checks in all five socket paths and the
-enchanting eligibility path. The crafting result fix is checked separately.
-Field presence alone is not sufficient.
-
-Keep Endgame's iteminfo JSON patch, including `Enchantable: true` and
-`Socketable: false`, for the eventual native release. Older Epic Loot can discard
-unknown fields when it rewrites its generated config; this bridge does not rely
-on those generated fields. On upgrade, verify the Endgame JSON patch has been
-applied again and the native runtime contains both settings for every tablet.
-
-Before removing the bridge, test the upgraded Epic Loot with tablets saved by
-the old build. Confirm values, effects, socket rules, save/restart, and further
-enchanting. Then remove `EndgameCompatibility.cs`, its startup/shutdown calls,
-its optional Endgame dependency, and this test project in one change. No live
-upgrade or removal proof has been completed yet.
-
-## Local checks
-
-Build the client and test harness, then run under Mono (not the .NET Core runtime).
-The harness loads the actual client DLL and an actual Epic Loot DLL. It invokes
-the production compatibility code; it does not copy that code into a test model.
-
-```sh
-dotnet build PraetorisClient.csproj -c Release
-dotnet build tests/EndgameCompatibility
-mono tests/EndgameCompatibility/bin/Debug/net481/EndgameCompatibility.exe \
- bin/Release/PraetorisClient.dll /path/to/production/EpicLoot.dll /path/to/Valheim
-mono tests/EndgameCompatibility/bin/Debug/net481/EndgameCompatibility.exe \
- bin/Release/PraetorisClient.dll /path/to/native-support/EpicLoot.dll /path/to/Valheim native
-```
-
-On 2026-10-18: Release build passed with 83 existing warnings and zero errors.
-The harness build had zero warnings and errors. The production binary passed 51
-checks for item names, native detection, patch targets, parameter binding, three
-real IL transformations, and rejection of changed method bodies. A local Epic
-Loot build containing PR 45 (SHA-256
-`1d3244a7387458bf4b056387ef8707d6172733ad0d6fabc63327dce289fc5e3b`)
-passed 25 checks, including native detection.
-
-These checks do not attach runtime patches or exercise Unity gameplay. An
-attempt to attach patches outside Unity failed in MonoMod's runtime detour
-initialization. Actual attachment, gameplay, and saved-item migration must be
-tested in Valheim.
-
-## Remaining live proof
-
-Valnet Client 01 was started and inspected on 2026-10-18. Production was Season 8
-8.0.46 with Epic Loot 0.14.13 and Praetoris Client 0.1.101. Valnet's maintained
-profile reported Season 8 8.0.34; its Epic Loot binary matched production.
-The profile and mmcli registry were backed up before attempting alignment.
-The SSH key agent then refused signing requests. Alignment, baseline
-reproduction, candidate deployment, and live proof did not run.
-
-Resume with a device lease and a verified current production mirror:
-
-1. Run the ordinary enchanting-table flow with Endgame and the unchanged client.
- Record tablet eligibility and socket behavior before deploying this change.
-2. Deploy the candidate client to that same profile. Keep Epic Loot unchanged.
-3. Confirm the compatibility startup log and absence of new errors. Check all
- tablet names and a normal weapon as a control.
-4. Use the existing `cli_epicloot_item`, `cli_epicloot_enchant_ui`,
- `cli_epicloot_roll`, and UI inspection commands from valheimCLI. Exercise the
- actual enchanting button, materials consumption, preview, and result dialog.
-5. Exercise socket insertion/swaps, chisels, and crafting transfers. Check that
- rejected actions consume no items and normal weapons retain their behavior.
-6. Save/restart with an enchanted tablet. Upgrade to the native-support build,
- verify the skip log and loaded JSON flags, and repeat the checks.
-7. Repeat the server/client flow on the current production mirror. Inspect both
- logs, restore profile backups, verify restoration, and release devices.
From 9a9183818ad4b4be5d944ee9061ce76dc8f5c056 Mon Sep 17 00:00:00 2001
From: jneb802 <73610029+jneb802@users.noreply.github.com>
Date: Mon, 19 Oct 2026 11:38:20 -0700
Subject: [PATCH 5/7] Require server approval for Map Room beta entry
---
CHANGELOG.md | 1 +
Source/Core/Plugin.cs | 3 +
Source/Endgame/EndgameBetaAccess.cs | 160 ++++++++++++++++++++++++
Source/NetworkWard/NetworkWardAccess.cs | 2 +-
4 files changed, 165 insertions(+), 1 deletion(-)
create mode 100644 Source/Endgame/EndgameBetaAccess.cs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d64563e..fc27ac1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,6 @@
## 0.1.102
+- Require server approval from the Endgame beta Steam ID list before entering MWL_MapRoom1. Leaving remains unrestricted.
- Reserve rights to original PraetorisClient and Valheim Endgame code and assets while preserving third-party license notices.
- Include Valheim Endgame 0.5.0 and its tablet configuration in this package.
- Allow tablets to be enchanted with the released Epic Loot 0.14.13, prevent new tablet sockets and shardstones, and restore the enchanting result display.
diff --git a/Source/Core/Plugin.cs b/Source/Core/Plugin.cs
index 149281d..4182302 100644
--- a/Source/Core/Plugin.cs
+++ b/Source/Core/Plugin.cs
@@ -104,6 +104,7 @@ public class PraetorisClientPlugin : BaseUnityPlugin
internal static ConfigEntry CreatureOwnerWardUpdateIntervalSeconds = null!;
internal static ConfigEntry DebugCreatureOwnerWard = null!;
internal static ConfigEntry NetworkWardAllowedSteamIds = null!;
+ internal static ConfigEntry EndgameBetaAllowedSteamIds = null!;
internal static ConfigEntry DebugServerChest = null!;
internal static ConfigEntry FreeDeparturePortGuid = null!;
internal static ConfigEntry BlockPeerServerSyncConfigSync = null!;
@@ -297,6 +298,8 @@ private void BindConfig()
SyncedDescription("Prevents trophy placement on the original spawn temple boss stones. Disable to allow normal placement. Changes apply without a restart. Reset stones and buildable boss stones remain usable."));
NetworkWardAllowedSteamIds = Config.Bind("NetworkWard", "AllowedSteamIds", "",
"Server-only whitelist for Network Ward access. Comma-separated SteamID64 values (Steam_ prefix also accepted). Empty denies everyone, including admins. Client settings cannot grant access. Requires authenticated Steam connections.");
+ EndgameBetaAllowedSteamIds = Config.Bind("EndgameBeta", "AllowedSteamIds", "",
+ "Server-only list for entry to MWL_MapRoom1. Comma-separated SteamID64 values (Steam_ prefix also accepted). Empty denies everyone, including admins. Changes apply on the next entry. Client settings cannot grant access. Requires authenticated Steam connections.");
LinkApiUrl = Config.Bind("BotApi", "LinkApiUrl", "", "Compatible bot Valheim link endpoint. Prefer the PRAETORISCLIENT_LINK_API_URL environment variable on dedicated servers.");
BotApiKey = Config.Bind("BotApi", "ApiKey", "", "API key sent to the bot in the X-API-Key header. Prefer the PRAETORISCLIENT_BOT_API_KEY environment variable on dedicated servers.");
LinkCommand = Config.Bind("Linking", "LinkCommand", "!link", "In-game chat command consumed before it is sent as chat.");
diff --git a/Source/Endgame/EndgameBetaAccess.cs b/Source/Endgame/EndgameBetaAccess.cs
new file mode 100644
index 0000000..c46ca6c
--- /dev/null
+++ b/Source/Endgame/EndgameBetaAccess.cs
@@ -0,0 +1,160 @@
+using System;
+using System.IO;
+using BepInEx.Configuration;
+using HarmonyLib;
+using PraetorisClient.NetworkWardFeature;
+using UnityEngine;
+
+namespace PraetorisClient
+{
+ internal sealed class EndgameBetaAccess : MonoBehaviour
+ {
+ private const string RequestRpc = "PraetorisEndgameBetaRequest";
+ private const string ResponseRpc = "PraetorisEndgameBetaResponse";
+ private static EndgameBetaAccess? _instance;
+ private ZNet? _session;
+ private ZRpc? _server;
+ private Teleport? _gateway;
+ private Teleport? _approvedGateway;
+ private int _request;
+ private bool _pending;
+ private float _deadline;
+ private float _retryAt;
+
+ private static bool IsEntrance(Teleport gateway)
+ {
+ Transform? exterior = gateway.transform.parent;
+ while (exterior != null && exterior.name != "Exterior") exterior = exterior.parent;
+ if (exterior == null || exterior.parent == null) return false;
+ Transform root = exterior.parent;
+ if (root.name != "MWL_MapRoom1" && root.name != "MWL_MapRoom1(Clone)") return false;
+ return root.Find("Exterior/Blueprint/Gateway") == gateway.transform &&
+ root.Find("Interior/ExteriorGateway") == gateway.m_targetPoint?.transform;
+ }
+
+ private bool CurrentConnection() => _session != null && _session == ZNet.instance &&
+ !_session.IsServer() && _server != null && ReferenceEquals(_server, _session.GetServerRPC()) &&
+ _server.IsConnected() && Player.m_localPlayer != null && !Player.m_localPlayer.IsDead();
+
+ private void Request(Teleport gateway)
+ {
+ if (_pending || Time.unscaledTime < _retryAt) return;
+ _session = ZNet.instance;
+ _server = _session?.GetServerRPC();
+ if (!CurrentConnection())
+ {
+ Notice("Endgame beta access requires approval from a connected Steam server.");
+ _retryAt = Time.unscaledTime + 1;
+ return;
+ }
+ _gateway = gateway;
+ _request = _request == int.MaxValue ? 1 : _request + 1;
+ _pending = true;
+ _deadline = Time.unscaledTime + 5;
+ _server!.Invoke(RequestRpc, _request);
+ }
+
+ private void Update()
+ {
+ if (!_pending) return;
+ if (!CurrentConnection()) { _pending = false; _gateway = null; return; }
+ if (Time.unscaledTime < _deadline) return;
+ _pending = false;
+ _gateway = null;
+ _retryAt = Time.unscaledTime + 1;
+ Notice("Unable to verify Endgame beta access. Please try again.");
+ }
+
+ private static void OnRequest(ZRpc rpc, int request)
+ {
+ if (ZNet.instance == null || !ZNet.instance.IsServer()) return;
+ ZNetPeer peer = ZNet.instance.GetPeer(rpc);
+ ZSteamSocket? steam = NetworkWardAccess.FindSteamSocket(rpc.GetSocket());
+ bool allowed = peer != null && peer.IsReady() && steam != null && IsAllowed(steam.GetHostName());
+ PraetorisClientPlugin.Log.LogInfo("Endgame beta entry " + (allowed ? "approved." : "denied."));
+ rpc.Invoke(ResponseRpc, request, allowed);
+ }
+
+ private static bool IsAllowed(string steamId)
+ {
+ try
+ {
+ ConfigFile config = PraetorisClientPlugin.Instance!.Config;
+ if (!File.Exists(config.ConfigFilePath)) return false;
+ ConfigFile snapshot = new ConfigFile(config.ConfigFilePath, false) { SaveOnConfigSet = false };
+ string allowed = snapshot.Bind("EndgameBeta", "AllowedSteamIds", "").Value;
+ config.Reload();
+ PraetorisClientPlugin.EndgameBetaAllowedSteamIds.Value = allowed;
+ return NetworkWardAccessList.Contains(allowed, steamId);
+ }
+ catch (Exception exception) when (exception is IOException || exception is UnauthorizedAccessException)
+ {
+ PraetorisClientPlugin.Log.LogWarning("Endgame beta access denied: cannot read server configuration. " + exception.Message);
+ return false;
+ }
+ }
+
+ private static void OnResponse(ZRpc rpc, int request, bool allowed)
+ {
+ EndgameBetaAccess? access = _instance;
+ if (access == null || !access._pending || request != access._request ||
+ !ReferenceEquals(rpc, access._server) || !access.CurrentConnection() ||
+ Time.unscaledTime >= access._deadline) return;
+ access._pending = false;
+ access._retryAt = Time.unscaledTime + 1;
+ Teleport? gateway = access._gateway;
+ access._gateway = null;
+ if (!allowed)
+ {
+ Notice("Valheim Endgame is currently in closed beta. You do not have access.");
+ return;
+ }
+ Player player = Player.m_localPlayer;
+ if (gateway == null || !gateway.isActiveAndEnabled || !IsEntrance(gateway) || player.IsTeleporting() ||
+ Vector3.Distance(player.transform.position, gateway.transform.position) > 5) return;
+ access._approvedGateway = gateway;
+ try { gateway.Interact(player, false, false); }
+ finally { access._approvedGateway = null; }
+ }
+
+ private static void Notice(string message)
+ {
+ Player.m_localPlayer?.Message(MessageHud.MessageType.Center, message);
+ PraetorisClientPlugin.Log.LogInfo(message);
+ }
+
+ private void OnDestroy()
+ {
+ _pending = false;
+ _approvedGateway = _gateway = null;
+ if (_instance == this) _instance = null;
+ }
+
+ [HarmonyPatch(typeof(Teleport), nameof(Teleport.Interact))]
+ private static class GateEntry
+ {
+ private static bool Prefix(Teleport __instance, Humanoid character, bool hold, ref bool __result)
+ {
+ if (!IsEntrance(__instance)) return true;
+ if (_instance != null && _instance._approvedGateway == __instance) return true;
+ __result = false;
+ if (hold || character != Player.m_localPlayer || character is not Player player ||
+ player.IsDead() || player.IsTeleporting() ||
+ Vector3.Distance(player.transform.position, __instance.transform.position) > 5) return false;
+ if (_instance == null) _instance = PraetorisClientPlugin.Instance!.gameObject.AddComponent();
+ _instance.Request(__instance);
+ return false;
+ }
+ }
+
+ [HarmonyPatch(typeof(ZNet), "OnNewConnection")]
+ private static class RegisterAccessRpc
+ {
+ private static void Postfix(ZNetPeer peer)
+ {
+ peer.m_rpc.Register(RequestRpc, OnRequest);
+ peer.m_rpc.Register(ResponseRpc, OnResponse);
+ }
+ }
+ }
+}
diff --git a/Source/NetworkWard/NetworkWardAccess.cs b/Source/NetworkWard/NetworkWardAccess.cs
index e119117..be4049b 100644
--- a/Source/NetworkWard/NetworkWardAccess.cs
+++ b/Source/NetworkWard/NetworkWardAccess.cs
@@ -95,7 +95,7 @@ private static void OnRequest(ZRpc rpc, int request)
rpc.Invoke(ResponseRpc, request, allowed);
}
- private static ZSteamSocket? FindSteamSocket(ISocket socket)
+ internal static ZSteamSocket? FindSteamSocket(ISocket socket)
{
// ServerSync and ConditionalConfigSync temporarily nest BufferingSocket
// wrappers during login. Their Original fields retain the real connection.
From 63c2c05b3e5de092b02b4bbe03eaba4270d9f71e Mon Sep 17 00:00:00 2001
From: jneb802 <73610029+jneb802@users.noreply.github.com>
Date: Mon, 19 Oct 2026 11:48:44 -0700
Subject: [PATCH 6/7] Use Jotunn custom RPC for Endgame beta access
---
Source/Core/Plugin.cs | 1 +
Source/Endgame/EndgameBetaAccess.cs | 62 +++++++++++++++++------------
2 files changed, 37 insertions(+), 26 deletions(-)
diff --git a/Source/Core/Plugin.cs b/Source/Core/Plugin.cs
index 4182302..cf99f59 100644
--- a/Source/Core/Plugin.cs
+++ b/Source/Core/Plugin.cs
@@ -142,6 +142,7 @@ public void Awake()
Instance = this;
BindConfig();
+ EndgameBetaAccess.Initialize();
PrefabManager.OnPrefabsRegistered += BossSpawnMessageSuppression.Refresh;
SynchronizationManager.OnConfigurationSynchronized += OnConfigurationSynchronized;
PraetorisMagicEffects.Register();
diff --git a/Source/Endgame/EndgameBetaAccess.cs b/Source/Endgame/EndgameBetaAccess.cs
index c46ca6c..cf9fdcc 100644
--- a/Source/Endgame/EndgameBetaAccess.cs
+++ b/Source/Endgame/EndgameBetaAccess.cs
@@ -1,7 +1,10 @@
using System;
+using System.Collections;
using System.IO;
using BepInEx.Configuration;
using HarmonyLib;
+using Jotunn.Entities;
+using Jotunn.Managers;
using PraetorisClient.NetworkWardFeature;
using UnityEngine;
@@ -9,8 +12,7 @@ namespace PraetorisClient
{
internal sealed class EndgameBetaAccess : MonoBehaviour
{
- private const string RequestRpc = "PraetorisEndgameBetaRequest";
- private const string ResponseRpc = "PraetorisEndgameBetaResponse";
+ private static CustomRPC _rpc = null!;
private static EndgameBetaAccess? _instance;
private ZNet? _session;
private ZRpc? _server;
@@ -21,6 +23,11 @@ internal sealed class EndgameBetaAccess : MonoBehaviour
private float _deadline;
private float _retryAt;
+ internal static void Initialize()
+ {
+ _rpc = NetworkManager.Instance.AddRPC("EndgameBetaAccess", OnRequest, OnResponse);
+ }
+
private static bool IsEntrance(Teleport gateway)
{
Transform? exterior = gateway.transform.parent;
@@ -34,7 +41,8 @@ private static bool IsEntrance(Teleport gateway)
private bool CurrentConnection() => _session != null && _session == ZNet.instance &&
!_session.IsServer() && _server != null && ReferenceEquals(_server, _session.GetServerRPC()) &&
- _server.IsConnected() && Player.m_localPlayer != null && !Player.m_localPlayer.IsDead();
+ _server.IsConnected() && ZRoutedRpc.instance != null &&
+ Player.m_localPlayer != null && !Player.m_localPlayer.IsDead();
private void Request(Teleport gateway)
{
@@ -51,7 +59,9 @@ private void Request(Teleport gateway)
_request = _request == int.MaxValue ? 1 : _request + 1;
_pending = true;
_deadline = Time.unscaledTime + 5;
- _server!.Invoke(RequestRpc, _request);
+ ZPackage package = new ZPackage();
+ package.Write(_request);
+ _rpc.SendPackage(ZRoutedRpc.instance.GetServerPeerID(), package);
}
private void Update()
@@ -65,14 +75,20 @@ private void Update()
Notice("Unable to verify Endgame beta access. Please try again.");
}
- private static void OnRequest(ZRpc rpc, int request)
+ private static IEnumerator OnRequest(long sender, ZPackage package)
{
- if (ZNet.instance == null || !ZNet.instance.IsServer()) return;
- ZNetPeer peer = ZNet.instance.GetPeer(rpc);
- ZSteamSocket? steam = NetworkWardAccess.FindSteamSocket(rpc.GetSocket());
- bool allowed = peer != null && peer.IsReady() && steam != null && IsAllowed(steam.GetHostName());
+ if (ZNet.instance == null || !ZNet.instance.IsServer() || package.Size() != sizeof(int)) yield break;
+ ZNetPeer peer = ZNet.instance.GetPeer(sender);
+ if (peer == null || !peer.IsReady()) yield break;
+ int request = package.ReadInt();
+ ZSteamSocket? steam = NetworkWardAccess.FindSteamSocket(peer.m_rpc.GetSocket());
+ bool allowed = steam != null && IsAllowed(steam.GetHostName());
PraetorisClientPlugin.Log.LogInfo("Endgame beta entry " + (allowed ? "approved." : "denied."));
- rpc.Invoke(ResponseRpc, request, allowed);
+ ZPackage response = new ZPackage();
+ response.Write(request);
+ response.Write(allowed);
+ _rpc.SendPackage(sender, response);
+ yield break;
}
private static bool IsAllowed(string steamId)
@@ -94,12 +110,15 @@ private static bool IsAllowed(string steamId)
}
}
- private static void OnResponse(ZRpc rpc, int request, bool allowed)
+ private static IEnumerator OnResponse(long sender, ZPackage package)
{
EndgameBetaAccess? access = _instance;
- if (access == null || !access._pending || request != access._request ||
- !ReferenceEquals(rpc, access._server) || !access.CurrentConnection() ||
- Time.unscaledTime >= access._deadline) return;
+ if (access == null || !access._pending || !access.CurrentConnection() ||
+ sender != ZRoutedRpc.instance.GetServerPeerID() ||
+ package.Size() != sizeof(int) + sizeof(bool) || Time.unscaledTime >= access._deadline) yield break;
+ int request = package.ReadInt();
+ bool allowed = package.ReadBool();
+ if (request != access._request) yield break;
access._pending = false;
access._retryAt = Time.unscaledTime + 1;
Teleport? gateway = access._gateway;
@@ -107,14 +126,15 @@ private static void OnResponse(ZRpc rpc, int request, bool allowed)
if (!allowed)
{
Notice("Valheim Endgame is currently in closed beta. You do not have access.");
- return;
+ yield break;
}
Player player = Player.m_localPlayer;
if (gateway == null || !gateway.isActiveAndEnabled || !IsEntrance(gateway) || player.IsTeleporting() ||
- Vector3.Distance(player.transform.position, gateway.transform.position) > 5) return;
+ Vector3.Distance(player.transform.position, gateway.transform.position) > 5) yield break;
access._approvedGateway = gateway;
try { gateway.Interact(player, false, false); }
finally { access._approvedGateway = null; }
+ yield break;
}
private static void Notice(string message)
@@ -146,15 +166,5 @@ private static bool Prefix(Teleport __instance, Humanoid character, bool hold, r
return false;
}
}
-
- [HarmonyPatch(typeof(ZNet), "OnNewConnection")]
- private static class RegisterAccessRpc
- {
- private static void Postfix(ZNetPeer peer)
- {
- peer.m_rpc.Register(RequestRpc, OnRequest);
- peer.m_rpc.Register(ResponseRpc, OnResponse);
- }
- }
}
}
From 864fb502271ecc88a7f71187bf26c274f8a5ca54 Mon Sep 17 00:00:00 2001
From: jneb802 <73610029+jneb802@users.noreply.github.com>
Date: Mon, 19 Oct 2026 12:12:59 -0700
Subject: [PATCH 7/7] Add live server switch for Endgame beta access
---
CHANGELOG.md | 1 +
Source/Core/Plugin.cs | 3 +++
Source/Endgame/EndgameBetaAccess.cs | 37 ++++++++++++++++++++++++-----
3 files changed, 35 insertions(+), 6 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fc27ac1..e0b1568 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,7 @@
## 0.1.102
- Require server approval from the Endgame beta Steam ID list before entering MWL_MapRoom1. Leaving remains unrestricted.
+- Add a server-synced Endgame beta enable setting that can restore normal Map Room entry without a restart.
- Reserve rights to original PraetorisClient and Valheim Endgame code and assets while preserving third-party license notices.
- Include Valheim Endgame 0.5.0 and its tablet configuration in this package.
- Allow tablets to be enchanted with the released Epic Loot 0.14.13, prevent new tablet sockets and shardstones, and restore the enchanting result display.
diff --git a/Source/Core/Plugin.cs b/Source/Core/Plugin.cs
index cf99f59..efcd5b1 100644
--- a/Source/Core/Plugin.cs
+++ b/Source/Core/Plugin.cs
@@ -105,6 +105,7 @@ public class PraetorisClientPlugin : BaseUnityPlugin
internal static ConfigEntry DebugCreatureOwnerWard = null!;
internal static ConfigEntry NetworkWardAllowedSteamIds = null!;
internal static ConfigEntry EndgameBetaAllowedSteamIds = null!;
+ internal static ConfigEntry EndgameBetaEnabled = null!;
internal static ConfigEntry DebugServerChest = null!;
internal static ConfigEntry FreeDeparturePortGuid = null!;
internal static ConfigEntry BlockPeerServerSyncConfigSync = null!;
@@ -299,6 +300,8 @@ private void BindConfig()
SyncedDescription("Prevents trophy placement on the original spawn temple boss stones. Disable to allow normal placement. Changes apply without a restart. Reset stones and buildable boss stones remain usable."));
NetworkWardAllowedSteamIds = Config.Bind("NetworkWard", "AllowedSteamIds", "",
"Server-only whitelist for Network Ward access. Comma-separated SteamID64 values (Steam_ prefix also accepted). Empty denies everyone, including admins. Client settings cannot grant access. Requires authenticated Steam connections.");
+ EndgameBetaEnabled = Config.Bind("EndgameBeta", "Enabled", true,
+ SyncedDescription("Restricts entry to MWL_MapRoom1 to the server beta list. Disable to restore normal gateway entry. Changes apply to connected clients without a restart."));
EndgameBetaAllowedSteamIds = Config.Bind("EndgameBeta", "AllowedSteamIds", "",
"Server-only list for entry to MWL_MapRoom1. Comma-separated SteamID64 values (Steam_ prefix also accepted). Empty denies everyone, including admins. Changes apply on the next entry. Client settings cannot grant access. Requires authenticated Steam connections.");
LinkApiUrl = Config.Bind("BotApi", "LinkApiUrl", "", "Compatible bot Valheim link endpoint. Prefer the PRAETORISCLIENT_LINK_API_URL environment variable on dedicated servers.");
diff --git a/Source/Endgame/EndgameBetaAccess.cs b/Source/Endgame/EndgameBetaAccess.cs
index cf9fdcc..ec26a69 100644
--- a/Source/Endgame/EndgameBetaAccess.cs
+++ b/Source/Endgame/EndgameBetaAccess.cs
@@ -22,10 +22,13 @@ internal sealed class EndgameBetaAccess : MonoBehaviour
private bool _pending;
private float _deadline;
private float _retryAt;
+ private float _refreshAt;
+ private static DateTime _configWriteUtc;
internal static void Initialize()
{
_rpc = NetworkManager.Instance.AddRPC("EndgameBetaAccess", OnRequest, OnResponse);
+ _instance = PraetorisClientPlugin.Instance!.gameObject.AddComponent();
}
private static bool IsEntrance(Teleport gateway)
@@ -66,6 +69,21 @@ private void Request(Teleport gateway)
private void Update()
{
+ if (ZNet.instance != null && ZNet.instance.IsServer())
+ {
+ if (Time.unscaledTime >= _refreshAt)
+ {
+ _refreshAt = Time.unscaledTime + 1;
+ RefreshServerConfig();
+ }
+ return;
+ }
+ if (!PraetorisClientPlugin.EndgameBetaEnabled.Value)
+ {
+ _pending = false;
+ _gateway = null;
+ return;
+ }
if (!_pending) return;
if (!CurrentConnection()) { _pending = false; _gateway = null; return; }
if (Time.unscaledTime < _deadline) return;
@@ -82,7 +100,8 @@ private static IEnumerator OnRequest(long sender, ZPackage package)
if (peer == null || !peer.IsReady()) yield break;
int request = package.ReadInt();
ZSteamSocket? steam = NetworkWardAccess.FindSteamSocket(peer.m_rpc.GetSocket());
- bool allowed = steam != null && IsAllowed(steam.GetHostName());
+ bool allowed = RefreshServerConfig() && (!PraetorisClientPlugin.EndgameBetaEnabled.Value ||
+ steam != null && NetworkWardAccessList.Contains(PraetorisClientPlugin.EndgameBetaAllowedSteamIds.Value, steam.GetHostName()));
PraetorisClientPlugin.Log.LogInfo("Endgame beta entry " + (allowed ? "approved." : "denied."));
ZPackage response = new ZPackage();
response.Write(request);
@@ -91,17 +110,24 @@ private static IEnumerator OnRequest(long sender, ZPackage package)
yield break;
}
- private static bool IsAllowed(string steamId)
+ private static bool RefreshServerConfig()
{
try
{
ConfigFile config = PraetorisClientPlugin.Instance!.Config;
if (!File.Exists(config.ConfigFilePath)) return false;
+ DateTime written = File.GetLastWriteTimeUtc(config.ConfigFilePath);
+ if (written == _configWriteUtc) return true;
ConfigFile snapshot = new ConfigFile(config.ConfigFilePath, false) { SaveOnConfigSet = false };
+ bool enabled = snapshot.Bind("EndgameBeta", "Enabled", true).Value;
string allowed = snapshot.Bind("EndgameBeta", "AllowedSteamIds", "").Value;
config.Reload();
+ bool repairEnabled = PraetorisClientPlugin.EndgameBetaEnabled.Value != enabled;
+ PraetorisClientPlugin.EndgameBetaEnabled.Value = enabled;
PraetorisClientPlugin.EndgameBetaAllowedSteamIds.Value = allowed;
- return NetworkWardAccessList.Contains(allowed, steamId);
+ if (repairEnabled) config.Reload();
+ _configWriteUtc = File.GetLastWriteTimeUtc(config.ConfigFilePath);
+ return true;
}
catch (Exception exception) when (exception is IOException || exception is UnauthorizedAccessException)
{
@@ -155,14 +181,13 @@ private static class GateEntry
{
private static bool Prefix(Teleport __instance, Humanoid character, bool hold, ref bool __result)
{
- if (!IsEntrance(__instance)) return true;
+ if (!PraetorisClientPlugin.EndgameBetaEnabled.Value || !IsEntrance(__instance)) return true;
if (_instance != null && _instance._approvedGateway == __instance) return true;
__result = false;
if (hold || character != Player.m_localPlayer || character is not Player player ||
player.IsDead() || player.IsTeleporting() ||
Vector3.Distance(player.transform.position, __instance.transform.position) > 5) return false;
- if (_instance == null) _instance = PraetorisClientPlugin.Instance!.gameObject.AddComponent();
- _instance.Request(__instance);
+ _instance!.Request(__instance);
return false;
}
}