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; 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(); modules.Register(37,_ => { }); ValidationCatalog.Apply(modules,gameVersion); Try(()=> { adaptationReport=game.EvaluateAdaptation(modules); AdaptationManifest.ApplyGate(modules,adaptationReport); }); var opcodeFile=Path.Combine(directory,"opcodes.json"); if(File.Exists(opcodeFile)) Try(() => opcodes.Load(File.ReadAllText(opcodeFile),gameVersion,"本地缓存")); 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;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(); } 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; default: Open(); notice="用法:/icr on|off 编号;stop;pos X Y Z;face 弧度;scale 倍率"; 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); DrawAdaptation(); 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==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}"); } 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 DrawAdaptation() { if(!ImGui.CollapsingHeader("版本适配与诊断 (M0)")) return; if(ImGui.Button("重新评估适配清单")) Try(()=> { adaptationReport=game.EvaluateAdaptation(modules); AdaptationManifest.ApplyGate(modules,adaptationReport); }); if(adaptationReport is not {} report) { ImGui.TextWrapped("尚未生成适配诊断报告。"); return; } ImGui.TextWrapped($"客户端 {report.ClientVersion}(已审计基线 {report.AuditedVersion})· 可用模块 {report.ReadyCount} · 受阻模块 {report.BlockedCount}"); ImGui.TextWrapped(report.ActiveSendSummary); foreach(var entry in report.Entries) { var tag=entry.Issue switch { AdaptationIssue.None=>"就绪", AdaptationIssue.MissingImplementation=>"未实现", AdaptationIssue.VersionMismatch=>"版本不匹配", AdaptationIssue.OffsetMismatch=>"SDK 偏移变化", AdaptationIssue.SignatureMissing=>"签名缺失", AdaptationIssue.MultipleMatches=>"签名不唯一", AdaptationIssue.RuntimeFault=>"运行异常", _=>"受限" }; ImGui.TextWrapped($"{entry.FeatureId} · {entry.FeatureName}:{tag} · {entry.Summary}"); } } private void DrawStatusBlock() { ImGui.TextWrapped($"已选 {config.BlockedStatuses.Count} 项 · 本次启用拦截 {statusBlock.Blocked} 次 · 最近状态 ID {statusBlock.LastStatus}"); ImGui.TextWrapped("仅屏蔽本地角色后续收到的已选状态更新;开启前已有状态须等下一次更新。默认选择 1257 强制移动,空名单不屏蔽。录像回放暂停处理。"); ImGui.TextWrapped("关闭后恢复正常接收,已屏蔽状态须等服务端再次同步。本地处理不代表服务端状态被删除;实际移动或机制效果待实测。"); if(!ImGui.CollapsingHeader("状态名单(已审计候选,效果待逐项确认)")) return; var sheet=game.Data.GetExcelSheet(); foreach(var id in StatusCatalog.AuditedBlockCandidates.Order()) { var selected=config.BlockedStatuses.Contains(id); var name=sheet?.GetRowOrDefault(id)?.Name.ToString() ?? "未知状态"; if(ImGui.Checkbox($"{id} · {name}",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().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(()=>opcodes.Load(File.ReadAllText(DataPath("opcodes.json")),gameVersion,"本地文件")); 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}" : "未就绪(需角色在场且非过图/占卜回放)")}"); ImGui.InputInt("测试技能 ID (UP_ActionSend)",ref manualSendActionId); ImGui.InputInt("请求序号 RequestId",ref manualSendRequestId); if(ImGui.Button("加入技能发送队列 (当前目标)")) Try(()=> { 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(()=> { 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 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 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(); modules.Dispose(); network.Dispose(); game.Frames.Clear(); cancellation.Cancel(); http.Dispose(); cancellation.Dispose(); } }