Initial import of IC.Rebuild

This commit is contained in:
2026-10-13 22:55:05 +08:00
commit 16d61f7a5e
87 changed files with 17054 additions and 0 deletions
+493
View File
@@ -0,0 +1,493 @@
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<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(()=>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();
}
}