813 lines
47 KiB
C#
813 lines
47 KiB
C#
using System.Globalization;
|
||
using System.Numerics;
|
||
using Dalamud.Bindings.ImGui;
|
||
using Dalamud.Game.Command;
|
||
using Dalamud.Game.Gui.Dtr;
|
||
using Dalamud.Plugin;
|
||
using Dalamud.Plugin.Services;
|
||
using ICRebuild.Core;
|
||
using ICRebuild.Runtime;
|
||
|
||
namespace ICRebuild;
|
||
|
||
public sealed class Plugin : IDalamudPlugin
|
||
{
|
||
private readonly IDalamudPluginInterface pi;
|
||
private readonly ICommandManager commands;
|
||
private readonly IFramework framework;
|
||
private readonly IChatGui chat;
|
||
private readonly IPluginLog log;
|
||
private readonly Configuration config;
|
||
private readonly IDtrBarEntry serviceBar;
|
||
private readonly PrecisionSliders sliders = new();
|
||
private ServiceBarStatus? lastServiceStatus;
|
||
private long lastServiceUpdate;
|
||
private readonly ModuleManager modules = new();
|
||
private readonly GameContext game;
|
||
private readonly CombatModules combat;
|
||
private readonly MovementModules movement;
|
||
private readonly StatusBlockModule statusBlock;
|
||
private readonly CoordinateTools coordinates;
|
||
private readonly NetworkCapture capture;
|
||
private readonly NetworkManager network;
|
||
private readonly PacketBuffer packets = new();
|
||
private readonly OpcodeMap opcodes = new();
|
||
private readonly HttpClient http = new() { Timeout = TimeSpan.FromSeconds(20) };
|
||
private readonly CancellationTokenSource cancellation = new();
|
||
private readonly string gameVersion = GameContext.ReadGameVersion();
|
||
private readonly string directory;
|
||
private bool open, disposed;
|
||
private Vector3 position;
|
||
private float rotation;
|
||
private int timeline;
|
||
private int actionIdForTimeline;
|
||
private int packetOpcode = -1;
|
||
private int packetDirection;
|
||
private string notice = "参数自动保存;每次重新加载后,所有功能默认关闭。";
|
||
private string file = "packets.jsonl";
|
||
private Task? pending;
|
||
private Task? captureSave;
|
||
private long lastCaptureSave;
|
||
private readonly string captureSessionFile=$"capture-{DateTime.Now:yyyyMMdd-HHmmss}-{Guid.NewGuid():N}.jsonl";
|
||
private string captureSaveStatus="尚未保存";
|
||
private DungeonSnapshot dungeon = DungeonSnapshot.Empty;
|
||
private AdaptationReport? adaptationReport;
|
||
private int manualSendActionId = 7;
|
||
private int manualSendRequestId = 1;
|
||
private int manualTriggerCommand = 3;
|
||
private int manualTriggerParam1 = 1;
|
||
private int smartGroundActionId = 188;
|
||
private uint lastEventId;
|
||
private ulong lastEventTargetId;
|
||
private uint lastEventParam;
|
||
private long lastEventTime;
|
||
private long lastRetainerActionTime;
|
||
private long lastTalkAdvanceTime;
|
||
private long lastYesnoTime;
|
||
private string statusFilterText = "";
|
||
private readonly IDisposable eventSubscription;
|
||
|
||
public Plugin(IDalamudPluginInterface pluginInterface,ICommandManager commands,IFramework framework,
|
||
IObjectTable objects,IClientState client,ICondition conditions,IDataManager data,ISigScanner scanner,
|
||
IGameInteropProvider interop,ITargetManager targets,IChatGui chat,IPluginLog log,IDtrBar dtrBar)
|
||
{
|
||
pi=pluginInterface; this.commands=commands; this.framework=framework; this.chat=chat; this.log=log;
|
||
config=pi.GetPluginConfig() as Configuration ?? new(); config.Normalize();
|
||
directory=pi.GetPluginConfigDirectory(); Directory.CreateDirectory(directory);
|
||
game=new(objects,client,conditions,data,scanner,interop,targets);
|
||
combat=new(game,config,modules); coordinates=new(game,modules);
|
||
movement=new(game,config,modules); movement.Register(); combat.Register(); coordinates.Register();
|
||
statusBlock=new(game,config,modules); statusBlock.Register();
|
||
Try(()=>
|
||
{
|
||
combat.LoadMovementCatalog();
|
||
if(gameVersion!="unknown" && config.MovementListGameVersion!=gameVersion)
|
||
{
|
||
combat.AddAllMovementSkills(); Save();
|
||
notice=$"已按当前动作表导入 {combat.MovementSkills.Count(MovementSkillCatalog.Mappable)} 个玩家位移技能;所有模块仍默认关闭。";
|
||
}
|
||
});
|
||
network=new(game,gameVersion);
|
||
capture=new(network,modules,packets); capture.Register();
|
||
eventSubscription=network.Pipeline.Subscribe(NetworkPhase.Received,OnNetworkReceivedForEvents);
|
||
modules.Register(37,_ => { });
|
||
ValidationCatalog.Apply(modules,gameVersion);
|
||
Try(()=>
|
||
{
|
||
adaptationReport=game.EvaluateAdaptation(modules);
|
||
AdaptationManifest.ApplyGate(modules,adaptationReport);
|
||
});
|
||
Try(() => EnsureOpcodes());
|
||
serviceBar=dtrBar.Get("IC Rebuild · 高度与迷宫");
|
||
serviceBar.Shown=false;
|
||
serviceBar.OnClick=_=>Open();
|
||
commands.AddHandler("/icr",new CommandInfo(Command) { HelpMessage="IC Rebuild 设置。on/off 编号;stop;pos X Y Z;ground 技能ID [target|self];finish [事件ID];retainer;yes;no;select 索引;face 弧度;scale 倍率;export [文件名]。" });
|
||
pi.UiBuilder.Draw+=Draw; pi.UiBuilder.OpenConfigUi+=Open; pi.UiBuilder.OpenMainUi+=Open;
|
||
framework.Update+=Update;
|
||
}
|
||
private void Open() => open=true;
|
||
private void Update(IFramework _)
|
||
{
|
||
try
|
||
{
|
||
game.Refresh(modules.IsEnabled(10)||modules.IsEnabled(11));
|
||
game.DrainFaults(modules);
|
||
combat.Update();
|
||
network.PumpActiveSends();
|
||
if(config.FastEventFinish) Try(ProcessFastDialogAdvance);
|
||
if(config.AutoRetainerReventure) Try(ProcessRetainerAutomation);
|
||
}
|
||
catch(Exception ex) { modules.Disable(20); notice=ex.Message; log.Error(ex,"IC frame update"); }
|
||
if(modules.IsEnabled(11))
|
||
{
|
||
try { dungeon=game.ReadDungeon(); }
|
||
catch(Exception ex) { dungeon=DungeonSnapshot.Empty; game.Fault(11,ex); }
|
||
}
|
||
if(Environment.TickCount64-lastServiceUpdate>=200)
|
||
{
|
||
lastServiceUpdate=Environment.TickCount64;
|
||
Try(UpdateServiceBar);
|
||
}
|
||
if(config.AutoSaveCapture && modules.IsEnabled(36) && Environment.TickCount64-lastCaptureSave>=5000 && captureSave?.IsCompleted!=false)
|
||
{
|
||
lastCaptureSave=Environment.TickCount64;
|
||
var snapshot=packets.Snapshot();
|
||
if(snapshot.Length>0) captureSave=SaveCaptureAsync(DataPath(captureSessionFile),snapshot);
|
||
}
|
||
}
|
||
private void UpdateServiceBar()
|
||
{
|
||
var status=ServiceBarPresentation.Build(game.Frames.Read(),modules.Status[9].State,modules.Status[10].State,
|
||
modules.Status[11].State,config.Value(9),movement.Height.Applied);
|
||
if(status==lastServiceStatus) return;
|
||
serviceBar.Text=status.Text;
|
||
serviceBar.Tooltip=status.Tooltip;
|
||
serviceBar.Shown=status.Shown;
|
||
lastServiceStatus=status;
|
||
}
|
||
private void Save() { config.Normalize(); pi.SavePluginConfig(config); }
|
||
private void Toggle(int id,bool enabled)
|
||
{
|
||
if(enabled) { config.Requested.Add(id); modules.Enable(id); }
|
||
else { config.Requested.Remove(id); modules.Disable(id); }
|
||
foreach(var s in modules.Status.Values) if (!s.Requested) config.Requested.Remove(s.Feature.Id);
|
||
Save();
|
||
}
|
||
private void Try(Action action)
|
||
{
|
||
try { action(); }
|
||
catch(Exception ex) { notice=ex.Message; log.Warning(ex,"IC operation failed"); }
|
||
}
|
||
private static float Number(string value) => float.Parse(value,NumberStyles.Float,CultureInfo.InvariantCulture);
|
||
private void Command(string _,string arguments)
|
||
{
|
||
Try(() =>
|
||
{
|
||
var args=arguments.Split(' ',StringSplitOptions.RemoveEmptyEntries);
|
||
if(args.Length==0) { Open(); return; }
|
||
switch(args[0].ToLowerInvariant())
|
||
{
|
||
case "on" when args.Length==2: case "off" when args.Length==2:
|
||
var id=int.Parse(args[1],CultureInfo.InvariantCulture);
|
||
if(!modules.Status.ContainsKey(id)) throw new ArgumentException("编号不在首批范围");
|
||
Toggle(id,args[0].Equals("on",StringComparison.OrdinalIgnoreCase));
|
||
var state=modules.Status[id];
|
||
chat.Print($"IC {id}: {StatusPresentation.Summary(state)}{(StatusPresentation.ShowDetail(state) ? " · "+state.Detail : "")}"); break;
|
||
case "stop": modules.DisableAll(); config.Requested.Clear(); Save(); break;
|
||
case "pos" when args.Length==4: coordinates.Position(new(Number(args[1]),Number(args[2]),Number(args[3]))); break;
|
||
case "face" when args.Length==2: coordinates.Facing(Number(args[1])); break;
|
||
case "scale" when args.Length==2: coordinates.SetScale(Number(args[1])); break;
|
||
case "export" when args.Length<=2:
|
||
if(pending?.IsCompleted==false) throw new InvalidOperationException("已有文件操作正在运行");
|
||
var exportPath=DataPath(args.Length==2 ? args[1] : "packets.jsonl");
|
||
var exportRecords=packets.Snapshot();
|
||
pending=Background(()=>PacketFiles.ExportAtomic(exportPath,exportRecords));
|
||
chat.Print($"IC 开始导出 {exportRecords.Length} 条到 {exportPath}"); break;
|
||
case "ground" or "castpos" when args.Length>=2:
|
||
var actId=uint.Parse(args[1],CultureInfo.InvariantCulture);
|
||
var useTarget=args.Length<3 || !args[2].Equals("self",StringComparison.OrdinalIgnoreCase);
|
||
ExecuteGroundCast(actId,useTarget); break;
|
||
case "finish":
|
||
uint? evId=args.Length>=2 ? (args[1].StartsWith("0x",StringComparison.OrdinalIgnoreCase)
|
||
? uint.Parse(args[1][2..],NumberStyles.HexNumber)
|
||
: uint.Parse(args[1],CultureInfo.InvariantCulture)) : null;
|
||
ExecuteEventFinish(evId); break;
|
||
case "retainer" or "reventure":
|
||
ProcessRetainerAutomation();
|
||
chat.Print("[IC Rebuild] 已尝试一键处理当前雇员探险窗口"); break;
|
||
case "yes":
|
||
ConfirmYesno(true); break;
|
||
case "no":
|
||
ConfirmYesno(false); break;
|
||
case "select" or "choose" when args.Length==2:
|
||
var optIndex=int.Parse(args[1],CultureInfo.InvariantCulture);
|
||
SelectDialogOption(optIndex); break;
|
||
default: Open(); notice="用法:/icr on|off 编号;stop;pos X Y Z;ground 技能ID [target|self];finish [事件ID];retainer;yes;no;select 索引"; break;
|
||
}
|
||
});
|
||
}
|
||
private unsafe void Draw()
|
||
{
|
||
if(!open) return;
|
||
ImGui.SetNextWindowSize(new(790,650),ImGuiCond.FirstUseEver);
|
||
if(ImGui.Begin("IC Rebuild##ICRebuild",ref open))
|
||
{
|
||
var region = (int)game.Client.ClientLanguage == 4 || game.Client.ClientLanguage.ToString().Contains("Chinese") ? "国服" : "国际服";
|
||
ImGui.TextWrapped($"IC Rebuild 0.1.16 · {region} · API 15 · 客户端 {gameVersion}");
|
||
if(ImGui.Button("关闭全部模块")) { modules.DisableAll(); config.Requested.Clear(); Try(Save); }
|
||
ImGui.TextWrapped(notice);
|
||
foreach(var group in Features.All.GroupBy(x=>x.Group))
|
||
{
|
||
if(!ImGui.CollapsingHeader(group.Key)) continue;
|
||
foreach(var feature in group)
|
||
{
|
||
ImGui.PushID(feature.Id);
|
||
var enabled=modules.IsEnabled(feature.Id);
|
||
if(ImGui.Checkbox($"{feature.Id} · {feature.Name}",ref enabled)) Try(()=>Toggle(feature.Id,enabled));
|
||
var status=modules.Status[feature.Id];
|
||
ImGui.TextWrapped(StatusPresentation.Summary(status));
|
||
if(StatusPresentation.ShowDetail(status)) ImGui.TextWrapped(status.Detail);
|
||
if(feature.Min!=feature.Max)
|
||
{
|
||
var value=config.Value(feature.Id);
|
||
if(sliders.Float(feature.Id==9 ? "预设高度偏移" : "参数",ref value,feature.Min,feature.Max)) { config.Values[feature.Id]=value; Try(Save); }
|
||
}
|
||
if(feature.Id==4)
|
||
{
|
||
if(ImGui.SmallButton("一键完全防击退 (距离与持续时间归零)##antiKnockZero"))
|
||
{
|
||
config.Values[4]=0; config.KnockSeconds=0; Try(Save);
|
||
}
|
||
}
|
||
if(feature.Id==8) DrawStatusBlock();
|
||
if(feature.Id==9)
|
||
{
|
||
ImGui.BeginDisabled(!modules.IsEnabled(9));
|
||
if(ImGui.Button(movement.Height.IsApplied ? "回到 0" : "生效"))
|
||
movement.Height.Toggle(modules.IsEnabled(9),config.Value(9));
|
||
ImGui.EndDisabled();
|
||
ImGui.SameLine(); ImGui.Text($"已应用:{movement.Height.Applied:F3} · 当前偏移:{movement.EffectiveHeight:F3}");
|
||
ImGui.TextWrapped("勾选后先设置参数,再点击生效。修改预设不会自动应用;回到 0 会取消偏移,位置同步后恢复原始高度。");
|
||
}
|
||
if(feature.Id==14)
|
||
{
|
||
var frame=game.Frames.Read(); var observed=combat.LastTargetRadius;
|
||
ImGui.TextWrapped(observed!=null && frame.Ready && observed.EntityId==frame.TargetEntityId
|
||
? $"当前目标原生半径 {observed.Original:F3} → {observed.Effective:F3};自身半径不增加。"
|
||
: "等待当前目标的原生半径读取。");
|
||
ImGui.TextWrapped($"目标修改 {combat.TargetRadiusChanges} · 原生圈补偿 {combat.NativeRingBonuses} · 距离检查命中 {combat.RadiusBonuses}");
|
||
}
|
||
if(feature.Id==18)
|
||
{
|
||
ImGui.TextWrapped("结印技能(天之印、地之印、人之印)复唱归零,结印无需等待 GCD。依赖模块 17。");
|
||
}
|
||
if(feature.Id==20)
|
||
{
|
||
var pvpState=game.IsPvP ? "是 (生效中)" : "否 (仅PvP区域生效)";
|
||
var mgr=FFXIVClientStructs.FFXIV.Client.Game.ActionManager.Instance();
|
||
var currentLock=mgr!=null ? mgr->AnimationLock : 0f;
|
||
ImGui.Text($"PvP 区域:【{pvpState}】 · 当前动画锁:{currentLock:F3}s");
|
||
ImGui.Text("快速预设:"); ImGui.SameLine();
|
||
if(ImGui.SmallButton("极限 0.5s")) { config.Values[20]=0.5f; Try(Save); }
|
||
ImGui.SameLine();
|
||
if(ImGui.SmallButton("平滑 0.6s")) { config.Values[20]=0.6f; Try(Save); }
|
||
ImGui.SameLine();
|
||
if(ImGui.SmallButton("默认 0.7s")) { config.Values[20]=0.7f; Try(Save); }
|
||
}
|
||
ImGui.PopID();
|
||
}
|
||
switch(group.Key)
|
||
{
|
||
case "移动":
|
||
ImGui.TextWrapped("7 拦截本地角色的指定强制移动路径,不删除状态,也不等于解除所有眩晕或禁锢。");
|
||
var knock=config.KnockSeconds;
|
||
if(sliders.Float("击退持续秒数",ref knock,0,5)) { config.KnockSeconds=knock; Try(Save); } break;
|
||
case "深层迷宫":
|
||
if(modules.IsEnabled(11)) ImGui.TextWrapped(dungeon.Description);
|
||
ImGui.TextWrapped("专用模式须先开启飞天遁地。特殊层停止高度偏移;未知迷宫状态不应用专用模式偏移。"); break;
|
||
case "战斗": DrawTimelines(); break;
|
||
case "坐标": DrawCoordinates(); break;
|
||
case "网络": DrawNetwork(); break;
|
||
}
|
||
}
|
||
}
|
||
ImGui.End();
|
||
}
|
||
private void DrawStatusBlock()
|
||
{
|
||
ImGui.TextWrapped($"已选 {config.BlockedStatuses.Count} 项 · 本次启用拦截 {statusBlock.Blocked} 次 · 最近状态 ID {statusBlock.LastStatus}");
|
||
ImGui.TextWrapped("仅屏蔽本地角色后续收到的已选状态更新;开启前已有状态须等下一次更新。默认选择 1257 强制移动,空名单不屏蔽。录像回放暂停处理。");
|
||
ImGui.TextWrapped("关闭后恢复正常接收,已屏蔽状态须等服务端再次同步。本地处理不代表服务端状态被删除;实际移动或机制效果待实测。");
|
||
if(!ImGui.CollapsingHeader("状态名单管理(分类预设与搜索)")) return;
|
||
|
||
ImGui.Text("快捷预设:");
|
||
ImGui.SameLine();
|
||
if(ImGui.SmallButton("全选滑冰##selSlippery"))
|
||
{
|
||
foreach(var id in StatusCatalog.LegacySlipperyCandidates.Where(StatusCatalog.AuditedBlockCandidates.Contains))
|
||
config.BlockedStatuses.Add(id);
|
||
Try(Save);
|
||
}
|
||
ImGui.SameLine();
|
||
if(ImGui.SmallButton("全选强制移动##selMarch"))
|
||
{
|
||
foreach(var id in StatusCatalog.ForcedMarch.Where(StatusCatalog.AuditedBlockCandidates.Contains))
|
||
config.BlockedStatuses.Add(id);
|
||
Try(Save);
|
||
}
|
||
ImGui.SameLine();
|
||
if(ImGui.SmallButton("全选混乱##selConf"))
|
||
{
|
||
foreach(var id in StatusCatalog.Confusion.Where(StatusCatalog.AuditedBlockCandidates.Contains))
|
||
config.BlockedStatuses.Add(id);
|
||
Try(Save);
|
||
}
|
||
ImGui.SameLine();
|
||
if(ImGui.SmallButton("全选全部候选##selAll"))
|
||
{
|
||
foreach(var id in StatusCatalog.AuditedBlockCandidates)
|
||
config.BlockedStatuses.Add(id);
|
||
Try(Save);
|
||
}
|
||
ImGui.SameLine();
|
||
if(ImGui.SmallButton("清空已选##clearStatus"))
|
||
{
|
||
config.BlockedStatuses.Clear();
|
||
Try(Save);
|
||
}
|
||
|
||
ImGui.InputText("搜索状态名称或 ID##statusSearch",ref statusFilterText,64);
|
||
|
||
var sheet=game.Data.GetExcelSheet<Lumina.Excel.Sheets.Status>();
|
||
foreach(var id in StatusCatalog.AuditedBlockCandidates.Order())
|
||
{
|
||
var name=sheet?.GetRowOrDefault(id)?.Name.ToString() ?? "未知状态";
|
||
if(!string.IsNullOrWhiteSpace(statusFilterText))
|
||
{
|
||
if(!id.ToString().Contains(statusFilterText,StringComparison.OrdinalIgnoreCase) &&
|
||
!name.Contains(statusFilterText,StringComparison.OrdinalIgnoreCase))
|
||
continue;
|
||
}
|
||
var selected=config.BlockedStatuses.Contains(id);
|
||
if(ImGui.Checkbox($"{id} · {name}##status{id}",ref selected))
|
||
{
|
||
if(selected) config.BlockedStatuses.Add(id); else config.BlockedStatuses.Remove(id);
|
||
Try(Save);
|
||
}
|
||
}
|
||
}
|
||
private void DrawTimelines()
|
||
{
|
||
ImGui.TextWrapped("14 只扩大当前目标的原生半径,显示、距离与突进落点保持一致。突进应停在扩大后的目标圈边缘附近;自身半径不增加,不叠加为技能射程。");
|
||
ImGui.TextWrapped($"14 前置检查 {combat.RadiusPrechecks} · 最终检查 {combat.RadiusFinalChecks} · 距离补偿 {combat.RadiusBonuses}");
|
||
ImGui.TextWrapped($"14 原生圈更新 {combat.NativeRingCalls} · 绘制半径补偿 {combat.NativeRingBonuses}");
|
||
ImGui.TextWrapped($"14 目标半径修改 {combat.TargetRadiusChanges} · 自身半径保持原值 {combat.SelfRadiusSkips}");
|
||
if(!ImGui.CollapsingHeader($"16 · Timeline 名单管理({config.BlockedTimelines.Count} 项)###timeline-manager")) return;
|
||
var mode=(int)(config.DashMode ?? DashFilterMode.AllLocal);
|
||
if(ImGui.Combo("16 位移屏蔽模式",ref mode,"全部本地技能位移\0仅屏蔽名单内\0仅放行名单内\0")) { config.DashMode=(DashFilterMode)mode; Try(Save); }
|
||
ImGui.TextWrapped("已按当前动作表预填玩家位移技能,默认仅屏蔽名单内。也可切换全部本地技能位移。空名单在名单模式下不屏蔽。");
|
||
if(ImGui.Button("加入全部位移技能")) Try(()=>
|
||
{
|
||
var added=combat.AddAllMovementSkills(); Save();
|
||
notice=$"已加入 {added} 个新位移编号,名单共有 {config.BlockedTimelines.Count} 个编号";
|
||
});
|
||
var unmapped=combat.MovementSkills.Where(s=>!MovementSkillCatalog.Mappable(s)).ToArray();
|
||
ImGui.TextWrapped($"动作表:{combat.MovementSkills.Length} 个玩家位移技能,{unmapped.Length} 个没有有效映射;共享位移编号的技能会联动。");
|
||
if(ImGui.CollapsingHeader("查看位移技能名单"))
|
||
{
|
||
foreach(var skill in combat.MovementSkills)
|
||
{
|
||
if(!MovementSkillCatalog.Mappable(skill)) { ImGui.TextWrapped($"未映射:{skill.Name} ({skill.ActionId})"); continue; }
|
||
var selected=config.BlockedTimelines.Contains(skill.Timeline);
|
||
if(ImGui.Checkbox($"{skill.Name} · {(skill.PvP ? "PvP" : "PvE")} · ID {skill.ActionId}##movement{skill.ActionId}",ref selected))
|
||
{
|
||
if(selected) config.BlockedTimelines.Add(skill.Timeline); else config.BlockedTimelines.Remove(skill.Timeline);
|
||
Try(Save);
|
||
}
|
||
}
|
||
}
|
||
ImGui.InputInt("技能 ID(转换到名单)",ref actionIdForTimeline);
|
||
if(ImGui.Button("添加技能到名单")) Try(()=>
|
||
{
|
||
if(actionIdForTimeline<=0) throw new ArgumentException("请输入有效技能 ID");
|
||
var action=game.Data.GetExcelSheet<Lumina.Excel.Sheets.Action>().GetRowOrDefault((uint)actionIdForTimeline);
|
||
if(action==null || action.Value.AnimationEnd.RowId==0 || action.Value.AnimationEnd.RowId>ushort.MaxValue) throw new ArgumentException("技能没有可用的结束 Timeline");
|
||
config.BlockedTimelines.Add(action.Value.AnimationEnd.RowId); Save();
|
||
notice=$"已添加 {action.Value.Name} → Timeline {action.Value.AnimationEnd.RowId}";
|
||
});
|
||
if(ImGui.CollapsingHeader($"手动 Timeline 编号({config.BlockedTimelines.Count} 项)###timeline-raw"))
|
||
{
|
||
ImGui.InputInt("Timeline ID",ref timeline);
|
||
if(ImGui.Button("添加 Timeline") && timeline is >0 and <=ushort.MaxValue) { config.BlockedTimelines.Add((uint)timeline); Try(Save); }
|
||
foreach(var item in config.BlockedTimelines.Order().ToArray())
|
||
{ ImGui.Text(item.ToString()); ImGui.SameLine(); if(ImGui.SmallButton($"删除##timeline{item}")) { config.BlockedTimelines.Remove(item); Try(Save); } }
|
||
}
|
||
ImGui.Text($"16 本地位移调用 {combat.DashCalls} · 已拦截 {combat.DashBlocks}");
|
||
var last=combat.LastDash;
|
||
if(last!=null) ImGui.TextWrapped($"最近 Timeline {last.Timeline} · 类型 {last.MoveType} · 标志 {last.Flags2:X2}/{last.Flags3:X2} · {(last.Blocked ? "已拦截" : "已放行")}");
|
||
}
|
||
private void DrawCoordinates()
|
||
{
|
||
ImGui.InputFloat3("坐标 X Y Z",ref position);
|
||
if(ImGui.Button("读取自身位置") && game.Objects.LocalPlayer!=null) position=game.Objects.LocalPlayer.Position;
|
||
ImGui.SameLine(); if(ImGui.Button("应用坐标")) Try(()=>coordinates.Position(position));
|
||
ImGui.InputFloat("面向弧度",ref rotation);
|
||
if(ImGui.Button("应用面向")) Try(()=>coordinates.Facing(rotation));
|
||
if(ImGui.Button("应用目标缩放")) Try(()=>coordinates.SetScale(config.Value(29)));
|
||
ImGui.SameLine(); if(ImGui.Button("恢复修改过的缩放")) Try(coordinates.RestoreAll);
|
||
ImGui.SameLine(); if(ImGui.Button("目标缩放设为 1")) Try(()=>coordinates.SetScale(1));
|
||
ImGui.TextWrapped("坐标和面向是一次性操作,关闭模块不会把角色移回原地。缩放设为 1 是显式操作,不代表所有对象原始缩放均为 1。");
|
||
}
|
||
private void DrawNetwork()
|
||
{
|
||
ImGui.TextWrapped($"记录目录:{directory}");
|
||
ImGui.TextWrapped($"当前 {packets.Snapshot().Length} 条,丢弃 {packets.Dropped} 条,采集异常 {capture.Errors} 次。");
|
||
ImGui.TextWrapped($"缓冲载荷 {packets.PayloadBytes/1024.0:F1} KiB / {packets.PayloadByteLimit/1024/1024} MiB;最多 2048 条,超限移除最旧记录。");
|
||
ImGui.TextWrapped($"本次加载累计:完整上行 {capture.CompleteUp} · 完整下行 {capture.CompleteDown} · 仅元数据 {capture.MetadataOnly}。");
|
||
ImGui.TextWrapped("按消息长度复制游戏载荷(不含 IPC 头),单包上限 64 KiB。当前版本首轮样本已验证完整上下行;读取失败不标记完整,不采集聊天/登录通道。上行入口记录不代表服务端已收到。");
|
||
ImGui.TextWrapped($"最近采集问题:{capture.LastIssue}");
|
||
var manager=network.Pipeline;
|
||
ImGui.TextWrapped($"收发管理器:订阅 {manager.SubscriberCount} · 收包 {manager.Received} · 发送入口 {manager.BeforeSend} · 发送返回 {manager.AfterSend} · 订阅异常 {manager.ObserverErrors}");
|
||
ImGui.TextWrapped($"最近原生发送返回码:{(manager.LastSendResult<0 ? "无" : manager.LastSendResult.ToString())}(不代表服务端确认)。暂停与过滤仅影响日志,管理器观察继续。");
|
||
DrawActiveSendSection();
|
||
var autoSave=config.AutoSaveCapture;
|
||
if(ImGui.Checkbox("每 5 秒自动保存最近 2048 条记录",ref autoSave)) { config.AutoSaveCapture=autoSave; Try(Save); }
|
||
ImGui.TextWrapped($"自动保存:{captureSaveStatus} · {captureSessionFile}");
|
||
var paused=packets.Paused;
|
||
if(ImGui.Checkbox("暂停记录",ref paused)) packets.Paused=paused;
|
||
ImGui.InputText("文件名",ref file,128);
|
||
if(ImGui.Button("导入离线记录")) Try(()=>
|
||
{
|
||
var imported=PacketFiles.Import(DataPath(file));
|
||
packets.Replace(imported);
|
||
notice=$"已导入 {imported.Length} 条,保留原时间、方向、版本及序号";
|
||
});
|
||
ImGui.SameLine(); if(ImGui.Button("导出记录") && pending?.IsCompleted!=false)
|
||
{ var exportRecords=packets.Snapshot(); Try(()=>{ var path=DataPath(file); pending=Background(()=>PacketFiles.ExportAtomic(path,exportRecords)); }); }
|
||
ImGui.SameLine(); if(ImGui.Button("清空记录")) packets.Clear();
|
||
if(ImGui.Button("载入本地 opcodes.json")) Try(()=>{ EnsureOpcodes(true); notice=$"已成功载入 {gameVersion} Opcode 映射({opcodes.Snapshot.Source})"; });
|
||
ImGui.SameLine(); if(ImGui.Button("更新在线 Opcode") && pending?.IsCompleted!=false) pending=UpdateOpcodes();
|
||
ImGui.TextWrapped($"映射版本:{opcodes.Snapshot.GameVersion},来源:{opcodes.Snapshot.Source}");
|
||
var url=config.OpcodeUrl;
|
||
if(ImGui.InputText("Opcode HTTPS 地址",ref url,1024)) { config.OpcodeUrl=url; Try(Save); }
|
||
ImGui.Combo("方向过滤",ref packetDirection,"全部\0上行\0下行\0");
|
||
ImGui.InputInt("Opcode 过滤(-1 为全部)",ref packetOpcode);
|
||
packets.DirectionFilter=packetDirection==0 ? null : (PacketDirection)(packetDirection-1);
|
||
packets.OpcodeFilter=packetOpcode<0 ? null : packetOpcode;
|
||
var snapshot=packets.Snapshot();
|
||
if(modules.IsEnabled(37))
|
||
{
|
||
foreach(var pair in PacketCorrelator.Ping(snapshot,opcodes).TakeLast(5))
|
||
ImGui.Text($"Ping {pair.Request} → {pair.Response}: {pair.Elapsed.TotalMilliseconds:F1} ms");
|
||
foreach(var pair in PacketCorrelator.Actions(snapshot,opcodes).TakeLast(5))
|
||
ImGui.Text($"动作 {pair.Request} → {pair.Response}: {pair.Elapsed.TotalMilliseconds:F1} ms");
|
||
}
|
||
foreach(var p in snapshot.Where(p=>(packetOpcode<0 || p.Opcode==packetOpcode) && (packetDirection==0 || p.Direction==(PacketDirection)(packetDirection-1))).TakeLast(50))
|
||
{
|
||
var name=modules.IsEnabled(37) ? opcodes.Resolve(p.GameVersion,p.Direction,p.Opcode) : null;
|
||
var summary=p.PayloadComplete ? PacketParser.Parse(name,p.Payload,p.GameVersion).Summary : $"{name ?? "未知 Opcode"} · 仅元数据:{p.CaptureIssue ?? "载荷长度未知"}";
|
||
var origin=p.Context?.Origin==PacketOrigin.PluginActiveSend ? "插件主动发送" : "Zone 原始载荷";
|
||
var context=p.Context is {} c ? $"{origin} · {c.Phase} · 观察 {c.ObservationId}" : "旧记录,处理阶段未知";
|
||
ImGui.TextWrapped($"{p.Sequence} {p.Time:HH:mm:ss.fff} {p.Direction} 0x{p.Opcode:X4} ({p.Payload.Length} 字节已采集) {context} · {summary}");
|
||
}
|
||
}
|
||
private void DrawActiveSendSection()
|
||
{
|
||
if(!ImGui.CollapsingHeader("主动发送与业务扩展 (M4)")) return;
|
||
var session=game.ReadSendSession();
|
||
var dispatcher=network.ActiveSender;
|
||
ImGui.TextWrapped($"原生发送通道:{(network.CanSendNative ? "已就绪 (ReceiverSendPacket + ZoneUp)" : "未就绪(需开启 36 或完成绑定)")} · 队列 {dispatcher.PendingQueueCount} · 等待响应 {dispatcher.InFlightCount}");
|
||
ImGui.TextWrapped($"发送会话:{(session.IsValid ? $"就绪 · 区域 {session.Territory} · Entity 0x{session.LocalEntityId:X8}" : "未就绪(需角色在场且非过图/占卜回放)")}");
|
||
if(opcodes.Snapshot.GameVersion!=gameVersion)
|
||
ImGui.TextColored(new Vector4(1,0.4f,0.4f,1),$"提示:Opcode 映射尚未就绪(当前为 {opcodes.Snapshot.GameVersion});发送时将自动载入");
|
||
|
||
if(ImGui.CollapsingHeader("1. 地面技能智能释放 (Smart Ground Cast)", ImGuiTreeNodeFlags.DefaultOpen))
|
||
{
|
||
ImGui.TextWrapped("无需鼠标框选瞄准,通过 UP_ActionSendPos 瞬间以当前目标或自身坐标为圆心释放地面区域技能。");
|
||
var smartTarget = config.SmartGroundCastAtTarget;
|
||
if (ImGui.Checkbox("优先释放在当前目标位置(若无目标则释放在脚下)##smartGroundTarget", ref smartTarget))
|
||
{
|
||
config.SmartGroundCastAtTarget = smartTarget;
|
||
Try(Save);
|
||
}
|
||
ImGui.InputInt("地面技能 ID##smartGroundAction", ref smartGroundActionId);
|
||
if (ImGui.Button("释放地面技能 (UP_ActionSendPos)")) Try(() =>
|
||
{
|
||
if (smartGroundActionId <= 0) throw new ArgumentException("请输入有效技能 ID");
|
||
ExecuteGroundCast((uint)smartGroundActionId, config.SmartGroundCastAtTarget);
|
||
});
|
||
ImGui.SameLine();
|
||
ImGui.Text("常用预设:");
|
||
ImGui.SameLine();
|
||
if (ImGui.SmallButton("野战治疗阵 (188)")) Try(() => { smartGroundActionId = 188; ExecuteGroundCast(188, config.SmartGroundCastAtTarget); });
|
||
ImGui.SameLine();
|
||
if (ImGui.SmallButton("庇护所 (3569)")) Try(() => { smartGroundActionId = 3569; ExecuteGroundCast(3569, config.SmartGroundCastAtTarget); });
|
||
ImGui.SameLine();
|
||
if (ImGui.SmallButton("地星 (7439)")) Try(() => { smartGroundActionId = 7439; ExecuteGroundCast(7439, config.SmartGroundCastAtTarget); });
|
||
ImGui.SameLine();
|
||
if (ImGui.SmallButton("土遁 (2260)")) Try(() => { smartGroundActionId = 2260; ExecuteGroundCast(2260, config.SmartGroundCastAtTarget); });
|
||
ImGui.SameLine();
|
||
if (ImGui.SmallButton("铃兰 (25862)")) Try(() => { smartGroundActionId = 25862; ExecuteGroundCast(25862, config.SmartGroundCastAtTarget); });
|
||
}
|
||
|
||
if(ImGui.CollapsingHeader("2. 交互/剧情极速跳过与开箱 (Fast Event Finish / Interact)", ImGuiTreeNodeFlags.DefaultOpen))
|
||
{
|
||
ImGui.TextWrapped("支持自动极速快进剧情与NPC对白 Talk、自动确认 Yes/No 提示框,以及手动发包立即完成开箱/门锁事件。");
|
||
var fastEv = config.FastEventFinish;
|
||
if (ImGui.Checkbox("自动快进剧情与NPC对话 (Fast Talk)##fastEventFinish", ref fastEv))
|
||
{
|
||
config.FastEventFinish = fastEv;
|
||
Try(Save);
|
||
}
|
||
ImGui.SameLine();
|
||
var fastYes = config.FastSelectYesno;
|
||
if (ImGui.Checkbox("自动确认 Yes/No 提示框 (FastSelectYesno)##fastYesno", ref fastYes))
|
||
{
|
||
config.FastSelectYesno = fastYes;
|
||
Try(Save);
|
||
}
|
||
var evTimeStr = lastEventTime > 0 ? $"{(Environment.TickCount64 - lastEventTime) / 1000} 秒前" : "无";
|
||
ImGui.TextWrapped($"最近捕获事件:ID 0x{lastEventId:X8} · 目标 0x{lastEventTargetId:X8} · 参数 0x{lastEventParam:X8} · 时间 {evTimeStr}");
|
||
if (ImGui.Button("立即完成当前事件 (UP_EventFinish)")) Try(() => ExecuteEventFinish());
|
||
ImGui.SameLine();
|
||
if (ImGui.Button("与当前目标发起事件 (UP_EventStart)")) Try(() =>
|
||
{
|
||
EnsureOpcodes();
|
||
var targetId = game.Targets.Target?.EntityId is > 0 and var tid ? (ulong)tid : 0xE0000000;
|
||
if (targetId == 0xE0000000) throw new ArgumentException("请先选中一个可交互的目标");
|
||
var evId = lastEventId != 0 ? lastEventId : 1;
|
||
var packet = AuditedPacketEncoder.EncodeEventStart(opcodes, gameVersion, targetId, evId);
|
||
var tx = network.EnqueueSend(packet);
|
||
notice = $"已发送事件发起 #{tx.Id}:目标 0x{targetId:X8} 事件 0x{evId:X8}";
|
||
});
|
||
}
|
||
|
||
if(ImGui.CollapsingHeader("3. 生活自动化:雇员一键重派探险 (Retainer Re-Venture)", ImGuiTreeNodeFlags.DefaultOpen))
|
||
{
|
||
ImGui.TextWrapped("打开雇员探险结算界面时,自动或一键触发【再次派遣】与【确认】,省去重复繁琐的鼠标点击。");
|
||
var autoRet = config.AutoRetainerReventure;
|
||
if (ImGui.Checkbox("自动重派雇员探险 (AutoRetainerReventure)##autoRetainer", ref autoRet))
|
||
{
|
||
config.AutoRetainerReventure = autoRet;
|
||
Try(Save);
|
||
}
|
||
if (ImGui.Button("一键重派当前雇员探险窗口")) Try(() =>
|
||
{
|
||
ProcessRetainerAutomation();
|
||
notice = "已尝试处理当前雇员探险窗口";
|
||
});
|
||
ImGui.SameLine();
|
||
ImGui.TextColored(new Vector4(0.4f, 1, 0.4f, 1), "状态:安全防抖 450ms 原生调用");
|
||
}
|
||
|
||
if(ImGui.CollapsingHeader("基础指令与发包诊断"))
|
||
{
|
||
ImGui.InputInt("测试技能 ID (UP_ActionSend)",ref manualSendActionId);
|
||
ImGui.InputInt("请求序号 RequestId",ref manualSendRequestId);
|
||
if(ImGui.Button("加入技能发送队列 (当前目标)")) Try(()=>
|
||
{
|
||
EnsureOpcodes();
|
||
if(manualSendActionId<=0) throw new ArgumentException("请输入有效技能 ID");
|
||
if(manualSendRequestId is <=0 or >ushort.MaxValue) manualSendRequestId=1;
|
||
var targetId=game.Targets.Target?.EntityId is >0 and var tid ? (ulong)tid : session.LocalEntityId;
|
||
var packet=AuditedPacketEncoder.EncodeActionSend(opcodes,gameVersion,(uint)manualSendActionId,1,(ushort)manualSendRequestId,targetId);
|
||
var tx=network.EnqueueSend(packet);
|
||
manualSendRequestId=manualSendRequestId>=ushort.MaxValue ? 1 : manualSendRequestId+1;
|
||
notice=$"已加入发送事务 #{tx.Id}:{packet.Name} (0x{packet.Opcode:X4})";
|
||
});
|
||
ImGui.SameLine();
|
||
if(ImGui.Button("加入 ClientTrigger 队列")) Try(()=>
|
||
{
|
||
EnsureOpcodes();
|
||
if(manualTriggerCommand<=0 || manualTriggerCommand>ushort.MaxValue) throw new ArgumentException("指令编号超出范围");
|
||
var packet=AuditedPacketEncoder.EncodeClientTrigger(opcodes,gameVersion,(uint)manualTriggerCommand,(uint)Math.Max(0,manualTriggerParam1));
|
||
var tx=network.EnqueueSend(packet);
|
||
notice=$"已加入发送事务 #{tx.Id}:{packet.Name} (0x{packet.Opcode:X4})";
|
||
});
|
||
ImGui.SameLine();
|
||
if(ImGui.Button("清空待发队列"))
|
||
{
|
||
var canceled=dispatcher.CancelAllQueued("用户手动清空队列");
|
||
notice=$"已取消 {canceled} 条待发事务";
|
||
}
|
||
ImGui.InputInt("Trigger 指令 (ushort)",ref manualTriggerCommand);
|
||
ImGui.InputInt("Trigger 参数 1 (uint)",ref manualTriggerParam1);
|
||
}
|
||
|
||
foreach(var tx in dispatcher.Snapshot().TakeLast(10))
|
||
{
|
||
var rtt=tx.RoundTripMs is {} d ? $" · RTT {d} ms (下行 #{tx.ResponseObservationId})" : "";
|
||
var obs=tx.NativeResult is {} nr ? $" · 原生返回 {nr}" : "";
|
||
ImGui.TextWrapped($"事务 #{tx.Id} {tx.PacketName} (0x{tx.Opcode:X4}) [{tx.State}]{obs}{rtt} · {tx.Detail}");
|
||
}
|
||
}
|
||
private void OnNetworkReceivedForEvents(NetworkObservation obs)
|
||
{
|
||
var msg=obs.Message;
|
||
if(!msg.Complete || msg.Direction!=PacketDirection.Down) return;
|
||
var downEventStartOpcode=opcodes.ResolveOpcode(gameVersion,PacketDirection.Down,"DOWN_EventStart");
|
||
if(downEventStartOpcode.HasValue && msg.Opcode==downEventStartOpcode.Value && msg.Payload.Length>=0x10)
|
||
{
|
||
lastEventTargetId=System.Buffers.Binary.BinaryPrimitives.ReadUInt64LittleEndian(msg.Payload[..8]);
|
||
lastEventId=System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(msg.Payload.Slice(8,4));
|
||
lastEventParam=System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(msg.Payload.Slice(12,4));
|
||
lastEventTime=Environment.TickCount64;
|
||
}
|
||
}
|
||
private unsafe void ExecuteGroundCast(uint actionId,bool atTarget)
|
||
{
|
||
if(actionId==0) throw new ArgumentException("请输入有效地面技能 ID");
|
||
var player=game.Objects.LocalPlayer;
|
||
if(player==null) throw new InvalidOperationException("本地角色未就绪");
|
||
Vector3 targetPos=atTarget && game.Targets.Target is { } target ? target.Position : player.Position;
|
||
var destStr=atTarget && game.Targets.Target!=null ? $"目标【{game.Targets.Target.Name}】" : "自身脚下";
|
||
|
||
var am=FFXIVClientStructs.FFXIV.Client.Game.ActionManager.Instance();
|
||
if(am!=null)
|
||
{
|
||
var loc=targetPos;
|
||
var success=am->UseActionLocation(FFXIVClientStructs.FFXIV.Client.Game.ActionType.Action,actionId,0xE000_0000,&loc);
|
||
if(success)
|
||
{
|
||
notice=$"地面技能 {actionId} 已智能释放至 {destStr} ({targetPos.X:F1}, {targetPos.Y:F1}, {targetPos.Z:F1})";
|
||
chat.Print($"[IC Rebuild] 地面技能 {actionId} 已释放至 {destStr}");
|
||
return;
|
||
}
|
||
}
|
||
|
||
EnsureOpcodes();
|
||
var session=game.ReadSendSession();
|
||
if(!session.IsValid) throw new InvalidOperationException("当前角色或区域会话未就绪");
|
||
ushort facing=(ushort)((player.Rotation+MathF.PI)/(MathF.PI*2f)*ushort.MaxValue);
|
||
manualSendRequestId=manualSendRequestId>=ushort.MaxValue ? 1 : manualSendRequestId+1;
|
||
var packet=AuditedPacketEncoder.EncodeActionSendPos(opcodes,gameVersion,actionId,1,(ushort)manualSendRequestId,targetPos,facing);
|
||
var tx=network.EnqueueSend(packet);
|
||
notice=$"已加入地面技能发包事务 #{tx.Id}:技能 {actionId} -> {destStr} ({targetPos.X:F1}, {targetPos.Y:F1}, {targetPos.Z:F1})";
|
||
chat.Print($"[IC Rebuild] 地面技能 {actionId} 已排队发包至 {destStr}");
|
||
}
|
||
private void ExecuteEventFinish(uint? explicitEventId=null)
|
||
{
|
||
EnsureOpcodes();
|
||
var targetEventId=explicitEventId ?? lastEventId;
|
||
if(targetEventId==0) throw new InvalidOperationException("当前无可用事件 ID,请先触发交互或传入目标事件 ID");
|
||
var packet=AuditedPacketEncoder.EncodeEventFinish(opcodes,gameVersion,targetEventId,0,0,ReadOnlySpan<uint>.Empty);
|
||
var tx=network.EnqueueSend(packet);
|
||
notice=$"已加入事件完成事务 #{tx.Id}:事件 ID 0x{targetEventId:X8}";
|
||
chat.Print($"[IC Rebuild] 立即完成事件 0x{targetEventId:X8} 已排队发送");
|
||
}
|
||
private unsafe void ProcessFastDialogAdvance()
|
||
{
|
||
var mgr=FFXIVClientStructs.FFXIV.Client.UI.RaptureAtkUnitManager.Instance();
|
||
if(mgr==null) return;
|
||
var now=Environment.TickCount64;
|
||
if(now-lastTalkAdvanceTime>=200)
|
||
{
|
||
var talk=mgr->GetAddonByName("Talk",1);
|
||
if(talk!=null && talk->IsVisible && talk->IsReady)
|
||
{
|
||
var val=stackalloc FFXIVClientStructs.FFXIV.Component.GUI.AtkValue[1];
|
||
val[0].SetInt(0);
|
||
talk->FireCallback(1,val,true);
|
||
lastTalkAdvanceTime=now;
|
||
}
|
||
}
|
||
if(config.FastSelectYesno && now-lastYesnoTime>=300)
|
||
{
|
||
var yesno=mgr->GetAddonByName("SelectYesno",1);
|
||
if(yesno!=null && yesno->IsVisible && yesno->IsReady)
|
||
{
|
||
var val=stackalloc FFXIVClientStructs.FFXIV.Component.GUI.AtkValue[1];
|
||
val[0].SetInt(0);
|
||
yesno->FireCallback(1,val,true);
|
||
lastYesnoTime=now;
|
||
notice="已自动确认【SelectYesno】提示框";
|
||
}
|
||
}
|
||
}
|
||
private unsafe void ConfirmYesno(bool yes)
|
||
{
|
||
var mgr=FFXIVClientStructs.FFXIV.Client.UI.RaptureAtkUnitManager.Instance();
|
||
if(mgr==null) return;
|
||
var yesno=mgr->GetAddonByName("SelectYesno",1);
|
||
if(yesno!=null && yesno->IsVisible && yesno->IsReady)
|
||
{
|
||
var val=stackalloc FFXIVClientStructs.FFXIV.Component.GUI.AtkValue[1];
|
||
val[0].SetInt(yes ? 0 : 1);
|
||
yesno->FireCallback(1,val,true);
|
||
chat.Print($"[IC Rebuild] 已手动{(yes ? "确认(Yes)" : "取消(No)")}当前提示框");
|
||
}
|
||
else chat.Print("[IC Rebuild] 当前无可见的 Yes/No 提示框");
|
||
}
|
||
private unsafe void SelectDialogOption(int index)
|
||
{
|
||
var mgr=FFXIVClientStructs.FFXIV.Client.UI.RaptureAtkUnitManager.Instance();
|
||
if(mgr==null) return;
|
||
var selectString=mgr->GetAddonByName("SelectString",1);
|
||
if(selectString!=null && selectString->IsVisible && selectString->IsReady)
|
||
{
|
||
var val=stackalloc FFXIVClientStructs.FFXIV.Component.GUI.AtkValue[1];
|
||
val[0].SetInt(index);
|
||
selectString->FireCallback(1,val,true);
|
||
chat.Print($"[IC Rebuild] 已选择【SelectString】第 {index + 1} 项 (索引 {index})");
|
||
return;
|
||
}
|
||
var selectIconString=mgr->GetAddonByName("SelectIconString",1);
|
||
if(selectIconString!=null && selectIconString->IsVisible && selectIconString->IsReady)
|
||
{
|
||
var val=stackalloc FFXIVClientStructs.FFXIV.Component.GUI.AtkValue[1];
|
||
val[0].SetInt(index);
|
||
selectIconString->FireCallback(1,val,true);
|
||
chat.Print($"[IC Rebuild] 已选择【SelectIconString】第 {index + 1} 项 (索引 {index})");
|
||
return;
|
||
}
|
||
chat.Print("[IC Rebuild] 当前无可见的选择列表 (SelectString / SelectIconString)");
|
||
}
|
||
private unsafe void ProcessRetainerAutomation()
|
||
{
|
||
var mgr=FFXIVClientStructs.FFXIV.Client.UI.RaptureAtkUnitManager.Instance();
|
||
if(mgr==null) return;
|
||
var now=Environment.TickCount64;
|
||
if(now-lastRetainerActionTime<750) return;
|
||
var resultAddon=mgr->GetAddonByName("RetainerTaskResult",1);
|
||
if(resultAddon!=null && resultAddon->IsVisible && resultAddon->IsReady)
|
||
{
|
||
var val=stackalloc FFXIVClientStructs.FFXIV.Component.GUI.AtkValue[1];
|
||
val[0].SetInt(0);
|
||
resultAddon->FireCallback(1,val,true);
|
||
lastRetainerActionTime=now;
|
||
notice="已自动执行雇员探险【再次派遣】";
|
||
return;
|
||
}
|
||
var askAddon=mgr->GetAddonByName("RetainerTaskAsk",1);
|
||
if(askAddon!=null && askAddon->IsVisible && askAddon->IsReady)
|
||
{
|
||
var val=stackalloc FFXIVClientStructs.FFXIV.Component.GUI.AtkValue[1];
|
||
val[0].SetInt(0);
|
||
askAddon->FireCallback(1,val,true);
|
||
lastRetainerActionTime=now;
|
||
notice="已自动执行雇员探险【确定派遣】";
|
||
}
|
||
}
|
||
private string DataPath(string name)
|
||
{
|
||
if(string.IsNullOrWhiteSpace(name) || Path.GetFileName(name)!=name || name.IndexOfAny(Path.GetInvalidFileNameChars())>=0) throw new ArgumentException("请输入当前记录目录中的文件名");
|
||
return Path.Combine(directory,name);
|
||
}
|
||
private void EnsureOpcodes(bool force=false)
|
||
{
|
||
if(!force && opcodes.Snapshot.GameVersion==gameVersion) return;
|
||
var path=DataPath("opcodes.json");
|
||
if(!File.Exists(path) && pi.AssemblyLocation.DirectoryName is { } devDir)
|
||
path=Path.Combine(devDir,"opcodes.json");
|
||
if(File.Exists(path))
|
||
opcodes.Load(File.ReadAllText(path),gameVersion,"本地文件");
|
||
}
|
||
private async Task Background(Action action)
|
||
{ try { await Task.Run(action,cancellation.Token); if(!disposed) notice="文件操作完成"; } catch(Exception ex) { if(!disposed) notice=ex.Message; } }
|
||
private async Task SaveCaptureAsync(string path,PacketRecord[] records)
|
||
{
|
||
try
|
||
{
|
||
await Task.Run(()=>PacketFiles.ExportAtomic(path,records));
|
||
if(!disposed) captureSaveStatus=$"{DateTime.Now:HH:mm:ss} 已保存 {records.Length} 条";
|
||
}
|
||
catch(Exception ex) { if(!disposed) captureSaveStatus=$"保存失败:{ex.Message}"; }
|
||
}
|
||
private async Task UpdateOpcodes()
|
||
{
|
||
try
|
||
{
|
||
var uri=new Uri(config.OpcodeUrl);
|
||
if(uri.Scheme!=Uri.UriSchemeHttps) throw new InvalidDataException("Opcode 地址必须使用 HTTPS");
|
||
using var response=await http.GetAsync(uri,HttpCompletionOption.ResponseHeadersRead,cancellation.Token);
|
||
response.EnsureSuccessStatusCode();
|
||
if(response.Content.Headers.ContentLength>4*1024*1024) throw new InvalidDataException("Opcode 文件超过 4 MiB");
|
||
await using var stream=await response.Content.ReadAsStreamAsync(cancellation.Token);
|
||
using var content=new MemoryStream();
|
||
var buffer=new byte[8192];
|
||
int read;
|
||
while((read=await stream.ReadAsync(buffer,cancellation.Token))>0)
|
||
{ if(content.Length+read>4*1024*1024) throw new InvalidDataException("Opcode 文件超过 4 MiB"); content.Write(buffer,0,read); }
|
||
var json=System.Text.Encoding.UTF8.GetString(content.ToArray());
|
||
if(disposed) return;
|
||
var validated=new OpcodeMap(); validated.Load(json,gameVersion,uri.Host);
|
||
var temporary=DataPath("opcodes.json.tmp");
|
||
await File.WriteAllTextAsync(temporary,json,cancellation.Token);
|
||
cancellation.Token.ThrowIfCancellationRequested();
|
||
File.Move(temporary,DataPath("opcodes.json"),true);
|
||
opcodes.Load(json,gameVersion,uri.Host);
|
||
if(!disposed) notice="Opcode 映射已更新";
|
||
}
|
||
catch(Exception ex) { if(!disposed) notice=$"Opcode 更新失败:{ex.Message}"; }
|
||
}
|
||
public void Dispose()
|
||
{
|
||
if(disposed) return; disposed=true;
|
||
framework.Update-=Update; commands.RemoveHandler("/icr");
|
||
pi.UiBuilder.Draw-=Draw; pi.UiBuilder.OpenConfigUi-=Open; pi.UiBuilder.OpenMainUi-=Open;
|
||
serviceBar.OnClick=null; serviceBar.Dispose();
|
||
eventSubscription.Dispose();
|
||
modules.Dispose(); network.Dispose(); game.Frames.Clear(); cancellation.Cancel(); http.Dispose(); cancellation.Dispose();
|
||
}
|
||
}
|