474 lines
30 KiB
C#
474 lines
30 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;
|
||
|
||
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);
|
||
});
|
||
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;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);
|
||
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 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<Lumina.Excel.Sheets.Status>();
|
||
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<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});发送时将自动载入");
|
||
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 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();
|
||
modules.Dispose(); network.Dispose(); game.Frames.Clear(); cancellation.Cancel(); http.Dispose(); cancellation.Dispose();
|
||
}
|
||
}
|