DanielWillett.ModularRpcs.Unity 1.0.0-prerelease28

This is a prerelease version of DanielWillett.ModularRpcs.Unity.
There is a newer prerelease version of this package available.
See the version list below for details.
dotnet add package DanielWillett.ModularRpcs.Unity --version 1.0.0-prerelease28
                    
NuGet\Install-Package DanielWillett.ModularRpcs.Unity -Version 1.0.0-prerelease28
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="DanielWillett.ModularRpcs.Unity" Version="1.0.0-prerelease28" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="DanielWillett.ModularRpcs.Unity" Version="1.0.0-prerelease28" />
                    
Directory.Packages.props
<PackageReference Include="DanielWillett.ModularRpcs.Unity" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add DanielWillett.ModularRpcs.Unity --version 1.0.0-prerelease28
                    
#r "nuget: DanielWillett.ModularRpcs.Unity, 1.0.0-prerelease28"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package DanielWillett.ModularRpcs.Unity@1.0.0-prerelease28
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=DanielWillett.ModularRpcs.Unity&version=1.0.0-prerelease28&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=DanielWillett.ModularRpcs.Unity&version=1.0.0-prerelease28&prerelease
                    
Install as a Cake Tool

ModularRPCs

Modular RPCs is a transparent communication layer between two CLR/.NET programs, optionally over a network.

It uses a Roslyn source generator or dynamic code generation to create stubs for send methods that write data to binary streams and send it over whatever medium is required.

It is currently in a semi-stable preview state, but has the majority of the features needed to be effective and is pretty well tested. However, the code-base is very complex so there may still be bugs that haven't come to light yet. I use the library in a few of my projects so it has some use 'in the field'.

Features

  • Dynamic IL code generation (TypeBuilders, DynamicMethods) for scenerios where a source generator can't be used.
    • Use virtual instead of partial for send methods.
  • Remote-cancellation using a CancellationToken.
  • Optimized 'Raw' send/receive methods that can directly send binary data without the overhead of serialization.
  • Define custom serializers for any type.
  • Built-in IServiceProvider support for fetching instances of types.
  • Exception handling.
  • Return values and exception handling using RpcTask[<>].
    • An exception in a receive method will be rethrown by the send method if awaited.
  • Multi-cast broadcasts
    • Doesn't support return values.
  • First-class support for Unity components through the DanielWillett.ModularRpcs.Unity package.
  • Roslyn incremental source generator.
  • Legacy support all the way back to .NET Standard 2.0.
    • .NET Framework 4.6.1+, .NET Core 2.0+, .NET 5+, Unity 2018.1+, Mono 5.4+
    • Source generators for Roslyn 3.11, 4.0, 4.4, and 4.8. Source-gen should support Unity version 2020.2 and later.
  • Supports return types of any awaitable type (or regular values), not just Task<>.
    • Doesn't apply to awaitables that use extension members.
  • Supports null values, nullable value types, collections, strings (UTF-8), and provides optimizations for primtive types.
  • Separate optimized workflows for working with Streams or raw binary data to avoid unnecessary data copying.

Missing Features

  • Static functions.
  • Automatic type serialization/deserialization.
  • Support for other formats like JSON.
  • Dictionary serialization.

Supported Transport Modes

Third party packages can be made fairly easily to add support for other transport modes. Take a look at some of the existing packages in this repository for an example.

  • Loopback
    • Communicate with objects in the same process (app domain).
  • Web Sockets
    • Communicate over the ws:// or wss:// protocols.
  • Named Pipes
    • Communicate with objects usually on the same computer. Uses the Named Pipes API for interprocess communication.

Supported Primitive Types

All the following types can be serialized/parsed individually or in an enumerable.

Enum and nullable arrays are not supported by default.

Base

  • bool, char, double, float, int, long, uint, ulong, short, ushort, sbyte, byte, nint, nuint
  • Half (.NET 5+)
  • Int128, UInt128 (.NET 7+)
  • decimal
  • DateTimeOffset
  • DateTime
  • TimeSpan
  • Guid
  • String (any encoding, UTF8 by default)
  • All enums
  • Nullable value types of any supported value type
  • Collections of any supported value type.

ModularRPCs.Unity

  • Vector2, Vector3, Vector4
  • Bounds
  • Color, Color32
  • Quaternion
  • Matrix4x4
  • Plane
  • Ray, Ray2D
  • Rect
  • Resolution

MSBuild Properties

Property Description Default
DisableModularRPCsSourceGenerator Disables source generation features. False

Installation

Install via NuGet.

