Skip to content

✨ 运行时 SDL2/SDL3 后端切换 - #147

Open
zsd2024 wants to merge 1 commit into
TinyLake:mainfrom
zsd2024:main
Open

✨ 运行时 SDL2/SDL3 后端切换#147
zsd2024 wants to merge 1 commit into
TinyLake:mainfrom
zsd2024:main

Conversation

@zsd2024

@zsd2024 zsd2024 commented Jul 15, 2026

Copy link
Copy Markdown

摘要

为 MindustryX 添加运行时 SDL2/SDL3 后端动态切换能力,支持通过环境变量、启动参数或游戏内设置即时切换,无需重新编译。

动机

SDL3 后端提供更好的现代图形 API 支持和潜在性能提升。但 SDL2 仍是默认兼容性最好的后端。本 PR 允许用户在两种后端之间自由选择,并支持 SDL3 加载失败时自动回退到 SDL2。

架构设计

启动流程链

main()
  └─ getSdlVersion()
        ├─ 1. 环境变量 MINDUSTRY_SDL=3 → SDL3
        ├─ 2. 系统属性 mindustry.sdlversion → 对应后端
        └─ 3. 解析 settings.bin → 从持久化设置读取
  └─ startSdl3() / startSdl2()
        ├─ SDL2: 直接 new SdlApplication(...)
        └─ SDL3: 反射加载 arc.backend.sdl3.* (类名隔离)

类名隔离方案

SDL3 后端源码位于 arc.backend.sdl 包内(与 SDL2 相同),通过 Shadow plugin 在构建时 relocate 到 arc.backend.sdl3 避免类加载冲突:

arc.backend.sdl.SdlApplication → arc.backend.sdl3.SdlApplication

运行时通过 Class.forName("arc.backend.sdl3.SdlApplication") 反射调用,不干扰 SDL2 的类加载。

设置持久化

  • 存储格式:int (0=SDL2, 1=SDL3)
  • 兼容旧格式:自动检测 String 类型旧值 ("sdl2"/"sdl3") 并迁移
  • 系统属性 mindustry.sdlversion.active 记录本次实际加载的后端,供 UI 展示

变更详情

Arc: backends/backend-sdl3/build.gradle

变更 说明
Shadow plugin 添加 com.gradleup.shadow,relocate 到 arc.backend.sdl3
排除 lwjgl 依赖 避免 SDL2/SDL3 混合 JAR 中出现重复类
Outgoing variants apiElements/runtimeElements 指向 shadowJar

Work: desktop/src/mindustry/desktop/DesktopLauncher.java

变更 说明
重构 提取 buildConfig() 复用 SdlConfig 构建逻辑
新增 startSdl3() 反射加载 SDL3 后端
新增 getSdlVersion() 链式回退检测
新增 readSdlFromSettingsFile() 直接解析 settings.bin
新增 copySdlConfig() 反射拷贝配置到 SDL3 的 SdlConfig
添加 System.setProperty("mindustry.sdlversion.active", ...)

Work: core/src/mindustry/ui/dialogs/SettingsMenuDialog.java

变更 说明
SDL 选择器 标准 sliderPref 滑块 (0=SDL2, 1=SDL3)
迁移兼容 instanceof String 检测旧格式并自动转换
状态显示 当前实际加载的 SDL 版本标签

Work: build.gradle

变更 说明
依赖 :desktop 添加 backend-sdl3 依赖

Work: core/assets/bundles/bundle*.properties

setting.sdlversion.name SDL 后端
setting.sdlversion.description 在 SDL2 和 SDL3 后端之间切换。需要重启游戏。
setting.sdlversion.sdl2 SDL 2
setting.sdlversion.sdl3 SDL 3
setting.sdlversion.active 当前 SDL: {0}

使用方法

环境变量(优先级最高):

MINDUSTRY_SDL=3 java -jar Mindustry.jar

游戏内设置:

设置 → 图形 → SDL 后端 滑块切换,重启后生效。

测试指南

  1. SDL2 默认启动:不设环境变量,桌面版应正常启动
  2. SDL3 启动MINDUSTRY_SDL=3 启动,验证 SDL3 后端加载
  3. 设置切换:图形设置中切换 SDL 版本,关闭游戏,重新启动查看当前加载版本
  4. 回退机制MINDUSTRY_SDL=3 但 SDL3 加载失败应自动回退到 SDL2
  5. 全量构建gradle desktop:dist server:dist android:assembleRelease 均通过

回退注意事项

  • SDL3 加载失败时自动回退 SDL2,仅输出警告日志
  • 旧版 settings.bin 中的 String 格式值 ("sdl2"/"sdl3") 自动迁移
  • Shadow plugin 不会影响现有构建管线

