Jae's Blog

Headless road to ARM: status

Being currently assigned to the issue about ARM support for Resonite headlesses (GH-2555), time for an update since there hasn’t been one in some time.

First off, everything is looking great, current status being:

  • 6 PRs are currently open (FreeImage, Opus, Crunch, Assimp, MSDFGen, RNNoise)
  • 1 PR has been merged šŸŽ‰ (Brotli)
  • 1 repo is missing (Freetype)

This marks the first ARM-related PR being reviewed and merged into an official repository, being the PR #1 on the Brotli repo, which bundled Windows, Linux x64 and Linux ARM CI/CD builds.

As a reminder, I am currently providing a complete package of all libraries built directly for ARM on my website.

Next steps would be to:

  • Get an official fork of the Freetype repository (requested on 2025/04/09)
  • Create a container image bundling my libraries and a way to download the headless easily on ARM machines
  • Get all the current PRs reviewed and merged

The second one is more important as distribution is probably the biggest issue for complete ARM support of the headless, SteamCMD not supporting this architecture.

I am very confident to say that we will reach official support very soon, given how well this has been going so far.

My plans after this feature is shipped is to work on the macOS support (GH-1412) as it’s also marked as ā€œcommunity help wantedā€.

Resostats outage postmortem

Today, from approximately 16:30 UTC to 17:45 UTC, the Resostats Dashboard which provides various public metrics on Resonite was offline.

Background

Routine maintenance was being done on the machine hosting Resostats, namely updating the packages, containers, cleaning up some debugging tools.
Configuration changes were committed to try and have the TSDB sync faster to the S3 storage bucket that backs the whole instance.

Metrics stored on the Mimir instances do not have any set expiration.

The S3 bucket itself is fully replicated and backed up using Restic in multiple places, including rsync.net as an external one.

The cause

While committing changes to the mimir configuration, the compactor_blocks_retention_period configuration key was swapped from 0 to 12h.

The compactor_blocks_retention_period configuration key in mimir specifies the retention period for blocks. Anything older than the set amount will get marked for deletion, then cleaned up.
You can read more about this in the official mimir configuration documentation.

This prompted the mimir instances to start marking blocks older than 12h for deletion, thus cleaning inadvertently years of historical data.

Restoration

The error in the configuration was quickly spotted and corrected, but the blocks already marked for deletion were already being cleaned up regardless.
Given the backup hosted on rsync.net was the closest and fastest for this available server, the decision was taken to restore everything from there.

The restoration process was easy enough, given Restic provides a nice command for this:

$ restic  --password-file=/sec/pass -r sftp:user@user.rsync.net:bck/st-fi/mimir-blocks restore latest --target /srv/pool/mimir-blocksCode language: Bash (bash)

Most of the time spent was the stressful wait for the backup to be downloaded onto the machine.

In the end, about 12h of metrics were lost, which is not that much considering the scale of the outage.

Learnings

From now on, a backup will be done before starting any maintenance.
The current backup strategy has also been proven robust enough to withstand an event like this one.

Turns out having a proper backup strategy is damn effective.

The state of IPv6 in Resonite

Given sessions in Resonite are hosted by the players themselves, IPv6 is very useful in this context as there are no needs to battle with CGNAT or other network shenanigans and restrictions ISPs might put in place to save up on IP space.

As full native IPv6 support is currently being worked on (see GH-143 for a more in-depth status), some parts already do support it.

This is the case for:

The main website:

; <<>> DiG 9.20.4 <<>> AAAA resonite.com
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 42354
;; flags: qr rd ra; QUERY: 1, ANSWER: 7, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 512
;; QUESTION SECTION:
;resonite.com.                  IN      AAAA

;; ANSWER SECTION:
resonite.com.           300     IN      AAAA    2606:4700:3030::6815:4001
resonite.com.           300     IN      AAAA    2606:4700:3030::6815:2001
resonite.com.           300     IN      AAAA    2606:4700:3030::6815:5001
resonite.com.           300     IN      AAAA    2606:4700:3030::6815:6001
resonite.com.           300     IN      AAAA    2606:4700:3030::6815:1001
resonite.com.           300     IN      AAAA    2606:4700:3030::6815:3001
resonite.com.           300     IN      AAAA    2606:4700:3030::6815:7001

