diff --git a/Cargo.lock b/Cargo.lock index 1b0effff0..18d549e12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3696,6 +3696,7 @@ dependencies = [ "clap", "dasp", "downcast-rs", + "egui", "encoding_rs", "enumset", "flash-lso", diff --git a/core/Cargo.toml b/core/Cargo.toml index 2935ddf6d..a9e180c64 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -51,6 +51,7 @@ realfft = "3.3.0" hashbrown = { version = "0.13.2", features = ["raw"] } scopeguard = "1.1.0" fluent-templates = "0.8.0" +egui = { version = "0.22.0", optional = true } [target.'cfg(not(target_family = "wasm"))'.dependencies.futures] version = "0.3.28" diff --git a/core/src/context.rs b/core/src/context.rs index 322754e10..d29f2e29e 100644 --- a/core/src/context.rs +++ b/core/src/context.rs @@ -26,7 +26,7 @@ use crate::stub::StubCollection; use crate::tag_utils::{SwfMovie, SwfSlice}; use crate::timer::Timers; use core::fmt; -use gc_arena::{Collect, MutationContext}; +use gc_arena::{Collect, DynamicRootSet, MutationContext}; use instant::Instant; use rand::rngs::SmallRng; use ruffle_render::backend::RenderBackend; @@ -216,6 +216,9 @@ pub struct UpdateContext<'a, 'gc> { /// Manager of in-progress media streams. pub stream_manager: &'a mut StreamManager<'gc>, + + /// Dynamic root for allowing handles to GC objects to exist outside of the GC. + pub dynamic_root: &'a mut DynamicRootSet<'gc>, } /// Convenience methods for controlling audio. @@ -373,6 +376,7 @@ impl<'a, 'gc> UpdateContext<'a, 'gc> { actions_since_timeout_check: self.actions_since_timeout_check, frame_phase: self.frame_phase, stream_manager: self.stream_manager, + dynamic_root: self.dynamic_root, } } diff --git a/core/src/debug_ui.rs b/core/src/debug_ui.rs new file mode 100644 index 000000000..eacec628c --- /dev/null +++ b/core/src/debug_ui.rs @@ -0,0 +1,45 @@ +mod display_object; +mod handle; + +use crate::context::UpdateContext; +use crate::debug_ui::display_object::DisplayObjectWindow; +use crate::debug_ui::handle::DisplayObjectHandle; +use hashbrown::HashMap; + +#[derive(Default)] +pub struct DebugUi { + display_objects: HashMap, + queued_messages: Vec, +} + +#[derive(Debug)] +pub enum Message { + TrackDisplayObject(DisplayObjectHandle), + TrackStage, +} + +impl DebugUi { + pub fn show(&mut self, egui_ctx: &egui::Context, context: &mut UpdateContext) { + let mut messages = std::mem::take(&mut self.queued_messages); + self.display_objects.retain(|object, window| { + let object = object.fetch(context); + window.show(egui_ctx, context, object, &mut messages) + }); + for message in messages { + match message { + Message::TrackDisplayObject(object) => self.track_display_object(object), + Message::TrackStage => { + self.track_display_object(DisplayObjectHandle::new(context, context.stage)) + } + } + } + } + + pub fn queue_message(&mut self, message: Message) { + self.queued_messages.push(message); + } + + pub fn track_display_object(&mut self, handle: DisplayObjectHandle) { + self.display_objects.insert(handle, Default::default()); + } +} diff --git a/core/src/debug_ui/display_object.rs b/core/src/debug_ui/display_object.rs new file mode 100644 index 000000000..8438e15af --- /dev/null +++ b/core/src/debug_ui/display_object.rs @@ -0,0 +1,233 @@ +use crate::context::UpdateContext; +use crate::debug_ui::handle::DisplayObjectHandle; +use crate::debug_ui::Message; +use crate::display_object::{DisplayObject, TDisplayObject, TDisplayObjectContainer}; +use egui::{CollapsingHeader, ComboBox, Grid, Id, Ui, Window}; +use std::borrow::Cow; +use swf::BlendMode; + +const ALL_BLEND_MODES: [BlendMode; 14] = [ + BlendMode::Normal, + BlendMode::Layer, + BlendMode::Multiply, + BlendMode::Screen, + BlendMode::Lighten, + BlendMode::Darken, + BlendMode::Difference, + BlendMode::Add, + BlendMode::Subtract, + BlendMode::Invert, + BlendMode::Alpha, + BlendMode::Erase, + BlendMode::Overlay, + BlendMode::HardLight, +]; + +#[derive(Debug, Eq, PartialEq, Hash, Default, Copy, Clone)] +enum Panel { + #[default] + Position, + Display, + Children, +} + +#[derive(Debug, Default)] +pub struct DisplayObjectWindow { + open_panel: Panel, +} + +impl DisplayObjectWindow { + pub fn show<'gc>( + &mut self, + egui_ctx: &egui::Context, + context: &mut UpdateContext<'_, 'gc>, + object: DisplayObject<'gc>, + messages: &mut Vec, + ) -> bool { + let mut keep_open = true; + Window::new(summary_name(object)) + .id(Id::new(object.as_ptr())) + .open(&mut keep_open) + .show(egui_ctx, |ui| { + ui.horizontal(|ui| { + ui.selectable_value(&mut self.open_panel, Panel::Position, "Position"); + ui.selectable_value(&mut self.open_panel, Panel::Display, "Display"); + if let Some(ctr) = object.as_container() { + if !ctr.is_empty() { + ui.selectable_value( + &mut self.open_panel, + Panel::Children, + format!("Children ({})", ctr.num_children()), + ); + } + } + }); + ui.separator(); + + match self.open_panel { + Panel::Position => self.show_position(ui, object), + Panel::Display => self.show_display(ui, context, object, messages), + Panel::Children => self.show_children(ui, context, object, messages), + } + }); + keep_open + } + + pub fn show_display<'gc>( + &mut self, + ui: &mut Ui, + context: &mut UpdateContext<'_, 'gc>, + object: DisplayObject<'gc>, + messages: &mut Vec, + ) { + Grid::new(ui.id().with("display")) + .num_columns(2) + .show(ui, |ui| { + if let Some(parent) = object.parent() { + ui.label("Parent"); + if ui.button(summary_name(parent)).clicked() { + messages.push(Message::TrackDisplayObject(DisplayObjectHandle::new( + context, parent, + ))); + } + ui.end_row(); + } + + let was_visible = object.visible(); + let mut is_visible = was_visible; + ui.label("Visibility"); + ui.checkbox(&mut is_visible, "Visible"); + ui.end_row(); + if is_visible != was_visible { + object.set_visible(context.gc_context, is_visible); + } + + ui.label("Blend mode"); + let old_blend = object.blend_mode(); + let mut new_blend = old_blend; + ComboBox::from_id_source(ui.id().with("blendmode")) + .selected_text(blend_mode_name(old_blend)) + .show_ui(ui, |ui| { + for mode in ALL_BLEND_MODES { + ui.selectable_value(&mut new_blend, mode, blend_mode_name(mode)); + } + }); + ui.end_row(); + if new_blend != old_blend { + object.set_blend_mode(context.gc_context, new_blend); + } + }); + } + + pub fn show_position(&mut self, ui: &mut Ui, object: DisplayObject<'_>) { + Grid::new(ui.id().with("position")) + .num_columns(2) + .show(ui, |ui| { + ui.label("Name"); + // &mut of a temporary thing because we don't want to actually be able to change this + // If we disable it, the user can't highlight or interact with it, so this makes it readonly but enabled + ui.text_edit_singleline(&mut object.name().to_string()); + ui.end_row(); + + ui.label("World Bounds"); + ui.label(object.world_bounds().to_string()); + ui.end_row(); + + ui.label("Local Bounds"); + ui.label(object.local_bounds().to_string()); + ui.end_row(); + + let base = object.base(); + let matrix = base.matrix(); + ui.label("Local Position"); + ui.label(format!("{}, {}", matrix.tx, matrix.ty)); + ui.end_row(); + + ui.label("Local Rotation"); + ui.label(format!("{}, {}", matrix.b, matrix.c)); + ui.end_row(); + + ui.label("Local Scale"); + ui.label(format!("{}, {}", matrix.a, matrix.d)); + ui.end_row(); + }); + } + + pub fn show_children<'gc>( + &mut self, + ui: &mut Ui, + context: &mut UpdateContext<'_, 'gc>, + object: DisplayObject<'gc>, + messages: &mut Vec, + ) { + if let Some(ctr) = object.as_container() { + for child in ctr.iter_render_list() { + show_display_tree(ui, context, child, messages); + } + } + } +} + +pub fn show_display_tree<'gc>( + ui: &mut Ui, + context: &mut UpdateContext<'_, 'gc>, + object: DisplayObject<'gc>, + messages: &mut Vec, +) { + CollapsingHeader::new(summary_name(object)) + .id_source(ui.id().with(object.as_ptr())) + .show(ui, |ui| { + if ui.button("Track").clicked() { + messages.push(Message::TrackDisplayObject(DisplayObjectHandle::new( + context, object, + ))); + } + if let Some(ctr) = object.as_container() { + for child in ctr.iter_render_list() { + show_display_tree(ui, context, child, messages); + } + } + }); +} + +fn summary_name(object: DisplayObject) -> Cow { + let do_type = match object { + DisplayObject::Stage(_) => "Stage", + DisplayObject::Bitmap(_) => "Bitmap", + DisplayObject::Avm1Button(_) => "Avm1Button", + DisplayObject::Avm2Button(_) => "Avm2Button", + DisplayObject::EditText(_) => "EditText", + DisplayObject::Graphic(_) => "Graphic", + DisplayObject::MorphShape(_) => "MorphShape", + DisplayObject::MovieClip(_) => "MovieClip", + DisplayObject::Text(_) => "Text", + DisplayObject::Video(_) => "Video", + DisplayObject::LoaderDisplay(_) => "LoaderDisplay", + }; + + let name = object.name(); + if name.is_empty() { + Cow::Borrowed(do_type) + } else { + Cow::Owned(format!("{do_type} \"{name}\"")) + } +} + +fn blend_mode_name(mode: BlendMode) -> &'static str { + match mode { + BlendMode::Normal => "Normal", + BlendMode::Layer => "Layer", + BlendMode::Multiply => "Multiply", + BlendMode::Screen => "Screen", + BlendMode::Lighten => "Lighten", + BlendMode::Darken => "Darken", + BlendMode::Difference => "Difference", + BlendMode::Add => "Add", + BlendMode::Subtract => "Subtract", + BlendMode::Invert => "Invert", + BlendMode::Alpha => "Alpha", + BlendMode::Erase => "Erase", + BlendMode::Overlay => "Overlay", + BlendMode::HardLight => "HardLight", + } +} diff --git a/core/src/debug_ui/handle.rs b/core/src/debug_ui/handle.rs new file mode 100644 index 000000000..688553cc1 --- /dev/null +++ b/core/src/debug_ui/handle.rs @@ -0,0 +1,52 @@ +use crate::context::UpdateContext; +use crate::display_object::{DisplayObject, DisplayObjectPtr, TDisplayObject}; +use gc_arena::{DynamicRoot, Rootable}; +use std::fmt::{Debug, Formatter}; +use std::hash::{Hash, Hasher}; + +// TODO: Make this generic somehow, we'll want AVM1 and AVM2 object handles too +#[derive(Clone)] +pub struct DisplayObjectHandle { + root: DynamicRoot]>, + ptr: *const DisplayObjectPtr, +} + +impl DisplayObjectHandle { + pub fn new<'gc>( + context: &mut UpdateContext<'_, 'gc>, + object: impl Into>, + ) -> Self { + let object = object.into(); + Self { + root: context.dynamic_root.stash(context.gc_context, object), + ptr: object.as_ptr(), + } + } + + pub fn fetch<'gc>(&self, context: &mut UpdateContext<'_, 'gc>) -> DisplayObject<'gc> { + *context.dynamic_root.fetch(&self.root) + } +} + +impl Debug for DisplayObjectHandle { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("DisplayObjectHandle") + .field(&self.ptr) + .finish() + } +} + +impl PartialEq for DisplayObjectHandle { + #[inline(always)] + fn eq(&self, other: &DisplayObjectHandle) -> bool { + self.ptr == other.ptr + } +} + +impl Hash for DisplayObjectHandle { + fn hash(&self, state: &mut H) { + self.ptr.hash(state); + } +} + +impl Eq for DisplayObjectHandle {} diff --git a/core/src/display_object.rs b/core/src/display_object.rs index c7f6524a7..b1a83b750 100644 --- a/core/src/display_object.rs +++ b/core/src/display_object.rs @@ -17,6 +17,7 @@ use ruffle_macros::enum_trait_object; use ruffle_render::transform::Transform; use std::cell::{Ref, RefMut}; use std::fmt::Debug; +use std::hash::Hash; use std::sync::Arc; use swf::{BlendMode, ColorTransform, Fixed8}; diff --git a/core/src/lib.rs b/core/src/lib.rs index 18e8e8071..7c78c10ba 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -48,6 +48,8 @@ mod xml; pub mod backend; pub mod compatibility_rules; pub mod config; +#[cfg(feature = "egui")] +pub mod debug_ui; pub mod external; pub mod i18n; pub mod stub; diff --git a/core/src/player.rs b/core/src/player.rs index 1e4572d80..9d1efc482 100644 --- a/core/src/player.rs +++ b/core/src/player.rs @@ -46,7 +46,7 @@ use crate::stub::StubCollection; use crate::tag_utils::SwfMovie; use crate::timer::Timers; use crate::vminterface::Instantiator; -use gc_arena::{ArenaParameters, Collect, GcCell}; +use gc_arena::{ArenaParameters, Collect, DynamicRootSet, GcCell}; use gc_arena::{MutationContext, Rootable}; use instant::Instant; use rand::{rngs::SmallRng, SeedableRng}; @@ -160,6 +160,9 @@ struct GcRootData<'gc> { /// List of actively playing streams to decode. stream_manager: StreamManager<'gc>, + + /// Dynamic root for allowing handles to GC objects to exist outside of the GC. + dynamic_root: DynamicRootSet<'gc>, } impl<'gc> GcRootData<'gc> { @@ -185,6 +188,7 @@ impl<'gc> GcRootData<'gc> { &mut ExternalInterface<'gc>, &mut AudioManager<'gc>, &mut StreamManager<'gc>, + &mut DynamicRootSet<'gc>, ) { ( self.stage, @@ -203,6 +207,7 @@ impl<'gc> GcRootData<'gc> { &mut self.external_interface, &mut self.audio_manager, &mut self.stream_manager, + &mut self.dynamic_root, ) } } @@ -313,6 +318,10 @@ pub struct Player { /// Any compatibility rules to apply for this movie. compatibility_rules: CompatibilityRules, + + /// Debug UI windows + #[cfg(feature = "egui")] + debug_ui: Rc>, } impl Player { @@ -1750,6 +1759,7 @@ impl Player { external_interface, audio_manager, stream_manager, + dynamic_root, ) = root_data.update_context_params(); let mut update_context = UpdateContext { @@ -1799,6 +1809,7 @@ impl Player { frame_phase: &mut self.frame_phase, stub_tracker: &mut self.stub_tracker, stream_manager, + dynamic_root, }; let prev_frame_rate = *update_context.frame_rate; @@ -1845,6 +1856,23 @@ impl Player { ) } + #[cfg(feature = "egui")] + pub fn show_debug_ui(&mut self, egui_ctx: &egui::Context) { + // To allow using `mutate_with_update_context` and passing the context inside the debug ui, + // we avoid borrowing directly from self here. + // This method should only be called once and it will panic if it tries to recursively render. + let debug_ui = self.debug_ui.clone(); + let mut debug_ui = debug_ui.borrow_mut(); + self.mutate_with_update_context(|context| { + debug_ui.show(egui_ctx, context); + }); + } + + #[cfg(feature = "egui")] + pub fn debug_ui_message(&mut self, message: crate::debug_ui::Message) { + self.debug_ui.borrow_mut().queue_message(message) + } + /// Update the current state of the player. /// /// The given function will be called with the current stage root, current @@ -2203,6 +2231,7 @@ impl PlayerBuilder { gc_context, interner: &mut interner, }; + let dynamic_root = DynamicRootSet::new(gc_context); GcRoot { callstack: GcCell::allocate(gc_context, GcCallstack::default()), @@ -2228,6 +2257,7 @@ impl PlayerBuilder { timers: Timers::new(), unbound_text_fields: Vec::new(), stream_manager: StreamManager::new(), + dynamic_root, }, ), } @@ -2317,6 +2347,8 @@ impl PlayerBuilder { spoofed_url: self.spoofed_url.clone(), compatibility_rules: self.compatibility_rules.clone(), stub_tracker: StubCollection::new(), + #[cfg(feature = "egui")] + debug_ui: Default::default(), // GC data gc_arena: Rc::new(RefCell::new(GcArena::new( diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 6cf3422cb..8a771c331 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -13,7 +13,7 @@ cpal = "0.15.2" egui = "0.22.0" egui-wgpu = { version = "0.22.0", features = ["winit"] } egui-winit = "0.22.0" -ruffle_core = { path = "../core", features = ["audio", "clap", "mp3", "nellymoser", "default_compatibility_rules"] } +ruffle_core = { path = "../core", features = ["audio", "clap", "mp3", "nellymoser", "default_compatibility_rules", "egui"] } ruffle_render = { path = "../render", features = ["clap"] } ruffle_render_wgpu = { path = "../render/wgpu", features = ["clap"] } ruffle_video_software = { path = "../video/software", optional = true } diff --git a/desktop/assets/texts/en-US/main_menu.ftl b/desktop/assets/texts/en-US/main_menu.ftl index fb16cdcda..7c5663cbb 100644 --- a/desktop/assets/texts/en-US/main_menu.ftl +++ b/desktop/assets/texts/en-US/main_menu.ftl @@ -22,4 +22,7 @@ help-menu-join-discord = Join Discord help-menu-report-a-bug = Report a Bug... help-menu-sponsor-development = Sponsor Development... help-menu-translate-ruffle = Translate Ruffle... -help-menu-about = About Ruffle \ No newline at end of file +help-menu-about = About Ruffle + +debug-menu = Debug Tools +debug-menu-open-stage = View Stage Info \ No newline at end of file diff --git a/desktop/src/gui.rs b/desktop/src/gui.rs index a3d173495..433e1e5fa 100644 --- a/desktop/src/gui.rs +++ b/desktop/src/gui.rs @@ -14,6 +14,7 @@ use egui::*; use fluent_templates::fluent_bundle::FluentValue; use fluent_templates::{static_loader, Loader}; use ruffle_core::backend::ui::US_ENGLISH; +use ruffle_core::debug_ui::Message as DebugMessage; use ruffle_core::Player; use std::collections::HashMap; use sys_locale::get_locale; @@ -105,6 +106,10 @@ impl RuffleGui { self.as3_warning(egui_ctx); + if let Some(player) = player { + player.show_debug_ui(egui_ctx); + } + if !self.context_menu.is_empty() { self.context_menu(egui_ctx); } @@ -194,6 +199,16 @@ impl RuffleGui { } }); }); + menu::menu_button(ui, text(&self.locale, "debug-menu"), |ui| { + ui.add_enabled_ui(player.is_some(), |ui| { + if Button::new(text(&self.locale, "debug-menu-open-stage")).ui(ui).clicked() { + ui.close_menu(); + if let Some(player) = player { + player.debug_ui_message(DebugMessage::TrackStage); + } + } + }); + }); menu::menu_button(ui, text(&self.locale, "help-menu"), |ui| { if ui.button(text(&self.locale, "help-menu-join-discord")).clicked() { self.launch_website(ui, "https://discord.gg/ruffle"); diff --git a/swf/src/types/rectangle.rs b/swf/src/types/rectangle.rs index 440a92ce5..0c904dd49 100644 --- a/swf/src/types/rectangle.rs +++ b/swf/src/types/rectangle.rs @@ -1,4 +1,5 @@ use crate::{types::point::Coordinate as PointCoordinate, Point, Twips}; +use std::fmt::{Display, Formatter}; pub trait Coordinate: PointCoordinate + Ord { const INVALID: Self; @@ -127,3 +128,16 @@ impl Default for Rectangle { Self::INVALID } } + +impl Display for Rectangle +where + T: Display + Coordinate, +{ + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}, {} to {}, {}", + self.x_min, self.y_min, self.x_max, self.y_max + ) + } +}