文件清单

patches/arc/0009-H.BUILD-backend-sdl3-Shadow-jar-to-arc.backend.sdl3.patch  (new)
patches/client/0075-FC-runtime-SDL2-SDL3-backend-switching.patch            (new)

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces runtime switching between the SDL2 and SDL3 backends. It adds shadow jar relocation to the SDL3 backend to prevent class conflicts, refactors the desktop launcher to dynamically load the selected backend via reflection, and adds a setting slider in the UI to allow users to switch backends. The review feedback highlights several critical improvements: correcting a potential compilation error by using Core.settings instead of settings, resetting the active SDL version system property to "sdl2" upon fallback, using try-with-resources to prevent resource leaks when reading the settings file, and filtering out static/final fields during reflection copying to avoid access exceptions.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +67 to +72
+ if(settings.has("sdlversion")){
+ Object val = settings.get("sdlversion", null);
+ if(val instanceof String){
+ settings.put("sdlversion", val.equals("sdl3") ? 1 : 0);
+ }
+ }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

SettingsMenuDialog.java 中,直接使用 settings 变量可能会导致编译错误。在 Mindustry/Arc 框架中,通常需要通过 Core.settings 来访问全局设置。建议将其修改为 Core.settings 以确保代码能够正确编译。

+            if(Core.settings.has("sdlversion")){
+                Object val = Core.settings.get("sdlversion", null);
+                if(val instanceof String){
+                    Core.settings.put("sdlversion", val.equals("sdl3") ? 1 : 0);
+                }
+            }

Comment on lines +128 to +129
+ }catch(Throwable e){
+ Log.warn("SDL3 backend failed to load, falling back to SDL2", e);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

当 SDL3 后端加载失败并自动回退到 SDL2 时,系统属性 mindustry.sdlversion.active 仍然保持为 "sdl3"。这会导致游戏内设置界面错误地显示当前正在运行 SDL3。建议在捕获异常的回退逻辑中,将该系统属性重置为 "sdl2"

+                }catch(Throwable e){
+                    Log.warn("SDL3 backend failed to load, falling back to SDL2", e);
+                    System.setProperty("mindustry.sdlversion.active", "sdl2");

Comment on lines +201 to +227
+ DataInputStream stream;
+ boolean compressed = bytes.length >= 2 && bytes[0] == (byte)0x78
+ && (bytes[1] == (byte)0x01 || bytes[1] == (byte)0x5E || bytes[1] == (byte)0x9c || bytes[1] == (byte)0xda);
+ if(compressed){
+ stream = new DataInputStream(new InflaterInputStream(new ByteArrayInputStream(bytes)));
+ }else{
+ stream = new DataInputStream(new ByteArrayInputStream(bytes));
+ }
+
+ int count = stream.readInt();
+ for(int i = 0; i < count; i++){
+ String key = stream.readUTF();
+ byte type = stream.readByte();
+ String value = null;
+ switch(type){
+ case 0: stream.readBoolean(); break;
+ case 1: value = stream.readInt() == 0 ? "sdl2" : "sdl3"; break;
+ case 2: stream.readLong(); break;
+ case 3: stream.readFloat(); break;
+ case 4: value = stream.readUTF(); break;
+ case 5: stream.skipBytes(stream.readInt()); break;
+ }
+ if(key.equals("sdlversion") && value != null){
+ return value;
+ }
+ }
+ }catch(Exception ignored){}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

readSdlFromSettingsFile 方法中,创建的 DataInputStream(以及可能包装的 InflaterInputStream)在使用后没有被关闭,这可能会导致文件句柄或内存资源泄漏。建议使用 Java 的 try-with-resources 语句来确保流被安全、自动地关闭。

+            byte[] bytes = sf.readBytes();
+            boolean compressed = bytes.length >= 2 && bytes[0] == (byte)0x78
+                && (bytes[1] == (byte)0x01 || bytes[1] == (byte)0x5E || bytes[1] == (byte)0x9c || bytes[1] == (byte)0xda);
+            try(DataInputStream stream = compressed ?
+                new DataInputStream(new InflaterInputStream(new ByteArrayInputStream(bytes))) :
+                new DataInputStream(new ByteArrayInputStream(bytes))){
+
+                int count = stream.readInt();
+                for(int i = 0; i < count; i++){
+                    String key = stream.readUTF();
+                    byte type = stream.readByte();
+                    String value = null;
+                    switch(type){
+                        case 0: stream.readBoolean(); break;
+                        case 1: value = stream.readInt() == 0 ? "sdl2" : "sdl3"; break;
+                        case 2: stream.readLong(); break;
+                        case 3: stream.readFloat(); break;
+                        case 4: value = stream.readUTF(); break;
+                        case 5: stream.skipBytes(stream.readInt()); break;
+                    }
+                    if(key.equals("sdlversion") && value != null){
+                        return value;
+                    }
+                }
+            }