<ItemGroup>

    <PackageReference Include="DanielWillett.ModularRpcs" Version="*" />

    
    <PackageReference Include="DanielWillett.ModularRpcs.NamedPipes" Version="*" />
    <PackageReference Include="DanielWillett.ModularRpcs.WebSockets" Version="*" />
    
    
    <PackageReference Include="DanielWillett.ModularRpcs.Unity" Version="*" />
    
    
    <PackageReference Include="DanielWillett.ReflectionTools" Version="4.0.0" />

</ItemGroup>

Examples

Standard registration of services

Server

The following example registers the necessary services on a server-like process using extension methods and sets up the server using the Named Pipes transport mode.


IServiceCollection collection = new ServiceCollection()
    .AddLogging(l => l.AddConsole())

    // register logging for ReflectionTools package
    .AddReflectionTools(isStaticDefault: true)

    // register all ModRPC services for a server.
    .AddModularRpcs(
        isServer: true,
        // optional
        (services, config, parsers, parserFactories) =>
        {
            // set serialization config properties
            config.StringEncoding = Encoding.ASCII;
            config.MaximumGlobalArraySize = 256;
            config.MaximumStringLength = 8192;
            config.MaximumArraySizes[typeof(byte)] = 16384;

            // or

            services.GetRequiredService<IConfiguration>()
                    .GetSection("ModularRPCs")
                    .Bind(config);

            // register type parsers
            //   note: clients need to have the same parsers as the server
            parsers[typeof(Point)]  = new PointParser();
            parsers[typeof(Size)]   = new SizeParser();
            parsers[typeof(Vector)] = new VectorParser();

            // register IArrayBinaryTypeParser parsers
            parsers.AddManySerializer<Point>(
                new PointParser.Many(config)
            );

            // register IBinaryParserFactory implementations
            parserFactories.Add(new DictionaryParserFactory(config));
        },
        // optional (singleton or scoped?)
        scoped: false
    )
    
    // adds a service that will be initialized as an RPC object
    .AddRpcService<IPostDispatchService, PostDispatchService>();

/// <summary>
/// Hosts the named pipes server using ModularRPCs.
/// </summary>
internal class ModularRpcsServer : IHostedService
{
    private readonly IServiceProvider _serviceProvider;
    private NamedPipeEndpoint? _endpoint;

    public ModularRpcsServer(IServiceProvider serviceProvider)
    {
        // if you'd prefer to not inject the service provider,
        // you can supply the services needed to NamedPipeEndpoint.CreateServerAsync instead

        _serviceProvider = serviceProvider;
    }

    public async Task StartAsync(CancellationToken cancellationToken)
    {
        const string pipeName = "Company.Product.RPCs";

        _endpoint = NamedPipeEndpoint.AsServer(_serviceProvider, pipeName);
        await _endpoint.CreateServerAsync(cancellationToken);
        
        // server is ready to receive clients
    }

    public async Task StopAsync(CancellationToken cancellationToken)
    {
        if (_endpoint != null)
        {
            // CloseServerAsync is the same as disposing
            await _endpoint.CloseServerAsync(cancellationToken);
            _endpoint = null;
        }
    }
}

Client

The following example registers the necessary services on a client-like process using extension methods and sets up the client to connect via a Named Pipe.


IServiceCollection collection = new ServiceCollection()
    .AddLogging(l => l.AddConsole())

    // register logging for ReflectionTools package
    .AddReflectionTools(isStaticDefault: true)

    // register all ModRPC services for a client.
    // for optional configuration, see server example
    //   note: clients need to have the same parsers as the server
    .AddModularRpcs(isServer: false)
    
    // adds a service that will be initialized as an RPC object
    .AddRpcService<PostEmailNotificationService>();

/// <summary>
/// Hosts a connection the server using Named Pipes.
/// </summary>
internal class ModularRpcsClient : IHostedService
{
    private readonly IServiceProvider _serviceProvider;
    private NamedPipeEndpoint? _endpoint;
    private NamedPipeClientsideRemoteRpcConnection? _connection;

    public ModularRpcsClient(IServiceProvider serviceProvider)
    {
        // if you'd prefer to not inject the service provider,
        // you can supply the services needed to NamedPipeEndpoint.RequestConnectionAsync instead

        _serviceProvider = serviceProvider;
    }

    public async Task StartAsync(CancellationToken cancellationToken)
    {
        const string pipeName = "Company.Product.RPCs";

        _endpoint = NamedPipeEndpoint.AsClient(_serviceProvider, pipeName);
        _connection = await _endpoint.RequestConnectionAsync(TimeSpan.FromSeconds(15d), cancellationToken); // 15s timeout
        
        // client is connected
    }

