XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

AutoMiningScript

【SpaceEngineer】全自动挖矿脚本

公开
关注 0 Fork 0 Star 0
UTF-8
using System;
using System.Collections.Generic;
using System.Text;
using Sandbox.ModAPI.Ingame;
using VRage.Game.GUI.TextPanel;
using VRage.Game.ModAPI.Ingame.Utilities;
using VRageMath;

namespace AutoMiningScript
{
    public partial class Program
    {
        // Screens belong to this rigid grid. A docked miner never draws on the mother ship.
        public class Dashboard
        {
            readonly Program p;
            readonly List<Screen> screens = new List<Screen>();
            int nextScreen, pageOverride = -1, autoOverride = -1;
            List<KeyValuePair<string, string>> configurationRows;
            sealed class RegionView
            {
                public Job First;
                public Vector3D Up, Right;
                public double Extent = 1;
                public int Total, Complete, Sampled, Empty;
                public readonly List<Job> Dots = new List<Job>();
            }
            Dictionary<string, RegionView> mapViews = new Dictionary<string, RegionView>(), buildingViews;
            List<string> mapRegions = new List<string>(), buildingRegions;
            List<Job> buildingSource;
            int jobCursor, buildingDone, completedJobs;
            double nextJobRefresh;
            double discoveredAt = -30;
            static readonly Color Background = new Color(9, 17, 27);
            static readonly Color Panel = new Color(23, 36, 51);
            static readonly Color White = new Color(225, 235, 246);
            static readonly Color Muted = new Color(138, 159, 181);
            static readonly Color Blue = new Color(57, 174, 235);
            static readonly Color Green = new Color(71, 218, 162);
            static readonly Color Amber = new Color(255, 188, 78);
            static readonly Color Red = new Color(255, 102, 117);
            static readonly Color ConsoleBackground = new Color(3, 7, 12);
            static readonly Color ConsolePanel = new Color(16, 27, 39);
            static readonly Color ConsoleLabel = new Color(211, 224, 237);
            static readonly Color ConsoleValue = new Color(250, 252, 255);

            class Screen
            {
                public IMyTextSurface Surface;
                public int Page;
                public string Miner = "";
                public string Font = "Debug";
                public string Key;
                public bool AutoPage, Started;
                public double Interval, ManualHold, HoldUntil, AdvanceAt, Progress;
                public int RenderedPage = -1, Slide, Count, PendingStep;
            }

            public Dashboard(Program program) { p = program; }
            public int ScreenCount { get { return screens.Count; } }
            public void ClearTransientCache() { configurationRows = null; }
            public void Update(RoleLogic role)
            {
                if (p.HasBudget(0.45)) RefreshJobs(role.GetJobs());
            }
            void RefreshJobs(List<Job> jobs)
            {
                if (buildingViews == null && p.Now < nextJobRefresh && jobs == buildingSource) return;
                if (buildingViews == null || jobs != buildingSource || jobCursor > jobs.Count)
                {
                    buildingSource = jobs; buildingViews = new Dictionary<string, RegionView>(); buildingRegions = new List<string>(); jobCursor = buildingDone = 0;
                }
                int work = 0;
                while (jobCursor < jobs.Count && work++ < 64 && p.HasBudget(0.55))
                {
                    Job j = jobs[jobCursor++]; RegionView view;
                    if (!buildingViews.TryGetValue(j.RegionId, out view))
                    {
                        view = new RegionView { First = j, Up = Data.Perpendicular(j.Direction, j.Up) };
                        view.Right = Vector3D.Cross(j.Direction, view.Up); buildingViews[j.RegionId] = view; buildingRegions.Add(j.RegionId);
                    }
                    Vector3D d = j.Entry - view.First.Entry;
                    view.Extent = Math.Max(view.Extent, Math.Max(Math.Abs(Vector3D.Dot(d, view.Right)), Math.Abs(Vector3D.Dot(d, view.Up))) + j.Radius);
                    view.Total++;
                    if (Finished(j)) { view.Complete++; buildingDone++; }
                    if (j.Kind == JobKind.Probe && j.Outcome == "Complete") view.Sampled++;
                    if (j.Outcome == "SurveyEmpty") view.Empty++;
                    if (view.Dots.Count < 400) view.Dots.Add(j);
                }
                if (jobCursor < jobs.Count) return;
                mapViews = buildingViews; mapRegions = buildingRegions; completedJobs = buildingDone;
                buildingViews = null; buildingRegions = null; nextJobRefresh = p.Now + 0.5;
            }

            public void Command(string command)
            {
                string[] words = command.Trim().ToLowerInvariant().Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
                if (words.Length == 0 || words[0] != "page") return;
                string value = words.Length > 1 ? words[1] : "next";
                if (discoveredAt < 0) Discover();
                if (value == "stop")
                {
                    foreach (var screen in screens) if (AutoPaging(screen)) screen.Progress = Progress(screen);
                    autoOverride = 0; return;
                }
                if (value == "play")
                {
                    autoOverride = 1;
                    foreach (var screen in screens) { screen.HoldUntil = p.Now; RestartSlide(screen); }
                    return;
                }
                if (value == "auto")
                {
                    pageOverride = -1; autoOverride = -1;
                    foreach (var screen in screens) { screen.RenderedPage = -1; screen.Slide = screen.PendingStep = 0; screen.HoldUntil = p.Now; RestartSlide(screen); }
                    return;
                }
                bool step = value == "+" || value == "-";
                if (!step)
                {
                    int current = pageOverride >= 0 ? pageOverride : screens.Count > 0 ? screens[0].Page : 0;
                    int target = value == "next" ? (current + 1) % 5 : value == "prev" ? (current + 4) % 5 : ParsePage(value, -1);
                    if (target < 0) return;
                    pageOverride = target;
                }
                foreach (var screen in screens)
                {
                    if (step) screen.PendingStep += value == "+" ? 1 : -1;
                    else { screen.Slide = screen.PendingStep = 0; screen.RenderedPage = -1; }
                    screen.HoldUntil = p.Now + screen.ManualHold;
                    RestartSlide(screen);
                }
            }

            bool AutoPaging(Screen screen) { return autoOverride >= 0 ? autoOverride == 1 : screen.AutoPage; }
            void RestartSlide(Screen screen) { screen.Started = true; screen.AdvanceAt = Math.Max(p.Now, screen.HoldUntil) + screen.Interval; screen.Progress = 0; }
            double Progress(Screen screen) { return Data.Clamp((p.Now - (screen.AdvanceAt - screen.Interval)) / screen.Interval, 0, 1); }
            static int Wrap(int value, int count) { return ((value % count) + count) % count; }

            void UpdateSlide(Screen screen, int page, int count)
            {
                if (screen.RenderedPage != page) { screen.RenderedPage = page; screen.Slide = 0; RestartSlide(screen); }
                if (screen.Count != count)
                {
                    screen.Slide = Math.Min(screen.Slide, count - 1); screen.Count = count;
                    RestartSlide(screen);
                }
                if (screen.PendingStep != 0)
                {
                    screen.Slide = Wrap(screen.Slide + screen.PendingStep, count); screen.PendingStep = 0;
                    RestartSlide(screen);
                }
                if (!screen.Started) RestartSlide(screen);
                if (AutoPaging(screen) && count > 1 && p.Now >= screen.AdvanceAt)
                {
                    screen.Slide = (screen.Slide + 1) % count;
                    // A delayed draw advances once, then grants the new slide a full dwell.
                    RestartSlide(screen);
                }
            }

            static int ParsePage(string value, int fallback)
            {
                switch (value.ToLowerInvariant())
                {
                    case "fleet": case "0": return 0;
                    case "miner": case "1": return 1;
                    case "map": case "2": return 2;
                    case "diagnostics": case "3": return 3;
                    case "config": case "4": return 4;
                    default: return fallback;
                }
            }

            void Discover()
            {
                var previous = new Dictionary<string, Screen>();
                foreach (var screen in screens) previous[screen.Key] = screen;
                screens.Clear();
                bool defaultAuto = p.Config.Flag("Display", "AutoPage", true);
                double defaultInterval = p.Config.Number("Display", "PageInterval", 8, 2, 120);
                double defaultHold = p.Config.Number("Display", "ManualHoldSeconds", 20, 0, 300);
                var blocks = new List<IMyTerminalBlock>();
                string groupName = p.Config.Text("Display", "Group", "AMS Screens");
                string tag = p.Config.Text("Display", "Tag", "[AMS LCD]");
                var group = p.GridTerminalSystem.GetBlockGroupWithName(groupName);
                if (group != null) group.GetBlocks(blocks);
                else p.GridTerminalSystem.GetBlocksOfType(blocks, b => b.CubeGrid == p.Me.CubeGrid && b.CustomName.Contains(tag));
                // A programming block's own main screen is available without naming or grouping.
                // Insert first so a full external-screen group cannot starve the local console.
                blocks.RemoveAll(b => b.EntityId == p.Me.EntityId);
                if (p.Config.Flag("Display", "BuiltIn", true)) blocks.Insert(0, p.Me);
                for (int n = 0; n < blocks.Count && screens.Count < 16; n++)
                {
                    var b = blocks[n];
                    if (b.CubeGrid != p.Me.CubeGrid) continue;
                    bool builtIn = b.EntityId == p.Me.EntityId;
                    var ini = new MyIni(); MyIniParseResult error;
                    if (!ini.TryParse(b.CustomData, out error)) { p.Log(L.F(L.DisplayScreenIni, b.CustomName)); continue; }
                    int count = 1;
                    var provider = b as IMyTextSurfaceProvider;
                    if (provider != null) count = provider.SurfaceCount;
                    for (int index = 0; index < count && screens.Count < 16; index++)
                    {
                        string section = "AMS.Screen" + (index == 0 ? "" : "." + index);
                        if (index > 0 && !ini.ContainsSection(section)) continue;
                        if (!ini.Get(section, "Enabled").ToBoolean(true)) continue;
                        var surface = provider != null ? provider.GetSurface(index) : b as IMyTextSurface;
                        if (surface == null) continue;
                        int defaultPage = ParsePage(p.Config.Text("Display", "Page", p.Config.Role == "fleet" ? "fleet" : "miner"), p.Config.Role == "fleet" ? 0 : 1);
                        if (builtIn) defaultPage = ParsePage(p.Config.Text("Display", "BuiltInPage", "config"), 4);
                        string font = ini.Get(section, "Font").ToString(p.Config.Text("Display", "Font", "Debug"));
                        var screen = new Screen { Surface = surface, Page = ParsePage(ini.Get(section, "Page").ToString(), defaultPage), Miner = ini.Get(section, "Miner").ToString(), Font = font, Key = b.EntityId + "/" + index };
                        try
                        {
                            screen.AutoPage = ScreenFlag(ini, section, "AutoPage", defaultAuto);
                            screen.Interval = ScreenNumber(ini, section, "PageInterval", defaultInterval, 2, 120);
                            screen.ManualHold = ScreenNumber(ini, section, "ManualHoldSeconds", defaultHold, 0, 300);
                        }
                        catch (ArgumentException e) { p.Log(L.F(L.DisplayScreenIni, b.CustomName) + " / " + e.Message); continue; }
                        Screen old;
                        if (previous.TryGetValue(screen.Key, out old) && old.Page == screen.Page && old.Miner == screen.Miner && old.AutoPage == screen.AutoPage && old.Interval == screen.Interval && old.ManualHold == screen.ManualHold)
                        {
                            old.Surface = surface; old.Font = font; screen = old;
                        }
                        screens.Add(screen);
                        surface.ContentType = ContentType.SCRIPT;
                        surface.Script = "";
                        surface.ScriptBackgroundColor = Background;
                    }
                }
                discoveredAt = p.Now;
            }

            static bool ScreenFlag(MyIni ini, string section, string key, bool fallback)
            {
                if (!ini.ContainsKey(section, key)) return fallback;
                bool value;
                if (!bool.TryParse(ini.Get(section, key).ToString(), out value)) throw new ArgumentException(L.F(L.CommonConfigBool, section, key));
                return value;
            }
            static double ScreenNumber(MyIni ini, string section, string key, double fallback, double min, double max)
            {
                double value = Data.ReadNumber(ini, section, key, fallback);
                if (value < min || value > max) throw new ArgumentException(L.F(L.CommonConfigRange, section, key, min, max));
                return value;
            }

            public void Draw(RoleLogic role)
            {
                if (!p.HasBudget(0.55)) return;
                if (p.Now - discoveredAt >= 30) Discover();
                if (screens.Count == 0) return;
                var fleet = role.GetTelemetry(); var jobs = role.GetJobs();
                RefreshJobs(jobs);
                fleet.Sort((a, b) => string.CompareOrdinal(a.Id, b.Id));
                int perTick = p.Config.Integer("Display", "ScreensPerTick", 2, 1, 16);
                for (int n = 0; n < Math.Min(perTick, screens.Count); n++)
                {
                    if (!p.HasBudget(0.6)) return;
                    if (nextScreen >= screens.Count) nextScreen = 0;
                    var screen = screens[nextScreen++];
                    int page = pageOverride >= 0 ? pageOverride : screen.Page;
                    using (var frame = screen.Surface.DrawFrame())
                    {
                        var canvas = new Canvas(frame, screen.Surface, screen.Font, page == 4);
                        int count = 1;
                        if (page == 0) count = Math.Max(1, (fleet.Count + 6) / 7);
                        else if (page == 1 && screen.Miner.Length == 0)
                        {
                            bool own = false; foreach (var item in fleet) if (item.Id == p.Config.Id) { own = true; break; }
                            if (!own) count = Math.Max(1, fleet.Count);
                        }
                        else if (page == 2)
                        {
                            count = Math.Max(1, mapRegions.Count);
                        }
                        else if (page == 4) { int perPage = canvas.Width >= 720 ? 5 : 4; count = Math.Max(1, (ConfigurationRows().Count + perPage - 1) / perPage); }
                        UpdateSlide(screen, page, count);
                        int slide = screen.Slide;
                        if (page == 4)
                        {
                            Configuration(canvas, role, fleet, slide);
                            Carousel(canvas, screen);
                            continue;
                        }
                        canvas.Box(0, 0, 512, 512, Background);
                        canvas.Text(L.Brand + " / " + Version, 22, 18, 0.8f, White);
                        canvas.Text(p.Config.FleetId + "  /  " + p.Config.Id, 22, 48, 0.5f, Muted);
                        canvas.Box(22, 76, 468, 2, Blue);
                        if (page == 0) Fleet(canvas, fleet, jobs, slide);
                        else if (page == 1) Miner(canvas, fleet, screen.Miner, slide);
                        else if (page == 2) Map(canvas, fleet, slide);
                        else Diagnostics(canvas, role);
                        canvas.Box(22, 474, 468, 1, Panel);
                        canvas.Text(new[] { L.DisplayFleet, L.DisplayMiner, L.DisplayMap, L.DisplayDiagnostics, L.DisplayConfiguration }[page], 22, 486, 0.45f, Blue);
                        canvas.Text(L.F(L.DisplayFooter, F(p.Now, "0")), 490, 486, 0.42f, Muted, TextAlignment.RIGHT);
                        Carousel(canvas, screen);
                    }
                }
            }

            void Fleet(Canvas c, List<Telemetry> fleet, List<Job> jobs, int listPage)
            {
                int online = 0, docked = 0, alerts = 0, waiting = 0;
                foreach (var t in fleet) { if (Fresh(t)) online++; if (t.Connected) docked++; if (!Fresh(t) || t.Reason.Length > 0 || t.State == FlightState.Fault) alerts++; if (t.State == FlightState.Holding) waiting++; }
                c.Text(L.F(L.DisplayOnlineCount, online, fleet.Count), 22, 92, 0.6f, Green);
                c.Text(L.F(L.DisplayDockQueue, docked, waiting), 245, 92, 0.55f, Blue);
                c.Text(L.F(L.DisplayJobAlerts, completedJobs, jobs.Count, alerts), 22, 119, 0.48f, alerts > 0 ? Amber : Muted);
                c.Text(L.DisplayMinerStatus, 22, 153, 0.42f, Muted);
                c.Text(L.DisplayBatteryShort, 297, 153, 0.42f, Muted);
                c.Text(L.DisplayHydrogenShort, 357, 153, 0.42f, Muted);
                c.Text(L.DisplayCargoShort, 422, 153, 0.42f, Muted);
                int pages = Math.Max(1, (fleet.Count + 6) / 7), start = (listPage % pages) * 7;
                for (int i = start; i < Math.Min(fleet.Count, start + 7); i++)
                {
                    var t = fleet[i]; float y = 178 + (i - start) * 37;
                    bool fresh = Fresh(t); Color color = !fresh || t.State == FlightState.Fault ? Red : t.Reason.Length > 0 ? Amber : White;
                    c.Box(22, y - 5, 468, 33, Panel);
                    c.Text(Short(Short(t.Id, 12) + "  " + (fresh ? L.State(t.State) : L.F(L.DisplayOfflineAge, F(p.Now - t.ReceivedAt, "0"))), 35), 29, y, 0.42f, color);
                    c.Text(Percent(t.Battery), 297, y, 0.42f, t.Battery < .25 ? Red : Green);
                    c.Text(Percent(t.Hydrogen), 357, y, 0.42f, Blue);
                    c.Text(Percent(t.Cargo), 422, y, 0.42f, t.Cargo > .9 ? Amber : White);
                }
                if (fleet.Count == 0) c.Text(L.DisplayWaitingMiners, 22, 194, 0.48f, Amber);
                c.Text(L.F(L.DisplayList, (listPage % pages) + 1, pages, FirstAlert(fleet)), 22, 448, 0.43f, alerts > 0 ? Amber : Muted);
            }

            void Miner(Canvas c, List<Telemetry> fleet, string miner, int listPage)
            {
                Telemetry t = null;
                foreach (var item in fleet) if (item.Id == miner || (miner.Length == 0 && item.Id == p.Config.Id)) { t = item; break; }
                if (t == null && miner.Length == 0 && fleet.Count > 0) t = fleet[listPage % fleet.Count];
                if (t == null) { c.Text(L.F(L.DisplayNoTelemetry, miner.Length > 0 ? miner : L.DisplayThisMiner), 22, 103, 0.6f, Amber); return; }
                c.Text(Short(t.Id, 22), 22, 94, 0.8f, White);
                c.Text(Fresh(t) ? L.State(t.State) : L.F(L.DisplayOfflineAge, F(p.Now - t.ReceivedAt, "0.0")), 22, 130, 0.56f, Fresh(t) ? Blue : Red);
                Bar(c, L.DisplayBattery, t.Battery, 174, t.Battery < .25 ? Red : Green);
                Bar(c, L.DisplayHydrogen, t.Hydrogen, 218, Blue);
                Bar(c, L.DisplayCargo, t.Cargo, 262, t.Cargo > .9 ? Amber : Blue);
                c.Text(L.F(L.DisplayPower, F(t.StoredMWh, "0.00"), F(t.CapacityMWh, "0.00"), F(t.NetMW, "+0.00;-0.00;0.00")), 22, 300, 0.43f, White);
                c.Text(L.F(L.DisplayMotion, Duration(t.EnduranceSeconds), F(t.Velocity.Length(), "0.0"), F(t.Gravity, "0.00")), 22, 325, 0.45f, White);
                c.Text(L.F(L.DisplayHomeBrake, F(t.DistanceHome, "0"), F(t.ThrustMargin, "0.0")), 22, 351, 0.45f, White);
                c.Text(L.F(L.DisplayDepth, F(t.Depth, "0.0"), F(t.TargetDepth, "0.0"), t.Connected ? L.DisplayConnected : L.DisplayFlying), 22, 377, 0.48f, White);
                c.Text(L.F(L.DisplayJob, Short(t.JobId, 36)), 22, 405, 0.43f, Muted);
                c.Text(Short(t.Reason.Length > 0 ? t.Reason : L.OreList(t.OreSummary), 55), 22, 435, 0.43f, t.Reason.Length > 0 ? Amber : Green);
            }

            void Map(Canvas c, List<Telemetry> fleet, int listPage)
            {
                c.Text(L.DisplayMiningPlane, 22, 93, 0.6f, White);
                if (mapRegions.Count == 0) { c.Text(buildingViews == null ? L.DisplayNoArea : L.CommonBudgetDeferred, 22, 140, 0.55f, Amber); return; }
                // Region paging keeps independently rotated mining areas legible.
                string region = mapRegions[listPage % mapRegions.Count]; RegionView view = mapViews[region]; Job first = view.First;
                Vector3D up = view.Up, right = view.Right;
                double extent = view.Extent; int total = view.Total, complete = view.Complete, sampled = view.Sampled, empty = view.Empty;
                c.Box(22, 129, 468, 295, Panel);
                c.Box(256, 139, 1, 275, Background); c.Box(32, 277, 448, 1, Background);
                float scale = (float)(132 / extent);
                foreach (var j in view.Dots)
                {
                    if (!p.HasBudget(0.7)) break;
                    var d = j.Entry - first.Entry; float x = 256 + (float)Vector3D.Dot(d, right) * scale, y = 277 - (float)Vector3D.Dot(d, up) * scale;
                    Color color = Finished(j) ? (j.Outcome == "Blocked" || j.Outcome == "InvalidSample" ? Red : j.Kind == JobKind.Manual ? Green : Blue) : j.Owner.Length > 0 ? Amber : Muted;
                    c.Dot(x, y, Math.Max(3, Math.Min(8, (float)j.Radius * scale)), color);
                }
                foreach (var t in fleet)
                {
                    var d = t.Position - first.Entry; float x = 256 + (float)Vector3D.Dot(d, right) * scale, y = 277 - (float)Vector3D.Dot(d, up) * scale;
                    if (x > 27 && x < 485 && y > 134 && y < 420) c.Text("+", x, y, 0.6f, Fresh(t) ? Blue : Red, TextAlignment.CENTER);
                }
                c.Text(Short(region, 20) + "  " + complete + "/" + total + "  +/- " + F(extent, "0") + "m", 22, 434, 0.42f, White);
                c.Text(L.F(L.DisplayMapLegend, sampled, empty), 22, 455, 0.36f, Muted);
            }

