diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 000000000..cab295f79
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,57 @@
+root = true
+
+[*]
+indent_style = space
+indent_size = 4
+# Editor Guidelines VS/Rider extension: 180-col guide
+guidelines = 180
+
+[*.cs]
+indent_size = 4
+tab_width = 4
+
+# A BOM is what stops an editor from misreading a UTF-8 file as CP1252 and silently
+# round-tripping non-ASCII characters into mojibake (it happened to the tooltip pip
+# glyphs in EpicLoot.cs). Prefer \uXXXX escapes for glyphs in string literals anyway.
+charset = utf-8-bom
+
+# --- One True Brace Style (1TBS) ---
+csharp_new_line_before_open_brace = none
+csharp_new_line_before_else = false
+csharp_new_line_before_catch = false
+csharp_new_line_before_finally = false
+csharp_new_line_before_members_in_object_initializers = true
+csharp_new_line_before_members_in_anonymous_types = true
+csharp_new_line_between_query_expression_clauses = true
+
+# Always use braces (1TBS mandates them, even on single-line blocks)
+csharp_prefer_braces = true:suggestion
+
+# Block / switch indentation
+csharp_indent_case_contents = true
+csharp_indent_switch_labels = true
+csharp_indent_block_contents = true
+csharp_indent_braces = false
+
+# Keep formatting deviations as hints, never warnings/errors
+dotnet_diagnostic.IDE0055.severity = suggestion
+
+# --- Reduce modernization nagging (merged from both old files) ---
+dotnet_diagnostic.IDE0003.severity = none # Remove qualification
+dotnet_diagnostic.IDE0031.severity = none # Use null propagation
+dotnet_diagnostic.IDE0051.severity = none # Remove unused private members
+dotnet_diagnostic.IDE0060.severity = none # Remove unused parameter
+dotnet_diagnostic.IDE0066.severity = none # Convert switch to expression
+dotnet_diagnostic.IDE0074.severity = none # Use compound assignment
+dotnet_diagnostic.IDE0090.severity = none # 'new' expression can be simplified
+dotnet_diagnostic.IDE0130.severity = none # Namespace doesn't match folder
+dotnet_diagnostic.IDE0270.severity = none # Use coalesce expression
+
+# Localization files are read with BOM detection but must never carry one for
+# other tooling; plain UTF-8 is the contract.
+[*.{json,md}]
+charset = utf-8
+
+# --- Generated / third-party Unity code: skip style analysis ---
+[ValheimUnity/**.cs]
+generated_code = true
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
old mode 100755
new mode 100644
index 21c7eba24..22320aacd
--- a/.gitignore
+++ b/.gitignore
@@ -15,6 +15,11 @@
/**/obj/
/packages/
+# Always ignore the valheim assembly metadata as it gets updated each time a build happens
+ValheimUnity/Assets/ExternalLibraries/**.meta
+ValheimUnity/Assets/Exports.meta
+EpicLoot/ValheimUnity/Assets/ExternalLibraries/EpicLoot.dll
+
# Never ignore Asset meta data
!/[Aa]ssets/**/*.meta
@@ -62,13 +67,15 @@ sysinfo.txt
*.apk
*.unitypackage
+# Releases
+EpicLoot/Releases/
+
# Crashlytics generated file
crashlytics-build.properties
*.swp
/valheim_Data/BepInEx-out/plugins/ExtendedItemDataFramework.dll
/valheim_Data/BepInEx-out/plugins/EpicLoot/translations.json
-/valheim_Data/BepInEx-out/plugins/EpicLoot/recipes.json
/valheim_Data/BepInEx-out/plugins/EpicLoot/magiceffects.json
/valheim_Data/BepInEx-out/plugins/EpicLoot/loottables.json
/valheim_Data/BepInEx-out/plugins/EpicLoot/legendaries.json
@@ -92,3 +99,9 @@ ValheimUnity/Packages/packages-lock.json
Common
ValheimMods.sln
*.env
+/EpicLoot/shardstone_testing_matrix.csv
+/.gitignore
+/Common/Common.projitems
+/EpicLoot/EpicLoot.csproj
+/EpicLoot/EpicLoot.sln
+/EpicLoot/shardstone_testing_matrix.csv
diff --git a/AdvancedPortals/AdvancedPortals.csproj b/AdvancedPortals/AdvancedPortals.csproj
index 3d8dc0b87..78b66c99b 100644
--- a/AdvancedPortals/AdvancedPortals.csproj
+++ b/AdvancedPortals/AdvancedPortals.csproj
@@ -51,7 +51,7 @@
-
-
-
+
+
+
\ No newline at end of file
diff --git a/AdvancedPortals/README.md b/AdvancedPortals/README.md
index 339288a68..d4c3fbd9b 100644
--- a/AdvancedPortals/README.md
+++ b/AdvancedPortals/README.md
@@ -1,7 +1,7 @@
# Advanced Portals
Author: [RandyKnapp](https://discord.gg/ZNhYeavv3C)
-Source: [Github](https://github.com/OrianaVenture/Randy_Vapok_ValheimMods/tree/main/AdvancedPortals)
+Source: [Github](https://github.com/RandyKnapp/ValheimMods/tree/main/AdvancedPortals)
Patreon: [patreon.com/randyknapp](https://www.patreon.com/randyknapp)
Discord: [RandyKnapp's Mod Community](https://discord.gg/ZNhYeavv3C)
diff --git a/BuildTasks.props b/BuildTasks.props
index 6eb505bb9..608a82f8d 100644
--- a/BuildTasks.props
+++ b/BuildTasks.props
@@ -8,6 +8,11 @@
+
+
+
diff --git a/Common/Common.projitems b/Common/Common.projitems
index c0d416dd4..79d3786aa 100644
--- a/Common/Common.projitems
+++ b/Common/Common.projitems
@@ -9,6 +9,10 @@
Common
-
+
+
\ No newline at end of file
diff --git a/Common/src/Modding/AssetBundleLoader.cs b/Common/src/Modding/AssetBundleLoader.cs
new file mode 100644
index 000000000..db5521aac
--- /dev/null
+++ b/Common/src/Modding/AssetBundleLoader.cs
@@ -0,0 +1,46 @@
+using System;
+using System.IO;
+using System.Reflection;
+using UnityEngine;
+
+namespace Common {
+ ///
+ /// Loads an asset bundle embedded in a mod assembly as a manifest resource.
+ ///
+ /// Replaces the copy of this method that lived in EpicLoot, Jam, AdvancedPortals and
+ /// EquipmentAndQuickSlots. Those used Assembly.GetCallingAssembly(); that is unsafe here, because the
+ /// caller can be another type in this shared project rather than the mod, so the assembly is explicit.
+ ///
+ public static class AssetBundleLoader {
+ ///
+ /// Loads the bundle embedded as "<assembly name>.<filename>". Returns null (and logs) on failure.
+ ///
+ public static AssetBundle LoadFromResources(string filename, Assembly assembly) {
+ if (assembly == null) { throw new ArgumentNullException(nameof(assembly)); }
+
+ string resourceName = $"{assembly.GetName().Name}.{filename}";
+ try {
+ using (Stream stream = assembly.GetManifestResourceStream(resourceName)) {
+ if (stream == null) {
+ ModLogger.LogError($"Embedded asset bundle '{resourceName}' not found in {assembly.GetName().Name}.");
+ return null;
+ }
+ return AssetBundle.LoadFromStream(stream);
+ }
+ } catch (Exception e) {
+ ModLogger.LogError($"Failed to load embedded asset bundle '{resourceName}': {e.Message}");
+ return null;
+ }
+ }
+
+ ///
+ /// Loads the bundle and assigns it to so the shared loaders
+ /// can find it without being handed one.
+ ///
+ public static AssetBundle LoadIntoContext(string filename, Assembly assembly) {
+ AssetBundle bundle = LoadFromResources(filename, assembly);
+ if (bundle != null) { ModContext.AssetBundle = bundle; }
+ return bundle;
+ }
+ }
+}
diff --git a/Common/src/Modding/ConfigBinder.cs b/Common/src/Modding/ConfigBinder.cs
new file mode 100644
index 000000000..655335f37
--- /dev/null
+++ b/Common/src/Modding/ConfigBinder.cs
@@ -0,0 +1,97 @@
+using BepInEx.Configuration;
+using Jotunn.Extensions;
+
+namespace Common {
+ ///
+ /// Binding helpers over .
+ ///
+ /// BindServerConfig marks entries IsAdminOnly, which is what makes Jotunn's SynchronizationManager
+ /// treat them as server-authoritative; BindClientConfig leaves them local to each player.
+ ///
+ /// Prefer the ...InOrder variants for new config files: they keep the settings grouped the way the
+ /// code declares them instead of alphabetised. See the remarks on .
+ ///
+ public static class ConfigBinder {
+ private static ConfigFile Cfg => ModContext.Cfg;
+
+ // -- Server synced (admin only) ------------------------------------------------------------
+
+ public static ConfigEntry BindServerConfig(string category, string key, bool value, string description,
+ AcceptableValueBase acceptableValues = null, bool advanced = false) {
+ return Cfg.Bind(category, key, value,
+ new ConfigDescription(description, acceptableValues,
+ new ConfigurationManagerAttributes { IsAdminOnly = true, IsAdvanced = advanced }));
+ }
+
+ public static ConfigEntry BindServerConfig(string category, string key, int value, string description,
+ bool advanced = false, int valMin = 0, int valMax = 150) {
+ return Cfg.Bind(category, key, value,
+ new ConfigDescription(description, new AcceptableValueRange(valMin, valMax),
+ new ConfigurationManagerAttributes { IsAdminOnly = true, IsAdvanced = advanced }));
+ }
+
+ public static ConfigEntry BindServerConfig(string category, string key, float value, string description,
+ bool advanced = false, float valMin = 0f, float valMax = 150f) {
+ return Cfg.Bind(category, key, value,
+ new ConfigDescription(description, new AcceptableValueRange(valMin, valMax),
+ new ConfigurationManagerAttributes { IsAdminOnly = true, IsAdvanced = advanced }));
+ }
+
+ public static ConfigEntry BindServerConfig(string category, string key, string value, string description,
+ AcceptableValueList acceptableValues = null, bool advanced = false) {
+ return Cfg.Bind(category, key, value,
+ new ConfigDescription(description, acceptableValues,
+ new ConfigurationManagerAttributes { IsAdminOnly = true, IsAdvanced = advanced }));
+ }
+
+ /// Generic escape hatch for types without a dedicated overload above.
+ public static ConfigEntry BindServerConfig(string category, string key, T value, string description,
+ AcceptableValueBase acceptableValues, bool advanced = false) {
+ return Cfg.Bind(category, key, value,
+ new ConfigDescription(description, acceptableValues,
+ new ConfigurationManagerAttributes { IsAdminOnly = true, IsAdvanced = advanced }));
+ }
+
+ // -- Client local --------------------------------------------------------------------------
+
+ public static ConfigEntry BindClientConfig(string category, string key, T value, string description,
+ AcceptableValueBase acceptableValues = null, bool advanced = false) {
+ return Cfg.Bind(category, key, value,
+ new ConfigDescription(description, acceptableValues,
+ new ConfigurationManagerAttributes { IsAdminOnly = false, IsAdvanced = advanced }));
+ }
+
+ // -- Ordered ---------------------------------------------------------------------------------
+
+ ///
+ /// Server-synced entry bound through Jotunn's ordered binder.
+ ///
+ ///
+ /// ConfigurationManager sorts sections alphabetically and, within a section, by the Order
+ /// attribute. Jotunn's BindConfigInOrder exploits both: it prefixes the section with the position
+ /// it was first bound in ("2 - Balance") and hands each entry a descending Order, so what the
+ /// player sees matches the grouping the code declares rather than an alphabetical jumble.
+ ///
+ /// Two consequences to plan for:
+ /// - Keep a config file to **nine sections or fewer**. The prefix is not zero padded, so a tenth
+ /// section sorts as "10 - ..." between "1 - ..." and "2 - ...".
+ /// - The prefix is part of the section name written to disk, so inserting a section renames every
+ /// section after it and orphans the player's saved values. Add new sections at the end, or
+ /// migrate the old names (EpicLoot's ELConfig does the latter).
+ ///
+ public static ConfigEntry BindServerConfigInOrder(string category, string key, T value, string description,
+ AcceptableValueBase acceptableValues = null, bool advanced = false) {
+ return Cfg.BindConfigInOrder(category, key, value, description, synced: true,
+ acceptableValues: acceptableValues,
+ configAttributes: new ConfigurationManagerAttributes { IsAdvanced = advanced });
+ }
+
+ /// Client-local counterpart of .
+ public static ConfigEntry BindClientConfigInOrder(string category, string key, T value, string description,
+ AcceptableValueBase acceptableValues = null, bool advanced = false) {
+ return Cfg.BindConfigInOrder(category, key, value, description, synced: false,
+ acceptableValues: acceptableValues,
+ configAttributes: new ConfigurationManagerAttributes { IsAdvanced = advanced });
+ }
+ }
+}
diff --git a/Common/src/Modding/ConfigChangeDebouncer.cs b/Common/src/Modding/ConfigChangeDebouncer.cs
new file mode 100644
index 000000000..cd98cf17c
--- /dev/null
+++ b/Common/src/Modding/ConfigChangeDebouncer.cs
@@ -0,0 +1,57 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace Common {
+ // Debounces config apply work so a burst of rapid SettingChanged events for one entry
+ // (a user typing into a field, a config file reload, or a server config sync) collapses
+ // into a single apply once the edits settle, instead of re-doing the heavy work (recipe
+ // rebuilds, world scans) on every intermediate value.
+ //
+ // Mirrors the EnqueueWorldUpdate/DrainWorldUpdates coroutine pattern (BepInEx ThreadingHelper,
+ // yielding null each frame - WaitForSeconds is not reliably honoured by that runner). No locking;
+ // like EnqueueWorldUpdate this assumes the config handlers run on the main thread.
+ public static class ConfigChangeDebouncer {
+ // Latest action to run per key (the changed ConfigEntry instance).
+ private static readonly Dictionary