SteonMod
View as Markdown

Fusion Unity kit (v0)

Makes a Unity game a Fusion guest. Built in BUILD.md step 7.1 with The Long Dark (Unity 6, IL2CPP, Direct3D 11) as the first game. It shows the game's world inside Fusion, drawn by the game from Fusion's camera with its depth, sends its collision and characters, and answers Fusion's tools (Router Studio, fusion-cli game).

It has two parts:

PartWhereDoes
FusionKit.dll (C#, a MelonLoader mod)mod/Everything that needs Unity's objects: picking the game's cameras, moving the camera that draws the world to Fusion's camera while it renders, reading the picture and depth back, physics traces, finding characters, answering questions through Il2CppInterop's reflection
fusion_unity.dll (Rust)src/Everything else, through the guest SDK (crates/fusion-guest): the Fusion protocol, Fusion's space vs Unity's (src/units.rs), read-back pixels into Fusion's picture (rows, depth in meters), the collision sampler (fusion_guest::sampler, shared with the Unreal kit), entity lists, router files as JSON

The router's router.toml and router.lua sit in the game's UserData\FusionKit\ folder; the kit reads its [block.bind] data and runs its script (docs/router-api.md, "On the Unity kit").

Router scripts

router.lua runs in Lua 5.4 inside fusion_unity.dll (src/script.rs, mlua), with the kit's Router API module (lua/router_api.lua: Router.block and its checks, the sandbox, Game.*). Game.* asks the C# mod (mod/Script.cs) through one callback: an operation and JSON arguments in, a JSON value out. Objects cross as handles the mod keeps alive until Lua's garbage collector lets go of them; o.field, o:Method(...) and statics (Game.static) are Il2CppInterop reflection on the game's own proxies. A call from the kit may take 0.5 s at most (2 s to load), and a script 64 MB; an endless loop is stopped with a message, not a frozen game. A script that doesn't load stops the kit's play (questions are still answered, so Router Studio can fix it). fusion-cli game lua "<code>" --game-id <id> runs a snippet on the live game.

The crate is its own Cargo workspace (kits/unity/Cargo.toml): mlua's Lua 5.4 can't be built together with fusion-lua's Luau. cargo test --manifest-path kits/unity/Cargo.toml runs its tests (the router API with a pretend game, the sandbox, the time limit).

How a frame gets to Fusion

  1. Two cameras (often the same one). The output camera is the one whose picture reaches the screen (bind.camera, or the game's main 3D camera); the kit makes it draw into its own texture at the size Fusion asks for. The view camera draws the world: the output camera if it sees anything, or else the camera the kit sees rendering with the most layers. Some games render a hidden world camera with their own code and composite it (The Long Dark's CameraGlobalRT renders FPSCamera); the kit finds it through Camera.onPreCull.
  2. Camera.onPreCull (the view camera is about to render): the kit moves it to Fusion's camera (place, rotation, field of view). If the game sets the camera's matrices itself, the kit sets them too: Fusion's view, and the game's own projection widened or narrowed to Fusion's field of view (so borders and jitter the game adds stay).
  3. A command buffer on the view camera copies its depth (deferred: the resolved depth buffer; forward: the depth texture) into a float texture while it renders.
  4. End of the frame, on Direct3D 11 when the game and Fusion use the same GPU (the usual case): a command buffer with IssuePluginEvent runs fusion_unity.dll on Unity's render thread after the frame's drawing. It copies both textures into a free one of Fusion's shared layer textures with a small compute shader (rows turned over, Unity's reversed depth into meters) and signals the shared fence (fusion_win::d3d11: the textures are made by a D3D12 device of the kit's own on the game's GPU and opened on the game's D3D11 device, so Fusion opens them like any other kit's). No read-back, nothing waits.
  5. Otherwise (another graphics API, another GPU than Fusion's, or the copy failed; or FUSION_CPU_PICTURES=1 in the game's environment): the kit asks for both textures back (AsyncGPUReadback, no stall), and a few frames later fk_publish turns them into a Fusion CPU picture with depth, stamped with the camera it was drawn from.
  6. Either way the game gets its cameras back as they were, so its own code never sees them moved.

