Files
IC.Rebuild/Plugin.cs
T

786 lines
46 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 lastAutoEventFinishTime;
private long lastTalkAdvanceTime;
private long lastYesnoTime;
private string statusFilterText = "";
private uint pendingEventFinishId;
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;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();
var finishId=Interlocked.Exchange(ref pendingEventFinishId,0);
if(finishId!=0 && config.FastEventFinish)
{
Try(()=>
{
EnsureOpcodes();
var packet=AuditedPacketEncoder.EncodeEventFinish(opcodes,gameVersion,finishId,0,0,ReadOnlySpan<uint>.Empty);
var tx=network.EnqueueSend(packet);
notice=$"已自动结束事件 0x{finishId:X8} (事务 #{tx.Id})";
});
}
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;
default: Open(); notice="用法:/icr on|off 编号;stop;pos X Y Z;ground 技能ID [target|self];finish [事件ID];retainer;yes;no"; break;
}
});
}
private 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)
{
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("监听 DOWN_EventStart,直接发送 UP_EventFinish 完成 NPC 对话、开宝箱、副本传送门与剧情过渡。");
var fastEv = config.FastEventFinish;
if (ImGui.Checkbox("自动极速结束交互与对话 (FastEventFinish)##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;
if(config.FastEventFinish && lastEventId!=0 && Environment.TickCount64-lastAutoEventFinishTime>=800)
{
lastAutoEventFinishTime=Environment.TickCount64;
Volatile.Write(ref pendingEventFinishId,lastEventId);
}
}
}
private void ExecuteGroundCast(uint actionId,bool atTarget)
{
EnsureOpcodes();
if(actionId==0) throw new ArgumentException("请输入有效地面技能 ID");
var session=game.ReadSendSession();
if(!session.IsValid) throw new InvalidOperationException("当前角色或区域会话未就绪");
var player=game.Objects.LocalPlayer;
if(player==null) throw new InvalidOperationException("本地角色未就绪");
Vector3 targetPos=atTarget && game.Targets.Target is { } target ? target.Position : player.Position;
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);
var destStr=atTarget && game.Targets.Target!=null ? $"目标【{game.Targets.Target.Name}】" : "自身脚下";
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 ProcessRetainerAutomation()
{
var mgr=FFXIVClientStructs.FFXIV.Client.UI.RaptureAtkUnitManager.Instance();
if(mgr==null) return;
var now=Environment.TickCount64;
if(now-lastRetainerActionTime<450) 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();
}
}