SteonMod
View as Markdown

Routers on the guest SDK: your plugin or program speaks Fusion

For games no kit covers (Unity Mono, Bethesda, Source, custom engines) through your own plugin in the game's mod loader (BepInEx, MelonLoader, OWML, SKSE/F4SE/NVSE, a Source server plugin, an injected DLL), and for open-source games. The SDK is one implementation with three faces:

  • C#: FusionGuest.cs (one file; C# 7.3, .NET Framework 3.5+, no unsafe): Fusion.Guest.
  • C/C++: fusion_guest.h + fusion_guest_c.dll: fusion_guest_*.
  • Rust: the fusion-guest crate. fusion-cli where shows where they are. Ship fusion_guest_c.dll next to your plugin's DLL.

Porting a passthrough mod

In the modIn FusionDo
its link (shared memory such as Local\SkyCraft_v1, UDP, WebSocket)the SDKdelete; call the SDK
a fork of SkyCraft's Fabric modminecraft.player (Fusion's)delete; your scenario uses it
the game draws the other game's blocksFusion draws every game's layersdelete
the game's camera drives Minecraft'sFusion's camera drives every gameread camera(), move your camera there
raycasts into a collision field for Minecraftsend_triangles / send_boxes (16 m chunks done for you)keep the raycasts, send the result
NPC proxiessend_entities (a block that gives entities)keep the NPC finder, send a list
"host paused"Sleep / Wake eventsmap to your pause
frame sync, fallback when the other game hangsFusion's compositor and heartbeatsdelete
install scripts[router.setup] (below)turn them into steps

router.toml

fusion-cli router new <id> --name "<Name>" --kit sdk --steam-app <N> writes it. Then:

[router]
id = "mygame"
name = "My Game"
version = "0.1.0"
game_exe = "MyGame.exe"
units = { length = "m", up_axis = "y", health_max = 100 }   # you send Fusion's space

[router.link]
protocol = "fusion"
game_id = "mygame"              # what your plugin passes to Connect / TryConnect

[router.launch]
steam_app = 123456

[[block]]
id = "mygame.world"
name = "My Game's World"
kind = "world"
gives = ["layer:color+depth", "collision"]
cost = { ram_gb = 3.0, gpu = "medium", must_stay_awake = true }

[[block]]
id = "mygame.npcs"
name = "My Game's NPCs"
kind = "npc_group"
gives = ["entities"]
actions = ["remove", "set_health"]
cost = { ram_gb = 0.0, gpu = "none" }

[router.setup]                  # one click for players: check the loader, copy your plugin
installed_when = "{game}/BepInEx/plugins/MyGameFusion/MyGameFusion.dll"

[[router.setup.require]]
name = "BepInEx 5"
check = "{game}/BepInEx/core/BepInEx.dll"
guide = "Install BepInEx 5 (x64) into the game's folder and start the game once."
url = "https://github.com/BepInEx/BepInEx/releases"

[[router.setup.step]]
copy = "{router}/plugin"        # your built plugin + fusion_guest_c.dll, in the router folder
to = "{game}/BepInEx/plugins/MyGameFusion"

The plugin's loop (C#; C has the same calls)

Fusion.Guest guest;
void Update()                                    // the plugin's per-frame hook
{
    if (guest == null && (guest = Fusion.Guest.TryConnect("mygame")) == null) return;
    foreach (var e in guest.Update())            // once per frame
    {
        switch (e.Kind)
        {
            case Fusion.EventKind.Sleep: Pause(true); break;
            case Fusion.EventKind.Wake: Pause(false); break;
            case Fusion.EventKind.Action: guest.Reply(e.RequestId, Act(e.Entity, e.Name, e.Args)); break;
            case Fusion.EventKind.Spawn: case Fusion.EventKind.Teleport: guest.Reply(e.RequestId, false); break;
            case Fusion.EventKind.Query: guest.Answer(e.RequestId, false, "no questions"); break;
        }
    }
    if (guest.Ended) { guest.Dispose(); guest = null; return; }
    guest.SendEntities(npcs);                    // List<Fusion.Entity>: Id, Kind "mygame.npc", Name, Feet, Health...
    guest.SendTriangles(groundNearCamera);       // 9 floats per triangle, Fusion's space
}

Fusion's space: meters, Y up, right-handed, -Z forward, around an origin you pick near where play starts. Unity: (x, y, -z), yaw -unityYawDegrees in radians. Z-up right-handed engines (Source in inches, Bethesda in ~1.43 cm units): (x, z, -y) * metersPerUnit, yaw = game yaw − 90°. Check with F7 (collision) and F8 (entities) in Fusion.

The picture

  1. guest.TryGetCamera(out cam); move your world camera to it before it renders (position: CameraToWorld[12..14]; forward: -column 2; up: column 1; FovYDegrees), and give it back after.
  2. Render into a texture of RenderSize, and depth into a one-channel float texture.
  3. GPU (Direct3D 11): from the render thread, fusion_guest_d3d11_publish(g, ctx, color, depth, &mode, flags, cam.camera_id); from Unity's main thread, id = guest.D3D11Queue(color.GetNativeTexturePtr(), depth.GetNativeTexturePtr(), Fusion.DepthMode.Device(near, far, SystemInfo.usesReversedZBuffer), Fusion.PictureFlags.BottomUp | Fusion.PictureFlags.Opaque, cam.CameraId) then cb.IssuePluginEvent(Fusion.Guest.D3D11RenderEvent, id); Graphics.ExecuteCommandBuffer(cb). -1 → guest.Error; use the CPU path.
  4. CPU (anything else): guest.Picture(w, h, true) once per size, then guest.PublishFrame(rgba8, depthMeters, cam.CameraId) (alpha below 128 = nothing there).

A character players play as (optional)

Block: kind = "character", needs = ["input"], gives = ["pose"]. Each frame: guest.TryGetInput(out input); while input.Possessed != 0, move with MoveX/MoveY and turn to Yaw/Pitch through the game's own movement; guest.SetPose(id, x, y, z, yaw) and guest.SetView(eyeX, eyeY, eyeZ, 0, 0, Fusion.ViewFlags.InWorld). To walk on another game's World, add needs = ["collision"] and read Collision events with TryGetCollision.

Check

fusion-cli router check routers/<id>, then fusion-cli router conform routers/<id> --start (the plugin installed, Fusion closed). Working examples: example-sdk.router.toml and Fusion's sdks/csharp/example (a C# world), tools/c-world (C).

Sharing a router whose package carries a DLL needs a verified author (Fusion's docs/trust.md).