diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml
new file mode 100644
index 0000000..f10b7a9
--- /dev/null
+++ b/.github/workflows/fuzz.yml
@@ -0,0 +1,39 @@
+name: Fuzz smoke
+
+on:
+ pull_request:
+ paths:
+ - .github/workflows/fuzz.yml
+ - src/**
+ - tests/OpenDisNet.Fuzz/**
+ - Directory.Build.props
+ - OpenDisNet.slnx
+ push:
+ branches: [main]
+ paths:
+ - .github/workflows/fuzz.yml
+ - src/**
+ - tests/OpenDisNet.Fuzz/**
+ - Directory.Build.props
+ - OpenDisNet.slnx
+ schedule:
+ - cron: "43 4 * * 3"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ parser:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-dotnet@v6
+ with:
+ dotnet-version: 10.x
+ - run: dotnet restore --locked-mode
+ - name: Build fuzz target
+ run: dotnet build tests/OpenDisNet.Fuzz/OpenDisNet.Fuzz.csproj --configuration Release --no-restore --no-incremental
+ - name: Run bounded parser fuzz smoke corpus
+ run: dotnet run --project tests/OpenDisNet.Fuzz/OpenDisNet.Fuzz.csproj --configuration Release --no-build --no-restore -- --smoke
diff --git a/CHANGELOG.md b/CHANGELOG.md
index abb61a9..5a8505b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,20 @@ All notable changes are documented here. This project follows Semantic
Versioning starting with 1.0 and uses prerelease versions when conformance is
incomplete.
-## 1.1.0 - Unreleased
+## 1.2.0 - Unreleased
+
+- Added framed span and segmented-sequence readers that distinguish incomplete
+ input from invalid data and report the number of consumed octets.
+- Added header-only inspection and safe raw preservation for explicitly allowed
+ non-v7 datagrams instead of interpreting them with v7 body layouts.
+- Added builders for Entity State, Fire, Detonation, and Transmitter PDUs plus
+ non-mutating semantic validation with structured warnings and errors.
+- Expanded BenchmarkDotNet coverage to fixed, variable, vendor-defined, framed,
+ header-only, and invalid-input paths.
+- Added a SharpFuzz parser target, deterministic mutation corpus, and scheduled
+ CI smoke-fuzz workflow.
+
+## 1.1.0 - 2026-07-25
- Added first-class .NET 8 support alongside .NET 9 and .NET 10, including
full unit-test, package validation, and packed-package consumer coverage.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 8974c05..e1a7895 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -5,6 +5,15 @@ vectors, round-trip tests, and malformed-input tests. Run `dotnet test
--configuration Release` before opening a pull request. Do not submit IEEE
specification text or non-public operational packet captures.
+Parser and framing changes should also run the bounded fuzz corpus:
+
+```shell
+dotnet run --project tests/OpenDisNet.Fuzz -c Release -- --smoke
+```
+
+See [`docs/fuzzing.md`](docs/fuzzing.md) before starting a sustained SharpFuzz
+campaign or contributing a minimized crash regression.
+
The 1.x public API follows semantic versioning. Additive API changes must update
`src/OpenDisNet/PublicAPI.Unshipped.txt`, include tests, and describe the
consumer benefit in the changelog. Removals or incompatible signature changes
diff --git a/OpenDisNet.slnx b/OpenDisNet.slnx
index 2c9e95a..f333b10 100644
--- a/OpenDisNet.slnx
+++ b/OpenDisNet.slnx
@@ -9,6 +9,7 @@
+
diff --git a/README.md b/README.md
index a2c26b1..6c3209a 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,7 @@
[](https://github.com/RejectKid/OpenDisNet/actions/workflows/ci.yml)
[](https://github.com/RejectKid/OpenDisNet/actions/workflows/benchmarks.yml)
+[](https://github.com/RejectKid/OpenDisNet/actions/workflows/fuzz.yml)
[](https://www.nuget.org/packages/OpenDisNet)
[](https://github.com/RejectKid/OpenDisNet/releases/latest)
@@ -45,6 +46,55 @@ Unknown and vendor-defined PDU bodies are retained rather than discarded.
Use `DisSerializer.Serialize(pdu)` for the reverse operation. See the
[public API design](docs/api-design.md) for the supported design rules.
+When reading packet captures, pipelines, or buffers containing multiple PDUs,
+use the framed API. It distinguishes incomplete input from invalid input and
+reports exactly how many octets to advance:
+
+```csharp
+DisReadStatus status = DisSerializer.TryRead(
+ buffer,
+ out IDisPdu? pdu,
+ out int consumed,
+ out DisParseError error);
+
+if (status == DisReadStatus.Done)
+ buffer = buffer[consumed..];
+```
+
+The same API accepts `ReadOnlySequence`. `TryReadHeader` inspects routing
+fields without decoding or allocating a PDU body. If version enforcement is
+explicitly disabled, non-v7 bodies are returned as `UnknownPdu`; they are never
+interpreted using a v7 layout.
+
+## Build and validate common PDUs
+
+`DisPduBuilder` establishes discriminators and related fields for common
+workflows. Semantic validation remains separate from bounded wire parsing:
+
+```csharp
+using OpenDisNet.Validation;
+
+FirePdu fire = DisPduBuilder.CreateFire(
+ firingEntity,
+ targetEntity,
+ munitionEntity,
+ 42,
+ descriptor,
+ launchLocation,
+ velocity,
+ range: 5_000,
+ exerciseId: 1);
+
+DisValidationResult validation = DisValidator.Validate(fire);
+foreach (DisValidationIssue issue in validation.Issues)
+ Console.WriteLine($"{issue.Severity}: {issue.Path}: {issue.Message}");
+```
+
+Builders are also available for Entity State, Detonation, and Transmitter PDUs.
+Validation reports discriminator inconsistencies, non-finite coordinates,
+invalid physical values, incomplete radio state, and unset primary identifiers
+without changing the PDU.
+
## Create and serialize a Signal PDU
Signal data normally comes from an audio codec, tactical-data-link implementation,
@@ -135,9 +185,9 @@ and checked big-endian primitives are under
## Benchmarks
-The BenchmarkDotNet suite measures typed parsing, non-throwing parsing, allocated
-serialization, and serialization into caller-owned storage for representative
-Signal PDU payload sizes. Run it on any supported runtime:
+The BenchmarkDotNet suite measures typed, framed, and header-only parsing plus
+caller-owned serialization across Signal payload sizes and representative fixed,
+variable, vendor-defined, and malformed PDUs. Run it on any supported runtime:
```shell
dotnet run --project benchmarks/OpenDisNet.Benchmarks -c Release -f net10.0
@@ -158,6 +208,10 @@ GitHub-hosted runners are appropriate for comparing runtimes within one run.
Use controlled, dedicated hardware before treating results from different runs
as a strict performance regression gate.
+The parser also has a coverage-guided SharpFuzz target and a bounded CI mutation
+campaign. See [parser fuzzing](docs/fuzzing.md) for local smoke, corpus creation,
+instrumentation, and sustained fuzzing instructions.
+
## Security
Treat network datagrams as untrusted. See [`SECURITY.md`](SECURITY.md) for the
diff --git a/SECURITY.md b/SECURITY.md
index e0d5a04..f1ddcf9 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -28,7 +28,11 @@ not authorization decisions.
- Low-or-higher vulnerabilities in direct or transitive NuGet dependencies fail
restore.
- Pull requests receive dependency review, cross-platform tests, parser hostile-
- input regression tests, formatting checks, and CodeQL analysis.
+ input regression tests, bounded parser fuzzing, formatting checks, and CodeQL
+ analysis.
- Weekly CodeQL analysis covers changes outside pull-request activity.
+- A SharpFuzz target exercises header, datagram, framed-span, segmented-sequence,
+ validation, serialization, and round-trip paths; its bounded mutation corpus
+ runs in CI and supports sustained coverage-guided campaigns.
- Release packages include SHA-256 checksums and GitHub build-provenance
attestations and are published to NuGet through short-lived OIDC credentials.
diff --git a/benchmarks/OpenDisNet.Benchmarks/InvalidInputBenchmarks.cs b/benchmarks/OpenDisNet.Benchmarks/InvalidInputBenchmarks.cs
new file mode 100644
index 0000000..077a8b0
--- /dev/null
+++ b/benchmarks/OpenDisNet.Benchmarks/InvalidInputBenchmarks.cs
@@ -0,0 +1,52 @@
+using BenchmarkDotNet.Attributes;
+using OpenDisNet.Pdus;
+
+namespace OpenDisNet.Benchmarks;
+
+public enum InvalidInputScenario
+{
+ TruncatedHeader,
+ TruncatedPdu,
+ RandomKilobyte,
+}
+
+[MemoryDiagnoser]
+public class InvalidInputBenchmarks
+{
+ private byte[] _datagram = null!;
+
+ [ParamsAllValues]
+ public InvalidInputScenario Scenario { get; set; }
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ _datagram = Scenario switch
+ {
+ InvalidInputScenario.TruncatedHeader => [7, 1, 1],
+ InvalidInputScenario.TruncatedPdu => CreateTruncatedPdu(),
+ InvalidInputScenario.RandomKilobyte => CreateRandomInput(),
+ _ => throw new ArgumentOutOfRangeException(),
+ };
+ }
+
+ [Benchmark(Baseline = true)]
+ public bool TryDeserialize() => DisSerializer.TryDeserialize(_datagram, out _, out _);
+
+ [Benchmark]
+ public DisReadStatus TryRead() => DisSerializer.TryRead(_datagram, out _, out _, out _);
+
+ private static byte[] CreateTruncatedPdu()
+ {
+ var signal = new SignalPdu { Radio = new RadioId(new EntityId(1, 1, 1), 1) };
+ signal.SetData(new byte[160]);
+ return DisSerializer.Serialize(signal)[..^1];
+ }
+
+ private static byte[] CreateRandomInput()
+ {
+ var bytes = new byte[1024];
+ new Random(42).NextBytes(bytes);
+ return bytes;
+ }
+}
diff --git a/benchmarks/OpenDisNet.Benchmarks/RepresentativePduBenchmarks.cs b/benchmarks/OpenDisNet.Benchmarks/RepresentativePduBenchmarks.cs
new file mode 100644
index 0000000..de364cc
--- /dev/null
+++ b/benchmarks/OpenDisNet.Benchmarks/RepresentativePduBenchmarks.cs
@@ -0,0 +1,88 @@
+using BenchmarkDotNet.Attributes;
+using OpenDisNet.Enumerations;
+using OpenDisNet.Pdus;
+using OpenDisNet.Protocol;
+
+namespace OpenDisNet.Benchmarks;
+
+public enum PduScenario
+{
+ EntityState,
+ Fire,
+ Transmitter,
+ UnknownVendor,
+}
+
+[MemoryDiagnoser]
+public class RepresentativePduBenchmarks
+{
+ private IDisPdu _pdu = null!;
+ private byte[] _datagram = null!;
+ private byte[] _destination = null!;
+
+ [ParamsAllValues]
+ public PduScenario Scenario { get; set; }
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ _pdu = CreatePdu(Scenario);
+ _datagram = DisSerializer.Serialize(_pdu);
+ _destination = new byte[_datagram.Length];
+ }
+
+ [Benchmark(Baseline = true)]
+ public IDisPdu Deserialize() => DisSerializer.Deserialize(_datagram);
+
+ [Benchmark]
+ public DisReadStatus TryRead() => DisSerializer.TryRead(_datagram, out _, out _, out _);
+
+ [Benchmark]
+ public bool TryReadHeader() => DisSerializer.TryReadHeader(_datagram, out _, out _);
+
+ [Benchmark]
+ public int SerializeCallerOwned() => DisSerializer.Serialize(_pdu, _destination);
+
+ private static IDisPdu CreatePdu(PduScenario scenario) => scenario switch
+ {
+ PduScenario.EntityState => DisPduBuilder.CreateEntityState(
+ new EntityId(1, 10, 42),
+ new EntityType
+ {
+ EntityKind = EntityKind.Platform,
+ Domain = new Domain { Value = (byte)PlatformDomain.Air },
+ Country = Country.UnitedStatesOfAmericaUsa,
+ Category = 1,
+ },
+ new Vector3Double { X = 1_000, Y = 2_000, Z = 3_000 },
+ ForceId.Friendly,
+ exerciseId: 1),
+ PduScenario.Fire => DisPduBuilder.CreateFire(
+ new EntityId(1, 10, 42),
+ new EntityId(1, 10, 43),
+ new EntityId(1, 10, 44),
+ 7,
+ new MunitionDescriptor { Quantity = 1 },
+ new Vector3Double { X = 100, Y = 200, Z = 300 },
+ new Vector3Float { X = 400, Y = 500, Z = 600 },
+ range: 5_000,
+ exerciseId: 1),
+ PduScenario.Transmitter => CreateTransmitter(),
+ PduScenario.UnknownVendor => new UnknownPdu(
+ new DisHeader(DisProtocolVersion.Ieee1278_1_2012, 1, (PduType)200, (ProtocolFamily)200, 42, 0, 0, 0),
+ Enumerable.Range(0, 256).Select(index => unchecked((byte)(index * 31))).ToArray()),
+ _ => throw new ArgumentOutOfRangeException(nameof(scenario)),
+ };
+
+ private static TransmitterPdu CreateTransmitter()
+ {
+ TransmitterPdu transmitter = DisPduBuilder.CreateTransmitter(
+ new RadioId(new EntityId(1, 10, 42), 7),
+ new RadioType(),
+ frequency: 225_000_000,
+ power: 50,
+ exerciseId: 1);
+ transmitter.ModulationParameters = Enumerable.Range(0, 64).Select(index => (byte)index).ToArray();
+ return transmitter;
+ }
+}
diff --git a/docs/fuzzing.md b/docs/fuzzing.md
new file mode 100644
index 0000000..9d05f5a
--- /dev/null
+++ b/docs/fuzzing.md
@@ -0,0 +1,27 @@
+# Parser fuzzing
+
+`OpenDisNet.Fuzz` is a SharpFuzz 2.3 harness for the public header, datagram,
+framed-span, and segmented-sequence parsing paths. Successful parses are also
+semantically validated, serialized, and parsed again. Unexpected exceptions or
+differences between contiguous and segmented framing are treated as crashes.
+
+Run the bounded deterministic smoke corpus used by CI:
+
+```shell
+dotnet run --project tests/OpenDisNet.Fuzz -c Release -- --smoke
+```
+
+Create a starting corpus containing all 72 standardized PDU types plus malformed
+and non-v7 inputs:
+
+```shell
+dotnet run --project tests/OpenDisNet.Fuzz -c Release -- --write-corpus artifacts/fuzz-corpus
+```
+
+For a sustained coverage-guided campaign, install AFL++ and the
+`SharpFuzz.CommandLine` tool, instrument the Release build of
+`OpenDisNet.Fuzz.dll`, and run AFL++ against the generated corpus. Follow the
+[SharpFuzz usage instructions](https://github.com/Metalnem/SharpFuzz#usage) for
+the current instrumentation and runner commands. Fuzz findings and generated
+corpora belong under `artifacts/` and must not be committed without minimizing
+them and adding a focused regression test.
diff --git a/docs/release-candidate.md b/docs/release-candidate.md
index 69b0ede..f567b7c 100644
--- a/docs/release-candidate.md
+++ b/docs/release-candidate.md
@@ -7,11 +7,11 @@ package validation compares every new artifact with the stable 1.0.0 baseline.
## Install an RC
-Release candidates use SemVer tags such as `v1.1.0-rc.1`. After a candidate is
+Release candidates use SemVer tags such as `vX.Y.Z-rc.1`. After a candidate is
published, install that exact version so test results remain reproducible:
```shell
-dotnet add package OpenDisNet --version 1.1.0-rc.1
+dotnet add package OpenDisNet --version -rc.1
```
GitHub and NuGet identify RC builds as prereleases. Do not use an RC in a
diff --git a/docs/releasing.md b/docs/releasing.md
index 9fdb778..ea12ec1 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -5,7 +5,7 @@ publishes to NuGet.org, and creates a GitHub release for tags such as `v1.0.1`.
GitHub-generated notes are categorized using `.github/release.yml`, and package
plus symbol artifacts are attached to the release shown on the repository page.
-Prerelease tags use SemVer identifiers such as `v1.1.0-rc.1`. The workflow
+Prerelease tags use SemVer identifiers such as `vX.Y.Z-rc.1`. The workflow
derives the package version from the tag and marks the GitHub release as a
prerelease automatically. Before publishing any tag, the workflow validates the
public API, compares the package with the 1.0.0 compatibility baseline, and runs
diff --git a/src/OpenDisNet/DisReadStatus.cs b/src/OpenDisNet/DisReadStatus.cs
new file mode 100644
index 0000000..b3f97ef
--- /dev/null
+++ b/src/OpenDisNet/DisReadStatus.cs
@@ -0,0 +1,14 @@
+namespace OpenDisNet;
+
+/// Describes the outcome of reading one framed DIS PDU from a buffer.
+public enum DisReadStatus
+{
+ /// One complete PDU was decoded.
+ Done,
+
+ /// The buffer ended before the complete PDU was available.
+ NeedMoreData,
+
+ /// The buffer contains an invalid DIS header or PDU.
+ InvalidData,
+}
diff --git a/src/OpenDisNet/DisSerializer.cs b/src/OpenDisNet/DisSerializer.cs
index 975f7a3..e5e0062 100644
--- a/src/OpenDisNet/DisSerializer.cs
+++ b/src/OpenDisNet/DisSerializer.cs
@@ -1,3 +1,4 @@
+using System.Buffers;
using OpenDisNet.Binary;
using OpenDisNet.Pdus;
using OpenDisNet.Protocol;
@@ -7,6 +8,129 @@ namespace OpenDisNet;
/// Serializes and deserializes Distributed Interactive Simulation PDUs.
public static class DisSerializer
{
+ /// Attempts to inspect a DIS header without decoding its PDU body.
+ public static bool TryReadHeader(ReadOnlySpan source, out DisHeader header, out DisParseError error)
+ {
+ header = default;
+ error = default;
+
+ if (source.Length < 4)
+ return Fail(DisParseErrorCode.TruncatedHeader, "A DIS header requires at least 4 bytes to identify its layout.", source.Length, out error);
+
+ int requiredHeaderSize = RequiredHeaderSize(source[3]);
+ if (source.Length < requiredHeaderSize)
+ return Fail(DisParseErrorCode.TruncatedHeader, $"This DIS header requires {requiredHeaderSize} bytes.", source.Length, out error);
+
+ try
+ {
+ var reader = new DisBinaryReader(source[..requiredHeaderSize]);
+ header = DisHeaderCodec.Read(ref reader);
+ if (header.Length < requiredHeaderSize)
+ return Fail(DisParseErrorCode.InvalidLength, $"Invalid PDU length {header.Length}.", 8, out error);
+ return true;
+ }
+ catch (DisParseException exception)
+ {
+ return Fail(DisParseErrorCode.InvalidField, exception.Message, exception.Offset, out error);
+ }
+ }
+
+ /// Attempts to inspect a possibly segmented DIS header without decoding its PDU body.
+ public static bool TryReadHeader(ReadOnlySequence source, out DisHeader header, out DisParseError error)
+ {
+ int available = (int)Math.Min(source.Length, DisHeader.Size);
+ if (source.IsSingleSegment)
+ return TryReadHeader(source.FirstSpan[..available], out header, out error);
+
+ Span headerBytes = stackalloc byte[DisHeader.Size];
+ source.Slice(0, available).CopyTo(headerBytes);
+ return TryReadHeader(headerBytes[..available], out header, out error);
+ }
+
+ /// Attempts to read the first complete DIS PDU from a buffer.
+ public static DisReadStatus TryRead(
+ ReadOnlySpan source,
+ out IDisPdu? pdu,
+ out int bytesConsumed,
+ out DisParseError error) =>
+ TryRead(source, out pdu, out bytesConsumed, out error, null);
+
+ /// Attempts to read the first complete DIS PDU from a buffer with explicit parse options.
+ public static DisReadStatus TryRead(
+ ReadOnlySpan source,
+ out IDisPdu? pdu,
+ out int bytesConsumed,
+ out DisParseError error,
+ DisParseOptions? options)
+ {
+ pdu = null;
+ bytesConsumed = 0;
+ if (!TryReadHeader(source, out DisHeader header, out error))
+ return error.Code == DisParseErrorCode.TruncatedHeader ? DisReadStatus.NeedMoreData : DisReadStatus.InvalidData;
+
+ options ??= DisParseOptions.Default;
+ if (header.Length > options.MaximumPduLength)
+ {
+ Fail(DisParseErrorCode.InvalidLength, $"Invalid PDU length {header.Length}.", 8, out error);
+ return DisReadStatus.InvalidData;
+ }
+
+ if (source.Length < header.Length)
+ {
+ Fail(DisParseErrorCode.TruncatedPdu, $"The header declares {header.Length} bytes; only {source.Length} were received.", source.Length, out error);
+ return DisReadStatus.NeedMoreData;
+ }
+
+ DisParseOptions framedOptions = options with { RequireExactDatagramLength = true };
+ if (!TryDeserialize(source[..header.Length], out pdu, out error, framedOptions))
+ return DisReadStatus.InvalidData;
+
+ bytesConsumed = header.Length;
+ return DisReadStatus.Done;
+ }
+
+ /// Attempts to read the first complete DIS PDU from a possibly segmented buffer.
+ public static DisReadStatus TryRead(
+ ReadOnlySequence source,
+ out IDisPdu? pdu,
+ out int bytesConsumed,
+ out DisParseError error) =>
+ TryRead(source, out pdu, out bytesConsumed, out error, null);
+
+ /// Attempts to read the first complete DIS PDU from a possibly segmented buffer with explicit parse options.
+ public static DisReadStatus TryRead(
+ ReadOnlySequence source,
+ out IDisPdu? pdu,
+ out int bytesConsumed,
+ out DisParseError error,
+ DisParseOptions? options)
+ {
+ pdu = null;
+ bytesConsumed = 0;
+ if (!TryReadHeader(source, out DisHeader header, out error))
+ return error.Code == DisParseErrorCode.TruncatedHeader ? DisReadStatus.NeedMoreData : DisReadStatus.InvalidData;
+
+ options ??= DisParseOptions.Default;
+ if (header.Length > options.MaximumPduLength)
+ {
+ Fail(DisParseErrorCode.InvalidLength, $"Invalid PDU length {header.Length}.", 8, out error);
+ return DisReadStatus.InvalidData;
+ }
+
+ if (source.Length < header.Length)
+ {
+ Fail(DisParseErrorCode.TruncatedPdu, $"The header declares {header.Length} bytes; only {source.Length} were received.", (int)Math.Min(source.Length, int.MaxValue), out error);
+ return DisReadStatus.NeedMoreData;
+ }
+
+ ReadOnlySequence frame = source.Slice(0, header.Length);
+ if (frame.IsSingleSegment)
+ return TryRead(frame.FirstSpan, out pdu, out bytesConsumed, out error, options);
+
+ byte[] contiguousFrame = frame.ToArray();
+ return TryRead(contiguousFrame, out pdu, out bytesConsumed, out error, options);
+ }
+
/// Deserializes one complete DIS datagram and requires the specified PDU type.
public static TPdu Deserialize(ReadOnlySpan datagram, DisParseOptions? options = null)
where TPdu : class, IDisPdu
@@ -51,19 +175,11 @@ public static bool TryDeserialize(
pdu = null;
error = default;
- if (datagram.Length < 4)
- return Fail(DisParseErrorCode.TruncatedHeader, "A DIS header requires at least 4 bytes to identify its layout.", datagram.Length, out error);
-
- int requiredHeaderSize = datagram[3] == (byte)ProtocolFamily.LiveEntity
- ? DisHeader.MinimumSize
- : DisHeader.Size;
- if (datagram.Length < requiredHeaderSize)
- return Fail(DisParseErrorCode.TruncatedHeader, $"This DIS header requires {requiredHeaderSize} bytes.", datagram.Length, out error);
+ if (!TryReadHeader(datagram, out DisHeader header, out error))
+ return false;
try
{
- var reader = new DisBinaryReader(datagram);
- DisHeader header = DisHeaderCodec.Read(ref reader);
int headerSize = header.EncodedSize;
if (options.RequireVersion7 && header.ProtocolVersion != DisProtocolVersion.Ieee1278_1_2012)
@@ -75,7 +191,10 @@ public static bool TryDeserialize(
if (options.RequireExactDatagramLength && header.Length != datagram.Length)
return Fail(DisParseErrorCode.TrailingData, $"The datagram contains {datagram.Length - header.Length} trailing bytes.", header.Length, out error);
- pdu = PduRegistry.Parse(header, datagram.Slice(headerSize, header.Length - headerSize));
+ ReadOnlySpan body = datagram.Slice(headerSize, header.Length - headerSize);
+ pdu = header.ProtocolVersion == DisProtocolVersion.Ieee1278_1_2012
+ ? PduRegistry.Parse(header, body)
+ : new UnknownPdu(header, body.ToArray());
return true;
}
catch (DisParseException exception)
@@ -131,4 +250,7 @@ private static bool Fail(DisParseErrorCode code, string message, int offset, out
error = new(code, message, offset);
return false;
}
+
+ private static int RequiredHeaderSize(byte protocolFamily) =>
+ protocolFamily == (byte)ProtocolFamily.LiveEntity ? DisHeader.MinimumSize : DisHeader.Size;
}
diff --git a/src/OpenDisNet/Pdus/DisPduBuilder.cs b/src/OpenDisNet/Pdus/DisPduBuilder.cs
new file mode 100644
index 0000000..c14da67
--- /dev/null
+++ b/src/OpenDisNet/Pdus/DisPduBuilder.cs
@@ -0,0 +1,134 @@
+using OpenDisNet.Enumerations;
+
+namespace OpenDisNet.Pdus;
+
+/// Creates commonly used DIS v7 PDUs with valid discriminators and related fields.
+public static class DisPduBuilder
+{
+ /// Creates an Entity State PDU with its primary identity, type, and position populated.
+ public static EntityStatePdu CreateEntityState(
+ EntityId entityId,
+ EntityType entityType,
+ Vector3Double location,
+ ForceId forceId = ForceId.Other,
+ byte exerciseId = 0)
+ {
+ ArgumentNullException.ThrowIfNull(entityId);
+ ArgumentNullException.ThrowIfNull(entityType);
+ ArgumentNullException.ThrowIfNull(location);
+
+ return new EntityStatePdu
+ {
+ ExerciseId = exerciseId,
+ EntityId = entityId,
+ EntityType = entityType,
+ ForceId = forceId,
+ EntityLocation = location,
+ };
+ }
+
+ /// Creates a Fire PDU and derives its event simulation address from the firing entity.
+ public static FirePdu CreateFire(
+ EntityId firingEntityId,
+ EntityId targetEntityId,
+ EntityId munitionEntityId,
+ ushort eventNumber,
+ MunitionDescriptor descriptor,
+ Vector3Double location,
+ Vector3Float velocity,
+ float range = 0,
+ byte exerciseId = 0)
+ {
+ ArgumentNullException.ThrowIfNull(firingEntityId);
+ ArgumentNullException.ThrowIfNull(targetEntityId);
+ ArgumentNullException.ThrowIfNull(munitionEntityId);
+ ArgumentNullException.ThrowIfNull(descriptor);
+ ArgumentNullException.ThrowIfNull(location);
+ ArgumentNullException.ThrowIfNull(velocity);
+
+ return new FirePdu
+ {
+ ExerciseId = exerciseId,
+ FiringEntityId = firingEntityId,
+ TargetEntityId = targetEntityId,
+ MunitionExpendibleId = munitionEntityId,
+ EventId = CreateEventIdentifier(firingEntityId, eventNumber),
+ Descriptor = descriptor,
+ LocationInWorldCoordinates = location,
+ Velocity = velocity,
+ Range = range,
+ };
+ }
+
+ /// Creates a Detonation PDU and derives its event simulation address from the source entity.
+ public static DetonationPdu CreateDetonation(
+ EntityId sourceEntityId,
+ EntityId targetEntityId,
+ EntityId explodingEntityId,
+ ushort eventNumber,
+ MunitionDescriptor descriptor,
+ Vector3Double location,
+ Vector3Float velocity,
+ DetonationResult result,
+ byte exerciseId = 0)
+ {
+ ArgumentNullException.ThrowIfNull(sourceEntityId);
+ ArgumentNullException.ThrowIfNull(targetEntityId);
+ ArgumentNullException.ThrowIfNull(explodingEntityId);
+ ArgumentNullException.ThrowIfNull(descriptor);
+ ArgumentNullException.ThrowIfNull(location);
+ ArgumentNullException.ThrowIfNull(velocity);
+
+ return new DetonationPdu
+ {
+ ExerciseId = exerciseId,
+ SourceEntityId = sourceEntityId,
+ TargetEntityId = targetEntityId,
+ ExplodingEntityId = explodingEntityId,
+ EventId = CreateEventIdentifier(sourceEntityId, eventNumber),
+ Descriptor = descriptor,
+ LocationInWorldCoordinates = location,
+ Velocity = velocity,
+ DetonationResult = result,
+ };
+ }
+
+ /// Creates a Transmitter PDU for an entity radio.
+ public static TransmitterPdu CreateTransmitter(
+ RadioId radio,
+ RadioType radioType,
+ ulong frequency,
+ float power,
+ TransmitterTransmitState transmitState = TransmitterTransmitState.OnAndTransmitting,
+ TransmitterInputSource inputSource = TransmitterInputSource.Other,
+ byte exerciseId = 0)
+ {
+ ArgumentNullException.ThrowIfNull(radio.Entity);
+ ArgumentNullException.ThrowIfNull(radioType);
+
+ return new TransmitterPdu
+ {
+ ExerciseId = exerciseId,
+ RadioHeader = new RadioCommsHeader
+ {
+ RadioReferenceId = radio.Entity,
+ RadioNumber = radio.Number,
+ },
+ RadioEntityType = radioType,
+ Frequency = frequency,
+ Power = power,
+ TransmitState = transmitState,
+ InputSource = inputSource,
+ };
+ }
+
+ private static EventIdentifier CreateEventIdentifier(EntityId entityId, ushort eventNumber) => new()
+ {
+ SimulationAddress = new SimulationAddress
+ {
+ Site = entityId.SiteId,
+ Application = entityId.ApplicationId,
+ },
+ EventNumber = eventNumber,
+ };
+}
diff --git a/src/OpenDisNet/PublicAPI.Unshipped.txt b/src/OpenDisNet/PublicAPI.Unshipped.txt
index 7dc5c58..2d82a22 100644
--- a/src/OpenDisNet/PublicAPI.Unshipped.txt
+++ b/src/OpenDisNet/PublicAPI.Unshipped.txt
@@ -1 +1,29 @@
#nullable enable
+OpenDisNet.DisReadStatus
+OpenDisNet.DisReadStatus.Done = 0 -> OpenDisNet.DisReadStatus
+OpenDisNet.DisReadStatus.InvalidData = 2 -> OpenDisNet.DisReadStatus
+OpenDisNet.DisReadStatus.NeedMoreData = 1 -> OpenDisNet.DisReadStatus
+OpenDisNet.Pdus.DisPduBuilder
+OpenDisNet.Validation.DisValidationIssue
+OpenDisNet.Validation.DisValidationIssue.Message.get -> string!
+OpenDisNet.Validation.DisValidationIssue.Path.get -> string!
+OpenDisNet.Validation.DisValidationIssue.Severity.get -> OpenDisNet.Validation.DisValidationSeverity
+OpenDisNet.Validation.DisValidationResult
+OpenDisNet.Validation.DisValidationResult.HasWarnings.get -> bool
+OpenDisNet.Validation.DisValidationResult.Issues.get -> System.Collections.Generic.IReadOnlyList!
+OpenDisNet.Validation.DisValidationResult.IsValid.get -> bool
+OpenDisNet.Validation.DisValidationSeverity
+OpenDisNet.Validation.DisValidationSeverity.Error = 1 -> OpenDisNet.Validation.DisValidationSeverity
+OpenDisNet.Validation.DisValidationSeverity.Warning = 0 -> OpenDisNet.Validation.DisValidationSeverity
+OpenDisNet.Validation.DisValidator
+static OpenDisNet.DisSerializer.TryRead(System.Buffers.ReadOnlySequence source, out OpenDisNet.Pdus.IDisPdu? pdu, out int bytesConsumed, out OpenDisNet.DisParseError error) -> OpenDisNet.DisReadStatus
+static OpenDisNet.DisSerializer.TryRead(System.Buffers.ReadOnlySequence source, out OpenDisNet.Pdus.IDisPdu? pdu, out int bytesConsumed, out OpenDisNet.DisParseError error, OpenDisNet.DisParseOptions? options) -> OpenDisNet.DisReadStatus
+static OpenDisNet.DisSerializer.TryRead(System.ReadOnlySpan source, out OpenDisNet.Pdus.IDisPdu? pdu, out int bytesConsumed, out OpenDisNet.DisParseError error) -> OpenDisNet.DisReadStatus
+static OpenDisNet.DisSerializer.TryRead(System.ReadOnlySpan source, out OpenDisNet.Pdus.IDisPdu? pdu, out int bytesConsumed, out OpenDisNet.DisParseError error, OpenDisNet.DisParseOptions? options) -> OpenDisNet.DisReadStatus
+static OpenDisNet.DisSerializer.TryReadHeader(System.Buffers.ReadOnlySequence source, out OpenDisNet.Protocol.DisHeader header, out OpenDisNet.DisParseError error) -> bool
+static OpenDisNet.DisSerializer.TryReadHeader(System.ReadOnlySpan source, out OpenDisNet.Protocol.DisHeader header, out OpenDisNet.DisParseError error) -> bool
+static OpenDisNet.Pdus.DisPduBuilder.CreateDetonation(OpenDisNet.Pdus.EntityId! sourceEntityId, OpenDisNet.Pdus.EntityId! targetEntityId, OpenDisNet.Pdus.EntityId! explodingEntityId, ushort eventNumber, OpenDisNet.Pdus.MunitionDescriptor! descriptor, OpenDisNet.Pdus.Vector3Double! location, OpenDisNet.Pdus.Vector3Float! velocity, OpenDisNet.Enumerations.DetonationResult result, byte exerciseId = 0) -> OpenDisNet.Pdus.DetonationPdu!
+static OpenDisNet.Pdus.DisPduBuilder.CreateEntityState(OpenDisNet.Pdus.EntityId! entityId, OpenDisNet.Pdus.EntityType! entityType, OpenDisNet.Pdus.Vector3Double! location, OpenDisNet.Enumerations.ForceId forceId = OpenDisNet.Enumerations.ForceId.Other, byte exerciseId = 0) -> OpenDisNet.Pdus.EntityStatePdu!
+static OpenDisNet.Pdus.DisPduBuilder.CreateFire(OpenDisNet.Pdus.EntityId! firingEntityId, OpenDisNet.Pdus.EntityId! targetEntityId, OpenDisNet.Pdus.EntityId! munitionEntityId, ushort eventNumber, OpenDisNet.Pdus.MunitionDescriptor! descriptor, OpenDisNet.Pdus.Vector3Double! location, OpenDisNet.Pdus.Vector3Float! velocity, float range = 0, byte exerciseId = 0) -> OpenDisNet.Pdus.FirePdu!
+static OpenDisNet.Pdus.DisPduBuilder.CreateTransmitter(OpenDisNet.Pdus.RadioId radio, OpenDisNet.Pdus.RadioType! radioType, ulong frequency, float power, OpenDisNet.Enumerations.TransmitterTransmitState transmitState = OpenDisNet.Enumerations.TransmitterTransmitState.OnAndTransmitting, OpenDisNet.Enumerations.TransmitterInputSource inputSource = OpenDisNet.Enumerations.TransmitterInputSource.Other, byte exerciseId = 0) -> OpenDisNet.Pdus.TransmitterPdu!
+static OpenDisNet.Validation.DisValidator.Validate(OpenDisNet.Pdus.IDisPdu! pdu) -> OpenDisNet.Validation.DisValidationResult!
diff --git a/src/OpenDisNet/Validation/DisValidationIssue.cs b/src/OpenDisNet/Validation/DisValidationIssue.cs
new file mode 100644
index 0000000..2438d5d
--- /dev/null
+++ b/src/OpenDisNet/Validation/DisValidationIssue.cs
@@ -0,0 +1,31 @@
+namespace OpenDisNet.Validation;
+
+/// Severity assigned to a semantic validation issue.
+public enum DisValidationSeverity
+{
+ /// The value is legal on the wire but is likely incomplete or unintended.
+ Warning,
+
+ /// The value is internally inconsistent or cannot represent a meaningful PDU.
+ Error,
+}
+
+/// Describes one semantic issue in a decoded or constructed PDU.
+public sealed class DisValidationIssue
+{
+ internal DisValidationIssue(DisValidationSeverity severity, string path, string message)
+ {
+ Severity = severity;
+ Path = path;
+ Message = message;
+ }
+
+ /// Gets the severity of the issue.
+ public DisValidationSeverity Severity { get; }
+
+ /// Gets the property path associated with the issue.
+ public string Path { get; }
+
+ /// Gets the human-readable explanation.
+ public string Message { get; }
+}
diff --git a/src/OpenDisNet/Validation/DisValidationResult.cs b/src/OpenDisNet/Validation/DisValidationResult.cs
new file mode 100644
index 0000000..995bb77
--- /dev/null
+++ b/src/OpenDisNet/Validation/DisValidationResult.cs
@@ -0,0 +1,16 @@
+namespace OpenDisNet.Validation;
+
+/// Contains the non-mutating semantic validation result for a PDU.
+public sealed class DisValidationResult
+{
+ internal DisValidationResult(IReadOnlyList issues) => Issues = issues;
+
+ /// Gets every issue in deterministic field order.
+ public IReadOnlyList Issues { get; }
+
+ /// Gets whether validation found no errors. Warnings do not make a result invalid.
+ public bool IsValid => !Issues.Any(issue => issue.Severity == DisValidationSeverity.Error);
+
+ /// Gets whether validation found at least one warning.
+ public bool HasWarnings => Issues.Any(issue => issue.Severity == DisValidationSeverity.Warning);
+}
diff --git a/src/OpenDisNet/Validation/DisValidator.cs b/src/OpenDisNet/Validation/DisValidator.cs
new file mode 100644
index 0000000..791a5ed
--- /dev/null
+++ b/src/OpenDisNet/Validation/DisValidator.cs
@@ -0,0 +1,152 @@
+using OpenDisNet.Enumerations;
+using OpenDisNet.Pdus;
+using OpenDisNet.Protocol;
+
+namespace OpenDisNet.Validation;
+
+/// Performs non-mutating semantic checks separately from wire-format parsing.
+public static class DisValidator
+{
+ /// Validates discriminator consistency and common PDU-specific numeric invariants.
+ public static DisValidationResult Validate(IDisPdu pdu)
+ {
+ ArgumentNullException.ThrowIfNull(pdu);
+ var issues = new List();
+
+ if (pdu is Pdu typed)
+ ValidateDiscriminators(typed, issues);
+
+ switch (pdu)
+ {
+ case EntityStatePdu entityState:
+ ValidateVector(entityState.EntityLocation, nameof(EntityStatePdu.EntityLocation), issues);
+ ValidateVector(entityState.EntityLinearVelocity, nameof(EntityStatePdu.EntityLinearVelocity), issues);
+ ValidateAngles(entityState.EntityOrientation, nameof(EntityStatePdu.EntityOrientation), issues);
+ WarnIfUnset(entityState.EntityId, nameof(EntityStatePdu.EntityId), issues);
+ break;
+ case FirePdu fire:
+ ValidateVector(fire.LocationInWorldCoordinates, nameof(FirePdu.LocationInWorldCoordinates), issues);
+ ValidateVector(fire.Velocity, nameof(FirePdu.Velocity), issues);
+ ValidateNonNegative(fire.Range, nameof(FirePdu.Range), issues);
+ WarnIfUnset(fire.FiringEntityId, nameof(FirePdu.FiringEntityId), issues);
+ break;
+ case DetonationPdu detonation:
+ ValidateVector(detonation.LocationInWorldCoordinates, nameof(DetonationPdu.LocationInWorldCoordinates), issues);
+ ValidateVector(detonation.LocationOfEntityCoordinates, nameof(DetonationPdu.LocationOfEntityCoordinates), issues);
+ ValidateVector(detonation.Velocity, nameof(DetonationPdu.Velocity), issues);
+ WarnIfUnset(detonation.SourceEntityId, nameof(DetonationPdu.SourceEntityId), issues);
+ break;
+ case TransmitterPdu transmitter:
+ ValidateNonNegative(transmitter.Power, nameof(TransmitterPdu.Power), issues);
+ ValidateNonNegative(transmitter.TransmitFrequencyBandwidth, nameof(TransmitterPdu.TransmitFrequencyBandwidth), issues);
+ if (transmitter.TransmitState == TransmitterTransmitState.OnAndTransmitting && transmitter.Frequency == 0)
+ AddError(issues, nameof(TransmitterPdu.Frequency), "A transmitting radio must specify a non-zero frequency.");
+ if (transmitter.RadioHeader is null)
+ AddError(issues, nameof(TransmitterPdu.RadioHeader), "The radio header is required.");
+ else
+ WarnIfUnset(transmitter.RadioHeader.RadioReferenceId, $"{nameof(TransmitterPdu.RadioHeader)}.{nameof(RadioCommsHeader.RadioReferenceId)}", issues);
+ break;
+ case SignalPdu signal:
+ ValidateSignalData(signal, issues);
+ break;
+ }
+
+ return new DisValidationResult(issues.ToArray());
+ }
+
+ private static void ValidateDiscriminators(Pdu pdu, List issues)
+ {
+ if (pdu.ProtocolVersion != DisProtocolVersion.Ieee1278_1_2012)
+ AddError(issues, nameof(Pdu.ProtocolVersion), "Typed OpenDisNet PDUs use DIS protocol version 7.");
+
+ try
+ {
+ Pdu expected = PduFactory.Create(pdu.PduType);
+ if (expected.GetType() != pdu.GetType())
+ AddError(issues, nameof(Pdu.PduType), $"{pdu.GetType().Name} cannot use PDU type {pdu.PduType}.");
+ if (expected.ProtocolFamily != pdu.ProtocolFamily)
+ AddError(issues, nameof(Pdu.ProtocolFamily), $"{pdu.GetType().Name} belongs to protocol family {expected.ProtocolFamily}.");
+ }
+ catch (ArgumentOutOfRangeException)
+ {
+ AddError(issues, nameof(Pdu.PduType), $"PDU type {(byte)pdu.PduType} is not a standardized DIS v7 type.");
+ }
+ }
+
+ private static void ValidateSignalData(SignalPdu signal, List issues)
+ {
+ if (signal.Data is null)
+ {
+ AddError(issues, nameof(SignalPdu.Data), "Signal data is required.");
+ return;
+ }
+
+ int maximumBits = checked(signal.Data.Length * 8);
+ int minimumBits = signal.Data.Length == 0 ? 0 : checked((signal.Data.Length - 1) * 8 + 1);
+ if (signal.DataBitLength < minimumBits || signal.DataBitLength > maximumBits)
+ AddError(issues, nameof(SignalPdu.DataBitLength), "The meaningful bit length must describe every supplied octet, allowing only unused bits in the final octet.");
+ }
+
+ private static void ValidateAngles(EulerAngles? value, string path, List issues)
+ {
+ if (value is null)
+ {
+ AddError(issues, path, "The value is required.");
+ return;
+ }
+ ValidateFinite(value.Psi, $"{path}.{nameof(EulerAngles.Psi)}", issues);
+ ValidateFinite(value.Theta, $"{path}.{nameof(EulerAngles.Theta)}", issues);
+ ValidateFinite(value.Phi, $"{path}.{nameof(EulerAngles.Phi)}", issues);
+ }
+
+ private static void ValidateVector(Vector3Double? value, string path, List issues)
+ {
+ if (value is null)
+ {
+ AddError(issues, path, "The value is required.");
+ return;
+ }
+ ValidateFinite(value.X, $"{path}.{nameof(Vector3Double.X)}", issues);
+ ValidateFinite(value.Y, $"{path}.{nameof(Vector3Double.Y)}", issues);
+ ValidateFinite(value.Z, $"{path}.{nameof(Vector3Double.Z)}", issues);
+ }
+
+ private static void ValidateVector(Vector3Float? value, string path, List issues)
+ {
+ if (value is null)
+ {
+ AddError(issues, path, "The value is required.");
+ return;
+ }
+ ValidateFinite(value.X, $"{path}.{nameof(Vector3Float.X)}", issues);
+ ValidateFinite(value.Y, $"{path}.{nameof(Vector3Float.Y)}", issues);
+ ValidateFinite(value.Z, $"{path}.{nameof(Vector3Float.Z)}", issues);
+ }
+
+ private static void ValidateNonNegative(float value, string path, List issues)
+ {
+ ValidateFinite(value, path, issues);
+ if (float.IsFinite(value) && value < 0)
+ AddError(issues, path, "The value cannot be negative.");
+ }
+
+ private static void ValidateFinite(double value, string path, List issues)
+ {
+ if (!double.IsFinite(value))
+ AddError(issues, path, "The value must be finite.");
+ }
+
+ private static void WarnIfUnset(EntityId? entityId, string path, List issues)
+ {
+ if (entityId is null)
+ {
+ AddError(issues, path, "The entity identifier is required.");
+ return;
+ }
+ if (entityId.SiteId == 0 && entityId.ApplicationId == 0 && entityId.EntityNumber == 0)
+ issues.Add(new(DisValidationSeverity.Warning, path, "The entity identifier is unset."));
+ }
+
+ private static void AddError(List issues, string path, string message) =>
+ issues.Add(new(DisValidationSeverity.Error, path, message));
+}
diff --git a/tests/OpenDisNet.Fuzz/OpenDisNet.Fuzz.csproj b/tests/OpenDisNet.Fuzz/OpenDisNet.Fuzz.csproj
new file mode 100644
index 0000000..c61699f
--- /dev/null
+++ b/tests/OpenDisNet.Fuzz/OpenDisNet.Fuzz.csproj
@@ -0,0 +1,13 @@
+
+
+ Exe
+ net10.0
+ false
+
+
+
+
+
+
+
+
diff --git a/tests/OpenDisNet.Fuzz/ParserFuzzTarget.cs b/tests/OpenDisNet.Fuzz/ParserFuzzTarget.cs
new file mode 100644
index 0000000..759e7e3
--- /dev/null
+++ b/tests/OpenDisNet.Fuzz/ParserFuzzTarget.cs
@@ -0,0 +1,109 @@
+using System.Buffers;
+using OpenDisNet;
+using OpenDisNet.Pdus;
+using OpenDisNet.Protocol;
+using OpenDisNet.Validation;
+
+internal static class ParserFuzzTarget
+{
+ private static readonly DisParseOptions PermissiveOptions = new()
+ {
+ RequireVersion7 = false,
+ MaximumPduLength = ushort.MaxValue,
+ };
+
+ public static void Run(ReadOnlySpan input)
+ {
+ DisSerializer.TryReadHeader(input, out _, out _);
+ DisSerializer.TryDeserialize(input, out _, out _, PermissiveOptions);
+
+ DisReadStatus status = DisSerializer.TryRead(input, out IDisPdu? pdu, out int consumed, out _, PermissiveOptions);
+ if (status != DisReadStatus.Done)
+ return;
+
+ if (pdu is null || consumed <= 0 || consumed > input.Length)
+ throw new InvalidOperationException("A successful framed read returned an invalid result.");
+
+ _ = DisValidator.Validate(pdu);
+ byte[] serialized = DisSerializer.Serialize(pdu);
+ if (DisSerializer.TryRead(serialized, out _, out int roundTripConsumed, out _, PermissiveOptions) != DisReadStatus.Done ||
+ roundTripConsumed != serialized.Length)
+ {
+ throw new InvalidOperationException("A parsed PDU did not survive serialization and framed parsing.");
+ }
+
+ var segmented = CreateSegmentedSequence(input[..consumed]);
+ if (DisSerializer.TryRead(segmented, out _, out int segmentedConsumed, out _, PermissiveOptions) != DisReadStatus.Done ||
+ segmentedConsumed != consumed)
+ {
+ throw new InvalidOperationException("Contiguous and segmented parsing produced different framing results.");
+ }
+ }
+
+ public static void RunSmokeCorpus()
+ {
+ IReadOnlyList seeds = CreateSeeds();
+ foreach (byte[] seed in seeds)
+ {
+ Run(seed);
+ for (int length = 0; length < seed.Length; length += Math.Max(1, seed.Length / 8))
+ Run(seed.AsSpan(0, length));
+
+ for (int index = 0; index < seed.Length; index += Math.Max(1, seed.Length / 16))
+ {
+ byte original = seed[index];
+ seed[index] ^= 0xFF;
+ Run(seed);
+ seed[index] = original;
+ }
+ }
+
+ var random = new Random(1278);
+ for (int iteration = 0; iteration < 1_000; iteration++)
+ {
+ byte[] bytes = new byte[random.Next(0, 2048)];
+ random.NextBytes(bytes);
+ Run(bytes);
+ }
+ }
+
+ public static void WriteCorpus(string directory)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(directory);
+ Directory.CreateDirectory(directory);
+ IReadOnlyList seeds = CreateSeeds();
+ for (int index = 0; index < seeds.Count; index++)
+ File.WriteAllBytes(Path.Combine(directory, $"pdu-{index:D2}.bin"), seeds[index]);
+ }
+
+ private static IReadOnlyList CreateSeeds()
+ {
+ var seeds = new List { Array.Empty(), new byte[] { 7, 1, 1 } };
+ for (byte pduType = 1; pduType <= 72; pduType++)
+ seeds.Add(DisSerializer.Serialize(PduFactory.Create((PduType)pduType, exerciseId: 1)));
+
+ var nonV7Header = new DisHeader((DisProtocolVersion)6, 1, PduType.EntityState, ProtocolFamily.EntityInformationInteraction, 0, 16, 0, 0);
+ seeds.Add(DisSerializer.Serialize(new UnknownPdu(nonV7Header, new byte[4])));
+ return seeds;
+ }
+
+ private static ReadOnlySequence CreateSegmentedSequence(ReadOnlySpan input)
+ {
+ int split = input.Length / 2;
+ var first = new BufferSegment(input[..split].ToArray());
+ BufferSegment last = first.Append(input[split..].ToArray());
+ return new ReadOnlySequence(first, 0, last, last.Memory.Length);
+ }
+
+ private sealed class BufferSegment : ReadOnlySequenceSegment
+ {
+ public BufferSegment(ReadOnlyMemory memory) => Memory = memory;
+
+ public BufferSegment Append(ReadOnlyMemory memory)
+ {
+ var segment = new BufferSegment(memory) { RunningIndex = RunningIndex + Memory.Length };
+ Next = segment;
+ return segment;
+ }
+ }
+}
diff --git a/tests/OpenDisNet.Fuzz/Program.cs b/tests/OpenDisNet.Fuzz/Program.cs
new file mode 100644
index 0000000..1cbcb51
--- /dev/null
+++ b/tests/OpenDisNet.Fuzz/Program.cs
@@ -0,0 +1,20 @@
+using SharpFuzz;
+
+if (args is ["--smoke"])
+{
+ ParserFuzzTarget.RunSmokeCorpus();
+ return;
+}
+
+if (args is ["--write-corpus", string directory])
+{
+ ParserFuzzTarget.WriteCorpus(directory);
+ return;
+}
+
+Fuzzer.OutOfProcess.Run(stream =>
+{
+ using var input = new MemoryStream();
+ stream.CopyTo(input);
+ ParserFuzzTarget.Run(input.ToArray());
+});
diff --git a/tests/OpenDisNet.Fuzz/packages.lock.json b/tests/OpenDisNet.Fuzz/packages.lock.json
new file mode 100644
index 0000000..650eea7
--- /dev/null
+++ b/tests/OpenDisNet.Fuzz/packages.lock.json
@@ -0,0 +1,30 @@
+{
+ "version": 1,
+ "dependencies": {
+ "net10.0": {
+ "SharpFuzz": {
+ "type": "Direct",
+ "requested": "[2.3.0, )",
+ "resolved": "2.3.0",
+ "contentHash": "5f11toR82RVCIBTr5XuuauJgesGVXIAO6vBCvLThNbrcer9NN6qB2HCA/Q122BvH2qRPsLx14O6QVtCbgRQhog==",
+ "dependencies": {
+ "SharpFuzz.Common": "2.2.0",
+ "dnlib": "4.4.0"
+ }
+ },
+ "dnlib": {
+ "type": "Transitive",
+ "resolved": "4.4.0",
+ "contentHash": "cKHI720q+zfEEvzklWVGt6B0TH3AibAyJbpUJl4U6KvTP13tycfnqJpkGHRZ/oQ45BTIoIxIwltHIJVDN+iCqQ=="
+ },
+ "SharpFuzz.Common": {
+ "type": "Transitive",
+ "resolved": "2.2.0",
+ "contentHash": "biITWpwnMR7HUp43lAGU97DWq/4LfyXqqhuOK0Z4IuRP97KjQMOe/GKq3wE1KY21gNrc7OPO9HbAtQUvMKTImA=="
+ },
+ "opendisnet": {
+ "type": "Project"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/OpenDisNet.PackageSmoke/Program.cs b/tests/OpenDisNet.PackageSmoke/Program.cs
index bd101e4..5d0f865 100644
--- a/tests/OpenDisNet.PackageSmoke/Program.cs
+++ b/tests/OpenDisNet.PackageSmoke/Program.cs
@@ -2,6 +2,7 @@
using OpenDisNet.Enumerations;
using OpenDisNet.Pdus;
using OpenDisNet.Protocol;
+using OpenDisNet.Validation;
var signal = new SignalPdu
{
@@ -21,6 +22,23 @@
if (!decoded.Data.AsSpan().SequenceEqual("external-consumer"u8))
throw new InvalidOperationException("The packed Signal PDU API did not round-trip its payload.");
+if (DisSerializer.TryRead(datagram, out IDisPdu? framed, out int consumed, out _) != DisReadStatus.Done ||
+ framed is not SignalPdu || consumed != datagram.Length)
+{
+ throw new InvalidOperationException("The packed framed-reading API did not consume the Signal PDU.");
+}
+
+FirePdu fire = DisPduBuilder.CreateFire(
+ new EntityId(1, 2, 3),
+ new EntityId(1, 2, 4),
+ new EntityId(1, 2, 5),
+ 1,
+ new MunitionDescriptor { Quantity = 1 },
+ new Vector3Double(),
+ new Vector3Float());
+if (!DisValidator.Validate(fire).IsValid)
+ throw new InvalidOperationException("The packed builder and validation APIs produced an invalid Fire PDU.");
+
foreach (PduType type in Enum.GetValues().Where(x => (byte)x is >= 1 and <= 72))
{
Pdu pdu = PduFactory.Create(type, exerciseId: 7);
diff --git a/tests/OpenDisNet.Tests/FramedReadingTests.cs b/tests/OpenDisNet.Tests/FramedReadingTests.cs
new file mode 100644
index 0000000..309b794
--- /dev/null
+++ b/tests/OpenDisNet.Tests/FramedReadingTests.cs
@@ -0,0 +1,118 @@
+using System.Buffers;
+using OpenDisNet.Pdus;
+using OpenDisNet.Protocol;
+
+namespace OpenDisNet.Tests;
+
+[TestClass]
+public sealed class FramedReadingTests
+{
+ [TestMethod]
+ public void PermissiveVersionParsingPreservesNonVersion7Body()
+ {
+ byte[] body = [0xAA, 0xBB, 0xCC, 0xDD];
+ var header = new DisHeader(
+ (DisProtocolVersion)6,
+ 1,
+ PduType.EntityState,
+ ProtocolFamily.EntityInformationInteraction,
+ 42,
+ 0,
+ 0,
+ 0);
+ byte[] datagram = DisSerializer.Serialize(new UnknownPdu(header, body));
+
+ Assert.IsFalse(DisSerializer.TryDeserialize(datagram, out _, out DisParseError strictError));
+ Assert.AreEqual(DisParseErrorCode.UnsupportedProtocolVersion, strictError.Code);
+
+ var options = new DisParseOptions { RequireVersion7 = false };
+ Assert.IsTrue(DisSerializer.TryDeserialize(datagram, out IDisPdu? parsed, out _, options));
+ UnknownPdu unknown = Assert.IsInstanceOfType(parsed);
+ Assert.AreEqual((byte)6, (byte)unknown.Header.ProtocolVersion);
+ Assert.AreSequenceEqual(body, unknown.Body.ToArray());
+ }
+
+ [TestMethod]
+ public void HeaderInspectionDoesNotRequireThePduBody()
+ {
+ byte[] datagram = DisSerializer.Serialize(new FirePdu());
+
+ Assert.IsTrue(DisSerializer.TryReadHeader(datagram.AsSpan(0, DisHeader.Size), out DisHeader header, out _));
+ Assert.AreEqual(PduType.Fire, header.PduType);
+ Assert.AreEqual(datagram.Length, header.Length);
+ }
+
+ [TestMethod]
+ public void FramedSpanReadingConsumesOnePduAtATime()
+ {
+ byte[] first = DisSerializer.Serialize(new FirePdu());
+ byte[] second = DisSerializer.Serialize(new EntityStatePdu());
+ byte[] combined = [.. first, .. second];
+
+ Assert.AreEqual(DisReadStatus.Done, DisSerializer.TryRead(combined, out IDisPdu? firstPdu, out int firstConsumed, out _));
+ Assert.IsInstanceOfType(firstPdu);
+ Assert.AreEqual(first.Length, firstConsumed);
+
+ Assert.AreEqual(DisReadStatus.Done, DisSerializer.TryRead(combined.AsSpan(firstConsumed), out IDisPdu? secondPdu, out int secondConsumed, out _));
+ Assert.IsInstanceOfType(secondPdu);
+ Assert.AreEqual(second.Length, secondConsumed);
+ }
+
+ [TestMethod]
+ public void SegmentedReadingMatchesContiguousReading()
+ {
+ byte[] datagram = DisSerializer.Serialize(new EntityStatePdu());
+ ReadOnlySequence sequence = CreateSequence(datagram, split: 5);
+
+ Assert.IsTrue(DisSerializer.TryReadHeader(sequence, out DisHeader header, out _));
+ Assert.AreEqual(PduType.EntityState, header.PduType);
+ Assert.AreEqual(DisReadStatus.Done, DisSerializer.TryRead(sequence, out IDisPdu? pdu, out int consumed, out _));
+ Assert.IsInstanceOfType(pdu);
+ Assert.AreEqual(datagram.Length, consumed);
+ }
+
+ [TestMethod]
+ public void FramedReadingDistinguishesIncompleteAndInvalidInput()
+ {
+ byte[] datagram = DisSerializer.Serialize(new FirePdu());
+ Assert.AreEqual(DisReadStatus.NeedMoreData, DisSerializer.TryRead(datagram.AsSpan(0, datagram.Length - 1), out _, out int incompleteConsumed, out DisParseError incompleteError));
+ Assert.AreEqual(0, incompleteConsumed);
+ Assert.AreEqual(DisParseErrorCode.TruncatedPdu, incompleteError.Code);
+
+ byte[] invalid = (byte[])datagram.Clone();
+ invalid[8] = 0;
+ invalid[9] = 1;
+ Assert.AreEqual(DisReadStatus.InvalidData, DisSerializer.TryRead(invalid, out _, out int invalidConsumed, out DisParseError invalidError));
+ Assert.AreEqual(0, invalidConsumed);
+ Assert.AreEqual(DisParseErrorCode.InvalidLength, invalidError.Code);
+ }
+
+ [TestMethod]
+ public void FramedReadingHonorsMaximumPduLength()
+ {
+ byte[] datagram = DisSerializer.Serialize(new EntityStatePdu());
+ var options = new DisParseOptions { MaximumPduLength = datagram.Length - 1 };
+
+ Assert.AreEqual(DisReadStatus.InvalidData, DisSerializer.TryRead(datagram, out _, out _, out DisParseError error, options));
+ Assert.AreEqual(DisParseErrorCode.InvalidLength, error.Code);
+ }
+
+ private static ReadOnlySequence CreateSequence(byte[] bytes, int split)
+ {
+ var first = new Segment(bytes.AsMemory(0, split));
+ Segment last = first.Append(bytes.AsMemory(split));
+ return new ReadOnlySequence(first, 0, last, last.Memory.Length);
+ }
+
+ private sealed class Segment : ReadOnlySequenceSegment
+ {
+ public Segment(ReadOnlyMemory memory) => Memory = memory;
+
+ public Segment Append(ReadOnlyMemory memory)
+ {
+ var segment = new Segment(memory) { RunningIndex = RunningIndex + Memory.Length };
+ Next = segment;
+ return segment;
+ }
+ }
+}
diff --git a/tests/OpenDisNet.Tests/Pdus/DisPduBuilderTests.cs b/tests/OpenDisNet.Tests/Pdus/DisPduBuilderTests.cs
new file mode 100644
index 0000000..a4f5781
--- /dev/null
+++ b/tests/OpenDisNet.Tests/Pdus/DisPduBuilderTests.cs
@@ -0,0 +1,58 @@
+using OpenDisNet.Enumerations;
+using OpenDisNet.Pdus;
+
+namespace OpenDisNet.Tests.Pdus;
+
+[TestClass]
+public sealed class DisPduBuilderTests
+{
+ [TestMethod]
+ public void EntityStateBuilderCreatesRoundTrippablePdu()
+ {
+ EntityStatePdu pdu = DisPduBuilder.CreateEntityState(
+ new EntityId(1, 2, 3),
+ new EntityType { EntityKind = EntityKind.Platform },
+ new Vector3Double { X = 10, Y = 20, Z = 30 },
+ ForceId.Friendly,
+ exerciseId: 4);
+
+ EntityStatePdu parsed = DisSerializer.Deserialize(DisSerializer.Serialize(pdu));
+ Assert.AreEqual((byte)4, parsed.ExerciseId);
+ Assert.AreEqual((ushort)3, parsed.EntityId.EntityNumber);
+ Assert.AreEqual(30, parsed.EntityLocation.Z);
+ }
+
+ [TestMethod]
+ public void FireAndDetonationBuildersDeriveEventAddress()
+ {
+ var source = new EntityId(10, 20, 30);
+ var target = new EntityId(10, 20, 31);
+ var munition = new EntityId(10, 20, 32);
+ var descriptor = new MunitionDescriptor { Quantity = 1 };
+ var location = new Vector3Double { X = 100, Y = 200, Z = 300 };
+ var velocity = new Vector3Float { X = 1, Y = 2, Z = 3 };
+
+ FirePdu fire = DisPduBuilder.CreateFire(source, target, munition, 99, descriptor, location, velocity, range: 500);
+ DetonationPdu detonation = DisPduBuilder.CreateDetonation(source, target, munition, 99, descriptor, location, velocity, DetonationResult.EntityImpact);
+
+ Assert.AreEqual((ushort)10, fire.EventId.SimulationAddress.Site);
+ Assert.AreEqual((ushort)20, fire.EventId.SimulationAddress.Application);
+ Assert.AreEqual((ushort)99, detonation.EventId.EventNumber);
+ Assert.IsInstanceOfType(DisSerializer.Deserialize(DisSerializer.Serialize(fire)));
+ Assert.IsInstanceOfType(DisSerializer.Deserialize(DisSerializer.Serialize(detonation)));
+ }
+
+ [TestMethod]
+ public void TransmitterBuilderPopulatesRadioAndOperatingState()
+ {
+ TransmitterPdu transmitter = DisPduBuilder.CreateTransmitter(
+ new RadioId(new EntityId(1, 2, 3), 7),
+ new RadioType(),
+ frequency: 225_000_000,
+ power: 50);
+
+ Assert.AreEqual((ushort)7, transmitter.RadioHeader.RadioNumber);
+ Assert.AreEqual(TransmitterTransmitState.OnAndTransmitting, transmitter.TransmitState);
+ Assert.IsInstanceOfType(DisSerializer.Deserialize(DisSerializer.Serialize(transmitter)));
+ }
+}
diff --git a/tests/OpenDisNet.Tests/Validation/DisValidatorTests.cs b/tests/OpenDisNet.Tests/Validation/DisValidatorTests.cs
new file mode 100644
index 0000000..53e85af
--- /dev/null
+++ b/tests/OpenDisNet.Tests/Validation/DisValidatorTests.cs
@@ -0,0 +1,74 @@
+using OpenDisNet.Enumerations;
+using OpenDisNet.Pdus;
+using OpenDisNet.Protocol;
+using OpenDisNet.Validation;
+
+namespace OpenDisNet.Tests.Validation;
+
+[TestClass]
+public sealed class DisValidatorTests
+{
+ [TestMethod]
+ public void BuilderOutputPassesSemanticValidation()
+ {
+ EntityStatePdu pdu = DisPduBuilder.CreateEntityState(
+ new EntityId(1, 2, 3),
+ new EntityType { EntityKind = EntityKind.Platform },
+ new Vector3Double { X = 1, Y = 2, Z = 3 });
+
+ DisValidationResult result = DisValidator.Validate(pdu);
+ Assert.IsTrue(result.IsValid);
+ Assert.IsFalse(result.HasWarnings);
+ Assert.HasCount(0, result.Issues);
+ }
+
+ [TestMethod]
+ public void ValidatorReportsNumericAndDiscriminatorErrors()
+ {
+ var fire = new FirePdu
+ {
+ FiringEntityId = new EntityId(1, 2, 3),
+ Range = -1,
+ Velocity = new Vector3Float { X = float.NaN },
+ ProtocolFamily = ProtocolFamily.RadioCommunications,
+ };
+
+ DisValidationResult result = DisValidator.Validate(fire);
+ Assert.IsFalse(result.IsValid);
+ Assert.IsTrue(result.Issues.Any(issue => issue.Path == nameof(FirePdu.Range)));
+ Assert.IsTrue(result.Issues.Any(issue => issue.Path == $"{nameof(FirePdu.Velocity)}.{nameof(Vector3Float.X)}"));
+ Assert.IsTrue(result.Issues.Any(issue => issue.Path == nameof(Pdu.ProtocolFamily)));
+ }
+
+ [TestMethod]
+ public void ValidatorReportsIncompleteTransmitterAndWarnings()
+ {
+ var transmitter = new TransmitterPdu
+ {
+ TransmitState = TransmitterTransmitState.OnAndTransmitting,
+ Power = 1,
+ };
+
+ DisValidationResult result = DisValidator.Validate(transmitter);
+ Assert.IsFalse(result.IsValid);
+ Assert.IsTrue(result.HasWarnings);
+ Assert.IsTrue(result.Issues.Any(issue => issue.Path == nameof(TransmitterPdu.Frequency)));
+ Assert.IsTrue(result.Issues.Any(issue => issue.Severity == DisValidationSeverity.Warning));
+ }
+
+ [TestMethod]
+ public void ValidatorReportsNullRequiredModelsWithoutThrowing()
+ {
+ var entity = new EntityStatePdu
+ {
+ EntityId = null!,
+ EntityLocation = null!,
+ EntityOrientation = null!,
+ };
+
+ DisValidationResult result = DisValidator.Validate(entity);
+ Assert.IsFalse(result.IsValid);
+ Assert.IsTrue(result.Issues.Any(issue => issue.Path == nameof(EntityStatePdu.EntityId)));
+ Assert.IsTrue(result.Issues.Any(issue => issue.Path == nameof(EntityStatePdu.EntityLocation)));
+ }
+}