godot --editor project/ 和 godot --headless --export-release "Linux" out.zip 是同一个二进制,行为天差地别,分野发生在 Main::setup() 的前几百行参数解析。这段代码在 main/main.cpp 里(4.2.2 约 4000 行),读起来像一本开关账簿:每个参数设一两个全局标志,后面的启动路径全靠这些标志分叉。这篇把解析逻辑和分发路径理清楚。
解析框架:手写的两阶段扫描
Godot 没用任何命令行库(getopt、CLI11 都不用),参数解析是 Main::setup() 里一个手写循环。参数被装进 List<String> args,逐个匹配:
// main/main.cpp,Main::setup() 内(简化)
List<String>::Element *I = args.front();
while (I) {
List<String>::Element *N = I->next();
if (I->get() == "-h" || I->get() == "--help") {
// 打印帮助,后面 goto error 退出
} else if (I->get() == "-e" || I->get() == "--editor") {
editor = true;
} else if (I->get() == "--headless") {
// 设置 display driver 为 headless
audio_driver = "Dummy";
display_driver = "headless";
} else if (I->get() == "--path") {
N = N->next(); // 消费下一个参数作为值
// ...
}
I = N;
}
设计特点是就地消费:带值的参数(--path、--export-release)在匹配时把下一个元素也吃掉。这种写法没有声明式的参数表,新增参数就是加一个 else if 分支——优点是零依赖、错误信息可以完全定制(引擎的错误提示会猜测你想干什么,比如检测到项目路径下有 project.godot 会提示"你是不是想 --editor"),缺点是这段代码现在 1000 多行,review 新参数 PR 时要人肉检查有没有和现有分支冲突。
参数树:标志位如何决定启动路径
解析阶段设置的关键标志:
| 参数 | 设置的标志 | 影响 |
|---|---|---|
-e / --editor |
editor = true |
MainLoop 选 EditorNode,加载编辑器模块 |
--headless |
display_driver = "headless" |
DisplayServer 用空实现,音频用 Dummy |
-s / --script |
script = "..." |
MainLoop 换成脚本,跑完即退 |
--export-release |
export = true + 预设名 |
编辑器隐式开启,导完退出 |
--path |
切换项目目录 | 在找 project.godot 之前生效 |
--fixed-fps N |
fixed_fps = N |
强制每帧 delta,做确定性测试 |
这些标志在 setup() 后半段和 start() 里被消费,形成一棵分发树:
优先级值得记住:--script 直接替换 MainLoop,意味着它连 SceneTree 都不建。很多 CI 脚本用 godot --headless -s tool.gd 跑工具逻辑,如果工具里写了 get_tree() 会拿到 null——因为根本没有 SceneTree,脚本得自己 extends SceneTree 或 extends MainLoop 才能获得迭代回调。
参数的脚本侧可见性
解析完的参数有两条通道暴露给 GDScript:
OS.get_cmdline_args():引擎消费的完整参数列表(含--editor这类引擎级参数);OS.get_cmdline_user_args():--分隔符之后的参数,引擎自己不碰,留给游戏。
约定俗成的用法:
godot -- --server --port 7777 --map de_dust2
var args := OS.get_cmdline_user_args()
if "--server" in args:
var i := args.find("--port")
var port := args[i + 1].to_int() if i >= 0 else 7777
用户参数和引擎参数严格分开的设计避免了撞名——游戏想要一个叫 --path 的参数不会和引擎的冲突。注意导出后的游戏二进制也遵守同一套,--headless 之类对玩家机器同样生效,这既是调试利器也是攻击面(发布版可用 ProjectSettings 关闭部分参数响应)。
面向 CI 的参数族
自动化流程里常用的几个,行为都有明确契约:
--check-only(配合--script):只解析编译脚本不进主循环,语法检查的 CI 关卡;--validate-extension-api:GDExtension 绑定校验,godot-cpp 的 CI 用它确认绑定和引擎 ABI 匹配;--dump-extension-api:导出当前引擎的完整 API JSON,写绑定生成器时的输入;--doctool:从源码注释生成类参考 XML,文档贡献者用;--quit-after N:跑 N 帧后自动退出,配合--write-movie或冒烟测试,避免 CI 上的游戏进程永不退出。
--headless 的实现:换驱动而不是关窗口
--headless 不是"不显示窗口",是把 DisplayServer 的实现类整个换掉。Main::setup2() 里按 display_driver 字符串选 DisplayServer 子类:Windows 上默认 DisplayServerWindows,headless 时是 DisplayServerHeadless——一个所有方法都返回空/假值的实现。音频驱动同理换成 AudioDriverDummy。
这个设计的好处是上层代码零分支:渲染服务器照样初始化(Vulkan 的 headless 模式照样能跑离屏渲染和计算管线),场景树照样迭代,只是没有 swapchain 可以呈现。CI 上跑游戏逻辑测试、服务端跑专用服务器(--headless + 专用服务器导出预设)全靠这层。坑是"假值"会传染:DisplayServer.screen_get_size() 在 headless 下返回 (-1,-1) 之类,代码里假设屏幕尺寸合法的逻辑会在 CI 上炸,而本地永远复现不了。
几个实战参数的行为
--fixed-fps 20:每帧delta固定为 1/20 秒,不管真实流逝时间。做录制回放测试时这是确定性来源——物理和动画都吃 delta,固定后两次运行逐帧一致。但它不减速真实时间,游戏会"快放"。--write-movie out.avi(4.x 的电影录制):内部就是把 fixed-fps 加上每帧截图编码,两者配合得到帧帧精确的录像。--single-window:4.x 默认多窗口(每个 Window 节点一个原生窗口),这个参数强制所有子窗口内嵌进主窗口。远程桌面和某些 Linux 合成器下多窗口有兼容问题时的逃生门。--verbose/--quiet:控制print_line族的路由,不影响printerr。写启动脚本抓日志时注意 stderr 不受--quiet压制。- 位置参数:
Main::setup()末尾,没被任何 flag 消费的裸参数被当成"要运行的场景或包路径"——godot level2.tscn直接跑该场景而不是 main scene,调试时很有用。
工程化推论
- 导出预设的参数注入:
export_presets.cfg里可以填自定义启动参数,Android/iOS 导出会带过去。环境区分(dev/staging/prod 服务器地址)常用这招,比在代码里写死强。 --path与 cwd 的区别:--path只影响 project.godot 的查找,不改变资源路径解析的基准——res://永远指向项目根。写打包脚本时 cd 到项目目录再用相对路径,别依赖 cwd。- headless 导出需要编辑器二进制:
--export-release要求 editor 构建(导出逻辑在 editor 代码里),服务器 CI 上要用Godot_v4.2.2-stable_linux.x86_64(编辑器版)而不是导出模板。
本文代码引自 Godot 4.2.2-stable 的 main/main.cpp(Main::setup()、Main::start())、platform/linuxbsd/detect.py、servers/display_server.h,均有删减与简化。