mirror of
https://github.com/goatcorp/Dalamud.git
synced 2026-02-24 13:41:49 +01:00
feat(network): Add SafePacket, pointer validation, and network tests
Add debug/testing utilities for network packet inspection and first-ever test coverage for the network monitoring area, as discussed in #2592. - SafePacket: managed wrapper with bounds checking and lifetime safety - NetworkPointerValidator: debug utility for pointer address validation - Make NetworkMonitorWidget.IsFiltered internal static for testability - Add 49 unit tests covering SafePacket, pointer validation, and opcode filter parsing (ranges, exclusions, complex filters) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bef50438f5
commit
486f831a26
6 changed files with 830 additions and 49 deletions
90
Dalamud.Test/Game/Network/NetworkPointerValidatorTests.cs
Normal file
90
Dalamud.Test/Game/Network/NetworkPointerValidatorTests.cs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
using System;
|
||||
|
||||
using Dalamud.Game.Network;
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace Dalamud.Test.Game.Network;
|
||||
|
||||
public class NetworkPointerValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void NullPointer_ReturnsFalse()
|
||||
{
|
||||
Assert.False(NetworkPointerValidator.IsValidPacketPointer(nint.Zero, 32));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x1)]
|
||||
[InlineData(0xFF)]
|
||||
[InlineData(0xFFFF)]
|
||||
public void BelowMinAddress_ReturnsFalse(long address)
|
||||
{
|
||||
Assert.False(NetworkPointerValidator.IsValidPacketPointer((nint)address, 32));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x800000000000)]
|
||||
[InlineData(0xFFFFFFFFFFFF)]
|
||||
public void AboveMaxAddress_ReturnsFalse(long address)
|
||||
{
|
||||
Assert.False(NetworkPointerValidator.IsValidPacketPointer((nint)address, 32));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
[InlineData(-100)]
|
||||
public void NonPositiveSize_ReturnsFalse(int size)
|
||||
{
|
||||
Assert.False(NetworkPointerValidator.IsValidPacketPointer((nint)0x10000, size));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x10000, 1)]
|
||||
[InlineData(0x100000, 1024)]
|
||||
[InlineData(0x7FFFFFFFFFFF, 1)]
|
||||
public void ValidPointerAndSize_ReturnsTrue(long address, int size)
|
||||
{
|
||||
Assert.True(NetworkPointerValidator.IsValidPacketPointer((nint)address, size));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SafeRead_InvalidPointer_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
NetworkPointerValidator.SafeRead<int>(nint.Zero, 0, 32));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SafeRead_NegativeOffset_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
NetworkPointerValidator.SafeRead<int>((nint)0x10000, -1, 32));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SafeRead_OffsetExceedsPacket_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
NetworkPointerValidator.SafeRead<int>((nint)0x10000, 30, 32));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrySafeRead_InvalidPointer_ReturnsFalse()
|
||||
{
|
||||
Assert.False(NetworkPointerValidator.TrySafeRead<int>(nint.Zero, 0, 32, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrySafeRead_NegativeOffset_ReturnsFalse()
|
||||
{
|
||||
Assert.False(NetworkPointerValidator.TrySafeRead<int>((nint)0x10000, -1, 32, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrySafeRead_OffsetExceedsPacket_ReturnsFalse()
|
||||
{
|
||||
Assert.False(NetworkPointerValidator.TrySafeRead<int>((nint)0x10000, 30, 32, out _));
|
||||
}
|
||||
}
|
||||
165
Dalamud.Test/Game/Network/OpCodeFilterTests.cs
Normal file
165
Dalamud.Test/Game/Network/OpCodeFilterTests.cs
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
using Dalamud.Interface.Internal.Windows.Data.Widgets;
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace Dalamud.Test.Game.Network;
|
||||
|
||||
public class OpCodeFilterTests
|
||||
{
|
||||
[Fact]
|
||||
public void EmptyFilter_MatchesAll()
|
||||
{
|
||||
Assert.True(NetworkMonitorWidget.IsFiltered(string.Empty, 100));
|
||||
Assert.True(NetworkMonitorWidget.IsFiltered(string.Empty, 0));
|
||||
Assert.True(NetworkMonitorWidget.IsFiltered(string.Empty, ushort.MaxValue));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhitespaceOnlyFilter_MatchesAll()
|
||||
{
|
||||
Assert.True(NetworkMonitorWidget.IsFiltered(" ", 100));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SingleExactMatch()
|
||||
{
|
||||
Assert.True(NetworkMonitorWidget.IsFiltered("100", 100));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SingleExact_NoMatch()
|
||||
{
|
||||
Assert.False(NetworkMonitorWidget.IsFiltered("100", 200));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommaSeparatedList_MatchesIncluded()
|
||||
{
|
||||
Assert.True(NetworkMonitorWidget.IsFiltered("100,200,300", 200));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommaSeparatedList_RejectsExcluded()
|
||||
{
|
||||
Assert.False(NetworkMonitorWidget.IsFiltered("100,200,300", 150));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Range_MatchesWithinBounds()
|
||||
{
|
||||
Assert.True(NetworkMonitorWidget.IsFiltered("100-200", 150));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Range_MatchesLowerBound()
|
||||
{
|
||||
Assert.True(NetworkMonitorWidget.IsFiltered("100-200", 100));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Range_MatchesUpperBound()
|
||||
{
|
||||
Assert.True(NetworkMonitorWidget.IsFiltered("100-200", 200));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Range_RejectsOutside()
|
||||
{
|
||||
Assert.False(NetworkMonitorWidget.IsFiltered("100-200", 50));
|
||||
Assert.False(NetworkMonitorWidget.IsFiltered("100-200", 250));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenEndedRangeStart_MatchesUpTo()
|
||||
{
|
||||
// "-400" means everything up to 400
|
||||
Assert.True(NetworkMonitorWidget.IsFiltered("-400", 0));
|
||||
Assert.True(NetworkMonitorWidget.IsFiltered("-400", 200));
|
||||
Assert.True(NetworkMonitorWidget.IsFiltered("-400", 400));
|
||||
Assert.False(NetworkMonitorWidget.IsFiltered("-400", 401));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenEndedRangeEnd_MatchesFrom()
|
||||
{
|
||||
// "700-" means everything from 700 onward
|
||||
Assert.True(NetworkMonitorWidget.IsFiltered("700-", 700));
|
||||
Assert.True(NetworkMonitorWidget.IsFiltered("700-", 1000));
|
||||
Assert.True(NetworkMonitorWidget.IsFiltered("700-", ushort.MaxValue));
|
||||
Assert.False(NetworkMonitorWidget.IsFiltered("700-", 699));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Exclusion_ExcludesSingleOpCode()
|
||||
{
|
||||
// Only exclusion, no inclusion -> matches everything except excluded
|
||||
Assert.False(NetworkMonitorWidget.IsFiltered("!50", 50));
|
||||
Assert.True(NetworkMonitorWidget.IsFiltered("!50", 100));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Exclusion_ExcludesRange()
|
||||
{
|
||||
Assert.False(NetworkMonitorWidget.IsFiltered("!50-100", 75));
|
||||
Assert.True(NetworkMonitorWidget.IsFiltered("!50-100", 150));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MixedInclusionAndExclusion()
|
||||
{
|
||||
// Include 0-400, but exclude 50-100
|
||||
var filter = "-400,!50-100";
|
||||
Assert.True(NetworkMonitorWidget.IsFiltered(filter, 10));
|
||||
Assert.False(NetworkMonitorWidget.IsFiltered(filter, 75));
|
||||
Assert.True(NetworkMonitorWidget.IsFiltered(filter, 200));
|
||||
Assert.False(NetworkMonitorWidget.IsFiltered(filter, 500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComplexFilter()
|
||||
{
|
||||
// Example from UI tooltip: -400,!50-100,650,700-980,!941
|
||||
var filter = "-400,!50-100,650,700-980,!941";
|
||||
|
||||
Assert.True(NetworkMonitorWidget.IsFiltered(filter, 10)); // in -400 range
|
||||
Assert.False(NetworkMonitorWidget.IsFiltered(filter, 75)); // excluded by !50-100
|
||||
Assert.True(NetworkMonitorWidget.IsFiltered(filter, 300)); // in -400 range
|
||||
Assert.False(NetworkMonitorWidget.IsFiltered(filter, 500)); // not in any include
|
||||
Assert.True(NetworkMonitorWidget.IsFiltered(filter, 650)); // exact match
|
||||
Assert.True(NetworkMonitorWidget.IsFiltered(filter, 700)); // in 700-980
|
||||
Assert.True(NetworkMonitorWidget.IsFiltered(filter, 800)); // in 700-980
|
||||
Assert.False(NetworkMonitorWidget.IsFiltered(filter, 941)); // excluded by !941
|
||||
Assert.True(NetworkMonitorWidget.IsFiltered(filter, 980)); // in 700-980
|
||||
Assert.False(NetworkMonitorWidget.IsFiltered(filter, 990)); // not in any include
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpacesAreStripped()
|
||||
{
|
||||
Assert.True(NetworkMonitorWidget.IsFiltered(" 100 , 200 ", 100));
|
||||
Assert.True(NetworkMonitorWidget.IsFiltered(" 100 , 200 ", 200));
|
||||
Assert.False(NetworkMonitorWidget.IsFiltered(" 100 , 200 ", 150));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidFilter_ReturnsFalse()
|
||||
{
|
||||
Assert.False(NetworkMonitorWidget.IsFiltered("abc", 100));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExclusionOnly_MatchesEverythingElse()
|
||||
{
|
||||
// No include entries -> treat as "match all except excluded"
|
||||
Assert.True(NetworkMonitorWidget.IsFiltered("!50,!100", 200));
|
||||
Assert.False(NetworkMonitorWidget.IsFiltered("!50,!100", 50));
|
||||
Assert.False(NetworkMonitorWidget.IsFiltered("!50,!100", 100));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BoundaryValues()
|
||||
{
|
||||
Assert.True(NetworkMonitorWidget.IsFiltered("0", 0));
|
||||
Assert.True(NetworkMonitorWidget.IsFiltered("65535", ushort.MaxValue));
|
||||
}
|
||||
}
|
||||
234
Dalamud.Test/Game/Network/SafePacketTests.cs
Normal file
234
Dalamud.Test/Game/Network/SafePacketTests.cs
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
using System;
|
||||
|
||||
using Dalamud.Game.Network;
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace Dalamud.Test.Game.Network;
|
||||
|
||||
public class SafePacketTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConstructFromByteArray_CopiesData()
|
||||
{
|
||||
var data = new byte[] { 0x01, 0x02, 0x03, 0x04 };
|
||||
using var packet = new SafePacket(data);
|
||||
|
||||
Assert.Equal(4, packet.Size);
|
||||
Assert.Equal(data, packet.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstructFromByteArray_IsolatesFromSource()
|
||||
{
|
||||
var data = new byte[] { 0x01, 0x02, 0x03, 0x04 };
|
||||
using var packet = new SafePacket(data);
|
||||
|
||||
data[0] = 0xFF;
|
||||
Assert.Equal(0x01, packet.ToArray()[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstructFromNull_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new SafePacket(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstructFromEmptyArray_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new SafePacket(Array.Empty<byte>()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpCode_ReadsFirstTwoBytes()
|
||||
{
|
||||
var data = new byte[] { 0x34, 0x12, 0x00, 0x00 };
|
||||
using var packet = new SafePacket(data);
|
||||
|
||||
Assert.Equal(0x1234, packet.OpCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpCode_SingleByte_Throws()
|
||||
{
|
||||
var data = new byte[] { 0x01 };
|
||||
using var packet = new SafePacket(data);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => packet.OpCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_Int32AtOffset()
|
||||
{
|
||||
var data = new byte[] { 0x00, 0x00, 0x78, 0x56, 0x34, 0x12 };
|
||||
using var packet = new SafePacket(data);
|
||||
|
||||
Assert.Equal(0x12345678, packet.Read<int>(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_NegativeOffset_Throws()
|
||||
{
|
||||
var data = new byte[] { 0x01, 0x02, 0x03, 0x04 };
|
||||
using var packet = new SafePacket(data);
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => packet.Read<int>(-1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_ExceedsBounds_Throws()
|
||||
{
|
||||
var data = new byte[] { 0x01, 0x02 };
|
||||
using var packet = new SafePacket(data);
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => packet.Read<int>(0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_OffsetAtBoundary_Throws()
|
||||
{
|
||||
var data = new byte[] { 0x01, 0x02, 0x03, 0x04 };
|
||||
using var packet = new SafePacket(data);
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => packet.Read<int>(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRead_Success()
|
||||
{
|
||||
var data = new byte[] { 0x34, 0x12, 0x00, 0x00 };
|
||||
using var packet = new SafePacket(data);
|
||||
|
||||
Assert.True(packet.TryRead<ushort>(0, out var value));
|
||||
Assert.Equal(0x1234, value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRead_OutOfBounds_ReturnsFalse()
|
||||
{
|
||||
var data = new byte[] { 0x01, 0x02 };
|
||||
using var packet = new SafePacket(data);
|
||||
|
||||
Assert.False(packet.TryRead<int>(0, out var value));
|
||||
Assert.Equal(default, value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRead_NegativeOffset_ReturnsFalse()
|
||||
{
|
||||
var data = new byte[] { 0x01, 0x02, 0x03, 0x04 };
|
||||
using var packet = new SafePacket(data);
|
||||
|
||||
Assert.False(packet.TryRead<int>(-1, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsSpan_ReturnsFullData()
|
||||
{
|
||||
var data = new byte[] { 0x01, 0x02, 0x03 };
|
||||
using var packet = new SafePacket(data);
|
||||
|
||||
Assert.Equal(data, packet.AsSpan().ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsSpan_WithRange_ReturnsSubset()
|
||||
{
|
||||
var data = new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05 };
|
||||
using var packet = new SafePacket(data);
|
||||
|
||||
var span = packet.AsSpan(1, 3);
|
||||
Assert.Equal(new byte[] { 0x02, 0x03, 0x04 }, span.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsSpan_InvalidRange_Throws()
|
||||
{
|
||||
var data = new byte[] { 0x01, 0x02 };
|
||||
using var packet = new SafePacket(data);
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => packet.AsSpan(1, 5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsSpan_NegativeOffset_Throws()
|
||||
{
|
||||
var data = new byte[] { 0x01, 0x02 };
|
||||
using var packet = new SafePacket(data);
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => packet.AsSpan(-1, 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToArray_ReturnsIndependentCopy()
|
||||
{
|
||||
var data = new byte[] { 0x01, 0x02, 0x03 };
|
||||
using var packet = new SafePacket(data);
|
||||
|
||||
var copy = packet.ToArray();
|
||||
copy[0] = 0xFF;
|
||||
Assert.Equal(0x01, packet.ToArray()[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_ClearsData()
|
||||
{
|
||||
var data = new byte[] { 0x01, 0x02, 0x03, 0x04 };
|
||||
var packet = new SafePacket(data);
|
||||
|
||||
packet.Dispose();
|
||||
|
||||
Assert.Throws<ObjectDisposedException>(() => packet.AsSpan());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_DoubleDispose_NoThrow()
|
||||
{
|
||||
var data = new byte[] { 0x01, 0x02 };
|
||||
var packet = new SafePacket(data);
|
||||
|
||||
packet.Dispose();
|
||||
packet.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_AfterDispose_Throws()
|
||||
{
|
||||
var data = new byte[] { 0x01, 0x02, 0x03, 0x04 };
|
||||
var packet = new SafePacket(data);
|
||||
packet.Dispose();
|
||||
|
||||
Assert.Throws<ObjectDisposedException>(() => packet.Read<int>(0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpCode_AfterDispose_Throws()
|
||||
{
|
||||
var data = new byte[] { 0x01, 0x02 };
|
||||
var packet = new SafePacket(data);
|
||||
packet.Dispose();
|
||||
|
||||
Assert.Throws<ObjectDisposedException>(() => packet.OpCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRead_AfterDispose_ReturnsFalse()
|
||||
{
|
||||
var data = new byte[] { 0x01, 0x02, 0x03, 0x04 };
|
||||
var packet = new SafePacket(data);
|
||||
packet.Dispose();
|
||||
|
||||
Assert.False(packet.TryRead<int>(0, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToArray_AfterDispose_Throws()
|
||||
{
|
||||
var data = new byte[] { 0x01, 0x02 };
|
||||
var packet = new SafePacket(data);
|
||||
packet.Dispose();
|
||||
|
||||
Assert.Throws<ObjectDisposedException>(() => packet.ToArray());
|
||||
}
|
||||
}
|
||||
98
Dalamud/Game/Network/NetworkPointerValidator.cs
Normal file
98
Dalamud/Game/Network/NetworkPointerValidator.cs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Dalamud.Game.Network;
|
||||
|
||||
/// <summary>
|
||||
/// Provides validation utilities for network packet pointers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is a debug/development utility for validating pointer safety
|
||||
/// during packet inspection and analysis workflows.
|
||||
/// </remarks>
|
||||
internal static class NetworkPointerValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimum address threshold below which pointers are considered invalid.
|
||||
/// Addresses below this are typically reserved by the OS.
|
||||
/// </summary>
|
||||
private const long MinValidAddress = 0x10000;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum valid user-mode address for 64-bit Windows.
|
||||
/// Addresses above this are kernel-mode and inaccessible from user-mode.
|
||||
/// </summary>
|
||||
private const long MaxValidAddress = 0x7FFFFFFFFFFF;
|
||||
|
||||
/// <summary>
|
||||
/// Validates a network packet pointer before use.
|
||||
/// </summary>
|
||||
/// <param name="ptr">The pointer to validate.</param>
|
||||
/// <param name="minSize">The minimum expected size of the data.</param>
|
||||
/// <returns>True if the pointer appears valid; false otherwise.</returns>
|
||||
public static bool IsValidPacketPointer(nint ptr, int minSize)
|
||||
{
|
||||
if (ptr == nint.Zero)
|
||||
return false;
|
||||
|
||||
if (ptr < MinValidAddress)
|
||||
return false;
|
||||
|
||||
if (ptr > MaxValidAddress)
|
||||
return false;
|
||||
|
||||
if (minSize <= 0)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Safely reads a value from a packet pointer with bounds checking.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The unmanaged type to read.</typeparam>
|
||||
/// <param name="ptr">The base pointer to read from.</param>
|
||||
/// <param name="offset">The byte offset from the base pointer.</param>
|
||||
/// <param name="packetSize">The total size of the packet for bounds checking.</param>
|
||||
/// <returns>The value read from memory.</returns>
|
||||
/// <exception cref="ArgumentOutOfRangeException">Thrown when the read would exceed packet boundaries.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when the pointer is invalid.</exception>
|
||||
public static unsafe T SafeRead<T>(nint ptr, int offset, int packetSize) where T : unmanaged
|
||||
{
|
||||
if (!IsValidPacketPointer(ptr, packetSize))
|
||||
throw new ArgumentException("Invalid packet pointer.", nameof(ptr));
|
||||
|
||||
var size = sizeof(T);
|
||||
if (offset < 0 || offset > packetSize || size > packetSize - offset)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(offset),
|
||||
$"Cannot read {size} bytes at offset {offset} from packet of size {packetSize}.");
|
||||
}
|
||||
|
||||
return *(T*)(ptr + offset);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to safely read a value from a packet pointer with bounds checking.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The unmanaged type to read.</typeparam>
|
||||
/// <param name="ptr">The base pointer to read from.</param>
|
||||
/// <param name="offset">The byte offset from the base pointer.</param>
|
||||
/// <param name="packetSize">The total size of the packet for bounds checking.</param>
|
||||
/// <param name="value">The value read from memory, or default if the read failed.</param>
|
||||
/// <returns>True if the read succeeded; false otherwise.</returns>
|
||||
public static unsafe bool TrySafeRead<T>(nint ptr, int offset, int packetSize, out T value) where T : unmanaged
|
||||
{
|
||||
value = default;
|
||||
|
||||
if (!IsValidPacketPointer(ptr, packetSize))
|
||||
return false;
|
||||
|
||||
var size = sizeof(T);
|
||||
if (offset < 0 || offset > packetSize || size > packetSize - offset)
|
||||
return false;
|
||||
|
||||
value = *(T*)(ptr + offset);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
188
Dalamud/Game/Network/SafePacket.cs
Normal file
188
Dalamud/Game/Network/SafePacket.cs
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Dalamud.Game.Network;
|
||||
|
||||
/// <summary>
|
||||
/// A safe wrapper around network packet data with lifetime and bounds guarantees.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This class copies packet data to managed memory, ensuring:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Lifetime safety - data persists as long as this object.</description></item>
|
||||
/// <item><description>Bounds checking - all reads are validated against packet size.</description></item>
|
||||
/// <item><description>Thread safety - the copied data cannot be modified externally.</description></item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Intended for debug tooling and packet inspection utilities.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class SafePacket : IDisposable
|
||||
{
|
||||
private readonly byte[] data;
|
||||
private bool disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SafePacket"/> class by copying data from an unmanaged pointer.
|
||||
/// </summary>
|
||||
/// <param name="ptr">The source pointer to copy from.</param>
|
||||
/// <param name="size">The number of bytes to copy.</param>
|
||||
/// <exception cref="ArgumentException">Thrown when the pointer is invalid.</exception>
|
||||
/// <exception cref="ArgumentOutOfRangeException">Thrown when size is not positive.</exception>
|
||||
internal SafePacket(nint ptr, int size)
|
||||
{
|
||||
if (!NetworkPointerValidator.IsValidPacketPointer(ptr, size))
|
||||
throw new ArgumentException("Invalid packet pointer.", nameof(ptr));
|
||||
|
||||
if (size <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(size), "Size must be positive.");
|
||||
|
||||
this.data = new byte[size];
|
||||
Marshal.Copy(ptr, this.data, 0, size);
|
||||
this.Size = size;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SafePacket"/> class from existing data.
|
||||
/// </summary>
|
||||
/// <param name="data">The source data to copy.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when data is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when data is empty.</exception>
|
||||
internal SafePacket(byte[] data)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(data);
|
||||
|
||||
if (data.Length == 0)
|
||||
throw new ArgumentException("Data cannot be empty.", nameof(data));
|
||||
|
||||
this.data = new byte[data.Length];
|
||||
data.CopyTo(this.data, 0);
|
||||
this.Size = data.Length;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total size of the packet data in bytes.
|
||||
/// </summary>
|
||||
public int Size { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the packet opcode (first two bytes interpreted as ushort).
|
||||
/// </summary>
|
||||
/// <exception cref="ObjectDisposedException">Thrown when the packet has been disposed.</exception>
|
||||
/// <exception cref="InvalidOperationException">Thrown when packet is too small to contain an opcode.</exception>
|
||||
public ushort OpCode
|
||||
{
|
||||
get
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(this.disposed, this);
|
||||
|
||||
if (this.Size < sizeof(ushort))
|
||||
throw new InvalidOperationException("Packet too small to contain an opcode.");
|
||||
|
||||
return BitConverter.ToUInt16(this.data, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Safely reads a value of type T at the specified byte offset.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The unmanaged type to read.</typeparam>
|
||||
/// <param name="offset">The byte offset from the start of the packet.</param>
|
||||
/// <returns>The value read from the packet data.</returns>
|
||||
/// <exception cref="ObjectDisposedException">Thrown when the packet has been disposed.</exception>
|
||||
/// <exception cref="ArgumentOutOfRangeException">Thrown when the read would exceed packet boundaries.</exception>
|
||||
public unsafe T Read<T>(int offset) where T : unmanaged
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(this.disposed, this);
|
||||
|
||||
var size = sizeof(T);
|
||||
if (offset < 0 || offset > this.Size || size > this.Size - offset)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(offset),
|
||||
$"Cannot read {size} bytes at offset {offset} from packet of size {this.Size}.");
|
||||
}
|
||||
|
||||
return MemoryMarshal.Read<T>(this.data.AsSpan(offset));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to safely read a value of type T at the specified byte offset.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The unmanaged type to read.</typeparam>
|
||||
/// <param name="offset">The byte offset from the start of the packet.</param>
|
||||
/// <param name="value">When this method returns, contains the value read, or default if the read failed.</param>
|
||||
/// <returns>True if the read succeeded; false otherwise.</returns>
|
||||
public unsafe bool TryRead<T>(int offset, out T value) where T : unmanaged
|
||||
{
|
||||
value = default;
|
||||
|
||||
if (this.disposed)
|
||||
return false;
|
||||
|
||||
var size = sizeof(T);
|
||||
if (offset < 0 || offset > this.Size || size > this.Size - offset)
|
||||
return false;
|
||||
|
||||
value = MemoryMarshal.Read<T>(this.data.AsSpan(offset));
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a read-only span of the entire packet data.
|
||||
/// </summary>
|
||||
/// <returns>A read-only span covering all packet data.</returns>
|
||||
/// <exception cref="ObjectDisposedException">Thrown when the packet has been disposed.</exception>
|
||||
public ReadOnlySpan<byte> AsSpan()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(this.disposed, this);
|
||||
return this.data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a read-only span of a portion of the packet data.
|
||||
/// </summary>
|
||||
/// <param name="offset">The starting offset.</param>
|
||||
/// <param name="length">The number of bytes to include.</param>
|
||||
/// <returns>A read-only span covering the specified portion of packet data.</returns>
|
||||
/// <exception cref="ObjectDisposedException">Thrown when the packet has been disposed.</exception>
|
||||
/// <exception cref="ArgumentOutOfRangeException">Thrown when the range exceeds packet boundaries.</exception>
|
||||
public ReadOnlySpan<byte> AsSpan(int offset, int length)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(this.disposed, this);
|
||||
|
||||
if (offset < 0 || length < 0 || offset > this.Size || length > this.Size - offset)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(offset),
|
||||
$"Range [{offset}, {offset + length}) exceeds packet size {this.Size}.");
|
||||
}
|
||||
|
||||
return this.data.AsSpan(offset, length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a copy of the packet data as a new byte array.
|
||||
/// </summary>
|
||||
/// <returns>A new byte array containing a copy of the packet data.</returns>
|
||||
/// <exception cref="ObjectDisposedException">Thrown when the packet has been disposed.</exception>
|
||||
public byte[] ToArray()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(this.disposed, this);
|
||||
|
||||
var copy = new byte[this.Size];
|
||||
this.data.CopyTo(copy, 0);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
if (!this.disposed)
|
||||
{
|
||||
Array.Clear(this.data);
|
||||
this.disposed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -139,7 +139,7 @@ internal unsafe class NetworkMonitorWidget : IDataWindowWidget
|
|||
|
||||
foreach (var packet in this.packets)
|
||||
{
|
||||
if (!this.filterRecording && !this.IsFiltered(packet.OpCode))
|
||||
if (!this.filterRecording && !IsFiltered(this.filterString, packet.OpCode))
|
||||
continue;
|
||||
|
||||
ImGui.TableNextColumn();
|
||||
|
|
@ -200,55 +200,15 @@ internal unsafe class NetworkMonitorWidget : IDataWindowWidget
|
|||
}
|
||||
}
|
||||
|
||||
private static string GetTargetName(uint targetId)
|
||||
/// <summary>
|
||||
/// Determines whether a given opcode passes the filter criteria.
|
||||
/// </summary>
|
||||
/// <param name="filterString">The comma-separated filter string (e.g. "-400,!50-100,650,700-980,!941").</param>
|
||||
/// <param name="opcode">The opcode to test.</param>
|
||||
/// <returns>True if the opcode passes the filter (should be shown/recorded); false otherwise.</returns>
|
||||
internal static bool IsFiltered(string filterString, ushort opcode)
|
||||
{
|
||||
if (targetId == PlayerState.Instance()->EntityId)
|
||||
return "Local Player";
|
||||
|
||||
var cachedName = NameCache.Instance()->GetNameByEntityId(targetId);
|
||||
if (cachedName.HasValue)
|
||||
return cachedName.ToString();
|
||||
|
||||
var obj = GameObjectManager.Instance()->Objects.GetObjectByEntityId(targetId);
|
||||
if (obj != null)
|
||||
return obj->NameString;
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private void OnReceivePacketDetour(PacketDispatcher* thisPtr, uint targetId, nint packet)
|
||||
{
|
||||
var opCode = *(ushort*)(packet + 2);
|
||||
var targetName = GetTargetName(targetId);
|
||||
this.RecordPacket(new NetworkPacketData(Interlocked.Increment(ref this.nextPacketIndex), DateTime.Now, opCode, NetworkMessageDirection.ZoneDown, targetId, targetName));
|
||||
this.hookZoneDown.OriginalDisposeSafe(thisPtr, targetId, packet);
|
||||
}
|
||||
|
||||
private bool SendPacketDetour(ZoneClient* thisPtr, nint packet, uint a3, uint a4, bool a5)
|
||||
{
|
||||
var opCode = *(ushort*)packet;
|
||||
this.RecordPacket(new NetworkPacketData(Interlocked.Increment(ref this.nextPacketIndex), DateTime.Now, opCode, NetworkMessageDirection.ZoneUp, 0, string.Empty));
|
||||
return this.hookZoneUp.OriginalDisposeSafe(thisPtr, packet, a3, a4, a5);
|
||||
}
|
||||
|
||||
private void RecordPacket(NetworkPacketData packet)
|
||||
{
|
||||
if (this.filterRecording && !this.IsFiltered(packet.OpCode))
|
||||
return;
|
||||
|
||||
this.packets.Enqueue(packet);
|
||||
|
||||
while (this.packets.Count > this.trackedPackets)
|
||||
{
|
||||
this.packets.TryDequeue(out _);
|
||||
}
|
||||
|
||||
this.autoScrollPending = true;
|
||||
}
|
||||
|
||||
private bool IsFiltered(ushort opcode)
|
||||
{
|
||||
var filterString = this.filterString.Replace(" ", string.Empty);
|
||||
filterString = filterString.Replace(" ", string.Empty);
|
||||
|
||||
if (filterString.Length == 0)
|
||||
return true;
|
||||
|
|
@ -304,6 +264,52 @@ internal unsafe class NetworkMonitorWidget : IDataWindowWidget
|
|||
}
|
||||
}
|
||||
|
||||
private static string GetTargetName(uint targetId)
|
||||
{
|
||||
if (targetId == PlayerState.Instance()->EntityId)
|
||||
return "Local Player";
|
||||
|
||||
var cachedName = NameCache.Instance()->GetNameByEntityId(targetId);
|
||||
if (cachedName.HasValue)
|
||||
return cachedName.ToString();
|
||||
|
||||
var obj = GameObjectManager.Instance()->Objects.GetObjectByEntityId(targetId);
|
||||
if (obj != null)
|
||||
return obj->NameString;
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private void OnReceivePacketDetour(PacketDispatcher* thisPtr, uint targetId, nint packet)
|
||||
{
|
||||
var opCode = *(ushort*)(packet + 2);
|
||||
var targetName = GetTargetName(targetId);
|
||||
this.RecordPacket(new NetworkPacketData(Interlocked.Increment(ref this.nextPacketIndex), DateTime.Now, opCode, NetworkMessageDirection.ZoneDown, targetId, targetName));
|
||||
this.hookZoneDown.OriginalDisposeSafe(thisPtr, targetId, packet);
|
||||
}
|
||||
|
||||
private bool SendPacketDetour(ZoneClient* thisPtr, nint packet, uint a3, uint a4, bool a5)
|
||||
{
|
||||
var opCode = *(ushort*)packet;
|
||||
this.RecordPacket(new NetworkPacketData(Interlocked.Increment(ref this.nextPacketIndex), DateTime.Now, opCode, NetworkMessageDirection.ZoneUp, 0, string.Empty));
|
||||
return this.hookZoneUp.OriginalDisposeSafe(thisPtr, packet, a3, a4, a5);
|
||||
}
|
||||
|
||||
private void RecordPacket(NetworkPacketData packet)
|
||||
{
|
||||
if (this.filterRecording && !IsFiltered(this.filterString, packet.OpCode))
|
||||
return;
|
||||
|
||||
this.packets.Enqueue(packet);
|
||||
|
||||
while (this.packets.Count > this.trackedPackets)
|
||||
{
|
||||
this.packets.TryDequeue(out _);
|
||||
}
|
||||
|
||||
this.autoScrollPending = true;
|
||||
}
|
||||
|
||||
#pragma warning disable SA1313
|
||||
private readonly record struct NetworkPacketData(ulong Index, DateTime Time, ushort OpCode, NetworkMessageDirection Direction, uint TargetEntityId, string TargetName);
|
||||
#pragma warning restore SA1313
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue