feat: 跨实例同步移植 WIP 检查点(第1轮+2a+2b+synced_servers)
Sync source to CNB / Sync Git ref (push) Has been cancelled

从 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轮),当前编译不通。
This commit is contained in:
AxTps committed 2026-10-03 22:18:37 +08:00
1 parent 5a289c3a15
commit ed61a5942c
43 files changed
+10607

No files matched your search

+2
View File
@@ -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,
@@ -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<Vec<u8>>,
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<Self> {
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<PathBuf> {
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<bool> {
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<LinkMode> {
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<Option<SyncCheckpoint>> {
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<String> {
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<NbtCompound> {
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<Vec<u8>> {
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<u8>,
) -> crate::Result<NbtCompound> {
let (root, _) = quartz_nbt::io::read_nbt(
&mut Cursor::new(bytes),
quartz_nbt::io::Flavor::Uncompressed,
)?;
Ok(root)
}
@@ -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<String>,
pub game_version: Option<String>,
pub eligible: bool,
pub disabled_reason: Option<GameOptionsSourceIssue>,
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<f64>,
pub max: Option<f64>,
pub step: Option<f64>,
pub unit: Option<String>,
pub choices: Vec<GameOptionEditorChoice>,
}
#[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<String>,
pub eventual_keys: Vec<String>,
pub game_versions: Vec<String>,
pub status: GameOptionCompatibilityStatus,
pub mapping: Option<GameOptionMappingKind>,
pub reason: Option<GameOptionCompatibilityReason>,
}
#[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<GameOptionCompatibilityBucket>,
}
#[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<String>,
pub sync_enabled: bool,
pub canonical_value: Option<CanonicalValue>,
pub value_state: GameOptionValueState,
pub option_revision: u64,
pub editor: GameOptionEditorDefinition,
pub compatibility: GameOptionCompatibility,
pub validation_error: Option<GameOptionValidationIssue>,
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<GameSettingCategory>,
pub settings: Vec<EditableGameSetting>,
}
#[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<GameSettingChange>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GameSettingChange {
pub option_id: String,
pub base_option_revision: u64,
pub sync_enabled: Option<bool>,
#[serde(
default,
with = "serde_with::rust::double_option",
skip_serializing_if = "Option::is_none"
)]
pub canonical_value: Option<Option<CanonicalValue>>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct SaveGameSettingsResult {
pub state: Option<GameSettingsEditorState>,
pub applied: u32,
pub migrated: u32,
pub deferred: u32,
pub unsupported: u32,
pub failed: u32,
pub conflicts: Vec<String>,
}
#[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::<f64>().ok() == right.parse::<f64>().ok()
}
_ => left == right,
}
}
@@ -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,
),
];
@@ -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,
),
];
@@ -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"
),
];
@@ -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,
};
@@ -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",
];
@@ -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"),
];
@@ -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<Item = &'static SupportedSetting> {
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(),
}
}
@@ -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,
),
];
@@ -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<String> {
if raw.starts_with('"') {
serde_json::from_str(raw).ok()
} else {
Some(raw.to_string())
}
}
fn encode_string_token(value: &str) -> Option<String> {
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::<u32>())
.collect::<Result<Vec<_>, _>>()
.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<String> {
let (key, modifier) = split_key_binding(value);
let key = key.parse::<i32>().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<String> {
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::<i32>().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::<u32>())
.collect::<Result<Vec<_>, _>>();
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<CanonicalValue> {
match definition.encoding {
ValueEncoding::Bool => match raw {
"true" => Some(CanonicalValue::Bool(true)),
"false" => Some(CanonicalValue::Bool(false)),
_ => None,
},
ValueEncoding::Integer => {
raw.parse::<i64>().ok().map(CanonicalValue::Integer)
}
ValueEncoding::Decimal => raw
.parse::<f64>()
.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::<f64>().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::<i64>().ok().map(CanonicalValue::Integer)
}
ValueEncoding::MenuBackgroundBlur => raw
.parse::<f64>()
.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<String> {
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::<f64>()
.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::<f64>()
.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<CanonicalValue> {
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<String> {
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<String> {
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::<f64>().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::<i64>().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::<i64>()
.is_ok_and(|value| (0..=10).contains(&value))
} else {
raw.parse::<f64>().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()
}
}
@@ -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,
),
];
@@ -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<Option<bool>> {
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<bool> {
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(())
}
@@ -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<GameOptionsDocument>,
inspection_error: Option<String>,
}
/// 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<Vec<ParticipatingInstance>> {
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::<Vec<_>>();
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::<i32>()
.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<CanonicalValue>, 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)
}
}
@@ -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<String> {
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(())
}
@@ -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<String, String>;
#[derive(Default, Serialize, Deserialize)]
pub(super) struct ArchiveIndex {
#[serde(default)]
pub version: u32,
pub bundles: Vec<LanguageBundle>,
}
#[derive(Default, Serialize, Deserialize)]
pub(super) struct LanguageBundle {
pub nested_path: String,
pub locales: BTreeMap<String, Translations>,
#[serde(default)]
pub deprecated: DeprecatedTranslations,
}
#[derive(Default, Serialize, Deserialize)]
pub(super) struct DeprecatedTranslations {
#[serde(default)]
removed: Vec<String>,
#[serde(default)]
renamed: BTreeMap<String, String>,
}
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<Translations> {
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::<BTreeMap<String, serde_json::Value>>(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<ArchiveIndex> {
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<R: Read + Seek>(
archive: &mut ZipArchive<R>,
name: &str,
limit: usize,
budget: &mut usize,
) -> crate::Result<Vec<u8>> {
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<R: Read + Seek>(
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<String> =
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::<serde_json::Value>(&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<u8>,
hash: &str,
) -> crate::Result<Vec<u8>> {
if sha1_bytes(&bytes) != hash {
return Err(input_error("Minecraft locale resource checksum mismatch"));
}
Ok(bytes)
}
@@ -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<String>,
archive: ArchiveSource,
nested_path: String,
translation_key: String,
choices: BTreeMap<String, String>,
}
#[derive(Default, Serialize)]
pub struct GameSettingLocaleLabels {
pub settings: BTreeMap<String, GameSettingLocaleLabel>,
}
#[derive(Serialize)]
pub struct GameSettingLocaleLabel {
pub label: String,
pub choices: BTreeMap<String, String>,
pub source: Option<GameSettingSource>,
}
#[derive(Serialize)]
pub struct GameSettingSource {
pub instance_id: String,
pub file_name: String,
pub file_path: String,
pub project: Option<GameSettingSourceProject>,
}
#[derive(Serialize)]
pub struct GameSettingSourceProject {
pub id: String,
pub title: String,
pub icon_url: Option<String>,
}
struct Candidate {
snapshot_id: String,
snapshot: Snapshot,
keys: BTreeMap<String, String>,
}
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<State>) {
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<String, String> {
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<String> {
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<String, Arc<ArchiveIndex>>,
) -> crate::Result<Arc<ArchiveIndex>> {
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<String> {
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<String>,
refresh_sources: bool,
) -> crate::Result<GameSettingLocaleLabels> {
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::<Vec<_>>()).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<String, Translations> = 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)
}
@@ -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<String, Arc<str>>;
struct SnapshotIndex {
snapshot: Snapshot,
keys: BTreeMap<String, (usize, Arc<str>)>,
}
#[derive(Default)]
pub(super) struct OriginResolver {
archives: HashMap<String, Arc<ArchiveKeys>>,
snapshots: HashMap<String, SnapshotIndex>,
}
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<Option<Origin>> {
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<ArchiveKeys> {
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::<ArchiveKeys>(&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<str> = 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)
}
@@ -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<ArchiveSource>,
}
pub(super) async fn mod_projects(
state: &State,
hashes: &[&str],
) -> crate::Result<HashMap<String, Project>> {
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::<Vec<_>>(),
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<T: Serialize>(
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<String> {
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<Snapshot> {
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<ArchiveIndex> {
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::<ArchiveIndex>(&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)
}
@@ -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<String>,
pub source_game_version: Option<String>,
pub backfilled: bool,
pub observation: Option<Observation>,
pub origin: Option<Origin>,
}
#[tracing::instrument(skip_all, fields(scope), err)]
pub(super) async fn load(
pool: &SqlitePool,
scope: Option<&str>,
) -> crate::Result<Vec<OriginRow>> {
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<String> = row.get("source_instance_id");
let source_game_version: Option<String> = row.get("source_game_version");
let backfilled: bool = row.get("backfilled");
let observation_json: Option<String> = row.get("observation_json");
let origin_json: Option<String> = 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(())
}
@@ -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<bool> {
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;
@@ -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<GameOptionsLine>,
}
#[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<GameOptionsEntry>,
}
#[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<Option<Self>> {
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<Self> {
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<bool> {
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<Vec<u8>> {
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<Vec<u8>> {
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<String>) -> 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<PathBuf> {
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<u8>)> {
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))
}
@@ -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<Option<(Vec<u8>, 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<bool> {
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<GameOptionsPackSource>,
) -> 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<Vec<u8>> = 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(())
}
@@ -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<bool> {
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(&current_key) {
continue;
}
let Some(current_raw) = document.value(&current_key) else {
continue;
};
let candidate = if let Some(definition) = definition {
decode_value_for_version(
definition,
&current_key,
current_raw,
&metadata.applied_content_set.game_version,
)
} else if matches!(stored.kind, GameOptionKind::External) {
if validate_raw_key_value(&current_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<String>,
state: &State,
) -> crate::Result<bool> {
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)
}
@@ -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<GameOptionValidationIssue> {
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<GameSettingsEditorState> {
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::<Vec<_>>();
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<GameSettingsEditorState> {
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<GameSettingsEditorState> {
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<SaveGameSettingsResult> {
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)
}
@@ -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<Vec<GameOptionsSourceCandidate>> {
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)
}
@@ -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<bool> {
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<AppliedSettings> {
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::<i32>().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<SyncOutcome> {
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<SyncOutcome> {
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
}
@@ -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,
};
@@ -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<SyncedOptionJoinResolution>,
},
}
#[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<Vec<PendingChange>> {
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<bool> {
Ok(read(state).await?.iter().any(|change| {
change.instance_id == instance_id && change.option == option
}))
}
@@ -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<Vec<NbtCompound>> {
if !path.exists() {
return Ok(Vec::new());
}
servers_from_root(read_nbt_file(path).await?)
}
pub(super) fn servers_from_bytes(
bytes: Vec<u8>,
) -> crate::Result<Vec<NbtCompound>> {
servers_from_root(nbt_from_bytes(bytes)?)
}
fn servers_from_root(root: NbtCompound) -> crate::Result<Vec<NbtCompound>> {
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<Vec<u8>> {
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<bool>,
) -> 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<bool>,
) {
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()
}
@@ -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};
@@ -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<NbtCompound>,
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::<Vec<_>>();
let mut retained = load_local(&metadata.instance.id, state)
.await?
.into_iter()
.filter(|server| server.source != ServerSource::Modpack)
.collect::<Vec<_>>();
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<bool> {
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<String> = 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<bool> {
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,
}
}
File diff suppressed because it is too large. Load diff
@@ -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<bool> {
canonical_initialized(state).await
}
async fn canonical_initialized(state: &State) -> crate::Result<bool> {
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<Vec<CanonicalServer>> {
read_canonical_with(&state.pool).await
}
async fn read_canonical_with<'e>(
executor: impl Executor<'e, Database = Sqlite>,
) -> crate::Result<Vec<CanonicalServer>> {
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<u8> = 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<CanonicalServer>, Vec<LocalServer>)> {
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<bool> {
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<bool> {
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::<HashSet<_>>();
for row in &current {
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<i64> {
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<Vec<LocalServer>> {
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<Vec<LocalServer>> {
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<String> =
row.get("excluded_synced_server_id");
let nbt: Vec<u8> = 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<Vec<ProjectionEntry>> {
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<u8> = 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)
}
@@ -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<Self> {
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<bool>,
}
#[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<String> {
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<bool> {
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<String>,
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<Self> {
match value {
"synced" => Some(Self::Synced),
"instance" => Some(Self::Instance),
_ => None,
}
}
}
+15
View File
@@ -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<u32>,
}
/// 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<Option<GameVersionMetadata>> {
Ok(None)
}
#[cfg(target_os = "windows")]
use winreg::{RegKey, enums::HKEY_CURRENT_USER};
+7
View File
@@ -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 {
@@ -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<bool> {
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<bool> {
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::<i64, _>("revision").max(0) as u64,
(r.get::<i64, _>("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<HashMap<String, StoredOption>> {
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<String> = row.try_get("raw_key")?;
let json: Option<String> = 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<HashMap<String, StoredPreference>> {
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())
}
@@ -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<String>),
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<String>,
pub(crate) value: Option<CanonicalValue>,
pub(crate) seeded: bool,
pub(crate) revision: u64,
}
#[derive(Clone)]
pub(crate) struct StoredPreference {
pub(crate) enabled: bool,
pub(crate) revision: u64,
}
@@ -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",
}
}
}
@@ -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::*;
+21
View File
@@ -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<AsyncMutex<()>>,
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<bool> {
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(),
}))
}