;; Query time: 16 msec
;; SERVER: 192.168.1.1#53(192.168.1.1) (UDP)
;; WHEN: Tue Jan 14 17:44:41 EET 2025
;; MSG SIZE  rcvd: 237Code language: CSS (css)

The assets server:

; <<>> DiG 9.20.4 <<>> AAAA assets.resonite.com
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 41689
;; flags: qr rd ra; QUERY: 1, ANSWER: 7, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 512
;; QUESTION SECTION:
;assets.resonite.com.           IN      AAAA

;; ANSWER SECTION:
assets.resonite.com.    300     IN      AAAA    2606:4700:3030::6815:4001
assets.resonite.com.    300     IN      AAAA    2606:4700:3030::6815:3001
assets.resonite.com.    300     IN      AAAA    2606:4700:3030::6815:7001
assets.resonite.com.    300     IN      AAAA    2606:4700:3030::6815:2001
assets.resonite.com.    300     IN      AAAA    2606:4700:3030::6815:1001
assets.resonite.com.    300     IN      AAAA    2606:4700:3030::6815:5001
assets.resonite.com.    300     IN      AAAA    2606:4700:3030::6815:6001

;; Query time: 16 msec
;; SERVER: 192.168.1.1#53(192.168.1.1) (UDP)
;; WHEN: Tue Jan 14 17:44:12 EET 2025
;; MSG SIZE  rcvd: 244Code language: CSS (css)

The API:

; <<>> DiG 9.20.4 <<>> AAAA api.resonite.com
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 1091
;; flags: qr rd ra; QUERY: 1, ANSWER: 3, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 512
;; QUESTION SECTION:
;api.resonite.com.              IN      AAAA

;; ANSWER SECTION:
api.resonite.com.       91      IN      CNAME   skyfrost-api.azurewebsites.net.
skyfrost-api.azurewebsites.net. 120 IN  CNAME   waws-prod-am2-757.sip-v4andv6.azurewebsites.windows.net.
waws-prod-am2-757.sip-v4andv6.azurewebsites.windows.net. 91 IN AAAA 2603:1020:206:6::24

;; Query time: 26 msec
;; SERVER: 192.168.1.1#53(192.168.1.1) (UDP)
;; WHEN: Tue Jan 14 17:46:08 EET 2025
;; MSG SIZE  rcvd: 183Code language: CSS (css)

The site status.yellowdogman.com also has out-of-the box IPv6 support.

Some others have to be setup manually (as per GH-3232) via the hosts file or other user-run DNS servers, with:

  • wiki.resonite.com
  • metrics.yellowdogman.com
  • go.resonite.com

To access those domains over IPv6, you will need to amend your /etc/hosts or C:\Windows\System32\drivers\etc\hosts with the following:

[2a01:4f9:2b:2b4a::2] wiki.resonite.com
[2a01:4f9:2b:2b4a::2] metrics.yellowdogman.com
[2a01:4f9:2b:2b4a::2] go.resonite.com
Code language: CSS (css)

Edit: those AAAA records are now added!

Joining any session using a network string like lnl://[<IPv6 address>]:<port>/ is already supported, which only leaves the relays and bridges needing IPv6 support, a mod made by a community member already existing to solve this issue until official support is added.

In the end, I’m very confident that we will see full native IPv6 support land in Resonite this year, if not already in Q1, given this is actively being worked on.

Once those those two issues (relays and bridges + AAAA records missing) are addressed, the only thing missing IPv6 will be… the bug tracker, GitHub, which I already talked about in this article (spoiler, we ain’t seeing IPv6 from them anytime soon).

Also special thanks to ProbablePrime for looking into it!

The making of the Resonite sessions Discord bot

If you are a Resonite player and are in the Discord guild, you might be familiar with the #active-sessions channel, in which a bot displays the 10 most popular sessions on Resonite as well as some stats.

