diff --git a/LazyPrune/LazyPrune.csproj b/LazyPrune/LazyPrune.csproj
new file mode 100644
index 0000000..bec5b03
--- /dev/null
+++ b/LazyPrune/LazyPrune.csproj
@@ -0,0 +1,6 @@
+
+
+
+ LoadedObjectHack
+
+
diff --git a/LazyPrune/Main.cs b/LazyPrune/Main.cs
new file mode 100644
index 0000000..f7b186c
--- /dev/null
+++ b/LazyPrune/Main.cs
@@ -0,0 +1,128 @@
+using ABI_RC.Core.IO;
+using ABI_RC.Systems.GameEventSystem;
+using MelonLoader;
+using UnityEngine;
+
+namespace NAK.LazyPrune;
+
+public class LazyPrune : MelonMod
+{
+ private static MelonLogger.Instance Logger;
+
+ //private const int MAX_OBJECTS_UNLOADED_AT_ONCE = 5; // just to alleviate hitch on mass destruction
+ private const float OBJECT_CACHE_TIMEOUT = 3f; // minutes
+ private static readonly Dictionary _loadedObjects = new();
+
+ private static string _lastLoadedWorld;
+
+ public override void OnInitializeMelon()
+ {
+ Logger = LoggerInstance;
+
+ // every minute, check for objects to prune
+ SchedulerSystem.AddJob(CheckForObjectsToPrune, 1f, 60f, -1);
+
+ // listen for world load
+ CVRGameEventSystem.World.OnLoad.AddListener(OnWorldLoaded);
+
+ // listen for local avatar load/clear events
+ CVRGameEventSystem.Avatar.OnLocalAvatarLoad.AddListener((avatar) => OnObjectCreated(avatar.loadedObject));
+ CVRGameEventSystem.Avatar.OnLocalAvatarClear.AddListener((avatar) => OnObjectDestroyed(avatar ? avatar.loadedObject : null));
+
+ // listen for remote avatar load/clear events
+ CVRGameEventSystem.Avatar.OnRemoteAvatarLoad.AddListener((_, avatar) => OnObjectCreated(avatar.loadedObject));
+ CVRGameEventSystem.Avatar.OnRemoteAvatarClear.AddListener((_, avatar) => OnObjectDestroyed(avatar != null ? avatar.loadedObject : null));
+
+ // listen for spawnable instantiate/destroy events
+ CVRGameEventSystem.Spawnable.OnInstantiate.AddListener((_, spawnable) => OnObjectCreated(spawnable.loadedObject));
+ CVRGameEventSystem.Spawnable.OnDestroy.AddListener((_, spawnable) => OnObjectDestroyed(spawnable != null ? spawnable.loadedObject : null));
+ }
+
+ #region Game Events
+
+ private static void OnWorldLoaded(string guid)
+ {
+ if (_lastLoadedWorld != guid)
+ ForcePrunePendingObjects();
+
+ _lastLoadedWorld = guid;
+ }
+
+ private static void OnObjectCreated(CVRObjectLoader.LoadedObject loadedObject)
+ {
+ if (loadedObject == null)
+ return; // uhh
+
+ if (_loadedObjects.ContainsKey(loadedObject))
+ {
+ _loadedObjects[loadedObject] = -1; // mark as ineligible for pruning
+ return; // already in cache
+ }
+
+ loadedObject.refCount++; // increment ref count
+ _loadedObjects.Add(loadedObject, -1); // mark as ineligible for pruning
+ }
+
+ private static void OnObjectDestroyed(CVRObjectLoader.LoadedObject loadedObject)
+ {
+ if (loadedObject == null)
+ return; // handled by AttemptPruneObject
+
+ if (loadedObject.refCount > 2)
+ return; // we added our own ref, so begin death count at 2 (decrements one more after this callback)
+
+ if (_loadedObjects.ContainsKey(loadedObject))
+ _loadedObjects[loadedObject] = Time.time + OBJECT_CACHE_TIMEOUT * 60f;
+ }
+
+ #endregion Game Events
+
+ #region Lazy Pruning
+
+ private static void ForcePrunePendingObjects()
+ {
+ for (int i = _loadedObjects.Count - 1; i >= 0; i--)
+ {
+ (CVRObjectLoader.LoadedObject loadedObject, var killTime) = _loadedObjects.ElementAt(i);
+ if (killTime > 0) AttemptPruneObject(loadedObject); // prune all pending objects
+ }
+ }
+
+ private static void CheckForObjectsToPrune()
+ {
+ //int unloaded = 0;
+ float time = Time.time;
+ for (int i = _loadedObjects.Count - 1; i >= 0; i--)
+ {
+ (CVRObjectLoader.LoadedObject loadedObject, var killTime) = _loadedObjects.ElementAt(i);
+ if (!(killTime < time)) continue;
+ AttemptPruneObject(loadedObject); // prune expired objects
+ //if (unloaded++ >= MAX_OBJECTS_UNLOADED_AT_ONCE) break; // limit unloads per check
+ }
+ }
+
+ private static void AttemptPruneObject(CVRObjectLoader.LoadedObject loadedObject)
+ {
+ if (loadedObject == null)
+ {
+ Logger.Error("Attempted to prune null object. This happens on initial load sometimes.");
+ return;
+ }
+
+ if (loadedObject.refCount > 1)
+ {
+ Logger.Error($"Object {loadedObject.prefabName} has ref count {loadedObject.refCount}, expected 1");
+ _loadedObjects[loadedObject] = -1; // mark as ineligible for pruning ???
+ return; // is something somehow holding a reference?
+ }
+
+ Logger.Msg($"Pruning object {loadedObject.prefabName}");
+ _loadedObjects.Remove(loadedObject); // remove from cache
+
+ loadedObject.refCount--; // decrement ref count
+ if (CVRObjectLoader.Instance != null) // provoke destruction
+ CVRObjectLoader.Instance.CheckForDestruction(loadedObject);
+ }
+
+ #endregion Lazy Pruning
+}
\ No newline at end of file
diff --git a/LazyPrune/Properties/AssemblyInfo.cs b/LazyPrune/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000..44dffc5
--- /dev/null
+++ b/LazyPrune/Properties/AssemblyInfo.cs
@@ -0,0 +1,30 @@
+using MelonLoader;
+using NAK.LazyPrune.Properties;
+using System.Reflection;
+
+[assembly: AssemblyVersion(AssemblyInfoParams.Version)]
+[assembly: AssemblyFileVersion(AssemblyInfoParams.Version)]
+[assembly: AssemblyInformationalVersion(AssemblyInfoParams.Version)]
+[assembly: AssemblyTitle(nameof(NAK.LazyPrune))]
+[assembly: AssemblyCompany(AssemblyInfoParams.Author)]
+[assembly: AssemblyProduct(nameof(NAK.LazyPrune))]
+
+[assembly: MelonInfo(
+ typeof(NAK.LazyPrune.LazyPrune),
+ nameof(NAK.LazyPrune),
+ AssemblyInfoParams.Version,
+ AssemblyInfoParams.Author,
+ downloadLink: "https://github.com/NotAKidOnSteam/NAK_CVR_Mods/tree/main/LazyPrune"
+)]
+
+[assembly: MelonGame("Alpha Blend Interactive", "ChilloutVR")]
+[assembly: MelonPlatform(MelonPlatformAttribute.CompatiblePlatforms.WINDOWS_X64)]
+[assembly: MelonPlatformDomain(MelonPlatformDomainAttribute.CompatibleDomains.MONO)]
+[assembly: HarmonyDontPatchAll]
+
+namespace NAK.LazyPrune.Properties;
+internal static class AssemblyInfoParams
+{
+ public const string Version = "1.0.0";
+ public const string Author = "NotAKidoS";
+}
\ No newline at end of file
diff --git a/LazyPrune/README.md b/LazyPrune/README.md
new file mode 100644
index 0000000..0f03252
--- /dev/null
+++ b/LazyPrune/README.md
@@ -0,0 +1,16 @@
+# LazyPrune
+
+Prevents loaded objects from immediately unloading on destruction. Should prevent needlessly unloading & reloading all avatars/props on world rejoin or GS reconnection.
+
+Unused objects are pruned after 3 minutes, or when loading into a different world.
+
+---
+
+Here is the block of text where I tell you this mod is not affiliated with or endorsed by ABI.
+https://documentation.abinteractive.net/official/legal/tos/#7-modding-our-games
+
+> This mod is an independent creation not affiliated with, supported by, or approved by Alpha Blend Interactive.
+
+> Use of this mod is done so at the user's own risk and the creator cannot be held responsible for any issues arising from its use.
+
+> To the best of my knowledge, I have adhered to the Modding Guidelines established by Alpha Blend Interactive.
diff --git a/LazyPrune/format.json b/LazyPrune/format.json
new file mode 100644
index 0000000..f921e3c
--- /dev/null
+++ b/LazyPrune/format.json
@@ -0,0 +1,24 @@
+{
+ "_id": -1,
+ "name": "LazyPrune",
+ "modversion": "1.0.0",
+ "gameversion": "2024r175",
+ "loaderversion": "0.6.1",
+ "modtype": "Mod",
+ "author": "NotAKidoS",
+ "description": "Prevents loaded objects from immediately unloading on destruction. Should prevent needlessly unloading & reloading all avatars/props on world rejoin or GS reconnection.\n\nUnused objects are pruned after 3 minutes, or when loading into a different world.",
+ "searchtags": [
+ "cache",
+ "prune",
+ "bundle",
+ "download",
+ "load",
+ ],
+ "requirements": [
+ "None"
+ ],
+ "downloadlink": "https://github.com/NotAKidOnSteam/NAK_CVR_Mods/releases/download/r30/LazyPrune.dll",
+ "sourcelink": "https://github.com/NotAKidOnSteam/NAK_CVR_Mods/tree/main/LazyPrune/",
+ "changelog": "- Initial Release",
+ "embedcolor": "#1c75f1"
+}
\ No newline at end of file
diff --git a/NAK_CVR_Mods.sln b/NAK_CVR_Mods.sln
index 24b7d75..f1314b6 100644
--- a/NAK_CVR_Mods.sln
+++ b/NAK_CVR_Mods.sln
@@ -53,6 +53,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ScriptingSpoofer", "Scripti
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LuaTTS", "LuaTTS\LuaTTS.csproj", "{24A069F4-4D69-4ABD-AA16-77765469245B}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LazyPrune", "LazyPrune\LazyPrune.csproj", "{8FA6D481-5801-4E4C-822E-DE561155D22B}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -159,6 +161,10 @@ Global
{24A069F4-4D69-4ABD-AA16-77765469245B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{24A069F4-4D69-4ABD-AA16-77765469245B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{24A069F4-4D69-4ABD-AA16-77765469245B}.Release|Any CPU.Build.0 = Release|Any CPU
+ {8FA6D481-5801-4E4C-822E-DE561155D22B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {8FA6D481-5801-4E4C-822E-DE561155D22B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {8FA6D481-5801-4E4C-822E-DE561155D22B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {8FA6D481-5801-4E4C-822E-DE561155D22B}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE