The coney-tools command line¶
coney-tools is Coney's own command line (see Conventions). This page covers twenty groups:
check, which runs what CI would fail on for the files a change touched, docs, which checks
pages against the documentation standards, build, which
configures, builds and tests the engine under the machine's build slots, repo, which checks
the repository's own rules (titles, descriptions, labels), wad, which reads the game's
archive, WARRIORS.DIR and WARRIORS.WAD, from your own disc, extract, which turns every asset of
the disc into open formats, audio, which reads its sound data, movies, which reads the movies'
headers, xbox, which reads the Xbox disc's archive, progress, which keeps the progress tables
of the README and the docs current, orig, which indexes the @orig tags for the fidelity audit,
natives, which renders the script-binding masterlist, refs,
which builds the game reference lists, missions, which builds the mission status pages, pcsx2
and trace, which record the original's per-update traces in PCSX2 and compare them with Coney's,
clips, which checks, publishes and cleans up in-game video clips,
mcp, which starts the debugging servers for agent tools, doctor, which checks a
contributor's machine and disc, ghidra, which sets up your own Ghidra project with our function names, and
workspace, which sets up the local workspace. Everything is read in place and streamed, so the 1.4 GB
WAD is never loaded into memory. The format is described in WARRIORS.DIR / .WAD.
Run the commands from inside the checkout:
Naming the disc¶
Every wad command takes the disc as an optional first argument, DISC:
- a folder: a mounted disc (
H:\on Windows,/mnt/discon Linux) or any folder that holdsWARRIORS.DIR,WARRIORS.WADand, fornames,SLUS_212.15; - an
.isoimage (ISO 9660, 2048-byte sectors). Coney reads it with its own small reader.
Without DISC, the tools use game_dir from your coney.local.toml (see
Local workspace). Problems print one line, coney-tools: <what is wrong>, and exit with code 2.
info¶
Prints the entry count, the total size of the entries and of the WAD, a count of entries by their first four bytes (a rough content breakdown; printable ones show as text) and how many entries a names file resolves.
list¶
One line per entry: index, offset, size, the hash as 8 hex digits and the name when the names file knows it.
names¶
Recovers names: it takes file-name-like strings from SLUS_212.15 and from every WAD entry, hashes each as
./ee_files/<name> (CRC-32 of the lowercased text, Name hashing) and keeps those that
match an entry. It writes one name per line to the output file and prints how many it found. It takes a few
minutes because it reads the whole WAD. Whether a names list may be published is an open legal question, so the
tool always recovers the names from your disc and the list is never committed.
wad extract¶
uv run --project python coney-tools wad extract [DISC] ../../scratch/wad [--names FILE] [--only HASH_OR_NAME ...]
Writes entries into the output folder. A file is named by its recovered name, otherwise <hash>.bin. --only
takes entry hashes (8 hex digits) or names (global.lua), and without it every entry is written, about 1.4 GB.
Game data stays out of the checkout
extract and names refuse an output path inside the repository, because extracted files are game data and
must never be committed (LEGAL.md). Use a
folder beside the checkout, such as ../../scratch/.
scenes¶
Parses every record scene_list.cnk lists (scene headers and the segments of long scenes) with the layout on
Scenes and prints counts and hashes only: records parsed and failed, roles, objects,
cameras and lights, total frames, broken segment chains, how many headers' frame counts differ from their parts, the
event types seen, and SHA-256 digests of the list and of the records. Records whose list names are cut to 16
characters are found by content. Exits 1 when a record fails to parse.
extract: every asset in open formats¶
uv run --project python coney-tools extract [DISC] OUT_DIR [--only TYPE ...] [--verify] [--jobs N] [--xbox XBOX_DISC [--xbox-index FILE] [--xbox-texture-map FILE]]
Reads your disc once and writes every asset into OUT_DIR (outside the repository, like wad extract): the
folder Coney's install step will produce, and that the engine and mods will read. Each type has its own top folder:
| Type | Writes |
|---|---|
disc |
disc/: the executable, the IOP modules and image and SYSTEM.CNF, copied; disc/files.json lists every disc file with its size and SHA-1 |
movies |
movies/*.bik, copied, and movies/index.json (size, frames, rate, audio tracks) |
audio |
audio/sounds/<name>.wav (streamed sounds, named as the game names them, unnamed/<hash>.wav otherwise), audio/banks/<bank>/<name>.wav, audio/music/<track>.wav, and sounds.json, music.json, classes.json |
scripts |
scripts/<name>.lua: the compiled Lua chunks as stored (not source), and scripts/index.json |
textures |
textures/<dictionary>/<texture>.png, the streamed world's in textures/worlds/<file>/, and textures/index.json (sizes, depth, mipmaps, filter, addressing, sprite rectangles) |
models |
models/<name>.gltf and .bin: every model as glTF 2.0, a character with its skin, each textured from ../textures/; models/index.json (dictionary, counts, bone offsets) |
animations |
animations/<clip>.json (root channels, a rotation channel per bone, events), animations/characters/<name>.json (clips, the 722 anim slots, the range list) and animations/index.json |
levels |
levels/<level>/: collision.json and collision.gltf, paths.json, occluders.json, subtitles.json, the sky models and the light glows as glTF, level.json and the file as stored (source.lev) |
worlds |
worlds/<world>/world.gltf: the streamed world assembled, every part's atomics at their sectors; world.json (manifest, sectors); parts no sector names in unused/ |
scenes |
scenes/<record>.json: every in-engine scene record (roles, poses, clips, object, camera and light tracks) and scenes/list.json |
data |
data/global/*.json (Character, Object, Anim and Dependency Lists, sprite sheet table), data/objects/<level>.json (placed objects), data/fonts/, data/icon/ (the memory card icon as glTF and PNG) |
index |
index/wad.json: every WAD entry's kind, name and resources, and every resource's chunks |
raw |
what no decoder takes, as stored, each with a .json description: only the five old RenderWare files no code reads |
--only limits the run to some types; the others' files and records stay as an earlier run left them. A dictionary
or resource is named by its recovered name, or by its hash in hex. manifest.json lists every file with its SHA-256
and, per type, the file count, the bytes and a digest over the type's files, so two runs (or two discs) can be compared
with it. The output is deterministic. At the end the command prints, per type, the files, bytes and digest and the
stage's counts; --verify then compares each type's file count with the expected one for the NTSC-U disc and exits 1
on a difference. --jobs sets the worker processes that decode the audio (default up to four). A full run takes
about 15 minutes and writes about 11 GB, most of it WAV and scene JSON. Models, levels and worlds are glTF 2.0 that
any viewer opens, textured from textures/; coordinates keep the axes the game stores. Which formats are decoded
and which are still raw: Asset inventory.
--xbox XBOX_DISC adds your Xbox disc (a full image, an XISO or an extracted folder) as the preferred source: the PS2
disc is still read whole, then each texture and movie the Xbox has a better version of is replaced in place, at the same
path, and its index.json record says "source": "xbox" (with the PS2 size kept). A texture is replaced when its Xbox
match (same picture by colour and structure) is larger; a movie by its 1280 × 720 _hd version when that is as long.
Everything else stays the PS2 disc's. The rules and the numbers: Xbox assets.
The Xbox disc adds about half an hour (matching the world textures) and its default.xbe SHA-1 goes into
manifest.json. Finding the Xbox resources takes a pass over its 1.7 GB archive; --xbox-index FILE keeps the result
(the file xbox index writes): it is read when it was made from this disc and otherwise made and written there.
--xbox-texture-map FILE also writes which Xbox texture replaced which PS2 one, for a reader of the disc formats
(layout).
audio¶
The game's sound data, read from your own disc (DISC as for wad): the sound, music and class tables of
warriors.glr, the sound banks in the WAD and the streamed files IOP/BFW.SND and IOP/MUSIC.SND. The formats are
on Audio data.
uv run --project python coney-tools audio info [DISC]
uv run --project python coney-tools audio list {sounds,music,banks} [DISC]
uv run --project python coney-tools audio decode [DISC] NAME OUT.wav [--bank BANK]
info prints how many sounds stream from BFW.SND (mono and stereo) and how many play from a bank, the sample
rates, the music tracks and every bank with its sound count, with short SHA-1s of the tables to tell discs apart.
list prints one line per sound (hash, where its bytes are, offset, size, rate, class, flags, priority, distances,
volume, pitch variation), per music track or per bank sound. decode writes one sound or music track as a 16-bit
WAV file and prints its length, peak and RMS: NAME is a sound's name as the game writes it
(vags/character/voices/5/attack_01), a track's (music/warriors_theme) or a 0x hash; --bank takes a bank
sound from that bank. Like wad extract, it refuses a path inside the repository.
movies¶
Reads the header and frame index of each movie the game names (PSS/<name>.BIK) and prints one line per movie:
file size, Bink revision letter, width and height, frame count, frame rate, running time and each audio track's rate,
channels, sample size, transform (DCT or RDFT) and flags; then the totals and a SHA-256 of the headers and frame
indexes. Nothing is decoded. Exits 1 when a movie is missing or its header does not hold together. What the values
mean for playback: Movies.
xbox¶
The Xbox version is an optional asset source (Xbox assets). The xbox commands read
its archive, XBoxWad.idx over eight volume files, from your own Xbox disc and print counts, offsets and hashes,
so the survey on that page can be repeated. DISC is required and is one of:
- a full disc image (Redump style, 7.8 GB): the game partition is found by its
MICROSOFT*XBOX*MEDIAvolume descriptor at byte0x18300000(XGD1) or0x1FB20000(XGD2); - an XISO, an image of just the game partition;
- an extracted folder holding
XBoxWad.idx, the*.wad<N>volumes and, fornames,default.xbe.
The file system (XDVDFS) is read with Coney's own small reader and every file is streamed. Problems print one line
and exit with code 2, as for wad.
| Command | What it prints |
|---|---|
xbox files DISC |
Every file with its size, then the file, folder and byte totals |
xbox info DISC [--names FILE] |
The entry count, entries and bytes per volume, entries and bytes per kind (pack, texture dictionary, model, scene record, streamed world, ...) and how many entries a names file resolves |
xbox list DISC [--names FILE] |
One line per entry: index, volume, offset, size, hash, and the name when known |
xbox names DISC OUT_FILE [--candidates FILE ...] |
Matches names against the index and writes them, one path below ee_files\ per line; prints how many entries stay unnamed, by kind |
xbox extract DISC OUT_DIR [--names FILE] [--only HASH_OR_NAME ...] |
Writes entries as files: a named one under its path (paks/global.pak), the rest as <hash>.bin |
xbox resources DISC [--ps2 PS2_DISC] |
The resource index: packs, resources, chunks, distinct (resource hash, chunk type) keys, and the graphics chunks by their first two words; with --ps2, how many of the PS2's texture, model, animation and character resource hashes the Xbox has |
xbox index DISC OUT_FILE |
Writes the resource index, xbox-resources.bin (where each resource's chunks lie in the volumes; layout); prints its record count, size and SHA-256 |
xbox texture-map PS2_DISC DISC OUT_FILE [--xbox-index FILE] |
Matches every PS2 texture as extract --xbox does (into a temporary folder, deleted after) and writes the texture map, xbox-textures.bin; prints its record count and SHA-256. About 25 minutes |
xbox textures DISC [--ps2 PS2_DISC] |
Texture chunks by format (DXT1, DXT2/3, DXT4/5), mip count and size; with --ps2, the texture resources both discs share and how many hold the same number of textures |
A names file has one name per line and the tools take the last word of each line, so the output of wad names,
xbox names, wad list and xbox list all work. A bare name (global.pak, as from the PS2) is tried in each of the
Xbox folders ee_files\, ee_files\paks\ and ee_files\anims\; a name with a folder as given.
names keeps every name from --candidates and every scene record's own name (plus .scn) that matches. It also
guesses: level<N>.lev, every file-name-like string in default.xbe and in the Lua and text entries, and every
identifier there plus .lev. A guess is kept only when its extension fits the kind of the entry it matches (.lev
on a level, .pak on a pack, ...), which rejects chance matches. With the PS2 names as candidates it gives the
4,038 names on the research page:
uv run --project python coney-tools wad names PS2_DISC ../../scratch/names.txt
uv run --project python coney-tools xbox names XBOX_DISC ../../scratch/xbox-names.txt --candidates ../../scratch/names.txt
--ps2 takes the PS2 disc as the wad commands do and reads its WARRIORS.WAD. resources, index and
textures read the whole Xbox archive (1.7 GB) and, with --ps2, the whole PS2 one.
Game data stays out of the checkout
xbox extract, xbox names, xbox index and xbox texture-map refuse an output path inside the repository, as
wad extract and wad names do.
progress¶
The Progress page and the badges in the Progress section of README.md are generated from
three inputs:
docs/progress/functions.toml (the original functions Coney reimplements), docs/progress/totals.toml (the
denominators, copied from the source map) and the status table of the
roadmap. The page says what the numbers mean; this section says how to run the commands.
Prints the reimplemented and researched shares, the milestones and the subsystems that have reimplemented code.
--json prints everything, per subsystem and per coverage category, for other tools. It exits with 1 when
functions.toml and the @orig tags in src/ disagree, and lists each problem.
Checks the inputs and exits with 1 when they disagree; CI runs it. Nothing generated is committed: the tables of the
progress page and the README's badge data are produced by the documentation build (mkdocs_hooks.py), which
publishes them from main, so pull requests never conflict on them. check fails on:
- an
@origtag insrc/with no[[function]]infunctions.toml, or the other way round, or the two naming the function differently; - a
subsystemthat is not the one whose range intotals.tomlholds the address (the message names the right one; outside every range it isunattributed); - an address inside middleware or outside
.text, an address listed twice, or sizes that overlap; - a progress page without its single
<!-- progress:generated -->marker, where the build puts the tables and pictures, or a history page without its<!-- progress:history -->marker.
mkdocs build --strict shows the tables on the page locally too, and fails on the same problems.
The pictures come from progress_grid.py (one square per function, from the Ghidra listing, the tracker's measures
and the source map's file tables), progress_picture.py (layout and SVG), progress_history.py (one point per day
from git) and progress_site.py (the page's HTML and the files of the site); mkdocs_hooks.py calls them. The
page's tooltips are docs/javascripts/progress-grid.js, the colours docs/stylesheets/progress-grid.css, and what
they mean is on the page. Where the fidelity audit's results will be read is
progress_grid.load_audited: it returns no addresses until the audit stores a machine-readable result on main, and
the picture then draws the Audited state.
Writes what the build publishes to <folder>, to look at it without a docs build: the progress and history pages as
the build fills them (progress/index.md, progress/history.md), the badge files (progress/badges/*.json,
shields.io endpoint JSON that the README's badges read), the pictures (progress/progress.svg,
progress-size.svg, missions.svg, history.svg), the raw numbers (progress/progress.json), the per-function
table the tooltips read (progress/functions.json) and the history (progress/history.json). It rebuilds the daily
history from git, a few seconds a day; --no-history keeps it to today's point. It refuses to write while the inputs
disagree, as check finds.
The docs build does the same, with the history limited so a pull request's build stays quick: it keeps the days of the
file named by CONEY_PROGRESS_HISTORY_CACHE (the previously published progress/history.json) and today, and measures
the missing days only when CONEY_PROGRESS_HISTORY=full. The docs workflow sets both on a push to main: it
downloads the published file (a missing one just means every day is measured), builds with full and deploys the
result, so each deploy extracts one commit. A cached day counts only for the commit it was measured at, and a day
that could not be measured is recorded with its commit and not tried again. The file also records a digest of the
tracker's measuring code (progress.py, progress_research.py): when that code changes, the cache is ignored and
every day is measured again, so the chart never mixes two definitions. A pull request's build has no cache (only the
deploy learns the site's address), so its History page shows today alone; locally, point
CONEY_PROGRESS_HISTORY_CACHE at a downloaded history.json to see the days.
Checks the size of every function in functions.toml against the executable SLUS_212.15 on your own disc (DISC
works as for the wad commands). The executable has no symbols, so the command takes every jal target, every
function pointer in .data and every address into .text that code builds with a lui / addiu pair (the function
pointers handed over in code, such as the script binding wrappers) as the start of a function, and a function's
span runs to the next of those starts, alignment padding included. Before the pairs were counted, a binding
wrapper's span ran over its unseen neighbours (38,440 bytes for HuCreate's 480). A given size larger than the
span is an error (exit code 1); one smaller than the code before the padding, or an address no call reaches, is a
warning. --fill writes the span into each entry that has no
size yet. It prints only the addresses and sizes of the functions already listed.
The span is an upper bound: a function that nothing calls directly and no pointer names (one reached
only by a tail jump) is not seen as a start, and the span of the function before it includes it, unless that function
is itself listed in functions.toml: a listed function always ends the span of the one before it. When --fill
gives a size that looks too large for what the research page describes, check the size Ghidra shows.
orig¶
The fidelity audit compares each function Coney reimplements with the original function at its @orig address in
Ghidra (The @orig tag). The orig commands give it its index and its order of
work. They only read: the source, the research pages, the shared Ghidra project through reading endpoints, and the
open issues through gh.
uv run --project python coney-tools orig index --out <folder> [--ghidra auto|live|listing|off] [--url URL]
uv run --project python coney-tools orig queue --out <folder> [--issues FILE] [--label bug] [--show N] [--ghidra ...]
orig index reads every @orig tag in src/ and writes to <folder> (use your scratch folder):
index.tsv: one line per tag: the original's address, name and source file, the Coney function the tag documents, its file and line, where the tag sits (commentabove a function,bodyinside one,otheron a class or file), and the research links that document it (the function's comment, else its declaration's, its class's or its file's);untagged.tsv: the functions defined in the gameplay folders (paths.gameplayof.github/conventions.yml) that have no tag and no Coney-only marker. A tag covers a function through its own comment, or through its declaration's: the declaration with the same scope, name and parameter types (any one, when the name is not overloaded), in the same file pair, folder or, for a member, anywhere; a file's private helper only through a declaration in its own file. Each is amember, afreefunction, a file's privatehelper(an anonymous namespace orstatic) or aninlinemember defined in its class, and itsexemptcolumn is 1 when it needs neither (Coney-only code). A marker is@coney-only <reason>in the function's comment or its file's header comment, or the older wording ("Coney's own", "(no @orig)");research-gaps.md: the research sections tagged code links that cite no original address, links to a page or section that does not exist, tags whose linked pages never cite their address, and tags with no research link;report.md: the counts, the malformed tags (a line with@orig 0xthat is not the whole form@orig 0x<address> <Name> (<File>.cpp)), the tags Ghidra does not bear out, and with a running servercallees.tsvand its table: the original functions the tagged ones call that no tag covers (game code only; the libraries the original links in are left out, bydocs/progress/totals.toml), most-called first.
The Ghidra check (--ghidra, default auto) uses a running ghidra-mcp (Ghidra + ghidra-mcp) when one
answers on --url, else the committed listing docs/progress/ghidra-functions.tsv, which is as current as its last
progress ghidra export and gives no callees. Each tag gets one of: no-function (Ghidra has no function there),
not-a-start (the address is inside another function), names-differ (they share fewer than half the shorter
name's words: the tag may name the wrong function), renamed (half or more, usually a rename in Ghidra since the tag was
written) or unnamed (Ghidra still has its default FUN_ name). Names agree when they are equal without case and
punctuation, so DVDWadIndex::Find matches DVDWadIndex_Find, and a binding's lua_ wrapper prefix is ignored. A
full live run asks the server about each of the 2,400 tagged functions twice (the function, its callees), one request
at a time; on the development machine it took about 11 seconds.
orig queue writes the same files and the audit's queue, queue.md (a table per src/ directory, then the first
--show entries) and queue.tsv (every entry). An entry is an original function with each Coney place that tags it,
or an untagged function; its score adds up its reasons (orig_queue.WEIGHTS):
- an open issue with
--label(defaultbug) that names it (its address, its original or Coney name), its file or a research section it links, weighted by the issue's priority label; the area labels of the issues add at most 4 points between them; - a tag Ghidra does not bear out, as above;
- research that grades it only
inferredorspeculativeanywhere, cites it with no level, or does not cite it; - a linked section that cites no address, or linked pages that lack its address;
- for untagged code, its kind and length.
--issues FILE reads a saved gh issue list --json number,title,body,labels,comments instead of asking GitHub. The
order is a starting point for the audit team, which re-ranks as it learns. The files hold addresses, names, paths and
issue numbers, never the original's code; they change with every pull request that touches src/, so they are
regenerated when needed and never committed.
Refuses a function that a change adds to a gameplay folder with neither @orig nor @coney-only <reason>, and a
folder of src/ that is in neither paths.gameplay nor paths.coney_only (Coney-only code).
The exempt functions pass: a member defined in its class whose body is one statement (an accessor, a setter, a
forwarder) and a constructor or destructor with an empty body. A file's private helpers are not exempt. Only what
the change adds counts: a function untagged now that was not untagged, under the same scope, name and parameter types
in the same folder (a file's private helper: in the same file), where the branch left --base. So editing an old
untagged function, moving it to another file of its folder, or renaming its file with git mv (to any folder)
passes; removing a tag, adding an untagged overload, changing an untagged function's parameter types, or growing an
exempt accessor into more than one statement fails.
The older wording ("Coney's own", "(no @orig)") marks only functions that had it at --base; a new function needs
@coney-only <reason>. The functions that said neither before the check existed are the audit's backlog in
untagged.tsv. Each finding names the file, line and function and what to add; exit code 1 when there is one.
check changed runs it as its orig step and CI's python job runs it with --base HEAD^1.
This check refuses decompiler fingerprints in the C++ lines a change adds under src/, since HEAD left --base,
committed or not, with untracked files counted whole (Porting, not transcribing). With
--all it checks every line of src/. It reads only the code part of each line: comments, string and character
literals and raw strings are blanked first, so a shader's in_pos0 or a comment that cites FUN_00123456 passes.
It finds these fingerprints:
- Ghidra's automatic names:
iVar1,puVar3,param_1,local_1c,auStack_20,DAT_,FUN_,LAB_,unaff_,extraout_, andin_followed by a MIPS register; undefinedandundefined1toundefined8;- a raw read at a byte offset,
*(T *)(x + 0x40)or*reinterpret_cast<T*>(x + 0x40); goto.
A line with // copy-check: allow <reason> passes. Each finding names the file, the line, the text and what to do,
and the exit code is 1 when there is one. check changed runs it in its orig step, and CI's python job runs it
with --base HEAD^1. On 2026-10-21 --all found none in src/. The check cannot see a transcription's shape; the
orig-reviewer agent judges that.
uv run --project python coney-tools orig review-stamp (--branch B | --worktree WT) [--base origin/main] [--ledger F]
uv run --project python coney-tools orig review-stamp (--folder src/x/ | --address 0x...)... [--ref HEAD] [--every]
uv run --project python coney-tools orig check-review --body PR.md [--head B] [--base B] [--cwd DIR] [--ledger F]
uv run --project python coney-tools orig record [FINDINGS.tsv ...] [--ref COMMIT] [--ledger F]
These commands feed the orig review, which compares @orig functions with the original
(Orig review, the orig-reviewer agent). Each one works on units. A
unit is a function definition with a body that a tag covers: its own tag, one in its body, or its declaration's.
A unit has a key (Brain::think(int), with the file in front for a file's private helper) and a hash of its body
lines. The source is read from commits, not from the working tree.
review-stampprints JSON.- With
--branchor--worktree(pr mode) it gives the branch'stip, thebaseit left, and the units that are new at the tip or whose body or addresses changed. - With
--folderor--address(sweep mode) it gives the units at--refin scope that the ledger has not seen at their current body;--everylists all of them.
- With
check-reviewis the release gate. A branch that changes no unit passes. Otherwise the passing verdict (pull_requests.orig_review.verdict_file,ORIG-REVIEW.json) next to the description must cover every changed unit at its current body, as the verdict says. A rebase that leaves those bodies as they were keeps the verdict.- The branch is found in this order:
--head, then the branch checked out in--cwd, then the description's folder under the workspace's scratch. - The exit code is 0 for a pass (which is recorded in the ledger), 1 for a refusal with the reasons, and 2 when the branch cannot be found.
- The release step calls
orig_review.gate(orcheck_verdict_file, which writes nothing).
- The branch is found in this order:
recordadds the rows of sweepFINDINGS.tsvfiles (Sweep findings) to the ledger. Each row is matched by address and Coney name to the units at--ref, the commit the sweep read; rows that match nothing are printed. It then prints how many units at HEAD are unreviewed, stale, or in each class.
The ledger is pull_requests.orig_review.ledger under the workspace's scratch folder (orig-review/ledger.jsonl).
It is one JSON object per review and is never committed. orig queue reads it:
- an address whose units were all reviewed
cleanordiff-deliberate-okat their current bodies leaves the queue; - an unreviewed one gains 3 points, and a stale one 5;
- a review finding adds its class's weight, from
unclear(6) todiff-bug(40).
natives¶
The script-binding reference (docs/references/bindings/, under Game references) is generated from
research/bindings/<category>.yaml (schema: Research workflow).
Checks the YAML and writes docs/references/bindings/: index.md, one page per category, each binding under an anchor
of its name in lower case (hud.md#hudsetobjective), mission1.md (the first mission's coverage) and story.md
(the later levels' coverage). A page left over from a removed category is deleted. Run it after editing the YAML and
commit the pages with the change. With --check it writes nothing and exits with 1 when a page is stale; CI runs it
that way. Either way it refuses to go on while the YAML has problems, and lists them: an unknown key, a file that is
not a category, a missing description on a described entry, a thorough entry with an argument left unexplained, an
argument name used twice, usage counts that contradict each other, a levels list out of story order, a name listed
twice.
Sets the coney key of every entry from Coney's binding table, kBindings in src/scripting/script_bindings.cpp:
real is implemented, routed (handed to a stand-in for a subsystem Coney lacks) is partial, stub and
recording are not implemented, and so is a binding the table does not list; the default is written as no key. Run
it, then natives render, after changing the table. It fails when a line of the table is not one kind("Name", ...)
entry, or names a binding the masterlist lacks. With --check it writes nothing and exits with 1 when a status is
stale; CI runs it that way.
Writes src/debug/native_signatures.cpp, the C++ table the debug menus build their argument
editors from: every binding's name, category, the type of each argument (number, integer, handle, boolean, string, a
table of numbers or strings with its fixed count, userdata) with its default, and the types of its results. A number
argument whose description says it is a handle gets the handle editor. Only names, types and defaults go in, never a
description. Run it after editing the YAML and commit the file with the change; with --check it writes nothing and
exits with 1 when the file is stale, as CI runs it.
Reads the first mission's compiled scripts (level99.lua and its three chapter scripts) and global.lua from your
disc (the game_dir of coney.local.toml when no disc is given), and sets every entry's usage.mission1: true when
those scripts name the binding, or a global.lua helper they reach does (followed by name, as a Table.field or as a
callback string), or one of the two helpers the engine calls when a mission ends (UnlockAndLoad, runNextMission).
It prints the coverage (bindings, traced, Coney status), which natives render also publishes as
Mission 1 coverage. Only names are read; nothing from the disc is written. Run
natives render after it. With --check it writes nothing and exits with 1 when a marker is stale; it needs the disc,
so CI does not run it.
The same for the rest of the story: for each later level of STORY_LEVELS (coney_tools/natives.py: missions 2-18
and the hub in story order, then the flashbacks and the Armies of the Night levels) it reads the level's scripts
(levelNN.lua and its levelNN_*.lua chapters, found by name in the WAD names list, without the language files and
the scene tests) with the same reach rule, and sets every entry's usage.levels to the numbers of the levels that can
call it (no key when none). It prints each level's bindings and how many are new (called by no earlier level, the
first mission included) and traced; natives render publishes them as
Story coverage. Run natives render after it; --check as for mission1.
Prints the counts by category, evidence level, detail and Coney status, and how many bindings the game's scripts call.
refs¶
The Game references are generated from the lists in research/references/
(Research workflow has their schema and rules).
Reads your own disc (DISC works as for the wad commands) and refreshes every list, or only the ones named
(characters, levels, ...): the configuration calls of the compiled scripts, the Character List of
warriors.glr, every animation clip, three tables of the executable, the scene list scene_list.cnk, the
movies in the disc's PSS folder (when DISC is a folder) and the WAD's entry names. It keeps every
hand-written field and every entry it did not produce, then renders the pages. --names adds WAD names found
another way (a file with one name per line, or the last word of each line, as wad names writes), each kept only
when it hashes to an entry; the names already in wad-names.yaml are always used. A full run takes about half a
minute and keeps only names, ids and numbers.
Checks every list against its schema and writes docs/references/index.md (with the families of
still-to-list.yaml) and one page per list. With --check it
writes nothing and exits with 1 when a page is out of date; CI runs it that way. A list that fails its schema stops
both, with each problem named (exit code 2).
Rewrites every PNG below docs/references/images/ (or FOLDER) in place as a 256-colour palette image with
transparency. coney --render-references writes full-colour images of about 26 KB each; with a palette they take
under a quarter of that and look the same at 256 pixels. The quantizer is deterministic, so a re-render gives the same
files. A file the palette would not make smaller (most radar icons) is left as it is. Run it after rendering and before
refs extract links the images.
missions¶
The Missions pages are generated from research/missions.yaml
(Research workflow has its schema and who updates it).
Checks the list (every story level present, each mission's checkpoint count equal to the Sections column of the
levels list, each status consistent with its checkpoints) and writes docs/missions/index.md and one page per level,
with the script-binding numbers of Story coverage read from
research/bindings/. With --check it writes nothing and exits with 1 when a page is out of date; CI runs it that
way. Run natives render first when bindings changed.
pcsx2¶
The pcsx2 commands drive the original in PCSX2 over PINE and the PCSX2-MCP fork's debug server, for runtime research
and parity checks. What they do and why, the scenario format and the address expressions are in
Recording a trace. The folders come from coney.local.toml (pcsx2_dir,
game_dir, scratch_dir) unless --pcsx2-dir, --iso and --scratch give them; PINE must be enabled in PCSX2's
inis/PCSX2.ini. Problems print one line and exit with code 2.
Makes or updates the portable copies pcsx2, pcsx2-b, ... (--count, default pcsx2_copies, 5) under
pcsx2_root from the fork build pcsx2_mcp_dir names: the build's program files only, portable.txt, and in each
copy's ini PINE on pcsx2_pine_base + i, the debug server on pcsx2_debug_base + i and SDL = false. A copy's
settings, BIOS, memory cards, states and recordings stay; a copy whose PCSX2 runs is refused (exit code 1).
--dry-run prints what would change. Details: PCSX2 copies.
Several agents share the copies through claims (Several at once); any command that starts PCSX2 needs one.
uv run --project python coney-tools pcsx2 claim --agent ID [--copy NAME] [--json]
uv run --project python coney-tools pcsx2 release --agent ID [--copy NAME] [--force]
uv run --project python coney-tools pcsx2 status [--json]
uv run --project python coney-tools pcsx2 check-ports --agent ID [--debug-port N] [--pine-port N]
claim takes a free copy (or the named one) atomically and prints its folder, PINE port and debug server port (a line
pcsx2-dir=... port=... debug-port=...; --json has port and debug_port, which is null for a copy without the
debug server); it hands back your own claim when you hold one, and takes over a stale one (its processes gone, or
older than --max-age-hours, default 4, with no process) saying so. A claim whose copy has a live process is never
stale, whatever its age or owner: a PCSX2 running from the copy's folder, a live recorded PCSX2 or holder pid, or a
listening PINE or debug server port; that includes a claim whose owner file is unreadable (shown as holder ?), which
is stale only after a few seconds and only with nothing running in the copy. A copy running PCSX2 without a claim is
never handed out. release closes the PCSX2 it recorded (only while that process is still a PCSX2) and drops the
claim; another agent's claim needs --force. status lists every copy with its holder, the time, the PINE and debug
ports, whether PCSX2 runs (by pid or by either port) and stale claims; it warns about copies whose ini has
[InputSources] SDL = true. The copies are the pcsx2* folders with an inis/PCSX2.ini under pcsx2_root (default:
../../emulators); two copies on one port (PINE or debug server) are refused, naming both. The claims are under
pcsx2_claims_dir (default: pcsx2-claims/ in the shared scratch folder, the main checkout's ../../scratch,
whatever a worktree's scratch_dir says, so every worktree sees the same claims).
A port is only a number, and every copy's ports are readable (status, the ini), so nothing may act on ports it
did not get from its own claim. check-ports succeeds (and prints ok: ID holds NAME) only when the ports given are
the debug server and PINE ports of one copy whose live claim ID holds, and exits 2 naming the holder otherwise
(Registry.require_ports, the check everything else below calls). From a script, DebugClient.claimed("ID")
(coney_tools.debug_server) connects to the debug port of the claim ID holds and to no other. For the shared pcsx2
MCP server, a hook makes the same check before pcsx2_connect (The PCSX2 MCP guard).
uv run --project python coney-tools pcsx2 keys --agent ID --copy NAME KEY [KEY...] [--hold-ms N] [--gap-ms N]
uv run --project python coney-tools pcsx2 screenshot --copy NAME --out PNG
Both are Windows-only and never focus a window. keys posts key down and up messages (WM_KEYDOWN) to the copy's
window by handle, one key after another (W+K presses two together; names: letters, digits, space, return,
arrows, f1..f12), each held --hold-ms (default 300). screenshot writes the game widget's picture, read by
handle, so it works while other windows cover it (not while it is minimised); it is refused inside the repository.
Copies a save state (a .p2s file, or slot:N for quick-save slot N, which is only read) to OUT with the named
patches of research/traces/patches/ (one file per pass) applied to its EE memory. It refuses an OUT inside the
repository or in PCSX2's sstates/ folder, and refuses the copy when the state does not hold a patch's original
instruction words. PCSX2 saves states with zstd, which Python reads from 3.14 (uv installs it).
Copies a save state (a .p2s file or slot:N, only read) to OUT with every compressed entry rewritten with plain
deflate, the same contents byte for byte. Ghidra's zip reader, which the EE extension's PCSX2SaveStateImporter.java
script uses, cannot read PCSX2's zstd, and Python cannot write PCSX2's other choice, Deflate64; plain deflate is read
by all three, so PCSX2's own compression setting stays as it is. The same refusals as prepare-state apply. Why we
do not usually load states into Ghidra: Tools.
Starts PCSX2 on a state file (-fastboot -statefile, the disc through a hard link in the scratch folder when its path
holds commas or parentheses), waits until the game runs, and leaves it running; with no state it boots the disc and
returns once PINE answers; when the copy runs the debug server, it also waits for that port, and prints both. It is the
only way agents start PCSX2: it never takes the keyboard focus (WMI starts the
process outside the agent's process tree; why: Driving PCSX2). With
CONEY_WINDOW_DISPLAY set (Which display), it moves each PCSX2 window onto that
display as it appears, without activating it; pcsx2 record does the same. It refuses to
start when something already serves PINE or the copy's debug server port. It runs under --agent's claim (made, and
kept, if you hold none) and records
PCSX2's pid in it;
--pcsx2-dir must be that claim's copy.
uv run --project python coney-tools pcsx2 record SCENARIO --out CSV --agent ID [--state SOURCE] [--attach] [--keep-open]
[--recording P2M2]
Makes the scenario's patched state copy, starts PCSX2 on it, plays the scenario's input script and writes one CSV row
per character update, then closes PCSX2. It prints the updates recorded, the reads per poll, the time per poll and the
updates missed. The scenario names a quick-save slot (slot) or a state file under the scratch folder (state);
--state copies another state, --attach records a PCSX2 already running a patched state, and --keep-open leaves
PCSX2 running; the claim is released at the end when record made it. The CSV is a measurement of the game: it is
refused inside the repository. With
hooks among the scenario's patches it also writes each hook's call log to <CSV stem>.<hook>.csv beside it, and
makes the scenario's calls (Hooks).
--recording P2M2 takes the pad from a PCSX2 input recording instead of the scenario's input script, so a runtime
trace can be repeated exactly: the recording (and its start savestate <P2M2>_SaveState.p2s, patched as the
scenario says) is copied into the copy's recordings/coney-record/, PCSX2 starts on that savestate, and through the
debug server record pauses emulation, plays the recording (which loads its savestate again), samples step 0 and
resumes. The trace's steps then count from the recording's first frame. A recording that ends before the scenario's
updates stops with PCSX2 paused at its last frame and an error saying so; a power-on recording (no savestate) takes
no patches. It cannot be combined with --attach or --state, and needs the copy's debug server.
uv run --project python coney-tools pcsx2 recordings make NAME --script SCRIPT [--state STATE.p2s] [--updates N]
[--frames-per-update 2] [--author TEXT] [--force]
Writes an input script (Coney's format, Input scripts) as a PCSX2 input recording
NAME.p2m2 in the scratch folder's recordings/, so the original replays the script exactly, frame by frame, as Coney
does. A script's frame is one character update of 1/30 s and PCSX2 takes the pad at 60 Hz, so each script frame becomes
--frames-per-update frames (2). Port 1 follows the script with the pad bytes Coney makes (buttons with full
pressure, sticks from the percentages), port 2 stays at rest. --state is the savestate it starts from, copied beside
it as NAME.p2m2_SaveState.p2s; without it the recording starts from power-on. The length is the script's last frame
plus one second unless --updates says. A recording already there needs --force. Then recordings push it to a copy
and play it (Input recordings). The layout it writes is PCSX2's own, from
pcsx2/Recording/InputRecordingFile.h.
uv run --project python coney-tools pcsx2 recordings push|pull NAME --agent ID --copy NAME [--force]
Copies an input recording (NAME.p2m2 below recordings/, such as fights/rumble-1.p2m2) and its start savestate
between the shared scratch folder's recordings/ and the claimed copy's recordings/ (push to the copy, pull
back), checking each copy's SHA-256. A different file already there needs --force. Recordings are made and played
in the copy with the MCP server's pcsx2_input_recording (Input recordings).
uv run --project python coney-tools pcsx2 clip STATE --out CLIP --agent ID [--seconds N] [--iso DISC] [--scratch DIR]
Records a short clip of the original (--seconds, 1 to 10, default 10) with PCSX2's own video capture of its GS
output: nothing on the desktop is read. For the run, the claimed copy's ini switches the capture on (with sound),
points PCSX2's videos folder at a staging folder beside the clip, turns every on-screen display element off (FPS,
messages, the recording indicator) and binds "Toggle Video Capture" to F12, which is posted to PCSX2's window by handle
to start and stop it; the ini is put back when PCSX2 closes. The capture is then re-encoded into CLIP as Coney's
clips are (960 x 720, H.264 and AAC, no metadata, cut at the length) through clips.run_ffmpeg, and the staging
folder deleted. PCSX2 2.9.94 tears the first ~1.15 s of its capture (a black band along the bottom), so the capture
runs 1.5 s longer (CAPTURE_START_TRIM) and the clip starts after that, still lasting --seconds.
--out is a path under the scratch folder, <workspace>/scratch/<task>/clips/NAME.mp4; give it absolute (the
folder clips folder TASK prints), because a relative ../../scratch/... is wrong from a worktree, which sits three
levels below the workspace. The game comes from game_dir in coney.local.toml; without it pass --iso DISC,
and likewise --scratch DIR (an absolute path) without scratch_dir.
CLIP follows the clips rules. It needs FFmpeg as clips finds it, and Windows (keys by
handle). PCSX2 starts on STATE under --agent's claim, released at the end when clip made it.
uv run --project python coney-tools pcsx2 recipe list
uv run --project python coney-tools pcsx2 recipe show NAME
uv run --project python coney-tools pcsx2 recipe run NAME --copy COPY --agent ID [--arg NAME=VALUE ...] [--json]
uv run --project python coney-tools pcsx2 recipe render [--check]
The shared PCSX2 recipes of research/pcsx2-recipes.toml (the schema is the docstring of
repo:python/src/coney_tools/recipes.py). list names them by group, show prints one with its parameters,
preconditions, evidence and steps. run makes its steps on a copy --agent holds a claim on and whose PCSX2 runs,
over that copy's PINE port (and its debug server for pause, resume and step): it refuses another agent's copy, a
copy that runs nothing, an emulation state the recipe does not take and a running state that lacks a patch or hook the
recipe names (read back from memory). --arg gives a parameter, --json adds one line with every value shown.
render writes the page from the file; with --check it writes nothing and exits with 1 when
the page is out of date, as CI runs it. Every recipe must have an evidence level, a Ghidra address and a
confirmed_runtime date, copy and state, or the file is refused.
clips¶
uv run --project python coney-tools clips folder TASK
uv run --project python coney-tools clips check CLIP [CLIP...]
uv run --project python coney-tools clips publish (--pr N | --issue N) [--edit] (--body TEXT | --body-file FILE) CLIP [CLIP...]
uv run --project python coney-tools clips clean [--older-than HOURS] [--dry-run]
In-game video clips for issues and pull requests, made by coney --record (Recording a clip) or
pcsx2 clip (above). The rules and why: Clips; the limits are workspace.clips in
.github/conventions.yml.
foldermakes and prints a task's clips folder,<scratch>/<task>/clips/, where<scratch>is the workspace's shared scratch folder (../../scratchbeside the main checkout).checkruns ffprobe on each clip and prints its length, size, picture and whether it has sound. It refuses a clip outside a task's clips folder or inside a git checkout, one longer thanmax_seconds(10), and one with any tag beyond the fixed ones an MP4's boxes carry (major_brand,minor_version,compatible_brands,language,handler_name,vendor_idand a version-freeencoder); one bigger thanwarn_mb(10 MB) gets a warning.publishmakes the same checks, then uploads every clip in one gh command,gh pr|issue comment N(oreditwith--edit, into the description) with one--attachper clip, and deletes the clips once gh succeeds. When gh fails, it keeps them and says so (exit 1). A body may place a clip with a./clips/<name>.mp4reference, which gh replaces with the upload when the clip is named the same way. It is the only way clips are uploaded.cleandeletes every file in the task clips folders older thankeep_hours(24;--older-thanoverrides it), including what a failed recording left (part files, apcsx2 clipstaging folder);--dry-runonly counts.
FFmpeg and ffprobe are found in ffmpeg_dir of coney.local.toml, else beside CONEY_FFMPEG (the variable Coney's
--record reads), else on PATH. Every FFmpeg command the tools run goes through clips.run_ffmpeg, which refuses a
capture device or any input that is not a file.
trace¶
Plays the scenario's input script on Coney headless (--play-level and the options of its [coney] table, --frames
its updates, --trace CSV). --coney defaults to build/dev/src/platform/coney, --disc to game_dir.
uv run --project python coney-tools trace diff ORIGINAL CONEY [--scenario SCENARIO] [--columns COLUMN ...]
[--tolerance COLUMN=VALUE ...] [--from STEP] [--to STEP] [--shift N] [--start-frame] [--context N]
Compares two traces step by step and prints, per column, the first step outside its tolerance, the largest difference
and its step, then the rows of both around each divergence. It exits with 0 when every column is within tolerance, 1
when one is not and 2 when a trace cannot be read. With --scenario, the columns, tolerances, start frame and first
step (the first update of input) come from the scenario's [diff] table; flags override them.
Comparing with Coney explains the alignment and the start frame.
uv run --project python coney-tools trace fight-report TRACE... [--humans] [--hz N] [--exclude HUMAN ...]
[--engage-distance METRES] [--from STEP] [--original-gangs ID,ID,...]
uv run --project python coney-tools trace fight-report --original TRACE... [--original TRACE...]
--coney TRACE... [--coney TRACE...] [--margin SHARE] [--original-gangs ID,ID,...] [--coney-gangs ID,ID,...]
Behaviour statistics of a gang fight, from the all-humans trace of either game (Coney's --trace-humans, the
original's pcsx2 record of the all-humans field set; Tracing), by the rules of
Rumble: what the comparison computes. The two games draw different
random numbers, so a fight is compared by rates, shares and distributions, not update by update. A trace needs the
columns step, human and gang; a metric whose columns are missing is left out.
- The fighters are the humans of the gangs in
--original-gangs(--coney-gangsfor Coney's runs), or else of the gangs that fight (a target, an attack, a block or a knock-out), less a crowd of brain type 4 (btype). - The fight runs from the first step a fighter's brain is on (
brain_off0, or without that column the first goal pushed) toRumbleOver, the step before every fighter'sbrain_offis 1 (or every goal stack has been empty for 30 steps). A trace without those columns runs from--fromto the first step where one gang or none stands. - Out of the fight is
Human_IsDownOrDead's mask,state0x80050000(dying, knocked out, wounded) orstate_hi0x1(dead), or clip 349, or gone from the trace; in a trace withoutclipandstate,ko1 orhealth0. Every rate is per minute a human stands, at--hzupdates a second (30).
For the whole match (all) and each side (and each human with --humans) it prints:
- attacks (
attack.total_per_min, the wind-ups:phasebit0x1turning on, withattack.distance_p10,p50,p90to the target then; withoutphase, a change to an attack command incommand, also by kind asattack.square_per_min, ...); - blocks (
block.per_min, the Blocking goal, 27, coming on top) and held blocks (block.held_per_min,blockingor the block command); reactions (react.per_min), knock-downs (down.per_min), health losses (health.losses_per_min) and taunts (taunt.per_min, clip 603 starting); - target changes (
target.changes_per_min, split intotarget.switch_per_minA to B,set_per_minfrom none andclear_per_minto none, and by cause astarget.cause.pick_per_min,front,hit_back,help,engage,displaced,reaction,order,otherandunknown: from Coney'starget_causescolumn, or the original'sset-targethook log beside the trace,<stem>.set-target.csv, counting only the calls that change the target, each caller named by the tableSET_TARGET_CAUSESinrepo:python/src/coney_tools/fight_trace.py, which follows What each set-target caller means; a caller it does not name yet isunknown. The causes are not a split oftarget.changes_per_minand need not add up to it: they count every change in the window, also while the human is out (the knock-out's drop) and both halves of a pick's clear and set, wherechanges_per_mincounts the changes seen between two sampled rows while he stands; both are divided by the minutes he stands) and goal changes and interruptions (goal.changes_per_min,goal.interruptions_per_min: a goal pushed over another, or a stack replaced instead of popped); - the time share of each goal (
goal.NAME) and the idle share (idle.share, an empty stack while standing); the fight goals, runs of Fight on top, a new one whenfight_updatesstarts again (fight_goal.count,median_s,p90_s,share_4s); - fighters on one enemy (
attackers.mean,p90,max,share1toshare4) and the active places held on a fighter (active.share0,share1,share2,share3plus); - waiting: the distance to the target with Fight on top and no active place (
fight_active0), split by whether the target's own target is him (wait.mutual_p50,wait.other_p50, and the share in 4-4.75 m,_band); - help (
help.calls_per_min,help.helpers_per_min,help.respond_per_min): Coney'shelp_callsandhelpers_sentcolumns, or for the original thehelp-callandsend-helperhook logs beside the trace (<stem>.help-call.csv,<stem>.send-helper.csv); - the distance to the target (
distance.p10,p50,p90), the time to the first engagement (engage.first_s,median_s,share: the first attack, or a target within--engage-distance, 3 m by default) and the knock-out times (ko.count,first_s,median_s,last_s); - per side, its fighters out at the end (
out.at_end), its tactic (tactic.first,last,changes,first_updates, fromg_tactic),teamups.max,order.shareandchosen.share; - the match's length and winner (
match.length_s,match.winner: the side with fighters standing at the end).
--exclude leaves humans out (a spectator, the pad's player); --from skips the steps before one.
With --original and --coney it prints, for every metric, the original's spread (min / median / max over its runs)
beside Coney's and marks with << each metric where Coney's median is outside the original's spread (above,
below, missing, or different for a winner the original never had). --margin widens the spread by that share of
its width. A goal or an attack one side never uses counts as zero. Each --original and --coney names the runs of
one orientation (which gang is side 1), and the two are paired in order, so each orientation of Coney's is compared
with the original's runs of the same orientation; give as many of one as of the other. Sides are labelled g0, g1,
... by rank, the lowest id first, or in the order of --original-gangs and --coney-gangs when their ids differ. The
exit code is 1 when a metric is outside the spread, 0 when none is, 2 when a trace cannot be read. With one run of the
original the spread is a point, so record several (Comparing with Coney).
uv run --project python coney-tools trace mission SCENARIO --side coney|original --out CSV [--coney EXE] [--disc DISC]
[--state STATE] [--agent ID] [--updates N] [--follow ROUTE [--follow-from EVENT]]
Plays a mission scenario (research/traces/missions/*.toml) on one game with its course, an adaptive pad that plays
by what is on screen, and writes the game's event log to CSV (keep it outside the repository: it holds the game's
text). --side coney runs Coney headless with --pad-pipe and --event-log (--coney, --disc as for trace
coney); --side original starts PCSX2 on the scenario's state with its patches and event hooks, as pcsx2 record
does, under your claim (--agent), and reads the events from the hooks' ring. --state starts from another state
file or slot:N (a copy, never the slot itself); --updates caps the run. It prints its progress every 1000 updates
and ends when the scenario's until event comes, or at its updates. Beside the log it writes the run's route,
<CSV stem>.route.csv (step,x,y,z,heading,buttons,lx,ly: the player's feet and heading after each update in play,
then the pad given for the next one; the buttons are Coney's bits in hexadecimal, the left stick as raw bytes).
--follow ROUTE (Coney only) plays another run's route instead of the course, usually the original's, so Coney's
player goes where the original's went and what is heard and spawned on the way can be compared. The route is cut
into walks (a waypoint each 0.5 m of the recorded travel, walked to with the stick at 60 %) and actions (the updates
in which the recorded player pressed a button, or turned the stick while standing still, played back pad for pad once
he stands within 0.25 m of where it began and faces its heading); updates out of play hold the pad at rest, so a
scene or a load does not use up the route. --follow-from "kind name" lets the scenario's course play until that
event ("call CarSpawnRadio"), then follows the route from the step its own run saw it, found in the event log beside
the route: for a stretch one game's start state has half done. A route is followed, not replayed: Coney may take
longer over it (a waypoint it cannot reach is given up after 40 updates without headway).
uv run --project python coney-tools trace events-diff ORIGINAL CONEY [--rules RULES] [--kinds KIND ...] [--window N]
[--limit N] [--labels JSON] [--from EVENT]
Compares two event logs in order, not frame by frame, by the rules of research/traces/events.toml (--rules for
another): the furthest milestone both reached and the one Coney never reached, then the events missing in Coney, the
extra ones, those out of order, milestones whose spacing differs by more than --window updates, and the frequent
events by count. --from "kind name" starts both logs at their first such event, for two runs that began at
different points of a mission; --labels names hashed texts by a JSON object of label to text (a string table read
from your disc, kept in scratch). It exits with 0 when Coney has every compared event in order, 1 when not.
Differential playthroughs explains the method.
repo¶
uv run --project python coney-tools repo check
uv run --project python coney-tools repo check-title FILE|-
uv run --project python coney-tools repo check-docs [--body FILE] # changed paths on stdin
uv run --project python coney-tools repo check-body [--body FILE|-] [--title TITLE] [--worktree WT [--base REF]]
[--local-images]
uv run --project python coney-tools repo review PR.md --worktree WT [--base REF] [--model M] [--effort E]
[--claude EXE] [--timeout-minutes N]
uv run --project python coney-tools repo check-review PR.md --worktree WT [--base REF]
uv run --project python coney-tools repo review-stamp PR.md --worktree WT [--base REF]
uv run --project python coney-tools repo labels [--apply] [--repo OWNER/NAME]
uv run --project python coney-tools repo render [--check]
uv run --project python coney-tools repo audit [--files] [--github] [--local] [--json] [--only CHECK ...] [--strict]
[--show N] [--snapshot FILE] [--save-snapshot FILE] [--attachments] [--workspace DIR] [--list]
uv run --project python coney-tools repo pr-health [--json] [--pending-hours H] [--verbose] [--labels] [--rerun] [--apply]
uv run --project python coney-tools repo hygiene (--event N | --sweep) [--apply] [--snapshot FILE]
The checks of the repository's own rules, which CI runs on every pull request. Every value they apply (the commit
areas and verbs, the description's sections, the path classes, the labels) is a key of .github/conventions.yml
(Repository conventions); a missing or malformed file is a one-line error and exit code 2.
repo checkloads and validates.github/conventions.ymland checks that each agent entry file (CLAUDE.md,GEMINI.md) holds the@AGENTS.mdimport line and nothing more. Exit 1 when a pointer file is wrong.repo check-titlechecks the first line of a commit message or a title (-reads stdin) againstcommits.title:area: Verb the rest, a known area and verb, the length limit, no trailing period, the breaking suffix last. Thetitlejob of theprworkflow runs it on the pull request title.repo check-docsreads the changed paths on stdin and refuses a change underpaths.codethat has no change underpaths.docsand noDocs: noneline in the body.repo check-bodychecks a pull request description against the template (What a pull request shows). Given--worktree, it also reads the branch's commits (--base, defaultorigin/main, toHEAD) and requires the AI useModel:line to name every model aCo-Authored-Bytrailer on them credits: see the Model line. Given--title(thebodyjob passes the pull request's title), a title area a player sees (pull_requests.runtime.areas:gameplay,graphics,audio) must have a line startingRuntime:in the Testing section: the evidence of a comparison with the original running, with numbers, orRuntime: none (<reason>)(the Runtime line). Without--titlethat rule is not checked and the output says so;repo check-reviewalways has the title (line 1 ofPR.md).--local-imagesaccepts an image that points at a local file, as in a description thatgh pr create --attachis about to upload; the release guard hook passes it (The release queue).repo review,repo check-reviewandrepo review-stampare the release gate on a description's content: see review. All three refuse a description whose title or body fails the checks above.repo labelsprints thegh label createcommands forlabels.list;--applyruns them (the coordinator does).repo renderrewrites the generated blocks ofAGENTS.md,CONTRIBUTING.mdand every guide underdocs/guides/from the conventions: the text between a<!-- conventions:begin NAME -->line and the next<!-- conventions:end -->line (the commit areas and verbs, the label table, the rules for a pull request description, the required checks, the documentation-only paths). Run it after changing.github/conventions.ymland commit both.--checkchanges nothing and exits 1 naming each file with a stale block, as thepythonworkflow does; a wrong marker (an unknown block name, an end without a begin, a block never closed) is a one-line error with the file and line, exit code 2. Generated blocks lists the blocks.repo auditreports where the repository departs from the conventions and changes nothing: see audit.repo pr-healthlists the open pull requests that are stuck, and with--rerunrestarts the checks that failed for a reason that no longer holds: see pr-health.repo hygienewrites the audit's findings back as comments: see hygiene.
The Model line and the branch's trailers¶
A branch whose commits carry a Co-Authored-By: <model name> <noreply address> trailer was written with that model, so
the description's Model: line must say so. Without this check only the pr-reviewer agent caught a line that named
the wrong model, and it cost three review rounds (pull requests #460, #466 and #467).
- What counts. A trailer on a commit of
base..HEADwhose key iscommits.trailers.ai_co_author.key(any case) and whose address is one ofcommits.trailers.ai_co_author.noreply; a person credited as a co-author is not a model. Each model counts once (compared without regard to case or runs of spaces). - The match. The model name must appear on the
Model:line, in any case and anywhere on it (Claude Opus 5.5is found inClaude Sonnet 5.5 and claude opus 5.5 (review)), not as the start of a longer version (Claude Opus 5.55). A branch with no such trailer needs nothing; aModel:line ofn/afor a human-written change passes as before. Whoever the description says wrote the commits, a trailer on them has to be named:a personwith an AI trailer is refused. When the AI use lines are themselves unusable (a missing or emptyModel:for an AI-written branch) only that is reported, not the trailers too. - The base must be fresh and right.
base..HEADis what the branch has that the base lacks. With a staleorigin/main, a branch that has merged a newermainalso counts the commits it merged in, and so their trailers (other authors' models) must be named: rungit fetchand the refusal goes away. A stacked branch (Dependent units are stacked) with the default base also counts the parent unit's trailers: pass--base <parent branch>. - The message. One reason per missing model, naming it:
AI use: 'Model:' does not name Claude Opus 5.5, which a Co-Authored-By trailer on the branch's commits credits. - Where it runs. Locally, where the branch is at hand:
repo check-review,repo reviewandrepo review-stampread the commits of the worktree after--base(defaultorigin/main) and refuse before the agent starts, andrepo check-body --worktree WTdoes the same for a description on its own. CI does not run it: thebodyjob gets the description from the event and has no commits (its checkout is shallow, and a full history for one string comparison would slow a required check on every edit), so it passes no models and stays a description-only check, asDocs: noneis. The coordinator's release step is the gate (The description review). A branch git cannot list (an unknown--base, not a worktree) exits 2 rather than passing as one without trailers. - One source. The trailer's key and addresses are
commits.trailersand the rule's switch ispull_requests.ai_use.model.names_trailer_modelsin .github/conventions.yml; the check holds no copy, and the generated rules in CONTRIBUTING.md state it.
repo review¶
The gate a description passes before the coordinator pushes it (The description review).
PR.md is the description file as agents write it: the title on line 1, then the body; image paths in it are relative
to its folder.
repo reviewruns thepr-revieweragent once, headlessly: it reads.claude/agents/pr-reviewer.mdfrom the checkout the command runs in, hands it toclaude -p --agents FILE --agent pr-reviewer --model M --dangerously-skip-permissions(a throwaway run, inPR.md's folder) with a task namingPR.md, the worktree and--base(defaultorigin/main), and then runscheck-review. Its task has the agent stamp with the same--base. It removes an oldREVIEW.jsonandREVIEW.mdfirst, so a run that writes no verdict cannot leave an earlier pass behind.--modeloverrides the agent file's model,--effortsets the effort level,--claudenames the executable and--timeout-minutes(default 30) stops a run that hangs. Exit 0 when the verdict releases the branch, 1 when it does not orclaudefailed, 2 when the review cannot run (no description, no agent file, not a worktree).repo check-reviewfirst runs the checks CI runs on the description:check-titleonPR.md's line 1 andcheck-bodyon the rest (PR.md's convention: title on line 1, body after it), except that a local image counts as an upload, sincegh --attachuploads it when the pull request is opened. Each reason is printed with atitle:orbody:prefix, together with the verdict's, so a missing template section is caught before the push. It also holds theModel:line to theCo-Authored-Bytrailers of the commits after--base(defaultorigin/main), which CI cannot see (the Model line).repo reviewruns the same checks before it starts the agent and stops at once when they fail. It then readsREVIEW.jsonnext toPR.mdand passes only when the verdict ispasswith no blocker, itstipis the worktree's HEAD, itsbody_sha256isPR.md's hash now, and every local imagePR.mdshows is listed with its hash now and differs from every other one. Exit 1 lists each reason (a title or body CI would refuse, a stale tip, an edited description, a replaced or unreviewed picture, the blockers); 2 whenPR.mdcannot be read.repo review-stampprints those values as JSON (tip,body_sha256,imageswithpathandsha256,nullfor a missing file). The agent copies them into its verdict, so the hash is computed in one place. It first runs the same title and body checks ascheck-review(one function,precheck, including theModel:line against the commits after--base, defaultorigin/main) and, when they fail, prints the reasons with theirtitle:orbody:prefix on stderr, prints no stamp and exits 1, so a reviewer launched as a subagent cannot review a description thatcheck-reviewwould refuse at release. Exit 2 whenPR.mdcannot be read or the worktree's commits cannot be listed. It takes the rules from the checkout the tool is installed in, so it runs from any folder (the agent runs it in the scratch folder).
The file names, the agent and the hash rule are pull_requests.review in .github/conventions.yml; the rules in full
are the generated list in The description review.
repo audit¶
repo audit runs the checks of hygiene.checks in .github/conventions.yml, in up to three scopes
(the checks):
--filesreads the checkout and needs no network (the default when no scope is given). Thepythonworkflow runs it on every pull request, andcheck changedruns it as itsrepostep. It checks the pointer files, that every file the conventions name exists, that the repository's address is only in the README, that the issue forms, the pull request template,.github/scripts/ci-scope.shand pre-commit's size limit still agree with the file, and that the generated blocks are current.--githubreads issues, pull requests, labels, the repository settings and the ruleset throughgh(read only;ghmust be signed in) and checks them: the sections of each open issue (a missing one, one left asx, headings that are not the form's###headings in its order, an Area that is not an option), its Screenshots, its labels, a closed issue that still carries anS-status label, the word "owner" for a person (say "user" for who tested or filed it, "maintainer" for who decides), closed issues and merged pull requests that keep an unchecked item with no follow-up issue number, open pull requests whose description names a path on your machine or an image that is not uploaded, stacked pull requests, how long an item has waited, and drift between the labels, settings and ruleset on GitHub and the file.--save-snapshot FILEalso writes what it read, and--snapshot FILEaudits a saved snapshot instead of reading GitHub (the tests do; so can you, offline). The link check needsorigin/mainin the checkout (git fetch origin) and says so when it is missing.--attachmentsalso downloads the.txtand.logfiles attached to open issues and scans them for possible game data and personal information (issue.attachment-data,issue.attachment-personal; Attached logs); without it those two checks do not run and a notice says so.--localreads the workspace around the checkout: the free space of its drive, merged worktrees and stray files among the worktrees (workspace cleanwould remove them), and scratch folders of merged or abandoned branches that still hold files taken from the original (save states, traces, dumps). The limits are theworkspacesection of the file. It asks GitHub once for the merged pull requests of every branch, judges the worktrees and walks the scratch folders several at a time, and does not read the last activity of a worktree it will not report (abuild/folder isworkspace clean's business), so a workspace of 80 worktrees takes seconds, not minutes.
Each finding has the check's id, a severity (error or warn), the item (#201, a file, a worktree), a message and a
fix class: auto (a coney-tools command fixes it), skill (a person or the /repo-hygiene skill fixes it with
judgement) or maintainer (a maintainer decides). The text report groups findings by check and prints the counts;
--show N limits the findings printed per check (0 prints all). --json prints one object with errors,
warnings, checks (the count per check that ran), notices (what could not be read) and findings, each with
check, keys (the conventions keys it enforces), severity, item, url, message and fix. --only takes a
check id or a group (issue) and may be repeated. Exit code 1 when a finding has severity error (any finding with
--strict), 2 when gh or the snapshot cannot be read or the conventions are unusable, otherwise 0. The audit never
edits an issue, a pull request or a label; fixing the findings is the coordinator's job.
repo pr-health¶
repo pr-health answers "which open pull requests need someone to act?" with one read of the open pull requests and
their checks (gh pr list --json) and one of the branch ruleset (gh api repos/{owner}/{repo}/rules/branches/main,
which names the required checks, so nothing is hard-coded). It prints one line per cause, sorted by pull request
number, and changes nothing (except --rerun --apply, below):
#7 fix-bat-cross checks-failed lint: body, markdownlint -> author
#9 research-x checks-failed code: python-ok; lint: markdownlint -> author
#12 throw-arc conflict with main -> author
#15 char-stats ci-stuck build-ok 4.2 h (limit 3.0 h) -> coordinator
#18 workouts-2 auto-merge-off into main -> coordinator
| Cause | When | Acts |
|---|---|---|
checks-failed |
a required check failed (failure, timeout, cancelled). Its class is lint for the checks in hygiene.pr_health.lint_checks (title, body, docs, format, mkdocs, markdownlint, actionlint) and code for the others (build-ok, python-ok); a mix lists both |
author |
conflict |
GitHub says the pull request conflicts with its base | author |
ci-stuck |
a required check has been queued or running longer than hygiene.thresholds.pr_pending_hours (default 3; --pending-hours H overrides it for one run) |
coordinator |
auto-merge-off |
the pull request is not a draft, targets main and has auto-merge off |
coordinator |
A failing check that is not required is ignored. A draft is never stuck. A stacked pull request (base other than
main) is reported as stacked and never as lacking auto-merge; its failures and conflicts count. GitHub answers
UNKNOWN for mergeability while it computes it, which means "not known yet": it is not a finding and shows only with
--verbose, together with the drafts and stacked pull requests, so a rerun a minute later settles it.
--json prints checked, stuck (each with number, branch, cause, kind, detail, who, checks, url),
other (the --verbose lines), notices and labels (see below). Exit code 0 when nothing is stuck, 1 when something
is, 2 when gh or the conventions cannot be read. Run it from the coordinator's loop to find the pull requests to
unstick or to push.
--labels also compares the labels on every open pull request with its causes and prints what would change, after the
findings: add S-conflict to #12 (conflict with main), remove S-failing from #7 (cleared) (would add ... until
--labels --apply). hygiene.pr_health.labels in .github/conventions.yml maps the causes to labels (conflict to
S-conflict, checks-failed to S-failing); a pull request with the cause gets the label, one without it loses the
label (a draft is never stuck, so it carries neither), and no other label is ever added or removed.
--labels --apply makes the
changes through gh api on the issue labels endpoints and needs a token that may write issues; the exit code is still
the findings' (the hygiene workflow accepts 1). While GitHub answers UNKNOWN for a pull request's mergeability the
command rereads up to hygiene.pr_health.unknown_retries times, unknown_wait_seconds apart, and a pull request still
unknown keeps its conflict label as it is. The labels must exist in the repository with their colours: repo labels
--apply creates them from labels.list.
--rerun restarts CI that failed for a reason that no longer holds, so a stuck pull request does not wait for a
person to notice. It adds a line per run, #<n> <branch> rerun <checks> (run <id>): <why> -> <outcome>, and in
--json a rerun list (an empty list when there is nothing to rerun; the stuck list and the exit code are the same
with or without --rerun, so exit code 1 still means a pull request is stuck). Without --apply it is a dry run that
prints would rerun and changes nothing; --apply calls gh run rerun <run> --failed. It reads the
head commit and description of each pull request and, for a failed required check, the runs of the head commit and
sometimes its job log. A required check is judged stale in two ways, and a real lint, build or test failure is never
restarted:
| Stale because | How it is known | What happens |
|---|---|---|
| the text was fixed | the title or body check failed and the pull request's current title or description passes repo check-title or check-body |
status new-event: the report says to edit the title or description; no rerun is made |
| the runner or GitHub failed | the check concluded CANCELLED, STARTUP_FAILURE or TIMED_OUT, or it failed and its log matches a pattern of hygiene.pr_health.rerun.log_patterns (the runner was lost or shut down, an action could not be downloaded) |
would rerun / reran |
Each rerun entry is one run, with these fields (stable; scripts such as the PR guard read them):
| Field | Type | Meaning |
|---|---|---|
number |
integer | The pull request's number |
branch |
string | Its head branch |
run |
integer | The workflow run (an id of gh run list) holding the stale checks |
checks |
list of strings | The stale required checks of that run, sorted |
reasons |
list of strings | Why each is stale, in the order of checks |
status |
string | would-rerun (dry run), reran, refused (GitHub would not), capped (already rerun max_per_sha times) or new-event (a fixed title or description) |
detail |
string | Empty for would-rerun and reran; otherwise what happened and the new event the pull request needs |
A rerun reuses the event the run started from, with the title and description of that time, so it cannot make title
or body see a fix (observed: a rerun made after a description was fixed failed on the old text). A fixed text needs a
new event instead (an edit of the title or description, or closing and reopening the pull request), which the report
names; it is not done automatically because reopening changes the pull request's state.
The limits are in hygiene.pr_health.rerun of the conventions file: max_per_sha (a workflow is rerun at most that
many times on one head commit, counted from the attempts GitHub keeps for its runs, so restarting the tool loses
nothing; at the cap the status is capped and the line says to push a commit), the names of the title and
description checks, the conclusions and the log patterns. A run is restarted with --failed only when all its failed
jobs are stale; when it also holds a real failure, only the stale jobs are restarted (--job). When GitHub refuses
(gh run rerun fails, for example "cannot be retried"), the status is refused and the line carries GitHub's message
and the new event the pull request needs: push a commit for a runner failure. The reads are gh run list --commit
and gh api repos/{owner}/{repo}/actions/jobs/<id>/logs; only --apply writes.
repo hygiene¶
repo hygiene is what the hygiene workflow runs (Workflows); you can run it too.
It audits GitHub (repo audit --github, plus --files for a sweep) and writes the result back as comments, with the
settings in hygiene.comments of .github/conventions.yml:
--event Nis for issue or pull requestNthat just changed. It creates the item's one comment when it has findings, edits it when they change, and edits it to "found nothing to fix here now" when they are gone. A comment is never deleted. Findings of the checks inhygiene.comments.report_only(the idleness checks) are left out, because a comment is activity and would end the idleness it reports.--sweepis the weekly run. It keeps one report comment (counts per check, then the findings) on the open issue titledhygiene.report_issue.title, creating that issue (with the report issue's labels and a task-form body) when it is missing. Item comments are written by a sweep only whenhygiene.comments.on_sweepis true, and then at mostmax_per_sweepof them, resolves first; it starts false, so the sweep only reports and item comments come from events.- Both scan attached logs, as
repo audit --attachmentsdoes (Attached logs): an event the logs of its own issue, a sweep those of every open issue, withinhygiene.attachments.max_per_rundownloads. A download that fails is a notice (printed, and in the report), not a finding. - Without
--applynothing is written: it printswould create comment on #7: ...for each write.--snapshot FILEaudits a saved snapshot (audit); the comments are still read from GitHub.
Only a comment by hygiene.comments.author (the Actions bot) whose first line is <!-- MARKER --> (the report's is
<!-- MARKER:report -->) counts as the workflow's own, so pasting the marker never makes it edit someone else's
comment. Text from GitHub quoted in a comment (a title in a finding) cannot mention anyone, link, embed an image or
close the marker, and the report writes #12 so that it makes no cross-reference in the item's timeline. Exit code 0
when it ran, 2 when gh or the conventions cannot be read.
issue¶
uv run --project python coney-tools issue draft [task|bug]
uv run --project python coney-tools issue check --title T --body FILE [--form F] [--leftover] [--online] [--attach F]
uv run --project python coney-tools issue create --title T --body FILE [--label L ...] [--parent N] [--leftover]
[--attach F] [--offline] [--dry-run]
uv run --project python coney-tools issue edit N --body FILE [--title T] [--offline] [--dry-run]
uv run --project python coney-tools issue link N --parent P [--dry-run]
Drafts, checks and files issues in their form's exact shape (Filing an issue). Every rule is
read from issues in .github/conventions.yml and from the form files in .github/ISSUE_TEMPLATE/; nothing is
copied into the code.
draftprints a blank body: the form's###headings in order, each preceded by an HTML comment with the form's description, required or optional, and the allowed values (the Area'sA-labels, a bug's dropdown choices), then theFrom:line. The form istaskunless you namebug.checkvalidates a title and a body file (-reads stdin) and prints one line per problem, tagged with its kind (title,headings,empty,choices,screenshots,from,links,wording,status); exit 0 when there is none, 1 otherwise. A dropdown answer is the option as written under its heading, with or without a list bullet or backticks (--play-levelis an option, not a bullet). The form comes from--form, else the title prefix, else the headings. Docs and research pages and their{#anchor}s are looked up inorigin/mainof the checkout with git, offline; a missingorigin/mainis a note, not a pass.--leftoverwants theFrom:line.--onlinereads the state of each cited#Nwithghand compares it with the words "open", "merged" and "closed" beside it.--attachsays local screenshots will be uploaded, so a local image in the Screenshots section is accepted.createrunscheck(the states online unless--offline), thengh issue createwith the form's labels and the Area'sA-label.--labeladds a label (checked againstlabels.list; a type or status label replaces the form's).--parent Nlinks the new issue as a sub-issue of N through the sub-issues API, and tells you theissue linkcommand to run if only that step fails.--dry-runchecks and prints theghcommands, running none.editruns the same check on the new body (with--title, or the issue's current title read withgh) beforegh issue edit.linkmakes an existing issue a sub-issue of another.
The body is filed without the draft's comments. Exit 2 when the conventions, a form file or the body file cannot be read.
check¶
uv run --project python coney-tools check changed [--base origin/main] [--build-dir build/dev] [--only STEP ...]
[--skip STEP ...] [--jobs N] [--clang-tidy PATH] [--agent ID] [--max-wait MIN] [--no-cache] [--detach]
uv run --project python coney-tools check result
The local gate for the files a change touched (Testing): run it in your worktree before you
hand a unit over. The changed files are everything added or modified since your branch left --base (the merge
base), committed or not, with untracked files. Each step runs only when the change touches what it checks, prints one
line, and any failure makes the command exit 1:
| Step | Runs when | What it does |
|---|---|---|
repo |
always | repo audit --files: .github/conventions.yml loads, and the agent pointer files, the generated blocks, the issue forms, the pull request template, the CI scope script and pre-commit's size limit agree with it |
orig |
a file under src/ changed |
orig check: a new function in a gameplay folder carries @orig or @coney-only, and every folder of src/ is listed as gameplay or Coney-only |
pre-commit |
a file changed | pre-commit run --files on the changed files: clang-format, ruff, whitespace, the 512 KB guard, the real-clock rule |
clang-tidy |
C++ under src/ or tests/ changed |
clang-tidy on the files CI's pull-request job would check, with the compiler warnings the GCC and Clang builds enable |
docs |
a file matching paths.docs_only of .github/conventions.yml changed (docs/, research/, top-level Markdown, mkdocs.yml, the MkDocs hooks and theme overrides, the shared artwork, requirements-docs.txt, ...), or the python the build imports (python/src/coney_tools/progress*.py, config.py) |
docs check on the changed pages, then mkdocs build --strict into a throwaway folder; both run, so one run shows every problem |
python |
python/ or .claude/ changed (the tests also run the hooks) |
ruff check and ruff format --check over python and .claude/hooks, pytest python/tests and mypy --config-file python/pyproject.toml python/src python/tests |
Which files clang-tidy checks. The same rules as CI (.github/scripts/tidy-files.sh): a changed .cpp itself, a
changed header through every .cpp that includes it, directly or through other headers. A test keeps the two in
agreement. When the build or tidy setup changed (CMakeLists.txt, cmake/, CMakePresets.json, .clang-tidy, the
build workflow) the nightly CI run checks every file, an hour's work; the command says so and checks the changed ones
only. Pull requests get no clang-tidy in CI, so this is their gate (building).
How it runs clang-tidy. With the repository's .clang-tidy and the compile_commands.json of --build-dir
(every preset writes one; configuring is enough, no build, and a stale or missing entry is reported with the
coney-tools build --configure-only to rerun). It finds clang-tidy 19 as clang-tidy-19 on PATH, in Homebrew's
llvm@19, in the Visual Studio LLVM folder that vswhere names, or as clang-tidy on PATH or in Homebrew's llvm
(the first of version 19 wins; another version is used with a note), or from --clang-tidy. A ccache or sccache
launcher in the database is removed from the copy clang-tidy reads, as CI's job does. The compile database of an MSVC
build makes clang-tidy run in clang-cl mode, where MSVC's /W4 misses what GCC and Clang reject (a designated
initializer that leaves a field out, -Wmissing-designated-field-initializers). So the command adds -Wall -Wextra
-Wpedantic (the flags of cmake/coney_target.cmake, passed as /clang:-W... in that mode) and lets the compiler's
diagnostics through the -* of .clang-tidy; with the config's WarningsAsErrors they fail like CI's -Werror.
Every program the command starts runs below normal priority, like a build's (Windows: below-normal priority class;
elsewhere nice -n 5).
Tidy slots, the cache and waiting. clang-tidy runs only on the files the shared result cache has not seen clean in
the same state, and only while the command holds a slot of the machine's tidy lane
(building): tidy_slots of coney.local.toml gates at once (default 1), each running
tidy_jobs clang-tidy processes (default 0: a build slot's share, the cores divided by build_slots); --jobs
overrides the second for one run. A gate whose files are all cached takes no slot, and one of at most 6 files takes the
fast lane's slot instead. The summary line says how many files came from the cache (254 files (clang-tidy 19, 251
cached)). The queue is the build slots' own: fair, with the same claim files; build status lists the lane, each
holder with its file count and progress (tidy 12/40), and build release --lane tidy cleans up after a crash. The
queue entry carries --agent (default: CONEY_AGENT, else the worktree's folder name). --max-wait minutes (default
and most 40, inside an agent's prompt cache) bound the wait; a gate that gives up skips clang-tidy, runs the other steps
and exits 75 unless one of them failed. --detach runs the whole gate in the background, apart from the shell (default
wait 240 minutes), and returns at once; check result prints its output and exits with its code (75 while it runs, as
build result does, and 1 when its process is gone; "still running" is judged as for build result, by the pid and
when that process started). --no-cache tidies every file again.
Linux and macOS: use a Clang database. On Windows the dev preset uses MSVC and the command adapts. On Linux the
default compiler is GCC, whose database can carry flags clang-tidy rejects, and CI tidies a Clang 19 build. The
command notes a GCC database; configure a Clang one once, beside build/dev, with build (it sets the
compilers for the run and configures a second folder; a hook refuses a raw cmake), and point the command at it:
uv run --project python coney-tools build --agent ID --configure-only --preset dev --build-dir build/tidy \
--env CC=clang-19 --env CXX=clang++-19 # macOS: the Apple or Homebrew clang
uv run --project python coney-tools check changed --build-dir build/tidy
CI runs the repo and pre-commit steps on Windows, Linux and macOS in its python workflow, so the command's
handling of paths, programs and long command lines is tested on all three.
--only and --skip take step names, for a rerun of the one that failed. Exit codes: 0 all passed, 1 a step failed, 2
a setup problem (no compile database, no clang-tidy), 75 clang-tidy gave up waiting for a slot. The command prints at
most 40 lines of a failing step; run the step's tool by hand for the rest. What stays CI's: warnings only GCC emits,
macOS, the sanitizers and the full clang-tidy pass (see the local gate).
docs¶
uv run --project python coney-tools docs check [PATHS ...] [--base origin/main] [--summary] [--quiet]
Checks Markdown pages against the standard of Writing these docs: the skeleton, the
"Verified against" line and the evidence levels of a research page, and the Style rules every page keeps. Writing-docs
owns the rules and the heuristics; this entry is the command. Without PATHS it checks the pages changed since
--base (committed or not, untracked included), as check changed does in its docs step and the docs
workflow does on a pull request. With PATHS it checks those files, or every page under a folder, whether or not they
changed. It prints path:line: rule: message for each finding (--quiet prints only the counts), a warning (a long
page) never fails, and --summary adds how many pages of each kind pass and how many pages break each rule: run it
on docs for the standing of the whole tree. Exit 0 when no page has an error, 1 when one has, 2 when the command is
not run inside a checkout, the conventions cannot be read, --base is unknown, or a named path is missing, outside
the checkout or not a page the check covers (README.md, a generated page).
build¶
uv run --project python coney-tools build [--agent ID] [--preset dev|release|ci|asan] [--test] [-R REGEX] [-L LABEL]
[--target NAME ...] [--configure | --configure-only] [--build-dir FOLDER] [--env NAME=VALUE ...] [--priority]
[--lane auto|main] [--max-wait MIN] [--budget MIN] [--detach] [-j N] [--dir CHECKOUT]
uv run --project python coney-tools build status [--json] [--dir CHECKOUT]
uv run --project python coney-tools build result [--json] [--dir CHECKOUT]
uv run --project python coney-tools build release --agent ID [--force] [--slot N] [--lane main|fast|tidy]
[--dir CHECKOUT]
--dir names the checkout the command is about and works before or after the subcommand (build --dir X result and
build result --dir X).
The one way an agent configures, builds and tests the engine. Several agents share one machine and a full build uses
every core, so a build takes a slot like a PCSX2 copy takes a claim, and at most build_slots (default 2) run at
once. The command replaces cmake --preset, cmake --build --preset and ctest --preset; a hook in
.claude/settings.json refuses those, ctest and ninja typed directly in a shell
(.claude/hooks/build_guard.py). The tool's own subprocesses are not shell
commands, so the hook never sees them. --agent names whose run a slot, a queue place and a log are; a person typing
the command in a terminal may leave it out and gets their user name (me when it has no usable characters). It
stays required whenever the output is not a terminal, as under every agent tool, and inside Claude Code
(CLAUDECODE set), so every agent's build says whose it is.
What a run does. From the checkout (the current one, or --dir):
- Precheck, before it waits for anything:
CMakePresets.jsonnames the preset, andcmake,ctestandninjaare found. On Windows it runsvcvars64.batonce and captures the environment (found withvswherein the installer folder underProgram Files (x86), which is not onPATH; inside a Developer shell it does nothing), so no special shell is needed. - The build folder's lock, held to the very end (see "One run per build folder" below).
- Configure and build, one claim:
cmake --preset Pwhen the build folder has noCMakeCache.txtor with--configure, thencmake --build --preset P -j N, through the compiler cache.--configure-onlystops after the configure (the compile databasecheck changedreads).--target NAME(repeatable) builds only those targets and what they need:--target coneyis a play build, the executable without the roughly thousand test files ofconey_tests. It is refused with--test(the tests would run an oldconey_testsorconey) and with--configure-only. - Test, with
--testand only after a successful build, a claim of its own (it queues again between the two, so a long test run does not hold a slot against other agents' builds longer than it must):ctest --preset P -j N, with the filters-R REGEXand-L LABELwhen given (see "Choosing tests" below).
The disc for the tests. The disc tests read only the environment variable CONEY_DISC
(repo:tests/support/disc_play_fixtures.h) and skip without it, and ctest counts a skipped test as passed. So the
command sets CONEY_DISC for the whole run (CMake reads it too when it configures, to register the coney --disc
smoke tests): a value already in the environment, or given with --env CONEY_DISC=..., is kept (an empty one counts
as not set); otherwise it is the game_dir of coney.local.toml (the one .iso in that folder, else the folder
itself, as trace hands it to Coney), when that exists. A folder configured before the variable was set keeps the
smoke tests it registered then; --configure registers them. With --test the summary gets a line before the test
line: disc tests: ran with the disc (CONEY_DISC from the environment | game_dir in coney.local.toml), or
disc tests: SKIPPED (<why>) with the two ways to run them, and N skipped joins the test counts when ctest
reported skips (a disc configured and tests still skipping is named, and the log says which). A suite that passes
with disc tests: SKIPPED ran no disc test.
The log. The full output goes to a file of its own per run, build-<preset>-<agent>-<date>-<time>.log in the
worktree's folder of the scratch directory (scratch/<worktree>/; the process id is added if that name exists), and
its path is the first line printed. No two runs share a log, so one never garbles another's; the newest 20 logs of a
worktree are kept. What is printed after is short: a line per step (outcome, time, lane, how many steps were out of
date, time spent waiting), a line on the compiler cache after the build (compiler cache: 980 hits, 12 misses
(98.8 %): this run's calls alone, from a stats log of its own, though other builds share the cache; or off and
why, or not used for a folder configured before the cache was on), the first compile, link and CMake errors (MSVC,
GCC and Clang forms) and the failed steps,
the failed tests by name (the first eight, then a count; the counts and the names are ctest's own, read from its "The
following tests FAILED:" list up to its blank line or "Errors while running CTest" line, labels after a state
included, so a test's output that looks like a list entry is never taken for a failure; without that list, or when it
holds fewer names than ctest counted, the ***Failed and ***Timeout lines supply the rest) with N passed, M failed
of T, and a last result: line that names the failing step or the first failing tests. A command that exits non-zero
with no error, no failed step and no failed test in its output says so plainly, stopped from outside (no failing step
found): it was killed, timed out or interrupted by something else. Exit code 0 on success, 1 on a failed step or
a filter that selects no tests, 2 on a precheck or usage problem, 75 when it gave up waiting for a slot or for the
build folder, 130 on an interrupt or when its caller is gone. The children run at below-normal priority, on Windows
with no console window of their own (CREATE_NO_WINDOW, so a build started from a background task opens no terminal
that takes the focus), and never outlive the command (below).
Process lifetimes. An orphaned ctest goes on starting tests after its caller is gone, and killing the tests one
by one only lets it start the next, so nothing the command starts may outlive it
(process_lifetime.py):
- The command is killed. On Windows it puts itself in a job object with
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSEbefore it starts anything;cmake,ninja, the compilers,ctestand everyconey_testsinherit the job, and when the command ends, however it ends (Task Manager included), Windows ends them all. On Linux and macOS each child runs in a session of its own, so its whole tree is one process group that the command stops on an interrupt,SIGTERMorSIGHUP; on Linux the child also getsSIGKILLwhen the command dies bySIGKILL(PR_SET_PDEATHSIG). - Its caller is gone. A watch thread looks every two seconds at the command's ancestors, up to the shell that ran it
(through
uvand Python's launchers; under Git Bash, as far as the native chain goes). When one has ended (the agent tool restarted, the background shell was killed), the command stops its child, starts nothing more and leaves as on an interrupt, releasing its claims (by force 15 seconds later if a wait does not notice); exit130. - An older run died without these guards. When a claim or a folder lock whose process is gone is taken over or
released with
build release, the build programs that run started (cmake,ctest,ninja,coney_tests: its direct children by parent pid, started after the claim was made, as Windows reuses pids) are stopped with everything under them, and the run that took over saysstopped N process trees <agent>'s run left running. A claim that is stale only by age (its pid lives) is taken over without stopping anything, as the pid may be somebody else's by now.
Parallelism. -j defaults to the cores divided by the slots (at least 1), so two builds share the machine
instead of each starting a job per core; -j N overrides it, for both the build and ctest.
The slots. Each run holds a claim: a folder slot-N.claim/ with owner.json (agent, worktree, preset, the step it
is at, pid, start time) in the claims directory, built whole in a private folder and renamed into place, so exactly one
caller wins and none sees a half-made claim. The directory is build-claims/ in the shared scratch folder, the main
checkout's ../../scratch, whatever a worktree's scratch_dir says (keys build_claims_dir and build_slots in
coney.local.toml). Nothing is cached: status is rebuilt from the folders and the live
processes every time. A claim is released on success, on failure and on an interrupt, and is stale (taken over by
the next claimer, who says so and stops what the dead run left running; see "Process lifetimes" above) when its process
is gone, or when it is older than four hours with a process that lives (a hung build, or a reused pid). Takeovers go
through a lock folder, as the PCSX2 claims do.
The queue. With no slot free, the build waits in a first-come queue of ticket files (queue/). Only the head of
the queue may claim, so waiters cannot overtake one another; --priority tickets go ahead of normal ones (for the
coordinator, or a build somebody is waiting on). A ticket whose process is gone is dropped. While it waits, the
command prints its place, a rough wait (from the holders' recorded step and how long they have run it) and the
holders, every minute. It does not wait for ever: after --max-wait minutes (default and most 40, per claim;
also spelled --slots-wait-timeout), so that an agent never outlives its one-hour prompt cache, it leaves the queue and
exits 75 with still queued, position N of M, try again later and the way on: do other work and run the same
command again, or start it with --detach, write STATUS.md and stop (Build waits).
The time budget. --max-wait caps one wait; --budget (minutes, default and most 45) caps the whole call,
waits and steps together, so an agent's wait plus its build stays inside the hour its prompt cache lasts. A larger
--max-wait or --budget, or --budget 0 (no budget), is refused with exit 2 unless the run is detached. Each
finished step's duration is kept in durations.json in the claims directory, the last ten per preset, step and size
(ci/configure/main, ci/build/fast or ci/build/main by lane, ci/test/filtered with -R or -L, ci/test/full
without); a build of some targets only has them in its key (dev/build/main:coney) and a build through the compiler
cache +ccache (dev/build/main+ccache), because both take a fraction of a full build without the cache and would
otherwise pull its median down or up; a failed step is not recorded. While a run waits for a slot, it leaves the
queue with exit 75 as soon as
the time the call has taken plus the median of what it waits for (a phase's steps added up; with no history, the
typical 5 minutes for a configure, 15 for a build, 10 for a full test run, 3 for anything in the fast lane) would pass
the budget, and says so with the numbers: time budget: 25 min of the call so far; the build would take about 30 min
(median of 4 runs) and the budget is 45 min. It also leaves at its first look when the queue's own estimate of the
wait still ahead (from the holders' steps), added to those, would pass the budget: the queue ahead is about 50 min.
The test phase counts the build before it, and the wait for the build folder counts too. The budget only decides
whether to keep waiting: a step that has started always runs to its end, and a free slot is always taken.
Detached runs. --detach checks the run as usual (precheck, refusals), then starts the same run in a process of its
own, apart from the calling shell (on Windows CREATE_NO_WINDOW in a new process group, never DETACHED_PROCESS, which
makes Windows Terminal show an empty window for the venv launcher's child; out of the caller's job when the job allows
it; elsewhere a session of its own), and returns at once printing the log path and the child's pid. A detached run may
wait and run as long as it needs: --max-wait defaults to 240 and --budget to 0, and neither is capped. Its
children still end with it, but nothing watches the shell that started it. It records itself in build-result.json in
the worktree's scratch folder (state, agent, preset, pid, when that process started, log, start time; at the end the exit
code, the printed summary and the end time; its console output goes to build-detached.out beside it), however it ends,
a refused precheck included. A second --detach in a worktree whose detached run still runs is refused. build result
prints that record: the run's own exit code and summary once it has ended, 75 and still running while it runs, and
1 with ended without a result when its process is gone without writing one (killed); --json prints the record
itself. "Still running" means the recorded pid lives and that process started when the record says: the system
hands a dead process's pid to a later one, and a stale record once kept a worktree "busy" for good because another
program had been given the pid. A pid whose process began at another time is gone (an older record without the start
time is judged by the record's own time: a process that began long after it is a stranger), and a start time that
cannot be read counts as running.
The fast lane. A small job should not queue behind a full build. Before claiming, the command measures the job.
For a build: the manifest is brought up to date for real with ninja build.ninja (it re-checks the CONFIGURE_DEPENDS
globs, which is cheap, and re-runs CMake only when a CMake file or a glob changed, which the build would do first
anyway; under the folder's lock, outside a slot), then ninja -n -d explain (of the --target targets only, when
given) runs on a copy of build.ninja, and the
causes it names are counted: each "output x older than most recent input y", "output x doesn't exist" or "command
line changed for x". A plain ninja -n is no use here: the globs make ninja want to re-run CMake first, and a dry run
stops there having planned nothing; and a dry run on a copy plans too much, because it cannot ask a restat step
whether its output really changed, and everything behind such a step is planned too (CMake's module dependency collation
was one, before module scanning was turned off: 410 planned steps for a touched source
whose real build was 10). The causes are what a real build must do whatever the restat steps find: one per touched
source, and one for each source that includes a touched header. For a test run, ctest -N with the same -R and -L
counts the tests selected. A build with fewer than build_fast_lane_steps causes (default 50) or a filtered test run of
at most build_fast_lane_tests tests (default 20) takes the fast lane: one extra slot of its own (fast-1.claim/,
queue queue-fast/), so at most one such job runs at once, with a short queue of its own. A first build, a
--configure, a build that cannot be measured (the manifest refresh failed, ninja printed no cause) and any test run
without a filter take a main slot. A threshold of 0 turns the lane off. The summary line says what decided it ("fast
lane, 2 steps out of date").
A filtered test run is also measured by time: one disc playthrough of a single test can hold a slot for half an
hour, which the fast lane exists to prevent. When any test the filter selects has a recorded time, and those times add
up to more than build_fast_lane_test_seconds (default 300; 0 turns the rule off), the run takes a main slot
whatever its count, and says so ("1 test expected to take 25.0 min by its recorded time, ... so the main lane"). A test
with no recorded time adds nothing, so a run of only such tests goes by its count. The times are ctest's own averages
(Testing/Temporary/CTestCostData.txt in the build folder) and, because a build folder is often new, a copy kept per
preset in claims/test-costs.json that every test run updates from its folder's file; the folder's own times win.
--lane main sends any run, build and tests, to a main slot without measuring, for a job you know is long; the
default, --lane auto, chooses as above.
One run per build folder. Two runs in one build folder collide: a second build's link fails
with LNK1168 while the first's tests hold coney_tests.exe, and the logs and the dependency log are written by both.
So each build folder (the checkout, the preset or --build-dir) has a lock, taken before anything else and held from
the configure to the last test, however the run ends. A second run in the same folder waits for it, in a queue
like the slots' (claims/folders/<worktree>-<folder>-<digest>/, the same stale rules: the holder's process is gone, or
four hours), printing who holds it and the step it is at every minute, and gives up after --max-wait minutes with
exit 75 as for a slot. Runs in other folders, other worktrees or other presets are not held up by it. The lock
covers the run from its measuring on, so the manifest refresh and the dry run never overlap a build either, and
a run that waited looks at the folder again once the lock is its own (the run before it may have configured it).
Choosing tests. -R REGEX runs the tests whose names match and -L LABEL those with a label that
matches (ctest's two filters; both together select the tests that satisfy both). The command lists the selection
first (ctest -N), takes the fast lane for 20 tests or fewer and fails, naming the filter, when nothing matches. Every
test that needs the player's disc has the label disc: the Catch2 tag [disc] of a test case becomes a ctest label
(catch_discover_tests(... ADD_TAGS_AS_LABELS)), and the coney --disc checks that exist only when CONEY_DISC is set
at configure time carry it too. So build --test -L disc runs exactly the disc tests (they skip without the disc), and
every other tag is a label as well (-L routes). -L matches a part of a label name: -L '^disc$' is the exact one.
-R is a CMake regular expression, and the tool passes it to ctest exactly as it was typed (a test checks that a
regex with ^ ( | ) $ . + \ and spaces reaches ctest unchanged), so a test that matches nothing is usually the
expression. Test names hold spaces, ., +, parentheses, : and quotes, and . + ( ) [ ] * ? ^ $ | mean something to
the expression: escape the ones that are part of a name with a backslash ('^(L1 \+ R1 with a full rage meter starts
rage|coney\.help)$'), and quote the argument against your shell with single quotes (PowerShell and bash both leave
those alone; a double-quoted $ or backtick is the shell's). Without anchors a name matches as a substring, which is
often enough: -R 'rage meter'. Preview a filter without a run: ctest --preset P -N -R ... -L ... lists what it
selects, and the hook allows it.
Another build folder. --build-dir FOLDER (relative to the checkout) configures, builds and tests in that folder
instead of build/<preset>, with the preset's settings (cmake --preset P -B FOLDER, cmake --build FOLDER, ctest
--test-dir FOLDER), for a second configuration beside the first, such as the Clang one that
check changed reads for clang-tidy. --env NAME=VALUE (repeatable) sets an environment variable for the
run, such as CC=clang-19; CMake reads CC and CXX at a folder's first configure and keeps them in the cache.
build status prints every lane (main, fast, and the clang-tidy lane of check changed, whose "preset" is
the gate's file count): each slot's holder, worktree, preset, step and minutes, the queue in order (place, agent,
priority, minutes waiting) and the stale entries; --json prints the same as one object. build release --agent ID
[--lane main|fast|tidy] is manual cleanup: it frees the slots a crashed or stuck run of that agent left, stops what a
crashed run left running, and drops its tickets. A slot whose process still runs, or another agent's, needs --force,
which frees the slot but does not stop the process. How agents use it, and what exit 75 asks of them: Builds and
tests.
coord¶
coord status answers "what waits for the coordinator?" in one read. Anyone can run it at a prompt, and the
coordinator's Tether runs it to wake the session when new work arrives:
uv run --project python coney-tools coord status # one line per section, then the ids
uv run --project python coney-tools coord status --json --github
uv run --project python coney-tools coord status --ids --github --kind unit # one id per line, for a poller
Without --github it reads only the machine, in a few seconds, and never touches the network. It reads:
- the PCSX2 copies (the rows of
pcsx2 status --json); - the build slots and queues (
build status --json); - each worktree's last detached build (
build result); - the release queue: each
PR.mdorPR-<n>.mdin the scratch folder of a branch that has a worktree, and whetheroriginhas the branch, as the local clone knows it; - the tracks: each scratch folder's
STATUS.md, kept while its worktree exists or for three days after its last change.
--github adds, through gh:
- the open issues: number, status, priority and area labels, the other labels and age, but no titles;
- the open pull requests: branch, draft, mergeability, auto-merge, the required checks in one word, and the causes
the
repo pr-healthjudge finds, whose report is included whole; - the CI queue's counts (
gh run list --status queuedand--status in_progress); - for each unit file, the pull request that released it: an open one for its branch, or one merged or closed after the file last changed. A unit with neither is queued.
--json prints one object: time, checkout, github, the sections worktrees, pcsx2, builds, results,
tracks, release, issues, prs and ci, and action. The last three sections are null without --github.
Each section has error, null when it was read. A section that could not be read holds only error, and the
others stand, so a failure is never taken for "nothing waits". The exit code is 0, and 2 when the command cannot run
at all (outside a checkout). action lists the ids of what waits, sorted:
| Id | When |
|---|---|
issue:<n>:S-needs-triage |
an open issue carries S-needs-triage |
pr:<n>:<cause> |
repo pr-health calls the pull request stuck for that cause |
unit:<branch>:<file> |
a unit file is queued |
build:done:<worktree> |
the worktree's detached build ended within the last hour |
An id stays the same while the thing waits, and leaves the list once it has been dealt with, so a reader can tell new
work from old by comparing lists. The issue, pr and unit ids need GitHub and appear only in a --github read.
--ids prints only the ids, one per line, for a program that watches them, and cannot be combined with --json.
--kind issue|pr|unit|build (repeatable) reads only the sections those kinds come from, and prints only their ids,
so a poller pays for one read. A GitHub kind needs --github (exit 2 without it). When a section the ids come from
could not be read (for unit and build ids that includes the worktree list), --ids prints nothing, names the
section and its error on stderr and exits 1, so a watcher reports a failed read instead of an empty list.
Waking the coordinator¶
Between turns the coordinator's session sees nothing, so new work would wait until someone typed. The
Tether plugin, which the maintainer runs in every session, watches for it and
wakes the session when it arrives. It needs Tether 1.0.0-alpha.31 or later. Tether reads .claude/tether.json in the
session's folder. The repository commits no such file, because Tether would then apply it to every agent's worktree
too, and the maintainer's own file holds machine settings. The coordinator's local file (listed in
.git/info/exclude) carries the watch settings below. This block is the source those settings are copied from:
change it here first, then in the local file. python/tests/test_coord_cli.py checks that every coney-tools
command in it parses.
{
"watch": {
"storeName": "coney",
"wakePreamble": "Coordinator: new work. Handle it or say why it can wait:",
"idleWakeMinutes": 0,
"pollers": [
{
"id": "triage",
"kind": "issues",
"title": "Issues to triage",
"labels": ["S-needs-triage"],
"everySeconds": 120,
"remindMinutes": 30,
"remindMaxMinutes": 240
},
{
"id": "release",
"title": "Release queue",
"command": ["uv", "run", "-q", "--project", "python", "coney-tools", "coord", "status", "--ids", "--github", "--kind", "unit"],
"everySeconds": 120,
"timeoutMs": 240000,
"remindMinutes": 30,
"remindMaxMinutes": 240
},
{
"id": "builds",
"title": "Detached builds",
"command": ["uv", "run", "-q", "--project", "python", "coney-tools", "coord", "status", "--ids", "--kind", "build"],
"everySeconds": 15,
"timeoutMs": 90000
},
{
"id": "prs",
"kind": "prs",
"title": "Open pull requests",
"command": ["uv", "run", "-q", "--project", "python", "coney-tools", "repo", "pr-health", "--json"],
"everySeconds": 300,
"timeoutMs": 240000,
"worktrees": "<workspace>/worktrees/coney",
"busyMarker": ".coney-busy",
"assignHours": 4
}
]
}
}
Replace <workspace> in the prs poller with the folder that holds repos/ and worktrees/ on your machine. What
each poller wakes for:
triagewakes for each issue waiting for triage: an open issue carryingS-needs-triage, read every two minutes. It wakes once per issue, keyed by its number, so the coordinator's own comment does not wake it again.releasewakes for eachunit:id, a release queue file with no pull request yet, read every two minutes.buildswakes for eachbuild:done:id, a detached build that ended within the hour, read every 15 seconds.prsis Tether's pull-request guard, reading health throughrepo pr-health. It wakes for stuck pull requests and keeps their owner and waiver records.
Tether starts a turn only for ids it has not told before, and never while a turn runs or you type. Ids still waiting
are told again 30 minutes later, then after 60, 120 and at most every four hours (remindMinutes). The builds
poller has no reminders: a build result is news once.
A wake the engine refuses (after you interrupt a turn) shows wake refused. It is tried again two minutes later, or
as soon as your next turn starts. A wake that started no turn is sent again.
idleWakeMinutes above 0 also wakes the session after that many idle minutes with nothing new, but never while a
subagent is at work. A poller whose read fails shows as failing in the status line and in /tether-watch. Its ids
are never taken for "nothing waits". /tether-watch reports each poller's last read.
doctor¶
Checks this machine and the disc for building and contributing, section by section (build tools, configuration,
disc, research tools, disk), and prints a fix for each problem. Exit code 1 when something the engine's build or the
disc needs is missing, 0 otherwise (notes, such as another release of the game or a missing research tool, do not
change it). Read only; no --agent, works without coney.local.toml. What it checks:
Check your setup.
ghidra¶
uv run --project python coney-tools ghidra setup [--disc DISC] [--no-names] [--any-release] [--project-dir DIR]
[--project-name NAME] [--overwrite] [--dry-run]
uv run --project python coney-tools ghidra import-names [--program NAME] [--listing FILE] [--project-dir DIR]
[--project-name NAME] [--overwrite] [--dry-run]
setup makes a contributor's own Ghidra project of SLUS_212.15: it fetches the pinned Ghidra and the Emotion
Engine extension when ghidra_install has none, compiles r5900.sla, imports and analyses the executable from the
disc (--disc, else game_dir) into <ghidra_projects>/warriors after checking its SHA-1 against the NTSC-U
release, and then runs import-names. import-names applies the names of docs/progress/ghidra-functions.tsv to the
program by address with a Ghidra script run by analyzeHeadless, and prints how many were matched, created and
skipped. Both need a JDK 21, skip what is done, print counts and hashes only, and keep their logs in ghidra/ in the
scratch folder; --dry-run changes nothing (for import-names it opens the program read-only and prints the counts a
real run would give). Exit codes: 0 done, 2 a missing piece, a refused disc or a failed Ghidra step (the message names
its log). What each step does and what the counts mean: Starting your own project.
mcp¶
Runs one of the debugging servers that the repository's .mcp.json registers for agent tools, with stdin and stdout
passed through to it: ghidra-mcp is ghidra-mcp's Python bridge from ghidra_mcp_repo (run by uv, talking to the
headless server on port 8090 unless GHIDRA_MCP_URL says otherwise), pcsx2 is the PCSX2-MCP fork's Node.js server
from pcsx2_mcp_dir (its pcsx2-mcp-server/). The server connects to nothing until pcsx2_connect; with
--copy NAME it starts with that copy's ports in PCSX2_DEBUG_PORT and PCSX2_PINE_PORT, which a fork server from
the env-default-ports change on takes as pcsx2_connect's defaults (an older build ignores them, so pass the ports
to pcsx2_connect anyway). The pcsx2 server always starts with PCSX2_REQUIRE_AGENT=1, which a fork build
from v2.9.129.9 on reads as "every pcsx2_* call must carry an agent argument, and not default" (an older
build ignores it); that build also has pcsx2_disconnect and releases a connection idle for PCSX2_IDLE_MINUTES
(default 30, read from the environment the agent tool gives) when another caller wants its port (The PCSX2 MCP
guard). The checkout is CLAUDE_PROJECT_DIR when the agent tool
sets it, else the current folder; a worktree reads the main checkout's coney.local.toml. A missing key, folder or
program (uv, node) prints one line on stderr and exits with code 2, so a server that is not installed fails
without affecting the others. Why and how it is set up: Setting up an agent tool.
workspace¶
uv run --project python coney-tools workspace init [--workspace DIR] [--repo-url URL] [--fetch TOOL ...] [--dry-run]
Creates the missing folders of the local workspace, clones the repository when run outside one
(--repo-url), writes coney.local.toml from the example when there is none, checks the prerequisites and, with
--fetch ghidra|ghidra-mcp|pcsx2-mcp-release|pcsx2-mcp|ccache|all, downloads the research tools and, on Windows,
the compiler cache at pinned versions, checked by SHA-256 (pcsx2-mcp-release is the
PCSX2-MCP fork's Windows release, the PCSX2 the copies run, pcsx2 setup-copies; the two MCP projects are cloned at
a pinned commit; on Linux and macOS ccache comes from the package manager).
Every step already done is skipped; --dry-run only prints them. It never downloads game files or a BIOS. Exit
codes: 1 when a prerequisite of the engine is missing, 2 for a usage error. The launchers bootstrap.sh and
bootstrap.ps1 install uv (a pinned version, with Astral's installer, which adds it to PATH) and run it, adding
--fetch ccache when no --fetch is given. What each
step does and the pinned versions: Bootstrap.
uv run --project python coney-tools workspace clean [--apply] [--build-hours N] [--worktree-hours N] [--fetch]
[--min-gb N] [--margin-gb N] [--shell-hours N] [--log-idle-hours N]
uv run --project python coney-tools workspace disk [--min-gb N]
clean removes the worktrees whose work is merged (with their local branches) and deletes the build/ folders
nobody has touched for --build-hours (default workspace.build_idle_hours, 4), skipping any worktree holding the
busy file (workspace.busy_marker, .coney-busy). When the drive is still under --min-gb (default
workspace.disk_min_free_gb, 30) after that, it deletes more build/ folders, oldest last use first and never a
worktree's sources, until --margin-gb (default workspace.disk_margin_gb, 10) above the floor is free, skipping
worktrees that are busy or hold or wait for a build slot (disk pressure). It is a dry run without --apply, and
reports each deletion, the bytes reclaimed and the free space before and after. It also lists, and with --apply
stops, the stale processes: shells older than --shell-hours (default 3) waiting in a sleep loop on files
unchanged for --log-idle-hours (default 1), and orphaned ctest or coney_tests runs whose parent is gone.
disk prints the workspace drive's free space and exits 1 below --min-gb (default: workspace.disk_min_free_gb of
.github/conventions.yml, 30). Rules and the busy file:
Cleanup.