diff --git a/BossMod/Config/ConfigNode.cs b/BossMod/Config/ConfigNode.cs index d3b6fc1336..03c569adba 100644 --- a/BossMod/Config/ConfigNode.cs +++ b/BossMod/Config/ConfigNode.cs @@ -43,7 +43,7 @@ public PropertyComboAttribute(string falseText, string trueText) } } -// attribute that specifies slider should be used for displaying float property +// attribute that specifies slider should be used for displaying float/int property [AttributeUsage(AttributeTargets.Field)] public class PropertySliderAttribute : Attribute { diff --git a/BossMod/Config/ConfigUI.cs b/BossMod/Config/ConfigUI.cs index ac2bb2fdc6..3f633b3364 100644 --- a/BossMod/Config/ConfigUI.cs +++ b/BossMod/Config/ConfigUI.cs @@ -87,6 +87,7 @@ public static void DrawNode(ConfigNode node, ConfigRoot root, UITree tree, World bool v => DrawProperty(props, node, field, v), Enum v => DrawProperty(props, node, field, v), float v => DrawProperty(props, node, field, v), + int v => DrawProperty(props, node, field, v), GroupAssignment v => DrawProperty(props, node, field, v, root, tree, ws), _ => false }; @@ -171,6 +172,31 @@ private static bool DrawProperty(PropertyDisplayAttribute props, ConfigNode node return true; } + private static bool DrawProperty(PropertyDisplayAttribute props, ConfigNode node, FieldInfo member, int v) + { + var slider = member.GetCustomAttribute(); + if (slider != null) + { + var flags = ImGuiSliderFlags.None; + if (slider.Logarithmic) + flags |= ImGuiSliderFlags.Logarithmic; + if (ImGui.DragInt(props.Label, ref v, slider.Speed, (int)slider.Min, (int)slider.Max, "%d", flags)) + { + member.SetValue(node, v); + node.NotifyModified(); + } + } + else + { + if (ImGui.InputInt(props.Label, ref v)) + { + member.SetValue(node, v); + node.NotifyModified(); + } + } + return true; + } + private static bool DrawProperty(PropertyDisplayAttribute props, ConfigNode node, FieldInfo member, GroupAssignment v, ConfigRoot root, UITree tree, WorldState ws) { var group = member.GetCustomAttribute(); diff --git a/BossMod/Framework/Plugin.cs b/BossMod/Framework/Plugin.cs index c2d8355022..69a4913106 100644 --- a/BossMod/Framework/Plugin.cs +++ b/BossMod/Framework/Plugin.cs @@ -70,7 +70,7 @@ public Plugin( _wndBossmod = new(_bossmod); _wndBossmodPlan = new(_bossmod); _wndBossmodHints = new(_bossmod); - _wndReplay = new(_ws, dalamud.ConfigDirectory); + _wndReplay = new(_ws, new(dalamud.ConfigDirectory.FullName + "/replays")); _wndDebug = new(_ws, _autorotation); dalamud.UiBuilder.DisableAutomaticUiHide = true; diff --git a/BossMod/Framework/Utils.cs b/BossMod/Framework/Utils.cs index b86a901104..9056171d70 100644 --- a/BossMod/Framework/Utils.cs +++ b/BossMod/Framework/Utils.cs @@ -2,6 +2,7 @@ using System.Globalization; using System.Reflection; using System.Runtime.InteropServices; +using System.Text.RegularExpressions; namespace BossMod; @@ -246,7 +247,9 @@ public static int UpperBound(this SortedList list, T // sort elements of a list by key public static void SortBy(this List list, Func proj) where TKey : notnull, IComparable => list.Sort((l, r) => proj(l).CompareTo(proj(r))); + public static void SortBy(this TValue[] arr, Func proj) where TKey : notnull, IComparable => Array.Sort(arr, (l, r) => proj(l).CompareTo(proj(r))); public static void SortByReverse(this List list, Func proj) where TKey : notnull, IComparable => list.Sort((l, r) => proj(r).CompareTo(proj(l))); + public static void SortByReverse(this TValue[] arr, Func proj) where TKey : notnull, IComparable => Array.Sort(arr, (l, r) => proj(r).CompareTo(proj(l))); // get enumerable of zero or one elements, depending on whether argument is null public static IEnumerable ZeroOrOne(T? value) where T : struct @@ -336,7 +339,7 @@ public static string StringToIdentifier(string v) v = v.Replace("'", null); v = v.Replace('-', ' '); v = CultureInfo.InvariantCulture.TextInfo.ToTitleCase(v); - v = v.Replace(" ", null); + v = Regex.Replace(v, "[^a-zA-Z0-9]", ""); return v; } } diff --git a/BossMod/Replay/ReplayManagementConfig.cs b/BossMod/Replay/ReplayManagementConfig.cs index 5c786b8857..e096f58d13 100644 --- a/BossMod/Replay/ReplayManagementConfig.cs +++ b/BossMod/Replay/ReplayManagementConfig.cs @@ -6,6 +6,16 @@ public class ReplayManagementConfig : ConfigNode [PropertyDisplay("Show replay management UI")] public bool ShowUI = false; + [PropertyDisplay("Auto record replays on duty start")] + public bool AutoRecord = false; + + [PropertyDisplay("Auto stop replays on duty end")] + public bool AutoStop = false; + + [PropertyDisplay("Max replays to keep before removal")] + [PropertySlider(0, 1000)] + public int MaxReplays = 0; + [PropertyDisplay("Store server packets in the replay")] public bool DumpServerPackets = false; diff --git a/BossMod/Replay/ReplayManagementWindow.cs b/BossMod/Replay/ReplayManagementWindow.cs index 839f8c499e..3927a46baf 100644 --- a/BossMod/Replay/ReplayManagementWindow.cs +++ b/BossMod/Replay/ReplayManagementWindow.cs @@ -1,4 +1,5 @@ using ImGuiNET; +using Lumina.Excel.GeneratedSheets; using System.IO; namespace BossMod; @@ -19,16 +20,21 @@ public class ReplayManagementWindow : UIWindow _ws = ws; _logDir = logDir; _config = Service.Config.Get(); - _config.Modified += ApplyConfig; _manager = new(logDir.FullName); - ApplyConfig(null, EventArgs.Empty); - UpdateTitle(); + + _ws.CurrentZoneChanged += OnZoneChanged; + _config.Modified += OnConfigChanged; + if (!UpdateAutoRecord(_ws.CurrentCFCID)) + UpdateTitle(); + RespectCloseHotkey = false; + IsOpen = _config.ShowUI; } protected override void Dispose(bool disposing) { - _config.Modified -= ApplyConfig; + _config.Modified -= OnConfigChanged; + _ws.CurrentZoneChanged -= OnZoneChanged; _recorder?.Dispose(); _manager.Dispose(); } @@ -49,25 +55,12 @@ public override void PreOpenCheck() public override void Draw() { - if (ImGui.Button(_recorder == null ? "Start recording" : "Stop recording")) + if (ImGui.Button(!IsRecording() ? "Start recording" : "Stop recording")) { - if (_recorder == null) - { - try - { - _recorder = new(_ws, _config.WorldLogFormat, true, _logDir, "World"); - } - catch (Exception ex) - { - Service.Log($"Failed to start recording: {ex}"); - } - } + if (!IsRecording()) + StartRecording(); else - { - _recorder.Dispose(); - _recorder = null; - } - UpdateTitle(); + StopRecording(); } if (_recorder != null) @@ -85,11 +78,99 @@ public override void Draw() _manager.Draw(); } + public void StartRecording() + { + if (IsRecording()) + return; // already recording + + // if there are too many replays, delete oldest + if (_config.MaxReplays > 0) + { + try + { + var replays = _logDir.GetFiles(); + replays.SortBy(f => f.LastWriteTime); + foreach (var f in replays.Take(replays.Length - _config.MaxReplays)) + f.Delete(); + } + catch (Exception ex) + { + Service.Log($"Failed to delete old replays: {ex}"); + } + } + + try + { + _recorder = new(_ws, _config.WorldLogFormat, true, _logDir, GetPrefix()); + } + catch (Exception ex) + { + Service.Log($"Failed to start recording: {ex}"); + } + + UpdateTitle(); + } + + public void StopRecording() + { + _recorder?.Dispose(); + _recorder = null; + UpdateTitle(); + } + + public bool IsRecording() => _recorder != null; + public override void OnClose() { SetVisible(false); } - private void ApplyConfig(object? sender, EventArgs args) => IsOpen = _config.ShowUI; private void UpdateTitle() => WindowName = $"Replay recording: {(_recorder != null ? "in progress..." : "idle")}{_windowID}"; + + private bool UpdateAutoRecord(uint cfcId) + { + if (!IsRecording() && _config.AutoRecord && cfcId != 0) + { + StartRecording(); + return true; + } + + if (IsRecording() && _config.AutoStop && cfcId == 0) + { + StopRecording(); + return true; + } + + return false; + } + + private void OnConfigChanged(object? sender, EventArgs args) => IsOpen = _config.ShowUI; + private void OnZoneChanged(object? sender, WorldState.OpZoneChange op) => UpdateAutoRecord(op.CFCID); + + private unsafe string GetPrefix() + { + string? prefix = null; + if (_ws.CurrentCFCID != 0) + prefix ??= Service.LuminaRow(_ws.CurrentCFCID)?.Name.ToString(); + if (_ws.CurrentZone != 0) + prefix ??= Service.LuminaRow(_ws.CurrentZone)?.PlaceName.Value?.NameNoArticle.ToString(); + prefix ??= "World"; + prefix = Utils.StringToIdentifier(prefix); + + var player = _ws.Party.Player(); + if (player != null) + prefix += $"_{player.Class}{player.Level}_{player.Name.Replace(" ", null)}"; + + var cf = FFXIVClientStructs.FFXIV.Client.Game.UI.ContentsFinder.Instance(); + if (cf->IsUnrestrictedParty) + prefix += "_U"; + if (cf->IsLevelSync) + prefix += "_LS"; + if (cf->IsMinimalIL) + prefix += "_MI"; + if (cf->IsSilenceEcho) + prefix += "_NE"; + + return prefix; + } } diff --git a/TODO b/TODO index 3b8f3dc762..90c2d0f42e 100644 --- a/TODO +++ b/TODO @@ -16,12 +16,7 @@ network rework: - utility to inject custom ipcs to the stream for debugging? general: -- live replay --- checkbox - record ops to temp struct --- start record == rundown + start listening to main worldstate events --- on bossmodule activation == mark as 'interesting' --- on bossmodule deactivation or zone change == if current is interesting, move it to last (discard prev last); clear and restart current --- button to 'keep' last (add as temp to replay manager) or to 'save' last (save into replay file) +- autoreplay improvements - react to module manager transitions? - better timing tracking for: statuses, gauges, cooldowns, cast times, anim lock, ... - constrain bossmodules to zone id (e.g. for T04) - revise module categories - consider merging fates/hunts/quests/gold saucer?/pvp? into outdoor?/casual?