How it works

How an event reaches a mod, and travels back

Sacred Gold is a 32-bit game from 2004, and mods run in a 64-bit JVM. Everything on this page bridges that gap.

Two processes, one wire

A 64-bit JVM can’t load into a 32-bit process, so mods never run inside the game. A Rust host starts a JVM next to Sacred Gold and injects a Frida agent into the game.

The three pieces talk in a strict line. The agent hooks x86 instructions and reports what it sees to the host. The host passes it to the JVM as short text frames over a pair of named pipes. The agent never talks to the JVM directly.

Frames have a channel of their own, so a mod can’t break it by printing. System.out still ends up in the host console, but getContext().log() is the better habit: it tags each line with the mod’s id and appends it to logs/mods.log in the game folder.

The idea of reaching into a running program instead of its files owes something to SpongePowered’s Mixin, which rewrites JVM bytecode as classes load. Sacred Gold has no JVM to weave into, so Frida patches x86 instructions in the native process instead.

What crosses the wire

Seven frame types cover everything. EVT reports something that already happened and expects no reply: a level-up, a death, gold changing hands. ASK comes from a hook placed before the game commits a write, and the game thread waits for the answer.

END answers an ASK: let the write through, cancel it, or replace one of its fields. CMD and RES let a mod ask the game a question instead of only reacting. LOG and BYE complete the set.

a gold pickup, boosted
1ASK 7 gold.delta delta=40 current=1180 dir=gain
2END 7 set.delta=60

Here a mod rewrites one field. The ASK carries the pickup, the END raises the delta from 40 to 60, and the game computes the new total itself. A Gold listener asks for exactly this when it returns Gold.Mutation.change(60), like the Kotlin example on the home page.

Timing

An ASK stops the game thread, so it can’t wait forever. The host gives each ASK 250 ms, and a watchdog checks every 125 ms. An unanswered ask therefore resolves 250 to 375 ms after it was sent, plus scheduling delay. After that, the host answers for the mod, and the original value goes through unchanged. Treat 250 ms as a target, not a guarantee, and keep vetoable handlers well under it.

Priority and vetoes

Events are read-only. A listener that wants a different outcome returns a mutation: a new value, a veto, a reset to the game’s own number, or nothing. The loader applies each answer before the next listener runs, so getValue() always shows the result so far. Two mods that double the same gain make it four times as large.

Listeners run in a fixed order: FIRST, NORMAL, LAST, then MONITOR. A veto doesn’t stop the dispatch. Later listeners still see the event and can lift the veto with a reset. A listener with nothing to add after a veto can opt out with @Subscribe(ignoreVetoed = true), and a mutation marked last() ends the deciding early.

MONITOR is the odd one out. It always runs, sees what the earlier listeners decided, and can’t change any of it. A monitor returns nothing, and one that returns a mutation fails the build’s lint check. That suits a tracer mod that only wants to watch.

Why a total can’t be rewritten

Gold and level are fields the game checks against itself. It keeps an XOR-encoded mirror of each and compares it with the live value from time to time. Rewrite the total directly, and the checker spots the mismatch and resets the field to 1.

That’s why a Gold listener decides the delta, never the total, and why LevelUp can only be watched. Change the amount about to be added, and let the game commit its own total and refresh its own mirror. This rule is usually the first thing that trips up a new hook.

Why this shape at all

Nothing here touches the game on disk. Hooks live only in the running process and vanish when it exits, so nothing works until the game runs.

The two-process split exists only because a 32-bit game and a 64-bit JVM can’t share an address space. The frame format, the deadline, and the priority order all follow from one constraint: the game thread can’t wait long. Read the full wire format in protocol’s docs, and the event model in coderpack.