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.
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.
No Fatigue
https://steamcommunity.com/sharedfiles/filedetails/?id=2567998029
/Bundles - no changes
opus code fixes
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.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).[HarmonyPostfix] attribute on the method so intent is explicit, and use nameof() where possible for refactor-safety.static β Harmony never instantiates it.Opus tips
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.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.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
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
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;
}
}
}
}Allow Tonnage Before Patrol Area
https://steamcommunity.com/sharedfiles/filedetails/?id=3460665030
Opus imrovements
| # | 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
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.try { harmony.PatchAll(); } catch in the loader will now also surface the assembly version, which makes bug reports easier.MODNAME, so users can grep log files easily.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
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
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);
}
}
}
}Ultimate Storage Slots
https://steamcommunity.com/sharedfiles/filedetails/?id=3677740684
Claude Sonnet 4.6 (pre-work) improvements
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
| # | 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
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;
}
}
}
}Custom Stack Limits
https://steamcommunity.com/sharedfiles/filedetails/?id=3437617137
Opus 4.7 tips
TargetMethods().Callvirt β Call β GetStackLimit is a static extension method, so Call is the correct opcode (Callvirt works but is semantically wrong and slower).ldfld) too, not just operand β safer against future edge cases (writes, field-address loads).__originalMethod parameter β no more hardcoded strings that drift out of sync with the patches.GetStackLimit (null data / null name).Settings β change one constant to bump all 30 limits.FieldInfo/MethodInfo resolved once, not per instruction.ModLoader (uses LogError instead of LogFormat for failures).ModLoader.cs
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
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
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
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
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}()");
}
}
}Always have Blueprints
https://steamcommunity.com/sharedfiles/filedetails/?id=3670612683
Opus 4.7 review
ShipSelectorUIPatch.cs contains ShipSelectorDetailsUIPatch class. Confusing.___playerCareer β if the field is ever null, Harmony will throw and the menu will break.OnLoaded β if PatchAll ever fails, the whole mod fails silently with no log.1 β making the minimum points configurable (or at least a const) is cleaner.[HarmonyPrefix] attribute β works without it via naming, but explicit is better.ModLoader.cs.Opus 4.7 improvements
| 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
OnLoaded and assign to a static field that the patch reads.public const string Version = "1.0.0"; and log it β useful when users report bugs.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
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
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;
}
}
}
}Brighter Nights
https://steamcommunity.com/sharedfiles/filedetails/?id=3699304305
Opus 4.7 review
| 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) |
| 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 |
| 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 |
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.
BrighterNights.cs) β avoids UBOAT's flaky multiβfile Roslyn compile.mod.json permissions stay minimal: ["Reflection"] β no IO needed.using list (UBOAT's namespace layout is unintuitive; trimming usings broke compilation).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).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.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
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;
}
}
}
}Dynamic Distance Labels
https://steamcommunity.com/sharedfiles/filedetails/?id=3405289645
Opus 4.7 recap
| # | 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 |
| # | 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 |
| # | 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 |
Source/
βββ Constants.cs (new)
βββ ModLoader.cs (cleaned)
βββ UnitsUtilityPatch.cs (renamed + rewritten)
βββ DynamicDistanceHelper.cs (deduplicated)
βββ MapLineUIPatches.cs (leak fixed)
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.ProcessMetricUnits β AppendMetric to reflect what they actually do (append to a StringBuilder, not "process").Constants if you want them user-configurable later.LeftAlt/RightAlt.Camera.main lookup in MapLineUIPatches with a cached reference (Camera.main is a FindObjectWithTag call internally).[HarmonyPatch] guard via Prepare() that logs and skips if UnitsUtility.locale field is missing (future-proofing against game updates)./Bundles - no changes
Constants.cs
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
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
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
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
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
}
}
}
}
Faster Uboat
https://steamcommunity.com/sharedfiles/filedetails/?id=3679032862
improvements by opus 4.7
Details
# Recap of improvements over your originalπ§Ή Code structure
_fiPlayerShipchain trying four possible field names. Replaced withGetComponentInParent<PlayerShip>()+ a static fallback reference.System.Reflectiondependency entirely.FasterUboatConfigclass. Change balance in one place instead of three.FasterUboatConfig.SpeedKmhForGear(...). They can't desync anymore.0.27777779fβKmhToMs = 1f / 3.6f.π§ Harmony usage
[HarmonyPatch]attributes +PatchAllinstead of manual_h.Patch(...)calls. Cleaner, auto-discovers patches, survives game updates better.βοΈ Runtime behavior
MoveTowardswith separateAccelMsPerSec(6) andDecelMsPerSec(4) rates.GetComponentInParentis O(1) up the hierarchy.π Reliability fixes
FindObjectsOfTypeinstead ofFindObjectsOfTypeAllβ the latter also returns prefabs/inactive assets and is slower.OnDestroycleanup of the staticCurrentShipreference so save/load doesn't keep stale pointers.β Preserved (intentionally unchanged)
[NonSerializedInGameState]save-safety attributesHideAndDontSavehide flags on the runtime component[DefaultExecutionOrder(10000)]to run after the game's physics writesπ Net result
π Key debugging lesson
When UBOAT throws
BadImageFormatException, it's almost always a compile error in disguise. Scroll up in the log forCSxxxxerrors β Roslyn compiled garbage, then Mono refused to load it.FasterUboat.csDetails