diff --git a/Cargo.lock b/Cargo.lock index d867932812c3..537b695fa9ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4121,6 +4121,7 @@ dependencies = [ "image", "indexmap", "jpegxr", + "lazy_static", "linkme", "lzma-rs", "nellymoser-rs", diff --git a/core/Cargo.toml b/core/Cargo.toml index cb765775e967..0016d232a5b8 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -69,6 +69,7 @@ id3 = "1.16.0" either = "1.13.0" chardetng = "0.1.17" tracy-client = { version = "0.17.6", optional = true, default-features = false } +lazy_static = "1.5.0" [target.'cfg(not(target_family = "wasm"))'.dependencies.futures] workspace = true diff --git a/core/assets/texts/en-US/flags.ftl b/core/assets/texts/en-US/flags.ftl new file mode 100644 index 000000000000..34a516b545d5 --- /dev/null +++ b/core/assets/texts/en-US/flags.ftl @@ -0,0 +1,5 @@ +flag-tab_skip-name = Skip some objects when tabbing +flag-tab_skip-description = + Flash Player skips some objects from automatic tab ordering based on their coordinates. + Disable it when some objects are not being focused when tabbing. + Enable it when some objects are being focused when they shouldn't be. diff --git a/core/src/backend/ui.rs b/core/src/backend/ui.rs index 2c8a9bbd1709..5779404369a9 100644 --- a/core/src/backend/ui.rs +++ b/core/src/backend/ui.rs @@ -1,4 +1,5 @@ use crate::backend::navigator::OwnedFuture; +use crate::flags::CompatibilityFlag; pub use crate::loader::Error as DialogLoaderError; use chrono::{DateTime, Utc}; use downcast_rs::Downcast; @@ -128,6 +129,10 @@ pub trait UiBackend: Downcast { /// Mark that any previously open dialog has been closed fn close_file_dialog(&mut self); + + fn flag_enabled(&self, flag: CompatibilityFlag) -> bool { + flag.definition().default_value + } } impl_downcast!(UiBackend); diff --git a/core/src/flags.rs b/core/src/flags.rs new file mode 100644 index 000000000000..bae7011d156f --- /dev/null +++ b/core/src/flags.rs @@ -0,0 +1,279 @@ +//! Compatibility flags help configure Ruffle by enabling and disabling specific behaviors. +//! +//! They are meant to be used for instance in the following situations. +//! +//! 1. They fix bugs in Flash Player that make some content misbehave. +//! Note that in general we don't fix bugs in Flash Player -- we are bug compatible. +//! However, there are examples where a bug is so sneaky, some content could have +//! been created with an assumption that the bug doesn't exist and the bug affects it. +//! +//! 2. They genuinely improve the experience of using Ruffle at the cost +//! of lowering compatibility with Flash Player. +//! +//! 3. They improve the "perceived" compatibility at the cost of the "real" compatibility. +//! For instance, something does not work in Flash Player, and we make it work. + +use fluent_templates::LanguageIdentifier; +use lazy_static::lazy_static; +use std::{collections::HashMap, fmt::Display, str::FromStr}; + +use crate::i18n::core_text; + +/// The definition of a compatibility flag. +/// +/// It's a static definition, it's meant to describe flags statically. +/// See [`define_ruffle_flags!`]. +pub struct CompatibilityFlagDefinition { + /// The flag we're defining. + pub flag: CompatibilityFlag, + + /// Short identifier of the flag. + /// + /// Has to be unique, used for e.g. specifying the flag in config. + pub id: &'static str, + + /// Whether the flag is enabled by default in Ruffle. + pub default_value: bool, + + /// Whether Flash Player behaves as if the flag is enabled or disabled. + pub flash_player_value: bool, +} + +impl CompatibilityFlagDefinition { + pub fn name(&self, language: &LanguageIdentifier) -> String { + core_text(language, &format!("flag-{}-name", self.id)) + } + + pub fn description(&self, language: &LanguageIdentifier) -> String { + core_text(language, &format!("flag-{}-description", self.id)) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CompatibilityFlags(HashMap); + +impl CompatibilityFlags { + pub fn empty() -> Self { + Self(HashMap::new()) + } + + pub fn new(flags: HashMap) -> Self { + Self(flags) + } + + pub fn all_flags() -> &'static Vec { + &RUFFLE_ALL_FLAGS + } + + pub fn enabled(&self, flag: CompatibilityFlag) -> Result { + self.0 + .get(&flag) + .cloned() + .ok_or_else(|| flag.definition().default_value) + } + + pub fn set(&mut self, flag: CompatibilityFlag, enabled: bool) { + if enabled == flag.definition().default_value { + self.0.remove(&flag); + } else { + self.0.insert(flag, enabled); + } + } + + pub fn override_with(&mut self, other: &CompatibilityFlags) { + for (flag, value) in &other.0 { + self.0.insert(*flag, *value); + } + } + + pub fn overridden(&self) -> std::collections::hash_map::Keys<'_, CompatibilityFlag, bool> { + self.0.keys() + } +} + +impl FromStr for CompatibilityFlags { + type Err = String; + + fn from_str(value: &str) -> Result { + if value.is_empty() { + return Ok(CompatibilityFlags::new(HashMap::new())); + } + + let mut flags = HashMap::new(); + for flag in value.split(",") { + let flag = flag.trim(); + if flag.is_empty() { + continue; + } + let (id, value) = if let Some(flag) = flag.strip_prefix('-') { + (flag, false) + } else if let Some(flag) = flag.strip_prefix('+') { + (flag, true) + } else { + (flag, true) + }; + flags.insert( + CompatibilityFlag::from_id(id).ok_or_else(|| flag.to_string())?, + value, + ); + } + Ok(CompatibilityFlags::new(flags)) + } +} + +impl Display for CompatibilityFlags { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut first = true; + for (&flag, &value) in &self.0 { + let def = flag.definition(); + if def.default_value != value { + if !first { + write!(f, ",")?; + } else { + first = false; + } + + if !value { + write!(f, "-")?; + } + + write!(f, "{}", def.id)?; + } + } + Ok(()) + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for CompatibilityFlags { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + FromStr::from_str(&s).map_err(serde::de::Error::custom) + } +} + +macro_rules! define_compatibility_flags { + ($(flag($flag:ident, $id:expr, $($key:ident: $value:expr),* $(,)?));* $(;)?) => { + /// The collection of all compatibility flags. + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub enum CompatibilityFlag { + $($flag),* + } + + lazy_static! { + static ref RUFFLE_FLAGS: HashMap = HashMap::from([ + $((CompatibilityFlag::$flag, CompatibilityFlagDefinition { + flag: CompatibilityFlag::$flag, + id: $id, + $($key: $value),* + })),* + ]); + + static ref RUFFLE_FLAG_IDS: HashMap<&'static str, CompatibilityFlag> = HashMap::from([ + $(($id, CompatibilityFlag::$flag)),* + ]); + + static ref RUFFLE_ALL_FLAGS: Vec = vec![ + $(CompatibilityFlag::$flag),* + ]; + } + + impl CompatibilityFlag { + pub fn from_id(id: &str) -> Option { + RUFFLE_FLAG_IDS.get(id).cloned() + } + + pub fn definition(&self) -> &'static CompatibilityFlagDefinition { + RUFFLE_FLAGS.get(self).expect("Missing flag definition") + } + } + }; +} + +define_compatibility_flags!( + flag( + TabSkip, "tab_skip", + default_value: true, + flash_player_value: true, + ); +); + +#[cfg(test)] +mod tests { + use crate::flags::{CompatibilityFlag, CompatibilityFlags}; + + #[test] + fn test_parse_empty() { + let flags = "".parse::(); + assert_eq!(flags, Ok(CompatibilityFlags::empty())); + + let flags = flags.unwrap(); + assert_eq!(flags.enabled(CompatibilityFlag::TabSkip), Err(true)); + } + + #[test] + fn test_parse_positive() { + let flags = "tab_skip".parse::(); + assert!(flags.is_ok()); + + let flags = flags.unwrap(); + assert_eq!(flags.enabled(CompatibilityFlag::TabSkip), Ok(true)); + } + + #[test] + fn test_parse_positive2() { + let flags = "+tab_skip".parse::(); + assert!(flags.is_ok()); + + let flags = flags.unwrap(); + assert_eq!(flags.enabled(CompatibilityFlag::TabSkip), Ok(true)); + } + + #[test] + fn test_parse_negative() { + let flags = "-tab_skip".parse::(); + assert!(flags.is_ok()); + + let flags = flags.unwrap(); + assert_eq!(flags.enabled(CompatibilityFlag::TabSkip), Ok(false)); + } + + #[test] + fn test_parse_space() { + let flags = " tab_skip , ".parse::(); + assert!(flags.is_ok()); + + let flags = flags.unwrap(); + assert_eq!(flags.enabled(CompatibilityFlag::TabSkip), Ok(true)); + } + + #[test] + fn test_parse_space2() { + let flags = " , tab_skip ".parse::(); + assert!(flags.is_ok()); + + let flags = flags.unwrap(); + assert_eq!(flags.enabled(CompatibilityFlag::TabSkip), Ok(true)); + } + + #[test] + fn test_to_string1() { + let flags = "tab_skip".parse::(); + assert!(flags.is_ok()); + + let flags = flags.unwrap(); + assert_eq!(flags.to_string(), ""); + } + + #[test] + fn test_to_string2() { + let flags = "-tab_skip".parse::(); + assert!(flags.is_ok()); + + let flags = flags.unwrap(); + assert_eq!(flags.to_string(), "-tab_skip"); + } +} diff --git a/core/src/focus_tracker.rs b/core/src/focus_tracker.rs index a3cbfa8b298f..c2118453d30d 100644 --- a/core/src/focus_tracker.rs +++ b/core/src/focus_tracker.rs @@ -7,6 +7,7 @@ pub use crate::display_object::{ }; use crate::display_object::{EditText, InteractiveObject, TInteractiveObject}; use crate::events::{ClipEvent, KeyCode}; +use crate::flags::CompatibilityFlag; use crate::prelude::Avm2Value; use crate::Player; use either::Either; @@ -279,7 +280,7 @@ impl<'gc> FocusTracker<'gc> { pub fn tab_order(&self, context: &mut UpdateContext<'gc>) -> TabOrder<'gc> { let mut tab_order = TabOrder::fill(context); - tab_order.sort(); + tab_order.sort(context.ui.flag_enabled(CompatibilityFlag::TabSkip)); tab_order } @@ -425,15 +426,15 @@ impl<'gc> TabOrder<'gc> { } } - fn sort(&mut self) { + fn sort(&mut self, allow_ignoring_duplicates: bool) { if self.is_custom() { - self.sort_with(CustomTabOrdering); + self.sort_with(CustomTabOrdering, allow_ignoring_duplicates); } else { - self.sort_with(AutomaticTabOrdering); + self.sort_with(AutomaticTabOrdering, allow_ignoring_duplicates); } } - fn sort_with(&mut self, ordering: impl TabOrdering) { + fn sort_with(&mut self, ordering: impl TabOrdering, allow_ignoring_duplicates: bool) { self.objects.sort_by_cached_key(|&o| ordering.key(o)); let to_skip = self @@ -443,7 +444,7 @@ impl<'gc> TabOrder<'gc> { .count(); self.objects.drain(..to_skip); - if ordering.ignore_duplicates() { + if allow_ignoring_duplicates && ordering.ignore_duplicates() { self.objects.dedup_by_key(|&mut o| ordering.key(o)); } } diff --git a/core/src/lib.rs b/core/src/lib.rs index c37bd6fbae7c..539d48e69091 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -30,6 +30,7 @@ pub mod context_menu; mod drawing; mod ecma_conversions; pub mod events; +pub mod flags; pub mod focus_tracker; mod font; mod frame_lifecycle; diff --git a/desktop/assets/texts/en-US/preferences_dialog.ftl b/desktop/assets/texts/en-US/preferences_dialog.ftl index 6b3882505f81..195f53dc4e61 100644 --- a/desktop/assets/texts/en-US/preferences_dialog.ftl +++ b/desktop/assets/texts/en-US/preferences_dialog.ftl @@ -1,5 +1,16 @@ preferences-dialog = Ruffle Preferences +preferences-panel-general = General +preferences-panel-compatibility-flags = Compatibility Flags + +preferences-panel-flag-overridden = This flag has been overridden by the user +preferences-panel-flag-id = ID +preferences-panel-flag-name = Name +preferences-panel-flag-description = Description +preferences-panel-flag-enabled = Enabled +preferences-panel-flag-fp = FP +preferences-panel-flag-fp-tooltip = The value which imitates the behavior of Flash Player + preference-locked-by-cli = Read-Only (Set by CLI) graphics-backend = Graphics Backend diff --git a/desktop/src/backends/ui.rs b/desktop/src/backends/ui.rs index 06a9ab71a58d..8d070669d3e7 100644 --- a/desktop/src/backends/ui.rs +++ b/desktop/src/backends/ui.rs @@ -14,6 +14,7 @@ use ruffle_core::backend::ui::{ DialogLoaderError, DialogResultFuture, FileDialogResult, FileFilter, FontDefinition, FullscreenError, LanguageIdentifier, MouseCursor, UiBackend, }; +use ruffle_core::flags::CompatibilityFlag; use std::rc::Rc; use std::sync::Arc; use tracing::error; @@ -373,4 +374,8 @@ impl UiBackend for DesktopUiBackend { } fn close_file_dialog(&mut self) {} + + fn flag_enabled(&self, flag: CompatibilityFlag) -> bool { + self.preferences.flag_enabled(flag) + } } diff --git a/desktop/src/cli.rs b/desktop/src/cli.rs index c93654ee113f..e5f485213915 100644 --- a/desktop/src/cli.rs +++ b/desktop/src/cli.rs @@ -5,6 +5,7 @@ use clap::{Parser, ValueEnum}; use ruffle_core::backend::navigator::SocketMode; use ruffle_core::config::Letterbox; use ruffle_core::events::{GamepadButton, KeyCode}; +use ruffle_core::flags::CompatibilityFlags; use ruffle_core::{LoadBehavior, PlayerRuntime, StageAlign, StageScaleMode}; use ruffle_render::quality::StageQuality; use ruffle_render_wgpu::clap::{GraphicsBackend, PowerPreference}; @@ -243,6 +244,13 @@ pub struct Opt { /// (like inlining constant pool entries) can't be disabled. #[clap(long)] pub no_avm2_optimizer: bool, + + /// Configure compatibility flags. + /// + /// Flags are comma separated, optionally prefixed + /// with a "-" to disable a flag, and with a "+" to enable it. + #[clap(long, value_parser(parse_flags), default_value = "")] + pub flags: CompatibilityFlags, } fn parse_movie_file_or_url(path: &str) -> Result { @@ -295,6 +303,12 @@ fn parse_gamepad_button(mapping: &str) -> Result<(GamepadButton, KeyCode), Error Ok((button, KeyCode::from_code(key_code as u32))) } +fn parse_flags(value: &str) -> Result { + value + .parse::() + .map_err(|flag| anyhow!("Unknown flag: {}", flag)) +} + impl Opt { pub fn trace_path(&self) -> Option<&Path> { None diff --git a/desktop/src/gui/dialogs/preferences_dialog.rs b/desktop/src/gui/dialogs/preferences_dialog.rs index 33a333c3a1d2..b4562d9ed5d1 100644 --- a/desktop/src/gui/dialogs/preferences_dialog.rs +++ b/desktop/src/gui/dialogs/preferences_dialog.rs @@ -4,13 +4,22 @@ use crate::log::FilenamePattern; use crate::preferences::{storage::StorageBackend, GlobalPreferences}; use cpal::traits::{DeviceTrait, HostTrait}; use egui::{Align2, Button, Checkbox, ComboBox, DragValue, Grid, Ui, Widget, Window}; +use egui_extras::{Column, TableBuilder}; +use ruffle_core::flags::CompatibilityFlags; use ruffle_render_wgpu::clap::{GraphicsBackend, PowerPreference}; use std::borrow::Cow; use unic_langid::LanguageIdentifier; +#[derive(PartialEq, Eq, Clone, Copy, Debug)] +enum PreferencesDialogPanel { + General, + CompatibilityFlags, +} + pub struct PreferencesDialog { available_backends: wgpu::Backends, preferences: GlobalPreferences, + open_panel: PreferencesDialogPanel, graphics_backend: GraphicsBackend, graphics_backend_readonly: bool, @@ -51,6 +60,10 @@ pub struct PreferencesDialog { open_url_mode: OpenUrlMode, open_url_mode_readonly: bool, open_url_mode_changed: bool, + + flags: CompatibilityFlags, + flags_cli: CompatibilityFlags, + flags_changed: bool, } impl PreferencesDialog { @@ -109,14 +122,18 @@ impl PreferencesDialog { open_url_mode_readonly: preferences.cli.open_url_mode.is_some(), open_url_mode_changed: false, + flags: preferences.flags(), + flags_cli: preferences.cli.flags.clone(), + flags_changed: false, + preferences, + open_panel: PreferencesDialogPanel::General, } } pub fn show(&mut self, locale: &LanguageIdentifier, egui_ctx: &egui::Context) -> bool { let mut keep_open = true; let mut should_close = false; - let locked_text = text(locale, "preference-locked-by-cli"); Window::new(text(locale, "preferences-dialog")) .open(&mut keep_open) @@ -124,53 +141,183 @@ impl PreferencesDialog { .collapsible(false) .resizable(false) .show(egui_ctx, |ui| { - ui.vertical_centered_justified(|ui| { - Grid::new("preferences-dialog-graphics") - .num_columns(2) - .striped(true) - .show(ui, |ui| { - self.show_graphics_preferences(locale, &locked_text, ui); - - if cfg!(target_os = "linux") { - self.show_gamemode_preferences(locale, &locked_text, ui); - } + self.show_top_panel(locale, ui); - self.show_open_url_mode_preferences(locale, &locked_text, ui); + ui.separator(); - self.show_language_preferences(locale, ui); + match self.open_panel { + PreferencesDialogPanel::General => { + self.show_general_preferences(locale, egui_ctx, ui); + } + PreferencesDialogPanel::CompatibilityFlags => { + self.show_compatibility_flags(locale, ui); + } + } - self.show_theme_preferences(locale, ui); + ui.separator(); - self.show_audio_preferences(locale, ui); + if !self.show_bottom_panel(locale, ui) { + should_close = true; + } + }); - self.show_video_preferences(egui_ctx, locale, ui); + keep_open && !should_close + } - self.show_log_preferences(locale, ui); + fn show_top_panel(&mut self, locale: &LanguageIdentifier, ui: &mut Ui) { + ui.horizontal(|ui| { + ui.selectable_value( + &mut self.open_panel, + PreferencesDialogPanel::General, + text(locale, "preferences-panel-general"), + ); + ui.selectable_value( + &mut self.open_panel, + PreferencesDialogPanel::CompatibilityFlags, + text(locale, "preferences-panel-compatibility-flags"), + ); + }); + } - self.show_storage_preferences(locale, &locked_text, ui); + fn show_general_preferences( + &mut self, + locale: &LanguageIdentifier, + egui_ctx: &egui::Context, + ui: &mut Ui, + ) { + let locked_text = text(locale, "preference-locked-by-cli"); - self.show_misc_preferences(locale, ui); - }); + ui.vertical_centered_justified(|ui| { + Grid::new("preferences-dialog-graphics") + .num_columns(2) + .striped(true) + .show(ui, |ui| { + self.show_graphics_preferences(locale, &locked_text, ui); - if self.restart_required() { - ui.colored_label( - ui.style().visuals.error_fg_color, - "A restart is required to apply the selected changes", - ); + if cfg!(target_os = "linux") { + self.show_gamemode_preferences(locale, &locked_text, ui); } - ui.horizontal(|ui| { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if Button::new(text(locale, "save")).ui(ui).clicked() { - self.save(); - should_close = true; - } - }) + self.show_open_url_mode_preferences(locale, &locked_text, ui); + + self.show_language_preferences(locale, ui); + + self.show_theme_preferences(locale, ui); + + self.show_audio_preferences(locale, ui); + + self.show_video_preferences(egui_ctx, locale, ui); + + self.show_log_preferences(locale, ui); + + self.show_storage_preferences(locale, &locked_text, ui); + + self.show_misc_preferences(locale, ui); + }); + }); + } + + fn show_compatibility_flags(&mut self, locale: &LanguageIdentifier, ui: &mut Ui) { + let locked_text: &str = &text(locale, "preference-locked-by-cli"); + + ui.vertical_centered_justified(|ui| { + TableBuilder::new(ui) + .striped(true) + .resizable(false) + .cell_layout(egui::Layout::left_to_right(egui::Align::LEFT)) + .column(Column::exact(12.0)) + .column(Column::auto()) + .column(Column::remainder().resizable(true)) + .column(Column::auto()) + .column(Column::auto()) + .header(20.0, |mut header| { + header.col(|_| {}); + header.col(|ui| { + ui.strong(text(locale, "preferences-panel-flag-id")); + }); + header.col(|ui| { + ui.strong(text(locale, "preferences-panel-flag-description")); }); + header.col(|ui| { + ui.strong(text(locale, "preferences-panel-flag-enabled")); + }); + header.col(|ui| { + ui.strong(text(locale, "preferences-panel-flag-fp")) + .on_hover_text_at_pointer(text( + locale, + "preferences-panel-flag-fp-tooltip", + )); + }); + }) + .body(|mut body| { + for &flag in CompatibilityFlags::all_flags() { + let def = flag.definition(); + body.row(18.0, |mut row| { + row.col(|ui| { + if self.flags_cli.enabled(flag).is_ok() { + ui.label("🔒").on_hover_text_at_pointer(locked_text); + } else if self.flags.enabled(flag).is_ok() { + ui.label("⚠").on_hover_text_at_pointer(text( + locale, + "preferences-panel-flag-overridden", + )); + } + }); + row.col(|ui| { + ui.label(def.id); + }); + row.col(|ui| { + ui.label(def.name(locale)) + .on_hover_text_at_pointer(def.description(locale)); + }); + row.col(|ui| { + if let Ok(mut cli_value) = self.flags_cli.enabled(flag) { + ui.add_enabled_ui(false, |ui| { + ui.checkbox(&mut cli_value, ""); + }); + } else { + let orig_value = + self.flags.enabled(flag).unwrap_or_else(|default| default); + let mut value = orig_value; + ui.checkbox(&mut value, ""); + if value != orig_value { + self.flags.set(flag, value); + self.flags_changed = true; + } + } + }); + row.col(|ui| { + ui.add_enabled_ui(false, |ui| { + let mut value = flag.definition().flash_player_value; + ui.checkbox(&mut value, ""); + }); + }); + }); + } }); - }); + }); + } - keep_open && !should_close + fn show_bottom_panel(&mut self, locale: &LanguageIdentifier, ui: &mut Ui) -> bool { + let mut should_close = false; + ui.vertical_centered_justified(|ui| { + if self.restart_required() { + ui.colored_label( + ui.style().visuals.error_fg_color, + "A restart is required to apply the selected changes", + ); + } + + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if Button::new(text(locale, "save")).ui(ui).clicked() { + self.save(); + should_close = true; + } + }) + }); + }); + !should_close } fn restart_required(&self) -> bool { @@ -566,6 +713,9 @@ impl PreferencesDialog { if self.open_url_mode_changed { preferences.set_open_url_mode(self.open_url_mode); } + if self.flags_changed { + preferences.set_flags(self.flags.clone()); + } }) { // [NA] TODO: Better error handling... everywhere in desktop, really tracing::error!("Could not save preferences: {e}"); diff --git a/desktop/src/preferences.rs b/desktop/src/preferences.rs index a127b7cb7a85..0f7437f7f8cd 100644 --- a/desktop/src/preferences.rs +++ b/desktop/src/preferences.rs @@ -10,6 +10,7 @@ use crate::preferences::read::read_preferences; use crate::preferences::write::PreferencesWriter; use anyhow::{Context, Error}; use ruffle_core::backend::ui::US_ENGLISH; +use ruffle_core::flags::{CompatibilityFlag, CompatibilityFlags}; use ruffle_frontend_utils::bookmarks::{read_bookmarks, Bookmarks, BookmarksWriter}; use ruffle_frontend_utils::parse::DocumentHolder; use ruffle_frontend_utils::recents::{read_recents, Recents, RecentsWriter}; @@ -222,6 +223,30 @@ impl GlobalPreferences { }) } + pub fn flags(&self) -> CompatibilityFlags { + let mut flags = self + .preferences + .lock() + .expect("Non-poisoned preferences") + .flags + .clone(); + flags.override_with(&self.cli.flags); + flags + } + + pub fn flag_enabled(&self, flag: CompatibilityFlag) -> bool { + if let Ok(value) = self.cli.flags.enabled(flag) { + return value; + } + + self.preferences + .lock() + .expect("Non-poisoned preferences") + .flags + .enabled(flag) + .unwrap_or_else(|default| default) + } + pub fn recents(&self, fun: impl FnOnce(&Recents) -> R) -> R { fun(&self.recents.lock().expect("Recents is not reentrant")) } @@ -279,6 +304,7 @@ pub struct SavedGlobalPreferences { pub storage: StoragePreferences, pub theme_preference: ThemePreference, pub open_url_mode: OpenUrlMode, + pub flags: CompatibilityFlags, } impl Default for SavedGlobalPreferences { @@ -302,6 +328,7 @@ impl Default for SavedGlobalPreferences { storage: Default::default(), theme_preference: Default::default(), open_url_mode: Default::default(), + flags: CompatibilityFlags::empty(), } } } diff --git a/desktop/src/preferences/read.rs b/desktop/src/preferences/read.rs index 9144f51442f4..eb0a311240a2 100644 --- a/desktop/src/preferences/read.rs +++ b/desktop/src/preferences/read.rs @@ -71,6 +71,10 @@ pub fn read_preferences(input: &str) -> ParseDetails { result.open_url_mode = value; } + if let Some(value) = document.parse_from_str(&mut cx, "flags") { + result.flags = value; + } + document.get_table_like(&mut cx, "log", |cx, log| { if let Some(value) = log.parse_from_str(cx, "filename_pattern") { result.log.filename_pattern = value; diff --git a/desktop/src/preferences/write.rs b/desktop/src/preferences/write.rs index dcfb4dbdf48d..2769209507f7 100644 --- a/desktop/src/preferences/write.rs +++ b/desktop/src/preferences/write.rs @@ -3,6 +3,7 @@ use crate::gui::ThemePreference; use crate::log::FilenamePattern; use crate::preferences::storage::StorageBackend; use crate::preferences::{GlobalPreferencesWatchers, SavedGlobalPreferences}; +use ruffle_core::flags::CompatibilityFlags; use ruffle_frontend_utils::parse::DocumentHolder; use ruffle_render_wgpu::clap::{GraphicsBackend, PowerPreference}; use toml_edit::value; @@ -131,6 +132,19 @@ impl<'a> PreferencesWriter<'a> { values.open_url_mode = open_url_mode; }); } + + pub fn set_flags(&mut self, flags: CompatibilityFlags) { + self.0 + .edit(|values: &mut SavedGlobalPreferences, toml_document| { + let flags_string = flags.to_string(); + if !flags_string.is_empty() { + toml_document["flags"] = value(flags_string); + } else { + toml_document.remove("flags"); + } + values.flags = flags; + }); + } } #[cfg(test)] @@ -327,4 +341,19 @@ mod tests { "", ); } + + #[test] + #[allow(clippy::unwrap_used)] + fn set_flags() { + test( + "flags = 6\n", + |writer| writer.set_flags(CompatibilityFlags::empty()), + "", + ); + test( + "flags = \"tab_skip\"", + |writer| writer.set_flags("-tab_skip".parse::().unwrap()), + "flags = \"-tab_skip\"\n", + ); + } } diff --git a/tests/framework/src/backends/ui.rs b/tests/framework/src/backends/ui.rs index 4905098f1d08..5b22e72f6bd0 100644 --- a/tests/framework/src/backends/ui.rs +++ b/tests/framework/src/backends/ui.rs @@ -4,6 +4,7 @@ use ruffle_core::backend::ui::{ DialogLoaderError, DialogResultFuture, FileDialogResult, FileFilter, FontDefinition, FullscreenError, LanguageIdentifier, MouseCursor, UiBackend, US_ENGLISH, }; +use ruffle_core::flags::{CompatibilityFlag, CompatibilityFlags}; use url::Url; /// A simulated file dialog response, for use in tests @@ -79,13 +80,15 @@ impl FileDialogResult for TestFileDialogResult { pub struct TestUiBackend { fonts: Vec, clipboard: String, + flags: CompatibilityFlags, } impl TestUiBackend { - pub fn new(fonts: Vec) -> Self { + pub fn new(fonts: Vec, flags: CompatibilityFlags) -> Self { Self { fonts, clipboard: "".to_string(), + flags, } } } @@ -187,4 +190,8 @@ impl UiBackend for TestUiBackend { } fn close_file_dialog(&mut self) {} + + fn flag_enabled(&self, flag: CompatibilityFlag) -> bool { + self.flags.enabled(flag).unwrap_or_else(|default| default) + } } diff --git a/tests/framework/src/options.rs b/tests/framework/src/options.rs index 44f1102bd7fd..0fc593cb269d 100644 --- a/tests/framework/src/options.rs +++ b/tests/framework/src/options.rs @@ -6,6 +6,7 @@ use anyhow::{anyhow, Result}; use approx::relative_eq; use image::ImageFormat; use regex::Regex; +use ruffle_core::flags::CompatibilityFlags; use ruffle_core::tag_utils::SwfMovie; use ruffle_core::{PlayerBuilder, PlayerRuntime, ViewportDimensions}; use ruffle_render::backend::RenderBackend; @@ -31,6 +32,7 @@ pub struct TestOptions { pub log_fetch: bool, pub required_features: RequiredFeatures, pub fonts: HashMap, + pub flags: CompatibilityFlags, } impl Default for TestOptions { @@ -49,6 +51,7 @@ impl Default for TestOptions { log_fetch: false, required_features: RequiredFeatures::default(), fonts: Default::default(), + flags: CompatibilityFlags::empty(), } } } diff --git a/tests/framework/src/runner.rs b/tests/framework/src/runner.rs index c905798bed86..0a47c7ddbd03 100644 --- a/tests/framework/src/runner.rs +++ b/tests/framework/src/runner.rs @@ -11,6 +11,7 @@ use pretty_assertions::Comparison; use ruffle_core::backend::navigator::NullExecutor; use ruffle_core::events::{KeyCode, TextControlCode as RuffleTextControlCode}; use ruffle_core::events::{MouseButton as RuffleMouseButton, MouseWheelDelta}; +use ruffle_core::flags::CompatibilityFlags; use ruffle_core::limits::ExecutionLimit; use ruffle_core::tag_utils::SwfMovie; use ruffle_core::{Player, PlayerBuilder, PlayerEvent}; @@ -57,6 +58,7 @@ impl TestRunner { socket_events: Option>, renderer: Option<(Box, Box)>, viewport_dimensions: ViewportDimensions, + flags: CompatibilityFlags, ) -> Result { if test.options.num_frames.is_none() && test.options.num_ticks.is_none() { return Err(anyhow!( @@ -87,7 +89,7 @@ impl TestRunner { .with_navigator(navigator) .with_max_execution_duration(Duration::from_secs(300)) .with_fs_commands(Box::new(fs_command_provider)) - .with_ui(TestUiBackend::new(test.fonts()?)) + .with_ui(TestUiBackend::new(test.fonts()?, flags)) .with_viewport_dimensions( viewport_dimensions.width, viewport_dimensions.height, diff --git a/tests/framework/src/test.rs b/tests/framework/src/test.rs index c35ce71dec32..b9218dd0268a 100644 --- a/tests/framework/src/test.rs +++ b/tests/framework/src/test.rs @@ -60,6 +60,7 @@ impl Test { socket_events, renderer, viewport_dimensions, + self.options.flags.clone(), )?; Ok(runner) } diff --git a/tests/tests/swfs/flags/README.md b/tests/tests/swfs/flags/README.md new file mode 100644 index 000000000000..ddcce5c929d5 --- /dev/null +++ b/tests/tests/swfs/flags/README.md @@ -0,0 +1,4 @@ +# Compatibility flags tests + +Warning: Outputs for these tests does not come from Flash Player! +They are meant to test the behavior of Ruffle with flags incompatible with Flash Player. diff --git a/tests/tests/swfs/flags/tab_skip/Test.as b/tests/tests/swfs/flags/tab_skip/Test.as new file mode 100644 index 000000000000..f77d2e3aa45b --- /dev/null +++ b/tests/tests/swfs/flags/tab_skip/Test.as @@ -0,0 +1,51 @@ +package { +import flash.display.*; +import flash.text.*; +import flash.events.*; + +public class Test extends MovieClip { + private var obj1: TextField; + private var obj2: TextField; + private var obj3: TextField; + + public function Test() { + stage.scaleMode = "noScale"; + + obj1 = new TextField(); + obj1.type = "input"; + obj1.border = true; + obj1.name = "obj1"; + obj1.x = 70; + obj1.y = 10; + obj1.width = 10; + obj1.height = 10; + + obj2 = new TextField(); + obj2.type = "input"; + obj2.border = true; + obj2.name = "obj2"; + obj2.x = 10; + obj2.y = 20; + obj2.width = 10; + obj2.height = 10; + + obj3 = new TextField(); + obj3.type = "input"; + obj3.border = true; + obj3.name = "obj3"; + obj3.x = 40; + obj3.y = 40; + obj3.width = 10; + obj3.height = 10; + + stage.focus = obj1; + + for each (var obj in [obj1, obj2, obj3]) { + obj.addEventListener("focusIn", function (evt:FocusEvent):void { + trace("Focus changed: " + evt.relatedObject.name + " -> " + evt.target.name); + }); + this.stage.addChild(obj); + } + } +} +} diff --git a/tests/tests/swfs/flags/tab_skip/input.json b/tests/tests/swfs/flags/tab_skip/input.json new file mode 100644 index 000000000000..0174240affe6 --- /dev/null +++ b/tests/tests/swfs/flags/tab_skip/input.json @@ -0,0 +1,6 @@ +[ + { "type": "KeyDown", "key_code": 9 }, + { "type": "KeyDown", "key_code": 9 }, + { "type": "KeyDown", "key_code": 9 }, + { "type": "KeyDown", "key_code": 9 } +] diff --git a/tests/tests/swfs/flags/tab_skip/output.txt b/tests/tests/swfs/flags/tab_skip/output.txt new file mode 100644 index 000000000000..7bf7db3ae1c9 --- /dev/null +++ b/tests/tests/swfs/flags/tab_skip/output.txt @@ -0,0 +1,4 @@ +Focus changed: obj1 -> obj2 +Focus changed: obj2 -> obj3 +Focus changed: obj3 -> obj1 +Focus changed: obj1 -> obj2 diff --git a/tests/tests/swfs/flags/tab_skip/test.swf b/tests/tests/swfs/flags/tab_skip/test.swf new file mode 100644 index 000000000000..6894bcd25481 Binary files /dev/null and b/tests/tests/swfs/flags/tab_skip/test.swf differ diff --git a/tests/tests/swfs/flags/tab_skip/test.toml b/tests/tests/swfs/flags/tab_skip/test.toml new file mode 100644 index 000000000000..75c23f1bdb93 --- /dev/null +++ b/tests/tests/swfs/flags/tab_skip/test.toml @@ -0,0 +1,3 @@ +num_ticks = 1 + +flags = "-tab_skip"