            void Diagnostics(Canvas c, RoleLogic role)
            {
                c.Text(L.DisplaySystemDiagnostics, 22, 93, 0.64f, White);
                double load = (double)p.Runtime.CurrentInstructionCount / Math.Max(1, p.Runtime.MaxInstructionCount);
                Bar(c, L.DisplayInstructions, load, 144, load > .7 ? Amber : Blue);
                c.Text(L.F(L.DisplayCpuScreens, F(p.Runtime.LastRunTimeMs, "0.000"), screens.Count), 22, 179, 0.47f, Muted);
                var lines = (role.Diagnostics ?? "").Replace("\r", "").Split('\n');
                float y = 210;
                foreach (var line in lines)
                {
                    if (y > 310) break;
                    c.Text(Short(line, 60), 22, y, 0.43f, White); y += 23;
                }
                c.Text(L.DisplayRecentEvents, 22, 329, 0.48f, Blue);
                string[] events = p.Events.ToArray();
                for (int i = Math.Max(0, events.Length - 4); i < events.Length; i++)
                    c.Text(Short(events[i], 61), 22, 358 + (i - Math.Max(0, events.Length - 4)) * 25, 0.4f, Muted);
            }

            void Configuration(Canvas c, RoleLogic role, List<Telemetry> fleet, int listPage)
            {
                // The console is composed for the actual viewport, not a square inside it.
                // Preserve font aspect ratio and spend extra width on content rather than margins.
                float width = c.Width - 44, right = c.Width - 22;
                bool wide = c.Width >= 720;
                c.Box(0, 0, c.Width, c.Height, ConsoleBackground);
                c.TextFit(L.Brand + " / " + Version, 22, 14, width * 0.59f, 0.95f, ConsoleValue);
                c.TextFit(L.DisplayConfiguration, right, 20, width * 0.38f, 0.78f, Blue, TextAlignment.RIGHT);
                c.TextFit(L.Role(p.Config.Role) + " / " + L.Language, 22, 59, width, 0.68f, ConsoleLabel);
                c.Box(22, 90, width, 2, Blue);
                string status = (role.Diagnostics ?? "").Replace("\r", "").Split('\n')[0];
                if (status.Length == 0) status = L.DisplayUnknown;
                Color statusColor = Amber;
                foreach (var t in fleet) if (t.Id == p.Config.Id)
                {
                    status = Fresh(t) ? L.State(t.State) : L.DisplayOffline;
                    if (t.Reason.Length > 0) status += " / " + t.Reason;
                    statusColor = !Fresh(t) || t.State == FlightState.Fault ? Red : t.Reason.Length > 0 || t.State == FlightState.Paused ? Amber : Green;
                    break;
                }
                c.TextFit(L.F(L.DisplayConfigStatus, status), 22, 102, width, 0.70f, statusColor);
                var rows = ConfigurationRows();
                int perPage = wide ? 5 : 4;
                int pages = Math.Max(1, (rows.Count + perPage - 1) / perPage), start = (listPage % pages) * perPage;
                float rowHeight = (c.Height - 192) / perPage;
                for (int i = start; i < Math.Min(rows.Count, start + perPage); i++)
                {
                    string value = rows[i].Value;
                    if (rows[i].Key == L.DisplayConfigMass && p.Config.Number("Flight", "DepartureMass", 0, 0, 1e10) == 0)
                        value = role.MeasuredDepartureMass > 0 ? L.F(L.DisplayAutoMass, F(role.MeasuredDepartureMass, "0")) : L.DisplayMeasuringMass;
                    float y = 144 + (i - start) * rowHeight;
                    c.Box(22, y, width, rowHeight - 4, ConsolePanel);
                    if (wide)
                    {
                        // Independent columns keep long IDs and translated labels from overlapping.
                        float labelY = y + Math.Max(3, (rowHeight - 4 - c.TextHeight(0.80f)) / 2);
                        float valueY = y + Math.Max(3, (rowHeight - 4 - c.TextHeight(0.90f)) / 2);
                        c.TextFit(rows[i].Key, 34, labelY, width * 0.46f - 24, 0.80f, ConsoleLabel);
                        c.TextFit(value, right - 12, valueY, width * 0.54f - 24, 0.90f, ConsoleValue, TextAlignment.RIGHT);
                    }
                    else
                    {
                        // Stack on square/narrow screens instead of shrinking either text column.
                        c.TextFit(rows[i].Key, 34, y + 3, width - 24, 0.80f, ConsoleLabel);
                        c.TextFit(value, right - 12, y + rowHeight * 0.45f, width - 24, 0.90f, ConsoleValue, TextAlignment.RIGHT);
                    }
                }
                c.TextFit(L.F(L.DisplayConfigPages, (listPage % pages) + 1, pages), 22, c.Height - 32, width, 0.65f, Blue);
            }

