SteonMod
View as Markdown

From your passthrough mod to a Fusion router

For authors of passthrough mods (a SkyCraft port, "Minecraft in <game>", a ReShade bridge) who want their game in Fusion. Start with making-routers.md if you haven't.

What you keep is what you learned about your game: which object is the player, how to get past its menus, where its NPCs and their health are, how it pauses, which build works. What you drop is everything Fusion now does for every game: the link, the Minecraft side, drawing the other game, frame sync, the installer. A port that took weeks becomes a router of tens of lines (a kit) or your plugin minus most of its code (the SDK).

The one big change: your game is a guest, not the host

In your mod, your game is the host: it runs in front, draws everything (its world and Minecraft's blocks), and drives a hidden Minecraft through a private link.

In Fusion, Fusion is the host, and every game is a guest:

  • your game gives a World block: it draws its world from Fusion's camera, and sends its ground and walls (collision) and its NPCs;
  • Minecraft is the minecraft.player Character block, already in Fusion;
  • Fusion layers the pictures with their depth, hands your collision to Minecraft, runs both games' sleep and wake, and puts the player's camera where Minecraft's eyes are.

"Minecraft in your game" is then a scenario, not a mod:

[scenario]
name = "Minecraft in My Game"
description = "Play as the Minecraft player in My Game's world."

[[block]]
use = "mygame.world"

[[block]]
use = "minecraft.player"

And because your game is now a block, the same router also fuses it with every other game in Fusion: another game's character in your world, your NPCs in someone else's.

Part by part

In your modIn FusionYou
The link: shared memory (Local\SkyCraft_v1), UDP, a WebSocket, its message schemathe Fusion protocol, through a kit or the guest SDKdelete it
Minecraft's Fabric mod (SkyCraft's, plus your patch)routers/minecraft, kept up to date with Fusion. Your patch's choices (creative mode, no digging) become the player's own modpack or Lua addonsdelete the fork; note in notes.md what your patch did
Your game draws Minecraft's blocks and bodyFusion draws Minecraft's meshes itself, lit and depth-sorteddelete
Camera sync (your camera drives Minecraft's)Fusion's camera drives every game. Your game draws from it: a kit does that by itself; with the SDK you move your camera to camera() each frameturn it around: read Fusion's camera instead of sending yours
Collision for Minecraft (raycasts into a block field, BSP brushes...)your World gives collision: a kit traces it by itself; with the SDK you send triangles or boxes, in 16 m chunks (done for you). The Collision Hub hands it to Minecraft and to every other gamekeep your collision code, send its result
NPC proxies, damage formulasnpc_group block: list them (entities), with health and team; Fusion's entity registry, Lua and the cards use the list. Units (health_max) replace pair-specific formulaskeep your NPC finder, send a list
Pause ("host paused" flag), focus tricks, a hidden windowsleep and wake (Sleep/Wake events); kits keep the game running unfocusedmap pause to Sleep
Frame sync (wait for frame N, reuse N-1), fallback when the other game hangsFusion's compositor and heartbeatsdelete
Installer scripts, "copy these files", JVM flags[router.setup]: requirement checks (is BepInEx there?) and copy steps, which players run with one click in My Games, and can undoturn the script into steps
"Works with version X"tested_builds (router recon prints the build's hash); fusion-cli game diff after an updatelist the build
README credits, what workscontributors, based_on, license in router.toml; notes.md; MODLOG.mdmove them over, keep SkyCraft's MIT notice if you kept its code

Which path

Your gameDo this
Unreal 4/5Drop your plugin: write a router on the Unreal kit (agent/unreal.md). Most of what your plugin found is one line of [block.bind] data each
Unity IL2CPPThe same on the Unity kit (agent/unity.md)
Unity Mono (BepInEx 5, OWML, MelonLoader on Mono)Keep your plugin; swap its link for FusionGuest.cs (below). The Unity kit doesn't run Mono games yet
Skyrim, Fallout (SKSE/F4SE/NVSE), Source (a server plugin), an injected DLLKeep your plugin; swap its link for fusion_guest.h (below)
Open sourceLink the SDK into its source (guest-sdk.md)

Make the router first: fusion-cli router new mygame --name "My Game" --kit sdk --steam-app <N>. It writes routers/mygame/router.toml; set gives on the world block to what you'll send (layer:color+depth, collision, entities) and add an npc_group block if you list NPCs.

Then, in your plugin (C# here; C/C++ has the same calls, fusion_guest_*):

Fusion.Guest guest;      // FusionGuest.cs; put fusion_guest_c.dll next to your plugin's DLL

void Update()            // your plugin's per-frame hook
{
    if (guest == null)
    {
        guest = Fusion.Guest.TryConnect("mygame");       // the router's game_id; null until Fusion asks
        if (guest == null) return;
    }
    foreach (var e in guest.Update())                    // once per frame: heartbeat, sleep, frame cap
    {
        if (e.Kind == Fusion.EventKind.Sleep) PauseGame(true);      // your old "host paused" code
        else if (e.Kind == Fusion.EventKind.Wake) PauseGame(false);
        else if (e.Kind == Fusion.EventKind.Action) guest.Reply(e.RequestId, DoAction(e.Entity, e.Name));
        else if (e.Kind == Fusion.EventKind.Spawn || e.Kind == Fusion.EventKind.Teleport) guest.Reply(e.RequestId, false);
        else if (e.Kind == Fusion.EventKind.Query) guest.Answer(e.RequestId, false, "not supported");
    }
    if (guest.Ended) { guest.Dispose(); guest = null; return; }   // Fusion closed: give the game back

    guest.SendEntities(FindNpcs());                      // a few times a second is plenty
    guest.SendTriangles(CollisionNear(cameraPosition));  // your collision code's result, Fusion's space
}

Fusion's space is meters, Y up, right-handed, -Z forward. Pick an origin near where play starts (floats lose precision far away) and convert at your plugin's edge:

Your engineTo Fusion
Unity (meters, Y up, left-handed)(x, y, -z) minus the origin; yaw: -unityYawDegrees in radians
Z-up, right-handed (Source: inches; Bethesda: units of about 1.43 cm)(x, z, -y) * metersPerUnit minus the origin; yaw: game yaw − 90°

Check with Fusion's F7 (collision) and F8 (entities): boxes in the wrong place mean the conversion is off.

The picture: draw from Fusion's camera

Each frame, before your world camera renders:

  1. guest.TryGetCamera(out cam): Fusion's camera. cam.CameraToWorld is column-major; the camera looks down its -Z. Convert its position and its forward (-column 2) and up (column 1) to your engine and put your world camera there, with cam.FovYDegrees.
  2. Have the camera draw into a texture of RenderSize (and its depth into a one-channel float texture), instead of the screen.
  3. Hand both to Fusion:
    • On the GPU (Direct3D 11), best: from the render thread (a C++ Present hook): fusion_guest_d3d11_publish(g, context, color, depth, &mode, flags, cam.camera_id). In Unity, which draws on its own render thread: id = guest.D3D11Queue(color.GetNativeTexturePtr(), depth.GetNativeTexturePtr(), Fusion.DepthMode.Device(near, far, SystemInfo.usesReversedZBuffer), Fusion.PictureFlags.BottomUp | Fusion.PictureFlags.Opaque, cam.CameraId), then commandBuffer.IssuePluginEvent(Fusion.Guest.D3D11RenderEvent, id) and Graphics.ExecuteCommandBuffer(commandBuffer) after the camera rendered. About 1 frame behind Fusion's camera.
    • On the CPU, works everywhere (Direct3D 9, OpenGL, another GPU): read the pixels back (Unity: AsyncGPUReadback) and guest.PublishFrame(rgba8, depthMeters, cam.CameraId). A few frames behind.
    • If the GPU path returns -1, guest.Error says why; switch to the CPU path.
  4. Give your camera back its own place after it rendered, so the game's own code never sees it moved.

Fusion's Unity kit does exactly this for IL2CPP games (kits/unity/mod/Picture.cs: the camera that really draws the world, depth from a command buffer, the render event) and is the reference for a Mono plugin. The Unity-side lines above are a guide, not yet run in a Mono game.

Characters (optional)

To let players play as your game's character, add a block kind = "character", needs = ["input"], gives = ["pose"]. Each frame: guest.TryGetInput(out input); while input.Possessed != 0, move your player with MoveX/MoveY and turn it to Yaw/Pitch the way your game's own controls do, then guest.SetPose(id, feet...) and guest.SetView(eyes..., Fusion.ViewFlags.InWorld). Fusion's camera follows the eyes.

Setup for players

Turn your install instructions into [router.setup] (block-manifest.md, "[router.setup]: one-click setup"):

[router.setup]
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"
to = "{game}/BepInEx/plugins/MyGameFusion"

A router that carries a plugin DLL is shared only by verified authors (trust.md); ask to become one when you publish.

Check it

fusion-cli router check routers/mygame             # the files, no game needed
fusion-cli router conform routers/mygame --start   # starts the game; numbers for every port
fusion-cli check-scenario scenarios/minecraft-in-mygame.toml

router conform measures what used to need eyes: whether the picture arrives and how many frames it lags behind the camera (from the picture itself), whether collision and NPCs arrive, whether it survives sleep and wake, and whether it ignores what it can't do.

Then play the scenario in Fusion. F7 shows the collision Minecraft walks on, F8 the NPCs.