using BepInEx; using BepInEx.Configuration; using UnityEngine; using UnityEngine.UI; using UnityEngine.SceneManagement; namespace BurglinGnomes.UltraWideFix { /// /// Ultrawide UI fix for Burglin' Gnomes (Unity 6, Mono). /// /// The camera needs no patching: it is orthographic with a constant /// orthographicSize (5.0), so a wider display already shows proportionally /// more world (visible width 17.8 -> 35.6 going 16:9 -> 32:9). That is /// correct Hor+ behaviour. /// /// The actual problem is UI scaling: /// * HUD canvases (ScreenSpaceOverlay + ScaleWithScreenSize) ship /// matchWidthOrHeight = 0.453, which is width-biased. At 5120px wide that /// yields scaleFactor 1.948 and a ballooned HUD. Matching HEIGHT (1.0) /// gives 1.333, and raising referenceResolution.y shrinks it further /// while giving the layout MORE logical room (cures overlapping text). /// * World-space label canvases (NamePlate, ...) are NOT affected by /// CanvasScaler.scaleFactor - CanvasScaler.HandleWorldCanvas() overwrites /// it with dynamicPixelsPerUnit every frame. Their size comes from the /// canvas transform's localScale, so that is what we scale. /// [BepInPlugin(PluginGuid, PluginName, PluginVersion)] public class UltraWideFixPlugin : BaseUnityPlugin { public const string PluginGuid = "com.programmingpug.burglingnomes.ultrawidefix"; public const string PluginName = "Burglin' Gnomes UltraWide Fix"; public const string PluginVersion = "1.0.0"; private ConfigEntry _enabled; private ConfigEntry _match; private ConfigEntry _hudScale; private ConfigEntry _worldScale; private ConfigEntry _designHeight; private ConfigEntry _verbose; // Baselines captured once per canvas so repeated applies never compound. private readonly System.Collections.Generic.Dictionary _refBaseline = new System.Collections.Generic.Dictionary(); private readonly System.Collections.Generic.Dictionary _scaleBaseline = new System.Collections.Generic.Dictionary(); private float _timer; private void Awake() { _enabled = Config.Bind("General", "Enabled", true, "Master switch for the ultrawide UI fix."); _match = Config.Bind("HUD", "MatchWidthOrHeight", 1.0f, new ConfigDescription( "CanvasScaler match for HUD canvases. 0 = scale by width, 1 = by height. " + "Keep at 1 for ultrawide; lower values re-introduce width scaling and " + "cramp the layout vertically.", new AcceptableValueRange(0f, 1f))); _hudScale = Config.Bind("HUD", "HudScale", 0.85f, new ConfigDescription( "HUD size multiplier. Below 1 shrinks the HUD AND gives the layout more " + "logical room, which is what cures overlapping text. 1 = stock size.", new AcceptableValueRange(0.4f, 1.5f))); _designHeight = Config.Bind("HUD", "DesignHeight", 1080f, "The game's design reference height. Do not change unless you know why."); _worldScale = Config.Bind("World", "WorldLabelScale", 1.0f, new ConfigDescription( "Size multiplier for in-world labels (nameplates). At 32:9 you see twice " + "as much world, so more labels are on screen and can collide; lower this " + "to shrink them. 1 = stock size.", new AcceptableValueRange(0.3f, 1.5f))); _verbose = Config.Bind("Debug", "Verbose", false, "Log every canvas adjustment."); SceneManager.sceneLoaded += (_, __) => Apply(); Logger.LogInfo($"{PluginName} {PluginVersion} loaded."); } private void Start() => Apply(); // Canvases are created lazily (menus, popups), so re-apply periodically. // We also re-read the config file each tick: BepInEx does NOT pick up // external edits on its own, so without this you'd have to restart the // game after every tweak. private void Update() { _timer += Time.unscaledDeltaTime; if (_timer < 2f) return; _timer = 0f; try { var stamp = System.IO.File.GetLastWriteTimeUtc(Config.ConfigFilePath); if (stamp != _configStamp) { _configStamp = stamp; Config.Reload(); Logger.LogInfo($"Config reloaded: HudScale={_hudScale.Value} " + $"WorldLabelScale={_worldScale.Value} " + $"Match={_match.Value} Enabled={_enabled.Value}"); // values changed -> re-derive from baselines on this pass } } catch (System.Exception e) { Logger.LogWarning($"Config reload failed (check the .cfg syntax): {e.Message}"); } Apply(); } private System.DateTime _configStamp; private void Apply() { if (!_enabled.Value) return; foreach (var cs in FindObjectsOfType()) { var canvas = cs.GetComponent(); if (canvas == null) continue; int id = cs.GetInstanceID(); if (canvas.renderMode == RenderMode.WorldSpace) { if (Mathf.Approximately(_worldScale.Value, 1f)) continue; var cur = cs.transform.localScale; // Games hide world-space UI by zeroing its scale. Never cache a // zero baseline: if we did, multiplying it would pin the canvas // to zero forever and the UI could never reappear. if (!_scaleBaseline.TryGetValue(id, out var base3)) { if (cur.x <= 0.0001f) continue; // hidden right now; revisit later base3 = cur; _scaleBaseline[id] = base3; } // Respect the game hiding it: leave zeroed canvases alone. if (cur.x <= 0.0001f) continue; var want = base3 * _worldScale.Value; if (cs.transform.localScale != want) { cs.transform.localScale = want; if (_verbose.Value) Logger.LogInfo($"World '{cs.name}' localScale {base3} -> {want}"); } continue; } if (cs.uiScaleMode != CanvasScaler.ScaleMode.ScaleWithScreenSize) continue; if (!_refBaseline.TryGetValue(id, out var baseRef)) { baseRef = cs.referenceResolution; _refBaseline[id] = baseRef; } float targetH = _designHeight.Value / Mathf.Max(0.01f, _hudScale.Value); var targetRef = new Vector2(baseRef.x, targetH); if (!Mathf.Approximately(cs.matchWidthOrHeight, _match.Value)) cs.matchWidthOrHeight = _match.Value; if (cs.referenceResolution != targetRef) { cs.referenceResolution = targetRef; // expected scale now; Canvas.scaleFactor lags a frame float exp = Mathf.Pow(Screen.width / targetRef.x, 1f - _match.Value) * Mathf.Pow(Screen.height / targetRef.y, _match.Value); Logger.LogInfo($"HUD '{cs.name}' ref {baseRef} -> {targetRef} " + $"match={_match.Value} => scale={exp:F3}"); } } } } }