            List<KeyValuePair<string, string>> ConfigurationRows()
            {
                if (configurationRows != null) return configurationRows;
                var rows = new List<KeyValuePair<string, string>>();
                rows.Add(new KeyValuePair<string, string>(L.DisplayConfigId, p.Config.Id));
                rows.Add(new KeyValuePair<string, string>(L.DisplayConfigFleet, p.Config.FleetId));
                rows.Add(new KeyValuePair<string, string>(L.DisplayConfigBase, p.Config.BaseId));
                if (p.Config.Role == "miner")
                {
                    ConfigRow(rows, L.DisplayConfigMass, "Flight", "DepartureMass", "0", " kg");
                    ConfigRow(rows, L.DisplayConfigReturnBattery, "Energy", "ReturnBattery", "0.25");
                    ConfigRow(rows, L.DisplayConfigChargeTarget, "Energy", "ChargeTarget", "0.90");
                    ConfigRow(rows, L.DisplayConfigReturnCargo, "Cargo", "ReturnFill", "0.95");
                    ConfigRow(rows, L.DisplayConfigDrillSpeed, "Mining", "DrillSpeed", "0.5", " m/s");
                    ConfigRow(rows, L.DisplayConfigHoleRadius, "Mining", "HoleRadius", "3", " m");
                    ConfigRow(rows, L.DisplayConfigSortieDepth, "Mining", "SortieDepthBudget", "15", " m");
                }
                if (p.Config.Role == "fleet" || p.Config.Role == "marker" || p.Config.Role == "miner")
                {
                    rows.Add(new KeyValuePair<string, string>(L.DisplayConfigTargetOres, L.OreList(p.Config.Text("Mining", "TargetOres", "Iron,Nickel,Cobalt"))));
                    ConfigRow(rows, L.DisplayConfigDepthLimit, "Survey", "MaxDepth", "100", " m");
                }
                if (p.Config.Role == "fleet")
                {
                    ConfigRow(rows, L.DisplayConfigMinerLimit, "Fleet", "MaxMiners", "16");
                    ConfigRow(rows, L.DisplayConfigDockGroup, "Fleet", "DockGroup", "AMS Docks");
                    ConfigRow(rows, L.DisplayConfigCargoGroup, "Fleet", "CargoGroup", "AMS Cargo");
                }
                if (p.Config.Role == "fleet" || p.Config.Role == "miner")
                {
                    ConfigRow(rows, L.DisplayConfigDockSpeed, "Dock", "MaxBaseSpeed", "5", " m/s");
                    ConfigRow(rows, L.DisplayConfigDockAge, "Dock", "MaxTelemetryAge", "0.5", " s");
                }
                ConfigRow(rows, L.DisplayConfigWatchdogTimeout, "Watchdog", "Timeout", "3", " s");
                ConfigRow(rows, L.DisplayConfigFont, "Display", "Font", "Debug");
                configurationRows = rows; return rows;
            }

            void ConfigRow(List<KeyValuePair<string, string>> rows, string label, string section, string key, string fallback, string unit = "")
            {
                rows.Add(new KeyValuePair<string, string>(label, p.Config.Text(section, key, fallback) + unit));
            }

            void Carousel(Canvas c, Screen screen)
            {
                if (screen.Count <= 1) return;
                bool playing = AutoPaging(screen);
                if (playing) screen.Progress = Progress(screen);
                Color color = playing && p.Now >= screen.HoldUntil ? Blue : Muted;
                float width = c.Width - 44;
                c.Box(22, c.Height - 3, width, 2, Panel);
                c.Box(22, c.Height - 3, width * (float)screen.Progress, 2, color);
                // Bound sprite cost even for a fleet with many mining regions.
                int dots = Math.Min(10, screen.Count), active = screen.Slide * dots / screen.Count;
                Color activeColor = playing && p.Now >= screen.HoldUntil ? Blue : ConsoleValue;
                for (int i = 0; i < dots; i++) c.Dot(c.Width / 2 + (i - (dots - 1) / 2f) * 10, c.Height - 7, 2, i == active ? activeColor : Muted);
            }

            static bool Finished(Job j) { return j.Outcome == "Complete" || j.Outcome == "Blocked" || j.Outcome == "SurveyHit" || j.Outcome == "SurveyEmpty" || j.Outcome == "InvalidSample"; }
            bool Fresh(Telemetry t) { return p.Now - t.ReceivedAt <= p.Config.Number("Display", "OfflineSeconds", 5, 1, 60); }
            string FirstAlert(List<Telemetry> fleet) { foreach (var t in fleet) if (!Fresh(t)) return Short(t.Id + " " + L.DisplayOffline, 40); else if (t.Reason.Length > 0) return Short(t.Id + " " + t.Reason, 40); return L.DisplayNominal; }
            // CJK glyphs take approximately twice the width of Latin UI glyphs.
            static string Short(string s, int n)
            {
                s = (s ?? "").Replace('\n', ' ').Replace('\r', ' '); int width = 0;
                for (int i = 0; i < s.Length; i++) { width += s[i] >= 0x2e80 ? 2 : 1; if (width > n) return s.Substring(0, Math.Max(0, i - 1)) + "~"; }
                return s;
            }
            static string F(double n, string format) { return Data.Finite(n) ? n.ToString(format, Data.Culture) : "--"; }
            static string Percent(double n) { return F(Data.Clamp(n, 0, 1) * 100, "0") + "%"; }
            static string Duration(double seconds) { return !Data.Finite(seconds) || seconds < 0 || seconds > 86400 ? "--" : L.F(L.DisplayMinutes, F(seconds / 60, "0")); }
            static void Bar(Canvas c, string title, double value, float y, Color color)
            {
                c.Text(title, 22, y, 0.43f, Muted); c.Text(Percent(value), 490, y, 0.45f, color, TextAlignment.RIGHT);
                c.Box(22, y + 21, 468, 8, Panel); c.Box(22, y + 21, (float)Data.Clamp(value, 0, 1) * 468, 8, color);
            }

            class Canvas
            {
                readonly MySpriteDrawFrame frame;
                readonly Vector2 origin;
                readonly float scale;
                readonly string font;
                readonly IMyTextSurface surface;
                readonly StringBuilder measurement = new StringBuilder();
                public readonly float Width, Height;
                public Canvas(MySpriteDrawFrame drawFrame, IMyTextSurface textSurface, string fontName, bool fullViewport)
                {
                    font = fontName; surface = textSurface;
                    frame = drawFrame; scale = Math.Max(1, Math.Min(surface.SurfaceSize.X, surface.SurfaceSize.Y)) / 512f;
                    Width = fullViewport ? surface.SurfaceSize.X / scale : 512;
                    Height = fullViewport ? surface.SurfaceSize.Y / scale : 512;
                    origin = (surface.TextureSize - new Vector2(Width, Height) * scale) / 2;
                }
                public void Box(float x, float y, float width, float height, Color color)
                {
                    if (width <= 0 || height <= 0) return;
                    frame.Add(new MySprite(SpriteType.TEXTURE, "SquareSimple", origin + new Vector2(x + width / 2, y + height / 2) * scale, new Vector2(width, height) * scale, color));
                }
                public void Dot(float x, float y, float radius, Color color)
                {
                    frame.Add(new MySprite(SpriteType.TEXTURE, "Circle", origin + new Vector2(x, y) * scale, new Vector2(radius * 2) * scale, color));
                }
                public void Text(string text, float x, float y, float size, Color color, TextAlignment alignment = TextAlignment.LEFT)
                {
                    var sprite = MySprite.CreateText(text, font, color, size * scale, alignment);
                    sprite.Position = origin + new Vector2(x, y) * scale; frame.Add(sprite);
                }
                Vector2 Measure(string text, float size)
                {
                    measurement.Clear(); measurement.Append(text);
                    return surface.MeasureStringInPixels(measurement, font, size * scale) / scale;
                }
                public float TextHeight(float size) { return Measure("Ag", size).Y; }
                public void TextFit(string text, float x, float y, float width, float size, Color color, TextAlignment alignment = TextAlignment.LEFT)
                {
                    text = (text ?? "").Replace('\n', ' ').Replace('\r', ' ');
                    if (width <= 0) return;
                    if (Measure(text, size).X > width)
                    {
                        // Keep readable glyphs. Never shrink a long value into tiny text.
                        const string ellipsis = "...";
                        if (Measure(ellipsis, size).X > width) return;
                        int low = 0, high = text.Length;
                        while (low < high)
                        {
                            int mid = (low + high + 1) / 2;
                            if (Measure(text.Substring(0, mid) + ellipsis, size).X <= width) low = mid;
                            else high = mid - 1;
                        }
                        if (low > 0 && char.IsHighSurrogate(text[low - 1])) low--;
                        text = text.Substring(0, low) + ellipsis;
                    }
                    Text(text, x, y, size, color, alignment);
                }
            }
        }
    }
}