Files

402 lines
26 KiB
C#
Raw Permalink 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.Buffers.Binary;
using System.Text.Json;
using System.Collections.ObjectModel;
namespace ICRebuild.Core;
public enum PacketDirection { Up, Down }
public sealed record PacketRecord(long Sequence, long MonotonicTicks, DateTimeOffset Time, PacketDirection Direction,
int Opcode, string GameVersion, uint? Source, uint? Target, byte[] Payload, bool PayloadComplete = true,string? CaptureIssue = null,
PacketContext? Context = null);
public sealed record OpcodeSnapshot(string GameVersion, string Source, IReadOnlyDictionary<int,string> Up, IReadOnlyDictionary<int,string> Down);
public sealed class OpcodeMap
{
private OpcodeSnapshot snapshot = new("unknown","none",new ReadOnlyDictionary<int,string>(new Dictionary<int,string>()),new ReadOnlyDictionary<int,string>(new Dictionary<int,string>()));
public OpcodeSnapshot Snapshot => Volatile.Read(ref snapshot);
public string? Resolve(string version, PacketDirection direction, int opcode)
{
var state = Snapshot;
return version != "unknown" && state.GameVersion == version ? (direction == PacketDirection.Up ? state.Up : state.Down).GetValueOrDefault(opcode) : null;
}
public int? ResolveOpcode(string version, PacketDirection direction, string name)
{
var state = Snapshot;
if (version == "unknown" || state.GameVersion != version || string.IsNullOrWhiteSpace(name)) return null;
var dict = direction == PacketDirection.Up ? state.Up : state.Down;
int? found = null;
foreach (var kv in dict)
{
if (kv.Value != name) continue;
if (found.HasValue) return null; // Refuse ambiguous multi-opcode mappings.
found = kv.Key;
}
return found;
}
public void Load(string json, string version, string source)
{
if (string.IsNullOrWhiteSpace(version) || version == "unknown") throw new InvalidDataException("客户端版本未知,不能应用映射");
using var document = JsonDocument.Parse(json,new JsonDocumentOptions { MaxDepth = 12 });
if (!document.RootElement.TryGetProperty(version,out var entries) || entries.ValueKind != JsonValueKind.Object)
throw new InvalidDataException($"映射不包含版本 {version}");
Dictionary<int,string> up = [], down = [];
foreach (var property in entries.EnumerateObject())
{
Dictionary<int,string> target;
if (property.Name.StartsWith("UP_",StringComparison.Ordinal)) target = up;
else if (property.Name.StartsWith("DOWN_",StringComparison.Ordinal)) target = down;
else continue;
if (property.Value.ValueKind != JsonValueKind.Array) throw new InvalidDataException("Opcode 值应为数组");
foreach (var item in property.Value.EnumerateArray())
{
if (!item.TryGetInt32(out var code) || code is < 0 or > 65535) throw new InvalidDataException("Opcode 超出 ushort 范围");
if (target.TryGetValue(code,out var old) && old != property.Name) throw new InvalidDataException($"Opcode {code} 存在冲突");
target[code] = property.Name;
}
}
if (up.Count + down.Count == 0) throw new InvalidDataException("映射为空");
var additions=CurrentProtocolEvidence.AddMissing(version,down);
if(additions>0) source+=$" + 本机核实 {additions} 项";
Volatile.Write(ref snapshot,new(version,source,new ReadOnlyDictionary<int,string>(up),new ReadOnlyDictionary<int,string>(down)));
}
}
public sealed record ParseResult(bool Parsed, string Summary);
public static class PacketParser
{
public const string AuditedClientVersion="2026.10.06.0000.0000";
public const string BaselineClientVersion="2026.09.15.0000.0000";
public static bool IsAuditedVersion(string? version) => version is AuditedClientVersion or BaselineClientVersion;
public static ParseResult Parse(string? name, ReadOnlySpan<byte> payload,string? gameVersion=null)
{
if (name == null) return new(false,"未知 Opcode;保留原始数据");
var current=IsAuditedVersion(gameVersion);
if (TryEventTier(name,out var eventLabel,out var eventCapacity,out var eventLength))
return EventPacket(name,eventLabel,payload,current,eventCapacity,eventLength);
switch (name)
{
case "UP_PingReq":
var pingLength=current ? 0x20 : 0x1C;
if (payload.Length != pingLength) return Invalid(name,payload.Length,pingLength);
return new(true,$"Ping 请求 time={U32(payload,0)} ms reported={U32(payload,4)} ms");
case "DOWN_PingRsp":
if (current ? payload.Length!=0x20 : payload.Length<0x10) return Invalid(name,payload.Length,current ? 0x20 : 0x10);
return new(true,$"Ping 响应 echo={U32(payload,0)} ms field04={U32(payload,4)}(未解释);其余字节不解释");
case "UP_UpdatePositionHandler":
var positionLength=current ? 0x18 : 0x28;
if (payload.Length != positionLength) return Invalid(name,payload.Length,positionLength);
return Position(payload,8);
case "UP_UpdatePositionInstance":
if (payload.Length != 0x30) return Invalid(name,payload.Length,0x30);
var first = Position(payload,12); var second = Position(payload,24);
return new(first.Parsed && second.Parsed,$"{first.Summary}; new {second.Summary}");
case "UP_EventStart":
if (payload.Length != 0x10) return Invalid(name,payload.Length,0x10);
return new(true,$"对象={U64(payload,0)} event={U32(payload,8)}");
case "UP_ActionSend":
if(!current) return Unverified(name);
if(payload.Length!=0x20) return Invalid(name,payload.Length,0x20);
return new(true,$"动作请求 · 技能={U32(payload,0)} 类型={payload[5]} 请求序号={U16(payload,6)} 目标=0x{U64(payload,0x10):X16} 附加={U32(payload,0x18)}");
case "UP_ActionSendPos":
if(!current) return Unverified(name);
if(payload.Length!=0x18) return Invalid(name,payload.Length,0x18);
var actionPos=Position(payload,0x0C);
return actionPos.Parsed
? new(true,$"地面动作请求 · 技能={U32(payload,0)} 类型={payload[5]} 请求序号={U16(payload,6)} {actionPos.Summary}")
: actionPos;
case "UP_ClientTrigger":
if(!current) return Unverified(name);
if(payload.Length!=0x20) return Invalid(name,payload.Length,0x20);
return new(true,$"客户端触发器 · 指令={U32(payload,0)} 参数=({U32(payload,4)},{U32(payload,8)},{U32(payload,12)},{U32(payload,16)}) 目标=0x{U64(payload,0x18):X16}");
case "DOWN_UpdateHate": case "DOWN_UpdateHater":
if(!current) return new(false,$"{name}:此版本的仇恨列表布局未核实");
return Enmity(name,payload,name=="DOWN_UpdateHate" ? 8 : 32);
case "DOWN_EffectResultBasic":
if(!current) return new(false,"此版本的精简效果结果布局未核实");
if(payload.Length!=24) return Invalid(name,payload.Length,24);
if(payload[0]>1) return new(false,"精简效果结果计数超出此包容量");
if(payload[0]==0) return new(true,"精简效果结果条目=0");
return new(true,$"精简效果结果条目=1 · 序列={U32(payload,4)} · 当前 HP={U32(payload,12)} · 目标序号={payload[16]};其他字段未解释");
case "DOWN_JobGauge":
if(!current) return new(false,"此版本的职业量表布局未核实");
if(payload.Length!=16) return Invalid(name,payload.Length,16);
return new(true,$"职业量表更新 · 职业 ID={payload[0]};各职业量表字段保留原始数据");
case "DOWN_UpdateClassInfo":
if(!current) return new(false,"此版本的职业信息布局未核实");
if(payload.Length!=16) return Invalid(name,payload.Length,16);
return new(true,$"职业信息 · 职业 ID={payload[0]} · 当前等级={U16(payload,2)} · 经验={U32(payload,8)};其他字段未解释");
case "DOWN_FirstAttack":
if(!current) return new(false,"此版本的战斗归属布局未核实");
if(payload.Length!=16) return Invalid(name,payload.Length,16);
return new(true,$"战斗归属 · 类型={payload[0]} · 标记者=0x{U64(payload,8):X16};不是技能伤害包");
case "DOWN_ActorControl":
if(!current) return Unverified(name);
if(payload.Length!=0x18) return Invalid(name,payload.Length,0x18);
var category=U16(payload,0);
var note=category==0x22 ? "(Arg0 为分派前原始值)" : "";
return new(true,$"角色控制 · 类型=0x{category:X4} 参数=({U32(payload,4)},{U32(payload,8)},{U32(payload,12)},{U32(payload,16)}){note}");
case "DOWN_ActorControlSelf":
if(!current) return Unverified(name);
if(payload.Length!=0x28) return Invalid(name,payload.Length,0x28);
return new(true,$"自身控制 · 类型=0x{U16(payload,0):X4} 参数=({U32(payload,4)},{U32(payload,8)},{U32(payload,12)},{U32(payload,16)},{U32(payload,20)},{U32(payload,24)},{U32(payload,28)},{U32(payload,32)})");
case "DOWN_ActorControlTarget":
if(!current) return Unverified(name);
if(payload.Length!=0x20) return Invalid(name,payload.Length,0x20);
return new(true,$"目标控制 · 类型=0x{U16(payload,0):X4} 参数=({U32(payload,4)},{U32(payload,8)},{U32(payload,12)},{U32(payload,16)}) 目标=0x{U64(payload,0x18):X16}");
case "DOWN_Effect":
if(!current) return Unverified(name);
if(payload.Length!=0x7C) return Invalid(name,payload.Length,0x7C);
var lockTime=F32(payload,0x10);
if(!float.IsFinite(lockTime) || lockTime<0) return new(false,"技能效果动画锁时间无效");
var targetCount=payload[0x21];
if(targetCount>1) return new(false,$"单目标技能效果目标计数 {targetCount} 超出容量 1");
return new(true,$"技能效果 · 序列={U32(payload,0x0C)} 请求序号={U16(payload,0x18)} 动画锁={lockTime:F2}s 类型={payload[0x1F]} 目标数={targetCount} 主目标=0x{U64(payload,0x70):X16}(动作与效果字为分派前原始值)");
case "DOWN_StatusEffectList":
if(!current) return Unverified(name);
if(payload.Length!=0x180) return Invalid(name,payload.Length,0x180);
var activeSlots=0;
for(var i=0;i<30;i++)
{
var slotOffset=0x14+i*12;
if(!float.IsFinite(F32(payload,slotOffset+4))) return new(false,"状态列表包含非有限持续时间");
if(U16(payload,slotOffset)!=0) activeSlots++;
}
return new(true,$"状态列表 · 职业={payload[0]} 等级={payload[1]} HP={U32(payload,4)}/{U32(payload,8)} 资源={U16(payload,12)}/{U16(payload,14)} 护盾={payload[0x10]}% 非空槽位={activeSlots}/30(状态 ID 为分派转换前原始值)");
case "DOWN_EffectResult":
if(current)
{
if(payload.Length!=0x60) return Invalid(name,payload.Length,0x60);
int entryCount=payload[0];
if(entryCount>1) return new(false,"效果结果计数超出单条目包容量");
if(entryCount==0) return new(true,"效果结果条目=0");
int statusCount=payload[0x19];
if(statusCount>4) return new(false,"效果状态计数超限");
for(var i=0;i<statusCount;i++)
if(!float.IsFinite(F32(payload,0x1C+i*16+8))) return new(false,"效果状态持续时间包含非有限数");
return new(true,$"效果结果条目=1 · 序列={U32(payload,4)} HP={U32(payload,0x0C)}/{U32(payload,0x10)} MP={U16(payload,0x14)} 目标序号={payload[0x16]} 职业={payload[0x17]} 护盾={payload[0x18]}% 状态数={statusCount}");
}
if (payload.Length < 4) return Invalid(name,payload.Length,4);
int count = payload[0];
if (count > 16 || payload.Length < 4 + count * 0x58) return new(false,"效果结果计数超限或载荷截断");
for (var i=0;i<count;i++)
if (payload[4+i*0x58+0x15] > 4) return new(false,"效果状态计数超限");
return new(true,$"效果结果条目={count};历史布局解析,须样本核验");
case "DOWN_ActorDelete":
if(!current) return Unverified(name);
if(payload.Length!=8) return Invalid(name,payload.Length,8);
return new(true,$"对象移除 · 槽位索引={payload[0]};其余字节未由消费函数读取");
case "DOWN_ObjectSpawn":
if(!current) return Unverified(name);
if(payload.Length!=0x40) return Invalid(name,payload.Length,0x40);
var objPos=Position(payload,0x34);
return objPos.Parsed
? new(true,$"对象生成 · 槽位={payload[0]} 类型={payload[1]} BaseId={U32(payload,4)} EntityId=0x{U32(payload,8):X8} {objPos.Summary}")
: objPos;
case "DOWN_NpcSpawn":
if(!current) return Unverified(name);
if(payload.Length!=0x290) return Invalid(name,payload.Length,0x290);
var npcPos=Position(payload,0x200);
return npcPos.Parsed
? new(true,$"NPC 生成 · 槽位={payload[0x7E]} BaseId={U32(payload,0x40)} Owner=0x{U32(payload,0x54):X8} 等级={payload[0x86]} 职业={payload[0x87]} HP={U32(payload,0x60)}/{U32(payload,0x5C)} {npcPos.Summary}")
: npcPos;
default: return new(false,$"{name}:尚无已接入解析器,保留原始数据");
}
}
private static bool TryEventTier(string name,out string label,out int capacity,out int expectedLength)
{
label=""; capacity=0; expectedLength=0;
ReadOnlySpan<char> suffix;
if(name.StartsWith("UP_EventFinish",StringComparison.Ordinal)) { label="事件完成"; suffix=name.AsSpan("UP_EventFinish".Length); }
else if(name.StartsWith("UP_EventAction",StringComparison.Ordinal)) { label="事件动作"; suffix=name.AsSpan("UP_EventAction".Length); }
else return false;
if(suffix.IsEmpty) { capacity=2; expectedLength=0x10; return true; }
if(!int.TryParse(suffix,out capacity)) return false;
expectedLength=capacity switch { 4=>0x18, 8=>0x28, 16=>0x48, 32=>0x88, 64=>0x108, 128=>0x208, 255=>0x408, _=>0 };
return expectedLength!=0;
}
private static ParseResult EventPacket(string name,string label,ReadOnlySpan<byte> payload,bool current,int capacity,int expectedLength)
{
if(!current) return new(false,$"{name}:变长事件分档仅核实 {AuditedClientVersion},保留 {payload.Length} 字节原始数据");
if(payload.Length!=expectedLength) return Invalid(name,payload.Length,expectedLength);
int argCount=payload[7];
if(argCount>capacity) return new(false,$"{name} 参数计数 {argCount} 超出分档容量 {capacity}");
var previewCount=Math.Min(argCount,4);
var args=new List<string>(previewCount);
for(var i=0;i<previewCount;i++) args.Add(U32(payload,8+i*4).ToString());
var suffix=argCount>previewCount ? ",..." : "";
return new(true,$"{label} · event={U32(payload,0)} scene={U16(payload,4)} 状态={payload[6]} 参数数={argCount}/{capacity} [{string.Join(",",args)}{suffix}]");
}
private static ushort U16(ReadOnlySpan<byte> p,int at) => BinaryPrimitives.ReadUInt16LittleEndian(p[at..]);
private static uint U32(ReadOnlySpan<byte> p,int at) => BinaryPrimitives.ReadUInt32LittleEndian(p[at..]);
private static ulong U64(ReadOnlySpan<byte> p,int at) => BinaryPrimitives.ReadUInt64LittleEndian(p[at..]);
private static float F32(ReadOnlySpan<byte> p,int at) => BinaryPrimitives.ReadSingleLittleEndian(p[at..]);
private static ParseResult Unverified(string name) => new(false,$"{name}:此版本的字段布局未核实");
private static ParseResult Enmity(string name,ReadOnlySpan<byte> payload,int capacity)
{
var expected=8+capacity*8; // 4-byte prefix, fixed entries, 4 trailing bytes kept uninterpreted.
if(payload.Length!=expected) return Invalid(name,payload.Length,expected);
var count=payload[0];
if(count>capacity) return new(false,$"仇恨列表计数 {count} 超过容量 {capacity}");
var entries=new List<string>(count);
for(var i=0;i<count;i++) entries.Add($"0x{U32(payload,4+i*8):X8}:{payload[8+i*8]}");
var label=name=="DOWN_UpdateHate" ? "目标仇恨列表" : "自身敌对列表";
return new(true,$"{label} {count} 项(对象:仇恨值){string.Join(", ",entries)}");
}
private static ParseResult Position(ReadOnlySpan<byte> p,int at)
{
float x=BinaryPrimitives.ReadSingleLittleEndian(p[at..]), y=BinaryPrimitives.ReadSingleLittleEndian(p[(at+4)..]), z=BinaryPrimitives.ReadSingleLittleEndian(p[(at+8)..]);
return float.IsFinite(x)&&float.IsFinite(y)&&float.IsFinite(z) ? new(true,$"X={x:F3} Y={y:F3} Z={z:F3}") : new(false,"坐标包含非有限数");
}
private static ParseResult Invalid(string name,int actual,int expected) => new(false,$"{name} 长度不匹配:{actual},预期 {expected}");
}
public sealed class PacketBuffer(int capacity = 2048, int maxPayload = 65536,int maxBytes = 8*1024*1024)
{
private readonly object sync = new();
private readonly Queue<PacketRecord> records = new();
private long sequence;
private bool paused = true;
private PacketDirection? directionFilter;
private int? opcodeFilter;
private long dropped;
private long payloadBytes;
public long PayloadBytes { get { lock(sync) return payloadBytes; } }
public int PayloadByteLimit => maxBytes;
public bool Paused { get { lock(sync) return paused; } set { lock(sync) paused=value; } }
public PacketDirection? DirectionFilter { get { lock(sync) return directionFilter; } set { lock(sync) directionFilter=value; } }
public int? OpcodeFilter { get { lock(sync) return opcodeFilter; } set { lock(sync) opcodeFilter=value; } }
public long Dropped { get { lock(sync) return dropped; } }
public bool Accepts(PacketDirection direction,int? opcode=null)
{
lock(sync) return !paused && (!directionFilter.HasValue || directionFilter==direction) &&
(!opcode.HasValue || !opcodeFilter.HasValue || opcodeFilter==opcode);
}
public bool Add(PacketDirection direction,int opcode,string version,uint? source,uint? target,ReadOnlySpan<byte> payload,bool payloadComplete = true,string? captureIssue = null,
PacketContext? context=null,long? monotonicTicks=null,DateTimeOffset? time=null)
{
lock(sync)
{
if (paused || (directionFilter.HasValue && directionFilter!=direction) || (opcodeFilter.HasValue && opcodeFilter!=opcode)) return false;
if (payload.Length>maxPayload || payload.Length>maxBytes || opcode is <0 or >65535 || capacity <= 0 || !Enum.IsDefined(direction)) { dropped++; return false; }
while (records.Count>0 && (records.Count>=capacity || payloadBytes+payload.Length>maxBytes))
{ payloadBytes-=records.Dequeue().Payload.Length; dropped++; }
records.Enqueue(new(++sequence,monotonicTicks ?? System.Diagnostics.Stopwatch.GetTimestamp(),time ?? DateTimeOffset.UtcNow,direction,opcode,version,source,target,payload.ToArray(),payloadComplete,captureIssue,context));
payloadBytes+=payload.Length;
return true;
}
}
public PacketRecord[] Snapshot() { lock(sync) return records.ToArray(); }
public void Clear() { lock(sync) { records.Clear(); dropped=0; payloadBytes=0; } }
public void Replace(IEnumerable<PacketRecord> imported)
{
var input=imported.ToArray();
if(capacity<=0 || maxBytes<0) throw new InvalidDataException("无效缓冲区容量");
foreach(var p in input)
if(p.Payload == null || p.Payload.Length>maxPayload || p.Opcode is <0 or >65535 || !Enum.IsDefined(p.Direction) || string.IsNullOrWhiteSpace(p.GameVersion))
throw new InvalidDataException("无效记录");
lock(sync)
{
records.Clear(); payloadBytes=0; dropped=Math.Max(0,input.Length-capacity);
foreach(var p in input.TakeLast(capacity))
{
if(p.Payload.Length>maxBytes) { dropped++; continue; }
while(records.Count>0 && payloadBytes+p.Payload.Length>maxBytes)
{ payloadBytes-=records.Dequeue().Payload.Length; dropped++; }
records.Enqueue(p with { Payload=p.Payload.ToArray() }); payloadBytes+=p.Payload.Length;
}
sequence=input.Length==0 ? 0 : input.Max(p=>p.Sequence);
}
}
}
public sealed record PacketCorrelation(long Request,long Response,TimeSpan Elapsed);
public static class PacketCorrelator
{
public static PacketCorrelation[] Ping(IEnumerable<PacketRecord> packets,OpcodeMap map)
{
Dictionary<(string,uint),Queue<PacketRecord>> requests=[];
List<PacketCorrelation> results=[];
foreach(var packet in packets.OrderBy(p=>p.Time).ThenBy(p=>p.Sequence))
{
if(!packet.PayloadComplete) continue;
var name=map.Resolve(packet.GameVersion,packet.Direction,packet.Opcode);
var current=packet.GameVersion==PacketParser.AuditedClientVersion;
if(name=="UP_PingReq" && packet.Payload.Length==(current ? 0x20 : 0x1C))
{
var key=(packet.GameVersion,BinaryPrimitives.ReadUInt32LittleEndian(packet.Payload));
if(!requests.TryGetValue(key,out var queue)) requests[key]=queue=new();
queue.Enqueue(packet);
}
else if(name=="DOWN_PingRsp" && (current ? packet.Payload.Length==0x20 : packet.Payload.Length>=0x10))
{
var key=(packet.GameVersion,BinaryPrimitives.ReadUInt32LittleEndian(packet.Payload));
if(requests.TryGetValue(key,out var queue) && queue.TryDequeue(out var request))
{
var elapsed=packet.Time-request.Time;
if(elapsed>=TimeSpan.Zero && elapsed<=TimeSpan.FromMinutes(1)) results.Add(new(request.Sequence,packet.Sequence,elapsed));
}
}
}
return results.ToArray();
}
public static PacketCorrelation[] Actions(IEnumerable<PacketRecord> packets,OpcodeMap map)
{
Dictionary<(string,ushort),Queue<PacketRecord>> requests=[];
List<PacketCorrelation> results=[];
foreach(var packet in packets.OrderBy(p=>p.Time).ThenBy(p=>p.Sequence))
{
if(!packet.PayloadComplete || packet.GameVersion!=PacketParser.AuditedClientVersion) continue;
var name=map.Resolve(packet.GameVersion,packet.Direction,packet.Opcode);
if((name=="UP_ActionSend" && packet.Payload.Length==0x20) || (name=="UP_ActionSendPos" && packet.Payload.Length==0x18))
{
var reqId=BinaryPrimitives.ReadUInt16LittleEndian(packet.Payload.AsSpan(6));
if(reqId==0) continue;
if(!requests.TryGetValue((packet.GameVersion,reqId),out var queue)) requests[(packet.GameVersion,reqId)]=queue=new();
queue.Enqueue(packet);
}
else if(name=="DOWN_Effect" && packet.Payload.Length==0x7C && packet.Payload[0x21]<=1)
{
var reqId=BinaryPrimitives.ReadUInt16LittleEndian(packet.Payload.AsSpan(0x18));
if(reqId>0 && requests.TryGetValue((packet.GameVersion,reqId),out var queue) && queue.TryDequeue(out var request))
{
var elapsed=packet.Time-request.Time;
if(elapsed>=TimeSpan.Zero && elapsed<=TimeSpan.FromMinutes(1)) results.Add(new(request.Sequence,packet.Sequence,elapsed));
}
}
}
return results.ToArray();
}
}
public static class PacketFiles
{
public static void ExportAtomic(string path,IEnumerable<PacketRecord> packets)
{
var temporary=path+"."+Guid.NewGuid().ToString("N")+".tmp";
try { Export(temporary,packets); File.Move(temporary,path,true); }
finally { if(File.Exists(temporary)) File.Delete(temporary); }
}
public static void Export(string path,IEnumerable<PacketRecord> packets)
{
using var writer = new StreamWriter(path,false,new System.Text.UTF8Encoding(false));
foreach (var packet in packets) writer.WriteLine(JsonSerializer.Serialize(packet));
}
public static PacketRecord[] Import(string path)
{
if (new FileInfo(path).Length > 32*1024*1024) throw new InvalidDataException("记录文件超过 32 MiB");
var records = new List<PacketRecord>();
foreach(var line in File.ReadLines(path))
{
if (line.Length>100000) throw new InvalidDataException("记录行过长");
if (string.IsNullOrWhiteSpace(line)) continue;
var packet=JsonSerializer.Deserialize<PacketRecord>(line) ?? throw new InvalidDataException("空记录");
if (packet.Payload == null || packet.Payload.Length>65536 || packet.Opcode is <0 or >65535 || !Enum.IsDefined(packet.Direction) || string.IsNullOrWhiteSpace(packet.GameVersion))
throw new InvalidDataException("无效记录");
records.Add(packet);
if (records.Count>10000) throw new InvalidDataException("记录超过 10000 条");
}
return records.ToArray();
}
}