SteonMod
View as Markdown

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

PieceWhat it isWho writes
Discovery mapping Local\Fusion_DiscoveryA list of the games Fusion is waiting for, each with the name of its channelFusion adds entries; a guest claims its entry
Channel (one per guest)One shared-memory block holding everything belowboth
Headermagic number, protocol version, layout hash, process ids, heartbeats, states, the GPU to useboth (different fields)
Slots: camera, inputlatest camera and controlsFusion → guest
Slots: layers, pose, viewthe guest's picture layers, its character's pose, where that character looks fromguest → Fusion
layer_bufferswho owns each picture buffer right nowboth, with compare-and-swap
Rings: to_guest, to_hostevents and commands, in orderone producer each
Bulk stream (optional, its own mapping)big messages: textures, meshesguest → 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 collision publishes its collision;
  • Fusion → guest: a game whose block needs collision gets 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") and name ("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, named name_base. No fence. For games whose graphics can't share textures with Direct3D 12.
    • Color only (depth_format 0): buffer i is at i * width * height * 4. Fusion shows it flat on top, like the game's own interface (LayerKind::GUI above HAND); Minecraft (OpenGL) sends its hand + HUD this way. LayerFlags::BOTTOM_UP says the first row is the bottom (OpenGL read-backs).
    • Color + depth (depth_format 41, R32F; step 5.9): buffer i is at i * width * height * 8: the RGBA8 pixels, then one little-endian f32 per 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's picture(w, h, true) makes one (guest-sdk.md).
  • 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 an update_id; Fusion shows the mesh when all part_count parts arrived, replacing its previous update; part_count 0 removes it. attach says whether origin is 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) and MeshClear (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 an ActionResult once the character stands there);
  • does the mouse look itself and sends the result in InputState (yaw, pitch, flags POSSESSED | LOOK), so its camera never waits for the game;
  • sends keys and mouse buttons as RawInput messages (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 as move_y, D/A as move_x, Space/Left Ctrl/Left Shift as the JUMP/CROUCH/SPRINT buttons, the left and right mouse buttons as ATTACK/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 with ViewFlags::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 a Pose but never a View (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), like Spawn; 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.

CommandAnswer (JSON)
infokit, 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 OBJECTan 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 TEXTclasses as above, plus objects whose own name has the text
test ROUTER.TOMLthe 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.TOMLthe 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 to SLEEPING. 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 to RUNNING.
  • SetFrameCap / SetResolutionScale: stage mode. fps = 0 and scale = 1 mean "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

  1. Edit schema/fusion.fidl. Fields must be naturally aligned with no hidden padding; the generator tells you exactly where to add a _padN field.
  2. Run cargo run -p fusion-codegen. It rewrites every generated file:
FileFor
crates/fusion-protocol/src/generated.rsRust (Fusion, Rust kits)
kits/common/cpp/fusion_protocol.hC++ kits (e.g. SKSE)
kits/common/csharp/FusionProtocol.csC# kits (Unity: MelonLoader/BepInEx; C# 9, unsafe code on)
kits/common/java/fusion/protocol/FusionProtocol.javaJava kits (Fabric; FFM API, JDK 22+); the rules are in kits/common/java/fusion/kit (hand-written)
schema/fusion.layout.txtthe canonical layout and its hash
  1. Bump version in the schema if old kits can't work with the change.
  2. cargo test --workspace. A test fails if the generated files are stale.
  3. cargo run -p fusion-codegen -- --check-languages compiles 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 to fusion::Channel*.
  • C#: var ch = (Channel*)ptr;. Arrays of structs are raw bytes with an accessor: ch->pose.value.bones(i) returns a Transform*.
  • Java: segment.get(JAVA_INT, Channel.OFFSET_HEADER + ChannelHeader.OFFSET_MAGIC). For atomics use JAVA_LONG.varHandle() with getAcquire / setRelease / compareAndSet on the same offsets.

Tests

  • cargo test -p fusion-protocol: unit tests, plus tests/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.rs runs kits/common/java/fusion/kit/SelfTest.java as a guest (skipped without java on PATH): discovery, camera and controls, rings both ways, a teleport, a CPU layer, and a 96 KB mesh through a bulk stream.