    public async Task StopAsync(CancellationToken cancellationToken)
    {
        if (_connection != null)
        {
            await _connection.CloseAsync(cancellationToken);
            _connection.Dispose();
            _connection = null;
        }
    }
}

Communicate using a specific instance of a class

The following example shows a scenerio where a client in a game can define a RPC linked to a specific animal (using IRpcObject<>).

When done with an IRpcObject, you must call the Release extension method on it.<br> (this is not required on Unity components)

[GenerateRpcSource]
public partial class Animal : IRpcObject<int>
{
    // part of IRpcObject
    public int Identifier { get; }

    public Animal(int instanceId)
    {
        Identifier = instanceId;
    }
    
    /// <summary>
    /// Sends a request to the server to interact with this animal.
    /// </summary>
    /// <returns>The result of the interaction</returns>
    [RpcSend(nameof(ReceiveJump)]
    public partial RpcTask<string> SendInteractRequestAsync(
        Vector3 interactPoint,              // value parameter
        CancellationToken token = default   // injected parameter
    );

    [RpcReceive]
    private async Task<string> HandleInteractRequest(
        Vector3 interactPoint,              // value parameter (mapped by order)
        IModularRpcLocalConnection client,  // injected parameter
        CancellationToken token             // injected parameter
    )
    {
        // check to make sure the client should be allowed to interact (proximity check perhaps)
        if (!CanClientInteract(client, interactPoint))
            throw new InvalidOperationException();

        // perform the interaction (omitted for brevity)
        InteractionResult r = await InteractAsync(interactPoint, token);
        return r.Message;
    }
}

Communicate between two different services

The following example shows one service in one process broadcasting an event to all clients in the IRpcConnectionLifetime.

namespace Company.Services;

[GenerateRpcSource]
public partial class PostDispatchService : IPostDispatchService
{
    public PostDispatchService(/* ... */)
    {
        // ...
    }

    public async Task HandlePostReceived(Post post)
    {
        BroadcastNewPost(post.Id).IgnoreNoConnections();
    }
    
    [RpcSend]
    private void BroadcastNewPost(uint postPk);

    // or (not really any reason to do it this way)

    [RpcSend, RpcFireAndForget]
    private RpcBroadcastTask BroadcastNewPost(uint postPk);
}

[GenerateRpcSource]
// set default target type assembly-qualified name, can also be supplied in RpcReceive
// can use typeof if the assembly is loaded.
[RpcDefaultTargetType("Company.Services.PostDispatchService, CompanyAssembly")]
public partial class PostEmailNotificationService
{
    // listen for PostDispatchService.BroadcastNewPost to be sent.
    [RpcReceive("BroadcastNewPost")]
    private async Task ReceiveNewPost(uint postPk)
    {
        // query post info from DB and send email
    }
}

Custom serializable types

The following example defines a custom data type that implements IRpcSerializable, which allows a type to define their own serialization callbacks.

Defining a type this way automatically adds the binary parser to the serializer and also adds support for collections and nullable values of this type.

Reference types must define a public parameterless constructor if another constructor is present. Value types always start as their default value (zero'd) and will not call any constructors.

[RpcSerializable(
    minimumSize: sizeof(int) + sizeof(char) + SerializationHelper.MinimumStringSize,
    
    // isFixedSize indicates whether or not all instances of this type will be the exact same size.
    //  this allows for significant performance boosts for fixed types
    isFixedSize: false
)]
public struct CustomDataType : IRpcSerializable
{
    public int Int32;
    public string String;
    public char Character;

    // calculates the size of this object.
    // must return the exact size that will be written to in Write
    public int GetSize(IRpcSerializer serializer)
    {
        return sizeof(int) + sizeof(char) + serializer.GetSize(String);
    }

    // writes this object's data to the binary buffer
    // returns the number of bytes written for error-checking purposes.
    public int Write(Span<byte> writeTo, IRpcSerializer serializer)
    {
        int w = sizeof(int) + sizeof(char);
        Unsafe.WriteUnaligned(ref writeTo[0], Int32);
        Unsafe.WriteUnaligned(ref writeTo[4], Character);
        w += serializer.WriteObject(String, writeTo.Slice(6));
        return w;
    }
    
    // reads this object's data from the binary buffer
    // returns the number of bytes read for error-checking purposes.
    public int Read(Span<byte> readFrom, IRpcSerializer serializer)
    {
        int r = sizeof(int) + sizeof(char);
        Int32 = Unsafe.ReadUnaligned<int>(ref readFrom[0]);
        Character = Unsafe.ReadUnaligned<char>(ref readFrom[4]);
        String = serializer.ReadObject<string>(readFrom.Slice(6), out int bytesRead);
        r += bytesRead;
        return r;
    }
}

'Raw' binary

The following example shows an advanced case where it may be beneficial to send raw binary data without the overhead of serialization.

See documentation for Raw for a list of supported byte collection types.

public async Task SendSomeBinary()
{
    int ovhSize = ProxyGenerator.Instance.CalculateOverheadSize(SendBinary, out int idStartIndex);
    int size = 32 + ovhSize;
    byte[] buffer = new byte[size];
    
    // Uncomment if IRpcObject, WriteIdentifier is an extension method
    // this.WriteIdentifier(buffer + idStartIndex);
    
    for (int i = 0; i < 32; i++)
        buffer[i + ovhSize] = (byte)i;
    
    // the array isn't reused, so canTakeOwnership is true
    // if it was stack-allocated or part of a static buffer, this should be false
    await SendBinary(buffer, size, true);
}

/// <param name="data">The binary data to send.</param>
/// <param name="size">Number of bytes to read (optional).</param>
/// <param name="canTakeOwnership">Whether or not the data can be accessed if there's a context change (like if a method is awaited).</param>
[RpcSend(nameof(ReceiveBinary), Raw = true]
private partial async RpcTask SendBinary(byte[] data, int size, bool canTakeOwnership);

// params mean the same thing as above.
// canTakeOwnership may vary with different transport implementations
// if canTakeOwnership is false, data must be copied to a new buffer BEFORE awaiting
[RpcReceive(Raw = true)]
private void ReceiveBinary(byte[] data, bool canTakeOwnership)
{
    if (!canTakeOwnership)
    {
        byte[] newArray = new byte[data.Length];
        Buffer.BlockCopy(data, 0, newArray, 0, data.Length);
        data = newArray;
    }
    
    await Task.Delay(5000);

    for (int i = 0; i < 32; i++)
        Console.WriteLine(data[i]);
}

Unity Components

The following example shows how to initialize ModularRPCs without a service provider and create a UnityEngine component that can send/receive RPC messages.

The code generater has to create an OnDestory method, so Unity components can implement IExplicitFinalizerRpcObject if they also need to run code when the component is destroyed. This is only for source-generated types.

Dynamically generated types must make Start and OnDestroy virtual if they're defined.

/* server-side initialization without a service provider */

// optionally add logging
void LogCallback(Type src, LogSeverity severity, Exception? exception, string? message)
{
    Debug.Log($"[{src.Name}][{severity}] {message}{(exception == null ? string.Empty : Environment.NewLine + exception)}";
}

ProxyGenerator.Instance.SetLogger(LogCallback);

ServerRpcConnectionLifetime lifetime = new ServerRpcConnectionLifetime();
// or ClientRpcConnectionLifetime lifetime = new ClientRpcConnectionLifetime();

DefaultSerializer serializer = new DefaultSerializer(/* optional config */);
RpcRouter router = new RpcRouter(serializer, lifetime, ProxyGenerator.Instance);

// optionally add logging
serializer.SetLogger(LogCallback);
router.SetLogger(LogCallback);


/* object creation */
int instanceId = _nextInstanceId++;

GameObject gameObject = new GameObject("Animal");
Animal animal = gameObject.AddRpcComponent<Animal>(router);
Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 was computed.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  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. 
.NET Core netcoreapp2.0 was computed.  netcoreapp2.1 was computed.  netcoreapp2.2 was computed.  netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.0 is compatible.  netstandard2.1 is compatible. 
.NET Framework net461 is compatible.  net462 was computed.  net463 was computed.  net47 was computed.  net471 was computed.  net472 was computed.  net48 was computed.  net481 was computed. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen40 was computed.  tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (1)

Showing the top 1 NuGet packages that depend on DanielWillett.ModularRpcs.Unity:

Package Downloads
Uncreated.Warfare

Main framework for the Uncreated Warfare Military Simulation server based off of the military game Squad.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.0-prerelease29 42 2/2/2026
1.0.0-prerelease28 39 1/28/2026
1.0.0-prerelease22 252 10/6/2025
1.0.0-prerelease16 254 7/9/2025
1.0.0-prerelease15 174 7/5/2025
1.0.0-prerelease13 157 6/19/2025
1.0.0-prerelease12 147 6/19/2025
1.0.0-prerelease09 455 4/9/2025
1.0.0-prerelease03 1,731 10/10/2024
1.0.0-prerelease02 188 9/19/2024
1.0.0-prerelease01 104 8/8/2024

Initial pre-release.