BattleShip ships a native C mod system: at boot the engine compiles each
mod's .c source at runtime (via libtcc), links it against the engine's own
export table, and calls the mod's ModInit. Mods can subscribe to engine
events, detour any exported engine function, and play custom audio — without
recompiling the port.
This page is the map. It tells you what the system can do, the three ways a
mod hooks into the engine, and a complete reference of the events you can
subscribe to. For the step-by-step authoring workflow (project layout, build,
packaging to .o2r, hot reload), the canonical how-to is
workspace/template/README.md.
Scope: the mod loader is desktop-only (Windows / Linux / macOS). It is compiled out on Android and any build configured with
-DDISABLE_SCRIPTING. Mods are loaded from<install>/mods/, either as unpacked folders (handy for development) or.o2rarchives (for distribution).
- Read
workspace/template/README.md— the authoring guide: folder layout,manifest.json, building, packaging, hot reload. - Copy
workspace/template/to start a new mod. It builds a heartbeat mod that logs once per second — the smallest complete example. - See
workspace/hooktest/for a minimal function-detour mod (it hooksosSpTaskStartGoand passes through). - See
workspace/playertint/for a minimal fighter-event mod (it tints each player a distinct color viaFighterEnvColorQueryEvent). - Use this page as your reference for which events exist and how to read their payloads.
Pick the mechanism that matches what you're trying to do.
The engine fires named events at well-defined points (per-frame, per-display,
ImGui render). Subscribe from ModInit, unsubscribe from ModExit:
#include "mod.h"
#include "port/hooks/Events.h"
ListenerID gListener;
void OnFrame(IEvent* event) {
/* runs every frame, after the game tick */
}
MOD_INIT() {
gListener = REGISTER_LISTENER(GamePostUpdateEvent, EVENT_PRIORITY_NORMAL, OnFrame);
}
MOD_EXIT() {
UNREGISTER_LISTENER(GamePostUpdateEvent, gListener);
}This is the right tool when an engine event already exists for the moment you care about. See the Event reference below.
Any function in the engine's export table can be detoured. Calls to it land in your replacement; the original comes back through an out-pointer so you can call it (chain) or skip it:
typedef sb32 (*orig_fn)(GObj*);
static orig_fn s_orig = NULL;
static sb32 my_replacement(GObj* g) {
/* before */
sb32 rv = s_orig(g); /* call the original (optional) */
/* after */
return rv;
}
MOD_INIT() {
mod_install_hook("itTomatoFallProcUpdate", my_replacement, (void**)&s_orig);
}Hooks are owned by the installing mod — on unload/hot-reload the engine
removes each of the mod's hooks before re-running ModInit, so reloading a
hook-installing mod is safe. Use this when there's a discrete engine symbol for
the behavior you want to change.
Some fighter decisions happen deep inside thousand-line procs or fkind-keyed
macros where there's no discrete symbol to detour. For those, the engine fires a
Fighter event carrying a mutable payload: it fills in the vanilla default,
your listener can overwrite the out-field, and the engine reads the payload back.
With zero listeners these are no-ops and the game behaves byte-identically.
void OnEnvColorQuery(IEvent* event) {
FighterEnvColorQueryEvent* e = (FighterEnvColorQueryEvent*)event;
if (e->player == 0) {
e->rgba = 0xFF0000FF; /* force player 1's env color to red */
}
}
MOD_INIT() {
REGISTER_LISTENER(FighterEnvColorQueryEvent, EVENT_PRIORITY_NORMAL, OnEnvColorQuery);
}See the Fighter events table for each override point's payload and semantics.
Every event is declared with DEFINE_EVENT, which generates a struct of the
same name whose first member is IEvent Event; followed by the payload fields.
In a listener, cast the IEvent* you receive to the event's type and
read/write its fields:
void OnX(IEvent* event) {
SomeEvent* e = (SomeEvent*)event;
/* read e->field, write e->out_field, or cancel with event->Cancelled = true */
}Listener priority is one of EVENT_PRIORITY_LOW, EVENT_PRIORITY_NORMAL,
EVENT_PRIORITY_HIGH (higher runs first).
Declared in port/hooks/list/EngineEvent.h.
These carry no payload — they're pure notifications.
| Event | Fires |
|---|---|
GamePreUpdateEvent |
In port.cpp, before the per-frame game tick (gmMain dispatch). |
GamePostUpdateEvent |
In port.cpp, after the per-frame game tick. The most common hook for reading/writing game state each frame. |
DisplayPreUpdateEvent |
Before the GFX dispatch (RCP frame submit). |
DisplayPostUpdateEvent |
After the GFX dispatch. |
EngineRenderMenubarEvent |
During ImGui menubar render — add your own menu items here. |
EngineRenderModsEvent |
During ImGui main render — draw your own ImGui windows here. |
Declared in port/hooks/list/FighterEvent.h.
These are mutable — fill in the out-field to override, leave it to take the
vanilla path. Pointer fields are passed as void* to keep the header free of
decomp types; cast them to the documented decomp type inside your listener.
| Event | Fires at | Payload | Semantics |
|---|---|---|---|
FighterEnvColorQueryEvent |
ftdisplaymain.c ftDisplayMainProcDisplay |
int32_t player; uint32_t rgba; |
Query. rgba in/out: 0 = no override; any other value is forced as the env color (RGBA word). |
FighterDamageDirectionApplyEvent |
ftmain.c ftMainProcessHitCollisionStatsMain, after default damage_lr is computed |
void* this_fp; void* attacker_fp; void* attack_coll; |
Apply. Derive the hitbox id from the attacker's attack_colls; overwrite this_fp->damage_lr (cast this_fp to FTStruct*, attack_coll to FTAttackColl*). |
FighterHitboxSlotResetEvent |
ftmain.c ftMainParseMotionEvent make-attack path |
int32_t player; int32_t slot; |
Notify. Zero your per-player/per-hitbox override slot so stale state from a prior attack with the same id doesn't bleed in. |
FighterParentKindResolveEvent |
ftcommonattack1/100.c fkind whitelists |
int32_t fkind; int32_t resolved_fkind; |
Query. resolved_fkind in/out: starts at fkind; rewrite it so a synthetic fighter passes its parent's jab / rapid-jab gates. |
FighterRapidJabStatusQueryEvent |
ftcommonattack100.c Start/Loop/End SetStatus helpers |
int32_t fkind; int32_t phase; int32_t status_id; |
Query. status_id in/out: -1 = use the vanilla fkind switch; else the rapid-jab status id for phase (0=begin, 1=loop, 2=end). |
Whole-function override points deliberately have no event — detour the engine symbol with
mod_install_hookinstead:ftMainSetStatus,ftParamClearAttackCollAll,ftSamusSpecialNGetChargeShotPosition.
A full mod that forces player 1's environment color to red. Drop this in
src/ of a copied template, build, and load. A complete, packaged version
that tints all four players a distinct color ships in
workspace/playertint/ — copy that folder to start
from a working fighter-event mod instead of the heartbeat template.
/* tint.c — force player 1's fighter env color to red. */
#include "mod.h"
#include "port/hooks/Events.h"
static ListenerID s_listener;
static void OnEnvColorQuery(IEvent* event) {
FighterEnvColorQueryEvent* e = (FighterEnvColorQueryEvent*)event;
if (e->player == 0) { /* player index is 0-based */
e->rgba = 0xFF0000FF; /* R=FF G=00 B=00 A=FF */
}
}
MOD_INIT() {
s_listener = REGISTER_LISTENER(FighterEnvColorQueryEvent,
EVENT_PRIORITY_NORMAL, OnEnvColorQuery);
mod_log("[Tint] init OK\n");
}
MOD_EXIT() {
UNREGISTER_LISTENER(FighterEnvColorQueryEvent, s_listener);
}PORTstruct layout. The engine is built with-DPORT, which changes the layout of many decomp structs (pointer fields collapse tou32reloc tokens).mod.hdefinesPORT 1before anything else for you — keep that include first, or cross-boundary struct reads return garbage.- Logging.
mod_log(fmt, ...)writes to%APPDATA%/BattleShip/ssb64.log(unbuffered) and works in both Debug and Release.printf/stderronly surface in the Debug-build console on Windows; in Release they go nowhere. Rely onmod_log. - Hot reload. The Mods menu's Reload action runs every mod's
ModExit, removes its owned hooks, frees its compiled image, recompiles from disk, and re-runsModInit. Unregister listeners and free allocations inModExitso reload stays clean. - Build errors surface at load, not at author-build time.
merge_mod.pyonly preprocesses/amalgamates your sources; the real compile happens in libtcc when the engine loads the mod. Watchssb64.logfor compile errors. - Pillow is a packaging dependency.
merge_mod.pyimports Pillow (PIL). Ifcmake --buildfails withNo module named 'PIL', install it withpip install Pillow— into the interpreter CMake selected, which can differ from thepythonon yourPATH(CMake'sfind_package(Python3)may pick another install). The build output prints the interpreter path it found.
| Piece | File |
|---|---|
Author API (MOD_INIT, mod_install_hook, mod_log, CALL_FUNC) |
workspace/template/include/mod.h |
Event macros (DEFINE_EVENT, REGISTER_LISTENER, IEvent, priorities) |
libultraship ship/events/EventTypes.h, bridge/eventsbridge.h |
| Event catalog | port/hooks/list/EngineEvent.h, port/hooks/list/FighterEvent.h |
| Runtime loader / mod mount | port/port.cpp (MountModsDir), port/mods/ |
| Packaging helper | cmake/ModO2R.cmake, tools/merge_mod.py |