Writing an addon
The addon API, what an addon cannot do, and releasing everything on disable
這一頁還沒有翻譯,以下顯示英文原文。
An addon is one jar, one manifest and one class. It loads into a classloader of its own, so it can be switched on and off while the game runs — which is also the constraint that shapes the whole API.
The shape of one
{
"id": "example",
"name": "Example",
"version": "1.0.0",
"main": "com.example.ExampleAddon",
"description": "Does a thing"
}public final class ExampleAddon implements SocketSvcAddon {
private AddonKeybind key;
@Override public void onEnable(AddonContext context) {
key = context.keybind("Say hello");
context.command(args -> context.log("hello " + String.join(" ", args)));
}
@Override public void onDisable() {
// Everything the context handed out is released for you.
}
}Compile against the client API jar as compileOnly — it is on the same Maven coordinate as the rest of SocketSVC's APIs; see the Maven section of the updater docs. The id must match [a-z0-9][a-z0-9_-]{0,63}: it is a directory name and a config key as well as a name.
AddonContext gives you the addon's own data directory, the client voice API, a log, a chat line, a keybind slot and a subcommand reached as /ssvc addon <id> ....
What an addon cannot do, and why
Everything a mod registers with the mod loader happens once, during startup. An addon enabled from a settings screen has missed all of it by minutes:
| Cannot be added later | Because |
|---|---|
| Keybinds | KeyBindingHelper.registerKeyBinding is an init-time call |
| Network payload types | PayloadTypeRegistry is read when the protocol is built |
| HUD layers | HudElementRegistry.addLast is an init-time call |
| Client commands | the registration callback has already fired |
| Mixins | applied by the transformer as classes load; fixed during early boot |
| Items, blocks, sounds | registries freeze after startup, and a server syncs them at login |
So SocketSVC registers those sockets itself and lends them out:
| What you ask for | What SocketSVC registered for you |
|---|---|
keybind(label) | One of eight reserved key slots. Returns null when all are taken, which is ordinary rather than an error: running out of keys happens to a player with several addons, and an addon should carry on without its shortcut. |
command(handler) | A branch of SocketSVC's one command tree, reached as /ssvc addon <id> .... Pair it with completions(handler) for tab completion past your id — SocketSVC can only complete as far as the id, because past that the vocabulary is yours. |
clientTick(task) | A slot on the client tick, which is a loader event. The right place for work that must happen off the render path. |
worldRenderer(renderer) | A slot in a mixin into the level renderer, handing over a PoseStack and a SubmitNodeCollector. This is the one that could not be worked around any other way — a mixin applies when classes are transformed, long before your jar has been opened. |
settings(page) | A page of the Addons tab, under your addon's own switch. An addon has no mod menu entry and no settings screen of its own, so the screen a player already opens to switch it on is where its settings go. |
worldRenderer also papers over a loader difference nothing else could: Fabric has a world-render event that gives both of those, and NeoForge and Forge have no event that gives a SubmitNodeCollector at all. Going through SocketSVC means an addon is written once and draws on all three.
Registering any of them twice replaces the previous one rather than running both, and all of them are handed back when the addon is disabled.
If you need an item, a mixin or a registry entry, write a mod instead. The manager installs mods too — it just tells the player, honestly and repeatedly, that yours takes effect on the next restart.
Settings
An addon cannot open a settings screen of its own — it has no mod menu entry, no keybind it did not borrow, and no place in any other mod's config. Without somewhere to put them, the only way to change anything about a running addon is a chat command the player has to know the spelling of, whose answer scrolls away.
So settings are a slot like the others. Describe what your settings are, and SocketSVC draws them on the Addons tab, under the switch that turns your addon on:
context.settings(settings -> {
settings.section("Sound");
settings.slider("Volume", "%", 0, 200, volume::percent, percent -> volume.set(percent / 100.0))
.tooltip("How loud films are, for you alone.");
settings.section("Performance");
settings.choice("Quality", List.of("full", "high", "medium", "low"),
() -> quality.level().id(), chosen -> quality.set(Level.parse(chosen)));
settings.toggle("Match the film's own rate", quality::fpsIsAutomatic,
automatic -> quality.setFps(automatic ? 0 : 24));
settings.action("Show what is playing", () -> "in chat",
() -> screens.report().forEach(context::showMessage));
});Six rows exist: section, note, toggle, slider, choice and action. Each control takes a supplier read whenever the row is drawn and a setter called when it is moved, so nothing on the screen is a copy of your state that could go stale — turn a volume down with a keybind and the slider on an open screen follows it.
A player reaches them from the addon's row on the tab, which only offers the link when the addon is running and has registered a page.
Three things the API will not do for you
Saving. The setter is where you write the value down, normally into context.dataDirectory(). SocketSVC does not know which of your settings are worth keeping, and one that silently forgot half of them would be worse than none.
Debouncing. A slider calls its setter for every step of a drag — dozens of times on the way across the track. Anything costing more than assigning a field belongs behind a clientTick that notices the value stopped moving; the cinema restarts its decoders 400 ms after the last change rather than on each one.
Translating. Labels are your own strings, shown as given — so get them from context.translate(key, args...), which reads your jar's assets/<id>/lang/<language>.json (the shape Minecraft's own language files have) in the game's language, then en_us, and answers with the key itself when neither has it. A page is described again on every layout, so it follows a language change by itself; a keybind's label is read once when it is claimed.
The page is described in a callback that runs again on every rebuild of the screen, not once when it opens, so rows whose existence depends on your own state appear and disappear while the player is looking at them. It runs on the client thread inside a screen: describe, do not block. A page that throws switches the addon off and says so, the same as one that throws on the tick — these suppliers are read on every frame the screen is drawn, so one that throws does it at that rate.
A page is capped at AddonSettings.MAX_ROWS; rows past it are dropped and the player is told the page was cut short.
Releasing everything on disable
onDisable() has to let go of everything, and this is the part that is easy to get wrong.
Dropping the classloader only frees your classes if nothing still points into them. A thread you started, a listener you registered with another mod, an instance you handed out and it cached — any one of those keeps the whole loader alive, and every enable/disable cycle strands another one. It is invisible until a player has toggled twenty times and run out of memory.
SocketSVC checks afterwards rather than trusting you: a loader still reachable after a disable is a warning in the log naming your addon.
What the context lends is released for you
Keybind slots, the command handler and voice channels opened through context.voice() are all taken back automatically. The context itself goes dead — calling into it after a disable throws, rather than quietly working, because an addon still using its context is an addon that has not let go.
Sound in the world
context.voice() hands an addon SocketSVC's client API, whose local channels are how an addon
makes a noise somewhere - a cinema speaker, a walkie-talkie, a phone ringing in a pocket.
var speaker = context.voice().createPositionalChannel(id, new SpatialPosition(x, y, z), 32f);
speaker.setDirection(SpatialDirection.cone(0, 0, 1, 60, 240, 0.2f)); // facing +Z
speaker.play(pcm); // 20 ms, 48 kHz, mono, 960 samplesThree kinds: positional, entity (follows one, and goes quiet when it is gone rather than freezing where it last was) and static (no placement - a UI sound). All are mixed by the same worker, through the same HRTF, as voice, and none of it touches the voice connection: a local channel is this client rendering audio it already has, and nobody else hears it.
setDirection gives a channel a cone - full volume inside the inner angle, falling to outerGain
by the outer one. Both angles are full cone widths, not half-angles, the same convention OpenAL
and the Web Audio API use. It multiplies into the distance gain, so a cone never makes a channel
audible somewhere it would not already have been, and outerGain above zero is allowed because a
real loudspeaker is quieter behind rather than silent.
Height is the honest limit
SocketSVC's HRTF is horizontal by construction. A channel above the listener is placed correctly left-to-right and flat vertically - right for speakers at ear height, and the thing to know about before hanging one from a ceiling.
Channels an addon opens through its context are closed when the addon is disabled.
Pace against `queued()`, never against a clock
play() does not block and does not pace. A short queue behind it absorbs ordinary jitter, and a
frame offered when that queue is full is refused — growing it instead would turn a clock
problem into latency nobody can see.
So anything feeding a continuous stream — a film, a radio — has to be held back by something, and
the only honest measure of "far enough ahead" is channel.queued(): how much the mixer has not
played yet, out of channel.capacity(). Feed while it is below a frame or two of target, wait
while it is not, and nothing is ever refused.
Pacing against a wall clock does not work, and the cinema is the worked example of why. It held its sound to within 150 ms of the picture and assumed 60 ms was sitting in the mixer — 210 ms of lead against a 160 ms queue, which is not an edge case but the steady state. Every frame past the cap was refused, and the sound stuttered continuously until it was paced against the queue instead.
Media and screens
Two more things the API lends, because an addon should not have to fetch what SocketSVC already has:
Path ffmpeg = context.voice().ffmpegBinary(); // null when the player declined the download
List<VoiceScreen> screens = context.voice().screens();
double age = context.voice().screenSnapshotAgeSeconds();ffmpegBinary() is the on-demand binary the soundboard already downloads. null is an ordinary answer, not an error.
screens() is what the server said about its screens: where they are, what is on them, and where each playhead was. The playhead travels as a position and the moment it was true, not as a running clock, so where a film is now is screen.positionAt(age). SocketSVC resolves and re-syncs those; it draws nothing and decodes nothing. Turning one into a picture is what the cinema addon does, and it is worth reading as the worked example of every handout on this page.
Addons that run on the server
An addon can have a server half too — a plain jar in SocketSVC's addons/ folder, against com.socketsvc.api.addon, that works on Paper, Fabric, NeoForge and Forge without being built four times. See Servers and addons.
Next
Publishing covers getting one to players.