1077 lines
64 KiB
C#
1077 lines
64 KiB
C#
using System.Buffers.Binary;
|
|
using ICRebuild.Core;
|
|
|
|
int passed=0;
|
|
void Test(string name,Action body) { body(); Console.WriteLine($"PASS {name}"); passed++; }
|
|
void Check(bool condition,string message="assertion failed") { if(!condition) throw new Exception(message); }
|
|
void Throws<T>(Action action) where T:Exception { try { action(); } catch(T) { return; } throw new Exception($"Expected {typeof(T).Name}"); }
|
|
|
|
Test("network send phases preserve order owned payload identity and raw result",()=>
|
|
{
|
|
using var pipeline=new NetworkPipeline("v1");
|
|
var bytes=new byte[]{1,2,3};
|
|
List<string> order=[]; NetworkObservation? before=null,after=null;
|
|
using var a=pipeline.Subscribe(NetworkPhase.BeforeSend,p=>{order.Add("before");before=p;});
|
|
using var b=pipeline.Subscribe(NetworkPhase.AfterSend,p=>{order.Add("after");after=p;});
|
|
int calls=0,reads=0;
|
|
var result=pipeline.Send(()=>{calls++;order.Add("native");bytes[0]=99;return 0;},filter=>
|
|
{ reads++;order.Add("read");return new(new(42,null,bytes,true)); });
|
|
Check(result==0 && calls==1 && reads==1 && order.SequenceEqual(["read","before","native","after"]));
|
|
Check(before!=null && after!=null && ReferenceEquals(before.Message,after.Message));
|
|
Check(after!.NativeResult==0 && before!.NativeResult==null && after.Message.Payload.SequenceEqual(new byte[]{1,2,3}));
|
|
Check(after.Message.GameVersion=="v1" && after.Message.Direction==PacketDirection.Up && pipeline.ObserverErrors==0);
|
|
});
|
|
Test("network observer and capture failures never skip or retry the native send",()=>
|
|
{
|
|
using var pipeline=new NetworkPipeline("v1"); int calls=0,notified=0;
|
|
using var a=pipeline.Subscribe(NetworkPhase.BeforeSend,_=>throw new InvalidOperationException("observer"));
|
|
using var b=pipeline.Subscribe(NetworkPhase.BeforeSend,_=>notified++);
|
|
using var c=pipeline.Subscribe(NetworkPhase.AfterSend,_=>throw new InvalidOperationException("after"));
|
|
Func<byte> original=()=>{calls++;return 7;};
|
|
Check(pipeline.Send(original,_=>new(new(1,null,[],true)))==7);
|
|
Check(calls==1 && notified==1 && pipeline.ObserverErrors==2);
|
|
Check(pipeline.Send(original,_=>throw new InvalidDataException("reader"))==7);
|
|
Check(calls==2 && pipeline.ReadErrors==1 && notified==1);
|
|
var after=pipeline.AfterSend;
|
|
Throws<InvalidOperationException>(()=>pipeline.Send(()=>{calls++;throw new InvalidOperationException("native");},_=>new(new(1,null,[],true))));
|
|
Check(calls==3 && pipeline.AfterSend==after);
|
|
});
|
|
Test("network receive reads only after native success and preserves metadata failure",()=>
|
|
{
|
|
using var pipeline=new NetworkPipeline("v1");
|
|
int calls=0,reads=0; NetworkObservation? observed=null;
|
|
using var s=pipeline.Subscribe(NetworkPhase.Received,p=>observed=p);
|
|
Func<Func<int,bool>,CaptureRead> capture=_=>{reads++;return new(new(42,123,[],false,"truncated"));};
|
|
Check(pipeline.Receive(()=>{calls++;return 0;},capture)==0 && reads==0 && observed==null);
|
|
Check(pipeline.Receive(()=>{calls++;return 2;},capture)==2);
|
|
Check(calls==2 && reads==1 && observed is { NativeResult:2 } && !observed.Message.Complete && observed.Message.Issue=="truncated");
|
|
Check(pipeline.ReadErrors==1 && pipeline.ObserverErrors==0);
|
|
Throws<InvalidOperationException>(()=>pipeline.Receive(()=>{calls++;throw new InvalidOperationException();},capture));
|
|
Check(calls==3 && reads==1);
|
|
});
|
|
Test("log pause direction and opcode filters do not filter independent network observers",()=>
|
|
{
|
|
using var pipeline=new NetworkPipeline("v1");
|
|
var buffer=new PacketBuffer(); var recorder=new PacketRecorder(buffer);
|
|
using var up=pipeline.Subscribe(NetworkPhase.BeforeSend,recorder.Record);
|
|
using var down=pipeline.Subscribe(NetworkPhase.Received,recorder.Record);
|
|
int received=0,sent=0;
|
|
using var businessDown=pipeline.Subscribe(NetworkPhase.Received,_=>received++,42);
|
|
using var businessUp=pipeline.Subscribe(NetworkPhase.AfterSend,_=>sent++,42);
|
|
void SendPair()
|
|
{
|
|
pipeline.Send(()=>1,_=>new(new(42,null,[1],true)));
|
|
pipeline.Receive(()=>1,_=>new(new(42,123,[2],true)));
|
|
}
|
|
SendPair(); Check(buffer.Snapshot().Length==0); // paused by default
|
|
buffer.Paused=false;buffer.OpcodeFilter=43; SendPair(); Check(buffer.Snapshot().Length==0);
|
|
buffer.OpcodeFilter=null;buffer.DirectionFilter=PacketDirection.Down;SendPair();
|
|
Check(received==3 && sent==3 && buffer.Snapshot().Length==1 && recorder.CompleteDown==1 && recorder.CompleteUp==0);
|
|
buffer.DirectionFilter=null; SendPair();
|
|
Check(buffer.Snapshot().Length==3 && recorder.CompleteUp==1 && pipeline.ObserverErrors==0);
|
|
var record=buffer.Snapshot()[^1];
|
|
Check(record.Context is {Phase:NetworkPhase.Received,Stage:PacketStage.RawIpcPayload,Origin:PacketOrigin.NativeZone,NativeResult:1});
|
|
});
|
|
Test("network cancellation and shutdown remove callbacks without duplicate reattachment",()=>
|
|
{
|
|
using var pipeline=new NetworkPipeline("v1"); int calls=0,reads=0;
|
|
CaptureRead Capture(Func<int,bool> filter) { reads++;return filter(42) ? new(new(42,null,[],true)) : new(null); }
|
|
for(int i=0;i<3;i++)
|
|
{
|
|
var sub=pipeline.Subscribe(NetworkPhase.BeforeSend,_=>calls++,42);
|
|
pipeline.Send(()=>1,Capture); sub.Dispose();sub.Dispose();
|
|
pipeline.Send(()=>1,Capture); // no observers: do not copy memory
|
|
}
|
|
Check(calls==3 && reads==3 && pipeline.SubscriberCount==0);
|
|
IDisposable? after=null;
|
|
using var before=pipeline.Subscribe(NetworkPhase.BeforeSend,_=>after!.Dispose());
|
|
after=pipeline.Subscribe(NetworkPhase.AfterSend,_=>calls++);
|
|
pipeline.Send(()=>1,Capture); Check(calls==3);
|
|
pipeline.Dispose();pipeline.Send(()=>1,Capture);
|
|
Check(pipeline.SubscriberCount==0 && reads==4 && pipeline.ObserverErrors==0);
|
|
Throws<ObjectDisposedException>(()=>pipeline.Subscribe(NetworkPhase.Received,_=>{}));
|
|
});
|
|
Test("shared network subscriptions survive recorder shutdown until final owner releases",()=>
|
|
{
|
|
using var pipeline=new NetworkPipeline("v1");
|
|
int started=0,stopped=0,logs=0,business=0;
|
|
var shared=new SharedModuleResource(s=>{started++;s.Defer(()=>stopped++);});
|
|
var recorder=new ResourceScope();var consumer=new ResourceScope();
|
|
shared.Acquire(recorder);recorder.Own(pipeline.Subscribe(NetworkPhase.Received,_=>logs++));
|
|
shared.Acquire(consumer);consumer.Own(pipeline.Subscribe(NetworkPhase.Received,_=>business++));
|
|
pipeline.Receive(()=>1,_=>new(new(42,null,[],true))); recorder.Dispose();
|
|
pipeline.Receive(()=>1,_=>new(new(42,null,[],true)));
|
|
Check(started==1 && stopped==0 && logs==1 && business==2);
|
|
consumer.Dispose(); Check(stopped==1 && pipeline.SubscriberCount==0);
|
|
});
|
|
Test("network record context roundtrips without changing timestamps or payload",()=>
|
|
{
|
|
var path=Path.Combine(Path.GetTempPath(),Guid.NewGuid()+".jsonl");
|
|
try
|
|
{
|
|
var message=new NetworkMessage(42,PacketDirection.Up,"v1",new(7,null,[1,2],true));
|
|
var buffer=new PacketBuffer { Paused=false };
|
|
new PacketRecorder(buffer).Record(new(message,NetworkPhase.BeforeSend));
|
|
PacketFiles.Export(path,buffer.Snapshot());var imported=PacketFiles.Import(path).Single();
|
|
Check(imported.Time==message.Time && imported.MonotonicTicks==message.MonotonicTicks && imported.Payload.SequenceEqual(message.Payload.ToArray()));
|
|
Check(imported.Context is {ObservationId:42,Phase:NetworkPhase.BeforeSend,NativeResult:null});
|
|
}
|
|
finally { File.Delete(path); }
|
|
});
|
|
|
|
Test("status blocking requires current local identity and rejects replay or stale frames",()=>
|
|
{
|
|
var local=(nint)0x10000;
|
|
var manager=local+StatusBlockPolicy.ManagerOffset;
|
|
var frame=new GameFrame(true,local,false,false,DungeonSnapshot.Empty,
|
|
LocalStatusManagerAddress:manager,LocalEntityId:42);
|
|
Check(StatusBlockPolicy.IsLocal(frame,manager,local,42));
|
|
Check(!StatusBlockPolicy.IsLocal(frame,manager+16,local,42));
|
|
Check(!StatusBlockPolicy.IsLocal(frame,manager,local+0x10000,42));
|
|
Check(!StatusBlockPolicy.IsLocal(frame,manager,local,43)); // recycled address
|
|
Check(!StatusBlockPolicy.IsLocal(frame,manager,0,42));
|
|
Check(!StatusBlockPolicy.IsLocal(frame with { IsReplay=true },manager,local,42));
|
|
Check(!StatusBlockPolicy.IsLocal(frame with { LocalEntityId=0xE0000000 },manager,local,0xE0000000));
|
|
Check(!StatusBlockPolicy.IsLocal(frame with { LocalStatusManagerAddress=manager+16 },manager+16,local,42));
|
|
long now=0;
|
|
var frames=new FrameStore(()=>now,10);
|
|
frames.Publish(frame); now=11;
|
|
Check(!StatusBlockPolicy.IsLocal(frames.Read(),manager,local,42));
|
|
frames.Publish(frame); frames.Clear();
|
|
Check(!StatusBlockPolicy.IsLocal(frames.Read(),manager,local,42));
|
|
});
|
|
Test("status selection respects both list halves and immutable audited settings",()=>
|
|
{
|
|
var selected=new HashSet<uint>{1257,142,65535};
|
|
var settings=new SettingsSnapshot(new Dictionary<int,float>(),[],true,0,blockedStatuses:selected);
|
|
selected.Clear();
|
|
Check(settings.BlockedStatuses.SetEquals([1257u]));
|
|
foreach(var index in new[]{0,29,30,59}) Check(StatusBlockPolicy.IsSelected(index,1257,settings.BlockedStatuses));
|
|
foreach(var index in new[]{-1,60,int.MaxValue}) Check(!StatusBlockPolicy.IsSelected(index,1257,settings.BlockedStatuses));
|
|
Check(!StatusBlockPolicy.IsSelected(0,0,settings.BlockedStatuses));
|
|
Check(!StatusBlockPolicy.IsSelected(0,1681,settings.BlockedStatuses));
|
|
Check(!StatusBlockPolicy.IsSelected(0,142,new HashSet<uint>{142}));
|
|
Check(!StatusBlockPolicy.IsSelected(0,1257,new HashSet<uint>()));
|
|
});
|
|
Test("blocked status forwards an empty update once and preserves native result and refresh flag",()=>
|
|
{
|
|
foreach(byte refresh in new byte[]{0,1})
|
|
{
|
|
int calls=0;
|
|
StatusSetter original=(manager,index,id,remaining,param,source,flag)=>
|
|
{
|
|
calls++;
|
|
Check(manager==(nint)0x1234 && index==59 && flag==refresh);
|
|
Check(id==0 && remaining==0 && param==0 && source==0);
|
|
return 1; // batch caller must see a change and refresh flags
|
|
};
|
|
Check(StatusBlockPolicy.Forward(original,true,0x1234,59,1257,12.5f,7,0x123456789ABCDEF0,refresh)==1);
|
|
Check(calls==1);
|
|
}
|
|
});
|
|
Test("unselected status preserves every argument and original failure is never retried",()=>
|
|
{
|
|
int calls=0;
|
|
StatusSetter original=(manager,index,id,remaining,param,source,flag)=>
|
|
{
|
|
calls++;
|
|
Check(manager==(nint)0x1234 && index==30 && id==1681 && remaining==-12.5f);
|
|
Check(param==ushort.MaxValue && source==0x123456789ABCDEF0 && flag==1);
|
|
return 0;
|
|
};
|
|
Check(StatusBlockPolicy.Forward(original,false,0x1234,30,1681,-12.5f,ushort.MaxValue,0x123456789ABCDEF0,1)==0);
|
|
Check(calls==1);
|
|
calls=0;
|
|
StatusSetter failing=(_,_,_,_,_,_,_)=>{calls++;throw new InvalidOperationException();};
|
|
Throws<InvalidOperationException>(()=>StatusBlockPolicy.Forward(failing,true,0x1234,0,1257,0,0,0,0));
|
|
Check(calls==1);
|
|
});
|
|
|
|
Test("approved scope contains exactly 24 unique features",()=>
|
|
{
|
|
int[] expected=[..Enumerable.Range(1,11),..Enumerable.Range(13,8),27,28,29,36,37];
|
|
Check(Features.All.Select(f=>f.Id).SequenceEqual(expected));
|
|
});
|
|
Test("partial startup rolls back resources in reverse order",()=>
|
|
{
|
|
using var manager=new ModuleManager(); List<int> released=[];
|
|
manager.Register(9,s=> { s.Defer(()=>released.Add(1)); s.Defer(()=>released.Add(2)); throw new InvalidOperationException("scan failed"); });
|
|
Check(!manager.Enable(9)); Check(released.SequenceEqual([2,1]));
|
|
Check(!manager.IsEnabled(9)); Check(manager.Status[9].State==ModuleState.Failed);
|
|
});
|
|
Test("cleanup failure does not skip remaining resources",()=>
|
|
{
|
|
var scope=new ResourceScope(); var released=false;
|
|
scope.Defer(()=>released=true); scope.Defer(()=>throw new InvalidOperationException());
|
|
Throws<AggregateException>(scope.Dispose); Check(released); scope.Dispose();
|
|
});
|
|
Test("repeated enable and disable are idempotent",()=>
|
|
{
|
|
var manager=new ModuleManager(); int started=0,released=0;
|
|
manager.Register(9,s=>{started++;s.Defer(()=>released++);});
|
|
Check(manager.Enable(9)); Check(manager.Enable(9)); manager.Disable(9); manager.Disable(9); manager.Dispose(); manager.Dispose();
|
|
Check(started==1 && released==1); Throws<ObjectDisposedException>(()=>manager.Enable(9));
|
|
});
|
|
Test("dependent modules stop before their dependency",()=>
|
|
{
|
|
using var manager=new ModuleManager(); List<int> stopped=[];
|
|
manager.Register(9,s=>s.Defer(()=>stopped.Add(9)));
|
|
manager.Register(10,s=>s.Defer(()=>stopped.Add(10)),9);
|
|
Check(!manager.Enable(10)); Check(manager.Enable(9)); Check(manager.Enable(10));
|
|
manager.Disable(9); Check(stopped.SequenceEqual([10,9]));
|
|
});
|
|
Test("one unavailable module does not block another",()=>
|
|
{
|
|
using var manager=new ModuleManager(); manager.Register(11,_=>{});
|
|
Check(!manager.Enable(8)); Check(manager.Enable(11)); Check(manager.IsEnabled(11));
|
|
});
|
|
Test("failed cleanup prevents duplicate native attachment",()=>
|
|
{
|
|
using var manager=new ModuleManager(); var starts=0;
|
|
manager.Register(9,s=>{starts++;s.Defer(()=>throw new InvalidOperationException("dispose failed"));});
|
|
Check(manager.Enable(9)); manager.Disable(9); Check(!manager.Enable(9)); Check(starts==1);
|
|
});
|
|
Test("deep dungeon special floors and unknown state",()=>
|
|
{
|
|
Check(Policies.HeightDelta(true,true,new(true,100,1,9),-7)==-7);
|
|
Check(Policies.HeightDelta(true,true,new(true,100,1,10),-7)==0);
|
|
Check(Policies.HeightDelta(true,true,new(true,1108,3,99),-7)==0);
|
|
Check(Policies.HeightDelta(true,true,new(true,1290,4,99),-7)==0);
|
|
Check(Policies.HeightDelta(true,true,new(true,100,1,99),-7)==-7);
|
|
Check(Policies.HeightDelta(true,true,DungeonSnapshot.Empty,-7)==0);
|
|
Check(Policies.HeightDelta(false,false,DungeonSnapshot.Empty,-7)==0);
|
|
Check(Policies.HeightDelta(true,false,DungeonSnapshot.Empty,-7)==-7);
|
|
});
|
|
Test("nonfinite and out of range parameters",()=>
|
|
{
|
|
Check(Policies.FiniteClamp(float.NaN,0,5,1)==1);
|
|
Check(Policies.FiniteClamp(float.PositiveInfinity,0,5)==0);
|
|
Check(Policies.FiniteClamp(999,0,5)==5);
|
|
Check(Policies.HeightDelta(true,false,DungeonSnapshot.Empty,float.NaN)==0);
|
|
});
|
|
Test("timeline list semantics and empty list",()=>
|
|
{
|
|
HashSet<uint> list=[1,2];
|
|
Check(Policies.BlockTimeline(1,list,true)); Check(!Policies.BlockTimeline(3,list,true));
|
|
Check(!Policies.BlockTimeline(1,list,false)); Check(Policies.BlockTimeline(3,list,false));
|
|
Check(!Policies.BlockTimeline(1,new HashSet<uint>(),false));
|
|
});
|
|
Test("fall flag transformation preserves unrelated bits",()=>
|
|
{
|
|
Check(Policies.FallFlags(0x80000000)==0x80000000);
|
|
Check((Policies.FallFlags(0x80000700)&0x80000000)!=0);
|
|
Check((Policies.FallFlags(0x700)&0x700)==0);
|
|
});
|
|
const string mapping="""{"version-a":{"UP_PingReq":[1],"DOWN_PingRsp":[1],"UP_EventStart":[3]}}""";
|
|
Test("opcode version and direction isolation",()=>
|
|
{
|
|
var map=new OpcodeMap(); map.Load(mapping,"version-a","fixture");
|
|
Check(map.Resolve("version-a",PacketDirection.Up,1)=="UP_PingReq");
|
|
Check(map.Resolve("version-a",PacketDirection.Down,1)=="DOWN_PingRsp");
|
|
Check(map.Resolve("version-b",PacketDirection.Up,1)==null);
|
|
Check(map.Resolve("unknown",PacketDirection.Up,1)==null);
|
|
});
|
|
Test("invalid mapping updates retain previous mapping",()=>
|
|
{
|
|
var map=new OpcodeMap(); map.Load(mapping,"version-a","fixture");
|
|
Throws<InvalidDataException>(()=>map.Load("""{"version-a":{"UP_A":[1],"UP_B":[1]}}""","version-a","bad"));
|
|
Throws<InvalidDataException>(()=>map.Load("""{"version-a":{"UP_A":[65536]}}""","version-a","bad"));
|
|
Throws<InvalidDataException>(()=>map.Load(mapping,"missing","bad"));
|
|
Check(map.Resolve("version-a",PacketDirection.Up,1)=="UP_PingReq");
|
|
});
|
|
Test("packet parser rejects malformed and ambiguous payloads",()=>
|
|
{
|
|
Check(!PacketParser.Parse("UP_PingReq",new byte[2]).Parsed);
|
|
Check(PacketParser.Parse("UP_PingReq",new byte[28]).Parsed);
|
|
Check(!PacketParser.Parse("UP_EventAction",new byte[1028]).Parsed);
|
|
Check(!PacketParser.Parse(null,new byte[8]).Parsed);
|
|
var effect=new byte[4+0x58]; effect[0]=17;
|
|
Check(!PacketParser.Parse("DOWN_EffectResult",effect).Parsed);
|
|
effect[0]=2; Check(!PacketParser.Parse("DOWN_EffectResult",effect).Parsed);
|
|
effect[0]=1; effect[4+0x15]=5; Check(!PacketParser.Parse("DOWN_EffectResult",effect).Parsed);
|
|
effect[4+0x15]=4; Check(PacketParser.Parse("DOWN_EffectResult",effect).Parsed);
|
|
var movement=new byte[40]; BinaryPrimitives.WriteSingleLittleEndian(movement.AsSpan(12),float.NaN);
|
|
Check(!PacketParser.Parse("UP_UpdatePositionHandler",movement).Parsed);
|
|
});
|
|
Test("bounded buffer pause filter and copy ownership",()=>
|
|
{
|
|
var buffer=new PacketBuffer(2,10); byte[] data=[1,2];
|
|
Check(!buffer.Add(PacketDirection.Up,1,"a",null,null,data)); buffer.Paused=false;
|
|
Check(buffer.Add(PacketDirection.Up,1,"a",null,null,data)); data[0]=9;
|
|
Check(buffer.Snapshot()[0].Payload[0]==1);
|
|
buffer.Add(PacketDirection.Up,2,"a",null,null,data); buffer.Add(PacketDirection.Down,3,"a",null,null,data);
|
|
Check(buffer.Snapshot().Length==2 && buffer.Dropped==1);
|
|
Check(!buffer.Add(PacketDirection.Up,1,"a",null,null,new byte[11])); Check(buffer.Dropped==2);
|
|
buffer.DirectionFilter=PacketDirection.Up; Check(!buffer.Add(PacketDirection.Down,1,"a",null,null,data));
|
|
});
|
|
Test("offline import preserves chronology and invalid replacement is atomic",()=>
|
|
{
|
|
var buffer=new PacketBuffer(2); var time=DateTimeOffset.Parse("2026-01-01T00:00:00Z");
|
|
PacketRecord[] records=[new(42,99,time,PacketDirection.Up,1,"a",3,4,[5])];
|
|
buffer.Replace(records); Check(buffer.Snapshot()[0].Time==time && buffer.Snapshot()[0].Sequence==42);
|
|
Throws<InvalidDataException>(()=>buffer.Replace([records[0] with {Opcode=-1}]));
|
|
Check(buffer.Snapshot()[0].Sequence==42);
|
|
});
|
|
Test("ping correlation uses echoed value and version, not unrelated packets",()=>
|
|
{
|
|
var map=new OpcodeMap(); map.Load(mapping,"version-a","fixture");
|
|
var now=DateTimeOffset.UtcNow; var up=new byte[28]; var down=new byte[16];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(up,123); BinaryPrimitives.WriteUInt32LittleEndian(down,123);
|
|
PacketRecord[] packets=[new(1,0,now,PacketDirection.Up,1,"version-a",null,null,up),new(2,0,now.AddMilliseconds(75),PacketDirection.Down,1,"version-a",null,null,down)];
|
|
var pairs=PacketCorrelator.Ping(packets,map); Check(pairs.Length==1 && pairs[0].Elapsed.TotalMilliseconds==75);
|
|
Check(PacketCorrelator.Ping([packets[0],packets[1] with {GameVersion="version-b"}],map).Length==0);
|
|
});
|
|
Test("random truncated buffers never throw or read beyond input",()=>
|
|
{
|
|
var random=new Random(701); string?[] names=[null,"UP_PingReq","DOWN_PingRsp","UP_UpdatePositionHandler","UP_UpdatePositionInstance","UP_EventStart","UP_EventAction","DOWN_EffectResult"];
|
|
for(var i=0;i<3000;i++) { var data=new byte[random.Next(0,1800)]; random.NextBytes(data); PacketParser.Parse(names[i%names.Length],data); }
|
|
});
|
|
Test("metadata events cannot masquerade as complete payloads",()=>
|
|
{
|
|
var map=new OpcodeMap(); map.Load(mapping,"version-a","fixture");
|
|
var buffer=new PacketBuffer(); buffer.Paused=false;
|
|
buffer.Add(PacketDirection.Up,1,"version-a",null,null,ReadOnlySpan<byte>.Empty,false);
|
|
Check(!buffer.Snapshot()[0].PayloadComplete);
|
|
Check(PacketCorrelator.Ping(buffer.Snapshot(),map).Length==0);
|
|
});
|
|
Test("concurrent capture stays bounded with unique ordered sequences",()=>
|
|
{
|
|
var buffer=new PacketBuffer(256); buffer.Paused=false;
|
|
Parallel.For(0,8000,i=>buffer.Add(PacketDirection.Up,i%65536,"a",null,null,ReadOnlySpan<byte>.Empty,false));
|
|
var records=buffer.Snapshot();
|
|
Check(records.Length==256 && buffer.Dropped==7744);
|
|
Check(records.Select(p=>p.Sequence).Distinct().Count()==256);
|
|
Check(records.Zip(records.Skip(1)).All(p=>p.First.Sequence<p.Second.Sequence));
|
|
});
|
|
Test("packet export roundtrip preserves timestamps and metadata completeness",()=>
|
|
{
|
|
var path=Path.Combine(AppContext.BaseDirectory,"roundtrip-"+Guid.NewGuid()+".jsonl");
|
|
try
|
|
{
|
|
PacketRecord[] data=[new(42,10,DateTimeOffset.Parse("2026-01-01T00:00:00+08:00"),PacketDirection.Down,2,"a",null,12,[],false)];
|
|
PacketFiles.Export(path,data);
|
|
var loaded=PacketFiles.Import(path);
|
|
Check(loaded.Length==1 && loaded[0].Time==data[0].Time && loaded[0].Sequence==42 && !loaded[0].PayloadComplete);
|
|
}
|
|
finally { if(File.Exists(path)) File.Delete(path); }
|
|
});
|
|
Test("native worker reads only the published frame",()=>
|
|
{
|
|
long now=100;
|
|
var frames=new FrameStore(()=>Interlocked.Read(ref now),500);
|
|
Check(!frames.Read().Ready);
|
|
var expected=new GameFrame(true,(nint)123,true,false,new(true,1108,3,98));
|
|
frames.Publish(expected);
|
|
Parallel.For(0,10000,_=>Check(frames.Read()==expected));
|
|
now=601; Check(!frames.Read().Ready && frames.Read().LocalAddress==0);
|
|
now=200; frames.Publish(expected); frames.Clear(); Check(!frames.Read().Ready);
|
|
});
|
|
Test("published settings are isolated from UI collection mutations",()=>
|
|
{
|
|
Dictionary<int,float> values=new() { [14]=2 }; HashSet<uint> list=[10];
|
|
var settings=new SettingsSnapshot(values,list,true,2);
|
|
values[14]=5; list.Clear();
|
|
Parallel.For(0,5000,_=>Check(settings.Value(14)==2 && settings.Timelines.Contains(10)));
|
|
});
|
|
Test("acceleration preserves native output instead of restoring zero",()=>
|
|
{
|
|
float state=0; int calls=0;
|
|
AccelerationPolicy.Run(ref state,true,()=>{ Check(state==100); state=6; calls++; });
|
|
Check(state==6 && calls==1);
|
|
state=3;
|
|
AccelerationPolicy.Run(ref state,false,()=>{ Check(state==3); state=4; calls++; });
|
|
Check(state==4 && calls==2);
|
|
});
|
|
Test("acceleration applies only to the moving local owner",()=>
|
|
{
|
|
var frame=new GameFrame(true,(nint)123,false,false,DungeonSnapshot.Empty);
|
|
Check(AccelerationPolicy.ShouldPrime(frame,(nint)123,true,0));
|
|
Check(!AccelerationPolicy.ShouldPrime(frame,(nint)456,true,0));
|
|
Check(!AccelerationPolicy.ShouldPrime(frame,(nint)123,false,0));
|
|
Check(!AccelerationPolicy.ShouldPrime(frame with {IsSlowed=true},(nint)123,true,0));
|
|
Check(!AccelerationPolicy.ShouldPrime(frame,(nint)123,true,float.NaN));
|
|
});
|
|
Test("atomic snapshot export keeps previous file when serialization fails",()=>
|
|
{
|
|
var path=Path.Combine(AppContext.BaseDirectory,"atomic-"+Guid.NewGuid()+".jsonl");
|
|
IEnumerable<PacketRecord> Broken()
|
|
{
|
|
yield return new(1,0,DateTimeOffset.UtcNow,PacketDirection.Up,1,"a",null,null,[],false);
|
|
throw new InvalidOperationException("capture interrupted");
|
|
}
|
|
try
|
|
{
|
|
File.WriteAllText(path,"previous");
|
|
Throws<InvalidOperationException>(()=>PacketFiles.ExportAtomic(path,Broken()));
|
|
Check(File.ReadAllText(path)=="previous");
|
|
Check(Directory.GetFiles(AppContext.BaseDirectory,Path.GetFileName(path)+".*.tmp").Length==0);
|
|
}
|
|
finally { if(File.Exists(path)) File.Delete(path); }
|
|
});
|
|
Test("dash metadata decodes ushort ID without including flags",()=>
|
|
{
|
|
Check(DashPolicy.Timeline([0x34,0x12,0xF1,0xAF])==0x1234);
|
|
Check(DashPolicy.Timeline([0x34])==0);
|
|
});
|
|
Test("dash modes distinguish all-local from empty lists",()=>
|
|
{
|
|
HashSet<uint> empty=[], selected=[20];
|
|
Check(DashPolicy.Block(true,1,1,20,DashFilterMode.AllLocal,empty));
|
|
Check(!DashPolicy.Block(true,2,1,20,DashFilterMode.AllLocal,empty));
|
|
Check(!DashPolicy.Block(true,1,1,0,DashFilterMode.AllLocal,empty));
|
|
Check(!DashPolicy.Block(false,1,1,20,DashFilterMode.AllLocal,empty));
|
|
Check(!DashPolicy.Block(true,1,1,20,DashFilterMode.BlockListed,empty));
|
|
Check(!DashPolicy.Block(true,1,1,20,DashFilterMode.AllowListed,empty));
|
|
Check(DashPolicy.Block(true,1,1,20,DashFilterMode.BlockListed,selected));
|
|
Check(!DashPolicy.Block(true,1,1,20,DashFilterMode.AllowListed,selected));
|
|
Check(DashPolicy.Block(true,1,1,21,DashFilterMode.AllowListed,selected));
|
|
});
|
|
Test("target radius is expanded once while own radius remains unchanged",()=>
|
|
{
|
|
var frame=new GameFrame(true,1,false,false,DungeonSnapshot.Empty,TargetAddress:2,TargetEntityId:20);
|
|
Check(TargetRadiusPolicy.Adjust(frame,2,20,1,5)==6);
|
|
Check(TargetRadiusPolicy.Adjust(frame,1,10,0.5f,5)==0.5f);
|
|
Check(TargetRadiusPolicy.Adjust(frame,3,30,1,5)==1);
|
|
// Native landing uses target + own radius: 6 + 0.5, not the erroneous 6 + 5.5.
|
|
Check(TargetRadiusPolicy.Adjust(frame,2,20,1,5)+TargetRadiusPolicy.Adjust(frame,1,10,0.5f,5)==6.5f);
|
|
});
|
|
Test("render range and movement reads agree on the same target radius",()=>
|
|
{
|
|
var frame=new GameFrame(true,1,false,false,DungeonSnapshot.Empty,TargetAddress:2,TargetEntityId:20);
|
|
Check(TargetRadiusPolicy.Adjust(frame,2,20,1,5)==6);
|
|
using(NativeTargetRingScope.Enter(true)) Check(TargetRadiusPolicy.Adjust(frame,2,20,1,5)==6);
|
|
using(ActionRangeCheckScope.Enter(true)) Check(TargetRadiusPolicy.Adjust(frame,2,20,1,5)==6);
|
|
float workerResult=0;
|
|
var worker=new Thread(()=>workerResult=TargetRadiusPolicy.Adjust(frame,2,20,1,5)); worker.Start(); worker.Join();
|
|
Check(workerResult==6);
|
|
});
|
|
Test("target changes stale identities and invalid radii are handled",()=>
|
|
{
|
|
var frame=new GameFrame(true,1,false,false,DungeonSnapshot.Empty,TargetAddress:2,TargetEntityId:20);
|
|
Check(TargetRadiusPolicy.Adjust(frame,2,99,1,5)==1);
|
|
Check(TargetRadiusPolicy.Adjust(frame with {TargetAddress=3,TargetEntityId=30},2,20,1,5)==1);
|
|
Check(TargetRadiusPolicy.Adjust(frame with {Ready=false},2,20,1,5)==1);
|
|
Check(TargetRadiusPolicy.Adjust(frame with {TargetAddress=1,TargetEntityId=10},1,10,1,5)==1);
|
|
Check(TargetRadiusPolicy.Adjust(frame,2,20,-1,5)==-1);
|
|
Check(float.IsNaN(TargetRadiusPolicy.Adjust(frame,2,20,float.NaN,5)));
|
|
Check(TargetRadiusPolicy.Adjust(frame,2,20,1,float.NaN)==1);
|
|
});
|
|
Test("movement catalogue includes player displacement skills and excludes NPCs",()=>
|
|
{
|
|
var skills=MovementSkillCatalog.Select([
|
|
new(7386,"Onslaught",5189,true,true,false),new(29079,"Onslaught",12244,true,true,true),
|
|
new(42,"NPC movement",111,false,true,false),new(43,"Ordinary skill",222,true,false,false),
|
|
new(44,"Unmapped movement",0,true,true,false),new(45,"Shared",5189,true,true,false)]);
|
|
Check(skills.Length==4 && skills.Count(MovementSkillCatalog.Mappable)==3);
|
|
Check(skills.Where(MovementSkillCatalog.Mappable).Select(s=>s.Timeline).Distinct().Count()==2);
|
|
Check(!skills.Any(s=>s.ActionId is 42 or 43));
|
|
});
|
|
Test("audited status candidates exclude mismatched legacy entries",()=>
|
|
{
|
|
Check(StatusCatalog.AuditedBlockCandidates.Count==38);
|
|
Check(StatusCatalog.RejectedLegacyBlockIds.All(id=>!StatusCatalog.AuditedBlockCandidates.Contains(id)));
|
|
Check(StatusCatalog.AuditedBlockCandidates.Contains(604));
|
|
Check(StatusCatalog.AuditedBlockCandidates.Contains(1293));
|
|
Check(StatusCatalog.AuditedBlockCandidates.Contains(1422));
|
|
Check(!StatusCatalog.AuditedBlockCandidates.Contains(142));
|
|
});
|
|
Test("slow guard includes newly audited player Heavy rows",()=>
|
|
{
|
|
Check(StatusCatalog.SlowCandidates.Count==37 && StatusCatalog.AuditedSlow.Count==40);
|
|
Check(new uint[]{5044,5175,5356}.All(StatusCatalog.AuditedSlow.Contains));
|
|
Check(!StatusCatalog.AuditedSlow.Contains(630));
|
|
});
|
|
Test("permission, action and cooldown identifiers stay distinct",()=>
|
|
{
|
|
Check(NativeIds.MovementPermissionChecks.Contains(96));
|
|
Check(!NativeIds.MovementPermissionChecks.Contains(14));
|
|
Check(NativeIds.MudraActions.Contains(2259));
|
|
Check(!Policies.IsMudra(233)); // 233 is Ten's animation timeline, not its action ID.
|
|
Check(!Policies.IsMudra(-1));
|
|
Check(NativeIds.GlobalCooldownGroup==57);
|
|
});
|
|
Test("render diagnostics scope is independent of action diagnostics scope",()=>
|
|
{
|
|
Check(!NativeTargetRingScope.Active);
|
|
using(NativeTargetRingScope.Enter(true))
|
|
{
|
|
Check(NativeTargetRingScope.Active && !ActionRangeCheckScope.Active);
|
|
using(NativeTargetRingScope.Enter(false)) Check(!NativeTargetRingScope.Active);
|
|
Check(NativeTargetRingScope.Active);
|
|
}
|
|
Check(!NativeTargetRingScope.Active);
|
|
});
|
|
Test("render diagnostics scope unwinds after errors",()=>
|
|
{
|
|
using(NativeTargetRingScope.Enter(true))
|
|
{
|
|
bool workerState=true;
|
|
var worker=new Thread(()=>workerState=NativeTargetRingScope.Active); worker.Start(); worker.Join(); Check(!workerState);
|
|
}
|
|
try { using var scope=NativeTargetRingScope.Enter(true); throw new InvalidOperationException(); } catch(InvalidOperationException) { }
|
|
Check(!NativeTargetRingScope.Active);
|
|
});
|
|
Test("shared range resource releases its leases independently",()=>
|
|
{
|
|
int starts=0,stops=0;
|
|
var shared=new SharedModuleResource(s=>{starts++;s.Defer(()=>stops++);});
|
|
var a=new ResourceScope(); var b=new ResourceScope();
|
|
shared.Acquire(a); shared.Acquire(b); Check(starts==1);
|
|
a.Dispose(); Check(stops==0);
|
|
b.Dispose(); Check(stops==1);
|
|
using var c=new ResourceScope(); shared.Acquire(c); Check(starts==2);
|
|
});
|
|
Test("shared range startup failure cleans its partial resources",()=>
|
|
{
|
|
var stopped=false;
|
|
var shared=new SharedModuleResource(s=>{s.Defer(()=>stopped=true);throw new InvalidOperationException();});
|
|
using var owner=new ResourceScope(); Throws<InvalidOperationException>(()=>shared.Acquire(owner)); Check(stopped);
|
|
});
|
|
Test("failed shared cleanup prevents another module from attaching again",()=>
|
|
{
|
|
int starts=0;
|
|
var shared=new SharedModuleResource(s=>{starts++;s.Defer(()=>throw new InvalidOperationException("native cleanup failed"));});
|
|
var first=new ResourceScope(); shared.Acquire(first); Throws<AggregateException>(first.Dispose);
|
|
using var second=new ResourceScope(); Throws<InvalidOperationException>(()=>shared.Acquire(second)); Check(starts==1);
|
|
});
|
|
Test("explicit user validation persists across enabling and disabling",()=>
|
|
{
|
|
using var manager=new ModuleManager(); manager.Register(1,_=>{});
|
|
ValidationCatalog.Apply(manager,ValidationCatalog.ClientVersion);
|
|
Check(StatusPresentation.Summary(manager.Status[1])=="已实测有效 · 已关闭");
|
|
Check(manager.Enable(1)); Check(StatusPresentation.Summary(manager.Status[1])=="已实测有效 · 已开启");
|
|
manager.Disable(1); Check(StatusPresentation.Summary(manager.Status[1])=="已实测有效 · 已关闭");
|
|
Check(!StatusPresentation.ShowDetail(manager.Status[1]));
|
|
});
|
|
Test("untested unavailable and runtime failures have distinct wording",()=>
|
|
{
|
|
using var manager=new ModuleManager(); manager.Register(14,_=>{}); manager.Register(20,_=>{});
|
|
Check(StatusPresentation.Summary(manager.Status[14])=="待复测 · 已关闭");
|
|
Check(StatusPresentation.Summary(manager.Status[20])=="待实测 · 已关闭");
|
|
Check(StatusPresentation.Summary(manager.Status[8])=="暂不可用 · 已关闭");
|
|
manager.Status[14].State=ModuleState.Failed; Check(StatusPresentation.ShowDetail(manager.Status[14]));
|
|
Check(StatusPresentation.Summary(manager.Status[14]).Contains("运行异常"));
|
|
});
|
|
Test("validation list matches user confirmation and is version-specific",()=>
|
|
{
|
|
Check(ValidationCatalog.UserConfirmed.SetEquals(new[]{1,2,3,9,10,11,13,14,15,16,17,19,27,28,29}));
|
|
using var manager=new ModuleManager(); manager.Register(1,_=>{});
|
|
ValidationCatalog.Apply(manager,"other-version"); Check(manager.Status[1].Adaptation!=Adaptation.Verified);
|
|
manager.Register(14,_=>{}); ValidationCatalog.Apply(manager,ValidationCatalog.ClientVersion);
|
|
Check(manager.Status[14].Adaptation==Adaptation.Verified);
|
|
manager.Status[14].Adaptation=Adaptation.Testable;
|
|
ValidationCatalog.Apply(manager,ValidationCatalog.BaselineClientVersion);
|
|
Check(manager.Status[14].Adaptation==Adaptation.Verified);
|
|
});
|
|
Test("height preset is inactive until applied and second click returns zero",()=>
|
|
{
|
|
var height=new HeightActivation(); var preset=-7f;
|
|
Check(height.Applied==0 && !height.IsApplied);
|
|
height.Toggle(true,preset); Check(height.Applied==-7 && height.IsApplied);
|
|
preset=-3; Check(height.Applied==-7);
|
|
height.Toggle(true,preset); Check(height.Applied==0 && !height.IsApplied);
|
|
height.Toggle(true,preset); Check(height.Applied==-3);
|
|
});
|
|
Test("height activation rejects disabled module and resets between sessions",()=>
|
|
{
|
|
var height=new HeightActivation(); height.Toggle(false,-7); Check(height.Applied==0);
|
|
height.Toggle(true,-7); height.Reset(); Check(height.Applied==0);
|
|
height.Toggle(true,float.NaN); Check(height.Applied==0);
|
|
height.Toggle(true,999); Check(height.Applied==10);
|
|
height.Toggle(false,-4); Check(height.Applied==0);
|
|
Check(new HeightActivation().Applied==0);
|
|
});
|
|
Test("service bar distinguishes prepared applied and dungeon-suppressed height",()=>
|
|
{
|
|
var frame=new GameFrame(true,123,false,false,new(true,1,1,3));
|
|
var prepared=ServiceBarPresentation.Build(frame,ModuleState.Enabled,ModuleState.Disabled,ModuleState.Disabled,-7,0);
|
|
Check(prepared.Shown && prepared.Text.Contains("Y:SET") && prepared.Tooltip.Contains("effective 0"));
|
|
var active=ServiceBarPresentation.Build(frame,ModuleState.Enabled,ModuleState.Enabled,ModuleState.Enabled,-3,-7);
|
|
Check(active.Text=="IC Y:-7 D:1 F:3" && active.Tooltip.Contains("Preset -3, applied -7"));
|
|
var special=ServiceBarPresentation.Build(frame with {Dungeon=new(true,1,1,10)},ModuleState.Enabled,ModuleState.Enabled,ModuleState.Enabled,-7,-7);
|
|
Check(special.Text.Contains("Y:HOLD") && special.Text.Contains("F:10*") && special.Tooltip.Contains("effective 0"));
|
|
Check(new[]{prepared,active,special}.All(s=>s.Text.All(c=>c<128) && s.Tooltip.All(c=>c<128)));
|
|
});
|
|
Test("service bar clears stale state during logout and reports disabled or failed modules",()=>
|
|
{
|
|
var frame=new GameFrame(true,123,false,false,DungeonSnapshot.Empty);
|
|
var outside=ServiceBarPresentation.Build(frame,ModuleState.Enabled,ModuleState.Enabled,ModuleState.Enabled,-7,-7);
|
|
Check(outside.Text.Contains("Y:HOLD") && outside.Text.Contains("F:--"));
|
|
var off=ServiceBarPresentation.Build(frame,ModuleState.Disabled,ModuleState.Disabled,ModuleState.Disabled,-7,0);
|
|
Check(off.Text=="IC Y:OFF D:0 F:OFF");
|
|
var failed=ServiceBarPresentation.Build(frame,ModuleState.Failed,ModuleState.Disabled,ModuleState.Failed,-7,0);
|
|
Check(failed.Text.Contains("Y:ERR") && failed.Text.Contains("F:ERR"));
|
|
Check(!ServiceBarPresentation.Build(frame with {Ready=false},ModuleState.Enabled,ModuleState.Enabled,ModuleState.Enabled,-7,-7).Shown);
|
|
});
|
|
Test("native up capture uses declared length and excludes the 32-byte send prefix",()=>
|
|
{
|
|
var payload=Enumerable.Range(0,28).Select(i=>(byte)i).ToArray();
|
|
var memory=PacketMemoryFixture.Up(payload);
|
|
var result=NativePacketCapture.Up(memory.Read,0x1000,_=>true);
|
|
Check(result.Error==null && result.Packet is {Complete:true,Opcode:838,Target:null});
|
|
Check(result.Packet!.Payload.SequenceEqual(payload));
|
|
Check(memory.Reads.SequenceEqual(new[]{((nint)0x1000,16),((nint)0x1020,28)}));
|
|
result.Packet.Payload[0]=255; Check(memory.Blocks[0x1020][0]==0);
|
|
});
|
|
Test("native down capture follows owned message and excludes the IPC header",()=>
|
|
{
|
|
byte[] payload=[1,3,5,7,9]; var memory=PacketMemoryFixture.Down(payload);
|
|
var result=NativePacketCapture.Down(memory.Read,0x1000,_=>true);
|
|
Check(result.Error==null && result.Packet is {Complete:true,Opcode:354,Target:0x12345678});
|
|
Check(result.Packet!.Payload.SequenceEqual(payload));
|
|
Check(memory.Reads.SequenceEqual(new[]{((nint)0x1010,8),((nint)0x2020,32),((nint)0x3000,4),((nint)0x3010,5)}));
|
|
});
|
|
Test("native invalid lengths fall back without reading or allocating payloads",()=>
|
|
{
|
|
foreach(var length in new ulong[]{0,3,15,65553,0x100000010,ulong.MaxValue})
|
|
{
|
|
var up=PacketMemoryFixture.Up([],length);
|
|
var result=NativePacketCapture.Up(up.Read,0x1000,_=>true);
|
|
Check(result.Packet is {Complete:false,Payload.Length:0,Issue:not null}); Check(up.Reads.Count==1);
|
|
var down=PacketMemoryFixture.Down([],length);
|
|
var received=NativePacketCapture.Down(down.Read,0x1000,_=>true);
|
|
Check(length<4 ? received.Error!=null : received.Packet is {Complete:false,Payload.Length:0,Issue:not null});
|
|
Check(down.Reads.All(r=>r.Address!=0x3010));
|
|
}
|
|
});
|
|
Test("native truncated and partial memory reads never become complete packets",()=>
|
|
{
|
|
var up=PacketMemoryFixture.Up([1,2],32);
|
|
var down=PacketMemoryFixture.Down([1,2],32);
|
|
Check(NativePacketCapture.Up(up.Read,0x1000,_=>true).Packet is {Complete:false,Payload.Length:0});
|
|
Check(NativePacketCapture.Down(down.Read,0x1000,_=>true).Packet is {Complete:false,Payload.Length:0});
|
|
up.ShortReads=true; Check(NativePacketCapture.Up(up.Read,0x1000,_=>true).Error!=null);
|
|
down.Blocks.Remove(0x2020); Check(NativePacketCapture.Down(down.Read,0x1000,_=>true).Error!=null);
|
|
});
|
|
Test("native filters avoid copying payload and paused buffer rejects new records",()=>
|
|
{
|
|
var up=PacketMemoryFixture.Up([1,2,3]); var down=PacketMemoryFixture.Down([1,2,3]);
|
|
Check(NativePacketCapture.Up(up.Read,0x1000,_=>false).Packet==null && up.Reads.Count==1);
|
|
Check(NativePacketCapture.Down(down.Read,0x1000,_=>false).Packet==null && down.Reads.Count==3);
|
|
var buffer=new PacketBuffer(); Check(!buffer.Accepts(PacketDirection.Up));
|
|
buffer.Paused=false; buffer.DirectionFilter=PacketDirection.Up; buffer.OpcodeFilter=838;
|
|
Check(buffer.Accepts(PacketDirection.Up) && buffer.Accepts(PacketDirection.Up,838));
|
|
Check(!buffer.Accepts(PacketDirection.Down) && !buffer.Accepts(PacketDirection.Up,839));
|
|
buffer.Paused=true; Check(!buffer.Add(PacketDirection.Up,838,"v",null,null,[1]));
|
|
});
|
|
Test("native payload bounds include true empty payload and exact 64 KiB maximum",()=>
|
|
{
|
|
Check(NativePacketCapture.TryPayloadLength(16,out var empty) && empty==0);
|
|
Check(NativePacketCapture.TryPayloadLength(65552,out var maximum) && maximum==65536);
|
|
Check(!NativePacketCapture.TryPayloadLength(65553,out _));
|
|
var zero=PacketMemoryFixture.Down([]);
|
|
Check(NativePacketCapture.Down(zero.Read,0x1000,_=>true).Packet is {Complete:true,Payload.Length:0});
|
|
var full=PacketMemoryFixture.Up(new byte[65536]);
|
|
Check(NativePacketCapture.Up(full.Read,0x1000,_=>true).Packet is {Complete:true,Payload.Length:65536});
|
|
});
|
|
Test("native invalid pointers are rejected before memory access",()=>
|
|
{
|
|
var memory=new PacketMemoryFixture();
|
|
foreach(var pointer in new nint[]{0,-1,nint.MaxValue})
|
|
{
|
|
Check(NativePacketCapture.Up(memory.Read,pointer,_=>true).Error!=null);
|
|
Check(NativePacketCapture.Down(memory.Read,pointer,_=>true).Error!=null);
|
|
}
|
|
Check(memory.Reads.Count==0);
|
|
var down=PacketMemoryFixture.Down([]);
|
|
BinaryPrimitives.WriteUInt64LittleEndian(down.Blocks[0x1010],ulong.MaxValue);
|
|
Check(NativePacketCapture.Down(down.Read,0x1000,_=>true).Error!=null && down.Reads.Count==1);
|
|
});
|
|
Test("native downstream target normalization matches reserved IDs",()=>
|
|
{
|
|
Check(NativePacketCapture.NormalizeTarget(0x12345678)==0x12345678);
|
|
Check(NativePacketCapture.NormalizeTarget(0x5FF80000)==0x5FF80002);
|
|
Check(NativePacketCapture.NormalizeTarget(0x5FF80001)==0x5FF80001);
|
|
Check(NativePacketCapture.NormalizeTarget(0xFFFFFFFF)==0x5FF80001);
|
|
});
|
|
Test("capture failure reason survives export and legacy metadata remains incomplete",()=>
|
|
{
|
|
var path=Path.Combine(AppContext.BaseDirectory,"capture-issue-"+Guid.NewGuid()+".jsonl");
|
|
try
|
|
{
|
|
var buffer=new PacketBuffer {Paused=false};
|
|
buffer.Add(PacketDirection.Up,838,"v",null,null,[],false,"test failed copy");
|
|
PacketFiles.ExportAtomic(path,buffer.Snapshot());
|
|
var packet=PacketFiles.Import(path).Single(); Check(!packet.PayloadComplete && packet.CaptureIssue=="test failed copy");
|
|
var legacy=System.Text.Json.JsonSerializer.Serialize(packet with {CaptureIssue=null});
|
|
legacy=legacy.Replace(",\"CaptureIssue\":null",""); File.WriteAllText(path,legacy);
|
|
var old=PacketFiles.Import(path).Single(); Check(!old.PayloadComplete && old.CaptureIssue==null);
|
|
}
|
|
finally { File.Delete(path); }
|
|
});
|
|
Test("full-payload buffers bound total bytes across capture import and clear",()=>
|
|
{
|
|
var buffer=new PacketBuffer(10,16,5) {Paused=false};
|
|
Check(buffer.Add(PacketDirection.Up,1,"v",null,null,[1,2,3]));
|
|
Check(buffer.Add(PacketDirection.Down,2,"v",null,null,[4,5,6]));
|
|
Check(buffer.PayloadBytes==3 && buffer.Dropped==1 && buffer.Snapshot().Single().Opcode==2);
|
|
Check(!buffer.Add(PacketDirection.Up,3,"v",null,null,new byte[6])); Check(buffer.PayloadBytes==3);
|
|
var records=Enumerable.Range(0,4).Select(i=>new PacketRecord(i,i,DateTimeOffset.UtcNow,PacketDirection.Up,i,"v",null,null,new byte[2])).ToArray();
|
|
buffer.Replace(records); Check(buffer.PayloadBytes==4 && buffer.Dropped==2 && buffer.Snapshot().Select(p=>p.Opcode).SequenceEqual([2,3]));
|
|
buffer.Clear(); Check(buffer.PayloadBytes==0 && buffer.Dropped==0);
|
|
});
|
|
Test("current movement and ping payload lengths are version-bound",()=>
|
|
{
|
|
const string v=PacketParser.AuditedClientVersion;
|
|
Check(PacketParser.Parse("UP_UpdatePositionHandler",new byte[24],v).Parsed);
|
|
Check(!PacketParser.Parse("UP_UpdatePositionHandler",new byte[40],v).Parsed);
|
|
Check(!PacketParser.Parse("UP_UpdatePositionHandler",new byte[23],v).Parsed);
|
|
Check(PacketParser.Parse("UP_UpdatePositionHandler",new byte[40]).Parsed);
|
|
Check(PacketParser.Parse("UP_PingReq",new byte[32],v).Parsed);
|
|
Check(!PacketParser.Parse("UP_PingReq",new byte[28],v).Parsed);
|
|
Check(PacketParser.Parse("DOWN_PingRsp",new byte[32],v).Parsed);
|
|
Check(!PacketParser.Parse("DOWN_PingRsp",new byte[16],v).Parsed);
|
|
var bad=new byte[24]; BinaryPrimitives.WriteSingleLittleEndian(bad.AsSpan(8),float.NaN);
|
|
Check(!PacketParser.Parse("UP_UpdatePositionHandler",bad,v).Parsed);
|
|
});
|
|
Test("verified PingRsp fills only missing mappings for the audited version",()=>
|
|
{
|
|
const string v=PacketParser.AuditedClientVersion;
|
|
var map=new OpcodeMap();
|
|
map.Load($$$"""{"{{{v}}}":{"UP_PingReq":[838]}}""",v,"test");
|
|
Check(map.Resolve(v,PacketDirection.Down,338)=="DOWN_PingRsp"); Check(map.Snapshot.Source.Contains("本机核实"));
|
|
map.Load($$$"""{"{{{v}}}":{"DOWN_Explicit":[338]}}""",v,"test");
|
|
Check(map.Resolve(v,PacketDirection.Down,338)=="DOWN_Explicit");
|
|
map.Load($$$"""{"{{{v}}}":{"DOWN_PingRsp":[339]}}""",v,"test");
|
|
Check(map.Resolve(v,PacketDirection.Down,338)==null);
|
|
map.Load("""{"other":{"UP_PingReq":[838]}}""","other","test"); Check(map.Resolve("other",PacketDirection.Down,338)==null);
|
|
Throws<InvalidDataException>(()=>map.Load($$$"""{"{{{v}}}":{}}""",v,"test"));
|
|
});
|
|
Test("current Ping pairs require full exact-length payload and matching echo",()=>
|
|
{
|
|
const string v=PacketParser.AuditedClientVersion;
|
|
var map=new OpcodeMap(); map.Load($$$"""{"{{{v}}}":{"UP_PingReq":[838]}}""",v,"test");
|
|
var request=new byte[32]; var response=new byte[32];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(request,14606677); BinaryPrimitives.WriteUInt32LittleEndian(response,14606677);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(response.AsSpan(4),333); // not RTT; do not use it as elapsed time
|
|
var time=DateTimeOffset.UtcNow;
|
|
var a=new PacketRecord(1,0,time,PacketDirection.Up,838,v,null,null,request);
|
|
var b=new PacketRecord(2,0,time.AddMilliseconds(83),PacketDirection.Down,338,v,null,null,response);
|
|
var pairs=PacketCorrelator.Ping([a,b],map); Check(pairs.Length==1 && pairs[0].Elapsed.TotalMilliseconds==83);
|
|
Check(PacketCorrelator.Ping([a with {Payload=new byte[28]},b],map).Length==0);
|
|
Check(PacketCorrelator.Ping([a,b with {PayloadComplete=false}],map).Length==0);
|
|
Check(PacketCorrelator.Ping([a,b with {Payload=new byte[32]}],map).Length==0);
|
|
});
|
|
Test("audited protocol additions preserve explicit opcode and name assignments",()=>
|
|
{
|
|
var down=new Dictionary<int,string>{{671,"DOWN_Explicit"},{100,"DOWN_UpdateHater"}};
|
|
Check(CurrentProtocolEvidence.AddMissing("other",down)==0);
|
|
Check(CurrentProtocolEvidence.AddMissing(PacketParser.AuditedClientVersion,down)==5);
|
|
Check(down[671]=="DOWN_Explicit" && !down.ContainsKey(558));
|
|
Check(down[697]=="DOWN_EffectResultBasic" && down[338]=="DOWN_PingRsp");
|
|
Check(CurrentProtocolEvidence.AddMissing(PacketParser.AuditedClientVersion,down)==0);
|
|
});
|
|
Test("enmity parsers respect capacities and native byte value stride",()=>
|
|
{
|
|
foreach(var (name,capacity) in new[]{("DOWN_UpdateHate",8),("DOWN_UpdateHater",32)})
|
|
{
|
|
var payload=new byte[8+capacity*8]; payload[0]=(byte)capacity;
|
|
for(var i=0;i<capacity;i++) { BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4+i*8),(uint)(100+i)); payload[8+i*8]=(byte)i; payload[9+i*8]=255; }
|
|
var parsed=PacketParser.Parse(name,payload,PacketParser.AuditedClientVersion);
|
|
Check(parsed.Parsed && parsed.Summary.Contains("0x00000064:0"));
|
|
payload[0]=(byte)(capacity+1); Check(!PacketParser.Parse(name,payload,PacketParser.AuditedClientVersion).Parsed);
|
|
payload[0]=0; Check(PacketParser.Parse(name,payload,PacketParser.AuditedClientVersion).Parsed);
|
|
Check(!PacketParser.Parse(name,payload[..^1],PacketParser.AuditedClientVersion).Parsed);
|
|
Check(!PacketParser.Parse(name,payload,"other").Parsed);
|
|
}
|
|
});
|
|
Test("basic effect result decodes only audited fields and rejects unsupported counts",()=>
|
|
{
|
|
var payload=new byte[24]; payload[0]=1;
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4),123);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12),456); payload[16]=7;
|
|
var result=PacketParser.Parse("DOWN_EffectResultBasic",payload,PacketParser.AuditedClientVersion);
|
|
Check(result.Parsed && result.Summary.Contains("序列=123") && result.Summary.Contains("HP=456") && result.Summary.Contains("目标序号=7"));
|
|
payload[0]=2; Check(!PacketParser.Parse("DOWN_EffectResultBasic",payload,PacketParser.AuditedClientVersion).Parsed);
|
|
payload[0]=0; Check(PacketParser.Parse("DOWN_EffectResultBasic",payload,PacketParser.AuditedClientVersion).Parsed);
|
|
Check(!PacketParser.Parse("DOWN_EffectResultBasic",payload[..^1],PacketParser.AuditedClientVersion).Parsed);
|
|
Check(!PacketParser.Parse("DOWN_EffectResultBasic",payload,"other").Parsed);
|
|
});
|
|
Test("new protocol parsers bound arbitrary lengths and counts",()=>
|
|
{
|
|
var random=new Random(671697558);
|
|
string[] names=["DOWN_UpdateHate","DOWN_UpdateHater","DOWN_EffectResultBasic"];
|
|
for(var i=0;i<1500;i++) { var payload=new byte[random.Next(0,300)]; random.NextBytes(payload); PacketParser.Parse(names[i%3],payload,PacketParser.AuditedClientVersion); }
|
|
});
|
|
Test("remaining fixed packets decode only proven fields and retain 64-bit tag identity",()=>
|
|
{
|
|
var p=new byte[16]; p[0]=31;
|
|
BinaryPrimitives.WriteUInt16LittleEndian(p.AsSpan(2),100);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(p.AsSpan(8),123456);
|
|
Check(PacketParser.Parse("DOWN_JobGauge",p,PacketParser.AuditedClientVersion).Summary.Contains("职业 ID=31"));
|
|
var info=PacketParser.Parse("DOWN_UpdateClassInfo",p,PacketParser.AuditedClientVersion);
|
|
Check(info.Parsed && info.Summary.Contains("当前等级=100") && info.Summary.Contains("经验=123456"));
|
|
p[0]=2; BinaryPrimitives.WriteUInt64LittleEndian(p.AsSpan(8),0x123456789ABCDEF0);
|
|
var tag=PacketParser.Parse("DOWN_FirstAttack",p,PacketParser.AuditedClientVersion);
|
|
Check(tag.Parsed && tag.Summary.Contains("类型=2") && tag.Summary.Contains("123456789ABCDEF0"));
|
|
foreach(var name in new[]{"DOWN_JobGauge","DOWN_UpdateClassInfo","DOWN_FirstAttack"})
|
|
{
|
|
Check(!PacketParser.Parse(name,p[..15],PacketParser.AuditedClientVersion).Parsed);
|
|
Check(!PacketParser.Parse(name,new byte[17],PacketParser.AuditedClientVersion).Parsed);
|
|
Check(!PacketParser.Parse(name,p,"other").Parsed);
|
|
}
|
|
});
|
|
Test("gauge evidence label preserves explicit ActorGauge mapping",()=>
|
|
{
|
|
var map=new Dictionary<int,string>{{8,"DOWN_ActorGauge"}};
|
|
CurrentProtocolEvidence.AddMissing(PacketParser.AuditedClientVersion,map);
|
|
Check(map[8]=="DOWN_ActorGauge" && map[614]=="DOWN_JobGauge");
|
|
Check(map[788]=="DOWN_UpdateClassInfo" && map[214]=="DOWN_FirstAttack");
|
|
});
|
|
Test("stage 2 upstream action, trigger and event tier parsers enforce audited sizes and capacities",()=>
|
|
{
|
|
const string v=PacketParser.AuditedClientVersion;
|
|
var act=new byte[32]; BinaryPrimitives.WriteUInt32LittleEndian(act,7535); act[5]=1; BinaryPrimitives.WriteUInt16LittleEndian(act.AsSpan(6),180);
|
|
Check(PacketParser.Parse("UP_ActionSend",act,v).Summary.Contains("请求序号=180"));
|
|
Check(!PacketParser.Parse("UP_ActionSend",new byte[24],v).Parsed && !PacketParser.Parse("UP_ActionSend",act,"other").Parsed);
|
|
|
|
var pos=new byte[24]; BinaryPrimitives.WriteUInt32LittleEndian(pos,3573); pos[5]=1; BinaryPrimitives.WriteUInt16LittleEndian(pos.AsSpan(6),181);
|
|
BinaryPrimitives.WriteSingleLittleEndian(pos.AsSpan(12),10f); BinaryPrimitives.WriteSingleLittleEndian(pos.AsSpan(16),20f); BinaryPrimitives.WriteSingleLittleEndian(pos.AsSpan(20),30f);
|
|
Check(PacketParser.Parse("UP_ActionSendPos",pos,v).Parsed);
|
|
Check(!PacketParser.Parse("UP_ActionSendPos",new byte[32],v).Parsed);
|
|
BinaryPrimitives.WriteSingleLittleEndian(pos.AsSpan(16),float.NaN); Check(!PacketParser.Parse("UP_ActionSendPos",pos,v).Parsed);
|
|
|
|
var trig=new byte[32]; BinaryPrimitives.WriteUInt32LittleEndian(trig,3); BinaryPrimitives.WriteUInt32LittleEndian(trig.AsSpan(4),1);
|
|
Check(PacketParser.Parse("UP_ClientTrigger",trig,v).Summary.Contains("指令=3"));
|
|
Check(!PacketParser.Parse("UP_ClientTrigger",new byte[24],v).Parsed);
|
|
|
|
foreach(var (suffix,cap,len) in new[]{("",2,16),("4",4,24),("8",8,40),("16",16,72),("32",32,136),("64",64,264),("128",128,520),("255",255,1032)})
|
|
{
|
|
foreach(var prefix in new[]{"UP_EventFinish","UP_EventAction"})
|
|
{
|
|
var p=new byte[len]; p[7]=(byte)cap;
|
|
Check(PacketParser.Parse(prefix+suffix,p,v).Parsed);
|
|
Check(!PacketParser.Parse(prefix+suffix,new byte[len-1],v).Parsed);
|
|
if(cap<255) { p[7]=(byte)(cap+1); Check(!PacketParser.Parse(prefix+suffix,p,v).Parsed); }
|
|
}
|
|
}
|
|
});
|
|
Test("stage 2 downstream combat, status and spawn parsers enforce audited sizes, counts and finite floats",()=>
|
|
{
|
|
const string v=PacketParser.AuditedClientVersion;
|
|
Check(PacketParser.Parse("DOWN_ActorControl",new byte[24],v).Parsed && !PacketParser.Parse("DOWN_ActorControl",new byte[32],v).Parsed);
|
|
Check(PacketParser.Parse("DOWN_ActorControlSelf",new byte[40],v).Parsed && !PacketParser.Parse("DOWN_ActorControlSelf",new byte[32],v).Parsed);
|
|
Check(PacketParser.Parse("DOWN_ActorControlTarget",new byte[32],v).Parsed && !PacketParser.Parse("DOWN_ActorControlTarget",new byte[24],v).Parsed);
|
|
Check(PacketParser.Parse("DOWN_ActorDelete",new byte[8],v).Parsed && !PacketParser.Parse("DOWN_ActorDelete",new byte[16],v).Parsed);
|
|
|
|
var eff=new byte[124]; eff[0x21]=1; BinaryPrimitives.WriteUInt16LittleEndian(eff.AsSpan(0x18),190); BinaryPrimitives.WriteSingleLittleEndian(eff.AsSpan(0x10),0.6f);
|
|
Check(PacketParser.Parse("DOWN_Effect",eff,v).Summary.Contains("请求序号=190"));
|
|
eff[0x21]=2; Check(!PacketParser.Parse("DOWN_Effect",eff,v).Parsed);
|
|
eff[0x21]=1; BinaryPrimitives.WriteSingleLittleEndian(eff.AsSpan(0x10),float.PositiveInfinity); Check(!PacketParser.Parse("DOWN_Effect",eff,v).Parsed);
|
|
Check(!PacketParser.Parse("DOWN_Effect",new byte[120],v).Parsed);
|
|
|
|
var er=new byte[96]; er[0]=1; er[0x19]=2; BinaryPrimitives.WriteUInt16LittleEndian(er.AsSpan(0x1E),1200); BinaryPrimitives.WriteSingleLittleEndian(er.AsSpan(0x24),15f);
|
|
Check(PacketParser.Parse("DOWN_EffectResult",er,v).Summary.Contains("状态数=2"));
|
|
er[0x19]=5; Check(!PacketParser.Parse("DOWN_EffectResult",er,v).Parsed);
|
|
er[0x19]=1; BinaryPrimitives.WriteSingleLittleEndian(er.AsSpan(0x24),float.NaN); Check(!PacketParser.Parse("DOWN_EffectResult",er,v).Parsed);
|
|
var legacyEr=new byte[92]; legacyEr[0]=1; Check(PacketParser.Parse("DOWN_EffectResult",legacyEr,"legacy").Parsed);
|
|
|
|
var sl=new byte[384]; sl[0]=37; sl[1]=100; BinaryPrimitives.WriteUInt16LittleEndian(sl.AsSpan(20),48); BinaryPrimitives.WriteSingleLittleEndian(sl.AsSpan(24),30f);
|
|
Check(PacketParser.Parse("DOWN_StatusEffectList",sl,v).Summary.Contains("非空槽位=1/30"));
|
|
BinaryPrimitives.WriteSingleLittleEndian(sl.AsSpan(24),float.NaN); Check(!PacketParser.Parse("DOWN_StatusEffectList",sl,v).Parsed);
|
|
|
|
var obj=new byte[64];
|
|
Check(PacketParser.Parse("DOWN_ObjectSpawn",obj,v).Parsed);
|
|
BinaryPrimitives.WriteSingleLittleEndian(obj.AsSpan(0x34),float.NaN); Check(!PacketParser.Parse("DOWN_ObjectSpawn",obj,v).Parsed);
|
|
|
|
var npc=new byte[656]; npc[0x86]=100;
|
|
Check(PacketParser.Parse("DOWN_NpcSpawn",npc,v).Summary.Contains("等级=100"));
|
|
BinaryPrimitives.WriteSingleLittleEndian(npc.AsSpan(0x200),float.NaN); Check(!PacketParser.Parse("DOWN_NpcSpawn",npc,v).Parsed);
|
|
|
|
var random=new Random(20261012);
|
|
string[] names=["UP_ActionSend","UP_ActionSendPos","UP_ClientTrigger","UP_EventFinish16","UP_EventAction32","DOWN_ActorControl","DOWN_ActorControlSelf","DOWN_ActorControlTarget","DOWN_Effect","DOWN_EffectResult","DOWN_StatusEffectList","DOWN_ActorDelete","DOWN_ObjectSpawn","DOWN_NpcSpawn"];
|
|
for(var i=0;i<1500;i++) { var payload=new byte[random.Next(0,700)]; random.NextBytes(payload); PacketParser.Parse(names[i%names.Length],payload,v); }
|
|
});
|
|
Test("action correlator pairs UP_ActionSend and UP_ActionSendPos with DOWN_Effect by positive RequestId",()=>
|
|
{
|
|
const string v=PacketParser.AuditedClientVersion;
|
|
var map=new OpcodeMap(); map.Load($$$"""{"{{{v}}}":{"UP_ActionSend":[913],"UP_ActionSendPos":[891],"DOWN_Effect":[787]}}""",v,"test");
|
|
var t=DateTimeOffset.UtcNow;
|
|
var send=new byte[32]; BinaryPrimitives.WriteUInt16LittleEndian(send.AsSpan(6),179);
|
|
var sendPos=new byte[24]; BinaryPrimitives.WriteUInt16LittleEndian(sendPos.AsSpan(6),180);
|
|
var eff1=new byte[124]; eff1[0x21]=1; BinaryPrimitives.WriteUInt16LittleEndian(eff1.AsSpan(0x18),179);
|
|
var eff2=new byte[124]; eff2[0x21]=1; BinaryPrimitives.WriteUInt16LittleEndian(eff2.AsSpan(0x18),180);
|
|
var effZero=new byte[124]; effZero[0x21]=1;
|
|
var records=new[]{
|
|
new PacketRecord(10,1,t,PacketDirection.Up,913,v,null,null,send),
|
|
new PacketRecord(11,2,t.AddMilliseconds(50),PacketDirection.Down,787,v,null,null,effZero),
|
|
new PacketRecord(12,3,t.AddMilliseconds(95),PacketDirection.Down,787,v,null,null,eff1),
|
|
new PacketRecord(13,4,t.AddMilliseconds(200),PacketDirection.Up,891,v,null,null,sendPos),
|
|
new PacketRecord(14,5,t.AddMilliseconds(280),PacketDirection.Down,787,v,null,null,eff2)
|
|
};
|
|
var pairs=PacketCorrelator.Actions(records,map);
|
|
Check(pairs.Length==2 && pairs[0].Request==10 && pairs[0].Response==12 && pairs[0].Elapsed.TotalMilliseconds==95);
|
|
Check(pairs[1].Request==13 && pairs[1].Response==14 && pairs[1].Elapsed.TotalMilliseconds==80);
|
|
});
|
|
Test("stage 3 M0 adaptation manifest distinguishes missing implementation, version mismatch, offset mismatch, signature issues and runtime faults",()=>
|
|
{
|
|
const string v=PacketParser.AuditedClientVersion;
|
|
using var manager=new ModuleManager();
|
|
foreach(var f in Features.All.Where(f=>f.Id!=20)) manager.Register(f.Id,_=>{});
|
|
|
|
var okReport=AdaptationManifest.Evaluate(manager,v,_=>1,true);
|
|
Check(okReport.ReadyCount==23 && okReport.BlockedCount==1 && okReport.ActiveSendReady);
|
|
Check(okReport.Entries.Single(m=>m.FeatureId==20).Issue==AdaptationIssue.MissingImplementation);
|
|
|
|
var baseReport=AdaptationManifest.Evaluate(manager,PacketParser.BaselineClientVersion,_=>1,true);
|
|
Check(baseReport.ReadyCount==23 && baseReport.BlockedCount==1 && baseReport.ActiveSendReady);
|
|
|
|
AdaptationManifest.ApplyGate(manager,okReport);
|
|
Check(manager.Enable(8) && manager.Enable(36) && !manager.Enable(20));
|
|
|
|
var verReport=AdaptationManifest.Evaluate(manager,"2026.99.99.0000.0000",_=>1,true);
|
|
Check(!verReport.ActiveSendReady && verReport.ActiveSendSummary.Contains("仅核对"));
|
|
Check(verReport.Entries.Single(m=>m.FeatureId==8).Issue==AdaptationIssue.VersionMismatch);
|
|
Check(verReport.Entries.Single(m=>m.FeatureId==36).Issue==AdaptationIssue.VersionMismatch);
|
|
Check(verReport.Entries.Single(m=>m.FeatureId==1).Issue==AdaptationIssue.None);
|
|
Check(verReport.Entries.Single(m=>m.FeatureId==37).Issue==AdaptationIssue.None);
|
|
AdaptationManifest.ApplyGate(manager,verReport);
|
|
Check(!manager.IsEnabled(8) && !manager.Enable(8) && !manager.Enable(36));
|
|
Check(manager.Enable(1) && manager.Enable(37));
|
|
|
|
AdaptationManifest.ApplyGate(manager,okReport);
|
|
Check(manager.Enable(8));
|
|
|
|
var offsetReport=AdaptationManifest.Evaluate(manager,v,_=>1,false);
|
|
Check(offsetReport.Entries.Single(m=>m.FeatureId==8).Issue==AdaptationIssue.OffsetMismatch);
|
|
Check(offsetReport.Entries.Single(m=>m.FeatureId==14).Issue==AdaptationIssue.None);
|
|
|
|
var multiSend=AdaptationManifest.Evaluate(manager,v,sig=>sig==AdaptationManifest.ActiveSendReceiverSignature ? 2 : 1,true);
|
|
Check(!multiSend.ActiveSendReady && multiSend.ActiveSendSummary.Contains("多重匹配"));
|
|
|
|
var missingSig=AdaptationManifest.Evaluate(manager,v,sig=>sig.StartsWith("40 53 56 41 56",StringComparison.Ordinal) ? 0 : 1,true);
|
|
Check(missingSig.Entries.Single(m=>m.FeatureId==8).Issue==AdaptationIssue.SignatureMissing);
|
|
|
|
manager.Status[14].State=ModuleState.Failed;
|
|
manager.Status[14].Detail="hook target fault";
|
|
var faultReport=AdaptationManifest.Evaluate(manager,v,_=>1,true);
|
|
var m14=faultReport.Entries.Single(m=>m.FeatureId==14);
|
|
Check(m14.Issue==AdaptationIssue.RuntimeFault && m14.Summary.Contains("hook target fault"));
|
|
});
|
|
Test("stage 3 M4 native send buffer layout and audited encoders roundtrip through NativePacketCapture and PacketParser",()=>
|
|
{
|
|
const string v=PacketParser.AuditedClientVersion;
|
|
var map=new OpcodeMap();
|
|
map.Load($$$"""{"{{{v}}}":{"UP_ActionSend":[913],"UP_EventStart":[720],"UP_ClientTrigger":[963],"UP_EventFinish":[501],"UP_EventFinish4":[502],"UP_EventFinish8":[503],"UP_EventFinish16":[504],"UP_EventFinish32":[505],"UP_EventFinish64":[506],"UP_EventFinish128":[507],"UP_EventFinish255":[508],"UP_EventAction":[601],"UP_EventAction4":[602],"UP_EventAction8":[603],"UP_EventAction16":[604],"UP_EventAction32":[605],"UP_EventAction64":[606],"UP_EventAction128":[607],"UP_EventAction255":[608],"DOWN_Effect":[787],"DOWN_EventStart":[737],"DOWN_EventFinish":[217]}}""",v,"test");
|
|
|
|
var act=AuditedPacketEncoder.EncodeActionSend(map,v,7535,1,321,0x10001234);
|
|
Check(act.Name=="UP_ActionSend" && act.Opcode==913 && act.Payload.Length==32 && act.ExpectedResponseOpcode==787);
|
|
Check(PacketParser.Parse(act.Name,act.Payload,v).Summary.Contains("请求序号=321"));
|
|
|
|
var raw=act.NativeBuffer;
|
|
Check(raw.Length==NativeSendPacket.NativePrefixSize+act.Payload.Length);
|
|
var mem=new PacketMemoryFixture();
|
|
mem.Blocks[0x4000]=raw[..16];
|
|
mem.Blocks[0x4020]=raw[0x20..(0x20+act.Payload.Length)];
|
|
var captured=NativePacketCapture.Up(mem.Read,0x4000,_=>true);
|
|
Check(captured.Error==null && captured.Packet is {Complete:true,Opcode:913});
|
|
Check(captured.Packet!.Payload.SequenceEqual(act.Payload));
|
|
|
|
var evStart=AuditedPacketEncoder.EncodeEventStart(map,v,0x1000200030004000,0x00050001,7);
|
|
Check(evStart.Payload.Length==16 && evStart.ExpectedResponseOpcode==737 && PacketParser.Parse(evStart.Name,evStart.Payload,v).Parsed);
|
|
|
|
var trig=AuditedPacketEncoder.EncodeClientTrigger(map,v,3,1,2,3,4,0xABCDEF01);
|
|
Check(trig.Payload.Length==32 && PacketParser.Parse(trig.Name,trig.Payload,v).Summary.Contains("指令=3"));
|
|
|
|
foreach(var (count,expectedName,expectedLen) in new[]{(0,"UP_EventFinish",16),(2,"UP_EventFinish",16),(3,"UP_EventFinish4",24),(5,"UP_EventFinish8",40),(9,"UP_EventFinish16",72),(17,"UP_EventFinish32",136),(33,"UP_EventFinish64",264),(65,"UP_EventFinish128",520),(129,"UP_EventFinish255",1032)})
|
|
{
|
|
var args=Enumerable.Range(1,count).Select(i=>(uint)i).ToArray();
|
|
var fin=AuditedPacketEncoder.EncodeEventFinish(map,v,0x000A0001,0,0,args);
|
|
Check(fin.Name==expectedName && fin.Payload.Length==expectedLen && PacketParser.Parse(fin.Name,fin.Payload,v).Parsed);
|
|
var ea=AuditedPacketEncoder.EncodeEventAction(map,v,0x000A0001,0,0,args);
|
|
Check(ea.Name==expectedName.Replace("Finish","Action") && ea.Payload.Length==expectedLen && PacketParser.Parse(ea.Name,ea.Payload,v).Parsed);
|
|
}
|
|
Throws<ArgumentException>(()=>AuditedPacketEncoder.EncodeActionSend(map,v,1,1,0,0x10001234));
|
|
Throws<ArgumentOutOfRangeException>(()=>AuditedPacketEncoder.EncodeEventFinish(map,v,1,0,0,new uint[256]));
|
|
});
|
|
Test("stage 3 M4 ActiveSendScope tags plugin-initiated packets without affecting normal native Zone traffic",()=>
|
|
{
|
|
using var pipeline=new NetworkPipeline(PacketParser.AuditedClientVersion);
|
|
List<PacketContext> observed=[];
|
|
using var sub=pipeline.Subscribe(NetworkPhase.BeforeSend,obs=>observed.Add(obs.Context));
|
|
Check(pipeline.Send(()=>1,_=>new(new(913,null,new byte[32],true),null))==1);
|
|
using(ActiveSendScope.Enter())
|
|
{
|
|
Check(ActiveSendScope.Active);
|
|
Check(pipeline.Send(()=>1,_=>new(new(913,null,new byte[32],true),null))==1);
|
|
}
|
|
Check(!ActiveSendScope.Active);
|
|
Check(observed.Count==2);
|
|
Check(observed[0].Origin==PacketOrigin.NativeZone && observed[0].Phase==NetworkPhase.BeforeSend);
|
|
Check(observed[1].Origin==PacketOrigin.PluginActiveSend && observed[1].Phase==NetworkPhase.BeforeSend);
|
|
});
|
|
Test("stage 3 M4 ActivePacketDispatcher enforces rate limit, queue bounds, session/territory gate, cancel semantics, response correlation and recursive pump guard",()=>
|
|
{
|
|
const string v=PacketParser.AuditedClientVersion;
|
|
var map=new OpcodeMap();
|
|
map.Load($$$"""{"{{{v}}}":{"UP_ActionSend":[913],"UP_ClientTrigger":[963],"UP_EventStart":[720],"DOWN_Effect":[787],"DOWN_EventStart":[737]}}""",v,"test");
|
|
using var pipeline=new NetworkPipeline(v);
|
|
long now=1000;
|
|
var session=new SendSession(true,1108,(nint)0x2000,0x10000001);
|
|
byte nextNativeResult=1;
|
|
var dispatcher=new ActivePacketDispatcher(v,()=>now,maxQueue:4,minIntervalMs:100,queueTimeoutMs:1000,responseTimeoutMs:2000);
|
|
using var sub=dispatcher.Attach(pipeline);
|
|
byte NativeSender(ReadOnlySpan<byte> buf)
|
|
{
|
|
var opcode=BinaryPrimitives.ReadInt32LittleEndian(buf[..4]);
|
|
var payload=buf[NativeSendPacket.NativePrefixSize..].ToArray();
|
|
return pipeline.Send(()=>nextNativeResult,_=>new(new(opcode,null,payload,true),null));
|
|
}
|
|
|
|
var tx1=dispatcher.Enqueue(AuditedPacketEncoder.EncodeActionSend(map,v,7,1,501,0x10000001),session).Id;
|
|
var tx2=dispatcher.Enqueue(AuditedPacketEncoder.EncodeActionSend(map,v,7,1,502,0x10000001),session).Id;
|
|
var tx3=dispatcher.Enqueue(AuditedPacketEncoder.EncodeClientTrigger(map,v,3,1),session).Id;
|
|
Check(dispatcher.PendingQueueCount==3);
|
|
|
|
Check(dispatcher.Pump(session,NativeSender)?.Id==tx1);
|
|
Check(!dispatcher.Cancel(tx1)); // already SentNative
|
|
Check(dispatcher.Cancel(tx3,"test cancel"));
|
|
var snap1=dispatcher.Snapshot();
|
|
Check(snap1.Single(s=>s.Id==tx1) is {State:SendTransactionState.SentNative,NativeResult:1});
|
|
Check(snap1.Single(s=>s.Id==tx2).State==SendTransactionState.Queued);
|
|
Check(snap1.Single(s=>s.Id==tx3).State==SendTransactionState.Canceled);
|
|
|
|
// Rate limit prevents sending tx2 before +100ms
|
|
now=1050;
|
|
Check(dispatcher.Pump(session,NativeSender)==null);
|
|
|
|
// Confirm tx1 via DOWN_Effect with matching RequestId=501 and verify recursive Pump is forbidden inside Received callback
|
|
var eff=new byte[124]; eff[0x21]=1; BinaryPrimitives.WriteUInt16LittleEndian(eff.AsSpan(0x18),501);
|
|
bool recursiveRejected=false;
|
|
using(var guardSub=pipeline.Subscribe(NetworkPhase.Received,_=>
|
|
{
|
|
try { dispatcher.Pump(session,NativeSender); }
|
|
catch(InvalidOperationException) { recursiveRejected=true; }
|
|
}))
|
|
{
|
|
pipeline.Receive(()=>1,_=>new(new(787,0x10000001,eff,true),null));
|
|
}
|
|
Check(recursiveRejected);
|
|
var confirmed=dispatcher.Snapshot().Single(s=>s.Id==tx1);
|
|
Check(confirmed.State==SendTransactionState.Confirmed && confirmed.RoundTripMs==50 && confirmed.ResponseObservationId>0);
|
|
|
|
// Advance past rate limit and send tx2, then let it time out waiting for response
|
|
now=1100;
|
|
Check(dispatcher.Pump(session,NativeSender)?.Id==tx2);
|
|
Check(dispatcher.Snapshot().Single(s=>s.Id==tx2).State==SendTransactionState.SentNative);
|
|
now=3200;
|
|
dispatcher.Pump(session,NativeSender);
|
|
Check(dispatcher.Snapshot().Single(s=>s.Id==tx2).State==SendTransactionState.TimedOut);
|
|
|
|
// Territory change cancels queued items before native send
|
|
var tx4=dispatcher.Enqueue(AuditedPacketEncoder.EncodeActionSend(map,v,7,1,503,0x10000001),session).Id;
|
|
session=new SendSession(true,1109,(nint)0x2000,0x10000001);
|
|
now=3400;
|
|
Check(dispatcher.Pump(session,NativeSender)==null);
|
|
Check(dispatcher.Snapshot().Single(s=>s.Id==tx4).State==SendTransactionState.Canceled);
|
|
|
|
// Native return 0 marks transaction RejectedNative
|
|
nextNativeResult=0;
|
|
var tx5=dispatcher.Enqueue(AuditedPacketEncoder.EncodeActionSend(map,v,7,1,504,0x10000001),session).Id;
|
|
now=3600;
|
|
Check(dispatcher.Pump(session,NativeSender)?.Id==tx5);
|
|
Check(dispatcher.Snapshot().Single(s=>s.Id==tx5) is {State:SendTransactionState.RejectedNative,NativeResult:0});
|
|
});
|
|
Console.WriteLine($"{passed} tests passed.");
|
|
|