| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576 |
- #include "ScriptEngine.h"
- #include "WallpaperEngine/Media/ThumbnailPalette.h"
- #include "WallpaperEngine/VideoPlayback/MPV/GLPlayer.h"
- #include "Adapters/ScriptableObjectAdapter.h"
- #include "ScriptPropertiesObject.h"
- #include "ScriptableObject.h"
- #include "WallpaperEngine/Audio/AudioContext.h"
- #include "WallpaperEngine/Audio/Drivers/Recorders/PlaybackRecorder.h"
- #include "WallpaperEngine/Data/Model/Property.h"
- #include "WallpaperEngine/Data/Utils/ScopeGuard.h"
- #include "WallpaperEngine/Desktop/UserShortcut.h"
- #include "WallpaperEngine/Logging/Log.h"
- #include "WallpaperEngine/Render/CObject.h"
- #include "WallpaperEngine/Render/Objects/CSound.h"
- #include "WallpaperEngine/Render/Wallpapers/CScene.h"
- #include "WallpaperEngine/Scripting/Builtins.generated.h"
- #include "quickjs.h"
- #include <algorithm>
- #include <array>
- #include <cctype>
- #include <cerrno>
- #include <chrono>
- #include <cmath>
- #include <cstdio>
- #include <cstring>
- #include <ctime>
- #include <filesystem>
- #include <future>
- #include <optional>
- #include <poll.h>
- #include <ranges>
- #include <signal.h>
- #include <spawn.h>
- #include <sstream>
- #include <string_view>
- #include <sys/wait.h>
- #include <unistd.h>
- #include <vector>
- namespace WallpaperEngine::Render::Objects {
- class CSound;
- }
- using namespace WallpaperEngine::Scripting;
- using namespace WallpaperEngine::Data::Model;
- extern char** environ;
- extern float g_Time;
- extern float g_TimeLast;
- void scriptengine_dump (JSContext* ctx, JSValueConst obj) {
- JSPropertyEnum* props;
- uint32_t len;
- if (JS_GetOwnPropertyNames (ctx, &props, &len, obj, JS_GPN_STRING_MASK | JS_GPN_SYMBOL_MASK) < 0) {
- return;
- }
- for (uint32_t i = 0; i < len; ++i) {
- const char* name = JS_AtomToCString (ctx, props[i].atom);
- JSValue val = JS_GetProperty (ctx, obj, props[i].atom);
- const char* value_str = JS_ToCString (ctx, val);
- printf ("%s = %s\n", name, value_str ? value_str : "<non-string>");
- JS_FreeCString (ctx, value_str);
- JS_FreeValue (ctx, val);
- JS_FreeCString (ctx, name);
- }
- js_free (ctx, props);
- }
- std::optional<std::string> ScriptEngine::readScriptAsset (const std::string& path) const {
- try {
- return this->m_scene.getAssetLocator ().readString (path);
- } catch (std::filesystem::filesystem_error& e) {
- sLog.error ("ScriptEngine: cannot read ", path, ": ", e.what ());
- return std::nullopt;
- }
- }
- // same lookup as scenescript64: the import name lowercased under scripts/jsmodules/, ".js" appended
- // unless the name already has it ('WEMath' -> scripts/jsmodules/wemath.js)
- JSModuleDef* scriptengine_module_loader (JSContext* ctx, const char* module, void* opaque) {
- const auto* scriptEngine = static_cast<ScriptEngine*> (opaque);
- std::string name (module);
- std::ranges::transform (name, name.begin (), [] (unsigned char c) { return std::tolower (c); });
- std::string path = "scripts/jsmodules/" + name;
- if (name.find (".js") == std::string::npos) {
- path += ".js";
- }
- const auto source = scriptEngine->readScriptAsset (path);
- if (!source.has_value ()) {
- JS_ThrowReferenceError (ctx, "could not load module '%s'", module);
- return nullptr;
- }
- JSValue compiled
- = JS_Eval (ctx, source->c_str (), source->size (), module, JS_EVAL_TYPE_MODULE | JS_EVAL_FLAG_COMPILE_ONLY);
- if (JS_IsException (compiled)) {
- return nullptr;
- }
- auto* definition = static_cast<JSModuleDef*> (JS_VALUE_GET_PTR (compiled));
- JS_FreeValue (ctx, compiled);
- return definition;
- }
- JSValue ScriptEngine::dynamicToJs (DynamicValue& value) const {
- switch (value.getType ()) {
- case DynamicValue::Null:
- return JS_NULL;
- case DynamicValue::String:
- return JS_NewString (this->m_context, value.getString ().c_str ());
- case DynamicValue::Float:
- return JS_NewFloat64 (this->m_context, value.getFloat ());
- case DynamicValue::Int:
- return JS_NewInt32 (this->m_context, value.getInt ());
- case DynamicValue::Boolean:
- return JS_NewBool (this->m_context, value.getBool ());
- case DynamicValue::Vec2:
- return this->m_adapters.vec2->instantiate (value, true);
- case DynamicValue::Vec3:
- return this->m_adapters.vec3->instantiate (value, true);
- case DynamicValue::Vec4:
- return this->m_adapters.vec4->instantiate (value, true);
- default:
- return JS_UNDEFINED;
- }
- }
- bool ScriptEngine::hasScript (const DynamicValue& value) const {
- return std::ranges::any_of (this->m_scriptModules, [&value] (const auto& entry) {
- return &entry.second.value == &value;
- });
- }
- JSValue ScriptEngine::userPropertyToJs (Property& property) const {
- // same shape the real engine hands scripts (jsclasses/baseclasses.js), never the command itself
- if (property.is<PropertyUserShortcut> ()) {
- const auto shortcut = Desktop::UserShortcut::parse (property.getString ());
- JSValue result = JS_NewObject (this->m_context);
- JS_SetPropertyStr (this->m_context, result, "isbound", JS_NewBool (this->m_context, shortcut.has_value ()));
- return result;
- }
- if (property.is<PropertyColor> () && property.getType () == DynamicValue::Vec4) {
- DynamicValue rgb (glm::vec3 (property.getVec4 ()));
- return this->m_adapters.vec3->instantiate (rgb, true);
- }
- return this->dynamicToJs (property);
- }
- // real WE spreads a number returned for a vector property over every component and the property stays a
- // vector, so the next update() still gets a Vec
- static bool updateVectorFromNumber (DynamicValue& source, float number) {
- switch (source.getType ()) {
- case DynamicValue::Vec4:
- source.update (glm::vec4 (number), DynamicValue::UpdateSource::Script);
- return true;
- case DynamicValue::Vec3:
- source.update (glm::vec3 (number), DynamicValue::UpdateSource::Script);
- return true;
- case DynamicValue::Vec2:
- source.update (glm::vec2 (number), DynamicValue::UpdateSource::Script);
- return true;
- default:
- return false;
- }
- }
- static bool jsToDynamicValue (JSContext* ctx, JSValue val, DynamicValue& source) {
- if (JS_IsException (val)) {
- return false;
- }
- int tag = JS_VALUE_GET_TAG (val);
- // update()'s contract is "return the new value"; falling off the end of a function (or an
- // early "if (cond) return x;" with no else) yields undefined and is meant as "nothing to
- // change this frame", not "reset this property to zero" - DynamicValue::update(source) with
- // no value does the latter (it's meant for genuinely-null JSON properties at parse time, see
- // DynamicValueParser), and calling it here would zero out (e.g. scale -> 0, i.e. invisible)
- // any property whose script doesn't explicitly return on every path.
- if (tag == JS_TAG_UNDEFINED || tag == JS_TAG_UNINITIALIZED || tag == JS_TAG_NULL) {
- return false;
- }
- if (tag == JS_TAG_INT) {
- if (!updateVectorFromNumber (source, static_cast<float> (JS_VALUE_GET_INT (val)))) {
- source.update (JS_VALUE_GET_INT (val), DynamicValue::UpdateSource::Script);
- }
- return true;
- }
- if (tag == JS_TAG_BOOL) {
- source.update (static_cast<bool> (JS_VALUE_GET_BOOL (val)), DynamicValue::UpdateSource::Script);
- return true;
- }
- if (JS_TAG_IS_FLOAT64 (tag)) {
- const auto number = static_cast<float> (JS_VALUE_GET_FLOAT64 (val));
- if (!updateVectorFromNumber (source, number)) {
- source.update (number, DynamicValue::UpdateSource::Script);
- }
- return true;
- }
- if (tag == JS_TAG_STRING) {
- const char* str = JS_ToCString (ctx, val);
- source.update (std::string (str == nullptr ? "" : str), DynamicValue::UpdateSource::Script);
- JS_FreeCString (ctx, str);
- return true;
- }
- if (tag == JS_TAG_OBJECT) {
- JSValue x = JS_GetPropertyStr (ctx, val, "x");
- JSValue y = JS_GetPropertyStr (ctx, val, "y");
- JSValue z = JS_GetPropertyStr (ctx, val, "z");
- JSValue w = JS_GetPropertyStr (ctx, val, "w");
- ScopeGuard guard ([=] {
- JS_FreeValue (ctx, x);
- JS_FreeValue (ctx, y);
- JS_FreeValue (ctx, z);
- JS_FreeValue (ctx, w);
- });
- // runs every frame, a bad value from one script must not take the whole wallpaper down
- if (!JS_IsNumber (x) || !JS_IsNumber (y)) {
- static bool reported = false;
- if (!reported) {
- reported = true;
- sLog.error ("Script returned a vector without numeric x and y components, keeping the previous value");
- }
- return false;
- }
- double xVal = 0.0f, yVal = 0.0f, zVal = 0.0f, wVal = 0.0f;
- JS_ToFloat64 (ctx, &xVal, x);
- JS_ToFloat64 (ctx, &yVal, y);
- if (!JS_IsNumber (z)) {
- source.update (glm::vec2 (xVal, yVal), DynamicValue::UpdateSource::Script);
- return true;
- }
- JS_ToFloat64 (ctx, &zVal, z);
- if (!JS_IsNumber (w)) {
- source.update (glm::vec3 (xVal, yVal, zVal), DynamicValue::UpdateSource::Script);
- return true;
- }
- JS_ToFloat64 (ctx, &wVal, w);
- source.update (glm::vec4 (xVal, yVal, zVal, wVal), DynamicValue::UpdateSource::Script);
- return true;
- }
- return false;
- }
- void ScriptEngine::assignJsValue (JSValue val, DynamicValue& target) const {
- jsToDynamicValue (this->m_context, val, target);
- }
- // Objects keep "angles" in radians, scripts get degrees: scenescript64 2.8.42 converts every property whose
- // descriptor has flag 4 on the way in (sub_1816208E0, * 57.29578) and out (sub_181620E10, * 0.017453292), and
- // wallpaper64 only sets that flag on the object "angles" property (sub_1401E0530)
- static bool isDegreeProperty (std::string_view name) { return name == "angles"; }
- static DynamicValue scaledAngles (const DynamicValue& value, float factor) {
- switch (value.getType ()) {
- case DynamicValue::Float:
- return DynamicValue (value.getFloat () * factor);
- case DynamicValue::Vec2:
- return DynamicValue (value.getVec2 () * factor);
- case DynamicValue::Vec3:
- return DynamicValue (value.getVec3 () * factor);
- case DynamicValue::Vec4:
- return DynamicValue (value.getVec4 () * factor);
- default:
- return value;
- }
- }
- JSValue ScriptEngine::propertyToJs (DynamicValue& value, std::string_view name) const {
- if (!isDegreeProperty (name)) {
- return this->dynamicToJs (value);
- }
- DynamicValue degrees = scaledAngles (value, 57.29578f);
- return this->dynamicToJs (degrees);
- }
- void ScriptEngine::assignPropertyJsValue (JSValue val, DynamicValue& target, std::string_view name) const {
- // scenescript64 (sub_181620E10, string descriptors) stores ToString of whatever the script assigns, only
- // undefined/null are ignored: 3577513994 sets a text layer to getDate()'s number
- if (target.getType () == DynamicValue::String && !JS_IsString (val) && !JS_IsUndefined (val) && !JS_IsNull (val)
- && !JS_IsException (val) && JS_VALUE_GET_TAG (val) != JS_TAG_UNINITIALIZED) {
- const char* str = JS_ToCString (this->m_context, val);
- if (str != nullptr) {
- target.update (std::string (str), DynamicValue::UpdateSource::Script);
- JS_FreeCString (this->m_context, str);
- }
- return;
- }
- if (!isDegreeProperty (name)) {
- jsToDynamicValue (this->m_context, val, target);
- return;
- }
- DynamicValue degrees = scaledAngles (target, 57.29578f);
- if (!jsToDynamicValue (this->m_context, val, degrees)) {
- return;
- }
- const DynamicValue radians = scaledAngles (degrees, 0.017453292f);
- switch (radians.getType ()) {
- case DynamicValue::Float:
- target.update (radians.getFloat (), DynamicValue::UpdateSource::Script);
- break;
- case DynamicValue::Vec2:
- target.update (radians.getVec2 (), DynamicValue::UpdateSource::Script);
- break;
- case DynamicValue::Vec3:
- target.update (radians.getVec3 (), DynamicValue::UpdateSource::Script);
- break;
- case DynamicValue::Vec4:
- target.update (radians.getVec4 (), DynamicValue::UpdateSource::Script);
- break;
- default:
- target.update (radians, DynamicValue::UpdateSource::Script);
- break;
- }
- }
- ScriptEngine::ScriptEngine (Wallpapers::CScene& scene, Media::MediaSource& mediaSource) :
- m_scene (scene), m_mediaSource (mediaSource) {
- this->m_unregisterMediaUpdateCallback
- = mediaSource.addMetadataListener ([this] (const Media::MediaSource::MediaInfo& info) {
- this->notifyMediaUpdate (info);
- });
- this->m_unregisterAlbumArtUpdateCallback
- = mediaSource.addAlbumArtListener ([this] (const Media::MediaSource::MediaInfo& info) {
- // TODO: SEPARATE THESE INTO THEIR OWN UPDATES SO JS ONLY RECEIVES THE MEANINGFUL UPDATES
- this->notifyMediaUpdate (info);
- });
- this->m_runtime = JS_NewRuntime ();
- if (!this->m_runtime) {
- sLog.exception ("ScriptEngine: Failed to create JS runtime");
- }
- // debug leaks on termination
- JS_SetDumpFlags (this->m_runtime, JS_DUMP_LEAKS);
- this->m_context = JS_NewContext (this->m_runtime);
- if (!this->m_context) {
- JS_FreeRuntime (this->m_runtime);
- sLog.exception ("ScriptEngine: Failed to create JS context");
- }
- this->m_globalThis = JS_GetGlobalObject (this->m_context);
- // WE's own Vec2/Vec3/Vec4/Mat3/Mat4 classes, the natives below only create instances of them
- if (const auto baseClasses = this->readScriptAsset ("scripts/jsclasses/baseclasses.js"); baseClasses.has_value ()) {
- JSValue result = JS_Eval (
- this->m_context, baseClasses->c_str (), baseClasses->size (), "baseclasses.js", JS_EVAL_TYPE_GLOBAL
- );
- if (JS_IsException (result)) {
- logJSException (this->m_context, "baseclasses.js", baseClasses);
- }
- JS_FreeValue (this->m_context, result);
- }
- this->m_adapters = {
- .vec4 = std::unique_ptr<Adapters::VectorAdapter<4>> (new Adapters::VectorAdapter<4> (*this)),
- .vec3 = std::unique_ptr<Adapters::VectorAdapter<3>> (new Adapters::VectorAdapter<3> (*this)),
- .vec2 = std::unique_ptr<Adapters::VectorAdapter<2>> (new Adapters::VectorAdapter<2> (*this)),
- .object
- = std::unique_ptr<Adapters::ScriptableObjectAdapter> (new Adapters::ScriptableObjectAdapter (*this, "ILayer")),
- };
- this->m_engineObject = std::make_unique<EngineObject> (*this, scene);
- this->m_inputObject = std::make_unique<InputObject> (*this, scene);
- this->m_sceneObject = std::make_unique<SceneObject> (*this, scene);
- this->m_consoleObject = std::make_unique<ConsoleObject> (*this, scene);
- this->m_scriptPropertiesObject = std::make_unique<ScriptPropertiesObject> (*this, scene);
- JS_SetModuleLoaderFunc (this->m_runtime, nullptr, scriptengine_module_loader, this);
- this->installBuiltins ();
- JS_DefinePropertyValueStr (
- this->m_context, this->m_globalThis, "engine", this->m_engineObject->getInstance (), JS_PROP_C_W_E
- );
- JS_DefinePropertyValueStr (
- this->m_context, this->m_globalThis, "input", this->m_inputObject->getInstance (), JS_PROP_C_W_E
- );
- JS_DefinePropertyValueStr (
- this->m_context, this->m_globalThis, "thisScene", this->m_sceneObject->getInstance (), JS_PROP_C_W_E
- );
- JS_DefinePropertyValueStr (
- this->m_context, this->m_globalThis, "console", this->m_consoleObject->getInstance (), JS_PROP_C_W_E
- );
- JS_DefinePropertyValueStr (
- this->m_context, this->m_globalThis, "shared", JS_NewObject (this->m_context), JS_PROP_C_W_E
- );
- }
- ScriptEngine::~ScriptEngine () {
- this->m_unregisterMediaUpdateCallback ();
- this->m_unregisterAlbumArtUpdateCallback ();
- for (const auto& module : this->m_scriptModules | std::views::values) {
- JS_FreeValue (this->m_context, module.module);
- JS_FreeValue (this->m_context, module.thisObject);
- }
- for (const auto& entry : this->m_videoEndedCallbacks) {
- JS_FreeValue (this->m_context, entry.callback);
- }
- if (this->m_adapters.object) {
- this->m_adapters.object->clear ();
- }
- JS_FreeValue (this->m_context, this->m_globalThis);
- this->m_consoleObject.reset ();
- this->m_engineObject.reset ();
- this->m_inputObject.reset ();
- this->m_sceneObject.reset ();
- this->m_scriptPropertiesObject.reset ();
- this->m_scriptModules.clear ();
- if (this->m_context) {
- JS_FreeContext (this->m_context);
- }
- if (this->m_runtime) {
- JS_FreeRuntime (this->m_runtime);
- }
- // Freeing the runtime above runs a GC pass that finalizes every still-live layer object, whose
- // class belongs to the object adapter - so the adapters must outlive JS_FreeRuntime().
- this->m_adapters.vec4.reset ();
- this->m_adapters.vec3.reset ();
- this->m_adapters.vec2.reset ();
- this->m_adapters.object.reset ();
- }
- namespace {
- // the line the first stack frame inside `context` points at, with a caret under the column
- std::string sourceExcerpt (const std::string& stack, const char* context, const std::string& source) {
- const std::string marker = std::string (context) + ":";
- const auto at = stack.find (marker);
- if (at == std::string::npos) {
- return {};
- }
- int line = 0;
- int column = 0;
- if (std::sscanf (stack.c_str () + at + marker.size (), "%d:%d", &line, &column) < 1 || line < 1) {
- return {};
- }
- size_t begin = 0;
- for (int i = 1; i < line; i++) {
- begin = source.find ('\n', begin);
- if (begin == std::string::npos) {
- return {};
- }
- begin++;
- }
- const auto lineEnd = source.find ('\n', begin);
- std::string text = source.substr (begin, lineEnd == std::string::npos ? std::string::npos : lineEnd - begin);
- std::ranges::replace (text, '\t', ' ');
- std::string result = std::to_string (line) + ": " + text;
- if (column > 0) {
- result += "\n" + std::string (std::to_string (line).size () + 2 + column - 1, ' ') + "^";
- }
- return result;
- }
- } // namespace
- void WallpaperEngine::Scripting::logJSException (
- JSContext* ctx, const char* context, const std::optional<std::string>& source
- ) {
- // scripts that fail every tick would otherwise print the same error every frame
- static std::map<std::string, std::string> lastReported;
- JSValue exc = JS_GetException (ctx);
- ScopeGuard freeException ([=] { JS_FreeValue (ctx, exc); });
- if (JS_IsNull (exc) || JS_IsUndefined (exc) || JS_IsUninitialized (exc)) {
- sLog.error ("ScriptEngine [", context, "]: a native call failed without setting an exception");
- return;
- }
- std::string message;
- std::string stack;
- if (const char* str = JS_ToCString (ctx, exc); str != nullptr) {
- message = str;
- JS_FreeCString (ctx, str);
- }
- JSValue stackValue = JS_GetPropertyStr (ctx, exc, "stack");
- if (!JS_IsUndefined (stackValue)) {
- if (const char* str = JS_ToCString (ctx, stackValue); str != nullptr) {
- stack = str;
- JS_FreeCString (ctx, str);
- }
- }
- JS_FreeValue (ctx, stackValue);
- auto& last = lastReported[context];
- if (last == message + stack) {
- return;
- }
- last = message + stack;
- sLog.error ("ScriptEngine [", context, "]: ", message);
- if (!stack.empty ()) {
- sLog.error ("ScriptEngine [", context, "] stack: ", stack);
- }
- if (source.has_value ()) {
- if (const auto excerpt = sourceExcerpt (stack, context, *source); !excerpt.empty ()) {
- sLog.error ("ScriptEngine [", context, "] source:\n", excerpt);
- }
- }
- }
- void ScriptEngine::installBuiltins () {
- if (this->m_builtinsInstalled || !this->m_context) {
- return;
- }
- JSValue result = JS_Eval (
- this->m_context, SCENE_SCRIPT_BUILTINS, strlen (SCENE_SCRIPT_BUILTINS), "<scene-script-builtins>",
- JS_EVAL_TYPE_GLOBAL
- );
- if (JS_IsException (result)) {
- logJSException (this->m_context, "installBuiltins");
- }
- JS_FreeValue (this->m_context, result);
- this->m_builtinsInstalled = true;
- }
- // Layer-script API (Phase 2)
- void ScriptEngine::ensureLayerRegistry () {
- if (this->m_layerRegistryReady || !this->m_context) {
- return;
- }
- JSContext* ctx = this->m_context;
- JSValue globalObj = JS_GetGlobalObject (ctx);
- JS_SetPropertyStr (ctx, globalObj, "__textLayers", JS_NewObject (ctx));
- JS_FreeValue (ctx, globalObj);
- this->m_layerRegistryReady = true;
- }
- ScriptLayerHandle ScriptEngine::createLayerScript (
- const std::string& scriptSource, std::map<std::string, UserSettingUniquePtr>& initialScriptProps,
- const std::string& initialText
- ) {
- if (!this->m_context) {
- sLog.error ("ScriptEngine: No JS context available");
- return kInvalidLayerHandle;
- }
- this->ensureLayerRegistry ();
- JSContext* ctx = this->m_context;
- JSValue globalObj = JS_GetGlobalObject (ctx);
- // Seed initial scriptProperties and text as temporary globals the IIFE reads.
- JSValue seedProps = JS_NewObject (ctx);
- for (auto& [name, dynVal] : initialScriptProps) {
- JS_SetPropertyStr (ctx, seedProps, name.c_str (), this->dynamicToJs (*dynVal->value));
- }
- JS_SetPropertyStr (ctx, globalObj, "__layerSeedProps", seedProps);
- JS_SetPropertyStr (ctx, globalObj, "__layerSeedText", JS_NewString (ctx, initialText.c_str ()));
- const ScriptLayerHandle id = this->m_nextLayerId++;
- // Same stripping logic as evaluate(): WE scripts come as ES6 modules but
- // QuickJS is easier to drive as plain script evaluation.
- std::string body = scriptSource;
- size_t pos;
- while ((pos = body.find ("'use strict';")) != std::string::npos) {
- body.erase (pos, 13);
- }
- while ((pos = body.find ("\"use strict\";")) != std::string::npos) {
- body.erase (pos, 13);
- }
- while ((pos = body.find ("export ")) != std::string::npos) {
- body.erase (pos, 7);
- }
- // The IIFE gives every layer its own closure so two layers both defining `function update()`
- // or a top-level `var scriptProperties` don't clobber each other. Lifecycle hooks are
- // captured into globalThis.__textLayers[id] so tick/destroy can reach them later.
- // `typeof init === 'function'` is safe even when `init` was never declared.
- std::ostringstream wrapper;
- wrapper
- << "(function() {\n"
- << " var __id = " << id << ";\n"
- << " var __props = Object.assign({}, globalThis.__layerSeedProps || {});\n"
- << " var thisLayer = { text: String(globalThis.__layerSeedText || '') };\n"
- << " var thisScene = {\n"
- << " get time() { var c = globalThis.__sceneCtx; return c ? c.time : 0; },\n"
- << " get currentTime() { var c = globalThis.__sceneCtx; return c ? c.time : 0; },\n"
- << " get dt() { var c = globalThis.__sceneCtx; return c ? c.dt : 0; },\n"
- << " get fps() { var c = globalThis.__sceneCtx; return c ? c.fps : 60; },\n"
- << " };\n"
- // Minimal WE `engine` shim - just enough for built-in text scripts to run without
- // ReferenceError. `frametime` is the per-frame delta in seconds (what InsertFPS reads).
- << " var engine = {\n"
- << " get frametime() { var c = globalThis.__sceneCtx; return c ? c.dt : 0; },\n"
- << " get time() { var c = globalThis.__sceneCtx; return c ? c.time : 0; },\n"
- << " };\n"
- << " function createScriptProperties() {\n"
- << " var builder = {\n"
- << " addSlider: function(o){ if (!(o.name in __props)) __props[o.name] = o.value; return builder; },\n"
- << " addCheckbox: function(o){ if (!(o.name in __props)) __props[o.name] = o.value; return builder; },\n"
- << " addCombo: function(o){ if (!(o.name in __props)) __props[o.name] = o.value; return builder; },\n"
- << " addColor: function(o){ if (!(o.name in __props)) __props[o.name] = o.value; return builder; },\n"
- << " addText: function(o){ if (!(o.name in __props)) __props[o.name] = o.value; return builder; },\n"
- << " finish: function(){ return __props; }\n"
- << " };\n"
- << " return builder;\n"
- << " }\n"
- << body
- << "\n"
- // `_tick` wraps the user's `update()` so both WE text conventions work: mutating
- // `thisLayer.text` in place, or returning the new text as a string. Non-string/undefined
- // return leaves `thisLayer.text` as whatever the function assigned itself.
- << " globalThis.__textLayers[__id] = {\n"
- << " thisLayer: thisLayer,\n"
- << " thisScene: thisScene,\n"
- << " _init: (typeof init === 'function') ? init : null,\n"
- << " _destroy: (typeof destroy === 'function') ? destroy : null,\n"
- << " _tick: (typeof update === 'function')\n"
- << " ? function() {\n"
- << " var r = update(thisLayer.text);\n"
- << " if (typeof r === 'string') thisLayer.text = r;\n"
- << " }\n"
- << " : null,\n"
- << " _scriptProperties: (typeof scriptProperties !== 'undefined') ? scriptProperties : __props\n"
- << " };\n"
- << "})();\n";
- const std::string evalScript = wrapper.str ();
- JSValue result = JS_Eval (ctx, evalScript.c_str (), evalScript.size (), "<layer-script>", JS_EVAL_TYPE_GLOBAL);
- // Unset seeds so they don't leak into the next createLayerScript call.
- JS_SetPropertyStr (ctx, globalObj, "__layerSeedProps", JS_UNDEFINED);
- JS_SetPropertyStr (ctx, globalObj, "__layerSeedText", JS_UNDEFINED);
- JS_FreeValue (ctx, globalObj);
- if (JS_IsException (result)) {
- logJSException (ctx, "createLayerScript");
- JS_FreeValue (ctx, result);
- return kInvalidLayerHandle;
- }
- JS_FreeValue (ctx, result);
- this->m_layerInitialized[id] = false;
- return id;
- }
- void ScriptEngine::tickLayer (ScriptLayerHandle handle, double time, double deltaTime, double fps) {
- if (!this->m_context || handle == kInvalidLayerHandle) {
- return;
- }
- JSContext* ctx = this->m_context;
- JSValue globalObj = JS_GetGlobalObject (ctx);
- JSValue sceneCtx = JS_NewObject (ctx);
- JS_SetPropertyStr (ctx, sceneCtx, "time", JS_NewFloat64 (ctx, time));
- JS_SetPropertyStr (ctx, sceneCtx, "dt", JS_NewFloat64 (ctx, deltaTime));
- JS_SetPropertyStr (ctx, sceneCtx, "fps", JS_NewFloat64 (ctx, fps));
- JS_SetPropertyStr (ctx, globalObj, "__sceneCtx", sceneCtx);
- JSValue layers = JS_GetPropertyStr (ctx, globalObj, "__textLayers");
- JSValue layerObj = JS_GetPropertyUint32 (ctx, layers, static_cast<uint32_t> (handle));
- JS_FreeValue (ctx, layers);
- if (JS_IsUndefined (layerObj) || JS_IsNull (layerObj)) {
- JS_FreeValue (ctx, layerObj);
- JS_FreeValue (ctx, globalObj);
- return;
- }
- auto callHook = [&] (const char* prop, const char* tag) {
- JSValue fn = JS_GetPropertyStr (ctx, layerObj, prop);
- if (JS_IsFunction (ctx, fn)) {
- JSValue ret = JS_Call (ctx, fn, layerObj, 0, nullptr);
- if (JS_IsException (ret)) {
- logJSException (ctx, tag);
- }
- JS_FreeValue (ctx, ret);
- }
- JS_FreeValue (ctx, fn);
- };
- auto it = this->m_layerInitialized.find (handle);
- if (it != this->m_layerInitialized.end () && !it->second) {
- callHook ("_init", "layer.init");
- it->second = true;
- }
- callHook ("_tick", "layer.update");
- JS_FreeValue (ctx, layerObj);
- JS_FreeValue (ctx, globalObj);
- }
- std::string ScriptEngine::layerText (ScriptLayerHandle handle) {
- if (!this->m_context || handle == kInvalidLayerHandle) {
- return {};
- }
- JSContext* ctx = this->m_context;
- JSValue globalObj = JS_GetGlobalObject (ctx);
- JSValue layers = JS_GetPropertyStr (ctx, globalObj, "__textLayers");
- JSValue layerObj = JS_GetPropertyUint32 (ctx, layers, static_cast<uint32_t> (handle));
- std::string result;
- if (!JS_IsUndefined (layerObj) && !JS_IsNull (layerObj)) {
- JSValue thisLayer = JS_GetPropertyStr (ctx, layerObj, "thisLayer");
- JSValue textVal = JS_GetPropertyStr (ctx, thisLayer, "text");
- if (!JS_IsUndefined (textVal) && !JS_IsNull (textVal)) {
- const char* cstr = JS_ToCString (ctx, textVal);
- if (cstr) {
- result.assign (cstr);
- JS_FreeCString (ctx, cstr);
- }
- }
- JS_FreeValue (ctx, textVal);
- JS_FreeValue (ctx, thisLayer);
- }
- JS_FreeValue (ctx, layerObj);
- JS_FreeValue (ctx, layers);
- JS_FreeValue (ctx, globalObj);
- return result;
- }
- void ScriptEngine::destroyLayer (ScriptLayerHandle handle) {
- if (!this->m_context || handle == kInvalidLayerHandle) {
- return;
- }
- JSContext* ctx = this->m_context;
- JSValue globalObj = JS_GetGlobalObject (ctx);
- JSValue layers = JS_GetPropertyStr (ctx, globalObj, "__textLayers");
- JSValue layerObj = JS_GetPropertyUint32 (ctx, layers, static_cast<uint32_t> (handle));
- if (!JS_IsUndefined (layerObj) && !JS_IsNull (layerObj)) {
- JSValue fn = JS_GetPropertyStr (ctx, layerObj, "_destroy");
- if (JS_IsFunction (ctx, fn)) {
- JSValue ret = JS_Call (ctx, fn, layerObj, 0, nullptr);
- if (JS_IsException (ret)) {
- logJSException (ctx, "layer.destroy");
- }
- JS_FreeValue (ctx, ret);
- }
- JS_FreeValue (ctx, fn);
- }
- JS_FreeValue (ctx, layerObj);
- JS_FreeValue (ctx, layers);
- JS_FreeValue (ctx, globalObj);
- // Remove the entry from globalThis.__textLayers so GC can reclaim its closures.
- const std::string delScript = "delete globalThis.__textLayers[" + std::to_string (handle) + "];";
- JSValue delResult = JS_Eval (ctx, delScript.c_str (), delScript.size (), "<layer-destroy>", JS_EVAL_TYPE_GLOBAL);
- if (JS_IsException (delResult)) {
- logJSException (ctx, "layer.destroy.delete");
- }
- JS_FreeValue (ctx, delResult);
- this->m_layerInitialized.erase (handle);
- }
- JSValue ScriptEngine::call (JSValue module, int argc, JSValue argv[], const char* name) {
- JSValue function = JS_GetPropertyStr (this->m_context, module, name);
- ScopeGuard guard ([&] () { JS_FreeValue (this->m_context, function); });
- if (!JS_IsFunction (this->m_context, function)) {
- return JS_UNDEFINED;
- }
- return JS_Call (this->m_context, function, module, argc, argv);
- }
- void ScriptEngine::retireScript (const std::string& key) { this->m_retiredScriptKeys.push_back (key); }
- void ScriptEngine::dropObjectScripts (const ScriptableObject& object) {
- this->m_adapters.object->forget (object);
- for (auto& [key, module] : this->m_scriptModules) {
- if (module.object != &object) {
- continue;
- }
- module.object = nullptr;
- module.dropped = true;
- this->retireScript (key);
- }
- for (const char* global : { "thisLayer", "thisObject" }) {
- JSValue current = JS_GetPropertyStr (this->m_context, this->m_globalThis, global);
- if (Adapters::ScriptableObjectAdapter::getObject (current) == &object) {
- JS_SetPropertyStr (this->m_context, this->m_globalThis, global, JS_UNDEFINED);
- }
- JS_FreeValue (this->m_context, current);
- }
- }
- bool ScriptEngine::rebindScript (const std::string& key, DynamicValue& newValue) {
- const auto it = this->m_scriptModules.find (key);
- if (it == this->m_scriptModules.end () || it->second.value.getScriptSource () != newValue.getScriptSource ()) {
- return false;
- }
- // LoadedModule::value is a reference member, so the entry is erased and re-emplaced under the same key,
- // m_runningModule may currently point at the erased entry
- // the cached thisObject points at the old value, the replacement builds its own
- JS_FreeValue (this->m_context, it->second.thisObject);
- LoadedModule replacement { .value = newValue,
- .module = it->second.module,
- .object = it->second.object,
- .propertyName = it->second.propertyName,
- .order = it->second.order };
- this->m_scriptModules.erase (it);
- const auto inserted = this->m_scriptModules.emplace (key, replacement);
- this->m_runningModule = &inserted.first->second;
- return true;
- }
- void ScriptEngine::queueScript (
- const std::string& key, DynamicValue& currentValue, ScriptableObject& object, const std::string& propertyName
- ) {
- const auto source = currentValue.getScriptSource ();
- if (!source.has_value ()) {
- return;
- }
- auto it = this->m_scriptModules.find (key);
- if (it != this->m_scriptModules.end ()) {
- return;
- }
- // Compile-only first: JS_Eval(..., JS_EVAL_TYPE_MODULE) alone compiles AND evaluates in one
- // step, but its return value is always a Promise (the module's completion value), never the
- // namespace object. Compiling separately keeps the JS_TAG_MODULE value around so the real
- // namespace can be fetched via JS_GetModuleNamespace afterward.
- JSValue compiledModule = JS_Eval (
- this->m_context, source->c_str (), source->size (), key.c_str (),
- JS_EVAL_TYPE_MODULE | JS_EVAL_FLAG_COMPILE_ONLY
- );
- if (JS_IsException (compiledModule)) {
- logJSException (this->m_context, key.c_str (), source);
- return;
- }
- auto* moduleDef = static_cast<JSModuleDef*> (JS_VALUE_GET_PTR (compiledModule));
- // Register the entry (with a placeholder module value) and point m_runningModule at it before
- // evaluating below - top-level module code commonly does `export var scriptProperties =
- // createScriptProperties()...finish();`, and scriptpropertiescreator_finish()
- // (ScriptPropertiesObject.cpp) resolves it through getRunningModule(), which must already
- // point here.
- auto inserted = this->m_scriptModules.emplace (
- key,
- LoadedModule {
- .value = currentValue,
- .module = JS_UNDEFINED,
- .object = &object,
- .propertyName = propertyName,
- .order = this->m_nextModuleOrder++,
- }
- );
- if (!inserted.second) {
- JS_FreeValue (this->m_context, compiledModule);
- return;
- }
- this->m_runningModule = &inserted.first->second;
- // module top-level code (class bodies, helpers instantiated on load) can already touch thisLayer
- this->bindThisLayer (object, this->m_runningModule);
- // JS_EvalFunction runs the module body and returns a Promise - for a module with no top-level
- // await this resolves/rejects synchronously, so its state can be checked right away.
- // JS_IsException() on it is always false even if the module threw, since that exception gets
- // caught by the module machinery and stored as the promise's rejection reason instead.
- JSValue evalResult = JS_EvalFunction (this->m_context, compiledModule);
- // 3378399626's weather widget stores its offsets in shared at the top level from the defaults and only
- // picks the user's location up in applyUserProperties
- this->m_scriptPropertiesObject->deliverValues (inserted.first->second.value);
- if (JS_PromiseState (this->m_context, evalResult) == JS_PROMISE_REJECTED) {
- JSValue reason = JS_PromiseResult (this->m_context, evalResult);
- const char* str = JS_ToCString (this->m_context, reason);
- sLog.error ("ScriptEngine [", key, "] module evaluation rejected: ", str != nullptr ? str : "(no message)");
- if (str) {
- JS_FreeCString (this->m_context, str);
- }
- JS_FreeValue (this->m_context, reason);
- JS_FreeValue (this->m_context, evalResult);
- this->m_runningModule = nullptr;
- this->m_scriptModules.erase (inserted.first);
- return;
- }
- JS_FreeValue (this->m_context, evalResult);
- JSValue module = JS_GetModuleNamespace (this->m_context, moduleDef);
- if (JS_IsException (module)) {
- logJSException (this->m_context, key.c_str ());
- this->m_runningModule = nullptr;
- this->m_scriptModules.erase (inserted.first);
- return;
- }
- inserted.first->second.module = module;
- this->bindThisLayer (object, this->m_runningModule);
- // init() and the first update() run from tick(), once the whole scene has been constructed
- }
- namespace {
- enum AnimationOp {
- AnimPlay,
- AnimPause,
- AnimStop,
- AnimGetFrame,
- AnimSetFrame,
- AnimIsPlaying,
- AnimGetRate,
- AnimSetRate,
- AnimGetFrameCount,
- AnimGetName,
- AnimGetFps,
- };
- // backs every method/accessor of the IAnimation handle; func_data is [animation system id, clock id]
- JSValue animation_access (
- JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic, JSValueConst* func_data
- ) {
- int systemId = 0;
- int clockId = 0;
- JS_ToInt32 (ctx, &systemId, func_data[0]);
- JS_ToInt32 (ctx, &clockId, func_data[1]);
- auto* system = AnimationSystem::find (systemId);
- auto* clock = system == nullptr ? nullptr : system->clock (clockId);
- if (clock == nullptr) {
- return JS_UNDEFINED;
- }
- double number = 0.0;
- switch (magic) {
- case AnimPlay:
- clock->play ();
- return JS_UNDEFINED;
- case AnimPause:
- clock->pause ();
- return JS_UNDEFINED;
- case AnimStop:
- clock->stop ();
- return JS_UNDEFINED;
- case AnimGetFrame:
- return JS_NewFloat64 (ctx, clock->getFrame ());
- case AnimSetFrame:
- if (argc > 0 && JS_ToFloat64 (ctx, &number, argv[0]) == 0) {
- clock->setFrame (static_cast<float> (number));
- }
- return JS_UNDEFINED;
- case AnimIsPlaying:
- return JS_NewBool (ctx, clock->isPlaying ());
- case AnimGetRate:
- return JS_NewFloat64 (ctx, clock->getRate ());
- case AnimSetRate:
- if (argc > 0 && JS_ToFloat64 (ctx, &number, argv[0]) == 0) {
- clock->setRate (static_cast<float> (number));
- }
- return JS_UNDEFINED;
- case AnimGetFrameCount:
- return JS_NewFloat64 (ctx, clock->getDefinition ().length);
- case AnimGetName:
- return JS_NewString (ctx, clock->getDefinition ().name.c_str ());
- case AnimGetFps:
- return JS_NewFloat64 (ctx, clock->getDefinition ().fps);
- default:
- return JS_UNDEFINED;
- }
- }
- JSValue makeAnimationHandle (JSContext* ctx, int systemId, int clockId) {
- JSValue handle = JS_NewObject (ctx);
- JSValue data[] = { JS_NewInt32 (ctx, systemId), JS_NewInt32 (ctx, clockId) };
- const auto method = [&] (const char* name, int op, int length) {
- JS_SetPropertyStr (ctx, handle, name, JS_NewCFunctionData (ctx, animation_access, length, op, 2, data));
- };
- const auto accessor = [&] (const char* name, int getter, int setter) {
- JSAtom atom = JS_NewAtom (ctx, name);
- JSValue get = JS_NewCFunctionData (ctx, animation_access, 0, getter, 2, data);
- JSValue set = setter < 0 ? JS_UNDEFINED : JS_NewCFunctionData (ctx, animation_access, 1, setter, 2, data);
- JS_DefinePropertyGetSet (ctx, handle, atom, get, set, JS_PROP_ENUMERABLE);
- JS_FreeAtom (ctx, atom);
- };
- method ("play", AnimPlay, 0);
- method ("pause", AnimPause, 0);
- method ("stop", AnimStop, 0);
- method ("getFrame", AnimGetFrame, 0);
- method ("setFrame", AnimSetFrame, 1);
- method ("isPlaying", AnimIsPlaying, 0);
- accessor ("rate", AnimGetRate, AnimSetRate);
- accessor ("frameCount", AnimGetFrameCount, -1);
- accessor ("name", AnimGetName, -1);
- accessor ("fps", AnimGetFps, -1);
- JS_FreeValue (ctx, data[0]);
- JS_FreeValue (ctx, data[1]);
- return handle;
- }
- // thisObject.getAnimation(): func_data is [animation system id, address of the property's DynamicValue]
- JSValue this_object_get_animation (
- JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic, JSValueConst* func_data
- ) {
- int systemId = 0;
- int64_t address = 0;
- JS_ToInt32 (ctx, &systemId, func_data[0]);
- JS_ToInt64 (ctx, &address, func_data[1]);
- auto* system = AnimationSystem::find (systemId);
- auto* value = reinterpret_cast<DynamicValue*> (static_cast<intptr_t> (address));
- auto* clock = system == nullptr ? nullptr : system->clockOf (*value);
- if (clock == nullptr) {
- return JS_NULL;
- }
- return makeAnimationHandle (ctx, systemId, clock->getId ());
- }
- } // namespace
- // thisObject.<property name>: lets a listener registered by a property's script write that property
- // from outside (`listener.thisObject['Inner Color'] = color`). func_data is [engine address, value address, is angles]
- JSValue this_object_property (
- JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic, JSValueConst* func_data
- ) {
- int64_t engineAddress = 0;
- int64_t valueAddress = 0;
- JS_ToInt64 (ctx, &engineAddress, func_data[0]);
- JS_ToInt64 (ctx, &valueAddress, func_data[1]);
- auto* engine = reinterpret_cast<ScriptEngine*> (static_cast<intptr_t> (engineAddress));
- auto* value = reinterpret_cast<DynamicValue*> (static_cast<intptr_t> (valueAddress));
- int64_t degrees = 0;
- JS_ToInt64 (ctx, °rees, func_data[2]);
- const std::string_view name = degrees != 0 ? "angles" : "";
- if (magic == 0) {
- return engine->propertyToJs (*value, name);
- }
- if (argc > 0) {
- engine->assignPropertyJsValue (argv[0], *value, name);
- }
- return JS_UNDEFINED;
- }
- JSValue ScriptEngine::makeThisObject (DynamicValue& value, const std::string& propertyName) {
- JSValue handle = JS_NewObject (this->m_context);
- JSValue data[] = {
- JS_NewInt32 (this->m_context, this->m_animations.getId ()),
- JS_NewInt64 (this->m_context, static_cast<int64_t> (reinterpret_cast<intptr_t> (&value))),
- };
- JS_SetPropertyStr (
- this->m_context, handle, "getAnimation",
- JS_NewCFunctionData (this->m_context, this_object_get_animation, 0, 0, 2, data)
- );
- JS_FreeValue (this->m_context, data[0]);
- JS_FreeValue (this->m_context, data[1]);
- if (!propertyName.empty ()) {
- JSValue propertyData[] = {
- JS_NewInt64 (this->m_context, static_cast<int64_t> (reinterpret_cast<intptr_t> (this))),
- JS_NewInt64 (this->m_context, static_cast<int64_t> (reinterpret_cast<intptr_t> (&value))),
- JS_NewInt64 (this->m_context, propertyName == "angles" ? 1 : 0),
- };
- JSAtom atom = JS_NewAtom (this->m_context, propertyName.c_str ());
- JS_DefinePropertyGetSet (
- this->m_context, handle, atom,
- JS_NewCFunctionData (this->m_context, this_object_property, 0, 0, 3, propertyData),
- JS_NewCFunctionData (this->m_context, this_object_property, 1, 1, 3, propertyData), JS_PROP_ENUMERABLE
- );
- JS_FreeAtom (this->m_context, atom);
- JS_FreeValue (this->m_context, propertyData[0]);
- JS_FreeValue (this->m_context, propertyData[1]);
- JS_FreeValue (this->m_context, propertyData[2]);
- }
- return handle;
- }
- void ScriptEngine::bindThisLayer (ScriptableObject& object, LoadedModule* module) {
- JS_SetPropertyStr (this->m_context, this->m_globalThis, "thisLayer", this->m_adapters.object->instantiate (object));
- // thisObject is the property the running script is attached to (what getAnimation() hangs
- // off), not the layer
- if (module == nullptr) {
- JS_SetPropertyStr (
- this->m_context, this->m_globalThis, "thisObject", this->m_adapters.object->instantiate (object)
- );
- return;
- }
- if (JS_IsUndefined (module->thisObject)) {
- module->thisObject = this->makeThisObject (module->value, module->propertyName);
- }
- JS_SetPropertyStr (
- this->m_context, this->m_globalThis, "thisObject", JS_DupValue (this->m_context, module->thisObject)
- );
- }
- void ScriptEngine::dispatchAnimationEvents () {
- for (const auto& event : this->m_animations.takeEvents ()) {
- const auto* clock = this->m_animations.clock (event.clock);
- if (clock == nullptr) {
- continue;
- }
- for (auto& [key, module] : this->m_scriptModules) {
- if (&module.value != &clock->getRootValue () || !module.initialized || module.dropped) {
- continue;
- }
- this->m_runningModule = &module;
- if (module.object != nullptr) {
- this->bindThisLayer (*module.object, &module);
- }
- JSValue eventObject = JS_NewObject (this->m_context);
- JS_SetPropertyStr (
- this->m_context, eventObject, "name", JS_NewString (this->m_context, event.name.c_str ())
- );
- JS_SetPropertyStr (this->m_context, eventObject, "frame", JS_NewFloat64 (this->m_context, event.frame));
- JSValue args[] = { eventObject, this->propertyToJs (module.value, module.propertyName) };
- JSValue result = this->call (module.module, 2, args, "animationEvent");
- if (JS_IsException (result)) {
- logJSException (this->m_context, key.c_str (), module.value.getScriptSource ());
- } else {
- // like update(), the handler's return value becomes the property's new value
- this->assignPropertyJsValue (result, module.value, module.propertyName);
- }
- JS_FreeValue (this->m_context, result);
- JS_FreeValue (this->m_context, args[0]);
- JS_FreeValue (this->m_context, args[1]);
- break;
- }
- }
- }
- void ScriptEngine::initializeModule (const std::string& key, LoadedModule& module) {
- module.initialized = true;
- // init() receives the property's static/base value exactly once, before update() starts being
- // called every tick - scripts commonly stash it (e.g. audio-reactive scale scripts scaling a
- // captured base value) and would otherwise read undefined forever.
- JSValue initArgs[] = { this->propertyToJs (module.value, module.propertyName) };
- const DynamicValue valueBeforeInit (module.value);
- JSValue initResult = this->call (module.module, 1, initArgs, "init");
- if (JS_IsException (initResult)) {
- logJSException (this->m_context, key.c_str (), module.value.getScriptSource ());
- } else if (
- valueBeforeInit.getType () == module.value.getType () && valueBeforeInit.getVec4 () == module.value.getVec4 ()
- && valueBeforeInit.getString () == module.value.getString ()
- ) {
- // init() may hand back a different starting value, unless something already moved the property meanwhile
- this->assignPropertyJsValue (initResult, module.value, module.propertyName);
- }
- JS_FreeValue (this->m_context, initResult);
- JS_FreeValue (this->m_context, initArgs[0]);
- // the real engine hands every user property to applyUserProperties() once at load (and only the
- // changed ones afterward) - scripts like music selectors rely on that first call to start up
- JSValue userProps = JS_NewObject (this->m_context);
- for (const auto& [name, property] : this->m_engineObject->getScene ().getUserProperties ()) {
- JS_SetPropertyStr (this->m_context, userProps, name.c_str (), this->userPropertyToJs (*property));
- }
- JSValue applyArgs[] = { userProps };
- JSValue applyResult = this->call (module.module, 1, applyArgs, "applyUserProperties");
- if (JS_IsException (applyResult)) {
- logJSException (this->m_context, key.c_str (), module.value.getScriptSource ());
- }
- JS_FreeValue (this->m_context, applyResult);
- JS_FreeValue (this->m_context, userProps);
- if (this->m_mediaSource.getMediaInfo ().available) {
- this->notifyMediaUpdate (this->m_mediaSource.getMediaInfo (), &module);
- }
- }
- namespace {
- constexpr const char* CURSOR_HANDLERS[]
- = { "cursorEnter", "cursorLeave", "cursorMove", "cursorDown", "cursorUp", "cursorClick" };
- }
- bool ScriptEngine::hasCursorHandlers (const ScriptableObject& object) {
- for (auto& module : this->m_scriptModules | std::views::values) {
- if (module.object != &object || !module.initialized) {
- continue;
- }
- if (module.cursorHandlers < 0) {
- module.cursorHandlers = 0;
- for (const char* handler : CURSOR_HANDLERS) {
- JSValue function = JS_GetPropertyStr (this->m_context, module.module, handler);
- if (JS_IsFunction (this->m_context, function)) {
- module.cursorHandlers = 1;
- }
- JS_FreeValue (this->m_context, function);
- }
- }
- if (module.cursorHandlers > 0) {
- return true;
- }
- }
- return false;
- }
- void ScriptEngine::dispatchCursorEvent (
- const char* handler, ScriptableObject& object, const glm::vec2& worldPosition, const glm::vec3& localPosition
- ) {
- // Vec3 per WE's lib.sceneScript.d.ts, scripts call add()/subtract() on them
- const auto makePosition = [this] (const glm::vec3& value) {
- DynamicValue position (value);
- return this->m_adapters.vec3->instantiate (position);
- };
- for (auto& [key, module] : this->m_scriptModules) {
- if (module.object != &object || !module.initialized || module.cursorHandlers <= 0) {
- continue;
- }
- this->m_runningModule = &module;
- this->bindThisLayer (*module.object, &module);
- JSValue event = JS_NewObject (this->m_context);
- JS_SetPropertyStr (this->m_context, event, "worldPosition", makePosition (glm::vec3 (worldPosition, 0.0f)));
- JS_SetPropertyStr (this->m_context, event, "localPosition", makePosition (localPosition));
- JSValue args[] = { event };
- JSValue result = this->call (module.module, 1, args, handler);
- if (JS_IsException (result)) {
- logJSException (this->m_context, key.c_str (), module.value.getScriptSource ());
- }
- JS_FreeValue (this->m_context, result);
- JS_FreeValue (this->m_context, event);
- }
- this->m_runningModule = nullptr;
- }
- void ScriptEngine::addVideoEndedCallback (VideoPlayback::MPV::GLPlayer* player, JSValueConst callback) {
- if (player == nullptr || !JS_IsFunction (this->m_context, callback)) {
- return;
- }
- this->m_videoEndedCallbacks.push_back ({ player, JS_DupValue (this->m_context, callback) });
- }
- void ScriptEngine::tick () {
- this->m_engineObject->tick ();
- // index based, a callback is free to register more callbacks (and reallocate the vector)
- for (size_t index = 0; index < this->m_videoEndedCallbacks.size (); index++) {
- auto& entry = this->m_videoEndedCallbacks[index];
- if (!entry.player->hasEnded ()) {
- entry.notified = false;
- continue;
- }
- if (entry.notified) {
- continue;
- }
- entry.notified = true;
- JSValue callback = JS_DupValue (this->m_context, entry.callback);
- JSValue result = JS_Call (this->m_context, callback, JS_UNDEFINED, 0, nullptr);
- if (JS_IsException (result)) {
- logJSException (this->m_context, "video ended callback");
- }
- JS_FreeValue (this->m_context, result);
- JS_FreeValue (this->m_context, callback);
- }
- // safe to erase retired modules now, nothing is mid-iteration and no queueScript() is on the stack
- for (const auto& key : this->m_retiredScriptKeys) {
- const auto it = this->m_scriptModules.find (key);
- if (it == this->m_scriptModules.end ()) {
- continue;
- }
- JS_FreeValue (this->m_context, it->second.module);
- JS_FreeValue (this->m_context, it->second.thisObject);
- this->m_scriptModules.erase (it);
- }
- this->m_retiredScriptKeys.clear ();
- // long stalls (window dragged, suspend) shouldn't skip whole animations
- this->m_animations.tick (std::clamp (g_Time - g_TimeLast, 0.0f, 0.25f));
- // run any pending notifications
- // scene order like WE, not the order of the keys: 3444535389's bee script reads shared values that the
- // setup script of an earlier object writes, run before it the bee's position turned NaN for good.
- // Keys are looked up again per module, an update() may rebind (erase and re-add) an entry.
- std::vector<std::pair<uint64_t, std::string>> order;
- order.reserve (this->m_scriptModules.size ());
- for (const auto& [key, module] : this->m_scriptModules) {
- order.emplace_back (module.order, key);
- }
- std::ranges::sort (order);
- for (const auto& [unused, key] : order) {
- const auto entry = this->m_scriptModules.find (key);
- if (entry == this->m_scriptModules.end () || entry->second.dropped) {
- continue;
- }
- auto& module = entry->second;
- this->m_runningModule = &module;
- // `thisLayer` is a single global binding shared by every module and only set once, at
- // registration in queueScript() - without rebinding it here, every module but the last
- // registered would see whatever object queueScript() last ran for, not its own layer.
- if (module.object != nullptr) {
- this->bindThisLayer (*module.object, &module);
- }
- if (!module.initialized) {
- this->initializeModule (key, module);
- }
- JSValue args[] = { this->propertyToJs (module.value, module.propertyName) };
- JSValue result = this->call (module.module, 1, args, "update");
- ScopeGuard guard ([result, args, this] () {
- JS_FreeValue (this->m_context, result);
- JS_FreeValue (this->m_context, args[0]);
- });
- if (JS_IsException (result)) {
- logJSException (this->m_context, key.c_str (), module.value.getScriptSource ());
- continue;
- }
- if (key.starts_with ("scale_")) {
- static int scaleDiagnosticCounter = 0;
- if (++scaleDiagnosticCounter >= 300) {
- scaleDiagnosticCounter = 0;
- sLog.debug (
- "scale script '", key, "': update() returned tag=", JS_VALUE_GET_TAG (result), ", current vec3 = (",
- module.value.getVec3 ().x, ", ", module.value.getVec3 ().y, ", ", module.value.getVec3 ().z, ")"
- );
- }
- // Edge-triggered marker for when a pulse lands visually, timestamped so it can be
- // correlated against when the sound was played.
- static std::map<std::string, bool> wasPulsing;
- const bool pulsingNow = std::abs (module.value.getVec3 ().x - 1.0f) > 0.03f;
- if (pulsingNow && !wasPulsing[key]) {
- const auto now = std::chrono::system_clock::now ();
- const auto ms = std::chrono::duration_cast<std::chrono::milliseconds> (now.time_since_epoch ()) % 1000;
- const std::time_t t = std::chrono::system_clock::to_time_t (now);
- std::tm tmBuf {};
- localtime_r (&t, &tmBuf);
- char buf[16];
- std::strftime (buf, sizeof (buf), "%H:%M:%S", &tmBuf);
- sLog.debug (
- "[", buf, ".", (ms.count () < 100 ? "0" : ""), (ms.count () < 10 ? "0" : ""), ms.count (),
- "] scale script '", key, "': PULSE, vec3.x=", module.value.getVec3 ().x
- );
- }
- wasPulsing[key] = pulsingNow;
- }
- this->assignPropertyJsValue (result, module.value, module.propertyName);
- }
- this->dispatchAnimationEvents ();
- }
- Media::ThumbnailPalette ScriptEngine::thumbnailPaletteFor (const Media::MediaSource::MediaInfo& media) {
- if (!media.url.has_value ()) {
- return {};
- }
- // every module gets the event, decode the cover once per file
- if (this->m_paletteUrl == *media.url) {
- return this->m_palette;
- }
- this->m_paletteUrl = *media.url;
- this->m_palette = {};
- std::string path = *media.url;
- if (!path.starts_with ("file://")) {
- return this->m_palette;
- }
- this->m_palette = Media::loadThumbnailPalette (path.substr (7));
- return this->m_palette;
- }
- void ScriptEngine::notifyMediaUpdate (const Media::MediaSource::MediaInfo& media, LoadedModule* only) {
- JSContext* ctx = this->m_context;
- const auto palette = this->thumbnailPaletteFor (media);
- DynamicValue primaryColorValue (palette.primary);
- DynamicValue secondaryColorValue (palette.secondary);
- DynamicValue tertiaryColorValue (palette.tertiary);
- DynamicValue textColorValue (palette.text);
- DynamicValue highContrastColorValue (palette.highContrast);
- JSValue primaryColor = this->m_adapters.vec3->instantiate (primaryColorValue, true);
- JSValue secondaryColor = this->m_adapters.vec3->instantiate (secondaryColorValue, true);
- JSValue tertiaryColor = this->m_adapters.vec3->instantiate (tertiaryColorValue, true);
- JSValue textColor = this->m_adapters.vec3->instantiate (textColorValue, true);
- JSValue highContrastColor = this->m_adapters.vec3->instantiate (highContrastColorValue, true);
- JSValue propertiesEvent = JS_NewObject (ctx);
- JS_SetPropertyStr (ctx, propertiesEvent, "title", JS_NewString (ctx, media.title.c_str ()));
- JS_SetPropertyStr (ctx, propertiesEvent, "artist", JS_NewString (ctx, media.artist.c_str ()));
- JS_SetPropertyStr (ctx, propertiesEvent, "albumTitle", JS_NewString (ctx, media.album.c_str ()));
- JSValue playbackEvent = JS_NewObject (ctx);
- JS_SetPropertyStr (ctx, playbackEvent, "state", JS_NewInt32 (ctx, media.playbackState));
- JSValue mediaTimelineEvent = JS_NewObject (ctx);
- JS_SetPropertyStr (ctx, mediaTimelineEvent, "position", JS_NewFloat64 (ctx, media.position));
- JS_SetPropertyStr (ctx, mediaTimelineEvent, "duration", JS_NewFloat64 (ctx, media.duration));
- JSValue mediaThumbnailEvent = JS_NewObject (ctx);
- JS_SetPropertyStr (ctx, mediaThumbnailEvent, "hasThumbnail", JS_NewBool (ctx, media.url.has_value ()));
- JS_SetPropertyStr (ctx, mediaThumbnailEvent, "primaryColor", primaryColor);
- JS_SetPropertyStr (ctx, mediaThumbnailEvent, "secondaryColor", secondaryColor);
- JS_SetPropertyStr (ctx, mediaThumbnailEvent, "tertiaryColor", tertiaryColor);
- JS_SetPropertyStr (ctx, mediaThumbnailEvent, "textColor", textColor);
- JS_SetPropertyStr (ctx, mediaThumbnailEvent, "highContrastColor", highContrastColor);
- JSValue propertiesArgs[] = { propertiesEvent };
- JSValue playbackArgs[] = { playbackEvent };
- JSValue mediaTimelineArgs[] = { mediaTimelineEvent };
- JSValue mediaThumbnailArgs[] = { mediaThumbnailEvent };
- for (auto& module : this->m_scriptModules | std::views::values) {
- // modules that haven't run init() yet get the current media state replayed once they do
- if (!module.initialized || (only != nullptr && only != &module)) {
- continue;
- }
- JSValue result1 = this->call (module.module, 1, propertiesArgs, "mediaPropertiesChanged");
- JSValue result2 = this->call (module.module, 1, playbackArgs, "mediaPlaybackChanged");
- JSValue result3 = this->call (module.module, 1, mediaTimelineArgs, "mediaTimelineChanged");
- JSValue result4 = this->call (module.module, 1, mediaThumbnailArgs, "mediaThumbnailChanged");
- JS_FreeValue (ctx, result1);
- JS_FreeValue (ctx, result2);
- JS_FreeValue (ctx, result3);
- JS_FreeValue (ctx, result4);
- }
- // free all created objects as we don't keep a ref to them anymore
- JS_FreeValue (ctx, propertiesEvent);
- JS_FreeValue (ctx, playbackEvent);
- JS_FreeValue (ctx, mediaTimelineEvent);
- JS_FreeValue (ctx, mediaThumbnailEvent);
- }
|