meteor-decomp is worked on by many contributors (human and automated) in
parallel, each from their own fork. The claim protocol is what keeps
two contributors from decompiling the same FUN_<va> at the same time.
It is GitHub-native: a claim is a comment on a coordination issue,
and the source of truth is a dedicated orphan claims branch. There is
no central server and no shared bot account — every claimer authenticates
as themselves.
Note on the obsolete protocol. Older docs told you to set
status: wip/owner: <you>inconfig/<bin>.yamland commit that as the claim. That no longer works:config/<bin>.yamlis gitignored and regenerated bymake split, so it can never be a durable claim ledger. Use the protocol below instead.
A claim is a single JSON file on the orphan claims branch at
claims/<binary>/<decimal_rva>.json. The branch is tiny — it only ever
holds the handful of in-flight + PR-pinned claims, never the full solved
set. The solved set is not stored in the ledger at all: the committed
src/<binary>/_rosetta/FUN_<va>.cpp tree on develop is the solved set
(a VA is "solved" iff that file exists). You claim a free VA, match it,
open a PR; merging the PR auto-releases the claim.
free-set discovery
│
▼
/claim FUN_<va> <binary> ── comment on the coordination issue
│ fires claim.yml → tools/claim.py decide
▼
lease granted (60h) ── claims/<bin>/<rva>.json, state=active
│
├── heartbeat: re-post /claim before expiry → lease extended (idempotent)
│
├── open a PR adding src/<bin>/_rosetta/FUN_<va>.cpp
│ → claim-pr.yml pins it (state=pinned-by-PR, no expiry)
│
│ ┌─ PR merged → reconcile.yml release-solved deletes the claim
│ └─ PR closed unmerged → claim-pr.yml frees (deletes) the claim
│
└── lease lapses un-renewed → sweeper.yml: grace → quarantine → delete
A VA is free iff it is none of:
- solved —
src/<binary>/_rosetta/FUN_<va>.cppexists ondevelop; - live-claimed — a file exists at
claims/<binary>/<decimal_rva>.jsonon theclaimsbranch (sweeper deletes expired non-pinned claims, so the mere presence of a ledger file means "in flight — leave it"); - open-PR — its
_rosetta/FUN_<va>.cppis added by an open PR intodevelop(so two claimers don't both PR the same VA before the ledger catches up).
Subtract those three sets from the binary's function pool
(config/<binary>.yaml). To inspect the two GitHub-side sets manually:
# Solved set on develop (presence-of-file = solved):
git ls-tree -r --name-only origin/develop src/ffxivgame/_rosetta/
# Live claims on the claims branch:
git ls-tree -r --name-only origin/claims claims/ffxivgame/The automated path (decomp-agents distributed mode) computes all three
sets for you — see decomp-agents README.md § Distributed
mode.
Post a comment on the pinned coordination issue:
/claim FUN_004165b0 ffxivgame
- The VA token may be
FUN_<va>,0x<va>, or bare<va>hex (4–8 nibbles). The binary stem is optional and defaults toffxivgame. claim.ymlparses the VA from the (untrusted) comment body but binds the claim'sownerto the authenticated comment author — you can't spoof someone else's claim.- It runs
tools/claim.py decide, which is a 3-way decision:- granted — the VA was free (or its lease had expired): a lease is written with a 60h TTL.
- extended — you already hold a live lease (idempotent heartbeat; the lease is refreshed).
- rejected — the VA is already solved, or held by another owner. The bot replies with the reason (and the holder, if relevant).
- The bot replies on the issue with the outcome. On a grant you'll be
told to open a PR adding
src/<binary>/_rosetta/FUN_<va>.cppto pin it.
Do the work on a branch in your fork — see CONTRIBUTING.md
and docs/matching-workflow.md. The deliverable is
exactly one file: src/<binary>/_rosetta/FUN_<va>.cpp, GREEN under
make diff FUNC=FUN_<va>.
Heartbeat while you work: the lease is 60h. If a match takes longer,
re-post /claim FUN_<va> <binary> to refresh it (an owner-held live lease
is an idempotent extend). Automated agents heartbeat once before each
long match phase.
Open a PR from your fork into develop, titled
decomp: match <Sym> @0x<rva>. The PR adds your one _rosetta file.
claim-pr.yml(onopened/reopened) reads the PR's added_rosetta/FUN_<va>.cppfilenames via the API — it never checks out your PR head — and pins the matching claim:state=pinned-by-PR, no expiry. From now on the sweeper will never reclaim it, however long review takes.- If the PR is closed without merging,
claim-pr.yml(onclosed,merged=false) frees the claim (deletes the ledger file) so the VA returns to the pool.
On merge to develop, reconcile.yml:
- recomputes
docs/reconcile-state.jsonfrom the committed_rosettatree (make reconcile), - regenerates the progress regions in
README.md/docs/decomp-status.md/PLAN.md(make update-docs) and commits any doc churn back todevelop(loop-guarded), - then runs
tools/claim.py release-solved, which deletes every claim whose VA is now present insrc/<binary>/_rosettaondevelop.
reconcile.yml is the sole releaser for merged VAs, and it releases
strictly after the solved state is on develop — so a claim is only
dropped once the work is durably done. A merged-PR close in claim-pr.yml
is intentionally a no-op for exactly this reason.
One JSON object per file at claims/<binary>/<decimal_rva>.json:
{
"binary": "ffxivgame",
"rva": 196636,
"va": "0x0042fc1c",
"owner": "octocat",
"claimed_at": "2026-05-31T12:00:00+00:00",
"lease_expires_at": "2026-06-02T00:00:00+00:00",
"state": "active"
}rvais decimal and matches the path;vaisrva + IMAGE_BASE(0x400000) and matches the_rosettafilename.lease_expires_atisnullwhenstate == "pinned-by-PR".state ∈ {active, expiring, pinned-by-PR}:- active — a live lease.
- expiring — the sweeper saw the lease expire and granted a short
grace window; a heartbeat within grace reclaims it (the owner's next
/claimresets it toactive). - pinned-by-PR — an open PR holds it; never swept, no expiry.
Two contributors can /claim the same VA at the same instant. The
serialiser is not an Actions concurrency queue (which only de-dupes
and cancels — it can't FIFO two racers). It is GitHub's serialized
per-ref commit: each workflow checks out the claims branch, mutates
the ledger with tools/claim.py, commits, and git pushes. A
non-fast-forward push is rejected if another writer advanced the ref
between the fetch and the push — on rejection the workflow re-fetches,
hard-resets to the new tip, re-runs claim.py against the now-newer
ledger, and retries (bounded, with backoff). "Create at most once" falls
out for free: a given ledger path can be added at most once per ref state.
tools/claim.py itself is pure — it never touches git and never reads the
wall clock (--now is injected by the workflow) — so every decision is
deterministic and unit-testable.
The workflows shell out to this; you rarely run it by hand, but it's the
authoritative spec. All commands take --ledger-dir (default claims),
--src-root (default src), and --now ISO8601 (default $CLAIM_NOW,
then wall clock).
| Subcommand | What it does | Who calls it |
|---|---|---|
decide --binary B --va 0x.. --owner LOGIN |
3-way claim decision; mutates the ledger on grant/extend. Exit 0 = granted/extended, exit 2 = rejected. | claim.yml |
pin --binary B --va 0x.. [--va ..] --owner LOGIN |
Pin VA(s) to a PR (state=pinned-by-PR, no expiry); preserves an existing claim's original owner. |
claim-pr.yml (opened/reopened) |
free --binary B --va 0x.. [--va ..] |
Delete claim file(s) — un-merged PR close. | claim-pr.yml (closed, unmerged) |
sweep |
Two-phase grace/quarantine of expired non-pinned claims. | sweeper.yml |
release-solved |
Delete every claim whose VA is now solved on develop. |
reconcile.yml |
Knobs: --ttl-hours (env CLAIM_TTL_HOURS, default 60) is the
no-PR-yet lease length; --grace-hours (env CLAIM_GRACE_HOURS, default
6) is the sweep grace window.
The workflows are committed but inert until a maintainer does a one-time
bootstrap. Until then, /claim comments get a "claim system is not yet
wired" reply and nothing is recorded (the workflows skip-with-warning when
CLAIMS_BOT_TOKEN is unset). The four steps below light it up.
Status: this bootstrap has NOT been performed yet. A maintainer must run steps 1–3 once on the canonical repo before claims go live.
The claim workflows push the orphan claims branch under a dedicated
identity. Create a fine-grained personal access token owned by a
maintainer:
- Repository access: only this repository.
- Permissions: Contents → Read and write (nothing else).
- No expiry, or a long one you'll rotate.
Save it as a repository secret named CLAIMS_BOT_TOKEN:
# from a clone of the canonical repo, with `gh` authenticated as a maintainer:
gh secret set CLAIMS_BOT_TOKEN --repo <owner>/meteor-decomp
# (paste the PAT when prompted)The default GITHUB_TOKEN is intentionally not used for the ledger push
— the claim writes are attributed to the claims-bot identity, and using a
PAT keeps the compare-and-swap a real branch push.
The ledger lives on a branch with no shared history with develop (it
holds only claims/…json, never source). Create it once:
git switch --orphan claims
git rm -rf . 2>/dev/null || true # empty the worktree
git commit --allow-empty -m "claims: initialize empty ledger branch"
git push -u origin claims
git switch develop # back to normal workThe workflows checkout this branch shallowly, add/remove
claims/<bin>/<rva>.json files, and push. They never touch develop's
history.
Open one issue whose comments drive /claim. Pin it so contributors can
find it:
gh issue create --repo <owner>/meteor-decomp \
--title "Claim coordination — post /claim FUN_<va> <binary> here" \
--body "Comment \`/claim FUN_<va> <binary>\` to reserve a function. See docs/claim-protocol.md."
gh issue pin <issue-number> --repo <owner>/meteor-decompAny issue_comment starting with /claim fires claim.yml. (Comments on
any issue work, since claim.yml triggers on issue_comment globally —
but a single pinned issue keeps coordination legible and discoverable. Put
its number in decomp-agents' DECOMP_CLAIM_ISSUE.)
Defaults are sane; tune only if needed.
| Knob | Where | Default | Notes |
|---|---|---|---|
| Lease TTL | CLAIM_TTL_HOURS env / --ttl-hours (claim.py) |
60h | No-PR-yet lease length. |
| Sweep grace | CLAIM_GRACE_HOURS env in sweeper.yml / --grace-hours |
6h | Window between "marked expiring" and deletion. |
| Sweep cadence | cron in sweeper.yml |
13 */6 * * * (every 6h) |
Keep the cron interval ≤ grace so a claim survives at least one full grace window before phase-2 deletion. |
After the bootstrap, smoke-test by commenting /claim FUN_00416000 ffxivgame
on the coordination issue and confirming the bot replies and a
claims/ffxivgame/89152.json file lands on the claims branch.