Fusion Protocol (v1)
How Fusion and a guest game talk. The exact byte layout is defined in schema/fusion.fidl; this page explains the ideas.
The pieces¶
| Piece | What it is | Who writes |
|---|---|---|
Discovery mapping Local\Fusion_Discovery | A list of the games Fusion is waiting for, each with the name of its channel | Fusion adds entries; a guest claims its entry |
| Channel (one per guest) | One shared-memory block holding everything below | both |
| Header | magic number, protocol version, layout hash, process ids, heartbeats, states, the GPU to use | both (different fields) |
Slots: camera, input | latest camera and controls | Fusion → guest |
Slots: layers, pose, view | the guest's picture layers, its character's pose, where that character looks from | guest → Fusion |
layer_buffers | who owns each picture buffer right now | both, with compare-and-swap |
Rings: to_guest, to_host | events and commands, in order | one producer each |
| Bulk stream (optional, its own mapping) | big messages: textures, meshes | guest → Fusion |
Why discovery?¶
Games are often started by Steam or their own launcher, so Fusion can't hand them a command-line argument. Instead, the kit inside the game opens the discovery mapping, finds the entry for its game id (e.g. minecraft), claims it, and opens the channel named there.
Slots ("latest value")¶
For data where only the newest value matters (camera, pose). The writer never waits and the reader never sees half an update: a seqlock (sequence number that is odd while writing) lets the reader detect an update in progress and simply retry.
Rings (events and commands)¶
For things that must all arrive, in order (damage, "spawn this", collision data). Each ring has one producer and one consumer. A message uses one 256-byte slot, or several for big data (collision chunks up to ~240 KB). The producer never waits: if the ring is full the message is dropped, counted in dropped, and the sender is told.
Collision¶
CollisionChunk messages carry Fusion's Collision Hub in both directions:
- guest → Fusion: a game whose block gives
collisionpublishes its collision; - Fusion → guest: a game whose block needs
collisiongets the hub's collision around its pose (everything except its own).
Fusion also tells a guest once about each empty chunk around it (a chunk message with no triangles), so the guest can tell "nothing here" from "not sent yet": Minecraft holds its player until the ground below is known.
Each message is one 16 m chunk of Fusion's grid (chunk (0, 0, 0) covers 0..16 m on every axis) and replaces what its sender had in that chunk; no triangles means "empty here". Positions are in Fusion's space (meters, Y-up, right-handed, around Fusion's origin), like every position in the protocol: the kit converts, because only it knows where Fusion's origin sits in its game. Triangles are cut to the chunk's cube and wound so (b - a) × (c - a) points out of the solid. Sending an unchanged chunk again is cheap: Fusion compares contents and ignores it. tools/fake-guest (--ramp) is the reference.
Entities¶
A game whose block gives entities sends EntityList messages (guest → Fusion) into Fusion's entity registry: its NPCs, villagers, vehicles or crates. Each list is the whole list and replaces the one before, so an entity missing from it is gone (an empty list means none left). Send it a few times a second while things move.
Each EntityInfo has:
entity: the game's own id for it, the same in every list while it exists. Fusion gives it a Fusion id of its own, which stays the same as long as the game keeps listing it.transform: where it is (for a creature, its feet) and how it's turned, in Fusion's space.bounds_min/bounds_max: its box in meters, relative to its position and turned with it (a standing person is about (-0.3, 0, -0.3) .. (0.3, 1.8, 0.3)).health/max_health(0 = it has no health),team,kind("zombie") andname("Lydia", or "" to show the kind).
A long list goes in parts with the same list_id (part 0 .. part_count - 1, at most 64 entities per part is a good size). Fusion uses a list once all its parts arrived; a list with a part lost to a full ring is skipped, and the next one replaces it. tools/fake-guest (--npcs) is the reference.
When a game's process ends, Fusion removes its entities and its collision, and stops showing its picture. A game that's only frozen or asleep keeps them.
Spawning¶
Fusion asks a game to make something with a Spawn message (Fusion → guest): a kind such as "zombie", standing at transform (its feet, in Fusion's space, facing the transform's −Z). The game's router decides which kinds it can make. The guest answers with an ActionResult carrying the same request_id (ok 1 or 0); the new thing then shows up in its next EntityList. In Godot: FusionGuestView.request_spawn(kind, transform) and get_request_result(id). The Unreal kit is the reference (kits/unreal, Router.spawn).
Picture layers: GPU or CPU¶
A guest describes up to 4 layers in the layers slot. Each has LAYER_BUFFERS (3) buffers, and layer_buffers says who owns each one; ownership only changes by compare-and-swap (FREE → WRITING → READY → HOST → FREE), so neither side waits.
- GPU layers (the default): shared Direct3D 12 textures (color + linear depth) and a fence, composited at their real depth. fake-guest and the Unreal kit use them.
- CPU layers (
LayerFlags::CPU): plain pixels (RGBA8) in a mapping the guest creates, namedname_base. No fence. For games whose graphics can't share textures with Direct3D 12.- Color only (
depth_format0): buffer i is ati * width * height * 4. Fusion shows it flat on top, like the game's own interface (LayerKind::GUIaboveHAND); Minecraft (OpenGL) sends its hand + HUD this way.LayerFlags::BOTTOM_UPsays the first row is the bottom (OpenGL read-backs). - Color + depth (
depth_format41, R32F; step 5.9): buffer i is ati * width * height * 8: the RGBA8 pixels, then one little-endianf32per pixel, the distance in front of the camera in the game's length units. Fusion draws it in 3D with the same shader as GPU layers, so props and other games sort against it. The guest SDK'spicture(w, h, true)makes one (guest-sdk.md).
- Color only (
- A new size means new buffers: the guest bumps
generation, resets the layer's buffers, and writes the layer again.
Bulk streams¶
Ring messages top out at about 240 KB; a texture atlas or a mesh can be much bigger. A guest that sends big data makes its own mapping (a bulk stream: BulkHeader, then a byte ring) and announces it with a BulkStream message after every (re)connect. Each message in it is a {kind, len} header plus exactly the bytes it would have in a ring, so Fusion handles bulk and ring messages the same way. The writer never waits for long: a message that doesn't fit is dropped and counted. Rust: fusion_protocol::bulk; Java: fusion.kit.BulkWriter.
Mesh layers¶
Instead of pictures, a guest can send geometry its own renderer built (models, tints, baked shading, animation), and Godot draws it, lit and depth-tested with everything else:
MeshTexture: a texture (RGBA8, sRGB, top row first) by id, or a region of one (an animated sprite). Godot can only upload whole textures, so region updates are gathered and uploaded at most once a second (v0).MeshPart: one part of a mesh (one texture, opaque or translucent), a triangle list, front faces counter-clockwise (glTF). A mesh's parts share anupdate_id; Fusion shows the mesh when allpart_countparts arrived, replacing its previous update;part_count0 removes it.attachsays whetheroriginis in Fusion's world or follows the guest's pose (a body). Only the newest update per mesh is built each Fusion frame.MeshVertex: position, uv, color (tint), a packed normal (0 = use the triangle's) plus a glow byte, and a cutout flag (alpha-tested leaves).MeshLights(point lights belonging to a mesh) andMeshClear(remove every mesh).
Minecraft's world, its player's body and its entities come this way (kits/fabric).
Playing as a game's character ("possess", BUILD.md steps 1.3b and 1.6)¶
When a scenario plays as one of a game's Character blocks, Fusion:
- puts the character at the World's spawn point with a
Teleport(answered by anActionResultonce the character stands there); - does the mouse look itself and sends the result in
InputState(yaw,pitch, flagsPOSSESSED | LOOK), so its camera never waits for the game; - sends keys and mouse buttons as
RawInputmessages (keyboard scancodes as USB HID usages, the same numbers SDL uses), so the game's own controls, menus, chat and inventory work; - also fills
InputState's generic movement from the keys held down (1.6): W/S asmove_y, D/A asmove_x, Space/Left Ctrl/Left Shift as theJUMP/CROUCH/SPRINTbuttons, the left and right mouse buttons asATTACK/USE. A kit that moves its character itself uses these (fake-guest's cube does), one that replays keys (Minecraft's) ignores them; - moves its camera to the character's
View(its eyes, or a third-person spot; the game's field of view) once the guest writes one withViewFlags::IN_WORLD. While the game shows a screen of its own (SCREEN_OPEN), the mouse cursor is free and its position is sent instead. A guest that writes aPosebut never aView(for 1 s) gets Fusion's own third-person camera, 4 m from a point 1 m above the pose, turned by the mouse (1.6).
Actions¶
Action (Fusion → guest) asks the game to do something to one of its entities, by the game's own id (as in its EntityList): "remove", and later "stun", "play_animation"... The game's router decides which actions it knows, and answers with an ActionResult. Godot: FusionGuestView.request_action(entity, action, args). Minecraft's kit knows "remove".
Steering uses two standard actions, for blocks that need steering: Fusion finds a path on its navmesh and walks the NPC along it one waypoint at a time.
move_to:args[0..3]= the next waypoint (x, y, z) in Fusion's space (meters, Y up), likeSpawn; the kit converts it. Walk there, then wait for the next one.stop: stop where you are.
fake-guest's walkers know both.
Questions from Fusion's tools¶
Query (Fusion → guest) carries text: a command and what follows it. The kit answers with a QueryReply carrying the same request_id: ok = 1 and JSON, or ok = 0 and what went wrong in plain words (an unknown command says which ones the kit knows). Both have a text tail (TextBytes, UTF-8) and stay under 64 KB: a long list is cut and says "cut": true. Router Studio (F4) and fusion-cli game ask them; Godot: FusionGuestView.query(text) and get_query_reply(id). Nothing is changed by a question except reload.
| Command | Answer (JSON) |
|---|---|
info | kit, game, router, map (where the game is), player (its player's class), player_path, commands |
classes [text] | classes: live objects grouped by class, most first: class, path (the class's own path, for spawning), count, role (character, pawn or actor), parents (nearest first), player (the player is one); total, cut. text keeps classes whose name contains it |
objects CLASS [text] | objects of that class, nearest to the player first: name, path, class, at (game units), distance (m), player; total, cut |
inspect OBJECT | an object (by path, or a live actor's name): name, path, class, parents, role, at, fields: name, type (Float, Object, Struct...), owner (the class that declares it), value when it's plain (numbers, text, booleans; an object field's value is that object's path, to inspect next) |
find TEXT | classes as above, plus objects whose own name has the text |
test ROUTER.TOML | the kit tries this router's data on the running game without changing anything: checks (ok, what, detail): the player's class, the map, each entity rule (how many live objects match, what its health fields read), each spawn asset |
reload ROUTER.TOML | the running game uses this router's data from now on (Router Studio's "Apply live", fusion-cli router reload); {"reloaded": true}. A line --- router.lua --- after the TOML is followed by the router's Lua, which the kit runs instead of the installed file (hot reload); a script that fails is refused and the running router stays |
The Unreal kit answers all of them from UE4SS's reflection (router_api.lua, M.answer); fake-guest answers the first five about its cube, plank and walkers.
Sleep and stage mode¶
Fusion's scheduler sends these to a game that isn't the World's or the played Character's, or when free RAM runs low:
Sleep: release input and mute; about 150 ms later Fusion suspends the process. A kit should set its state toSLEEPING. Fusion shows the game as "asleep", not "not answering".Wake: sent right after Fusion resumes the process; the kit resets its watch of Fusion's heartbeat and goes back toRUNNING.SetFrameCap/SetResolutionScale: stage mode.fps = 0andscale = 1mean "no limit from Fusion": go back to the game's own settings. Fusion only sends them when it limits a game, or to lift a limit it set.
Kits that ignore these still sleep (the process is suspended), they just don't get a warning.
Kits in other languages¶
The generated files only describe the byte layout. A kit also needs the rules: discovery, heartbeats, seqlocks, rings, layer buffer ownership. Rust kits use the fusion-protocol crate. Java kits use kits/common/java/fusion/kit (FusionGuest, BulkWriter), which crates/fusion-protocol/tests/java_guest.rs checks against Fusion's own Rust side.
Heartbeats¶
Each side bumps a counter at least every 100 ms. If the other side's counter stops changing for 500 ms, it counts as lost (crashed, frozen, or suspended). Fusion then drops that block and keeps running. If the game's process is gone, its entities and collision go too. A lost game's discovery entry is opened again, so a kit that gave up on Fusion (Minecraft's waits 10 s) can connect again; when it's back, Fusion sends its collision again from scratch.
Version safety¶
The header carries a layout hash: a fingerprint of every struct, field, offset and constant in the schema. If a kit was built against a different schema, opening the channel fails with a clear "protocol mismatch" error instead of reading garbage.
Same GPU¶
The header carries the adapter LUID of Fusion's Direct3D 12 device. Guests must create their shared textures on that GPU.
Changing the protocol¶
- Edit
schema/fusion.fidl. Fields must be naturally aligned with no hidden padding; the generator tells you exactly where to add a_padNfield. - Run
cargo run -p fusion-codegen. It rewrites every generated file:
| File | For |
|---|---|
crates/fusion-protocol/src/generated.rs | Rust (Fusion, Rust kits) |
kits/common/cpp/fusion_protocol.h | C++ kits (e.g. SKSE) |
kits/common/csharp/FusionProtocol.cs | C# kits (Unity: MelonLoader/BepInEx; C# 9, unsafe code on) |
kits/common/java/fusion/protocol/FusionProtocol.java | Java kits (Fabric; FFM API, JDK 22+); the rules are in kits/common/java/fusion/kit (hand-written) |
schema/fusion.layout.txt | the canonical layout and its hash |
- Bump
versionin the schema if old kits can't work with the change. cargo test --workspace. A test fails if the generated files are stale.cargo run -p fusion-codegen -- --check-languagescompiles small checker programs in C++, C# and Java and confirms each compiler's real layout matches the schema (Rust is checked at compile time).
Using the generated code¶
- C++:
#include "fusion_protocol.h"; map the channel and cast tofusion::Channel*. - C#:
var ch = (Channel*)ptr;. Arrays of structs are raw bytes with an accessor:ch->pose.value.bones(i)returns aTransform*. - Java:
segment.get(JAVA_INT, Channel.OFFSET_HEADER + ChannelHeader.OFFSET_MAGIC). For atomics useJAVA_LONG.varHandle()withgetAcquire/setRelease/compareAndSeton the same offsets.
Tests¶
cargo test -p fusion-protocol: unit tests, plustests/cross_process.rs, which starts a real second process as a fake guest and checks:- 100,000 slot reads with no torn reads while the guest writes;
- 50,000 events, twenty 72 KB collision chunks and 5,000 commands, all in order;
- a crashed guest and a frozen guest are each detected in under 1 second.
tests/java_guest.rsrunskits/common/java/fusion/kit/SelfTest.javaas a guest (skipped withoutjavaon PATH): discovery, camera and controls, rings both ways, a teleport, a CPU layer, and a 96 KB mesh through a bulk stream.