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.
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
}
}
}
}
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 leavereflectionFatal = truepermanently and silently disable the mod on the second load.Fix 2 β
GetFieldcached inPrepareReflectionshipEquipmentTypeFieldis now looked up once during reflection setup and stored as an instance field. The old code calledtypeof(ShipEquipmentSlot).GetField(...)on every loop iteration insideAddSlots, which is an unnecessary allocation per slot created. The loop now uses the cached field directly.Fix 3 β
RemoveUpdateListenerinOnUnloadedThe update callback is now properly removed when the mod unloads. Without this,
OnUpdatewould 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
AddSlotsThe
now == beforestall detection now logs aLogWarningwith 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
OnUnloadedDestroy(newSlotGO)when expansion bails outscene.GetRootGameObjects(List<>)overload + cachedList<GameObject>DetectPlayerShipChange(every 0.5 s)playerShipGetEquipmentBoundpre-bound onceMakeGenericMethodon the hot pathTargetsis astatic readonlyarray of structsparenttransform inAddSlotsGetEquipmentfailure logs the equipment namelastLoggedBeforededuplicates the "Expanding X" linesupdateActiondelegateUltimateStorageSlots.csDetails