Write your async code once against
Task<T>. Ship allocation-free mirrors targetingGDTask,UniTask, or any custom task-like.
EngineTask is a C# source generator that takes a class authored in plain System.Threading.Tasks and emits, alongside it, parallel implementations that return engine-specific task-likes — GDTask for Godot, UniTask for Unity, or anything else you can describe in a small JSON file.
If you have a library you want to use from both a Godot project and a Unity project, you face a choice:
- Stick with
Task. Works everywhere, but every async method allocates aTask<T>reference on the heap, which is exactly the cost both engines' specialised task-likes were built to avoid. - Wrap. Write a
Task-based core and convert at the boundary with.AsTask()/.AsUniTask(). The conversion costs aren't zero, and the originalTask<T>allocation still happens. - Fork the library. Maintain two copies. Forever.
EngineTask lets you author one library and emit separate async-method-built state machines per target — when a Godot consumer awaits the GDTask mirror, the C# compiler builds the state machine with AsyncGDTaskMethodBuilder and no Task is ever allocated. Same for UniTask.
You write:
using System.Threading.Tasks;
using EngineTask;
namespace MyLib;
[GenerateMirror(TaskFlavour.GDTask)]
[GenerateMirror(TaskFlavour.UniTask)]
public partial class Calculator
{
public Task<int> AddAsync(int a, int b) => Task.FromResult(a + b);
}EngineTask emits two mirrors. In MyLib.GDTask.Calculator.GDTask.g.cs:
namespace MyLib.GDTask
{
public partial class Calculator
{
public global::GodotTask.GDTask<int> AddAsync(int a, int b)
=> global::GodotTask.GDTask.FromResult(a + b);
}
}And in MyLib.UniTask.Calculator.UniTask.g.cs:
namespace MyLib.UniTask
{
public partial class Calculator
{
public global::Cysharp.Threading.Tasks.UniTask<int> AddAsync(int a, int b)
=> global::Cysharp.Threading.Tasks.UniTask.FromResult(a + b);
}
}Same source, three state machines, three allocation profiles.
dotnet add package EngineTask
EngineTask ships as a Roslyn analyzer — no runtime dependency, no DLL in your output. The [GenerateMirror] and [MirrorIgnore] attributes are emitted into your compilation; you don't need a separate attributes package.
You'll also need a runtime reference to the task-like you're mirroring against — GDTask from the Delsin-Yu/GDTask.Nuget package for Godot, or Cysharp/UniTask for Unity.
C# allows async methods to return any task-like type carrying an [AsyncMethodBuilder] attribute. GDTask, UniTask, and Task all qualify. The compiler picks the builder based on the declared return type — emit the same method body with a different return-type annotation and you get a different state machine for free.
The generator's job is therefore just:
- Detect classes with
[GenerateMirror]. - Emit a parallel method per flavour with the return type swapped.
- Rewrite the body so
Task.Delay,Task.WhenAll,Task.FromResultetc. become the flavour-specific equivalents.
The translation tables for the built-in flavours are listed in docs/translation-tables.md, generated directly from the in-code tables and snapshot-tested so the docs cannot drift.
The central claim, expressed in bytes per call. Measured on .NET 10.0.0, x64, BenchmarkDotNet 0.15.8.
| Method | Allocated |
|---|---|
Task_FromResult (baseline) |
72 B |
GDTask_FromResult |
0 B |
UniTask_FromResult |
0 B |
| Method | Allocated |
|---|---|
Task_AsyncFromCompletedTask (baseline) |
72 B |
GDTask_AsyncFromCompletedTask |
0 B |
UniTask_AsyncFromCompletedTask |
0 B |
To reproduce:
dotnet run --project tests/EngineTask.Benchmarks -c Release -- --job short
If you target Stride, Unity's new Awaitable, or any other task-like, drop an enginetask.json into your project as an AdditionalFile:
{
"flavours": [
{
"id": "Awaitable",
"namespaceSuffix": "UnityAwaitable",
"typeMappings": {
"System.Threading.Tasks.Task": "global::UnityEngine.Awaitable",
"System.Threading.Tasks.Task`1": "global::UnityEngine.Awaitable"
},
"memberMappings": {
"System.Threading.Tasks.Task.FromResult": "global::UnityEngine.Awaitable.FromResult",
"System.Threading.Tasks.Task.CompletedTask": "global::UnityEngine.Awaitable.CompletedTask"
}
}
]
}Then [GenerateMirror("Awaitable")] resolves against it at compile time. Full walkthrough in docs/extending.md.
EngineTask reports six diagnostics:
| Id | Severity | Meaning |
|---|---|---|
ENGTASK001 |
Warning | Method body uses a Task-related API with no mapping; method skipped |
ENGTASK002 |
Warning | [GenerateMirror] applied to a non-partial class |
ENGTASK003 |
Error | async void cannot be mirrored |
ENGTASK004 |
Warning | A user-written partial of the mirror already declares this method; generated version skipped |
ENGTASK005 |
Warning | [GenerateMirror("X")] references a flavour not declared in any enginetask.json |
ENGTASK006 |
Warning | An enginetask.json is unparseable |
ENGTASK004 is also the basis for the recommended escape-hatch pattern when you need engine-specific code paths — see docs/escape-hatches.md.
docs/translation-tables.md— every type/member mapping for every built-in flavour. Generated from the in-code tables.docs/cancellation.md— recommendedCancellationTokenpatterns for Godot and Unity consumers.docs/extending.md— adding a custom flavour viaenginetask.json, with a UnityAwaitableworked example.docs/escape-hatches.md— engine-specific code patterns and how instance-member accesses pass through the rewriter.
Pre-release. Built and tested against .NET 10. The API shape, attribute parameters, and translation tables are likely stable but not formally frozen — semver becomes binding from 1.0.0 onward.
git clone https://github.com/adds39939/EngineTask
cd EngineTask
dotnet test EngineTask.slnx
The generator project targets netstandard2.0 (a Roslyn requirement). Every other project targets net10.0.
PRs welcome. The snapshot tests under tests/EngineTask.Generator.Tests/Snapshots/ are the de-facto spec — reviewing the diff is part of reviewing a change. If you add a translation-table entry or a new diagnostic, the relevant test files will surface it as a snapshot change for you to accept deliberately.
MIT.