The client can be driven from a shell: logging in, selecting a character, walking, fighting, chatting, reporting what it sees and taking screenshots, without anyone at the keyboard. It is developer tooling for scripted tests and demos, off unless a launcher asks for it, and it works the same on Windows and on Linux.
The socket is a build-time feature. It exists only in a client configured
with ENABLE_CONTROL_SOCKET=ON — the -mueditor presets and hand-configured
developer builds; the plain presets (linux-x64, windows-x64, …) and any
build that does not opt in compile none of it, and the control_socket_leak
test in their test suite proves it. A player build ignores the variable
below entirely: no socket, no log line, no change of behaviour.
In a build that has it, the client opens the socket only when
MU_CONTROL_SOCKET names a path:
MU_CONTROL_SOCKET=/run/user/1000/clients/default.sock ./Main /u127.0.0.1 /p44405Without the variable nothing is created, nothing is logged and the client
behaves exactly as before. With it, the client logs one line
(control socket listening on <path>), creates the socket with owner-only
permissions (0600), replaces a stale file left behind by a crashed client,
and removes the file when it exits normally.
An option would not do: the client's command line is split on spaces, so a path containing one could not be passed.
Newline-delimited JSON, one request object per line, exactly one response per line:
printf '{"cmd":"ping"}\n' | socat - UNIX-CONNECT:/run/user/1000/clients/default.sock
{"ok":true,"result":{"build":"Sep 16 2026 16:22:46","scene":"world"}}- A request carries
cmdand the command's own fields, plus an optionalidthat is echoed back so a caller can match answers to requests.idbelongs to the framing: a drop is named byitem, not byid. - A response is
{"ok":true,"result":{…}}or{"ok":false,"error":"<code>","message":"…"}— with aresultholding the progress made when a long-running command was interrupted or timed out. - Requests are served on the main thread, once per frame, after the packets of that frame have been processed. Nothing runs concurrently with game logic.
- Several connections may be open at once. Commands that drive the character
(
login,move,attack, …) run one at a time: a second one interrupts the first, whose caller is told how far it got. Reading commands (state,events,wait-for,screenshot) run alongside.
Error codes: bad_request, unknown_command, wrong_scene, busy,
interrupted, timeout, not_connected, login_failed, no_such_character,
no_such_skill, not_in_view, not_attackable, no_path, not_allowed,
warp_refused, skill_refused, insufficient_mana, not_pickable,
empty_slot, move_refused, not_open, failed.
| Command | What it does |
|---|---|
ping |
build identifier, the git commit the client was built from (commit_changed when tracked files differed from it), and the current scene |
scene |
which screen the client is on: login, character_list, world, … |
state |
the character and everything around it (see below) |
nearby |
the objects the client can see |
events (since, follow) |
recorded events, or a live stream of them |
wait-for (event, match, timeout) |
block until a matching event arrives |
screenshot (out, quality) |
capture the next frame as a JPEG to a path; without out it names itself, uniquely per capture; quality 1 to 100, default 100 |
hotkey (key) |
press one game key for a frame: esc, i, home, f1, … |
click-ui (x, y, button) |
click a window pixel (left by default) |
type (text) |
type text into the text field that has the focus, as the keyboard's text input does, e.g. an amount into the trade's zen box; not_open when no field has the focus |
ui |
the open windows by name — inventory, inventory_extension, character, trade, storage, storage_extension, mix, npc_shop, lucky_item, chat_input, party, command, my_shop, purchase_shop, npc_quest (the quest dialog of Sebina, Marlon and Devin), and message_box while a dialog waits for Enter or Esc — and the window pixels of the named elements that are shown: trade.confirm, trade.zen, inventory.repair, inventory.my_shop, npc_shop.repair, npc_shop.repair_all, my_shop.title, my_shop.open, my_shop.close, command.trade, command.purchase, command.party, npc_quest.answer.0 and on (the rows of the quest dialog's answers), npc_quest.complete, npc_quest.close, character.stat.strength (agility, vitality, energy, command: the character window's "+" buttons, while there are level-up points) |
slot-pixel (grid, slot) |
the window pixel of a slot's square: inventory and equipment (the slot numbers state reports), trade, trade_partner, storage, mix, npc_shop, and the personal shops my_shop and purchase_shop (slots 204 and up, as state and the server number them); not_open while that window is closed, bad_request for a slot the grid does not have |
login (account, password, server) |
server selection, credentials, character list |
select-char (name or slot) |
enter the world with that character |
logout, quit |
back to the character list; close the client |
move (x, y) |
walk there with the client's own path finder |
warp (gate) |
use a warp-list entry by name |
teleport (x, y, map) |
the game master's own move command; map is an index, the current map when omitted |
attack (target, times, interval) |
plain attacks on an id or character name |
skill (skill, target) |
cast a skill the character owns — with target it is aimed at that object, without one it is cast where the character stands |
pickup (item) |
walk to a drop and take it, by the id nearby reports for it |
use (slot), equip (slot, target_slot) |
inventory actions |
say (text), whisper (name, text) |
chat, including / commands |
party (action, target) |
invite, accept, decline, leave |
trade (action, target) |
request a trade with another player at most one tile away (not_allowed otherwise), or cancel the open one; the partner accepts with the Enter key, and items go in with click-ui |
halt |
stop the walk or repeated attack in progress |
state reports the scene and account on every screen, and in the world adds:
character name, class, level, experience, zen, HP/mana/SD/AG with their
maxima, map number and name, position, alive flag, safe-zone flag, current
target, the skills the character owns, equipment, inventory, buffs, party,
the open trade (partner, both offers with their items and zen, both confirm buttons and my_confirm_wait, the frames until my button takes clicks again, or null) and
nearby. An item carries slot, name, level, durability,
max_durability, and its width and height in inventory squares; one that
covers several squares is listed once for each of them. While a click would
repair it (the inventory's repair mode, or an NPC that repairs), a worn item
also carries repair_price, and while an NPC shop is open an inventory item
carries sell_price: both as the item's tooltip shows them.
The shops: npc_shop is the open NPC shop (repair_shop, tax_rate,
repair_all_price at an NPC that repairs, and its goods with price, tax
included) or null; my_shop is the player's own personal shop (open, the
title in its title field, its goods with price), purchase_shop the
personal shop the player looks into (seller, title, goods with price) or
null. repair_mode says whether a click repairs instead of picking up.
For the quests and their rewards: class_name (e.g. Blade Knight; class
is the client's number, e.g. 1 Dark Knight, 8 Blade Knight, 12 Blade Master),
level_up_points, stats (strength, agility, vitality, energy,
command, without bonuses), combo (the Blade Knight's combo from Marlon),
quests (the seven legacy quests with index, name and state:
active, complete, not_started, none, or unknown for a value outside
these, as in the quest events), and npc_quest, the quest
dialog on screen (quest, page, text, need_zen, and its answers, each
with its text and action: next turns the page, accept takes the quest,
complete hands it in, close ends the talk) or null.
Each nearby object carries id, kind, name, position, and a player,
monster or NPC also alive, level and hp_percent. hp_percent is a
percentage of full health (100 is untouched) and is null when the server
has not told the client that object's health — a threshold test has to allow
for the null rather than read it as zero. A player, monster or NPC also
carries pixel, the window pixel {x, y} at the middle of the box the mouse
picks it by, or null while it is not drawn: click-ui there talks to an NPC
or targets a player the way a player's click does. The pixel is taken from the
last rendered frame, so it lags a moving object by a frame, and a window drawn
over it catches the click instead.
hotkey and click-ui inject a key or a click below the game's own input
readers: the key counts as down for one rendered frame in the same key-state
scan a physical key goes through, and the click writes the same mouse
variables the event loop fills from real mouse events, at a window pixel. The
window system is never involved — no pointer movement, no focus change, no
synthetic OS events — so a scripted session can open the system menu, the
inventory or start the MU Helper from its HUD button while the human keeps
working elsewhere. Coordinates for click-ui are the pixels of the client
area, the same space a screenshot image is in, so a script can capture,
locate and click. What a click reaches is what reads those variables: the
current UI layer and the world. The older widget layer hit-tests through
CInput, which reads the operating system's cursor, and a click injected
below the readers never moves that — those windows are out of scope for
click-ui, and a key is the way to drive them. Key names are case-insensitive: the letters, the digits,
esc, enter, tab, space, backspace, home, end, insert,
delete, pageup, pagedown, up, down, left, right, printscreen
and f1–f12; anything else answers bad_request. Both answer once the
release frame has run; a second injection while one is in flight answers
busy, while a walk or an attack in flight is left alone — injecting a key is
an observation of the act slot, not a claim on it. The sequence follows
rendered frames, so an injection sent to a client that is not rendering (the
occluded-window case below) answers timeout and is dropped rather than
delivered late. Text goes into the focused text field with type. Not covered: key chords (Shift+L, Ctrl+Q), drags.
The packet handlers record what a scenario asserts on. Each event carries a
strictly increasing seq, a UTC time and its own fields:
| Event | Fields |
|---|---|
hit |
direction (dealt/received), attacker/target {id,name,kind}, damage, shield_damage, critical, missed |
killed |
victim, killer |
stat |
stat (life, mana, sd, ag, level, experience_gained — the experience of one kill, and damage_dealt with it — the cumulative total is state.experience), value, max |
chat |
sender, text, kind (public, whisper, party, guild, union, gens, gm) |
drop / drop_gone |
id (the id pickup takes), item, position / reason |
map |
map, map_name, position |
scene |
scene |
view_enter / view_leave |
object |
quest |
change: state with the legacy quest and its new state (active, complete, not_started, none, unknown), or reward with the character's name, the reward (level_up_points, second_class, points_per_level, combo, third_class), its amount (none for the two class changes, whose new class is class) and the character's class afterwards; the client records the rewards of every player in view, so a script matches its own name |
party |
change (invited with the inviter's name; list with the leader's name after every change of the members; left; result with result — failed, denied, full, user_left, other_party, left, opposing_gens, battle_zone, battle_zone_off — when an invitation formed no party) |
trade |
change (requested, opened, refused, unavailable, partner_confirm, closed), name for a request or an opened trade, state (checked, unchecked, reset; unknown for a value outside the protocol) for the partner's button, result (completed, cancelled, inventory_full, request_cancelled, reinforced_item) when it closes; refused also on the asked side, when a window that forbids trading is open and the client says no by itself |
disconnect |
reason |
error |
command, error, message |
The last 2,048 events are kept. Names and fields are plain lower snake case, so a test script can parse them next to a server-side event stream.
Build-time gate first. A player build contains no control code at all:
ENABLE_CONTROL_SOCKET is off by default, the plain presets and CI keep it
off, none of App/Control/ or the local-socket transport is compiled, and
the activation variable's name does not appear in the executable. What a
player can reach by setting an environment variable is therefore nothing.
What stays in every build is the mouse-free automation the in-game helper
already had (GameLogic/Automation) and the extracted login/character entry
points — refactorings of existing code, not new surface. The gate removes the
ready-made API and the flip-a-switch path; it does not defend against a
patched or rebuilt binary.
Within a developer build the socket is unauthenticated: any process of the
same user can drive the client, like any other local developer endpoint. It is
a filesystem socket with mode 0600 in the user's runtime directory, never a
network address, never enabled from config.ini, and never on unless the
launcher sets the variable.
The event recorders are one-line calls named App::Control::Events::Record…,
sitting at the end of the packet receive functions in
src/source/Network/Server/WSclient.cpp (hits, deaths, experience, stats,
chat, whisper, drops appearing and vanishing, view enter/leave, party changes,
invitations and answers, quest states and rewards,
trade steps, logout) plus the scene and map watcher in App/Control/ControlServer.cpp.
When one of those functions is rewritten:
rg -c 'App::Control::Events::' src/source/Network/Server/WSclient.cpp— the count is 32; a lower one means a tap was dropped. Compare it againstgit show upstream/main:…when the number itself is in doubt: the count is a smoke test, the list above is the contract.- Re-run the live checks that cover the dropped tap (a fight records
hit,killedandstat; a pickup recordsdropanddrop_gone; thetradein-game test records the trade steps, see in-game-tests.md).
- The client's main loop stops running while its window is fully occluded (the
compositor stops sending frame callbacks), and with it the socket stops
answering. Two clients side by side must both stay visible; tiled windows
answer
pingin about 4 ms. - The server's speed check bans an account after four warnings in an hour, so every command that walks paces its steps (300 ms between walk packets, 250 ms between automation steps). Do not remove that pacing.
- Attacks are refused inside a safe zone;
state'ssafe_zoneflag says when the character is in one, andattackanswersnot_allowedthere.