From 55b4a35f96d8aa183f608ec68296a8ef03659a73 Mon Sep 17 00:00:00 2001 From: BZLZHH Date: Sat, 11 Jul 2026 16:11:38 +0800 Subject: [PATCH 01/67] [Chore] (version): bump version to 1.3.5 --- MobileGlues | 2 +- app/build.gradle.kts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/MobileGlues b/MobileGlues index d0e6a2f6..6a5638dd 160000 --- a/MobileGlues +++ b/MobileGlues @@ -1 +1 @@ -Subproject commit d0e6a2f66839777c10394a82c46a319582dc50aa +Subproject commit 6a5638ddf87efd1f0990c98189a18b96cd0fe592 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 00a37fd0..fef4895a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "com.fcl.plugin.mobileglues" minSdk = 26 targetSdk = 36 - versionCode = 1340 - versionName = "1.3.4" + versionCode = 1350 + versionName = "1.3.5" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } From 938611b9ddab5d1ff48e6237616b584da700bde2 Mon Sep 17 00:00:00 2001 From: BZLZHH Date: Wed, 5 Aug 2026 13:23:57 -0400 Subject: [PATCH 02/67] [Refactor] (config): rewrite the config layer and adopt the new multidraw keys The config code was a bag of mutable fields whose setters wrote the file. That put a full synchronous write to external storage on the main thread behind every assignment, and forced an `isInitializing` flag to suppress it while loading. Defaults were spelled out in three places that disagreed with each other, range checks lived in the UI, and every failure was swallowed by a bare `runCatching {}`. Model and storage - MGConfig is an immutable data class. Each enum carries both the integer that goes on disk and the label shown in the spinner, and the adapters are built from the enums, so the option list and the value space cannot drift apart. res/values/array.xml is gone. - MGConfigCodec owns the key names and the fallback for every field. Decoding never throws: a single broken field falls back on its own instead of discarding the whole file. Keys the app does not know -- native reads hideMGEnvLevel -- survive a save instead of being stripped. - MGConfigStore is the only code that touches MG/config.json. An edit updates a StateFlow at once and is written after a 300 ms debounce on Dispatchers.IO, through temp file + fsync + rename so the game cannot read a torn file. It writes only when something actually changed, which stops "open the app and leave" from rewriting the file, and reports failures instead of hiding them. - A damaged config is backed up and the user is asked what to do; it is never silently replaced by defaults. - customGLVersion is clamped the way settings.cpp clamps it, so a hand-edited 38 is no longer rewritten to 0 -- which native reads back as 4.0. UI - One-way rendering: render(config) is the only writer of the views, and every callback returns early when the incoming value already equals the config. That replaces isSpinnerInitialized, revertSpinner() and the post{} timing tricks, which could not have worked anyway since AbsSpinner posts its selection callback rather than firing it inline. - Warning dialogs became suspend functions, so "ask, then apply" is a straight line instead of a pair of callbacks. - The GLSL cache size is a slider (off .. 1/16 of RAM) on a quadratic scale, and clearing the cache is an explicit button that appears only while the cache is off and the file still exists -- not a side effect of assigning -1, which used to delete the cache on every load of such a config. MultiDraw - multidrawMode is dead: native ignores it and only warns when it is present. It is replaced by one key per entry point carrying a backend NAME, plus the global multidrawDisableBackends list. The per-entry allowed sets mirror k_md_entries, because most backends are not a distinct strategy for most entry points. - The section is collapsed by default behind a one-line summary; its rows and chips are generated from the enums. - The submodule moves to 55ef3c4, where these keys are read. The two changes cannot be separated: the app no longer writes multidrawMode, so on the previous native every multidraw choice would silently do nothing. Adds unit tests for the on-disk contract and for the cases where the store must not write, and declares kotlinx-coroutines and lifecycle-runtime-ktx explicitly instead of relying on them arriving transitively through appcompat. --- MobileGlues | 2 +- app/build.gradle.kts | 6 + app/src/main/AndroidManifest.xml | 1 + .../fcl/plugin/mobileglues/MGApplication.kt | 31 + .../fcl/plugin/mobileglues/MGInfoGetter.kt | 28 + .../fcl/plugin/mobileglues/MainActivity.kt | 1086 +++++++++++------ .../mobileglues/settings/MGCacheExporter.kt | 26 + .../plugin/mobileglues/settings/MGConfig.kt | 413 ++++--- .../mobileglues/settings/MGConfigCodec.kt | 143 +++ .../mobileglues/settings/MGConfigStore.kt | 276 +++++ .../plugin/mobileglues/showAppInfoDialog.kt | 12 +- .../plugin/mobileglues/showMGGLInfoDialog.kt | 37 - .../fcl/plugin/mobileglues/utils/Constants.kt | 5 +- .../main/res/drawable/ic_expand_more_24.xml | 10 + app/src/main/res/layout/activity_main.xml | 181 ++- .../main/res/layout/item_multidraw_entry.xml | 26 + app/src/main/res/values-zh/strings.xml | 33 +- app/src/main/res/values/array.xml | 30 - app/src/main/res/values/strings.xml | 39 +- .../mobileglues/settings/MGConfigCodecTest.kt | 281 +++++ .../mobileglues/settings/MGConfigStoreTest.kt | 211 ++++ gradle/libs.versions.toml | 7 + 22 files changed, 2218 insertions(+), 666 deletions(-) create mode 100644 app/src/main/java/com/fcl/plugin/mobileglues/MGApplication.kt create mode 100644 app/src/main/java/com/fcl/plugin/mobileglues/MGInfoGetter.kt create mode 100644 app/src/main/java/com/fcl/plugin/mobileglues/settings/MGCacheExporter.kt create mode 100644 app/src/main/java/com/fcl/plugin/mobileglues/settings/MGConfigCodec.kt create mode 100644 app/src/main/java/com/fcl/plugin/mobileglues/settings/MGConfigStore.kt delete mode 100644 app/src/main/java/com/fcl/plugin/mobileglues/showMGGLInfoDialog.kt create mode 100644 app/src/main/res/drawable/ic_expand_more_24.xml create mode 100644 app/src/main/res/layout/item_multidraw_entry.xml delete mode 100644 app/src/main/res/values/array.xml create mode 100644 app/src/test/java/com/fcl/plugin/mobileglues/settings/MGConfigCodecTest.kt create mode 100644 app/src/test/java/com/fcl/plugin/mobileglues/settings/MGConfigStoreTest.kt diff --git a/MobileGlues b/MobileGlues index 6a5638dd..55ef3c4e 160000 --- a/MobileGlues +++ b/MobileGlues @@ -1 +1 @@ -Subproject commit 6a5638ddf87efd1f0990c98189a18b96cd0fe592 +Subproject commit 55ef3c4e8f15d3aa4268c801d74cf1e978844207 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fef4895a..a6434f40 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -100,5 +100,11 @@ dependencies { implementation(libs.appcompat) implementation(libs.constraintlayout) implementation(libs.google.material) + // 协程和 lifecycleScope 以前是从 appcompat 传递依赖里蹭来的,这里显式声明。 + implementation(libs.coroutines.android) + implementation(libs.lifecycle.runtime.ktx) implementation(project(":MobileGlues")) + + testImplementation(libs.junit) + testImplementation(libs.coroutines.test) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9ac54a40..52761aa5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,6 +13,7 @@ android:maxSdkVersion="29" /> by lazy { - linkedMapOf( - getString(R.string.option_angle_disable) to 0, - "OpenGL 4.6" to 46, "OpenGL 4.5" to 45, "OpenGL 4.4" to 44, - "OpenGL 4.3" to 43, "OpenGL 4.2" to 42, "OpenGL 4.1" to 41, - "OpenGL 4.0" to 40, "OpenGL 3.3" to 33, "OpenGL 3.2" to 32 - ) - } - private val binding by lazy { ActivityMainBinding.inflate(layoutInflater) } - private var config: MGConfig? = null - private var isSpinnerInitialized = false + private val store by lazy { (application as MGApplication).configStore } + private val cacheExporter by lazy { (application as MGApplication).cacheExporter } + + /** 查询 GPU 名字要创建一次 EGL 上下文,结果在进程内不会变,查一次就够。 */ + private var cachedIsAdreno740: Boolean? = null + + /** 正在进行的一次加载。onResume 不能在它完成之前把界面打回启动页。 */ + private var enterOptionsJob: Job? = null + + /** 滑块当前的 MiB 量程上限,见 [renderGlslCache]。 */ + private var glslCacheCeilingMebibytes: Int = 0 + + /** GLSL 缓存滑块的上限:设备总内存的 1/[GLSL_CACHE_RAM_DIVISOR]。 */ + private val maxGlslCacheMebibytes: Int by lazy { + val memoryInfo = ActivityManager.MemoryInfo() + getSystemService(ActivityManager::class.java).getMemoryInfo(memoryInfo) + val shareOfRam = memoryInfo.totalMem / (GLSL_CACHE_RAM_DIVISOR * 1024 * 1024) + shareOfRam.coerceIn(MIN_GLSL_CACHE_UPPER_BOUND_MIB, MAX_GLSL_CACHE_UPPER_BOUND_MIB).toInt() + } // ---- Activity Result Launchers ---- private val manageAllFilesLauncher = @@ -83,21 +117,13 @@ class MainActivity : AppCompatActivity(), AdapterView.OnItemSelectedListener, registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> when { isGranted -> handlePermissionResult(true) - !shouldShowRequestPermissionRationale(Manifest.permission.WRITE_EXTERNAL_STORAGE) -> showGoToSettingsDialog() + !shouldShowRequestPermissionRationale(Manifest.permission.WRITE_EXTERNAL_STORAGE) -> + showGoToSettingsDialog() + else -> handlePermissionResult(false) } } - private fun handlePermissionResult(isGranted: Boolean) { - if (isGranted) { - MGConfig(this).save() - showOptions() - } else { - snackbar(getString(R.string.permission_failed)) - hideOptions() - } - } - // ---- 生命周期 ---- override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -108,23 +134,36 @@ class MainActivity : AppCompatActivity(), AdapterView.OnItemSelectedListener, setContentView(binding.root) setSupportActionBar(binding.appBar) + setupSpinners() + setupMultidraw() + attachListeners() + setupWindowInsets() binding.openOptions.setOnClickListener { - if (hasMgDirectoryAccess()) { - // 如果已经授权,但因没有 MG 文件夹而停留在首屏,直接触发创建并进入设置 - handlePermissionResult(true) - } else { - // 如果未授权,走正常的授权流程 - checkPermission() - } + if (hasMgDirectoryAccess()) enterOptions() else requestPermission() } - setupWindowInsets() + + observeStore() } override fun onResume() { super.onResume() - checkPermissionSilently() + // 已授权且配置文件存在才自动进入设置;否则停在启动页。 + if (hasMgDirectoryAccess() && File(Constants.CONFIG_FILE_PATH).isFile) { + enterOptions() + } else if (enterOptionsJob?.isActive != true) { + // 有加载正在进行时不能打断它:刚授权完的那条路径上 ActivityResult 回调先于 onResume + // 送达,此刻 config.json 还没来得及建出来,文件「不存在」并不代表用户没有配置。 + leaveOptions() + } + } + + override fun onStop() { + super.onStop() + // 用 store 自己的作用域,不能用 lifecycleScope:旋转屏幕时后者会在 onDestroy 被取消, + // 这次保存可能还没轮到 IO 线程就被砍掉。 + store.flushAsync() } // ---- 菜单 ---- @@ -137,23 +176,204 @@ class MainActivity : AppCompatActivity(), AdapterView.OnItemSelectedListener, override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { R.id.action_about -> { - showAppInfoDialog(this, config); true + showAppInfoDialog(this) { showGlInfoDialog() }; true } R.id.action_remove -> { - showRemoveConfirmationDialog(); true + confirmRemoval(); true } else -> super.onOptionsItemSelected(item) } - // ---- UI 初始化 ---- + // ---- 配置 ⇄ 界面 ---- + + private fun observeStore() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { store.config.collect { config -> config?.let(::render) } } + launch { store.events.collect(::onStoreEvent) } + launch { + combine(store.config, store.glslCacheBytes) { config, cacheBytes -> + cacheBytes.takeIf { config?.glslCache == GlslCacheSize.Disabled } + }.distinctUntilChanged().collect(::renderGlslCacheDeleteButton) + } + } + } + } + + private fun onStoreEvent(event: ConfigStoreEvent) { + when (event) { + is ConfigStoreEvent.SaveFailed -> snackbar( + getString( + R.string.config_save_failed, + event.cause.message ?: event.cause.javaClass.simpleName, + ), + Snackbar.LENGTH_LONG, + ) + } + } + + /** + * 把配置渲染到界面上。唯一的写界面入口。 + * + * 这里不做「先摘掉监听器再赋值」的保护:Spinner 的选中回调是异步投递的(AbsSpinner 在布局 + * 阶段 post 出去),摘监听器根本挡不住。真正的保护在回调侧——取值与当前配置相同就直接返回, + * 见 [onItemSelected] / [onCheckedChanged]。这是个与时序无关的判据,因此不需要 + * `isSpinnerInitialized` 之类的标志位。 + * + * 输入框不参与常规回灌([includeCacheSize] 默认为 false),否则用户输入 "032" 会 + * 被规范化成 "32" 打断光标。 + */ + private fun render(config: MGConfig, includeCacheSize: Boolean = false) { + binding.spinnerAngle.setSelection(config.angle.ordinal) + binding.spinnerNoError.setSelection(config.noError.ordinal) + binding.angleClearWorkaround.setSelection(config.depthClearFix.ordinal) + binding.spinnerCustomGlVersion.setSelection(config.glVersion.ordinal) + + binding.switchExtCs.isChecked = config.extComputeShader + binding.switchExtDirectStateAccess.isChecked = config.extDirectStateAccess + binding.switchEnableFsr1.isChecked = config.fsr1Enabled + // 开关文案是「禁用 timer_query」,磁盘上 1 表示启用扩展:全 App 只有这一处取反。 + binding.switchExtTimerQuery.isChecked = !config.extTimerQuery + + renderMultidraw(config.multidraw) + + if (includeCacheSize) renderGlslCache(config.glslCache) + } + + /** + * 把缓存大小渲染到滑块上。 + * + * 滑块的取值是「档位」(0..[GLSL_CACHE_SLIDER_STEPS]),MB 由 [mebibytesAtPosition] 换算, + * 所以滑块自身的量程是常量——不会出现「上限低于当前值」这种 Slider 会抛异常的中间状态。 + * + * 数字标签直接来自配置而不是回算档位:档位是离散的,回算会有 ±1MB 的误差, + * 界面上显示的值必须和真正写进配置的值一致。 + */ + private fun renderGlslCache(size: GlslCacheSize) { + val mebibytes = size.mebibytesOrZero + // 配置里可能存着比设备内存 1/5 更大的值(旧配置或手工编辑):把量程抬到它, + // 宁可让滑块变长,也不能悄悄把用户的设置调低。 + glslCacheCeilingMebibytes = maxOf(maxGlslCacheMebibytes, mebibytes) + + binding.sliderGlslCache.value = positionForMebibytes(mebibytes).toFloat() + renderGlslCacheLabel(mebibytes) + binding.textGlslCacheMax.text = + getString(R.string.option_glsl_cache_value, glslCacheCeilingMebibytes) + } + + private fun renderGlslCacheLabel(mebibytes: Int) { + binding.textGlslCacheValue.text = if (mebibytes <= 0) { + getString(R.string.option_glsl_cache_off) + } else { + getString(R.string.option_glsl_cache_value, mebibytes) + } + } + + /** + * 档位 → MiB,幂曲线刻度。 + * + * 档位 0 是「关闭」;1..N 在 1 MiB 和上限之间按 [GLSL_CACHE_SLIDER_CURVE] 次幂分布: + * 靠近 0 的一段一格只差一点点(16/32/64 这些真正要调的档位有足够行程),靠近上限一格跨几十 MiB。 + * + * 弯度就是这一个指数:1 = 线性,越大越向低端倾斜(对数刻度相当于无穷大,低端过于夸张)。 + */ + private fun mebibytesAtPosition(position: Int): Int { + if (position <= 0) return 0 + val ceiling = glslCacheCeilingMebibytes + if (ceiling <= 1) return ceiling + val ratio = (position - 1).toDouble() / (GLSL_CACHE_SLIDER_STEPS - 1) + return (1 + (ceiling - 1) * ratio.pow(GLSL_CACHE_SLIDER_CURVE)) + .roundToInt() + .coerceIn(1, ceiling) + } + + /** [mebibytesAtPosition] 的逆运算。 */ + private fun positionForMebibytes(mebibytes: Int): Int { + if (mebibytes <= 0) return 0 + val ceiling = glslCacheCeilingMebibytes + if (ceiling <= 1) return GLSL_CACHE_SLIDER_STEPS + val ratio = ((mebibytes - 1).toDouble() / (ceiling - 1)).coerceIn(0.0, 1.0) + val position = ratio.pow(1.0 / GLSL_CACHE_SLIDER_CURVE) * (GLSL_CACHE_SLIDER_STEPS - 1) + return (1 + position.roundToInt()).coerceIn(1, GLSL_CACHE_SLIDER_STEPS) + } + + private fun enterOptions() { + if (enterOptionsJob?.isActive == true) return + enterOptionsJob = lifecycleScope.launch { + when (val result = store.load()) { + ConfigLoadResult.Missing -> { + // 先建立 config.json 再显示界面:菜单里的「移除 MobileGlues」是按 + // 「配置文件是否存在」来启用的,顺序反了它会一直是灰的。 + // 文件本来就不存在,写一份默认值不可能覆盖任何已有设置。 + store.flush() + showOptions(MGConfig.Default) + } + + is ConfigLoadResult.Loaded -> showOptions(result.config) + + is ConfigLoadResult.Corrupt -> promptCorruptConfig(result) + } + } + } + + private fun showOptions(config: MGConfig) { + render(config, includeCacheSize = true) + binding.openOptions.visibility = View.GONE + binding.scrollLayout.visibility = View.VISIBLE + invalidateOptionsMenu() + } + + /** + * 回到启动页,并让 store 回到未加载状态。 + * + * 后半句是必须的:用户在外部删掉 MG 目录后再切回本 App,如果 store 还攥着上一次读到的配置, + * 退到后台时的那次保存就会把目录连同配置一起重建出来。 + */ + private fun leaveOptions() { + store.forget() + hideOptions() + } + + private fun hideOptions() { + binding.openOptions.visibility = View.VISIBLE + binding.scrollLayout.visibility = View.GONE + invalidateOptionsMenu() + } + + /** 配置文件解析不了时问用户,绝不静默用默认值覆盖。 */ + private fun promptCorruptConfig(result: ConfigLoadResult.Corrupt) { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.dialog_config_corrupt_title) + .setMessage( + getString( + R.string.dialog_config_corrupt_message, + result.backup?.name ?: Constants.CONFIG_FILE_NAME, + result.cause.message ?: result.cause.javaClass.simpleName, + ) + ) + .setCancelable(false) + .setPositiveButton(R.string.dialog_config_corrupt_reset) { _, _ -> + lifecycleScope.launch { + store.resetToDefaults() + showOptions(MGConfig.Default) + } + } + .setNegativeButton(R.string.dialog_negative) { _, _ -> hideOptions() } + .show() + } + + // ---- 界面初始化 ---- + private fun setupWindowInsets() { val optionLayoutParams = binding.optionLayout.layoutParams as ViewGroup.MarginLayoutParams window.decorView.setOnApplyWindowInsetsListener { _, insets -> @Suppress("DEPRECATION") val bottomInset = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom + // insets 是平台的 android.view.WindowInsets,这里必须用平台的 Type 常量, + // 不能混用 WindowInsetsCompat.Type(数值恰好相同,但类型契约不同)。 + insets.getInsets(WindowInsets.Type.systemBars()).bottom } else { insets.systemWindowInsetBottom } @@ -162,24 +382,294 @@ class MainActivity : AppCompatActivity(), AdapterView.OnItemSelectedListener, } } + /** 选项列表直接由枚举生成,因此不存在选项和取值对不上的可能。 */ private fun setupSpinners() { - fun bindSpinner(spinner: Spinner, arrayRes: Int) { - ArrayAdapter.createFromResource(this, arrayRes, R.layout.spinner).also { adapter -> - adapter.setDropDownViewResource(R.layout.spinner) - spinner.adapter = adapter + bindSpinner(binding.spinnerAngle, AngleConfig.entries) + bindSpinner(binding.spinnerNoError, NoErrorConfig.entries) + bindSpinner(binding.angleClearWorkaround, DepthClearFixMode.entries) + bindSpinner(binding.spinnerCustomGlVersion, GlVersion.entries) + } + + // ---- MultiDraw ---- + + private val multidrawSpinners = LinkedHashMap() + private val multidrawChips = LinkedHashMap() + + /** + * 每个入口点一行、每个后端一枚筹码,全部由枚举生成。 + * + * 和别处的 Spinner 同理:列表就是枚举本身,所以「选项和取值对不上」在结构上不可能发生。 + * 每个入口点可选的后端还各不相同(native 的 `k_md_entries::allowed`),写死在布局里必然会错。 + */ + private fun setupMultidraw() { + MultidrawEntry.entries.forEach { entry -> + val row = layoutInflater.inflate(R.layout.item_multidraw_entry, binding.multidrawEntries, false) + row.findViewById(R.id.text_entry_point).text = entry.glFunction + + val spinner = row.findViewById(R.id.spinner_entry_backend) + spinner.adapter = ArrayAdapter( + this, R.layout.spinner, entry.allowed.map { it.label(this) }, + ).apply { setDropDownViewResource(R.layout.spinner) } + spinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) = + onMultidrawBackendSelected(entry, position) + + override fun onNothingSelected(parent: AdapterView<*>) = Unit + } + + multidrawSpinners[entry] = spinner + binding.multidrawEntries.addView(row) + } + + MultidrawBackend.entries.filter { it != MultidrawBackend.Auto }.forEach { backend -> + val chip = Chip(this).apply { + text = backend.label(this@MainActivity) + isCheckable = true + setOnCheckedChangeListener { _, isChecked -> + onMultidrawBackendDisabledChanged(backend, isChecked) + } + } + multidrawChips[backend] = chip + binding.chipsMultidrawDisabled.addView(chip) + } + + binding.multidrawHeader.setOnClickListener { toggleMultidrawSection() } + } + + private fun toggleMultidrawSection() { + val expanded = binding.multidrawContent.visibility != View.VISIBLE + TransitionManager.beginDelayedTransition(binding.optionLayout, revealTransition) + binding.multidrawContent.visibility = if (expanded) View.VISIBLE else View.GONE + binding.iconMultidrawExpand.animate() + .rotation(if (expanded) 180f else 0f) + .setDuration(REVEAL_DURATION_MS) + .start() + } + + private fun onMultidrawBackendSelected(entry: MultidrawEntry, position: Int) { + val current = store.config.value ?: return + val backend = entry.allowed.getOrNull(position) ?: return + if (backend == current.multidraw.backendOf(entry)) return + store.update { it.copy(multidraw = it.multidraw.with(entry, backend)) } + } + + private fun onMultidrawBackendDisabledChanged(backend: MultidrawBackend, disabled: Boolean) { + val current = store.config.value ?: return + if (disabled == (backend in current.multidraw.disabledBackends)) return + store.update { it.copy(multidraw = it.multidraw.withBackendDisabled(backend, disabled)) } + } + + private fun renderMultidraw(settings: MultidrawSettings) { + multidrawSpinners.forEach { (entry, spinner) -> + val position = entry.allowed.indexOf(settings.backendOf(entry)).coerceAtLeast(0) + spinner.setSelection(position) + } + multidrawChips.forEach { (backend, chip) -> + chip.isChecked = backend in settings.disabledBackends + } + + val customized = settings.customizedCount + binding.textMultidrawSummary.text = if (customized == 0) { + getString(R.string.option_multidraw_summary_auto) + } else { + getString(R.string.option_multidraw_summary_custom, customized) + } + } + + private fun bindSpinner(spinner: Spinner, options: List) { + spinner.adapter = + ArrayAdapter(this, R.layout.spinner, options.map { it.label(this) }).apply { + setDropDownViewResource(R.layout.spinner) + } + } + + private fun attachListeners() { + binding.spinnerAngle.onItemSelectedListener = this + binding.spinnerNoError.onItemSelectedListener = this + binding.spinnerCustomGlVersion.onItemSelectedListener = this + binding.angleClearWorkaround.onItemSelectedListener = this + + binding.switchExtCs.setOnCheckedChangeListener(this) + binding.switchExtTimerQuery.setOnCheckedChangeListener(this) + binding.switchExtDirectStateAccess.setOnCheckedChangeListener(this) + binding.switchEnableFsr1.setOnCheckedChangeListener(this) + + glslCacheCeilingMebibytes = maxGlslCacheMebibytes + binding.sliderGlslCache.valueTo = GLSL_CACHE_SLIDER_STEPS.toFloat() + binding.sliderGlslCache.addOnChangeListener { _, position, fromUser -> + onGlslCacheSliderChanged(position.toInt(), fromUser) + } + binding.buttonClearGlslCache.setOnClickListener { deleteGlslCache() } + } + + // ---- Spinner 回调 ---- + + override fun onItemSelected(adapterView: AdapterView<*>, view: View?, position: Int, id: Long) { + // 配置尚未加载:这是 Spinner 装上适配器时投递的初始化回调,不是用户操作。 + val current = store.config.value ?: return + + when (adapterView.id) { + R.id.spinner_angle -> { + val target = AngleConfig.entries.getOrNull(position) ?: return + if (target == current.angle) return + lifecycleScope.launch { + val approved = target != AngleConfig.ForceEnable || + !isAdreno740() || + confirm(R.string.warning_adreno_740_angle) + if (approved) store.update { it.copy(angle = target) } else revert() + } + } + + R.id.spinner_no_error -> { + val target = NoErrorConfig.entries.getOrNull(position) ?: return + store.update { it.copy(noError = target) } + } + + R.id.spinner_custom_gl_version -> { + val target = GlVersion.entries.getOrNull(position) ?: return + if (target == current.glVersion) return + if (current.glVersion == GlVersion.Default) { + // 只在「从不启用切到某个具体版本」时才需要冷静期。 + confirmThenUpdate( + R.string.warning_enabling_custom_gl_version, + CUSTOM_GL_VERSION_COOLDOWN_SECONDS, + ) { it.copy(glVersion = target) } + } else { + store.update { it.copy(glVersion = target) } + } + } + + R.id.angle_clear_workaround -> { + val target = DepthClearFixMode.entries.getOrNull(position) ?: return + if (target == current.depthClearFix) return + if (target == DepthClearFixMode.Disabled) { + store.update { it.copy(depthClearFix = target) } + } else { + confirmThenUpdate(R.string.warning_enabling_angle_clear_workaround) { + it.copy(depthClearFix = target) + } + } + } + } + } + + override fun onNothingSelected(parent: AdapterView<*>) = Unit + + // ---- Switch 回调 ---- + + override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) { + val current = store.config.value ?: return + + when (buttonView.id) { + R.id.switch_ext_cs -> { + if (isChecked == current.extComputeShader) return + if (isChecked) { + confirmThenUpdate(R.string.warning_ext_cs_enable) { + it.copy(extComputeShader = true) + } + } else { + store.update { it.copy(extComputeShader = false) } + } + } + + R.id.switch_enable_fsr1 -> { + if (isChecked == current.fsr1Enabled) return + if (isChecked) { + confirmThenUpdate(R.string.warning_fsr1_enable) { + it.copy(fsr1 = Fsr1Preset.UltraQuality) + } + } else { + store.update { it.copy(fsr1 = Fsr1Preset.Disabled) } + } + } + + // 开关文案是「禁用 timer_query」,勾上等于磁盘上写 0。 + R.id.switch_ext_timer_query -> store.update { it.copy(extTimerQuery = !isChecked) } + + R.id.switch_ext_direct_state_access -> + store.update { it.copy(extDirectStateAccess = isChecked) } + } + } + + // ---- GLSL 缓存输入框 ---- + + private fun onGlslCacheSliderChanged(position: Int, fromUser: Boolean) { + // 程序化回灌不算用户操作;数字标签由 renderGlslCache 直接按配置值设置。 + if (!fromUser) return + + val mebibytes = mebibytesAtPosition(position) + renderGlslCacheLabel(mebibytes) + // 关掉缓存只是改配置,不动已有的缓存文件——要不要删由用户按那个按钮决定。 + store.update { it.copy(glslCache = GlslCacheSize.ofMebibytes(mebibytes)) } + } + + /** + * 「删除已缓存的着色器」按钮只在「缓存已关闭 **且** 缓存文件确实还在」时出现, + * [cacheBytes] 为 `null` 即表示不该出现。 + * + * 用 [TransitionManager] 在整个选项面板上开一次延迟过渡:按钮淡入的同时, + * ChangeBounds 会把它下面的所有控件平滑地推下去让位,收起时同理。 + */ + private fun renderGlslCacheDeleteButton(cacheBytes: Long?) { + val button = binding.buttonClearGlslCache + cacheBytes?.let { + button.text = getString(R.string.option_glsl_cache_delete, formatCacheSize(it)) + } + + val target = if (cacheBytes != null) View.VISIBLE else View.GONE + if (button.visibility == target) return + + // 面板本身还没显示时不做动画,免得和「进入设置」那一下叠在一起。 + if (binding.scrollLayout.visibility == View.VISIBLE) { + TransitionManager.beginDelayedTransition(binding.optionLayout, revealTransition) + } + button.visibility = target + } + + private val revealTransition: Transition by lazy { + TransitionSet().apply { + ordering = TransitionSet.ORDERING_TOGETHER + addTransition(ChangeBounds()) + addTransition(Fade()) + duration = REVEAL_DURATION_MS + interpolator = PathInterpolator(0.4f, 0f, 0.2f, 1f) + } + } + + /** 与滑块保持同一套单位(MiB / KiB),不用 SI 的 MB。 */ + private fun formatCacheSize(bytes: Long): String { + val mebibytes = bytes / (1024.0 * 1024.0) + return if (mebibytes >= 1.0) { + getString(R.string.option_glsl_cache_size_mib, mebibytes) + } else { + getString(R.string.option_glsl_cache_size_kib, bytes / 1024.0) + } + } + + private fun deleteGlslCache() { + lifecycleScope.launch { + store.clearGlslCache().onFailure { cause -> + snackbar( + getString( + R.string.option_glsl_cache_delete_failed, + cause.message ?: cause.javaClass.simpleName, + ) + ) } } + } - bindSpinner(binding.spinnerAngle, R.array.angle_options) - bindSpinner(binding.spinnerNoError, R.array.no_error_options) - bindSpinner(binding.spinnerMultidrawMode, R.array.multidraw_mode_options) - bindSpinner(binding.angleClearWorkaround, R.array.angle_clear_workaround_options) + // ---- 权限 ---- - binding.spinnerCustomGlVersion.adapter = - ArrayAdapter(this, R.layout.spinner, ArrayList(glVersionMap.keys)) + private fun handlePermissionResult(isGranted: Boolean) { + if (isGranted) { + enterOptions() + } else { + snackbar(getString(R.string.permission_failed)) + leaveOptions() + } } - // ---- 权限检查 ---- private fun hasMgDirectoryAccess(): Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { Environment.isExternalStorageManager() @@ -190,36 +680,23 @@ class MainActivity : AppCompatActivity(), AdapterView.OnItemSelectedListener, private fun hasLegacyPermissions(): Boolean = ContextCompat.checkSelfPermission( this, - Manifest.permission.READ_EXTERNAL_STORAGE + Manifest.permission.READ_EXTERNAL_STORAGE, ) == PackageManager.PERMISSION_GRANTED && ContextCompat.checkSelfPermission( this, - Manifest.permission.WRITE_EXTERNAL_STORAGE + Manifest.permission.WRITE_EXTERNAL_STORAGE, ) == PackageManager.PERMISSION_GRANTED - private fun checkPermissionSilently() { - // 关键逻辑:只有在已经授权且配置文件/MG文件夹存在时,才自动进入设置 - val isConfigured = File(Constants.CONFIG_FILE_PATH).exists() - - if (hasMgDirectoryAccess() && isConfigured) { - (MGConfig.loadConfig(this) ?: MGConfig(this)).save() - showOptions() - } else { - // 否则(未授权,或已授权但未点击启用),停留在启动页并显示 openOptions 按钮 - hideOptions() - } - } - - private fun checkPermission() { + private fun requestPermission() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { showManageAllFilesDialog() + } else if (hasLegacyPermissions()) { + enterOptions() } else { - if (hasLegacyPermissions()) showOptions() - else requestPermissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) + requestPermissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) } } - // ---- 权限相关对话框 ---- @RequiresApi(Build.VERSION_CODES.R) private fun showManageAllFilesDialog() { MaterialAlertDialogBuilder(this) @@ -236,7 +713,7 @@ class MainActivity : AppCompatActivity(), AdapterView.OnItemSelectedListener, manageAllFilesLauncher.launch( Intent( Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, - "package:$packageName".toUri() + "package:$packageName".toUri(), ) ) } catch (_: Exception) { @@ -252,7 +729,7 @@ class MainActivity : AppCompatActivity(), AdapterView.OnItemSelectedListener, appSettingsLauncher.launch( Intent( Settings.ACTION_APPLICATION_DETAILS_SETTINGS, - Uri.fromParts("package", packageName, null) + Uri.fromParts("package", packageName, null), ) ) } @@ -260,304 +737,144 @@ class MainActivity : AppCompatActivity(), AdapterView.OnItemSelectedListener, .show() } - // ---- 选项面板显示/隐藏 ---- - private fun showOptions() { - isSpinnerInitialized = false - setAllListeners(null) - - config = (MGConfig.loadConfig(this) ?: MGConfig(this)).apply { - if (enableANGLE !in 0..3) enableANGLE = 0 - if (enableNoError !in 0..3) enableNoError = 0 - if (maxGlslCacheSize <= 0 && maxGlslCacheSize != -1) maxGlslCacheSize = 32 - - binding.inputMaxGlslCacheSize.setText(maxGlslCacheSize.toString()) - binding.spinnerAngle.setSelection(enableANGLE) - binding.spinnerNoError.setSelection(enableNoError) - binding.spinnerMultidrawMode.setSelection(multidrawMode) - binding.angleClearWorkaround.setSelection(angleDepthClearFixMode) - - binding.switchExtTimerQuery.isChecked = enableExtTimerQuery == 0 - binding.switchExtDirectStateAccess.isChecked = enableExtDirectStateAccess == 1 - binding.switchExtCs.isChecked = enableExtComputeShader == 1 - binding.switchEnableFsr1.isChecked = fsr1Setting == 1 - - binding.spinnerCustomGlVersion.setSelection(getSpinnerIndexByGLVersion(customGLVersion)) - } - - setAllListeners(this) - setupGlslCacheSizeWatcher() - - binding.openOptions.visibility = View.GONE - binding.scrollLayout.visibility = View.VISIBLE + // ---- 确认对话框 ---- - binding.root.post { isSpinnerInitialized = true } - } - - private fun hideOptions() { - binding.openOptions.visibility = View.VISIBLE - binding.scrollLayout.visibility = View.GONE - invalidateOptionsMenu() - } + /** + * 挂起直到用户做出选择。取消、返回键、对话框被系统关掉都算「否」。 + * + * 把对话框写成挂起函数之后,「先问再改」的流程就是一条直线,不再需要把 onConfirm / + * onCancel 两条回调拆开传。 + */ + private suspend fun confirm(@StringRes messageRes: Int, countdownSeconds: Int = 0): Boolean = + suspendCancellableCoroutine { continuation -> + var timer: CountDownTimer? = null + val hasCountdown = countdownSeconds > 0 - private fun setAllListeners(listener: Any?) { - val itemListener = listener as? AdapterView.OnItemSelectedListener - val checkedListener = listener as? CompoundButton.OnCheckedChangeListener - - invalidateOptionsMenu() - - binding.apply { - spinnerAngle.onItemSelectedListener = itemListener - spinnerNoError.onItemSelectedListener = itemListener - spinnerMultidrawMode.onItemSelectedListener = itemListener - spinnerCustomGlVersion.onItemSelectedListener = itemListener - angleClearWorkaround.onItemSelectedListener = itemListener + val dialog = MaterialAlertDialogBuilder(this) + .setTitle(R.string.dialog_title_warning) + .setMessage( + if (hasCountdown) styledMessage(messageRes) else getString(messageRes) + ) + .setCancelable(false) + .setPositiveButton(if (hasCountdown) R.string.ok else R.string.dialog_positive, null) + .setNegativeButton(R.string.dialog_negative, null) + .setOnDismissListener { + timer?.cancel() + if (continuation.isActive) continuation.resume(false) + } + .show() - switchExtCs.setOnCheckedChangeListener(checkedListener) - switchExtTimerQuery.setOnCheckedChangeListener(checkedListener) - switchExtDirectStateAccess.setOnCheckedChangeListener(checkedListener) - switchEnableFsr1.setOnCheckedChangeListener(checkedListener) - } - } + val positive = dialog.getButton(DialogInterface.BUTTON_POSITIVE) + positive.setOnClickListener { + if (continuation.isActive) continuation.resume(true) + dialog.dismiss() + } + dialog.getButton(DialogInterface.BUTTON_NEGATIVE) + .setOnClickListener { dialog.dismiss() } + + if (hasCountdown) { + positive.isEnabled = false + timer = object : CountDownTimer(countdownSeconds * 1000L, 1000L) { + override fun onTick(millisUntilFinished: Long) { + positive.text = getString( + R.string.ok_with_countdown, + (millisUntilFinished / 1000).toInt(), + ) + } - private fun setupGlslCacheSizeWatcher() { - binding.inputMaxGlslCacheSize.addTextChangedListener(object : TextWatcher { - override fun afterTextChanged(s: Editable?) { - val text = s.toString().trim() - if (text.isEmpty()) { - binding.inputMaxGlslCacheSizeLayout.error = null - config?.maxGlslCacheSize = 32 - return - } - text.toIntOrNull()?.let { number -> - if (number < -1 || number == 0) { - binding.inputMaxGlslCacheSizeLayout.error = - getString(R.string.option_glsl_cache_error_range) - } else { - binding.inputMaxGlslCacheSizeLayout.error = null - config?.maxGlslCacheSize = number + override fun onFinish() { + positive.text = getString(R.string.ok) + positive.setTextColor( + MaterialColors.getColor( + positive.context, + AppcompatR.attr.colorError, + Color.RED, + ) + ) + positive.isEnabled = true } - } ?: run { - binding.inputMaxGlslCacheSizeLayout.error = - getString(R.string.option_glsl_cache_error_invalid) - } + }.also { it.start() } } - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} - }) - } - - // ---- Spinner 回调 ---- - override fun onItemSelected(adapterView: AdapterView<*>, view: View?, position: Int, id: Long) { - if (!isSpinnerInitialized || config == null) return - - when (adapterView.id) { - R.id.spinner_angle -> handleAngleSelection(position) - R.id.spinner_no_error -> config?.enableNoError = position - R.id.spinner_multidraw_mode -> config?.multidrawMode = position - R.id.spinner_custom_gl_version -> handleCustomGLVersionSelection(position) - R.id.angle_clear_workaround -> handleAngleClearWorkaroundSelection(position) + continuation.invokeOnCancellation { + timer?.cancel() + dialog.dismiss() + } } - } - - override fun onNothingSelected(parent: AdapterView<*>) {} - private fun handleAngleSelection(position: Int) { - val previous = config?.enableANGLE ?: return - if (position == previous) return - - if (position == 3 && isAdreno740()) { - showWarningDialog( - messageRes = R.string.warning_adreno_740_angle, - onConfirm = { config?.enableANGLE = position }, - onCancel = { revertSpinner(binding.spinnerAngle, previous) } - ) - } else { - config?.enableANGLE = position + private fun confirmThenUpdate( + @StringRes messageRes: Int, + countdownSeconds: Int = 0, + transform: (MGConfig) -> MGConfig, + ) { + lifecycleScope.launch { + if (confirm(messageRes, countdownSeconds)) store.update(transform) else revert() } } - private fun handleAngleClearWorkaroundSelection(position: Int) { - val previous = config?.angleDepthClearFixMode ?: return - if (position == previous) return - - if (position >= 1) { - showWarningDialog( - messageRes = R.string.warning_enabling_angle_clear_workaround, - onConfirm = { config?.angleDepthClearFixMode = position }, - onCancel = { revertSpinner(binding.angleClearWorkaround, previous) } - ) - } else { - config?.angleDepthClearFixMode = position - } + /** 撤销 = 用当前生效的配置重新渲染一次。单向数据流下不需要记住「之前选的是哪一项」。 */ + private fun revert() { + store.config.value?.let { render(it) } } - private fun handleCustomGLVersionSelection(position: Int) { - val previous = config?.customGLVersion ?: return - val newValue = getGLVersionBySpinnerIndex(position) - if (newValue == previous) return - - if (previous == 0) { - showCountdownWarningDialog( - messageRes = R.string.warning_enabling_custom_gl_version, - cooldownSeconds = 41, - onConfirm = { config?.customGLVersion = newValue }, - onCancel = { - revertSpinner( - binding.spinnerCustomGlVersion, - getSpinnerIndexByGLVersion(previous) - ) - } - ) - } else { - config?.customGLVersion = newValue - } + private fun styledMessage(@StringRes id: Int): Spanned { + val errorColorHex = String.format( + "#%06X", + 0xFFFFFF and MaterialColors.getColor(this, AppcompatR.attr.colorError, Color.RED), + ) + return Html.fromHtml( + getString(id).replace("@colorError", errorColorHex), + Html.FROM_HTML_MODE_LEGACY, + ) } - // ---- Switch 回调 ---- - override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) { - if (config == null) return - - when (buttonView.id) { - R.id.switch_ext_cs -> handleSwitchWithWarning( - isChecked = isChecked, - warningMsgRes = R.string.warning_ext_cs_enable, - onConfirm = { config?.enableExtComputeShader = 1 }, - onCancel = { config?.enableExtComputeShader = 0 }, - button = buttonView - ) + // ---- MobileGlues 信息 ---- - R.id.switch_enable_fsr1 -> handleSwitchWithWarning( - isChecked = isChecked, - warningMsgRes = R.string.warning_fsr1_enable, - onConfirm = { config?.fsr1Setting = 1 }, - onCancel = { config?.fsr1Setting = 0 }, - button = buttonView - ) - - R.id.switch_ext_timer_query -> config?.enableExtTimerQuery = if (isChecked) 0 else 1 - R.id.switch_ext_direct_state_access -> config?.enableExtDirectStateAccess = - if (isChecked) 1 else 0 - } - } - - private fun handleSwitchWithWarning( - isChecked: Boolean, - @StringRes warningMsgRes: Int, - onConfirm: () -> Unit, - onCancel: () -> Unit, - button: CompoundButton - ) { - if (isChecked) { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.dialog_title_warning) - .setMessage(warningMsgRes) - .setCancelable(false) - .setOnKeyListener { _, keyCode, _ -> keyCode == KeyEvent.KEYCODE_BACK } - .setPositiveButton(R.string.dialog_positive) { _, _ -> onConfirm() } - .setNegativeButton(R.string.dialog_negative) { _, _ -> - button.isChecked = false - onCancel() - } + private fun showGlInfoDialog() { + lifecycleScope.launch { + val directory = cacheExporter.export().getOrElse { cacheExporter.directory } + // dlopen + 创建 EGL 上下文是重活,别放在主线程上。 + val info = withContext(Dispatchers.Default) { MGInfoGetter.info(directory) } + MaterialAlertDialogBuilder(this@MainActivity) + .setTitle(R.string.dialog_mg_gl_info_title) + .setMessage(info) + .setNegativeButton(R.string.dismiss, null) .show() - } else { - onCancel() + .findViewById(android.R.id.message) + ?.setTextIsSelectable(true) } } - // ---- 对话框辅助 ---- - private fun showWarningDialog( - @StringRes messageRes: Int, - onConfirm: () -> Unit, - onCancel: () -> Unit - ) { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.dialog_title_warning) - .setMessage(getString(messageRes)) - .setCancelable(false) - .setPositiveButton(R.string.dialog_positive) { _, _ -> onConfirm() } - .setNegativeButton(R.string.dialog_negative) { _, _ -> onCancel() } - .show() - } - - private fun showCountdownWarningDialog( - @StringRes messageRes: Int, - cooldownSeconds: Int, - onConfirm: () -> Unit, - onCancel: () -> Unit - ) { - val dialog = MaterialAlertDialogBuilder(this) - .setTitle(R.string.dialog_title_warning) - .setMessage(getStyledMessage(messageRes)) - .setCancelable(false) - .setPositiveButton(R.string.ok, null) - .setNegativeButton(R.string.dialog_negative) { _, _ -> onCancel() } - .show() - - val positiveButton = dialog.getButton(DialogInterface.BUTTON_POSITIVE) - positiveButton.isEnabled = false - positiveButton.setOnClickListener { onConfirm(); dialog.dismiss() } + // ---- 移除 MobileGlues ---- - object : CountDownTimer(cooldownSeconds * 1000L, 1000) { - override fun onTick(millisUntilFinished: Long) { - positiveButton.text = - getString(R.string.ok_with_countdown, (millisUntilFinished / 1000).toInt()) - } - - override fun onFinish() { - positiveButton.apply { - text = getString(R.string.ok) - setTextColor( - MaterialColors.getColor( - context, - AppcompatR.attr.colorError, - Color.RED - ) - ) - isEnabled = true - } - } - }.start() - } - - // ---- 删除 MobileGlues ---- - private fun showRemoveConfirmationDialog() { - showCountdownWarningDialog( - R.string.remove_mg_files_message, - 10, - { removeMobileGluesCompletely() }, - {}) + private fun confirmRemoval() { + lifecycleScope.launch { + if (!confirm(R.string.remove_mg_files_message, REMOVE_COOLDOWN_SECONDS)) return@launch + removeMobileGluesCompletely() + } } - private fun removeMobileGluesCompletely() { - val view = LayoutInflater.from(this).inflate(R.layout.progress_dialog_md3, null) - + private suspend fun removeMobileGluesCompletely() { val progressDialog = MaterialAlertDialogBuilder(this) .setTitle(R.string.removing_mobileglues) - .setView(view) + .setView(LayoutInflater.from(this).inflate(R.layout.progress_dialog_md3, null)) .setCancelable(false) .show() - lifecycleScope.launch(Dispatchers.IO) { - try { - File(Constants.MG_DIRECTORY).deleteRecursively() + val result = withContext(Dispatchers.IO) { + runCatching { File(Constants.MG_DIRECTORY).deleteRecursively() } + } + progressDialog.dismiss() - withContext(Dispatchers.Main) { - config = null - hideOptions() - progressDialog.dismiss() - showFinalDialog() - } - } catch (e: Exception) { - withContext(Dispatchers.Main) { - progressDialog.dismiss() - toast(getString(R.string.remove_failed, e.message)) - } + result + .onSuccess { + leaveOptions() + showRemovalCompleteDialog() } - } + .onFailure { toast(getString(R.string.remove_failed, it.message)) } } - private fun showFinalDialog() { + private fun showRemovalCompleteDialog() { MaterialAlertDialogBuilder(this) .setTitle(R.string.remove_complete_title) .setMessage(R.string.remove_complete_message) @@ -567,15 +884,21 @@ class MainActivity : AppCompatActivity(), AdapterView.OnItemSelectedListener, } // ---- GPU 检测 ---- - private fun getGPUName(): String? { + + private suspend fun isAdreno740(): Boolean { + cachedIsAdreno740?.let { return it } + val name = withContext(Dispatchers.Default) { queryGpuName() } + val result = name != null && + name.contains("adreno", ignoreCase = true) && + name.contains("740") + cachedIsAdreno740 = result + return result + } + + private fun queryGpuName(): String? { val eglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY) - if (eglDisplay == EGL14.EGL_NO_DISPLAY || !EGL14.eglInitialize( - eglDisplay, - IntArray(2), - 0, - IntArray(2), - 1 - ) + if (eglDisplay == EGL14.EGL_NO_DISPLAY || + !EGL14.eglInitialize(eglDisplay, IntArray(2), 0, IntArray(2), 1) ) return null var renderer: String? = null @@ -585,23 +908,12 @@ class MainActivity : AppCompatActivity(), AdapterView.OnItemSelectedListener, val numConfigs = IntArray(1) if (EGL14.eglChooseConfig( - eglDisplay, - configAttributes, - 0, - eglConfigs, - 0, - 1, - numConfigs, - 0 + eglDisplay, configAttributes, 0, eglConfigs, 0, 1, numConfigs, 0 ) ) { val contextAttributes = intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE) val eglContext = EGL14.eglCreateContext( - eglDisplay, - eglConfigs[0], - EGL14.EGL_NO_CONTEXT, - contextAttributes, - 0 + eglDisplay, eglConfigs[0], EGL14.EGL_NO_CONTEXT, contextAttributes, 0 ) if (eglContext != EGL14.EGL_NO_CONTEXT) { @@ -622,48 +934,30 @@ class MainActivity : AppCompatActivity(), AdapterView.OnItemSelectedListener, return renderer } - private fun isAdreno740(): Boolean { - return getGPUName()?.let { - it.contains( - "adreno", - ignoreCase = true - ) && it.contains("740") - } == true - } + // ---- Snackbar ---- - // ---- GL Version Spinner 辅助 ---- - private fun getGLVersionBySpinnerIndex(index: Int): Int { - val selected = - binding.spinnerCustomGlVersion.getItemAtPosition(index) as? String ?: return 0 - return glVersionMap[selected] ?: 0 + fun snackbar(text: CharSequence, duration: Int = Snackbar.LENGTH_SHORT) { + Snackbar.make(binding.root, text, duration).show() } - private fun getSpinnerIndexByGLVersion(glVersion: Int): Int { - val targetDisplay = glVersionMap.entries.firstOrNull { it.value == glVersion }?.key - ?: getString(R.string.option_angle_disable) - return glVersionMap.keys.indexOf(targetDisplay).coerceAtLeast(0) - } + private companion object { + const val CUSTOM_GL_VERSION_COOLDOWN_SECONDS = 41 + const val REMOVE_COOLDOWN_SECONDS = 10 - private fun revertSpinner(spinner: Spinner, position: Int) { - isSpinnerInitialized = false - spinner.setSelection(position) - spinner.post { isSpinnerInitialized = true } - } + /** 滑块上限取总内存的几分之一。 */ + const val GLSL_CACHE_RAM_DIVISOR = 16L - // ---- 样式化消息 ---- - private fun getStyledMessage(@StringRes id: Int): Spanned { - val errorColorHex = String.format( - "#%06X", - 0xFFFFFF and MaterialColors.getColor(this, AppcompatR.attr.colorError, Color.RED) - ) - return Html.fromHtml( - getString(id).replace("@colorError", errorColorHex), - Html.FROM_HTML_MODE_LEGACY - ) - } + /** 内存特别小的设备上也要留出可用的调节范围。 */ + const val MIN_GLSL_CACHE_UPPER_BOUND_MIB = 64L + const val MAX_GLSL_CACHE_UPPER_BOUND_MIB = 8192L - // ---- Snackbar ---- - fun snackbar(text: CharSequence, duration: Int = Snackbar.LENGTH_SHORT) { - Snackbar.make(binding.root, text, duration).show() + /** 滑块的档位数(不含 0 号「关闭」档)。 */ + const val GLSL_CACHE_SLIDER_STEPS = 200 + + /** 滑块刻度的弯度:1 = 线性,越大越向低端倾斜。 */ + const val GLSL_CACHE_SLIDER_CURVE = 2.0 + + /** 展开 / 收起的动画时长。 */ + const val REVEAL_DURATION_MS = 240L } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/settings/MGCacheExporter.kt b/app/src/main/java/com/fcl/plugin/mobileglues/settings/MGCacheExporter.kt new file mode 100644 index 00000000..1ef6e678 --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/settings/MGCacheExporter.kt @@ -0,0 +1,26 @@ +package com.fcl.plugin.mobileglues.settings + +import android.content.Context +import com.fcl.plugin.mobileglues.utils.Constants +import java.io.File + +/** + * 把当前配置导出到应用私有缓存目录,供 MGInfoGetter 通过 `MG_DIR_PATH` 读取。 + * + * 取代了原先挂在 MGConfig 伴生对象上的两个可变静态字段(`cacheConfigPath` / `cacheMGDir`): + * 那两个字段默认是 `File("")`,能工作只是因为调用方恰好先调了一次导出。 + */ +class MGCacheExporter(context: Context, private val store: MGConfigStore) { + + private val appContext = context.applicationContext + + val directory: File + get() = File(appContext.externalCacheDir ?: appContext.cacheDir, DIRECTORY_NAME) + + suspend fun export(): Result = + store.exportTo(File(directory, Constants.CONFIG_FILE_NAME)).map { directory } + + private companion object { + const val DIRECTORY_NAME = "MG" + } +} diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/settings/MGConfig.kt b/app/src/main/java/com/fcl/plugin/mobileglues/settings/MGConfig.kt index e253219e..5b4cd37f 100644 --- a/app/src/main/java/com/fcl/plugin/mobileglues/settings/MGConfig.kt +++ b/app/src/main/java/com/fcl/plugin/mobileglues/settings/MGConfig.kt @@ -1,179 +1,308 @@ package com.fcl.plugin.mobileglues.settings import android.content.Context -import com.fcl.plugin.mobileglues.utils.Constants -import com.google.gson.Gson -import com.google.gson.JsonObject -import com.google.gson.JsonParser -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import java.io.File +import androidx.annotation.StringRes +import com.fcl.plugin.mobileglues.R /** - * MobileGlues 配置类。 + * 一个会被原样写进 `MG/config.json` 的取值。 + * + * [wire] 是磁盘上的整数,必须与 MobileGlues native 端 `config/settings.h` 中对应的枚举一致, + * 并且永远不能修改——它已经存在于用户既有的配置文件里。 */ -class MGConfig private constructor(val context: Context, private var isInitializing: Boolean) { +interface WireValue { + val wire: Int +} - // 默认构造函数,供正常实例化使用 - constructor(context: Context) : this(context, false) +/** + * 会出现在 Spinner 里的取值。 + * + * 选项列表直接由枚举生成(见 MainActivity.setupSpinners),因此不存在 + * 「选项数量和取值范围对不上」的可能——新增一档只需要在枚举里加一行。 + * 也正因为如此,枚举的 `ordinal` 就是它在 Spinner 中的位置。 + */ +interface SpinnerOption : WireValue { + fun label(context: Context): CharSequence +} - // ---- 配置字段(UI 触发变更后自动保存) ---- +/** 磁盘上的整数 → 枚举。缺失或越界时回落到 [fallback],绝不抛异常。 */ +internal fun List.fromWire(wire: Int?, fallback: T): T = + firstOrNull { it.wire == wire } ?: fallback - var enableANGLE: Int = 1 - set(value) { - if (field != value) { - field = value; saveIfReady() - } - } +/** `enableANGLE` */ +enum class AngleConfig(override val wire: Int, @param:StringRes private val labelRes: Int) : + SpinnerOption { + DisableIfPossible(0, R.string.option_angle_disable_if_possible), + EnableIfPossible(1, R.string.option_angle_enable_if_possible), + ForceDisable(2, R.string.option_angle_disable), + ForceEnable(3, R.string.option_angle_enable); - var enableNoError: Int = 0 - set(value) { - if (field != value) { - field = value; saveIfReady() - } - } + override fun label(context: Context): CharSequence = context.getString(labelRes) +} - var enableExtTimerQuery: Int = 1 - set(value) { - if (field != value) { - field = value; saveIfReady() - } - } +/** `enableNoError` */ +enum class NoErrorConfig(override val wire: Int, @param:StringRes private val labelRes: Int) : + SpinnerOption { + Auto(0, R.string.option_no_error_auto), + DoNotIgnore(1, R.string.option_no_error_enable), + IgnoreShaderProgram(2, R.string.option_no_error_disable_pri), + IgnoreShaderProgramFramebuffer(3, R.string.option_no_error_disable_sec); - var enableExtComputeShader: Int = 0 - set(value) { - if (field != value) { - field = value; saveIfReady() - } - } + override fun label(context: Context): CharSequence = context.getString(labelRes) +} - var enableExtDirectStateAccess: Int = 0 - set(value) { - if (field != value) { - field = value; saveIfReady() - } - } +/** + * MultiDraw 的一种实现方式。 + * + * 写进 `config.json` 的是 [key] 这个**名字**而不是序号——native 特意这么改的: + * 序号一旦新增或调整,就会把用户已经写好的取值指到别的实现上。 + */ +enum class MultidrawBackend(val key: String, @param:StringRes private val labelRes: Int) { + Auto("auto", R.string.md_backend_auto), + Unroll("unroll", R.string.md_backend_unroll), + BaseVertex("basevertex", R.string.md_backend_basevertex), + Indirect("indirect", R.string.md_backend_indirect), + MultiIndirect("multiindirect", R.string.md_backend_multiindirect), + Native("native", R.string.md_backend_native), + NativeExt("nativeext", R.string.md_backend_nativeext), + Compute("compute", R.string.md_backend_compute); - var maxGlslCacheSize: Int = 32 - set(value) { - if (field != value) { - field = value - if (value == -1) clearCacheFile() - saveIfReady() - } - } + fun label(context: Context): CharSequence = context.getString(labelRes) - var multidrawMode: Int = 0 - set(value) { - if (field != value) { - field = value; saveIfReady() - } + companion object { + /** 与 native 的 `md_parse_backend` 一致:忽略大小写,以及空格 / 下划线 / 连字符。 */ + fun parse(raw: String?): MultidrawBackend? { + val normalized = raw + ?.filterNot { it == ' ' || it == '\t' || it == '_' || it == '-' } + ?.lowercase() + ?.takeIf { it.isNotEmpty() } + ?: return null + return entries.firstOrNull { it.key == normalized } } + } +} - var angleDepthClearFixMode: Int = 0 - set(value) { - if (field != value) { - field = value; saveIfReady() - } - } +/** + * 有多种实现可选的 MultiDraw 入口点。 + * + * [allowed] 抄自 native 的 `k_md_entries`:某个后端对某个入口点来说不是一种「不同的实现」时 + * (例如 glMultiDrawElements 没有 base vertex),native 会拒绝并回退到 auto, + * 所以界面上也不该把它列出来。 + */ +enum class MultidrawEntry( + val key: String, + val glFunction: String, + val allowed: List, +) { + Arrays( + "multidrawModeArrays", "glMultiDrawArrays", + listOf( + MultidrawBackend.Auto, MultidrawBackend.NativeExt, + MultidrawBackend.MultiIndirect, MultidrawBackend.Unroll, + ), + ), + Elements( + "multidrawModeElements", "glMultiDrawElements", + listOf( + MultidrawBackend.Auto, MultidrawBackend.MultiIndirect, MultidrawBackend.NativeExt, + MultidrawBackend.Native, MultidrawBackend.Indirect, MultidrawBackend.Unroll, + ), + ), + ElementsBaseVertex( + "multidrawModeElementsBaseVertex", "glMultiDrawElementsBaseVertex", + listOf( + MultidrawBackend.Auto, MultidrawBackend.MultiIndirect, MultidrawBackend.Native, + MultidrawBackend.Indirect, MultidrawBackend.BaseVertex, MultidrawBackend.Compute, + MultidrawBackend.Unroll, + ), + ), + ArraysIndirect( + "multidrawModeArraysIndirect", "glMultiDrawArraysIndirect", + listOf( + MultidrawBackend.Auto, MultidrawBackend.MultiIndirect, MultidrawBackend.Indirect, + ), + ), + ElementsIndirect( + "multidrawModeElementsIndirect", "glMultiDrawElementsIndirect", + listOf( + MultidrawBackend.Auto, MultidrawBackend.MultiIndirect, MultidrawBackend.Indirect, + ), + ), +} - var customGLVersion: Int = 0 - set(value) { - if (field != value) { - field = value; saveIfReady() - } - } +/** + * MultiDraw 的全部设置。 + * + * [backends] 只记录「不是 auto」的入口点:auto 等同于不写这个键,两种表示法必须只有一种, + * 否则两个内容相同的配置会判定为不相等,去抖保存就会被无谓地触发。 + */ +data class MultidrawSettings( + val backends: Map = emptyMap(), + val disabledBackends: Set = emptySet(), +) { + fun backendOf(entry: MultidrawEntry): MultidrawBackend = + backends[entry] ?: MultidrawBackend.Auto - var fsr1Setting: Int = 0 - set(value) { - if (field != value) { - field = value; saveIfReady() - } - } + fun with(entry: MultidrawEntry, backend: MultidrawBackend): MultidrawSettings = copy( + backends = if (backend == MultidrawBackend.Auto) { + backends - entry + } else { + backends + (entry to backend) + }, + ) - // ---- 对外操作 ---- + fun withBackendDisabled(backend: MultidrawBackend, disabled: Boolean): MultidrawSettings = copy( + disabledBackends = if (disabled) { + disabledBackends + backend + } else { + disabledBackends - backend + }, + ) - private fun saveIfReady() { - if (!isInitializing) save() - } + /** 偏离默认值的项数,用来在折叠状态下给出一句话摘要。 */ + val customizedCount: Int get() = backends.size + disabledBackends.size - fun save() { - runCatching { - val configFile = File(Constants.CONFIG_FILE_PATH) - configFile.parentFile?.mkdirs() - configFile.writeText(Gson().toJson(buildConfigMap())) - } + companion object { + val Default = MultidrawSettings() } +} - fun saveToCachePath() { - if (cacheConfigPath == null) { - val cacheDir = context.externalCacheDir ?: context.cacheDir - cacheMGDir = File(cacheDir, "MG").apply { mkdirs() } - cacheConfigPath = File(cacheMGDir, "config.json").absolutePath - } - runCatching { - File(cacheConfigPath!!).writeText(Gson().toJson(buildConfigMap())) +/** + * `angleDepthClearFixMode`。 + * + * native 的 `AngleDepthClearFixMode` 还有一个 `Mode2 = 2`,但那一档不对外开放, + * 本 App 不提供、也不接受它。 + */ +enum class DepthClearFixMode(override val wire: Int, @param:StringRes private val labelRes: Int) : + SpinnerOption { + Disabled(0, R.string.option_angle_clear_workaround_disable), + Mode1(1, R.string.option_angle_clear_workaround_enable_1); + + override fun label(context: Context): CharSequence = context.getString(labelRes) +} + +/** + * `customGLVersion`。 + * + * 只列出 native 认可的档位:`config/settings.cpp` 会把 34..39、31 及以下等中间值夹到这些档位上, + * 所以让用户选一个 native 会二次修改的值没有意义。 + */ +enum class GlVersion(override val wire: Int, private val literal: String?) : SpinnerOption { + Default(0, null), + Gl46(46, "OpenGL 4.6"), + Gl45(45, "OpenGL 4.5"), + Gl44(44, "OpenGL 4.4"), + Gl43(43, "OpenGL 4.3"), + Gl42(42, "OpenGL 4.2"), + Gl41(41, "OpenGL 4.1"), + Gl40(40, "OpenGL 4.0"), + Gl33(33, "OpenGL 3.3"), + Gl32(32, "OpenGL 3.2"); + + override fun label(context: Context): CharSequence = + literal ?: context.getString(R.string.option_custom_gl_version_default) + + companion object { + /** + * 这一项不能像别的枚举那样「不认识就回落到默认值」。 + * + * native 对 customGLVersion 是**夹取**而不是重置(settings.cpp):47 当 4.6 用,38 当 3.3 用, + * 31 当 3.2 用。若把这些值统统读成 0,本 App 下一次保存就会把它们写成 0, + * 而 0 在 native 那边等于 DEFAULT_GL_VERSION(40)——用户没动过任何开关,目标版本却被改了。 + * 所以这里照抄 native 的夹取规则,保证两边对同一个文件的理解永远一致。 + */ + fun fromWire(wire: Int?): GlVersion { + if (wire == null) return Default + entries.firstOrNull { it.wire == wire }?.let { return it } + return when { + wire > 46 -> Gl46 + wire in 34..39 -> Gl33 + wire in 1..31 -> Gl32 + else -> Default + } } } +} + +/** + * `fsr1Setting`。 + * + * native 端是 5 档画质预设,界面上目前只提供一个开关;把完整取值建模出来是为了让 + * 「配置里已经是 Balanced」这种情况能被正确识别并原样保留,而不是被开关改写成 1。 + */ +enum class Fsr1Preset(override val wire: Int) : WireValue { + Disabled(0), + UltraQuality(1), + Quality(2), + Balanced(3), + Performance(4), +} + +/** `maxGlslCacheSize`。把「关闭」这个用负数表达的状态显式建模出来。 */ +sealed interface GlslCacheSize { + + val wire: Int - // ---- 私有辅助 ---- + /** + * 不使用 GLSL 缓存。 + * + * native 的判据是 `maxGlslCacheSize > 0`,任何小于等于 0 的值都等于关闭; + * 写回磁盘时统一用 -1,与历史配置保持一致。 + */ + data object Disabled : GlslCacheSize { + override val wire: Int get() = -1 + } - private fun clearCacheFile() { - CoroutineScope(Dispatchers.IO).launch { - runCatching { File(Constants.GLSL_CACHE_FILE_PATH).delete() } + /** 以 MiB 为单位的上限,必须为正数。 */ + data class Limited(val mebibytes: Int) : GlslCacheSize { + init { + require(mebibytes > 0) { "GLSL cache size must be positive, was $mebibytes" } } + + override val wire: Int get() = mebibytes } - private fun buildConfigMap(): Map = mapOf( - "enableANGLE" to enableANGLE, - "enableNoError" to enableNoError, - "enableExtTimerQuery" to enableExtTimerQuery, - "enableExtComputeShader" to enableExtComputeShader, - "enableExtDirectStateAccess" to enableExtDirectStateAccess, - "maxGlslCacheSize" to maxGlslCacheSize, - "multidrawMode" to multidrawMode, - "angleDepthClearFixMode" to angleDepthClearFixMode, - "customGLVersion" to customGLVersion, - "fsr1Setting" to fsr1Setting - ) + /** 滑块上的位置:关闭对应 0。 */ + val mebibytesOrZero: Int get() = (this as? Limited)?.mebibytes ?: 0 companion object { - var cacheConfigPath: String? = null - var cacheMGDir: File = File("") + val Default: GlslCacheSize = Limited(32) - /** - * 从磁盘加载配置,文件不存在或解析失败时返回 null。 - */ - fun loadConfig(context: Context): MGConfig? { - val configFile = File(Constants.CONFIG_FILE_PATH) - if (!configFile.exists()) return null - - val configStr = runCatching { configFile.readText() }.getOrNull() ?: return null - - return runCatching { - val obj: JsonObject = JsonParser.parseString(configStr).asJsonObject - // 开启 isInitializing 拦截,防止在读取 JSON 赋值时触发大量冗余的 save() 磁盘 I/O - val config = MGConfig(context, isInitializing = true) - config.applyFromJson(obj) - config.isInitializing = false - config - }.getOrNull() - } + /** 滑块回来的 MiB 数 → 配置值,0 表示关闭。 */ + fun ofMebibytes(mebibytes: Int): GlslCacheSize = + if (mebibytes > 0) Limited(mebibytes) else Disabled - private fun MGConfig.applyFromJson(obj: JsonObject) { - fun JsonObject.int(key: String, default: Int) = get(key)?.asInt ?: default - - enableANGLE = obj.int("enableANGLE", 1) - enableNoError = obj.int("enableNoError", 0) - enableExtTimerQuery = obj.int("enableExtTimerQuery", 1) - enableExtComputeShader = obj.int("enableExtComputeShader", 0) - enableExtDirectStateAccess = obj.int("enableExtDirectStateAccess", 0) - maxGlslCacheSize = obj.int("maxGlslCacheSize", 32) - multidrawMode = obj.int("multidrawMode", 0) - angleDepthClearFixMode = obj.int("angleDepthClearFixMode", 0) - customGLVersion = obj.int("customGLVersion", 0) - fsr1Setting = obj.int("fsr1Setting", 0) + fun fromWire(wire: Int?): GlslCacheSize = when { + wire == null -> Default + wire > 0 -> Limited(wire) + // native 把 0 也当成关闭;旧版界面却把它显示成 32MiB,两边对同一个文件的理解是不一致的。 + else -> Disabled } } -} \ No newline at end of file +} + +/** + * MobileGlues 的配置,一个不可变的值。 + * + * 它不知道文件、不知道 UI、也没有任何副作用:改配置就是 [copy],落盘由 [MGConfigStore] 负责。 + * 这里的默认值是全 App 唯一的一份——[MGConfigCodec] 解析时的回落值也取自这里。 + */ +data class MGConfig( + val angle: AngleConfig = AngleConfig.EnableIfPossible, + val noError: NoErrorConfig = NoErrorConfig.Auto, + val multidraw: MultidrawSettings = MultidrawSettings.Default, + val depthClearFix: DepthClearFixMode = DepthClearFixMode.Disabled, + val glVersion: GlVersion = GlVersion.Default, + val glslCache: GlslCacheSize = GlslCacheSize.Default, + val extComputeShader: Boolean = false, + /** 磁盘上 1 表示「启用 timer_query 扩展」;界面上的开关文案是「禁用」,取反只发生在渲染那一处。 */ + val extTimerQuery: Boolean = true, + val extDirectStateAccess: Boolean = false, + val fsr1: Fsr1Preset = Fsr1Preset.Disabled, +) { + val fsr1Enabled: Boolean get() = fsr1 != Fsr1Preset.Disabled + + companion object { + val Default = MGConfig() + } +} diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/settings/MGConfigCodec.kt b/app/src/main/java/com/fcl/plugin/mobileglues/settings/MGConfigCodec.kt new file mode 100644 index 00000000..b183c3a9 --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/settings/MGConfigCodec.kt @@ -0,0 +1,143 @@ +package com.fcl.plugin.mobileglues.settings + +import com.google.gson.JsonObject + +/** + * `MG/config.json` 的读写格式。 + * + * 这里是键名、以及「磁盘上的值不合法时用什么」的唯一定义处;除此之外没有第二个地方 + * 知道 config.json 长什么样。 + * + * 两条约定: + * - [decode] 永不抛异常。单个字段坏掉(缺失、越界、类型不对)只会让该字段回落到默认值, + * 不会连累整份配置——只有 JSON 本身语法错误才算文件损坏,那由 [MGConfigStore] 处理。 + * - [encode] 会带上 [foreignKeysOf] 取出的未知键。native 端读的键比 App 认识的多 + * (例如 `hideMGEnvLevel`),不把它们写回去就等于每次保存都在删别人的设置。 + */ +internal object MGConfigCodec { + + private const val KEY_ANGLE = "enableANGLE" + private const val KEY_NO_ERROR = "enableNoError" + private const val KEY_EXT_TIMER_QUERY = "enableExtTimerQuery" + private const val KEY_EXT_COMPUTE_SHADER = "enableExtComputeShader" + private const val KEY_EXT_DIRECT_STATE_ACCESS = "enableExtDirectStateAccess" + private const val KEY_GLSL_CACHE = "maxGlslCacheSize" + /** + * 已废弃:native 不再读取它,只会在它还存在时打一条弃用警告。 + * 保留在 [KNOWN_KEYS] 里是为了让下一次保存把它清掉,而不是当成未知键永久留着。 + */ + private const val KEY_MULTIDRAW_LEGACY = "multidrawMode" + + private const val KEY_MULTIDRAW_DISABLE = "multidrawDisableBackends" + private const val KEY_DEPTH_CLEAR_FIX = "angleDepthClearFixMode" + private const val KEY_GL_VERSION = "customGLVersion" + private const val KEY_FSR1 = "fsr1Setting" + + private val KNOWN_KEYS = listOf( + KEY_ANGLE, + KEY_NO_ERROR, + KEY_EXT_TIMER_QUERY, + KEY_EXT_COMPUTE_SHADER, + KEY_EXT_DIRECT_STATE_ACCESS, + KEY_GLSL_CACHE, + KEY_MULTIDRAW_LEGACY, + KEY_MULTIDRAW_DISABLE, + KEY_DEPTH_CLEAR_FIX, + KEY_GL_VERSION, + KEY_FSR1, + ) + MultidrawEntry.entries.map { it.key } + + fun decode(root: JsonObject): MGConfig { + val defaults = MGConfig.Default + return MGConfig( + angle = AngleConfig.entries.fromWire(root.intOrNull(KEY_ANGLE), defaults.angle), + noError = NoErrorConfig.entries.fromWire(root.intOrNull(KEY_NO_ERROR), defaults.noError), + multidraw = decodeMultidraw(root), + depthClearFix = DepthClearFixMode.entries + .fromWire(root.intOrNull(KEY_DEPTH_CLEAR_FIX), defaults.depthClearFix), + glVersion = GlVersion.fromWire(root.intOrNull(KEY_GL_VERSION)), + glslCache = GlslCacheSize.fromWire(root.intOrNull(KEY_GLSL_CACHE)), + extComputeShader = root.boolOrNull(KEY_EXT_COMPUTE_SHADER) + ?: defaults.extComputeShader, + extTimerQuery = root.boolOrNull(KEY_EXT_TIMER_QUERY) ?: defaults.extTimerQuery, + extDirectStateAccess = root.boolOrNull(KEY_EXT_DIRECT_STATE_ACCESS) + ?: defaults.extDirectStateAccess, + fsr1 = Fsr1Preset.entries.fromWire(root.intOrNull(KEY_FSR1), defaults.fsr1), + ) + } + + fun encode(config: MGConfig, foreignKeys: JsonObject?): JsonObject = + (foreignKeys?.deepCopy() ?: JsonObject()).apply { + addProperty(KEY_ANGLE, config.angle.wire) + addProperty(KEY_NO_ERROR, config.noError.wire) + addProperty(KEY_EXT_TIMER_QUERY, config.extTimerQuery.wire) + addProperty(KEY_EXT_COMPUTE_SHADER, config.extComputeShader.wire) + addProperty(KEY_EXT_DIRECT_STATE_ACCESS, config.extDirectStateAccess.wire) + addProperty(KEY_GLSL_CACHE, config.glslCache.wire) + addProperty(KEY_DEPTH_CLEAR_FIX, config.depthClearFix.wire) + addProperty(KEY_GL_VERSION, config.glVersion.wire) + addProperty(KEY_FSR1, config.fsr1.wire) + encodeMultidraw(config.multidraw) + } + + private fun decodeMultidraw(root: JsonObject): MultidrawSettings = MultidrawSettings( + backends = MultidrawEntry.entries.mapNotNull { entry -> + // 与 native 一致:名字不认识、或者对这个入口点来说不是一种「不同的实现」,都当成 auto。 + MultidrawBackend.parse(root.stringOrNull(entry.key)) + ?.takeIf { it != MultidrawBackend.Auto && it in entry.allowed } + ?.let { entry to it } + }.toMap(), + disabledBackends = root.stringOrNull(KEY_MULTIDRAW_DISABLE) + .orEmpty() + .split(',', ';') + .mapNotNull { MultidrawBackend.parse(it) } + .filterTo(mutableSetOf()) { it != MultidrawBackend.Auto }, + ) + + private fun JsonObject.encodeMultidraw(settings: MultidrawSettings) { + MultidrawEntry.entries.forEach { entry -> + when (val backend = settings.backendOf(entry)) { + // auto 就是「不写这个键」,免得在配置里堆一串没有意义的 "auto"。 + MultidrawBackend.Auto -> remove(entry.key) + else -> addProperty(entry.key, backend.key) + } + } + + if (settings.disabledBackends.isEmpty()) { + remove(KEY_MULTIDRAW_DISABLE) + } else { + addProperty( + KEY_MULTIDRAW_DISABLE, + settings.disabledBackends.sortedBy { it.ordinal }.joinToString(",") { it.key }, + ) + } + + // native 已经不读它了,留着只会让它每次启动都打一条弃用警告。 + remove(KEY_MULTIDRAW_LEGACY) + } + + /** 取出配置文件里本 App 不认识的键,保存时原样写回。 */ + fun foreignKeysOf(root: JsonObject): JsonObject = + root.deepCopy().apply { KNOWN_KEYS.forEach { remove(it) } } + + /** native 端一律用 `> 0` 判断布尔开关,这里保持一致。 */ + private val Boolean.wire: Int get() = if (this) 1 else 0 + + private fun JsonObject.intOrNull(key: String): Int? { + val element = get(key) ?: return null + if (!element.isJsonPrimitive) return null + val primitive = element.asJsonPrimitive + return runCatching { + // 容忍被手工改成字符串的数字("32"):读进来之后下一次保存会写回真正的整数。 + if (primitive.isNumber) primitive.asInt else primitive.asString.trim().toInt() + }.getOrNull() + } + + private fun JsonObject.boolOrNull(key: String): Boolean? = intOrNull(key)?.let { it > 0 } + + private fun JsonObject.stringOrNull(key: String): String? { + val element = get(key) ?: return null + if (!element.isJsonPrimitive) return null + return runCatching { element.asString }.getOrNull() + } +} diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/settings/MGConfigStore.kt b/app/src/main/java/com/fcl/plugin/mobileglues/settings/MGConfigStore.kt new file mode 100644 index 00000000..f82af653 --- /dev/null +++ b/app/src/main/java/com/fcl/plugin/mobileglues/settings/MGConfigStore.kt @@ -0,0 +1,276 @@ +package com.fcl.plugin.mobileglues.settings + +import com.google.gson.Gson +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream +import java.io.IOException + +/** [MGConfigStore.load] 的结果。加载失败不等于「用默认值覆盖」,由调用方决定怎么办。 */ +sealed interface ConfigLoadResult { + + /** 配置文件不存在。此时 [MGConfigStore.config] 会变成默认值,但磁盘上什么都没写。 */ + data object Missing : ConfigLoadResult + + data class Loaded(val config: MGConfig) : ConfigLoadResult + + /** JSON 本身无法解析。原文件已尽量备份到 [backup],配置保持「未加载」直到用户决定。 */ + data class Corrupt(val backup: File?, val cause: Throwable) : ConfigLoadResult +} + +sealed interface ConfigStoreEvent { + data class SaveFailed(val cause: Throwable) : ConfigStoreEvent +} + +/** + * 全 App 唯一会碰 `MG/config.json` 的地方。 + * + * 设计要点: + * - [config] 为 `null` 表示「尚未加载」。UI 的回调在这个阶段一律无视——这取代了原先的 + * `isSpinnerInitialized` / `config == null` 两个标志位,而且含义是明确的状态而不是时序。 + * - [update] 只改内存并立刻发出新状态,落盘去抖后异步进行。连续敲键盘不再等于连续写外部存储。 + * - 写入是原子的(临时文件 + fsync + rename)。native 端会在游戏启动时读这个文件, + * 截断式写入可能让它读到半截 JSON。 + * - 写入失败通过 [events] 上报,不再被 `runCatching {}` 静默吞掉。 + */ +class MGConfigStore( + private val configFile: File, + private val glslCacheFile: File, + private val scope: CoroutineScope, + private val io: CoroutineDispatcher = Dispatchers.IO, + private val saveDebounceMillis: Long = SAVE_DEBOUNCE_MILLIS, +) { + + private val gson = Gson() + + private val mutableConfig = MutableStateFlow(null) + + /** `null` = 尚未加载。 */ + val config: StateFlow = mutableConfig.asStateFlow() + + private val mutableEvents = MutableSharedFlow(extraBufferCapacity = 8) + val events: SharedFlow = mutableEvents.asSharedFlow() + + /** + * GLSL 缓存文件的大小(字节),`null` 表示文件不存在。 + * + * 界面据此决定要不要给出「删除缓存」按钮、以及在按钮上显示多大,所以它是状态而不是 + * 一次性查询:删除之后必须立刻反映出来,把按钮收回去。 + */ + private val mutableGlslCacheBytes = MutableStateFlow(null) + val glslCacheBytes: StateFlow = mutableGlslCacheBytes.asStateFlow() + + /** 配置文件里本 App 不认识的键(例如 native 的 `hideMGEnvLevel`),保存时原样写回。 */ + private var foreignKeys: JsonObject? = null + + /** + * 内存里的配置和磁盘上的不一致。 + * + * 只有它为 true 才会真的写文件。没有这个判据的话,「退到后台就 flush 一次」会在好几种场景下 + * 写出不该写的东西:配置文件损坏后用户选了取消、用户在外部删掉了 MG 目录、或者用户根本什么都没改。 + */ + @Volatile + private var dirty = false + + private val pendingSave = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + /** 串行化所有对 [configFile] 的读写。 */ + private val fileLock = Mutex() + + init { + scope.launch { collectPendingSaves() } + } + + @OptIn(FlowPreview::class) + private suspend fun collectPendingSaves() { + pendingSave.debounce(saveDebounceMillis).collect { persist() } + } + + suspend fun load(): ConfigLoadResult = fileLock.withLock { + // 先把还没落盘的改动写出去。否则重新读盘会把用户刚刚改的值覆盖回旧值—— + // Activity 重建(旋转屏幕)时 onResume 的这次 load 正好会撞进去抖窗口里。 + persistLocked() + withContext(io) { readConfigFile() } + } + + /** + * 修改配置。[transform] 返回的结果与当前值相等时什么都不会发生——这让所有 + * 「界面回灌触发的回调」天然是幂等的。 + */ + fun update(transform: (MGConfig) -> MGConfig) { + val current = mutableConfig.value ?: return + val next = transform(current) + if (next == current) return + mutableConfig.value = next + dirty = true + pendingSave.tryEmit(Unit) + } + + /** 把去抖队列里还没写的改动立刻落盘,并等待完成。 */ + suspend fun flush() = persist() + + /** + * 同 [flush],但跑在 store 自己的作用域上。 + * + * Activity 的 onStop 必须用这个:`lifecycleScope` 会在 onDestroy 时被取消, + * 旋转屏幕产生的那次 flush 可能还没轮到 IO 线程执行就被砍掉了。 + */ + fun flushAsync() { + scope.launch { persist() } + } + + /** 配置文件损坏后由用户确认的重置。 */ + suspend fun resetToDefaults() { + foreignKeys = null + mutableConfig.value = MGConfig.Default + dirty = true + persist() + } + + /** + * 回到「未加载」状态,UI 的回调随即失效,并且不会再写盘。 + * + * 在两种情况下必须调用:MG 目录被删除(不管是本 App 删的还是用户在外部删的), + * 以及配置文件损坏而用户选择了不重置。 + */ + fun forget() { + foreignKeys = null + mutableConfig.value = null + dirty = false + } + + /** + * 删除 GLSL 缓存文件。 + * + * 这是一条只由用户显式触发的命令:既不是「把缓存上限设成关闭」的副作用,也不会在读取配置时 + * 顺手执行——删掉的是用户已经积累好的着色器缓存,不能由赋值语句代劳。 + */ + suspend fun clearGlslCache(): Result = withContext(io) { + runCatching { + if (glslCacheFile.exists() && !glslCacheFile.delete()) { + throw IOException("Could not delete ${glslCacheFile.path}") + } + }.also { refreshGlslCacheFile() } + } + + private fun refreshGlslCacheFile() { + mutableGlslCacheBytes.value = glslCacheFile.takeIf { it.isFile }?.length() + } + + /** 把当前配置导出到别处(供 MGInfoGetter 读取),不影响 [configFile]。 */ + suspend fun exportTo(file: File): Result = withContext(io) { + runCatching { + file.writeAtomically(serialize(mutableConfig.value ?: MGConfig.Default)) + file + } + } + + private fun readConfigFile(): ConfigLoadResult { + // 顺便刷新缓存文件的状态:游戏在后台跑过一轮之后它可能才出现、或者变大了。 + refreshGlslCacheFile() + + if (!configFile.isFile) { + foreignKeys = null + mutableConfig.value = MGConfig.Default + // 内存里有默认值而磁盘上什么都没有,两边确实不一致:让调用方 flush 一次即可建立文件。 + dirty = true + return ConfigLoadResult.Missing + } + + val text = try { + configFile.readText() + } catch (e: Exception) { + forget() + return ConfigLoadResult.Corrupt(backup = null, cause = e) + } + + val root = try { + JsonParser.parseString(text).asJsonObject + } catch (e: Exception) { + // forget() 之后 store 不再持有任何配置,也就不可能有哪次 flush 把损坏的文件覆盖掉。 + val backup = backUp(text) + forget() + return ConfigLoadResult.Corrupt(backup = backup, cause = e) + } + + val config = MGConfigCodec.decode(root) + foreignKeys = MGConfigCodec.foreignKeysOf(root) + mutableConfig.value = config + dirty = false + return ConfigLoadResult.Loaded(config) + } + + private suspend fun persist() = fileLock.withLock { persistLocked() } + + /** 调用方必须已经持有 [fileLock]。 */ + private suspend fun persistLocked() { + val config = mutableConfig.value + if (!dirty || config == null) return + + val payload = serialize(config) + // NonCancellable:写到一半被取消会留下一个只有临时文件、没有落地的保存。 + val outcome = withContext(NonCancellable + io) { + runCatching { configFile.writeAtomically(payload) } + } + + outcome.onFailure { mutableEvents.tryEmit(ConfigStoreEvent.SaveFailed(it)) } + // 写的过程中配置又被改了的话保持 dirty,那次改动自己排了一次保存。 + if (outcome.isSuccess && mutableConfig.value === config) dirty = false + } + + private fun serialize(config: MGConfig): String = + gson.toJson(MGConfigCodec.encode(config, foreignKeys)) + + private fun backUp(text: String): File? = runCatching { + File(configFile.parentFile, configFile.name + CORRUPT_BACKUP_SUFFIX) + .also { it.writeText(text) } + }.getOrNull() + + companion object { + private const val SAVE_DEBOUNCE_MILLIS = 300L + private const val CORRUPT_BACKUP_SUFFIX = ".corrupt" + } +} + +/** + * 先写临时文件再 rename。 + * + * 同目录下的 rename 是原子的,所以读的一方(游戏里的 MobileGlues)要么看到旧内容, + * 要么看到完整的新内容,不会看到写到一半的文件。 + */ +private fun File.writeAtomically(text: String) { + parentFile?.mkdirs() + val temporary = File(parentFile, "$name.tmp") + FileOutputStream(temporary).use { output -> + output.write(text.toByteArray()) + output.flush() + output.fd.sync() + } + if (!temporary.renameTo(this)) { + // 同目录 rename 正常不会失败;万一失败就退回直接写,至少不会把配置丢掉。 + temporary.delete() + writeText(text) + } +} diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/showAppInfoDialog.kt b/app/src/main/java/com/fcl/plugin/mobileglues/showAppInfoDialog.kt index 79f3603a..50a3fce8 100644 --- a/app/src/main/java/com/fcl/plugin/mobileglues/showAppInfoDialog.kt +++ b/app/src/main/java/com/fcl/plugin/mobileglues/showAppInfoDialog.kt @@ -6,12 +6,11 @@ import android.content.Intent import android.view.LayoutInflater import android.widget.TextView import androidx.core.net.toUri -import com.fcl.plugin.mobileglues.settings.MGConfig import com.google.android.material.button.MaterialButton import com.google.android.material.dialog.MaterialAlertDialogBuilder @SuppressLint("InflateParams") -fun showAppInfoDialog(context: Context, config: MGConfig?) { +fun showAppInfoDialog(context: Context, onShowGlInfo: () -> Unit) { val view = LayoutInflater.from(context).inflate(R.layout.dialog_app_info, null, false) view.findViewById(R.id.info_version).text = BuildConfig.VERSION_NAME @@ -26,14 +25,17 @@ fun showAppInfoDialog(context: Context, config: MGConfig?) { } .setPositiveButton(R.string.dialog_github) { _, _ -> context.startActivity( - Intent(Intent.ACTION_VIEW, "https://github.com/MobileGL-Dev/MobileGlues-release".toUri()) + Intent( + Intent.ACTION_VIEW, + "https://github.com/MobileGL-Dev/MobileGlues-release".toUri() + ) ) } .show() .let { dialog -> view.findViewById(R.id.button_gl_info)?.setOnClickListener { - showMGGLInfoDialog(context, config) + onShowGlInfo() dialog.dismiss() } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/showMGGLInfoDialog.kt b/app/src/main/java/com/fcl/plugin/mobileglues/showMGGLInfoDialog.kt deleted file mode 100644 index e85363dc..00000000 --- a/app/src/main/java/com/fcl/plugin/mobileglues/showMGGLInfoDialog.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.fcl.plugin.mobileglues - -import android.content.Context -import android.widget.TextView -import com.fcl.plugin.mobileglues.settings.MGConfig -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -object MGInfoGetter { - init { - System.loadLibrary("mobileglues_info_getter") - } - - external fun setenv(key: String, value: String, overwrite: Int): Int - - external fun getMobileGluesGLInfo(): String - - val mgGLInfo: String - get() = try { - setenv("MG_PLUGIN_STATUS", 1.toString(), 1) - setenv("MG_DIR_PATH", MGConfig.cacheMGDir.path, 1) - getMobileGluesGLInfo() - } catch (e: Throwable) { - "Error: ${e.message}" - } -} - -fun showMGGLInfoDialog(context: Context, config: MGConfig?) { - config?.saveToCachePath() - MaterialAlertDialogBuilder(context) - .setTitle(R.string.dialog_mg_gl_info_title) - .setMessage(MGInfoGetter.mgGLInfo) - .setNegativeButton(R.string.dismiss, null) - .show() - .let { dialog -> - dialog.findViewById(android.R.id.message)?.setTextIsSelectable(true) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/utils/Constants.kt b/app/src/main/java/com/fcl/plugin/mobileglues/utils/Constants.kt index e7be42e6..e6047c66 100644 --- a/app/src/main/java/com/fcl/plugin/mobileglues/utils/Constants.kt +++ b/app/src/main/java/com/fcl/plugin/mobileglues/utils/Constants.kt @@ -3,10 +3,11 @@ package com.fcl.plugin.mobileglues.utils import android.os.Environment object Constants { + const val CONFIG_FILE_NAME: String = "config.json" + val MG_DIRECTORY: String = "${Environment.getExternalStorageDirectory().absolutePath}/MG" - val CONFIG_FILE_PATH: String = "$MG_DIRECTORY/config.json" + val CONFIG_FILE_PATH: String = "$MG_DIRECTORY/$CONFIG_FILE_NAME" val GLSL_CACHE_FILE_PATH: String = "$MG_DIRECTORY/glsl_cache.tmp" } - diff --git a/app/src/main/res/drawable/ic_expand_more_24.xml b/app/src/main/res/drawable/ic_expand_more_24.xml new file mode 100644 index 00000000..d116cf44 --- /dev/null +++ b/app/src/main/res/drawable/ic_expand_more_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 4ab73f59..d7b530a1 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -37,26 +37,72 @@ android:layout_height="wrap_content" android:padding="8dp"> - - - + + + + + + + android:gravity="center_vertical" + android:orientation="horizontal"> + + + + + + + + + + - + + app:layout_constraintBottom_toTopOf="@+id/multidraw_section" /> - - - + + android:orientation="vertical" + app:layout_constraintBottom_toTopOf="@+id/text_option_gl_version"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 9bd8609a..102c719e 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -12,30 +12,33 @@ 关于 - 最大 GLSL 缓存大小 (输入 -1 以禁用) - 单位:兆字节(MB) + 最大 GLSL 缓存大小 + 关闭 + 删除已缓存的着色器(%1$s) + 删除 GLSL 缓存失败:%s 启用 ANGLE 作为 OpenGL ES 驱动 尽可能不启用 尽可能启用 不启用 启用 - 错误:不能为 0 或小于 -1。 - 错误:无效的值 - OpenGL 报错设置 自动 不忽略报错 忽略 shader/program 报错 忽略 shader/program/framebuffer 报错 - MultiDraw 模拟方案 - 自动 - 尽量使用 Indirect 方案模拟 - 尽量使用 BaseVertex 方案模拟 - 尽量使用 MultiDraw Indirect 方案模拟 - 强制使用 DrawElements 方案模拟 - 尽量使用 Compute 方案模拟 + MultiDraw 后端 + 全部自动 + 已自定义 %1$d 项 + 禁止使用以下后端 + 被禁用的后端等同于驱动不支持,会走同一套降级流程。 + + 自动 + 逐条绘制 + 原生 + 原生 EXT + 计算着色器 ANGLE Depth Clear 修复方案 不启用 @@ -47,6 +50,7 @@ (实验性) 启用内置的 FSR1 自定义目标 OpenGL 版本 + 不启用 ⚠️ 重要信息:

• 自定义 OpenGL 版本可能会造成游戏崩溃或渲染错误,请谨慎使用!
• 若发生问题,请首先关闭此功能!
@@ -66,6 +70,11 @@ 授权失败 + 保存设置失败:%s + 配置文件已损坏 + MG/config.json 无法解析,已保持原样未做修改,并备份为“%1$s”。\n\n详细信息:%2$s\n\n你可以把设置重置为默认值,或者取消后自行修复该文件。 + 重置为默认值 + 警告 启用不完整的 ARB_compute_shader 扩展可能会导致部分光影、Mods 错误,是否继续? 在 Adreno 740 设备上启用 ANGLE 极有可能导致严重渲染错误,是否继续? diff --git a/app/src/main/res/values/array.xml b/app/src/main/res/values/array.xml deleted file mode 100644 index 8d809b9f..00000000 --- a/app/src/main/res/values/array.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - @string/option_angle_disable_if_possible - @string/option_angle_enable_if_possible - @string/option_angle_disable - @string/option_angle_enable - - - - @string/option_no_error_auto - @string/option_no_error_enable - @string/option_no_error_disable_pri - @string/option_no_error_disable_sec - - - - @string/option_multidraw_mode_auto - @string/option_multidraw_mode_indirect - @string/option_multidraw_mode_basevertex - @string/option_multidraw_mode_multidraw_indirect - @string/option_multidraw_mode_drawelements - @string/option_multidraw_mode_compute - - - - @string/option_angle_clear_workaround_disable - @string/option_angle_clear_workaround_enable_1 - - \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ce335011..c2cae2b2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,30 +16,39 @@ About - Max GLSL Cache Size (enter -1 to disable) - Unit: Megabytes (MB) + Max GLSL Cache Size + Off + %1$d MiB + Delete cached shaders (%1$s) + %1$.1f MiB + %1$.0f KiB + Could not delete the GLSL cache: %s Use ANGLE as OpenGL ES driver Prefer Disabled Prefer Enabled Disable Enable - Error: invalid number. - Error: number cannot be 0 or less than -1. - OpenGL Error Setting Auto Do not ignore Ignore shader/program error Ignore shader/program/framebuffer error - MultiDraw Emulation - Auto - Prefer Indirect - Prefer BaseVertex - Prefer MultiDraw Indirect - Force DrawElements - Prefer Compute + MultiDraw backends + All automatic + %1$d customised + Never use these backends + A disabled backend is treated as if the driver did not have it. + + Auto + Draw one by one + BaseVertex + Indirect + MultiIndirect + Native + Native EXT + Compute shader ANGLE Depth Clear Workaround Disable @@ -51,6 +60,7 @@ (Experimental) Enable built-in FSR1 Custom target OpenGL Version + Disable ⚠️ Important Notice:

• Setting a custom OpenGL version may cause game crashes or rendering issues — proceed with caution!
• If any issues occur, please disable this feature first!
@@ -70,6 +80,11 @@ Permission denied + Failed to save settings: %s + Damaged configuration file + MG/config.json could not be parsed, so it has not been touched. A copy has been kept as \'%1$s\'.\n\nDetails: %2$s\n\nYou can reset the settings to their defaults, or cancel and repair the file yourself. + Reset to defaults + Warning Enabling the incomplete ARB_compute_shader extension may cause issues with some shaders and mods. Are you sure you want to continue? Enabling ANGLE on Adreno 740 devices is highly likely to cause severe rendering issues. Are you sure you want to continue? diff --git a/app/src/test/java/com/fcl/plugin/mobileglues/settings/MGConfigCodecTest.kt b/app/src/test/java/com/fcl/plugin/mobileglues/settings/MGConfigCodecTest.kt new file mode 100644 index 00000000..ee434f80 --- /dev/null +++ b/app/src/test/java/com/fcl/plugin/mobileglues/settings/MGConfigCodecTest.kt @@ -0,0 +1,281 @@ +package com.fcl.plugin.mobileglues.settings + +import com.google.gson.Gson +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * `MG/config.json` 是和 native 端共享的格式,这些用例锁住的是那份契约。 + */ +class MGConfigCodecTest { + + private fun parse(json: String): JsonObject = JsonParser.parseString(json).asJsonObject + + private fun encode(config: MGConfig, foreign: JsonObject? = null): JsonObject = + MGConfigCodec.encode(config, foreign) + + @Test + fun `wire values match the native settings header`() { + assertEquals(0, AngleConfig.DisableIfPossible.wire) + assertEquals(1, AngleConfig.EnableIfPossible.wire) + assertEquals(2, AngleConfig.ForceDisable.wire) + assertEquals(3, AngleConfig.ForceEnable.wire) + + // native 还有 Mode2 = 2,但那一档不对外开放,App 不提供也不接受。 + assertEquals(1, DepthClearFixMode.entries.last().wire) + assertEquals(4, Fsr1Preset.Performance.wire) + assertEquals(46, GlVersion.Gl46.wire) + assertEquals(0, GlVersion.Default.wire) + } + + @Test + fun `spinner position equals ordinal for every option`() { + // 适配器是按 entries 的顺序构建的,render() 依赖 ordinal 就是位置。 + listOf( + AngleConfig.entries, + NoErrorConfig.entries, + DepthClearFixMode.entries, + GlVersion.entries, + ).forEach { entries -> + entries.forEachIndexed { index, option -> assertEquals(index, (option as Enum<*>).ordinal) } + } + } + + @Test + fun `an empty object decodes to the defaults`() { + assertEquals(MGConfig.Default, MGConfigCodec.decode(parse("{}"))) + } + + @Test + fun `every field survives a round trip`() { + val config = MGConfig( + angle = AngleConfig.ForceDisable, + noError = NoErrorConfig.IgnoreShaderProgramFramebuffer, + multidraw = MultidrawSettings( + backends = mapOf( + MultidrawEntry.Elements to MultidrawBackend.MultiIndirect, + MultidrawEntry.ElementsBaseVertex to MultidrawBackend.Compute, + ), + disabledBackends = setOf(MultidrawBackend.Native, MultidrawBackend.Unroll), + ), + depthClearFix = DepthClearFixMode.Mode1, + glVersion = GlVersion.Gl33, + glslCache = GlslCacheSize.Disabled, + extComputeShader = true, + extTimerQuery = false, + extDirectStateAccess = true, + fsr1 = Fsr1Preset.Balanced, + ) + + val json = Gson().toJson(encode(config)) + assertEquals(config, MGConfigCodec.decode(parse(json))) + } + + @Test + fun `keys the app does not know survive a save`() { + // native 会读 hideMGEnvLevel,App 不认识它——保存时必须原样写回,否则等于替用户删设置。 + val root = parse("""{"enableANGLE":3,"hideMGEnvLevel":1,"somethingFromTheFuture":7}""") + + val config = MGConfigCodec.decode(root) + val encoded = encode(config, MGConfigCodec.foreignKeysOf(root)) + + assertEquals(1, encoded.get("hideMGEnvLevel").asInt) + assertEquals(7, encoded.get("somethingFromTheFuture").asInt) + assertEquals(3, encoded.get("enableANGLE").asInt) + } + + @Test + fun `foreign keys never include keys the app owns`() { + val root = parse("""{"enableANGLE":3,"maxGlslCacheSize":64,"hideMGEnvLevel":1}""") + val foreign = MGConfigCodec.foreignKeysOf(root) + + assertNull(foreign.get("enableANGLE")) + assertNull(foreign.get("maxGlslCacheSize")) + assertEquals(1, foreign.get("hideMGEnvLevel").asInt) + } + + @Test + fun `out of range values fall back to the defaults`() { + val decoded = MGConfigCodec.decode( + parse("""{"enableANGLE":99,"enableNoError":-4,"fsr1Setting":9}""") + ) + + assertEquals(MGConfig.Default.angle, decoded.angle) + assertEquals(MGConfig.Default.noError, decoded.noError) + assertEquals(MGConfig.Default.fsr1, decoded.fsr1) + } + + @Test + fun `an unlisted customGLVersion is clamped exactly like native does`() { + // settings.cpp: >46 -> 46, 34..39 -> 33, 1..31 -> 32, 0 -> DEFAULT_GL_VERSION. + // 若这些值被读成 Default(0),下一次保存就会把它们写成 0,native 随即当成 4.0, + // 用户没动过任何开关,目标版本却变了。 + assertEquals(GlVersion.Gl46, GlVersion.fromWire(47)) + assertEquals(GlVersion.Gl46, GlVersion.fromWire(99)) + assertEquals(GlVersion.Gl33, GlVersion.fromWire(38)) + assertEquals(GlVersion.Gl33, GlVersion.fromWire(34)) + assertEquals(GlVersion.Gl33, GlVersion.fromWire(39)) + assertEquals(GlVersion.Gl32, GlVersion.fromWire(31)) + assertEquals(GlVersion.Gl32, GlVersion.fromWire(1)) + assertEquals(GlVersion.Default, GlVersion.fromWire(0)) + assertEquals(GlVersion.Default, GlVersion.fromWire(-3)) + assertEquals(GlVersion.Default, GlVersion.fromWire(null)) + + // 已知档位必须原样通过。 + GlVersion.entries.forEach { assertEquals(it, GlVersion.fromWire(it.wire)) } + + // 读进来再写回去,落到磁盘上的必须是 native 会夹到的那一档,不能是 0。 + assertEquals(33, encode(MGConfigCodec.decode(parse("""{"customGLVersion":38}"""))) + .get("customGLVersion").asInt) + } + + @Test + fun `one broken field does not discard the rest of the config`() { + // 以前 applyFromJson 里任何一个 asInt 抛异常都会让整份配置作废并被默认值覆盖。 + val decoded = MGConfigCodec.decode( + parse("""{"enableANGLE":{"nope":true},"multidrawModeElements":"native","maxGlslCacheSize":128}""") + ) + + assertEquals(MGConfig.Default.angle, decoded.angle) + assertEquals(MultidrawBackend.Native, decoded.multidraw.backendOf(MultidrawEntry.Elements)) + assertEquals(GlslCacheSize.Limited(128), decoded.glslCache) + } + + @Test + fun `numbers written as strings are accepted and normalised`() { + val decoded = MGConfigCodec.decode(parse("""{"maxGlslCacheSize":"128"}""")) + assertEquals(GlslCacheSize.Limited(128), decoded.glslCache) + assertTrue(encode(decoded).get("maxGlslCacheSize").asJsonPrimitive.isNumber) + } + + @Test + fun `booleans follow the native greater-than-zero rule`() { + val decoded = MGConfigCodec.decode( + parse("""{"enableExtComputeShader":2,"enableExtTimerQuery":0,"enableExtDirectStateAccess":-1}""") + ) + + assertTrue(decoded.extComputeShader) + assertEquals(false, decoded.extTimerQuery) + assertEquals(false, decoded.extDirectStateAccess) + + val encoded = encode(decoded) + assertEquals(1, encoded.get("enableExtComputeShader").asInt) + assertEquals(0, encoded.get("enableExtTimerQuery").asInt) + } + + @Test + fun `every non-positive cache size means disabled, exactly like native reads it`() { + // native: `if (config_get_int("maxGlslCacheSize") > 0)` —— 否则缓存大小为 0,即不缓存。 + assertEquals(GlslCacheSize.Disabled, GlslCacheSize.fromWire(-1)) + assertEquals(GlslCacheSize.Disabled, GlslCacheSize.fromWire(0)) + assertEquals(GlslCacheSize.Disabled, GlslCacheSize.fromWire(-7)) + assertEquals(GlslCacheSize.Limited(64), GlslCacheSize.fromWire(64)) + assertEquals(GlslCacheSize.Default, GlslCacheSize.fromWire(null)) + + // 关闭状态写回磁盘统一用 -1,与历史配置一致。 + assertEquals(-1, GlslCacheSize.Disabled.wire) + assertEquals(-1, encode(MGConfigCodec.decode(parse("""{"maxGlslCacheSize":0}"""))) + .get("maxGlslCacheSize").asInt) + + assertThrows(IllegalArgumentException::class.java) { GlslCacheSize.Limited(0) } + } + + @Test + fun `slider mebibytes map to config values and back`() { + assertEquals(GlslCacheSize.Disabled, GlslCacheSize.ofMebibytes(0)) + assertEquals(GlslCacheSize.Limited(1), GlslCacheSize.ofMebibytes(1)) + assertEquals(GlslCacheSize.Limited(512), GlslCacheSize.ofMebibytes(512)) + + assertEquals(0, GlslCacheSize.Disabled.mebibytesOrZero) + assertEquals(512, GlslCacheSize.Limited(512).mebibytesOrZero) + } + + @Test + fun `the defaults are the same ones the previous implementation wrote`() { + val encoded = encode(MGConfig.Default) + + assertEquals(1, encoded.get("enableANGLE").asInt) + assertEquals(0, encoded.get("enableNoError").asInt) + assertEquals(1, encoded.get("enableExtTimerQuery").asInt) + assertEquals(0, encoded.get("enableExtComputeShader").asInt) + assertEquals(0, encoded.get("enableExtDirectStateAccess").asInt) + assertEquals(32, encoded.get("maxGlslCacheSize").asInt) + // MultiDraw 默认全自动 = 一个键都不写。 + MultidrawEntry.entries.forEach { assertNull(encoded.get(it.key)) } + assertNull(encoded.get("multidrawMode")) + assertNull(encoded.get("multidrawDisableBackends")) + assertEquals(0, encoded.get("angleDepthClearFixMode").asInt) + assertEquals(0, encoded.get("customGLVersion").asInt) + assertEquals(0, encoded.get("fsr1Setting").asInt) + } + + + @Test + fun `multidraw backends are persisted by name, one key per entry point`() { + val root = parse( + """{"multidrawModeArrays":"nativeext","multidrawModeElementsIndirect":"indirect"}""" + ) + val decoded = MGConfigCodec.decode(root) + + assertEquals(MultidrawBackend.NativeExt, decoded.multidraw.backendOf(MultidrawEntry.Arrays)) + assertEquals(MultidrawBackend.Indirect, decoded.multidraw.backendOf(MultidrawEntry.ElementsIndirect)) + assertEquals(MultidrawBackend.Auto, decoded.multidraw.backendOf(MultidrawEntry.Elements)) + + val encoded = encode(decoded) + assertEquals("nativeext", encoded.get("multidrawModeArrays").asString) + assertEquals("indirect", encoded.get("multidrawModeElementsIndirect").asString) + // auto 不写键,免得配置里堆一串没有意义的 "auto" + assertNull(encoded.get("multidrawModeElements")) + } + + @Test + fun `a backend that is not a distinct strategy for that entry point falls back to auto`() { + // basevertex 对 glMultiDrawElements 没有意义(它没有 base vertex),native 会拒绝并用 auto。 + val decoded = MGConfigCodec.decode(parse("""{"multidrawModeElements":"basevertex"}""")) + assertEquals(MultidrawBackend.Auto, decoded.multidraw.backendOf(MultidrawEntry.Elements)) + + // compute 只对 ElementsBaseVertex 是一种独立实现。 + assertTrue(MultidrawBackend.Compute in MultidrawEntry.ElementsBaseVertex.allowed) + assertTrue(MultidrawBackend.Compute !in MultidrawEntry.Elements.allowed) + } + + @Test + fun `backend names are parsed the way native parses them`() { + // md_parse_backend:忽略大小写以及空格 / 下划线 / 连字符 + assertEquals(MultidrawBackend.MultiIndirect, MultidrawBackend.parse("MultiIndirect")) + assertEquals(MultidrawBackend.MultiIndirect, MultidrawBackend.parse(" multi_indirect ")) + assertEquals(MultidrawBackend.NativeExt, MultidrawBackend.parse("native-ext")) + assertNull(MultidrawBackend.parse("nonsense")) + assertNull(MultidrawBackend.parse("")) + assertNull(MultidrawBackend.parse(null)) + } + + @Test + fun `the global disable list round-trips as a comma separated name list`() { + val decoded = MGConfigCodec.decode( + parse("""{"multidrawDisableBackends":"compute, native ; nonsense, auto"}""") + ) + + // 无法识别的名字和 auto 都被丢弃,与 native 的处理一致 + assertEquals( + setOf(MultidrawBackend.Native, MultidrawBackend.Compute), + decoded.multidraw.disabledBackends, + ) + assertEquals("native,compute", encode(decoded).get("multidrawDisableBackends").asString) + } + + @Test + fun `the deprecated multidrawMode key is dropped on save`() { + // native 已经不读它,留着只会让它每次启动都打一条弃用警告。 + val root = parse("""{"multidrawMode":5,"hideMGEnvLevel":1}""") + val encoded = encode(MGConfigCodec.decode(root), MGConfigCodec.foreignKeysOf(root)) + + assertNull(encoded.get("multidrawMode")) + assertEquals(1, encoded.get("hideMGEnvLevel").asInt) + } +} diff --git a/app/src/test/java/com/fcl/plugin/mobileglues/settings/MGConfigStoreTest.kt b/app/src/test/java/com/fcl/plugin/mobileglues/settings/MGConfigStoreTest.kt new file mode 100644 index 00000000..7808550c --- /dev/null +++ b/app/src/test/java/com/fcl/plugin/mobileglues/settings/MGConfigStoreTest.kt @@ -0,0 +1,211 @@ +package com.fcl.plugin.mobileglues.settings + +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * 这些用例盯的是「什么时候**不该**写文件」——配置丢失的老 bug 全部出在这里。 + */ +@OptIn(ExperimentalCoroutinesApi::class) +class MGConfigStoreTest { + + @get:Rule + val folder = TemporaryFolder() + + private val scheduler = TestCoroutineScheduler() + private val dispatcher = UnconfinedTestDispatcher(scheduler) + private val storeScope = CoroutineScope(dispatcher) + + private lateinit var mgDirectory: File + private lateinit var configFile: File + + @Before + fun setUp() { + mgDirectory = folder.newFolder("MG") + configFile = File(mgDirectory, "config.json") + } + + @After + fun tearDown() { + storeScope.cancel() + } + + private fun newStore() = MGConfigStore( + configFile = configFile, + glslCacheFile = File(mgDirectory, "glsl_cache.tmp"), + scope = storeScope, + io = dispatcher, + ) + + private fun readConfig(): JsonObject = + JsonParser.parseString(configFile.readText()).asJsonObject + + @Test + fun `a missing config file is reported as missing and only created on flush`() = + runTest(dispatcher) { + val store = newStore() + + assertEquals(ConfigLoadResult.Missing, store.load()) + assertEquals(MGConfig.Default, store.config.value) + assertFalse("load() 不允许自己建文件", configFile.exists()) + + store.flush() + assertTrue(configFile.exists()) + assertEquals(MGConfig.Default, MGConfigCodec.decode(readConfig())) + } + + @Test + fun `flush writes nothing when the user changed nothing`() = runTest(dispatcher) { + configFile.writeText("""{"enableANGLE":3,"hideMGEnvLevel":1}""") + val store = newStore() + assertTrue(store.load() is ConfigLoadResult.Loaded) + + // 模拟「加载之后文件被别人改了」,随后本 App 退到后台。 + val externallyWritten = """{"marker":true}""" + configFile.writeText(externallyWritten) + store.flush() + + assertEquals(externallyWritten, configFile.readText()) + } + + @Test + fun `a corrupt config file is backed up and never overwritten`() = runTest(dispatcher) { + val broken = "{ this is not json" + configFile.writeText(broken) + val store = newStore() + + val result = store.load() + assertTrue(result is ConfigLoadResult.Corrupt) + assertEquals(broken, (result as ConfigLoadResult.Corrupt).backup?.readText()) + assertNull("损坏之后 store 必须回到未加载状态", store.config.value) + + // 用户选了「取消」:后续任何写入尝试都不能碰原文件。 + store.update { it.copy(angle = AngleConfig.ForceEnable) } + store.flush() + assertEquals(broken, configFile.readText()) + } + + @Test + fun `forget stops the store from writing anything`() = runTest(dispatcher) { + configFile.writeText("""{"enableANGLE":2}""") + val store = newStore() + store.load() + store.update { it.copy(angle = AngleConfig.ForceEnable) } + + // 用户在外部删掉了 MG 目录,界面退回启动页。 + store.forget() + configFile.delete() + store.flush() + + assertFalse("退到后台不能把删掉的目录重建出来", configFile.exists()) + } + + @Test + fun `an edit is written and unknown keys survive it`() = runTest(dispatcher) { + configFile.writeText("""{"enableANGLE":0,"hideMGEnvLevel":1}""") + val store = newStore() + store.load() + + store.update { it.copy(angle = AngleConfig.ForceEnable, glslCache = GlslCacheSize.Disabled) } + store.flush() + + val root = readConfig() + assertEquals(3, root.get("enableANGLE").asInt) + assertEquals(-1, root.get("maxGlslCacheSize").asInt) + assertEquals(1, root.get("hideMGEnvLevel").asInt) + } + + @Test + fun `reloading does not clobber an edit that has not reached the disk yet`() = + runTest(dispatcher) { + configFile.writeText("""{"enableANGLE":0}""") + val store = newStore() + store.load() + store.update { it.copy(angle = AngleConfig.ForceEnable) } + + // Activity 重建(旋转屏幕)会在去抖窗口内再 load 一次。 + assertTrue(store.load() is ConfigLoadResult.Loaded) + + assertEquals(AngleConfig.ForceEnable, store.config.value?.angle) + assertEquals(3, readConfig().get("enableANGLE").asInt) + } + + @Test + fun `writing leaves no temporary file behind`() = runTest(dispatcher) { + val store = newStore() + store.load() + store.update { it.copy(fsr1 = Fsr1Preset.UltraQuality) } + store.flush() + + assertEquals(listOf("config.json"), mgDirectory.list()!!.sorted()) + } + + @Test + fun `turning the cache off does not touch the cache file`() = runTest(dispatcher) { + val cacheFile = File(mgDirectory, "glsl_cache.tmp") + cacheFile.writeText("shaders") // 7 字节 + configFile.writeText("""{"maxGlslCacheSize":64}""") + val store = newStore() + store.load() + + store.update { it.copy(glslCache = GlslCacheSize.Disabled) } + store.flush() + + assertTrue("关闭缓存只改配置,不能顺手删文件", cacheFile.exists()) + assertEquals(7L, store.glslCacheBytes.value) + assertEquals(-1, readConfig().get("maxGlslCacheSize").asInt) + } + + @Test + fun `clearing the cache deletes the file and updates the observable presence`() = + runTest(dispatcher) { + val cacheFile = File(mgDirectory, "glsl_cache.tmp") + cacheFile.writeText("shaders") // 7 字节 + configFile.writeText("{}") + val store = newStore() + store.load() + assertEquals(7L, store.glslCacheBytes.value) + + assertTrue(store.clearGlslCache().isSuccess) + + assertFalse(cacheFile.exists()) + assertNull("删掉之后按钮要能立刻收回去", store.glslCacheBytes.value) + } + + @Test + fun `clearing an absent cache is a no-op success`() = runTest(dispatcher) { + configFile.writeText("{}") + val store = newStore() + store.load() + + assertNull(store.glslCacheBytes.value) + assertTrue(store.clearGlslCache().isSuccess) + } + + @Test + fun `update is ignored while the store is not loaded`() = runTest(dispatcher) { + val store = newStore() + + store.update { it.copy(angle = AngleConfig.ForceEnable) } + store.flush() + + assertNull(store.config.value) + assertFalse(configFile.exists()) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 27aea1cf..eed6543d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,12 +5,19 @@ kotlin = "2.1.10" appcompat = "1.7.1" constraintlayout = "2.2.1" material = "1.13.0" +coroutines = "1.10.2" +lifecycle = "2.8.7" +junit = "4.13.2" [libraries] gson = { module = "com.google.code.gson:gson", version.ref = "gson" } appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } google-material = { group = "com.google.android.material", name = "material", version.ref = "material" } +coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } +lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } +coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } +junit = { module = "junit:junit", version.ref = "junit" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From 5cd4095647363350fb1dd793be764bf68f482958 Mon Sep 17 00:00:00 2001 From: BZLZHH Date: Wed, 5 Aug 2026 13:35:27 -0400 Subject: [PATCH 03/67] [Refactor] (multidraw): follow the backend rename in the native config keys MobileGlues renamed the two backends that were called "native" and "nativeext" after the extensions that actually provide them: native -> multibasevertex glMultiDrawElementsBaseVertexEXT nativeext -> multiarrays glMultiDraw{Arrays,Elements}EXT config.json carries these names rather than indices, so the app has to speak the new spelling: on the current native every value it used to write is rejected as an unknown backend name and silently falls back to auto. Reorders MultidrawBackend to match md_backend_t. The order is not cosmetic -- it groups the backends that issue one driver call per sub-draw (unroll, basevertex, indirect) ahead of the ones that issue a single call for the whole batch (multiarrays, multibasevertex, multiindirect), and it decides both the order of the chips and the order of the names written to multidrawDisableBackends. The Chinese labels for these two are dropped rather than retranslated. GLES core has no multi-draw command at all, not even 3.2, which added only the singular glDrawElementsBaseVertex; every batched backend here therefore comes from an extension, and calling one of them "native" claimed a distinction that does not exist. Both now show their technical name, the way BaseVertex, Indirect and MultiIndirect already did. Moves compute to the end of the glMultiDrawElementsBaseVertex list as well: it is in that entry point's allowed set but not in its auto ladder, so it is reachable only by an explicit choice and should not sit among the ones auto can pick. Adds a test pinning the full list of backend names and entry-point keys, so a later rename on the native side fails the build instead of quietly writing values the loader throws away. The submodule moves to 0a3d4f2, where the new names are read. --- MobileGlues | 2 +- .../plugin/mobileglues/settings/MGConfig.kt | 23 ++++++---- app/src/main/res/values-zh/strings.xml | 2 - app/src/main/res/values/strings.xml | 4 +- .../mobileglues/settings/MGConfigCodecTest.kt | 45 ++++++++++++++----- 5 files changed, 53 insertions(+), 23 deletions(-) diff --git a/MobileGlues b/MobileGlues index 55ef3c4e..0a3d4f2b 160000 --- a/MobileGlues +++ b/MobileGlues @@ -1 +1 @@ -Subproject commit 55ef3c4e8f15d3aa4268c801d74cf1e978844207 +Subproject commit 0a3d4f2b66cb4737614e180eedb724db9b0cdcd9 diff --git a/app/src/main/java/com/fcl/plugin/mobileglues/settings/MGConfig.kt b/app/src/main/java/com/fcl/plugin/mobileglues/settings/MGConfig.kt index 5b4cd37f..b7cbc0dc 100644 --- a/app/src/main/java/com/fcl/plugin/mobileglues/settings/MGConfig.kt +++ b/app/src/main/java/com/fcl/plugin/mobileglues/settings/MGConfig.kt @@ -59,12 +59,17 @@ enum class NoErrorConfig(override val wire: Int, @param:StringRes private val la */ enum class MultidrawBackend(val key: String, @param:StringRes private val labelRes: Int) { Auto("auto", R.string.md_backend_auto), + + // 前三种:每个子绘制各发一次驱动调用。 Unroll("unroll", R.string.md_backend_unroll), BaseVertex("basevertex", R.string.md_backend_basevertex), Indirect("indirect", R.string.md_backend_indirect), + + // 后三种:整批只发一次。声明顺序与 native 的 md_backend_t 一致,代价高低一目了然。 + MultiArrays("multiarrays", R.string.md_backend_multiarrays), + MultiBaseVertex("multibasevertex", R.string.md_backend_multibasevertex), MultiIndirect("multiindirect", R.string.md_backend_multiindirect), - Native("native", R.string.md_backend_native), - NativeExt("nativeext", R.string.md_backend_nativeext), + Compute("compute", R.string.md_backend_compute); fun label(context: Context): CharSequence = context.getString(labelRes) @@ -97,23 +102,25 @@ enum class MultidrawEntry( Arrays( "multidrawModeArrays", "glMultiDrawArrays", listOf( - MultidrawBackend.Auto, MultidrawBackend.NativeExt, + MultidrawBackend.Auto, MultidrawBackend.MultiArrays, MultidrawBackend.MultiIndirect, MultidrawBackend.Unroll, ), ), Elements( "multidrawModeElements", "glMultiDrawElements", listOf( - MultidrawBackend.Auto, MultidrawBackend.MultiIndirect, MultidrawBackend.NativeExt, - MultidrawBackend.Native, MultidrawBackend.Indirect, MultidrawBackend.Unroll, + MultidrawBackend.Auto, MultidrawBackend.MultiIndirect, MultidrawBackend.MultiArrays, + MultidrawBackend.MultiBaseVertex, MultidrawBackend.Indirect, MultidrawBackend.Unroll, ), ), ElementsBaseVertex( "multidrawModeElementsBaseVertex", "glMultiDrawElementsBaseVertex", listOf( - MultidrawBackend.Auto, MultidrawBackend.MultiIndirect, MultidrawBackend.Native, - MultidrawBackend.Indirect, MultidrawBackend.BaseVertex, MultidrawBackend.Compute, - MultidrawBackend.Unroll, + MultidrawBackend.Auto, MultidrawBackend.MultiIndirect, + MultidrawBackend.MultiBaseVertex, MultidrawBackend.Indirect, + MultidrawBackend.BaseVertex, MultidrawBackend.Unroll, + // 自动挡的阶梯里没有 compute,只能显式选,所以排在最后。 + MultidrawBackend.Compute, ), ), ArraysIndirect( diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 102c719e..b704ad1f 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -36,8 +36,6 @@ 自动 逐条绘制 - 原生 - 原生 EXT 计算着色器 ANGLE Depth Clear 修复方案 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c2cae2b2..783c1aee 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -45,9 +45,9 @@ Draw one by one BaseVertex Indirect + MultiArrays + MultiBaseVertex MultiIndirect - Native - Native EXT Compute shader ANGLE Depth Clear Workaround diff --git a/app/src/test/java/com/fcl/plugin/mobileglues/settings/MGConfigCodecTest.kt b/app/src/test/java/com/fcl/plugin/mobileglues/settings/MGConfigCodecTest.kt index ee434f80..be1403e8 100644 --- a/app/src/test/java/com/fcl/plugin/mobileglues/settings/MGConfigCodecTest.kt +++ b/app/src/test/java/com/fcl/plugin/mobileglues/settings/MGConfigCodecTest.kt @@ -61,7 +61,7 @@ class MGConfigCodecTest { MultidrawEntry.Elements to MultidrawBackend.MultiIndirect, MultidrawEntry.ElementsBaseVertex to MultidrawBackend.Compute, ), - disabledBackends = setOf(MultidrawBackend.Native, MultidrawBackend.Unroll), + disabledBackends = setOf(MultidrawBackend.MultiBaseVertex, MultidrawBackend.Unroll), ), depthClearFix = DepthClearFixMode.Mode1, glVersion = GlVersion.Gl33, @@ -138,11 +138,11 @@ class MGConfigCodecTest { fun `one broken field does not discard the rest of the config`() { // 以前 applyFromJson 里任何一个 asInt 抛异常都会让整份配置作废并被默认值覆盖。 val decoded = MGConfigCodec.decode( - parse("""{"enableANGLE":{"nope":true},"multidrawModeElements":"native","maxGlslCacheSize":128}""") + parse("""{"enableANGLE":{"nope":true},"multidrawModeElements":"multibasevertex","maxGlslCacheSize":128}""") ) assertEquals(MGConfig.Default.angle, decoded.angle) - assertEquals(MultidrawBackend.Native, decoded.multidraw.backendOf(MultidrawEntry.Elements)) + assertEquals(MultidrawBackend.MultiBaseVertex, decoded.multidraw.backendOf(MultidrawEntry.Elements)) assertEquals(GlslCacheSize.Limited(128), decoded.glslCache) } @@ -218,16 +218,16 @@ class MGConfigCodecTest { @Test fun `multidraw backends are persisted by name, one key per entry point`() { val root = parse( - """{"multidrawModeArrays":"nativeext","multidrawModeElementsIndirect":"indirect"}""" + """{"multidrawModeArrays":"multiarrays","multidrawModeElementsIndirect":"indirect"}""" ) val decoded = MGConfigCodec.decode(root) - assertEquals(MultidrawBackend.NativeExt, decoded.multidraw.backendOf(MultidrawEntry.Arrays)) + assertEquals(MultidrawBackend.MultiArrays, decoded.multidraw.backendOf(MultidrawEntry.Arrays)) assertEquals(MultidrawBackend.Indirect, decoded.multidraw.backendOf(MultidrawEntry.ElementsIndirect)) assertEquals(MultidrawBackend.Auto, decoded.multidraw.backendOf(MultidrawEntry.Elements)) val encoded = encode(decoded) - assertEquals("nativeext", encoded.get("multidrawModeArrays").asString) + assertEquals("multiarrays", encoded.get("multidrawModeArrays").asString) assertEquals("indirect", encoded.get("multidrawModeElementsIndirect").asString) // auto 不写键,免得配置里堆一串没有意义的 "auto" assertNull(encoded.get("multidrawModeElements")) @@ -249,7 +249,7 @@ class MGConfigCodecTest { // md_parse_backend:忽略大小写以及空格 / 下划线 / 连字符 assertEquals(MultidrawBackend.MultiIndirect, MultidrawBackend.parse("MultiIndirect")) assertEquals(MultidrawBackend.MultiIndirect, MultidrawBackend.parse(" multi_indirect ")) - assertEquals(MultidrawBackend.NativeExt, MultidrawBackend.parse("native-ext")) + assertEquals(MultidrawBackend.MultiArrays, MultidrawBackend.parse("multi-arrays")) assertNull(MultidrawBackend.parse("nonsense")) assertNull(MultidrawBackend.parse("")) assertNull(MultidrawBackend.parse(null)) @@ -258,15 +258,40 @@ class MGConfigCodecTest { @Test fun `the global disable list round-trips as a comma separated name list`() { val decoded = MGConfigCodec.decode( - parse("""{"multidrawDisableBackends":"compute, native ; nonsense, auto"}""") + parse("""{"multidrawDisableBackends":"compute, multibasevertex ; nonsense, auto"}""") ) // 无法识别的名字和 auto 都被丢弃,与 native 的处理一致 assertEquals( - setOf(MultidrawBackend.Native, MultidrawBackend.Compute), + setOf(MultidrawBackend.MultiBaseVertex, MultidrawBackend.Compute), decoded.multidraw.disabledBackends, ) - assertEquals("native,compute", encode(decoded).get("multidrawDisableBackends").asString) + assertEquals( + "multibasevertex,compute", + encode(decoded).get("multidrawDisableBackends").asString, + ) + } + + @Test + fun `backend keys are exactly the names native accepts`() { + // 这些名字是和 native 的 k_md_backend_names 共享的契约,改了就对不上了。 + assertEquals( + listOf( + "auto", "unroll", "basevertex", "indirect", + "multiarrays", "multibasevertex", "multiindirect", "compute", + ), + MultidrawBackend.entries.map { it.key }, + ) + assertEquals( + listOf( + "multidrawModeArrays", + "multidrawModeElements", + "multidrawModeElementsBaseVertex", + "multidrawModeArraysIndirect", + "multidrawModeElementsIndirect", + ), + MultidrawEntry.entries.map { it.key }, + ) } @Test From 3858ea200481ae22f3e5a615c2a63cd9a505dbc5 Mon Sep 17 00:00:00 2001 From: BZLZHH Date: Thu, 6 Aug 2026 05:13:39 -0400 Subject: [PATCH 04/67] [Build] (compose): switch the toolchain from View/XML to Compose + Miuix Kotlin 2.1.10 -> 2.3.20 with the compose compiler plugin, Compose BOM 2026.06.01, activity-compose, lifecycle-runtime-compose, documentfile and miuix-ui 0.9.0. Drops appcompat/constraintlayout/google-material and viewBinding, moves kotlinOptions to the compilerOptions DSL, and reduces the window theme to a plain NoActionBar platform theme now that Compose paints everything and the Activity owns edge-to-edge. --- app/build.gradle.kts | 21 +++++++++++++++------ app/src/main/res/values/themes.xml | 5 +++-- gradle/libs.versions.toml | 26 +++++++++++++++++--------- 3 files changed, 35 insertions(+), 17 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a6434f40..25b63bad 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) } android { @@ -81,12 +82,14 @@ android { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } - kotlinOptions { - jvmTarget = "11" + kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } } buildFeatures { buildConfig = true - viewBinding = true + compose = true } packaging { jniLibs { @@ -97,9 +100,15 @@ android { dependencies { implementation(libs.gson) - implementation(libs.appcompat) - implementation(libs.constraintlayout) - implementation(libs.google.material) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.documentfile) + implementation(libs.miuix.ui) // 协程和 lifecycleScope 以前是从 appcompat 传递依赖里蹭来的,这里显式声明。 implementation(libs.coroutines.android) implementation(libs.lifecycle.runtime.ktx) diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 3357ea97..8b5f567c 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,5 +1,6 @@ -