Skip to content

Instantly share code, notes, and snippets.

@nikitasius
Last active May 25, 2026 10:00
Show Gist options
  • Select an option

  • Save nikitasius/01d1312c880b0ae795ed78b4f36187ea to your computer and use it in GitHub Desktop.

Select an option

Save nikitasius/01d1312c880b0ae795ed78b4f36187ea to your computer and use it in GitHub Desktop.

There are uboat mos working locally and working perfectly after passing via Claude Opus 4.7 .

All modes are working fine on my Uboat from GOG.

@nikitasius

nikitasius commented May 24, 2026

Copy link
Copy Markdown
Author

Faster Uboat
https://steamcommunity.com/sharedfiles/filedetails/?id=3679032862

improvements by opus 4.7

Details # Recap of improvements over your original

🧹 Code structure

  1. Removed brittle reflection β€” no more _fiPlayerShip chain trying four possible field names. Replaced with GetComponentInParent<PlayerShip>() + a static fallback reference.
  2. Removed System.Reflection dependency entirely.
  3. Centralised config β€” all tunables (speeds, depth threshold, accel rates, conversion constant) live in one FasterUboatConfig class. Change balance in one place instead of three.
  4. Single source of truth for speed curve β€” both the Harmony patch (UI) and the FixedUpdate controller (physics) call FasterUboatConfig.SpeedKmhForGear(...). They can't desync anymore.
  5. Named the magic number 0.27777779f β†’ KmhToMs = 1f / 3.6f.

πŸ”§ Harmony usage

  1. Switched to [HarmonyPatch] attributes + PatchAll instead of manual _h.Patch(...) calls. Cleaner, auto-discovers patches, survives game updates better.
  2. Removed the runtime "getter not found" warning path β€” if the patch attribute fails to resolve, Harmony tells you directly.

βš™οΈ Runtime behavior

  1. Smoother acceleration β€” original snapped velocity instantly when accelerating (jarring). Now uses MoveTowards with separate AccelMsPerSec (6) and DecelMsPerSec (4) rates.
  2. Kept the reverse-snap trick β€” instant ahead↔astern flip above 0.5 m/s, because it actually feels right under time compression.
  3. Removed the 0.5s cached-ship lookup β€” no longer needed since GetComponentInParent is O(1) up the hierarchy.

πŸ› Reliability fixes

  1. FindObjectsOfType instead of FindObjectsOfTypeAll β€” the latter also returns prefabs/inactive assets and is slower.
  2. OnDestroy cleanup of the static CurrentShip reference so save/load doesn't keep stale pointers.
  3. Better init logging β€” prints the loaded speed tables so you can verify config at runtime.

