From ed61a5942cd94d0f29e49f3b0684b7f7edd5ba01 Mon Sep 17 00:00:00 2001 From: Ap_Tx <3045929398@qq.com> Date: Sat, 3 Oct 2026 22:17:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=B7=A8=E5=AE=9E=E4=BE=8B=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E7=A7=BB=E6=A4=8D=20WIP=20=E6=A3=80=E6=9F=A5=E7=82=B9?= =?UTF-8?q?=EF=BC=88=E7=AC=AC1=E8=BD=AE+2a+2b+synced=5Fservers=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 从 Axolotl 移植跨实例同步子系统,当前进度: - 第1轮:synced_options/files.rs + mod.rs 骨架,state 层补齐 SyncedOption/InstanceSyncedOptions/CanonicalValue/GameOptionKind 等 - 2a:game_options 的 api_types/options_file/catalog(10文件) - 2b:game_options 除 resource_pack_options/local_settings_editor 外的全部(settings_editor/write_shared_settings/fullscreen/launch_overrides/instance_support/read_instance_changes/pack_updates/source_selection + locales/5文件);State 补 synced_options_lock/game_locale_indexer/lock_synced_options - synced_servers:6 文件全部搬入 - 35+20 处 sqlx::query! 宏改写为非宏 query+bind 已知未完成:synced_servers::operations 依赖 synced_options::instance_option_supported(属 orchestration,第5轮),当前编译不通。 --- packages/app-lib/src/api/instance/mod.rs | 2 + .../src/api/instance/synced_options/files.rs | 338 ++++++ .../synced_options/game_options/api_types.rs | 203 ++++ .../game_options/catalog/accessibility.rs | 207 ++++ .../game_options/catalog/chat.rs | 194 +++ .../game_options/catalog/controls.rs | 413 +++++++ .../game_options/catalog/data_versions.rs | 127 ++ .../catalog/known_vanilla_keys.rs | 255 ++++ .../catalog/legacy_key_bindings.rs | 112 ++ .../game_options/catalog/mod.rs | 452 +++++++ .../game_options/catalog/sound.rs | 177 +++ .../game_options/catalog/version_changes.rs | 969 +++++++++++++++ .../game_options/catalog/video.rs | 450 +++++++ .../synced_options/game_options/fullscreen.rs | 149 +++ .../game_options/instance_support.rs | 498 ++++++++ .../game_options/launch_overrides.rs | 76 ++ .../game_options/locales/archive.rs | 272 +++++ .../game_options/locales/mod.rs | 581 +++++++++ .../game_options/locales/origins.rs | 144 +++ .../game_options/locales/sources.rs | 207 ++++ .../game_options/locales/storage.rs | 132 +++ .../synced_options/game_options/mod.rs | 73 ++ .../game_options/options_file.rs | 360 ++++++ .../game_options/pack_updates.rs | 302 +++++ .../game_options/read_instance_changes.rs | 371 ++++++ .../game_options/settings_editor.rs | 522 +++++++++ .../game_options/source_selection.rs | 409 +++++++ .../game_options/write_shared_settings.rs | 393 +++++++ .../src/api/instance/synced_options/mod.rs | 14 + .../api/instance/synced_options/pending.rs | 104 ++ .../src/api/instance/synced_servers/codec.rs | 112 ++ .../src/api/instance/synced_servers/mod.rs | 24 + .../api/instance/synced_servers/modpack.rs | 219 ++++ .../api/instance/synced_servers/operations.rs | 1037 +++++++++++++++++ .../api/instance/synced_servers/storage.rs | 369 ++++++ .../src/api/instance/synced_servers/types.rs | 131 +++ packages/app-lib/src/launcher/mod.rs | 15 + packages/app-lib/src/state/dirs.rs | 7 + packages/app-lib/src/state/game_options.rs | 68 ++ .../src/state/instances/model/game_options.rs | 52 + .../src/state/instances/model/instance.rs | 44 + .../app-lib/src/state/instances/model/mod.rs | 2 + packages/app-lib/src/state/mod.rs | 21 + 43 files changed, 10607 insertions(+) create mode 100644 packages/app-lib/src/api/instance/synced_options/files.rs create mode 100644 packages/app-lib/src/api/instance/synced_options/game_options/api_types.rs create mode 100644 packages/app-lib/src/api/instance/synced_options/game_options/catalog/accessibility.rs create mode 100644 packages/app-lib/src/api/instance/synced_options/game_options/catalog/chat.rs create mode 100644 packages/app-lib/src/api/instance/synced_options/game_options/catalog/controls.rs create mode 100644 packages/app-lib/src/api/instance/synced_options/game_options/catalog/data_versions.rs create mode 100644 packages/app-lib/src/api/instance/synced_options/game_options/catalog/known_vanilla_keys.rs create mode 100644 packages/app-lib/src/api/instance/synced_options/game_options/catalog/legacy_key_bindings.rs create mode 100644 packages/app-lib/src/api/instance/synced_options/game_options/catalog/mod.rs create mode 100644 packages/app-lib/src/api/instance/synced_options/game_options/catalog/sound.rs create mode 100644 packages/app-lib/src/api/instance/synced_options/game_options/catalog/version_changes.rs create mode 100644 packages/app-lib/src/api/instance/synced_options/game_options/catalog/video.rs create mode 100644 packages/app-lib/src/api/instance/synced_options/game_options/fullscreen.rs create mode 100644 packages/app-lib/src/api/instance/synced_options/game_options/instance_support.rs create mode 100644 packages/app-lib/src/api/instance/synced_options/game_options/launch_overrides.rs create mode 100644 packages/app-lib/src/api/instance/synced_options/game_options/locales/archive.rs create mode 100644 packages/app-lib/src/api/instance/synced_options/game_options/locales/mod.rs create mode 100644 packages/app-lib/src/api/instance/synced_options/game_options/locales/origins.rs create mode 100644 packages/app-lib/src/api/instance/synced_options/game_options/locales/sources.rs create mode 100644 packages/app-lib/src/api/instance/synced_options/game_options/locales/storage.rs create mode 100644 packages/app-lib/src/api/instance/synced_options/game_options/mod.rs create mode 100644 packages/app-lib/src/api/instance/synced_options/game_options/options_file.rs create mode 100644 packages/app-lib/src/api/instance/synced_options/game_options/pack_updates.rs create mode 100644 packages/app-lib/src/api/instance/synced_options/game_options/read_instance_changes.rs create mode 100644 packages/app-lib/src/api/instance/synced_options/game_options/settings_editor.rs create mode 100644 packages/app-lib/src/api/instance/synced_options/game_options/source_selection.rs create mode 100644 packages/app-lib/src/api/instance/synced_options/game_options/write_shared_settings.rs create mode 100644 packages/app-lib/src/api/instance/synced_options/mod.rs create mode 100644 packages/app-lib/src/api/instance/synced_options/pending.rs create mode 100644 packages/app-lib/src/api/instance/synced_servers/codec.rs create mode 100644 packages/app-lib/src/api/instance/synced_servers/mod.rs create mode 100644 packages/app-lib/src/api/instance/synced_servers/modpack.rs create mode 100644 packages/app-lib/src/api/instance/synced_servers/operations.rs create mode 100644 packages/app-lib/src/api/instance/synced_servers/storage.rs create mode 100644 packages/app-lib/src/api/instance/synced_servers/types.rs create mode 100644 packages/app-lib/src/state/game_options.rs create mode 100644 packages/app-lib/src/state/instances/model/game_options.rs diff --git a/packages/app-lib/src/api/instance/mod.rs b/packages/app-lib/src/api/instance/mod.rs index 8f5117c..0cf64a4 100644 --- a/packages/app-lib/src/api/instance/mod.rs +++ b/packages/app-lib/src/api/instance/mod.rs @@ -16,6 +16,8 @@ mod run; mod screenshot_groups; mod screenshots; mod upgrade; +pub(crate) mod synced_options; +pub(crate) mod synced_servers; pub use self::backup::{ BackupDeleteSummary, BackupExclusion, BackupExclusionKind, BackupOperation, diff --git a/packages/app-lib/src/api/instance/synced_options/files.rs b/packages/app-lib/src/api/instance/synced_options/files.rs new file mode 100644 index 0000000..db6222e --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/files.rs @@ -0,0 +1,338 @@ +use crate::state::{InstanceInstallStage, InstanceMetadata, SyncedOption}; +use crate::util::io; +use crate::{ErrorKind, State}; +use quartz_nbt::NbtCompound; +use sha1_smol::Sha1; +use std::io::Cursor; +use std::path::{Path, PathBuf}; + +pub(in crate::api::instance) struct SyncCheckpoint { + pub expected_sha1: String, + pub merge_base: Option>, + pub source_revision: i64, + pub status: CheckpointStatus, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(in crate::api::instance) enum CheckpointStatus { + Pending, + Ready, +} + +impl CheckpointStatus { + fn from_str(value: &str) -> Option { + match value { + "pending" => Some(Self::Pending), + "ready" => Some(Self::Ready), + _ => None, + } + } +} + +#[derive(Clone, Copy, Debug)] +pub(in crate::api::instance) enum LinkMode { + Symbolic, + #[cfg(windows)] + Hard, + #[cfg(windows)] + Copy, +} + +impl LinkMode { + fn as_str(self) -> &'static str { + match self { + Self::Symbolic => "symbolic", + #[cfg(windows)] + Self::Hard => "hard", + #[cfg(windows)] + Self::Copy => "copy", + } + } +} + +pub(in crate::api::instance) fn safe_instance_id(instance_id: &str) -> String { + instance_id.replace([':', '/', '\\'], "_") +} + +pub(crate) fn instance_game_dir( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result { + crate::state::instances::commands::instance_content_root( + &state.directories, + &metadata.instance, + ) +} + +pub(crate) fn synced_options_path(state: &State) -> PathBuf { + state.directories.synced_options_dir() +} + +pub(in crate::api::instance) fn sync_files_are_protected( + metadata: &InstanceMetadata, +) -> bool { + matches!( + metadata.instance.install_stage, + InstanceInstallStage::MinecraftInstalling + | InstanceInstallStage::PackInstalling + ) +} + +pub(in crate::api::instance) async fn instance_is_running( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result { + crate::state::instance_has_running_process(&metadata.instance.id, state) + .await +} + +pub(in crate::api::instance) fn option_can_apply_while_running( + option: SyncedOption, +) -> bool { + matches!( + option, + SyncedOption::GameOptions + | SyncedOption::Screenshots + | SyncedOption::ResourcePacks + ) +} + +pub(in crate::api::instance) fn instance_option_enabled( + metadata: &InstanceMetadata, + option: SyncedOption, +) -> bool { + match option { + SyncedOption::GameOptions => metadata.synced_options.game_options, + SyncedOption::CommandHistory => metadata.synced_options.command_history, + SyncedOption::MultiplayerServers => { + metadata.synced_options.multiplayer_servers + } + SyncedOption::CreativeHotbars => { + metadata.synced_options.creative_hotbars + } + SyncedOption::Screenshots => metadata.synced_options.screenshots, + SyncedOption::ResourcePacks => metadata.synced_options.resource_packs, + SyncedOption::DataPacks => metadata.synced_options.data_packs, + } +} + +pub(in crate::api::instance) async fn ensure_link( + source: &Path, + target: &Path, +) -> crate::Result { + if let Some(parent) = target.parent() { + io::create_dir_all(parent).await?; + } + if tokio::fs::symlink_metadata(target) + .await + .is_ok_and(|metadata| metadata.file_type().is_symlink()) + && tokio::fs::read_link(target) + .await + .is_ok_and(|current| current == source) + { + return Ok(LinkMode::Symbolic); + } + if tokio::fs::symlink_metadata(target).await.is_ok() { + io::remove_file(target).await?; + } + + #[cfg(unix)] + { + tokio::fs::symlink(source, target).await?; + Ok(LinkMode::Symbolic) + } + #[cfg(windows)] + { + if tokio::fs::symlink_file(source, target).await.is_ok() { + return Ok(LinkMode::Symbolic); + } + if tokio::fs::hard_link(source, target).await.is_ok() { + return Ok(LinkMode::Hard); + } + io::copy(source, target).await?; + Ok(LinkMode::Copy) + } +} + +pub(in crate::api::instance) async fn detach_link( + source: &Path, + target: &Path, +) -> crate::Result<()> { + let target_metadata = tokio::fs::symlink_metadata(target).await.ok(); + let contents = if target_metadata.is_some() && target.exists() { + Some(io::read(target).await?) + } else if source.exists() { + Some(io::read(source).await?) + } else { + None + }; + if target_metadata.is_some() { + io::remove_file(target).await?; + } + if let Some(contents) = contents { + io::write(target, contents).await?; + } + Ok(()) +} + +pub(in crate::api::instance) async fn begin_checkpoint( + instance_id: &str, + option: SyncedOption, + variant: &str, + expected_sha1: &str, + merge_base: Option<&[u8]>, + source_revision: i64, + state: &State, +) -> crate::Result<()> { + let option_name = option.as_str(); + sqlx::query( + " + INSERT INTO instance_sync_checkpoints + (instance_id, feature, variant, expected_sha1, merge_base, + source_revision, status, link_mode) + VALUES (?, ?, ?, ?, ?, ?, 'pending', NULL) + ON CONFLICT(instance_id, feature, variant) DO UPDATE SET + expected_sha1 = excluded.expected_sha1, + merge_base = excluded.merge_base, + source_revision = excluded.source_revision, + status = 'pending', + link_mode = NULL + ", + ) + .bind(instance_id) + .bind(option_name) + .bind(variant) + .bind(expected_sha1) + .bind(merge_base) + .bind(source_revision) + .execute(&state.pool) + .await?; + Ok(()) +} + +pub(in crate::api::instance) async fn finish_checkpoint( + instance_id: &str, + option: SyncedOption, + variant: &str, + mode: LinkMode, + state: &State, +) -> crate::Result<()> { + let option_name = option.as_str(); + let link_mode = mode.as_str(); + sqlx::query( + " + UPDATE instance_sync_checkpoints + SET status = 'ready', link_mode = ? + WHERE instance_id = ? AND feature = ? AND variant = ? + ", + ) + .bind(link_mode) + .bind(instance_id) + .bind(option_name) + .bind(variant) + .execute(&state.pool) + .await?; + Ok(()) +} + +pub(in crate::api::instance) async fn finish_plain_checkpoint( + instance_id: &str, + option: SyncedOption, + variant: &str, + state: &State, +) -> crate::Result<()> { + let option_name = option.as_str(); + sqlx::query( + " + UPDATE instance_sync_checkpoints + SET status = 'ready', link_mode = NULL + WHERE instance_id = ? AND feature = ? AND variant = ? + ", + ) + .bind(instance_id) + .bind(option_name) + .bind(variant) + .execute(&state.pool) + .await?; + Ok(()) +} + +pub(in crate::api::instance) async fn checkpoint( + instance_id: &str, + option: SyncedOption, + variant: &str, + state: &State, +) -> crate::Result> { + let option_name = option.as_str(); + let row = sqlx::query( + r#" + SELECT expected_sha1, merge_base, source_revision, status + FROM instance_sync_checkpoints + WHERE instance_id = ? AND feature = ? AND variant = ? + "#, + ) + .bind(instance_id) + .bind(option_name) + .bind(variant) + .fetch_optional(&state.pool) + .await?; + use sqlx::Row; + row.map(|row| { + let status: String = row.get("status"); + Ok(SyncCheckpoint { + expected_sha1: row.get("expected_sha1"), + merge_base: row.get("merge_base"), + source_revision: row.get("source_revision"), + status: CheckpointStatus::from_str(&status).ok_or_else(|| { + ErrorKind::InputError(format!( + "Unknown sync checkpoint status {status}" + )) + })?, + }) + }) + .transpose() +} + +pub(in crate::api::instance) async fn sha1_file( + path: &Path, +) -> crate::Result { + Ok(Sha1::from(io::read(path).await?).digest().to_string()) +} + +pub(in crate::api::instance) fn sha1_bytes(bytes: &[u8]) -> String { + Sha1::from(bytes).digest().to_string() +} + +pub(in crate::api::instance) async fn read_nbt_file( + path: &Path, +) -> crate::Result { + let bytes = io::read(path).await?; + let (root, _) = quartz_nbt::io::read_nbt( + &mut Cursor::new(bytes), + quartz_nbt::io::Flavor::Uncompressed, + )?; + Ok(root) +} + +pub(in crate::api::instance) fn nbt_to_bytes( + root: &NbtCompound, +) -> crate::Result> { + let mut bytes = Vec::new(); + quartz_nbt::io::write_nbt( + &mut bytes, + None, + root, + quartz_nbt::io::Flavor::Uncompressed, + )?; + Ok(bytes) +} + +pub(in crate::api::instance) fn nbt_from_bytes( + bytes: Vec, +) -> crate::Result { + let (root, _) = quartz_nbt::io::read_nbt( + &mut Cursor::new(bytes), + quartz_nbt::io::Flavor::Uncompressed, + )?; + Ok(root) +} diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/api_types.rs b/packages/app-lib/src/api/instance/synced_options/game_options/api_types.rs new file mode 100644 index 0000000..0a67f32 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/api_types.rs @@ -0,0 +1,203 @@ +use crate::state::{CanonicalValue, GameOptionKind}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GameOptionsSourceIssue { + InstallingOrUpdating, + UnsupportedVersion, + MissingOptionsFile, + NoSyncableSettings, + UnreadableOptionsFile, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct GameOptionsSourceCandidate { + pub source_id: String, + pub instance_id: String, + pub name: String, + pub icon_path: Option, + pub game_version: Option, + pub eligible: bool, + pub disabled_reason: Option, + pub recognized_setting_count: u32, + pub custom_setting_count: u32, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct GameSettingCategory { + pub id: String, + pub is_custom: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct GameOptionEditorDefinition { + #[serde(rename = "type")] + pub type_: String, + pub min: Option, + pub max: Option, + pub step: Option, + pub unit: Option, + pub choices: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct GameOptionEditorChoice { + pub value: String, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GameOptionCompatibilityStatus { + Ready, + Deferred, + WaitingForFile, + WaitingForBase, + NotAvailable, + UnsupportedValue, + Unmappable, + CatalogUncovered, + Controlled, + Degraded, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GameOptionMappingKind { + Direct, + Legacy, + Migrated, + Lossless, + Lossy, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GameOptionCompatibilityReason { + LauncherControlled, + CatalogUncovered, + InspectionFailed, + WaitingForOptionsFile, + UnsupportedValue, + MigratesOnWrite, + WaitingForCompatibleBase, + MissingSetting, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct GameOptionCompatibilityBucket { + pub instance_count: u32, + pub write_keys: Vec, + pub eventual_keys: Vec, + pub game_versions: Vec, + pub status: GameOptionCompatibilityStatus, + pub mapping: Option, + pub reason: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct GameOptionCompatibility { + pub total_participating: u32, + pub will_receive: u32, + pub write_now: u32, + pub left_local: u32, + pub buckets: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GameOptionValueState { + Canonical, + UniformLocal, + Mixed, + Unset, + Invalid, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GameOptionValidationIssue { + MissingValue, + NoCompatibleInstances, + InvalidValue, + ChangedSinceOpened, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct EditableGameSetting { + pub option_id: String, + pub category_id: String, + pub kind: GameOptionKind, + pub raw_key: Option, + pub sync_enabled: bool, + pub canonical_value: Option, + pub value_state: GameOptionValueState, + pub option_revision: u64, + pub editor: GameOptionEditorDefinition, + pub compatibility: GameOptionCompatibility, + pub validation_error: Option, + pub controlled: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct GameSettingsEditorState { + pub summary_revision: String, + pub canonical_revision: u64, + pub catalog_revision: u32, + pub total_participating: u32, + pub categories: Vec, + pub settings: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct UpdateGameSettingsRequest { + pub expected_summary_revision: String, + pub expected_canonical_revision: u64, + pub expected_catalog_revision: u32, + pub changes: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct GameSettingChange { + pub option_id: String, + pub base_option_revision: u64, + pub sync_enabled: Option, + #[serde( + default, + with = "serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub canonical_value: Option>, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct SaveGameSettingsResult { + pub state: Option, + pub applied: u32, + pub migrated: u32, + pub deferred: u32, + pub unsupported: u32, + pub failed: u32, + pub conflicts: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum SyncOutcome { + Applied, + Migrated, + Unchanged, + Deferred, + WaitingForFile, +} + +pub(super) fn canonical_values_equal( + left: &CanonicalValue, + right: &CanonicalValue, +) -> bool { + match (left, right) { + (CanonicalValue::Decimal(left), CanonicalValue::Decimal(right)) => { + left.parse::().ok() == right.parse::().ok() + } + _ => left == right, + } +} \ No newline at end of file diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/catalog/accessibility.rs b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/accessibility.rs new file mode 100644 index 0000000..ab30a7f --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/accessibility.rs @@ -0,0 +1,207 @@ +use super::{ + BOOL, SettingEditor, SupportedSetting, UNIT_INTERVAL, ValueEncoding, + ranged_setting, version_changes, versioned_setting, +}; + +const NARRATOR: &[&str] = &["0", "1", "2", "3"]; + +pub(super) const SETTINGS: &[SupportedSetting] = &[ + ranged_setting( + "narrator", + &["narrator"], + "1.12", + "26.3", + "accessibility", + true, + SettingEditor::Enum(NARRATOR), + ValueEncoding::Enum(NARRATOR), + ), + ranged_setting( + "subtitles", + &["showSubtitles"], + "1.9", + "26.3", + "accessibility", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "high_contrast", + &["highContrast"], + "1.19.4", + "26.3", + "accessibility", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "dark_splash", + &["darkMojangStudiosBackground"], + "1.17", + "26.3", + "accessibility", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "notification_time", + &["notificationDisplayTime"], + "1.19.4", + "26.3", + "accessibility", + true, + SettingEditor::Decimal { + min: 0.5, + max: 10.0, + step: 0.5, + unit: Some("multiplier"), + }, + ValueEncoding::Decimal, + ), + ranged_setting( + "fov_effects", + &["fovEffectScale"], + "1.16.2", + "26.3", + "accessibility", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "screen_effects", + &["screenEffectScale"], + "1.16.2", + "26.3", + "accessibility", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "darkness_pulsing", + &["darknessEffectScale"], + "1.19", + "26.3", + "accessibility", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "damage_tilt", + &["damageTiltStrength"], + "1.19.4", + "26.3", + "accessibility", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "glint_speed", + &["glintSpeed"], + "1.19.4", + "26.3", + "accessibility", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "glint_strength", + &["glintStrength"], + "1.19.4", + "26.3", + "accessibility", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "hide_lightning_flashes", + &["hideLightningFlashes"], + "1.18", + "26.3", + "accessibility", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "hide_splash_texts", + &["hideSplashTexts"], + "1.20.3", + "26.3", + "accessibility", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "high_contrast_outline", + &["highContrastBlockOutline"], + "1.21.2", + "26.3", + "accessibility", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "narrator_hotkey", + &["narratorHotkey"], + "1.20.2", + "26.3", + "accessibility", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "autosave_indicator", + &["showAutosaveIndicator"], + "1.18", + "26.3", + "accessibility", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "panorama_speed", + &["panoramaScrollSpeed"], + "1.19.3", + "26.3", + "accessibility", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + versioned_setting( + "menu_background_blur", + &["menuBackgroundBlurriness"], + version_changes::MENU_BACKGROUND_BLUR_KEYS, + "accessibility", + true, + SettingEditor::Integer { + min: 0, + max: 10, + step: 1, + }, + ValueEncoding::MenuBackgroundBlur, + ), + ranged_setting( + "rotate_with_minecart", + &["rotateWithMinecart"], + "1.21.2", + "26.3", + "accessibility", + true, + BOOL, + ValueEncoding::Bool, + ), +]; \ No newline at end of file diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/catalog/chat.rs b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/chat.rs new file mode 100644 index 0000000..ddaf03f --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/chat.rs @@ -0,0 +1,194 @@ +use super::{ + BOOL, SettingEditor, SupportedSetting, UNIT_INTERVAL, ValueEncoding, + ranged_setting, version_changes, versioned_setting, +}; + +const VISIBILITY: &[&str] = &["0", "1", "2"]; +const CHAT_PREVIEW: &[&str] = &["0", "1", "2"]; + +pub(super) const SETTINGS: &[SupportedSetting] = &[ + ranged_setting( + "chat_visibility", + &["chatVisibility"], + "1.3.1", + "26.3", + "chat", + true, + SettingEditor::Enum(VISIBILITY), + ValueEncoding::Enum(VISIBILITY), + ), + ranged_setting( + "chat_colors", + &["chatColors"], + "1.3.1", + "26.3", + "chat", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "chat_links", + &["chatLinks"], + "1.3.1", + "26.3", + "chat", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "chat_links_prompt", + &["chatLinksPrompt"], + "1.3.1", + "26.3", + "chat", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "chat_opacity", + &["chatOpacity"], + "1.3.1", + "26.3", + "chat", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "chat_scale", + &["chatScale"], + "1.5.1", + "26.3", + "chat", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "chat_width", + &["chatWidth"], + "1.5.1", + "26.3", + "chat", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "focused_chat_height", + &["chatHeightFocused"], + "1.5.1", + "26.3", + "chat", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "unfocused_chat_height", + &["chatHeightUnfocused"], + "1.5.1", + "26.3", + "chat", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "chat_line_spacing", + &["chatLineSpacing"], + "1.16", + "26.3", + "chat", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "chat_delay", + &["chatDelay"], + "1.16", + "26.3", + "chat", + true, + SettingEditor::Decimal { + min: 0.0, + max: 6.0, + step: 0.1, + unit: Some("seconds"), + }, + ValueEncoding::Decimal, + ), + ranged_setting( + "text_background_opacity", + &["textBackgroundOpacity"], + "1.14", + "26.3", + "chat", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "chat_background_only", + &["backgroundForChatOnly"], + "1.14", + "26.3", + "chat", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "auto_suggestions", + &["autoSuggestions"], + "1.13", + "26.3", + "chat", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "secure_chat_only", + &["onlyShowSecureChat"], + "1.19", + "26.3", + "chat", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "save_chat_drafts", + &["saveChatDrafts"], + "1.21.9", + "26.3", + "chat", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "hide_matched_names", + &["hideMatchedNames"], + "1.16.4", + "26.3", + "chat", + true, + BOOL, + ValueEncoding::Bool, + ), + versioned_setting( + "chat_preview", + &["chatPreview"], + version_changes::CHAT_PREVIEW_KEYS, + "chat", + true, + SettingEditor::Enum(CHAT_PREVIEW), + ValueEncoding::ChatPreview, + ), +]; \ No newline at end of file diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/catalog/controls.rs b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/controls.rs new file mode 100644 index 0000000..72003d0 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/controls.rs @@ -0,0 +1,413 @@ +use super::{ + BOOL, SettingEditor, SupportedSetting, ValueEncoding, ranged_setting, + setting, version_changes, versioned_setting, +}; + +macro_rules! key_setting { + ($id:literal, $key:literal, $since:literal, $until:literal) => { + ranged_setting( + $id, + &[$key], + $since, + $until, + "controls", + true, + SettingEditor::KeyBinding, + ValueEncoding::KeyBinding, + ) + }; +} + +pub(super) const SETTINGS: &[SupportedSetting] = &[ + setting( + "sensitivity", + &["mouseSensitivity"], + "controls", + true, + SettingEditor::Decimal { + min: 0.0, + max: 1.0, + step: 0.005, + unit: Some("percent"), + }, + ValueEncoding::Decimal, + ), + setting( + "invert_mouse", + &["invertYMouse"], + "controls", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "invert_horizontal_mouse", + &["invertXMouse"], + "1.21.9", + "26.3", + "controls", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "auto_jump", + &["autoJump"], + "1.10", + "26.3", + "controls", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "toggle_crouch", + &["toggleCrouch"], + "1.15", + "26.3", + "controls", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "toggle_sprint", + &["toggleSprint"], + "1.15", + "26.3", + "controls", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "toggle_attack", + &["toggleAttack"], + "1.21.9", + "26.3", + "controls", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "toggle_use", + &["toggleUse"], + "1.21.9", + "26.3", + "controls", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "discrete_mouse_scroll", + &["discrete_mouse_scroll"], + "1.14", + "26.3", + "controls", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "mouse_wheel_sensitivity", + &["mouseWheelSensitivity"], + "1.13", + "26.3", + "controls", + true, + SettingEditor::UnboundedDecimal, + ValueEncoding::Decimal, + ), + ranged_setting( + "raw_mouse_input", + &["rawMouseInput"], + "1.14.4", + "26.2", + "controls", + false, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "touchscreen", + &["touchscreen"], + "1.4.4", + "26.1.2", + "controls", + false, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "allow_cursor_changes", + &["allowCursorChanges"], + "1.21.9", + "26.3", + "controls", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "sprint_window", + &["sprintWindow"], + "1.21.9", + "26.3", + "controls", + true, + SettingEditor::UnboundedInteger, + ValueEncoding::Integer, + ), + ranged_setting( + "operator_items_tab", + &["operatorItemsTab"], + "1.19.3", + "26.3", + "controls", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "ctrl_click_right_click", + &["ctrlClickEmulatesRightClick"], + "26.3", + "26.3", + "controls", + false, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "quit_shortcuts", + &["quitShortcuts"], + "26.3", + "26.3", + "controls", + false, + BOOL, + ValueEncoding::Bool, + ), + key_setting!("key.forward", "key_key.forward", "1.0", "26.3"), + key_setting!("key.left", "key_key.left", "1.0", "26.3"), + key_setting!("key.back", "key_key.back", "1.0", "26.3"), + key_setting!("key.right", "key_key.right", "1.0", "26.3"), + key_setting!("key.jump", "key_key.jump", "1.0", "26.3"), + key_setting!("key.sneak", "key_key.sneak", "1.0", "26.3"), + key_setting!("key.sprint", "key_key.sprint", "1.7.2", "26.3"), + key_setting!("key.inventory", "key_key.inventory", "1.0", "26.3"), + versioned_setting( + "key.swap_offhand", + &["key_key.swapOffhand", "key_key.swapHands"], + version_changes::SWAP_OFFHAND_KEYS, + "controls", + true, + SettingEditor::KeyBinding, + ValueEncoding::KeyBinding, + ), + key_setting!("key.drop", "key_key.drop", "1.0", "26.3"), + key_setting!("key.use", "key_key.use", "1.0", "26.3"), + key_setting!("key.attack", "key_key.attack", "1.0", "26.3"), + key_setting!("key.pick_item", "key_key.pickItem", "1.0", "26.3"), + key_setting!("key.chat", "key_key.chat", "1.0", "26.3"), + key_setting!("key.player_list", "key_key.playerlist", "1.0", "26.3"), + key_setting!("key.command", "key_key.command", "1.3.1", "26.3"), + key_setting!("key.screenshot", "key_key.screenshot", "1.7.2", "26.3"), + key_setting!( + "key.perspective", + "key_key.togglePerspective", + "1.7.2", + "26.3" + ), + key_setting!("key.fullscreen", "key_key.fullscreen", "1.7.3", "26.3"), + key_setting!("key.advancements", "key_key.advancements", "1.12", "26.3"), + key_setting!("key.smooth_camera", "key_key.smoothCamera", "1.7.2", "26.3"), + key_setting!( + "key.spectator_outlines", + "key_key.spectatorOutlines", + "1.8", + "26.3" + ), + key_setting!( + "key.save_toolbar", + "key_key.saveToolbarActivator", + "1.12", + "26.3" + ), + key_setting!( + "key.load_toolbar", + "key_key.loadToolbarActivator", + "1.12", + "26.3" + ), + key_setting!( + "key.social_interactions", + "key_key.socialInteractions", + "1.16.4", + "26.3" + ), + key_setting!( + "key.quick_actions", + "key_key.quickActions", + "1.21.6", + "26.3" + ), + key_setting!( + "key.spectator_hotbar", + "key_key.spectatorHotbar", + "1.21.9", + "26.3" + ), + key_setting!("key.friends", "key_key.friends", "26.2", "26.3"), + key_setting!("key.toggle_gui", "key_key.toggleGui", "1.21.11", "26.3"), + key_setting!( + "key.toggle_spectator_shader", + "key_key.toggleSpectatorShaderEffects", + "1.21.11", + "26.3" + ), + key_setting!("key.hotbar.1", "key_key.hotbar.1", "1.7.2", "26.3"), + key_setting!("key.hotbar.2", "key_key.hotbar.2", "1.7.2", "26.3"), + key_setting!("key.hotbar.3", "key_key.hotbar.3", "1.7.2", "26.3"), + key_setting!("key.hotbar.4", "key_key.hotbar.4", "1.7.2", "26.3"), + key_setting!("key.hotbar.5", "key_key.hotbar.5", "1.7.2", "26.3"), + key_setting!("key.hotbar.6", "key_key.hotbar.6", "1.7.2", "26.3"), + key_setting!("key.hotbar.7", "key_key.hotbar.7", "1.7.2", "26.3"), + key_setting!("key.hotbar.8", "key_key.hotbar.8", "1.7.2", "26.3"), + key_setting!("key.hotbar.9", "key_key.hotbar.9", "1.7.2", "26.3"), + key_setting!( + "key.debug.overlay", + "key_key.debug.overlay", + "1.21.11", + "26.3" + ), + key_setting!( + "key.debug.modifier", + "key_key.debug.modifier", + "1.21.11", + "26.3" + ), + key_setting!( + "key.debug.reload_chunks", + "key_key.debug.reloadChunk", + "1.21.11", + "26.3" + ), + key_setting!( + "key.debug.hitboxes", + "key_key.debug.showHitboxes", + "1.21.11", + "26.3" + ), + key_setting!( + "key.debug.clear_chat", + "key_key.debug.clearChat", + "1.21.11", + "26.3" + ), + key_setting!("key.debug.crash", "key_key.debug.crash", "1.21.11", "26.3"), + key_setting!( + "key.debug.chunk_borders", + "key_key.debug.showChunkBorders", + "1.21.11", + "26.3" + ), + key_setting!( + "key.debug.advanced_tooltips", + "key_key.debug.showAdvancedTooltips", + "1.21.11", + "26.3" + ), + key_setting!( + "key.debug.copy_recreate_command", + "key_key.debug.copyRecreateCommand", + "1.21.11", + "26.3" + ), + key_setting!( + "key.debug.spectate", + "key_key.debug.spectate", + "1.21.11", + "26.3" + ), + key_setting!( + "key.debug.switch_game_mode", + "key_key.debug.switchGameMode", + "1.21.11", + "26.3" + ), + key_setting!( + "key.debug.options", + "key_key.debug.debugOptions", + "1.21.11", + "26.3" + ), + key_setting!( + "key.debug.focus_pause", + "key_key.debug.focusPause", + "1.21.11", + "26.3" + ), + key_setting!( + "key.debug.dump_dynamic_textures", + "key_key.debug.dumpDynamicTextures", + "1.21.11", + "26.3" + ), + key_setting!( + "key.debug.reload_resource_packs", + "key_key.debug.reloadResourcePacks", + "1.21.11", + "26.3" + ), + key_setting!( + "key.debug.profiling", + "key_key.debug.profiling", + "1.21.11", + "26.3" + ), + key_setting!( + "key.debug.copy_location", + "key_key.debug.copyLocation", + "1.21.11", + "26.3" + ), + key_setting!( + "key.debug.dump_version", + "key_key.debug.dumpVersion", + "1.21.11", + "26.3" + ), + key_setting!( + "key.debug.profiling_chart", + "key_key.debug.profilingChart", + "1.21.11", + "26.3" + ), + key_setting!( + "key.debug.fps_charts", + "key_key.debug.fpsCharts", + "1.21.11", + "26.3" + ), + key_setting!( + "key.debug.network_charts", + "key_key.debug.networkCharts", + "1.21.11", + "26.3" + ), + key_setting!( + "key.debug.lightmap_texture", + "key_key.debug.lightmapTexture", + "26.1", + "26.3" + ), + key_setting!( + "key.debug.improved_transparency", + "key_key.debug.improvedTransparency", + "26.3", + "26.3" + ), +]; \ No newline at end of file diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/catalog/data_versions.rs b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/data_versions.rs new file mode 100644 index 0000000..3754e6d --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/data_versions.rs @@ -0,0 +1,127 @@ +use phf::phf_map; + +/// https://minecraft.wiki/w/Module:Protocol_version/Versions +/// Covers 1.10-pre1, which introduced the options version field, through +/// 18w47a, the last snapshot without embedded `version.json` metadata. +pub(in crate::api::instance) const LEGACY_DATA_VERSIONS: phf::Map<&str, u32> = phf_map! { + "18w47a" => 1912, + "18w46a" => 1910, + "18w45a" => 1908, + "18w44a" => 1907, + "18w43c" => 1903, + "18w43b" => 1902, + "18w43a" => 1901, + "1.13.2" => 1631, + "1.13.2-pre2" => 1630, + "1.13.2-pre1" => 1629, + "1.13.1" => 1628, + "1.13.1-pre2" => 1627, + "1.13.1-pre1" => 1626, + "18w33a" => 1625, + "18w32a" => 1623, + "18w31a" => 1622, + "18w30b" => 1621, + "18w30a" => 1620, + "1.13" => 1519, + "1.13-pre10" => 1518, + "1.13-pre9" => 1517, + "1.13-pre8" => 1516, + "1.13-pre7" => 1513, + "1.13-pre6" => 1512, + "1.13-pre5" => 1511, + "1.13-pre4" => 1504, + "1.13-pre3" => 1503, + "1.13-pre2" => 1502, + "1.13-pre1" => 1501, + "18w22c" => 1499, + "18w22b" => 1498, + "18w22a" => 1497, + "18w21b" => 1496, + "18w21a" => 1495, + "18w20c" => 1493, + "18w20b" => 1491, + "18w20a" => 1489, + "18w19b" => 1485, + "18w19a" => 1484, + "18w16a" => 1483, + "18w15a" => 1482, + "18w14b" => 1481, + "18w14a" => 1479, + "18w11a" => 1478, + "18w10d" => 1477, + "18w10c" => 1476, + "18w10b" => 1474, + "18w10a" => 1473, + "18w09a" => 1472, + "18w08b" => 1471, + "18w08a" => 1470, + "18w07c" => 1469, + "18w07b" => 1468, + "18w07a" => 1467, + "18w06a" => 1466, + "18w05a" => 1464, + "18w03b" => 1463, + "18w03a" => 1462, + "18w02a" => 1461, + "18w01a" => 1459, + "17w50a" => 1457, + "17w49b" => 1455, + "17w49a" => 1454, + "17w48a" => 1453, + "17w47b" => 1452, + "17w47a" => 1451, + "17w46a" => 1449, + "17w45b" => 1448, + "17w45a" => 1447, + "17w43b" => 1445, + "17w43a" => 1444, + "1.12.2" => 1343, + "1.12.2-pre2" => 1342, + "1.12.2-pre1" => 1341, + "1.12.1" => 1241, + "1.12.1-pre1" => 1240, + "17w31a" => 1239, + "1.12" => 1139, + "1.12-pre7" => 1138, + "1.12-pre6" => 1137, + "1.12-pre5" => 1136, + "1.12-pre4" => 1135, + "1.12-pre3" => 1134, + "1.12-pre2" => 1133, + "1.12-pre1" => 1132, + "17w18b" => 1131, + "17w18a" => 1130, + "17w17b" => 1129, + "17w17a" => 1128, + "17w16b" => 1127, + "17w16a" => 1126, + "17w15a" => 1125, + "17w14a" => 1124, + "17w13b" => 1123, + "17w13a" => 1122, + "17w06a" => 1022, + "1.11.2" => 922, + "1.11.1" => 921, + "16w50a" => 920, + "1.11" => 819, + "1.11-pre1" => 818, + "16w44a" => 817, + "16w43a" => 816, + "16w42a" => 815, + "16w41a" => 814, + "16w40a" => 813, + "16w39c" => 812, + "16w39b" => 811, + "16w39a" => 809, + "16w38a" => 807, + "16w36a" => 805, + "16w35a" => 803, + "16w33a" => 802, + "16w32b" => 801, + "16w32a" => 800, + "1.10.2" => 512, + "1.10.1" => 511, + "1.10" => 510, + "1.10-pre2" => 507, + "1.10-pre1" => 506, +}; \ No newline at end of file diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/catalog/known_vanilla_keys.rs b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/known_vanilla_keys.rs new file mode 100644 index 0000000..339159d --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/known_vanilla_keys.rs @@ -0,0 +1,255 @@ +// Never treat these Minecraft keys as settings added by mods. NEVER_SYNC_KEYS are +// not safe or useful to share; KNOWN_VANILLA_KEYS covers the vanilla catalog, +// including entries that have a dedicated editor. +pub(super) const NEVER_SYNC_KEYS: &[&str] = &[ + "version", + "resourcePacks", + "incompatibleResourcePacks", + "lastServer", + "startedCleanly", + "tutorialStep", + "joinedFirstServer", + "onboardAccessibility", + "soundDevice", + "fullscreenResolution", + "overrideWidth", + "overrideHeight", + "difficulty", + "fboEnable", + "saturation", + "streamBytesPerPixel", + "streamMicVolume", + "streamGameVolume", + "streamKbps", + "streamFps", + "streamCompression", + "streamSendMetadata", + "streamPreferredServer", + "streamChatEnabled", + "streamChatUserFilter", + "streamMicToggleBehavior", + "skin", + "key_key.streamCommercial", + "key_key.streamPauseUnpause", + "key_key.streamStartStop", + "key_key.streamToggleMic", + "streamSystemVolume", + "showInventoryAchievementHint", + "enableWeakAttacks", + "skipRealms32bitWarning", + "key_Build", + "key_Load location", + "key_Save location", + "showFrameRate", + "limitFramerate", + "key_key.fog", + "chunksPerFrame", + "uploadsPerFrame", + "onlyShowSignedChat", + "skipFriendsListPromo", + "postProcessEnable", + "soundCategory_animals", + "soundCategory_blocks", + "soundCategory_mobs", + "soundCategory_players", + "soundCategory_records", + "shieldAnimation", +]; + +pub(super) const KNOWN_VANILLA_KEYS: &[&str] = &[ + "version", + "ao", + "biomeBlendRadius", + "chunkSectionFadeInTime", + "cutoutLeaves", + "enableVsync", + "entityDistanceScaling", + "entityShadows", + "forceUnicodeFont", + "japaneseGlyphVariants", + "fov", + "fovEffectScale", + "darknessEffectScale", + "glintSpeed", + "glintStrength", + "preferredGraphicsBackend", + "graphicsPreset", + "prioritizeChunkUpdates", + "fullscreen", + "exclusiveFullscreen", + "macFullscreenMenuVisibility", + "gamma", + "guiScale", + "debugGuiScale", + "maxAnisotropyBit", + "textureFiltering", + "maxFps", + "improvedTransparency", + "inactivityFpsLimit", + "mipmapLevels", + "narrator", + "particles", + "reducedDebugInfo", + "renderClouds", + "cloudRange", + "renderDistance", + "simulationDistance", + "screenEffectScale", + "soundDevice", + "vignette", + "weatherRadius", + "autoJump", + "rotateWithMinecart", + "operatorItemsTab", + "autoSuggestions", + "chatColors", + "chatLinks", + "chatLinksPrompt", + "discrete_mouse_scroll", + "invertXMouse", + "invertYMouse", + "realmsNotifications", + "showSubtitles", + "directionalAudio", + "bobView", + "toggleCrouch", + "toggleSprint", + "toggleAttack", + "toggleUse", + "sprintWindow", + "darkMojangStudiosBackground", + "hideLightningFlashes", + "hideSplashTexts", + "mouseSensitivity", + "damageTiltStrength", + "highContrast", + "highContrastBlockOutline", + "narratorHotkey", + "resourcePacks", + "incompatibleResourcePacks", + "lastServer", + "lang", + "chatVisibility", + "chatOpacity", + "chatLineSpacing", + "textBackgroundOpacity", + "backgroundForChatOnly", + "hideServerAddress", + "advancedItemTooltips", + "pauseOnLostFocus", + "overrideWidth", + "overrideHeight", + "chatHeightFocused", + "chatDelay", + "chatHeightUnfocused", + "chatScale", + "chatWidth", + "notificationDisplayTime", + "useNativeTransport", + "mainHand", + "attackIndicator", + "tutorialStep", + "mouseWheelSensitivity", + "allowCursorChanges", + "quitShortcuts", + "ctrlClickEmulatesRightClick", + "glDebugVerbosity", + "skipMultiplayerWarning", + "hideMatchedNames", + "joinedFirstServer", + "syncChunkWrites", + "showAutosaveIndicator", + "allowServerListing", + "inGameNotification", + "sharePresence", + "onlyShowSecureChat", + "saveChatDrafts", + "panoramaScrollSpeed", + "telemetryOptInExtra", + "onboardAccessibility", + "menuBackgroundBlurriness", + "startedCleanly", + "musicToast", + "musicFrequency", + "key_key.attack", + "key_key.use", + "key_key.forward", + "key_key.left", + "key_key.back", + "key_key.right", + "key_key.jump", + "key_key.sneak", + "key_key.sprint", + "key_key.drop", + "key_key.inventory", + "key_key.chat", + "key_key.playerlist", + "key_key.pickItem", + "key_key.command", + "key_key.friends", + "key_key.socialInteractions", + "key_key.toggleGui", + "key_key.toggleSpectatorShaderEffects", + "key_key.screenshot", + "key_key.togglePerspective", + "key_key.smoothCamera", + "key_key.fullscreen", + "key_key.spectatorOutlines", + "key_key.spectatorHotbar", + "key_key.swapOffhand", + "key_key.saveToolbarActivator", + "key_key.loadToolbarActivator", + "key_key.advancements", + "key_key.quickActions", + "key_key.debug.overlay", + "key_key.debug.modifier", + "key_key.hotbar.1", + "key_key.hotbar.2", + "key_key.hotbar.3", + "key_key.hotbar.4", + "key_key.hotbar.5", + "key_key.hotbar.6", + "key_key.hotbar.7", + "key_key.hotbar.8", + "key_key.hotbar.9", + "key_key.debug.reloadChunk", + "key_key.debug.showHitboxes", + "key_key.debug.clearChat", + "key_key.debug.crash", + "key_key.debug.showChunkBorders", + "key_key.debug.showAdvancedTooltips", + "key_key.debug.copyRecreateCommand", + "key_key.debug.spectate", + "key_key.debug.switchGameMode", + "key_key.debug.debugOptions", + "key_key.debug.focusPause", + "key_key.debug.dumpDynamicTextures", + "key_key.debug.reloadResourcePacks", + "key_key.debug.profiling", + "key_key.debug.copyLocation", + "key_key.debug.dumpVersion", + "key_key.debug.profilingChart", + "key_key.debug.fpsCharts", + "key_key.debug.networkCharts", + "key_key.debug.lightmapTexture", + "key_key.debug.improvedTransparency", + "soundCategory_master", + "soundCategory_music", + "soundCategory_record", + "soundCategory_weather", + "soundCategory_block", + "soundCategory_hostile", + "soundCategory_neutral", + "soundCategory_player", + "soundCategory_ambient", + "soundCategory_voice", + "soundCategory_ui", + "modelPart_cape", + "modelPart_jacket", + "modelPart_left_sleeve", + "modelPart_right_sleeve", + "modelPart_left_pants_leg", + "modelPart_right_pants_leg", + "modelPart_hat", + "fullscreenResolution", +]; \ No newline at end of file diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/catalog/legacy_key_bindings.rs b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/legacy_key_bindings.rs new file mode 100644 index 0000000..114b2c6 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/legacy_key_bindings.rs @@ -0,0 +1,112 @@ +// Before Minecraft 1.13, options.txt stored key bindings as numbers. This table +// converts those numbers to the key names used by current versions. +pub(in crate::api::instance) const LEGACY_KEY_BINDINGS: &[(i32, &str)] = &[ + (0, "key.keyboard.unknown"), + (1, "key.keyboard.escape"), + (2, "key.keyboard.1"), + (3, "key.keyboard.2"), + (4, "key.keyboard.3"), + (5, "key.keyboard.4"), + (6, "key.keyboard.5"), + (7, "key.keyboard.6"), + (8, "key.keyboard.7"), + (9, "key.keyboard.8"), + (10, "key.keyboard.9"), + (11, "key.keyboard.0"), + (12, "key.keyboard.minus"), + (13, "key.keyboard.equal"), + (14, "key.keyboard.backspace"), + (15, "key.keyboard.tab"), + (16, "key.keyboard.q"), + (17, "key.keyboard.w"), + (18, "key.keyboard.e"), + (19, "key.keyboard.r"), + (20, "key.keyboard.t"), + (21, "key.keyboard.y"), + (22, "key.keyboard.u"), + (23, "key.keyboard.i"), + (24, "key.keyboard.o"), + (25, "key.keyboard.p"), + (26, "key.keyboard.left.bracket"), + (27, "key.keyboard.right.bracket"), + (28, "key.keyboard.enter"), + (29, "key.keyboard.left.control"), + (30, "key.keyboard.a"), + (31, "key.keyboard.s"), + (32, "key.keyboard.d"), + (33, "key.keyboard.f"), + (34, "key.keyboard.g"), + (35, "key.keyboard.h"), + (36, "key.keyboard.j"), + (37, "key.keyboard.k"), + (38, "key.keyboard.l"), + (39, "key.keyboard.semicolon"), + (40, "key.keyboard.apostrophe"), + (41, "key.keyboard.grave.accent"), + (42, "key.keyboard.left.shift"), + (43, "key.keyboard.backslash"), + (44, "key.keyboard.z"), + (45, "key.keyboard.x"), + (46, "key.keyboard.c"), + (47, "key.keyboard.v"), + (48, "key.keyboard.b"), + (49, "key.keyboard.n"), + (50, "key.keyboard.m"), + (51, "key.keyboard.comma"), + (52, "key.keyboard.period"), + (53, "key.keyboard.slash"), + (54, "key.keyboard.right.shift"), + (55, "key.keyboard.keypad.multiply"), + (56, "key.keyboard.left.alt"), + (57, "key.keyboard.space"), + (58, "key.keyboard.caps.lock"), + (59, "key.keyboard.f1"), + (60, "key.keyboard.f2"), + (61, "key.keyboard.f3"), + (62, "key.keyboard.f4"), + (63, "key.keyboard.f5"), + (64, "key.keyboard.f6"), + (65, "key.keyboard.f7"), + (66, "key.keyboard.f8"), + (67, "key.keyboard.f9"), + (68, "key.keyboard.f10"), + (69, "key.keyboard.num.lock"), + (70, "key.keyboard.scroll.lock"), + (71, "key.keyboard.keypad.7"), + (72, "key.keyboard.keypad.8"), + (73, "key.keyboard.keypad.9"), + (74, "key.keyboard.keypad.subtract"), + (75, "key.keyboard.keypad.4"), + (76, "key.keyboard.keypad.5"), + (77, "key.keyboard.keypad.6"), + (78, "key.keyboard.keypad.add"), + (79, "key.keyboard.keypad.1"), + (80, "key.keyboard.keypad.2"), + (81, "key.keyboard.keypad.3"), + (82, "key.keyboard.keypad.0"), + (83, "key.keyboard.keypad.decimal"), + (87, "key.keyboard.f11"), + (88, "key.keyboard.f12"), + (100, "key.keyboard.f13"), + (101, "key.keyboard.f14"), + (102, "key.keyboard.f15"), + (141, "key.keyboard.keypad.equal"), + (156, "key.keyboard.keypad.enter"), + (157, "key.keyboard.right.control"), + (181, "key.keyboard.keypad.divide"), + (183, "key.keyboard.print.screen"), + (184, "key.keyboard.right.alt"), + (197, "key.keyboard.pause"), + (199, "key.keyboard.home"), + (200, "key.keyboard.up"), + (201, "key.keyboard.page.up"), + (203, "key.keyboard.left"), + (205, "key.keyboard.right"), + (207, "key.keyboard.end"), + (208, "key.keyboard.down"), + (209, "key.keyboard.page.down"), + (210, "key.keyboard.insert"), + (211, "key.keyboard.delete"), + (219, "key.keyboard.left.win"), + (220, "key.keyboard.right.win"), +]; \ No newline at end of file diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/catalog/mod.rs b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/mod.rs new file mode 100644 index 0000000..4b4555d --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/mod.rs @@ -0,0 +1,452 @@ +//! Defines the Minecraft settings Modrinth knows how to read and write. +//! +//! File keys and version conversions live here. The frontend supplies the labels +//! and translations shown to the user. + +mod accessibility; +mod chat; +mod controls; +mod data_versions; +mod known_vanilla_keys; +mod legacy_key_bindings; +mod sound; +mod version_changes; +mod video; + +use super::api_types::{ + GameOptionEditorChoice, GameOptionEditorDefinition, GameOptionMappingKind, +}; + +pub(super) use data_versions::LEGACY_DATA_VERSIONS; +use known_vanilla_keys::{KNOWN_VANILLA_KEYS, NEVER_SYNC_KEYS}; +pub(in crate::api::instance) use legacy_key_bindings::LEGACY_KEY_BINDINGS; +pub(in crate::api::instance) use version_changes::*; + +#[derive(Clone, Copy)] +pub(super) enum SettingEditor { + Boolean, + UnboundedInteger, + Integer { + min: i64, + max: i64, + step: i64, + }, + UnboundedDecimal, + Decimal { + min: f64, + max: f64, + step: f64, + unit: Option<&'static str>, + }, + Enum(&'static [&'static str]), + Language, + KeyBinding, +} + +#[derive(Clone, Copy, Eq, PartialEq)] +pub(super) enum ValueEncoding { + Bool, + Integer, + Decimal, + Enum(&'static [&'static str]), + QuotedEnum(&'static [&'static str]), + Text, + Graphics, + AmbientOcclusion, + ChatPreview, + Clouds, + GuiScale, + MenuBackgroundBlur, + MusicToast, + Fov, + KeyBinding, +} + +pub(super) struct SupportedSetting { + pub(super) id: &'static str, + pub(super) keys: &'static [&'static str], + pub(super) versioned_keys: &'static [VersionedKey], + /// The README's observed range: versions whose fresh options file contains + /// this key. Versioned settings carry the same ranges on each physical key. + pub(super) since: &'static str, + pub(super) until: &'static str, + pub(super) category: &'static str, + pub(super) default_on: bool, + pub(super) editor: SettingEditor, + pub(super) encoding: ValueEncoding, +} + +#[derive(Clone, Copy)] +pub(super) struct VersionedKey { + pub(super) key: &'static str, + pub(super) since: &'static str, + pub(super) until: &'static str, + pub(super) mapping: GameOptionMappingKind, +} + +pub(super) const BOOL: SettingEditor = SettingEditor::Boolean; +pub(super) const UNIT_INTERVAL: SettingEditor = SettingEditor::Decimal { + min: 0.0, + max: 1.0, + step: 0.01, + unit: Some("percent"), +}; + +pub(super) const fn setting( + id: &'static str, + keys: &'static [&'static str], + category: &'static str, + default_on: bool, + editor: SettingEditor, + encoding: ValueEncoding, +) -> SupportedSetting { + SupportedSetting { + id, + keys, + versioned_keys: &[], + since: "1.0", + until: "26.3", + category, + default_on, + editor, + encoding, + } +} + +pub(super) const fn ranged_setting( + id: &'static str, + keys: &'static [&'static str], + since: &'static str, + until: &'static str, + category: &'static str, + default_on: bool, + editor: SettingEditor, + encoding: ValueEncoding, +) -> SupportedSetting { + SupportedSetting { + id, + keys, + versioned_keys: &[], + since, + until, + category, + default_on, + editor, + encoding, + } +} + +pub(super) const fn versioned_setting( + id: &'static str, + keys: &'static [&'static str], + versioned_keys: &'static [VersionedKey], + category: &'static str, + default_on: bool, + editor: SettingEditor, + encoding: ValueEncoding, +) -> SupportedSetting { + SupportedSetting { + id, + keys, + versioned_keys, + since: "1.0", + until: "26.3", + category, + default_on, + editor, + encoding, + } +} + +const SETTING_GROUPS: &[&[SupportedSetting]] = &[ + video::SETTINGS, + sound::SETTINGS, + controls::SETTINGS, + chat::SETTINGS, + accessibility::SETTINGS, + GENERAL_SETTINGS, +]; + +const MAIN_HAND: &[&str] = &["left", "right"]; +const SHARE_PRESENCE: &[&str] = &["all", "limited", "none"]; + +// These settings all use the common value formats above, so they can stay together. +const GENERAL_SETTINGS: &[SupportedSetting] = &[ + ranged_setting( + "main_hand", + &["mainHand"], + "1.9", + "26.3", + "skin_customization", + true, + SettingEditor::Enum(MAIN_HAND), + ValueEncoding::Enum(MAIN_HAND), + ), + versioned_setting( + "cape", + &["modelPart_cape", "showCape"], + version_changes::CAPE_KEYS, + "skin_customization", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "hat", + &["modelPart_hat"], + "1.8", + "26.3", + "skin_customization", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "jacket", + &["modelPart_jacket"], + "1.8", + "26.3", + "skin_customization", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "left_sleeve", + &["modelPart_left_sleeve"], + "1.8", + "26.3", + "skin_customization", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "right_sleeve", + &["modelPart_right_sleeve"], + "1.8", + "26.3", + "skin_customization", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "left_pants_leg", + &["modelPart_left_pants_leg"], + "1.8", + "26.3", + "skin_customization", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "right_pants_leg", + &["modelPart_right_pants_leg"], + "1.8", + "26.3", + "skin_customization", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "allow_server_listing", + &["allowServerListing"], + "1.18", + "26.3", + "online", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "realms_notifications", + &["realmsNotifications"], + "1.8.9", + "26.3", + "online", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "hide_server_address", + &["hideServerAddress"], + "1.3.2", + "26.3", + "online", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "server_textures", + &["serverTextures"], + "1.3.1", + "1.7.5", + "online", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "snooper", + &["snooperEnabled"], + "1.3.1", + "1.17.1", + "online", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "extra_telemetry", + &["telemetryOptInExtra"], + "1.19.3", + "26.3", + "online", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "in_game_notifications", + &["inGameNotification"], + "26.2", + "26.3", + "online", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "share_presence", + &["sharePresence"], + "26.2", + "26.3", + "online", + true, + SettingEditor::Enum(SHARE_PRESENCE), + ValueEncoding::QuotedEnum(SHARE_PRESENCE), + ), +]; + +pub(super) fn all_supported_settings() +-> impl Iterator { + SETTING_GROUPS.iter().flat_map(|group| group.iter()) +} + +pub(super) fn setting_by_id( + option_id: &str, +) -> Option<&'static SupportedSetting> { + all_supported_settings().find(|setting| setting.id == option_id) +} + +pub(super) fn setting_by_file_key( + key: &str, +) -> Option<&'static SupportedSetting> { + all_supported_settings().find(|setting| setting.keys.contains(&key)) +} + +pub(super) fn setting_available_for_version( + setting: &SupportedSetting, + version: &str, +) -> bool { + if !setting.versioned_keys.is_empty() { + return physical_variant_for_version(setting, version).is_some(); + } + let Some(version) = release_version(version) else { + return false; + }; + release_version(setting.since).is_some_and(|since| since <= version) + && release_version(setting.until).is_some_and(|until| version <= until) +} + +pub(super) fn target_key_for_version<'a>( + setting: &'a SupportedSetting, + version: &str, +) -> Option<&'a str> { + if setting.versioned_keys.is_empty() { + setting_available_for_version(setting, version) + .then(|| setting.keys.first().copied()) + .flatten() + } else { + physical_variant_for_version(setting, version) + .map(|variant| variant.key) + } +} + +pub(super) fn is_never_sync_key(key: &str) -> bool { + NEVER_SYNC_KEYS.contains(&key) + || KNOWN_VANILLA_KEYS.contains(&key) + || key.starts_with("skip") + || key.starts_with("hideBundleTutorial") + || key.starts_with("key_") && !key.starts_with("key_key.") +} + +pub(super) fn editor_for( + setting: &SupportedSetting, +) -> GameOptionEditorDefinition { + let mut editor = GameOptionEditorDefinition { + type_: String::new(), + min: None, + max: None, + step: None, + unit: None, + choices: Vec::new(), + }; + match setting.editor { + SettingEditor::Boolean => editor.type_ = "bool".to_string(), + SettingEditor::UnboundedInteger => { + editor.type_ = "integer".to_string(); + editor.step = Some(1.0); + } + SettingEditor::Integer { min, max, step } => { + editor.type_ = "integer".to_string(); + editor.min = Some(min as f64); + editor.max = Some(max as f64); + editor.step = Some(step as f64); + } + SettingEditor::UnboundedDecimal => { + editor.type_ = "decimal".to_string(); + editor.step = Some(0.01); + } + SettingEditor::Decimal { + min, + max, + step, + unit, + } => { + editor.type_ = "decimal".to_string(); + editor.min = Some(min); + editor.max = Some(max); + editor.step = Some(step); + editor.unit = unit.map(str::to_string); + } + SettingEditor::Enum(choices) => { + editor.type_ = "enum".to_string(); + editor.choices = choices + .iter() + .map(|value| GameOptionEditorChoice { + value: (*value).to_string(), + }) + .collect(); + } + SettingEditor::Language => editor.type_ = "text".to_string(), + SettingEditor::KeyBinding => editor.type_ = "key_binding".to_string(), + } + editor +} + +pub(super) fn custom_setting_editor() -> GameOptionEditorDefinition { + GameOptionEditorDefinition { + type_: "external_raw".to_string(), + min: None, + max: None, + step: None, + unit: None, + choices: Vec::new(), + } +} diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/catalog/sound.rs b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/sound.rs new file mode 100644 index 0000000..8cf973a --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/sound.rs @@ -0,0 +1,177 @@ +use super::{ + BOOL, SettingEditor, SupportedSetting, UNIT_INTERVAL, ValueEncoding, + ranged_setting, version_changes, versioned_setting, +}; + +const MUSIC_TOAST: &[&str] = &["never", "pause", "pause_and_toast"]; +const MUSIC_FREQUENCY: &[&str] = &["CONSTANT", "DEFAULT", "FREQUENT"]; + +pub(super) const SETTINGS: &[SupportedSetting] = &[ + ranged_setting( + "language", + &["lang"], + "1.1", + "26.3", + "language", + true, + SettingEditor::Language, + ValueEncoding::Text, + ), + ranged_setting( + "force_unicode_font", + &["forceUnicodeFont"], + "1.7.2", + "26.3", + "language", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "japanese_glyph_variants", + &["japaneseGlyphVariants"], + "1.20.5", + "26.3", + "language", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "directional_audio", + &["directionalAudio"], + "1.19", + "26.3", + "music_and_sound", + true, + BOOL, + ValueEncoding::Bool, + ), + versioned_setting( + "music_toast", + &["musicToast", "showNowPlayingToast"], + version_changes::MUSIC_TOAST_KEYS, + "music_and_sound", + true, + SettingEditor::Enum(MUSIC_TOAST), + ValueEncoding::MusicToast, + ), + ranged_setting( + "music_frequency", + &["musicFrequency"], + "1.21.6", + "26.3", + "music_and_sound", + true, + SettingEditor::Enum(MUSIC_FREQUENCY), + ValueEncoding::QuotedEnum(MUSIC_FREQUENCY), + ), + versioned_setting( + "master_volume", + &["soundCategory_master", "sound"], + version_changes::MASTER_VOLUME_KEYS, + "music_and_sound", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + versioned_setting( + "music_volume", + &["soundCategory_music", "music"], + version_changes::MUSIC_VOLUME_KEYS, + "music_and_sound", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "record_volume", + &["soundCategory_record"], + "1.7.2", + "26.3", + "music_and_sound", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "weather_volume", + &["soundCategory_weather"], + "1.7.2", + "26.3", + "music_and_sound", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "blocks_volume", + &["soundCategory_block"], + "1.7.2", + "26.3", + "music_and_sound", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "hostile_volume", + &["soundCategory_hostile"], + "1.7.2", + "26.3", + "music_and_sound", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "neutral_volume", + &["soundCategory_neutral"], + "1.7.2", + "26.3", + "music_and_sound", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "players_volume", + &["soundCategory_player"], + "1.7.2", + "26.3", + "music_and_sound", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "ambient_volume", + &["soundCategory_ambient"], + "1.7.2", + "26.3", + "music_and_sound", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "voice_volume", + &["soundCategory_voice"], + "1.9", + "26.3", + "music_and_sound", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "ui_volume", + &["soundCategory_ui"], + "1.21.6", + "26.3", + "music_and_sound", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), +]; \ No newline at end of file diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/catalog/version_changes.rs b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/version_changes.rs new file mode 100644 index 0000000..a0fc60b --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/version_changes.rs @@ -0,0 +1,969 @@ +//! Handles settings Minecraft renamed or stored differently between versions. + +use super::super::api_types::canonical_values_equal; +use super::super::options_file::{ + GameOptionsDocument, input_error, validate_raw_key_value, +}; +use super::{ + GameOptionMappingKind, LEGACY_KEY_BINDINGS, SettingEditor, + SupportedSetting, ValueEncoding, VersionedKey, + setting_available_for_version, +}; +use crate::state::{CanonicalValue, StoredOption}; + +const GRAPHICS_VALUES: &[&str] = &["fast", "fancy", "fabulous", "custom"]; +const CLOUD_VALUES: &[&str] = &["false", "fast", "true"]; +const MUSIC_TOAST_VALUES: &[&str] = &["never", "pause", "pause_and_toast"]; + +fn decode_string_token(raw: &str) -> Option { + if raw.starts_with('"') { + serde_json::from_str(raw).ok() + } else { + Some(raw.to_string()) + } +} + +fn encode_string_token(value: &str) -> Option { + serde_json::to_string(value).ok() +} + +pub(in crate::api::instance) const GRAPHICS_KEYS: &[VersionedKey] = &[ + VersionedKey { + key: "fancyGraphics", + since: "1.0", + until: "1.15.2", + mapping: GameOptionMappingKind::Legacy, + }, + VersionedKey { + key: "graphicsMode", + since: "1.16", + until: "1.21.10", + mapping: GameOptionMappingKind::Migrated, + }, + VersionedKey { + key: "graphicsPreset", + since: "1.21.11", + until: "26.3", + mapping: GameOptionMappingKind::Direct, + }, +]; + +pub(in crate::api::instance) const AMBIENT_OCCLUSION_KEYS: &[VersionedKey] = &[ + VersionedKey { + key: "ao", + since: "1.0", + until: "1.4.7", + mapping: GameOptionMappingKind::Legacy, + }, + VersionedKey { + key: "ao", + since: "1.5.1", + until: "1.19.2", + mapping: GameOptionMappingKind::Legacy, + }, + VersionedKey { + key: "ao", + since: "1.19.3", + until: "26.3", + mapping: GameOptionMappingKind::Direct, + }, +]; + +pub(in crate::api::instance) const FOV_KEYS: &[VersionedKey] = + &[VersionedKey { + key: "fov", + since: "1.0", + until: "26.3", + mapping: GameOptionMappingKind::Direct, + }]; + +pub(in crate::api::instance) const CLOUD_KEYS: &[VersionedKey] = &[ + VersionedKey { + key: "clouds", + since: "1.0", + until: "1.7.10", + mapping: GameOptionMappingKind::Legacy, + }, + VersionedKey { + key: "renderClouds", + since: "1.8", + until: "1.8", + mapping: GameOptionMappingKind::Migrated, + }, + VersionedKey { + key: "renderClouds", + since: "1.8.1", + until: "26.3", + mapping: GameOptionMappingKind::Direct, + }, +]; + +pub(in crate::api::instance) const CHAT_PREVIEW_KEYS: &[VersionedKey] = &[ + VersionedKey { + key: "chatPreview", + since: "1.19", + until: "1.19", + mapping: GameOptionMappingKind::Legacy, + }, + VersionedKey { + key: "chatPreview", + since: "1.19.1", + until: "1.19.2", + mapping: GameOptionMappingKind::Direct, + }, +]; + +pub(in crate::api::instance) const MENU_BACKGROUND_BLUR_KEYS: + &[VersionedKey] = &[ + VersionedKey { + key: "menuBackgroundBlurriness", + since: "1.20.5", + until: "1.20.6", + mapping: GameOptionMappingKind::Legacy, + }, + VersionedKey { + key: "menuBackgroundBlurriness", + since: "1.21", + until: "26.3", + mapping: GameOptionMappingKind::Direct, + }, +]; + +pub(in crate::api::instance) const MASTER_VOLUME_KEYS: &[VersionedKey] = &[ + VersionedKey { + key: "sound", + since: "1.0", + until: "1.6.4", + mapping: GameOptionMappingKind::Legacy, + }, + VersionedKey { + key: "soundCategory_master", + since: "1.7.2", + until: "26.3", + mapping: GameOptionMappingKind::Direct, + }, +]; + +pub(in crate::api::instance) const MUSIC_VOLUME_KEYS: &[VersionedKey] = &[ + VersionedKey { + key: "music", + since: "1.0", + until: "1.6.4", + mapping: GameOptionMappingKind::Legacy, + }, + VersionedKey { + key: "soundCategory_music", + since: "1.7.2", + until: "26.3", + mapping: GameOptionMappingKind::Direct, + }, +]; + +pub(in crate::api::instance) const CAPE_KEYS: &[VersionedKey] = &[ + VersionedKey { + key: "showCape", + since: "1.4.2", + until: "1.7.10", + mapping: GameOptionMappingKind::Legacy, + }, + VersionedKey { + key: "modelPart_cape", + since: "1.8", + until: "26.3", + mapping: GameOptionMappingKind::Direct, + }, +]; + +pub(in crate::api::instance) const MUSIC_TOAST_KEYS: &[VersionedKey] = &[ + VersionedKey { + key: "showNowPlayingToast", + since: "1.21.6", + until: "1.21.10", + mapping: GameOptionMappingKind::Legacy, + }, + VersionedKey { + key: "musicToast", + since: "1.21.11", + until: "26.3", + mapping: GameOptionMappingKind::Direct, + }, +]; + +pub(in crate::api::instance) const SWAP_OFFHAND_KEYS: &[VersionedKey] = &[ + VersionedKey { + key: "key_key.swapHands", + since: "1.9", + until: "1.15.2", + mapping: GameOptionMappingKind::Legacy, + }, + VersionedKey { + key: "key_key.swapOffhand", + since: "1.16", + until: "26.3", + mapping: GameOptionMappingKind::Direct, + }, +]; + +pub(in crate::api::instance) fn release_version( + version: &str, +) -> Option<(u32, u32, u32)> { + let components = version + .split('.') + .map(|component| component.parse::()) + .collect::, _>>() + .ok()?; + match components.as_slice() { + [major, minor] => Some((*major, *minor, 0)), + [major, minor, patch] => Some((*major, *minor, *patch)), + _ => None, + } +} + +pub(in crate::api::instance) fn physical_variant_for_version<'a>( + definition: &'a SupportedSetting, + version: &str, +) -> Option<&'a VersionedKey> { + let version = release_version(version)?; + definition.versioned_keys.iter().find(|variant| { + let Some(since) = release_version(variant.since) else { + return false; + }; + let Some(until) = release_version(variant.until) else { + return false; + }; + version >= since && version <= until + }) +} + +pub(in crate::api::instance) fn physical_variant_for_present_key<'a>( + definition: &'a SupportedSetting, + key: &str, + version: &str, +) -> Option<&'a VersionedKey> { + let version = release_version(version)?; + definition.versioned_keys.iter().rev().find(|variant| { + variant.key == key + && release_version(variant.since) + .is_some_and(|since| since <= version) + && release_version(variant.until) + .is_some_and(|until| version <= until) + }) +} + +pub(in crate::api::instance) fn uses_modern_key_tokens(version: &str) -> bool { + release_version(version).is_some_and(|version| version >= (1, 13, 0)) +} + +pub(in crate::api::instance) fn split_key_binding( + value: &str, +) -> (&str, Option<&str>) { + value + .split_once(':') + .map_or((value, None), |(key, modifier)| (key, Some(modifier))) +} + +pub(in crate::api::instance) fn append_key_modifier( + mut key: String, + modifier: Option<&str>, +) -> String { + if let Some(modifier) = modifier { + key.push(':'); + key.push_str(modifier); + } + key +} + +pub(in crate::api::instance) fn legacy_key_binding_to_token( + value: &str, +) -> Option { + let (key, modifier) = split_key_binding(value); + let key = key.parse::().ok()?; + let token = match key { + -100 => "key.mouse.left".to_string(), + -99 => "key.mouse.right".to_string(), + -98 => "key.mouse.middle".to_string(), + -97..=-85 => format!("key.mouse.{}", key + 101), + _ => LEGACY_KEY_BINDINGS + .iter() + .find(|(legacy, _)| *legacy == key) + .map(|(_, token)| (*token).to_string())?, + }; + Some(append_key_modifier(token, modifier)) +} + +pub(in crate::api::instance) fn token_key_binding_to_legacy( + value: &str, +) -> Option { + let (token, modifier) = split_key_binding(value); + let key = match token { + "key.mouse.left" => -100, + "key.mouse.right" => -99, + "key.mouse.middle" => -98, + _ => { + if let Some(button) = token.strip_prefix("key.mouse.") { + let button = button.parse::().ok()?; + if !(4..=16).contains(&button) { + return None; + } + button - 101 + } else { + LEGACY_KEY_BINDINGS + .iter() + .find(|(_, candidate)| *candidate == token) + .map(|(legacy, _)| *legacy)? + } + } + }; + Some(append_key_modifier(key.to_string(), modifier)) +} + +pub(in crate::api::instance) fn valid_modern_key_binding(value: &str) -> bool { + let (key, modifier) = split_key_binding(value); + let key_valid = ["key.keyboard.", "key.mouse.", "scancode."] + .into_iter() + .find_map(|prefix| key.strip_prefix(prefix)) + .is_some_and(|identifier| { + !identifier.is_empty() + && identifier.chars().all(|character| { + character.is_ascii_alphanumeric() + || matches!(character, '.' | '_' | '-') + }) + }); + key_valid + && modifier.is_none_or(|modifier| { + !modifier.is_empty() + && modifier.len() <= 64 + && modifier.chars().all(|character| { + character.is_ascii_alphanumeric() + || matches!(character, '.' | '_' | '-' | ':' | '+') + }) + }) +} + +pub(in crate::api::instance) fn supported_settings_cover_game_version( + version: &str, +) -> bool { + if version.contains('w') || version.contains("snapshot") { + return false; + } + let components = version + .split('.') + .map(|component| component.parse::()) + .collect::, _>>(); + let Ok(components) = components else { + return false; + }; + match components.as_slice() { + [1, minor] => *minor <= 21, + [1, minor, patch] => *minor < 21 || (*minor == 21 && *patch <= 11), + [26, minor] | [26, minor, _] => (1..=3).contains(minor), + _ => false, + } +} + +pub(in crate::api::instance) fn decode_value( + definition: &SupportedSetting, + physical_key: &str, + raw: &str, +) -> Option { + match definition.encoding { + ValueEncoding::Bool => match raw { + "true" => Some(CanonicalValue::Bool(true)), + "false" => Some(CanonicalValue::Bool(false)), + _ => None, + }, + ValueEncoding::Integer => { + raw.parse::().ok().map(CanonicalValue::Integer) + } + ValueEncoding::Decimal => raw + .parse::() + .ok() + .filter(|value| value.is_finite()) + .map(|_| CanonicalValue::Decimal(raw.to_string())), + ValueEncoding::Enum(choices) => { + let value = decode_string_token(raw)?; + choices + .contains(&value.as_str()) + .then_some(CanonicalValue::Enum(value)) + } + ValueEncoding::QuotedEnum(choices) => { + let value = decode_string_token(raw)?; + choices + .contains(&value.as_str()) + .then_some(CanonicalValue::Enum(value)) + } + ValueEncoding::Text => Some(CanonicalValue::Text(raw.to_string())), + ValueEncoding::KeyBinding => { + if valid_modern_key_binding(raw) { + Some(CanonicalValue::KeyBinding(raw.to_string())) + } else { + legacy_key_binding_to_token(raw).map(CanonicalValue::KeyBinding) + } + } + ValueEncoding::Fov => raw.parse::().ok().and_then(|value| { + if value.fract() == 0.0 && (30.0..=110.0).contains(&value) { + return Some(CanonicalValue::Integer(value as i64)); + } + let degrees = value * 40.0 + 70.0; + (value.is_finite() + && (-1.0..=1.0).contains(&value) + && (degrees - degrees.round()).abs() <= 0.000_001) + .then(|| CanonicalValue::Integer(degrees.round() as i64)) + }), + ValueEncoding::GuiScale => { + raw.parse::().ok().map(CanonicalValue::Integer) + } + ValueEncoding::MenuBackgroundBlur => raw + .parse::() + .ok() + .filter(|value| value.is_finite() && value.fract() == 0.0) + .map(|value| CanonicalValue::Integer(value as i64)), + ValueEncoding::Graphics => { + let raw = decode_string_token(raw)?; + let value = match physical_key { + "fancyGraphics" => match raw.as_str() { + "false" => "fast", + "true" => "fancy", + _ => return None, + }, + "graphicsMode" => match raw.as_str() { + "0" => "fast", + "1" => "fancy", + "2" => "fabulous", + _ => return None, + }, + "graphicsPreset" if GRAPHICS_VALUES.contains(&raw.as_str()) => { + raw.as_str() + } + _ => return None, + }; + Some(CanonicalValue::Enum(value.to_string())) + } + ValueEncoding::AmbientOcclusion => { + let value = match raw { + "false" | "0" => "off", + "true" => "on", + "1" => "minimum", + "2" => "maximum", + _ => return None, + }; + Some(CanonicalValue::Enum(value.to_string())) + } + ValueEncoding::ChatPreview => { + let value = match raw { + "false" | "0" => "0", + "1" => "1", + "true" | "2" => "2", + _ => return None, + }; + Some(CanonicalValue::Enum(value.to_string())) + } + ValueEncoding::Clouds => { + let raw = decode_string_token(raw)?; + CLOUD_VALUES + .contains(&raw.as_str()) + .then_some(CanonicalValue::Enum(raw)) + } + ValueEncoding::MusicToast => { + let raw = decode_string_token(raw)?; + let value = match (physical_key, raw.as_str()) { + ("showNowPlayingToast", "false") => "never", + ("showNowPlayingToast", "true") => "pause_and_toast", + ("musicToast", "never" | "pause" | "pause_and_toast") => { + raw.as_str() + } + _ => return None, + }; + Some(CanonicalValue::Enum(value.to_string())) + } + } +} + +pub(in crate::api::instance) fn encode_value( + definition: &SupportedSetting, + physical_key: &str, + value: &CanonicalValue, + game_version: &str, + current_raw: Option<&str>, +) -> Option { + if !setting_available_for_version(definition, game_version) { + return None; + } + if let Some(current_raw) = current_raw + && physical_representation_supported_for_target( + definition, + physical_key, + current_raw, + game_version, + ) + && decode_value(definition, physical_key, current_raw) + .as_ref() + .is_some_and(|current| canonical_values_equal(current, value)) + { + return Some(current_raw.to_string()); + } + match (definition.encoding, value) { + (ValueEncoding::Bool, CanonicalValue::Bool(value)) => { + Some(value.to_string()) + } + (ValueEncoding::Integer, CanonicalValue::Integer(value)) => { + Some(value.to_string()) + } + (ValueEncoding::Decimal, CanonicalValue::Decimal(value)) => { + Some(value.clone()) + } + (ValueEncoding::Decimal, CanonicalValue::Integer(value)) => { + Some(value.to_string()) + } + (ValueEncoding::Enum(choices), CanonicalValue::Enum(value)) + if choices.contains(&value.as_str()) => + { + if current_raw.is_some_and(|raw| raw.starts_with('"')) { + encode_string_token(value) + } else { + Some(value.clone()) + } + } + (ValueEncoding::QuotedEnum(choices), CanonicalValue::Enum(value)) + if choices.contains(&value.as_str()) => + { + encode_string_token(value) + } + (ValueEncoding::Text, CanonicalValue::Text(value)) => { + Some(value.clone()) + } + (ValueEncoding::KeyBinding, CanonicalValue::KeyBinding(value)) + if valid_modern_key_binding(value) => + { + if !uses_modern_key_tokens(game_version) { + token_key_binding_to_legacy(value) + } else { + Some(value.clone()) + } + } + (ValueEncoding::Fov, CanonicalValue::Integer(value)) + if (30..=110).contains(value) => + { + let normalized = (*value as f64 - 70.0) / 40.0; + Some(format_decimal(normalized)) + } + (ValueEncoding::GuiScale, CanonicalValue::Integer(value)) + if (0..=8).contains(value) + && (release_version(game_version) + .is_some_and(|version| version >= (1, 13, 0)) + || *value <= 3) => + { + Some(value.to_string()) + } + (ValueEncoding::MenuBackgroundBlur, CanonicalValue::Integer(value)) + if (0..=10).contains(value) => + { + if release_version(game_version) + .is_some_and(|version| version >= (1, 21, 0)) + { + Some(value.to_string()) + } else { + Some(format!("{value}.0")) + } + } + (ValueEncoding::Graphics, CanonicalValue::Enum(value)) => { + match physical_key { + "fancyGraphics" => match value.as_str() { + "fast" => Some("false".to_string()), + "fancy" => Some("true".to_string()), + _ => None, + }, + "graphicsMode" => match value.as_str() { + "fast" => Some("0".to_string()), + "fancy" => Some("1".to_string()), + "fabulous" => Some("2".to_string()), + _ => None, + }, + "graphicsPreset" + if GRAPHICS_VALUES.contains(&value.as_str()) => + { + encode_string_token(value) + } + _ => None, + } + } + (ValueEncoding::AmbientOcclusion, CanonicalValue::Enum(value)) => { + let version = release_version(game_version)?; + if ((1, 5, 1)..=(1, 19, 2)).contains(&version) { + match value.as_str() { + "off" => Some("0".to_string()), + "minimum" => Some("1".to_string()), + "maximum" => Some("2".to_string()), + _ => None, + } + } else { + match value.as_str() { + "off" => Some("false".to_string()), + "on" => Some("true".to_string()), + _ => None, + } + } + } + (ValueEncoding::ChatPreview, CanonicalValue::Enum(value)) => { + if release_version(game_version) == Some((1, 19, 0)) { + match value.as_str() { + "0" => Some("false".to_string()), + "2" => Some("true".to_string()), + _ => None, + } + } else if matches!(value.as_str(), "0" | "1" | "2") { + Some(value.clone()) + } else { + None + } + } + (ValueEncoding::Clouds, CanonicalValue::Enum(value)) => { + let version = release_version(game_version)?; + if version <= (1, 8, 0) { + match value.as_str() { + "false" | "true" => Some(value.clone()), + _ => None, + } + } else if CLOUD_VALUES.contains(&value.as_str()) { + Some(value.clone()) + } else { + None + } + } + (ValueEncoding::MusicToast, CanonicalValue::Enum(value)) => { + match physical_key { + "showNowPlayingToast" => match value.as_str() { + "never" => Some("false".to_string()), + "pause_and_toast" => Some("true".to_string()), + _ => None, + }, + "musicToast" + if MUSIC_TOAST_VALUES.contains(&value.as_str()) => + { + encode_string_token(value) + } + _ => None, + } + } + _ => None, + } +} + +fn validate_value( + definition: Option<&SupportedSetting>, + value: &CanonicalValue, + enforce_editor_increment: bool, +) -> crate::Result<()> { + if let Some(definition) = definition { + let type_valid = match definition.encoding { + ValueEncoding::Bool => matches!(value, CanonicalValue::Bool(_)), + ValueEncoding::Integer + | ValueEncoding::GuiScale + | ValueEncoding::MenuBackgroundBlur => { + matches!(value, CanonicalValue::Integer(_)) + } + ValueEncoding::Decimal => matches!( + value, + CanonicalValue::Decimal(_) | CanonicalValue::Integer(_) + ), + ValueEncoding::Enum(_) + | ValueEncoding::QuotedEnum(_) + | ValueEncoding::Graphics + | ValueEncoding::AmbientOcclusion + | ValueEncoding::ChatPreview + | ValueEncoding::Clouds + | ValueEncoding::MusicToast => { + matches!(value, CanonicalValue::Enum(_)) + } + ValueEncoding::Text => matches!(value, CanonicalValue::Text(_)), + ValueEncoding::Fov => matches!(value, CanonicalValue::Integer(_)), + ValueEncoding::KeyBinding => { + matches!(value, CanonicalValue::KeyBinding(_)) + } + }; + if !type_valid { + return Err(input_error(format!( + "Invalid value type for {}", + definition.id + ))); + } + match (definition.editor, value) { + ( + SettingEditor::UnboundedDecimal, + CanonicalValue::Decimal(value), + ) if value + .parse::() + .ok() + .is_none_or(|value| !value.is_finite()) => + { + return Err(input_error("Invalid decimal value")); + } + ( + SettingEditor::Integer { min, max, step }, + CanonicalValue::Integer(value), + ) if *value < min + || *value > max + || enforce_editor_increment + && step > 0 + && (*value - min) % step != 0 => + { + return Err(input_error(format!( + "{} is outside its supported range or increment", + definition.id + ))); + } + ( + SettingEditor::Decimal { min, max, step, .. }, + CanonicalValue::Decimal(value), + ) => { + let parsed = value + .parse::() + .map_err(|_| input_error("Invalid decimal value"))?; + let increment = (parsed - min) / step; + if !parsed.is_finite() + || parsed < min + || parsed > max + || enforce_editor_increment + && step > 0.0 + && (increment - increment.round()).abs() > 0.000_001 + { + return Err(input_error(format!( + "{} is outside its supported range or increment", + definition.id + ))); + } + } + ( + SettingEditor::Decimal { min, max, step, .. }, + CanonicalValue::Integer(value), + ) => { + let parsed = *value as f64; + let increment = (parsed - min) / step; + if parsed < min + || parsed > max + || enforce_editor_increment + && step > 0.0 + && (increment - increment.round()).abs() > 0.000_001 + { + return Err(input_error(format!( + "{} is outside its supported range or increment", + definition.id + ))); + } + } + (SettingEditor::Enum(choices), CanonicalValue::Enum(value)) + if !choices.contains(&value.as_str()) => + { + return Err(input_error(format!( + "Invalid choice for {}", + definition.id + ))); + } + (SettingEditor::Language, CanonicalValue::Text(value)) + if value.is_empty() + || value.len() > 64 + || value.chars().any(char::is_control) => + { + return Err(input_error("Invalid language value")); + } + (SettingEditor::KeyBinding, CanonicalValue::KeyBinding(value)) + if value.len() > 256 + || value.contains(['\r', '\n']) + || !valid_modern_key_binding(value) => + { + return Err(input_error("Invalid key binding value")); + } + _ => {} + } + } else if let CanonicalValue::ExternalRaw(raw) = value { + validate_raw_key_value("external", raw)?; + } else { + return Err(input_error("Custom settings require a raw string value")); + } + Ok(()) +} + +pub(in crate::api::instance) fn validate_canonical_value( + definition: Option<&SupportedSetting>, + value: &CanonicalValue, +) -> crate::Result<()> { + validate_value(definition, value, true) +} + +pub(in crate::api::instance) fn validate_file_value( + definition: Option<&SupportedSetting>, + value: &CanonicalValue, +) -> crate::Result<()> { + validate_value(definition, value, false) +} + +pub(in crate::api::instance) fn decode_value_for_version( + definition: &SupportedSetting, + physical_key: &str, + raw: &str, + game_version: &str, +) -> Option { + if !setting_available_for_version(definition, game_version) { + return None; + } + let value = decode_value(definition, physical_key, raw)?; + validate_file_value(Some(definition), &value).ok()?; + let target_key = if definition.versioned_keys.is_empty() { + physical_key + } else { + physical_variant_for_version(definition, game_version)?.key + }; + encode_value(definition, target_key, &value, game_version, None)?; + Some(value) +} + +pub(in crate::api::instance) fn target_physical_key( + definition: Option<&SupportedSetting>, + stored: &StoredOption, + document: &GameOptionsDocument, + game_version: &str, +) -> Option { + if let Some(definition) = definition { + if !setting_available_for_version(definition, game_version) { + return None; + } + if !definition.versioned_keys.is_empty() { + observed_physical_key(definition, document, game_version)?; + return physical_variant_for_version(definition, game_version) + .map(|variant| variant.key.to_string()); + } + definition + .keys + .iter() + .find(|key| document.value(key).is_some()) + .map(|key| (*key).to_string()) + } else { + stored.raw_key.clone() + } +} + +pub(in crate::api::instance) fn observed_physical_key( + definition: &SupportedSetting, + document: &GameOptionsDocument, + game_version: &str, +) -> Option { + if !setting_available_for_version(definition, game_version) { + return None; + } + if let Some(target) = physical_variant_for_version(definition, game_version) + && document.value(target.key).is_some() + { + return Some(target.key.to_string()); + } + definition + .keys + .iter() + .find(|key| document.value(key).is_some()) + .map(|key| (*key).to_string()) +} + +pub(in crate::api::instance) fn alias_migration_needed( + definition: &SupportedSetting, + document: &GameOptionsDocument, + target_key: &str, +) -> bool { + document.value(target_key).is_none() + && definition + .keys + .iter() + .any(|key| *key != target_key && document.value(key).is_some()) +} + +pub(in crate::api::instance) fn physical_representation_supported_for_target( + definition: &SupportedSetting, + physical_key: &str, + raw: &str, + game_version: &str, +) -> bool { + if !setting_available_for_version(definition, game_version) { + return false; + } + let Some(target_version) = release_version(game_version) else { + return false; + }; + if matches!(definition.encoding, ValueEncoding::KeyBinding) { + return if target_version >= (1, 13, 0) { + valid_modern_key_binding(raw) + } else { + legacy_key_binding_to_token(raw).is_some() + }; + } + if matches!(definition.encoding, ValueEncoding::AmbientOcclusion) { + return if ((1, 5, 1)..=(1, 19, 2)).contains(&target_version) { + matches!(raw, "0" | "1" | "2") + } else { + matches!(raw, "true" | "false") + }; + } + if matches!(definition.encoding, ValueEncoding::Fov) { + return raw.parse::().is_ok_and(|value| { + value.is_finite() && (-1.0..=1.0).contains(&value) + }); + } + if matches!(definition.encoding, ValueEncoding::ChatPreview) { + return if target_version == (1, 19, 0) { + matches!(raw, "true" | "false") + } else { + matches!(raw, "0" | "1" | "2") + }; + } + if matches!(definition.encoding, ValueEncoding::GuiScale) { + return raw.parse::().is_ok_and(|value| { + if target_version >= (1, 13, 0) { + (0..=8).contains(&value) + } else { + (0..=3).contains(&value) + } + }); + } + if matches!(definition.encoding, ValueEncoding::MenuBackgroundBlur) { + return if target_version >= (1, 21, 0) { + raw.parse::() + .is_ok_and(|value| (0..=10).contains(&value)) + } else { + raw.parse::().is_ok_and(|value| { + value.is_finite() + && value.fract() == 0.0 + && (0.0..=10.0).contains(&value) + }) + }; + } + if matches!(definition.encoding, ValueEncoding::Clouds) { + return if target_version <= (1, 8, 0) { + matches!(raw, "true" | "false") + } else { + decode_string_token(raw) + .is_some_and(|value| CLOUD_VALUES.contains(&value.as_str())) + }; + } + + let mut variants = definition + .versioned_keys + .iter() + .filter(|variant| variant.key == physical_key) + .peekable(); + if variants.peek().is_none() { + return true; + } + variants.any(|variant| { + release_version(variant.since) + .is_some_and(|since| since <= target_version) + && release_version(variant.until) + .is_some_and(|until| target_version <= until) + }) +} + +pub(in crate::api::instance) fn format_decimal(value: f64) -> String { + let value = format!("{value:.8}"); + let value = value.trim_end_matches('0').trim_end_matches('.'); + if value == "-0" { + "0".to_string() + } else { + value.to_string() + } +} diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/catalog/video.rs b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/video.rs new file mode 100644 index 0000000..d57e706 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/video.rs @@ -0,0 +1,450 @@ +use super::{ + BOOL, SettingEditor, SupportedSetting, UNIT_INTERVAL, ValueEncoding, + ranged_setting, setting, version_changes, versioned_setting, +}; + +const GRAPHICS: &[&str] = &["fast", "fancy", "fabulous", "custom"]; +const AMBIENT_OCCLUSION: &[&str] = &["off", "on", "minimum", "maximum"]; +const PARTICLES: &[&str] = &["0", "1", "2"]; +const CLOUDS: &[&str] = &["false", "fast", "true"]; +const ATTACK_INDICATOR: &[&str] = &["0", "1", "2"]; +const INACTIVITY_FPS_LIMIT: &[&str] = &["afk", "minimized"]; +const PRIORITIZE_CHUNK_UPDATES: &[&str] = &["0", "1", "2"]; +const TEXTURE_FILTERING: &[&str] = &["0", "1", "2"]; +const GRAPHICS_BACKEND: &[&str] = &["default", "opengl", "vulkan"]; +const LEGACY_FPS_LIMIT: &[&str] = &["0", "1", "2"]; +const LEGACY_VIEW_DISTANCE: &[&str] = &["0", "1", "2", "3"]; + +pub(super) const SETTINGS: &[SupportedSetting] = &[ + versioned_setting( + "fov", + &["fov"], + version_changes::FOV_KEYS, + "video", + true, + SettingEditor::Integer { + min: 30, + max: 110, + step: 1, + }, + ValueEncoding::Fov, + ), + versioned_setting( + "graphics", + &["graphicsPreset", "graphicsMode", "fancyGraphics"], + version_changes::GRAPHICS_KEYS, + "video", + true, + SettingEditor::Enum(GRAPHICS), + ValueEncoding::Graphics, + ), + versioned_setting( + "ambient_occlusion", + &["ao"], + version_changes::AMBIENT_OCCLUSION_KEYS, + "video", + true, + SettingEditor::Enum(AMBIENT_OCCLUSION), + ValueEncoding::AmbientOcclusion, + ), + setting( + "brightness", + &["gamma"], + "video", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "render_distance", + &["renderDistance"], + "1.7.2", + "26.3", + "video", + false, + SettingEditor::Integer { + min: 2, + max: 64, + step: 1, + }, + ValueEncoding::Integer, + ), + ranged_setting( + "legacy_view_distance", + &["viewDistance"], + "1.0", + "1.6.4", + "video", + false, + SettingEditor::Enum(LEGACY_VIEW_DISTANCE), + ValueEncoding::Enum(LEGACY_VIEW_DISTANCE), + ), + ranged_setting( + "simulation_distance", + &["simulationDistance"], + "1.18", + "26.3", + "video", + false, + SettingEditor::Integer { + min: 2, + max: 64, + step: 1, + }, + ValueEncoding::Integer, + ), + ranged_setting( + "entity_distance", + &["entityDistanceScaling"], + "1.16", + "26.3", + "video", + false, + SettingEditor::Decimal { + min: 0.5, + max: 5.0, + step: 0.25, + unit: Some("multiplier"), + }, + ValueEncoding::Decimal, + ), + setting( + "gui_scale", + &["guiScale"], + "video", + true, + SettingEditor::Integer { + min: 0, + max: 8, + step: 1, + }, + ValueEncoding::GuiScale, + ), + ranged_setting( + "debug_gui_scale", + &["debugGuiScale"], + "26.3", + "26.3", + "video", + true, + SettingEditor::UnboundedInteger, + ValueEncoding::Integer, + ), + ranged_setting( + "graphics_backend", + &["preferredGraphicsBackend"], + "26.2", + "26.3", + "video", + false, + SettingEditor::Enum(GRAPHICS_BACKEND), + ValueEncoding::QuotedEnum(GRAPHICS_BACKEND), + ), + setting( + "particles", + &["particles"], + "video", + true, + SettingEditor::Enum(PARTICLES), + ValueEncoding::Enum(PARTICLES), + ), + versioned_setting( + "clouds", + &["renderClouds", "clouds"], + version_changes::CLOUD_KEYS, + "video", + true, + SettingEditor::Enum(CLOUDS), + ValueEncoding::Clouds, + ), + ranged_setting( + "cloud_range", + &["cloudRange"], + "1.21.6", + "26.3", + "video", + false, + SettingEditor::UnboundedInteger, + ValueEncoding::Integer, + ), + ranged_setting( + "entity_shadows", + &["entityShadows"], + "1.8.1", + "26.3", + "video", + true, + BOOL, + ValueEncoding::Bool, + ), + setting( + "view_bobbing", + &["bobView"], + "video", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "vsync", + &["enableVsync"], + "1.3.1", + "26.3", + "video", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "fullscreen", + &["fullscreen"], + "1.3.1", + "26.3", + "video", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "exclusive_fullscreen", + &["exclusiveFullscreen"], + "26.1", + "26.3", + "video", + false, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "mac_fullscreen_menu", + &["macFullscreenMenuVisibility"], + "26.3", + "26.3", + "video", + false, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "max_framerate", + &["maxFps"], + "1.7.2", + "26.3", + "video", + true, + SettingEditor::Integer { + min: 10, + max: 260, + step: 10, + }, + ValueEncoding::Integer, + ), + ranged_setting( + "legacy_framerate_limit", + &["fpsLimit"], + "1.0", + "1.6.4", + "video", + false, + SettingEditor::Enum(LEGACY_FPS_LIMIT), + ValueEncoding::Enum(LEGACY_FPS_LIMIT), + ), + ranged_setting( + "inactivity_framerate_limit", + &["inactivityFpsLimit"], + "1.21.2", + "26.3", + "video", + true, + SettingEditor::Enum(INACTIVITY_FPS_LIMIT), + ValueEncoding::QuotedEnum(INACTIVITY_FPS_LIMIT), + ), + ranged_setting( + "mipmap_levels", + &["mipmapLevels"], + "1.7.2", + "26.3", + "video", + false, + SettingEditor::Integer { + min: 0, + max: 4, + step: 1, + }, + ValueEncoding::Integer, + ), + ranged_setting( + "biome_blend_radius", + &["biomeBlendRadius"], + "1.13", + "26.3", + "video", + true, + SettingEditor::Integer { + min: 0, + max: 7, + step: 1, + }, + ValueEncoding::Integer, + ), + ranged_setting( + "prioritize_chunk_updates", + &["prioritizeChunkUpdates"], + "1.18", + "26.3", + "video", + true, + SettingEditor::Enum(PRIORITIZE_CHUNK_UPDATES), + ValueEncoding::Enum(PRIORITIZE_CHUNK_UPDATES), + ), + ranged_setting( + "attack_indicator", + &["attackIndicator"], + "1.9", + "26.3", + "video", + true, + SettingEditor::Enum(ATTACK_INDICATOR), + ValueEncoding::Enum(ATTACK_INDICATOR), + ), + ranged_setting( + "reduced_debug_info", + &["reducedDebugInfo"], + "1.8", + "26.3", + "video", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "chunk_fade_time", + &["chunkSectionFadeInTime"], + "1.21.11", + "26.3", + "video", + false, + SettingEditor::UnboundedDecimal, + ValueEncoding::Decimal, + ), + ranged_setting( + "cutout_leaves", + &["cutoutLeaves"], + "1.21.11", + "26.3", + "video", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "improved_transparency", + &["improvedTransparency"], + "1.21.11", + "26.3", + "video", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "texture_filtering", + &["textureFiltering"], + "1.21.11", + "26.3", + "video", + false, + SettingEditor::Enum(TEXTURE_FILTERING), + ValueEncoding::Enum(TEXTURE_FILTERING), + ), + ranged_setting( + "anisotropy", + &["maxAnisotropyBit"], + "1.21.11", + "26.3", + "video", + false, + SettingEditor::UnboundedInteger, + ValueEncoding::Integer, + ), + ranged_setting( + "vignette", + &["vignette"], + "1.21.11", + "26.3", + "video", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "weather_radius", + &["weatherRadius"], + "1.21.11", + "26.3", + "video", + false, + SettingEditor::UnboundedInteger, + ValueEncoding::Integer, + ), + ranged_setting( + "advanced_opengl", + &["advancedOpengl"], + "1.0", + "1.7.10", + "video", + false, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "anaglyph_3d", + &["anaglyph3d"], + "1.0", + "1.12.2", + "video", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "anisotropic_filtering", + &["anisotropicFiltering"], + "1.7.2", + "1.7.10", + "video", + false, + SettingEditor::UnboundedInteger, + ValueEncoding::Integer, + ), + ranged_setting( + "alternate_blocks", + &["allowBlockAlternatives"], + "1.8", + "1.8.9", + "video", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "held_item_tooltips", + &["heldItemTooltips"], + "1.4.6", + "1.19.3", + "video", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "use_vbo", + &["useVbo"], + "1.8", + "1.13.2", + "video", + false, + BOOL, + ValueEncoding::Bool, + ), +]; \ No newline at end of file diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/fullscreen.rs b/packages/app-lib/src/api/instance/synced_options/game_options/fullscreen.rs new file mode 100644 index 0000000..aa5a3b4 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/fullscreen.rs @@ -0,0 +1,149 @@ +//! Keeps the app-wide fullscreen setting aligned with the shared Minecraft value. + +use super::CATALOG_REVISION; +use crate::state::{ + CanonicalValue, State, load_game_option_preferences, + load_game_options_sync_state, load_shared_game_options, +}; +use chrono::Utc; +use sqlx::{Sqlite, Transaction}; + +const FULLSCREEN_OPTION_ID: &str = "fullscreen"; + +pub(crate) async fn shared_fullscreen_value( + state: &State, +) -> crate::Result> { + let preferences = load_game_option_preferences(&state.pool).await?; + if !preferences + .get(FULLSCREEN_OPTION_ID) + .is_some_and(|preference| preference.enabled) + { + return Ok(None); + } + let values = load_shared_game_options(&state.pool).await?; + Ok(values.get(FULLSCREEN_OPTION_ID).and_then(|stored| { + if !stored.seeded { + return None; + } + match stored.value.as_ref() { + Some(CanonicalValue::Bool(value)) => Some(*value), + _ => None, + } + })) +} + +pub(crate) async fn update_shared_fullscreen_from_app( + state: &State, + value: bool, +) -> crate::Result { + let values = load_shared_game_options(&state.pool).await?; + let preferences = load_game_option_preferences(&state.pool).await?; + let stored = values.get(FULLSCREEN_OPTION_ID); + let preference = preferences.get(FULLSCREEN_OPTION_ID); + let value_matches = stored.is_some_and(|stored| { + stored.seeded + && matches!( + stored.value.as_ref(), + Some(CanonicalValue::Bool(current)) if *current == value + ) + }); + let sync_enabled = preference.is_some_and(|preference| preference.enabled); + if !sync_enabled || value_matches { + return Ok(false); + } + + let option_revision = stored + .map(|stored| stored.revision) + .unwrap_or(0) + .max( + preference + .map(|preference| preference.revision) + .unwrap_or(0), + ) + .saturating_add(1) as i64; + let (canonical_revision, _) = + load_game_options_sync_state(&state.pool, CATALOG_REVISION).await?; + let next_canonical_revision = canonical_revision.saturating_add(1) as i64; + let canonical = CanonicalValue::Bool(value); + let value_json = serde_json::to_string(&canonical)?; + let canonical_type = canonical.type_name(); + let now = Utc::now().timestamp(); + let catalog_revision = CATALOG_REVISION as i64; + let kind = "vanilla"; + let raw_key: Option<&str> = None; + let value_codec = "catalog"; + let source_game_version: Option<&str> = None; + let mut tx = state.pool.begin().await?; + + sqlx::query( + "INSERT INTO synced_game_option_values + (option_id, kind, raw_key, canonical_type, + canonical_value_json, value_codec, seeded, revision, origin, + source_game_version, source_instance_id, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 1, ?, 'app_editor', ?, NULL, ?) + ON CONFLICT(option_id) DO UPDATE SET + kind = excluded.kind, raw_key = excluded.raw_key, + canonical_type = excluded.canonical_type, + canonical_value_json = excluded.canonical_value_json, + value_codec = excluded.value_codec, seeded = 1, + revision = excluded.revision, origin = 'app_editor', + source_game_version = NULL, source_instance_id = NULL, + updated_at = excluded.updated_at", + ) + .bind(FULLSCREEN_OPTION_ID) + .bind(kind) + .bind(raw_key) + .bind(canonical_type) + .bind(value_json) + .bind(value_codec) + .bind(option_revision) + .bind(source_game_version) + .bind(now) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO synced_game_option_preferences + (option_id, enabled, source, revision) + VALUES (?, ?, ?, ?) + ON CONFLICT(option_id) DO UPDATE SET + enabled = excluded.enabled, source = 'user', + revision = excluded.revision", + ) + .bind(FULLSCREEN_OPTION_ID) + .bind(true) + .bind("user") + .bind(option_revision) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO synced_game_option_state (singleton, revision, catalog_revision) + VALUES (1, ?, ?) + ON CONFLICT(singleton) DO UPDATE SET + revision = excluded.revision, + catalog_revision = excluded.catalog_revision", + ) + .bind(next_canonical_revision) + .bind(catalog_revision) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(true) +} + +pub(super) async fn update_app_fullscreen_setting( + tx: &mut Transaction<'_, Sqlite>, + value: &CanonicalValue, + sync_enabled: bool, +) -> crate::Result<()> { + if !sync_enabled { + return Ok(()); + } + let CanonicalValue::Bool(value) = value else { + return Ok(()); + }; + sqlx::query("UPDATE settings SET mc_force_fullscreen = ? WHERE id = 0") + .bind(*value) + .execute(&mut **tx) + .await?; + Ok(()) +} diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/instance_support.rs b/packages/app-lib/src/api/instance/synced_options/game_options/instance_support.rs new file mode 100644 index 0000000..b17e0ea --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/instance_support.rs @@ -0,0 +1,498 @@ +//! Works out which instances can receive each shared setting. + +use super::super as synced_options; +use super::CATALOG_REVISION; +use super::api_types::*; +use super::catalog::*; +use super::options_file::{ + GameOptionsDocument, options_path, read_document, sha1_bytes, +}; +use crate::state::{ + CanonicalValue, InstanceMetadata, State, StoredOption, + game_options_sync_is_enabled, +}; +use std::collections::BTreeMap; + +/// The instance data needed to decide whether a setting can be synced. +#[derive(Clone)] +pub(super) struct ParticipatingInstance { + metadata: InstanceMetadata, + deferred: bool, + fullscreen_controlled: bool, + pub(super) document: Option, + inspection_error: Option, +} + +/// Loads every instance with game-settings sync turned on. +/// +/// An instance may not have an `options.txt` until its first launch. It still takes +/// part in sync, but its settings are marked as waiting for the file. +pub(super) async fn load_participating_instances( + state: &State, +) -> crate::Result> { + if !game_options_sync_is_enabled(&state.pool).await? { + return Ok(Vec::new()); + } + let instances = crate::state::list_instances(&state.pool).await?; + let mut participants = Vec::new(); + for metadata in instances { + if !metadata.synced_options.game_options { + continue; + } + let deferred = synced_options::sync_files_are_protected(&metadata) + || synced_options::pending::contains( + &metadata.instance.id, + crate::state::SyncedOption::GameOptions, + state, + ) + .await?; + let path = options_path(&metadata, state)?; + let (document, inspection_error) = if path.exists() { + match read_document(&path).await { + Ok((document, _)) => (Some(document), None), + Err(error) => { + tracing::warn!( + "Could not inspect game options compatibility for {}: {error}", + metadata.instance.id + ); + (None, Some(error.to_string())) + } + } + } else { + (None, None) + }; + participants.push(ParticipatingInstance { + fullscreen_controlled: metadata + .launch_overrides + .force_fullscreen + .is_some(), + metadata, + deferred, + document, + inspection_error, + }); + } + Ok(participants) +} + +pub(super) fn summary_revision( + canonical_revision: u64, + participants: &[ParticipatingInstance], +) -> String { + let mut summary = format!("{CATALOG_REVISION}:{canonical_revision}"); + let mut participants = participants.iter().collect::>(); + participants.sort_by(|left, right| { + left.metadata.instance.id.cmp(&right.metadata.instance.id) + }); + for participant in participants { + summary.push('|'); + summary.push_str(&participant.metadata.instance.id); + summary.push(':'); + summary + .push_str(&participant.metadata.applied_content_set.game_version); + summary.push(':'); + summary.push_str(if participant.deferred { + "deferred" + } else { + "ready" + }); + summary.push(':'); + summary.push_str(if participant.inspection_error.is_some() { + "degraded" + } else if participant.document.is_some() { + "file" + } else { + "missing" + }); + summary.push(':'); + summary.push_str(if participant.fullscreen_controlled { + "fullscreen-controlled" + } else { + "fullscreen-shared" + }); + } + sha1_bytes(summary.as_bytes()) +} +/// Counts which instances can receive a setting now, later, or not at all. +/// +/// The settings screen uses these results for its preview, and the file writer uses +/// the same checks when it saves. A setting shown as blocked therefore cannot be +/// written behind the user's back. +pub(super) fn describe_instance_support( + definition: Option<&SupportedSetting>, + stored: &StoredOption, + value: Option<&CanonicalValue>, + participants: &[ParticipatingInstance], +) -> GameOptionCompatibility { + let total = participants.len() as u32; + let mut will_receive = 0; + let mut write_now = 0; + let mut buckets: BTreeMap< + (String, String, String, String), + GameOptionCompatibilityBucket, + > = BTreeMap::new(); + + for participant in participants { + let version = participant + .metadata + .applied_content_set + .game_version + .clone(); + // Check launcher-controlled settings first. Finding the key in options.txt + // does not make a launcher-owned setting safe to overwrite. + let (status, key, mapping, reason) = if stored.option_id == "fullscreen" + && participant.fullscreen_controlled + { + ( + GameOptionCompatibilityStatus::Controlled, + None, + None, + Some(GameOptionCompatibilityReason::LauncherControlled), + ) + } else if definition.is_some() + && !supported_settings_cover_game_version(&version) + { + ( + GameOptionCompatibilityStatus::CatalogUncovered, + None, + None, + Some(GameOptionCompatibilityReason::CatalogUncovered), + ) + } else if participant.inspection_error.is_some() { + ( + GameOptionCompatibilityStatus::Degraded, + None, + None, + Some(GameOptionCompatibilityReason::InspectionFailed), + ) + } else { + match &participant.document { + None => { + // An instance that has not launched yet has no options.txt. If we know + // the key Minecraft will create, wait for the file instead of excluding it. + if definition.is_none() + && stored.raw_key.is_some() + && matches!(value, Some(CanonicalValue::ExternalRaw(_))) + { + ( + GameOptionCompatibilityStatus::WaitingForFile, + stored.raw_key.clone(), + Some(GameOptionMappingKind::Direct), + Some(GameOptionCompatibilityReason::WaitingForOptionsFile), + ) + } else if let Some((key, mapping)) = + definition.and_then(|definition| { + target_key_for_version(definition, &version).map( + |key| { + let mapping = physical_variant_for_version( + definition, &version, + ) + .map(|variant| variant.mapping) + .unwrap_or(GameOptionMappingKind::Direct); + (key, mapping) + }, + ) + }) + { + let representable = value.is_none_or(|value| { + definition.is_some_and(|definition| { + encode_value( + definition, key, value, &version, None, + ) + .is_some() + }) + }); + if representable { + ( + GameOptionCompatibilityStatus::WaitingForFile, + Some(key.to_string()), + Some(mapping), + Some(GameOptionCompatibilityReason::WaitingForOptionsFile), + ) + } else { + ( + GameOptionCompatibilityStatus::UnsupportedValue, + Some(key.to_string()), + None, + Some(GameOptionCompatibilityReason::UnsupportedValue), + ) + } + } else { + ( + GameOptionCompatibilityStatus::NotAvailable, + None, + None, + Some(GameOptionCompatibilityReason::MissingSetting), + ) + } + } + Some(document) => { + // Vanilla keys sometimes change between versions. Replace an old key with + // its newer name only when we know the value can be converted safely. + let key = target_physical_key( + definition, stored, document, &version, + ); + if let Some(key) = key { + let needs_alias_migration = + definition.is_some_and(|definition| { + alias_migration_needed( + definition, document, &key, + ) + }); + let representable = + value.is_none_or(|value| match definition { + Some(definition) => encode_value( + definition, + &key, + value, + &version, + document.value(&key), + ) + .is_some(), + None => matches!( + value, + CanonicalValue::ExternalRaw(_) + ), + }); + if !representable { + ( + GameOptionCompatibilityStatus::UnsupportedValue, + Some(key.to_string()), + None, + Some(GameOptionCompatibilityReason::UnsupportedValue), + ) + } else { + let mapped = if needs_alias_migration { + Some(GameOptionMappingKind::Migrated) + } else { + definition.and_then(|definition| { + physical_variant_for_present_key( + definition, &key, &version, + ) + .map(|variant| variant.mapping) + .or_else(|| { + (matches!( + definition.encoding, + ValueEncoding::KeyBinding + ) && document.value(&key).is_some_and( + |raw| { + split_key_binding(raw) + .0 + .parse::() + .is_ok() + }, + )) + .then_some( + GameOptionMappingKind::Migrated, + ) + }) + }) + }; + let legacy = definition.is_some_and(|definition| { + definition.keys.first().copied() + != Some(key.as_str()) + }); + ( + if participant.deferred { + GameOptionCompatibilityStatus::Deferred + } else { + GameOptionCompatibilityStatus::Ready + }, + Some(key.to_string()), + Some(mapped.unwrap_or(if legacy { + GameOptionMappingKind::Legacy + } else { + GameOptionMappingKind::Direct + })), + needs_alias_migration + .then_some(GameOptionCompatibilityReason::MigratesOnWrite), + ) + } + } else if let Some(definition) = + definition.filter(|definition| { + setting_available_for_version(definition, &version) + && definition + .keys + .iter() + .any(|key| document.value(key).is_some()) + }) + { + let eventual_key = + target_key_for_version(definition, &version) + .map(str::to_string); + let representable = + eventual_key.as_deref().is_some_and(|key| { + value.is_none_or(|value| { + encode_value( + definition, key, value, &version, None, + ) + .is_some() + }) + }); + if representable { + ( + GameOptionCompatibilityStatus::WaitingForBase, + eventual_key, + Some(GameOptionMappingKind::Migrated), + Some(GameOptionCompatibilityReason::WaitingForCompatibleBase), + ) + } else { + ( + GameOptionCompatibilityStatus::UnsupportedValue, + eventual_key, + None, + Some(GameOptionCompatibilityReason::UnsupportedValue), + ) + } + } else { + ( + GameOptionCompatibilityStatus::NotAvailable, + None, + None, + Some(GameOptionCompatibilityReason::MissingSetting), + ) + } + } + } + }; + + let receives = matches!( + status, + GameOptionCompatibilityStatus::Ready + | GameOptionCompatibilityStatus::Deferred + | GameOptionCompatibilityStatus::WaitingForFile + | GameOptionCompatibilityStatus::WaitingForBase + ); + if receives { + will_receive += 1; + } + if matches!(status, GameOptionCompatibilityStatus::Ready) { + write_now += 1; + } + let status_key = serde_json::to_string(&status).unwrap_or_default(); + let key_name = key.clone().unwrap_or_default(); + let mapping_key = mapping + .as_ref() + .and_then(|mapping| serde_json::to_string(mapping).ok()) + .unwrap_or_default(); + let reason_key = reason + .as_ref() + .and_then(|reason| serde_json::to_string(reason).ok()) + .unwrap_or_default(); + // Group instances with the same result to keep the response small. Keep their + // Minecraft versions so the UI can explain which versions are affected. + let bucket = buckets + .entry((status_key, key_name.clone(), mapping_key, reason_key)) + .or_insert_with(|| GameOptionCompatibilityBucket { + instance_count: 0, + write_keys: if matches!( + status, + GameOptionCompatibilityStatus::Ready + | GameOptionCompatibilityStatus::Deferred + ) { + key.clone().into_iter().collect() + } else { + Vec::new() + }, + eventual_keys: if matches!( + status, + GameOptionCompatibilityStatus::WaitingForFile + | GameOptionCompatibilityStatus::WaitingForBase + ) { + key.clone().into_iter().collect() + } else { + Vec::new() + }, + game_versions: vec![version.clone()], + status, + mapping, + reason, + }); + bucket.instance_count += 1; + if !bucket.game_versions.contains(&version) { + bucket.game_versions.push(version); + bucket.game_versions.sort(); + } + } + + let left_local = total.saturating_sub(will_receive); + GameOptionCompatibility { + total_participating: total, + will_receive, + write_now, + left_local, + buckets: buckets.into_values().collect(), + } +} + +/// Uses the local value only when every readable instance currently agrees on it. +pub(super) fn find_common_local_value( + definition: Option<&SupportedSetting>, + stored: &StoredOption, + participants: &[ParticipatingInstance], +) -> (Option, GameOptionValueState) { + let mut values = Vec::new(); + let mut invalid = false; + for participant in participants { + if stored.option_id == "fullscreen" && participant.fullscreen_controlled + { + continue; + } + if definition.is_some() + && !supported_settings_cover_game_version( + &participant.metadata.applied_content_set.game_version, + ) + { + continue; + } + let Some(document) = &participant.document else { + continue; + }; + let key = if let Some(definition) = definition { + observed_physical_key( + definition, + document, + &participant.metadata.applied_content_set.game_version, + ) + } else { + target_physical_key( + None, + stored, + document, + &participant.metadata.applied_content_set.game_version, + ) + }; + let Some(key) = key else { + continue; + }; + let Some(raw_value) = document.value(&key) else { + continue; + }; + let value = if let Some(definition) = definition { + decode_value(definition, &key, raw_value) + } else { + Some(CanonicalValue::ExternalRaw(raw_value.to_string())) + }; + if let Some(value) = + value.filter(|value| validate_file_value(definition, value).is_ok()) + { + values.push(value); + } else { + invalid = true; + } + } + if invalid && values.is_empty() { + return (None, GameOptionValueState::Invalid); + } + let Some(first) = values.first().cloned() else { + return (None, GameOptionValueState::Unset); + }; + if values + .iter() + .all(|value| canonical_values_equal(value, &first)) + { + (Some(first), GameOptionValueState::UniformLocal) + } else { + (None, GameOptionValueState::Mixed) + } +} diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/launch_overrides.rs b/packages/app-lib/src/api/instance/synced_options/game_options/launch_overrides.rs new file mode 100644 index 0000000..73ba5f9 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/launch_overrides.rs @@ -0,0 +1,76 @@ +//! Writes synced settings and launcher overrides before Minecraft starts. + +use super::options_file::{ + GameOptionsDocument, input_error, options_path, read_document, sha1_bytes, + validate_raw_key_value, +}; +use super::write_shared_settings::{ + apply_shared_settings_to_instance, remember_processed_sha, + sync_is_active_for_instance, +}; +use crate::state::{InstanceMetadata, State}; +use crate::util::io; +use std::collections::HashSet; + +pub(super) fn currently_launcher_owned_keys( + metadata: &InstanceMetadata, +) -> HashSet { + let mut keys = HashSet::new(); + if metadata.launch_overrides.force_fullscreen.is_some() { + keys.insert("fullscreen".to_string()); + } + keys +} + +/// Writes canonical settings to `options.txt` before launch. +pub async fn sync_before_launch(instance_id: &str) -> crate::Result<()> { + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| input_error("Unknown instance"))?; + if sync_is_active_for_instance(&metadata, &state).await? { + let _ = + apply_shared_settings_to_instance(&metadata, &state, false).await?; + } + Ok(()) +} + +/// Applies launcher settings, such as forced fullscreen, after shared settings. +/// +/// Stores the written file's hash so the watcher ignores the app's write. +pub async fn apply_launcher_overrides( + instance_id: &str, + overrides: &[(String, String)], +) -> crate::Result<()> { + if overrides.is_empty() { + return Ok(()); + } + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| input_error("Unknown instance"))?; + let path = options_path(&metadata, &state)?; + let (mut document, input_bytes) = if path.exists() { + read_document(&path).await? + } else { + ( + GameOptionsDocument::for_instance(&metadata, &state) + .await? + .unwrap_or_else(GameOptionsDocument::empty), + Vec::new(), + ) + }; + for (key, value) in overrides { + validate_raw_key_value(key, value)?; + document.set(key, value, true)?; + } + let output_bytes = document.serialize()?; + let output_sha1 = sha1_bytes(&output_bytes); + if output_bytes != input_bytes { + io::write(&path, output_bytes).await?; + } + remember_processed_sha(instance_id, &output_sha1, &state).await?; + Ok(()) +} diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/locales/archive.rs b/packages/app-lib/src/api/instance/synced_options/game_options/locales/archive.rs new file mode 100644 index 0000000..3020cb0 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/locales/archive.rs @@ -0,0 +1,272 @@ +use super::super::options_file::{input_error, sha1_bytes}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +use std::io::{Cursor, Read, Seek}; +use std::path::Path; +use zip::ZipArchive; + +pub(super) const MAX_LANGUAGE_BYTES: usize = 4 * 1024 * 1024; +const MAX_ARCHIVE_ENTRIES: usize = 100_000; +const MAX_NESTED_BYTES: usize = 64 * 1024 * 1024; +const MAX_TOTAL_BYTES: usize = 128 * 1024 * 1024; +const MAX_NESTED_DEPTH: usize = 4; +pub(super) type Translations = BTreeMap; + +#[derive(Default, Serialize, Deserialize)] +pub(super) struct ArchiveIndex { + #[serde(default)] + pub version: u32, + pub bundles: Vec, +} + +#[derive(Default, Serialize, Deserialize)] +pub(super) struct LanguageBundle { + pub nested_path: String, + pub locales: BTreeMap, + #[serde(default)] + pub deprecated: DeprecatedTranslations, +} + +#[derive(Default, Serialize, Deserialize)] +pub(super) struct DeprecatedTranslations { + #[serde(default)] + removed: Vec, + #[serde(default)] + renamed: BTreeMap, +} + +impl DeprecatedTranslations { + pub fn apply(&self, translations: &mut Translations) { + for key in &self.removed { + translations.remove(key); + } + for (from, to) in &self.renamed { + if let Some(value) = translations.remove(from) { + translations.insert(to.clone(), value); + } else { + translations.remove(to); + } + } + } +} + +pub(super) fn parse_language( + bytes: &[u8], + legacy: bool, +) -> crate::Result { + if bytes.len() > MAX_LANGUAGE_BYTES { + return Err(input_error("Minecraft language file exceeds size limit")); + } + let values: Translations = if legacy { + String::from_utf8_lossy(bytes) + .lines() + .filter(|line| !line.starts_with('#')) + .filter_map(|line| line.split_once('=')) + .map(|(key, value)| (key.to_owned(), value.to_owned())) + .collect() + } else { + serde_json::from_slice::>(bytes)? + .into_iter() + .filter_map(|(key, value)| { + value.as_str().map(|s| (key, s.to_owned())) + }) + .collect() + }; + Ok(values + .into_iter() + .filter(|(key, value)| { + !key.is_empty() && key.len() <= 1024 && value.len() <= 32 * 1024 + }) + .collect()) +} + +pub(super) fn inspect( + path: &Path, + expected_hash: &str, +) -> crate::Result { + let mut file = + std::fs::File::open(path).map_err(crate::util::io::IOError::from)?; + let mut hasher = sha1_smol::Sha1::new(); + let mut buffer = vec![0; 64 * 1024].into_boxed_slice(); + loop { + let count = file + .read(&mut buffer) + .map_err(crate::util::io::IOError::from)?; + if count == 0 { + break; + } + hasher.update(&buffer[..count]); + } + if hasher.digest().to_string() != expected_hash { + return Err(input_error( + "Locale source changed before it could be indexed", + )); + } + file.rewind().map_err(crate::util::io::IOError::from)?; + let mut index = ArchiveIndex { + version: 2, + ..Default::default() + }; + let mut budget = MAX_TOTAL_BYTES; + let mut entries = MAX_ARCHIVE_ENTRIES; + inspect_archive(file, "", 0, &mut budget, &mut entries, &mut index)?; + Ok(index) +} + +fn read_entry( + archive: &mut ZipArchive, + name: &str, + limit: usize, + budget: &mut usize, +) -> crate::Result> { + let mut entry = archive + .by_name(name) + .map_err(|e| input_error(e.to_string()))?; + let limit = limit.min(*budget); + if entry.size() > limit as u64 { + return Err(input_error("Locale archive entry exceeds size limit")); + } + let mut bytes = Vec::new(); + entry + .by_ref() + .take(limit as u64 + 1) + .read_to_end(&mut bytes) + .map_err(crate::util::io::IOError::from)?; + if bytes.len() > limit { + return Err(input_error("Locale archive entry exceeds size limit")); + } + *budget -= bytes.len(); + Ok(bytes) +} + +fn inspect_archive( + reader: R, + nested_path: &str, + depth: usize, + budget: &mut usize, + entries: &mut usize, + index: &mut ArchiveIndex, +) -> crate::Result<()> { + let mut archive = + ZipArchive::new(reader).map_err(|e| input_error(e.to_string()))?; + if archive.len() > *entries { + return Err(input_error("Too many entries in locale archive")); + } + *entries -= archive.len(); + let names: BTreeSet = + archive.file_names().map(str::to_owned).collect(); + let mut bundle = LanguageBundle { + nested_path: nested_path.to_owned(), + ..Default::default() + }; + for name in &names { + let parts: Vec<_> = name.split('/').collect(); + let (namespace, filename) = match parts.as_slice() { + ["assets", namespace, "lang", filename] => (*namespace, *filename), + ["lang", filename] => ("minecraft", *filename), + _ => continue, + }; + if filename == "deprecated.json" { + if namespace == "minecraft" { + let bytes = + read_entry(&mut archive, name, MAX_LANGUAGE_BYTES, budget)?; + bundle.deprecated = serde_json::from_slice(&bytes)?; + } + continue; + } + let legacy = filename.ends_with(".lang"); + let Some(locale) = + filename.strip_suffix(if legacy { ".lang" } else { ".json" }) + else { + continue; + }; + if !valid_locale(locale) { + continue; + } + let bytes = read_entry(&mut archive, name, MAX_LANGUAGE_BYTES, budget)?; + match parse_language(&bytes, legacy) { + Ok(values) => bundle + .locales + .entry(locale.to_ascii_lowercase()) + .or_default() + .extend(values), + Err(error) => { + tracing::debug!(%name, %error, "Skipping malformed mod language file") + } + } + } + index.bundles.push(bundle); + let mut nested = BTreeSet::new(); + for manifest in [ + "fabric.mod.json", + "quilt.mod.json", + "META-INF/jarjar/metadata.json", + ] { + if !names.contains(manifest) { + continue; + } + let bytes = + read_entry(&mut archive, manifest, MAX_LANGUAGE_BYTES, budget)?; + let Ok(value) = serde_json::from_slice::(&bytes) + else { + continue; + }; + let jars = value + .get("jars") + .or_else(|| value.pointer("/quilt_loader/jars")); + for jar in jars.and_then(|v| v.as_array()).into_iter().flatten() { + if let Some(path) = jar + .as_str() + .or_else(|| jar.get("file").and_then(|v| v.as_str())) + .or_else(|| jar.get("path").and_then(|v| v.as_str())) + { + nested.insert(path.to_owned()); + } + } + } + if !nested.is_empty() && depth >= MAX_NESTED_DEPTH { + return Err(input_error("Locale archive nesting exceeds limit")); + } + for name in nested { + if !names.contains(&name) || !name.ends_with(".jar") { + continue; + } + let bytes = read_entry(&mut archive, &name, MAX_NESTED_BYTES, budget)?; + let path = if nested_path.is_empty() { + name + } else { + format!("{nested_path}!{name}") + }; + inspect_archive( + Cursor::new(bytes), + &path, + depth + 1, + budget, + entries, + index, + )?; + } + Ok(()) +} + +pub(super) fn valid_locale(locale: &str) -> bool { + !locale.is_empty() + && locale.len() <= 32 + && locale + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'_') +} + +pub(super) fn valid_hash(hash: &str) -> bool { + hash.len() == 40 && hash.bytes().all(|c| c.is_ascii_hexdigit()) +} + +pub(super) fn checked_bytes( + bytes: Vec, + hash: &str, +) -> crate::Result> { + if sha1_bytes(&bytes) != hash { + return Err(input_error("Minecraft locale resource checksum mismatch")); + } + Ok(bytes) +} diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/locales/mod.rs b/packages/app-lib/src/api/instance/synced_options/game_options/locales/mod.rs new file mode 100644 index 0000000..50eda36 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/locales/mod.rs @@ -0,0 +1,581 @@ +//! Caches mod translations and remembers which JAR supplied each setting. + +mod archive; +mod origins; +mod sources; +mod storage; + +use super::catalog::{is_never_sync_key, setting_by_file_key}; +use super::options_file::{ + GameOptionsDocument, input_error, options_path, read_document, +}; +use crate::state::{InstanceMetadata, State}; +use archive::{ArchiveIndex, Translations}; +use serde::{Deserialize, Serialize}; +use sources::{ArchiveSource, Snapshot}; +use std::collections::{BTreeMap, HashMap}; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; +use tokio::sync::Notify; + +#[derive(Default)] +pub(crate) struct GameLocaleIndexer { + notify: Notify, + started: AtomicBool, +} + +#[derive(Clone, Serialize, Deserialize)] +struct Observation { + snapshot: String, + raw_key: String, +} + +#[derive(Clone, Serialize, Deserialize)] +struct Origin { + instance_id: String, + game_version: String, + /// Rejects origins that older versions resolved from Minecraft instead of a mod. + #[serde(default, rename = "game_jar_hash", skip_serializing)] + legacy_game_jar_hash: Option, + archive: ArchiveSource, + nested_path: String, + translation_key: String, + choices: BTreeMap, +} + +#[derive(Default, Serialize)] +pub struct GameSettingLocaleLabels { + pub settings: BTreeMap, +} + +#[derive(Serialize)] +pub struct GameSettingLocaleLabel { + pub label: String, + pub choices: BTreeMap, + pub source: Option, +} + +#[derive(Serialize)] +pub struct GameSettingSource { + pub instance_id: String, + pub file_name: String, + pub file_path: String, + pub project: Option, +} + +#[derive(Serialize)] +pub struct GameSettingSourceProject { + pub id: String, + pub title: String, + pub icon_url: Option, +} + +struct Candidate { + snapshot_id: String, + snapshot: Snapshot, + keys: BTreeMap, +} + +pub(crate) fn queue_game_locale_index() { + if let Some(state) = State::get_if_initialized() { + tracing::info!( + started = state.game_locale_indexer.started.load(Ordering::Acquire), + "Game setting locales: indexing queued" + ); + state.game_locale_indexer.notify.notify_one(); + } else { + tracing::warn!( + "Game setting locales: cannot queue indexing before state initialization" + ); + } +} + +pub(crate) fn start_game_locale_indexer(state: Arc) { + if state + .game_locale_indexer + .started + .swap(true, Ordering::AcqRel) + { + return; + } + tracing::info!("Game setting locales: indexer started"); + state.game_locale_indexer.notify.notify_one(); + tokio::spawn(async move { + loop { + state.game_locale_indexer.notify.notified().await; + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + let started = std::time::Instant::now(); + if let Err(error) = index_installed_sources(&state).await { + tracing::warn!(%error, "Game setting locales: indexing failed"); + } + tracing::info!( + elapsed_ms = started.elapsed().as_millis(), + "Game setting locales: indexing pass finished" + ); + } + }); +} + +fn mod_translation_key(raw_key: &str) -> Option<&str> { + if setting_by_file_key(raw_key).is_some() || is_never_sync_key(raw_key) { + return None; + } + raw_key.strip_prefix("key_") +} + +fn document_keys(document: &GameOptionsDocument) -> BTreeMap { + document + .effective_entries() + .keys() + .filter(|key| mod_translation_key(key).is_some()) + .map(|key| (format!("external:{key}"), (*key).to_owned())) + .collect() +} + +pub(super) fn has_localizable_mod_settings( + document: &GameOptionsDocument, +) -> bool { + document + .effective_entries() + .keys() + .any(|key| mod_translation_key(key).is_some()) +} + +/// Records mod JAR hashes before saving new options. +pub(super) async fn capture_observation( + metadata: &InstanceMetadata, + state: &State, +) -> Option { + match sources::snapshot_instance(metadata, state).await { + Ok(id) => Some(id), + Err(error) => { + tracing::warn!(%error, instance_id = metadata.instance.id, + "Game setting locales: snapshot capture failed"); + None + } + } +} + +pub(super) async fn record_observations( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + metadata: &InstanceMetadata, + document: &GameOptionsDocument, + snapshot: Option<&str>, +) { + let Some(snapshot) = snapshot else { + return; + }; + for (id, raw_key) in document_keys(document) { + let observation = Observation { + snapshot: snapshot.to_owned(), + raw_key, + }; + if let Err(error) = storage::observe( + tx, + "", + &id, + &metadata.instance.id, + &metadata.applied_content_set.game_version, + &observation, + ) + .await + { + tracing::warn!(%error, "Could not record game-setting translation provenance"); + } + } +} + +async fn cached_archive( + state: &State, + source: &ArchiveSource, + cache: &mut HashMap>, +) -> crate::Result> { + if let Some(index) = cache.get(&source.hash) { + return Ok(index.clone()); + } + let index = Arc::new(sources::archive_index(state, source).await?); + if cache.len() >= 8 { + cache.clear(); + } + cache.insert(source.hash.clone(), index.clone()); + Ok(index) +} + +#[tracing::instrument(skip_all, err)] +async fn index_installed_sources(state: &State) -> crate::Result<()> { + let mut instances = crate::state::list_instances(&state.pool).await?; + tracing::info!( + instances = instances.len(), + "Game setting locales: indexing installed instances" + ); + instances.sort_by(|a, b| a.instance.id.cmp(&b.instance.id)); + let mut candidates = Vec::new(); + let mut resolver = origins::OriginResolver::default(); + for metadata in instances { + if super::super::sync_files_are_protected(&metadata) { + tracing::info!( + instance_id = metadata.instance.id, + "Game setting locales: skipping protected instance" + ); + continue; + } + let path = match options_path(&metadata, state) { + Ok(path) => path, + Err(error) => { + tracing::warn!(%error, instance_id = metadata.instance.id, "Game setting locales: options path resolution failed"); + continue; + } + }; + let document = match read_document(&path).await { + Ok((document, _)) => document, + Err(error) => { + tracing::warn!(%error, instance_id = metadata.instance.id, "Game setting locales: options file read failed"); + continue; + } + }; + let keys = document_keys(&document); + if keys.is_empty() { + continue; + } + let Some(snapshot_id) = capture_observation(&metadata, state).await + else { + continue; + }; + let snapshot = match sources::load_snapshot(state, &snapshot_id).await { + Ok(snapshot) => snapshot, + Err(error) => { + tracing::warn!(%error, instance_id = metadata.instance.id, %snapshot_id, + "Could not load game-setting locale snapshot"); + continue; + } + }; + tracing::info!(instance_id = metadata.instance.id, %snapshot_id, game_version = snapshot.game_version, + mods = snapshot.mods.len(), + "Game setting locales: snapshot loaded"); + resolver + .index_snapshot(state, &snapshot_id, snapshot.clone()) + .await?; + let mut tx = state.pool.begin().await?; + for (id, key) in &keys { + let observation = Observation { + snapshot: snapshot_id.clone(), + raw_key: key.clone(), + }; + storage::observe( + &mut tx, + &metadata.instance.id, + id, + &metadata.instance.id, + &snapshot.game_version, + &observation, + ) + .await?; + } + tx.commit().await?; + tracing::info!( + instance_id = metadata.instance.id, + options = keys.len(), + "Game setting locales: observations recorded" + ); + candidates.push(Candidate { + snapshot_id, + snapshot, + keys, + }); + } + let rows = storage::load(&state.pool, None).await?; + tracing::info!( + candidates = candidates.len(), + rows = rows.len(), + resolved = rows.iter().filter(|row| row.origin.is_some()).count(), + "Game setting locales: resolving translation origins" + ); + let mut pinned = 0; + let mut unresolved = 0; + for row in rows { + if row.origin.is_some() { + continue; + } + let mut observations = Vec::new(); + if let Some(observation) = &row.observation { + observations.push(observation.clone()); + } else { + let mut matching: Vec<_> = candidates + .iter() + .filter(|c| { + (row.scope.is_empty() + || row.scope == c.snapshot.instance_id) + && c.keys.contains_key(&row.option_id) + && (row.backfilled + || row + .source_instance_id + .as_ref() + .is_none_or(|id| *id == c.snapshot.instance_id) + && row.source_game_version.as_ref().is_none_or( + |v| *v == c.snapshot.game_version, + )) + }) + .collect(); + matching.sort_by_key(|c| { + ( + row.source_instance_id.as_deref() + != Some(c.snapshot.instance_id.as_str()), + row.source_game_version.as_deref() + != Some(c.snapshot.game_version.as_str()), + c.snapshot.instance_id.clone(), + ) + }); + observations.extend(matching.into_iter().map(|c| Observation { + snapshot: c.snapshot_id.clone(), + raw_key: c.keys[&row.option_id].clone(), + })); + } + let mut resolved = false; + let observation_count = observations.len(); + for observation in observations { + match resolver.resolve(state, &observation).await { + Ok(Some(origin)) => { + storage::pin(&state.pool, &row, &origin).await?; + resolved = true; + pinned += 1; + break; + } + Ok(None) => {} + Err(error) => { + tracing::warn!(%error, scope = row.scope, option_id = row.option_id, + snapshot_id = observation.snapshot, "Game setting locales: origin resolution failed") + } + } + } + if !resolved { + tracing::debug!( + scope = row.scope, + option_id = row.option_id, + observation_count, + backfilled = row.backfilled, + "Game setting locales: no matching translation origin" + ); + unresolved += 1; + } + } + tracing::info!( + pinned, + unresolved, + "Game setting locales: origin resolution finished" + ); + Ok(()) +} + +fn is_plain_label(value: &str) -> bool { + !value.trim().is_empty() && !value.contains('%') && !value.contains('§') +} + +fn plain_label(value: &str) -> Option { + is_plain_label(value).then(|| value.to_owned()) +} + +fn minecraft_locale(locale: &str) -> String { + match locale { + "es-419" => "es_mx".to_owned(), + "fil-PH" => "tl_ph".to_owned(), + "ms-MY" => "ms_my".to_owned(), + "zh-Hans" => "zh_cn".to_owned(), + "zh-Hant" => "zh_tw".to_owned(), + _ => locale.replace('-', "_").to_ascii_lowercase(), + } +} + +/// Loads translations for the settings shown in the modal. +#[tracing::instrument(skip_all, fields(instance_id, locale, requested = option_ids.len(), refresh_sources), err)] +pub async fn get_game_setting_locale_labels( + instance_id: Option<&str>, + locale: &str, + option_ids: Vec, + refresh_sources: bool, +) -> crate::Result { + tracing::info!("Game setting locales: label request received"); + if option_ids.len() > 16_384 { + return Err(input_error("Too many requested game-setting labels")); + } + let requested: std::collections::HashSet<_> = option_ids + .into_iter() + .filter(|id| { + id.strip_prefix("external:") + .and_then(mod_translation_key) + .is_some() + }) + .collect(); + if requested.is_empty() { + return Ok(GameSettingLocaleLabels::default()); + } + let locale = minecraft_locale(locale); + if !archive::valid_locale(&locale) { + return Err(input_error("Invalid Minecraft locale")); + } + let state = State::get().await?; + if refresh_sources { + queue_game_locale_index(); + } + let scope = instance_id.unwrap_or(""); + let mut rows = storage::load(&state.pool, Some(scope)).await?; + tracing::info!( + scope, + rows = rows.len(), + resolved = rows.iter().filter(|row| row.origin.is_some()).count(), + "Game setting locales: scoped origins loaded" + ); + if scope.is_empty() { + let mut sourced: std::collections::HashSet<_> = + rows.iter().map(|row| row.option_id.clone()).collect(); + // Unsaved global options have no provenance row; use an indexed local label until saved. + for row in storage::load(&state.pool, None).await? { + if !row.scope.is_empty() + && requested.contains(&row.option_id) + && row.origin.is_some() + && sourced.insert(row.option_id.clone()) + { + rows.push(row); + } + } + } + tracing::info!( + rows = rows.len(), + resolved = rows.iter().filter(|row| row.origin.is_some()).count(), + "Game setting locales: origins selected including fallbacks" + ); + let hashes: std::collections::HashSet<_> = rows + .iter() + .filter(|row| requested.contains(&row.option_id)) + .filter_map(|row| row.origin.as_ref()) + .filter(|origin| { + origin.legacy_game_jar_hash.as_deref() + != Some(origin.archive.hash.as_str()) + }) + .map(|origin| origin.archive.hash.as_str()) + .collect(); + let projects = sources::mod_projects(&state, &hashes.into_iter().collect::>()).await + .unwrap_or_else(|error| { + tracing::warn!(%error, "Game setting locales: mod project metadata unavailable"); + HashMap::new() + }); + let mut result = GameSettingLocaleLabels::default(); + let mut archives = HashMap::new(); + let mut dictionaries: HashMap = HashMap::new(); + let mut missing_origins = Vec::new(); + for row in rows { + if !requested.contains(&row.option_id) { + continue; + } + let Some(origin) = row.origin else { + missing_origins.push(row.option_id); + continue; + }; + if origin.legacy_game_jar_hash.as_deref() + == Some(origin.archive.hash.as_str()) + { + continue; + } + let dictionary_id = + format!("{}:{}", origin.archive.hash, origin.nested_path); + if !dictionaries.contains_key(&dictionary_id) { + let index = match cached_archive( + &state, + &origin.archive, + &mut archives, + ) + .await + { + Ok(index) => index, + Err(error) => { + tracing::warn!(%error, option_id = row.option_id, hash = origin.archive.hash, + "Game setting locales: label archive unavailable"); + continue; + } + }; + let Some(bundle) = index + .bundles + .iter() + .find(|b| b.nested_path == origin.nested_path) + else { + tracing::warn!( + option_id = row.option_id, + nested_path = origin.nested_path, + "Game setting locales: source bundle missing" + ); + continue; + }; + let mut translations = + bundle.locales.get("en_us").cloned().unwrap_or_default(); + if let Some(selected) = bundle.locales.get(&locale) { + translations.extend(selected.clone()); + } + bundle.deprecated.apply(&mut translations); + tracing::info!(%dictionary_id, %locale, translations = translations.len(), + "Game setting locales: dictionary loaded"); + dictionaries.insert(dictionary_id.clone(), translations); + } + let translations = &dictionaries[&dictionary_id]; + let label = translations + .get(&origin.translation_key) + .and_then(|s| plain_label(s)); + if let Some(label) = label { + let choices = origin + .choices + .iter() + .filter_map(|(value, key)| { + translations + .get(key) + .and_then(|s| plain_label(s)) + .map(|label| (value.clone(), label)) + }) + .collect(); + let source = origin + .archive + .path + .file_name() + .and_then(|name| name.to_str()) + .map(|file_name| GameSettingSource { + instance_id: origin.instance_id.clone(), + file_name: file_name.to_owned(), + file_path: format!("mods/{file_name}"), + project: projects.get(&origin.archive.hash).map( + |project| GameSettingSourceProject { + id: project.id.clone(), + title: project.title.clone(), + icon_url: project.icon_url.clone(), + }, + ), + }); + result.settings.insert( + row.option_id, + GameSettingLocaleLabel { + label, + choices, + source, + }, + ); + } else { + tracing::warn!( + option_id = row.option_id, + key = origin.translation_key, + key_present = + translations.contains_key(&origin.translation_key), + "Game setting locales: translation missing or rejected as a label" + ); + } + } + let mut missing: Vec<_> = requested + .iter() + .filter(|id| !result.settings.contains_key(*id)) + .collect(); + missing.sort(); + tracing::info!( + returned = result.settings.len(), + ?missing, + ?missing_origins, + "Game setting locales: label request completed" + ); + Ok(result) +} diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/locales/origins.rs b/packages/app-lib/src/api/instance/synced_options/game_options/locales/origins.rs new file mode 100644 index 0000000..67cffd9 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/locales/origins.rs @@ -0,0 +1,144 @@ +use super::sources::{self, ArchiveSource, Snapshot}; +use super::{Observation, Origin, is_plain_label, mod_translation_key}; +use crate::state::State; +use crate::util::io; +use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; + +type ArchiveKeys = BTreeMap>; + +struct SnapshotIndex { + snapshot: Snapshot, + keys: BTreeMap)>, +} + +#[derive(Default)] +pub(super) struct OriginResolver { + archives: HashMap>, + snapshots: HashMap, +} + +impl OriginResolver { + pub(super) async fn index_snapshot( + &mut self, + state: &State, + id: &str, + snapshot: Snapshot, + ) -> crate::Result<()> { + if self.snapshots.contains_key(id) { + return Ok(()); + } + let mut archives = Vec::new(); + for (source_index, source) in snapshot.mods.iter().enumerate() { + let archive = if let Some(keys) = self.archives.get(&source.hash) { + Arc::clone(keys) + } else { + match load_archive_keys(state, source).await { + Ok(keys) => { + let keys = Arc::new(keys); + self.archives + .insert(source.hash.clone(), Arc::clone(&keys)); + keys + } + Err(error) => { + tracing::warn!(hash = source.hash, %error, + "Game setting locales: origin archive unavailable"); + continue; + } + } + }; + archives.push((source_index, archive)); + } + let index = tokio::task::spawn_blocking(move || { + let mut keys = BTreeMap::new(); + for (source_index, archive) in archives { + for (key, nested_path) in archive.iter() { + keys.entry(key.clone()).or_insert_with(|| { + (source_index, Arc::clone(nested_path)) + }); + } + } + SnapshotIndex { snapshot, keys } + }) + .await + .map_err(|error| super::input_error(error.to_string()))?; + self.snapshots.insert(id.to_owned(), index); + Ok(()) + } + + pub(super) async fn resolve( + &mut self, + state: &State, + observation: &Observation, + ) -> crate::Result> { + let Some(key) = mod_translation_key(&observation.raw_key) else { + return Ok(None); + }; + if !self.snapshots.contains_key(&observation.snapshot) { + let snapshot = + sources::load_snapshot(state, &observation.snapshot).await?; + self.index_snapshot(state, &observation.snapshot, snapshot) + .await?; + } + let index = &self.snapshots[&observation.snapshot]; + Ok(index + .keys + .get(key) + .map(|(source_index, nested_path)| Origin { + instance_id: index.snapshot.instance_id.clone(), + game_version: index.snapshot.game_version.clone(), + legacy_game_jar_hash: None, + archive: index.snapshot.mods[*source_index].clone(), + nested_path: nested_path.to_string(), + translation_key: key.to_owned(), + choices: BTreeMap::new(), + })) + } +} + +/// A complete key index also caches misses until the archive hash changes. +async fn load_archive_keys( + state: &State, + source: &ArchiveSource, +) -> crate::Result { + if !super::archive::valid_hash(&source.hash) { + return Err(super::input_error("Invalid locale archive hash")); + } + let path = sources::root(state) + .join("origin-keys-v1") + .join(format!("{}.json", source.hash)); + if let Ok(bytes) = io::read(&path).await { + let cached = tokio::task::spawn_blocking(move || { + serde_json::from_slice::(&bytes) + }) + .await + .map_err(|error| super::input_error(error.to_string()))?; + if let Ok(keys) = cached { + return Ok(keys); + } + } + let index = sources::archive_index(state, source).await?; + let keys = tokio::task::spawn_blocking(move || { + let mut keys = ArchiveKeys::new(); + for mut bundle in index.bundles { + let Some(mut english) = bundle.locales.remove("en_us") else { + continue; + }; + bundle.deprecated.apply(&mut english); + let nested_path: Arc = bundle.nested_path.into(); + for (key, value) in english { + if is_plain_label(&value) { + keys.entry(key).or_insert_with(|| Arc::clone(&nested_path)); + } + } + } + keys + }) + .await + .map_err(|error| super::input_error(error.to_string()))?; + if let Err(error) = sources::write_json(&path, &keys).await { + tracing::warn!(hash = source.hash, %error, + "Game setting locales: could not cache origin keys"); + } + Ok(keys) +} diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/locales/sources.rs b/packages/app-lib/src/api/instance/synced_options/game_options/locales/sources.rs new file mode 100644 index 0000000..280596a --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/locales/sources.rs @@ -0,0 +1,207 @@ +use super::super::options_file::{input_error, sha1_bytes}; +use super::archive::{self, ArchiveIndex}; +use crate::state::ModrinthProjectId; +use crate::state::{CachedEntry, InstanceMetadata, Project, State}; +use crate::util::{fetch, io}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, HashMap}; +use std::path::PathBuf; + +#[derive(Clone, Serialize, Deserialize)] +pub(super) struct ArchiveSource { + pub hash: String, + pub path: PathBuf, +} + +#[derive(Clone, Serialize, Deserialize)] +pub(super) struct Snapshot { + pub instance_id: String, + pub game_version: String, + pub mods: Vec, +} + +pub(super) async fn mod_projects( + state: &State, + hashes: &[&str], +) -> crate::Result> { + let files = CachedEntry::get_file_many( + hashes, + None, + &state.pool, + &state.api_semaphore, + ) + .await?; + let project_ids: std::collections::HashSet<_> = files + .iter() + .filter_map(|file| { + ModrinthProjectId::new(file.project_id.as_str().to_string()).ok() + }) + .collect(); + let projects = CachedEntry::get_project_many( + &project_ids.into_iter().collect::>(), + None, + &state.pool, + &state.api_semaphore, + ) + .await?; + let projects: HashMap<_, _> = projects + .into_iter() + .map(|project| (project.id.clone(), project)) + .collect(); + Ok(files + .into_iter() + .filter_map(|file| { + projects + .get(&file.project_id) + .cloned() + .map(|project| (file.hash, project)) + }) + .collect()) +} + +pub(super) fn root(state: &State) -> PathBuf { + state + .directories + .metadata_dir() + .join("game-locales") + .join("v1") +} + +pub(super) async fn write_json( + path: &std::path::Path, + data: &T, +) -> crate::Result<()> { + let parent = path + .parent() + .ok_or_else(|| input_error("Missing locale cache directory"))?; + io::create_dir_all(parent).await?; + io::write(path, serde_json::to_vec(data)?).await?; + Ok(()) +} + +pub(super) async fn snapshot_instance( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result { + let version = &metadata.applied_content_set.game_version; + let instance_dir = + crate::state::instances::commands::instance_content_root( + &state.directories, + &metadata.instance, + )?; + let direct_linked = metadata.instance.is_direct_linked(); + let cache_key_path = if direct_linked { + instance_dir.to_string_lossy().into_owned() + } else { + metadata.instance.path.clone() + }; + let scanned = + crate::state::instances::adapters::filesystem::scan_content_files_from( + &instance_dir, + &cache_key_path, + )?; + let scanned: Vec<_> = scanned + .into_iter() + .filter(|f| f.enabled && f.relative_path.starts_with("mods/")) + .collect(); + let keys: Vec<_> = + scanned.iter().map(|f| f.hash_cache_key.as_str()).collect(); + let hashes = CachedEntry::get_file_hash_many( + &keys, + None, + &state.pool, + &state.api_semaphore, + ) + .await?; + let by_path: BTreeMap<_, _> = + hashes.into_iter().map(|h| (h.path, h.hash)).collect(); + let mut mods = Vec::new(); + for file in scanned { + let path = instance_dir.join(&file.relative_path); + let key = format!("{cache_key_path}/{}", file.relative_path); + let hash = match by_path.get(&key) { + Some(hash) => hash.clone(), + None => fetch::sha1_file_async(&path).await?.1, + }; + mods.push(ArchiveSource { hash, path }); + } + mods.sort_by(|a, b| a.path.cmp(&b.path)); + let snapshot = Snapshot { + instance_id: metadata.instance.id.clone(), + game_version: version.clone(), + mods, + }; + let bytes = serde_json::to_vec(&snapshot)?; + let id = sha1_bytes(&bytes); + let path = root(state).join("snapshots").join(format!("{id}.json")); + if !io::read(&path).await.is_ok_and(|cached| cached == bytes) { + tracing::info!( + snapshot_id = id, + instance_id = metadata.instance.id, + "Game setting locales: writing missing or invalid snapshot cache" + ); + write_json(&path, &snapshot).await?; + } + Ok(id) +} + +#[tracing::instrument(skip_all, fields(snapshot_id = id), err)] +pub(super) async fn load_snapshot( + state: &State, + id: &str, +) -> crate::Result { + if !archive::valid_hash(id) { + return Err(input_error("Invalid locale snapshot hash")); + } + let bytes = + io::read(root(state).join("snapshots").join(format!("{id}.json"))) + .await?; + archive::checked_bytes(bytes.clone(), id).inspect_err(|error| { + tracing::warn!(%error, expected_hash = id, actual_hash = sha1_bytes(&bytes), + "Game setting locales: snapshot checksum mismatch"); + })?; + Ok(serde_json::from_slice(&bytes)?) +} + +pub(super) async fn archive_index( + state: &State, + source: &ArchiveSource, +) -> crate::Result { + if !archive::valid_hash(&source.hash) { + return Err(input_error("Invalid locale archive hash")); + } + let path = root(state) + .join("archives") + .join(format!("{}.json", source.hash)); + if let Ok(bytes) = io::read(&path).await { + let source_path = source.path.clone(); + let cached = tokio::task::spawn_blocking(move || { + serde_json::from_slice::(&bytes) + .ok() + .filter(|index| index.version >= 2 || !source_path.exists()) + }) + .await + .map_err(|error| input_error(error.to_string()))?; + if let Some(index) = cached { + tracing::debug!( + hash = source.hash, + version = index.version, + bundles = index.bundles.len(), + "Game setting locales: archive cache hit" + ); + return Ok(index); + } + } + tracing::info!( + hash = source.hash, + "Game setting locales: rebuilding archive cache" + ); + let source = source.clone(); + let index = tokio::task::spawn_blocking(move || { + archive::inspect(&source.path, &source.hash) + }) + .await + .map_err(|e| input_error(e.to_string()))??; + write_json(&path, &index).await?; + Ok(index) +} diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/locales/storage.rs b/packages/app-lib/src/api/instance/synced_options/game_options/locales/storage.rs new file mode 100644 index 0000000..7b91eee --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/locales/storage.rs @@ -0,0 +1,132 @@ +use super::{Observation, Origin}; +use sqlx::{Sqlite, SqlitePool, Transaction}; + +pub(super) struct OriginRow { + pub scope: String, + pub option_id: String, + pub source_instance_id: Option, + pub source_game_version: Option, + pub backfilled: bool, + pub observation: Option, + pub origin: Option, +} + +#[tracing::instrument(skip_all, fields(scope), err)] +pub(super) async fn load( + pool: &SqlitePool, + scope: Option<&str>, +) -> crate::Result> { + let rows = sqlx::query( + "SELECT scope, option_id, source_instance_id, source_game_version, + backfilled, observation_json, origin_json + FROM game_option_locale_origins + WHERE (? IS NULL OR scope = ?) + ORDER BY scope, option_id", + ) + .bind(scope) + .bind(scope) + .fetch_all(pool) + .await?; + use sqlx::Row; + rows.into_iter() + .filter(|row| { + let option_id: String = row.get("option_id"); + option_id + .strip_prefix("external:") + .and_then(super::mod_translation_key) + .is_some() + }) + .map(|row| { + let scope: String = row.get("scope"); + let option_id: String = row.get("option_id"); + let source_instance_id: Option = row.get("source_instance_id"); + let source_game_version: Option = row.get("source_game_version"); + let backfilled: bool = row.get("backfilled"); + let observation_json: Option = row.get("observation_json"); + let origin_json: Option = row.get("origin_json"); + let observation = observation_json + .as_deref() + .map(serde_json::from_str) + .transpose() + .inspect_err(|error| tracing::warn!(%error, scope = scope, option_id = option_id, + "Game setting locales: stored observation could not be decoded"))?; + let origin = origin_json + .as_deref() + .map(serde_json::from_str) + .transpose() + .inspect_err(|error| tracing::warn!(%error, scope = scope, option_id = option_id, + "Game setting locales: stored origin could not be decoded"))?; + Ok(OriginRow { + scope, + option_id, + source_instance_id, + source_game_version, + backfilled, + observation, + origin, + }) + }) + .collect() +} + +#[tracing::instrument( + skip_all, + fields(scope, option_id, instance_id, game_version), + err +)] +pub(super) async fn observe( + tx: &mut Transaction<'_, Sqlite>, + scope: &str, + option_id: &str, + instance_id: &str, + game_version: &str, + observation: &Observation, +) -> crate::Result<()> { + let json = serde_json::to_string(observation)?; + sqlx::query( + "INSERT INTO game_option_locale_origins + (scope, option_id, source_instance_id, source_game_version, observation_json) + SELECT ?, ?, ?, ?, ? + WHERE ? <> '' OR EXISTS (SELECT 1 FROM synced_game_option_values WHERE option_id = ?) + ON CONFLICT(scope, option_id) DO UPDATE SET observation_json = excluded.observation_json + WHERE game_option_locale_origins.observation_json IS NULL + AND game_option_locale_origins.origin_json IS NULL + AND game_option_locale_origins.backfilled = 0 + AND game_option_locale_origins.source_instance_id = excluded.source_instance_id + AND game_option_locale_origins.source_game_version = excluded.source_game_version", + ) + .bind(scope) + .bind(option_id) + .bind(instance_id) + .bind(game_version) + .bind(json) + .bind(scope) + .bind(option_id) + .execute(&mut **tx) + .await?; + Ok(()) +} + +#[tracing::instrument(skip_all, fields(scope = row.scope, option_id = row.option_id), err)] +pub(super) async fn pin( + pool: &SqlitePool, + row: &OriginRow, + origin: &Origin, +) -> crate::Result<()> { + let json = serde_json::to_string(origin)?; + let result = sqlx::query( + "UPDATE game_option_locale_origins SET origin_json = ? + WHERE scope = ? AND option_id = ? AND origin_json IS NULL", + ) + .bind(json) + .bind(&row.scope) + .bind(&row.option_id) + .execute(pool) + .await?; + tracing::debug!( + rows_affected = result.rows_affected(), + key = origin.translation_key, + "Game setting locales: origin pin completed" + ); + Ok(()) +} diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/mod.rs b/packages/app-lib/src/api/instance/synced_options/game_options/mod.rs new file mode 100644 index 0000000..08c86cf --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/mod.rs @@ -0,0 +1,73 @@ +//! Syncs selected values from Minecraft's `options.txt` between instances. +//! +//! When sync is first enabled, the user chooses an instance to supply the initial +//! values. Each instance keeps its own `options.txt`; we update only the selected +//! settings and leave modpack defaults, comments, and disabled settings alone. +//! +//! The watcher imports enabled values whenever `options.txt` changes. After the +//! app writes the file, it stores the file hash and ignores the matching watcher +//! event. On conflict, the latest save is persisted. +//! +//! Minecraft has renamed and reformatted settings over time. `catalog` +//! contains the file keys and conversions for the versions we support. Any other +//! key is treated as a custom setting and copied as-is to every synced instance. +//! Custom settings can be turned off individually in the settings screen. + +mod api_types; +mod catalog; +mod fullscreen; +mod instance_support; +mod launch_overrides; +pub(crate) mod locales; +mod options_file; +mod pack_updates; +mod read_instance_changes; +mod settings_editor; +mod source_selection; +mod write_shared_settings; + +pub use crate::state::{CanonicalValue, GameOptionKind}; +pub use api_types::{ + EditableGameSetting, GameOptionCompatibility, + GameOptionCompatibilityBucket, GameOptionCompatibilityReason, + GameOptionCompatibilityStatus, GameOptionEditorChoice, + GameOptionEditorDefinition, GameOptionMappingKind, + GameOptionValidationIssue, GameOptionValueState, + GameOptionsSourceCandidate, GameOptionsSourceIssue, GameSettingCategory, + GameSettingChange, GameSettingsEditorState, SaveGameSettingsResult, + UpdateGameSettingsRequest, +}; +pub use launch_overrides::{apply_launcher_overrides, sync_before_launch}; +pub(crate) use locales::{ + GameLocaleIndexer, queue_game_locale_index, start_game_locale_indexer, +}; +pub use locales::{GameSettingLocaleLabels, get_game_setting_locale_labels}; +pub use pack_updates::{GameOptionsPackSource, capture_pack_base}; +pub use settings_editor::{get_config, preview_changes, save_changes}; +pub use source_selection::list_sync_sources; + +pub(crate) use fullscreen::{ + shared_fullscreen_value, update_shared_fullscreen_from_app, +}; +pub(crate) use write_shared_settings::sync_all_participating_instances; + +pub(in crate::api::instance) use pack_updates::{ + detach_instance, prepare_instance_update_with_state, +}; +pub(in crate::api::instance) use source_selection::initialize_from_source_instance; +pub(in crate::api::instance) use write_shared_settings::{ + apply_instance_with_state, capture_instance_file_change, +}; + +pub(in crate::api::instance) async fn canonical_exists( + state: &crate::state::State, +) -> crate::Result { + crate::state::shared_game_options_exist(&state.pool).await +} + +const OPTIONS_FILE: &str = "options.txt"; +const CATALOG_REVISION: u32 = 6; +const MAX_OPTIONS_BYTES: usize = 2 * 1024 * 1024; +const MAX_OPTIONS_LINES: usize = 16_384; +const MAX_KEY_BYTES: usize = 1_024; +const MAX_VALUE_BYTES: usize = 32 * 1024; \ No newline at end of file diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/options_file.rs b/packages/app-lib/src/api/instance/synced_options/game_options/options_file.rs new file mode 100644 index 0000000..0b2c01b --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/options_file.rs @@ -0,0 +1,360 @@ +//! Reads and edits `options.txt` without changing unrelated lines or file formatting. + +use super::catalog::{LEGACY_DATA_VERSIONS, release_version}; +use super::{ + MAX_KEY_BYTES, MAX_OPTIONS_BYTES, MAX_OPTIONS_LINES, MAX_VALUE_BYTES, + OPTIONS_FILE, +}; +use crate::ErrorKind; +use crate::state::{InstanceMetadata, State}; +use crate::util::io; +use encoding_rs::{Encoding, UTF_8, UTF_16BE, UTF_16LE}; +use sha1_smol::Sha1; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +#[derive(Clone)] +pub(super) struct GameOptionsDocument { + pub(super) encoding: &'static Encoding, + pub(super) bom: Bom, + pub(super) default_line_ending: String, + pub(super) lines: Vec, +} + +#[derive(Clone, Copy)] +pub(super) enum Bom { + None, + Utf8, + Utf16Le, + Utf16Be, +} + +#[derive(Clone)] +pub(super) struct GameOptionsLine { + pub(super) raw: String, + pub(super) ending: String, + pub(super) entry: Option, +} + +#[derive(Clone)] +pub(super) struct GameOptionsEntry { + pub(super) key: String, + pub(super) value: String, + pub(super) changed: bool, +} + +impl GameOptionsDocument { + pub(super) fn empty() -> Self { + Self { + encoding: UTF_8, + bom: Bom::None, + default_line_ending: "\n".to_string(), + lines: Vec::new(), + } + } + + /// Creates settings in the target game's format. Modern key bindings need + /// its data version so Minecraft does not run legacy migrations on them. + /// Unknown versions without embedded metadata wait for Minecraft's own file. + pub(super) async fn for_instance( + metadata: &InstanceMetadata, + state: &State, + ) -> crate::Result> { + let content_set = &metadata.applied_content_set; + let version = &content_set.game_version; + let version_jar = content_set.loader_version.as_ref().map_or_else( + || version.clone(), + |loader| format!("{version}-{loader}"), + ); + let path = state + .directories + .version_dir(&version_jar) + .join(format!("{version_jar}.jar")); + let embedded_version = if path.exists() { + crate::launcher::read_game_version_metadata_from_jar(&path) + .await? + .and_then(|metadata| metadata.world_version) + } else { + None + }; + let data_version = embedded_version + .or_else(|| LEGACY_DATA_VERSIONS.get(version.as_str()).copied()); + if data_version.is_none() + && !release_version(version) + .is_some_and(|(major, minor, _)| major == 1 && minor < 10) + { + return Ok(None); + } + let mut document = Self::empty(); + if let Some(data_version) = data_version { + document.set("version", &data_version.to_string(), true)?; + } + Ok(Some(document)) + } + + pub(super) fn parse(bytes: &[u8]) -> crate::Result { + if bytes.len() > MAX_OPTIONS_BYTES { + return Err(input_error("options.txt is too large to sync safely")); + } + + let (bom, encoding, contents) = + if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) { + (Bom::Utf8, UTF_8, &bytes[3..]) + } else if bytes.starts_with(&[0xFF, 0xFE]) { + (Bom::Utf16Le, UTF_16LE, &bytes[2..]) + } else if bytes.starts_with(&[0xFE, 0xFF]) { + (Bom::Utf16Be, UTF_16BE, &bytes[2..]) + } else { + let mut detector = chardetng::EncodingDetector::new(); + detector.feed(bytes, true); + (Bom::None, detector.guess(None, true), bytes) + }; + let (decoded, decode_errors) = + encoding.decode_without_bom_handling(contents); + if decode_errors { + return Err(input_error( + "options.txt contains bytes that cannot be decoded losslessly", + )); + } + let roundtrip = encode_without_bom(encoding, &decoded)?; + if roundtrip.as_slice() != contents { + return Err(input_error( + "options.txt encoding cannot be round-tripped safely", + )); + } + + let mut lines = Vec::new(); + let mut start = 0; + let source: &str = decoded.as_ref(); + for (index, byte) in source.as_bytes().iter().enumerate() { + if *byte != b'\n' { + continue; + } + let raw_end = + if index > start && source.as_bytes()[index - 1] == b'\r' { + index - 1 + } else { + index + }; + let ending = if raw_end == index { "\n" } else { "\r\n" }; + lines.push(Self::parse_line(&source[start..raw_end], ending)); + start = index + 1; + } + if start < source.len() { + lines.push(Self::parse_line(&source[start..], "")); + } + if lines.len() > MAX_OPTIONS_LINES { + return Err(input_error( + "options.txt has too many lines to sync safely", + )); + } + let default_line_ending = lines + .iter() + .find(|line| !line.ending.is_empty()) + .map(|line| line.ending.clone()) + .unwrap_or_else(|| "\n".to_string()); + Ok(Self { + encoding, + bom, + default_line_ending, + lines, + }) + } + + pub(super) fn parse_line(raw: &str, ending: &str) -> GameOptionsLine { + let entry = raw.split_once(':').and_then(|(key, value)| { + if key.is_empty() + || key.len() > MAX_KEY_BYTES + || value.len() > MAX_VALUE_BYTES + { + None + } else { + Some(GameOptionsEntry { + key: key.to_string(), + value: value.to_string(), + changed: false, + }) + } + }); + GameOptionsLine { + raw: raw.to_string(), + ending: ending.to_string(), + entry, + } + } + + pub(super) fn effective_entries(&self) -> HashMap<&str, (usize, usize)> { + let mut entries = HashMap::new(); + for (index, line) in self.lines.iter().enumerate() { + if let Some(entry) = &line.entry { + entries + .entry(entry.key.as_str()) + .and_modify(|value: &mut (usize, usize)| { + value.0 = index; + value.1 += 1; + }) + .or_insert((index, 1)); + } + } + entries + } + + pub(super) fn value(&self, key: &str) -> Option<&str> { + let (index, _) = self.effective_entries().get(key).copied()?; + self.lines[index] + .entry + .as_ref() + .map(|entry| entry.value.as_str()) + } + + pub(super) fn set( + &mut self, + key: &str, + value: &str, + insert: bool, + ) -> crate::Result { + validate_raw_key_value(key, value)?; + if let Some((index, _)) = self.effective_entries().get(key).copied() { + let entry = self.lines[index].entry.as_mut().expect("entry index"); + if entry.value == value { + return Ok(false); + } + entry.value = value.to_string(); + entry.changed = true; + return Ok(true); + } + if !insert { + return Ok(false); + } + + let ending = if let Some(last) = self.lines.last_mut() { + if last.ending.is_empty() { + last.ending = self.default_line_ending.clone(); + String::new() + } else { + self.default_line_ending.clone() + } + } else { + self.default_line_ending.clone() + }; + self.lines.push(GameOptionsLine { + raw: String::new(), + ending, + entry: Some(GameOptionsEntry { + key: key.to_string(), + value: value.to_string(), + changed: true, + }), + }); + Ok(true) + } + + pub(super) fn serialize(&self) -> crate::Result> { + let mut text = String::new(); + for line in &self.lines { + match &line.entry { + Some(entry) if entry.changed => { + text.push_str(&entry.key); + text.push(':'); + text.push_str(&entry.value); + } + _ => text.push_str(&line.raw), + } + text.push_str(&line.ending); + } + let encoded = encode_without_bom(self.encoding, &text)?; + let bom: &[u8] = match self.bom { + Bom::None => &[], + Bom::Utf8 => &[0xEF, 0xBB, 0xBF], + Bom::Utf16Le => &[0xFF, 0xFE], + Bom::Utf16Be => &[0xFE, 0xFF], + }; + let mut result = Vec::with_capacity(bom.len() + encoded.len()); + result.extend_from_slice(bom); + result.extend_from_slice(&encoded); + Ok(result) + } +} + +pub(super) fn encode_without_bom( + encoding: &'static Encoding, + text: &str, +) -> crate::Result> { + if encoding == UTF_16LE || encoding == UTF_16BE { + let mut encoded = Vec::with_capacity(text.len().saturating_mul(2)); + for unit in text.encode_utf16() { + let bytes = if encoding == UTF_16LE { + unit.to_le_bytes() + } else { + unit.to_be_bytes() + }; + encoded.extend_from_slice(&bytes); + } + return Ok(encoded); + } + + let (encoded, _, had_errors) = encoding.encode(text); + if had_errors { + return Err(input_error( + "A synced option value cannot be represented in this file's encoding", + )); + } + Ok(encoded.into_owned()) +} + +pub(super) fn input_error(message: impl Into) -> crate::Error { + ErrorKind::InputError(message.into()).into() +} + +pub(super) fn validate_raw_key_value( + key: &str, + value: &str, +) -> crate::Result<()> { + if key.is_empty() + || key.len() > MAX_KEY_BYTES + || key.contains(':') + || key.chars().any(char::is_control) + { + return Err(input_error("Invalid options.txt setting key")); + } + if value.len() > MAX_VALUE_BYTES + || value + .chars() + .any(|character| character.is_control() && character != '\t') + { + return Err(input_error("Invalid options.txt setting value")); + } + Ok(()) +} + +pub(super) fn options_path( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result { + Ok(crate::state::instances::commands::instance_content_root( + &state.directories, + &metadata.instance, + )? + .join(OPTIONS_FILE)) +} + +pub(super) fn sha1_bytes(bytes: &[u8]) -> String { + Sha1::from(bytes).digest().to_string() +} + +/// Reads an `options.txt` only when it is safe to rewrite. +/// +/// The parser keeps its text encoding, line endings, comments, and duplicate keys. +pub(super) async fn read_document( + path: &Path, +) -> crate::Result<(GameOptionsDocument, Vec)> { + let metadata = tokio::fs::symlink_metadata(path) + .await + .map_err(|error| io::IOError::with_path(error, path))?; + if metadata.file_type().is_symlink() { + return Err(input_error("Refusing to sync a symlinked options.txt")); + } + let bytes = io::read(path).await?; + let document = GameOptionsDocument::parse(&bytes)?; + Ok((document, bytes)) +} \ No newline at end of file diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/pack_updates.rs b/packages/app-lib/src/api/instance/synced_options/game_options/pack_updates.rs new file mode 100644 index 0000000..8a5c404 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/pack_updates.rs @@ -0,0 +1,302 @@ +//! Keeps game settings intact when a modpack is installed or updated. + +use super::MAX_OPTIONS_BYTES; +use super::options_file::{input_error, options_path, sha1_bytes}; +use super::write_shared_settings::{ + apply_shared_settings_to_instance, capture_instance_options, + sync_is_active_for_instance, +}; +use crate::state::{InstanceLink, InstanceMetadata, State, SyncedOption}; +use crate::util::io; +use encoding_rs::{UTF_8, UTF_16BE, UTF_16LE}; +use std::io::ErrorKind; +use std::path::Path; + +const YOSBR_OPTIONS_PATH: &str = "config/yosbr/options.txt"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GameOptionsPackSource { + ClientOverrides, + Overrides, + ClientOverridesYosbr, + OverridesYosbr, +} + +impl GameOptionsPackSource { + fn as_str(self) -> &'static str { + match self { + Self::ClientOverrides | Self::ClientOverridesYosbr => { + "client_overrides" + } + Self::Overrides | Self::OverridesYosbr => "overrides", + } + } + + fn is_yosbr(self) -> bool { + matches!(self, Self::ClientOverridesYosbr | Self::OverridesYosbr) + } +} + +fn detected_encoding_name(bytes: &[u8]) -> String { + if bytes.starts_with(&[0xFF, 0xFE]) { + return UTF_16LE.name().to_string(); + } + if bytes.starts_with(&[0xFE, 0xFF]) { + return UTF_16BE.name().to_string(); + } + if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) { + return UTF_8.name().to_string(); + } + let mut detector = chardetng::EncodingDetector::new(); + detector.feed(bytes, true); + detector.guess(None, true).name().to_string() +} + +fn pack_version_id(metadata: &InstanceMetadata) -> Option<&str> { + match &metadata.link { + InstanceLink::ModrinthModpack { version_id, .. } => Some(version_id), + InstanceLink::ServerProjectModpack { + content_version_id, .. + } => Some(content_version_id), + InstanceLink::ImportedModpack { version_id, .. } => { + version_id.as_deref() + } + InstanceLink::SharedInstance { .. } => None, + _ => None, + } +} + +async fn read_pack_options( + path: &Path, + name: &str, +) -> crate::Result, String, String)>> { + let metadata = match tokio::fs::symlink_metadata(path).await { + Ok(metadata) => metadata, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(io::IOError::with_path(error, path).into()), + }; + if metadata.file_type().is_symlink() { + return Err(input_error(format!( + "Refusing to capture a symlinked {name}" + ))); + } + if metadata.len() > MAX_OPTIONS_BYTES as u64 { + return Err(input_error(format!( + "The modpack {name} is too large to capture for syncing" + ))); + } + let bytes = io::read(path).await?; + let sha1 = sha1_bytes(&bytes); + let encoding = detected_encoding_name(&bytes); + Ok(Some((bytes, sha1, encoding))) +} + +async fn options_target_exists(path: &Path) -> crate::Result { + match tokio::fs::symlink_metadata(path).await { + Ok(metadata) if metadata.file_type().is_symlink() => { + Err(input_error("Refusing to replace a symlinked options.txt")) + } + Ok(_) => Ok(true), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(false), + Err(error) => Err(io::IOError::with_path(error, path).into()), + } +} + +pub(super) async fn materialize_yosbr_options_if_missing( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + let path = options_path(metadata, state)?; + if options_target_exists(&path).await? { + return Ok(()); + } + let template_path = + crate::state::instances::commands::instance_content_root( + &state.directories, + &metadata.instance, + )? + .join(YOSBR_OPTIONS_PATH); + let Some((bytes, _, _)) = + read_pack_options(&template_path, "YOSBR options.txt").await? + else { + return Ok(()); + }; + io::write(&path, bytes).await?; + Ok(()) +} + +pub(in crate::api::instance) async fn detach_instance( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + let feature = SyncedOption::GameOptions.as_str(); + sqlx::query( + "DELETE FROM instance_sync_checkpoints + WHERE instance_id = ? AND feature = ?", + ) + .bind(metadata.instance.id) + .bind(feature) + .execute(&state.pool) + .await?; + Ok(()) +} + +/// Before replacing modpack files, saves `options.txt` and shares any settings the +/// player changed in Minecraft. +pub(in crate::api::instance) async fn prepare_instance_update_with_state( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + sqlx::query( + "DELETE FROM instance_game_option_update_state WHERE instance_id = ?", + ) + .bind(metadata.instance.id) + .execute(&state.pool) + .await?; + if sync_is_active_for_instance(metadata, state).await? { + let _ = capture_instance_options(metadata, state, true).await?; + } + let path = options_path(metadata, state)?; + let (had_file, bytes, sha1) = if path.exists() { + let file_metadata = tokio::fs::symlink_metadata(&path) + .await + .map_err(|error| io::IOError::with_path(error, &path))?; + if file_metadata.file_type().is_symlink() { + return Err(input_error( + "Refusing to prepare a symlinked options.txt for a modpack update", + )); + } + if file_metadata.len() > MAX_OPTIONS_BYTES as u64 { + return Err(input_error( + "options.txt is too large to preserve for a modpack update", + )); + } + let bytes = io::read(&path).await?; + (true, Some(bytes.clone()), Some(sha1_bytes(&bytes))) + } else { + (false, None, None) + }; + sqlx::query( + "INSERT INTO instance_game_option_update_state + (instance_id, had_file, sha1, document) + VALUES (?, ?, ?, ?) + ON CONFLICT(instance_id) DO UPDATE SET + had_file = excluded.had_file, sha1 = excluded.sha1, + document = excluded.document", + ) + .bind(metadata.instance.id) + .bind(had_file) + .bind(sha1) + .bind(bytes) + .execute(&state.pool) + .await?; + Ok(()) +} + +/// Keeps the new modpack's `options.txt` as its default, then reapplies the user's +/// shared settings. +pub async fn capture_pack_base( + instance_id: &str, + source: Option, +) -> crate::Result<()> { + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| input_error("Unknown instance"))?; + let path = options_path(&metadata, &state)?; + let feature = SyncedOption::GameOptions.as_str(); + sqlx::query( + "DELETE FROM instance_sync_checkpoints + WHERE instance_id = ? AND feature = ?", + ) + .bind(instance_id) + .bind(feature) + .execute(&state.pool) + .await?; + + if source.is_none() { + let pending = sqlx::query( + "SELECT had_file, document + FROM instance_game_option_update_state + WHERE instance_id = ?", + ) + .bind(instance_id) + .fetch_optional(&state.pool) + .await?; + if let Some(pending) = pending { + use sqlx::Row; + let had_file: bool = pending.get("had_file"); + let document: Option> = pending.get("document"); + if had_file { + if let Some(document) = document { + io::write(&path, document).await?; + } + } else if path.exists() { + io::remove_file(&path).await?; + } + } + } + + let captured = if source.is_some_and(GameOptionsPackSource::is_yosbr) { + let template_path = + crate::state::instances::commands::instance_content_root( + &state.directories, + &metadata.instance, + )? + .join(YOSBR_OPTIONS_PATH); + let template = read_pack_options(&template_path, "YOSBR options.txt") + .await? + .ok_or_else(|| { + input_error("The modpack YOSBR options.txt is missing") + })?; + if !options_target_exists(&path).await? { + io::write(&path, template.0.clone()).await?; + } + Some(template) + } else { + read_pack_options(&path, "options.txt").await? + }; + let (document_bytes, sha1, encoding) = captured + .map(|(document, sha1, encoding)| { + (Some(document), Some(sha1), Some(encoding)) + }) + .unwrap_or((None, None, None)); + let source_name = + source.map(GameOptionsPackSource::as_str).unwrap_or("none"); + let version_id = pack_version_id(&metadata); + sqlx::query( + "INSERT INTO instance_game_option_pack_bases + (instance_id, pack_version_id, source, sha1, encoding, document) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(instance_id) DO UPDATE SET + pack_version_id = excluded.pack_version_id, + source = excluded.source, sha1 = excluded.sha1, + encoding = excluded.encoding, document = excluded.document", + ) + .bind(instance_id) + .bind(version_id) + .bind(source_name) + .bind(sha1) + .bind(encoding) + .bind(document_bytes) + .execute(&state.pool) + .await?; + sqlx::query( + "DELETE FROM instance_game_option_update_state WHERE instance_id = ?", + ) + .bind(instance_id) + .execute(&state.pool) + .await?; + + if sync_is_active_for_instance(&metadata, &state).await? + && let Err(error) = + apply_shared_settings_to_instance(&metadata, &state, true).await + { + tracing::warn!( + "Captured the modpack options.txt for {}, but shared settings could not be overlaid yet: {error}", + metadata.instance.id + ); + } + Ok(()) +} diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/read_instance_changes.rs b/packages/app-lib/src/api/instance/synced_options/game_options/read_instance_changes.rs new file mode 100644 index 0000000..9ac39db --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/read_instance_changes.rs @@ -0,0 +1,371 @@ +//! Imports saved `options.txt` settings into the canonical values. + +use super::CATALOG_REVISION; +use super::api_types::canonical_values_equal; +use super::catalog::*; +use super::fullscreen::update_app_fullscreen_setting; +use super::launch_overrides::currently_launcher_owned_keys; +use super::options_file::{GameOptionsDocument, validate_raw_key_value}; +use crate::state::{ + CanonicalValue, GameOptionKind, InstanceMetadata, State, + load_game_option_preferences, load_game_options_sync_state, + load_shared_game_options, +}; +use chrono::Utc; +use std::collections::HashSet; + +pub(super) fn custom_setting_id(key: &str) -> String { + format!("external:{key}") +} + +/// Imports every enabled setting present in `options.txt`. The caller skips file +/// hashes produced by app writes. +pub(super) async fn read_instance_changes_into_shared_settings( + metadata: &InstanceMetadata, + document: &GameOptionsDocument, + state: &State, +) -> crate::Result { + let values = load_shared_game_options(&state.pool).await?; + let preferences = load_game_option_preferences(&state.pool).await?; + let launcher_keys = currently_launcher_owned_keys(metadata); + let now = Utc::now().timestamp(); + let mut updates = Vec::new(); + for (option_id, stored) in &values { + if !preferences + .get(option_id) + .is_some_and(|preference| preference.enabled) + { + continue; + } + if matches!(stored.kind, GameOptionKind::External) + && stored.raw_key.is_none() + { + continue; + } + if stored + .raw_key + .as_deref() + .is_some_and(|key| setting_by_file_key(key).is_some()) + { + continue; + } + let definition = setting_by_id(option_id); + if definition.is_some() + && !supported_settings_cover_game_version( + &metadata.applied_content_set.game_version, + ) + { + continue; + } + let current_key = if let Some(definition) = definition { + observed_physical_key( + definition, + document, + &metadata.applied_content_set.game_version, + ) + } else { + stored.raw_key.clone() + }; + let Some(current_key) = current_key else { + continue; + }; + if launcher_keys.contains(¤t_key) { + continue; + } + let Some(current_raw) = document.value(¤t_key) else { + continue; + }; + let candidate = if let Some(definition) = definition { + decode_value_for_version( + definition, + ¤t_key, + current_raw, + &metadata.applied_content_set.game_version, + ) + } else if matches!(stored.kind, GameOptionKind::External) { + if validate_raw_key_value(¤t_key, current_raw).is_err() { + continue; + } + Some(CanonicalValue::ExternalRaw(current_raw.to_string())) + } else { + None + }; + let Some(candidate) = candidate else { + continue; + }; + if stored + .value + .as_ref() + .is_some_and(|current| canonical_values_equal(current, &candidate)) + { + continue; + } + updates.push((option_id.to_string(), candidate, stored.revision)); + } + + let mut changed = false; + let mut fullscreen_value = None; + if !updates.is_empty() { + let (canonical_revision, _) = + load_game_options_sync_state(&state.pool, CATALOG_REVISION).await?; + let mut tx = state.pool.begin().await?; + for (option_id, candidate, revision) in updates { + let next_revision = revision.saturating_add(1) as i64; + let current_revision = revision as i64; + let value_json = serde_json::to_string(&candidate)?; + let canonical_type = candidate.type_name(); + let result = sqlx::query( + "UPDATE synced_game_option_values + SET canonical_type = ?, canonical_value_json = ?, seeded = 1, + revision = ?, origin = 'instance', source_game_version = ?, + source_instance_id = ?, updated_at = ? + WHERE option_id = ? AND revision = ?", + ) + .bind(canonical_type) + .bind(value_json) + .bind(next_revision) + .bind(metadata.applied_content_set.game_version) + .bind(metadata.instance.id) + .bind(now) + .bind(option_id) + .bind(current_revision) + .execute(&mut *tx) + .await?; + if result.rows_affected() == 0 { + continue; + } + if option_id == "fullscreen" { + fullscreen_value = Some(candidate.clone()); + } + changed = true; + } + if changed { + let next_revision = canonical_revision.saturating_add(1) as i64; + sqlx::query( + "UPDATE synced_game_option_state + SET revision = ?, catalog_revision = ? + WHERE singleton = 1", + ) + .bind(next_revision) + .bind(CATALOG_REVISION as i64) + .execute(&mut *tx) + .await?; + } + if let Some(value) = fullscreen_value.as_ref() { + update_app_fullscreen_setting(&mut tx, value, true).await?; + } + tx.commit().await?; + } + let discovered = + discover_custom_settings(metadata, document, &launcher_keys, state) + .await?; + Ok(discovered || changed) +} + +/// Finds settings added by mods and adds them to the shared settings list. +pub(super) async fn discover_custom_settings( + metadata: &InstanceMetadata, + document: &GameOptionsDocument, + excluded_keys: &HashSet, + state: &State, +) -> crate::Result { + let existing = load_shared_game_options(&state.pool).await?; + let preferences = load_game_option_preferences(&state.pool).await?; + let (canonical_revision, _) = + load_game_options_sync_state(&state.pool, CATALOG_REVISION).await?; + let entries = document.effective_entries(); + let now = Utc::now().timestamp(); + let mut discovered = false; + let locale_snapshot = + if super::locales::has_localizable_mod_settings(document) + && document.effective_entries().keys().any(|key| { + let id = setting_by_file_key(key) + .map(|s| s.id.to_owned()) + .unwrap_or_else(|| custom_setting_id(key)); + !existing.contains_key(&id) + }) + { + super::locales::capture_observation(metadata, state).await + } else { + None + }; + let mut tx = state.pool.begin().await?; + let game_version = metadata.applied_content_set.game_version.as_str(); + + for definition in all_supported_settings() { + for key in definition.keys { + let custom_id = custom_setting_id(key); + let Some(custom) = existing.get(&custom_id) else { + continue; + }; + if !custom.seeded { + continue; + } + let revision = custom.revision.saturating_add(1) as i64; + sqlx::query( + "UPDATE synced_game_option_values + SET seeded = 0, revision = ?, updated_at = ? + WHERE option_id = ?", + ) + .bind(revision) + .bind(now) + .bind(custom_id) + .execute(&mut *tx) + .await?; + sqlx::query( + "UPDATE synced_game_option_preferences + SET enabled = 0, source = 'catalog_default', revision = ? + WHERE option_id = ?", + ) + .bind(revision) + .bind(custom_id) + .execute(&mut *tx) + .await?; + discovered = true; + } + } + + if supported_settings_cover_game_version(game_version) { + for definition in all_supported_settings() { + if existing.contains_key(definition.id) { + continue; + } + let discovered_value = definition.keys.iter().find_map(|key| { + if excluded_keys.contains(*key) { + return None; + } + let raw = document.value(key)?; + decode_value_for_version(definition, key, raw, game_version) + }); + let Some(value) = discovered_value else { + continue; + }; + let value_json = serde_json::to_string(&value)?; + let canonical_type = value.type_name(); + sqlx::query( + "INSERT INTO synced_game_option_values + (option_id, kind, raw_key, canonical_type, + canonical_value_json, value_codec, seeded, revision, origin, + source_game_version, source_instance_id, updated_at) + VALUES (?, 'vanilla', NULL, ?, ?, 'catalog', 1, 1, + 'instance', ?, ?, ?)", + ) + .bind(definition.id) + .bind(canonical_type) + .bind(value_json) + .bind(game_version) + .bind(metadata.instance.id) + .bind(now) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO synced_game_option_preferences + (option_id, enabled, source, revision) + VALUES (?, ?, 'catalog_default', 1)", + ) + .bind(definition.id) + .bind(definition.default_on) + .execute(&mut *tx) + .await?; + discovered = true; + } + } + + for (key, (index, _)) in entries { + if excluded_keys.contains(key) + || setting_by_file_key(key).is_some() + || is_never_sync_key(key) + { + continue; + } + let Some(entry) = &document.lines[index].entry else { + continue; + }; + if validate_raw_key_value(key, &entry.value).is_err() { + continue; + } + let option_id = custom_setting_id(key); + let should_seed = + existing.get(&option_id).is_none_or(|stored| !stored.seeded); + if !should_seed { + continue; + } + let revision = existing + .get(&option_id) + .map(|stored| stored.revision) + .unwrap_or(0) + .max( + preferences + .get(&option_id) + .map(|preference| preference.revision) + .unwrap_or(0), + ) + .saturating_add(1) as i64; + let value = CanonicalValue::ExternalRaw(entry.value.clone()); + let value_json = serde_json::to_string(&value)?; + + sqlx::query( + "INSERT INTO synced_game_option_values + (option_id, kind, raw_key, canonical_type, + canonical_value_json, value_codec, seeded, revision, origin, + source_game_version, source_instance_id, updated_at) + VALUES (?, 'external', ?, 'external_raw', ?, 'exact_raw', + 1, ?, 'instance', ?, ?, ?) + ON CONFLICT(option_id) DO UPDATE SET + raw_key = excluded.raw_key, + canonical_value_json = excluded.canonical_value_json, + seeded = 1, revision = excluded.revision, + origin = 'instance', + source_game_version = excluded.source_game_version, + source_instance_id = excluded.source_instance_id, + updated_at = excluded.updated_at", + ) + .bind(option_id) + .bind(key) + .bind(value_json) + .bind(revision) + .bind(game_version) + .bind(metadata.instance.id) + .bind(now) + .execute(&mut *tx) + .await?; + sqlx::query( + " + INSERT INTO synced_game_option_preferences + (option_id, enabled, source, revision) + VALUES (?, 0, 'discovery_default', ?) + ON CONFLICT(option_id) DO NOTHING + ", + ) + .bind(&option_id) + .bind(revision) + .execute(&mut *tx) + .await?; + discovered = true; + } + + if discovered { + let next_revision = canonical_revision.saturating_add(1) as i64; + sqlx::query( + "UPDATE synced_game_option_state + SET revision = ?, catalog_revision = ? + WHERE singleton = 1", + ) + .bind(next_revision) + .bind(CATALOG_REVISION as i64) + .execute(&mut *tx) + .await?; + } + super::locales::record_observations( + &mut tx, + metadata, + document, + locale_snapshot.as_deref(), + ) + .await; + tx.commit().await?; + if discovered { + super::locales::queue_game_locale_index(); + } + Ok(discovered) +} diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/settings_editor.rs b/packages/app-lib/src/api/instance/synced_options/game_options/settings_editor.rs new file mode 100644 index 0000000..36b4b8a --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/settings_editor.rs @@ -0,0 +1,522 @@ +//! Loads, previews, and saves the game settings shown in the app. + +use super::CATALOG_REVISION; +use super::api_types::*; +use super::catalog::*; +use super::fullscreen::update_app_fullscreen_setting; +use super::instance_support::{ + describe_instance_support, find_common_local_value, + load_participating_instances, summary_revision, +}; +use super::options_file::input_error; +use super::write_shared_settings::sync_all_participating_instances; +use crate::state::{ + CanonicalValue, GameOptionKind, State, StoredOption, + game_options_sync_is_enabled, load_game_option_preferences, + load_game_options_sync_state, load_shared_game_options, +}; +use chrono::Utc; + +fn validation_issue( + sync_enabled: bool, + value: Option<&CanonicalValue>, + compatibility: &GameOptionCompatibility, +) -> Option { + if !sync_enabled { + return None; + } + if value.is_none() { + return Some(GameOptionValidationIssue::MissingValue); + } + if compatibility.total_participating > 0 && compatibility.will_receive == 0 + { + return Some(GameOptionValidationIssue::NoCompatibleInstances); + } + None +} + +/// Loads everything shown in the game-settings modal. +/// +/// It returns message IDs and setting details; the frontend provides the translated +/// labels and explanations. +pub(super) async fn load_settings_editor( + state: &State, +) -> crate::Result { + let (canonical_revision, catalog_revision) = + load_game_options_sync_state(&state.pool, CATALOG_REVISION).await?; + let values = load_shared_game_options(&state.pool).await?; + let preferences = load_game_option_preferences(&state.pool).await?; + let participants = load_participating_instances(state).await?; + let mut settings = Vec::new(); + + for definition in all_supported_settings() { + let retained = values.contains_key(definition.id) + || preferences.contains_key(definition.id); + let observed = participants.iter().any(|participant| { + participant.document.as_ref().is_some_and(|document| { + definition + .keys + .iter() + .any(|key| document.value(key).is_some()) + }) + }); + if !retained && !observed { + continue; + } + let stored = values.get(definition.id).cloned().unwrap_or_else(|| { + StoredOption { + option_id: definition.id.to_string(), + kind: GameOptionKind::Vanilla, + raw_key: None, + value: None, + seeded: false, + revision: 0, + } + }); + let preference = preferences.get(definition.id); + let (canonical_value, value_state) = if stored.seeded { + (stored.value.clone(), GameOptionValueState::Canonical) + } else { + find_common_local_value(Some(definition), &stored, &participants) + }; + let compatibility = describe_instance_support( + Some(definition), + &stored, + canonical_value.as_ref(), + &participants, + ); + let sync_enabled = + preference.map(|value| value.enabled).unwrap_or(false); + let validation_error = validation_issue( + sync_enabled, + canonical_value.as_ref(), + &compatibility, + ); + settings.push(EditableGameSetting { + option_id: definition.id.to_string(), + category_id: definition.category.to_string(), + kind: GameOptionKind::Vanilla, + raw_key: None, + sync_enabled, + canonical_value, + value_state, + option_revision: stored + .revision + .max(preference.map(|value| value.revision).unwrap_or(0)), + editor: editor_for(definition), + compatibility, + validation_error, + controlled: false, + }); + } + + let mut custom_values = values + .values() + .filter(|value| { + matches!(value.kind, GameOptionKind::External) + && value + .raw_key + .as_deref() + .is_none_or(|key| setting_by_file_key(key).is_none()) + }) + .cloned() + .collect::>(); + custom_values.sort_by(|left, right| left.raw_key.cmp(&right.raw_key)); + for stored in custom_values { + let preference = preferences.get(&stored.option_id); + let (canonical_value, value_state) = if stored.seeded { + (stored.value.clone(), GameOptionValueState::Canonical) + } else { + find_common_local_value(None, &stored, &participants) + }; + let compatibility = describe_instance_support( + None, + &stored, + canonical_value.as_ref(), + &participants, + ); + let sync_enabled = preference.is_some_and(|value| value.enabled); + let validation_error = validation_issue( + sync_enabled, + canonical_value.as_ref(), + &compatibility, + ); + settings.push(EditableGameSetting { + option_id: stored.option_id.clone(), + category_id: "custom".to_string(), + kind: GameOptionKind::External, + raw_key: stored.raw_key.clone(), + sync_enabled, + canonical_value, + value_state, + option_revision: stored + .revision + .max(preference.map(|value| value.revision).unwrap_or(0)), + editor: custom_setting_editor(), + compatibility, + validation_error, + controlled: false, + }); + } + + let category_definitions = [ + "skin_customization", + "video", + "language", + "music_and_sound", + "controls", + "chat", + "accessibility", + "online", + "custom", + ]; + let categories = category_definitions + .into_iter() + .filter(|id| { + *id == "custom" + || settings.iter().any(|setting| setting.category_id == *id) + }) + .map(|id| GameSettingCategory { + id: id.to_string(), + is_custom: id == "custom", + }) + .collect(); + + Ok(GameSettingsEditorState { + summary_revision: summary_revision(canonical_revision, &participants), + canonical_revision, + catalog_revision, + total_participating: participants.len() as u32, + categories, + settings, + }) +} + +pub async fn get_config() -> crate::Result { + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + load_settings_editor(&state).await +} + +/// Previews how the user's unsaved changes would apply to their instances. +pub async fn preview_changes( + request: UpdateGameSettingsRequest, +) -> crate::Result { + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + if request.expected_catalog_revision != CATALOG_REVISION { + return load_settings_editor(&state).await; + } + let mut editor_state = load_settings_editor(&state).await?; + let participants = load_participating_instances(&state).await?; + for change in request.changes { + let Some(setting) = editor_state + .settings + .iter_mut() + .find(|setting| setting.option_id == change.option_id) + else { + continue; + }; + if setting.option_revision != change.base_option_revision { + setting.validation_error = + Some(GameOptionValidationIssue::ChangedSinceOpened); + continue; + } + if let Some(value) = change.canonical_value { + let definition = setting_by_id(&setting.option_id); + if let Some(value) = value { + if validate_canonical_value(definition, &value).is_err() { + setting.validation_error = + Some(GameOptionValidationIssue::InvalidValue); + } else { + setting.canonical_value = Some(value); + setting.value_state = GameOptionValueState::Canonical; + setting.validation_error = None; + } + } else { + setting.canonical_value = None; + setting.value_state = GameOptionValueState::Unset; + setting.validation_error = None; + } + } + if let Some(enabled) = change.sync_enabled { + setting.sync_enabled = enabled; + } + let stored = StoredOption { + option_id: setting.option_id.clone(), + kind: setting.kind, + raw_key: setting.raw_key.clone(), + value: setting.canonical_value.clone(), + seeded: setting.canonical_value.is_some(), + revision: setting.option_revision, + }; + setting.compatibility = describe_instance_support( + setting_by_id(&setting.option_id), + &stored, + setting.canonical_value.as_ref(), + &participants, + ); + if setting.sync_enabled && setting.canonical_value.is_none() { + setting.validation_error = + Some(GameOptionValidationIssue::MissingValue); + } else if setting.sync_enabled + && setting.compatibility.total_participating > 0 + && setting.compatibility.will_receive == 0 + { + setting.validation_error = + Some(GameOptionValidationIssue::NoCompatibleInstances); + } + } + Ok(editor_state) +} + +/// Saves changes if nothing has changed since the modal opened, then updates each +/// instance that can receive them. +pub async fn save_changes( + request: UpdateGameSettingsRequest, +) -> crate::Result { + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + if !game_options_sync_is_enabled(&state.pool).await? { + return Err(input_error( + "Turn on game-settings sync before editing shared values.", + )); + } + let current_editor = load_settings_editor(&state).await?; + let UpdateGameSettingsRequest { + expected_summary_revision, + expected_canonical_revision, + expected_catalog_revision, + changes, + } = request; + if expected_catalog_revision != CATALOG_REVISION + || expected_summary_revision != current_editor.summary_revision + || expected_canonical_revision != current_editor.canonical_revision + { + return Ok(SaveGameSettingsResult { + state: Some(current_editor), + conflicts: changes + .into_iter() + .map(|change| change.option_id) + .collect(), + ..SaveGameSettingsResult::default() + }); + } + let stored_values = load_shared_game_options(&state.pool).await?; + let participants = load_participating_instances(&state).await?; + let mut conflicts = Vec::new(); + let mut accepted = Vec::new(); + + for mut change in changes { + let Some(setting) = current_editor + .settings + .iter() + .find(|setting| setting.option_id == change.option_id) + else { + return Err(input_error(format!( + "Unknown game setting {}", + change.option_id + ))); + }; + if setting.option_revision != change.base_option_revision { + conflicts.push(change.option_id); + continue; + } + if let Some(Some(value)) = &change.canonical_value { + validate_canonical_value(setting_by_id(&setting.option_id), value)?; + } + let resulting_enabled = + change.sync_enabled.unwrap_or(setting.sync_enabled); + let stored_seeded = stored_values + .get(&setting.option_id) + .is_some_and(|stored| stored.seeded); + if resulting_enabled + && !stored_seeded + && change.canonical_value.is_none() + && setting.canonical_value.is_some() + { + change.canonical_value = Some(setting.canonical_value.clone()); + } + let resulting_value = match &change.canonical_value { + Some(value) => value.as_ref(), + None => setting.canonical_value.as_ref(), + }; + if resulting_enabled { + if resulting_value.is_none() { + return Err(input_error(format!( + "Choose a value before enabling {}", + setting.option_id + ))); + } + let projected = StoredOption { + option_id: setting.option_id.clone(), + kind: setting.kind, + raw_key: setting.raw_key.clone(), + value: resulting_value.cloned(), + seeded: resulting_value.is_some(), + revision: setting.option_revision, + }; + let compatibility = describe_instance_support( + setting_by_id(&setting.option_id), + &projected, + resulting_value, + &participants, + ); + if compatibility.total_participating > 0 + && compatibility.will_receive == 0 + { + return Err(input_error(format!( + "{} is not compatible with any participating instance", + setting.option_id + ))); + } + } + accepted.push((setting.clone(), change)); + } + + let mut changed = false; + let mut fullscreen_value = None; + let mut tx = state.pool.begin().await?; + for (setting, change) in accepted { + let resulting_enabled = + change.sync_enabled.unwrap_or(setting.sync_enabled); + let value_changed = + change.canonical_value.as_ref().is_some_and(|value| { + setting.value_state != GameOptionValueState::Canonical + || value.as_ref() != setting.canonical_value.as_ref() + }); + let selection_changed = change + .sync_enabled + .is_some_and(|enabled| enabled != setting.sync_enabled); + if !value_changed && !selection_changed { + continue; + } + if resulting_enabled + && setting.option_id == "fullscreen" + && let Some(value) = change + .canonical_value + .as_ref() + .and_then(|value| value.as_ref()) + .or(setting.canonical_value.as_ref()) + { + fullscreen_value = Some(value.clone()); + } + changed = true; + let revision = setting.option_revision.saturating_add(1) as i64; + if let Some(value) = change.canonical_value { + let now = Utc::now().timestamp(); + if let Some(value) = value { + let value_json = serde_json::to_string(&value)?; + let canonical_type = value.type_name(); + let kind = if matches!(setting.kind, GameOptionKind::External) { + "external" + } else { + "vanilla" + }; + let raw_key = setting.raw_key.as_deref(); + let value_codec = if setting_by_id(&setting.option_id).is_some() + { + "catalog" + } else { + "exact_raw" + }; + let source_game_version: Option<&str> = None; + sqlx::query( + "INSERT INTO synced_game_option_values + (option_id, kind, raw_key, canonical_type, + canonical_value_json, value_codec, seeded, revision, origin, + source_game_version, source_instance_id, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 1, ?, 'app_editor', ?, NULL, ?) + ON CONFLICT(option_id) DO UPDATE SET + kind = excluded.kind, raw_key = excluded.raw_key, + canonical_type = excluded.canonical_type, + canonical_value_json = excluded.canonical_value_json, + value_codec = excluded.value_codec, seeded = 1, + revision = excluded.revision, origin = 'app_editor', + source_game_version = NULL, source_instance_id = NULL, + updated_at = excluded.updated_at", + ) + .bind(setting.option_id) + .bind(kind) + .bind(raw_key) + .bind(canonical_type) + .bind(value_json) + .bind(value_codec) + .bind(revision) + .bind(source_game_version) + .bind(now) + .execute(&mut *tx) + .await?; + } else { + sqlx::query( + "UPDATE synced_game_option_values + SET canonical_value_json = NULL, seeded = 0, revision = ?, + origin = 'app_editor', source_game_version = NULL, + source_instance_id = NULL, updated_at = ? + WHERE option_id = ?", + ) + .bind(revision) + .bind(now) + .bind(setting.option_id) + .execute(&mut *tx) + .await?; + } + } + if let Some(enabled) = change.sync_enabled { + sqlx::query( + "INSERT INTO synced_game_option_preferences + (option_id, enabled, source, revision) + VALUES (?, ?, ?, ?) + ON CONFLICT(option_id) DO UPDATE SET + enabled = excluded.enabled, source = 'user', + revision = excluded.revision", + ) + .bind(setting.option_id) + .bind(enabled) + .bind("user") + .bind(revision) + .execute(&mut *tx) + .await?; + } + } + if changed { + let next_revision = + current_editor.canonical_revision.saturating_add(1) as i64; + let catalog_revision = CATALOG_REVISION as i64; + sqlx::query( + "INSERT INTO synced_game_option_state (singleton, revision, catalog_revision) + VALUES (1, ?, ?) + ON CONFLICT(singleton) DO UPDATE SET + revision = excluded.revision, + catalog_revision = excluded.catalog_revision", + ) + .bind(next_revision) + .bind(catalog_revision) + .execute(&mut *tx) + .await?; + } + if let Some(value) = fullscreen_value.as_ref() { + update_app_fullscreen_setting(&mut tx, value, true).await?; + } + tx.commit().await?; + + let write_result = if changed { + sync_all_participating_instances(&state).await + } else { + SaveGameSettingsResult { + state: None, + applied: 0, + migrated: 0, + deferred: 0, + unsupported: 0, + failed: 0, + conflicts: Vec::new(), + } + }; + let result = SaveGameSettingsResult { + state: Some(load_settings_editor(&state).await?), + conflicts, + ..write_result + }; + Ok(result) +} diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/source_selection.rs b/packages/app-lib/src/api/instance/synced_options/game_options/source_selection.rs new file mode 100644 index 0000000..173f0ed --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/source_selection.rs @@ -0,0 +1,409 @@ +//! Finds eligible source instances and copies the first shared settings from one. + +use super::super as synced_options; +use super::CATALOG_REVISION; +use super::api_types::{GameOptionsSourceCandidate, GameOptionsSourceIssue}; +use super::catalog::*; +use super::fullscreen::update_app_fullscreen_setting; +use super::launch_overrides::currently_launcher_owned_keys; +use super::options_file::{ + input_error, options_path, read_document, validate_raw_key_value, +}; +use super::read_instance_changes::custom_setting_id; +use crate::state::{ + CanonicalValue, InstanceMetadata, State, load_game_option_preferences, + load_game_options_sync_state, load_shared_game_options, +}; +use chrono::Utc; + +/// Uses the selected instance's `options.txt` as the initial shared settings. +pub(in crate::api::instance) async fn initialize_from_source_instance( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + let path = options_path(metadata, state)?; + if !path.exists() { + return Err(input_error( + "Launch the source instance once so Minecraft can create options.txt.", + )); + } + let (document, _) = read_document(&path).await?; + let launcher_keys = currently_launcher_owned_keys(metadata); + let entries = document.effective_entries(); + let current_values = load_shared_game_options(&state.pool).await?; + let current_preferences = load_game_option_preferences(&state.pool).await?; + let (current_revision, _) = + load_game_options_sync_state(&state.pool, CATALOG_REVISION).await?; + let now = Utc::now().timestamp(); + let source_version = metadata.applied_content_set.game_version.as_str(); + let source_id = metadata.instance.id.as_str(); + let locale_snapshot = + if super::locales::has_localizable_mod_settings(&document) { + super::locales::capture_observation(metadata, state).await + } else { + None + }; + let mut tx = state.pool.begin().await?; + let catalog_revision = CATALOG_REVISION as i64; + + sqlx::query( + "INSERT INTO sync_feature_settings + (feature, globally_enabled, new_instance_default) + VALUES ('game_options', 1, 1) + ON CONFLICT(feature) DO UPDATE SET globally_enabled = 1", + ) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO instance_sync_preferences (instance_id, feature, enabled) + VALUES (?, 'game_options', 1) + ON CONFLICT(instance_id, feature) DO UPDATE SET enabled = 1", + ) + .bind(source_id) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO synced_game_option_state (singleton, revision, catalog_revision) + VALUES (1, 0, ?) + ON CONFLICT(singleton) DO UPDATE SET + catalog_revision = excluded.catalog_revision", + ) + .bind(catalog_revision) + .execute(&mut *tx) + .await?; + sqlx::query( + "UPDATE synced_game_option_values + SET seeded = 0, revision = revision + 1, updated_at = ?", + ) + .bind(now) + .execute(&mut *tx) + .await?; + for definition in all_supported_settings() { + for key in definition.keys { + let custom_id = custom_setting_id(key); + let Some(custom) = current_values.get(&custom_id) else { + continue; + }; + let revision = custom + .revision + .max( + current_preferences + .get(&custom_id) + .map(|preference| preference.revision) + .unwrap_or(0), + ) + .saturating_add(1) as i64; + sqlx::query( + "UPDATE synced_game_option_values + SET seeded = 0, revision = ?, updated_at = ? + WHERE option_id = ?", + ) + .bind(revision) + .bind(now) + .bind(custom_id) + .execute(&mut *tx) + .await?; + sqlx::query( + "UPDATE synced_game_option_preferences + SET enabled = 0, source = 'catalog_default', revision = ? + WHERE option_id = ?", + ) + .bind(revision) + .bind(custom_id) + .execute(&mut *tx) + .await?; + } + } + let mut seeded_any = false; + let mut fullscreen_value = None; + let mut fullscreen_sync_enabled = false; + + for definition in all_supported_settings() { + if !supported_settings_cover_game_version(source_version) { + break; + } + let found = definition.keys.iter().find_map(|key| { + if launcher_keys.contains(*key) { + return None; + } + let (index, _) = entries.get(*key).copied()?; + let entry = document.lines[index].entry.as_ref()?; + decode_value_for_version( + definition, + key, + &entry.value, + source_version, + ) + .map(|value| ((*key).to_string(), value)) + }); + let Some((_physical_key, value)) = found else { + continue; + }; + if definition.id == "fullscreen" { + fullscreen_value = Some(value.clone()); + fullscreen_sync_enabled = current_preferences + .get(definition.id) + .map(|preference| preference.enabled) + .unwrap_or(definition.default_on); + } + seeded_any = true; + let option_revision = current_values + .get(definition.id) + .map(|value| value.revision) + .unwrap_or(0) + .max( + current_preferences + .get(definition.id) + .map(|value| value.revision) + .unwrap_or(0), + ) + .saturating_add(1) as i64; + let value_json = serde_json::to_string(&value)?; + let canonical_type = value.type_name(); + sqlx::query( + "INSERT INTO synced_game_option_values + (option_id, kind, raw_key, canonical_type, + canonical_value_json, value_codec, seeded, revision, origin, + source_game_version, source_instance_id, updated_at) + VALUES (?, 'vanilla', NULL, ?, ?, 'catalog', 1, ?, + 'source_seed', ?, ?, ?) + ON CONFLICT(option_id) DO UPDATE SET + kind = 'vanilla', raw_key = NULL, + canonical_type = excluded.canonical_type, + canonical_value_json = excluded.canonical_value_json, + value_codec = excluded.value_codec, seeded = 1, + revision = excluded.revision, origin = 'source_seed', + source_game_version = excluded.source_game_version, + source_instance_id = excluded.source_instance_id, + updated_at = excluded.updated_at", + ) + .bind(definition.id) + .bind(canonical_type) + .bind(value_json) + .bind(option_revision) + .bind(source_version) + .bind(source_id) + .bind(now) + .execute(&mut *tx) + .await?; + let default_enabled = definition.default_on; + sqlx::query( + "INSERT INTO synced_game_option_preferences + (option_id, enabled, source, revision) + VALUES (?, ?, 'catalog_default', ?) + ON CONFLICT(option_id) DO NOTHING", + ) + .bind(definition.id) + .bind(default_enabled) + .bind(option_revision) + .execute(&mut *tx) + .await?; + } + + for (key, (index, _)) in entries { + if launcher_keys.contains(key) + || setting_by_file_key(key).is_some() + || is_never_sync_key(key) + { + continue; + } + let Some(entry) = &document.lines[index].entry else { + continue; + }; + if validate_raw_key_value(key, &entry.value).is_err() { + continue; + } + let option_id = custom_setting_id(key); + let value = CanonicalValue::ExternalRaw(entry.value.clone()); + seeded_any = true; + let option_revision = current_values + .get(&option_id) + .map(|value| value.revision) + .unwrap_or(0) + .max( + current_preferences + .get(&option_id) + .map(|value| value.revision) + .unwrap_or(0), + ) + .saturating_add(1) as i64; + let value_json = serde_json::to_string(&value)?; + sqlx::query( + "INSERT INTO synced_game_option_values + (option_id, kind, raw_key, canonical_type, + canonical_value_json, value_codec, seeded, revision, origin, + source_game_version, source_instance_id, updated_at) + VALUES (?, 'external', ?, 'external_raw', ?, 'exact_raw', + 1, ?, 'source_seed', ?, ?, ?) + ON CONFLICT(option_id) DO UPDATE SET + raw_key = excluded.raw_key, + canonical_value_json = excluded.canonical_value_json, + seeded = 1, revision = excluded.revision, + origin = 'source_seed', + source_game_version = excluded.source_game_version, + source_instance_id = excluded.source_instance_id, + updated_at = excluded.updated_at", + ) + .bind(option_id) + .bind(key) + .bind(value_json) + .bind(option_revision) + .bind(source_version) + .bind(source_id) + .bind(now) + .execute(&mut *tx) + .await?; + sqlx::query( + " + INSERT INTO synced_game_option_preferences + (option_id, enabled, source, revision) + VALUES (?, 0, 'discovery_default', ?) + ON CONFLICT(option_id) DO NOTHING + ", + ) + .bind(option_id) + .bind(option_revision) + .execute(&mut *tx) + .await?; + } + if !seeded_any { + return Err(input_error( + "The selected options.txt has no settings that can be safely used as a sync baseline.", + )); + } + + let next_revision = current_revision.saturating_add(1) as i64; + sqlx::query( + "UPDATE synced_game_option_state + SET revision = ?, catalog_revision = ? + WHERE singleton = 1", + ) + .bind(next_revision) + .bind(catalog_revision) + .execute(&mut *tx) + .await?; + if let Some(value) = fullscreen_value.as_ref() { + update_app_fullscreen_setting(&mut tx, value, fullscreen_sync_enabled) + .await?; + } + super::locales::record_observations( + &mut tx, + metadata, + &document, + locale_snapshot.as_deref(), + ) + .await; + tx.commit().await?; + super::locales::queue_game_locale_index(); + Ok(()) +} + +pub async fn list_sync_sources() +-> crate::Result> { + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + let instances = crate::state::list_instances(&state.pool).await?; + let mut sources = Vec::with_capacity(instances.len()); + for metadata in instances { + let path = options_path(&metadata, &state)?; + let version_supported = supported_settings_cover_game_version( + &metadata.applied_content_set.game_version, + ); + let mut eligible = true; + let mut disabled_reason = None; + let mut recognized_setting_count = 0; + let mut custom_setting_count = 0; + let installing = synced_options::sync_files_are_protected(&metadata); + if !installing && !path.exists() { + eligible = false; + disabled_reason = Some(GameOptionsSourceIssue::MissingOptionsFile); + } else if !installing { + match read_document(&path).await { + Ok((document, _)) => { + let launcher_keys = + currently_launcher_owned_keys(&metadata); + let entries = document.effective_entries(); + let source_version = + metadata.applied_content_set.game_version.as_str(); + let recognized = if version_supported { + all_supported_settings() + .filter(|setting| { + setting.keys.iter().any(|key| { + if launcher_keys.contains(*key) { + return false; + } + document.value(key).is_some_and(|raw| { + decode_value_for_version( + setting, + key, + raw, + source_version, + ) + .is_some() + }) + }) + }) + .count() + } else { + 0 + }; + let custom = entries + .into_iter() + .filter(|(key, (index, _))| { + !launcher_keys.contains(*key) + && setting_by_file_key(key).is_none() + && !is_never_sync_key(key) + && document.lines[*index] + .entry + .as_ref() + .is_some_and(|entry| { + validate_raw_key_value( + key, + &entry.value, + ) + .is_ok() + }) + }) + .count(); + if recognized + custom == 0 { + eligible = false; + disabled_reason = Some(if version_supported { + GameOptionsSourceIssue::NoSyncableSettings + } else { + GameOptionsSourceIssue::UnsupportedVersion + }); + } else { + recognized_setting_count = recognized as u32; + custom_setting_count = custom as u32; + } + } + Err(error) => { + tracing::warn!( + "Could not inspect {} as a game-settings source: {error}", + metadata.instance.id + ); + eligible = false; + disabled_reason = + Some(GameOptionsSourceIssue::UnreadableOptionsFile); + } + } + } + sources.push(GameOptionsSourceCandidate { + source_id: metadata.instance.id.clone(), + instance_id: metadata.instance.id, + name: metadata.instance.name, + icon_path: metadata.instance.icon_path, + game_version: Some(metadata.applied_content_set.game_version), + eligible, + disabled_reason, + recognized_setting_count, + custom_setting_count, + }); + } + sources.sort_by(|left, right| { + left.name + .cmp(&right.name) + .then(left.instance_id.cmp(&right.instance_id)) + }); + Ok(sources) +} diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/write_shared_settings.rs b/packages/app-lib/src/api/instance/synced_options/game_options/write_shared_settings.rs new file mode 100644 index 0000000..b781fcd --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/write_shared_settings.rs @@ -0,0 +1,393 @@ +//! Imports `options.txt` saves and copies canonical values to synced instances. + +use super::super as synced_options; +use super::CATALOG_REVISION; +use super::api_types::{ + GameOptionMappingKind, SaveGameSettingsResult, SyncOutcome, +}; +use super::catalog::*; +use super::options_file::{ + GameOptionsDocument, options_path, read_document, sha1_bytes, +}; +use super::pack_updates::materialize_yosbr_options_if_missing; +use super::read_instance_changes::read_instance_changes_into_shared_settings; +use super::source_selection::initialize_from_source_instance; +use crate::state::{ + CanonicalValue, InstanceMetadata, State, SyncedOption, + game_options_sync_is_enabled, load_game_option_preferences, + load_game_options_sync_state, load_shared_game_options, +}; +use crate::util::io; + +struct AppliedSettings { + count: usize, + used_migration: bool, +} + +pub(super) async fn sync_is_active_for_instance( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result { + Ok(metadata.synced_options.game_options + && !synced_options::pending::contains( + &metadata.instance.id, + SyncedOption::GameOptions, + state, + ) + .await? + && game_options_sync_is_enabled(&state.pool).await?) +} + +pub(super) async fn remember_processed_sha( + instance_id: &str, + sha1: &str, + state: &State, +) -> crate::Result<()> { + let (canonical_revision, _) = + load_game_options_sync_state(&state.pool, CATALOG_REVISION).await?; + synced_options::begin_checkpoint( + instance_id, + SyncedOption::GameOptions, + "default", + sha1, + None, + canonical_revision as i64, + state, + ) + .await?; + synced_options::finish_plain_checkpoint( + instance_id, + SyncedOption::GameOptions, + "default", + state, + ) + .await +} + +async fn apply_shared_settings_to_document( + metadata: &InstanceMetadata, + document: &mut GameOptionsDocument, + state: &State, +) -> crate::Result { + let values = load_shared_game_options(&state.pool).await?; + let preferences = load_game_option_preferences(&state.pool).await?; + let mut count = 0; + let mut used_migration = false; + + for (option_id, preference) in preferences { + if !preference.enabled { + continue; + } + if option_id == "fullscreen" + && metadata.launch_overrides.force_fullscreen.is_some() + { + continue; + } + let Some(stored) = values.get(&option_id) else { + continue; + }; + let Some(value) = stored.value.as_ref().filter(|_| stored.seeded) + else { + continue; + }; + let definition = setting_by_id(&option_id); + if definition.is_some() + && !supported_settings_cover_game_version( + &metadata.applied_content_set.game_version, + ) + { + continue; + } + if definition.is_none() + && stored + .raw_key + .as_deref() + .is_some_and(|key| setting_by_file_key(key).is_some()) + { + continue; + } + + let target_version = &metadata.applied_content_set.game_version; + let key = + target_physical_key(definition, stored, document, target_version) + .or_else(|| { + definition.and_then(|definition| { + target_key_for_version(definition, target_version) + .map(str::to_string) + }) + }) + .or_else(|| stored.raw_key.clone()); + let Some(key) = key else { + continue; + }; + + let migrated = definition.is_some_and(|definition| { + alias_migration_needed(definition, document, &key) + || document.value(&key).is_some_and(|raw| { + !physical_representation_supported_for_target( + definition, + &key, + raw, + target_version, + ) + }) + }); + let raw_value = if let Some(definition) = definition { + let Some(raw_value) = encode_value( + definition, + &key, + value, + target_version, + document.value(&key), + ) else { + continue; + }; + raw_value + } else if let CanonicalValue::ExternalRaw(raw_value) = value { + raw_value.clone() + } else { + continue; + }; + + document.set(&key, &raw_value, true)?; + count += 1; + used_migration |= migrated + || definition.is_some_and(|definition| { + (matches!(definition.encoding, ValueEncoding::KeyBinding) + && split_key_binding(&raw_value).0.parse::().is_ok()) + || physical_variant_for_present_key( + definition, + &key, + target_version, + ) + .map(|variant| { + variant.mapping != GameOptionMappingKind::Direct + }) + .unwrap_or_else(|| { + definition.keys.first().copied() != Some(key.as_str()) + }) + }); + } + + Ok(AppliedSettings { + count, + used_migration, + }) +} + +pub(super) async fn apply_shared_settings_to_instance( + metadata: &InstanceMetadata, + state: &State, + allow_protected: bool, +) -> crate::Result { + if !allow_protected && synced_options::sync_files_are_protected(metadata) { + return Ok(SyncOutcome::Deferred); + } + if !sync_is_active_for_instance(metadata, state).await? { + return Ok(SyncOutcome::Unchanged); + } + + let path = options_path(metadata, state)?; + if !path.exists() { + materialize_yosbr_options_if_missing(metadata, state).await?; + } + let file_was_missing = !path.exists(); + let (mut document, input_bytes) = if path.exists() { + read_document(&path).await? + } else { + let Some(document) = + GameOptionsDocument::for_instance(metadata, state).await? + else { + return Ok(SyncOutcome::WaitingForFile); + }; + (document, Vec::new()) + }; + if document.value("version").is_none() + && !supported_settings_cover_game_version( + &metadata.applied_content_set.game_version, + ) + { + return Ok(SyncOutcome::Deferred); + } + let applied = + apply_shared_settings_to_document(metadata, &mut document, state) + .await?; + if file_was_missing && applied.count == 0 { + return Ok(SyncOutcome::WaitingForFile); + } + + let output_bytes = document.serialize()?; + let output_sha1 = sha1_bytes(&output_bytes); + let changed = file_was_missing || output_bytes != input_bytes; + + // Register the expected bytes before touching the file. The filesystem + // watcher can observe the write immediately on some platforms; recording + // the checkpoint first prevents that event from being misclassified as a + // user edit and importing stale data back into the shared store. + synced_options::begin_checkpoint( + &metadata.instance.id, + SyncedOption::GameOptions, + "default", + &output_sha1, + None, + load_game_options_sync_state(&state.pool, CATALOG_REVISION) + .await? + .0 as i64, + state, + ) + .await?; + if changed { + io::write(&path, output_bytes).await?; + } + synced_options::finish_plain_checkpoint( + &metadata.instance.id, + SyncedOption::GameOptions, + "default", + state, + ) + .await?; + + Ok(if changed && applied.used_migration { + SyncOutcome::Migrated + } else if changed { + SyncOutcome::Applied + } else { + SyncOutcome::Unchanged + }) +} + +pub(super) async fn capture_instance_options( + metadata: &InstanceMetadata, + state: &State, + allow_protected: bool, +) -> crate::Result { + if !allow_protected && synced_options::sync_files_are_protected(metadata) { + return Ok(SyncOutcome::Deferred); + } + if !sync_is_active_for_instance(metadata, state).await? { + return Ok(SyncOutcome::Unchanged); + } + + let path = options_path(metadata, state)?; + if !path.exists() { + return Ok(SyncOutcome::WaitingForFile); + } + let (document, input_bytes) = read_document(&path).await?; + let input_sha1 = sha1_bytes(&input_bytes); + let checkpoint = synced_options::checkpoint( + &metadata.instance.id, + SyncedOption::GameOptions, + "default", + state, + ) + .await?; + if checkpoint + .as_ref() + .is_some_and(|checkpoint| checkpoint.expected_sha1 == input_sha1) + { + return Ok(SyncOutcome::Unchanged); + } + + let canonical_changed = + if crate::state::shared_game_options_exist(&state.pool).await? { + read_instance_changes_into_shared_settings( + metadata, &document, state, + ) + .await? + } else { + initialize_from_source_instance(metadata, state).await?; + true + }; + remember_processed_sha(&metadata.instance.id, &input_sha1, state).await?; + + if canonical_changed { + let _ = sync_other_participating_instances( + state, + Some(&metadata.instance.id), + ) + .await; + } + Ok(SyncOutcome::Unchanged) +} + +pub(in crate::api::instance) async fn capture_instance_file_change( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + capture_instance_options(metadata, state, false).await?; + Ok(()) +} + +pub(in crate::api::instance) async fn apply_instance_with_state( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + apply_shared_settings_to_instance(metadata, state, false).await?; + Ok(()) +} + +pub(super) async fn sync_other_participating_instances( + state: &State, + excluded_instance_id: Option<&str>, +) -> SaveGameSettingsResult { + let mut result = SaveGameSettingsResult { + state: None, + applied: 0, + migrated: 0, + deferred: 0, + unsupported: 0, + failed: 0, + conflicts: Vec::new(), + }; + match game_options_sync_is_enabled(&state.pool).await { + Ok(true) => {} + Ok(false) => return result, + Err(error) => { + tracing::warn!( + "Failed to inspect global game-options state before fan-out: {error}" + ); + result.failed += 1; + return result; + } + } + let instances = match crate::state::list_instances(&state.pool).await { + Ok(instances) => instances, + Err(error) => { + tracing::warn!( + "Failed to list instances for game-options fan-out: {error}" + ); + result.failed += 1; + return result; + } + }; + for metadata in instances { + if excluded_instance_id == Some(metadata.instance.id.as_str()) + || !metadata.synced_options.game_options + { + continue; + } + match apply_shared_settings_to_instance(&metadata, state, false).await { + Ok(SyncOutcome::Applied | SyncOutcome::Unchanged) => { + result.applied += 1 + } + Ok(SyncOutcome::Migrated) => result.migrated += 1, + Ok(SyncOutcome::Deferred | SyncOutcome::WaitingForFile) => { + result.deferred += 1 + } + Err(error) => { + tracing::warn!( + "Failed to write synced game options into {}: {error}", + metadata.instance.id + ); + result.failed += 1; + } + } + } + result +} + +pub(crate) async fn sync_all_participating_instances( + state: &State, +) -> SaveGameSettingsResult { + sync_other_participating_instances(state, None).await +} diff --git a/packages/app-lib/src/api/instance/synced_options/mod.rs b/packages/app-lib/src/api/instance/synced_options/mod.rs new file mode 100644 index 0000000..142bfd3 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/mod.rs @@ -0,0 +1,14 @@ +mod files; +mod game_options; +pub(in crate::api::instance) mod pending; + +const COMMAND_HISTORY_FILE: &str = "command_history.txt"; +const HOTBAR_FILE: &str = "hotbar.nbt"; + +pub(super) use self::files::{ + CheckpointStatus, begin_checkpoint, checkpoint, detach_link, ensure_link, + finish_checkpoint, finish_plain_checkpoint, instance_game_dir, + instance_is_running, instance_option_enabled, nbt_from_bytes, nbt_to_bytes, + option_can_apply_while_running, read_nbt_file, safe_instance_id, + sha1_bytes, sha1_file, sync_files_are_protected, synced_options_path, +}; \ No newline at end of file diff --git a/packages/app-lib/src/api/instance/synced_options/pending.rs b/packages/app-lib/src/api/instance/synced_options/pending.rs new file mode 100644 index 0000000..e362f92 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/pending.rs @@ -0,0 +1,104 @@ +use super::files::synced_options_path; +use crate::State; +use crate::state::SyncedOption; +use crate::util::io; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SyncedOptionJoinResolution { + UseSynced, + UseInstance, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(tag = "action", rename_all = "snake_case")] +pub(super) enum PendingAction { + SelectSource, + Join { + resolution: Option, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub(super) struct PendingChange { + pub instance_id: String, + pub option: SyncedOption, + pub action: PendingAction, +} + +pub(super) async fn read(state: &State) -> crate::Result> { + let path = synced_options_path(state).join("pending-changes.json"); + match tokio::fs::read(path).await { + Ok(bytes) => Ok(serde_json::from_slice(&bytes)?), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + Ok(Vec::new()) + } + Err(error) => Err(error.into()), + } +} + +async fn write(changes: &[PendingChange], state: &State) -> crate::Result<()> { + let directory = synced_options_path(state); + io::create_dir_all(&directory).await?; + let temporary = directory.join("pending-changes.json.tmp"); + io::write(&temporary, serde_json::to_vec(changes)?).await?; + tokio::fs::rename(temporary, directory.join("pending-changes.json")) + .await?; + Ok(()) +} + +/// Callers hold the synced-options lock while updating or replaying requests. +pub(super) async fn save( + change: PendingChange, + state: &State, +) -> crate::Result<()> { + let mut changes = read(state).await?; + changes.retain(|previous| { + previous.option != change.option + || (change.action != PendingAction::SelectSource + && previous.instance_id != change.instance_id) + }); + changes.push(change); + write(&changes, state).await +} + +pub(super) async fn remove( + change: &PendingChange, + state: &State, +) -> crate::Result<()> { + let mut changes = read(state).await?; + let previous_len = changes.len(); + changes.retain(|previous| previous != change); + if changes.len() != previous_len { + write(&changes, state).await?; + } + Ok(()) +} + +pub(super) async fn cancel( + option: SyncedOption, + instance_id: Option<&str>, + state: &State, +) -> crate::Result<()> { + let mut changes = read(state).await?; + let previous_len = changes.len(); + changes.retain(|change| { + change.option != option + || instance_id.is_some_and(|id| change.instance_id != id) + }); + if changes.len() != previous_len { + write(&changes, state).await?; + } + Ok(()) +} + +pub(in crate::api::instance) async fn contains( + instance_id: &str, + option: SyncedOption, + state: &State, +) -> crate::Result { + Ok(read(state).await?.iter().any(|change| { + change.instance_id == instance_id && change.option == option + })) +} \ No newline at end of file diff --git a/packages/app-lib/src/api/instance/synced_servers/codec.rs b/packages/app-lib/src/api/instance/synced_servers/codec.rs new file mode 100644 index 0000000..7c54960 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_servers/codec.rs @@ -0,0 +1,112 @@ +use super::super::synced_options::{ + nbt_from_bytes, nbt_to_bytes, read_nbt_file, +}; +use crate::ErrorKind; +use quartz_nbt::{NbtCompound, NbtList, NbtTag}; +use std::path::Path; + +pub(crate) async fn read_servers( + path: &Path, +) -> crate::Result> { + if !path.exists() { + return Ok(Vec::new()); + } + servers_from_root(read_nbt_file(path).await?) +} + +pub(super) fn servers_from_bytes( + bytes: Vec, +) -> crate::Result> { + servers_from_root(nbt_from_bytes(bytes)?) +} + +fn servers_from_root(root: NbtCompound) -> crate::Result> { + let list = root.get::<_, &NbtList>("servers").map_err(|_| { + ErrorKind::InputError( + "servers.dat does not contain a valid servers list".to_string(), + ) + })?; + list.iter() + .map(|tag| match tag { + NbtTag::Compound(compound) => Ok(compound.clone()), + _ => Err(ErrorKind::InputError( + "servers.dat contains an invalid server entry".to_string(), + ) + .into()), + }) + .collect() +} + +pub(crate) async fn write_servers( + path: &Path, + servers: &[NbtCompound], +) -> crate::Result<()> { + if let Some(parent) = path.parent() { + crate::util::io::create_dir_all(parent).await?; + } + crate::util::io::write(path, servers_to_bytes(servers)?).await?; + Ok(()) +} + +pub(super) fn servers_to_bytes( + servers: &[NbtCompound], +) -> crate::Result> { + let mut list = NbtList::new(); + for server in servers { + list.push(server.clone()); + } + let mut root = NbtCompound::new(); + root.insert("servers", list); + nbt_to_bytes(&root) +} + +pub(crate) fn server_data( + name: String, + address: String, + accept_textures: Option, +) -> NbtCompound { + let mut server = NbtCompound::new(); + server.insert("name", name); + server.insert("ip", address); + if let Some(accept_textures) = accept_textures { + server.insert("acceptTextures", i8::from(accept_textures)); + } + server.insert("hidden", 0_i8); + server +} + +pub(super) fn update_server_data( + server: &mut NbtCompound, + name: String, + address: String, + accept_textures: Option, +) { + server.insert("name", name); + server.insert("ip", address); + match accept_textures { + Some(value) => server.insert("acceptTextures", i8::from(value)), + None => { + server.inner_mut().remove("acceptTextures"); + } + } +} + +pub(super) fn server_hidden(server: &NbtCompound) -> bool { + server.get::<_, i8>("hidden").unwrap_or(0) != 0 +} + +pub(super) fn server_address(server: &NbtCompound) -> String { + server.get::<_, &str>("ip").unwrap_or_default().to_string() +} + +pub(super) fn server_identity_address(server: &NbtCompound) -> String { + server_address(server).trim().to_ascii_lowercase() +} + +pub(super) fn server_identity_name(server: &NbtCompound) -> String { + server + .get::<_, &str>("name") + .unwrap_or_default() + .trim() + .to_ascii_lowercase() +} diff --git a/packages/app-lib/src/api/instance/synced_servers/mod.rs b/packages/app-lib/src/api/instance/synced_servers/mod.rs new file mode 100644 index 0000000..4f41d6e --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_servers/mod.rs @@ -0,0 +1,24 @@ +mod codec; +mod modpack; +mod operations; +mod storage; +mod types; + +const SERVERS_FILE: &str = "servers.dat"; + +pub(crate) use self::codec::server_data; +pub use self::modpack::{capture_modpack_servers, clear_modpack_servers}; +pub(crate) use self::operations::{ + add_user_server, ensure_managed_server, list_server_records, + remove_server_by_index, update_server_by_index, +}; +pub use self::operations::{ + desync_server, list_synced_servers, remove_synced_server, + update_synced_server, +}; +pub(super) use self::operations::{ + detach_servers, ensure_servers, merge_servers_from_instance, + reconcile_servers, seed_servers, +}; +pub(super) use self::storage::canonical_exists; +pub use self::types::{DesyncServerMode, ServerSource, SyncedServer}; diff --git a/packages/app-lib/src/api/instance/synced_servers/modpack.rs b/packages/app-lib/src/api/instance/synced_servers/modpack.rs new file mode 100644 index 0000000..bb029f0 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_servers/modpack.rs @@ -0,0 +1,219 @@ +use super::super::synced_options::instance_game_dir; +use super::SERVERS_FILE; +use super::codec::{read_servers, servers_from_bytes}; +use super::operations::{compose_instance, effective}; +use super::storage::{load_local, write_local_rows}; +use super::types::{LocalServer, ServerSource}; +use crate::state::{CachedEntry, InstanceLink, InstanceMetadata}; +use crate::util::fetch::{DownloadMeta, DownloadReason, fetch}; +use crate::{ErrorKind, State}; +use async_zip::base::read::seek::ZipFileReader; +use quartz_nbt::NbtCompound; +use std::io::Cursor; +use uuid::Uuid; + +pub async fn capture_modpack_servers(instance_id: &str) -> crate::Result<()> { + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| ErrorKind::InputError("Unknown instance".to_string()))?; + let path = instance_game_dir(&metadata, &state)?.join(SERVERS_FILE); + let servers = read_servers(&path).await?; + replace_modpack_servers(&metadata, servers, &state).await?; + if effective(&metadata, &state).await? { + compose_instance(&metadata, &state).await?; + } + Ok(()) +} + +pub async fn clear_modpack_servers(instance_id: &str) -> crate::Result<()> { + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| ErrorKind::InputError("Unknown instance".to_string()))?; + replace_modpack_servers(&metadata, Vec::new(), &state).await?; + if effective(&metadata, &state).await? { + compose_instance(&metadata, &state).await?; + } + Ok(()) +} + +async fn replace_modpack_servers( + metadata: &InstanceMetadata, + servers: Vec, + state: &State, +) -> crate::Result<()> { + let mut local = servers + .into_iter() + .enumerate() + .map(|(position, data)| LocalServer { + id: Uuid::new_v4().to_string(), + source: ServerSource::Modpack, + excluded_synced_server_id: None, + data, + position: position as i64, + }) + .collect::>(); + let mut retained = load_local(&metadata.instance.id, state) + .await? + .into_iter() + .filter(|server| server.source != ServerSource::Modpack) + .collect::>(); + for server in &mut retained { + server.position = local.len() as i64; + local.push(server.clone()); + } + let mut tx = state.pool.begin().await?; + write_local_rows(&mut tx, &metadata.instance.id, &local).await?; + let version_id = modpack_version_id(&metadata.link); + sqlx::query( + "INSERT INTO instance_server_pack_state (instance_id, version_id) + VALUES (?, ?) + ON CONFLICT(instance_id) DO UPDATE SET + version_id = excluded.version_id", + ) + .bind(metadata.instance.id) + .bind(version_id) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) +} + +pub(super) async fn pack_state_matches_link( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result { + let row = sqlx::query( + "SELECT version_id + FROM instance_server_pack_state + WHERE instance_id = ?", + ) + .bind(metadata.instance.id) + .fetch_optional(&state.pool) + .await?; + Ok(row.is_some_and(|row| { + use sqlx::Row; + let version_id: Option = row.get("version_id"); + version_id.as_deref() == modpack_version_id(&metadata.link) + })) +} + +pub(super) async fn pack_state_exists( + instance_id: &str, + state: &State, +) -> crate::Result { + Ok(sqlx::query_scalar::<_, bool>( + "SELECT EXISTS( + SELECT 1 FROM instance_server_pack_state WHERE instance_id = ? + )", + ) + .bind(instance_id) + .fetch_one(&state.pool) + .await?) +} + +pub(super) async fn reconstruct_modpack_servers( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + let version_id = modpack_version_id(&metadata.link).ok_or_else(|| { + ErrorKind::InputError( + "This modpack does not have a recoverable Modrinth version." + .to_string(), + ) + })?; + let typed_version_id = + crate::state::ModrinthVersionId::new(version_id.to_string())?; + let version = CachedEntry::get_version( + &typed_version_id, + None, + &state.pool, + &state.api_semaphore, + ) + .await? + .ok_or_else(|| { + ErrorKind::InputError(format!( + "Modpack version {version_id} could not be found" + )) + })?; + let primary_file = version + .files + .iter() + .find(|file| file.primary) + .or_else(|| version.files.first()) + .ok_or_else(|| { + ErrorKind::InputError(format!( + "Modpack version {version_id} has no downloadable file" + )) + })?; + let download_meta = DownloadMeta { + reason: DownloadReason::Modpack, + game_version: metadata.applied_content_set.game_version.clone(), + loader: metadata.applied_content_set.loader.as_str().to_string(), + dependent_on: Some(version_id.to_string()), + }; + let mrpack = fetch( + &primary_file.url, + primary_file.hashes.get("sha1").map(String::as_str), + Some(&download_meta), + None, + &state.api_semaphore, + &state.pool, + ) + .await?; + let mut archive = ZipFileReader::with_tokio(Cursor::new(&mrpack)).await?; + let mut selected = None; + for (index, entry) in archive.file().entries().iter().enumerate() { + let Ok(filename) = entry.filename().as_str() else { + continue; + }; + let priority = if filename == "client-overrides/servers.dat" { + 2 + } else if filename == "overrides/servers.dat" { + 1 + } else { + continue; + }; + if selected + .is_none_or(|(selected_priority, _)| priority >= selected_priority) + { + selected = Some((priority, index)); + } + } + + let servers = if let Some((_, index)) = selected { + let mut bytes = Vec::new(); + let mut reader = archive.reader_with_entry(index).await?; + reader.read_to_end_checked(&mut bytes).await?; + servers_from_bytes(bytes)? + } else { + Vec::new() + }; + replace_modpack_servers(metadata, servers, state).await +} + +pub(super) fn is_modpack_link(link: &InstanceLink) -> bool { + matches!( + link, + InstanceLink::ModrinthModpack { .. } + | InstanceLink::ServerProjectModpack { .. } + | InstanceLink::ImportedModpack { .. } + ) +} + +fn modpack_version_id(link: &InstanceLink) -> Option<&str> { + match link { + InstanceLink::ModrinthModpack { version_id, .. } => Some(version_id), + InstanceLink::ServerProjectModpack { + content_version_id, .. + } => Some(content_version_id), + InstanceLink::ImportedModpack { + version_id: Some(version_id), + .. + } => Some(version_id), + _ => None, + } +} diff --git a/packages/app-lib/src/api/instance/synced_servers/operations.rs b/packages/app-lib/src/api/instance/synced_servers/operations.rs new file mode 100644 index 0000000..453a852 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_servers/operations.rs @@ -0,0 +1,1037 @@ +use super::super::synced_options::{ + CheckpointStatus, checkpoint, detach_link, ensure_link, finish_checkpoint, + instance_game_dir, instance_is_running, instance_option_enabled, + instance_option_supported, sha1_bytes, sha1_file, sync_files_are_protected, +}; +use crate::state::{InstanceMetadata, SyncedOption}; +use crate::{ErrorKind, State}; +use quartz_nbt::NbtCompound; +use uuid::Uuid; + +use super::SERVERS_FILE; +use super::codec::{ + read_servers, server_address, server_hidden, server_identity_address, + server_identity_name, servers_to_bytes, update_server_data, write_servers, +}; +use super::modpack::{ + is_modpack_link, pack_state_exists, pack_state_matches_link, + reconstruct_modpack_servers, +}; +use super::storage::{ + begin_server_checkpoint, canonical_exists, commit_server_state, + generated_path, load_local, load_projection_entries, read_canonical, + read_server_snapshot, server_revision, write_local, +}; +use super::types::{ + CanonicalServer, DesyncServerMode, LocalServer, ProjectionEntry, + ProjectionOwner, ServerRecord, ServerSource, SyncedServer, +}; +use std::collections::HashSet; + +fn normalized_server_address(data: &NbtCompound) -> (String, u16) { + let address = server_identity_address(data); + let (host, port) = crate::server_address::parse_server_address(&address) + .unwrap_or((&address, 25565)); + (host.trim().to_string(), port) +} + +async fn sync_candidates( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result> { + let locals = load_local(&metadata.instance.id, state).await?; + let mut servers = + read_servers(&instance_game_dir(metadata, state)?.join(SERVERS_FILE)) + .await?; + for local in locals { + let local_address = normalized_server_address(&local.data); + let index = servers + .iter() + .position(|data| data == &local.data) + .or_else(|| { + servers.iter().position(|data| { + !local_address.0.is_empty() + && normalized_server_address(data) == local_address + }) + }); + if let Some(index) = index { + servers.remove(index); + } + } + Ok(servers) +} + +pub(in crate::api::instance) async fn seed_servers( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + if is_modpack_link(&metadata.link) + && (!pack_state_matches_link(metadata, state).await? + || !pack_state_exists(&metadata.instance.id, state).await?) + && let Err(error) = reconstruct_modpack_servers(metadata, state).await + { + tracing::warn!( + "Failed to reconstruct the server pack state for {}: {error}", + metadata.instance.id + ); + } + if is_modpack_link(&metadata.link) + && !pack_state_exists(&metadata.instance.id, state).await? + { + return Err(ErrorKind::InputError( + "This linked modpack cannot be used as the multiplayer base until its supplied server list has been reconstructed. Choose an unmanaged instance instead." + .to_string(), + ) + .into()); + } + let servers = sync_candidates(metadata, state).await?; + let canonical = servers + .into_iter() + .map(|data| CanonicalServer { + id: Uuid::new_v4().to_string(), + data, + }) + .collect::>(); + commit_server_state(Some(&canonical), None, state).await?; + regenerate_servers(state).await +} + +pub(in crate::api::instance) async fn merge_servers_from_instance( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + if is_modpack_link(&metadata.link) + && (!pack_state_matches_link(metadata, state).await? + || !pack_state_exists(&metadata.instance.id, state).await?) + && let Err(error) = reconstruct_modpack_servers(metadata, state).await + { + tracing::warn!( + "Failed to reconstruct the server pack state for {}: {error}", + metadata.instance.id + ); + } + if is_modpack_link(&metadata.link) + && !pack_state_exists(&metadata.instance.id, state).await? + { + return Err(ErrorKind::InputError( + "This linked modpack cannot join multiplayer syncing until its supplied server list has been reconstructed." + .to_string(), + ) + .into()); + } + let candidates = sync_candidates(metadata, state).await?; + + let mut canonical = read_canonical(state).await?; + for data in candidates { + let address = server_identity_address(&data); + let exists = canonical.iter().any(|server| { + server.data == data + || (!address.is_empty() + && server_identity_address(&server.data) == address) + }); + if !exists { + canonical.push(CanonicalServer { + id: Uuid::new_v4().to_string(), + data, + }); + } + } + if commit_server_state(Some(&canonical), None, state).await? { + regenerate_servers(state).await?; + } + Ok(()) +} + +pub(in crate::api::instance) async fn ensure_servers( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + if is_modpack_link(&metadata.link) + && !pack_state_matches_link(metadata, state).await? + && let Err(error) = reconstruct_modpack_servers(metadata, state).await + { + tracing::warn!( + "Failed to reconstruct the server pack state for {}; retaining its last known per-instance server metadata: {error}", + metadata.instance.id + ); + } + if !canonical_exists(state).await? { + if !is_modpack_link(&metadata.link) { + let servers = sync_candidates(metadata, state).await?; + let canonical = servers + .into_iter() + .map(|data| CanonicalServer { + id: Uuid::new_v4().to_string(), + data, + }) + .collect::>(); + commit_server_state(Some(&canonical), None, state).await?; + } else { + commit_server_state(Some(&[]), None, state).await?; + } + } + compose_instance(metadata, state).await +} + +pub(in crate::api::instance) async fn detach_servers( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + let generated = generated_path(state, &metadata.instance.id); + let local = instance_game_dir(metadata, state)?.join(SERVERS_FILE); + detach_link(&generated, &local).await +} + +pub(in crate::api::instance) async fn reconcile_servers( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + if !effective(metadata, state).await? { + return Ok(()); + } + let local_path = instance_game_dir(metadata, state)?.join(SERVERS_FILE); + if !local_path.exists() { + return compose_instance(metadata, state).await; + } + let checkpoint = checkpoint( + &metadata.instance.id, + SyncedOption::MultiplayerServers, + "default", + state, + ) + .await?; + if checkpoint + .as_ref() + .is_some_and(|value| value.status == CheckpointStatus::Pending) + { + return compose_instance(metadata, state).await; + } + let actual = sha1_file(&local_path).await?; + let revision = server_revision(state).await?; + if checkpoint + .as_ref() + .map(|value| value.expected_sha1.as_str()) + == Some(actual.as_str()) + { + if checkpoint + .as_ref() + .is_some_and(|value| value.source_revision == revision) + { + return Ok(()); + } + return compose_instance(metadata, state).await; + } + + let current = read_servers(&local_path).await?; + let projections = + load_projection_entries(&metadata.instance.id, state).await?; + let projection_matches = match_projection_entries(¤t, &projections); + let mut matched = HashSet::new(); + let mut canonical = read_canonical(state).await?; + let mut locals = load_local(&metadata.instance.id, state).await?; + + let mut canonical_order = Vec::new(); + + for ((position, data), projection) in + current.into_iter().enumerate().zip(projection_matches) + { + if let Some(projection) = projection { + matched.insert(projection.id.clone()); + match projection.owner { + ProjectionOwner::Synced => { + canonical_order.push(projection.id.clone()); + if let Some(server) = canonical + .iter_mut() + .find(|server| server.id == projection.id) + && data != projection.data + { + server.data = data; + } + } + ProjectionOwner::Instance => { + if let Some(server) = locals + .iter_mut() + .find(|server| server.id == projection.id) + { + if data != projection.data { + server.data = data; + } + server.position = position as i64; + } + } + } + } else { + let id = Uuid::new_v4().to_string(); + canonical_order.push(id.clone()); + canonical.push(CanonicalServer { id, data }); + } + } + + canonical.retain(|server| { + !projections.iter().any(|projection| { + projection.owner == ProjectionOwner::Synced + && projection.id == server.id + && !matched.contains(&projection.id) + }) + }); + locals.retain(|server| { + !projections.iter().any(|projection| { + projection.owner == ProjectionOwner::Instance + && projection.id == server.id + && !matched.contains(&projection.id) + }) + }); + let by_id = canonical + .iter() + .cloned() + .map(|server| (server.id.clone(), server)) + .collect::>(); + let visible = canonical_order.iter().cloned().collect::>(); + let mut ordered = canonical_order + .into_iter() + .filter_map(|id| by_id.get(&id).cloned()); + for server in &mut canonical { + if visible.contains(&server.id) + && let Some(next) = ordered.next() + { + *server = next; + } + } + let canonical_changed = commit_server_state( + Some(&canonical), + Some((&metadata.instance.id, &locals)), + state, + ) + .await?; + if canonical_changed { + regenerate_servers(state).await + } else { + compose_instance(metadata, state).await + } +} + +fn match_projection_entries<'a>( + current: &[NbtCompound], + projections: &'a [ProjectionEntry], +) -> Vec> { + let mut matches = vec![None; current.len()]; + let mut matched_projections = HashSet::new(); + + for (position, data) in current.iter().enumerate() { + let candidate = projections + .iter() + .enumerate() + .filter(|(index, projection)| { + !matched_projections.contains(index) && projection.data == *data + }) + .min_by_key(|(_, projection)| { + projection.position.abs_diff(position as i64) + }) + .map(|(index, _)| index); + if let Some(index) = candidate { + matches[position] = Some(index); + matched_projections.insert(index); + } + } + + for (position, data) in current.iter().enumerate() { + if matches[position].is_some() { + continue; + } + let address = server_identity_address(data); + if address.is_empty() { + continue; + } + let candidate = projections + .iter() + .enumerate() + .filter(|(index, projection)| { + !matched_projections.contains(index) + && server_identity_address(&projection.data) == address + }) + .min_by_key(|(_, projection)| { + projection.position.abs_diff(position as i64) + }) + .map(|(index, _)| index); + if let Some(index) = candidate { + matches[position] = Some(index); + matched_projections.insert(index); + } + } + + for (position, data) in current.iter().enumerate() { + if matches[position].is_some() { + continue; + } + let name = server_identity_name(data); + if name.is_empty() { + continue; + } + let candidate = projections + .iter() + .enumerate() + .filter(|(index, projection)| { + !matched_projections.contains(index) + && server_identity_name(&projection.data) == name + }) + .min_by_key(|(_, projection)| { + projection.position.abs_diff(position as i64) + }) + .map(|(index, _)| index); + if let Some(index) = candidate { + matches[position] = Some(index); + matched_projections.insert(index); + } + } + + matches + .into_iter() + .map(|projection| projection.map(|index| &projections[index])) + .collect() +} + +#[tracing::instrument(name = "server_records", skip_all, fields(instance_id = %metadata.instance.id))] +pub(crate) async fn list_server_records( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result> { + if participating(metadata, state).await? { + let (canonical, locals) = + read_server_snapshot(&metadata.instance.id, state).await?; + return Ok(merge_server_records(canonical, locals)); + } + list_local_server_records(metadata, state).await +} + +async fn list_server_records_locked( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result> { + if participating(metadata, state).await? { + return compose_records(metadata, state).await; + } + list_local_server_records(metadata, state).await +} + +async fn list_local_server_records( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result> { + Ok( + read_servers(&instance_game_dir(metadata, state)?.join(SERVERS_FILE)) + .await? + .into_iter() + .map(|data| ServerRecord { + id: Uuid::new_v4().to_string(), + source: ServerSource::LocalDesynced, + data, + }) + .collect(), + ) +} + +/// Keeps an automatically added server local, even before server sync is enabled. +pub(crate) async fn ensure_managed_server( + metadata: &InstanceMetadata, + data: NbtCompound, + state: &State, +) -> crate::Result<()> { + let _guard = state.lock_synced_options().await; + let path = instance_game_dir(metadata, state)?.join(SERVERS_FILE); + let syncing = participating(metadata, state).await?; + let records = list_server_records_locked(metadata, state).await?; + let address = normalized_server_address(&data); + let name = server_identity_name(&data); + let existing_index = records + .iter() + .position(|record| { + normalized_server_address(&record.data) == address + && server_identity_name(&record.data) == name + }) + .or_else(|| { + records.iter().position(|record| { + normalized_server_address(&record.data) == address + }) + }); + let existing = existing_index.map(|index| &records[index]); + let position = existing_index.unwrap_or_else(|| { + records + .iter() + .position(ServerRecord::hidden) + .unwrap_or(records.len()) + }); + let data = existing + .map(|record| { + let mut existing_data = record.data.clone(); + if record.hidden() { + existing_data.insert( + "name", + data.get::<_, &str>("name").unwrap_or_default().to_string(), + ); + existing_data.insert("hidden", 0_i8); + } + existing_data + }) + .unwrap_or(data); + let excluded_synced_server_id = existing + .filter(|record| record.source == ServerSource::UserSynced) + .map(|record| record.id.clone()); + let mut locals = load_local(&metadata.instance.id, state).await?; + if let Some(local) = locals + .iter_mut() + .find(|server| normalized_server_address(&server.data) == address) + { + let excluded_synced_server_id = excluded_synced_server_id + .or_else(|| local.excluded_synced_server_id.clone()); + if existing.is_some_and(|record| record.data == data) + && local.data == data + && local.position == position as i64 + && local.excluded_synced_server_id == excluded_synced_server_id + && (!syncing || path.exists()) + { + return Ok(()); + } + local.data = data.clone(); + local.position = position as i64; + local.excluded_synced_server_id = excluded_synced_server_id; + } else { + locals.push(LocalServer { + id: Uuid::new_v4().to_string(), + source: ServerSource::LinkedServerProject, + excluded_synced_server_id, + data: data.clone(), + position: position as i64, + }); + } + write_local(&metadata.instance.id, &locals, state).await?; + if syncing { + if effective(metadata, state).await? { + compose_instance(metadata, state).await?; + } + } else if existing.is_none_or(|record| record.data != data) { + let mut servers = records + .into_iter() + .map(|record| record.data) + .collect::>(); + if let Some(index) = existing_index { + servers[index] = data; + } else { + servers.insert(position, data); + } + write_servers(&path, &servers).await?; + } + Ok(()) +} + +pub(crate) async fn add_user_server( + metadata: &InstanceMetadata, + mut data: NbtCompound, + state: &State, +) -> crate::Result { + let _guard = state.lock_synced_options().await; + let id = Uuid::new_v4().to_string(); + if participating(metadata, state).await? { + let mut canonical = read_canonical(state).await?; + canonical.push(CanonicalServer { + id: id.clone(), + data, + }); + commit_server_state(Some(&canonical), None, state).await?; + regenerate_servers(state).await?; + } else { + let path = instance_game_dir(metadata, state)?.join(SERVERS_FILE); + let mut servers = read_servers(&path).await?; + let insert_index = servers + .iter() + .position(server_hidden) + .unwrap_or(servers.len()); + servers.insert(insert_index, std::mem::take(&mut data)); + write_servers(&path, &servers).await?; + } + Ok(id) +} + +async fn update_server_locked( + metadata: &InstanceMetadata, + server_id: &str, + data: NbtCompound, + state: &State, +) -> crate::Result<()> { + if participating(metadata, state).await? { + let records = compose_records(metadata, state).await?; + let record = records + .iter() + .find(|record| record.id == server_id) + .ok_or_else(|| { + ErrorKind::InputError("Unknown server".to_string()) + })?; + if record.source == ServerSource::UserSynced { + let mut canonical = read_canonical(state).await?; + let server = canonical + .iter_mut() + .find(|server| server.id == server_id) + .ok_or_else(|| { + ErrorKind::InputError("Unknown server".to_string()) + })?; + server.data = data; + commit_server_state(Some(&canonical), None, state).await?; + regenerate_servers(state).await?; + } else { + let mut locals = load_local(&metadata.instance.id, state).await?; + let server = locals + .iter_mut() + .find(|server| server.id == server_id) + .ok_or_else(|| { + ErrorKind::InputError("Unknown server".to_string()) + })?; + server.data = data; + write_local(&metadata.instance.id, &locals, state).await?; + compose_instance(metadata, state).await?; + } + return Ok(()); + } + Err(ErrorKind::InputError( + "Stable server editing requires multiplayer syncing for this instance." + .to_string(), + ) + .into()) +} + +pub(crate) async fn update_server_by_index( + metadata: &InstanceMetadata, + index: usize, + name: String, + address: String, + accept_textures: Option, + state: &State, +) -> crate::Result<()> { + let _guard = state.lock_synced_options().await; + if participating(metadata, state).await? { + let records = list_server_records_locked(metadata, state).await?; + let record = records + .get(index) + .filter(|record| !record.hidden()) + .ok_or_else(|| { + ErrorKind::InputError(format!( + "No editable server at index {index}" + )) + })?; + let mut data = record.data.clone(); + update_server_data(&mut data, name, address, accept_textures); + return update_server_locked(metadata, &record.id, data, state).await; + } + let path = instance_game_dir(metadata, state)?.join(SERVERS_FILE); + let mut servers = read_servers(&path).await?; + let server = servers + .get_mut(index) + .filter(|server| !server_hidden(server)) + .ok_or_else(|| { + ErrorKind::InputError(format!( + "No editable server at index {index}" + )) + })?; + update_server_data(server, name, address, accept_textures); + write_servers(&path, &servers).await +} + +async fn remove_server_locked( + metadata: &InstanceMetadata, + server_id: &str, + state: &State, +) -> crate::Result<()> { + if !participating(metadata, state).await? { + return Err(ErrorKind::InputError( + "Stable server removal requires multiplayer syncing for this instance." + .to_string(), + ) + .into()); + } + let records = compose_records(metadata, state).await?; + let record = records + .iter() + .find(|record| record.id == server_id) + .ok_or_else(|| ErrorKind::InputError("Unknown server".to_string()))?; + if record.source == ServerSource::UserSynced { + let mut canonical = read_canonical(state).await?; + canonical.retain(|server| server.id != server_id); + commit_server_state(Some(&canonical), None, state).await?; + regenerate_servers(state).await + } else { + let mut locals = load_local(&metadata.instance.id, state).await?; + locals.retain(|server| server.id != server_id); + write_local(&metadata.instance.id, &locals, state).await?; + compose_instance(metadata, state).await + } +} + +pub(crate) async fn remove_server_by_index( + metadata: &InstanceMetadata, + index: usize, + state: &State, +) -> crate::Result<()> { + let _guard = state.lock_synced_options().await; + if participating(metadata, state).await? { + let records = list_server_records_locked(metadata, state).await?; + let record = records + .get(index) + .filter(|record| !record.hidden()) + .ok_or_else(|| { + ErrorKind::InputError(format!( + "No removable server at index {index}" + )) + })?; + return remove_server_locked(metadata, &record.id, state).await; + } + let path = instance_game_dir(metadata, state)?.join(SERVERS_FILE); + let mut servers = read_servers(&path).await?; + if servers.get(index).is_none_or(server_hidden) { + return Err(ErrorKind::InputError(format!( + "No removable server at index {index}" + )) + .into()); + } + servers.remove(index); + write_servers(&path, &servers).await +} + +pub async fn desync_server( + instance_id: &str, + server_id: &str, + mode: DesyncServerMode, +) -> crate::Result<()> { + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| ErrorKind::InputError("Unknown instance".to_string()))?; + if !participating(&metadata, &state).await? { + return Err(ErrorKind::InputError( + "This instance is not participating in multiplayer server syncing." + .to_string(), + ) + .into()); + } + let mut canonical = read_canonical(&state).await?; + let server = canonical + .iter() + .find(|server| server.id == server_id) + .cloned() + .ok_or_else(|| { + ErrorKind::InputError( + "Only a synced user server can be desynced.".to_string(), + ) + })?; + let local_position = compose_records(&metadata, &state) + .await? + .iter() + .position(|record| record.id == server_id) + .unwrap_or_default() as i64; + let mut locals = load_local(instance_id, &state).await?; + locals.push(LocalServer { + id: Uuid::new_v4().to_string(), + source: ServerSource::LocalDesynced, + excluded_synced_server_id: (mode + == DesyncServerMode::KeepInOtherInstances) + .then(|| server.id.clone()), + data: server.data, + position: local_position, + }); + if mode == DesyncServerMode::RemoveFromOtherInstances { + canonical.retain(|candidate| candidate.id != server_id); + } + let canonical_changed = commit_server_state( + Some(&canonical), + Some((instance_id, &locals)), + &state, + ) + .await?; + if canonical_changed { + regenerate_servers(&state).await?; + } else { + compose_instance(&metadata, &state).await?; + } + Ok(()) +} + +pub async fn list_synced_servers() -> crate::Result> { + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + Ok(read_canonical(&state) + .await? + .into_iter() + .map(|server| { + let CanonicalServer { id, data } = server; + SyncedServer { + id, + name: data + .get::<_, &str>("name") + .unwrap_or_default() + .to_string(), + address: server_address(&data), + accept_textures: data + .get::<_, i8>("acceptTextures") + .ok() + .map(|value| value != 0), + } + }) + .collect()) +} + +pub async fn update_synced_server(server: SyncedServer) -> crate::Result<()> { + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + let mut canonical = read_canonical(&state).await?; + let target = canonical + .iter_mut() + .find(|candidate| candidate.id == server.id) + .ok_or_else(|| { + ErrorKind::InputError("Unknown synced server".to_string()) + })?; + update_server_data( + &mut target.data, + server.name, + server.address, + server.accept_textures, + ); + commit_server_state(Some(&canonical), None, &state).await?; + regenerate_servers(&state).await +} + +pub async fn remove_synced_server(server_id: &str) -> crate::Result<()> { + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + let mut canonical = read_canonical(&state).await?; + let previous_len = canonical.len(); + canonical.retain(|server| server.id != server_id); + if canonical.len() == previous_len { + return Err( + ErrorKind::InputError("Unknown synced server".to_string()).into() + ); + } + commit_server_state(Some(&canonical), None, &state).await?; + regenerate_servers(&state).await +} + +pub(super) async fn compose_instance( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + let records = compose_records(metadata, state).await?; + let bytes = servers_to_bytes( + &records + .iter() + .map(|record| record.data.clone()) + .collect::>(), + )?; + let expected = sha1_bytes(&bytes); + let revision = server_revision(state).await?; + begin_server_checkpoint( + &metadata.instance.id, + &records, + &expected, + revision, + state, + ) + .await?; + let generated = generated_path(state, &metadata.instance.id); + if let Some(parent) = generated.parent() { + crate::util::io::create_dir_all(parent).await?; + } + crate::util::io::write(&generated, &bytes).await?; + let target = instance_game_dir(metadata, state)?.join(SERVERS_FILE); + let mode = ensure_link(&generated, &target).await?; + finish_checkpoint( + &metadata.instance.id, + SyncedOption::MultiplayerServers, + "default", + mode, + state, + ) + .await +} + +async fn compose_records( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result> { + let canonical = read_canonical(state).await?; + let locals = load_local(&metadata.instance.id, state).await?; + Ok(merge_server_records(canonical, locals)) +} + +fn merge_server_records( + canonical: Vec, + locals: Vec, +) -> Vec { + let exclusions = locals + .iter() + .filter_map(|server| server.excluded_synced_server_id.as_deref()) + .collect::>(); + let mut records = canonical + .into_iter() + .filter(|server| !exclusions.contains(server.id.as_str())) + .map(|server| ServerRecord { + id: server.id, + source: ServerSource::UserSynced, + data: server.data, + }) + .collect::>(); + let mut locals = locals; + locals.sort_by_key(|server| server.position); + for server in locals { + let position = usize::try_from(server.position) + .unwrap_or(records.len()) + .min(records.len()); + records.insert( + position, + ServerRecord { + id: server.id, + source: server.source, + data: server.data, + }, + ); + } + records +} + +async fn regenerate_servers(state: &State) -> crate::Result<()> { + for metadata in crate::state::list_instances(&state.pool).await? { + if effective(&metadata, state).await? { + compose_instance(&metadata, state).await?; + } + } + Ok(()) +} + +pub(super) async fn effective( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result { + if sync_files_are_protected(metadata) + || instance_is_running(metadata, state).await? + { + return Ok(false); + } + participating(metadata, state).await +} + +async fn participating( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result { + if !instance_option_enabled(metadata, SyncedOption::MultiplayerServers) { + return Ok(false); + } + let global_enabled: bool = sqlx::query_scalar( + "SELECT EXISTS( + SELECT 1 FROM sync_feature_settings + WHERE feature = 'multiplayer_servers' AND globally_enabled = 1 + )", + ) + .fetch_one(&state.pool) + .await?; + + Ok(instance_option_supported( + metadata, + SyncedOption::MultiplayerServers, + global_enabled, + state, + ) + .await) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn server(name: &str, address: &str) -> NbtCompound { + super::super::codec::server_data( + name.to_string(), + address.to_string(), + None, + ) + } + + #[test] + fn merge_preserves_local_entries_and_synced_exclusions() { + let records = merge_server_records( + vec![ + CanonicalServer { + id: "synced-a".to_string(), + data: server("Synced A", "a.example.test"), + }, + CanonicalServer { + id: "synced-b".to_string(), + data: server("Synced B", "b.example.test"), + }, + ], + vec![ + LocalServer { + id: "local".to_string(), + source: ServerSource::Modpack, + excluded_synced_server_id: None, + data: server("Pack server", "pack.example.test"), + position: 1, + }, + LocalServer { + id: "desynced-b".to_string(), + source: ServerSource::LocalDesynced, + excluded_synced_server_id: Some("synced-b".to_string()), + data: server("Private B", "b.example.test"), + position: 2, + }, + ], + ); + + assert_eq!( + records + .iter() + .map(|record| (record.id.as_str(), record.source)) + .collect::>(), + vec![ + ("synced-a", ServerSource::UserSynced), + ("local", ServerSource::Modpack), + ("desynced-b", ServerSource::LocalDesynced), + ] + ); + } + + #[test] + fn projection_matching_prefers_exact_then_address_then_name() { + let projections = vec![ + ProjectionEntry { + id: "exact".to_string(), + owner: ProjectionOwner::Synced, + data: server("Exact", "exact.example.test"), + position: 2, + }, + ProjectionEntry { + id: "address".to_string(), + owner: ProjectionOwner::Synced, + data: server("Old name", "same.example.test"), + position: 0, + }, + ProjectionEntry { + id: "name".to_string(), + owner: ProjectionOwner::Instance, + data: server("Same name", "old.example.test"), + position: 1, + }, + ]; + let current = vec![ + server("Exact", "exact.example.test"), + server("New name", "same.example.test"), + server("Same name", "new.example.test"), + ]; + + let matches = match_projection_entries(¤t, &projections); + assert_eq!( + matches + .into_iter() + .map(|projection| projection + .map(|projection| projection.id.as_str())) + .collect::>(), + vec![Some("exact"), Some("address"), Some("name")] + ); + } +} diff --git a/packages/app-lib/src/api/instance/synced_servers/storage.rs b/packages/app-lib/src/api/instance/synced_servers/storage.rs new file mode 100644 index 0000000..548a1c2 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_servers/storage.rs @@ -0,0 +1,369 @@ +use super::super::synced_options::{ + nbt_from_bytes, nbt_to_bytes, safe_instance_id, synced_options_path, +}; +use super::SERVERS_FILE; +use super::types::{ + CanonicalServer, LocalServer, ProjectionEntry, ProjectionOwner, + ServerRecord, ServerSource, +}; +use crate::{ErrorKind, State}; +use sqlx::{Executor, Sqlite, Transaction}; +use std::collections::HashSet; +use std::path::PathBuf; + +pub(in crate::api::instance) async fn canonical_exists( + state: &State, +) -> crate::Result { + canonical_initialized(state).await +} + +async fn canonical_initialized(state: &State) -> crate::Result { + Ok(sqlx::query_scalar::<_, bool>( + "SELECT EXISTS( + SELECT 1 FROM synced_server_state WHERE singleton = 1 + )", + ) + .fetch_one(&state.pool) + .await?) +} + +pub(super) async fn read_canonical( + state: &State, +) -> crate::Result> { + read_canonical_with(&state.pool).await +} + +async fn read_canonical_with<'e>( + executor: impl Executor<'e, Database = Sqlite>, +) -> crate::Result> { + let rows = sqlx::query( + "SELECT id, nbt + FROM synced_servers + ORDER BY position", + ) + .fetch_all(executor) + .await?; + use sqlx::Row; + rows.into_iter() + .map(|row| { + let id: String = row.get("id"); + let nbt: Vec = row.get("nbt"); + Ok(CanonicalServer { + id, + data: nbt_from_bytes(nbt)?, + }) + }) + .collect() +} + +/// Reads canonical servers and instance overrides from the same committed revision. +pub(super) async fn read_server_snapshot( + instance_id: &str, + state: &State, +) -> crate::Result<(Vec, Vec)> { + let mut tx = state.pool.begin().await?; + let canonical = read_canonical_with(&mut *tx).await?; + let local = load_local_with(instance_id, &mut *tx).await?; + tx.commit().await?; + Ok((canonical, local)) +} + +pub(super) async fn commit_server_state( + canonical: Option<&[CanonicalServer]>, + local: Option<(&str, &[LocalServer])>, + state: &State, +) -> crate::Result { + let mut tx = state.pool.begin().await?; + let mut canonical_changed = false; + if let Some(canonical) = canonical { + canonical_changed = write_canonical_rows(&mut tx, canonical).await?; + sqlx::query( + "INSERT INTO synced_server_state (singleton, revision) + VALUES (1, 0) + ON CONFLICT(singleton) DO NOTHING", + ) + .execute(&mut *tx) + .await?; + if canonical_changed { + sqlx::query( + "UPDATE synced_server_state + SET revision = revision + 1 + WHERE singleton = 1", + ) + .execute(&mut *tx) + .await?; + } + } + if let Some((instance_id, local)) = local { + write_local_rows(&mut tx, instance_id, local).await?; + } + tx.commit().await?; + Ok(canonical_changed) +} + +async fn write_canonical_rows( + tx: &mut Transaction<'_, Sqlite>, + servers: &[CanonicalServer], +) -> crate::Result { + let current = sqlx::query( + "SELECT id, position, nbt + FROM synced_servers + ORDER BY position", + ) + .fetch_all(&mut **tx) + .await?; + let mut desired = Vec::with_capacity(servers.len()); + for (position, server) in servers.iter().enumerate() { + desired.push(( + server.id.as_str(), + position as i64, + nbt_to_bytes(&server.data)?, + )); + } + if current.len() == desired.len() + && current.iter().zip(&desired).all(|(current, desired)| { + current.id == desired.0 + && current.position == desired.1 + && current.nbt == desired.2 + }) + { + return Ok(false); + } + + let desired_ids = + desired.iter().map(|(id, _, _)| *id).collect::>(); + for row in ¤t { + if !desired_ids.contains(row.id.as_str()) { + sqlx::query("DELETE FROM synced_servers WHERE id = ?") + .bind(&row.id) + .execute(&mut **tx) + .await?; + } + } + + let position_offset = current + .iter() + .map(|row| row.position) + .max() + .unwrap_or(0) + .max(servers.len() as i64) + .saturating_add(1); + sqlx::query("UPDATE synced_servers SET position = position + ?") + .bind(position_offset) + .execute(&mut **tx) + .await?; + + for (id, position, nbt) in desired { + sqlx::query( + "INSERT INTO synced_servers (id, position, nbt) + VALUES (?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + position = excluded.position, + nbt = excluded.nbt", + ) + .bind(id) + .bind(position) + .bind(nbt) + .execute(&mut **tx) + .await?; + } + Ok(true) +} + +pub(super) async fn server_revision(state: &State) -> crate::Result { + Ok(sqlx::query_scalar::<_, i64>( + "SELECT revision + FROM synced_server_state + WHERE singleton = 1", + ) + .fetch_optional(&state.pool) + .await? + .unwrap_or(0)) +} + +pub(super) async fn load_local( + instance_id: &str, + state: &State, +) -> crate::Result> { + load_local_with(instance_id, &state.pool).await +} + +async fn load_local_with<'e>( + instance_id: &str, + executor: impl Executor<'e, Database = Sqlite>, +) -> crate::Result> { + let rows = sqlx::query( + "SELECT id, source, excluded_synced_server_id, nbt, position + FROM instance_servers + WHERE instance_id = ? + ORDER BY position", + ) + .bind(instance_id) + .fetch_all(executor) + .await?; + use sqlx::Row; + rows.into_iter() + .map(|row| { + let id: String = row.get("id"); + let source: String = row.get("source"); + let excluded_synced_server_id: Option = + row.get("excluded_synced_server_id"); + let nbt: Vec = row.get("nbt"); + let position: i64 = row.get("position"); + Ok(LocalServer { + id, + source: ServerSource::from_str(&source).ok_or_else( + || { + ErrorKind::InputError(format!( + "Unknown server source {source}" + )) + }, + )?, + excluded_synced_server_id, + data: nbt_from_bytes(nbt)?, + position, + }) + }) + .collect() +} + +pub(super) async fn write_local( + instance_id: &str, + servers: &[LocalServer], + state: &State, +) -> crate::Result<()> { + commit_server_state(None, Some((instance_id, servers)), state).await?; + Ok(()) +} + +pub(super) async fn write_local_rows( + tx: &mut Transaction<'_, Sqlite>, + instance_id: &str, + servers: &[LocalServer], +) -> crate::Result<()> { + sqlx::query("DELETE FROM instance_servers WHERE instance_id = ?") + .bind(instance_id) + .execute(&mut **tx) + .await?; + for server in servers { + let source = server.source.as_str(); + let nbt = nbt_to_bytes(&server.data)?; + sqlx::query( + "INSERT INTO instance_servers + (instance_id, id, source, excluded_synced_server_id, + nbt, position) + VALUES (?, ?, ?, + (SELECT id FROM synced_servers WHERE id = ?), ?, ?)", + ) + .bind(instance_id) + .bind(&server.id) + .bind(source) + .bind(&server.excluded_synced_server_id) + .bind(nbt) + .bind(server.position) + .execute(&mut **tx) + .await?; + } + Ok(()) +} + +pub(super) async fn load_projection_entries( + instance_id: &str, + state: &State, +) -> crate::Result> { + let rows = sqlx::query( + "SELECT server_id, owner, nbt, position + FROM instance_server_projection_entries + WHERE instance_id = ? + ORDER BY position", + ) + .bind(instance_id) + .fetch_all(&state.pool) + .await?; + use sqlx::Row; + rows.into_iter() + .map(|row| { + let server_id: String = row.get("server_id"); + let owner: String = row.get("owner"); + let nbt: Vec = row.get("nbt"); + let position: i64 = row.get("position"); + Ok(ProjectionEntry { + id: server_id, + owner: ProjectionOwner::from_str(&owner).ok_or_else( + || { + ErrorKind::InputError(format!( + "Unknown server projection owner {owner}" + )) + }, + )?, + data: nbt_from_bytes(nbt)?, + position, + }) + }) + .collect() +} + +pub(super) async fn begin_server_checkpoint( + instance_id: &str, + servers: &[ServerRecord], + expected_sha1: &str, + source_revision: i64, + state: &State, +) -> crate::Result<()> { + let mut tx = state.pool.begin().await?; + sqlx::query( + "DELETE FROM instance_server_projection_entries WHERE instance_id = ?", + ) + .bind(instance_id) + .execute(&mut *tx) + .await?; + for (position, server) in servers.iter().enumerate() { + let owner = if server.source == ServerSource::UserSynced { + ProjectionOwner::Synced + } else { + ProjectionOwner::Instance + } + .as_str(); + let nbt = nbt_to_bytes(&server.data)?; + let position = position as i64; + sqlx::query( + "INSERT INTO instance_server_projection_entries + (instance_id, owner, server_id, nbt, position) + VALUES (?, ?, ?, ?, ?)", + ) + .bind(instance_id) + .bind(owner) + .bind(&server.id) + .bind(nbt) + .bind(position) + .execute(&mut *tx) + .await?; + } + sqlx::query( + "INSERT INTO instance_sync_checkpoints + (instance_id, feature, variant, expected_sha1, merge_base, + source_revision, status, link_mode) + VALUES (?, 'multiplayer_servers', 'default', ?, NULL, ?, + 'pending', NULL) + ON CONFLICT(instance_id, feature, variant) DO UPDATE SET + expected_sha1 = excluded.expected_sha1, + merge_base = NULL, + source_revision = excluded.source_revision, + status = 'pending', + link_mode = NULL", + ) + .bind(instance_id) + .bind(expected_sha1) + .bind(source_revision) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) +} + +pub(super) fn generated_path(state: &State, instance_id: &str) -> PathBuf { + synced_options_path(state) + .join("servers/generated") + .join(safe_instance_id(instance_id)) + .join(SERVERS_FILE) +} diff --git a/packages/app-lib/src/api/instance/synced_servers/types.rs b/packages/app-lib/src/api/instance/synced_servers/types.rs new file mode 100644 index 0000000..97b78d6 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_servers/types.rs @@ -0,0 +1,131 @@ +use quartz_nbt::NbtCompound; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ServerSource { + UserSynced, + Modpack, + LinkedServerProject, + LocalDesynced, +} + +impl ServerSource { + pub(super) fn as_str(self) -> &'static str { + match self { + Self::UserSynced => "user_synced", + Self::Modpack => "modpack", + Self::LinkedServerProject => "linked_server_project", + Self::LocalDesynced => "local_desynced", + } + } + + pub(super) fn from_str(value: &str) -> Option { + match value { + "user_synced" => Some(Self::UserSynced), + "modpack" => Some(Self::Modpack), + "linked_server_project" => Some(Self::LinkedServerProject), + "local_desynced" => Some(Self::LocalDesynced), + _ => None, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DesyncServerMode { + KeepInOtherInstances, + RemoveFromOtherInstances, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SyncedServer { + pub id: String, + pub name: String, + pub address: String, + pub accept_textures: Option, +} + +#[derive(Clone, Debug)] +pub(crate) struct ServerRecord { + pub id: String, + pub source: ServerSource, + pub data: NbtCompound, +} + +impl ServerRecord { + pub fn name(&self) -> String { + self.data + .get::<_, &str>("name") + .unwrap_or_default() + .to_string() + } + + pub fn address(&self) -> String { + self.data + .get::<_, &str>("ip") + .unwrap_or_default() + .to_string() + } + + pub fn icon(&self) -> Option { + self.data.get::<_, &str>("icon").ok().map(ToOwned::to_owned) + } + + pub fn hidden(&self) -> bool { + self.data.get::<_, i8>("hidden").unwrap_or(0) != 0 + } + + pub fn accept_textures(&self) -> Option { + self.data + .get::<_, i8>("acceptTextures") + .ok() + .map(|value| value != 0) + } +} + +#[derive(Clone)] +pub(super) struct CanonicalServer { + pub id: String, + pub data: NbtCompound, +} + +#[derive(Clone)] +pub(super) struct LocalServer { + pub id: String, + pub source: ServerSource, + pub excluded_synced_server_id: Option, + pub data: NbtCompound, + pub position: i64, +} + +#[derive(Clone)] +pub(super) struct ProjectionEntry { + pub id: String, + pub owner: ProjectionOwner, + pub data: NbtCompound, + pub position: i64, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum ProjectionOwner { + Synced, + Instance, +} + +impl ProjectionOwner { + pub(super) fn as_str(self) -> &'static str { + match self { + Self::Synced => "synced", + Self::Instance => "instance", + } + } + + pub(super) fn from_str(value: &str) -> Option { + match value { + "synced" => Some(Self::Synced), + "instance" => Some(Self::Instance), + _ => None, + } + } +} diff --git a/packages/app-lib/src/launcher/mod.rs b/packages/app-lib/src/launcher/mod.rs index 5e9c524..c256e8d 100644 --- a/packages/app-lib/src/launcher/mod.rs +++ b/packages/app-lib/src/launcher/mod.rs @@ -47,6 +47,21 @@ use tokio::io::AsyncReadExt; use tokio::process::Command; use tokio_util::sync::CancellationToken; +pub(crate) struct GameVersionMetadata { + pub world_version: Option, +} + +/// Reads the embedded `version.json` DataVersion from a client jar. +/// +/// Ported as a stub to match the upstream Axolotl implementation, which also +/// returns `None`; callers fall back to the legacy data-version table. +pub(crate) async fn read_game_version_metadata_from_jar( + _path: &Path, +) -> crate::Result> { + Ok(None) +} + + #[cfg(target_os = "windows")] use winreg::{RegKey, enums::HKEY_CURRENT_USER}; diff --git a/packages/app-lib/src/state/dirs.rs b/packages/app-lib/src/state/dirs.rs index eae7211..4d48b76 100644 --- a/packages/app-lib/src/state/dirs.rs +++ b/packages/app-lib/src/state/dirs.rs @@ -68,10 +68,17 @@ impl DirectoryInfo { /// Get the Minecraft instance metadata directory #[inline] + pub fn metadata_dir(&self) -> PathBuf { self.config_dir.join(METADATA_FOLDER_NAME) } + /// Get the directory containing launcher-wide synced option files. + #[inline] + pub(crate) fn synced_options_dir(&self) -> PathBuf { + self.config_dir.join("synced-options") + } + /// Get the Minecraft java versions metadata directory #[inline] pub fn java_versions_dir(&self) -> PathBuf { diff --git a/packages/app-lib/src/state/game_options.rs b/packages/app-lib/src/state/game_options.rs new file mode 100644 index 0000000..7d966c0 --- /dev/null +++ b/packages/app-lib/src/state/game_options.rs @@ -0,0 +1,68 @@ +use super::{CanonicalValue, GameOptionKind, StoredOption, StoredPreference}; +use sqlx::{Row, SqlitePool}; +use std::collections::HashMap; + +pub(crate) async fn shared_game_options_exist( + pool: &SqlitePool, +) -> crate::Result { + Ok(sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM synced_game_option_state WHERE singleton = 1)").fetch_one(pool).await?) +} + +pub(crate) async fn game_options_sync_is_enabled( + pool: &SqlitePool, +) -> crate::Result { + Ok(sqlx::query_scalar("SELECT globally_enabled FROM sync_feature_settings WHERE feature = 'game_options'").fetch_optional(pool).await?.unwrap_or(false)) +} + +pub(crate) async fn load_game_options_sync_state( + pool: &SqlitePool, + current_catalog_revision: u32, +) -> crate::Result<(u64, u32)> { + let row = sqlx::query("SELECT revision, catalog_revision FROM synced_game_option_state WHERE singleton = 1").fetch_optional(pool).await?; + Ok(row + .map(|r| { + ( + r.get::("revision").max(0) as u64, + (r.get::("catalog_revision").max(1) as u32) + .max(current_catalog_revision), + ) + }) + .unwrap_or((0, current_catalog_revision))) +} + +pub(crate) async fn load_shared_game_options( + pool: &SqlitePool, +) -> crate::Result> { + let rows = sqlx::query("SELECT option_id, kind, raw_key, canonical_value_json, seeded, revision FROM synced_game_option_values").fetch_all(pool).await?; + let mut out = HashMap::with_capacity(rows.len()); + for row in rows { + let option_id: String = row.get("option_id"); + let kind: String = row.get("kind"); + let raw_key: Option = row.try_get("raw_key")?; + let json: Option = row.try_get("canonical_value_json")?; + let seeded: bool = row.get("seeded"); + let revision: i64 = row.get("revision"); + out.insert( + option_id.clone(), + StoredOption { + option_id, + kind: if kind == "external" { + GameOptionKind::External + } else { + GameOptionKind::Vanilla + }, + raw_key, + value: json.as_deref().map(serde_json::from_str).transpose()?, + seeded, + revision: revision.max(0) as u64, + }, + ); + } + Ok(out) +} + +pub(crate) async fn load_game_option_preferences( + pool: &SqlitePool, +) -> crate::Result> { + Ok(sqlx::query("SELECT option_id, enabled, revision FROM synced_game_option_preferences").fetch_all(pool).await?.into_iter().map(|r| { let option_id: String = r.get("option_id"); let enabled: bool = r.get("enabled"); let revision: i64 = r.get("revision"); (option_id, StoredPreference { enabled, revision: revision.max(0) as u64 }) }).collect()) +} \ No newline at end of file diff --git a/packages/app-lib/src/state/instances/model/game_options.rs b/packages/app-lib/src/state/instances/model/game_options.rs new file mode 100644 index 0000000..c7cc63f --- /dev/null +++ b/packages/app-lib/src/state/instances/model/game_options.rs @@ -0,0 +1,52 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", content = "value", rename_all = "snake_case")] +pub enum CanonicalValue { + Bool(bool), + Integer(i64), + Decimal(String), + Enum(String), + Text(String), + StringList(Vec), + KeyBinding(String), + ExternalRaw(String), +} + +impl CanonicalValue { + pub(crate) fn type_name(&self) -> &'static str { + match self { + Self::Bool(_) => "bool", + Self::Integer(_) => "integer", + Self::Decimal(_) => "decimal", + Self::Enum(_) => "enum", + Self::Text(_) => "text", + Self::StringList(_) => "string_list", + Self::KeyBinding(_) => "key_binding", + Self::ExternalRaw(_) => "external_raw", + } + } +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GameOptionKind { + Vanilla, + External, +} + +#[derive(Clone)] +pub(crate) struct StoredOption { + pub(crate) option_id: String, + pub(crate) kind: GameOptionKind, + pub(crate) raw_key: Option, + pub(crate) value: Option, + pub(crate) seeded: bool, + pub(crate) revision: u64, +} + +#[derive(Clone)] +pub(crate) struct StoredPreference { + pub(crate) enabled: bool, + pub(crate) revision: u64, +} \ No newline at end of file diff --git a/packages/app-lib/src/state/instances/model/instance.rs b/packages/app-lib/src/state/instances/model/instance.rs index 1e392f9..b2a60b3 100644 --- a/packages/app-lib/src/state/instances/model/instance.rs +++ b/packages/app-lib/src/state/instances/model/instance.rs @@ -109,3 +109,47 @@ pub struct InstanceSyncedOptions { #[serde(default)] pub data_packs: bool, } + + +/// The set of per-instance synchronization features. Mirrors the Axolotl +/// `SyncedOption` enum, including its string keys used by the +/// `instance_sync_preferences` / `sync_feature_settings` tables. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SyncedOption { + GameOptions, + CommandHistory, + MultiplayerServers, + CreativeHotbars, + Screenshots, + ResourcePacks, + DataPacks, +} + +impl SyncedOption { + pub const ALL: [Self; 7] = [ + Self::GameOptions, + Self::CommandHistory, + Self::MultiplayerServers, + Self::CreativeHotbars, + Self::Screenshots, + Self::ResourcePacks, + Self::DataPacks, + ]; + + pub const fn is_available(self) -> bool { + true + } + + pub const fn as_str(self) -> &'static str { + match self { + Self::GameOptions => "game_options", + Self::CommandHistory => "command_history", + Self::MultiplayerServers => "multiplayer_servers", + Self::CreativeHotbars => "creative_hotbars", + Self::Screenshots => "screenshots", + Self::ResourcePacks => "resource_packs", + Self::DataPacks => "data_packs", + } + } +} diff --git a/packages/app-lib/src/state/instances/model/mod.rs b/packages/app-lib/src/state/instances/model/mod.rs index 288449e..11d8096 100644 --- a/packages/app-lib/src/state/instances/model/mod.rs +++ b/packages/app-lib/src/state/instances/model/mod.rs @@ -65,3 +65,5 @@ pub use self::update_check::*; fn unknown_value(kind: &str, value: &str) -> crate::Error { crate::ErrorKind::InputError(format!("Unknown {kind} {value}")).into() } +mod game_options; +pub use self::game_options::*; \ No newline at end of file diff --git a/packages/app-lib/src/state/mod.rs b/packages/app-lib/src/state/mod.rs index 3875ae1..597f7d9 100644 --- a/packages/app-lib/src/state/mod.rs +++ b/packages/app-lib/src/state/mod.rs @@ -67,6 +67,9 @@ pub use self::mr_auth::*; mod legacy_converter; +mod game_options; +pub use self::game_options::*; + pub mod attached_world_data; pub mod server_join_log; @@ -148,6 +151,9 @@ pub struct State { configured_http_client_update: AsyncMutex<()>, pub(crate) file_watcher: FileWatcher, + + pub(crate) synced_options_lock: Arc>, + pub(crate) game_locale_indexer: crate::api::instance::synced_options::game_options::locales::GameLocaleIndexer, } #[derive(Default)] @@ -555,6 +561,12 @@ impl State { )) } + pub(crate) async fn lock_synced_options( + &self, + ) -> tokio::sync::OwnedMutexGuard<()> { + self.synced_options_lock.clone().lock_owned().await + } + pub fn initialized() -> bool { LAUNCHER_STATE.initialized() } @@ -969,6 +981,13 @@ impl State { } } +pub(crate) async fn instance_has_running_process( + instance_id: &str, + state: &State, +) -> crate::Result { + Ok(state.process_manager.has_instance_process(instance_id)) +} + #[cfg(test)] pub(crate) async fn test_state( directories: DirectoryInfo, @@ -1016,6 +1035,8 @@ pub(crate) async fn test_state( configured_http_client: RwLock::new(configured_http_client), configured_http_client_update: AsyncMutex::new(()), file_watcher, + synced_options_lock: Arc::new(AsyncMutex::new(())), + game_locale_indexer: Default::default(), })) }