On The Long Dark: the GPU path is 1.0 frame (17 ms) behind Fusion's camera, the read-back 3.7 frames (62 ms); both about 55 pictures a second at 1280x720.

Collision, entities, questions

  • Collision: up to 256 vertical Physics.Raycasts a frame where the shared sampler asks (a 0.5 m grid in 16 m columns around Fusion's camera, nearest first, out to 40 m), triggers left out. Finished columns are sent as Collision Hub chunks.
  • Getting into the world: once the scene is bind.map (if set) and settled, the router's enter/ready decide. When the game's active scene changes later (another region, an interior; not a scene added to it), the kit gets into the world again and picks a new origin.
  • Entities, 4 times a second within 150 m of Fusion's camera: the router's bind.entity rules (a component class name; health / max_health as field paths on it), or with no rules every NavMeshAgent. Their box is their collider's bounds (or their renderer's).
  • Questions (docs/protocol.md): info, classes, objects, inspect, find, test, reload. inspect lists every component of a GameObject with its fields (the proxies' properties), plain values, and the game's own methods (what a router.lua can call).
  • Sleep: Time.timeScale 0, audio paused, the output camera off.
  • Spawn: the router's Lua spawn, or a copy of the bind.entity rule's spawn prefab (a prefab the game has loaded, by name, or a Resources path) on the ground there. Teleport moves the player (bind.player, or the GameObject tagged Player). Actions: the router's Lua actions, or the kit's own remove, set_health, move_to, stop.
  • Focus: Unity pauses an unfocused game; the kit turns Application.runInBackground on and patches its setter (Harmony) so the game can't turn it off again.

Build, install, test

MelonLoader must be in the game (step 1.1) and have run once (it generates the proxy assemblies the mod compiles against).

kits\unity\install.ps1 -Game thelongdark       # game closed; builds fusion_unity.dll and the mod (against the game's files)
# start the game (Steam, or tld.exe --melonloader.hideconsole), then with Fusion closed:
target\debug\fusion-cli.exe host-test --game-id thelongdark --eye 0,1.7,0 --look 0,1.7,-10 --depth-fade 60 --out <dir>
target\debug\fusion-cli.exe router conform routers\thelongdark
target\debug\fusion-cli.exe game info --game-id thelongdark

The kit logs to MelonLoader's console and to UserData\FusionKit\fusion_kit.log, with a report every 10 s (frames, traces, entity lists, frame rate, which cameras rendered how often).

Don't click into MelonLoader's console window while the game runs: Windows' console selection pauses every program writing to it, the game included (--melonloader.hideconsole hides it).

Known limits (v0)

  • GPU pictures on Direct3D 11 only. Direct3D 12 and Vulkan Unity games use the read-back path (their native texture pointers aren't D3D11 textures; the kit checks).
  • The map waits for the game's boot: the kit opens bind.map once the game has left its first scene, or after 30 s in it (The Long Dark stays in its Empty boot scene: 30 s). A map opened from a boot scene never finished loading.
  • The Long Dark's depth doesn't match its picture: it renders its world camera several times a frame with different projections (an SSAO pass with a wider view, among others), and the kit copies the depth of the last one. Conformance passes, with the note that its depth doesn't match its collision.
  • The Long Dark, second session: after a first Fusion session ends, its cameras' field of view reads NaN in the next session in the same game run, and the kit falls back to the weapon camera. Restart the game between Fusion sessions (Fusion starts it per session anyway).
  • If Fusion's window has another shape than the game's, the picture is stretched (the game renders at its own aspect).
  • Collision is a height surface from above, like the Unreal kit's: nothing under roofs and overhangs. Unity's colliders could be read exactly later (primitives, readable meshes, terrain).
  • No possess (Character blocks) and no BepInEx adapter yet.
  • A copy of a prefab may need the game's own spawner to come alive (The Long Dark's wolf prefab copies arrive with their animal part inactive): a router's Lua spawn can call the game's.