Comment on lines +312 to +319
+ private static void copySdlConfig(SdlConfig src, Object dst, Class<?> cfgClass) throws Exception{
+ for(Field f : SdlConfig.class.getFields()){
+ try{
+ Field dstField = cfgClass.getField(f.getName());
+ dstField.set(dst, f.get(src));
+ }catch(NoSuchFieldException ignored){
+ }
+ }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

copySdlConfig 方法中,使用反射遍历并拷贝 SdlConfig 的所有字段时,可能会遇到 staticfinal 字段。在较新的 Java 版本(如 Java 17+)中,尝试通过反射修改 final 字段或访问不当的字段可能会抛出 IllegalAccessException。建议在拷贝时过滤掉 staticfinal 字段,以提高反射操作的安全性和兼容性。

+    private static void copySdlConfig(SdlConfig src, Object dst, Class<?> cfgClass) throws Exception{
+        for(Field f : SdlConfig.class.getFields()){
+            if(Modifier.isStatic(f.getModifiers()) || Modifier.isFinal(f.getModifiers())) continue;
+            try{
+                Field dstField = cfgClass.getField(f.getName());
+                if(!Modifier.isFinal(dstField.getModifiers())){
+                    dstField.set(dst, f.get(src));
+                }
+            }catch(NoSuchFieldException ignored){
+            }
+        }

@zsd2024
zsd2024 marked this pull request as draft July 15, 2026 10:38
@zsd2024
zsd2024 marked this pull request as ready for review July 15, 2026 10:45
@way-zer

way-zer commented Jul 15, 2026

Copy link
Copy Markdown
Member

看起来可行。有些问题:

  1. SDL应该初始化比较早,是否能用Core.settings?
  2. Loader版本不应该支持SDL3,因为已经初始化好,无法变更。

* SDL3 运行时动态加载,支持环境变量(MINDUSTRY_SDL)和设置文件切换
* readSdlFromSettingsFile 使用内置 Settings.loadValues
* src/mindustryX/bundles/: SDL 后端选项文案
* Loader 模式下不显示 SDL 切换选项
@zsd2024

zsd2024 commented Jul 16, 2026

Copy link
Copy Markdown
Author

看起来可行。有些问题:

  1. SDL应该初始化比较早,是否能用Core.settings?
  2. Loader版本不应该支持SDL3,因为已经初始化好,无法变更。

Q1: SDL 应该初始化比较早,是否能用 Core.settings?

不能。Core.settingsSdlApplication 构造函数中初始化,而 SDL 后端选择必须在 SdlApplication 创建之前完成(因为要决定用哪个后端创建窗口)。main() 中现有代码在创建 Application 前手动构造 Fi 路径的做法(如 loadFileLogger(new Fi(OS.getAppDataDirectoryString(appName)).child(...)))已证明了这一限制。

readSdlFromSettingsFile() 当前使用 new Settings(appName) + setDataDirectory() + loadValues() 来读取,不依赖 Core.files,与现有做法一致。相比原 patch 中手动解析二进制格式(DataInputStream + InflaterInputStream + 逐字段 switch),更简洁且复用 Arc 内置逻辑。

Q2: Loader 版本不应该支持 SDL3,因为已经初始化好,无法变更。

已处理。SettingsMenuDialog.java 中 SDL 版本选择区域包裹在 if(!mindustryX.VarsX.isLoader) 中,Loader 模式下不显示切换选项。

@zsd2024

zsd2024 commented Jul 16, 2026

Copy link
Copy Markdown
Author

(要不要顺便加强制 Wayland)

@zsd2024

zsd2024 commented Jul 16, 2026

Copy link
Copy Markdown
Author

以及 Android 支持(?

@zsd2024

zsd2024 commented Jul 19, 2026

Copy link
Copy Markdown
Author

SDL3 路径下 X UI 语言错误的调查

症状

SDL3 后端(MINDUSTRY_SDL=3)启动时,X UI 部分文本显示为英文(如 "QQ Group"、"Add Panel"),部分文本为中文(如 "检查更新"、"辅助按钮")。SDL2 后端无此问题。

根因分析

1. 初始化顺序差异

SDL3 的 startSdl3() 调用 copySdlConfig(),其第 189 行:

cfgClass.getField("appVersion").set(dst, Version.combined());

Version.combined() 第 87 行引用了 VarsX.version

"\nMindustryX " + VarsX.version;

这使得 VarsX 类在 main() 期间就被触发加载,远早于游戏 locale 的初始化。

SDL2 路径的 startSdl2() 不经过 copySdlConfig(SDL2 SdlConfigappVersion 字段),VarsX 首次加载发生在 Hooks.beforeInit() 中,此时游戏 locale 已完成初始化。

2. 各修复方案及其限制

方案 A:init 块中用 Core.bundle?.locale ?: Locale.getDefault()(原始代码)

SDL3 路径:VarsX 加载时 Core.bundle 为 null,回退到 Locale.getDefault()(系统 locale,非游戏 locale,如英文系统则为英文)。

方案 B:移去 copySdlConfig 中的 Version.combined() 调用

确实可避免 VarsX 提前加载,但 SDL3 SdlConfigappVersion 字段用于 Wayland 窗口元数据,完全移除会丢失窗口版本信息。

方案 C:LazyBundle 代理对象

bundle 改为一个代理对象,真正实现延迟到首次方法调用时才取 Core.bundle?.locale。理论上正确:

  • SDL3:VarsX 在 main() 加载,但 bundle 代理不解析 → 首次方法调用时 Core.bundle 已设 → 正确 locale
  • SDL2:首次 VarsX 访问已在 beforeInit 中,Core.bundle 已设 → 正确

用户测试后反馈仍有问题。

方案 D:var bundle + resolveBundle()Core.bundle?.locale

Hooks.beforeInit() 中先 registerBundle()resolveBundle()registerBundle() 会创建新的 MDTX bundle 并通过反射设置 locale:

Core.bundle = I18NBundle.createBundle(handle, Locale.getDefault());
Reflect.set(Core.bundle, "locale", originBundle.getLocale());

若反射设置 locale 字段失败(例如因 Java 模块系统限制、字段改名或 Reflect.set 内部异常被 catch 吞掉),Core.bundle.getLocale() 返回的将是 Locale.getDefault()(系统 locale,如英文),而非游戏的实际语言。

方案 E:resolveBundle()Core.settings?.getString("locale")

不依赖 Core.bundle,直接从已落盘的 locale 设置判断语言。理论上:

  • SDL3:loadSettings() 先执行(含 settings.load()),Core.settings 就绪 → 读 locale 设置 → 若为 "zh_CN""default" + Locale.getDefault() 已被 Locale.setDefault(chinese) 改为中文 → 正确

用户测试后反馈仍有问题。

3. 中英交错的原因

用户观察到部分中文("检查更新"、"辅助按钮")和部分英文("QQ Group"、"Add Panel")同时出现。这有两种可能:

  • 中文文本来自游戏自身的 Core.bundle(其 locale 正确),英文文本来自 VarsX.bundle(其 resolveBundle() 未能正确设为中文实现)
  • VarsX.bundle 在不同读取点间被 var 字段重新赋值,导致部分调用拿到中文实现、部分拿到英文实现

目前无法进一步隔离问题(不具备运行时调试环境),且所有可控变量已穷举尝试。

结论

该问题属于 Java 类加载顺序与游戏 i18n 初始化流程的耦合问题,在 SDL3 路径上被 Version.combined()VarsX.version 的提前引用暴露。SDL2 路径因不触发该引用而天然规避。

建议方向

  1. 不依赖 Core.bundleLocale.getDefault():在 DesktopLauncher(具备完整启动上下文的入口)中显式确定语言并注入 VarsX,而非在静态 init 中推测。
  2. 版本信息分离:将 VarsX.versionVarsX.bundle 拆分到不同的类加载单元,使 Version.combined() 只触发 version 加载而不触发 bundle 初始化。
  3. 推迟 resolve 时机:将语言决定权从 init 块和 beforeInit 移到 UI 首次构建阶段,此时 locale 已完全就绪且无竞态。

@way-zer

way-zer commented Jul 29, 2026

Copy link
Copy Markdown
Member

初始化过程不应该依赖VarsX.version,据我所知,就算是原版的version,这时候也没正确初始化。

@zsd2024

zsd2024 commented Jul 30, 2026

Copy link
Copy Markdown
Author

初始化过程不应该依赖VarsX.version,据我所知,就算是原版的version,这时候也没正确初始化。

SDL3 应用元数据里面有版本号来着

@way-zer

way-zer commented Jul 31, 2026

Copy link
Copy Markdown
Member

不填吧,影响比较小

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants