๐Ÿ“ฆ about ๐Ÿงป posts

A big part of s&box is calling c++ from c#, and calling c# from c++. So while that's easy enough to do manually for a few functions, using DllImport and all that noise, it's not really that feasible with the amount of stuff we want to bind.

Plus there's downsides to DllImport. The automatic marshalling can be really sub-optimal. When doing this stuff with a game engine everything counts.. so you want to be as bare metal as possible.

InteropGen

We made a tool called InteropGen. It allows you to write some c++ looking code, which then creates a translation layer between c++ and c#. A typical def file looks like this.

include "filesystem/ifilesystem.h"


native accessor g_pFullFileSystem as NativeEngine.FullFileSystem
{
	string GetSymLink( string pPath, string pathID );
	void AddSymLink( string pPath, string pathID, string realPath );
	void ClearSymLinks();

	void AddAddonsSearchPaths( bool addContentPaths );
}

InteropGen will take this and make the global variable g_pFullFileSystem accessible from c#. Like this.

if ( createSymLink )
{
	NativeEngine.FullFileSystem.AddSymLink( entry.Path, "GAME", fileName );
}

On the c# side the code generated looks like this..

		internal static delegate* unmanaged< IntPtr, IntPtr, IntPtr, void > g_pFllFlSystm_AddSymLink;

		internal static void AddSymLink( string pPath, string pathID, string realPath ) 
		{ 
			if ( g_pFllFlSystm_AddSymLink == null ) throw new System.Exception( "Function Pointer Is Null" );
			g_pFllFlSystm_AddSymLink( Sandbox.Interop.GetPointer( pPath ), Sandbox.Interop.GetPointer( pathID ), Sandbox.Interop.GetPointer( realPath ) ); 
		
		}

Where on the c++ side the exported function is as simple as could be..

void g_pFllFlSystm_AddSymLink( const char* pPath, const char* pathID, const char* realPath )
{
	g_pFullFileSystem->AddSymLink( pPath, pathID, realPath );
}

Function Pointers

As you probably noticed, we're using function pointers to exchange functions, rather than some DllImport shit (exporting every function we needed didn't seem like a good idea to me).

To do this, on startup we build an array of all the function pointers and send it over to c#, along with a hash etc to verify that it's all correct.

				&Exports::RenderTools_DrawScreenQuad,
				&Exports::StmGmSrvr_SetServerName,
				&Exports::StmGmSrvr_SetMapName,
				&Exports::StmGmSrvr_SetGameTags,
				&Exports::WindowsGlue_FindFile,
			};

			
			void* imported[50];
			
			fn_initialize init = (fn_initialize) host->CreateDelegate( "Sandbox.Engine", "Sandbox.Interop", "CreateInterface");
			
			init( "engine", 44229, exported, structs, imported );
			
			int i = 0;
			
			Imports::SandboxEngine_Bootstrap_PreInit = (int (CC *)( const char*,int,int,int )) imported[i++];
			Imports::SandboxEngine_Bootstrap_Init = (int (CC *)()) imported[i++];
			Imports::Sandbox_EngineLoop_EnterMainMenu = (void (CC *)()) imported[i++];

Here you can see that the init function is exporting c++ function pointers, and importing c# function pointers. We verify that they're all running from the same version of the def files using a hash.. to allow us to shit the bed if the dlls were compiled at different times.

Structs

While we're doing this we also send the size of any referenced struct sizes. In our def files we don't define the struct's layout or any of its members. It's redundant, it's just a block of memory. If it's the right size and the members are in the right order, it should just work. So structs are just defined like this.

native struct Vector4D is Vector4
native struct QAngle is Angles
native struct Quaternion is Rotation
native struct CTransformUnaligned is Transform
native struct Rect_t is NativeRect
native struct Rect3D_t is Rect3D
native struct AABB_t is BBox
native struct VMatrix is Matrix
native struct RnCapsule_t is Capsule
native struct Vector2D is Vector2

Then in the init code, we send an array of all the struct's sizes. On the c# side we verify that those sizes all match - and shit the bed if they don't.

Inline

Binding classes 1:1 isn't always possible. There's always something that needs casting, or converting, or that's const for no reason. We could change the underlying code, but we added a feature to our interopgen called inline.

#include "hammerapp.h"

native class CHammerApp as NativeHammer.CHammerApp
{
	void OnReloadGameData();
	void RefreshEntitiesGameData();

	inline IMaterial GetCurrentMaterial()
	{
		return this->GetActiveMaterial().GetResourceHandle();
	}
}

This saves so much time and effort.. and means we don't have to try to contort InteropGen with tons of wacky [Attributes] to try to get what we want, which is the route we were going down before we added inline.

Managed Functions

Defining managed static functions are easy..

managed static class Sandbox.Engine.Bootstrap
{
	static bool PreInit( string gameFolder, bool isDedicatedServer, bool isRetail, bool toolsMode );
	static bool Init();
}

And calling them from c++ is very easy

//
// Pre-init kicks off logging and sets up some basic things
// we might be better off passing in pAppDict completely eventually
//
if (!Sandbox::Engine::Bootstrap::PreInit( gamePath.Get(), pAppDict->IsDedicatedServer(), IsRetail(), g_pApplication->IsInToolsMode() ))
{
	Plat_FatalError( "Bootstrap::PreInit returned false" );
}

And regular managed classes are just as easy..

managed class ServerList
{
	void OnStarted();
	void OnServerResponded( void* ptr, ulong steamid );
	void OnFinished();
}

The objects are stored as an int handle in c++.. so they're totally safe and feels nice to use.

#pragma once

class ServerList
{
	public:
		ServerList() { m_ObjectId = 0;  }
		ServerList( unsigned int id ) { m_ObjectId = id;  }
		unsigned int m_ObjectId = 0;
		operator unsigned int() const { return m_ObjectId; }
		unsigned int ptr(){ return m_ObjectId; }
		bool HasObject(){ return m_ObjectId > 0; }
		void OnStarted() const;
		void OnServerResponded( void* ptr,uint64 steamid ) const;
		void OnFinished() const;
};

Reflections

The InteropGen code has evolved over time, and is pretty messy and utilitarian.. but it's one of the big things we got right here. We don't really worry about binding stuff any more unless it's under very specific circumstances.

We could have tried to do fancy things, like using the actual c++ header files and trying to translate those for direct access from c# - but I'm positive that would have been a car crash.. We'd have ended up having to change the c++ headers to work for the interop, instead of the other way around. Plus we don't need to bind everything. The less we bind the better.

We could have tried to use Source Generators to automatically create all this code in the background based on markup we added to c# classes. That would probably work, and could probably be a good way around doing this stuff. But it's ultimately another layer of bullshit on top of something with a very simple goal.

question_answer

Add a Comment

An error has occurred. This application may no longer respond until reloaded. Reload ๐Ÿ—™