Screenshot of Discord showing game stats as well as an embed for a single session at the bottom.

What you might not know, is that I’m the author of this bot, that I originally started as just a small oneshot project to have some fun.

For some background, when Neos was still a thing (technically still is, but in what state), a bot like this was in the Discord guild, showing sessions and game stats like server metrics and session counts.

When Resonite was released, the channel was there, however, no metrics or posts were ever made, saying that the bot would be revived at some point in the future(TM).

At the time, I was a bit bored and wanted a new project, so I decided to start experimenting with .NET Discord bots and in term set myself the objective to re-create the original bot.

Why .NET? One reason being that I use .NET all the time as it’s fairly easy to use, the other one being that most of the Resonite community also knows .NET due to being used to make mods and whatnot.

The bot itself is fairly simple and divided in multiple parts built around the .NET Hosted Services:

  • Discord client service – handles the connectivity to Discord
  • Interaction service – provides the command handler for the few commands the bot has
  • Startup service – sets up some bot must-haves like the logger and other data
  • Message service – the core functionality of the bot that runs all the logic that makes the magic happen
  • Healthcheck service – this one is optional, but important when hosting the bot in Docker

Let’s go through all of those in detail and see why they were made that way.

I’m gonna group those together as they belong to the same component really: handling Discord-related shenanigans.

The bot has a few commands:

  • /info which shows some info about the bot
  • /setchannel which sets the channel in which you want the notifications
  • /which which shows which channel is set as the notification one
  • /unregister which unsets the notification channel
  • /setting allows you to toggle some settings like if you want thumbnails or not

All of those commands (with the exception of /info) are admin-only.

This part is honestly fairly boring and straightforward, the rest just passes a Discord bot token, connects to the chat service and tries to log in.

        await _client.LoginAsync(TokenType.Bot, token);
        await _client.StartAsync();
Code language: C# (cs)

The message service

Now this is the juicy stuff.

This part handles:

  • Retrieving the info from the Resonite API
  • Formats it neatly into a message
  • Generates embeds from the session info
  • Storing which messages were sent where in a DB
  • Checking if messages can be updated
  • Updating said messages with the new data

First off, the bot uses a relatively simple SQLite DB to avoid everything being hardcoded. The first versions were using direct channel IDs, but this is far from ideal if you want something modular without having to host multiple copies.

The DB basically stores the guild ID, channel ID and settings for the bot to send the updates in the right place, containing what we want.

Speaking of settings, there is only one so far: show the session thumbnails or not. The reason for this is a difference between the Discord & Resonite ToS. While nipples aren’t considered sexual on Resonite, they are on Discord, meaning having a session thumbnail showing nipples on Discord without gating the channel to 18+ users would be violating the ToS of the platform.

One thing I am quite proud of is how stable the bot it, nowadays it rarely, if ever, crashes alone, which it used to do quite often.

The bot is made to handle errors gracefully and never shut down or crash the program unless something really, really warrants it. When running this bot, all the errors that would normally crash it are instead logged with an error message and stacktrace to make it easy to debug.

Another thing to note is that the database schema hasn’t been updated since the bot basically released and touching it is considered a very last resort thing. Having the DB break during an upgrade would be disastrous, requiring all admins to re-set the notifications channel. As they say, if it ain’t broken, don’t fix it.

Out of all the variables in the mix, Discord is the most unstable one, having lots of outages, sometimes lasting hours at a time, just being slow for no reason whatsoever or thinking existing things (such as channels or messages) don’t exist even though they do.

This is why the whole checking logic exists, it will first check if the channel exists, if it does will check if the message exists, and if it does, try to update it. If it fails at any point, it will try again for a while then delete the message, try to re-send it, and ultimately, if this fails, delete the channel from the DB and the admin will have to re-set the notification channel again.

The re-try was implemented after some issues raised from Discord:

  • The bot would forget about the message (because Discord said it didn’t exist anymore) and would send a second one in the same channel or over and over until restarted
  • Sometimes the checks would fail on first try and delete everything gracefully without notifying anybody
  • Bot would crash because everything ā€œceasedā€ to exist

On the Resonite side, if an error happens while contacting the API, the bot will just skip this cycle and try updating the next time (one minute by default). This used to crash the bot (whoops) in the early days.

The latest addition made was the Docker healthcheck, given recently the bot crashed in the main Resonite guild (GH-3521) and no monitoring was triggered.

Now the bot has a small HTTP server running, simply returning the date that a curl instance will check every 30 seconds.

The CI/CD

It’s no secret that I love GitLab as a software. I also work daily on and with it in my day-to-day job.

The CI/CD used in this project is extensive, but classic:

  • Secret detection
  • SAST scan
  • Dependency scanning
  • NuGet build
  • NuGet deployment
  • ARM64 and x86 Docker builds
  • Release publishing

The first three are kinda explicit, and will warn if any secrets have been pushed, if any unsafe codepaths are detected or if any dependencies needs updating.

Now the most important thing to highlight are the separated Docker builds for the two architectures. I originally tried combining the builds into a single one as you would do by specifying multiple architectures in buildx, however this did not work.

An error when building ARM on x86 (with virtualization) and vice versa would always arise, though the same command would work for other projects.

To avoid errors when doing things manually, the release process is also automated, triggering when a tag is detected. It will basically build as usual with the version tag and then publish the release following a markdown template. It will also automatically fill-in some details like image tags, etc from the changelog.


Fun fact, the bot is Open-Source under the MIT license, meaning you are welcome to host your own version.

Some stats about the project so far:

  • 47 pull requests merged
  • 97 commits (PRs are squashed)
  • 16 releases
  • 6+ months of existence
  • 10k (roughly) of users served in the Resonite Discord alone
  • Way too many hours sunk into overengineering this

What’s to come? Nothing much really, this bot has basically achieved its goal of having the active session list published somewhere.

The project is basically in maintenance mode, housed under the Crystalline Shard development group which I am one of the founders.

Now for the self-criticism: if I had to restart the project from scratch, I would probably opt into an even less complex design. Some services are really huge files that only increased in complexity with time.

Currently nothing too bad, but I think a refactor would be warranted to decrease complexity and make it more maintainable.

I wouldn’t touch the language though, since the bot’s footprint is really small, only about 30Mb to 50Mb of RAM used total during normal runtime.

In the end, this bot is a really fun project to make and maintain, and I’m extremely happy that it got canonized and used in an official manner.

For a quick roadmap:

  • GL-33 have someone completely review the codebase
  • GL-39 disable unneeded intents for the bot (on hold for evaluation)
  • GL-48 solve a mystery crash that randomly happens… sometimes

Let’s see in about 6 more months to see how everything evolved with even more bugs being squashed.

Setting up a Resonite Headless and getting metrics from it

As you may know, I made a mod for the platform Resonite that allows you to export metrics from the game as OpenMetrics data readable by Prometheus amongst other software.

In this post, we’re gonna see:

  1. How to setup a basic Resonite headless on Windows
  2. How to install the Resonite mod loader
  3. How to install the Headless Prometheus Exporter mod
  4. How to install and configure Grafana and Prometheus to scrape the metrics

First, some pre-requisites:

Setting up a headless in Windows is really easy. To first get the headless files, there are two ways that all begin the same way, sending /headlessCode to the Resonite Bot while logged in-game. This will give you the code needed to activate the beta branch for the headless.

Now, to download the headless, you have two ways:

  1. Use the graphical Steam client
  2. Use SteamCMD

Using the Steam client is the easiest. Just right-click on Resonite, hit ā€œPropertiesā€, then ā€œBetasā€, enter the code you previously got into the field and click on ā€œCheck codeā€.
You should now be able to select the Headless branch in the small dropdown and will download it automatically to your Steam game folder.

When using SteamCMD, unpack the zip file it comes in in a directory, hold SHIFT and right-click, then select ā€œOpen PowerShell window hereā€. Once the PowerShell open, you can use the following command to download the headless (replace your account name, password and headless code):

