diff --git a/core/src/avm1/color_transform_object.rs b/core/src/avm1/color_transform_object.rs index 91caf87ca..790991274 100644 --- a/core/src/avm1/color_transform_object.rs +++ b/core/src/avm1/color_transform_object.rs @@ -229,6 +229,21 @@ impl<'gc> TObject<'gc> for ColorTransformObject<'gc> { .add_property_with_case(activation, gc_context, name, get, set, attributes) } + fn set_watcher( + &self, + gc_context: MutationContext<'gc, '_>, + name: Cow, + callback: Executable<'gc>, + user_data: Value<'gc>, + ) { + self.base() + .set_watcher(gc_context, name, callback, user_data); + } + + fn remove_watcher(&self, gc_context: MutationContext<'gc, '_>, name: Cow) -> bool { + self.base().remove_watcher(gc_context, name) + } + fn has_property( &self, activation: &mut Activation<'_, 'gc>, diff --git a/core/src/avm1/function.rs b/core/src/avm1/function.rs index 6b862d10a..03f8324f5 100644 --- a/core/src/avm1/function.rs +++ b/core/src/avm1/function.rs @@ -624,6 +624,20 @@ impl<'gc> TObject<'gc> for FunctionObject<'gc> { .add_property_with_case(activation, gc_context, name, get, set, attributes) } + fn set_watcher( + &self, + gc_context: MutationContext<'gc, '_>, + name: Cow, + callback: Executable<'gc>, + user_data: Value<'gc>, + ) { + self.base.set_watcher(gc_context, name, callback, user_data); + } + + fn remove_watcher(&self, gc_context: MutationContext<'gc, '_>, name: Cow) -> bool { + self.base.remove_watcher(gc_context, name) + } + fn has_property( &self, activation: &mut Activation<'_, 'gc>, diff --git a/core/src/avm1/globals/object.rs b/core/src/avm1/globals/object.rs index c8d0a8462..f0611552c 100644 --- a/core/src/avm1/globals/object.rs +++ b/core/src/avm1/globals/object.rs @@ -162,6 +162,55 @@ pub fn register_class<'gc>( Ok(Value::Undefined) } +/// Implements `Object.prototype.watch` +fn watch<'gc>( + activation: &mut Activation<'_, 'gc>, + context: &mut UpdateContext<'_, 'gc, '_>, + this: Object<'gc>, + args: &[Value<'gc>], +) -> Result, Error<'gc>> { + let name = if let Some(name) = args.get(0) { + name.coerce_to_string(activation, context)? + } else { + return Ok(false.into()); + }; + let callback = if let Some(callback) = args.get(1) { + if let Some(callback) = callback + .coerce_to_object(activation, context) + .as_executable() + { + callback + } else { + return Ok(false.into()); + } + } else { + return Ok(false.into()); + }; + let user_data = args.get(2).cloned().unwrap_or(Value::Undefined); + + this.set_watcher(context.gc_context, name, callback, user_data); + + Ok(true.into()) +} + +/// Implements `Object.prototype.unmwatch` +fn unwatch<'gc>( + activation: &mut Activation<'_, 'gc>, + context: &mut UpdateContext<'_, 'gc, '_>, + this: Object<'gc>, + args: &[Value<'gc>], +) -> Result, Error<'gc>> { + let name = if let Some(name) = args.get(0) { + name.coerce_to_string(activation, context)? + } else { + return Ok(false.into()); + }; + + let result = this.remove_watcher(context.gc_context, name); + + Ok(result.into()) +} + /// Partially construct `Object.prototype`. /// /// `__proto__` and other cross-linked properties of this object will *not* @@ -218,6 +267,20 @@ pub fn fill_proto<'gc>( DontDelete | DontEnum, Some(fn_proto), ); + object_proto.as_script_object().unwrap().force_set_function( + "watch", + watch, + gc_context, + DontDelete | DontEnum, + Some(fn_proto), + ); + object_proto.as_script_object().unwrap().force_set_function( + "unwatch", + unwatch, + gc_context, + DontDelete | DontEnum, + Some(fn_proto), + ); } /// Implements `ASSetPropFlags`. diff --git a/core/src/avm1/object.rs b/core/src/avm1/object.rs index 399033f9c..22d1784e9 100644 --- a/core/src/avm1/object.rs +++ b/core/src/avm1/object.rs @@ -259,6 +259,23 @@ pub trait TObject<'gc>: 'gc + Collect + Debug + Into> + Clone + Copy attributes: EnumSet, ); + /// Set the 'watcher' of a given property. + /// + /// The property does not need to exist at the time of this being called. + fn set_watcher( + &self, + gc_context: MutationContext<'gc, '_>, + name: Cow, + callback: Executable<'gc>, + user_data: Value<'gc>, + ); + + /// Removed any assigned 'watcher' from the given property. + /// + /// The return value will indicate if there was a watcher present before this method was + /// called. + fn remove_watcher(&self, gc_context: MutationContext<'gc, '_>, name: Cow) -> bool; + /// Checks if the object has a given named property. fn has_property( &self, diff --git a/core/src/avm1/script_object.rs b/core/src/avm1/script_object.rs index 6a71d7c45..bbdc2400b 100644 --- a/core/src/avm1/script_object.rs +++ b/core/src/avm1/script_object.rs @@ -8,6 +8,7 @@ use core::fmt; use enumset::EnumSet; use gc_arena::{Collect, GcCell, MutationContext}; use std::borrow::Cow; +use std::collections::HashMap; pub const TYPE_OF_OBJECT: &str = "object"; @@ -18,6 +19,50 @@ pub enum ArrayStorage<'gc> { Properties { length: usize }, } +#[derive(Debug, Clone, Collect)] +#[collect(no_drop)] +pub struct Watcher<'gc> { + callback: Executable<'gc>, + user_data: Value<'gc>, +} + +impl<'gc> Watcher<'gc> { + pub fn new(callback: Executable<'gc>, user_data: Value<'gc>) -> Self { + Self { + callback, + user_data, + } + } + + #[allow(clippy::too_many_arguments)] + pub fn call( + &self, + activation: &mut Activation<'_, 'gc>, + context: &mut UpdateContext<'_, 'gc, '_>, + name: &str, + old_value: Value<'gc>, + new_value: Value<'gc>, + this: Object<'gc>, + base_proto: Option>, + ) -> Result, crate::avm1::error::Error<'gc>> { + let args = [ + Value::String(name.to_string()), + old_value, + new_value, + self.user_data.clone(), + ]; + self.callback.exec( + name, + activation, + context, + this, + base_proto, + &args, + ExecutionReason::Special, + ) + } +} + #[derive(Debug, Copy, Clone, Collect)] #[collect(no_drop)] pub struct ScriptObject<'gc>(GcCell<'gc, ScriptObjectData<'gc>>); @@ -28,6 +73,7 @@ pub struct ScriptObjectData<'gc> { interfaces: Vec>, type_of: &'static str, array: ArrayStorage<'gc>, + watchers: HashMap>, } unsafe impl<'gc> Collect for ScriptObjectData<'gc> { @@ -36,6 +82,7 @@ unsafe impl<'gc> Collect for ScriptObjectData<'gc> { self.values.trace(cc); self.array.trace(cc); self.interfaces.trace(cc); + self.watchers.trace(cc); } } @@ -45,6 +92,7 @@ impl fmt::Debug for ScriptObjectData<'_> { .field("prototype", &self.prototype) .field("values", &self.values) .field("array", &self.array) + .field("watchers", &self.watchers) .finish() } } @@ -62,6 +110,7 @@ impl<'gc> ScriptObject<'gc> { values: PropertyMap::new(), array: ArrayStorage::Properties { length: 0 }, interfaces: vec![], + watchers: HashMap::new(), }, )) } @@ -78,6 +127,7 @@ impl<'gc> ScriptObject<'gc> { values: PropertyMap::new(), array: ArrayStorage::Vector(Vec::new()), interfaces: vec![], + watchers: HashMap::new(), }, )); object.sync_native_property("length", gc_context, Some(0.into()), false); @@ -97,6 +147,7 @@ impl<'gc> ScriptObject<'gc> { values: PropertyMap::new(), array: ArrayStorage::Properties { length: 0 }, interfaces: vec![], + watchers: HashMap::new(), }, )) .into() @@ -116,6 +167,7 @@ impl<'gc> ScriptObject<'gc> { values: PropertyMap::new(), array: ArrayStorage::Properties { length: 0 }, interfaces: vec![], + watchers: HashMap::new(), }, )) } @@ -191,7 +243,7 @@ impl<'gc> ScriptObject<'gc> { pub(crate) fn internal_set( &self, name: &str, - value: Value<'gc>, + mut value: Value<'gc>, activation: &mut Activation<'_, 'gc>, context: &mut UpdateContext<'_, 'gc, '_>, this: Object<'gc>, @@ -257,6 +309,28 @@ impl<'gc> ScriptObject<'gc> { //we'd resolve and return up there, but we have borrows that need //to end before we can do so. if !worked { + let watcher = self.0.read().watchers.get(name).cloned(); + let mut return_value = Ok(()); + if let Some(watcher) = watcher { + let old_value = self.get(name, activation, context)?; + value = match watcher.call( + activation, + context, + name, + old_value, + value.clone(), + this, + base_proto, + ) { + Ok(value) => value, + Err(Error::ThrownValue(error)) => { + return_value = Err(Error::ThrownValue(error)); + Value::Undefined + } + Err(_) => Value::Undefined, + }; + } + let rval = match self .0 .write(context.gc_context) @@ -285,6 +359,8 @@ impl<'gc> ScriptObject<'gc> { ExecutionReason::Special, ); } + + return return_value; } } @@ -479,6 +555,24 @@ impl<'gc> TObject<'gc> for ScriptObject<'gc> { ); } + fn set_watcher( + &self, + gc_context: MutationContext<'gc, '_>, + name: Cow, + callback: Executable<'gc>, + user_data: Value<'gc>, + ) { + self.0 + .write(gc_context) + .watchers + .insert(name.to_string(), Watcher::new(callback, user_data)); + } + + fn remove_watcher(&self, gc_context: MutationContext<'gc, '_>, name: Cow) -> bool { + let old = self.0.write(gc_context).watchers.remove(name.as_ref()); + old.is_some() + } + fn define_value( &self, gc_context: MutationContext<'gc, '_>, diff --git a/core/src/avm1/shared_object.rs b/core/src/avm1/shared_object.rs index 030bf7500..92b5f5e1a 100644 --- a/core/src/avm1/shared_object.rs +++ b/core/src/avm1/shared_object.rs @@ -191,6 +191,21 @@ impl<'gc> TObject<'gc> for SharedObject<'gc> { .add_property_with_case(activation, gc_context, name, get, set, attributes) } + fn set_watcher( + &self, + gc_context: MutationContext<'gc, '_>, + name: Cow, + callback: Executable<'gc>, + user_data: Value<'gc>, + ) { + self.base() + .set_watcher(gc_context, name, callback, user_data); + } + + fn remove_watcher(&self, gc_context: MutationContext<'gc, '_>, name: Cow) -> bool { + self.base().remove_watcher(gc_context, name) + } + fn has_property( &self, activation: &mut Activation<'_, 'gc>, diff --git a/core/src/avm1/sound_object.rs b/core/src/avm1/sound_object.rs index 2661fdbbb..fa8627e39 100644 --- a/core/src/avm1/sound_object.rs +++ b/core/src/avm1/sound_object.rs @@ -251,6 +251,21 @@ impl<'gc> TObject<'gc> for SoundObject<'gc> { .add_property_with_case(activation, gc_context, name, get, set, attributes) } + fn set_watcher( + &self, + gc_context: MutationContext<'gc, '_>, + name: Cow, + callback: Executable<'gc>, + user_data: Value<'gc>, + ) { + self.base() + .set_watcher(gc_context, name, callback, user_data); + } + + fn remove_watcher(&self, gc_context: MutationContext<'gc, '_>, name: Cow) -> bool { + self.base().remove_watcher(gc_context, name) + } + fn has_property( &self, activation: &mut Activation<'_, 'gc>, diff --git a/core/src/avm1/stage_object.rs b/core/src/avm1/stage_object.rs index ec1b03940..fd0282f69 100644 --- a/core/src/avm1/stage_object.rs +++ b/core/src/avm1/stage_object.rs @@ -332,6 +332,23 @@ impl<'gc> TObject<'gc> for StageObject<'gc> { .add_property_with_case(activation, gc_context, name, get, set, attributes) } + fn set_watcher( + &self, + gc_context: MutationContext<'gc, '_>, + name: Cow, + callback: Executable<'gc>, + user_data: Value<'gc>, + ) { + self.0 + .read() + .base + .set_watcher(gc_context, name, callback, user_data); + } + + fn remove_watcher(&self, gc_context: MutationContext<'gc, '_>, name: Cow) -> bool { + self.0.read().base.remove_watcher(gc_context, name) + } + fn has_property( &self, activation: &mut Activation<'_, 'gc>, diff --git a/core/src/avm1/super_object.rs b/core/src/avm1/super_object.rs index 40c3a538f..156fe6022 100644 --- a/core/src/avm1/super_object.rs +++ b/core/src/avm1/super_object.rs @@ -237,6 +237,21 @@ impl<'gc> TObject<'gc> for SuperObject<'gc> { //`super` cannot have properties defined on it } + fn set_watcher( + &self, + _gc_context: MutationContext<'gc, '_>, + _name: Cow, + _callback: Executable<'gc>, + _user_data: Value<'gc>, + ) { + //`super` cannot have properties defined on it + } + + fn remove_watcher(&self, _gc_context: MutationContext<'gc, '_>, _name: Cow) -> bool { + //`super` cannot have properties defined on it + false + } + fn has_property( &self, activation: &mut Activation<'_, 'gc>, diff --git a/core/src/avm1/value_object.rs b/core/src/avm1/value_object.rs index ea7285164..a6ddacad8 100644 --- a/core/src/avm1/value_object.rs +++ b/core/src/avm1/value_object.rs @@ -231,6 +231,26 @@ impl<'gc> TObject<'gc> for ValueObject<'gc> { .add_property_with_case(activation, gc_context, name, get, set, attributes) } + fn set_watcher( + &self, + gc_context: MutationContext<'gc, '_>, + name: Cow, + callback: Executable<'gc>, + user_data: Value<'gc>, + ) { + self.0 + .read() + .base + .set_watcher(gc_context, name, callback, user_data); + } + + fn remove_watcher(&self, gc_context: MutationContext<'gc, '_>, name: Cow) -> bool { + self.0 + .write(gc_context) + .base + .remove_watcher(gc_context, name) + } + fn define_value( &self, gc_context: MutationContext<'gc, '_>, diff --git a/core/src/avm1/xml_attributes_object.rs b/core/src/avm1/xml_attributes_object.rs index 08d4b979c..fe06c8998 100644 --- a/core/src/avm1/xml_attributes_object.rs +++ b/core/src/avm1/xml_attributes_object.rs @@ -157,6 +157,21 @@ impl<'gc> TObject<'gc> for XMLAttributesObject<'gc> { .add_property_with_case(activation, gc_context, name, get, set, attributes) } + fn set_watcher( + &self, + gc_context: MutationContext<'gc, '_>, + name: Cow, + callback: Executable<'gc>, + user_data: Value<'gc>, + ) { + self.base() + .set_watcher(gc_context, name, callback, user_data); + } + + fn remove_watcher(&self, gc_context: MutationContext<'gc, '_>, name: Cow) -> bool { + self.base().remove_watcher(gc_context, name) + } + fn define_value( &self, gc_context: MutationContext<'gc, '_>, diff --git a/core/src/avm1/xml_idmap_object.rs b/core/src/avm1/xml_idmap_object.rs index 8379c01aa..b53eeeae9 100644 --- a/core/src/avm1/xml_idmap_object.rs +++ b/core/src/avm1/xml_idmap_object.rs @@ -155,6 +155,21 @@ impl<'gc> TObject<'gc> for XMLIDMapObject<'gc> { .add_property_with_case(activation, gc_context, name, get, set, attributes) } + fn set_watcher( + &self, + gc_context: MutationContext<'gc, '_>, + name: Cow, + callback: Executable<'gc>, + user_data: Value<'gc>, + ) { + self.base() + .set_watcher(gc_context, name, callback, user_data); + } + + fn remove_watcher(&self, gc_context: MutationContext<'gc, '_>, name: Cow) -> bool { + self.base().remove_watcher(gc_context, name) + } + fn define_value( &self, gc_context: MutationContext<'gc, '_>, diff --git a/core/src/avm1/xml_object.rs b/core/src/avm1/xml_object.rs index ea2c0fb59..737d55702 100644 --- a/core/src/avm1/xml_object.rs +++ b/core/src/avm1/xml_object.rs @@ -145,6 +145,21 @@ impl<'gc> TObject<'gc> for XMLObject<'gc> { .add_property_with_case(activation, gc_context, name, get, set, attributes) } + fn set_watcher( + &self, + gc_context: MutationContext<'gc, '_>, + name: Cow, + callback: Executable<'gc>, + user_data: Value<'gc>, + ) { + self.base() + .set_watcher(gc_context, name, callback, user_data); + } + + fn remove_watcher(&self, gc_context: MutationContext<'gc, '_>, name: Cow) -> bool { + self.base().remove_watcher(gc_context, name) + } + fn define_value( &self, gc_context: MutationContext<'gc, '_>, diff --git a/core/tests/regression_tests.rs b/core/tests/regression_tests.rs index 56b8848ab..c754d0024 100644 --- a/core/tests/regression_tests.rs +++ b/core/tests/regression_tests.rs @@ -193,6 +193,8 @@ swf_tests! { (loadvariables_method, "avm1/loadvariables_method", 3), (xml_load, "avm1/xml_load", 1), (with_return, "avm1/with_return", 1), + (watch, "avm1/watch", 1), + #[ignore] (watch_virtual_property, "avm1/watch_virtual_property", 1), (cross_movie_root, "avm1/cross_movie_root", 5), (roots_and_levels, "avm1/roots_and_levels", 1), (swf6_case_insensitive, "avm1/swf6_case_insensitive", 1), diff --git a/core/tests/swfs/avm1/watch/LoggingWatcher.as b/core/tests/swfs/avm1/watch/LoggingWatcher.as new file mode 100644 index 000000000..b2567ad61 --- /dev/null +++ b/core/tests/swfs/avm1/watch/LoggingWatcher.as @@ -0,0 +1,25 @@ +class LoggingWatcher { + var count = 0; + var value = true; + + function LoggingWatcher() { + trace("// this.watch(\"value\", this.log)"); + this.watch("value", this.log); + } + + function log(property, oldValue, newValue, bounds) { + var userdata; + if (typeof bounds === "object") { + userdata = "{ "; + for (var key in bounds) { + userdata += key + "=" + bounds[key] + " "; + } + userdata += "}"; + } else { + userdata = bounds; + } + this.count++; + trace("LoggingWatcher count " + this.count + ": " + property + " changed from " + oldValue + " to " + newValue + " with userdata " + userdata); + return newValue; + } +} \ No newline at end of file diff --git a/core/tests/swfs/avm1/watch/output.txt b/core/tests/swfs/avm1/watch/output.txt new file mode 100644 index 000000000..61c8c2521 --- /dev/null +++ b/core/tests/swfs/avm1/watch/output.txt @@ -0,0 +1,118 @@ +// watch() +false + +// watch("variable") +false + +// watch("variable", true) +false + +// watch("variable", clamper, {min: 5, max: 10}) +true + +// variable = 5 +Clamper: variable changed from undefined to 5 with userdata { min=5 max=10 } +// variable +5 + +// variable = 10 +Clamper: variable changed from 5 to 10 with userdata { min=5 max=10 } +// variable +10 + +// variable = 4 +Clamper: variable changed from 10 to 4 with userdata { min=5 max=10 } +// variable +5 + +// variable = 11 +Clamper: variable changed from 5 to 11 with userdata { min=5 max=10 } +// variable +10 + +// variable = 6 +Clamper: variable changed from 10 to 6 with userdata { min=5 max=10 } +// variable +6 + +// delete(variable) + +// variable = 3 +Clamper: variable changed from undefined to 3 with userdata { min=5 max=10 } +// variable +5 + +// watch("variable", clamper, {min: 15, max: 20}) +true + +// variable = 14 +Clamper: variable changed from 5 to 14 with userdata { min=15 max=20 } +// variable +15 + +// variable = 21 +Clamper: variable changed from 15 to 21 with userdata { min=15 max=20 } +// variable +20 + +// variable = 18 +Clamper: variable changed from 20 to 18 with userdata { min=15 max=20 } +// variable +18 + +// watch("variable", exceptionalClamper, {min: 15, max: 20}) +true + +// variable = 14 +exceptionalClamper: variable changed from 18 to 14 with userdata { min=15 max=20 } +ERROR: too low! +// variable +undefined + +// variable = 21 +exceptionalClamper: variable changed from undefined to 21 with userdata { min=15 max=20 } +ERROR: too high! +// variable +undefined + +// variable = 18 +exceptionalClamper: variable changed from undefined to 18 with userdata { min=15 max=20 } +// variable +18 + +// unwatch("variable") +true + +// variable = 4 +// variable +4 + +// variable = 11 +// variable +11 + +// unwatch("variable") +false + +// unwatch() +false + +// variable = 6 +// variable +6 + +// delete(variable) + + +// var loggingWatcher = new LoggingWatcher() +// this.watch("value", this.log) + +// loggingWatcher.value = true +LoggingWatcher count 1: value changed from true to true with userdata undefined + +// loggingWatcher.value = false +LoggingWatcher count 2: value changed from true to false with userdata undefined + +// loggingWatcher.count +2 + diff --git a/core/tests/swfs/avm1/watch/test.fla b/core/tests/swfs/avm1/watch/test.fla new file mode 100644 index 000000000..60d65285c Binary files /dev/null and b/core/tests/swfs/avm1/watch/test.fla differ diff --git a/core/tests/swfs/avm1/watch/test.swf b/core/tests/swfs/avm1/watch/test.swf new file mode 100644 index 000000000..0df156e1e Binary files /dev/null and b/core/tests/swfs/avm1/watch/test.swf differ diff --git a/core/tests/swfs/avm1/watch_virtual_property/output.txt b/core/tests/swfs/avm1/watch_virtual_property/output.txt new file mode 100644 index 000000000..29c0a7be6 --- /dev/null +++ b/core/tests/swfs/avm1/watch_virtual_property/output.txt @@ -0,0 +1,61 @@ +// watch("variable", plusOne) +true + +// addProperty("variable", getter, setter) +plusOne: variable changed from undefined to undefined with userdata undefined +true + +// variable = 10 +plusOne: variable changed from NaN to 10 with userdata undefined +setter: ignoring new value of 11 +// variable +getter: returning 5 +5 + +// variable = 4 +plusOne: variable changed from 11 to 4 with userdata undefined +setter: ignoring new value of 5 +// variable +getter: returning 5 +5 + +// unwatch("variable") +false + +// variable = 10 +plusOne: variable changed from 5 to 10 with userdata undefined +setter: ignoring new value of 11 +// variable +getter: returning 5 +5 + +// variable = 4 +plusOne: variable changed from 11 to 4 with userdata undefined +setter: ignoring new value of 5 +// variable +getter: returning 5 +5 + +// watch("variable", plusOne) +true + +// variable = 10 +plusOne: variable changed from 5 to 10 with userdata undefined +setter: ignoring new value of 11 +// variable +getter: returning 5 +5 + +// variable = 4 +plusOne: variable changed from 11 to 4 with userdata undefined +setter: ignoring new value of 5 +// variable +getter: returning 5 +5 + +// delete(variable) + +// addProperty("variable", getter, null) +plusOne: variable changed from undefined to undefined with userdata undefined +true + diff --git a/core/tests/swfs/avm1/watch_virtual_property/test.fla b/core/tests/swfs/avm1/watch_virtual_property/test.fla new file mode 100644 index 000000000..4743af586 Binary files /dev/null and b/core/tests/swfs/avm1/watch_virtual_property/test.fla differ diff --git a/core/tests/swfs/avm1/watch_virtual_property/test.swf b/core/tests/swfs/avm1/watch_virtual_property/test.swf new file mode 100644 index 000000000..192388832 Binary files /dev/null and b/core/tests/swfs/avm1/watch_virtual_property/test.swf differ