Skip to main content

Interop

A YYC exe has no source, headers or debug symbols, but it has something almost as good: every GML script and object event is a named native function, and the runtime has registries of builtins and assets. Lodestone reads all of that from the running game and writes it out as a C# project, so your mod can say Scripts.dealDamage instead of "gml_Script_dealDamage", and the compiler (and IntelliSense) checks the names.

The project is called interop. It is generated per game, on your machine, from your copy of the game.

Where it lives​

<game>\Lodestone\Interop\<Game>.Interop\
<Game>.Interop.csproj
Scripts.g.cs
Objects.g.cs
Builtins.g.cs
Assets.g.cs
codemap.json
.stamp
<game>\Lodestone\Interop\<Game>.vars.json

<Game> is the exe name with every character that is not a letter or digit replaced by _, and leading or trailing underscores trimmed: Dwarf Eats Mountain.exe becomes Dwarf_Eats_Mountain, StoneShard.exe stays StoneShard. (A name that starts with a digit or is a C# keyword gets a leading underscore.) That string is the namespace, and the value you pass to --interop or <InteropGame>.

The generated project is a normal SDK-style project targeting net10.0. It references CoreLoader.dll from ..\..\CoreLoader.dll (the file in Lodestone\) without copying it.

When it regenerates​

On the first launch the loader generates it. After that it only regenerates when something it depends on changed. The .stamp file records:

<exe size>|<exe last-write ticks>|<GML function count>|<builtin count>|<loader version>|f<format>

If the stamp matches, nothing is regenerated and the Loader tab says up to date. The stamp stops matching when:

  • the game exe changes (a patch);
  • the loader's version changes, or its generated-code format number does (so an old interop is rewritten when its shape changes);
  • the variable harvester learned new variables (see below): it deletes the stamp to force a regenerate on the next launch.

You can also press Regenerate interop now in the overlay's Loader tab. The Loader tab shows the current status (up to date, generated in N ms, failed: ...).

Generation waits until the game has really loaded: builtins resolve a moment after startup, and some games load their sprites, rooms and sounds seconds later, so scanning too early would record an empty asset list. It waits until an asset answers, or gives up after about 30 seconds and generates anyway. In that case the result is marked partial and not stamped, so the next launch tries again.

It never freezes the game. What needs the game thread (objects, assets, builtins; tens of thousands of builtin calls in a large game) is collected in slices of a few milliseconds per frame. What does not (scanning code for argument counts, writing several megabytes of source) runs on a worker thread. Generation took 260 ms on Stoneshard and 80 ms on Dwarf Eats Mountain in the last measurement.

What is inside​

Every file starts with a marker that it is generated, and says do not edit:

// <auto-generated>
// Generated by CoreLoader from Dwarf Eats Mountain (2026-09-29 17:11). Regenerated when the game changes; do not edit.
// </auto-generated>
Generated output from the Dwarf Eats Mountain demo; not a source file in the repository.

Scripts.g.cs​

One static field per script in the game, in a static class Scripts. Each is a ScriptRef you can call or hook:

/// <summary><c>gml_Script_dealDamage</c> - reads 6 arguments</summary>
public static readonly global::CoreLoader.ScriptRef6 dealDamage = new("gml_Script_dealDamage");
Generated output from the Dwarf Eats Mountain demo; not a source file in the repository.

A ScriptRef has:

MemberWhat it does
Symbol, Exists, AddressThe full symbol, whether the running game has the script, and its address. Resolution is lazy: a script removed by a game update fails when it is used, with its name in the error
Call(params RValue[])Calls the script as the current context
CallAs(Instance self, Instance other, params RValue[])Runs it as an instance
CallAs(InstanceRef self, params RValue[])Runs it as the instance an InstanceRef names (self and other both); the form to use for an instance you held across frames
Before(HookHandler), After(HookHandler)Hooks it; returns a HookHandle (dispose to unhook)

Where the argument count can be read from the compiled code, the field is a typed variant (ScriptRef1 to ScriptRef8) that adds an Invoke with exactly that many parameters, so a call with the wrong number of arguments is a compile error. Otherwise the field is a plain ScriptRef and you use Call(params). For example Scripts.scr_get_XP in Stoneshard is a ScriptRef1 and Scripts.scr_loot a plain ScriptRef.

Names that are not valid C# identifiers are sanitised, C# keywords get an @ prefix, internal names containing @ are left out, and a name that would collide with another member is skipped. The full symbol is always in the field's initialiser, so use Symbol when in doubt.

How argument counts are inferred​

The runtime does not record how many arguments a GML script takes, so the loader reads it from the machine code. YYC compiles every read of an argument as a guard on argc followed by a load from the argument array: argc > j ? args[j] : undefined becomes a compare of the argc register, a conditional jump, then mov r64, [argsReg + 8*j]. The largest such j, plus one, is how many arguments the script reads. The scan stops at the next function's entry, so a small script never inherits its neighbour's arguments.

Two limits follow from that. A script that indexes arguments dynamically (argument[i]) shows no guards, reports 0, and stays untyped. And the count is how many arguments the script reads, not necessarily how many it is documented to take. The scan was checked against known scripts on both runtimes (scr_approach 3, scr_atr_set 2, dealDamage 6, selectDrops 4, key_to_string 1). In Dwarf Eats Mountain 366 of 486 scripts got a typed Invoke; only counts 1 to 8 are typed.

The implementation is in CodeScan.cs.

Objects.g.cs​

A static class Objects, with one nested static class per object in the game. Each contains:

public static class oSys
{
public const string Name = "oSys";
/// <summary>The object by name, resolved in the running game (null if it no longer exists).</summary>
public static global::CoreLoader.GmlObject? Object => global::CoreLoader.GmlObject.Find(Name);
/// <summary>The first live instance, or null when there is none.</summary>
public static global::CoreLoader.InstanceRef? First => Object is { InstanceCount: > 0 } o ? o.Instance(0) : null;
// ...
}
Generated output from the Dwarf Eats Mountain demo; not a source file in the repository.
  • Name, the object's name.
  • Object, a GmlObject looked up by name each time (cached), so an object index that shuffles between game versions is harmless.
  • First, an InstanceRef for the first live instance, or null when there is none.
  • Vars, a class of string constants for the variables seen on live instances (next section).
  • One EventRef per event the object has, named <EventType>_<number>:
public static readonly global::CoreLoader.EventRef Step_0 = new("gml_Object_oMiner_Step_0");
Generated output from the Dwarf Eats Mountain demo; not a source file in the repository.

Event names follow GameMaker's own symbol names: Create_0, Destroy_0, Step_0, Step_1, Alarm_0, Draw_0, Other_20 and so on. The number is GameMaker's event number for that type (the user events are the Other_ ones). An EventRef has Symbol, Exists, Address, Call(Instance self, Instance other = default), Before and After.

An object whose name is Name, Object, First or Vars has an underscore appended, since those members are taken.

InstanceVars (a sibling of Objects) lists GameMaker's built-in instance variables (x, y, object_index, sprite_index, depth, alarm and so on). Every harvested Vars class repeats them, because variable_instance_get_names does not list built-ins and the harvest would never see them.

Builtins.g.cs​

A static class Builtins with a wrapper for every GML builtin, using the argument count this game's runtime actually registers:

/// <summary><c>irandom</c> (1 argument)</summary>
public static global::CoreLoader.RValue irandom(global::CoreLoader.RValue a0) => global::CoreLoader.Game.CallBuiltin("irandom", a0);
Generated output from the Dwarf Eats Mountain demo; not a source file in the repository.

Variadic builtins take params RValue[]. The point of using the registry rather than the documentation is that arities differ between runtimes (see runtime differences): a wrapper that compiles is a call the runtime accepts.

Assets.g.cs​

String constants for sprite, room and sound names, in nested classes Assets.Sprites, Assets.Rooms and Assets.Sounds, for use with asset_get_index and friends. Sprites and sounds that mods added at runtime are not included.

codemap.json​

The same information plus addresses and variables, as JSON for tools: the game name, every function with its address, every script with its argument count, every object with its index, events and variables, builtins with arity, and the sprite, room and sound lists. Browsing it is often the fastest way to see what a game has; see Finding hooks.

Variables​

A YYC exe does not record which variables an object has; they come into being as its code assigns them. So the only reliable source is live instances. The variable harvester walks the object table a slice per frame (about 1.5 ms of budget), reads the variable names from each object's first live instance, and accumulates them across sessions in Lodestone\Interop\<Game>.vars.json. A full pass takes place about once a minute.

When it learns something new it saves the file and marks the interop stale, so the next launch (or Regenerate interop now) emits Objects.<obj>.Vars.<name> for it. In Dwarf Eats Mountain it learned 2,020 names in the first minute of play.

Consequences:

  • Vars only contains names the harvester has seen. Play the game, and visit the area that has the object, before expecting a constant to exist. If it is missing, use the string name.
  • Objects that were never seen live have no Vars class (only InstanceVars applies).
  • vars.json is plain JSON, kept across interop regenerations: the harvest keeps adding to it.

Read and write a variable through an InstanceRef's indexer, with the constant:

if (Objects.oSys.First is { } sys)
UI.Text($"gold (oSys.gold): {sys[Objects.oSys.Vars.gold].AsReal:N0}");
Source: managed/Examples/InteropExample/InteropExample.cs

Referencing it​

From a mod made with the template​

Pass --interop <Game_Interop_Namespace> to dotnet new coreloader-mod (see Getting started). The template adds this to the csproj:

<ProjectReference Include="$(CoreLoaderDir)\Interop\INTEROP_PLACEHOLDER.Interop\INTEROP_PLACEHOLDER.Interop.csproj" />
Source: managed/Templates/CoreLoaderMod/MyMod.csproj

Where the placeholder has been replaced by your interop name, and CoreLoaderDir is <game>\Lodestone. The project builds the interop dll next to your mod, and the deploy target copies it to <game>\Mods\ with your dll. The mod's using <Game_Interop_Namespace>; then gives you Scripts, Objects, Builtins and Assets.

A mod compiled against an interop must declare [CoreModGame("<that game>")] (CL0005 checks it). When the interop dll is rebuilt, hot reload reloads the mods that loaded it.

From a project in this repository​

Name the game with InteropGame and let managed\Directory.Build.targets find it:

<InteropGame>Dwarf_Eats_Mountain</InteropGame>

It searches <game>\Lodestone\Interop\<InteropGame>.Interop\ under every folder in CoreLoaderGameDirs (from CoreLoader.user.props, written by tools\setup-dev.ps1, or from the CORELOADER_GAME_DIRS environment variable). If it is not found the project still loads and builds, with nothing compiled and a warning that says what to run. -p:InteropProject=<path> overrides the search. See Working in this repository.

Walking through InteropExample​

InteropExample is written against the Dwarf Eats Mountain interop.

using CoreLoader;
using Dwarf_Eats_Mountain; // the generated interop for Dwarf Eats Mountain

[assembly: CoreModInfo(typeof(InteropExample.InteropExampleMod), "Interop Example", "1.0.0", "Lodestone")]
[assembly: CoreModGame("Dwarf Eats Mountain")]
Source: managed/Examples/InteropExample/InteropExample.cs

The namespace import is the whole reference. The mod names the game it is for, because an interop names one game's scripts, objects and assets and cannot work anywhere else.

public override void OnInitialize()
{
// A script hook through the generated ref...
Scripts.dealDamage.After(_ => _hits++);
// ...and an object event hook, Objects.<object>.<Event>_<n>.
Objects.oMiner.Step_0.Before(_ => _unitSteps++);
Log.Info("hooked dealDamage and oMiner.Step_0 through the generated interop");
}
Source: managed/Examples/InteropExample/InteropExample.cs

Scripts.dealDamage.After(...) hooks a script and Objects.oMiner.Step_0.Before(...) hooks an event, both with the same HookHandler. Neither lambda uses its HookCall, and neither keeps it, which is what Concepts requires. If dealDamage did not exist, the mod would not compile.

// A typed builtin wrapper: the arity comes from this game's own registry.
if (UI.Button("Builtins.irandom(6) + 1"))
_status = $"rolled {Builtins.irandom(6).AsReal + 1}";
Source: managed/Examples/InteropExample/InteropExample.cs

Builtins.irandom(6) passes one argument because this runtime registers irandom with one. The RValue result is read within the frame, so the pool covers its lifetime.

// Scripts whose argument count was read from their code get a typed
// Invoke: key_to_string reads exactly one argument.
if (UI.Button("Scripts.key_to_string.Invoke(32)"))
_status = $"key 32 is {Scripts.key_to_string.Invoke(32)}";
Source: managed/Examples/InteropExample/InteropExample.cs

Scripts.key_to_string is a ScriptRef1, so Invoke takes exactly one RValue; 32 converts implicitly.

Interop or strings?​

Use interop when you write a mod for one particular game: you get compile-time checking and IntelliSense over thousands of names. Use string symbols (Hooks.Before("scr_get_XP", ...), Game.CallScript, GmlObject.Find) for a mod that has to work in any game, such as the Console or ScriptSpy, and for a name the interop does not have (a variable the harvester has not seen). Both can live in one mod.