feat: 跨实例同步移植 WIP 检查点(第1轮+2a+2b+synced_servers)
Sync source to CNB / Sync Git ref (push) Has been cancelled
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:
1 parent
5a289c3a15
commit
ed61a5942c
43 files changed
+10607
No files matched your search
@@ -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,
|
||||
}
|
||||
}
|
||||
+207
@@ -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"
|
||||
),
|
||||
];
|
||||
+127
@@ -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,
|
||||
};
|
||||
+255
@@ -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",
|
||||
];
|
||||
+112
@@ -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,
|
||||
),
|
||||
];
|
||||
+969
@@ -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(())
|
||||
}
|
||||
+371
@@ -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(¤t_key) {
|
||||
continue;
|
||||
}
|
||||
let Some(current_raw) = document.value(¤t_key) else {
|
||||
continue;
|
||||
};
|
||||
let candidate = if let Some(definition) = definition {
|
||||
decode_value_for_version(
|
||||
definition,
|
||||
¤t_key,
|
||||
current_raw,
|
||||
&metadata.applied_content_set.game_version,
|
||||
)
|
||||
} else if matches!(stored.kind, GameOptionKind::External) {
|
||||
if validate_raw_key_value(¤t_key, current_raw).is_err() {
|
||||
continue;
|
||||
}
|
||||
Some(CanonicalValue::ExternalRaw(current_raw.to_string()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let Some(candidate) = candidate else {
|
||||
continue;
|
||||
};
|
||||
if stored
|
||||
.value
|
||||
.as_ref()
|
||||
.is_some_and(|current| canonical_values_equal(current, &candidate))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
updates.push((option_id.to_string(), candidate, stored.revision));
|
||||
}
|
||||
|
||||
let mut changed = false;
|
||||
let mut fullscreen_value = None;
|
||||
if !updates.is_empty() {
|
||||
let (canonical_revision, _) =
|
||||
load_game_options_sync_state(&state.pool, CATALOG_REVISION).await?;
|
||||
let mut tx = state.pool.begin().await?;
|
||||
for (option_id, candidate, revision) in updates {
|
||||
let next_revision = revision.saturating_add(1) as i64;
|
||||
let current_revision = revision as i64;
|
||||
let value_json = serde_json::to_string(&candidate)?;
|
||||
let canonical_type = candidate.type_name();
|
||||
let result = sqlx::query(
|
||||
"UPDATE synced_game_option_values
|
||||
SET canonical_type = ?, canonical_value_json = ?, seeded = 1,
|
||||
revision = ?, origin = 'instance', source_game_version = ?,
|
||||
source_instance_id = ?, updated_at = ?
|
||||
WHERE option_id = ? AND revision = ?",
|
||||
)
|
||||
.bind(canonical_type)
|
||||
.bind(value_json)
|
||||
.bind(next_revision)
|
||||
.bind(metadata.applied_content_set.game_version)
|
||||
.bind(metadata.instance.id)
|
||||
.bind(now)
|
||||
.bind(option_id)
|
||||
.bind(current_revision)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
continue;
|
||||
}
|
||||
if option_id == "fullscreen" {
|
||||
fullscreen_value = Some(candidate.clone());
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
if changed {
|
||||
let next_revision = canonical_revision.saturating_add(1) as i64;
|
||||
sqlx::query(
|
||||
"UPDATE synced_game_option_state
|
||||
SET revision = ?, catalog_revision = ?
|
||||
WHERE singleton = 1",
|
||||
)
|
||||
.bind(next_revision)
|
||||
.bind(CATALOG_REVISION as i64)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
if let Some(value) = fullscreen_value.as_ref() {
|
||||
update_app_fullscreen_setting(&mut tx, value, true).await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
}
|
||||
let discovered =
|
||||
discover_custom_settings(metadata, document, &launcher_keys, state)
|
||||
.await?;
|
||||
Ok(discovered || changed)
|
||||
}
|
||||
|
||||
/// Finds settings added by mods and adds them to the shared settings list.
|
||||
pub(super) async fn discover_custom_settings(
|
||||
metadata: &InstanceMetadata,
|
||||
document: &GameOptionsDocument,
|
||||
excluded_keys: &HashSet<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)
|
||||
}
|
||||
+393
@@ -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 ¤t {
|
||||
if !desired_ids.contains(row.id.as_str()) {
|
||||
sqlx::query("DELETE FROM synced_servers WHERE id = ?")
|
||||
.bind(&row.id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
let position_offset = current
|
||||
.iter()
|
||||
.map(|row| row.position)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.max(servers.len() as i64)
|
||||
.saturating_add(1);
|
||||
sqlx::query("UPDATE synced_servers SET position = position + ?")
|
||||
.bind(position_offset)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
for (id, position, nbt) in desired {
|
||||
sqlx::query(
|
||||
"INSERT INTO synced_servers (id, position, nbt)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
position = excluded.position,
|
||||
nbt = excluded.nbt",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(position)
|
||||
.bind(nbt)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(super) async fn server_revision(state: &State) -> crate::Result<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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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::*;
|
||||
@@ -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(),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user