兼容配置(compat JSON)
compat JSON 是整套适配体系的可分享载体:游戏内界面写出来的是它,社区配置库分发的是它,桥接模组安装的也是它。这一页从「文件放哪」讲到「字段怎么写」,再到「怎么贡献一份、怎么自建一个配置源」。
本页内容: 文件与生效规则 · 内置配置 · 社区配置库 · 字段说明 · 贡献配置 · 自建配置源 · 排障
一、文件放在哪,什么情况下才会生效
- 三个目录:
compat/default/(从 JAR 释放,只读)、compat/mods/(桥接模组安装,只读)、compat/user/(你的改动,优先级最高)。 - 身份来自 JSON 里的
mod_id,不是文件名;但user/层的文件必须叫<mod_id>.json,游戏内界面与热重载都按这个名字找文件。 - 三个门槛全过才会加载:目标
mod_id已安装、dependencies里每个 mod 都已加载、mod_versions里有一条匹配当前模组版本。任一不满足,整份文件跳过(日志会区分「模组未加载」和「依赖未加载」)。 - 以点开头的文件(如
.applied.json)不会被当成配置解析。 - 旧格式
{modid}_keys.json会在启动时自动迁移:原文件备份改名,字段转换到新格式。
二、内置了什么
JAR 内只带三份配置,每次启动释放到 config/controlflex/compat/default/:
| 模组 | 内置了什么 |
|---|---|
| Just Enough Items (JEI) | 配方 / 用途键位落在界面层,一批不需要暴露的内部按键加入 ignoreKeys |
| Epic Fight | 战斗模式相关的键位与界面规则,并指向官方桥接模组 cfx-compat-epicfight |
| Nightfall | 该模组的按键与界面适配 |
同目录下的 exempt_mods.json 不是兼容配置,它声明「库类模组」名单,让 Fabric API、Kotlin 语言支持这类库不进入适配列表。其余模组靠自动发现或社区配置库补充。
三、社区配置库
官方源是 ControlFlexMC/cfx-compat-configs:一棵纯静态目录树,按 MC 版本与加载器分目录,每个模组一份 JSON,外加一份 manifest.json 记录路径与 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/
客户端只会请求两种 URL:{baseUrl}/{mcVersion}/{loader}/manifest.json 与 manifest 里 path 指向的内容文件。只读当前加载器目录,某个版本 / 加载器没做就是 404 —— 这是正常情况,不算失败,也不影响其他源。
游戏内路径:设置 → 模组适配 → 右上「更多」→ 同步最新配置。模组详情页会出现「找到 N 个社区适配配置」横幅 → 选变体 → 只读预览 → 应用。应用会先备份旧的 user 文件、原子覆盖、写入 .applied.json,再热重载该模组,无需重启。
同一个 mod_id 但内容不同 = 并行变体,由玩家挑选;同一个 sha256 出现在多个源 = 互为镜像,按源列表顺序回退重试。内容按 sha256 缓存到 compat/repo/content/<mod_id>/<sha256>.json,文件不可变。
官方源是怎么钉版本的
- 内置两个源,指向同一个 ref:
cdn.jsdelivr.net/gh/ControlFlexMC/cfx-compat-configs@<ref>与raw.githubusercontent.com/ControlFlexMC/cfx-compat-configs/refs/heads/<ref>。前者快、后者稳,互为镜像。 <ref>是一个版本 tag,跟着 ControlFlex 的发版走(本版是0.8.8),不是main—— 所以老版本玩家同步到的永远是那套格式能吃的配置,不会被上游新格式搞坏。- 仓库里为此保留了一条版本线一支分支 / tag:
0.8.4、0.8.5、0.8.7、0.8.8…。你要给某个旧版本补配置,就切到对应分支,别往main上放旧格式文件。 - 两个内置源可以禁用、可以调序,但不能删除;「恢复默认源」只重置它们。自己加的源随便增删。
仓库里现在覆盖了什么
按「MC 版本 / 加载器」分目录,目前已收录 12 个模组的配置。同一份配置通常会复制到适用的每个加载器目录,所以不同版本线的覆盖并不一致:
| MC 版本 | 加载器 | 已收录模组 |
|---|---|---|
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 与 1.19.2 Forge)· mts |
1.16.5 · 1.12.2 |
Forge | mts |
这张表会随仓库变,以目录树为准;create / emi 的配置还通过 dependencies 要求对应桥接模组已安装 —— 它们的键位是桥接提供的。
四、字段说明
顶层字段
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
mod_id |
string | 是 | 配置所属的模组 ID(如 jei、epicfight)。为空、或该模组未安装 → 整份跳过,它本身就是激活门槛的一部分。 |
dependencies |
object[] | 否 | 附加门槛:{ "mod_id": "...", "mod_name": "...", "home_pages": [...] },运行时只读 mod_id,全部已加载才激活。 |
contributors |
string[] | 否 | 作者名,显示在社区配置预览页。 |
config_version |
string | 否 | 这份配置自身的版本号,只做展示,不参与逻辑。 |
mod_versions |
array | 是 | 按模组版本分的规则集合,见下。旧名 versions 已不再接受。 |
mod_name · home_page |
string | 否 | 纯元数据,给 manifest 生成器和预览界面用,运行时忽略。 |
dependencies 的实际用法
它不是「自动装依赖」,而是一道激活闸门:数组里每个 mod_id 都已加载,这份配置才生效。真实配置里主要用在两种场景 —— 配置依赖某个桥接模组提供的键位,或者这份配置本身就属于一个独立兼容 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": {} } } ]
}
只读 mod_id(大小写不敏感),mod_name / home_pages 只在社区配置预览页展示。写成字符串数组或非数组会被整段忽略。目标模组就是自己的配置不需要它。
mod_versions 与版本匹配
数组按顺序求值,首个匹配生效;如果没有任何一条匹配当前模组版本,整份配置不生效(日志会给警告)。
| 写法 | 示例 | 含义 |
|---|---|---|
| 通配 | ""(或 "(,)") |
匹配所有版本 |
| 精确 | "1.21.1" |
完全相等 |
| 区间 | "[1.0,2.0)" |
1.0 ≤ v < 2.0(方括号包含,圆括号不包含) |
| 单边 | "[21.1,)" / "(,1.20]" |
v ≥ 21.1 / v ≤ 1.20 |
- 比较按点分段做数值比较:缺的段补 0,非数字前缀会被剥掉(
v1.0→1.0,0-beta→0)。 - 区间写坏(缺括号 / 逗号、边界无法解析、下界大于上界)→ 整条 entry 被丢弃,不只是这个字段。
- 把具体的区间放前面,最后用一条
""通配兜底。
三个按键分区与派发通道
三个分区都是「裸键名 → 每键设置」的对象。裸键名 = KeyMapping.getName() 去掉 modid: 前缀;内部动作 ID 仍是 {modid}:{keyName}。一个键出现在多个分区就是多分区的成员(并集);一个都没写的键默认只在游戏内生效。
"inGameKeys": {
"channels": ["keyMapping"],
"key.epicfight.dodge": {},
"key.epicfight.attack": { "channels": ["keyMapping", "eventBus"],
"extraBehaviors": ["suppressLayerSwitch"] }
}
| 通道 | 含义 |
|---|---|
keyMapping |
写 KeyMapping.setPressed() / isDown 状态(原版同款路径)。 |
eventBus |
广播一个 Forge InputEvent.Key(每 tick 合并)。 |
glfwPoll |
把按住状态镜像进合成的 InputConstants.isKeyDown(),伺候那些自己轮询 GLFW 的模组。 |
screenInput |
界面打开时喂 screen.keyPressed/keyReleased(鼠标键则合成一次点击)。 |
virtualKbm |
走虚拟键鼠,当作真实硬件输入注入。独占:替换其他所有通道,玩家不可覆盖。 |
分区没写 channels 时的默认值:
| 分区 | 默认通道 |
|---|---|
inGameKeys | keyMapping, eventBus |
screenKeys | keyMapping, screenInput |
overlayKeys | keyMapping, eventBus |
- 模组触发两次(两条路径都在监听)→ 只留
keyMapping。 - 模组只听 Forge 事件 →
["eventBus"]。 - 按住打开的菜单自己轮询 GLFW → 加
glfwPoll。
extraBehaviors
每键行为开关,目前只有一个取值:suppressLayerSwitch —— 该动作的绑定被按住期间,压制层级切换动作;保存时这个绑定会被强制成 HOLD 模式。用于模组自带的「修饰键式」按键(例如 Iron's Spells 的 key.irons_spellbooks.spell_bar_modifier)。显式写 [] 表示明确关闭。
phasePersistentKeys
声明某个键的绑定要跨相位切换保持一致:
"inGameKeys": { "key.irons_spellbooks.spell_wheel": {} },
"overlayKeys": { "key.irons_spellbooks.spell_wheel": {} },
"phasePersistentKeys": { "key.irons_spellbooks.spell_wheel": ["inGame", "overlay"] }
- 只允许
["inGame", "overlay"]与["inGame", "screen"]两种配对,源必须是inGame,目标恰好一个。 - 必须同时在两个分区里声明这个键,只写配对不算数(会警告并忽略)——靠并集隐式获得成员资格也不行。
- 运行时目标层的绑定是被软锁定的,值从游戏层实时派生(不落盘复制):按住不放的按钮跨过切换仍然按住,界面 / 叠层弹出时不会把菜单「抖」关掉。
- 这类动作的绑定会被物化为 HOLD,并且跳过物品抑制。在绑定界面里编辑目标层会写入一份快照绑定并解锁;重置该键可恢复锁定与派生。
ignoreKeys
把动作从绑定界面里藏掉(调试键、作弊键、重复键):"ignoreKeys": ["key.epicfight.config"]。匹配动作 ID 时会自动补 modid: 前缀。
itemSuppressKeys
主手物品匹配时压制指定动作。典型场景:模组对特定物品接管了输入(战斗模式下普攻 / 交互由模组的连招与武器技能系统处理),把通用动作压掉避免打架。
"itemSuppressKeys": [
{ "item": "efn:yamato_dmc*", "playerState": ["epicfight:battle_mode"], "suppressActions": ["use"] },
{ "tag": "minecraft:swords", "playerState": ["epicfight:battle_mode"], "suppressActions": ["attack", "use"] }
]
| 字段 | 说明 |
|---|---|
item | 物品 ID(modid:item_id),支持 * 通配。与 tag 互斥。 |
tag | 物品标签。与 item 互斥。 |
playerState | 可选的 modId:state 列表(OR 关系),空 = 只要物品匹配就压制。未知状态会警告并按不匹配处理。 |
suppressActions | 动作短名(attack、use)或完整动作 ID。 |
每 tick 对主手物品求值一次;被 phasePersistentKeys 命中的动作豁免。
tips
绑定行上的提示文案:
"tips": [
{ "key": "key.epicfight.weapon_innate_skill",
"text": { "en_us": "Bind to the same button as Attack; use HOLD mode.",
"zh_cn": "请绑定为与「攻击」相同的按键,并使用 HOLD 模式。" } }
]
语言码没有白名单,运行时按玩家语言取,回落 en_us;连 en_us 都没有就不显示。至少写 en_us。
界面与叠层的摇杆、光标
键是全限定类名:界面用 screen.getClass().getName();叠层用「打开者」类名,"*" 表示该模组的兜底。
"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 |
含义 |
|---|---|
DEFAULT | 引擎默认:左摇杆增量虚拟鼠标,右摇杆在界面里不做事 |
VIRTUAL_MOUSE | 摇杆驱动增量虚拟光标 |
RADIAL_PATH | 以屏幕中心为原点的极坐标光标(轮盘类界面) |
DISABLED | 忽略该轴;摇杆按下的绑定仍然有效 |
| 参数 | 默认 | 说明 |
|---|---|---|
threshold | 0.5 | 摇杆死区 |
cursorRadius | 100 | 离屏幕中心的距离(逻辑像素 × GUI 缩放) |
cursorSpeed | 1.0 | 光标速度(夹在 0.1–3.0) |
showCursor | true | 是否画出光标(false 仍跟踪位置,只是不画) |
keepLastPosition | true | 松开摇杆后停在原地 |
angleBias | 1.0 | 角度灵敏度 |
条目还支持 cursorStyle(system / bedrock / hand)与 disabled: true(完全退出,不再回退)。解析顺序:user 层优先于内置,具体叠层条目优先于同模组的 "*",命中但缺所需那一侧(左 / 右)就继续往下一个配置找。
从旧格式迁移(≤ 0.8.5)
0.8.7 起启动时会自动迁移旧文件(先备份再改字段)。如果你在手工维护配置,或者要读一份老配置,对照这张表:
| 旧格式 | 现在 |
|---|---|
versions | mod_versions |
guiKeys: ["k"] | screenKeys: { "k": {} } |
required_mod: "x" | dependencies: [{ "mod_id": "x" }] |
loader 字段 | 删除,改由源目录(forge / fabric / neoforge)区分 |
GLFW_COMPAT 标记 | channels: ["glfwPoll"] |
PHASE_PERSISTENT 标记 | phasePersistentKeys + 在游戏内 / 目标分区各声明一次 |
旧摇杆数组(screens + mode) | screen 对象(按类名)+ behavior;RADIAL_CURSOR → RADIAL_PATH、returnToCenter: false → keepLastPosition: true |
| —— | overlayKeys · 按叠层的 overlay 配置 · overlay 目标端的相位保持 |
写完先自查这几条
- 三个分区必须是对象,写成数组会被整段丢弃(只留一条警告)。
- 分区里的
channels是保留键(分区默认值),不要拿它当键名。 - 每键的
channels: []与extraBehaviors: []是有意义的显式空,不等于省略。 mod_versions首个匹配生效:具体区间放前面,""兜底放最后;没有一条匹配 = 整份配置不生效。- 写坏的
version会丢掉整条 entry,不只是这个字段。 - 身份来自 JSON 里的
mod_id,但 user 层文件名必须叫<mod_id>.json。 phasePersistentKeys必须两边都声明才生效;只写配对会被忽略。- UTF-8、无 trailing comma;只有默认行为不对时才去加
channels覆盖。
最小模板
{
"mod_id": "examplemod",
"contributors": ["yourname"],
"config_version": "1.0.0",
"mod_versions": [
{
"version": "",
"inGameKeys": {},
"screenKeys": {},
"overlayKeys": {},
"ignoreKeys": [],
"tips": [],
"screen": {},
"overlay": {}
}
]
}
五、如何贡献一份配置
- Fork 仓库,并切到匹配分支。配置格式随 ControlFlex 版本变化,源是按分支消费的;只 fork 到默认分支时要先把对应版本分支拉下来。
- 新建
<mc-version>/<loader>/mods/<mod_id>.json。键名用模组 Controls 分类里的裸键名(不带modid:前缀),找不到就开日志里的[ModCompat]行。 - 重新生成 manifest:
python3 generate_manifest.py。改了内容不重算 sha256,是玩家侧「内容下载失败」的头号原因。 - 游戏内验证。把你的 fork 当源加进源列表,或直接把文件丢进
compat/user/重启。加载是否成功看[ModCompat]日志。 - 提 PR,写清测试过的模组版本、加载器,以及这份配置修好了什么。
只影响某个加载器就只放那个目录;两边行为一致就复制一份进另一个目录。文件大小上限 2 MB(实际配置通常只有几 KB)。
六、如何添加其他配置源
一个「源」就是一棵静态 HTTP 目录树:{baseUrl}/{mcVersion}/{loader}/manifest.json 加内容文件。没有服务端逻辑,任何静态托管都能当源。
两条路线
- Fork 官方仓库(推荐):目录布局、manifest 生成器、一批能用的配置都现成,只加自己的配置就行。代价是要跟上游同步。
- 从零搭一棵:自控发布节奏、放私有或实验性配置,按下面的规范建目录即可。
manifest.json 关键字段
{
"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"]
}
]
}
| 字段 | 说明 |
|---|---|
schemaVersion | 数字;大于 1 会被拒绝(客户端提示升级),缺失或 0 可接受。 |
items[].mod_id | 需匹配 ^[a-z0-9_-]+$,且在 manifest 内唯一;重复会让整份 manifest 解析失败。 |
items[].path | 相对 manifest 所在目录;禁止绝对路径、反斜杠、空段与 ..。惯例是 mods/<file>.json。 |
items[].sha256 | 内容文件的 SHA-256,64 位小写十六进制。下载后校验,不匹配就换下一个镜像。它是变体的唯一身份。 |
home_pages · description · dependencies | 纯展示字段(dependencies 只用来显示,客户端不会自动装依赖)。 |
官方仓库的 generate_manifest.py 可以照搬到自己的源:扫描 <mc-version>/<loader>/mods/*.json,抽出 mod_id / mod_name / home_page,算 sha256,幂等写回 manifest。
托管选择与硬性限制
| 托管 | baseUrl 示例 | 缓存 |
|---|---|---|
| GitHub raw | https://raw.githubusercontent.com/<user>/<repo>/refs/heads/<branch> |
约 5 分钟;官方内置源用的就是这种 |
| jsDelivr | https://cdn.jsdelivr.net/gh/<user>/<repo>@<branch> |
分支引用约 12 小时,建议打 tag |
| 自建 / 对象存储 | https://files.example.com/cfx-source/ |
自己定 |
| 项目 | 限制 |
|---|---|
| baseUrl | 必须是带主机名的 http:// 或 https:// |
| 单文件大小 | ≤ 2 MB(manifest 与内容文件都算) |
| 超时 | 连接 5 秒 / 读取 30 秒 |
| 重定向 | 跟随 301/302/303/307/308,最多 5 跳;拒绝 https → http 降级 |
| 编码 | UTF-8 JSON |
在游戏里添加源
- 进 设置 → 模组适配 → 右上「更多」→ 编辑源列表。
- 把 baseUrl 粘到下方输入框 → 添加 → 完成;列表有变化时关闭对话框会触发一次后台同步。
- 打开「同步最新配置」,确认你这个源同步成功(有分源进度与日志);之后模组卡片会出现社区配置角标。
源列表行为:内置源可以禁用 / 调序但不能删除;你自己的源可增删调序;「恢复默认源」只重置那两个内置源。每个源缓存在 compat/repo/manifest/<source-id>/,id 由 URL 派生(小写,冲突时加 -1 / -2)。
本地测试
cd <your-source-root>
python3 -m http.server 8000
# then add http://127.0.0.1:8000 in "Edit source list"
允许 http:// 源(只拒绝 https → http 的重定向降级),本地测试不需要证书。改完文件点一次「同步最新配置」即可。
七、排障
| 现象 | 检查 |
|---|---|
| 源显示「从未同步」/ 同步失败 | 浏览器打开 {baseUrl}/{mcVersion}/{loader}/manifest.json 能不能看到;目录名必须与游戏版本名、加载器名完全一致(1.20.1 不是 1.20)。 |
| manifest 解析报错 | 重复的 mod_id、path 带绝对路径 / 反斜杠 / ..、sha256 不是 64 位小写十六进制、schemaVersion > 1、JSON 语法错误。 |
| manifest 同步了,内容下载失败 | 内容字节与 manifest 里的 sha256 对不上(改了 JSON 忘了重新生成 manifest 是最常见原因)、文件超过 2 MB、或本轮所有镜像都失败。 |
| 应用了但没变化 | 配置自己的门槛没过:mod_id 模组没装、dependencies 没全加载、mod_versions 没有一条匹配当前版本(首个匹配生效,注意区间写法与顺序)、键名写错。搜 [ModCompat] 日志。 |
| 玩家看不到更新 | jsDelivr 的 @branch 缓存约 12 小时;换 raw 地址、打 tag,或者等缓存过期。 |
| 中文乱码 | 文件必须是 UTF-8(建议无 BOM)。 |