.\steamcmd.exe +force_install_dir ./resonite +login <account name> <account password> +app_license_request 2519830 +app_update 2519830 -beta headless -betapassword <headless code> validate +quitCode language: Bash (bash)

You should now be able to find the headless within the resonite\Headless directory near where SteamCMD is unpacked.

Now, to run the mod itself, the headless is not enough, we need to extend it via the Resonite Mod Loader. Its installation is straightforward, as outlined by their README file:

Download ResoniteModLoader.dll to Resonite’s Libraries folder (C:\Program Files (x86)\Steam\steamapps\common\Resonite\Libraries).
You may need to create this folder if it’s missing.
Place 0Harmony.dll into a rml_libs folder under your Resonite install directory (C:\Program Files (x86)\Steam\steamapps\common\Resonite\rml_libs).
You will need to create this folder.
Add the following to Resonite’s launch options: -LoadAssembly Libraries/ResoniteModLoader.dll. If you put ResoniteModLoader.dll somewhere else you will need to change the path.
Optionally add mod DLL files to a rml_mods folder under your Resonite install directory (C:\Program Files (x86)\Steam\steamapps\common\Resonite\rml_mods).
You can create the folder if it’s missing, or launch Resonite once with ResoniteModLoader installed and it will be created automatically.
Start the game. If you want to verify that ResoniteModLoader is working you can check the Resonite logs. (C:\Program Files (x86)\Steam\steamapps\common\Resonite\Logs).
The modloader adds some very obvious logs on startup, and if they’re missing something has gone wrong. Here is an example log file where everything worked correctly.

Those same instructions also apply to the headless software. On certain Windows version, you might want to right click and open the properties of ResoniteModLoader.dll and 0Harmony.dll then check the ā€œunblockā€ checkbox as it will prevent the mod loader from functioning correctly.

Once this is done, head over to the releases tab of the Headless Prometheus Exporter mod and download the HeadlessPrometheusExporter.dll file. You will need to move this file in the rml_mods folder that should be located in the headless directory. If this folder isn’t there, you can create it. Also check the properties of this dll as well for the unblock checkmark.

Now that the mod installation is done, we have one last step: configuring our headless. This step is also incredibly easy, being documented on the Resonite Wiki.
I can recommend going in the Config folder then copying the example configuration file provided to fit your needs. It is not recommended to start out with a ā€œminimalā€ configuration file, that might lack some essential settings and result in the headless not working as intended or not starting at all. Once you are familiar with what goes where and does what, feel free to trim the configuration file following your needs.

If you still have your PowerShell window open, you can type cd resonite\Headless to navigate to where the executable is and then use the following to start it with the mods:

.\Resonite.exe -LoadAssembly Libraries\ResoniteModLoader.dllCode language: Bash (bash)

After waiting a bit for it to start, you should be able to visit https://localhost:9000 in a web browser and see some metrics being displayed such as some world stats, engine FPS and many others.

Now we can tackle the last issue: how to display those metrics. For this, we’re going to use Prometheus in combination with Grafana, which are hands-on probably the best solution for this.

We’re gonna use my pre-made minimal Grafana setup for this. You can obtain the files by either using git clone https://g.j4.lc/general-stuff/configuration/grafana-minimal-setup.git or by downloading a ZIP or tarball of the source.

Getting started with it is extremely easy, but first, let’s go through each configuration file and what it does.

First, let’s open prometheus/prometheus.yml:

scrape_configs:
  - job_name: 'resonite-headless'
    scrape_interval: 15s
    static_configs:
      - targets: ['host.docker.internal:9000']
Code language: YAML (yaml)

This one is fairly simple. This configures Prometheus, whose job is to aggregate the metrics the mod is exposing. In this particular configuration, we are telling it to scrape our headless at the address host.docker.internal:9000 every 15 seconds.

Note that in most cases, you would need to use localhost:9000 or container_name:9000; host.docker.internal is only used because the headless is not in a container and on the host machine.

This leads us to our docker-compose.yml:

services:
    grafana:
        image: grafana/grafana
        ports:
            - 3000:3000
        volumes:
            - ./grafana:/var/lib/grafana

    prometheus:
        image: prom/prometheus
        volumes:
            - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
            - ./prometheus/data:/prometheus
        command:
            - '--config.file=/etc/prometheus/prometheus.yml'
            - '--storage.tsdb.path=/prometheus'
            - '--log.level=debug'
        extra_hosts:
            - 'host.docker.internal:host-gateway'
Code language: YAML (yaml)

It basically defines two services:

  • Grafana, which:
    ; Will store all of its data in the directory ./grafana
    ; Will be accessible on the port 3000
  • Prometheus, which:
    ; Will store all of its data in the directory ./prometheus/data
    ; Have access to the configuration file mentioned earlier
    ; Pass the localhost of the machine inside (as well as passing some command-line configuration arguments defining the configuration file path and the storage path)

Now that this is out of the way, open a PowerShell window in the directory where the docker-compose.yml file is located and make sure Docker is launched. There, just write docker-compose up -d and watch Docker automatically pull and start the images. If you are curious, you can use docker compose logs -f to see all the logs in real time.

After waiting for a minute or two, you can visit http://localhost:3000 in a web browser to setup Grafana. The default login should be admin and admin for both user and password.

Once in Grafana, head to the menu on the left, go in ā€œConnectionsā€ then ā€œData sourcesā€. There, select ā€œAdd new data sourceā€ and select Prometheus. You will only need to set the URL of said source to http://prometheus:9090, you can then go on the bottom and click on ā€œSave & Testā€.

You can now either select to go to the explore view or create a brand new dashboard on the top right of the screen. I can recommend playing with the Explore view for a bit before starting to build dashboards, as it will teach you the different types of visualisation as well as some useful queries.

Some metrics you can query include:

# RESONITE HEADLESS PROMETHEUS EXPORTER
totalPlayers 1
totalWorlds 1
# WORLD STATS 
world_users{label="S-09444920-caab-4c3d-a242-a50b028c33e6"} 1
world_maxusers{label="S-09444920-caab-4c3d-a242-a50b028c33e6"} 16
world_network{label="S-09444920-caab-4c3d-a242-a50b028c33e6",type="totalCorrections"} 82
world_network{label="S-09444920-caab-4c3d-a242-a50b028c33e6",type="totalProcessedMessages"} 4049
world_network{label="S-09444920-caab-4c3d-a242-a50b028c33e6",type="totalReceivedConfirmations"} 0
world_network{label="S-09444920-caab-4c3d-a242-a50b028c33e6",type="totalReceivedControls"} 3
world_network{label="S-09444920-caab-4c3d-a242-a50b028c33e6",type="totalReceivedDeltas"} 554
world_network{label="S-09444920-caab-4c3d-a242-a50b028c33e6",type="totalReceivedFulls"} 0
world_network{label="S-09444920-caab-4c3d-a242-a50b028c33e6",type="totalReceivedStreams"} 3491
world_network{label="S-09444920-caab-4c3d-a242-a50b028c33e6",type="totalSentConfirmations"} 554
world_network{label="S-09444920-caab-4c3d-a242-a50b028c33e6",type="totalSentDeltas"} 2410
world_network{label="S-09444920-caab-4c3d-a242-a50b028c33e6",type="totalSentControls"} 3
world_network{label="S-09444920-caab-4c3d-a242-a50b028c33e6",type="totalSentFulls"} 1
world_network{label="S-09444920-caab-4c3d-a242-a50b028c33e6",type="totalSentStreams"} 106
engineFps 59.98274
completedGatherJobs 0
startedGatherJobs 0
failedGatherJobs 0
engineUpdateTime 0.0004873Code language: PHP (php)

Noting that the S-09444920-caab-4c3d-a242-a50b028c33e6 is not a set variable as it is a session ID.

Now that you’ve done all of this, you can now enjoy having a nice dashboard displaying metrics about your headless!

You can also expand on the subject by reading further:

Jae 2012-2025, CC BY-SA 4.0 unless stated otherwise.