From 18c333b08952b537de6f6f4866ea0796769fb571 Mon Sep 17 00:00:00 2001 From: Hirek193 Date: Tue, 10 Nov 2026 00:57:37 +0100 Subject: [PATCH] wasm display support --- .gitmodules | 4 + CMakeLists.txt | 15 + CMake_modules/wamr/CMakeLists.txt | 67 +++ application/application.cpp | 7 + application/application.h | 6 + model/Texture.cpp | 4 + ref/wamr | 1 + scripting/wasmscreenkernels.cpp | 242 ++++++++ scripting/wasmscreenkernels.h | 36 ++ scripting/wasmscreenrules.cpp | 126 ++++ scripting/wasmscreenrules.h | 74 +++ scripting/wasmscreens.cpp | 942 ++++++++++++++++++++++++++++++ scripting/wasmscreens.h | 72 +++ scripting/wasmscreensstub.cpp | 51 ++ tests/unit/CMakeLists.txt | 5 + tests/unit/test_wasm_host.cpp | 288 +++++++++ tests/unit/test_wasm_kernels.cpp | 121 ++++ tests/unit/test_wasm_screens.cpp | 103 ++++ utilities/Globals.cpp | 33 ++ utilities/Globals.h | 4 + vehicle/DynObj.cpp | 77 ++- vehicle/DynObj.h | 13 + vehicle/Train.cpp | 110 +++- vehicle/Train.h | 14 + wasm_screens_doc.md | 129 ++++ 25 files changed, 2521 insertions(+), 23 deletions(-) create mode 100644 CMake_modules/wamr/CMakeLists.txt create mode 160000 ref/wamr create mode 100644 scripting/wasmscreenkernels.cpp create mode 100644 scripting/wasmscreenkernels.h create mode 100644 scripting/wasmscreenrules.cpp create mode 100644 scripting/wasmscreenrules.h create mode 100644 scripting/wasmscreens.cpp create mode 100644 scripting/wasmscreens.h create mode 100644 scripting/wasmscreensstub.cpp create mode 100644 tests/unit/test_wasm_host.cpp create mode 100644 tests/unit/test_wasm_kernels.cpp create mode 100644 tests/unit/test_wasm_screens.cpp create mode 100644 wasm_screens_doc.md diff --git a/.gitmodules b/.gitmodules index df7f5dda..55d108a7 100644 --- a/.gitmodules +++ b/.gitmodules @@ -32,3 +32,7 @@ [submodule "betterRenderer/thirdparty/bvh"] path = betterRenderer/thirdparty/bvh url = https://github.com/madmann91/bvh.git +[submodule "ref/wamr"] + path = ref/wamr + url = https://github.com/bytecodealliance/wasm-micro-runtime + shallow = true diff --git a/CMakeLists.txt b/CMakeLists.txt index 7137ab39..b75f3295 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -72,6 +72,7 @@ option(WITH_OPENGL_LEGACY "Compile with OpenGL legacy renderer" ON) option(WITH_PYTHON "Python 3.14 embedded scripting" ON) option(WITH_UART "Compile with libserialport" ON) option(WITH_LUA "Compile with lua scripting support" ON) +option(WITH_WASM "WebAssembly screens (wasmscreen:, wasmdestinationsign:), run by the WAMR interpreter in ref/wamr" ON) option(WITH_OPENVR "Compile with OpenVR" OFF) option(WITH_ZMQ "Compile with cppzmq" OFF) option(WITH_CRASHPAD "Compile with crashpad" OFF) @@ -321,6 +322,13 @@ if (WITH_LUA) set(SOURCES ${SOURCES} "scripting/lua.cpp") endif() +if (WITH_WASM) + set(DEFINITIONS ${DEFINITIONS} "WITH_WASM") + set(SOURCES ${SOURCES} "scripting/wasmscreens.cpp") +else() + set(SOURCES ${SOURCES} "scripting/wasmscreensstub.cpp") +endif() + if (WITHDUMPGEN) set(DEFINITIONS ${DEFINITIONS} "WITHDUMPGEN") endif() @@ -463,6 +471,8 @@ set(CORE_SOURCES "utilities/parser.cpp" "utilities/utilities.cpp" "scripting/ladderlogic.cpp" +"scripting/wasmscreenrules.cpp" +"scripting/wasmscreenkernels.cpp" "simulation/simulationtestrules.cpp" "vehicle/enginestart.cpp" "vehicle/handover.cpp" @@ -677,6 +687,11 @@ if(WIN32 AND WITH_PYTHON) ) endif() +if (WITH_WASM) + add_subdirectory("CMake_modules/wamr" "${CMAKE_CURRENT_BINARY_DIR}/wamr") + target_link_libraries(${PROJECT_NAME} eu07_wamr) +endif () + if (WITH_BETTER_RENDERER) add_subdirectory("betterRenderer") set_target_properties(betterRenderer betterRenderer_fsr2 betterRenderer_shaders PROPERTIES FOLDER "renderers") diff --git a/CMake_modules/wamr/CMakeLists.txt b/CMake_modules/wamr/CMakeLists.txt new file mode 100644 index 00000000..74e82358 --- /dev/null +++ b/CMake_modules/wamr/CMakeLists.txt @@ -0,0 +1,67 @@ +# The WebAssembly Micro Runtime (ref/wamr) for the wasm screens (scripting/wasmscreens.cpp). +# +# Built here, in a directory of its own, because the WAMR build scripts set their configuration with +# add_definitions() and include_directories(): kept in this directory, none of it reaches the game's sources. +# Only the interpreter is built (see WAMR_BUILD_FAST_INTERP), with instruction metering so that a screen stuck in a loop +# can't hang the worker thread, and without the signal-based bounds checks, which would compete with the +# game's own crash handling. + +if (WIN32) + set(WAMR_BUILD_PLATFORM "windows") +elseif (APPLE) + set(WAMR_BUILD_PLATFORM "darwin") +else () + set(WAMR_BUILD_PLATFORM "linux") +endif () + +set(WAMR_ROOT_DIR "${CMAKE_SOURCE_DIR}/ref/wamr") + +set(WAMR_BUILD_INTERP 1) +# the fast interpreter counts the instructions only where it dispatches with computed gotos (GCC, Clang): built with +# MSVC it would let a screen stuck in a loop hang the worker. the classic interpreter counts them everywhere +if (MSVC) + set(WAMR_BUILD_FAST_INTERP 0) +else () + set(WAMR_BUILD_FAST_INTERP 1) +endif () +set(WAMR_BUILD_AOT 0) +set(WAMR_BUILD_JIT 0) +set(WAMR_BUILD_FAST_JIT 0) +set(WAMR_BUILD_LIBC_BUILTIN 0) +set(WAMR_BUILD_LIBC_WASI 0) +set(WAMR_BUILD_MULTI_MODULE 0) +set(WAMR_BUILD_LIB_PTHREAD 0) +set(WAMR_BUILD_LIB_WASI_THREADS 0) +set(WAMR_BUILD_THREAD_MGR 0) +set(WAMR_BUILD_SIMD 0) +set(WAMR_BUILD_BULK_MEMORY 1) +set(WAMR_BUILD_REF_TYPES 1) +set(WAMR_BUILD_MINI_LOADER 0) +set(WAMR_BUILD_INSTRUCTION_METERING 1) +# NOTE: the calls into the natives use WAMR's assembler invokeNative (MASM with MSVC). its C version +# (WAMR_BUILD_INVOKE_NATIVE_GENERAL) crashes on Windows x64 as soon as a module runs +set(WAMR_DISABLE_HW_BOUND_CHECK 1) +set(WAMR_DISABLE_STACK_HW_BOUND_CHECK 1) + +find_package(Threads REQUIRED) +include("${WAMR_ROOT_DIR}/build-scripts/runtime_lib.cmake") + +add_library(eu07_wamr STATIC ${WAMR_RUNTIME_LIB_SOURCE}) +target_include_directories(eu07_wamr INTERFACE "${WAMR_ROOT_DIR}/core/iwasm/include") +# with MSVC the WAMR headers declare the API dllimport unless told otherwise; the library is linked statically +target_compile_definitions(eu07_wamr INTERFACE "WASM_RUNTIME_API_EXTERN=") +target_link_libraries(eu07_wamr PRIVATE Threads::Threads) +if (WIN32) + target_link_libraries(eu07_wamr PRIVATE ws2_32 ntdll) +else () + target_link_libraries(eu07_wamr PRIVATE m ${CMAKE_DL_LIBS}) +endif () +if (MSVC) + # third-party code: its warnings are not ours to fix + target_compile_options(eu07_wamr PRIVATE /W0) +else () + target_compile_options(eu07_wamr PRIVATE -w) +endif () +set_target_properties(eu07_wamr PROPERTIES + ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib" + FOLDER "libraries") diff --git a/application/application.cpp b/application/application.cpp index a398117c..98f004a9 100644 --- a/application/application.cpp +++ b/application/application.cpp @@ -425,6 +425,11 @@ int eu07_application::init(int Argc, char *Argv[]) { m_taskqueue.init(); } + crashreport_add_info("wasm_enabled", Global.wasm_enabled ? "yes" : "no"); + if (Global.wasm_enabled) + { + m_wasmqueue.init(); + } if ((result = init_modes()) != 0) { ErrorLog("Failed to initialize game modes"); @@ -635,6 +640,7 @@ int eu07_application::run() EditorTerrain.update(Global.pCamera.Pos); m_taskqueue.update(); + m_wasmqueue.update(); opengl_texture::reset_unit_cache(); if (!GfxRenderer->Render()) @@ -732,6 +738,7 @@ void eu07_application::exit() // the python workers detach their GL context on the way out, so they have to be done before the windows they use are destroyed m_taskqueue.exit(); + m_wasmqueue.exit(); step("the windows"); for (auto *window : m_windows) { diff --git a/application/application.h b/application/application.h index 11415603..4a1d1ad1 100644 --- a/application/application.h +++ b/application/application.h @@ -11,6 +11,7 @@ http://mozilla.org/MPL/2.0/. #include "application/applicationmode.h" #include "scripting/PyInt.h" +#include "scripting/wasmscreens.h" #include "network/manager.h" #include "utilities/headtrack.h" #ifdef WITH_UART @@ -49,6 +50,10 @@ public: // frees the python gil and swaps out the main thread void release_python_lock(); + // the screens drawn by WebAssembly modules + wasm_screen_queue & + wasm_screens() { + return m_wasmqueue; } void exit(); void @@ -122,6 +127,7 @@ private: modeptr_array m_modes { nullptr }; // collection of available application behaviour modes mode_stack m_modestack; // current behaviour mode python_taskqueue m_taskqueue; + wasm_screen_queue m_wasmqueue; std::vector m_windows; int m_glfwversion; diff --git a/model/Texture.cpp b/model/Texture.cpp index 4a7b2479..e9dc063d 100644 --- a/model/Texture.cpp +++ b/model/Texture.cpp @@ -722,6 +722,10 @@ void opengl_texture::make_request() { auto const components { Split( name, '?' ) }; + if( components.front().ends_with( ".wasm" ) ) { + // wasm destination signs are drawn by the vehicles showing them (wasm_screen_queue) + return; + } auto dictionary = std::make_shared( components.back() ); diff --git a/ref/wamr b/ref/wamr new file mode 160000 index 00000000..25bd7eb6 --- /dev/null +++ b/ref/wamr @@ -0,0 +1 @@ +Subproject commit 25bd7eb63e828e4bd242cc9b38d260b4b31c6605 diff --git a/scripting/wasmscreenkernels.cpp b/scripting/wasmscreenkernels.cpp new file mode 100644 index 00000000..4353f189 --- /dev/null +++ b/scripting/wasmscreenkernels.cpp @@ -0,0 +1,242 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +#include "stdafx.h" +#include "scripting/wasmscreenkernels.h" + +#include +#include +#include +#include +#include + +namespace wasm_screens +{ + +namespace +{ + +constexpr int glow_radius{6}; +constexpr double glow_factor{0.08}; + +using light = std::array; + +// led_util's get_px_str: the light gathered from the diodes around, weighted 1 / (0.08 + distance squared), the sums +// taken in the same order as there to get the very same numbers +light glow_at(std::span const Source, int const Width, int const Height, int const X, int const Y) +{ + std::array sum{}; + auto const left{std::max(X - glow_radius, 0)}; + auto const right{std::min(X + glow_radius + 1, Width)}; + auto const top{std::max(Y - glow_radius, 0)}; + auto const bottom{std::min(Y + glow_radius + 1, Height)}; + for (auto i = left; i < right; ++i) + { + auto const m{glow_factor + static_cast((i - X) * (i - X))}; + if (m > 20.0) + { + continue; + } + for (auto j = top; j < bottom; ++j) + { + auto const s{m + static_cast((j - Y) * (j - Y))}; + if (s > 20.0) + { + continue; + } + auto const *pixel{&Source[(static_cast(j) * Width + i) * 4]}; + for (std::size_t channel = 0; channel < 3; ++channel) + { + sum[channel] += static_cast(pixel[channel]) / s; + } + } + } + return {static_cast(sum[0] * glow_factor), static_cast(sum[1] * glow_factor), static_cast(sum[2] * glow_factor)}; +} + +double sinc(double X) +{ + if (X == 0.0) + { + return 1.0; + } + X *= std::numbers::pi; + return std::sin(X) / X; +} + +double lanczos(double const X) +{ + if (X >= -3.0 && X < 3.0) + { + return sinc(X) * sinc(X / 3.0); + } + return 0.0; +} + +constexpr int precision_bits{32 - 8 - 2}; + +// the fixed-point weights of one pass, as Pillow's Resample.c (precompute_coeffs, normalize_coeffs_8bpc) makes them +struct coefficients +{ + int size{0}; + std::vector> bounds; // first input sample and the number of samples, per output sample + std::vector weights; + + coefficients(int const Input, int const Output) + { + auto const scale{static_cast(Input) / static_cast(Output)}; + auto const filterscale{std::max(scale, 1.0)}; + auto const support{3.0 * filterscale}; + size = static_cast(std::ceil(support)) * 2 + 1; + bounds.reserve(Output); + weights.assign(static_cast(Output) * size, 0); + std::vector prepared(size); + for (auto xx = 0; xx < Output; ++xx) + { + auto const center{(xx + 0.5) * scale}; + auto const ss{1.0 / filterscale}; + auto const xmin{std::max(static_cast(center - support + 0.5), 0)}; + auto const xmax{std::min(static_cast(center + support + 0.5), Input) - xmin}; + auto total{0.0}; + for (auto x = 0; x < xmax; ++x) + { + prepared[x] = lanczos((x + xmin - center + 0.5) * ss); + total += prepared[x]; + } + for (auto x = 0; x < xmax; ++x) + { + if (total != 0.0) + { + prepared[x] /= total; + } + auto const fixed{prepared[x] * static_cast(1 << precision_bits)}; + weights[static_cast(xx) * size + x] = static_cast(prepared[x] < 0.0 ? -0.5 + fixed : 0.5 + fixed); + } + bounds.emplace_back(xmin, xmax); + } + } + + static std::uint8_t clip(std::int64_t const Sum) + { + if (Sum >= (std::int64_t{1} << (precision_bits + 8))) + { + return 255; + } + if (Sum <= 0) + { + return 0; + } + return static_cast(Sum >> precision_bits); + } +}; + +constexpr std::int64_t rounding{std::int64_t{1} << (precision_bits - 1)}; + +// one pass of the resampling, along rows (Step 4: neighbouring pixels) or along columns (Step: a row) +struct pass +{ + coefficients const &weights; + std::size_t step; // bytes between the samples the pass mixes +}; + +std::uint8_t mix(std::uint8_t const *First, pass const &Pass, std::size_t const Index) +{ + auto const count{Pass.weights.bounds[Index].second}; + auto const offset{Index * static_cast(Pass.weights.size)}; + auto sum{rounding}; + for (auto sample = 0; sample < count; ++sample) + { + sum += static_cast(First[sample * Pass.step]) * Pass.weights.weights[offset + sample]; + } + return coefficients::clip(sum); +} + +} // namespace + +bool led_post(std::span const Source, int const Width, int const Height, std::span const Target, bool const Bloom) +{ + auto const targetwidth{Width * 2 + 2}; + auto const targetheight{Height * 2 + 2}; + if (Width <= 0 || Height <= 0 || Source.size() < image_size(Width, Height) || Target.size() < image_size(targetwidth, targetheight)) + { + return false; + } + // the light of every diode; above 255 it is clamped only in the final pixels, as led_util (Pillow) does + std::vector diodes(static_cast(Width) * Height); + for (auto y = 0; y < Height; ++y) + { + for (auto x = 0; x < Width; ++x) + { + auto const at{(static_cast(y) * Width + x) * 4}; + diodes[static_cast(y) * Width + x] = Bloom ? glow_at(Source, Width, Height, x, y) : light{Source[at], Source[at + 1], Source[at + 2]}; + } + } + // a black frame, then the diodes + for (std::size_t idx = 0; idx < image_size(targetwidth, targetheight); ++idx) + { + Target[idx] = idx % 4 == 3 ? 255 : 0; + } + for (auto i = 0; i < Width * 2; ++i) + { + for (auto j = 0; j < Height * 2; ++j) + { + auto const &diode{diodes[static_cast(j / 2) * Width + i / 2]}; + auto const shade{1.0 - static_cast(i % 2 + j % 2) * 0.15}; + auto *pixel{&Target[(static_cast(j + 1) * targetwidth + (i + 1)) * 4]}; + for (std::size_t channel = 0; channel < 3; ++channel) + { + pixel[channel] = static_cast(std::clamp(static_cast(static_cast(diode[channel]) * shade), 0, 255)); + } + } + } + return true; +} + +bool resize_lanczos(std::span const Source, int const Width, int const Height, std::span const Target, int const TargetWidth, + int const TargetHeight) +{ + if (Width <= 0 || Height <= 0 || TargetWidth <= 0 || TargetHeight <= 0 || Source.size() < image_size(Width, Height) || + Target.size() < image_size(TargetWidth, TargetHeight)) + { + return false; + } + coefficients const horizontal{Width, TargetWidth}; + coefficients const vertical{Height, TargetHeight}; + + std::vector temporary(image_size(TargetWidth, Height)); + pass const along_row{horizontal, 4}; + for (auto y = 0; y < Height; ++y) + { + for (auto x = 0; x < TargetWidth; ++x) + { + auto const *first{&Source[(static_cast(y) * Width + horizontal.bounds[x].first) * 4]}; + for (std::size_t channel = 0; channel < 3; ++channel) + { + temporary[(static_cast(y) * TargetWidth + x) * 4 + channel] = mix(first + channel, along_row, x); + } + } + } + pass const along_column{vertical, static_cast(TargetWidth) * 4}; + for (auto y = 0; y < TargetHeight; ++y) + { + for (auto x = 0; x < TargetWidth; ++x) + { + auto const *first{&temporary[(static_cast(vertical.bounds[y].first) * TargetWidth + x) * 4]}; + auto *pixel{&Target[(static_cast(y) * TargetWidth + x) * 4]}; + for (std::size_t channel = 0; channel < 3; ++channel) + { + pixel[channel] = mix(first + channel, along_column, y); + } + pixel[3] = 255; + } + } + return true; +} + +} // namespace wasm_screens diff --git a/scripting/wasmscreenkernels.h b/scripting/wasmscreenkernels.h new file mode 100644 index 00000000..15400e4b --- /dev/null +++ b/scripting/wasmscreenkernels.h @@ -0,0 +1,36 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +#pragma once + +#include +#include + +// image filters the wasm screens call through the host (eu07.led_post, eu07.resize_lanczos), run natively rather than +// in the interpreter. they give the very pixels of the Python screens: led_util.render_post() and Pillow's +// resize(..., Image.LANCZOS). the images are RGBA, 4 bytes a pixel, row by row; alpha is ignored and written as 255 +namespace wasm_screens +{ + +// the size in bytes of an RGBA image +constexpr std::size_t image_size(int const Width, int const Height) +{ + return static_cast(Width) * static_cast(Height) * 4; +} + +// led_util's render_post: every diode becomes 2x2 pixels with a darker grid between them, glowing into its neighbours +// (Bloom), in a 1 pixel black frame. Target takes (2 Width + 2) x (2 Height + 2) pixels. returns false for sizes that +// don't fit the spans +bool led_post(std::span Source, int Width, int Height, std::span Target, bool Bloom); + +// Pillow's resize with the Lanczos filter, horizontally then vertically, each pass rounded to 8 bits. returns false +// for sizes that don't fit the spans +bool resize_lanczos(std::span Source, int Width, int Height, std::span Target, int TargetWidth, int TargetHeight); + +} // namespace wasm_screens diff --git a/scripting/wasmscreenrules.cpp b/scripting/wasmscreenrules.cpp new file mode 100644 index 00000000..efd4bf42 --- /dev/null +++ b/scripting/wasmscreenrules.cpp @@ -0,0 +1,126 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +#include "stdafx.h" +#include "scripting/wasmscreenrules.h" + +#include "utilities/parser.h" + +#include + +namespace wasm_screens +{ + +void screen_config::deserialize(cParser &Input) +{ + while (deserialize_mapping(Input)) + { + // all work done by while() + } +} + +bool screen_config::deserialize_mapping(cParser &Input) +{ + // token can be a key or block end + auto const key{Input.getToken(true, "\n\r\t ,;[]")}; + + if (key.empty() || key == "}") + { + return false; + } + + if (key == "{") + { + script = Input.getToken(); + } + else if (key == "target:") + { + target = Input.getToken(); + } + else if (key == "frames:") + { + frames = Input.getToken(); + } + else if (key == "parameters:") + { + parameters = Input.getToken(); + } + else if (key == "instance:") + { + instancing = Input.getToken(); + } + else if (key == "background:") + { + background = Input.getToken(); + } + else + { + // a key of a later version: skip its value + Input.getToken(); + } + + return true; +} + +frame_schedule::frame_schedule(std::uint64_t const Start, int const Interval, int const Phase) + : m_interval{std::max(Interval, 1)}, m_next{Start + static_cast(std::max(Phase, 0))} +{ +} + +int frame_schedule::effective_interval(int const Configured, int const Requested) +{ + return std::max({Configured, Requested, 1}); +} + +int frame_schedule::phase(std::string_view const Key, int const Interval) +{ + if (Interval <= 1) + { + return 0; + } + // FNV-1a: the same offset for the same name on every compiler + std::uint32_t hash{2166136261u}; + for (auto const character : Key) + { + hash ^= static_cast(character); + hash *= 16777619u; + } + return static_cast(hash % static_cast(Interval)); +} + +bool frame_schedule::poll(std::uint64_t const Frame) +{ + if (Frame < m_next) + { + return false; + } + m_next = Frame + static_cast(m_interval); + return true; +} + +bool is_safe_relative_path(std::string_view const Path) +{ + if (Path.empty() || Path.front() == '/' || Path.front() == '\\' || Path.find(':') != std::string_view::npos) + { + return false; + } + std::size_t start{0}; + while (start <= Path.size()) + { + auto const end{std::min(Path.find_first_of("/\\", start), Path.size())}; + if (Path.substr(start, end - start) == "..") + { + return false; + } + start = end + 1; + } + return true; +} + +} // namespace wasm_screens diff --git a/scripting/wasmscreenrules.h b/scripting/wasmscreenrules.h new file mode 100644 index 00000000..923cf926 --- /dev/null +++ b/scripting/wasmscreenrules.h @@ -0,0 +1,74 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +#pragma once + +#include +#include +#include + +class cParser; + +// the parts of the wasm screens that don't need the runtime: the .mmd entries, the frame schedule and the +// file access rules. kept apart from scripting/wasmscreens.cpp so the unit tests can check them +namespace wasm_screens +{ + +// a wasm screen as the .mmd file describes it: +// wasmscreen: { script.wasm target: submodel frames: 4 parameters: key=value&key=value } +// wasmdestinationsign: { script.wasm frames: 30 instance: name parameters: ... background: texture } +// every key but the script is optional, and an unknown key is skipped along with its value +struct screen_config +{ + std::string script; // the .wasm file, relative to the vehicle folder when it has no path of its own + std::string target{"none"}; // cab screens: the submodel the screen is drawn on + std::string parameters; // extra key=value pairs passed to the screen with the simulation state + std::string instancing; // destination signs: "name" or "type", one sign per vehicle or per vehicle type + std::string background; // destination signs: the texture shown while the sign is off + int frames{0}; // the screen's own refresh interval in frames, used when longer than the one set in eu07.ini + + void deserialize(cParser &Input); + + private: + bool deserialize_mapping(cParser &Input); +}; + +// how often a screen gets fresh data, counted in rendered frames +class frame_schedule +{ + public: + frame_schedule() = default; + // Start: the frame the screen was created on. the first refresh comes Phase frames later + frame_schedule(std::uint64_t Start, int Interval, int Phase); + + // the interval of a screen: the one set in eu07.ini, or the screen's own one when that is longer. at least 1 + static int effective_interval(int Configured, int Requested); + // a fixed offset in [0, Interval) taken from the screen's name, so that the screens sharing an interval + // don't all refresh on the same frame + static int phase(std::string_view Key, int Interval); + + // true when the screen is due on Frame, and then not again until Interval frames later. a screen that + // wasn't asked for a while (a vehicle out of sight) is due on the first frame it is asked again + bool poll(std::uint64_t Frame); + + int interval() const + { + return m_interval; + } + + private: + int m_interval{1}; + std::uint64_t m_next{0}; +}; + +// true for a file a screen may read: a relative path inside the screen's folder, without '..', without a +// drive or a leading separator +bool is_safe_relative_path(std::string_view Path); + +} // namespace wasm_screens diff --git a/scripting/wasmscreens.cpp b/scripting/wasmscreens.cpp new file mode 100644 index 00000000..26521435 --- /dev/null +++ b/scripting/wasmscreens.cpp @@ -0,0 +1,942 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +#include "stdafx.h" +#include "scripting/wasmscreens.h" +#include "scripting/wasmscreenkernels.h" + +#include "input/command.h" +#include "interfaces/ITexture.h" +#include "utilities/Globals.h" +#include "utilities/Logs.h" +#include "utilities/dictionary.h" +#include "utilities/utilities.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +// the wasm stack of a screen, in bytes +constexpr std::uint32_t stack_size{64 * 1024}; +// the most instructions one call into a screen may run, so that a screen stuck in a loop can't hang the worker +constexpr int instruction_limit{200'000'000}; +// the largest side of a screen image, in pixels +constexpr int max_image_side{4096}; +constexpr std::size_t no_texture{std::numeric_limits::max()}; + +// the type of a value in the input of a screen, as eu07.value_type() reports it +enum class value_type : std::int32_t +{ + none = 0, + floating = 1, + integer = 2, + boolean = 3, + string = 4, + vec2_list = 5 +}; + +// the input of one refresh, indexed by key. when two values share a key the later one counts, as it does for the +// python screens, which get the values in the same order +class input_view +{ + public: + struct entry + { + value_type type{value_type::none}; + std::size_t index{0}; + }; + + explicit input_view(dictionary_source const &Source) : m_source{Source} + { + index(Source.floats, value_type::floating); + index(Source.integers, value_type::integer); + index(Source.bools, value_type::boolean); + index(Source.strings, value_type::string); + index(Source.vec2_lists, value_type::vec2_list); + } + + entry find(std::string_view const Key) const + { + if (auto const lookup{m_entries.find(Key)}; lookup != m_entries.end()) + { + return lookup->second; + } + return {}; + } + + dictionary_source const &source() const + { + return m_source; + } + + private: + template void index(Sequence_ const &Values, value_type const Type) + { + for (std::size_t idx = 0; idx < Values.size(); ++idx) + { + m_entries.insert_or_assign(std::string_view{Values[idx].first}, entry{Type, idx}); + } + } + + dictionary_source const &m_source; + std::unordered_map m_entries; +}; + +struct pending_command +{ + std::string name; + std::int32_t param1{0}; + std::int32_t param2{0}; + std::uint32_t vehicle{0}; +}; + +// the length of the UTF-8 sequence starting with the byte, 0 for a byte that can't start one +std::size_t utf8_length(std::uint8_t const Lead) +{ + if (Lead < 0x80) + { + return 1; + } + if (Lead >= 0xC2 && Lead <= 0xDF) + { + return 2; + } + if (Lead >= 0xE0 && Lead <= 0xEF) + { + return 3; + } + if (Lead >= 0xF0 && Lead <= 0xF4) + { + return 4; + } + return 0; +} + +// true for text in valid UTF-8 +bool is_utf8(std::string_view const Text) +{ + std::size_t idx{0}; + while (idx < Text.size()) + { + auto const length{utf8_length(static_cast(Text[idx]))}; + if (length == 0 || idx + length > Text.size()) + { + return false; + } + auto const sequence{Text.substr(idx + 1, length - 1)}; + if (false == std::ranges::all_of(sequence, [](char const Byte) { return (static_cast(Byte) & 0xC0) == 0x80; })) + { + return false; + } + idx += length; + } + return true; +} + +std::int32_t to_int32(double const Value) +{ + if (std::isnan(Value)) + { + return 0; + } + return static_cast(std::clamp(Value, static_cast(std::numeric_limits::min()), static_cast(std::numeric_limits::max()))); +} + +std::int32_t to_int32(std::size_t const Value) +{ + return static_cast(std::min(Value, std::numeric_limits::max())); +} + +} // namespace + +class wasm_screen +{ + public: + wasm_screen(wasm_screen_queue::screen_request const &Request, wasm_screens::frame_schedule const &Schedule) + : kind{Request.kind}, script{Request.script}, folder{substr_path(Request.script)}, texture{Request.texture}, m_schedule{Schedule} + { + } + ~wasm_screen() + { + release_runtime(); + } + wasm_screen(wasm_screen const &) = delete; + wasm_screen &operator=(wasm_screen const &) = delete; + + bool poll(std::uint64_t const Frame) + { + std::scoped_lock lock{m_schedule_mutex}; + return m_schedule.poll(Frame); + } + + // frees the module instance. done by the queue when it stops, as the runtime goes with it + void release_runtime() + { + if (exec_env != nullptr) + { + wasm_runtime_destroy_exec_env(exec_env); + exec_env = nullptr; + } + if (instance != nullptr) + { + wasm_runtime_deinstantiate(instance); + instance = nullptr; + } + failed = true; + } + + // set when made, read-only afterwards + wasm_screen_kind const kind; + std::string const script; + std::string const folder; + ITexture *const texture; + + // set by the worker when the screen can't run (any more); a failed screen isn't refreshed + std::atomic failed{false}; + + // the worker's part + wasm_module_inst_t instance{nullptr}; + wasm_exec_env_t exec_env{nullptr}; + wasm_function_inst_t render{nullptr}; + wasm_function_inst_t width{nullptr}; + wasm_function_inst_t height{nullptr}; + bool commands_ignored{false}; // a screen outside the cab tried to send a command, and it was logged + // the call in progress + input_view const *input{nullptr}; + std::vector *commands{nullptr}; + std::uint32_t vehicle{0}; + + // the image, handed from the worker to the main thread + std::mutex image_mutex; + std::vector image; + int image_width{0}; + int image_height{0}; + bool image_pending{false}; // drawn and not yet uploaded + std::size_t uploaded_texture{no_texture}; // the gl texture the image went to, to upload it again when it's made anew + + private: + std::mutex m_schedule_mutex; + wasm_screens::frame_schedule m_schedule; +}; + +namespace +{ + +wasm_screen *screen_of(wasm_exec_env_t Env) +{ + return static_cast(wasm_runtime_get_custom_data(wasm_runtime_get_module_inst(Env))); +} + +struct lookup_result +{ + input_view const *view{nullptr}; + input_view::entry entry; +}; + +lookup_result lookup(wasm_exec_env_t Env, char const *Key, std::uint32_t const Length) +{ + auto const *screen{screen_of(Env)}; + if (screen == nullptr || screen->input == nullptr || Key == nullptr) + { + return {}; + } + return {screen->input, screen->input->find(std::string_view{Key, Length})}; +} + +// the natives of the "eu07" module, which a screen imports. see wasm_screens_doc.md + +std::int32_t native_value_type(wasm_exec_env_t Env, char const *Key, std::uint32_t const Length) +{ + return static_cast(lookup(Env, Key, Length).entry.type); +} + +double native_get_float(wasm_exec_env_t Env, char const *Key, std::uint32_t const Length) +{ + auto const [view, entry]{lookup(Env, Key, Length)}; + using enum value_type; + switch (entry.type) + { + case floating: + return view->source().floats[entry.index].second; + case integer: + return view->source().integers[entry.index].second; + case boolean: + return view->source().bools[entry.index].second ? 1.0 : 0.0; + default: + return 0.0; + } +} + +std::int32_t native_get_int(wasm_exec_env_t Env, char const *Key, std::uint32_t const Length) +{ + auto const [view, entry]{lookup(Env, Key, Length)}; + using enum value_type; + switch (entry.type) + { + case floating: + return to_int32(view->source().floats[entry.index].second); + case integer: + return view->source().integers[entry.index].second; + case boolean: + return view->source().bools[entry.index].second ? 1 : 0; + default: + return 0; + } +} + +std::int32_t native_get_bool(wasm_exec_env_t Env, char const *Key, std::uint32_t const Length) +{ + auto const [view, entry]{lookup(Env, Key, Length)}; + using enum value_type; + switch (entry.type) + { + case floating: + return view->source().floats[entry.index].second != 0.0 ? 1 : 0; + case integer: + return view->source().integers[entry.index].second != 0 ? 1 : 0; + case boolean: + return view->source().bools[entry.index].second ? 1 : 0; + default: + return 0; + } +} + +// copies the string into the buffer, as UTF-8 (names of sceneries and vehicles may be in windows-1250). +// returns the length of the whole string, which may be more than the buffer took, or -1 when there is no such string +std::int32_t native_get_string(wasm_exec_env_t Env, char const *Key, std::uint32_t const Length, char *Buffer, std::uint32_t const Capacity) +{ + auto const [view, entry]{lookup(Env, Key, Length)}; + if (entry.type != value_type::string) + { + return -1; + } + auto const &value{view->source().strings[entry.index].second}; + auto const text{is_utf8(value) ? value : win1250_to_utf8(value)}; + if (Buffer != nullptr) + { + std::memcpy(Buffer, text.data(), std::min(text.size(), Capacity)); + } + return to_int32(text.size()); +} + +std::int32_t native_get_vec2_count(wasm_exec_env_t Env, char const *Key, std::uint32_t const Length) +{ + auto const [view, entry]{lookup(Env, Key, Length)}; + if (entry.type != value_type::vec2_list) + { + return 0; + } + return to_int32(view->source().vec2_lists[entry.index].second.size()); +} + +// writes the point as two f32 at Output. returns 1 on success, 0 when there is no such point +std::int32_t native_get_vec2(wasm_exec_env_t Env, char const *Key, std::uint32_t const Length, std::int32_t const Index, std::uint32_t const Output) +{ + auto const [view, entry]{lookup(Env, Key, Length)}; + if (entry.type != value_type::vec2_list || Index < 0) + { + return 0; + } + auto const &points{view->source().vec2_lists[entry.index].second}; + if (static_cast(Index) >= points.size()) + { + return 0; + } + auto *instance{wasm_runtime_get_module_inst(Env)}; + std::array const point{points[Index].x, points[Index].y}; + if (false == wasm_runtime_validate_app_addr(instance, Output, sizeof(point))) + { + // the runtime has raised an exception, which ends the call + return 0; + } + std::memcpy(wasm_runtime_addr_app_to_native(instance, Output), point.data(), sizeof(point)); + return 1; +} + +void native_command(wasm_exec_env_t Env, char const *Name, std::uint32_t const Length, std::int32_t const Param1, std::int32_t const Param2) +{ + auto *screen{screen_of(Env)}; + if (screen == nullptr || screen->commands == nullptr || Name == nullptr) + { + return; + } + if (screen->kind != wasm_screen_kind::cab) + { + if (false == screen->commands_ignored) + { + screen->commands_ignored = true; + WriteLog(std::format("WASM screen \"{}\": commands are only taken from screens in the cab", screen->script)); + } + return; + } + screen->commands->emplace_back(std::string{Name, Length}, Param1, Param2, screen->vehicle); +} + +void native_log(wasm_exec_env_t Env, char const *Text, std::uint32_t const Length) +{ + auto const *screen{screen_of(Env)}; + if (screen == nullptr || Text == nullptr) + { + return; + } + WriteLog(std::format("WASM screen \"{}\": {}", screen->script, std::string_view{Text, Length})); +} + +// reads a file from the screen's folder into the buffer. returns the size of the whole file, which may be more +// than the buffer took, or -1 when there is no such file or the path leads outside the folder +std::int32_t native_file_read(wasm_exec_env_t Env, char const *Path, std::uint32_t const Length, char *Buffer, std::uint32_t const Capacity) +{ + auto const *screen{screen_of(Env)}; + if (screen == nullptr || Path == nullptr) + { + return -1; + } + std::string_view const path{Path, Length}; + if (false == wasm_screens::is_safe_relative_path(path)) + { + return -1; + } + std::ifstream file{screen->folder + std::string{path}, std::ios::binary | std::ios::ate}; + if (false == file.is_open()) + { + return -1; + } + auto const size{static_cast(std::max(file.tellg(), 0))}; + if (Buffer != nullptr && Capacity > 0) + { + file.seekg(0); + file.read(Buffer, static_cast(std::min(size, Capacity))); + } + return to_int32(size); +} + +// seconds of a steady clock, the same for every screen, for animations that don't depend on how often the +// screen is refreshed +double native_time(wasm_exec_env_t /*Env*/) +{ + static auto const start{std::chrono::steady_clock::now()}; + return std::chrono::duration(std::chrono::steady_clock::now() - start).count(); +} + +// the bytes of an image in the module's memory; an empty span for one that lies outside (the runtime then raises an +// exception, which ends the call) +std::span module_image(wasm_exec_env_t Env, std::uint32_t const Offset, int const Width, int const Height) +{ + auto *instance{wasm_runtime_get_module_inst(Env)}; + auto const size{wasm_screens::image_size(Width, Height)}; + if (false == wasm_runtime_validate_app_addr(instance, Offset, size)) + { + return {}; + } + return {static_cast(wasm_runtime_addr_app_to_native(instance, Offset)), size}; +} + +bool valid_side(std::int32_t const Side) +{ + return Side > 0 && Side <= max_image_side; +} + +// led_util's render_post, natively: Target takes (2 Width + 2) x (2 Height + 2) pixels. returns 1, or 0 for an image +// that doesn't fit +std::int32_t native_led_post(wasm_exec_env_t Env, std::uint32_t const Source, std::int32_t const Width, std::int32_t const Height, std::uint32_t const Target, std::int32_t const Bloom) +{ + if (false == valid_side(Width) || false == valid_side(Height)) + { + return 0; + } + auto const source{module_image(Env, Source, Width, Height)}; + auto const target{module_image(Env, Target, Width * 2 + 2, Height * 2 + 2)}; + if (source.empty() || target.empty()) + { + return 0; + } + return wasm_screens::led_post(source, Width, Height, target, Bloom != 0) ? 1 : 0; +} + +// Pillow's resize(..., LANCZOS), natively. returns 1, or 0 for an image that doesn't fit +std::int32_t native_resize_lanczos(wasm_exec_env_t Env, std::uint32_t const Source, std::int32_t const Width, std::int32_t const Height, std::uint32_t const Target, + std::int32_t const TargetWidth, std::int32_t const TargetHeight) +{ + if (false == valid_side(Width) || false == valid_side(Height) || false == valid_side(TargetWidth) || false == valid_side(TargetHeight)) + { + return 0; + } + auto const source{module_image(Env, Source, Width, Height)}; + auto const target{module_image(Env, Target, TargetWidth, TargetHeight)}; + if (source.empty() || target.empty()) + { + return 0; + } + return wasm_screens::resize_lanczos(source, Width, Height, target, TargetWidth, TargetHeight) ? 1 : 0; +} + +template NativeSymbol native(char const *Name, Function_ *Function, char const *Signature) +{ + return NativeSymbol{Name, reinterpret_cast(Function), Signature, nullptr}; +} + +} // namespace + +struct wasm_screen_queue::implementation +{ + struct module_entry + { + std::vector bytes; // the runtime reads the module from here as long as it is loaded + wasm_module_t module{nullptr}; + }; + + struct task + { + std::shared_ptr screen; + std::shared_ptr input; + std::uint32_t vehicle{0}; + }; + + implementation() + : natives{native("value_type", &native_value_type, "(*~)i"), + native("get_float", &native_get_float, "(*~)F"), + native("get_int", &native_get_int, "(*~)i"), + native("get_bool", &native_get_bool, "(*~)i"), + native("get_string", &native_get_string, "(*~*~)i"), + native("get_vec2_count", &native_get_vec2_count, "(*~)i"), + native("get_vec2", &native_get_vec2, "(*~ii)i"), + native("command", &native_command, "(*~ii)"), + native("log", &native_log, "(*~)"), + native("file_read", &native_file_read, "(*~*~)i"), + native("time", &native_time, "()F"), + native("led_post", &native_led_post, "(iiiii)i"), + native("resize_lanczos", &native_resize_lanczos, "(iiiiii)i")} + { + } + + void run(std::stop_token const &Stop); + void process(task const &Task); + bool instantiate(wasm_screen &Screen); + wasm_module_t load_module(std::string const &Script); + bool call(wasm_screen &Screen, wasm_function_inst_t Function, std::string_view Name, std::int32_t &Result) const; + void present(wasm_screen &Screen, std::uint32_t Pixels) const; + static void upload(wasm_screen &Screen); + static void execute(pending_command const &Command); + + // the runtime keeps the array and sorts it, so it lives as long as the queue + std::array natives; + bool initialized{false}; + std::atomic frame{0}; + + std::mutex screens_mutex; + std::unordered_map> screens; + + std::mutex tasks_mutex; + std::condition_variable_any tasks_condition; + std::deque tasks; + std::jthread worker; + + std::mutex commands_mutex; + std::vector commands; + + // the worker's, and the queue's once the worker stopped + std::unordered_map modules; +}; + +void wasm_screen_queue::implementation::run(std::stop_token const &Stop) +{ + wasm_runtime_init_thread_env(); + while (false == Stop.stop_requested()) + { + task next; + { + std::unique_lock lock{tasks_mutex}; + if (false == tasks_condition.wait(lock, Stop, [this] { return false == tasks.empty(); })) + { + break; + } + next = std::move(tasks.front()); + tasks.pop_front(); + } + process(next); + } + wasm_runtime_destroy_thread_env(); +} + +void wasm_screen_queue::implementation::process(task const &Task) +{ + auto &screen{*Task.screen}; + if (screen.failed) + { + return; + } + if (screen.instance == nullptr && false == instantiate(screen)) + { + screen.failed = true; + return; + } + + input_view const view{*Task.input}; + std::vector sent; + screen.input = &view; + screen.commands = &sent; + screen.vehicle = Task.vehicle; + std::int32_t pixels{0}; + auto const rendered{call(screen, screen.render, "screen_render", pixels)}; + screen.input = nullptr; + screen.commands = nullptr; + if (false == rendered) + { + return; + } + // 0: the screen didn't change, keep the image there is + if (pixels != 0) + { + present(screen, static_cast(pixels)); + } + if (false == sent.empty()) + { + std::scoped_lock lock{commands_mutex}; + commands.insert(commands.end(), std::make_move_iterator(sent.begin()), std::make_move_iterator(sent.end())); + } +} + +bool wasm_screen_queue::implementation::instantiate(wasm_screen &Screen) +{ + auto *module{load_module(Screen.script)}; + if (module == nullptr) + { + return false; + } + std::array error{}; + Screen.instance = wasm_runtime_instantiate(module, stack_size, 0, error.data(), static_cast(error.size())); + if (Screen.instance == nullptr) + { + ErrorLog(std::format("WASM screen \"{}\": instantiation failed: {}", Screen.script, error.data())); + return false; + } + wasm_runtime_set_custom_data(Screen.instance, &Screen); + Screen.exec_env = wasm_runtime_create_exec_env(Screen.instance, stack_size); + if (Screen.exec_env == nullptr) + { + ErrorLog(std::format("WASM screen \"{}\": failed to create the execution environment", Screen.script)); + return false; + } + Screen.render = wasm_runtime_lookup_function(Screen.instance, "screen_render"); + Screen.width = wasm_runtime_lookup_function(Screen.instance, "screen_width"); + Screen.height = wasm_runtime_lookup_function(Screen.instance, "screen_height"); + if (Screen.render == nullptr || Screen.width == nullptr || Screen.height == nullptr) + { + ErrorLog(std::format("WASM screen \"{}\": the module has to export screen_render, screen_width and screen_height", Screen.script)); + return false; + } + if (auto *init{wasm_runtime_lookup_function(Screen.instance, "screen_init")}; init != nullptr) + { + std::int32_t result{0}; + if (false == call(Screen, init, "screen_init", result)) + { + return false; + } + if (result != 0) + { + ErrorLog(std::format("WASM screen \"{}\": screen_init returned {}", Screen.script, result)); + return false; + } + } + WriteLog(std::format("WASM screen \"{}\": ready", Screen.script)); + return true; +} + +wasm_module_t wasm_screen_queue::implementation::load_module(std::string const &Script) +{ + if (auto const lookup{modules.find(Script)}; lookup != modules.end()) + { + return lookup->second.module; + } + // a failure is kept as well, so the module isn't tried again for every screen using it + auto &entry{modules[Script]}; + std::ifstream file{Script, std::ios::binary}; + if (false == file.is_open()) + { + ErrorLog(std::format("WASM screen \"{}\": file not found", Script)); + return nullptr; + } + entry.bytes.assign(std::istreambuf_iterator{file}, std::istreambuf_iterator{}); + if (entry.bytes.empty() || entry.bytes.size() > std::numeric_limits::max()) + { + ErrorLog(std::format("WASM screen \"{}\": invalid module size", Script)); + return nullptr; + } + std::array error{}; + entry.module = wasm_runtime_load(entry.bytes.data(), static_cast(entry.bytes.size()), error.data(), static_cast(error.size())); + if (entry.module == nullptr) + { + ErrorLog(std::format("WASM screen \"{}\": failed to load the module: {}", Script, error.data())); + } + return entry.module; +} + +// calls a function of the screen that takes nothing and returns an i32. a failed call disables the screen +bool wasm_screen_queue::implementation::call(wasm_screen &Screen, wasm_function_inst_t Function, std::string_view const Name, std::int32_t &Result) const +{ + wasm_runtime_set_instruction_count_limit(Screen.exec_env, instruction_limit); + std::array cells{}; + if (false == wasm_runtime_call_wasm(Screen.exec_env, Function, 0, cells.data())) + { + auto const *exception{wasm_runtime_get_exception(Screen.instance)}; + ErrorLog(std::format("WASM screen \"{}\": {} failed: {}", Screen.script, Name, exception != nullptr ? exception : "unknown error")); + Screen.failed = true; + return false; + } + Result = static_cast(cells[0]); + return true; +} + +// copies the image the screen drew, Pixels being its address in the module's memory +void wasm_screen_queue::implementation::present(wasm_screen &Screen, std::uint32_t const Pixels) const +{ + std::int32_t width{0}; + std::int32_t height{0}; + if (false == call(Screen, Screen.width, "screen_width", width) || false == call(Screen, Screen.height, "screen_height", height)) + { + return; + } + if (width <= 0 || height <= 0 || width > max_image_side || height > max_image_side) + { + ErrorLog(std::format("WASM screen \"{}\": invalid image size {}x{}", Screen.script, width, height)); + Screen.failed = true; + return; + } + auto const size{static_cast(width) * static_cast(height) * 4}; + if (false == wasm_runtime_validate_app_addr(Screen.instance, Pixels, size)) + { + ErrorLog(std::format("WASM screen \"{}\": the image lies outside the module's memory", Screen.script)); + Screen.failed = true; + return; + } + auto const *pixels{static_cast(wasm_runtime_addr_app_to_native(Screen.instance, Pixels))}; + + std::scoped_lock lock{Screen.image_mutex}; + Screen.image.assign(pixels, pixels + size); + Screen.image_width = width; + Screen.image_height = height; + Screen.image_pending = true; +} + +void wasm_screen_queue::implementation::upload(wasm_screen &Screen) +{ + if (Screen.texture == nullptr) + { + return; + } + std::scoped_lock lock{Screen.image_mutex}; + if (Screen.image.empty() || false == Screen.texture->get_is_ready()) + { + // nothing drawn yet, or the texture isn't made yet or was released: the image goes up once it's there + return; + } + if (false == Screen.image_pending && Screen.texture->get_id() == Screen.uploaded_texture) + { + return; + } + Screen.texture->update_from_memory(Screen.image_width, Screen.image_height, Screen.image.data()); + Screen.uploaded_texture = Screen.texture->get_id(); + Screen.image_pending = false; +} + +void wasm_screen_queue::implementation::execute(pending_command const &Command) +{ + auto const lookup{simulation::commandMap.find(Command.name)}; + if (lookup == simulation::commandMap.end()) + { + ErrorLog(std::format("WASM screen: command [{}] not found", Command.name)); + return; + } + command_data data{}; + data.command = lookup->second; + data.action = GLFW_PRESS; + data.param1 = Command.param1; + data.param2 = Command.param2; + WriteLog(std::format("WASM screen: executing command [{}] with params: P1={} P2={} Target ID={}", Command.name, Command.param1, Command.param2, Command.vehicle)); + simulation::Commands.push(data, static_cast(command_target::vehicle) | Command.vehicle); +} + +wasm_screen_queue::wasm_screen_queue() : m_impl{std::make_unique()} {} + +wasm_screen_queue::~wasm_screen_queue() +{ + exit(); +} + +bool wasm_screen_queue::init() +{ + if (m_impl->initialized) + { + return true; + } + RuntimeInitArgs arguments{}; + arguments.mem_alloc_type = Alloc_With_System_Allocator; + arguments.native_module_name = "eu07"; + arguments.native_symbols = m_impl->natives.data(); + arguments.n_native_symbols = static_cast(m_impl->natives.size()); + arguments.running_mode = Mode_Interp; + if (false == wasm_runtime_full_init(&arguments)) + { + ErrorLog("WASM screens: failed to initialize the runtime"); + return false; + } + wasm_runtime_set_log_level(WASM_LOG_LEVEL_ERROR); + m_impl->initialized = true; + auto *impl{m_impl.get()}; + m_impl->worker = std::jthread([impl](std::stop_token const &Stop) { impl->run(Stop); }); + WriteLog(std::format("WASM screens: setup complete, refreshed every {} frames in the cab and every {} frames outside", Global.wasm_cabframes, Global.wasm_externalframes)); + return true; +} + +void wasm_screen_queue::exit() +{ + if (false == m_impl->initialized) + { + return; + } + if (m_impl->worker.joinable()) + { + m_impl->worker.request_stop(); + m_impl->worker.join(); + } + { + std::scoped_lock lock{m_impl->tasks_mutex}; + m_impl->tasks.clear(); + } + { + std::scoped_lock lock{m_impl->commands_mutex}; + m_impl->commands.clear(); + } + { + // the vehicles may hold their screens past this point, but the runtime goes now + std::scoped_lock lock{m_impl->screens_mutex}; + for (auto const &[key, screen] : m_impl->screens) + { + if (auto const live{screen.lock()}; live != nullptr) + { + live->release_runtime(); + } + } + m_impl->screens.clear(); + } + for (auto const &[script, entry] : m_impl->modules) + { + if (entry.module != nullptr) + { + wasm_runtime_unload(entry.module); + } + } + m_impl->modules.clear(); + wasm_runtime_destroy(); + m_impl->initialized = false; +} + +std::shared_ptr wasm_screen_queue::acquire(screen_request const &Request) +{ + if (false == m_impl->initialized || Request.script.empty()) + { + return nullptr; + } + auto const key{Request.key.empty() ? Request.script : Request.key}; + std::scoped_lock lock{m_impl->screens_mutex}; + if (auto const lookup{m_impl->screens.find(key)}; lookup != m_impl->screens.end()) + { + if (auto existing{lookup->second.lock()}; existing != nullptr) + { + return existing; + } + } + auto const configured{Request.kind == wasm_screen_kind::cab ? Global.wasm_cabframes : Global.wasm_externalframes}; + auto const interval{wasm_screens::frame_schedule::effective_interval(configured, Request.frames)}; + auto screen{std::make_shared(Request, wasm_screens::frame_schedule{m_impl->frame.load(), interval, wasm_screens::frame_schedule::phase(key, interval)})}; + m_impl->screens.insert_or_assign(key, screen); + return screen; +} + +bool wasm_screen_queue::due(wasm_screen &Screen) const +{ + if (false == m_impl->initialized || Screen.failed) + { + return false; + } + return Screen.poll(m_impl->frame.load()); +} + +void wasm_screen_queue::submit(std::shared_ptr const &Screen, std::shared_ptr Input, std::uint32_t const Vehicle) +{ + if (false == m_impl->initialized || Screen == nullptr || Input == nullptr || Screen->failed) + { + return; + } + { + std::scoped_lock lock{m_impl->tasks_mutex}; + auto const pending{std::ranges::find(m_impl->tasks, Screen, &implementation::task::screen)}; + if (pending != m_impl->tasks.end()) + { + // the refresh still waiting gets the newer input + pending->input = std::move(Input); + pending->vehicle = Vehicle; + } + else + { + m_impl->tasks.emplace_back(Screen, std::move(Input), Vehicle); + } + } + m_impl->tasks_condition.notify_one(); +} + +void wasm_screen_queue::update() +{ + if (false == m_impl->initialized) + { + return; + } + ++m_impl->frame; + { + std::scoped_lock lock{m_impl->screens_mutex}; + std::erase_if(m_impl->screens, [](auto const &Entry) { return Entry.second.expired(); }); + for (auto const &[key, screen] : m_impl->screens) + { + if (auto const live{screen.lock()}; live != nullptr) + { + implementation::upload(*live); + } + } + } + std::vector sent; + { + std::scoped_lock lock{m_impl->commands_mutex}; + sent.swap(m_impl->commands); + } + for (auto const &command : sent) + { + implementation::execute(command); + } +} diff --git a/scripting/wasmscreens.h b/scripting/wasmscreens.h new file mode 100644 index 00000000..a4933646 --- /dev/null +++ b/scripting/wasmscreens.h @@ -0,0 +1,72 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +#pragma once + +#include "scripting/wasmscreenrules.h" + +#include +#include +#include + +struct ITexture; +struct dictionary_source; + +// screens drawn by WebAssembly modules, the counterpart of the python screens (scripting/PyInt.h). +// a module gets the same state the python screens get, returns an RGBA image and may send commands. the +// interface between the two is described in wasm_screens_doc.md. +// unlike the python screens, which refresh after a time, a wasm screen refreshes every so many rendered +// frames: wasm.cabframes in eu07.ini for the screens in the cab, wasm.externalframes for the screens outside +// (destination signs) + +enum class wasm_screen_kind +{ + cab, // in the cab of the driven vehicle: may send commands to it + external // on the outside of a vehicle, such as a destination sign: shows only +}; + +// one screen: a module instance with its own memory, and the image it drew last +class wasm_screen; + +class wasm_screen_queue +{ + public: + struct screen_request + { + wasm_screen_kind kind{wasm_screen_kind::cab}; + std::string script; // the .wasm file + std::string key; // requests with the same key get the same screen, e.g. the destination signs of one train + ITexture *texture{nullptr}; // where the screen is drawn; none for a screen that only sends commands + int frames{0}; // the screen's own refresh interval from the .mmd, used when longer than the eu07.ini one + }; + + wasm_screen_queue(); + ~wasm_screen_queue(); + wasm_screen_queue(wasm_screen_queue const &) = delete; + wasm_screen_queue &operator=(wasm_screen_queue const &) = delete; + + // starts the runtime and its worker thread. returns true on success + bool init(); + // stops the worker and releases every screen, also the ones still held by vehicles + void exit(); + // the screen for the request, made on the first request with its key. nullptr when the wasm screens are off + std::shared_ptr acquire(screen_request const &Request); + // true once per refresh interval of the screen. the caller then gathers the screen's input and submit()s it + bool due(wasm_screen &Screen) const; + // queues a refresh of the screen with the given input, replacing a refresh of it still waiting. + // Vehicle: the vehicle that gets the commands the screen sends; cab screens only + void submit(std::shared_ptr const &Screen, std::shared_ptr Input, std::uint32_t Vehicle); + // main thread, once per rendered frame: uploads the finished images, runs the commands the screens sent and + // counts the frame + void update(); + + private: + struct implementation; + std::unique_ptr m_impl; +}; diff --git a/scripting/wasmscreensstub.cpp b/scripting/wasmscreensstub.cpp new file mode 100644 index 00000000..666b53f2 --- /dev/null +++ b/scripting/wasmscreensstub.cpp @@ -0,0 +1,51 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +// the wasm screens of a build without WITH_WASM: there are none, and the vehicles fall back to what they had before + +#include "stdafx.h" +#include "scripting/wasmscreens.h" + +struct wasm_screen_queue::implementation +{ +}; + +wasm_screen_queue::wasm_screen_queue() : m_impl{std::make_unique()} {} + +wasm_screen_queue::~wasm_screen_queue() = default; + +bool wasm_screen_queue::init() +{ + return false; +} + +void wasm_screen_queue::exit() +{ + // nothing was started +} + +std::shared_ptr wasm_screen_queue::acquire(screen_request const & /*Request*/) +{ + return nullptr; +} + +bool wasm_screen_queue::due(wasm_screen & /*Screen*/) const +{ + return false; +} + +void wasm_screen_queue::submit(std::shared_ptr const & /*Screen*/, std::shared_ptr /*Input*/, std::uint32_t /*Vehicle*/) +{ + // nothing to refresh +} + +void wasm_screen_queue::update() +{ + // nothing to upload +} diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index 17efffa7..b620cc0a 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -25,6 +25,11 @@ add_executable(${UNIT_TESTS_NAME} # the core library brings the game's build settings along target_link_libraries(${UNIT_TESTS_NAME} PRIVATE eu07_core) +# the wasm screen host, tested with the real runtime (test_wasm_host.cpp) +if (WITH_WASM) + target_sources(${UNIT_TESTS_NAME} PRIVATE "${CMAKE_SOURCE_DIR}/scripting/wasmscreens.cpp") + target_link_libraries(${UNIT_TESTS_NAME} PRIVATE eu07_wamr) +endif() target_include_directories(${UNIT_TESTS_NAME} PRIVATE "${DEPS_DIR}/doctest") # the names the tests CAPTURE are often C strings, shown as text rather than pointers target_compile_definitions(${UNIT_TESTS_NAME} PRIVATE EU07_UNIT_TESTS_DATA="${CMAKE_CURRENT_SOURCE_DIR}/data" DOCTEST_CONFIG_TREAT_CHAR_STAR_AS_STRING) diff --git a/tests/unit/test_wasm_host.cpp b/tests/unit/test_wasm_host.cpp new file mode 100644 index 00000000..d96ee716 --- /dev/null +++ b/tests/unit/test_wasm_host.cpp @@ -0,0 +1,288 @@ +#include "stdafx.h" +#include "doctest.h" + +// the wasm screen host (scripting/wasmscreens.cpp) with the real runtime: a module reads its input, draws an +// image that goes to the screen's texture and sends a command; a module stuck in a loop is stopped. built with +// WITH_WASM only + +#ifdef WITH_WASM + +#include "scripting/wasmscreens.h" +#include "input/command.h" +#include "interfaces/ITexture.h" +#include "utilities/Globals.h" +#include "utilities/dictionary.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +// the engine's command queue and command names, which the test build doesn't link: the commands the screens send +// are kept here instead + +namespace { + +std::vector> &sent_commands() { + static std::vector> commands; + return commands; +} + +} // namespace + +namespace simulation { +command_queue Commands; // NOSONAR the engine's extern global, input/command.h +std::unordered_map const commandMap{{"cmdtest", user_command::aidriverenable}}; // NOSONAR the engine's extern global, input/command.h +} // namespace simulation + +void command_queue::push(command_data const &Command, uint32_t const Recipient) { // NOSONAR signature from input/command.h + sent_commands().emplace_back(Command, Recipient); +} + +namespace { + +// the modules, encoded by hand. "paint": a 2x2 screen in the colour 0xFF0000vv, vv being the input value +// "speed", which sends the command "cmdtest" with the parameters (vv, 7): +// (import "eu07" "get_float" (func $get_float (param i32 i32) (result f64))) +// (import "eu07" "command" (func $command (param i32 i32 i32 i32))) +// (memory (export "memory") 1) +// (data (i32.const 0) "speed") (data (i32.const 16) "cmdtest") +// (func (export "screen_width") (result i32) i32.const 2) +// (func (export "screen_height") (result i32) i32.const 2) +// (func (export "screen_render") (result i32) (local $v i32) +// (local.set $v (i32.trunc_f64_s (call $get_float (i32.const 0) (i32.const 5)))) +// (i32.store (i32.const 1024) (i32.or (local.get $v) (i32.const 0xFF000000))) ... the same at 1028, 1032, 1036 +// (call $command (i32.const 16) (i32.const 7) (local.get $v) (i32.const 7)) +// (i32.const 1024)) +constexpr std::array paint_module{ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x12, 0x03, 0x60, 0x02, 0x7f, 0x7f, 0x01, + 0x7c, 0x60, 0x04, 0x7f, 0x7f, 0x7f, 0x7f, 0x00, 0x60, 0x00, 0x01, 0x7f, 0x02, 0x21, 0x02, 0x04, + 0x65, 0x75, 0x30, 0x37, 0x09, 0x67, 0x65, 0x74, 0x5f, 0x66, 0x6c, 0x6f, 0x61, 0x74, 0x00, 0x00, + 0x04, 0x65, 0x75, 0x30, 0x37, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x00, 0x01, 0x03, + 0x04, 0x03, 0x02, 0x02, 0x02, 0x05, 0x03, 0x01, 0x00, 0x01, 0x07, 0x39, 0x04, 0x06, 0x6d, 0x65, + 0x6d, 0x6f, 0x72, 0x79, 0x02, 0x00, 0x0c, 0x73, 0x63, 0x72, 0x65, 0x65, 0x6e, 0x5f, 0x77, 0x69, + 0x64, 0x74, 0x68, 0x00, 0x02, 0x0d, 0x73, 0x63, 0x72, 0x65, 0x65, 0x6e, 0x5f, 0x68, 0x65, 0x69, + 0x67, 0x68, 0x74, 0x00, 0x03, 0x0d, 0x73, 0x63, 0x72, 0x65, 0x65, 0x6e, 0x5f, 0x72, 0x65, 0x6e, + 0x64, 0x65, 0x72, 0x00, 0x04, 0x0a, 0x5e, 0x03, 0x04, 0x00, 0x41, 0x02, 0x0b, 0x04, 0x00, 0x41, + 0x02, 0x0b, 0x52, 0x01, 0x01, 0x7f, 0x41, 0x00, 0x41, 0x05, 0x10, 0x00, 0xaa, 0x21, 0x00, 0x41, + 0x80, 0x08, 0x20, 0x00, 0x41, 0x80, 0x80, 0x80, 0x78, 0x72, 0x36, 0x02, 0x00, 0x41, 0x84, 0x08, + 0x20, 0x00, 0x41, 0x80, 0x80, 0x80, 0x78, 0x72, 0x36, 0x02, 0x00, 0x41, 0x88, 0x08, 0x20, 0x00, + 0x41, 0x80, 0x80, 0x80, 0x78, 0x72, 0x36, 0x02, 0x00, 0x41, 0x8c, 0x08, 0x20, 0x00, 0x41, 0x80, + 0x80, 0x80, 0x78, 0x72, 0x36, 0x02, 0x00, 0x41, 0x10, 0x41, 0x07, 0x20, 0x00, 0x41, 0x07, 0x10, + 0x01, 0x41, 0x80, 0x08, 0x0b, 0x0b, 0x17, 0x02, 0x00, 0x41, 0x00, 0x0b, 0x05, 0x73, 0x70, 0x65, + 0x65, 0x64, 0x00, 0x41, 0x10, 0x0b, 0x07, 0x63, 0x6d, 0x64, 0x74, 0x65, 0x73, 0x74}; + +// "spin": exports the same functions as "paint", but screen_render never returns: (loop $l (br $l)) +constexpr std::array spin_module{ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x05, 0x01, 0x60, 0x00, 0x01, 0x7f, 0x03, + 0x04, 0x03, 0x00, 0x00, 0x00, 0x05, 0x03, 0x01, 0x00, 0x01, 0x07, 0x39, 0x04, 0x06, 0x6d, 0x65, + 0x6d, 0x6f, 0x72, 0x79, 0x02, 0x00, 0x0c, 0x73, 0x63, 0x72, 0x65, 0x65, 0x6e, 0x5f, 0x77, 0x69, + 0x64, 0x74, 0x68, 0x00, 0x00, 0x0d, 0x73, 0x63, 0x72, 0x65, 0x65, 0x6e, 0x5f, 0x68, 0x65, 0x69, + 0x67, 0x68, 0x74, 0x00, 0x01, 0x0d, 0x73, 0x63, 0x72, 0x65, 0x65, 0x6e, 0x5f, 0x72, 0x65, 0x6e, + 0x64, 0x65, 0x72, 0x00, 0x02, 0x0a, 0x15, 0x03, 0x04, 0x00, 0x41, 0x02, 0x0b, 0x04, 0x00, 0x41, + 0x02, 0x0b, 0x09, 0x00, 0x03, 0x40, 0x0c, 0x00, 0x0b, 0x41, 0x00, 0x0b}; + +// a texture that keeps what was uploaded to it +struct recording_texture : ITexture { + bool create(bool) override { return true; } + int get_width() const override { return width; } + int get_height() const override { return height; } + size_t get_id() const override { return id; } + void release() override { /* nothing held */ } + void make_stub() override { /* nothing held */ } + std::string_view get_traits() const override { return ""; } + std::string_view get_name() const override { return "recording"; } + std::string_view get_type() const override { return "make:"; } + bool is_stub() const override { return false; } + bool get_has_alpha() const override { return true; } + bool get_is_ready() const override { return ready; } + void set_components_hint(int) override { /* not used */ } + void make_from_memory(size_t const Width, size_t const Height, const uint8_t *Data) override { update_from_memory(Width, Height, Data); } + void update_from_memory(size_t const Width, size_t const Height, const uint8_t *Data) override { + width = static_cast(Width); + height = static_cast(Height); + pixels.assign(Data, Data + Width * Height * 4); + ++uploads; + } + + bool ready{true}; + size_t id{1}; + int width{0}; + int height{0}; + int uploads{0}; + std::vector pixels; +}; + +// writes a module into a folder of its own, removed with the object +class module_file { + public: + template + module_file(std::string const &Name, std::array const &Bytes) + : m_folder{std::filesystem::temp_directory_path() / std::format("eu07_wasm_test_{}", Name)} { + std::filesystem::create_directories(m_folder); + m_path = (m_folder / (Name + ".wasm")).generic_string(); + std::ofstream file{m_path, std::ios::binary}; + file.write(reinterpret_cast(Bytes.data()), static_cast(Bytes.size())); + } + ~module_file() { + std::error_code error; + std::filesystem::remove_all(m_folder, error); + } + module_file(module_file const &) = delete; + module_file &operator=(module_file const &) = delete; + + std::string const &path() const { return m_path; } + + private: + std::filesystem::path m_folder; + std::string m_path; +}; + +// runs frames until the condition holds, or gives up after a few seconds +bool run_until(wasm_screen_queue &Queue, std::function const &Condition) { + auto const deadline{std::chrono::steady_clock::now() + std::chrono::seconds(10)}; + while (std::chrono::steady_clock::now() < deadline) { + Queue.update(); + if (Condition()) { + return true; + } + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + return false; +} + +std::shared_ptr speed(double const Value) { + auto input{std::make_shared()}; + input->insert("speed", Value); + return input; +} + +} // namespace + +TEST_CASE("wasm host: a cab screen draws from its input and sends commands to its vehicle") { + sent_commands().clear(); + module_file const module{"paint", paint_module}; + wasm_screen_queue queue; + REQUIRE(queue.init()); + + recording_texture texture; + auto const screen{queue.acquire({.kind = wasm_screen_kind::cab, .script = module.path(), .key = "cab", .texture = &texture, .frames = 0})}; + REQUIRE(screen != nullptr); + // the same key, the same screen + CHECK(queue.acquire({.kind = wasm_screen_kind::cab, .script = module.path(), .key = "cab", .texture = &texture, .frames = 0}) == screen); + + REQUIRE(run_until(queue, [&queue, &screen] { return queue.due(*screen); })); + queue.submit(screen, speed(5.0), 42); + REQUIRE(run_until(queue, [&texture] { return texture.uploads > 0 && false == sent_commands().empty(); })); + + CHECK(texture.width == 2); + CHECK(texture.height == 2); + REQUIRE(texture.pixels.size() == 16); + std::array const expected{0x05, 0x00, 0x00, 0xFF}; + for (std::size_t pixel = 0; pixel < 4; ++pixel) { + CAPTURE(pixel); + CHECK(std::equal(expected.begin(), expected.end(), texture.pixels.begin() + static_cast(pixel * 4))); + } + + REQUIRE(sent_commands().size() == 1); + auto const &[command, recipient]{sent_commands().front()}; + CHECK(command.command == user_command::aidriverenable); + CHECK(command.param1 == 5.0); + CHECK(command.param2 == 7.0); + CHECK(recipient == (static_cast(command_target::vehicle) | 42u)); + + queue.exit(); +} + +TEST_CASE("wasm host: the image goes up again when the texture is made anew, and waits while it isn't there") { + sent_commands().clear(); + module_file const module{"paint", paint_module}; + wasm_screen_queue queue; + REQUIRE(queue.init()); + + recording_texture texture; + texture.ready = false; + auto const screen{queue.acquire({.kind = wasm_screen_kind::cab, .script = module.path(), .key = "cab", .texture = &texture, .frames = 0})}; + REQUIRE(run_until(queue, [&queue, &screen] { return queue.due(*screen); })); + queue.submit(screen, speed(9.0), 1); + REQUIRE(run_until(queue, [] { return false == sent_commands().empty(); })); + queue.update(); + CHECK(texture.uploads == 0); + + texture.ready = true; + queue.update(); + CHECK(texture.uploads == 1); + queue.update(); + CHECK(texture.uploads == 1); + + // released and made again: a new gl texture + texture.id = 2; + queue.update(); + CHECK(texture.uploads == 2); + + queue.exit(); +} + +TEST_CASE("wasm host: a screen outside the cab draws, but its commands are dropped") { + sent_commands().clear(); + module_file const module{"paint", paint_module}; + wasm_screen_queue queue; + REQUIRE(queue.init()); + + recording_texture texture; + auto const screen{queue.acquire({.kind = wasm_screen_kind::external, .script = module.path(), .key = "sign", .texture = &texture, .frames = 0})}; + REQUIRE(run_until(queue, [&queue, &screen] { return queue.due(*screen); })); + queue.submit(screen, speed(3.0), 0); + REQUIRE(run_until(queue, [&texture] { return texture.uploads > 0; })); + queue.update(); + CHECK(sent_commands().empty()); + + queue.exit(); +} + +TEST_CASE("wasm host: a screen stuck in a loop is stopped, and the others keep working") { + sent_commands().clear(); + module_file const spinning{"spin", spin_module}; + module_file const painting{"paint", paint_module}; + wasm_screen_queue queue; + REQUIRE(queue.init()); + + recording_texture stuck; + recording_texture working; + auto const first{queue.acquire({.kind = wasm_screen_kind::cab, .script = spinning.path(), .key = "spin", .texture = &stuck, .frames = 0})}; + auto const second{queue.acquire({.kind = wasm_screen_kind::cab, .script = painting.path(), .key = "paint", .texture = &working, .frames = 0})}; + REQUIRE(run_until(queue, [&queue, &first] { return queue.due(*first); })); + REQUIRE(run_until(queue, [&queue, &second] { return queue.due(*second); })); + queue.submit(first, speed(1.0), 1); + queue.submit(second, speed(2.0), 1); + + REQUIRE(run_until(queue, [&working] { return working.uploads > 0; })); + CHECK(stuck.uploads == 0); + // the stuck screen ran first, into the instruction limit, and isn't due any more + CHECK_FALSE(queue.due(*first)); + + queue.exit(); +} + +TEST_CASE("wasm host: the screens outlive the queue without harm") { + module_file const module{"paint", paint_module}; + std::shared_ptr screen; + recording_texture texture; + { + wasm_screen_queue queue; + REQUIRE(queue.init()); + screen = queue.acquire({.kind = wasm_screen_kind::cab, .script = module.path(), .key = "cab", .texture = &texture, .frames = 0}); + REQUIRE(run_until(queue, [&queue, &screen] { return queue.due(*screen); })); + queue.submit(screen, speed(4.0), 1); + REQUIRE(run_until(queue, [&texture] { return texture.uploads > 0; })); + } + // the queue and the runtime are gone, the vehicle still holds its screen + CHECK(screen != nullptr); + screen.reset(); +} + +#endif diff --git a/tests/unit/test_wasm_kernels.cpp b/tests/unit/test_wasm_kernels.cpp new file mode 100644 index 00000000..618524c7 --- /dev/null +++ b/tests/unit/test_wasm_kernels.cpp @@ -0,0 +1,121 @@ +#include "stdafx.h" +#include "doctest.h" + +#include "scripting/wasmscreenkernels.h" + +#include +#include +#include + +// the image filters the wasm screens call through the host give the pixels of the Python screens: led_util's +// render_post() and Pillow's resize(..., Image.LANCZOS). the expected images were made by them (Pillow 12.2) from a +// 5x3 image of (x*53 + y*97, x*31 + y*17 + 100, x*7 + y*151 + 40) mod 256 + +namespace { + +// RGB as Pillow gives it +constexpr std::array source{ + 0, 100, 40, 53, 131, 47, 106, 162, 54, 159, 193, 61, 212, 224, 68, 97, 117, 191, 150, 148, 198, 203, 179, 205, + 0, 210, 212, 53, 241, 219, 194, 134, 86, 247, 165, 93, 44, 196, 100, 97, 227, 107, 150, 2, 114, +}; +constexpr std::array posted{ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 33, 144, 77, 28, 122, 65, 99, 194, 97, + 84, 164, 82, 158, 236, 108, 134, 200, 91, 205, 255, 114, 174, 229, 96, 241, 255, 109, 204, 238, 92, 0, 0, 0, + 0, 0, 0, 28, 122, 65, 23, 100, 53, 84, 164, 82, 69, 135, 67, 134, 200, 91, 110, 165, 75, 174, 229, 96, + 143, 189, 79, 204, 238, 92, 168, 196, 76, 0, 0, 0, 0, 0, 0, 144, 173, 231, 122, 147, 196, 215, 229, 255, + 182, 194, 219, 255, 255, 255, 219, 232, 230, 67, 255, 255, 56, 255, 235, 101, 255, 255, 85, 255, 225, 0, 0, 0, + 0, 0, 0, 122, 147, 196, 100, 121, 161, 182, 194, 219, 150, 160, 180, 219, 232, 230, 180, 191, 189, 56, 255, 235, + 46, 213, 193, 85, 255, 225, 70, 212, 185, 0, 0, 0, 0, 0, 0, 234, 178, 126, 198, 151, 107, 255, 231, 148, + 251, 196, 125, 106, 255, 159, 90, 230, 135, 139, 255, 165, 118, 248, 140, 177, 65, 158, 150, 55, 134, 0, 0, 0, + 0, 0, 0, 198, 151, 107, 163, 124, 88, 251, 196, 125, 207, 161, 103, 90, 230, 135, 74, 189, 111, 118, 248, 140, + 97, 204, 115, 150, 55, 134, 123, 45, 110, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, +}; +constexpr std::array enlarged{ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 12, 46, 26, 22, 90, 53, 18, 71, 41, 50, 102, 55, 58, 108, 58, 53, 98, 52, + 88, 131, 65, 87, 128, 62, 84, 119, 58, 114, 144, 69, 102, 139, 63, 106, 133, 60, 136, 144, 67, 139, 144, 68, + 56, 71, 29, 0, 0, 0, 0, 0, 0, 16, 85, 39, 25, 165, 76, 21, 130, 59, 80, 188, 84, 94, 198, 89, + 86, 178, 78, 155, 246, 102, 150, 236, 97, 151, 221, 89, 232, 255, 112, 210, 255, 100, 213, 250, 94, 255, 255, 108, + 255, 255, 107, 113, 129, 44, 0, 0, 0, 0, 0, 0, 19, 67, 38, 31, 130, 74, 25, 100, 57, 72, 148, 78, + 83, 155, 82, 75, 137, 72, 128, 191, 92, 124, 181, 87, 119, 172, 81, 167, 225, 99, 146, 202, 89, 151, 197, 85, + 200, 238, 97, 198, 235, 95, 79, 96, 39, 0, 0, 0, 0, 0, 0, 70, 90, 108, 135, 177, 217, 108, 140, 171, + 172, 198, 208, 188, 211, 215, 167, 186, 192, 218, 235, 219, 227, 238, 214, 175, 222, 204, 95, 245, 222, 62, 245, 213, + 89, 240, 209, 122, 244, 224, 123, 243, 224, 49, 124, 99, 0, 0, 0, 0, 0, 0, 74, 89, 130, 139, 174, 255, + 110, 136, 201, 182, 194, 246, 197, 205, 253, 170, 182, 223, 244, 239, 255, 255, 234, 252, 186, 218, 236, 52, 255, 255, + 15, 245, 249, 49, 244, 240, 72, 255, 255, 73, 255, 255, 29, 130, 113, 0, 0, 0, 0, 0, 0, 76, 82, 96, + 147, 160, 188, 114, 123, 145, 166, 177, 183, 181, 187, 188, 163, 165, 163, 186, 224, 205, 188, 214, 191, 140, 200, 176, + 80, 251, 215, 54, 228, 190, 75, 211, 179, 105, 219, 206, 106, 217, 204, 42, 87, 82, 0, 0, 0, 0, 0, 0, + 124, 96, 69, 250, 189, 137, 195, 149, 108, 225, 207, 138, 250, 221, 143, 237, 195, 127, 134, 244, 156, 80, 245, 148, + 103, 228, 139, 135, 248, 165, 120, 252, 150, 129, 193, 143, 175, 68, 160, 178, 59, 161, 72, 34, 66, 0, 0, 0, + 0, 0, 0, 135, 101, 67, 255, 197, 133, 206, 153, 103, 255, 216, 134, 255, 228, 139, 247, 200, 122, 127, 255, 152, + 67, 255, 144, 98, 237, 134, 147, 255, 162, 131, 255, 146, 138, 187, 138, 189, 48, 157, 190, 37, 156, 76, 26, 64, + 0, 0, 0, 0, 0, 0, 57, 44, 32, 110, 85, 64, 84, 65, 48, 121, 94, 63, 140, 98, 65, 102, 86, 57, + 60, 120, 72, 39, 112, 68, 46, 103, 62, 61, 134, 76, 52, 133, 68, 56, 82, 64, 78, 34, 74, 77, 29, 73, + 31, 16, 29, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, +}; +constexpr std::array reduced{ + 2, 22, 10, 14, 58, 25, 35, 70, 30, 53, 83, 33, 74, 90, 34, 95, 99, 36, 37, 40, 13, 17, 60, 41, + 63, 155, 101, 115, 186, 110, 157, 217, 117, 161, 234, 120, 207, 255, 127, 81, 103, 47, 52, 64, 91, 142, 165, 218, + 186, 193, 220, 217, 219, 227, 58, 229, 227, 70, 251, 241, 32, 101, 90, 92, 72, 57, 230, 184, 137, 233, 214, 144, + 110, 238, 155, 114, 255, 157, 158, 131, 166, 64, 23, 60, 32, 24, 16, 84, 60, 38, 82, 70, 41, 27, 79, 44, + 40, 90, 45, 56, 29, 47, 22, 2, 17, +}; +constexpr std::array plain{ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 100, 40, 0, 85, 34, 53, 131, 47, + 45, 111, 39, 106, 162, 54, 90, 137, 45, 159, 193, 61, 135, 164, 51, 212, 224, 68, 180, 190, 57, 0, 0, 0, + 0, 0, 0, 0, 85, 34, 0, 70, 28, 45, 111, 39, 37, 91, 32, 90, 137, 45, 74, 113, 37, 135, 164, 51, + 111, 135, 42, 180, 190, 57, 148, 156, 47, 0, 0, 0, 0, 0, 0, 97, 117, 191, 82, 99, 162, 150, 148, 198, + 127, 125, 168, 203, 179, 205, 172, 152, 174, 0, 210, 212, 0, 178, 180, 53, 241, 219, 45, 204, 186, 0, 0, 0, + 0, 0, 0, 82, 99, 162, 67, 81, 133, 127, 125, 168, 105, 103, 138, 172, 152, 174, 142, 125, 143, 0, 178, 180, + 0, 147, 148, 45, 204, 186, 37, 168, 153, 0, 0, 0, 0, 0, 0, 194, 134, 86, 164, 113, 73, 247, 165, 93, + 209, 140, 79, 44, 196, 100, 37, 166, 85, 97, 227, 107, 82, 192, 90, 150, 2, 114, 127, 1, 96, 0, 0, 0, + 0, 0, 0, 164, 113, 73, 135, 93, 60, 209, 140, 79, 172, 115, 65, 37, 166, 85, 30, 137, 70, 82, 192, 90, + 67, 158, 74, 127, 1, 96, 105, 1, 79, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, +}; + +std::vector rgba(std::span const Rgb) { + std::vector result; + for (std::size_t idx = 0; idx < Rgb.size(); idx += 3) { + result.insert(result.end(), {Rgb[idx], Rgb[idx + 1], Rgb[idx + 2], 255}); + } + return result; +} + +} // namespace + +TEST_CASE("wasm kernels: led_post gives the pixels of led_util.render_post") { + auto const input{rgba(source)}; + std::vector output(wasm_screens::image_size(12, 8), 7); + REQUIRE(wasm_screens::led_post(input, 5, 3, output, true)); + CHECK(output == rgba(posted)); + + REQUIRE(wasm_screens::led_post(input, 5, 3, output, false)); + CHECK(output == rgba(plain)); +} + +TEST_CASE("wasm kernels: resize_lanczos gives the pixels of Pillow's resize with LANCZOS") { + auto const input{rgba(posted)}; + std::vector larger(wasm_screens::image_size(17, 11)); + REQUIRE(wasm_screens::resize_lanczos(input, 12, 8, larger, 17, 11)); + CHECK(larger == rgba(enlarged)); + + std::vector smaller(wasm_screens::image_size(7, 5)); + REQUIRE(wasm_screens::resize_lanczos(input, 12, 8, smaller, 7, 5)); + CHECK(smaller == rgba(reduced)); +} + +TEST_CASE("wasm kernels: images that don't fit their buffers are refused") { + std::vector const input(wasm_screens::image_size(5, 3)); + std::vector output(wasm_screens::image_size(12, 8) - 1); + CHECK_FALSE(wasm_screens::led_post(input, 5, 3, output, true)); + CHECK_FALSE(wasm_screens::led_post(input, 0, 3, output, true)); + CHECK_FALSE(wasm_screens::resize_lanczos(input, 5, 3, output, 12, 8)); + CHECK_FALSE(wasm_screens::resize_lanczos(input, 6, 3, output, 2, 2)); +} diff --git a/tests/unit/test_wasm_screens.cpp b/tests/unit/test_wasm_screens.cpp new file mode 100644 index 00000000..5224f4b9 --- /dev/null +++ b/tests/unit/test_wasm_screens.cpp @@ -0,0 +1,103 @@ +#include "stdafx.h" +#include "doctest.h" + +#include "scripting/wasmscreenrules.h" +#include "utilities/parser.h" + +// the parts of the wasm screens that don't need the runtime: the .mmd entries, the refresh schedule counted in +// frames and the files a screen may read + +using wasm_screens::frame_schedule; +using wasm_screens::screen_config; + +TEST_CASE("wasm screens: a cab screen entry with every key") { + cParser parser("{ ekran.wasm target: screen01 frames: 6 parameters: id=1&mode=full } next:", cParser::buffer_TEXT); + screen_config config; + config.deserialize(parser); + + CHECK(config.script == "ekran.wasm"); + CHECK(config.target == "screen01"); + CHECK(config.frames == 6); + CHECK(config.parameters == "id=1&mode=full"); + // the parser stops at the end of the block + CHECK(parser.getToken() == "next:"); +} + +TEST_CASE("wasm screens: the optional keys keep their defaults, an unknown key is skipped with its value") { + cParser parser("{ tablica.wasm later: 12 instance: name background: tablica_off }", cParser::buffer_TEXT); + screen_config config; + config.deserialize(parser); + + CHECK(config.script == "tablica.wasm"); + CHECK(config.target == "none"); + CHECK(config.frames == 0); + CHECK(config.parameters.empty()); + CHECK(config.instancing == "name"); + CHECK(config.background == "tablica_off"); +} + +TEST_CASE("wasm screens: the interval set in eu07.ini, or the screen's own when it is longer") { + CHECK(frame_schedule::effective_interval(4, 0) == 4); + CHECK(frame_schedule::effective_interval(4, 10) == 10); + CHECK(frame_schedule::effective_interval(4, 2) == 4); + // never less than every frame + CHECK(frame_schedule::effective_interval(0, 0) == 1); + CHECK(frame_schedule::effective_interval(-3, -1) == 1); +} + +TEST_CASE("wasm screens: a screen is due once per interval") { + frame_schedule schedule{100, 3, 0}; + CHECK(schedule.poll(100)); + // asked again in the same frame (the vehicle drawn in more than one pass) + CHECK_FALSE(schedule.poll(100)); + CHECK_FALSE(schedule.poll(101)); + CHECK_FALSE(schedule.poll(102)); + CHECK(schedule.poll(103)); + CHECK_FALSE(schedule.poll(105)); + CHECK(schedule.poll(106)); +} + +TEST_CASE("wasm screens: a screen out of sight for a while is due on the first frame it is asked again") { + frame_schedule schedule{0, 5, 0}; + CHECK(schedule.poll(0)); + CHECK(schedule.poll(42)); + CHECK_FALSE(schedule.poll(46)); + CHECK(schedule.poll(47)); +} + +TEST_CASE("wasm screens: the phase spreads the screens over the frames of an interval") { + auto const interval{30}; + auto const phase{frame_schedule::phase("make:dynamic/pkp/ed72/tablica.wasm?$timetable=ed72-001", interval)}; + CHECK(phase >= 0); + CHECK(phase < interval); + // the same name always gets the same phase + CHECK(phase == frame_schedule::phase("make:dynamic/pkp/ed72/tablica.wasm?$timetable=ed72-001", interval)); + // a screen refreshed every frame has no phase + CHECK(frame_schedule::phase("anything", 1) == 0); + + frame_schedule schedule{1000, interval, phase}; + auto first{std::uint64_t{0}}; + for (auto frame = std::uint64_t{1000}; frame < 1000 + interval; ++frame) { + if (schedule.poll(frame)) { + first = frame; + break; + } + } + CHECK(first == 1000 + static_cast(phase)); +} + +TEST_CASE("wasm screens: a screen reads files only below its own folder") { + CHECK(wasm_screens::is_safe_relative_path("font.png")); + CHECK(wasm_screens::is_safe_relative_path("data/font.png")); + CHECK(wasm_screens::is_safe_relative_path("data\\font.png")); + CHECK(wasm_screens::is_safe_relative_path("data/..font.png")); + + CHECK_FALSE(wasm_screens::is_safe_relative_path("")); + CHECK_FALSE(wasm_screens::is_safe_relative_path("../eu07.ini")); + CHECK_FALSE(wasm_screens::is_safe_relative_path("data/../../eu07.ini")); + CHECK_FALSE(wasm_screens::is_safe_relative_path("data\\..\\x")); + CHECK_FALSE(wasm_screens::is_safe_relative_path("..")); + CHECK_FALSE(wasm_screens::is_safe_relative_path("/etc/passwd")); + CHECK_FALSE(wasm_screens::is_safe_relative_path("\\windows")); + CHECK_FALSE(wasm_screens::is_safe_relative_path("c:/windows/win.ini")); +} diff --git a/utilities/Globals.cpp b/utilities/Globals.cpp index ce299ce5..6f27d631 100644 --- a/utilities/Globals.cpp +++ b/utilities/Globals.cpp @@ -72,6 +72,10 @@ void global_settings::FinalizeConfig() python_enabled = false; #endif +#ifndef WITH_WASM + wasm_enabled = false; +#endif + #ifdef _WIN32 Console::ModeSet(iFeedbackMode, iFeedbackPort); #endif @@ -877,6 +881,29 @@ bool global_settings::ConfigParseUI(cParser& Parser, const std::string& token) return false; } +bool global_settings::ConfigParseWasm(cParser& Parser, std::string_view const token) +{ + if (token == "wasm.enabled") + { + ParseOne(Parser, wasm_enabled, 1); + return true; + } + + if (token == "wasm.cabframes") + { + ParseOneClamped(Parser, wasm_cabframes, 1, 1000); + return true; + } + + if (token == "wasm.externalframes") + { + ParseOneClamped(Parser, wasm_externalframes, 1, 1000); + return true; + } + + return false; +} + bool global_settings::ConfigParsePython(cParser& Parser, const std::string& token) { if (token == "python.enabled") @@ -1373,6 +1400,9 @@ void global_settings::ConfigParse(cParser& Parser) if (ConfigParsePython(Parser, token)) continue; + if (ConfigParseWasm(Parser, token)) + continue; + if (ConfigParseNetwork(Parser, token)) continue; @@ -1840,6 +1870,9 @@ global_settings::export_as_text( std::ostream &Output ) const { export_as_text( Output, "python.threadedupload", python_threadedupload ); export_as_text( Output, "python.uploadmain", python_uploadmain ); export_as_text( Output, "python.mipmaps", python_mipmaps ); + export_as_text( Output, "wasm.enabled", wasm_enabled ); + export_as_text( Output, "wasm.cabframes", wasm_cabframes ); + export_as_text( Output, "wasm.externalframes", wasm_externalframes ); export_as_text( Output, "async.trainThreads", trainThreads ); for( auto const &server : network_servers ) { Output diff --git a/utilities/Globals.h b/utilities/Globals.h index 872338b4..afa40e85 100644 --- a/utilities/Globals.h +++ b/utilities/Globals.h @@ -296,6 +296,9 @@ struct global_settings { bool python_sharectx = true; bool python_uploadmain = true; std::chrono::duration python_minframetime {0.01f}; + bool wasm_enabled{true}; // screens drawn by WebAssembly modules (wasmscreen:, wasmdestinationsign: in the .mmd) + int wasm_cabframes{4}; // the wasm screens in the cab are refreshed every so many rendered frames + int wasm_externalframes{30}; // the wasm screens outside the cab (destination signs) are refreshed every so many rendered frames bool gfx_skiprendering = false; int gfx_framebuffer_width = -1; @@ -390,6 +393,7 @@ struct global_settings { bool ConfigParseSimulation(cParser& Parser, const std::string& token); bool ConfigParseUI(cParser& Parser, const std::string& token); bool ConfigParsePython(cParser& Parser, const std::string& token); + bool ConfigParseWasm(cParser& Parser, std::string_view token); bool ConfigParseNetwork(cParser& Parser, const std::string& token); bool ConfigParseHardware(cParser& Parser, const std::string& token); bool ConfigParseDebug(cParser& Parser, const std::string& token); diff --git a/vehicle/DynObj.cpp b/vehicle/DynObj.cpp index b9c55bfa..4842e5b1 100644 --- a/vehicle/DynObj.cpp +++ b/vehicle/DynObj.cpp @@ -35,6 +35,7 @@ http://mozilla.org/MPL/2.0/. #include "rendering/renderer.h" #include "application/uitranscripts.h" #include "input/messaging.h" +#include "application/application.h" #include "vehicle/Driver.h" // Ra: taki zapis funkcjonuje lepiej, ale może nie jest optymalny @@ -3139,6 +3140,20 @@ void TDynamicObject::update_destinations() { && ctOwner != nullptr)) ? // lcd signs are off for carriages without engine, potentially left on a siding DestinationSign.destination : DestinationSign.destination_off; + + update_wasm_destination(); +} + +void TDynamicObject::update_wasm_destination() { + + if( DestinationSign.wasm_sign == nullptr ) { return; } + // only while the sign shows the destination it draws + if( m_materialdata.replacable_skins[ 4 ] != DestinationSign.destination ) { return; } + + auto &queue { Application.wasm_screens() }; + if( false == queue.due( *DestinationSign.wasm_sign ) ) { return; } + // read anew on every refresh, so the sign follows the timetable + queue.submit( DestinationSign.wasm_sign, std::make_shared( DestinationSign.wasm_request ), 0 ); } bool TDynamicObject::Update(double dt, double dt1) @@ -7080,6 +7095,14 @@ void TDynamicObject::LoadMMediaFile( std::string const &TypeName, std::string co DestinationSign.script = asBaseDir + DestinationSign.script; } } + else if( token == "wasmdestinationsign:" ) { + DestinationSign.wasm.deserialize( parser ); + // supply vehicle folder as script path if none is provided + if( false == DestinationSign.wasm.script.empty() + && substr_path( DestinationSign.wasm.script ).empty() ) { + DestinationSign.wasm.script = asBaseDir + DestinationSign.wasm.script; + } + } // NOTE: legacy key, now expected as optional "background:" parameter in pydestinationsign: { parameter block } else if( token == "destinationsignbackground:" ) { parser.getTokens(); @@ -7099,7 +7122,9 @@ void TDynamicObject::LoadMMediaFile( std::string const &TypeName, std::string co if( ReplacableSkin != "none" ) { // potentially set blank destination texture - DestinationSign.destination_off = DestinationFind( DestinationSign.background.empty() ? "nowhere" : DestinationSign.background ); + auto const wasmbackground { Global.wasm_enabled && false == DestinationSign.wasm.script.empty() && false == DestinationSign.wasm.background.empty() }; + auto const &background { wasmbackground ? DestinationSign.wasm.background : DestinationSign.background }; + DestinationSign.destination_off = DestinationFind( background.empty() ? "nowhere" : background ); } // assign default samples to sound emitters which weren't included in the config file @@ -7920,6 +7945,7 @@ void TDynamicObject::DestinationClear() { if( false == DestinationSign.has_light ) { return; } // physical destination signs remain up until manually changed // update_destinations() shows the blank sign in place of the missing destination DestinationSign.destination = null_handle; + DestinationSign.wasm_sign.reset(); } void TDynamicObject::DestinationSet(std::string const &to, std::string const &numer) { @@ -7933,6 +7959,8 @@ void TDynamicObject::DestinationSet(std::string const &to, std::string const &nu if( std::abs( m_materialdata.multi_textures ) >= 4 ) { return; } // jak są 4 tekstury wymienne, to nie zmieniać rozkładem if( DestinationSign.sign == nullptr ) { return; } // no sign submodel, no problem + // a sign drawn for the previous destination + DestinationSign.wasm_sign.reset(); // now see if we can find any version of the destination texture std::vector const destinations = { @@ -7948,21 +7976,27 @@ void TDynamicObject::DestinationSet(std::string const &to, std::string const &nu } } // if we didn't get static texture we might be able to make one + if( true == destination_set_wasm( numer ) ) { return; } if( DestinationSign.script.empty() ) { return; } // no script so no way to make the texture if( numer == "none" ) { return; } // blank or incomplete/malformed timetable, don't bother + DestinationSign.destination = GfxRenderer->Fetch_Material( destination_request( DestinationSign.script, DestinationSign.instancing, DestinationSign.parameters ) ); +} + +std::string TDynamicObject::destination_request( std::string const &Script, std::string const &Instancing, std::string const &Parameters ) const { + auto const trainname { [this]() -> std::string { if( ctOwner != nullptr ) { return ctOwner->TrainName(); } if( Mechanik != nullptr ) { return Mechanik->TrainName(); } return "none"; } }; // shouldn't get here but, eh - auto const instance { [this]() -> std::string { - if( DestinationSign.instancing == "name" ) { return MoverParameters->Name; } - if( DestinationSign.instancing == "type" ) { return MoverParameters->TypeName; } + auto const instance { [this, &Instancing]() -> std::string { + if( Instancing == "name" ) { return MoverParameters->Name; } + if( Instancing == "type" ) { return MoverParameters->TypeName; } return "none"; } }; std::string signrequest { "make:" - + DestinationSign.script + + Script + "?" // timetable include + "$timetable=" + ( @@ -7973,15 +8007,40 @@ void TDynamicObject::DestinationSet(std::string const &to, std::string const &nu // NOTE: underscore doesn't have any magic meaning for the time being, it's just less likely to conflict with regular dictionary keys + "&_id1=" + trainname() }; // TBD, TODO: replace instancing with support for variables in extra parameters string? - if( false == DestinationSign.instancing.empty() ) { + if( false == Instancing.empty() ) { signrequest += "&_id2=" + instance(); } // optionl extra parameters - if( false == DestinationSign.parameters.empty() ) { - signrequest += "&" + DestinationSign.parameters; + if( false == Parameters.empty() ) { + signrequest += "&" + Parameters; } + return signrequest; +} - DestinationSign.destination = GfxRenderer->Fetch_Material( signrequest ); +bool TDynamicObject::destination_set_wasm( std::string const &Numer ) { + + if( false == Global.wasm_enabled ) { return false; } + if( DestinationSign.wasm.script.empty() ) { return false; } // no wasm sign, maybe a python one + if( Numer == "none" ) { return false; } // blank or incomplete/malformed timetable, don't bother + + auto const signrequest { destination_request( DestinationSign.wasm.script, DestinationSign.wasm.instancing, DestinationSign.wasm.parameters ) }; + auto const signmaterial { GfxRenderer->Fetch_Material( signrequest ) }; + if( signmaterial == null_handle ) { return false; } + auto &signtexture { GfxRenderer->Texture( GfxRenderer->Material( signmaterial )->GetTexture( 0 ) ) }; + + // vehicles showing the same request (one train, one instance) share the screen, as they share the texture + auto sign { Application.wasm_screens().acquire( { + .kind = wasm_screen_kind::external, + .script = DestinationSign.wasm.script, + .key = signrequest, + .texture = &signtexture, + .frames = DestinationSign.wasm.frames } ) }; + if( sign == nullptr ) { return false; } // the wasm screens are off + + DestinationSign.destination = signmaterial; + DestinationSign.wasm_sign = std::move( sign ); + DestinationSign.wasm_request = signrequest.substr( signrequest.find( '?' ) + 1 ); + return true; } material_handle TDynamicObject::DestinationFind( std::string Destination ) { diff --git a/vehicle/DynObj.h b/vehicle/DynObj.h index bfd477c4..feaa548e 100644 --- a/vehicle/DynObj.h +++ b/vehicle/DynObj.h @@ -22,8 +22,12 @@ http://mozilla.org/MPL/2.0/. #include "audio/sound.h" #include "world/Spring.h" #include "vehicle/mirrors.h" +#include "scripting/wasmscreenrules.h" #include +#include + +class wasm_screen; // scripting/wasmscreens.h #define EU07_SOUND_BOGIESOUNDS @@ -251,6 +255,9 @@ public: int update_rate { 0 }; // -1: per stop, 0: none, >0: fps // TBD, TODO: implement? std::string instancing; // potential method to generate more than one texture per timetable std::string parameters; // potential extra parameters supplied by mmd file + wasm_screens::screen_config wasm; // potential WebAssembly sign (wasmdestinationsign:), used in place of the python one while wasm screens are on + std::shared_ptr wasm_sign; // the wasm screen drawing the current destination + std::string wasm_request; // its input: the key=value pairs a python sign gets for its texture // methods void deserialize( cParser &Input ); private: @@ -726,6 +733,8 @@ private: void update_load_offset(); void shuffle_load_order(); void update_destinations(); + // feeds the wasm destination sign with fresh data, when it is shown and due + void update_wasm_destination(); bool Update(double dt, double dt1); bool FastUpdate(double dt); void Move(double fDistance); @@ -849,6 +858,10 @@ private: // blanks electronic destination sign at the end of the service; physical signs stay until changed void DestinationClear(); material_handle DestinationFind( std::string Destination ); + // the name of a generated destination sign texture: the script, the timetable, the instancing and the parameters + std::string destination_request( std::string const &Script, std::string const &Instancing, std::string const &Parameters ) const; + // makes the destination sign a wasm screen, when the vehicle has one and the wasm screens are on. returns true when it did + bool destination_set_wasm( std::string const &Numer ); void OverheadTrack(float o); glm::dvec3 get_future_movement() const; void move_set(double distance); diff --git a/vehicle/Train.cpp b/vehicle/Train.cpp index fb42c85f..084e65d4 100644 --- a/vehicle/Train.cpp +++ b/vehicle/Train.cpp @@ -38,6 +38,7 @@ http://mozilla.org/MPL/2.0/. #include #include #include +#include /* namespace input { @@ -10035,7 +10036,10 @@ bool TTrain::Update(double const Deltatime) // screens if (!FreeFlyModeFlag && simulation::Train == this) // don't bother if we're outside + { update_screens(Deltatime); + update_wasm_screens(); + } // update direction relay if (prevBatState != mvOccupied->Power24vIsAvailable) @@ -10564,6 +10568,92 @@ void TTrain::update_screens(double dt) } } +void TTrain::update_wasm_screens() +{ + auto &queue{Application.wasm_screens()}; + for (auto &entry : m_wasmscreens) + { + if (false == queue.due(*entry.screen)) + { + continue; + } + auto input{GetTrainState(entry.parameters)}; + input->insert("touches", *entry.touch_list); + entry.touch_list->clear(); + queue.submit(entry.screen, std::move(input), id()); + } +} + +bool TTrain::find_screen_surface(std::string const &Target, std::string_view const Kind, TSubModel *&Submodel, ITexture *&Texture) const +{ + Submodel = DynamicObject->mdKabina ? DynamicObject->mdKabina->GetFromName(Target) : + DynamicObject->mdLowPolyInt ? DynamicObject->mdLowPolyInt->GetFromName(Target) : + nullptr; + if (Submodel == nullptr) + { + WriteLog(std::format("{}: submodel {} not found - Ignoring screen", Kind, Target)); + return false; + } + auto const material{Submodel->GetMaterial()}; + if (material <= 0) + { + // sub model nie posiada tekstury lub tekstura wymienna - nie obslugiwana + WriteLog(std::format("{}: invalid texture id {} - Ignoring screen", Kind, material)); + return false; + } + + Texture = &GfxRenderer->Texture(GfxRenderer->Material(material)->GetTexture(0)); + return true; +} + +void TTrain::load_wasm_screen(cParser &Parser) +{ + wasm_screen_entry entry; + // the block is read whole even when the wasm screens are off + entry.config.deserialize(Parser); + if (entry.config.script.empty()) + { + return; + } + if (substr_path(entry.config.script).empty()) + { + entry.config.script = DynamicObject->asBaseDir + entry.config.script; + } + + TSubModel *submodel{nullptr}; + ITexture *texture{nullptr}; + if (entry.config.target != "none" && false == find_screen_surface(entry.config.target, "WASM Screen", submodel, texture)) + { + return; + } + + entry.screen = Application.wasm_screens().acquire({.kind = wasm_screen_kind::cab, + .script = entry.config.script, + .key = std::format("cab:{}:{}:{}", DynamicObject->name(), entry.config.target, entry.config.script), + .texture = texture, + .frames = entry.config.frames}); + if (entry.screen == nullptr) + { + // the wasm screens are off + return; + } + if (texture != nullptr) + { + // make the surface static so it doesn't get destroyed by garbage collector if the user spends long time outside cab + texture->create(true); + } + if (false == entry.config.parameters.empty()) + { + entry.parameters = dictionary_source(entry.config.parameters); + } + entry.touch_list = std::make_shared>(); + if (submodel != nullptr) + { + submodel->screen_touch_list = entry.touch_list; + } + m_wasmscreens.emplace_back(std::move(entry)); +} + void TTrain::add_distance(double const Distance) { @@ -10739,6 +10829,7 @@ bool TTrain::InitializeCab(int NewCabNo, std::string const &asFileName) m_controlmapper.clear(); // clear python screens m_screens.clear(); + m_wasmscreens.clear(); // reset sound positions auto const nullvector{glm::vec3()}; std::vector>> sounds = { @@ -10930,23 +11021,10 @@ bool TTrain::InitializeCab(int NewCabNo, std::string const &asFileName) TSubModel *submodel = nullptr; if (screen.target != "none") { - submodel = DynamicObject->mdKabina ? DynamicObject->mdKabina->GetFromName(screen.target) : - DynamicObject->mdLowPolyInt ? DynamicObject->mdLowPolyInt->GetFromName(screen.target) : - nullptr; - if (submodel == nullptr) + if (false == find_screen_surface(screen.target, "Python Screen", submodel, tex)) { - WriteLog("Python Screen: submodel " + screen.target + " not found - Ignoring screen"); continue; } - auto const material{submodel->GetMaterial()}; - if (material <= 0) - { - // sub model nie posiada tekstury lub tekstura wymienna - nie obslugiwana - WriteLog("Python Screen: invalid texture id " + std::to_string(material) + " - Ignoring screen"); - continue; - } - - tex = &GfxRenderer->Texture(GfxRenderer->Material(material)->GetTexture(0)); } else { @@ -10973,6 +11051,10 @@ bool TTrain::InitializeCab(int NewCabNo, std::string const &asFileName) if (Global.python_displaywindows) m_screens.back().viewer = std::make_unique(rt, m_screens.back().touch_list, m_screens.back().script); } + else if (token == "wasmscreen:") + { + load_wasm_screen(parser); + } else if (token == "pyscreenupdatetime:") { parser.getTokens(); diff --git a/vehicle/Train.h b/vehicle/Train.h index dd077e36..1d7e032c 100644 --- a/vehicle/Train.h +++ b/vehicle/Train.h @@ -19,6 +19,7 @@ http://mozilla.org/MPL/2.0/. #include "scripting/PyInt.h" #include "input/command.h" #include "scripting/pythonscreenviewer.h" +#include "scripting/wasmscreens.h" #include "utilities/dictionary.h" #undef snprintf // pyint.h->python @@ -145,6 +146,14 @@ class TTrain { using screenentry_sequence = std::vector; + // a screen of the cab drawn by a WebAssembly module (wasmscreen: in the .mmd) + struct wasm_screen_entry { + wasm_screens::screen_config config; + dictionary_source parameters; // the config's parameters, pre-processed + std::shared_ptr screen; + std::shared_ptr> touch_list; + }; + // methods bool CabChange(int iDirection); bool ShowNextCurrent{false}; // pokaz przd w podlaczonej lokomotywie (ET41) @@ -233,6 +242,10 @@ class TTrain { end cab_to_end() const { return cab_to_end( iCabn ); } void update_screens(double dt); + void update_wasm_screens(); + // finds the submodel and the texture a cab screen is drawn on. returns false when the screen is to be ignored + bool find_screen_surface(std::string const &Target, std::string_view Kind, TSubModel *&Submodel, ITexture *&Texture) const; + void load_wasm_screen(cParser &Parser); // command handlers // NOTE: we're currently using universal handlers and static handler map but it may be beneficial to have these implemented on individual class instance basis @@ -935,6 +948,7 @@ private: bool m_mastercontrollerinuse { false }; float m_mastercontrollerreturndelay { 0.f }; screenentry_sequence m_screens; + std::vector m_wasmscreens; uint16_t vid { 0 }; // train network recipient id float m_distancecounter { -1.f }; // distance traveled since meter was activated or -1 if inactive double m_brakehandlecp{ 0.0 }; diff --git a/wasm_screens_doc.md b/wasm_screens_doc.md new file mode 100644 index 00000000..494d07c0 --- /dev/null +++ b/wasm_screens_doc.md @@ -0,0 +1,129 @@ +# WebAssembly screens + +Screens drawn by WebAssembly modules, alongside the Python screens (`pyscreen:`, `pydestinationsign:`). A module +gets the same input a Python screen gets (the table in `python_var_doc.md`), returns an RGBA image and, in the cab, +may send commands. The modules run in the WAMR interpreter (`ref/wamr`), in a sandbox: a module reaches nothing but +the functions of the `eu07` module below, and reads files only from its own folder. + +Unlike a Python screen, which refreshes after a time (`python.updatetime`, `updatetime:`), a WebAssembly screen +refreshes every so many rendered frames, set in `eu07.ini`. + +## eu07.ini + +| key | default | meaning | +|-----------------------|---------|--------------------------------------------------------------------------------| +| `wasm.enabled` | 1 | 0: no WebAssembly screens; the vehicles show what they did before | +| `wasm.cabframes` | 4 | a screen in the cab is refreshed every so many frames (1-1000) | +| `wasm.externalframes` | 30 | a screen outside the cab (destination sign) is refreshed every so many frames | + +A screen may ask for a longer interval of its own (`frames:` below); the longer of the two counts. Screens sharing +an interval are spread over its frames, so they don't all refresh on the same one. A screen in the cab is refreshed +only while the player sits in that cab, a screen outside only while its vehicle is drawn. + +## .mmd + +In the cab section, next to `pyscreen:`: + +``` +wasmscreen: { ekran.wasm target: screen01 frames: 6 parameters: id=1&mode=full } +``` + +| key | meaning | +|---------------|---------------------------------------------------------------------------------------------------| +| (first token) | the module, relative to the vehicle folder when it has no path of its own | +| `target:` | the submodel the screen is drawn on (its texture is replaced); `none` for a screen that only sends commands | +| `frames:` | the screen's own refresh interval, used when longer than `wasm.cabframes` | +| `parameters:` | extra `key=value` pairs, `&`-separated, added to the input | + +A touch on the screen's submodel comes in the input as the list of points `touches`, as for the Python screens. + +In `internaldata:`, next to `pydestinationsign:`: + +``` +wasmdestinationsign: { tablica.wasm frames: 60 instance: name background: tablica_off parameters: style=lcd } +``` + +The keys are those of `pydestinationsign:` (`instance:`, `parameters:`, `background:`) and `frames:`. The input is +what a Python sign gets: the timetable (`$timetable`), `_id1` (the train), `_id2` (the instance) and the +parameters, read anew on every refresh, so the sign can follow the journey. The vehicles of one train showing the +same request share one screen and one texture. A static destination texture, when there is one, still comes first. + +A vehicle may have both `pydestinationsign:` and `wasmdestinationsign:`: with the WebAssembly screens on, the +WebAssembly one is used; with them off (or in a release without them) the Python one. An unknown key in either block +is skipped together with its value. + +## The module + +Exports: + +| export | meaning | +|-------------------------------|------------------------------------------------------------------------------------------| +| `memory` | the linear memory | +| `screen_init() -> i32` | optional; called once after loading. anything but 0 disables the screen | +| `screen_render() -> i32` | called on every refresh. returns the address of the image, or 0 to keep the previous one | +| `screen_width() -> i32` | the width of the image, asked after every `screen_render` that returned an image | +| `screen_height() -> i32` | the height of the image, up to 4096 on either side | + +The image is `width * height` pixels of 4 bytes, R G B A, in sRGB, its rows in the order the Python screens give +them (PIL's `tobytes()`). + +Imports, module `eu07` (keys and texts are a pointer and a length in bytes, in the module's memory): + +| import | meaning | +|-----------------------------------------------------------------|-----------------------------------------------------------------| +| `value_type(key, key_len) -> i32` | 0 no such value, 1 float, 2 int, 3 bool, 4 string, 5 list of points | +| `get_float(key, key_len) -> f64` | the value as a number (int and bool too); 0 when there is none | +| `get_int(key, key_len) -> i32` | the value as an integer (a float is truncated) | +| `get_bool(key, key_len) -> i32` | 1 or 0 | +| `get_string(key, key_len, buffer, capacity) -> i32` | copies the text, in UTF-8; returns its whole length, -1 when there is none | +| `get_vec2_count(key, key_len) -> i32` | the number of points in a list (`touches`) | +| `get_vec2(key, key_len, index, out) -> i32` | writes the point as two f32 at `out`; returns 1, or 0 when there is none | +| `command(name, name_len, param1, param2)` | sends a command (the names of `simulation::commandMap`) to the vehicle of the cab; ignored outside the cab | +| `log(text, text_len)` | writes a line to the log | +| `time() -> f64` | seconds of a steady clock shared by all screens, for animations (scrolling) that keep their pace whatever the refresh interval | +| `led_post(source, width, height, target, bloom) -> i32` | `led_util.render_post()` of the Python screens, run natively: each diode of the `width x height` image becomes 2x2 pixels with a darker grid, glowing into its neighbours when `bloom` is not 0, in a 1 pixel black frame; `target` takes `(2 width + 2) x (2 height + 2)`. returns 1, or 0 for sizes over 4096 | +| `resize_lanczos(source, width, height, target, target_width, target_height) -> i32` | Pillow's `resize(..., Image.LANCZOS)`, run natively, giving the same pixels. returns 1, or 0 for sizes over 4096 | +| `file_read(path, path_len, buffer, capacity) -> i32` | reads a file from the module's folder or below; returns the file's whole size, -1 when there is none or the path leads outside | + +`led_post` and `resize_lanczos` take RGBA images in the module's memory and write alpha 255; the source and the +target may overlap. They are what makes LED boards fast enough: in the interpreter the same filters take some 30 +times longer. + +A call into the module may run at most 200 million instructions; a module that traps or runs over is disabled and +the reason goes to the log. The module gets 64 KiB of stack and no heap from the host: it manages its own memory. + +## Building a module + +With clang (any version with the `wasm32` target), no C library: + +```c +// ekran.c +#include + +#define IMPORT(name) __attribute__((import_module("eu07"), import_name(name))) +IMPORT("get_float") double get_float(const char *key, int key_len); +IMPORT("command") void command(const char *name, int name_len, int param1, int param2); + +#define WIDTH 256 +#define HEIGHT 128 +static uint32_t pixels[WIDTH * HEIGHT]; + +__attribute__((export_name("screen_width"))) int screen_width(void) { return WIDTH; } +__attribute__((export_name("screen_height"))) int screen_height(void) { return HEIGHT; } + +__attribute__((export_name("screen_render"))) int screen_render(void) { + double const velocity = get_float("velocity", 8); + int const filled = (int)(velocity * WIDTH / 160.0); + for (int y = 0; y < HEIGHT; ++y) + for (int x = 0; x < WIDTH; ++x) + pixels[y * WIDTH + x] = x < filled ? 0xFF00C000u : 0xFF202020u; // A B G R, little-endian + return (int)(uintptr_t)pixels; +} +``` + +``` +clang --target=wasm32 -O2 -nostdlib -Wl,--no-entry -o ekran.wasm ekran.c +``` + +Any language that compiles to WebAssembly works the same way (Rust `wasm32-unknown-unknown`, Zig, AssemblyScript), +as long as the module imports nothing but the `eu07` functions above.