From a4f438e30c023370518f3a0dd00f229892e0e521 Mon Sep 17 00:00:00 2001 From: Anna Date: Sat, 28 Oct 2023 10:54:20 -0400 Subject: [PATCH] feat: add speed limits --- Configuration.cs | 18 ++++ DownloadTask.cs | 46 +++++++++- Plugin.cs | 54 +++++++++++- Ui/DownloadStatusWindow.cs | 10 ++- Ui/Tabs/Settings.cs | 35 ++++++++ Util/GloballyThrottledStream.cs | 146 ++++++++++++++++++++++++++++++++ Util/ImGuiHelper.cs | 16 ++++ heliosphere-plugin.csproj | 5 +- packages.lock.json | 37 ++++++++ 9 files changed, 357 insertions(+), 10 deletions(-) create mode 100644 Util/GloballyThrottledStream.cs diff --git a/Configuration.cs b/Configuration.cs index 83011e8..1ac2789 100644 --- a/Configuration.cs +++ b/Configuration.cs @@ -25,6 +25,12 @@ internal class Configuration : IPluginConfiguration { public byte[]? OneClickSalt; public string? OneClickHash; public string? OneClickCollection; + public long MaxKibsPerSecond; + public long AltMaxKibsPerSecond; + public SpeedLimit LimitNormal = SpeedLimit.On; + public SpeedLimit LimitInstance = SpeedLimit.Default; + public SpeedLimit LimitCombat = SpeedLimit.Default; + public SpeedLimit LimitParty = SpeedLimit.Default; public Configuration() { } @@ -44,6 +50,12 @@ internal Configuration(Configuration other) { this.OneClickSalt = other.OneClickSalt; this.OneClickHash = other.OneClickHash; this.OneClickCollection = other.OneClickCollection; + this.MaxKibsPerSecond = other.MaxKibsPerSecond; + this.AltMaxKibsPerSecond = other.AltMaxKibsPerSecond; + this.LimitNormal = other.LimitNormal; + this.LimitInstance = other.LimitInstance; + this.LimitCombat = other.LimitCombat; + this.LimitParty = other.LimitParty; } private void Redact() { @@ -62,4 +74,10 @@ internal static Configuration CloneAndRedact(Configuration other) { return redacted; } + + internal enum SpeedLimit { + Default, + On, + Alternate, + } } diff --git a/DownloadTask.cs b/DownloadTask.cs index b72fc31..26b408d 100644 --- a/DownloadTask.cs +++ b/DownloadTask.cs @@ -1,7 +1,9 @@ +using System.Diagnostics; using System.Net.Http.Headers; using System.Text; using Blake3; using Dalamud.Interface.Internal.Notifications; +using DequeNet; using gfoidl.Base64; using Heliosphere.Exceptions; using Heliosphere.Model; @@ -44,6 +46,33 @@ internal class DownloadTask : IDisposable { internal uint StateData { get; private set; } internal uint StateDataMax { get; private set; } internal Exception? Error { get; private set; } + private ConcurrentDeque Entries { get; } = new(); + + private const double Window = 5; + internal double BytesPerSecond { + get { + if (this.Entries.Count == 0) { + return 0; + } + + var total = 0u; + var removeTo = 0; + foreach (var entry in this.Entries) { + if (Stopwatch.GetElapsedTime(entry.Ticks) > TimeSpan.FromSeconds(Window)) { + removeTo += 1; + continue; + } + + total += entry.Data; + } + + for (var i = 0; i < removeTo; i++) { + this.Entries.TryPopLeft(out _); + } + + return total / Window; + } + } private bool _disposed; private string? _oldModName; @@ -461,7 +490,10 @@ StateCounter counter var chunk = chunks[i]; // get the content of this multipart chunk as a stream - await using var stream = await content.ReadAsStreamAsync(this.CancellationToken.Token); + await using var stream = new GloballyThrottledStream( + await content.ReadAsStreamAsync(this.CancellationToken.Token), + this.Entries + ); // now we're going to read each file in the chunk out, // decompress it, and write it to the disk @@ -662,7 +694,10 @@ await Retry(3, $"Error downloading {baseUri}/{hash}", async _ => { resp.EnsureSuccessStatusCode(); await using var file = File.Create(path); - await using var stream = await resp.Content.ReadAsStreamAsync(this.CancellationToken.Token); + await using var stream = new GloballyThrottledStream( + await resp.Content.ReadAsStreamAsync(this.CancellationToken.Token), + this.Entries + ); await new DecompressionStream(stream).CopyToAsync(file, this.CancellationToken.Token); return false; @@ -1190,6 +1225,11 @@ internal DownloadOptions(Dictionary> options) { private struct FullDownloadOptions { public bool Full; } + + internal struct Measurement { + internal long Ticks; + internal uint Data; + } } internal enum State { @@ -1293,4 +1333,4 @@ public override long Position { get => this._inner.Position; set => this._inner.Position = value; } -} \ No newline at end of file +} diff --git a/Plugin.cs b/Plugin.cs index 1a55f95..fdcf6db 100644 --- a/Plugin.cs +++ b/Plugin.cs @@ -2,6 +2,7 @@ using System.Net.Http.Headers; using System.Text.Json; using System.Text.RegularExpressions; +using Dalamud.Game.ClientState.Conditions; using Dalamud.Interface.Internal; using Dalamud.Interface.Internal.Notifications; using Dalamud.IoC; @@ -51,11 +52,17 @@ public class Plugin : IDalamudPlugin { [PluginService] internal ICommandManager CommandManager { get; init; } + [PluginService] + internal ICondition Condition { get; init; } + + [PluginService] + internal IDutyState DutyState { get; init; } + [PluginService] internal IFramework Framework { get; init; } [PluginService] - private IPluginLog PluginLog { get; init; } + internal IPartyList PartyList { get; init; } internal Configuration Config { get; } internal DownloadCodes DownloadCodes { get; } @@ -85,7 +92,6 @@ public Plugin() { Instance = this; GameFont = new GameFont(this); PluginInterface = this.Interface!; - Log = this.PluginLog!; this.Sentry = SentrySdk.Init(o => { o.Dsn = "https://540decab4a5941f1ba826cd50b4b6efd@sentry.heliosphere.app/4"; @@ -167,6 +173,8 @@ public Plugin() { this.LinkPayloads = new LinkPayloads(this); this.CommandHandler = new CommandHandler(this); + this.Framework!.Update += this.CalculateSpeedLimit; + if (startWithAvWarning || checkTask is { Status: TaskStatus.RanToCompletion, Result: true }) { this.PluginUi.OpenAntiVirusWarning(); } @@ -175,6 +183,7 @@ public Plugin() { } public void Dispose() { + this.Framework.Update -= this.CalculateSpeedLimit; this.CommandHandler.Dispose(); this.LinkPayloads.Dispose(); this.Server.Dispose(); @@ -186,6 +195,7 @@ public void Dispose() { GameFont.Dispose(); ImageLoadSemaphore.Dispose(); DownloadSemaphore.Dispose(); + GloballyThrottledStream.Shutdown(); foreach (var wrap in this.CoverImages.Deconstruct().Values) { wrap.Dispose(); @@ -196,6 +206,46 @@ internal void SaveConfig() { this.Interface.SavePluginConfig(this.Config); } + private void CalculateSpeedLimit(IFramework framework) { + var limit = this.CalculateLimit(); + GloballyThrottledStream.MaxBytesPerSecond = limit switch { + Configuration.SpeedLimit.On => this.Config.MaxKibsPerSecond * 1_024, + Configuration.SpeedLimit.Alternate => this.Config.AltMaxKibsPerSecond * 1_024, + Configuration.SpeedLimit.Default => this.Config.LimitNormal switch { + Configuration.SpeedLimit.On => this.Config.MaxKibsPerSecond * 1_024, + Configuration.SpeedLimit.Default => this.Config.MaxKibsPerSecond * 1_024, + Configuration.SpeedLimit.Alternate => this.Config.AltMaxKibsPerSecond * 1_024, + _ => 0, + }, + _ => 0, + }; + } + + private Configuration.SpeedLimit CalculateLimit() { + if (this.Condition[ConditionFlag.InCombat]) { + return this.Config.LimitCombat; + } + + if (this.DutyState.IsDutyStarted) { + return this.Config.LimitInstance; + } + + // if (this.Condition.Any( + // ConditionFlag.BoundByDuty, + // ConditionFlag.BoundByDuty56, + // ConditionFlag.BoundByDuty95, + // ConditionFlag.BoundToDuty97 + // )) { + // return this.Config.LimitInstance; + // } + + if (this.PartyList.Length > 0) { + return this.Config.LimitParty; + } + + return this.Config.LimitNormal; + } + internal async Task AddDownloadAsync(DownloadTask task, CancellationToken token = default) { bool wasAdded; using (var guard = await this.Downloads.WaitAsync(token)) { diff --git a/Ui/DownloadStatusWindow.cs b/Ui/DownloadStatusWindow.cs index 07df796..f01661b 100644 --- a/Ui/DownloadStatusWindow.cs +++ b/Ui/DownloadStatusWindow.cs @@ -77,6 +77,14 @@ private void DrawRealDownloads(Guard>.Handle guard) { continue; } + var bps = task.BytesPerSecond; + var speed = bps switch { + >= 1_073_741_824 => $"{bps / 1_073_741_824:N2} GiB/s", + >= 1_048_576 => $"{bps / 1_048_576:N2} MiB/s", + >= 1_024 => $"{bps / 1_024:N2} KiB/s", + _ => $"{bps:N2} B/s", + }; + var packageName = task switch { { PackageName: not null, VariantName: null } => $"{task.PackageName} - ", { PackageName: not null, VariantName: not null } => $"{task.PackageName} ({task.VariantName}) - ", @@ -85,7 +93,7 @@ private void DrawRealDownloads(Guard>.Handle guard) { ImGui.ProgressBar( (float) task.StateData / task.StateDataMax, new Vector2(ImGui.GetContentRegionAvail().X, 25 * ImGuiHelpers.GlobalScale), - $"{packageName}{task.State.Name()}: {task.StateData:N0} / {task.StateDataMax:N0}" + $"{packageName}{task.State.Name()}: {task.StateData:N0} / {task.StateDataMax:N0} ({speed})" ); ImGuiHelper.Tooltip("Hold Ctrl and click to cancel."); diff --git a/Ui/Tabs/Settings.cs b/Ui/Tabs/Settings.cs index de69246..56c4c90 100644 --- a/Ui/Tabs/Settings.cs +++ b/Ui/Tabs/Settings.cs @@ -61,6 +61,41 @@ internal void Draw() { ref this.Plugin.Config.DefaultCollection ); + if (ImGui.CollapsingHeader("Download speed limits")) { + anyChanged |= ImGuiHelper.InputLongVertical( + "Max download speed in KiBs (0 for unlimited)", + "##max-download-speed", + ref this.Plugin.Config.MaxKibsPerSecond + ); + + anyChanged |= ImGuiHelper.InputLongVertical( + "Alternate download speed in KiBs (0 for unlimited)", + "##max-download-speed", + ref this.Plugin.Config.AltMaxKibsPerSecond + ); + + void DrawLimitCombo(string title, string id, ref Configuration.SpeedLimit limit) { + ImGui.TextUnformatted(title); + if (!ImGui.BeginCombo(id, Enum.GetName(limit))) { + return; + } + + foreach (var option in Enum.GetValues()) { + if (ImGui.Selectable(Enum.GetName(option), option == limit)) { + limit = option; + anyChanged = true; + } + } + + ImGui.EndCombo(); + } + + DrawLimitCombo("Speed limit (default)", "##speed-limit-normal", ref this.Plugin.Config.LimitNormal); + DrawLimitCombo("Speed limit (in instance)", "##speed-limit-instance", ref this.Plugin.Config.LimitInstance); + DrawLimitCombo("Speed limit (in combat)", "##speed-limit-combat", ref this.Plugin.Config.LimitCombat); + DrawLimitCombo("Speed limit (in party)", "##speed-limit-party", ref this.Plugin.Config.LimitParty); + } + if (ImGui.CollapsingHeader("One-click install")) { anyChanged |= ImGui.Checkbox("Enable", ref this.Plugin.Config.OneClick); diff --git a/Util/GloballyThrottledStream.cs b/Util/GloballyThrottledStream.cs new file mode 100644 index 0000000..77be949 --- /dev/null +++ b/Util/GloballyThrottledStream.cs @@ -0,0 +1,146 @@ +using System.Diagnostics; +using DequeNet; + +namespace Heliosphere.Util; + +internal class GloballyThrottledStream : Stream { + private static long _maxBytesPerSecond; + + internal static long MaxBytesPerSecond { + get => _maxBytesPerSecond; + set { + // if new value is bigger, this is positive + var diff = value - _maxBytesPerSecond; + _maxBytesPerSecond = value; + + Mutex.Wait(); + try { + _freqTokens += diff * Stopwatch.Frequency; + } finally { + Mutex.Release(); + } + } + } + + private static readonly SemaphoreSlim Mutex = new(1, 1); + private static long _freqTokens; + private static long _lastRead = Stopwatch.GetTimestamp(); + + private Stream Inner { get; } + private ConcurrentDeque Entries { get; } + + public override bool CanRead => this.Inner.CanRead; + public override bool CanSeek => this.Inner.CanSeek; + public override bool CanWrite => this.Inner.CanWrite; + public override long Length => this.Inner.Length; + + public override long Position { + get => this.Inner.Position; + set => this.Inner.Position = value; + } + + internal GloballyThrottledStream(Stream inner, ConcurrentDeque entries) { + this.Inner = inner; + this.Entries = entries; + } + + protected override void Dispose(bool disposing) { + if (disposing) { + this.Inner.Dispose(); + } + } + + public override ValueTask DisposeAsync() { + return this.Inner.DisposeAsync(); + } + + internal static void Shutdown() { + Mutex.Dispose(); + } + + public override void Flush() { + this.Inner.Flush(); + } + + private static long AddTokens() { + var now = Stopwatch.GetTimestamp(); + var then = Interlocked.Exchange(ref _lastRead, now); + var tokensToAdd = (now - then) * _maxBytesPerSecond; + + long freqTokens; + Mutex.Wait(); + try { + var untilFull = _maxBytesPerSecond * Stopwatch.Frequency - _freqTokens; + if (untilFull > 0 && tokensToAdd > 0) { + _freqTokens += Math.Min(untilFull, tokensToAdd); + } + + freqTokens = _freqTokens; + } finally { + Mutex.Release(); + } + + return freqTokens; + + // var now = Stopwatch.GetTimestamp(); + // var then = Interlocked.Exchange(ref _lastRead, now); + // var tokensToAdd = (now - then) * _maxBytesPerSecond; + // + // var curTokens = Interlocked.CompareExchange(ref _freqTokens, 0, 0); + // var untilFull = _maxBytesPerSecond * Stopwatch.Frequency - curTokens; + // var freqTokens = untilFull > 0 && tokensToAdd > 0 + // ? Interlocked.Add(ref _freqTokens, Math.Min(untilFull, tokensToAdd)) + // : curTokens; + // + // return freqTokens; + } + + public override int Read(byte[] buffer, int offset, int count) { + var freqTokens = AddTokens(); + + int amt; + if (_maxBytesPerSecond == 0) { + amt = count; + } else { + var bytes = (int) (freqTokens / Stopwatch.Frequency); + // let's not do a million tiny reads + var exp = (int) Math.Truncate(Math.Log2(_maxBytesPerSecond)); + var lessThan = Math.Pow(2, Math.Clamp(exp, 0, 16)); + while (bytes < lessThan) { + Thread.Sleep(TimeSpan.FromMilliseconds(5)); + freqTokens = AddTokens(); + bytes = (int) (freqTokens / Stopwatch.Frequency); + } + + amt = Math.Min(bytes, count); + } + + var read = this.Inner.Read(buffer, offset, amt); + + Mutex.Wait(); + try { + _freqTokens -= read * Stopwatch.Frequency; + } finally { + Mutex.Release(); + } + + this.Entries.PushRight(new DownloadTask.Measurement { + Ticks = Stopwatch.GetTimestamp(), + Data = (uint) read, + }); + + return read; + } + + public override long Seek(long offset, SeekOrigin origin) { + return this.Inner.Seek(offset, origin); + } + + public override void SetLength(long value) { + this.Inner.SetLength(value); + } + + public override void Write(byte[] buffer, int offset, int count) { + this.Inner.Write(buffer, offset, count); + } +} diff --git a/Util/ImGuiHelper.cs b/Util/ImGuiHelper.cs index ff6c388..17de691 100644 --- a/Util/ImGuiHelper.cs +++ b/Util/ImGuiHelper.cs @@ -298,6 +298,22 @@ internal static bool InputTextVertical(string title, string id, ref string input return ImGui.InputText(id, ref input, max, flags); } + internal static bool InputLongVertical(string title, string id, ref long input) { + ImGui.TextUnformatted(title); + ImGui.SetNextItemWidth(-1); + var text = input.ToString(); + if (!ImGui.InputText(id, ref text, 100, ImGuiInputTextFlags.CharsDecimal)) { + return false; + } + + if (!long.TryParse(text, out var parsed)) { + return false; + } + + input = parsed; + return true; + } + internal static bool CollectionChooser(PenumbraIpc penumbra, string label, ref string? value) { var anyChanged = false; diff --git a/heliosphere-plugin.csproj b/heliosphere-plugin.csproj index 23c6e59..9135ed3 100644 --- a/heliosphere-plugin.csproj +++ b/heliosphere-plugin.csproj @@ -39,10 +39,6 @@ $(DalamudLibPath)\ImGui.NET.dll false - - $(DalamudLibPath)\ImGuiScene.dll - false - $(DalamudLibPath)\Newtonsoft.Json.dll false @@ -52,6 +48,7 @@ + diff --git a/packages.lock.json b/packages.lock.json index 8977981..3564ca9 100644 --- a/packages.lock.json +++ b/packages.lock.json @@ -14,6 +14,16 @@ "resolved": "2.1.12", "contentHash": "Sc0PVxvgg4NQjcI8n10/VfUQBAS4O+Fw2pZrAqBdRMbthYGeogzu5+xmIGCGmsEZ/ukMOBuAqiNiB5qA3MRalg==" }, + "DequeNet": { + "type": "Direct", + "requested": "[1.0.2, )", + "resolved": "1.0.2", + "contentHash": "k+H1EtqxGSdIjNSQTFfYfUNxa3do+ideGyHBeZv/I8F38RRaB+f7Rqod3LOm5PiVvPo46js00PDJrjkZ0WX3vw==", + "dependencies": { + "Microsoft.NETCore.Portable.Compatibility": "1.0.1", + "NETStandard.Library": "1.6.0" + } + }, "gfoidl.Base64": { "type": "Direct", "requested": "[2.0.0, )", @@ -220,16 +230,43 @@ "System.Buffers": "4.5.0" } }, + "Microsoft.NETCore.Jit": { + "type": "Transitive", + "resolved": "1.0.2", + "contentHash": "Ok2vWofa6X8WD9vc4pfLHwvJz1/B6t3gOAoZcjrjrQf7lQOlNIuZIZtLn3wnWX28DuQGpPJkRlBxFj7Z5txNqw==" + }, "Microsoft.NETCore.Platforms": { "type": "Transitive", "resolved": "5.0.0", "contentHash": "VyPlqzH2wavqquTcYpkIIAQ6WdenuKoFN0BdYBbCWsclXacSOHNQn66Gt4z5NBqEYW0FAPm5rlvki9ZiCij5xQ==" }, + "Microsoft.NETCore.Portable.Compatibility": { + "type": "Transitive", + "resolved": "1.0.1", + "contentHash": "Vd+lvLcGwvkedxtKn0U8s9uR4p0Lm+0U2QvDsLaw7g4S1W4KfPDbaW+ROhhLCSOx/gMYC72/b+z+o4fqS/oxVg==", + "dependencies": { + "Microsoft.NETCore.Runtime.CoreCLR": "1.0.2" + } + }, + "Microsoft.NETCore.Runtime.CoreCLR": { + "type": "Transitive", + "resolved": "1.0.2", + "contentHash": "A0x1xtTjYJWZr2DRzgfCOXgB0JkQg8twnmtTJ79wFje+IihlLbXtx6Z2AxyVokBM5ruwTedR6YdCmHk39QJdtQ==", + "dependencies": { + "Microsoft.NETCore.Jit": "1.0.2", + "Microsoft.NETCore.Windows.ApiSets": "1.0.1" + } + }, "Microsoft.NETCore.Targets": { "type": "Transitive", "resolved": "1.1.0", "contentHash": "aOZA3BWfz9RXjpzt0sRJJMjAscAUm3Hoa4UWAfceV9UTYxgwZ1lZt5nO2myFf+/jetYQo4uTP7zS8sJY67BBxg==" }, + "Microsoft.NETCore.Windows.ApiSets": { + "type": "Transitive", + "resolved": "1.0.1", + "contentHash": "SaToCvvsGMxTgtLv/BrFQ5IFMPRE1zpWbnqbpwykJa8W5XiX82CXI6K2o7yf5xS7EP6t/JzFLV0SIDuWpvBZVw==" + }, "Microsoft.Win32.Primitives": { "type": "Transitive", "resolved": "4.3.0",