跳到正文
Pixel-Pine Launcher

.pplx 扩展规范

PPL 自有扩展格式:包结构、清单字段、校验规则、加载顺序与打包。

最后更新:2026-11-11

状态:静态部分已实现。识别、安装、启停、依赖解析与加载顺序都有代码和单元测试; 运行时(真正把扩展注入游戏进程)尚未实现。下面凡是涉及运行时的部分都会单独标注。

为什么另起一套

社区里已有一套基于 JVM mixin 的加载器(RWML / RWPP)。PPL 不复用它的目录、描述符或包格式, 原因不是「想再造一个」:

  • 许可证不兼容。那套项目用的是自定义团队许可证,抄格式、抄字段都有风险。
  • 目录会打架。它占用 <游戏>/extensions/。两边共用同一目录时, 「谁启用了谁」根本说不清。
  • 它不是我们能修的。依赖别人的加载器,意味着对方的崩溃与兼容性变更 都会直接砸到 PPL 用户头上。
社区方案PPL
目录<游戏>/extensions/<游戏>/ppl/extensions/
描述符META-INF/extension.properties包根 ppl-extension.json
包后缀.jar.pplx(本质是 zip)
依赖解析加载器自己算启动器算好顺序,运行时只执行

包格式

一个 .pplx 就是普通 zip。目录层级没有强制要求,但描述符必须在包根:启动器只读这一个条目,不解压整个包。

my-extension/
├─ ppl-extension.json          必需,必须在包根
├─ lib/                        可选,扩展自带的依赖
└─ org/example/zoom/
   ├─ ZoomExtension.class
   └─ mixin/CameraMixin.class

描述符以外的东西启动器不关心,也不会执行——它只做静态校验与排序。 真正加载类、注入 mixin 是运行时的事。

清单字段

一份最小清单只有三个字段:

{
  "schema": 1,
  "id": "org.example.hello",
  "name": "Hello"
}

完整示例:

{
  "schema": 1,
  "id": "org.example.zoom",
  "name": "缩放控制",
  "version": "1.0.0",
  "apiVersion": 1,
  "mainClass": "org.example.zoom.ZoomExtension",
  "mixins": ["org.example.zoom.mixin.CameraMixin"],
  "dependsOn": ["org.ppl.core@>=1.0"],
  "loadAfter": [],
  "loadBefore": [],
  "author": "someone",
  "description": "把相机缩放上限放宽到 4 倍",
  "minGame": "1.15"
}
字段必填说明
schema否格式版本,默认 1。只接受 1,其它值直接拒收。
id是唯一标识,也是依赖关系的键。只允许字母、数字、.、-、_, 不超过 128 字符,不能以 . 开头。
name否展示名。留空时用 id。
version否仅用于展示,不参与任何求解。
apiVersion否需要的运行时 API 版本,默认 1。高于启动器支持的值会被跳过并在界面提示。
mainClass否入口类,由运行时实例化。
mixins否要注入的类清单。
dependsOn否硬依赖。可以带 @ 版本约束,但启动器只取 @ 前面的 id,不做版本求解。
loadAfter / loadBefore否软顺序偏好,只在没有硬依赖冲突时生效。
author / description否展示用。
minGame否最低游戏版本,仅提示,不参与校验。

列表字段会被归一化

dependsOn、loadAfter、loadBefore 里的字符串会被拆分、去空格、 去掉 @ 之后的版本约束,然后排序并去重。所以["b, a@1.0", "a"] 等价于 ["a", "b"]。

这样做的后果要清楚:写版本约束不会锁版本。想约束别人,只能靠文档和社区约定。

目录与启停

<游戏目录>/
└─ ppl/
   └─ extensions/
      ├─ org.example.zoom.pplx     启用中
      └─ .disabled/
         └─ org.example.old.pplx   已停用

启用 / 停用就是在这两个目录之间移动文件:可逆、不删数据,用户手动也能还原。 只认 .pplx 与 .zip,其它文件会被忽略。

报告「包已损坏」不会把文件删掉,也不会静默跳过:包里没有ppl-extension.json、zip 打不开、JSON 非法或字段不合规,都会在列表里标出来并写下原因。

加载顺序

启动器在启动游戏前算好顺序,规则是确定的:

  1. dependsOn 声明的依赖必须先加载(硬边)。
  2. 再叠加 loadAfter / loadBefore 的软边。
  3. 同时可用时按 id 字典序取最小。
  4. 有环 → 整组都不加载,界面明确报出来。
  5. 缺依赖 → 只跳过缺依赖的那一个,其余照常加载。
  6. apiVersion 高于运行时 → 跳过并提示。

「必须是确定的」不是洁癖:同一批扩展在两台机器上顺序不同,问题会变得无法复现。 这几条都有单元测试覆盖(crates/ppl-core/src/extensions.rs),因为顺序错会让游戏直接崩。

打包

cd my-extension
zip -r ../org.example.zoom.pplx . -x '.*'

文件名建议直接用 id,方便用户和依赖关系对上。包里的内容不限, 但启动器只校验描述符——坏包会拒收并标为「包已损坏」,不会悄悄放行一个永远不会生效的文件。

运行时现状

静态部分只负责「装好、排好、告诉运行时」。真正改写游戏类的是一个 JVM agent,目前没有任何代码,不要把它当成已实现:

启动器                          运行时(agent)
  │  算出 order                   │
  │  写 ppl/runtime/plan.json     │
  ├──────────────────────────────▶│  读 plan.json
  │  java -javaagent:...          │  按顺序加载各 .pplx
  │                               │  ClassFileTransformer 注入 mixin

在写生产代码之前,需要先验证四件事:

问题为什么必须先验证
游戏启动入口能否传 JVM 参数有些打包器会把参数吃掉,-javaagent 可能根本传不进去。
游戏用哪个 JRE、版本多少agent 编译出的 class 版本不能高于运行时。
类名映射从哪来混淆过的 jar,没有映射就写不出可用的 mixin。
Android 端怎么办那边是 dex,JVM agent 完全不适用,需要另一套。

RWML 兼容模式

启动器也能把社区加载器的扩展当文件管理:读取对方的META-INF/extension.properties 用于展示,开关就是移动文件。默认关闭,推荐用 PPL 格式。

  • 只做互操作:不加载、不注入、不包含对方任何代码。
  • 不替用户安装加载器:只检测 game-lib.jar.original 与 rwml-runtime。
  • 停用方式尚未实测:把它移进 extensions/.disabled/ 是基于「按目录扫描」的推测。

给扩展作者

  • id 一旦发布就别改:依赖关系、文件名都挂在它上面。
  • 别用 dependsOn 里的版本约束来「锁版本」,写了也会被忽略。
  • 多个扩展要协作,就抽一个 core 扩展出来,让别的扩展依赖它,而不是互相 loadAfter 打转。
  • 描述符里的 description 会直接显示在扩展页,写清楚「这个扩展改了什么」, 比写「优化体验」有用得多。

规范的权威版本在仓库 docs/EXTENSIONS.md,解析器在crates/ppl-core/src/extensions.rs。两处不一致时以代码为准, 并欢迎到 Issues 指出。