Skip to content
Conduit

mcp server

dayz-agentic-modding-mcp

MCP server for agentic DayZ modding: build, test stand, structured verdict instead of logs, a bridge into the running game, client eyes and hands, an API knowledge index scoped to one server's mod set

covalschi/dayz-agentic-modding-mcpcommunitylistedunreviewed

Set up

Runs on your computer. Pick your agent and operating system.

Before you start

  • uv (`curl -LsSf https://astral.sh/uv/install.sh | sh`)
~/Library/Application Support/Claude/claude_desktop_config.json
{
  "mcpServers": {
    "dayz-agentic-modding-mcp": {
      "command": "uvx",
      "args": [
        "dayz-agentic-modding-mcp"
      ]
    }
  }
}

same format Cursor, GitHub Copilot CLI, Junie, JetBrains AI Assistant, Augment (Auggie CLI), Factory Droid, Kiro, Devin CLI +12

Values in angle brackets are placeholders. Restart Claude Desktop after editing its config.Claude Desktop docs

README

synced Oct 21, 2026

dayz-agentic-modding-mcp

An MCP server that lets an agent build a DayZ mod, check that the client compiles, run a test server and get a structured verdict instead of a log -- and then publish the result to the Steam Workshop.

The server does the work itself: it calls FileBank, the signing tool and the diagnostic executable directly. A project does not need its own build script.

Why a profile

The server knows nothing about any particular mod. Everything specific lives in a two-part profile:

  • dayz-mcp.toml — portable, committed to the mod repository: which mods to pack, what a healthy boot looks like.
  • dayz-mcp.local.toml — machine-specific, never committed: where the game, the tools and the test stand live.

Mixing the halves is rejected on load: that is how a repository stops building on anyone else's machine. Start from dayz-mcp.example.toml.

A mod is declared once, by name: sources in <root>/Name by default, output @Name/addons/Name.pbo. build.sources can redirect a mod's source elsewhere (e.g. "." for a mod whose config.cpp sits at the repository root itself).

The portable half — dayz-mcp.toml

KeyMeaning
project.namewhat to call this project
build.modsone entry per mod, by name; source, pbo and @folder all follow from it
build.sourceswhere a mod's source really lives, relative to the profile ("." = the repository root itself)
build.excludewhat must never be packed. Listing it replaces the default wholesale; the default is .git, *.blend, *.blend1, .gitignore, .gitattributes, README.md, *.ps1
build.stagepack a filtered copy instead of refusing when something excluded is present — the layout a root-layout mod needs
build.project_rootthe directory every model path resolves against, relative to this file. It must contain the mod's prefix folder. Required by the model tools and by nothing else — see "The asset pipeline"
build.pre_scripta PowerShell script run before packing, for projects that generate code first
build.layout_classeswidget classes your own scripts declare (a modded map widget, say), so the layout lint does not refuse them as unknown
build.tokenswhere the layout generator's tokens file lives, if not the default ui/tokens.json — absolute, or relative to this file
expect.ready_linethe line the mod prints when it has finished loading — see below
expect.counterskey = value pairs carried by that line, compared numerically
expect.max_warningswarning budget; omit the key to disable the check
expect.forbidsubstrings that make a run bad regardless of anything else
expect.error_regexregexes marking script errors that belong to you, used by the client compile check
expect.noiseextra engine noise to ignore, on top of the built-in list
client.file_patchinglaunch the client with -filePatching and keep a junction <game>/<Mod> at each mod's source tree, so an edited .layout (or .c) is read without repacking -- the engine reads loose files from <game directory>/<pbo prefix>/..., not from inside the built @MyMod folder (measured). Needs machine.game to resolve; mod_build notes it and makes no link otherwise. Off by default; the stand's server config needs allowFilePatching = 1

The machine half — dayz-mcp.local.toml, never committed

KeyMeaning
machine.gamethe game installation; discovered automatically if absent
machine.servera dedicated server installation (Steam app 223350) to boot the stand from, instead of the diagnostic executable inside machine.game. Optional, and absent nothing changes. Set it when something must live beside the server binary — a proxy DLL, a different mpmissions — and that directory is also the one you play retail DayZ from. The mission named in the server config has to exist under that install's mpmissions
machine.toolsDayZ Tools; discovered automatically if absent
machine.blenderthe Blender executable, for asset_export only; discovered automatically if absent, and needed by nothing else
machine.stand_rootthe prepared test stand. The server boots against it and its logs are read from <stand_root>/profiles. Defaults to <root>/testenv
machine.configthe server config filename inside the stand (default serverDZ.cfg). It is a setting because a stand can hold a config that hangs forever after world-compile and a working one under another name; it must resolve inside stand_root
machine.portthe port server_start passes to the server (default 2302)
machine.windowclient window size, [width, height] in pixels, passed as -x/-y. Omit it to leave the size to the client's own DayZ.cfg
mods.requiredmod folder names, resolved under the game's own !Workshop folder — required = ["@CF"] means <game>/!Workshop/@CF
mods.extrafull paths to anything else to load, for mods that do not live in !Workshop
mods.server_onlyfolder names to route to -serverMod instead of -mod. Matching is by folder name against every mod being loaded, wherever it came from — mods.required, mods.extra or the project's own @Name folders; everything not listed goes to -mod. The diagnostic client never loads server-only mods, so the client compile check drops them

The ready line

server_start finishes when expect.ready_line appears in a log written after that boot began. It is the one thing the server cannot work out for itself, and without it two things change: nothing can be waited for, so the boot job starts the server, confirms it is still alive a moment later and finishes saying so; and log_verdict has no line to read counters off, so expect.counters never matches anything. Errors, crashes and the warning budget are still judged. A profile without a ready line is supported, not broken — project_open says so in its notes.

Tools

ToolWhat it does
project_open(path)read the profile, discover the game and tools, report what is missing
project_status()current project, running server, recent jobs
mod_build()pack and sign every declared mod; returns a job id. Refuses a second build of the same project while one is still running
server_start(timeout)start the test server, finish when it is ready. Refuses if the mission the config names is not under mpmissions beside the executable being launched (machine.server when one is declared, the game install otherwise) — the engine looks for missions beside the executable being run, not beside the -config. Returns the pid straight away — the process is spawned before the call returns, so the very next tool already sees a running server. Refuses if the game port is already held by someone else, and refuses on the spot if the image cannot be launched. Readiness comes from expect.ready_line when declared, otherwise from two engine signals together — the port bound AND the mission module compiled. The port binds about 17 s before the scripts do, and a boot called ready in between is listening with no mission: it answers queries and refuses every player. The job summary names which
server_status(pulse_seconds)pid, whether the process is alive, whether the log is growing (sampled pulse_seconds apart), and how long it has been stalled
server_stop(pid, force)stop the server this session started: closes its console window so it saves the world and exits, kills it only after 30 s or with force=True (optional pid for orphaned servers)
server_signatures(value)read — or deliberately change — the stand's verifySignatures. With no argument it only reports. It edits only the config the profile names as this stand's, refuses one that resolves outside machine.stand_root, refuses while a server is running against it, keeps the file's comments and line endings, and reads the value back out of the file afterwards
client_compile_check(extra_mods, wait_seconds)run the diagnostic client and read its logs
log_verdict(source, since)pass/fail with reasons: counters, forbidden strings, warning budget. source is "server" (the newest log in the stand) or "client" (the log the latest client_compile_check produced). since (an epoch timestamp, e.g. the since server_start returns) refuses a log written before the run being judged, so a stale log from an earlier boot cannot be mistaken for this one's result
log_tail(source, pattern, n)last lines, optionally filtered; same two sources
job_status(job_id)status of a long-running job
job_wait(job_id, timeout)wait for a job to finish
job_artifacts(job_id)retrieve outputs from a completed job
bridge_build()pack the bridge mod, whose sources ship with this server (bridge/), not with your project; returns a job id. Built unsigned — see below
bridge_status(window)is the bridge inside the running game still ticking: reports the tick number and whether it advanced over window seconds. Succeeds only for a tick that actually moved, or for a stand that restarted mid-sample
bridge_clear(force, probe_window)discard the command stuck in the mailbox, naming what it threw away. Refuses while the bridge looks alive unless force=True
world_ready(timeout)wait until the bridge inside the game is actually claiming commands. Call it once after the boot job finishes, before the first world command — see "server ready is not bridge ready" below
world_state(class_name, radius, pos)snapshot of the world from the bridge's once-a-second publish: players, position, health, hands. Free with no arguments; with class_name it also counts objects of that class nearby (one command round trip)
world_spawn(class_name, where, pos, quantity, slot)create an item on the ground (with no lifetime, so it cannot vanish mid-check), in the player's hands, in their inventory, or attached to the item they are holding (where="attachment", optional slot naming the CfgSlots slot)
world_teleport(pos)move the player to "x y z" — the same format world_state reports, so a read position can be handed straight back
world_set(what, value, target)set health (player or held item) or quantity (held item)
world_attach(class_name, host, slot)attach an item the player already has to another of their items. host is "hands", "player" (the character's own worn slots) or a config class looked up on the player; slot names the CfgSlots slot when there is more than one that fits. The mod reads the item's own inventory location back afterwards — the engine call's bool is about the call, not about where the item ended up — a tick later, because the move lands after the frame that asked for it, and names the slot the item actually landed in
world_detach(slot, host, to)take the attachment out of one slot and put it in the player's inventory (default), hands, or on the ground. The slot is required: a device can have several. In game this is a drag inside the inventory screen, which is not something a tool can ask for. Checks the slot is empty a tick later, for the same reason as world_attach. to="hands" while the hands hold something else is refused by name — the engine's own answer there is a bare false
world_move(class_name, to)move an item the player already has between hands, inventory (default) and ground — the drag inside the inventory screen a headless stand cannot make. class_name is "hands" for whatever is held, or a config class looked up on the player. Carry it, do not hold it is where every test of a worn or pocketed device starts, and world_spawn cannot get there. An ask that is already true comes back done, not failed; asking for the hands while they hold something else is refused by name
world_power(on, target, energy)switch a device on or off wherever it is on the player — worn, in a pocket, in hands — through its energy manager. energy optionally fills its own store first. The answer reads both facts back: switched on and working are different, and a device switched on with a flat battery has the first without the second
world_delete(class_name, radius, pos)delete objects of one class nearby. Requires the class; never deletes a real player
world_entities(class_name, radius, pos, limit)which objects are nearby, not how many: class, position, distance and health for each. A page, and it says so — the true total comes back beside the list
world_time_set(hour, minute, day, month, year)move the world clock. Every field left at -1 keeps its current value, read back from the engine first, because the engine sets a date as five numbers at once
world_weather_set(what, value, seconds, duration)move overcast, rain, fog, snowfall or wind. A nudge, not a lock: the engine keeps simulating weather afterwards, and both the tool and the mod say so
world_action(action_class, target_class, subject, radius, pos)run a mod's own action through the engine's gate — see below
world_exec(verb, args)the escape hatch: an arbitrary verb through the same transport, marked non-standard in every answer
client_start(timeout, extra_args, window, language)start the game client and connect it to the stand; returns a job id. Always windowed (window overrides the machine's own configured size for one launch). language, when given, is validated against the engine's own columns and rewritten as language="<Name>"; into the client's own DayZ.cfg before launch — every other byte untouched — since the engine reads it once, at its own startup, and has no such launch argument. Finishes when the bridge reports players >= 1 — a count, not a timer
client_status()pid, window geometry, whether the window is minimized or in front, the background setting, the player count, and whether a virtual controller is attached
client_stop()stop the client this session started, and unplug the virtual controller
client_shot(path)capture the client's window to a PNG, with lit_fraction — the number that tells a real frame from an all-black one. No focus needed
client_move(x, y, seconds)walk the character with the left stick. Analog, and the only tract that moves the character at all. No focus needed
client_look(x, y, seconds)turn the camera with the right stick. No focus needed
client_press(button, seconds)one gamepad button, from a closed table of fourteen names. No focus needed
client_trigger(which, value, seconds)pull an analog trigger — "left" or "right", travel in [0, 1]. This is how the weapon fires: DayZ binds FIRE to the right trigger and RAISE/AIM to the left, and neither is a button. No focus needed
client_chat(text, color)put a line in chat — delivered server-side by the bridge, so no keyboard, no window, no focus
client_type(text, submit)type into a client-side input field with real keystrokes. The only tool here that takes the foreground, and it says so in its answer
client_verdict(since)judge the live client by its own .RPT — an errors-and-crashes verdict; see below
ui_menu()what the client's interface is doing: open menu class, cursor, dialog. Free — republished every tick
ui_tree(root, depth, limit, offset)the client's widget tree: path, class, name, visibility, screen rectangle, depth, text and text size (engine pixels, TextWidget family only). A page, and it says so. root is "menu" (the default), "screen" or "workspace" (the same widget under the name that says what it is — the parent of every top-level window, menus and non-menus alike), "preview", or the name of a widget, which the client resolves against the whole workspace: a window a mod hangs off the workspace root is not a menu and has no reserved word of its own
ui_find(name, class_name, text, root, offset)the same walk, filtered in the client so the whole tree never has to travel — and filtered during the walk, before the 300-node ceiling, so a name is found wherever it is rather than only in the first page. total is what was walked, matched what passed the filter, and limit/offset page over the matches
ui_click(path, expect_name, expect_class, via)press a widget. via="script" goes through the open menu's handler with no focus; via="cursor" puts the real mouse on its rectangle and then asks the client what that did: under_cursor and hit (the engine's own hit test), menu_before/menu_after, windows_before/windows_after, and changed. The path-resolving probe's own "nothing was pressed" is kept as resolve_detail and no longer speaks for the click — it was written before the mouse moved
ui_cursor()what the real mouse is over right now, from the engine's GetWidgetUnderCursor: the widget with its screen path, the open menu class, and how many top-level widgets the workspace holds
ui_text(path, text, expect_name)write into an edit box, and read the value back out of the widget
ui_load(layout, fixture, host, depth, limit, offset)show a layout file in the client under a host of the bridge's own size, and list what the engine made of it. fixture fills it first — add rows, set text, show/hide, colour — from a dict, JSON text, or a project-relative .json path. The preview stays up for client_shot until ui_unload or the next ui_load; the HUD is hidden meanwhile
ui_unload()remove the preview ui_load put up, and give the HUD back
ui_preview(layout, fixture, host, live, name, root)a layout as the engine draws it, in one folder: a screenshot, every widget's rectangle, and the rectangle checks, with an HTML report over all three. live=False loads layout (fixture, host as in ui_load) and shoots the preview host; live=True shoots a root that is already on screen instead — the real thing with real data. root picks it: empty for the open scripted menu, "workspace" for every top-level widget at once, or a window's name, which is the only way to reach a panel a mod created under the workspace root rather than as a menu, with every .layout the project declares handed to the checks as its sources (notes says how many), so a self-sized label is not read as text_overflow just because nothing was loaded to compare it against. A drawn node is matched to its source by walking the source tree in the engine's own ascending-priority order (stable for ties), not declaration order, so a sibling reordered by priority still finds the right node. Pages past the 300-node ceiling on its own
ui_gallery(index, sizes, langs, strict)every entry of the project's preview index (preview/index.json by default) through ui_preview, into one index.html with every screenshot and its issue counts — the look before a push. Each entry is an object with name, layout, fixture and host keys (as ui_load takes them) or live: true in place of layout, with an optional root for a live entry that is not the open menu. A failed entry is recorded with its error rather than aborting the run, and counted in failed. sizes, a list of [width, height] pairs, restarts the client at each in turn before that round's entries run (the owner's 3840x1600, the players' 1920x1080); without it the client is used as it is. langs, a list of engine language names (validated the same way client_start's own language is), restarts the client into each in turn the same way — typically English and the mod's own language — so a layout that only overflows in translation is caught here; rounds are the product of sizes and langs, each entry's "language" key names its round's language ("" when none), and strict=True names failing entries as name@size@language once langs was given (name@size otherwise). Every restarted round is one whose size or language actually changed
layout_build(mod)generate every .layout a project describes under ui/<Mod>/*.json from the project's tokens file (ui/tokens.json unless build.tokens says otherwise) — one JSON per page, containers and tokens instead of coordinates (color/font/space/size are the fixed groups; device and any further top-level group a project names are read the same way — $group.name resolves to a number or a [w, h] pair; any other top-level key must likewise be an object, and a string value there is a note, same as note itself), list rows as their own files. The page root places itself like any other node: anchor: "center" centres the whole page in its host, at offsets it; a frame/panel root's at_frac offsets it as a fraction of the host instead, at an exact size, and does not mix with at, anchor or size: "screen". Writes only what changed (LF), refuses the first bad description with file, node and reason. mod_lint refuses a generated file that is behind its description (layout-stale). The same build without the server: python -m dayz_mcp.layoutgen <root> [mod]
mod_lint(mod, strict)judge the Enforce Script without packing or booting anything. mod_build runs it first and refuses on what it refuses; .layout files: quotes inside text, unquoted keys, unknown classes, ItemPreview priority (refuse), bare edit boxes, unclipped scrolls, duplicate names (warn); a generated .layout is rebuilt from its description under ui/ and compared: behind it refuses (layout-stale), generated but described by nothing warns (layout-orphan), a description that does not build refuses or a note it raises warns (layout-desc)
knowledge_build(layer, full, only)build or refresh a layer of the API index; returns a job id. only=[path] re-reads exactly the files you name
knowledge_status()what each layer holds, how old it is, and whether it still matches what is on disk
knowledge_find(name, kind, owner, layer, prefix, limit)find a class, method, constant, enum or config class by name
knowledge_show(name, ..., body)one declaration in full: signature, members, inheritance chain, and the source itself — read straight out of an archive if that is where it lives
knowledge_overrides(name, owner, layer)who overrides this class or method
knowledge_callers(name, kind, owner, layer)who calls this method or builds this class — every call site, with the class and method that made it
asset_export(blend, mod, source, name)export a model out of a .blend into build.project_root, headless; returns a job id. The optional first step — see below
asset_build(mod, source, deploy)binarize a mod's models from their MLOD sources, judge what came out, and only then put it in the mod; returns a job id
asset_check(mod, model)judge the models and textures a mod already ships. Builds nothing, needs no DayZ Tools, answers in milliseconds
asset_convert(source, output)convert one texture between .png and .paa, and judge the result
workshop_publish(mod, changenote, title, description, preview, previews, visibility, tags, content, requires, remove_requires)upload a built mod to the Steam Workshop; returns a job id. The content is the built @Name folder, whole; meta.cpp inside it decides between updating the item it names and creating one (title required; meta.cpp is written into the folder first; the new item is private unless visibility says otherwise). Sends title, description, preview (an image under 1 MB), visibility (public, friends, private, unlisted) and tags only when given, so an update leaves the rest as it was; content=False sends only those fields and leaves the item's files alone -- the way to fix a listing without shipping whatever the build folder holds; requires and remove_requires edit the item's Required Items, the section the launcher reads for dependencies, against what Steam lists now. Refuses a mod the project does not declare, a folder with no pbo or with a junction inside it, a meta.cpp it cannot read, a missing or oversized preview, a build or upload already running for the project, and a machine without the game's steam_api64.dll. Needs the Steam client running and logged in -- the job checks that, first. See "The Workshop"
workshop_status(mod)the built folder against the public listing of its item: pbos, which are unsigned, keys, links, size, when the newest pbo was built; the item's title, last update, size, visibility and subscribers; and stale -- built after the last upload. Goes nowhere near the Steam client, needs no key, and answers without a network, saying so

Layout primitives

Most of layout_build's JSON vocabulary (frame, panel, vbox, label, button, ...) reads as what it says. Two are worth a sentence each, because what they emit is not obvious from the description:

PrimitiveEmits
bara PanelWidgetClass track (track, default $rule) holding one child, <Name>Fill — position 0 0, size 0 h (exact, colour fill, default $accent), priority 1. The fill starts at width 0; a script widens it (bar.GetSize(w, h); fill.SetSize(w * value01, h)) — the generator never sees the fraction. h defaults to $size.bar. No children. A plain color is refused — track/fill are a bar's two colours
mapa MapWidgetClass with clipchildren 1 and nothing else — the engine's own map needs no more, and no priority line changes its paint order. No children: a map paints over them regardless

Windows and the ms-gamebar dialog. The first time a virtual controller is attached, Windows tries to open Xbox Game Bar. On a machine that has it, nothing is seen; on one that does not — LTSC, Server, or the Xbox packages removed — the shell cannot resolve the URI and puts up "Get an app to open this ms-gamebar link", once per attach. Nothing here causes it and nothing here can prevent it: the shell reacts to the device arriving, and does the same for a physical Xbox pad. So the call that plugs the pad in says so, with two remedies the machine's owner can apply — turning off Settings → Gaming → Xbox Game Bar → "Allow your controller to open Xbox Game Bar" (HKCU\Software\Microsoft\GameBar\UseNexusForGameBarEnabled = 0), or giving the URI a handler that does nothing.

Signatures, and why the engine's own message sends you the wrong way. Under verifySignatures = 2 a stand refuses every client with code 118 and "missing dta\bin.pbo" — a vanilla file name, with no mention of signatures at all. The cause is usually the keyring: this tool launches the diagnostic executable out of the CLIENT install, so the engine reads keys beside that executable, while dayz.bikey — the key that signs the game's own pbos — ships with the separate DayZServer install. A keys folder that is missing, or holding only a mod's own key, leaves the server unable to verify anything, vanilla included. client_start refuses and names which of the three it is; server_start only warns, because a headless boot with no client is still useful. An unsigned pbo on the client's -mod line is named the same way — including this server's own bridge, which is packed unsigned on purpose.

Three limits the engine imposes on the UI tools, none of them worked around: a plain TextWidget has SetText and no GetText anywhere in enwidgets.c, so a label's string cannot be read at all — what a mod's interface MEANS stays a question for the server-side bridge, where the data is real. A script-level click reaches only the open scripted menu, because Widget has SetHandler and no GetHandler; via="cursor" is there for everything else. And the client has to load the bridge: one pbo carries both halves, so a profile listing it under mods.server_only keeps it off the client's -mod line — that case is refused by name rather than answered with an empty tree.

job_wait is the tool meant to wait, and its timeout is capped at 600 seconds however large a value is passed. Two other tools sleep: server_status samples the log twice, pulse_seconds apart, capped at 10 seconds — that pause is how it tells a slow boot from a hung one — and bridge_status samples the bridge's tick twice, window apart, capped at the same 10 seconds, for the same reason. Everything else returns immediately; work that takes minutes happens behind a job id.

The bridge mod

bridge_build packs bridge/ from this server's own repository into @DZMCP_Bridge beside it. It is the server's mod, not yours: one copy serves every project, nothing is written into your repository except the job record, and no project's signing key is used — a -serverMod pbo is never handed to a client to verify, so it is built unsigned and its output folder is kept free of signatures and keys.

Building it does not load it. That stays your profile's decision, because the bridge is an extra pbo in the stand and a run without it has to remain possible. To attach it, add two lines to dayz-mcp.local.toml (the same two bridge_build's job summary prints):

[mods]
extra       = ["<path printed by bridge_build>/@DZMCP_Bridge"]
server_only = ["@DZMCP_Bridge"]

server_only is what routes it to -serverMod instead of -mod. Without it the stand boots perfectly well and bridge_status reports that the bridge never wrote any state — which is true, and easy to mistake for a broken bridge.

bridge_status also reports the command mailbox. Inside the game only the mod empties it, by claiming the command; on this side bridge_clear and server_start's pre-boot clearing do. So a command sent while the stand was down, or before the bridge was attached, is not discarded and does not expire on its own — it keeps blocking every later send, and a stand booted outside these tools would pick it up. server_start clears both transport files before every boot, so a server started through this tool never runs a command from a previous session; that is hygiene, not a substitute for knowing the command is there. The state comes back as stale_command, and bridge_clear() is the way out of it. Clearing is a separate tool on purpose: throwing away a queued command is a decision, not something a status check should do behind your back. It refuses while the bridge looks alive unless you pass force=True, and either way it reports the command id it discarded.

What bridge_status can tell apart

The tick alone is not enough to judge a bridge, because it restarts at 0 every boot while the state file survives in the profile directory. Every answer carries the channel's own verdict in heartbeat, and the four are genuinely different facts:

stateheartbeatmeaning
alivegrowingthe tick moved within one session — the only ok: true liveness answer
restartedrestarteda new world came up between the two samples: alive, not frozen, and anything sent to the old session is gone
frozenstalledthe same world seen twice, not moving — a script-side problem, so log_verdict is the next step
unknownunmeasurablea sample could not be read (or window=0): no comparison was made. Not a diagnosis — call again

Every answer that read a sample also carries session_id — the live world's id — and a restarted answer carries previous_session_id too, so a caller can say which world went away.

no_server, stale_command, no_state_file, invalid_state, unreadable_state and outdated_bridge come before any of that: nothing is running, a command is wedged, the mod is not loaded, the state document is valid JSON with a named field wrong (it says which, and checks twice before saying it), the file never parses at all, or it parses but predates this server's protocol (rebuild it with bridge_build).

The world commands

The world tools talk to the bridge over two JSON files in the server's -profiles directory: a command mailbox (written atomically from this side, deleted by the mod as its claim) and a state file the mod overwrites once a second. Enforce Script has no rename, so the mod cannot write atomically — the reader tolerates torn writes instead, and one failed read is never news. Four facts, all measured on a live stand, decide how to use them:

Server ready is not bridge ready. The bridge starts claiming commands tens of seconds after the server reports ready — the spread observed so far is 18–38 seconds, and it varies boot to boot. A command sent into that window is not rejected — it is claimed late and completes after the caller gave up. So: server_start, wait for the boot job, then world_ready(), then commands. Every world tool also refuses upfront if the tick is not moving, naming world_ready as the remedy.

Every argument value crosses the wire as a string. The mod's parser is strict: a JSON number anywhere in args rejects the whole args block. The tools stringify numbers and booleans themselves and refuse values with no faithful string form (lists, dicts, None). Positions travel as one string, "x y z".

A refusal is a result. The mod's own sentence comes back verbatim as the error: "no player is on the server", "the class does not exist", "the action's own Can() said no". Nobody connected is the normal state of a headless stand, and every verb that needs a player says so instead of silently doing nothing.

The session id protects against yesterday's command. Every command carries the session the bridge most recently published; the mod refuses any command addressed to another session (or none) without executing it. A command written while the stand was down can therefore never fire into a freshly booted world. The tools stamp the session automatically — it only matters if you write the mailbox by hand.

Measured on a live stand, three boots. world_time_set(hour=3, minute=7) moved the clock to 2026-09-20 03:07 and left the date where it was; world_weather_set("fog", 0.9, seconds=2) took the published fog from 0.085 to 0.900 and held it; world_entities(pos="7500 0 7500", radius=150, limit=5) listed 5 of 171 objects with truncated: true. Distances came back at 320 m for a 150 m radius until they were made horizontal, which is what the engine's own radius test measures.

Attachments, carrying and power are engine operations, not mod behaviour

world_attach, world_detach, world_move and world_power ship in this bridge rather than in a project's own copy of the dispatcher, and the line is worth stating: taking an item off a slot, putting one on, carrying one in a pocket instead of a hand, and throwing an energy manager's switch are the same calls whatever mod drew the device (FindAttachment, ServerTakeEntityAsAttachmentEx, ServerTakeEntityToInventory, ServerDropEntity, GetCompEM().SwitchOn). The rule under world_exec is about behaviour a mod defines; none of this is.

They exist because a worn device could not be reached at all: world_spawn attaches a NEW item to whatever is in HANDS, world_set knows health and quantity, and world_action needs the item in hands too — so on a live stand a flat battery could not come out, a fresh one could not go in, and nothing could switch a device on, which left every action whose condition reads IsWorking() untestable from outside the game. All three name the item the only way a tool that has never seen it can: "hands", "player" (the character's own worn slots), or a config class looked up on the player — hands first, then worn attachments and cargo, recursively, first match by IsKindOf. world_move covers the other half of the same gap: carry it, do not hold it is where a test of a worn or pocketed device starts, world_spawn puts an item in one place and leaves it, and the move between hands, inventory and ground is a drag inside the inventory screen — a gesture no tool can make.

Each one reads the result back out of the engine rather than reporting the call's own bool: whether the slot is empty now, whether the item's own inventory location says it landed in that slot, whether the manager says switched-on and working. That is the same rule as ui_text reading its field back, and for the same reason — SwitchOn() does nothing at all when the device cannot switch on, and says nothing about it.

An inventory move lands one tick after the frame that asked for it (measured 2026-09-07): ServerTakeEntityToInventory answered true and the slot still held the battery in that same frame, and the very next command found the slot empty and the battery in cargo — so a read-back taken where the call returns reports a failure for a move that worked. attach, detach and move defer their own verdict by a tick, the same DeferCompletion(1) ui_load uses to let a widget reach its first layout pass, and cost about a second more than the other world verbs for it. attach spends that tick on more than a yes: it names the slot the item landed in, read off the item's own inventory location, so a call that named no slot still comes back with the name world_detach will want.

One tick is not always enough (measured 2026-09-08). A move whose source is the hands does not go straight into the inventory: HumanInventory turns it into a HandEventMoveTo and posts it to the hand state machine, which applies it on the player's next command-handler frame — later than the bridge's next tick when the player is busy. Attaching the item held in hands to a player slot answered failed once for a move that had in fact worked. So the three verbs look again, up to five ticks (still inside the mod's own 30 s hard limit and well inside this side's 45 s wait), and the answer names the wait it spent: ... after 2 tick(s). A move that never lands is still refused by the mod in its own words, with the tick count, rather than by a timeout.

Actions, and why there is no verb dictionary

A semantic verb like "hand in the sample" lies: in a real mod the same words mean different things depending on which device is near, the player's faction, and what is already unlocked. That context is not enumerable, so the bridge does not try. world_action takes an action's class name, a target and the held item, and asks the engine to run it through its own gate — the same one a key press goes through. Applicability is decided by the action's own Can(), and its refusal is a meaningful test result, not a tool failure. The distinguishable answers: manager busy, player already acting, player sprinting, unknown action class, and "the action's own Can() said no". "Accepted" is not success either — the command stays running until the engine actually releases the action, and every failure path releases the manager so the player can still act afterwards.

world_exec is the escape hatch

Anything a mod exposes that is not an action — "how many points in the faction pool" — goes through world_exec(verb, args): an arbitrary verb over the same transport. Every answer is marked non_standard: this server does not know the verb, does not validate it, and does not answer for what the mod does with it. A verb the bridge build does not know comes back listing the verbs it does. A project that needs its own verb edits its own copy of the bridge's dispatcher (the comment above KnownVerbs() in bridge/scripts/5_Mission says exactly where); there is no registration machinery on purpose — a verb this server typed and validated would be a verb this server answers for.

The client: three input layers, and why there are three

The bridge reaches the server. What it cannot do is look at the client's screen or act through the client — walk a character across ground, open a menu, fill a field a mod drew. The client_* tools are that, and they use three different tracts because no one of them can do the other two's work. Every line below is a measurement against a live client, not a design intention.

TractWhat it doesNeeds the foreground
the bridge (world_*, client_chat)the world, and text into chatno
a virtual gamepad, ViGEmBus (client_move / look / press)movement, camera, and some interfaceno
real keystrokes, SendInput (client_type)text into a field that exists only on the clientyes, and it takes it

Keyboard emulation does not move the character, and window messages do nothing at all. SendInput scancodes with the foreground verified: 25 s of forward, 0 m. PostMessage/SendMessage WM_KEYDOWN into the main window and its children: 0 m, and no reaction from the menus either. The engine reads movement from raw input and ignores emulated keys, which is why no tool here offers a window message.

The virtual gamepad does move it, unfocused, and it is ANALOG — the reason it stays even where a key would do. Measured in one run with a third-party application holding the foreground throughout:

stick fully forward,   10.0 s  ->  38.40 m   (3.84 m/s)
stick at 0.3 forward,   8.0 s  ->  11.34 m   (1.42 m/s)

Same tract, same character, 2.7× the speed from stick deflection alone. "The character is walking, not running" cannot be expressed with a key, which knows only on and off. In the same run the character walked about 141 m of its own accord, and the mod's own count of objects within 10 m of it went 1 → 0 → 1 as it left the spot and came back — a state change caused by presence, which a teleport cannot produce.

Some of the interface answers the pad, and some does not. Measured, with the game window behind another application the whole time: back opens and closes the inventory, start opens the pause menu, b closes it — all at the default 0.1 s tap, so a tap is long enough for the engine to latch. But a moved nothing, at 0.1 s or at 0.5 s, and neither did the d-pad inside those screens: the client did not switch to controller-navigation mode, so there was no focused element for a confirm to act on. Treat menu dismissal as a gamepad job and menu confirmation as unproven.

The eyes need no focus either. A capture is a live frame with the window at the very bottom of the z-order (lit_fraction 0.9997 unfocused, 0.9997 focused in the same session). The one state that defeats them is a minimized window, whose client area collapses to 0×0 — refused with a reason rather than saved as a valid-looking empty picture.

All of that background behaviour rests on one client setting, pauseMode (GAME → UPDATE IN BACKGROUND). At the value measured here the client keeps drawing and simulating while unfocused, which is why the frame is live and the stick still moves the character. At "no graphics" both would stop silently — a frozen frame looks exactly like a live one. So client_start and client_status READ that setting and warn; they never write it, because it belongs to whoever owns the machine.

Two more profile settings ride along on the same launch line. client.file_patching adds -filePatching (the stand's server config needs allowFilePatching = 1 or the client is refused at connect), and machine.window — or a per-call window= override — adds -x/-y.

client_type is the only tool that takes the screen, and it is honest about it: the answer carries foreground_taken and a sentence saying the person at the machine could not type into their own window while it ran. It verifies the foreground with GetForegroundWindow after asking for it, because SetForegroundWindow returns success having done nothing when Windows refuses — and typing blind sends the keystrokes into whatever window the person is actually using. When the foreground cannot be had, nothing is typed and the refusal names the process holding it.

ViGEm is emulation of a real device and this is a test stand. The driver is signed and installs without a reboot, and the gamepad is a new device rather than a filter over the machine's own keyboard and mouse — a filter driver was tried here once and cost the machine's owner all keyboard and mouse input until it was unwound by hand. None of that is a promise about anticheat on a live server, and nothing in this phase makes one.

The knowledge index

An agent writing a mod keeps asking the game the same questions: is there such an API, what is it called, where is it declared, who overrides it. Answering them meant unpacking scripts.pbo and sweeping the text — and every session paid again. knowledge_* turns that work into a question.

It is a plain SQLite file in the project's own .dayz-mcp/, built by this server out of the game, the mods a project declares and the project's own sources. No embeddings, no external service, no key.

Three layers, and why their rhythms differ

LayerSourceGoes stale when
corethe game: dta/scripts.pbo for the API, Addons/*.pbo for the item classesthe game updates
depsthe archives of the mods the profile declares, read without unpacking thema dependency is updated, or the declared set changes
projectthe mod's own sources, read where they lieevery edit

One index built in one go would be wrong within a minute of being right: the game moves a few times a year, a dependency a few times a month, and the project between one agent turn and the next. So each layer is built, aged and measured on its own, and every build is incremental — unchanged sources are skipped by size and modification time, and only=[path] skips even the walk that discovers them.

An answer carries the age of the layer it came from

Staleness is measured, not guessed: a layer records the size and modification time of every source it read, and that is compared against the files as they are now.

  • Every answer names the layers it used and how old each one is. An answer with no results names every layer it searched — "not found" is worth exactly as much as the layers behind it are current.
  • The project layer's freshness is measured on every search, whether or not it contributed. That is the dangerous case: an agent adds a class, asks about it, and a layer built a minute ago says "not found" — a confident statement about code that exists.
  • A search over a layer that was never built is refused, and the refusal names the call that builds it. "Not found" and "not looked" are different facts, and only one of them is safe to act on.
  • Narrowing carries the same trap one level down, so an empty narrowed answer reports where the name does exist: asking kind='class' about a name the game declares only in a config gets a true "no" that reads as "the game has no such class".

Config classes live under kind='config', not kind='class'. Counted in this machine's own index of the game: 88 102 config classes against 43 595 script declarations of every kind put together, so mixed into one kind they bury every script answer. Separated, "does the game have an item class called X" is a question you can ask exactly.

What it does not answer

The index answers what exists: class, method, signature, where declared, who overrides. It does not answer what is right — that modded class X extends X compiles and silently fails to apply, that _co costs the alpha channel, that binarize takes directories rather than files. None of that is derivable from the sources; it was learned the hard way and lives in the modding skill and in the mod itself. The index does not try to replace either, and it does not try to understand what a field means or why a class is there.

Semantic search is deliberately not here

The decision was made by measurement, not caution: every lookup that shaped this server's earlier phases was a lookup by name. And an embedding index would break the rule the rest of this server keeps — install it and it works, with no external service and no key. The predecessor project this one deliberately did not build on documents its knowledge layer as local and free while its code imports a paid embedding client, fails without a key and carries hard-coded prices. Its two search tools also hang forever, because the client behind them was created without a timeout; hence the ceiling every search here runs under. If exact search turns out not to be enough, semantic search is a separate phase with one condition: the model ships inside the delivery.

The measured numbers

On this machine — the game with 2810 script files, 35 installed mods, one real project of 41 sources — through the tools, not their internals:

BuildResultTime
core2927 sources, 131 697 declarations (41 gave nothing)70.2 s
deps, four declared mods8 archives, 10 925 declarations0.9 s
deps, every mod installed here523 archives, 204 768 declarations, 3 archives unreadable and named139–147 s
project41 sources, 1196 declarations0.12 s

The index on disk: 74.7 MB for a real project's three layers; 110 MB for the 523 dependency archives on their own. Those archives are 92 GB, and none of them is unpacked.

Call sites are what the index pays for. The game's own scripts hold 43 579 declarations and 113 703 call sites, and recording the second set roughly doubles the index: measured on the game layer alone, 23.8 MB and 3.7 s to build without them, 49.6 MB and 4.5 s with. That is the price of being able to answer "who calls this", and it is stated here rather than discovered later on a full disk.

AnswerTime
knowledge_find, exact name4.2 ms end to end, of which 3.0 ms is the project walk
knowledge_find, prefix, limit 5003.2 ms of query
knowledge_overrides4.2 ms
knowledge_callers, 23 call sites out of 113 7030.38 ms of query
mod_lint on a 76-file mod277 ms of text checks, 7 ms of index checks
knowledge_show, a class with 400 members and its ancestry6.8 ms
knowledge_status, all three layers measured41 ms (110 ms on the first call after a build)

Incrementality, on the real project: a full rebuild 136 ms; one edited file found by the walk 8.8 ms (15×); the same file named through only= 5.8 ms (23×). On a 2810-file tree the walk dominates and only= is worth far more — but on a project of this size, 15× is what an ordinary rebuild actually buys.

The ceiling bites for real: a query measured at 77 ms, run under a 19.3 ms ceiling, was stopped at 19.9 ms, and the connection went on answering.

The asset pipeline

Getting a model from Blender into a mod is ten steps, and until this phase all of them were run by hand. The value is not in launching the tools. It is that every tool in this chain is structurally unable to report failure, and each of those silences had already cost days.

Measured on the real binaries, not assumed:

What happenedWhat the tool returned
binarize handed a file where it wanted a directory0, an empty output directory, not one line of text
binarize with a material that failed to load0, an ODOL of 46,190 bytes where a correct build is 58,644
binarize handed an already-binarized model0xC0000005 and a zero-length file in the output directory, on top of whatever was there
the Blender exporter with its own default argumentsFINISHED, exit 0, a valid MLOD carrying 2 of the model's 5 LODs, and no mention of it in 169 lines of log

So the rule this whole namespace is built on: the verdict is read off the artifact, never off the tool's report. The exit code is recorded and believed in neither direction.

The root is declared, not assumed

binarize has no project-root option at all — the full switch list was enumerated against the real binary. The root is the working directory of the process. The same command, the same input, a different directory, and out comes a valid ODOL with plausible texture paths that the engine renders untextured, with a success code and no complaint. The exporting Blender add-on has the same root in a preference of its own, remembered from whatever project was open last: on the machine this was developed on it pointed at a directory from an unrelated session, and against a wrong root the add-on does not fail either — it strips the drive letter, keeps the rest, and writes paths that look like paths.

build.project_root is that directory, stated once in the portable half of the profile. The server makes it the binarizer's working directory — through a drive letter of its own, see below — and pushes it into the add-on for the duration of the run, so what the add-on has stored decides nothing (it is reported, so you can go and fix it). That is what makes a wrong root impossible rather than detectable, and it is why the key is required before anything model-shaped will run at all.

The refusals it produces happen before a process exists — measured at 0.0003 s — and a refused build leaves the model the mod already ships byte for byte untouched.

A drive of its own for every build

binarize reads the whole drive before it reads a model. Read off the running process: for the first minutes its one busy thread sits inside FindNextFileW with a handle on the root of the drive its working directory is on, and nothing is read from the model until that walk is over. So how long a build takes is the size of the disk, not of the model:

binarize started fromOne small modelFive small models of another project
the model root itself, a folder on a large drive650.8 s510 to 540 s each
a drive letter substituted for that folder0.1 s0.1 s each

asset_build therefore maps a free drive letter onto build.project_root with subst, starts binarize from it with the source named on the letter, and removes the letter when the process is gone. A letter whose root is the declared root is that root, so nothing about the artifact changes: C3 and C4, the two checks that only a correct working directory can pass, pass, and the files come out the same size to within three bytes. The job's artifact records the directory the tool really ran from (cwd).

Every build maps a letter of its own and removes only its own — one that already points at the same root may be another server's build in flight. With no letter free the build runs from the folder itself, as correct as before and as slow, and its summary says so, because minutes spent walking a drive look exactly like a hang. The walk goes through junctions too, so whatever is linked into the root is walked with it: an unpacked game tree of 103,359 files turned the tenth of a second into half a second.

Twelve checks on the artifact, and four of them refuse

asset_check runs them without building anything and without DayZ Tools, because a fresh clone must be able to ask whether what it is shipping is healthy. Four refuse: a built model is there and is an ODOL (C1), no reference escapes the mod (C3), a material was actually inlined (C4), and nothing already binarized is offered back to binarize (C10). The rest warn: dangling references, an rvmat pointing into another mod, a transparency lost to DXT1 (C7), an animation that never reached the artifact, a model.cfg that is not the one the artifact was built from, a structural fingerprint that no longer matches what the last build deployed. Every finding says what to do.

C4 is the one worth knowing about. When binarize resolves an rvmat it copies that material's own stage textures into the model — fresnel, #(argb,8,8,3), env_land_co.paa, _nohq, _smdi — strings no MLOD contains. Six artifacts out of six were separated correctly by that one test, and it found a broken model on this machine that nobody knew about.

The Blender step is optional

asset_export is the only tool here that needs Blender, and everything downstream works on a .p3d from anywhere — a hand export, a partner's file, a model committed years ago. A machine with no Blender builds and ships a mod perfectly well; the refusal says so rather than presenting it as a broken installation. It does need the exporting add-on to be enabled in the Blender it finds, and it never writes Blender's user preferences back (verified: the preferences file was byte-identical after every run).

Export and build are two calls rather than one, because each half has its own verdict and a build refused by one and allowed by the other is not a decision.

Byte-equality is never promised

Neither half of this pipeline is reproducible, and the design says so instead of pretending:

  • The export. Seven exports of one unchanged source file — three from one session, three from another, and one made by hand in the GUI months earlier — gave seven different SHA-256s at a constant 334,032 bytes. The difference is the order of one internal block.
  • binarize. Four runs on one unchanged input gave three different results: the size moved by 5 bytes and two 8-byte fragments leaked out of compressed regions.

So a model is never cached or compared by content hash. What is compared is a structural fingerprint — the file's kind, its LOD count and its set of names. Across all seven of those exports that fingerprint was one value.

The measured numbers

One small model, on this machine, through the tools:

StepResultTime
asset_exportMLOD, 334,032 B, 5 LODs, clean2.1 s (about 8 s on a cold start)
asset_buildODOL v55, 58,646 B, 4 LODs, all five C4 markers0.1 s in binarize, from a drive letter of its own (from the folder itself: 650.8 s the same day, 43.8 s and 75.6–78.7 s on earlier runs)
asset_check1 model and 10 texture pairs judgedmilliseconds
asset_convertone PNG to DXT1, 50,764 B0.52 s
a refusal on a wrong rootbefore any process is started0.0003 s

Both logs are almost entirely boilerplate, and what is muted is counted rather than dropped: Blender's 169 lines came down to 4, and binarize's 91 to 6 — one of those six being the model's only genuine complaint.

Chained end to end, the export and the build reproduced a model that had been made by hand months earlier: same kind, same 4 LODs, the same 50 strings, and a size one byte apart.

What none of it answers

Whether the model looks right, is scaled right, is wound right, has a collision. Nothing outside the game answers that. C1–C12 shorten the road to it; they do not replace it.

The Workshop

workshop_publish is the last step of a mod's life on this machine and the only tool here whose effect is somewhere else, so it is built the way server_signatures is: everything that could publish the wrong thing is refused before a byte leaves, the answer says what is about to happen, and the item is read back afterwards rather than taken on Steam's word.

What goes up is what mod_build made. The @Name folder, whole -- addons/ with the pbos and their .bisign, keys/, mod.cpp, meta.cpp -- which is also what Publisher uploads. A junction or symlink inside that folder is refused rather than followed: Steam would upload the tree behind it, and the tree behind it is usually the sources.

meta.cpp says which item. Publisher leaves three lines in the folder it published from (protocol = 1; publishedid = N; name = "...";) and the launcher reads the id back out of them. The copy subscribers receive adds a fourth, timestamp = <int64>;, and CRLF line ends (read off a subscribed item 2026-09-19; the fourth decodes as the .NET DateTime.ToBinary() of the moment Publisher wrote the file, and nothing reads it back); the reader here takes either shape, and this server writes the three lines the id lives in -- Steam took that file for an update the same day. Otherwise it does the same. With the file, the item it names is updated; without it, an item is created and the same three lines are written into the folder before the content goes up, so subscribers receive the id inside the folder, and Publisher can carry on from a folder this server created. Keep that file with the sources -- a deleted build folder takes the item id with it. A meta.cpp that exists but carries no readable id is refused, not treated as absent, because a guess there publishes a duplicate.

A new item is private. Unless visibility says otherwise, an item is created hidden, so nothing is public before its page has been looked at; workshop_publish(mod, visibility="public", content=False) opens it without touching its files. An update leaves untouched whatever it was not given: no title means the title stays.

Required items are a section, not a sentence. The DayZ Launcher pulls an item's dependencies from the Workshop's Required Items list, not from its description, so workshop_publish(mod, requires=[...], content=False) puts Workshop ids there (ISteamUGC::AddDependency) and remove_requires takes them off. The job reads the item's current list first, through a details query with children, changes only what differs, and reads the list back into the summary -- a repeated call changes nothing. A refused change fails the job by name, after any listing update in the same call has already gone through, and the summary says so.

workshop_publish(mod, previews=[...], content=False) adds pictures to the item's screenshot strip (each under 1 MB, png/jpg/gif, relative to the project or absolute). Every call adds: Steam keeps what the page already shows, so a picture is sent once, and taking one down is done on the item's page. The binding is the flat export SteamAPI_ISteamUGC_AddItemPreviewFile of the game's DLL, measured 2026-09-27: five images accepted in one update, shown on the page.

content=False edits the listing and nothing else. Title, description, preview, visibility and tags go up; the folder's files do not, so the build folder may hold anything -- an unreleased build, no pbo at all -- and only its meta.cpp is read, for the item id. It is the way to fix a description without shipping whatever happens to be built. It cannot create an item, because an item without content is nothing, and it refuses when nothing was given. Sending the content again is cheap in any case: Steam moves only the files that changed (the first live update here, an unchanged 0.55 MB mod, took 10 s end to end and uploaded one 58-byte file, measured 2026-09-19), but it does stamp a new update on the item's page.

How it reaches Steam, and why from another process. There is no HTTP route for Workshop content, and Publisher has no command line -- measured 2026-09-19, its binaries carry the strings of a WPF dialog and nothing else -- so the upload goes the way Publisher's own does, ISteamUGC through the running Steam client, using the game's own steam_api64.dll, found beside the game the way FileBank is found beside the tools. No SDK is shipped or needed: the DLL exports the flat C API, and steamugc.py binds the dozen calls an upload takes with ctypes, trying interface versions newest first so a DLL from a newer game build still answers. That module runs as its own process per upload (python -m dayz_mcp.steamugc spec.json result.json), because SteamAPI_Init marks the account "in game" for the process's lifetime, a fault inside the DLL takes its process down, and a second Init after Shutdown in one process is not something Valve supports. The app id is the game's, the same number Publisher runs under, handed to the DLL both ways it looks for one -- the environment and steam_appid.txt in the working directory. Measured the same day: from a plain Python process, with the client running, SteamAPI_Init succeeds, the app id reads back, and the two result structs are the 24 and 16 bytes #pragma pack(push, 8) makes them.

What Steam needs from the machine. The Steam client running, logged into the account that owns the game and the item. That is the one refusal the job makes rather than the tool -- SteamAPI_Init is the only thing that can answer it -- and it is the first thing the job does, before anything is sent. An account that has not accepted the Workshop legal agreement can still create and update, but its item stays hidden until it has; Steam says so in the result, and the job summary repeats it with the link.

Read back. After a successful submit the job reads the item through the public GetPublishedFileDetails listing -- no key, no login -- and the summary says what it showed: the title, when it was last updated, and whether that is this upload. A private item is not in that listing at all, and the summary says that instead. workshop_status is the same read on demand, beside what is on disk, with stale -- the newest pbo is younger than the item's last update -- as the one fact derived from both.

Known limitations

  • A Workshop upload needs the Steam client, and the account it is logged into. workshop_publish cannot log in and must not: it uses whatever session the running client holds, and the job refuses when there is none or when that account does not own the game. The public listing it reads back does not show private items, so a freshly created item reads as "not visible" until it is made public.

  • Stale-pbo detection is mtime-based, not content-based. mod_build refuses a freshly built pbo that is older than its sources -- the usual cause is a running server still holding the old file open, so packing silently produced nothing. But git checkout changes a file's modification time without changing its content, so a perfectly good pbo built right after switching branches can trip this check too. If mod_build reports "stale pbo" immediately after a branch switch, this is the likely reason, not a real packing failure -- rebuild and it will pass. A mature tool in this space moved to a content hash for exactly this reason; that is future work here, not done in this phase (see packer.py, pack_one).

  • A mod source folder is packed whole. mod_build refuses to pack a mod whose source directory contains anything matching build.exclude (the seven-pattern default is listed above) rather than silently shipping it inside the published pbo. It refuses regardless of build.exclude when the source contains this server's own artifacts -- the signing keys, either half of the profile, the job store, the mod's previous build -- because packing those publishes the private signing key, and no project should have to configure that away. By default it does not stage a filtered copy first: a copy is always newer than the sources, which would permanently disable the stale-pbo check above if that check measured the copy. build.stage = true opts into copying anyway -- safe only because the stale-pbo comparison always measures the original source tree, never the copy. This is the layout a mod whose source is the repository root needs (it always contains at least .git).

  • A verdict judges the whole log, not just your mod's lines. log_verdict reads the log of the stand it is pointed at, so a stand shared with other mods counts their warnings against your expect.max_warnings budget and their errors as reasons. Two projects sharing one machine.stand_root will see each other's baseline. Either give each project its own stand, or set the budget knowing what else is loaded. A project-scoped filter, symmetric with expect.error_regex, is the obvious refinement and is not implemented.

  • expect.noise cannot rescue a line that already counts as an error. Classification is ordered forbid → crash → error → noise → warning, so a line containing ERROR or FATAL (or one of your forbid strings) is decided before noise is consulted. That order is deliberate -- noise matching first would let an innocuous substring swallow a fatal line -- but it means noise can only suppress warnings and ordinary lines, never demote an error-level one.

  • client_verdict is an errors-and-crashes verdict, not a readiness one. [expect] describes the server's log: its ready line and counters are printed by a mod's server-side init, and max_warnings is a budget counted over that same log. A client .RPT contains none of it, so those three keys are deliberately not applied here and the answer lists them in not_applied. forbid, error_regex and noise are about the text of a log line and still apply. There is no client-side ready line to declare; whether the client got in is answered by the player count client_start waits on, not by its log.

  • The client tools join a stand this session did not start; client_chat cannot. client_start will happily connect to whatever is already on the port, and says whose it is. But chat is delivered server-side, through the same channel as the world_* tools, and that channel acts only on a server this session started — so on a borrowed stand everything except client_chat works. The refusal names the pids holding the port rather than suggesting a server_start that would refuse them.

  • Chat is not reachable from the gamepad, and confirming a menu is not either. The game binds its chat line to Enter and nothing else, and there is no on-screen keyboard, so text is either a bridge message (client_chat, free) or real keystrokes (client_type, costs the foreground). client_type("", submit=True) sends Enter alone, which is how the chat line is opened — and, on the evidence above, the only confirm the tool set has.

  • Every knowledge search pays a walk of the project tree. That walk is how the project layer's staleness is measured on every answer, which is the one property the index exists for. It costs 3.0 ms on a real 41-source mod (whose tree holds about 1800 entries) and 21 ms on a tree of 2810 files. Caching it for a second or two would remove the cost and restore exactly the window of silence the design refuses; if it ever becomes too expensive, that trade has to be made deliberately, not by accident.

  • A build always goes through a job, and the job costs more than a small build does. Turnaround measured at 70–95 ms against a 6 ms project rebuild: job_wait polls at 100 ms. The single shape is deliberate — a caller must not have to know which build blocks — and nothing forces you to wait, because the next search measures the layer itself.

  • The dependency layer is measured against the profile as it is now. Add a mod to mods.required and its archives arrive as added; remove one and its archives read as missing. That is the requirement (the declared set is part of what the layer is built from) but it looks like the index went stale when what actually changed was the profile.

  • core always includes the game's configs, and that is most of its cost. 70 s with them against about 4 s for the scripts alone. There is no switch: without the configs "is there an item class called X" cannot be answered, and a second axis would make the staleness measurement ambiguous — the walk would not know whether to expect the Addons archives.

  • knowledge_show answers nearest-layer-first. For a class a dependency reopens with modded class, the mod's declaration comes before the game's. That is the right order and a surprising one; pass layer='core' for the game's own.

  • Conditional compilation is indexed, not resolved. 4.9% of the game's script lines sit inside #ifdef, including about a hundred class declarations. This server drives server, client and diagnostic builds, so there is no single correct set of defines: everything is indexed and the guard is recorded on the declaration. A name can therefore be reported that a particular build excludes — the alternative, filtering by one guess at the defines, would deny the existence of methods that are in the running build.

  • C12's fingerprint carries the file's size, and binarize's size is not stable. Rebuilding a model that nobody edited produced an artifact one byte larger than the shipped one, with the same kind, the same LOD count and the same fifty strings — and a different digest, because the size is part of it. The step is not always that small: of five rebuilds in a row of another unchanged model, four landed within three bytes of each other and one came out 230 bytes larger, with the same LODs and the same strings. So C12 can warn about a rebuild that changed nothing. It warns rather than refuses for exactly this reason, and the parts it is built from are reported beside it so the comparison can be made by hand. Splitting the digest into a stable half and a size is the obvious refinement and is not done.

  • A build that is killed outright leaves its drive letter behind. asset_build maps a letter onto the model root for the run and removes it when the run is over. If the server process itself is killed in between, the letter stays until the next logon (subst Z: /D removes it by hand). The next build takes another letter instead of borrowing that one, because a letter that already points at the same root may just as well be a build in flight in another server, and nothing tells the two apart.

  • A partial export warns; it does not refuse. With the exporter's own default arguments a model came out carrying 2 of its 5 LODs and passing every other check. This server does not pass those arguments, so it should not happen — but an object marked as a LOD and not linked into the scene counts on one side of the comparison and not the other, which is a legitimate reason for the counts to differ, so a refusal would have false positives. Read E3.

  • The containment rule cannot see every wrong root. It refuses a root that does not hold the mod's prefix folder, which is the measured failure. A root that does hold a folder of that name — a repository whose own mod directory is spelled like the prefix, for instance — passes it, and what catches that case is C10 or C3/C4 one layer down. Measured: pointed at such a root, the build refused, deployed nothing and left the shipped artifact untouched, but the refusal came from the job rather than from the call.

  • asset_export needs the exporting add-on enabled in Blender, and cannot install it. Blender is launched with the machine owner's real preferences, because starting it with --factory-startup takes the add-on away entirely. Their other add-ons are kept off the search path for the run (two of the ones installed here reach the network as they start and are blamed for crashes), which Blender reports as "Add-on not loaded" in the log — that line is this server's own doing, not a fault.

  • A binarised config has no body to show. knowledge_show(body=True) reads a declaration back out of the file or archive it was indexed from, but a config.bin holds the binary form while the index holds what CfgConvert made of it. The answer says so instead of returning nothing.

Testing

python -m pip install -e ".[dev]"
python -m pytest

That is the hermetic suite: everything runs against fixtures, on any machine, in about eighty seconds.

Corpus tests

A second half of the suite runs the readers and the checks against REAL artifacts — a binarized model that works in game, one built from the wrong directory, a texture whose alpha survived conversion and one whose did not, an unpacked scripts.pbo, a folder of installed mods. Those artifacts are not in this repository and never will be: they are gigabytes, and most of them belong to somebody's mod rather than to this server. The tests find them through environment variables named after the PROPERTY under test, never after the mod they came from, and skip when a variable is unset.

Set them once per machine: copy tests/samples.local.example.toml to tests/samples.local.toml (git-ignored) and fill in your own paths. That file is read by tests/conftest.py before any test module is imported — which is when the skips are decided — and never overrides a variable the environment already carries, so a one-off DAYZ_MCP_SAMPLE_ODOL=... pytest still wins.

The cheap half then runs by default. The expensive half — anything that launches DayZ Tools or Blender, or indexes a whole corpus — carries the corpus marker and stays out of the default run, because it takes minutes rather than seconds:

python -m pytest -m corpus

The one sample a normal machine does not already have is the vanilla layout corpus, because the game ships it packed. Unpack it once with this server's own formula, then point DAYZ_GUI_LAYOUTS at the result:

python -c "import subprocess; from pathlib import Path; from dayz_mcp.packer import bankrev_cmd; from dayz_mcp.paths import BANKREV_REL, find_tools; subprocess.run(bankrev_cmd(Path(find_tools()) / BANKREV_REL, Path(r'<game>/dta/gui.pbo'), Path(r'<corpus>')))"

<corpus>/gui/gui/layouts is then the directory to name. If you regenerate data/layout-vocab.json at all, regenerate it from the same unpack, so the vocabulary and the parse test are talking about one build of the game.

Install

python -m pip install -e ".[dev]"

Register in your MCP client:

{ "mcpServers": { "dayz": { "command": "dayz-mcp" } } }

Licence

GPL-3.0-or-later. See NOTICE.md.

↑↓ navigate openesc close