Skip to content

Latest commit

 

History

History
283 lines (246 loc) · 22.2 KB

control-socket.md

File metadata and controls

283 lines (246 loc) · 22.2 KB

Control socket

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.

Turning it on

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 /p44406

Without 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.

Talking to it

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 cmd and the command's own fields, plus an optional id that is echoed back so a caller can match answers to requests. id belongs to the framing: a drop is named by item, not by id.
  • A response is {"ok":true,"result":{…}} or {"ok":false,"error":"<code>","message":"…"} — with a result holding 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.

Commands

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, with their monster numbers
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, alt (shows every drop's name until pressed again), …
click-ui (x, y, button) click a window pixel (left by default)
hover-ui (x, y) move the pointer to a window pixel with no button, as physical motion: RmlUi hover and the game's own pointer (native hover, tooltips) both follow it
drag-ui (x, y, to_x, to_y, button) press at one window pixel, move to the other over eight frames with the button held, release there
type (text, enter) deliver committed UTF-8 to the focused field; optional boolean enter submits on a later frame
console (text) run one $ developer command ($fps 60, …) as if typed into the chat box, without opening it, so nothing typed shows in the frame; bad_request when the text is not a $ command the client knows, or its arguments are missing or malformed
bench the benchmark's progress: running, its status line, and last_run, the folder the last finished run was written to. Start a run with console and $bench run <pattern>; see benchmark.md
bones in the world: how render's bones compare with the character animation pre-pass. {"start":true} clears the counts and starts checking; each answer gives checked (characters drawn), players (of those, player models), mismatched (any bone off by more than 0.01), max_delta, and the pre-pass's last characters count and whether it ran parallel
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
tooltip (group, number, level, durability, luck, skill, option, excellent, ancient, harmony, harmony_level) the lines of an item's information tooltip (an item number no item uses, or harmony_level without harmony, is a bad_request), as the inventory builds them for an item made from these fields (as the server would send it; left out: 0, and full durability: the most the item has at its level and options, as the repair window counts it) and the hero as he is, without drawing them: lines, each with text ("\n" for a blank line), color (the TEXT_COLOR_* number) and bold
tooltip-snapshot (out, group) writes the tooltip lines of every item (of group only, when given: one group per request keeps the frame short) at every level 0 to 15, as plain, full (luck, skill, +4 option, every excellent option), ancient (set 1) and Jewel of Harmony (option 1 at level 13) items; with luck, skill, the +1 option or one excellent option (the first) alone; at durability 1 and 0; and expired, for the hero as he is, to the text file out: a line <group>/<number> <variant> +<level>, then one line <color> [bold ]<text> per tooltip line ( <blank> for a blank line, without its color: a blank line shows none). Two files compare with any diff tool, e.g. before and after a change of the tooltip code. Each request writes out anew, so a snapshot by groups takes one file per group. out is resolved like screenshot's; replies tooltips, the count, and out, the absolute path written. The Dark Horse and the Dark Raven get their header line but no tooltip lines, since the pet manager draws their tooltip
item-name (group, number, level) an item's name at a level (0 when left out), as the item windows, messages and quests name it (an item number no item uses is a bad_request)
item-name-snapshot (out, group) writes the name of every item (of group only, when given) at every level 0 to 15 to the text file out, a line <group>/<number> +<level> <name> each, to compare with any diff tool before and after a change of the names. out is resolved like screenshot's; replies names, the count, and out, the absolute path written
repair-tooltip (tooltip's fields) the lines of an item's repair tooltip (inventory repair mode, or an NPC that repairs), built for an item made from these fields as tooltip makes it (an item number no item uses is a bad_request; left out: 0, and full durability) without drawing them: lines, each with text (a lone line break for a blank line), color (the TEXT_COLOR_* number) and bold; no lines for an item that can't be repaired. The cost is what the open window charges, as in the game: an NPC that repairs, or the inventory (self-repair); 0 with neither open
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 a target
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

A target is an object id (the id of nearby), a character name, or {"monster": <number>}: the nearest living monster or NPC with that monster number. Traps, objects of the kind other, and summons, Kalima gates and transformed players are not found by their number; a trade needs a player either way.

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. The goods of the personal shops whose price isn't in zen also carry the price_item_type of their price, as the server encodes it: the item (group in the highest 4 bits, number in the others) or the account coin (0xFFF0 WCoin (C), 0xFFF1 WCoin (P), 0xFFF2 Goblin Points); see personal-shop.md. 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.

An object's kind is player, monster, npc, trap or other (the Cherry Blossom Tree, and the players of the castle siege preview), item for a drop, and unknown for anything else. Monster, npc, trap and other are the kinds of the monster list (Data/Monsters/Monsters.json, see monster-data.md); summons, Kalima gates and transformed players are player.

A character also carries monster, the monster number the server created it with (the number in the monster list), or null for a player. Summons, Kalima gates and transformed players have one too, with the kind player. Monster and NPC names are in the client's language, so a script finds an NPC by its monster number rather than by its name.

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. A drop carries pixel too (hover-ui there shows its name and lights it, click-ui picks it up), and label, the name it shows on the ground: text, color and background as #rrggbb, and bold. 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.

Synthetic input

hotkey and click-ui deliver input to RmlUi first, then to the older key/button readers only if the UI did not consume it. They do not move the OS pointer or change window focus. A click rests on its pixel for two frames before the press, as a real mouse does: an item grid only picks up an item it saw hovered. Use screenshot pixels as click-ui coordinates: for example, click the Menu button, then take another screenshot to inspect the panel. The older CInput widgets still hit-test the OS cursor and cannot be clicked remotely; use their keyboard navigation instead. Keys are case-insensitive: letters, digits, esc, enter, tab, space, backspace, home, end, insert, delete, pageup, pagedown, arrows, printscreen, f1–f12 and alt.

Focus a text field with a supported UI click or keyboard navigation before type. For example, {"cmd":"type","text":"hello"} inserts committed text; {"cmd":"type","text":"hello","enter":true} delivers Return on a later rendered frame. text must be 1–256 UTF-8 bytes with no NUL, ASCII control character or DEL; enter must be boolean. A successful reply confirms delivery, not that a field accepted the characters or a login succeeded. Its result reports only byte count and whether Enter was requested, never the text (which may be a password). No IME composition or key chords are synthesized; drag-ui is the only drag.

Only one injection runs at a time; another answers busy. Injection commands are observational and do not interrupt an ongoing world act. They answer after release processing on rendered frames. A stalled client times out and retracts pending input rather than delivering it later. Cancellation is not a rollback: text or effects already delivered before cancellation remain. An abandoned held RmlUi click is cleared without activating the pressed element. Independent physical input continues to work; a physical press of the same button cancels an outstanding scripted click before that press is routed, returning a failed response naming the physical press; a lost window/UI delivery target instead reports failed with a target-disappeared message. UI hover can remain at the injected pixel after a completed click or a cancelled click whose UI press did not need clearing (a non-primary click or a primary click the UI did not consume). A later physical or scripted motion updates it; the OS pointer itself never moves. A scripted key also uses the human's current physical modifiers for RmlUi navigation; focused portable fields receive the key without those modifiers.

Events

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,monster}, 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.

Security posture

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.

Keeping the taps when WSclient.cpp changes

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:

  1. 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 against git show upstream/main:… when the number itself is in doubt: the count is a smoke test, the list above is the contract.
  2. Re-run the live checks that cover the dropped tap (a fight records hit, killed and stat; a pickup records drop and drop_gone; the trade in-game test records the trade steps, see in-game-tests.md).

Notes from the field

  • 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 ping in 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's safe_zone flag says when the character is in one, and attack answers not_allowed there.