-
Notifications
You must be signed in to change notification settings - Fork 1
Development quickstart
Looking to make your own external tool? All you need is a bit of C# experience and the following:
- .NET Core SDK 6.0
- on Linux: usually
dotnet-sdk-6.0
, full instructions on MSDN - on Windows: comes with VS2022, or can be downloaded separately, full instructions on MSDN
- on Linux: usually
- A C#/.NET IDE
- On Windows, Microsoft's Visual Studio 2022 is recommended for working with BizHawk. The Community edition is free for anyone with a Microsoft account.
- On any platform, JetBrains Rider (requires license / product key) or VS Code may also be used.
- You can theoretically use any IDE or text editor, vi for example, and build using the dotnet CLI. This is a bad idea for beginners.
- BizHawk
- Building from source is optional, you only need the pre-built assemblies. 2.6.2 can be downloaded here.
The rest of this guide is aimed at 2.6.2. You may be able to follow along while referencing an older version, but this is not recommended.
Note: I haven't had time to double-check this guide against 2.8, but it should be identical modulo a few changes to method signatures. —Yoshi
Jump to:
(Looking for the changelogs? They've moved here.)
First, you need a new folder with a project file in it. Let's call the tool MyTool
—create/move/rename stuff until it looks like the below:
Note for Windows users: If file extensions are hidden in File Explorer (as is the default) then you may end up with a file called
MyTool.csproj.txt
, which won't work.
MyTool
├─ BizHawk
│ ├─ dll
│ │ ├─ BizHawk.Common.dll
│ │ └─ ... (other stuff)
│ ├─ EmuHawk.exe
│ └─ ... (other stuff)
└─ src
└─ MyTool.csproj
Write this to MyTool.csproj
(this will not be explained in this guide):
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<LangVersion>9.0</LangVersion>
<Nullable>enable</Nullable>
<TargetFramework>net48</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.0" PrivateAssets="all" />
<Reference Include="System.Windows.Forms" />
<Reference Include="BizHawk.Client.Common" HintPath="$(ProjectDir)../BizHawk/dll/BizHawk.Client.Common.dll" />
<Reference Include="BizHawk.Client.EmuHawk" HintPath="$(ProjectDir)../BizHawk/EmuHawk.exe" />
<Reference Include="BizHawk.Common" HintPath="$(ProjectDir)../BizHawk/dll/BizHawk.Common.dll" />
<Reference Include="BizHawk.WinForms.Controls" HintPath="$(ProjectDir)../BizHawk/dll/BizHawk.WinForms.Controls.dll" />
</ItemGroup>
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
<Copy SourceFiles="$(OutputPath)$(MSBuildProjectName).dll" DestinationFolder="$(ProjectDir)../BizHawk/ExternalTools" />
</Target>
</Project>
If you intend to use Git, turn MyTool
into a repo now. Add /src/bin
and /src/obj
to .gitignore
. You can also add /BizHawk
if you don't want to check that in.
You can now open MyTool.csproj
in your IDE and let it build up various caches. Though there are no source files in your project, you should be able to build an empty assembly.
On Linux, you can use this simple shell script that does both steps (write this to MyTool/build_and_run.sh
and chmod +x
):
#!/bin/sh
set -e
cd "$(dirname "$0")/src"
dotnet build
../BizHawk/EmuHawkMono.sh --mono-no-redirect --open-ext-tool-dll=MyTool
On Windows, build with your IDE, or from the command-line with dotnet build
(in MyTool\src
). Then, run with .\EmuHawk.exe --open-ext-tool-dll=MyTool
(in MyTool\BizHawk
).
Note: If you build and run now, EmuHawk should open with no extra window. After the next step, doing the same should open EmuHawk and an extra window.
The way EmuHawk finds ext. tools is by going through each assembly (.dll
file) in the ExternalTools
dir, and looking for classes which inherit the interface IExternalToolForm
and have the [ExternalTool]
attribute.
using BizHawk.Client.Common;
namespace Net.MyStuff.MyTool
{
[ExternalTool("MyTool")] // this appears in the Tools > External Tools submenu in EmuHawk
public sealed class MyToolForm : IExternalToolForm
{
// ...
}
}
That's technically the only requirement, but because it's inconvenient to reimplement things, there are helpers for you to use. First of all, you can create a window with WinForms, the UI framework used in EmuHawk, and it will be recognised.
using System.Windows.Forms;
using BizHawk.Client.Common;
namespace Net.MyStuff.MyTool
{
[ExternalTool("MyTool")]
public sealed class MyToolForm : Form, IExternalToolForm
{
// ...
}
}
If you've copied this into your IDE you'll have noticed that this is still missing implementations for IExternalToolForm
.
We have a class ToolFormBase
which provides default implementations, and does a few other things:
using BizHawk.Client.Common;
using BizHawk.Client.EmuHawk;
namespace Net.MyStuff.MyTool
{
[ExternalTool("MyTool")]
public sealed class MyToolForm : ToolFormBase, IExternalToolForm
{
protected override string WindowTitleStatic => "MyTool"; // required when superclass is ToolFormBase
// ...
}
}
ToolFormBase
inherits Form
so it still participates in WinForms.
The rest of this guide assumes that your tool inherits just
ToolFormBase
andIExternalToolForm
, as in the above example. You are free to add interfaces as necessary, but if you change superclass, members ofToolFormBase
will obviously not be usable. The most common problem will be a missing "update" method. You can reimplement these, or put the code inUpdateValues
, see the source forToolFormBase.UpdateValues
.
Whichever superclass you pick, WinForms UI elements can be created programmatically:
using System.Drawing;
using System.Windows.Forms;
using BizHawk.Client.Common;
using BizHawk.Client.EmuHawk;
namespace Net.MyStuff.MyTool
{
[ExternalTool("MyTool")]
public sealed class MyToolForm : ToolFormBase, IExternalToolForm
{
protected override string WindowTitleStatic => "MyTool";
public MyToolForm()
{
ClientSize = new Size(480, 320);
SuspendLayout();
Controls.Add(new Label { AutoSize = true, Text = "Hello, world!" });
ResumeLayout();
}
}
}
...or using a WYSIWYG editor in VS or Rider:
using System.Windows.Forms;
using BizHawk.Client.Common;
using BizHawk.Client.EmuHawk;
namespace Net.MyStuff.MyTool
{
[ExternalTool("MyTool")]
public sealed partial class MyToolForm : ToolFormBase, IExternalToolForm
{
protected override string WindowTitleStatic => "MyTool";
public MyToolForm()
{
InitializeComponent(); // defined in MyToolForm.Designer.cs, an auto-generated file made by the WinForms Designer
}
}
}
And note that either way, Control
s must be assigned to fields if you want to read from / write to them. This includes in Restart()
(read on).
You may find the Control
s in the BizHawk.WinForms.Controls
assembly useful when designing your UI, especially when trying to create "responsive" layouts programmatically.
No further help with WinForms will be given in this guide.
If you have experience with the Lua API, you may be wondering where the main frameadvance
loop is. Initialisation code, anything that would be before the main loop in Lua, goes in either the constructor or Restart
, and code which should run once per frame goes in UpdateAfter
:
public MyToolForm() // this is the constructor for the MyToolForm class
{
// executed once ever
}
public override void Restart()
{
// executed once after the constructor, and again every time a rom is loaded or reloaded
}
protected override void UpdateAfter()
{
// executed after every frame (except while turboing, use FastUpdateAfter for that)
}
The paradigm shifts from Lua (apart from the language differences) are knowing where to put initialisation code (can't use APIs from the constructor, read on) and, assuming you're porting gui.*
to WinForms, reworking UI to be event-based.
As of 2.5.2, the full execution order when loading an ext. tool is:
Activator.CreateInstance
/ default constructor,Form.Owner
prop setter,ApiInjector.UpdateApis
([RequiredService]
/[OptionalService]
props),ServiceInjector.UpdateServices
([RequiredApi]
/[OptionalApi]
props), setters of props inherited fromToolFormBase
,IToolFormAutoConfig
stuff,[ConfigPersist]
stuff,Form.Restart
,Form.Show
TODO double-check this in 2.6.2
Along with [ExternalTool]
, you can apply one of the [ExternalToolApplicability.*]
attributes to disallow your tool from being used with systems or roms it wasn't intended for. There's also [ExternalToolEmbeddedIcon]
for displaying an icon in the External Tools menu—check the HelloWorld tool source for how to use that.
There are over a dozen APIs available, mostly the same as in Lua. In .NET, these are represented as interfaces.
EmuHawk will try to pass your tool an object which inherits the API interface when you apply the [OptionalApi]
attribute to a mutable property:
[OptionalApi]
public IGameInfoApi? _maybeGameInfoAPI { get; set; }
public override void Restart()
{
lblLoadedGame.Text = _maybeGameInfoAPI?.GetRomName() ?? "(no game info available)"; // use your IDE to explore the APIs, or check BizHawk's source
// where lblLoadedGame is a field of type Label, initialised in the constructor
}
If you instead use the [RequiredApi]
attribute, EmuHawk will refuse to load your tool if it fails to fulfil the API. That only happens when something goes wrong, so it's easier to just assume everything is working:
[RequiredApi]
public IGameInfoApi? _maybeGameInfoAPI { get; set; }
private IGameInfoApi GameInfoAPI => _maybeGameInfoAPI!;
// you could also inline this, using the `!` operator everywhere, just remember that a null value means the constructor hasn't finished (these props are set after the ctor) or something has gone wrong
public override void Restart()
{
lblLoadedGame.Text = GameInfoAPI.GetRomName();
}
And doing this for every API you want to use would be annoying, so to save you the trouble there's an ApiContainer
which holds all of them:
public ApiContainer? _maybeAPIContainer { get; set; }
private ApiContainer APIs => _maybeAPIContainer!;
public override void Restart()
{
lblLoadedGame.Text = APIs.GameInfo.GetRomName();
}
In case you missed it:
[RequiredApi]
/[OptionalApi]
properties don't get an object assigned to them until after the constructor has finished. The same is true ofApiContainer
properties. UncaughtNullReferenceException
s in the ctor are probably because you overlooked this. If you need to make API calls to initialise your UI, simply do that inRestart
.
The purpose of this tool is to automatically create savestates at the start of each level while playing Super Mario Bros. (SMB1).
using System.Drawing;
using System.Windows.Forms;
using BizHawk.Client.Common;
using BizHawk.Client.EmuHawk;
namespace Net.MyStuff.SMBAutosaveTool
{
[ExternalTool("SMB Autosave")]
[ExternalToolApplicability.RomWhitelist(
CoreSystem.NES,
"EA343F4E445A9050D4B4FBAC2C77D0693B1D0922", // U
"AB30029EFEC6CCFC5D65DFDA7FBC6E6489A80805")] // E
public sealed class SMBAutosaveToolForm : ToolFormBase, IExternalToolForm
{
public ApiContainer? _maybeAPIContainer { get; set; }
private readonly Label _lblLevel;
private string _prevLevel = "1-1";
private int? _prevSlot = null;
private ApiContainer APIs => _maybeAPIContainer!;
protected override string WindowTitleStatic => "SMB Autosave";
public SMBAutosaveToolForm()
{
ClientSize = new Size(480, 320);
SuspendLayout();
Controls.Add(_lblLevel = new Label { AutoSize = true });
ResumeLayout();
}
private string ReadLevel()
{
var bytes = APIs.Memory.ReadByteRange(0x075C, 9);
if (bytes[8] == 0 || bytes[8] == 0xFF) return _prevLevel; // in the main menu
return $"{bytes[3] + 1}-{bytes[0] + 1}";
}
public override void Restart()
{
_prevLevel = "1-1"; // ReadLevel returns this when in the main menu, need to reset it
_lblLevel.Text = $"You are in World {ReadLevel()}";
APIs.EmuClient.StateLoaded += (_, _) => _prevLevel = ReadLevel(); // without this, loading a state would cause UpdateAfter to save a state because the level would be different
}
protected override void UpdateAfter()
{
var level = ReadLevel();
if (level == _prevLevel) return; // no change, short-circuit
// else the player has just gone to the next level
var nextSlot = ((_prevSlot ?? 0) + 1) % 10;
APIs.SaveState.SaveSlot(nextSlot);
_lblLevel.Text = $"You are in World {level}, load slot {nextSlot} to restart";
if (_prevSlot != null) _lblLevel.Text += $" or {_prevSlot} to go back to {_prevLevel}";
_prevSlot = nextSlot;
_prevLevel = level;
}
}
}
If you build and run that as-is, you might notice that it doesn't change which slot is selected for quick-loading (as shown in the status bar). That's (currently) not exposed in any of the APIs, which means you'll need to resort to hacks.
If your tool form inherits FormBase
or ToolFormBase
, it has a Config
property which holds the global config. Using that, you can read the value of every config option, and even write some of them.
For example, writing Config.SaveSlot
will let the autosave tool change which slot the quick-load hotkey uses. But it won't update the status bar, which brings us to...
If your tool form inherits ToolFormBase
, it has a MainForm
property that you can use to do some miscellaneous things with EmuHawk. One of these is the IMainFormForTools.SetMainformMovieInfo
method, which will update the status bar, but we can do better. This is where the hacks really begin. By casting MainForm
(the property) to MainForm
(the type), more methods are accessible. The one we want is UpdateStatusSlots
, which is called by SetMainformMovieInfo
to update just the save slot display.
With these two hacks, the SMB autosave is fully-functional.
protected override void UpdateAfter()
{
// ...
APIs.SaveState.SaveSlot(nextSlot);
Config!.SaveSlot = nextSlot;
((MainForm) MainForm).UpdateStatusSlots();
_lblLevel.Text = $"You are in World {level}, load slot {nextSlot} to restart";
// ...
}
And finally, if there's some EmuHawk functionality that you can't find in any of the ToolFormBase
props, you can try accessing their private
methods by reflection. That's out of the scope of this guide, but it would look something like this:
typeof(MainForm).GetMethod("TakeScreenshotClientToClipboard")!.Invoke(MainForm, new object?[0]);