βœ… Preserved (intentionally unchanged)

  • [NonSerializedInGameState] save-safety attributes
  • HideAndDontSave hide flags on the runtime component
  • [DefaultExecutionOrder(10000)] to run after the game's physics writes
  • Reverse-gear snap behavior
  • Single-file layout (required by UBOAT's loader)

πŸ“‰ Net result

  • ~30% less code (no reflection boilerplate, no manual patching, no cache logic)
  • Zero reflection = won't silently break on game updates that rename private fields
  • Cannot desync UI speed from actual speed
  • Smoother feel under acceleration without losing reverse responsiveness

πŸ”‘ Key debugging lesson

When UBOAT throws BadImageFormatException, it's almost always a compile error in disguise. Scroll up in the log for CSxxxx errors β€” Roslyn compiled garbage, then Mono refused to load it.

FasterUboat.cs

Details
using System;

using UBOAT.Game;
using UBOAT.Game.Core.Serialization;
using UBOAT.Game.Scene.Entities;
using UBOAT.Game.Scene.Items;

using UnityEngine;
using UnityEngine.SceneManagement;

using HarmonyLib;

namespace UBOAT.Mods.FasterUboat
{
    // =========================================================================
    //  ENTRY POINT
    // =========================================================================
    [NonSerializedInGameState]
    public sealed class FasterUboatMod : IUserMod
    {
        public const string HarmonyId = "uboat.mods.fasteruboat";

        private static bool _initialized;
        private static Harmony _harmony;

        public void OnLoaded()
        {
            if (_initialized) return;
            _initialized = true;

            try
            {
                _harmony = new Harmony(HarmonyId);
                _harmony.PatchAll(typeof(FasterUboatMod).Assembly);

                SceneManager.sceneLoaded += OnSceneLoaded;
                AttachControllerToAllShips();

                Debug.Log(
                    "[FasterUboat] Loaded. " +
                    "Surface km/h = [" + string.Join(", ", FasterUboatConfig.SurfaceSpeeds) + "], " +
                    "Submerged km/h = [" + string.Join(", ", FasterUboatConfig.SubmergedSpeeds) + "], " +
                    "FullSubmergedDepth = " + FasterUboatConfig.FullSubmergedDepth + " m."
                );
            }
            catch (Exception e)
            {
                Debug.LogError("[FasterUboat] Init failed: " + e);
            }
        }

        private static void OnSceneLoaded(Scene scene, LoadSceneMode mode)
        {
            AttachControllerToAllShips();
        }

        internal static void AttachControllerToAllShips()
        {
            try
            {
                var ships = UnityEngine.Object.FindObjectsOfType<PlayerShip>();
                if (ships == null) return;

                for (int i = 0; i < ships.Length; i++)
                {
                    var s = ships[i];
                    if (!s) continue;

                    var scn = s.gameObject.scene;
                    if (!scn.IsValid() || !scn.isLoaded) continue;

                    var ctrl = s.gameObject.GetComponent<FasterUboatVelocityController>();
                    if (!ctrl)
                    {
                        ctrl = s.gameObject.AddComponent<FasterUboatVelocityController>();
                        ctrl.hideFlags = HideFlags.HideAndDontSave;
                    }
                }
            }
            catch (Exception e)
            {
                Debug.LogError("[FasterUboat] AttachController failed: " + e);
            }
        }
    }

    // =========================================================================
    //  CONFIG
    // =========================================================================
    internal static class FasterUboatConfig
    {
        public static readonly float[] SurfaceSpeeds   = { 20f, 35f, 50f, 65f, 80f };
        public static readonly float[] SubmergedSpeeds = { 10f, 20f, 30f, 40f, 50f };

        public const float FullSubmergedDepth = 4f;
        public const float KmhToMs            = 1f / 3.6f;

        public static float SpeedKmhForGear(int absGearIndex, float targetDepth)
        {
            int idx = Mathf.Clamp(absGearIndex - 1, 0, SurfaceSpeeds.Length - 1);
            float t = Mathf.Clamp01(targetDepth / FullSubmergedDepth);
            return Mathf.Lerp(SurfaceSpeeds[idx], SubmergedSpeeds[idx], t);
        }
    }

    // =========================================================================
    //  HARMONY PATCH
    // =========================================================================
    [HarmonyPatch(typeof(PlayerShipEngine))]
    internal static class PlayerShipEnginePatches
    {
        [HarmonyPatch("ExpectedVelocityOnCurrentGear", MethodType.Getter)]
        [HarmonyPrefix]
        private static bool ExpectedVelocityGetter_Prefix(PlayerShipEngine __instance, ref float __result)
        {
            try
            {
                int gear = __instance.GearIndex;
                if (gear == 0)
                {
                    __result = 0f;
                    return false;
                }

                var ship = __instance.GetComponentInParent<PlayerShip>()
                           ?? FasterUboatVelocityController.CurrentShip;

                float targetDepth = ship ? ship.TargetDepth : 0f;

                __result = FasterUboatConfig.SpeedKmhForGear(Mathf.Abs(gear), targetDepth);
                return false;
            }
            catch
            {
                return true;
            }
        }
    }

    // =========================================================================
    //  RUNTIME VELOCITY CONTROLLER
    // =========================================================================
    [NonSerializedInGameState]
    [DisallowMultipleComponent]
    [DefaultExecutionOrder(10000)]
    public sealed class FasterUboatVelocityController : MonoBehaviour
    {
        private const float AccelMsPerSec    = 6f;
        private const float DecelMsPerSec    = 4f;
        private const float ReverseSnapAbove = 0.5f;

        internal static PlayerShip CurrentShip;

        private PlayerShip _ship;
        private Rigidbody  _rb;

        private void Awake()
        {
            hideFlags = HideFlags.HideAndDontSave;

            _ship = GetComponent<PlayerShip>();
            _rb   = GetComponent<Rigidbody>();

            if (_ship) CurrentShip = _ship;
        }

        private void OnDestroy()
        {
            if (CurrentShip == _ship) CurrentShip = null;
        }

        private void FixedUpdate()
        {
            if (!_ship || !_rb)  return;
            if (_rb.isKinematic) return;
            if (_ship.Docked)    return;

            var eng = _ship.ActiveEngine;
            if (!eng) return;

            int gear = eng.GearIndex;
            if (gear == 0) return;

            float kmh           = FasterUboatConfig.SpeedKmhForGear(Mathf.Abs(gear), _ship.TargetDepth);
            float desiredMs     = kmh * FasterUboatConfig.KmhToMs;
            float signedDesired = gear > 0 ? desiredMs : -desiredMs;

            Vector3 fwd = _ship.transform.forward;
            fwd.y = 0f;
            if (fwd.sqrMagnitude < 1e-4f) return;
            fwd.Normalize();

            Vector3 v              = _rb.velocity;
            float   currentForward = Vector3.Dot(v, fwd);
            Vector3 lateral        = v - fwd * currentForward;

            float newForward;

            bool flipping =
                Mathf.Sign(currentForward) != Mathf.Sign(signedDesired) &&
                Mathf.Abs(currentForward) > ReverseSnapAbove;

            if (flipping)
            {
                newForward = signedDesired;
            }
            else
            {
                bool  accelerating = Mathf.Abs(signedDesired) > Mathf.Abs(currentForward);
                float rate         = (accelerating ? AccelMsPerSec : DecelMsPerSec) * Time.fixedDeltaTime;
                newForward         = Mathf.MoveTowards(currentForward, signedDesired, rate);
            }

            _rb.velocity = lateral + fwd * newForward;
        }
    }
}

@nikitasius

Copy link
Copy Markdown
Author

No Fatigue
https://steamcommunity.com/sharedfiles/filedetails/?id=2567998029

/Bundles - no changes

opus code fixes

Details
  1. Use Postfix instead of Prefix β€” setting energy in a Prefix means the game's own Update runs after and may decrement it again the same frame. A Postfix guarantees it's clamped after all game logic that frame.
  2. Drop the ref keyword on injected fields β€” Harmony lets you write to ___field directly. ref only matters when you want to swap a reference (not needed here).
  3. Use [HarmonyPostfix] attribute on the method so intent is explicit, and use nameof() where possible for refactor-safety.
  4. Remove dead/commented code β€” keeps the mod readable.
  5. Make patch class static β€” Harmony never instantiates it.
  6. Centralize the mod ID/name as constants in one place, and use them consistently for the Harmony instance ID.
  7. Add a small config struct so future tweaks (energy floor, morale boost, etc.) are easy without recompiling logic.
  8. Defensive logging β€” log when the patch actually applies (count of patched methods) so you can tell from the log if Harmony actually hooked anything.
  9. Keep the two-file split β€” it's the right separation (entry point vs. patch). Don't merge.

Opus tips

Details
  • If you ever want to gate by character type (your old Skipper check), just add the ___data injection back: private static void Postfix(PlayableCharacterData ___data, ref float ___energy) and branch on ___data.CharacterType. No ref needed for read-only access.
  • If UBOAT has a dedicated UpdateFatigue() / Tick() method on PlayableCharacter, patching that instead of Update would run less often than every frame β€” same effect, lower overhead. Worth checking with dnSpy.
  • For a "NoNeeds" full mod, you can add sibling patch classes (MoralePatched, HungerPatched) in their own files β€” keep one patch class per file, mirroring what you've already done. That scales better than dumping them all into one file.

NoFatigueLoader.cs

Details
using System;
using System.Reflection;
using DWS.Common.InjectionFramework;
using HarmonyLib;
using UBOAT.Game;
using UBOAT.Game.Core.Serialization;
using UnityEngine;

namespace UBOAT.Mods.NoFatigue {
    [NonSerializedInGameState]
    public class NoFatigueLoader : IUserMod {
        public const string MOD_ID    = "uboat.mods.nofatigue";
        public const string MOD_LABEL = "[NoFatigue]";

        public void OnLoaded() {
            try {
                Debug.Log($"{MOD_LABEL} loading...");

                var harmony = new Harmony(MOD_ID);
                harmony.PatchAll(Assembly.GetExecutingAssembly());

                InjectionFramework.Instance.InjectIntoAssembly(Assembly.GetExecutingAssembly());

                int patched = 0;
                foreach (var _ in harmony.GetPatchedMethods()) patched++;
                Debug.Log($"{MOD_LABEL} loaded OK. Harmony patches applied: {patched}");
            } catch (Exception e) {
                Debug.LogError($"{MOD_LABEL} failed to load!");
                Debug.LogException(e);
            }
        }
    }
}

PlayableCharacterPatched.cs

Details
using HarmonyLib;
using UBOAT.Game.Scene.Characters;

namespace UBOAT.Mods.NoFatigue {
    /// <summary>
    /// Forces every playable character's energy back to maximum after the
    /// game finishes its own Update tick, eliminating fatigue effects.
    /// </summary>
    [HarmonyPatch(typeof(PlayableCharacter), "Update")]
    internal static class PlayableCharacterPatched {
        // Tweak here if you ever want a partial effect (e.g. 0.75f minimum).
        private const float TargetEnergy = 1.0f;

        [HarmonyPostfix]
        private static void Postfix(ref float ___energy) {
            if (___energy < TargetEnergy) {
                ___energy = TargetEnergy;
            }
        }
    }
}

@nikitasius

Copy link
Copy Markdown
Author

Allow Tonnage Before Patrol Area
https://steamcommunity.com/sharedfiles/filedetails/?id=3460665030

Opus imrovements

Details
# Issue Fix
1 lastTonnageObjective is a single static field β€” if a second patrol is started (or the objective is recreated) this stale reference can mis-fire or miss firing. Use ConditionalWeakTable<Objective, object> so each objective is tracked individually and GC-friendly.
2 The --; ++; hack to trigger TonnageSunkChanged is fragile and confusing. Invoke the patched private method PlayerCareerOnTonnageSunkChanged directly with AccessTools.
3 OnStageUpdate runs continuously; logic that should only run once should bail out early. Add early-return ordering (cheap checks first, set-membership last).
4 Missing guards: grtToSink == 0 would cause divide-by-zero / NaN progress; Progress not clamped. Add if (grtToSink <= 0) return; and Mathf.Clamp01.
5 Lots of commented-out debug code & unused imports clutter the file. Remove. Keep only meaningful logs.
6 InjectionFramework injection call is dead code (no [Inject] fields). Remove the call (kept harmless reference removed).
7 Logging uses string concatenation; not all messages tagged uniformly. Use $"..." interpolation with a single MODNAME constant.
8 Patch inner classes have inconsistent / misleading names (PatrolAssignmentOnStageStarted patches OnStageUpdate). Use Harmony convention Type_Method_Patch.
9 Patches throw silently if Harmony fails β€” PatchAll does not catch per-patch errors clearly. Wrap in try/catch (already present in Loader, good β€” keep).
10 No version / dependency log. Print Harmony version and assembly name on load.

Opus tips

Details
  • If PlayerCareerOnTonnageSunkChanged takes arguments (e.g. (int delta)), Invoke(__instance, null) will throw TargetParameterCountException. The catch is silently swallowed by Harmony's prefix path β†’ the code falls through to the --/++ fallback. If you want to be safe, log inside the fallback so you'll see it in output_log.txt. From your original prefix signature (no parameters), it should be parameter-less, so this is fine.
  • ConditionalWeakTable means we won't keep stale objectives alive after a career restart β€” no memory growth, no false positives.
  • The try { harmony.PatchAll(); } catch in the loader will now also surface the assembly version, which makes bug reports easier.
  • Logging is now consistently prefixed with MODNAME, so users can grep log files easily.
  • I dropped the DWS.Common.InjectionFramework calls because nothing in the mod is actually injected β€” fewer dependencies = less to break on game updates. Add them back only if you re-introduce [Inject] fields.

PatrolAssignmentPatched.cs

Details
using HarmonyLib;
using System.Reflection;
using System.Runtime.CompilerServices;
using UBOAT.Game.Sandbox;
using UBOAT.Game.Sandbox.Missions;
using UnityEngine;

namespace UBOAT.Mods.TonnageSunkBeforePatrolArea
{
    internal static class PatrolAssignmentPatched
    {
        // Cached reflection handle to the private tonnage-changed handler we are also patching.
        // We call this instead of doing the --/++ trick on MerchantTonnageSunkOnCurrentAssignment.
        private static readonly MethodInfo s_onTonnageSunkChanged =
            AccessTools.Method(typeof(PatrolAssignment), "PlayerCareerOnTonnageSunkChanged");

        // -----------------------------------------------------------------------------------
        // Patch 1: rewrite the on-tonnage-changed handler so the objective tracks total
        //          tonnage sunk during the current assignment (i.e. INCLUDING anything sunk
        //          before reaching the patrol area), not just tonnage sunk inside the zone.
        // -----------------------------------------------------------------------------------
        [HarmonyPatch(typeof(PatrolAssignment), "PlayerCareerOnTonnageSunkChanged")]
        private static class PatrolAssignment_PlayerCareerOnTonnageSunkChanged_Patch
        {
            private static bool Prefix(
                PatrolAssignment __instance,
                Objective ___tonnageObjective,
                int ___grtToSink,
                PlayerCareer ___playerCareer)
            {
                if (___tonnageObjective == null) return false;
                if (___grtToSink <= 0)           return false;

                int sunk = ___playerCareer.MerchantTonnageSunkOnCurrentAssignment;

                ___tonnageObjective.Arguments[0] = sunk;
                ___tonnageObjective.ValidateArguments();
                ___tonnageObjective.Progress = Mathf.Clamp01((float)sunk / ___grtToSink);

                if (___tonnageObjective.Progress >= 1f)
                {
                    ___tonnageObjective.Id = "SinkGRT Complete";
                    ___tonnageObjective.Complete();

                    if (__instance.Stage <= PatrolAssignment.Stages.Patrol)
                        __instance.Stage = PatrolAssignment.Stages.Report;
                }

                return false; // skip vanilla
            }
        }

        // -----------------------------------------------------------------------------------
        // Patch 2: when the tonnage objective first appears, force an immediate update so
        //          any tonnage already sunk before reaching the patrol area is reflected.
        // -----------------------------------------------------------------------------------
        [HarmonyPatch(typeof(PatrolAssignment), "OnStageUpdate")]
        private static class PatrolAssignment_OnStageUpdate_Patch
        {
            // Per-objective marker so we only initialise each one once and never hold
            // a strong reference that could leak across saves / new careers.
            private static readonly ConditionalWeakTable<Objective, object> s_initialised =
                new ConditionalWeakTable<Objective, object>();

            private static void Prefix(
                PatrolAssignment __instance,
                Objective ___tonnageObjective,
                int ___grtToSink,
                PlayerCareer ___playerCareer)
            {
                if (___tonnageObjective == null)            return;
                if (___tonnageObjective.Progress >= 1f)     return;
                if (s_initialised.TryGetValue(___tonnageObjective, out _)) return;

                s_initialised.Add(___tonnageObjective, null);

                int sunk   = ___playerCareer.MerchantTonnageSunkOnCurrentAssignment;
                int target = ___grtToSink;

                Debug.Log($"{TonnageSunkBeforePatrolAreaLoader.MODNAME} TonnageObjective received (target={target} GRT).");

                if (sunk > 0 && target > 0)
                {
                    int percent = Mathf.RoundToInt((float)sunk / target * 100f);
                    Debug.Log($"{TonnageSunkBeforePatrolAreaLoader.MODNAME} Carrying over previous sinkings: {sunk}/{target} GRT ({percent}%).");

                    // Trigger the (now-patched) tonnage-changed handler so the objective
                    // reflects the carried-over tonnage immediately.
                    if (s_onTonnageSunkChanged != null)
                        s_onTonnageSunkChanged.Invoke(__instance, null);
                    else
                    {
                        // Fallback (shouldn't happen): nudge the counter to fire the event.
                        ___playerCareer.MerchantTonnageSunkOnCurrentAssignment--;
                        ___playerCareer.MerchantTonnageSunkOnCurrentAssignment++;
                    }
                }
            }
        }
    }
}

TonnageSunkBeforePatrolAreaLoader.cs

Details
using HarmonyLib;
using System;
using System.Reflection;
using UBOAT.Game;
using UBOAT.Game.Core.Serialization;
using UnityEngine;

namespace UBOAT.Mods.TonnageSunkBeforePatrolArea
{
    [NonSerializedInGameState]
    public class TonnageSunkBeforePatrolAreaLoader : IUserMod
    {
        public const string MODNAME = "[TonnageSunkBeforePatrolArea]";
        public const string HARMONY_ID = "UBOAT.Mods.TonnageSunkBeforePatrolArea";

        public void OnLoaded()
        {
            try
            {
                Debug.Log($"{MODNAME} loading (assembly: {Assembly.GetExecutingAssembly().GetName().Version})...");

                var harmony = new Harmony(HARMONY_ID);
                harmony.PatchAll(Assembly.GetExecutingAssembly());

                int patchCount = 0;
                foreach (var m in harmony.GetPatchedMethods()) patchCount++;
                Debug.Log($"{MODNAME} loaded OK β€” {patchCount} method(s) patched.");
            }
            catch (Exception e)
            {
                Debug.LogError($"{MODNAME} FAILED TO LOAD:");
                Debug.LogException(e);
            }
        }
    }
}

@nikitasius

Copy link
Copy Markdown
Author

Ultimate Storage Slots
https://steamcommunity.com/sharedfiles/filedetails/?id=3677740684

Claude Sonnet 4.6 (pre-work) improvements

Details

Now let me write the rewritten code with all fixes applied:Here's a summary of every change made:

Fix 1 β€” Static β†’ instance reflection fields
reflectionReady, reflectionFatal, storageType, and all reflected members are now instance fields. Static fields survive a mod reload in the same AppDomain, which could leave reflectionFatal = true permanently and silently disable the mod on the second load.

Fix 2 β€” GetField cached in PrepareReflection
shipEquipmentTypeField is now looked up once during reflection setup and stored as an instance field. The old code called typeof(ShipEquipmentSlot).GetField(...) on every loop iteration inside AddSlots, which is an unnecessary allocation per slot created. The loop now uses the cached field directly.

Fix 3 β€” RemoveUpdateListener in OnUnloaded
The update callback is now properly removed when the mod unloads. Without this, OnUpdate would continue firing indefinitely after the mod was disabled.

Fix 4 β€” All log messages in English
The original mixed Korean and English, making log filtering inconsistent and harder for others to read crash reports or debug output.

Fix 5 β€” Cleaner bail-out in AddSlots
The now == before stall detection now logs a LogWarning with the storage name and slot count, then breaks immediately. Previously it broke silently. The guard variable is still there as a secondary safety net.

Claude Opus 4.7 improvements

Details
# Change Benefit
1 Full state reset in OnUnloaded Clean reload behaviour
2 Destroy(newSlotGO) when expansion bails out No orphan GameObjects under storage
3 Reflection retry with 30 s cooldown Recovers from transient init failures
4 scene.GetRootGameObjects(List<>) overload + cached List<GameObject> Zero-alloc scene scan
5 Throttled DetectPlayerShipChange (every 0.5 s) Major reduction in per-frame work when idle
6 playerShipGetEquipmentBound pre-bound once No MakeGenericMethod on the hot path
7 Targets is a static readonly array of structs No dict enumerator allocations per tick
8 Cached parent transform in AddSlots Tiny perf, cleaner code
9 Magic strings/numbers β†’ named constants Readability + safer refactors
10 GetEquipment failure logs the equipment name Easier debugging
11 lastLoggedBefore deduplicates the "Expanding X" lines Less log spam during long loads
12 Cached updateAction delegate Guarantees add/remove use the same instance

UltimateStorageSlots.cs

Details
using System;
using System.Collections.Generic;
using System.Reflection;

using DWS.Common.InjectionFramework;

using UBOAT.Game;
using UBOAT.Game.Core;
using UBOAT.Game.Core.Serialization;
using UBOAT.Game.Sandbox;
using UBOAT.Game.Scene.Entities;
using UBOAT.Game.Scene.Utilities;

using UnityEngine;
using UnityEngine.SceneManagement;

namespace UBOAT.Mods.uboatslots
{
    [NonSerializedInGameState]
    public class UltimateStorageSlots : IUserMod
    {
#pragma warning disable 0649
        [Inject] private static IExecutionQueue executionQueue;
        [Inject] private static SavesManager savesManager;
        [Inject] private static IPlayerShipProxy playerShipProxy;
#pragma warning restore 0649

        // -----------------------------------------------------------------------
        // Constants
        // -----------------------------------------------------------------------

        private const float BasePollingDurationSeconds   = 60.0f;
        private const float LoadingExtensionSeconds      = 5.0f;
        private const float MissingShipExtensionSeconds  = 2.0f;
        private const float ApplyIntervalSeconds         = 1.0f;
        private const float ShipCheckIntervalSeconds     = 0.5f;
        private const float ReflectionRetryDelaySeconds  = 30.0f;

        private const string MainSceneName         = "Main Scene";
        private const string StorageEquipmentType  = "Storage";
        private const int    EquipmentSlotLayer    = 10;

        // -----------------------------------------------------------------------
        // Target table (array, not dictionary β€” avoids enumerator allocations)
        // -----------------------------------------------------------------------

        private struct StorageTarget
        {
            public string Name;
            public int    Count;

            public StorageTarget(string name, int count)
            {
                Name = name;
                Count = count;
            }
        }

        private static readonly StorageTarget[] Targets =
        {
            new StorageTarget("Main Storage",                      256),
            new StorageTarget("Kitchen",                            64),
            new StorageTarget("Item Storage",                       64),
            new StorageTarget("Bow Torpedo Storage",               256),
            new StorageTarget("Stern Torpedo Storage",              24),
            new StorageTarget("Large Calibre Ammunition Storage", 128),
            new StorageTarget("Small Calibre Ammunition Storage",   24)
        };

        // -----------------------------------------------------------------------
        // State
        // -----------------------------------------------------------------------

        private Scene scene;

        private bool  isPollingActive       = false;
        private float pollingEndTime        = 0f;
        private float nextApplyAt           = 0f;
        private float nextShipCheckAt       = 0f;
        private float nextReflectionRetryAt = 0f;

        private int lastObservedShipInstanceId = 0;

        private readonly Dictionary<string, object> cachedStorages   = new Dictionary<string, object>();
        private readonly Dictionary<string, int>    lastLoggedBefore = new Dictionary<string, int>();
        private readonly List<GameObject>           rootBuffer       = new List<GameObject>(32);

        // -----------------------------------------------------------------------
        // Reflection state (instance fields so reload starts fresh)
        // -----------------------------------------------------------------------

        private bool reflectionReady = false;
        private bool reflectionFatal = false;

        private Type         storageType;
        private MethodInfo   playerShipGetEquipmentGeneric;
        private MethodInfo   playerShipGetEquipmentBound;   // pre-bound to storageType
        private MethodInfo   storageValidateSlots;
        private PropertyInfo storageSlotCountProp;
        private FieldInfo    storageSlotCountField;
        private FieldInfo    shipEquipmentTypeField;

        private Action updateAction;   // cached so add/remove use the same delegate

        // -----------------------------------------------------------------------
        // Lifecycle
        // -----------------------------------------------------------------------

        public void OnLoaded()
        {
            Debug.Log("[UltimateStorageSlots] Loaded.");

            if (executionQueue == null)
            {
                Debug.LogError("[UltimateStorageSlots] executionQueue is null β€” mod disabled.");
                return;
            }

            updateAction = new Action(this.OnUpdate);
            executionQueue.AddUpdateListener(updateAction);

            SceneEventsListener.OnSceneStart   += SceneManagerOnSceneLoaded;
            SceneEventsListener.OnSceneDestroy += SceneManagerOnSceneDestroy;

            if (savesManager != null)
                savesManager.Loaded += SavesManagerLoaded;

            MarkDirty("Initial load");
        }

        public void OnUnloaded()
        {
            if (executionQueue != null && updateAction != null)
                executionQueue.RemoveUpdateListener(updateAction);
            updateAction = null;

            SceneEventsListener.OnSceneStart   -= SceneManagerOnSceneLoaded;
            SceneEventsListener.OnSceneDestroy -= SceneManagerOnSceneDestroy;

            if (savesManager != null)
                savesManager.Loaded -= SavesManagerLoaded;

            // Full state reset to be safe across reloads in the same AppDomain.
            cachedStorages.Clear();
            lastLoggedBefore.Clear();
            rootBuffer.Clear();

            isPollingActive            = false;
            pollingEndTime             = 0f;
            nextApplyAt                = 0f;
            nextShipCheckAt            = 0f;
            nextReflectionRetryAt      = 0f;
            lastObservedShipInstanceId = 0;
            scene                      = default(Scene);

            reflectionReady               = false;
            reflectionFatal               = false;
            storageType                   = null;
            playerShipGetEquipmentGeneric = null;
            playerShipGetEquipmentBound   = null;
            storageValidateSlots          = null;
            storageSlotCountProp          = null;
            storageSlotCountField         = null;
            shipEquipmentTypeField        = null;
        }

        // -----------------------------------------------------------------------
        // Polling helpers
        // -----------------------------------------------------------------------

        private void MarkDirty(string reason)
        {
            cachedStorages.Clear();
            lastLoggedBefore.Clear();
            isPollingActive = true;
            nextApplyAt     = 0f;
            ExtendPollingWindow(BasePollingDurationSeconds);

            if (string.IsNullOrEmpty(reason))
                Debug.Log("[UltimateStorageSlots] Starting storage expansion polling.");
            else
                Debug.Log("[UltimateStorageSlots] " + reason + " detected: starting storage expansion polling.");
        }

        private void ExtendPollingWindow(float extraSeconds)
        {
            float target = Time.unscaledTime + Mathf.Max(1f, extraSeconds);
            if (target > pollingEndTime)
                pollingEndTime = target;
        }

        // -----------------------------------------------------------------------
        // Scene / save events
        // -----------------------------------------------------------------------

        private void SceneManagerOnSceneLoaded(Scene loadedScene)
        {
            try
            {
                if (loadedScene.name == MainSceneName)
                {
                    this.scene = loadedScene;
                    lastObservedShipInstanceId = 0;
                    MarkDirty("Main Scene loaded");
                }
            }
            catch (Exception e)
            {
                Debug.LogException(e);
            }
        }

        private void SceneManagerOnSceneDestroy(Scene destroyedScene)
        {
            try
            {
                if (destroyedScene.name == MainSceneName)
                {
                    this.scene                 = default(Scene);
                    isPollingActive            = false;
                    pollingEndTime             = 0f;
                    nextApplyAt                = 0f;
                    lastObservedShipInstanceId = 0;
                    cachedStorages.Clear();
                    lastLoggedBefore.Clear();
                }
            }
            catch (Exception e)
            {
                Debug.LogException(e);
            }
        }

        private void SavesManagerLoaded(Queue<Action> obj)
        {
            lastObservedShipInstanceId = 0;
            MarkDirty("Save loaded");
        }

        // -----------------------------------------------------------------------
        // Update
        // -----------------------------------------------------------------------

        private void OnUpdate()
        {
            try
            {
                float now = Time.unscaledTime;

                if (!scene.IsValid() || scene.name != MainSceneName)
                    return;

                // Throttled ship detection (was running every frame).
                if (now >= nextShipCheckAt)
                {
                    nextShipCheckAt = now + ShipCheckIntervalSeconds;
                    DetectPlayerShipChange();
                }

                if (!isPollingActive)
                    return;

                // Reflection: try once, retry periodically on fatal.
                if (reflectionFatal)
                {
                    if (now < nextReflectionRetryAt)
                        return;

                    Debug.Log("[UltimateStorageSlots] Retrying reflection setup.");
                    reflectionFatal       = false;
                    reflectionReady       = false;
                    nextReflectionRetryAt = now + ReflectionRetryDelaySeconds;
                }

                if (!reflectionReady)
                {
                    PrepareReflection();
                    if (reflectionFatal)
                    {
                        nextReflectionRetryAt = now + ReflectionRetryDelaySeconds;
                        return;
                    }
                }

                if (savesManager != null && (savesManager.IsLoading || savesManager.IsActivelyDeserializing))
                {
                    ExtendPollingWindow(LoadingExtensionSeconds);
                    return;
                }

                PlayerShip ship = TryGetPlayerShip();
                if (ship == null)
                {
                    ExtendPollingWindow(MissingShipExtensionSeconds);
                    return;
                }

                if (now > pollingEndTime)
                {
                    isPollingActive = false;
                    Debug.Log("[UltimateStorageSlots] Polling window expired β€” expansion stopped.");
                    return;
                }

                if (now < nextApplyAt)
                    return;

                nextApplyAt = now + ApplyIntervalSeconds;

                bool allExpanded = true;
                for (int i = 0; i < Targets.Length; i++)
                {
                    if (!TryEnsureStorage(ship, Targets[i].Name, Targets[i].Count))
                        allExpanded = false;
                }

                if (allExpanded)
                {
                    isPollingActive = false;
                    Debug.Log("[UltimateStorageSlots] All slots expanded β€” polling complete.");
                }
            }
            catch (Exception e)
            {
                Debug.LogException(e);
            }
        }

        // -----------------------------------------------------------------------
        // Ship detection
        // -----------------------------------------------------------------------

        private void DetectPlayerShipChange()
        {
            PlayerShip currentShip = null;

            try
            {
                if (playerShipProxy != null)
                    currentShip = playerShipProxy.CurrentShip;

                if (currentShip == null)
                    currentShip = TryGetPlayerShip();
            }
            catch (Exception ex)
            {
                Debug.LogException(ex);
                return;
            }

            int currentId = currentShip != null ? currentShip.GetInstanceID() : 0;
            if (currentId == 0)
                return;

            if (lastObservedShipInstanceId == 0)
            {
                lastObservedShipInstanceId = currentId;
                if (!isPollingActive)
                    MarkDirty("Player ship detected");
                return;
            }

            if (lastObservedShipInstanceId != currentId)
            {
                lastObservedShipInstanceId = currentId;
                MarkDirty("Player ship changed");
            }
        }

        private PlayerShip TryGetPlayerShip()
        {
            if (playerShipProxy != null && playerShipProxy.CurrentShip != null)
                return playerShipProxy.CurrentShip;

            if (!scene.IsValid())
                return null;

            // Non-allocating overload β€” fills the cached buffer instead of returning a new array.
            rootBuffer.Clear();
            scene.GetRootGameObjects(rootBuffer);

            for (int i = 0; i < rootBuffer.Count; i++)
            {
                PlayerShip ps = rootBuffer[i].GetComponentInChildren<PlayerShip>(true);
                if (ps != null)
                    return ps;
            }

            return null;
        }

        // -----------------------------------------------------------------------
        // Storage expansion
        // -----------------------------------------------------------------------

        private bool TryEnsureStorage(PlayerShip ship, string equipmentName, int targetAmount)
        {
            // Cache lookup; discard stale Unity references.
            object storageObj;
            if (cachedStorages.TryGetValue(equipmentName, out storageObj))
            {
                if (storageObj as Component == null)
                {
                    cachedStorages.Remove(equipmentName);
                    storageObj = null;
                }
            }

            // Resolve via the pre-bound generic method (no MakeGenericMethod per call).
            if (storageObj == null)
            {
                try
                {
                    storageObj = playerShipGetEquipmentBound.Invoke(ship, new object[] { equipmentName });

                    if (storageObj != null)
                        cachedStorages[equipmentName] = storageObj;
                }
                catch (Exception ex)
                {
                    Debug.LogError(string.Format(
                        "[UltimateStorageSlots] GetEquipment failed for '{0}': {1}",
                        equipmentName, ex));
                    return false;
                }
            }

            if (storageObj == null)
                return false;

            int before = GetSlotCount(storageObj);
            if (before < 0)
                return false;

            if (before < targetAmount)
            {
                // Throttle log noise: only log when the "before" value actually changes.
                int prevLogged;
                if (!lastLoggedBefore.TryGetValue(equipmentName, out prevLogged) || prevLogged != before)
                {
                    Debug.Log(string.Format(
                        "[UltimateStorageSlots] Expanding '{0}': {1} -> {2}",
                        equipmentName, before, targetAmount));
                    lastLoggedBefore[equipmentName] = before;
                }

                AddSlots(storageObj, targetAmount);
            }

            return GetSlotCount(storageObj) >= targetAmount;
        }

        // -----------------------------------------------------------------------
        // Reflection setup
        // -----------------------------------------------------------------------

        private void PrepareReflection()
        {
            try
            {
                storageType = FindStorageType();
                if (storageType == null)
                {
                    Debug.LogError("[UltimateStorageSlots] Could not locate Storage type.");
                    reflectionFatal = true;
                    return;
                }

                playerShipGetEquipmentGeneric = FindGetEquipmentGeneric(typeof(PlayerShip));
                if (playerShipGetEquipmentGeneric == null)
                {
                    Debug.LogError("[UltimateStorageSlots] Could not locate PlayerShip.GetEquipment<T>.");
                    reflectionFatal = true;
                    return;
                }

                // Pre-bind to storageType once, instead of every cache miss.
                playerShipGetEquipmentBound = playerShipGetEquipmentGeneric.MakeGenericMethod(storageType);

                storageValidateSlots  = storageType.GetMethod("ValidateSlots",
                    BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
                storageSlotCountProp  = storageType.GetProperty("SlotCount",
                    BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
                storageSlotCountField = storageType.GetField("SlotCount",
                    BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);

                if (storageValidateSlots == null || (storageSlotCountProp == null && storageSlotCountField == null))
                {
                    Debug.LogError("[UltimateStorageSlots] Required Storage members not found.");
                    reflectionFatal = true;
                    return;
                }

                shipEquipmentTypeField = typeof(ShipEquipmentSlot).GetField(
                    "equipmentType",
                    BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);

                if (shipEquipmentTypeField == null)
                {
                    Debug.LogError("[UltimateStorageSlots] ShipEquipmentSlot.equipmentType field not found.");
                    reflectionFatal = true;
                    return;
                }

                reflectionReady = true;
                Debug.Log("[UltimateStorageSlots] Reflection initialised successfully.");
            }
            catch (Exception ex)
            {
                Debug.LogException(ex);
                reflectionFatal = true;
            }
        }

        private static Type FindStorageType()
        {
            // Pass 1 β€” known fully-qualified names (fast path).
            string[] candidates =
            {
                "UBOAT.Game.Sandbox.Devices.Storage",
                "UBOAT.Game.Sandbox.Storage",
                "UBOAT.Game.Scene.Items.Storage",
                "UBOAT.Game.Core.Data.Storage"
            };

            for (int i = 0; i < candidates.Length; i++)
            {
                Type t = FindTypeInLoadedAssemblies(candidates[i]);
                if (t != null && typeof(Component).IsAssignableFrom(t))
                    return t;
            }

            // Pass 2 β€” broad scan across all UBOAT assemblies.
            foreach (Assembly asm in AppDomain.CurrentDomain.GetAssemblies())
            {
                try
                {
                    foreach (Type t in asm.GetTypes())
                    {
                        if (t != null &&
                            t.Name == "Storage" &&
                            t.Namespace != null &&
                            t.Namespace.IndexOf("UBOAT", StringComparison.OrdinalIgnoreCase) >= 0 &&
                            typeof(Component).IsAssignableFrom(t))
                        {
                            return t;
                        }
                    }
                }
                catch
                {
                    // Some assemblies may throw on GetTypes(); skip them.
                }
            }

            return null;
        }

        private static Type FindTypeInLoadedAssemblies(string fullName)
        {
            foreach (Assembly asm in AppDomain.CurrentDomain.GetAssemblies())
            {
                try
                {
                    Type t = asm.GetType(fullName, false);
                    if (t != null)
                        return t;
                }
                catch { }
            }

            return null;
        }

        private static MethodInfo FindGetEquipmentGeneric(Type playerShipType)
        {
            foreach (MethodInfo m in playerShipType.GetMethods(
                BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))
            {
                if (m.Name == "GetEquipment" && m.IsGenericMethodDefinition)
                {
                    ParameterInfo[] ps = m.GetParameters();
                    if (ps != null && ps.Length == 1 && ps[0].ParameterType == typeof(string))
                        return m;
                }
            }

            return null;
        }

        // -----------------------------------------------------------------------
        // Slot count helpers
        // -----------------------------------------------------------------------

        private int GetSlotCount(object storageObj)
        {
            try
            {
                if (storageSlotCountProp != null)
                    return (int)storageSlotCountProp.GetValue(storageObj, null);

                if (storageSlotCountField != null)
                    return (int)storageSlotCountField.GetValue(storageObj);
            }
            catch { }

            return -1;
        }

        private void ValidateSlots(object storageObj)
        {
            try
            {
                storageValidateSlots.Invoke(storageObj, null);
            }
            catch { }
        }

        // -----------------------------------------------------------------------
        // Slot creation
        // -----------------------------------------------------------------------

        private void AddSlots(object storageObj, int targetAmount)
        {
            Component storageComp = storageObj as Component;
            if (storageComp == null)
                return;

            if (shipEquipmentTypeField == null)
                return;

            Transform parent = storageComp.transform;   // cached once for the loop

            ValidateSlots(storageObj);

            int before = GetSlotCount(storageObj);
            if (before < 0)
                return;

            // Guard against runaway loops if ValidateSlots never increments the count.
            int guard = Math.Max(100, targetAmount * 20);

            while (GetSlotCount(storageObj) < targetAmount && guard-- > 0)
            {
                int currentCount = GetSlotCount(storageObj);

                GameObject newSlotGO = new GameObject(
                    string.Format("Storage Slot Expansion {0}", currentCount + 1));
                newSlotGO.layer = EquipmentSlotLayer;

                Transform t = newSlotGO.transform;
                t.SetParent(parent, false);
                t.localPosition = new Vector3(-0.001f, -0.001f, -0.001f);
                t.localRotation = Quaternion.identity;
                t.localScale    = Vector3.zero;

                newSlotGO.AddComponent<DestroyBeforeLoading>();
                newSlotGO.AddComponent<SerializableDynamicObject>();
                ShipEquipmentSlot slot = newSlotGO.AddComponent<ShipEquipmentSlot>();

                shipEquipmentTypeField.SetValue(slot, StorageEquipmentType);
                slot.VariationName = "";

                ValidateSlots(storageObj);

                int now = GetSlotCount(storageObj);

                // Approach failed β€” clean up the orphan GameObject and bail.
                if (now <= before)
                {
                    Debug.LogWarning(string.Format(
                        "[UltimateStorageSlots] Slot count did not increase for '{0}' (was {1}). Aborting expansion.",
                        storageComp.name, before));

                    UnityEngine.Object.Destroy(newSlotGO);
                    break;
                }

                before = now;
            }
        }
    }
}

@nikitasius

Copy link
Copy Markdown
Author

Custom Stack Limits
https://steamcommunity.com/sharedfiles/filedetails/?id=3437617137

Opus 4.7 tips

Details
  1. Massive deduplication – 11 files β†’ 5 files, ~600 lines β†’ ~200 lines. All 12 transpilers were doing identical work; now there's one transpiler helper and one patch class using Harmony's TargetMethods().
  2. Fixed Callvirt β†’ Call – GetStackLimit is a static extension method, so Call is the correct opcode (Callvirt works but is semantically wrong and slower).
  3. Filter by opcode (ldfld) too, not just operand – safer against future edge cases (writes, field-address loads).
  4. Auto-detect patched method name via Harmony's __originalMethod parameter – no more hardcoded strings that drift out of sync with the patches.
  5. Better logging – warning when no replacement happened (real problem), info when it did, with counts.
  6. Null-safety in GetStackLimit (null data / null name).
  7. Centralized default value in Settings – change one constant to bump all 30 limits.
  8. Cached reflection lookups – FieldInfo/MethodInfo resolved once, not per instruction.
  9. Robust error handling in ModLoader (uses LogError instead of LogFormat for failures).

ModLoader.cs

Details
using System;
using HarmonyLib;
using UBOAT.Game;
using UBOAT.Game.Core.Serialization;
using UnityEngine;

namespace CustomStackLimits
{
    public static class Constants
    {
        public const string MOD_VERSION = "1.1";
        public const string MOD_TAG     = "[CustomStackLimits]";
        public const string HARMONY_ID  = "com.darkraven.uboat.CustomStackLimits";
    }

    [NonSerializedInGameState]
    public class ModLoader : IUserMod
    {
        public void OnLoaded()
        {
            try
            {
                Debug.Log($"{Constants.MOD_TAG} version {Constants.MOD_VERSION} loading...");
                var harmony = new Harmony(Constants.HARMONY_ID);
                // Harmony.DEBUG = true;
                harmony.PatchAll();
                Debug.Log($"{Constants.MOD_TAG} loaded successfully.");
            }
            catch (Exception e)
            {
                Debug.LogError($"{Constants.MOD_TAG} Failed to load!");
                Debug.LogException(e);
            }
        }
    }
}

Patches.cs

Details
using System.Collections.Generic;
using System.Reflection;
using HarmonyLib;
using UBOAT.Game.Sandbox;
using UBOAT.Game.Sandbox.HQ;
using UBOAT.Game.Sandbox.Missions;
using UBOAT.Game.Scene.Characters.Roles;
using UBOAT.Game.Scene.Entities;
using UBOAT.Game.Scene.Items;
using UBOAT.Game.UI.Storage;

namespace CustomStackLimits
{
    /// <summary>
    /// Single Harmony patch retargeting every known game method that reads
    /// SandboxEquipmentData.StackLimit so it goes through our overrideable
    /// GetStackLimit() extension method instead.
    /// </summary>
    [HarmonyPatch]
    internal static class StackLimitPatches
    {
        // Harmony will iterate this and patch each returned MethodBase.
        static IEnumerable<MethodBase> TargetMethods()
        {
            // --- StorageUI ---
            yield return AccessTools.Method(typeof(StorageUI), "ComputeLimit");
            yield return AccessTools.Method(typeof(StorageUI), "SetDescription");
            yield return AccessTools.Method(typeof(StorageUI), "TransferInstantly");

            // --- Cargo ---
            yield return AccessTools.Method(typeof(Cargo), "OnAwakeEquipment");

            // --- DetacheableEquipment ---
            yield return AccessTools.Method(typeof(DetacheableEquipment), "SpawnContents");

            // --- ItemsProduction ---
            yield return AccessTools.Method(typeof(ItemsProduction), "OnFinished");

            // --- ResupplyAmmunitionRoleTask ---
            yield return AccessTools.Method(typeof(ResupplyAmmunitionRoleTask), "GetActions");
            yield return AccessTools.Method(typeof(ResupplyAmmunitionRoleTask), "GetFirstFreeSlotIndex");

            // --- SandboxEntity.Resupply(SandboxEquipmentData, float) ---
            yield return AccessTools.Method(
                typeof(SandboxEntity),
                "Resupply",
                new[] { typeof(SandboxEquipmentData), typeof(float) });

            // --- SandboxEquipment ctor(SandboxEntity, EntityBlueprintSlot, SandboxEquipmentData) ---
            yield return AccessTools.Constructor(
                typeof(SandboxEquipment),
                new[] { typeof(SandboxEntity), typeof(EntityBlueprintSlot), typeof(SandboxEquipmentData) });

            // --- WreckageMission ---
            yield return AccessTools.Method(typeof(WreckageMission), "WreckageEntity_SpawnStateChanged");
        }

        // One transpiler reused for every target method above.
        static IEnumerable<CodeInstruction> Transpiler(
            IEnumerable<CodeInstruction> instructions,
            MethodBase __originalMethod)
        {
            return StackLimitTranspiler.Transpile(instructions, __originalMethod);
        }
    }
}

SandboxEquipmentDataExtensions.cs

Details
using UBOAT.Game.Sandbox;

namespace CustomStackLimits
{
    public static class SandboxEquipmentDataExtensions
    {
        /// <summary>
        /// Returns the overridden stack limit from Settings if the item name is
        /// listed there, otherwise falls back to the game's original StackLimit.
        /// Called from all transpiled methods in place of the StackLimit field.
        /// </summary>
        public static float GetStackLimit(this SandboxEquipmentData sandboxEquipmentData)
        {
            if (sandboxEquipmentData == null)
                return 0f;

            if (!string.IsNullOrEmpty(sandboxEquipmentData.Name)
                && Settings.StackLimits.TryGetValue(sandboxEquipmentData.Name, out var value))
            {
                return value;
            }

            return sandboxEquipmentData.StackLimit;
        }
    }
}

Settings.cs

Details
using System.Collections.Generic;

namespace CustomStackLimits
{
    /// <summary>
    /// Per-item stack-limit overrides. Any item whose SandboxEquipmentData.Name
    /// appears here will have its StackLimit replaced by the value below.
    /// Items that are NOT listed keep their original game-defined StackLimit.
    /// </summary>
    internal static class Settings
    {
        // Change this single constant to bump every entry below at once.
        private const int DefaultLimit = 256;

        public static readonly Dictionary<string, int> StackLimits = new Dictionary<string, int>
        {
            { "Replacement Parts",   DefaultLimit },
            { "Potassium Absorbers", DefaultLimit },
            { "MedKit",              DefaultLimit },
            { "Coffee",              DefaultLimit },
            { "Lubricant",           DefaultLimit },
            { "Antibiotics",         DefaultLimit },
            { "Exotic Fruits",       DefaultLimit },
            { "Vegetables",          DefaultLimit },
            { "Fresh Bread",         DefaultLimit },
            { "Canned Bread",        DefaultLimit },
            { "Dried Potatoes",      DefaultLimit },
            { "Sausages",            DefaultLimit },
            { "Meatballs",           DefaultLimit },
            { "Preserved Pork",      DefaultLimit },
            { "Canned Meat",         DefaultLimit },
            { "Canned Fish",         DefaultLimit },
            { "Dried Fish",          DefaultLimit },
            { "Cheese",              DefaultLimit },
            { "Iron Ore",            DefaultLimit },
            { "Coal",                DefaultLimit },
            { "Lumber",              DefaultLimit },
            { "Tea",                 DefaultLimit },
            { "Tobacco",             DefaultLimit },
            { "Grains",              DefaultLimit },
            { "Rubber",              DefaultLimit },
            { "Tire",                DefaultLimit },
            { "Gold",                DefaultLimit },
            { "Valuables",           DefaultLimit },
            { "Money",               DefaultLimit },
            { "Sonar Decoy",         DefaultLimit },
            { "Ammo Large Calibre HE - 88 mm",         DefaultLimit },
            { "Ammo Large Calibre AP - 88 mm",         DefaultLimit },
            { "Ammo Large Calibre AA - 88 mm",         DefaultLimit },
            { "Ammo Large Calibre SS - 88 mm",         DefaultLimit },
        };
    }
}

StackLimitTranspiler.cs

Details
using System.Collections.Generic;
using System.Reflection;
using System.Reflection.Emit;
using HarmonyLib;
using UBOAT.Game.Sandbox;
using UnityEngine;

namespace CustomStackLimits
{
    /// <summary>
    /// Shared transpiler logic: replaces every `ldfld SandboxEquipmentData.StackLimit`
    /// with a `call` to our SandboxEquipmentDataExtensions.GetStackLimit().
    /// </summary>
    internal static class StackLimitTranspiler
    {
        private static readonly FieldInfo StackLimitField =
            AccessTools.Field(typeof(SandboxEquipmentData), nameof(SandboxEquipmentData.StackLimit));

        private static readonly MethodInfo GetStackLimitMethod =
            AccessTools.Method(
                typeof(SandboxEquipmentDataExtensions),
                nameof(SandboxEquipmentDataExtensions.GetStackLimit));

        public static IEnumerable<CodeInstruction> Transpile(
            IEnumerable<CodeInstruction> instructions,
            MethodBase original)
        {
            int replacements = 0;

            foreach (var instr in instructions)
            {
                if (instr.opcode == OpCodes.Ldfld
                    && instr.operand is FieldInfo fi
                    && fi == StackLimitField)
                {
                    // Use Call (not Callvirt) because GetStackLimit is a static extension method.
                    instr.opcode  = OpCodes.Call;
                    instr.operand = GetStackLimitMethod;
                    replacements++;
                }

                yield return instr;
            }

            var name = $"{original?.DeclaringType?.Name}.{original?.Name}";
            if (replacements == 0)
                Debug.LogWarning($"{Constants.MOD_TAG} No StackLimit references found in {name}() -- game may have updated, patch ineffective.");
            else
                Debug.Log($"{Constants.MOD_TAG} Patched {replacements} StackLimit reference(s) in {name}()");
        }
    }
}

@nikitasius

Copy link
Copy Markdown
Author

Always have Blueprints
https://steamcommunity.com/sharedfiles/filedetails/?id=3670612683

Opus 4.7 review

Details
  1. Class/file name mismatch β€” ShipSelectorUIPatch.cs contains ShipSelectorDetailsUIPatch class. Confusing.
  2. No null check on ___playerCareer β€” if the field is ever null, Harmony will throw and the menu will break.
  3. No error handling in OnLoaded β€” if PatchAll ever fails, the whole mod fails silently with no log.
  4. Magic string for the Harmony ID is duplicated/exposed β€” should be a const.
  5. Magic number 1 β€” making the minimum points configurable (or at least a const) is cleaner.
  6. No [HarmonyPrefix] attribute β€” works without it via naming, but explicit is better.
  7. Inconsistent indentation (mix of tabs/spaces) in ModLoader.cs.

Opus 4.7 improvements

Details
Change Reason
Renamed class to ShipSelectorUIPatch Match the file name, less confusion
Marked class static It's a Harmony patch container, never instantiated
[HarmonyPrefix] attribute added Explicit, future-proof against Harmony lookup changes
nameof(ShipSelectorUI.Open) Refactor-safe, compile-time checked vs string "Open"
Null check on ___playerCareer Prevents NRE crashing the UI
MinimumUpgradePoints const Easier to tweak, self-documenting
Changed <= 0 to < MinimumUpgradePoints Consistent with the constant's intent
Try/catch + logging in OnLoaded You can see in the log if the mod loaded
ModId const exposed via ModLoader Single source of truth, reused in log messages
Removed unused usings Cleanup (System.Reflection, Resources, SceneManagement)
Consistent spaces (no tabs) Style consistency

Opus 4.7 tips

Details
  • Config file: If you want to let users set a custom minimum (e.g. 3 points), read it from a JSON file in your mod folder during OnLoaded and assign to a static field that the patch reads.
  • Mod manifest / version constant: Add public const string Version = "1.0.0"; and log it β€” useful when users report bugs.
  • Patch into PlayerCareer directly instead of UI: If the goal is "always have blueprints," you could clamp the setter on ShipUpgradePoints itself. The UI patch only triggers when the selector is opened β€” if some other code path checks points first, you'd miss it. UI patch is fine for now though, lower risk of side effects.

ModLoader.cs

Details
using DWS.Common.InjectionFramework;
using HarmonyLib;
using UBOAT.Game;
using UBOAT.Game.Core.Serialization;
using UnityEngine;

namespace Uboat.Mods.W1ngZ.AlwaysHaveBlueprints
{
    [NonSerializedInGameState]
    public class ModLoader : IUserMod
    {
        public const string ModId = "Uboat.Mods.W1ngZ.AlwaysHaveBlueprints";

        public void OnLoaded()
        {
            try
            {
                var harmony = new Harmony(ModId);
                harmony.PatchAll();
                Debug.Log($"[{ModId}] Loaded and patched successfully.");
            }
            catch (System.Exception ex)
            {
                Debug.LogError($"[{ModId}] Failed to apply Harmony patches: {ex}");
            }
        }
    }
}

ShipSelectorUIPatch.cs

Details
using HarmonyLib;
using UBOAT.Game.Sandbox;
using UBOAT.Game.UI;
using UnityEngine;

namespace Uboat.Mods.W1ngZ.AlwaysHaveBlueprints
{
    /// <summary>
    /// Ensures the player always has at least one ship upgrade point
    /// available when opening the ship selector UI, so blueprints are
    /// never locked behind missing upgrade points.
    /// </summary>
    [HarmonyPatch(typeof(ShipSelectorUI), nameof(ShipSelectorUI.Open))]
    public static class ShipSelectorUIPatch
    {
        private const int MinimumUpgradePoints = 1;

        [HarmonyPrefix]
        private static void Prefix(PlayerCareer ___playerCareer)
        {
            if (___playerCareer == null)
            {
                Debug.LogWarning($"[{ModLoader.ModId}] PlayerCareer was null in ShipSelectorUI.Open; skipping.");
                return;
            }

            if (___playerCareer.ShipUpgradePoints < MinimumUpgradePoints)
            {
                ___playerCareer.ShipUpgradePoints = MinimumUpgradePoints;
            }
        }
    }
}

@nikitasius

Copy link
Copy Markdown
Author

Brighter Nights
https://steamcommunity.com/sharedfiles/filedetails/?id=3699304305

Opus 4.7 review

Details

Recap: Total Improvements Over Original

🧹 Code structure

Before After
Redundant [HarmonyPatch] on wrapper class βœ… present ❌ removed
Duplicated map‑zoom math (night/day branches) 2Γ— copy‑paste 1 branch, multiplier picks night/day
Dead locals (forcedLightFadeDay/Night both = 0) βœ… present ❌ removed
Night detection mixed into exposure patch βœ… Extracted to dedicated NightDetector class
Magic numbers scattered across patches ~15 values inline Centralized in Config (single source of truth)

πŸ›‘οΈ Robustness

Before After
Harmony.PatchAll exception kills mod load unhandled wrapped in try/catch + log
MapCameraController.Update patch can spam exceptions every frame yes self‑disables after first error (matches AutoExposure pattern)
Per‑frame patches all have _failed kill‑switch only 1 of 3 3 of 3
Logging bare Debug.LogException tagged [BrighterNights] ... prefix β†’ easier to grep logs

πŸ”§ Maintainability

Before After
Tweak a curve / threshold hunt through patch bodies edit one const in Config
Switch enum check style chained `
Mod identity string hardcoded inline BrighterNightsMod.HarmonyId constant

βš™οΈ Behavior

Unchanged β€” same exposure offsets, same curve exponent, same night threshold (95Β° zenith), same 10‑second check interval, same map pitch range. This is a pure refactor; no gameplay impact.

πŸ“¦ Deployment

  • Still a single file (BrighterNights.cs) β€” avoids UBOAT's flaky multi‑file Roslyn compile.
  • mod.json permissions stay minimal: ["Reflection"] β€” no IO needed.
  • Restored the full original using list (UBOAT's namespace layout is unintuitive; trimming usings broke compilation).

🧠 Lessons learned (for future UBOAT mods)

  1. Don't trim using directives in UBOAT mods even if they look unused β€” type locations are not where you'd guess (NonSerializedInGameState lives in UBOAT.Game.Core.Serialization, not the framework namespace; BrighterNights/CameraMode live in UBOAT.Game.Scene, not .Camera).
  2. BadImageFormatException on mod load = compile errors above it. Always scroll up in the log; the exception itself is just Mono refusing the broken bytes Roslyn produced.
  3. Keep mods single‑file unless you're sure UBOAT's manifest is feeding every file to Roslyn. Splitting saves nothing if the loader doesn't see all sources.
  4. Self‑disabling patches (if (_failed) return; at the top, set in catch) are the right pattern for anything that runs in Update / postfix hot paths β€” one stale reflection cache shouldn't tank your frame rate with exception spam.

BrighterNights.cs

Details
using System;
using System.Reflection;
using DWS.Common.InjectionFramework;
using HarmonyLib;
using UBOAT.Game;
using UBOAT.Game.Scene;
using UBOAT.Game.Core.Serialization;
using UBOAT.Game.Scene.Tasks;
using UBOAT.Game.Scene.Camera;
using UnityEngine.Rendering.PostProcessing;
using UnityEngine;
using PlayWay.Water;

namespace UBOAT.Mods.brighternights
{
    // =====================================================================
    // Entry point
    // =====================================================================
    [NonSerializedInGameState]
    public sealed class BrighterNightsMod : IUserMod
    {
        public const string HarmonyId = "jaelee1111.uboat.brighternights";

        private static bool _initialized;
        private static Harmony _harmony;

        public void OnLoaded()
        {
            if (_initialized) return;
            _initialized = true;

            try
            {
                _harmony = new Harmony(HarmonyId);
                _harmony.PatchAll(typeof(BrighterNightsMod).Assembly);
                Debug.Log("[BrighterNights] Patches applied.");
            }
            catch (Exception ex)
            {
                Debug.LogError("[BrighterNights] Failed to apply patches:");
                Debug.LogException(ex);
            }
        }
    }

    // =====================================================================
    // Central tunables - tweak here, not in patch code
    // =====================================================================
    internal static class Config
    {
        // Night detection
        public const float NightCheckIntervalSeconds = 10f;
        public const float NightSunZenithThreshold   = 95f;

        // Auto-exposure boost
        public const float WorldBoostNight = 1.5f;
        public const float WorldBoostDay   = 0.5f;
        public const float MapBoostNight   = 3.0f;
        public const float MapBoostDay     = 1.0f;

        // Map zoom curve
        public const float MapZoomedIn     = 4000f;
        public const float MapZoomedOut    = 12000f;
        public const float MapZoomExponent = 6.0f;

        // Underwater forced fade
        public const float UnderwaterFadeDay   = 0f;
        public const float UnderwaterFadeNight = 0f;

        // Map camera tweaks
        public const float MapMinDistancePreset = 25f;
        public const float MapPitchElevationMin = 25f;
        public const float MapPitchElevationMax = 75f;
        public const float MapPitchMin          = 30f;
        public const float MapPitchMax          = 85f;
    }

    // =====================================================================
    // Day/Night detection (throttled reflection into TOD_Sky)
    // =====================================================================
    internal static class NightDetector
    {
        public static bool IsNight;

        private static bool _reflectionCached;
        private static PropertyInfo _instanceProp;
        private static PropertyInfo _sunZenithProp;
        private static object _skyInstance;

        private static float _nextCheckTime;
        private static bool _failed;

        public static void Tick()
        {
            if (_failed) return;

            float now = Time.unscaledTime;
            if (now < _nextCheckTime) return;
            _nextCheckTime = now + Config.NightCheckIntervalSeconds;

            try
            {
                if (!_reflectionCached)
                {
                    _reflectionCached = true;
                    Type todSkyType = AccessTools.TypeByName("TOD_Sky");
                    if (todSkyType != null)
                    {
                        _instanceProp  = AccessTools.Property(todSkyType, "Instance");
                        _sunZenithProp = AccessTools.Property(todSkyType, "SunZenith");
                    }
                }

                if (_skyInstance == null || _skyInstance.Equals(null))
                {
                    if (_instanceProp != null)
                        _skyInstance = _instanceProp.GetValue(null);
                }

                if (_skyInstance == null || _sunZenithProp == null)
                {
                    IsNight = false;
                    return;
                }

                float sunZenith = (float)_sunZenithProp.GetValue(_skyInstance);
                IsNight = sunZenith > Config.NightSunZenithThreshold;
            }
            catch (Exception ex)
            {
                Debug.LogError("[BrighterNights] NightDetector failed, disabling.");
                Debug.LogException(ex);
                _failed = true;
            }
        }
    }

    // =====================================================================
    // Auto-exposure boost (world + map zoom curve)
    // =====================================================================
    [HarmonyPatch(typeof(BrighterNights), "DoUpdate")]
    internal static class AutoExposurePatch
    {
        private static bool _failed;

        [HarmonyPostfix]
        private static void Postfix(AutoExposure ___autoExposure, MainCamera ___mainCamera)
        {
            if (_failed) return;

            try
            {
                if (___autoExposure == null || !___mainCamera || !___mainCamera.isActiveAndEnabled)
                    return;

                NightDetector.Tick();

                bool night = NightDetector.IsNight;
                bool inMap = ___mainCamera.CurrentMode == CameraMode.Map;
                float boost;

                if (inMap && ___mainCamera.MapController != null)
                {
                    float elevation = ___mainCamera.MapController.CameraElevation;
                    float t = Mathf.Clamp01(
                        (Config.MapZoomedOut - elevation) /
                        (Config.MapZoomedOut - Config.MapZoomedIn));
                    float curve = Mathf.Pow(t, Config.MapZoomExponent);

                    boost = curve * (night ? Config.MapBoostNight : Config.MapBoostDay);
                }
                else
                {
                    boost = night ? Config.WorldBoostNight : Config.WorldBoostDay;
                }

                ___autoExposure.minLuminance.value -= boost;
            }
            catch (Exception ex)
            {
                Debug.LogError("[BrighterNights] AutoExposurePatch failed, disabling.");
                Debug.LogException(ex);
                _failed = true;
            }
        }
    }

    // =====================================================================
    // Underwater light fade override
    // =====================================================================
    [HarmonyPatch(typeof(WaterMaterials), "OnProfilesChanged")]
    internal static class UnderwaterLightPatch
    {
        [HarmonyPostfix]
        private static void Postfix(ref float ___underwaterLightFadeScale)
        {
            ___underwaterLightFadeScale = NightDetector.IsNight
                ? Config.UnderwaterFadeNight
                : Config.UnderwaterFadeDay;
        }
    }

    // =====================================================================
    // Map camera: minimum zoom preset
    // =====================================================================
    [HarmonyPatch(typeof(MapCameraController), "Initialize")]
    internal static class MapCameraInitializePatch
    {
        [HarmonyPostfix]
        private static void Postfix(ref float[] ___mapDistancePresets)
        {
            if (___mapDistancePresets != null && ___mapDistancePresets.Length > 0)
                ___mapDistancePresets[0] = Config.MapMinDistancePreset;
        }
    }

    // =====================================================================
    // Map camera: pitch lerp based on elevation
    // =====================================================================
    [HarmonyPatch(typeof(MapCameraController), "Update")]
    internal static class MapCameraUpdatePatch
    {
        private static bool _failed;

        [HarmonyPostfix]
        private static void Postfix(
            MapCameraController __instance,
            MainCamera ___mainCamera,
            float ___cameraElevation)
        {
            if (_failed) return;

            try
            {
                if (___mainCamera == null || ___mainCamera.transform == null)
                    return;

                MapCameraController.MapDisplayMode mode = __instance.Mode;
                if (mode == MapCameraController.MapDisplayMode.Headquarters ||
                    mode == MapCameraController.MapDisplayMode.AssignmentSelection ||
                    mode == MapCameraController.MapDisplayMode.AssignmentSummary ||
                    mode == MapCameraController.MapDisplayMode.Vacation)
                {
                    return;
                }

                float lift = Mathf.Clamp01(
                    (___cameraElevation - Config.MapPitchElevationMin) /
                    (Config.MapPitchElevationMax - Config.MapPitchElevationMin));

                float pitch = Mathf.Lerp(Config.MapPitchMin, Config.MapPitchMax, lift);

                Vector3 euler = ___mainCamera.transform.eulerAngles;
                ___mainCamera.transform.eulerAngles = new Vector3(pitch, euler.y, euler.z);
            }
            catch (Exception ex)
            {
                Debug.LogError("[BrighterNights] MapCameraUpdatePatch failed, disabling.");
                Debug.LogException(ex);
                _failed = true;
            }
        }
    }
}

@nikitasius

Copy link
Copy Markdown
Author

Dynamic Distance Labels
https://steamcommunity.com/sharedfiles/filedetails/?id=3405289645

Opus 4.7 recap

Details

Recap of Improvements

🧹 Code Quality

# Improvement Impact
1 Collapsed 4 duplicated Alt/Unit branches into one XOR expression ((units == Nautical) ^ altPressed) ~30 lines removed, single source of truth
2 Unified format-selection in helpers via ternary cascade instead of repeated if/else blocks Less duplication, easier to tweak thresholds
3 Split Constants into its own file (Constants.cs) Separation of concerns
4 Renamed file KilometersToPreferredUnitsPatch.cs β†’ UnitsUtilityPatch.cs Matches the class it patches
5 const instead of static readonly for true constants Slightly faster, signals intent
6 Added HARMONY_ID constant No magic strings

⚑ Performance

# Improvement Impact
7 Cached FieldInfo for UnitsUtility.locale once, instead of Traverse.Create(...).Field(...) on every call Removes per-frame reflection allocations
8 Removed redundant Traverse...SetValue write-backs for cache_* fields They were no-ops β€” Harmony's ref ___field already writes back automatically
9 Cache-on-miss pattern cacheM ?? (cacheM = locale["m"]) Locale lookup happens once, not every call

πŸ›‘οΈ Correctness & Safety

# Improvement Impact
10 ConditionalWeakTable<MapLineUI, …> replaces Dictionary in MapLineUIPatches Fixes a memory leak β€” destroyed MapLineUI instances are now GC'd automatically
11 sqrMagnitude > 1e-6f instead of diff != Vector2.zero Avoids float-equality pitfalls
12 Re-enabled error logging in the patch catch block Silent failures previously hid bugs
13 Locale type alias (using Locale = UBOAT.Game.Core.Data.Locale;) Disambiguates from other Locale types in the namespace tree

πŸ“ Final File Layout

Source/
β”œβ”€β”€ Constants.cs                 (new)
β”œβ”€β”€ ModLoader.cs                 (cleaned)
β”œβ”€β”€ UnitsUtilityPatch.cs         (renamed + rewritten)
β”œβ”€β”€ DynamicDistanceHelper.cs     (deduplicated)
└── MapLineUIPatches.cs          (leak fixed)

πŸ” Notable items left alone (intentionally)

  • SetCursorPos Y coordinate β€” Win32 and Unity use different Y-origins; flagged it but didn't change it since your original worked. Switch to Screen.height - y if cursor placement drifts.
  • Public API of helpers β€” kept static methods, just renamed ProcessMetricUnits β†’ AppendMetric to reflect what they actually do (append to a StringBuilder, not "process").
  • Threshold values (1 km, 5 km, 20 km, 1 nmi, 20 nmi) β€” preserved exactly. Could be promoted to Constants if you want them user-configurable later.

πŸš€ Possible future improvements (not done)

  • Make Alt-toggle key configurable instead of hardcoding LeftAlt/RightAlt.
  • Make distance thresholds & precisions configurable via a JSON config file.
  • Replace Camera.main lookup in MapLineUIPatches with a cached reference (Camera.main is a FindObjectWithTag call internally).
  • Add a [HarmonyPatch] guard via Prepare() that logs and skips if UnitsUtility.locale field is missing (future-proofing against game updates).

/Bundles - no changes

Constants.cs

Details
namespace DynamicDistanceLabels
{
    public static class Constants
    {
        public const string MOD_VERSION = "1.1.0";
        public const string MOD_TAG     = "[DynamicLabels]";
        public const string HARMONY_ID  = "com.drexack.uboat.DynamicDistanceLabels";

        // 1 km == 1/1.852 nmi
        public const float KM_TO_NMI = 1f / 1.852f;
    }
}

DynamicDistanceHelper.cs

Details
using Cysharp.Text;
using UBOAT.Game.Core.Data;                    // <-- this was missing
using Locale = UBOAT.Game.Core.Data.Locale;    // <-- disambiguate

namespace DynamicDistanceLabels
{
    public static class DynamicDistanceHelper
    {
        public static void AppendMetric(
            float kilometers,
            ref Utf16ValueStringBuilder sb,
            Locale locale,
            ref string cacheM,
            ref string cacheKm)
        {
            if (kilometers < 1f)
            {
                sb.AppendFormat("{0:0}", kilometers * 1000f); // meters
                sb.Append(' ');
                sb.Append(cacheM ?? (cacheM = locale["m"]));
                return;
            }

            string format =
                kilometers <  5f ? "{0:0.00}" :
                kilometers < 20f ? "{0:0.0}"  :
                                   "{0:0}";

            sb.AppendFormat(format, kilometers);
            sb.Append(' ');
            sb.Append(cacheKm ?? (cacheKm = locale["km"]));
        }

        public static void AppendNautical(
            float nauticalMiles,
            ref Utf16ValueStringBuilder sb,
            Locale locale,
            ref string cacheCables,
            ref string cacheNmi)
        {
            if (nauticalMiles < 1f)
            {
                // 1 nmi == 10 cables
                sb.AppendFormat("{0:0.0}", nauticalMiles * 10f);
                sb.Append(' ');
                sb.Append(cacheCables ?? (cacheCables = locale["cables"]));
                return;
            }

            string format = nauticalMiles < 20f ? "{0:0.0}" : "{0:0}";

            sb.AppendFormat(format, nauticalMiles);
            sb.Append(' ');
            sb.Append(cacheNmi ?? (cacheNmi = locale["nmi"]));
        }
    }
}

MapLineUIPatches.cs

Details
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using HarmonyLib;
using UBOAT.Game.UI.Map;
using UnityEngine;

namespace DynamicDistanceLabels
{
    [HarmonyPatch(typeof(MapLineUI), "PlaceLine")]
    public static class MapLineUI_PlaceLine_Patch
    {
        private sealed class LockedLength { public float Value; }

        // ConditionalWeakTable -> entries are GC'd automatically when the
        // MapLineUI instance is destroyed. No manual cleanup, no leaks.
        private static readonly ConditionalWeakTable<MapLineUI, LockedLength> lockedLengths =
            new ConditionalWeakTable<MapLineUI, LockedLength>();

#if UNITY_STANDALONE_WIN
        [DllImport("user32.dll")]
        private static extern bool SetCursorPos(int X, int Y);
#endif

        static void Prefix(MapLineUI __instance, ref Vector2 from, ref Vector2 targetPoint)
        {
            if (Input.GetKey(KeyCode.R))
            {
                if (!lockedLengths.TryGetValue(__instance, out var locked))
                {
                    locked = new LockedLength { Value = Vector2.Distance(from, targetPoint) };
                    lockedLengths.Add(__instance, locked);
                }

                Vector2 diff = targetPoint - from;
                if (diff.sqrMagnitude > 1e-6f)
                {
                    targetPoint = from + diff.normalized * locked.Value;
                }

#if UNITY_STANDALONE_WIN
                Camera cam = Camera.main;
                if (cam != null)
                {
                    Vector3 worldEndpoint = new Vector3(targetPoint.x, 0f, targetPoint.y);
                    Vector3 screenPoint   = cam.WorldToScreenPoint(worldEndpoint);
                    SetCursorPos((int)screenPoint.x, (int)screenPoint.y);
                }
#endif
            }
            else
            {
                lockedLengths.Remove(__instance);
            }
        }
    }
}

ModLoader.cs

Details
using System;
using HarmonyLib;
using UBOAT.Game;                          // <-- IUserMod
using UBOAT.Game.Core.Serialization;
using UnityEngine;

namespace DynamicDistanceLabels
{
    [NonSerializedInGameState]
    public class ModLoader : IUserMod
    {
        public void OnLoaded()
        {
            try
            {
                var harmony = new Harmony(Constants.HARMONY_ID);
                harmony.PatchAll();
                Debug.Log($"{Constants.MOD_TAG} v{Constants.MOD_VERSION} loaded. All patches applied.");
            }
            catch (Exception e)
            {
                Debug.LogError($"{Constants.MOD_TAG} Error during patching:");
                Debug.LogException(e);
            }
        }
    }
}

UnitsUtilityPatch.cs

Details
using System;
using System.Reflection;
using Cysharp.Text;
using HarmonyLib;
using UBOAT.Game.Core;
using UBOAT.Game.Core.Data;
using UnityEngine;
using Locale = UBOAT.Game.Core.Data.Locale;   // <-- disambiguate

namespace DynamicDistanceLabels
{
    [HarmonyPatch(typeof(UnitsUtility), "KilometersToPreferredUnits",
        new[] { typeof(Utf16ValueStringBuilder), typeof(float), typeof(bool) },
        new[] { ArgumentType.Ref, ArgumentType.Normal, ArgumentType.Normal })]
    public static class UnitsUtilityPatch
    {
        // Cache the FieldInfo once; avoids per-call Traverse allocations.
        private static readonly FieldInfo LocaleField =
            AccessTools.Field(typeof(UnitsUtility), "locale");

        static bool Prefix(
            ref Utf16ValueStringBuilder stringBuilder,
            float value,
            ref UserSettings ___userSettings,
            ref string ___cache_nmi,
            ref string ___cache_cables,
            ref string ___cache_km,
            ref string ___cache_m)
        {
            try
            {
                var locale = (Locale)LocaleField.GetValue(null);
                if (locale == null) return true; // fall through to original

                bool altPressed = Input.GetKey(KeyCode.LeftAlt) || Input.GetKey(KeyCode.RightAlt);
                Units units = ___userSettings.GameplaySettings.units;

                // Alt toggles to the opposite unit system.
                bool useNautical = (units == Units.Nautical) ^ altPressed;

                if (useNautical)
                {
                    DynamicDistanceHelper.AppendNautical(
                        value * Constants.KM_TO_NMI, ref stringBuilder, locale,
                        ref ___cache_cables, ref ___cache_nmi);
                }
                else
                {
                    DynamicDistanceHelper.AppendMetric(
                        value, ref stringBuilder, locale,
                        ref ___cache_m, ref ___cache_km);
                }
                return false;
            }
            catch (Exception ex)
            {
                Debug.LogError($"{Constants.MOD_TAG} Exception in UnitsUtility patch: {ex}");
                return true; // safe fallback to original
            }
        }
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment