Compat JSON
Compat JSON is the shareable unit behind the whole system: the in-game UI writes it, the community repo distributes it, bridge mods install it. This page covers where files go, how the fields work, how to contribute a config, and how to run your own source.
On this page: Files and loading rules · Built-in configs · Community repo · Field reference · Contributing · Your own source · Troubleshooting
1. Where files go, and when they load
- Three directories:
compat/default/(extracted from the JAR, read-only),compat/mods/(installed by bridge mods, read-only),compat/user/(your changes, highest priority). - Identity comes from the
mod_idinside the JSON, not the filename — but files inuser/must be named<mod_id>.json, because the UI and hot-reload look them up by that name. - Three gates must all pass: the target
mod_idis installed, every mod listed independenciesis loaded, and one entry inmod_versionsmatches the installed mod version. If any fails, the whole file is skipped (the log distinguishes "mod not loaded" from "dependencies not loaded"). - Files starting with a dot (such as
.applied.json) are never parsed as configs. - Legacy
{modid}_keys.jsonfiles are migrated automatically on startup: the original is backed up and renamed, the fields converted.
2. What ships in the JAR
Only three configs are bundled, extracted to config/controlflex/compat/default/ on every launch:
| Mod | What it provides |
|---|---|
| Just Enough Items (JEI) | Recipe / uses keys placed on the screen layer, plus a batch of internal keys added to ignoreKeys |
| Epic Fight | Battle-mode keys and screen rules, pointing at the official bridge cfx-compat-epicfight |
| Nightfall | Key and screen adaptation for the mod |
exempt_mods.json in the same directory is not a compat config — it declares library mods (Fabric API, Kotlin language support and friends) so they never clutter the adaptation lists. Everything else is covered by auto-discovery or the community repo.
3. The community repo
The official source is ControlFlexMC/cfx-compat-configs: a purely static directory tree, split by MC version and loader, one JSON per mod, plus a manifest.json recording each path and sha256.
<repo-root>/ # this directory's URL is the baseUrl players add
├── 1.20.1/ # MC version dir (must match the game version name exactly)
│ ├── forge/
│ │ ├── manifest.json
│ │ └── mods/
│ │ ├── jei.json
│ │ └── epicfight.json
│ └── fabric/
│ ├── manifest.json
│ └── mods/
│ └── jei.json
└── 1.21.1/
└── neoforge/
The client requests exactly two kinds of URL: {baseUrl}/{mcVersion}/{loader}/manifest.json and the content files referenced by path in that manifest. Only the current loader's directory is read; a missing version / loader is a 404 — normal, not a failure, and it does not affect other sources.
In game: Settings → Mod Adaptation → “More” (top right) → Sync latest configs. The mod's detail page then shows a “N community adaptation configs found” banner → pick a variant → read-only preview → Apply. Apply backs up the old user file, overwrites atomically, records it in .applied.json, and hot-reloads that mod — no restart.
Same mod_id with different content = parallel variants for the player to choose from; the same sha256 in several sources = mirrors, retried in source-list order. Content is cached by sha256 at compat/repo/content/<mod_id>/<sha256>.json; those files are immutable.
How the official source pins versions
- Two built-in sources point at the same ref:
cdn.jsdelivr.net/gh/ControlFlexMC/cfx-compat-configs@<ref>andraw.githubusercontent.com/ControlFlexMC/cfx-compat-configs/refs/heads/<ref>. The first is fast, the second is dependable; they mirror each other. <ref>is a version tag (currently0.8.8), bumped with every ControlFlex release — notmain— so players on older builds always sync configs their format can read and never get broken by an upstream format change.- That is why the repo keeps one branch / tag per version line:
0.8.4,0.8.5,0.8.7,0.8.8… To backfill configs for an old version, switch to that branch instead of dropping old-format files onmain. - The two built-in sources can be disabled and reordered but not deleted; "Restore default sources" only resets those two. Your own sources can be added and removed freely.
What the repo covers today
Directories are split by MC version / loader, and 12 mods have configs so far. The same config is usually copied into every loader directory it applies to, so coverage differs between version lines:
| MC version | Loader | Mods covered |
|---|---|---|
1.20.1 |
Forge | create · dragonminez · emi · epicfight · exposure · irons_spellbooks · jei · minemenu · mts · nightfall · superior_tweaks · tacz |
1.20.1 |
Fabric | create · emi · exposure · jei |
1.21.1 |
NeoForge | create · dragonminez · emi · epicfight · exposure · irons_spellbooks · jei · minemenu · mts |
1.21.1 |
Fabric | emi · exposure · jei · minemenu |
1.21.11 · 26.1.2 · 26.2 |
NeoForge / Fabric | jei · minemenu |
1.18.2 · 1.19.2 |
Fabric / Forge | emi (Fabric and 1.19.2 Forge only) · mts |
1.16.5 · 1.12.2 |
Forge | mts |
This table follows the repo; the directory tree is authoritative. The create / emi configs also require their bridge mod through dependencies — the keys come from the bridge.
4. Field reference
Top-level fields
| Field | Type | Required | Description |
|---|---|---|---|
mod_id |
string | Yes | The mod ID this config belongs to (e.g. jei, epicfight). Empty, or the mod not installed → the whole file is skipped; it is part of the activation gate itself. |
dependencies |
object[] | No | Extra gate: { "mod_id": "...", "mod_name": "...", "home_pages": [...] }. Only mod_id is read at runtime; all of them must be loaded for the config to activate. |
contributors |
string[] | No | Author names, shown on the community-config preview page. |
config_version |
string | No | Version of this config itself — display only, no runtime logic. |
mod_versions |
array | Yes | Version-specific rule sets, see below. The legacy name versions is no longer accepted. |
mod_name · home_page |
string | No | Pure metadata for the manifest generator and the preview UI; ignored at runtime. |
How dependencies is actually used
It is not "install dependencies for you" but an activation gate: the config only applies when every mod_id in the array is loaded. Real configs use it in two situations — the config depends on keys a bridge mod provides, or the config belongs to a standalone compat mod.
{
"mod_id": "emi",
"dependencies": [
{ "mod_id": "cfx_compat_emi", "mod_name": "Control Flex x EMI",
"home_pages": ["https://www.curseforge.com/minecraft/mc-mods/cfx-x-emi"] }
],
"mod_versions": [ { "version": "", "screenKeys": { "key.emi.view_recipes": {} } } ]
}
Only mod_id is read (case-insensitive); mod_name / home_pages are display-only on the community preview page. An array of plain strings, or a non-array value, is ignored wholesale. A config whose target is the mod itself does not need it.
mod_versions and version matching
The array is evaluated in order and the first match wins; if nothing matches the installed mod version, the entire config has no effect (a warning is logged).
| Form | Example | Meaning |
|---|---|---|
| Wildcard | "" (or "(,)") |
Matches every version |
| Exact | "1.21.1" |
Exactly equal |
| Interval | "[1.0,2.0)" |
1.0 ≤ v < 2.0 (brackets inclusive, parens exclusive) |
| Unbounded | "[21.1,)" / "(,1.20]" |
v ≥ 21.1 / v ≤ 1.20 |
- Comparison is numeric per dot-separated segment: missing segments count as 0, non-digit prefixes are stripped (
v1.0→1.0,0-beta→0). - A malformed interval (missing bracket / comma, unparseable bound, lower > upper) discards the whole entry, not just the field.
- Put specific intervals first and finish with a
""wildcard as fallback.
The three sections and dispatch channels
All three sections are objects mapping a bare key name to per-key settings. A bare key name is KeyMapping.getName() without the modid: prefix; the action ID is still {modid}:{keyName}. A key listed in several sections belongs to all of them (union); a key declared in none defaults to in-game only.
"inGameKeys": {
"channels": ["keyMapping"],
"key.epicfight.dodge": {},
"key.epicfight.attack": { "channels": ["keyMapping", "eventBus"],
"extraBehaviors": ["suppressLayerSwitch"] }
}
| Channel | Meaning |
|---|---|
keyMapping |
Writes KeyMapping.setPressed() / isDown state — the same path vanilla uses. |
eventBus |
Broadcasts a Forge InputEvent.Key (merged per tick). |
glfwPoll |
Mirrors the hold into synthetic InputConstants.isKeyDown() state, for mods that poll GLFW themselves. |
screenInput |
While a screen is open, feeds screen.keyPressed/keyReleased (or a synthetic click for mouse-bound keys). |
virtualKbm |
Injected through the virtual keyboard / mouse as real hardware input. Exclusive: replaces every other channel and is not user-overridable. |
Defaults when a section declares no channels:
| Section | Default channels |
|---|---|
inGameKeys | keyMapping, eventBus |
screenKeys | keyMapping, screenInput |
overlayKeys | keyMapping, eventBus |
- The mod fires twice (listening on both paths) → keep only
keyMapping. - The mod only listens to Forge events →
["eventBus"]. - A hold-to-open menu that polls GLFW itself → add
glfwPoll.
extraBehaviors
Per-key behavior flags; there is one value today: suppressLayerSwitch — while that action's binding is held, layer switching is suppressed, and the binding is forced to HOLD on save. Use it for modded modifier-style keys (Iron's Spells' key.irons_spellbooks.spell_bar_modifier, for example). An explicit [] means explicitly off.
phasePersistentKeys
Declares that a key's binding should follow it across a phase switch:
"inGameKeys": { "key.irons_spellbooks.spell_wheel": {} },
"overlayKeys": { "key.irons_spellbooks.spell_wheel": {} },
"phasePersistentKeys": { "key.irons_spellbooks.spell_wheel": ["inGame", "overlay"] }
- Only
["inGame", "overlay"]and["inGame", "screen"]are allowed: the source must beinGameand exactly one destination. - The key must also be declared in both sections; the pairing alone is ignored (and warned about) — implicit membership via the union does not count.
- At runtime the destination layer's binding is soft-locked and derived live from the game layers (no copy is persisted): a held button stays held across the transition, so a hold-to-open menu does not flicker shut when a screen or overlay appears.
- These bindings are materialized as HOLD and skip item suppression. Editing the destination layer in the binding UI writes a snapshot binding and unlocks it; resetting the key restores the lock and the derivation.
ignoreKeys
Hides actions from the binding UI (debug keys, cheat keys, duplicates): "ignoreKeys": ["key.epicfight.config"]. The modid: prefix is added automatically when matching action IDs.
itemSuppressKeys
Suppresses actions while the main-hand item matches. Typical case: the mod takes over input for specific items (in battle mode, attack / interact are handled by the mod's combo and weapon-skill systems), so the generic actions are suppressed to stop them fighting each other.
"itemSuppressKeys": [
{ "item": "efn:yamato_dmc*", "playerState": ["epicfight:battle_mode"], "suppressActions": ["use"] },
{ "tag": "minecraft:swords", "playerState": ["epicfight:battle_mode"], "suppressActions": ["attack", "use"] }
]
| Field | Description |
|---|---|
item | Item ID (modid:item_id); * wildcard supported. Mutually exclusive with tag. |
tag | Item tag. Mutually exclusive with item. |
playerState | Optional modId:state list (OR); empty = suppress whenever the item matches. Unknown states warn and count as no-match. |
suppressActions | Action short names (attack, use) or full action IDs. |
Evaluated once per tick against the main-hand item; actions matched by phasePersistentKeys are exempt.
tips
Hint text shown on binding rows:
"tips": [
{ "key": "key.epicfight.weapon_innate_skill",
"text": { "en_us": "Bind to the same button as Attack; use HOLD mode.",
"zh_cn": "请绑定为与「攻击」相同的按键,并使用 HOLD 模式。" } }
]
Language codes have no whitelist: the runtime uses the player's language and falls back to en_us; with no en_us nothing is shown. Always include en_us.
Stick and cursor behavior per screen / overlay
Keys are fully qualified class names: screen.getClass().getName() for screens; the opener class name for overlays, with "*" as the mod-level fallback.
"screen": {
"com.dragonminez.client.gui.UtilityMenuScreen": {
"leftStick": { "behavior": "VIRTUAL_MOUSE" },
"rightStick": { "behavior": "RADIAL_PATH", "threshold": 0.5, "cursorRadius": 90 }
}
},
"overlay": {
"io.redspace.ironsspellbooks.gui.overlays.SpellWheelOverlay": {
"rightStick": { "behavior": "RADIAL_PATH", "threshold": 0.3, "cursorRadius": 40, "showCursor": false }
}
}
behavior |
Meaning |
|---|---|
DEFAULT | Engine default: incremental virtual mouse on the left stick, nothing on the right stick in a GUI |
VIRTUAL_MOUSE | The stick drives the incremental virtual cursor |
RADIAL_PATH | Polar cursor from the screen center (wheel-style UIs) |
DISABLED | The axis is ignored; stick-click bindings still work |
| Parameter | Default | Description |
|---|---|---|
threshold | 0.5 | Stick deadzone |
cursorRadius | 100 | Distance from the screen center (logical pixels × GUI scale) |
cursorSpeed | 1.0 | Cursor speed (clamped 0.1–3.0) |
showCursor | true | Whether to draw the cursor (false still tracks position, just does not draw it) |
keepLastPosition | true | Stay in place when the stick is released |
angleBias | 1.0 | Angle sensitivity |
Entries also accept cursorStyle (system / bedrock / hand) and disabled: true (opt out entirely, no fallback). Resolution: user layer before built-ins, a specific overlay entry before the same mod's "*", and a hit that lacks the requested side (left / right) falls through to the next config.
Migrating from the legacy format (≤ 0.8.5)
Since 0.8.7 old files are migrated automatically on startup (backed up, then rewritten). If you maintain configs by hand, or have to read an old one, use this table:
| Legacy | Now |
|---|---|
versions | mod_versions |
guiKeys: ["k"] | screenKeys: { "k": {} } |
required_mod: "x" | dependencies: [{ "mod_id": "x" }] |
the loader field | Removed; the source directory (forge / fabric / neoforge) separates loaders |
GLFW_COMPAT flag | channels: ["glfwPoll"] |
PHASE_PERSISTENT flag | phasePersistentKeys + declared once in the game section and once in the destination section |
legacy stick arrays (screens + mode) | screen object keyed by class name + behavior; RADIAL_CURSOR → RADIAL_PATH, returnToCenter: false → keepLastPosition: true |
| — | overlayKeys · per-overlay overlay config · phase persistence with an overlay destination |
Self-check before you ship a config
- The three sections must be objects; the array form is dropped wholesale, with a single warning.
channelsinside a section is a reserved key (section defaults) — never use it as a key name.- Per-key
channels: []andextraBehaviors: []are meaningful explicit empties, not the same as omitting the field. mod_versionsis first-match-wins: specific intervals first, a""fallback last; no matching entry means the whole config is inert.- A malformed
versiondiscards the whole entry, not just the field. - Identity comes from the JSON's
mod_id, but user-layer files must be named<mod_id>.json. phasePersistentKeysonly works when the key is declared in both sections; the pairing alone is ignored.- UTF-8, no trailing commas; add
channelsoverrides only when the default behavior misbehaves.
Minimal template
{
"mod_id": "examplemod",
"contributors": ["yourname"],
"config_version": "1.0.0",
"mod_versions": [
{
"version": "",
"inGameKeys": {},
"screenKeys": {},
"overlayKeys": {},
"ignoreKeys": [],
"tips": [],
"screen": {},
"overlay": {}
}
]
}
The above is the quick-reference version. The complete field set, the real behavior of each channel, and the migration table from the ≤ 0.8.5 format live in the authoritative reference: English / Chinese.
5. Contributing a config
- Fork the repo and switch to the matching branch. The config format changes between ControlFlex versions and sources are consumed per branch; if your fork only has the default branch, pull the version branch down first.
- Create
<mc-version>/<loader>/mods/<mod_id>.json. Use the bare key names from the mod's Controls category (nomodid:prefix); the[ModCompat]log lines help you find them. - Regenerate the manifest:
python3 generate_manifest.py. Editing content without recomputing sha256 is the number one cause of "content download failed" on the player side. - Verify in game. Add your fork as a source, or drop the file into
compat/user/and restart. Check the[ModCompat]log to confirm it loaded. - Open a PR stating the mod version tested, the loader(s), and what the config fixes.
If a config only matters on one loader, keep it in that loader's directory; if it behaves the same everywhere, copy the file into the other directories. Files are capped at 2 MB (real configs are a few KB).
6. Adding another config source
A "source" is just a static HTTP directory tree: {baseUrl}/{mcVersion}/{loader}/manifest.json plus content files. No server-side logic, so any static host will do.
Two routes
- Fork the official repo (recommended): the layout, the manifest generator and a set of working configs are already there — you only add your own. The cost is keeping up with upstream.
- Build a tree from scratch: your own release cadence, private or experimental configs — just follow the layout below.
manifest.json key fields
{
"schemaVersion": 1,
"name": "My Pack Configs",
"items": [
{
"mod_id": "jei",
"mod_name": "Just Enough Items (JEI)",
"path": "mods/jei.json",
"sha256": "a4dcfadbca3d0a8b19be2ec5d191fc12801d199583393ef0fec13007e06b927f",
"home_pages": ["https://www.curseforge.com/minecraft/mc-mods/jei"]
}
]
}
| Field | Description |
|---|---|
schemaVersion | Number; greater than 1 is rejected (the client shows an upgrade hint); missing or 0 is tolerated. |
items[].mod_id | Must match ^[a-z0-9_-]+$ and be unique within the manifest; a duplicate fails the entire manifest parse. |
items[].path | Relative to the manifest's directory; absolute paths, backslashes, empty segments and .. are rejected. The convention is mods/<file>.json. |
items[].sha256 | SHA-256 of the content file, 64 lowercase hex characters. Verified after download; a mismatch tries the next mirror. It is the sole identity of a variant. |
home_pages · description · dependencies | Display-only fields (dependencies is for display; the client never installs dependencies). |
The official repo's generate_manifest.py can be reused as is: it scans <mc-version>/<loader>/mods/*.json, extracts mod_id / mod_name / home_page, computes the sha256 and writes the manifest back idempotently.
Hosting options and hard limits
| Host | baseUrl example | Caching |
|---|---|---|
| GitHub raw | https://raw.githubusercontent.com/<user>/<repo>/refs/heads/<branch> |
About 5 minutes; the official built-in sources use this form |
| jsDelivr | https://cdn.jsdelivr.net/gh/<user>/<repo>@<branch> |
Branch refs cache for roughly 12 hours; prefer a tag |
| Self-hosted / object storage | https://files.example.com/cfx-source/ |
Up to you |
| Item | Limit |
|---|---|
| baseUrl | Must be http:// or https:// with a host name |
| Per file | ≤ 2 MB (manifests and content files alike) |
| Timeouts | 5 s connect / 30 s read |
| Redirects | 301/302/303/307/308 followed, max 5 hops; https → http downgrades rejected |
| Encoding | UTF-8 JSON |
Adding the source in game
- Go to Settings → Mod Adaptation → “More” (top right) → Edit source list.
- Paste the baseUrl into the input at the bottom → Add → Done; closing the dialog with a changed list triggers a background sync.
- Open “Sync latest configs” and confirm your source synced (per-source progress and logs); the mod cards then show a community-config badge.
Source-list behavior: built-ins can be disabled and reordered but not deleted; your own sources can be added, removed and reordered freely; “Restore default sources” only resets the two built-ins. Each source caches under compat/repo/manifest/<source-id>/, where the id is derived from the URL (lowercase, with -1 / -2 on conflicts).
Local testing
cd <your-source-root>
python3 -m http.server 8000
# then add http://127.0.0.1:8000 in "Edit source list"
http:// sources are allowed (only https → http redirect downgrades are rejected), so local testing needs no certificate. Edit files, then hit “Sync latest configs” once.
7. Troubleshooting
| Symptom | Check |
|---|---|
| Source shows “Never synced” / sync fails | Open {baseUrl}/{mcVersion}/{loader}/manifest.json in a browser; directory names must exactly match the game version name and loader name (1.20.1, not 1.20). |
| Manifest parse error | Duplicate mod_id, path with an absolute path / backslash / .., sha256 that is not 64 lowercase hex characters, schemaVersion > 1, or a JSON syntax error. |
| Manifest synced but content download fails | The content bytes do not match the manifest sha256 (edited the JSON but forgot to regenerate the manifest is the usual cause), the file exceeds 2 MB, or every mirror failed in that round. |
| Applied but nothing changed | The config's own gates failed: the mod_id mod is missing, dependencies are not all loaded, no mod_versions entry matches (first match wins — mind interval syntax and order), or a key name is wrong. Search the [ModCompat] log. |
| Players never see an update | jsDelivr caches @branch for about 12 hours; switch to the raw URL, cut a tag, or wait for the cache to expire. |
| Garbled non-ASCII text | The file must be UTF-8 (BOM-free preferred). |