TripleG3.P2P
1.1.5
See the version list below for details.
dotnet add package TripleG3.P2P --version 1.1.5
NuGet\Install-Package TripleG3.P2P -Version 1.1.5
<PackageReference Include="TripleG3.P2P" Version="1.1.5" />
<PackageVersion Include="TripleG3.P2P" Version="1.1.5" />
<PackageReference Include="TripleG3.P2P" />
paket add TripleG3.P2P --version 1.1.5
#r "nuget: TripleG3.P2P, 1.1.5"
#:package TripleG3.P2P@1.1.5
#addin nuget:?package=TripleG3.P2P&version=1.1.5
#tool nuget:?package=TripleG3.P2P&version=1.1.5
TripleG3.P2P
High-performance, attribute-driven peer-to-peer messaging for .NET 10 / MAUI apps over UDP and TCP. Ship strongly-typed messages (records / classes / primitives / strings) with a tiny 8-byte header and pluggable serialization strategy.
Status: UDP and TCP transports plus three serializers (
None,JsonRaw, andLengthPrefixed) are implemented. Direct peer-to-peer TCP file transfer is available. RTP/H.264 video remains experimental.
Why?
Typical networking layers force you to hand-roll framing, routing, and serialization. TripleG3.P2P gives you:
- A single minimal interface:
ISerialBus(send, subscribe, start, close) - Deterministic wire contract via
[Udp]&[UdpMessage]attributes (order + protocol name stability) - Envelope-based dispatch that is assembly agnostic (type names / attribute names, not CLR identity)
- Choice between ultra-light delimiter serialization or raw JSON
- Safe, isolated subscriptions (late subscribers don't crash the loop)
- Zero allocations for header parsing (Span/Memory friendly design internally)
- NEW: Multi-endpoint broadcast fan-out with duplicate endpoint suppression
Features At A Glance
- Target framework:
net10.0(single TFM here; platform heads handled by MAUI host project) - 8‑byte header layout (Length / MessageType / SerializationProtocol)
- Attribute ordered property serialization with stable delimiter
@-@ - Automatic Envelope wrapping so handlers receive strong types directly
- Multiple simultaneous protocol instances via separate buses
- Multi-endpoint broadcast (one
SendAsync→ N peers) - Plug-in serializer model (
IMessageSerializer) - Graceful cancellation / disposal
- TCP transport (reliable stream) alongside UDP; same API
- Direct peer-to-peer TCP file transfer with receiver consent, cancellation, progress, and SHA-256 verification
Transport Summary
| Transport | Reliability | Ordering | Broadcast Fan-Out | Status |
|---|---|---|---|---|
| UDP | Best effort / loss possible | Not guaranteed across datagrams | Yes (multi-endpoint send) | Implemented |
| TCP | Reliable | Preserved per connection | Yes (writes to each stream) | Implemented |
Use UDP when you need lowest overhead and can tolerate loss; use TCP when you need reliability / ordering without building it yourself.
Peer-to-peer file transfer
File transfer is intentionally separate from ISerialBus. Create a PeerFileTransferClient for each peer, start its listener, and handle incoming requests explicitly. A receiver can reject a request or accept it with a destination path; cancellation tokens cancel active sender or receiver operations. Transfers use a dedicated versioned TCP protocol, stream in bounded chunks, write to a temporary .part file, and verify SHA-256 before moving the completed file into place.
var receiver = new PeerFileTransferClient(new FileTransferOptions {
LocalEndPoint = new IPEndPoint(IPAddress.Loopback, 9100)
});
receiver.TransferRequested += (request, cancellationToken) =>
new ValueTask<FileTransferDecision>(
FileTransferDecision.Accept(Path.Combine("received", request.FileName)));
await receiver.StartAsync();
var sender = new PeerFileTransferClient(new FileTransferOptions {
LocalEndPoint = new IPEndPoint(IPAddress.Loopback, 9101)
});
await sender.StartAsync();
var results = await sender.SendAsync(
"video.mp4",
[new IPEndPoint(IPAddress.Loopback, 9100)],
cancellationToken: cancellationToken);
If no TransferRequested handler is registered, inbound transfers are rejected. The receiver may reject a request from the handler, and the sender may cancel through its cancellation token. Configure MaximumFileBytes, BufferSize, and MaximumConcurrentTransfers for the host.
Installation
Install from NuGet:
dotnet add package TripleG3.P2P
Symbols are published; enable source stepping to debug internals.
Versioning & CI
CI rewrites the patch component using the GitHub Actions run number. Base <Version> in the csproj should be updated only for major/minor increments (e.g. 1.1.0 / 2.0.0). Published versions become Major.Minor.RunNumber and the workflow commits the updated version back without re-triggering.
Target Framework
net10.0
MAUI/platform variants are produced in a sibling project; this core library stays lean.
Core Concepts
ISerialBus
public interface ISerialBus {
bool IsListening { get; }
ValueTask StartListeningAsync(ProtocolConfiguration config, CancellationToken ct = default);
ValueTask CloseConnectionAsync();
void SubscribeTo<T>(Action<T> handler);
ValueTask SendAsync<T>(T message, MessageType messageType = MessageType.Data, CancellationToken ct = default);
}
Abstracts the transport (currently UDP + TCP). Your code remains identical besides construction via the factory.
Both built-in buses also implement ISubscriptionSerialBus, whose Subscribe<T> method returns an IDisposable unsubscription registration.
ProtocolConfiguration
public sealed class ProtocolConfiguration {
IPAddress LocalAddress { get; init; }
IPEndPoint RemoteEndPoint { get; init; }
IReadOnlyCollection<IPEndPoint> BroadcastEndPoints { get; init; }
int LocalPort { get; init; }
int MaxPayloadBytes { get; init; }
int MaxInboundConnections { get; init; }
SerializationProtocol SerializationProtocol { get; init; }
}
Controls binding + outbound destination and the serialization protocol used for every message on this bus instance.
Broadcasting / Fan-Out
Provide one or more BroadcastEndPoints to automatically fan out every SendAsync from a bus instance to: RemoteEndPoint ∪ BroadcastEndPoints (set semantics). Duplicate endpoints (same address:port) are suppressed.
Basic one-to-many:
await bus.StartListeningAsync(new ProtocolConfiguration {
LocalPort = 7000,
RemoteEndPoint = new IPEndPoint(IPAddress.Loopback, 7001), // primary
BroadcastEndPoints = new [] {
new IPEndPoint(IPAddress.Loopback, 7002),
new IPEndPoint(IPAddress.Loopback, 7003)
},
SerializationProtocol = SerializationProtocol.None
});
await bus.SendAsync(new Chat("me", "hi everyone")); // reaches 7001, 7002, 7003
Hub & Spokes (hub at 8000 → spokes 8001/8002, spokes reply only to hub):
// Hub
await hub.StartListeningAsync(new ProtocolConfiguration {
LocalPort = 8000,
RemoteEndPoint = new IPEndPoint(IPAddress.Loopback, 8001),
BroadcastEndPoints = new [] { new IPEndPoint(IPAddress.Loopback, 8002) },
SerializationProtocol = SerializationProtocol.JsonRaw
});
// Spoke 1
await s1.StartListeningAsync(new ProtocolConfiguration {
LocalPort = 8001,
RemoteEndPoint = new IPEndPoint(IPAddress.Loopback, 8000),
SerializationProtocol = SerializationProtocol.JsonRaw
});
// Spoke 2 (similar to s1; LocalPort = 8002)
await hub.SendAsync(new BroadcastAnnouncement("server", "hello spokes"));
Full Mesh (N peers each send to all others): create N buses where for each index i choose one peer as RemoteEndPoint and all remaining as BroadcastEndPoints. (See integration test Concurrent_Broadcasts_All_Messages_Delivered_Exactly_Once).
Duplicate endpoint suppression:
await bus.StartListeningAsync(new ProtocolConfiguration {
LocalPort = 8100,
RemoteEndPoint = new IPEndPoint(IPAddress.Loopback, 8101),
BroadcastEndPoints = new [] { // 8102 duplicated
new IPEndPoint(IPAddress.Loopback, 8102),
new IPEndPoint(IPAddress.Loopback, 8102)
}
});
// Only one datagram sent to 8102.
Mixed types broadcast (string + complex):
await sender.SendAsync("alpha");
await sender.SendAsync(new Person("carol", 27, new Address("2 Ave", "Metro")));
Concurrency: safe to invoke SendAsync concurrently; the underlying UDP socket will serialize sends. Out-of-order arrival is still possible (UDP). If ordering matters, include sequence numbers inside your messages.
Reliability Disclaimer: UDP does not guarantee delivery / ordering. The library only guarantees attempted one-shot fan-out; implement retries / ACK at the message layer if required.
Dependency Injection
Register UDP components:
services.AddP2PUdp(); // Registers None + JsonRaw + LengthPrefixed serializers and UDP bus
var bus = services.BuildServiceProvider().GetRequiredService<ISerialBus>();
Need TCP as well? Use SerialBusFactory.CreateTcp() (a combined DI extension can be added later).
Envelope (Generic)
Internal transport wrapper: TypeName + Message. The receiver inspects TypeName to look up subscriptions, then materializes only the requested type.
SerializationProtocol
None // Attribute-delimited (fast, compact)
JsonRaw // System.Text.Json UTF-8 payload
LengthPrefixed // Versioned attribute contract with lengths and explicit null markers
Add more by implementing IMessageSerializer.
Use LengthPrefixed for new attribute-based contracts. None remains available for wire compatibility, but delimiter text inside values, null versus empty strings, and some nested shapes are inherently ambiguous.
Attributes
[UdpMessage]or[UdpMessage("CustomName")]gives the logical protocol name (stable across assemblies)[UdpMessage<T>]generic variant usestypeof(T).Name(or supplied override) for convenience[Udp(order)]marks and orders properties participating in attribute serialization.- Unannotated properties are ignored by
NoneandLengthPrefixed. - Constructor parameters are matched to annotated properties by name and type.
MessageType
Currently: Data (extensible placeholder for control, ack, etc.)
Wire Format (UDP)
Header (8 bytes total):
- Bytes 0-3: Int32 PayloadLength (bytes after header)
- Bytes 4-5: Int16 MessageType
- Bytes 6-7: Int16 SerializationProtocol
Payload:
- If
SerializationProtocol.None:TypeName+ optional@-@+ serialized property segments (each delimited by@-@) - If
JsonRaw: UTF-8 JSON of theEnvelope<T> - If
LengthPrefixed: format version + length-prefixed UTF-8 type name + recursively framed ordered properties
Quick Start
using TripleG3.P2P.Attributes;
using TripleG3.P2P.Core;
using System.Net;
[UdpMessage("Person")] // Protocol type name
public record Person([property: Udp(1)] string Name,
[property: Udp(2)] int Age,
[property: Udp(3)] Address Address);
[UdpMessage<Address>] // Uses nameof(Address) unless overridden
public record Address([property: Udp(1)] string Street,
[property: Udp(2)] string City,
[property: Udp(3)] string State,
[property: Udp(4)] string Zip);
var bus = SerialBusFactory.CreateUdp();
await bus.StartListeningAsync(new ProtocolConfiguration {
LocalPort = 7000,
RemoteEndPoint = new IPEndPoint(IPAddress.Loopback, 7001),
SerializationProtocol = SerializationProtocol.None
});
bus.SubscribeTo<Person>(p => Console.WriteLine($"Person: {p.Name} ({p.Age}) {p.Address.City}"));
await bus.SendAsync(new Person("Alice", 28, new Address("1 Way", "Town", "ST", "00001")));
Run a second process with reversed ports (7001 ↔ 7000) to complete the loop.
TCP Quick Start (Reliable)
var tcpBus = SerialBusFactory.CreateTcp();
await tcpBus.StartListeningAsync(new ProtocolConfiguration {
LocalPort = 9000,
RemoteEndPoint = new IPEndPoint(IPAddress.Loopback, 9001),
SerializationProtocol = SerializationProtocol.JsonRaw
});
tcpBus.SubscribeTo<Person>(p => Console.WriteLine($"[TCP] {p.Name} ({p.Age})"));
await tcpBus.SendAsync(new Person("Alice", 28, new Address("1 Way", "Town", "ST", "00001")));
Multi-Protocol Usage (Same Message Types)
var udp = SerialBusFactory.CreateUdp();
var tcp = SerialBusFactory.CreateTcp();
await udp.StartListeningAsync(new ProtocolConfiguration { LocalPort = 7000, RemoteEndPoint = new IPEndPoint(IPAddress.Loopback, 7001), SerializationProtocol = SerializationProtocol.None });
await tcp.StartListeningAsync(new ProtocolConfiguration { LocalPort = 9000, RemoteEndPoint = new IPEndPoint(IPAddress.Loopback, 9001), SerializationProtocol = SerializationProtocol.JsonRaw });
udp.SubscribeTo<Chat>(c => Console.WriteLine($"[UDP] {c.User}: {c.Text}"));
tcp.SubscribeTo<Chat>(c => Console.WriteLine($"[TCP] {c.User}: {c.Text}"));
await udp.SendAsync(new Chat("me","low-latency"));
await tcp.SendAsync(new Chat("me","reliable"));
Using JSON Instead
var jsonBus = SerialBusFactory.CreateUdp();
await jsonBus.StartListeningAsync(new ProtocolConfiguration {
LocalPort = 7002,
RemoteEndPoint = new IPEndPoint(IPAddress.Loopback, 7003),
SerializationProtocol = SerializationProtocol.JsonRaw
});
JSON serializer ignores [Udp] ordering—standard JSON rules apply; TypeName embedded as typeName/TypeName.
Subscriptions
bus.SubscribeTo<string>(s => Console.WriteLine($"Raw string: {s}"));
bus.SubscribeTo<Person>(HandlePerson);
void HandlePerson(Person p) { /*...*/ }
- Multiple handlers per type allowed
- Subscription key is the protocol type name (attribute override or CLR name)
- If no handler matches, message is silently ignored
- Cast to
ISubscriptionSerialBusand useSubscribe<T>when the handler needs an independent lifetime.
Sending
await bus.SendAsync("Hello peer");
await bus.SendAsync(new Person("Bob", 42, new Address("2 Road", "City", "ST", "22222")));
All messages on a bus instance use that instance’s SerializationProtocol.
Graceful Shutdown
await bus.CloseConnectionAsync();
// or dispose
(bus as IDisposable)?.Dispose();
Cancels the receive loop & disposes socket.
Designing Message Contracts (Delimited Serializer)
- Add
[UdpMessage](optional if CLR name is acceptable) to each root message type. - Annotate properties you want serialized with
[Udp(order)](1-based ordering recommended). - Use only deterministic, immutable shapes (records ideal).
- Nested complex types must also follow the same attribute pattern.
- Changing order or adding/removing annotated properties is a protocol breaking change.
Example
[UdpMessage("Ping")] public record Ping([property: Udp(1)] long Ticks);
Primitive & String Support
Primitive-like types use invariant round-trip formatting and explicit converters, including DateTimeOffset.
Implementing a Custom Serializer
class MyBinarySerializer : IMessageSerializer {
public SerializationProtocol Protocol => (SerializationProtocol)42; // Add new enum value first
public byte[] Serialize<T>(T value) { /* return bytes */ }
public T? Deserialize<T>(ReadOnlySpan<byte> data) { /* parse */ }
public object? Deserialize(Type t, ReadOnlySpan<byte> data) { /* parse */ }
}
Registration options:
- Extend
SerialBusFactorywith a helper that injects your serializer. - Or (DI) register it as another
IMessageSerializer; the bus chooses byProtocolenum value.
Best practices:
- Keep format deterministic & version tolerant.
- Reuse buffers; avoid per-message large allocations.
- Reserve new enum value before shipping (ensure both sides understand it).
Error Handling & Resilience
- Malformed, truncated, oversized, or unknown-protocol frames are rejected before deserialization or large allocation.
- Cancellation is propagated to callers.
- A send throws when every configured endpoint fails; partial fan-out failures are logged.
- Individual subscriber exceptions are logged and do not block other handlers.
- TCP serializes writes per configured connection and removes failed connections so a later send can reconnect.
Performance Notes
- Header parsing uses
BinaryPrimitiveson a single span - Delimited serializer caches reflection lookups per type
- No dynamic allocations for header path; serialization aims to minimize intermediate copies
- Envelope design avoids repeated type discovery; only
TypeNamestring extracted first
Extending To Other Transports
Transport abstraction lives behind ISerialBus.
TCP (Implemented)
Use SerialBusFactory.CreateTcp() and the same ProtocolConfiguration (LocalPort is the listener; RemoteEndPoint and BroadcastEndPoints are configured send targets). Accepted sockets are receive-only sessions and are never added to fan-out. Ordering is preserved per configured connection, while different peers may progress independently.
Example:
var tcpBus = SerialBusFactory.CreateTcp();
await tcpBus.StartListeningAsync(new ProtocolConfiguration {
LocalPort = 9000,
RemoteEndPoint = new IPEndPoint(IPAddress.Loopback, 9001),
BroadcastEndPoints = new [] { new IPEndPoint(IPAddress.Loopback, 9002) },
SerializationProtocol = SerializationProtocol.JsonRaw
});
tcpBus.SubscribeTo<Chat>(c => Console.WriteLine($"[TCP] {c.User}: {c.Text}"));
await tcpBus.SendAsync(new Chat("alice", "over tcp"));
Examples and Tests
Tests are split by execution contract:
TripleG3.P2P.UnitTestscontains deterministic, in-process serializer, packetizer, depacketizer, cipher, sequence, bounds, and validation tests.TripleG3.P2P.IntegrationTestscontains loopback sockets, multi-peer fan-out, reconnect, malformed network input, receiver lifecycle, DI-over-UDP video, RTCP timing, and negotiation flows.
Pull-request and publish pipelines restore, build, and run only the unit-test project. Integration tests are intentionally manual because they bind local ports and use timing-sensitive multi-component flows.
# Pipeline-equivalent unit tests
dotnet test tests/TripleG3.P2P.UnitTests/TripleG3.P2P.UnitTests.csproj -c Release -warnaserror
# Manual integration tests
dotnet test tests/TripleG3.P2P.IntegrationTests/TripleG3.P2P.IntegrationTests.csproj -c Release -warnaserror
# Complete local validation
dotnet test TripleG3.P2P.slnx -c Release -warnaserror
The integration suite (MultiBroadcastTests, TcpIntegrationTests, TransportHardeningTests, and the video integration fixtures) proves:
- UDP multi-endpoint broadcast
- TCP fan-out & ordering guarantees
- Concurrent full-mesh delivery
- Duplicate endpoint de-duplication
- TCP reconnect and concurrent frame integrity
- Malformed input recovery, cancellation, and disposable subscriptions
- All three serialization protocols
- RTP video DI transfer over a real UDP socket
- Receiver start/stop/restart and disposal races
- RTCP timing and negotiation/keyframe signaling
Roadmap
- Optional authenticated secure-channel wrapper
- Source generator for zero-reflection fast path
- Optional compression & encryption layers
- Health / metrics callbacks
FAQ
Q: Do both peers need the exact same CLR types?
A: They need matching protocol type names and compatible property ordering (Delimited) or matching JSON contracts (JsonRaw). CLR assembly identity is not required.
Q: Can I mix serializers on the same socket?
A: One ISerialBus instance uses one SerializationProtocol. Create multiple instances for mixed protocols.
Q: Is ordering enforced?
A: Receiver trusts the order defined by [Udp(n)]. Reordering is a breaking change.
Minimal Cheat Sheet
var bus = SerialBusFactory.CreateUdp();
await bus.StartListeningAsync(new ProtocolConfiguration {
LocalPort = 7000,
RemoteEndPoint = new IPEndPoint(IPAddress.Loopback, 7001),
SerializationProtocol = SerializationProtocol.None
});
[UdpMessage("Chat")]
public record Chat([property: Udp(1)] string User, [property: Udp(2)] string Text);
bus.SubscribeTo<Chat>(c => Console.WriteLine($"{c.User}: {c.Text}"));
await bus.SendAsync(new Chat("me", "hi there"));
License
GPL-3.0-only. See LICENSE.
Contributing
Issues & PRs welcome: add tests / samples for new serializers or transports.
Happy messaging!
Memory Pooling (Partially Implemented)
The video pipeline now uses pooled buffers for:
- FU‑A (fragmented) NAL reassembly (rents a buffer, grows, returns after NAL complete)
- Consolidated frame (Annex B access unit) assembly via an
ArrayPoolFrameinsideEncodedAccessUnit(returned when disposed) - Intermediate single‑NAL copies (rented arrays tracked and released after frame consolidation)
Why: Reduce allocation pressure & GC pauses under sustained 30/60fps streaming. Hundreds or even thousands of frame buffers per minute are efficiently recycled.
Still Allocating:
- Outbound RTP packets use exact-length managed arrays so callback consumers may retain them safely.
- Control / negotiation JSON payloads
Guidance:
Dispose each EncodedAccessUnit you create/receive (tests show using var au = ...). If you skip disposal, large buffers remain rented and the pool cannot reclaim them quickly.
Planned refinements: size threshold to bypass pooling for very small frames, packet buffer recycling, allocation metrics counters.
Experimental: Real-Time Video (H.264 over RTP)
Early, evolving support for sending pre‑encoded H.264 access units (Annex B) over RTP/UDP.
Current Capabilities
- H.264 packetization: single NAL + FU‑A (RFC 6184 subset)
- Depacketization & reassembly to Annex B (start codes preserved)
- Minimal RTCP: Sender Report (SR) + Receiver Report (RR) with RTT & fraction lost
- Jitter, cumulative loss, fraction lost statistics (RFC3550 style)
- Forward‑only reorder buffer (drops late retrograde packets)
- Keyframe request path (PLI analogue) via control channel
- Simple JSON negotiation (Offer/Answer + keyframe request) over injectable reliable channel
- Pluggable payload cipher abstraction (NoOp / XOR test cipher)
- Partial memory pooling & zero‑copy optimizations for frame assembly
Not Yet Implemented / Limitations
- No NACK/RTX retransmissions, FEC, or packet pacing
- No SRTP / DTLS keying (encryption is placeholder only)
- No automatic SPS/PPS extraction; callers may supply
ProfileLevelIdandSpropParameterSetsduring negotiation. - No adaptive bitrate / congestion control (REMB/TCC/GCC)
- Reordering is bounded and loss-recovering, but NACK/RTX is not implemented.
- Only H.264 (no VP8/VP9/AV1/H.265)
- No simulcast / SVC layers, no audio, no A/V sync
- No ICE/STUN/TURN NAT traversal (you must provide transport)
Essential Types
| Type | Purpose |
|---|---|
EncodedAccessUnit |
Represents one encoded frame (Annex B) + metadata; disposable (pooled buffer) |
RtpVideoSender |
High-level sender: packetizes AUs, encrypts, emits RTP datagrams & sends SR |
RtpVideoReceiver |
Consumes RTP / RTCP, reassembles frames, updates stats, raises AccessUnitReceived |
H264RtpPacketizer / H264RtpDepacketizer |
Low-level packetization building blocks |
NegotiationManager |
Offer/Answer & keyframe (PLI analogue) signaling |
IVideoPayloadCipher |
Cipher abstraction (NoOp / XOR examples) |
Basic End‑to‑End Flow
// Outbound network send hooks (wire these to your UDP socket send method)
void SendRtp(ReadOnlySpan<byte> datagram) => udpSocket.SendTo(datagram.ToArray(), remoteRtpEndPoint);
void SendRtcp(ReadOnlySpan<byte> datagram) => udpSocket.SendTo(datagram.ToArray(), remoteRtcpEndPoint);
var sender = new RtpVideoSender(
ssrc: 0x1234_5678,
mtu: 1200,
cipher: new NoOpCipher(),
datagramOut: d => SendRtp(d.Span),
rtcpOut: d => SendRtcp(d.Span));
var receiver = new RtpVideoReceiver(new NoOpCipher());
receiver.AccessUnitReceived += au => {
try { Render(au); } finally { au.Dispose(); }
};
// For each encoded frame (Annex B) you obtain from your encoder:
using var au = new EncodedAccessUnit(encodedAnnexBFrame, isKeyFrame, rtpTimestamp90k, captureTicks);
sender.Send(au);
// Periodically (every ~2s or on key events) send a Sender Report:
sender.SendSenderReport(rtpTimestamp90k);
// Incoming network data:
void OnUdpData(byte[] buffer, int len)
{
var span = new ReadOnlySpan<byte>(buffer,0,len);
if (Rtcp.IsRtcpPacket(span))
{
receiver.ProcessRtcp(span);
sender.ProcessRtcp(span); // so sender can compute RTT
}
else
{
receiver.ProcessRtp(span);
}
}
Negotiation & Keyframe Requests
var chA = new InMemoryControlChannel();
var chB = new InMemoryControlChannel();
chA.MessageReceived += m => chB.SendReliableAsync(m);
chB.MessageReceived += m => chA.SendReliableAsync(m);
var offerer = new NegotiationManager(chA);
var answerer = new NegotiationManager(chB);
answerer.AttachEncoder(yourEncoder); // so remote PLI triggers RequestKeyFrame()
await offerer.CreateOfferAsync(new VideoSessionConfig(1280,720, 2_000_000, 30));
// After negotiation completes, either side can request a keyframe:
offerer.RequestKeyFrame();
Stats & RTT
var senderStats = sender.GetStats();
// senderStats.RttEstimateMs, PacketsSent, BytesSent
var recvStats = receiver.GetStats();
// recvStats.Jitter, PacketsLost, FractionLost (last interval), PacketsReceived
Memory & Disposal
Dispose every received EncodedAccessUnit after consuming its data. It returns the underlying pooled frame buffer to ArrayPool<byte>. If you retain frames (e.g., for rewind), copy out the bytes first with au.AnnexB.ToArray().
Keyframe Logic
Receiver issues RequestKeyFrame() on NegotiationManager (or directly via application logic) when:
- Startup / first frame needed
- Decoder error / corruption detected (you decide)
The answering side invokes encoder.RequestKeyFrame() (you supply the encoder implementation) so the next access unit is a keyframe.
Roadmap Snapshot
Minimal RTP Video API (Stable Surface)
Verbatim stable signatures exposed in TripleG3.P2P.Video namespace:
public sealed class EncodedAccessUnit : IDisposable
{
public EncodedAccessUnit(ReadOnlyMemory<byte> annexB, bool isKeyFrame, uint rtpTimestamp90k, long captureTicks);
public ReadOnlyMemory<byte> AnnexB { get; }
public bool IsKeyFrame { get; }
public uint RtpTimestamp90k { get; }
public long CaptureTicks { get; }
public void Dispose();
}
public interface IVideoPayloadCipher
{
int OverheadBytes { get; }
int Encrypt(Span<byte> buffer);
int Decrypt(Span<byte> buffer);
int Encrypt(ReadOnlySpan<byte> payload, Span<byte> output);
int Decrypt(ReadOnlySpan<byte> payload, Span<byte> output);
}
public sealed class NoOpCipher : IVideoPayloadCipher { /* OverheadBytes=0; pass-through */ }
public sealed class RtpVideoSender
{
public RtpVideoSender(uint ssrc, int mtu, IVideoPayloadCipher cipher, Action<ReadOnlyMemory<byte>> datagramOut, Action<ReadOnlyMemory<byte>>? rtcpOut = null);
public void Send(EncodedAccessUnit au);
public void SendSenderReport(uint rtpTimestamp90k);
public void ProcessRtcp(ReadOnlySpan<byte> packet);
public RtpVideoSenderStats GetStats(); // optional lightweight stats
}
public sealed class RtpVideoReceiver
{
public RtpVideoReceiver(IVideoPayloadCipher cipher);
public event Action<EncodedAccessUnit> AccessUnitReceived;
public void ProcessRtp(ReadOnlySpan<byte> packet);
public void ProcessRtcp(ReadOnlySpan<byte> packet);
public RtpVideoReceiverStats GetStats();
}
public static class Rtcp
{
public static bool IsRtcpPacket(ReadOnlySpan<byte> packet);
}
These are intended for direct consumption by TripleG3.Camera.Maui without reflection.
Logging & Diagnostics
Add logging (video pipeline & new code paths use Microsoft.Extensions.Logging):
services.AddLogging(b => b.AddConsole().SetMinimumLevel(LogLevel.Information));
Security Note (Video Ciphers)
NoOpCipher & XorTestCipher are NOT secure. They exist only for testing. Use a proper SRTP / DTLS-SRTP layer for real encryption (on roadmap).
Creating a New Transport
- Implement
ISerialBus(mirror UDP/TCP structure). - Accept an
IEnumerable<IMessageSerializer>. - Preserve the 8‑byte header (or version it explicitly).
- Provide a
SerialBusFactory.CreateX()helper. - Add integration tests: start, send, broadcast, mixed serializers.
- Update README & bump minor version.
MAUI Integration
In MauiProgram.CreateMauiApp:
builder.Services.AddP2PUdp();
builder.Services.AddLogging(b => b.AddDebug());
Inject ISerialBus into pages / services. Handle disposal on shutdown for clean socket release.
- Immediate: NACK/RTX, proper RTCP PLI/FIR packets, SRTP integration
- Near-term: Bandwidth estimation (REMB/TCC) & adaptive send pacing
- Later: ICE/STUN/TURN integration hooks, multi‑codec negotiation, richer jitter buffer
APIs are experimental; expect adjustments.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.Extensions.DependencyInjection (>= 9.0.9)
- Microsoft.Extensions.Logging.Abstractions (>= 9.0.9)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on TripleG3.P2P:
| Package | Downloads |
|---|---|
|
TripleG3.Camera.Maui
Cross-platform .NET MAUI camera view with frame broadcasting, live & buffered preview, and remote feed scaffolding for Android, Windows, iOS & Mac Catalyst. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.2.21 | 115 | 8/27/2026 |
| 1.1.17 | 308 | 9/3/2025 |
| 1.1.16 | 241 | 9/1/2025 |
| 1.1.15 | 249 | 9/1/2025 |
| 1.1.8 | 100 | 8/21/2026 |
| 1.1.7 | 118 | 7/30/2026 |
| 1.1.6 | 104 | 7/27/2026 |
| 1.1.5 | 105 | 7/27/2026 |
| 1.1.4 | 111 | 7/16/2026 |
| 1.1.3 | 111 | 7/15/2026 |
| 1.1.2 | 187 | 9/26/2025 |
| 1.1.1 | 356 | 9/15/2025 |
| 1.0.14 | 236 | 9/1/2025 |
| 1.0.12 | 232 | 9/1/2025 |
| 1.0.11 | 242 | 8/31/2025 |
| 1.0.10 | 251 | 8/30/2025 |
| 1.0.9 | 250 | 8/30/2025 |
| 1.0.8 | 295 | 8/30/2025 |
| 1.0.7 | 279 | 8/30/2025 |